v0.147.3 — 4c follow-up 3: the run does not end with the last app

Third real run, third thing only a live run could show. The per-app legs
finished in ~15 seconds; the remaining 40 of the 57-second run was the shares leg
and forget --prune, during which the card sat frozen on "calibre-web — 8 / 8
fájl". The same frozen-looking silence 4c exists to remove, relocated to the end.

Progress now carries a phase. The post-app stages announce themselves and the
app-scoped counters are cleared when a phase starts, so the last app's finished
numbers are never shown against work that is no longer about that app. Starting
the next app clears the phase again. Pinned by a test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Nn3VgQk9iwEGgyx6QJ2NvE
This commit is contained in:
2026-07-19 09:54:18 +02:00
parent 111369dd10
commit 63a22e5911
5 changed files with 88 additions and 2 deletions
+12
View File
@@ -1,5 +1,17 @@
## Changelog ## Changelog
### v0.147.3 — 4c follow-up 3: the run does not end with the last app (2026-07-19)
Third real run, third thing only a live run could show. The per-app legs finished in ~15 seconds;
the remaining **40 of the 57-second run** was the shares leg and `forget --prune` — during which the
card sat frozen on „calibre-web — 8 / 8 fájl". The same frozen-looking silence 4c exists to remove,
relocated to the end of the run.
Progress now carries a **phase**. The post-app stages announce themselves („Megosztott mappák
mentése…", „Karbantartás: régi mentések rendezése a távoli tárolón…") and the app-scoped counters are
cleared when a phase starts, so the last app's finished numbers are never shown against work that is
no longer about that app. Starting the next app clears the phase again. Pinned by a test.
### v0.147.2 — 4c follow-up 2: when NO counter can move, say what is being worked on (2026-07-19) ### v0.147.2 — 4c follow-up 2: when NO counter can move, say what is being worked on (2026-07-19)
The v0.147.1 file-count fallback fixed the incremental case but not the one the demo box actually The v0.147.1 file-count fallback fixed the incremental case but not the one the demo box actually
+8 -2
View File
@@ -55,7 +55,9 @@ func (m *Manager) SetOffboxNotify(fn func(dur time.Duration, snapshots int, err
} }
// SetOffboxOrphanEvent wires the offsite-repo continuity event push (main.go → notifier). // SetOffboxOrphanEvent wires the offsite-repo continuity event push (main.go → notifier).
func (m *Manager) SetOffboxOrphanEvent(fn func(eventType, renamedTo string)) { m.offboxOrphanEvent = fn } func (m *Manager) SetOffboxOrphanEvent(fn func(eventType, renamedTo string)) {
m.offboxOrphanEvent = fn
}
// SetOffboxSSH overrides the raw-ssh exec used for the orphaned-repo move-aside (tests). // SetOffboxSSH overrides the raw-ssh exec used for the orphaned-repo move-aside (tests).
func (m *Manager) SetOffboxSSH(fn func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error)) { func (m *Manager) SetOffboxSSH(fn func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error)) {
@@ -217,7 +219,9 @@ func (m *Manager) SetOffboxSizer(fn func(path string) int64) { m.offboxSizer = f
func (m *Manager) SetOffboxEnlargeBlockedNotifier(fn func(stack string, estBytes int64, usedGB, quotaGB int)) { func (m *Manager) SetOffboxEnlargeBlockedNotifier(fn func(stack string, estBytes int64, usedGB, quotaGB int)) {
m.offboxEnlargeBlockedNotify = fn m.offboxEnlargeBlockedNotify = fn
} }
func (m *Manager) SetOffboxPlaceCopier(fn func(src, dst string) (int, error)) { m.offboxPlaceCopier = fn } func (m *Manager) SetOffboxPlaceCopier(fn func(src, dst string) (int, error)) {
m.offboxPlaceCopier = fn
}
// offboxSize returns the mandatory-set byte estimator (nil seam → the real du -sb dirSizeBytes). // offboxSize returns the mandatory-set byte estimator (nil seam → the real du -sb dirSizeBytes).
func (m *Manager) offboxSize() func(string) int64 { func (m *Manager) offboxSize() func(string) int64 {
@@ -923,6 +927,7 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
// R-7b: the SHARES leg runs AFTER the per-app loop and BEFORE retention, so `forget --group-by // R-7b: the SHARES leg runs AFTER the per-app loop and BEFORE retention, so `forget --group-by
// host,tags` covers the `_shares` group for free. It is placed BEFORE the firstErr return on // host,tags` covers the `_shares` group for free. It is placed BEFORE the firstErr return on
// purpose: share protection must not be dropped because some unrelated app failed to push. // purpose: share protection must not be dropped because some unrelated app failed to push.
m.offboxProgress.setPhase(OffboxPhaseShares)
sharesRes, sharesErr := m.runOffboxSharesLeg(ctx, base, env, t) sharesRes, sharesErr := m.runOffboxSharesLeg(ctx, base, env, t)
m.recordSharesOffsiteStatus(sharesRes) m.recordSharesOffsiteStatus(sharesRes)
res.warns = append(res.warns, sharesRes.warns...) res.warns = append(res.warns, sharesRes.warns...)
@@ -942,6 +947,7 @@ func (m *Manager) runOffboxInternal(ctx context.Context, apps, base, env []strin
// unit-only-shape snapshots share a group with its NEW enlarged shape (same <stack> tag) and age // unit-only-shape snapshots share a group with its NEW enlarged shape (same <stack> tag) and age
// out naturally — the default host,paths grouping would strand old-shape snapshots in their own // out naturally — the default host,paths grouping would strand old-shape snapshots in their own
// permanently-retained group. prune takes an EXCLUSIVE lock (the C2 stale-lock step) → resticStep. // permanently-retained group. prune takes an EXCLUSIVE lock (the C2 stale-lock step) → resticStep.
m.offboxProgress.setPhase(OffboxPhaseRetention)
fctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout) fctx, cancel := context.WithTimeout(ctx, offboxBackupTimeout)
defer cancel() defer cancel()
if out, ferr := m.resticStep(fctx, env, base, "prune", "forget", "--group-by", "host,tags", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "6", "--prune"); ferr != nil { if out, ferr := m.resticStep(fctx, env, base, "prune", "forget", "--group-by", "host,tags", "--keep-daily", "7", "--keep-weekly", "4", "--keep-monthly", "6", "--prune"); ferr != nil {
@@ -52,6 +52,11 @@ type OffboxProgress struct {
// long it has been going — "working on X, 42s" is a completely different message from "0%". // long it has been going — "working on X, 42s" is a completely different message from "0%".
CurrentFile string `json:"current_file"` CurrentFile string `json:"current_file"`
ElapsedSec int64 `json:"elapsed_sec"` ElapsedSec int64 `json:"elapsed_sec"`
// Phase names the part of the run in progress. A run is NOT just the per-app loop: after the last
// app come the shares leg and `forget --prune`, which on the demo box took 40 of a 57-second run.
// Without this the card froze on the last app's finished counters for that whole tail — the same
// silence the slice exists to remove, just relocated. "" = per-app backup.
Phase string `json:"phase"`
} }
// resticStatusLine is the subset of restic's `--json` status object we consume. restic emits several // resticStatusLine is the subset of restic's `--json` status object we consume. restic emits several
@@ -150,6 +155,7 @@ func (p *offboxProgressState) setApp(app string) {
// A new app resets the byte counters: restic's percentages are per-invocation, and carrying // A new app resets the byte counters: restic's percentages are per-invocation, and carrying
// the previous app's 100% into the next app's start would show a bar that jumps backwards. // the previous app's 100% into the next app's start would show a bar that jumps backwards.
p.cur.CurrentApp = app p.cur.CurrentApp = app
p.cur.Phase = ""
p.cur.Percent, p.cur.BytesDone, p.cur.TotalBytes = 0, 0, 0 p.cur.Percent, p.cur.BytesDone, p.cur.TotalBytes = 0, 0, 0
p.cur.FilesDone, p.cur.TotalFiles = 0, 0 p.cur.FilesDone, p.cur.TotalFiles = 0, 0
p.cur.CurrentFile, p.cur.ElapsedSec = "", 0 p.cur.CurrentFile, p.cur.ElapsedSec = "", 0
@@ -158,6 +164,27 @@ func (p *offboxProgressState) setApp(app string) {
p.mu.Unlock() p.mu.Unlock()
} }
// setPhase marks a non-per-app stage of the run and clears the app-scoped counters, so the card
// stops showing the last app's finished numbers against work that is no longer about that app.
func (p *offboxProgressState) setPhase(phase string) {
p.mu.Lock()
if p.live {
p.cur.Phase = phase
p.cur.CurrentApp = ""
p.cur.Percent, p.cur.BytesDone, p.cur.TotalBytes = 0, 0, 0
p.cur.FilesDone, p.cur.TotalFiles = 0, 0
p.cur.CurrentFile, p.cur.ElapsedSec = "", 0
p.cur.DoneHuman, p.cur.TotalHuman = "", ""
}
p.mu.Unlock()
}
// OffboxPhaseShares / OffboxPhaseRetention are the post-app-loop stages.
const (
OffboxPhaseShares = "shares"
OffboxPhaseRetention = "retention"
)
func (p *offboxProgressState) update(r resticProgress) { func (p *offboxProgressState) update(r resticProgress) {
p.mu.Lock() p.mu.Lock()
if p.live { if p.live {
@@ -274,6 +274,32 @@ func TestProgressKeepsLastKnownCurrentFile(t *testing.T) {
} }
} }
// TestSetPhaseClearsAppScopedCounters — a run is not only the per-app loop. The shares leg and
// forget --prune follow it and took 40 of a 57-second run on the demo box. Without a phase the card
// kept showing the last app's finished counters ("calibre-web 8 / 8") for that whole tail, which is
// the same frozen-looking silence 4c exists to remove, just relocated to the end of the run.
func TestSetPhaseClearsAppScopedCounters(t *testing.T) {
var st offboxProgressState
st.begin()
st.setApp("calibre-web")
st.update(resticProgress{Percent: 100, BytesDone: 850000, TotalBytes: 850000, FilesDone: 8, TotalFiles: 8, CurrentFile: "/data/x"})
st.setPhase(OffboxPhaseRetention)
got := st.snapshot()
if got.Phase != OffboxPhaseRetention {
t.Errorf("phase = %q, want %q", got.Phase, OffboxPhaseRetention)
}
if got.CurrentApp != "" || got.FilesDone != 0 || got.TotalFiles != 0 || got.BytesDone != 0 || got.CurrentFile != "" {
t.Errorf("app-scoped counters survived the phase switch: %+v — the card would show the last "+
"app's finished numbers against retention work", got)
}
// Starting another app must clear the phase again, or the card would stay on „Karbantartás".
st.setApp("immich")
if p := st.snapshot(); p.Phase != "" || p.CurrentApp != "immich" {
t.Errorf("after setApp: phase=%q app=%q, want phase cleared and app set", p.Phase, p.CurrentApp)
}
}
// TestParseResticStatusClampsPercent — restic has been seen to report percent_done slightly above 1 // TestParseResticStatusClampsPercent — restic has been seen to report percent_done slightly above 1
// near completion. A bar wider than its track is a visible bug. // near completion. A bar wider than its track is a visible bug.
func TestParseResticStatusClampsPercent(t *testing.T) { func TestParseResticStatusClampsPercent(t *testing.T) {
@@ -212,6 +212,21 @@
if(p.active && box){ if(p.active && box){
/* Only claim a percentage once restic has told us a total — before the scan finishes, /* Only claim a percentage once restic has told us a total — before the scan finishes,
percent is 0 of 0, and a bar pinned at 0% reads as "stuck" rather than "measuring". */ percent is 0 of 0, and a bar pinned at 0% reads as "stuck" rather than "measuring". */
/* A run is not only the per-app loop: the shares leg and the retention/prune step follow
it and can dominate the wall clock (40 of 57 seconds, measured). Name them, or the
card freezes on the last app's finished counters for that whole tail. */
if(p.phase === 'retention'){
text.textContent = 'Karbantartás: régi mentések rendezése a távoli tárolón…';
bar.style.width = '100%';
box.className = 'alert alert-info'; box.style.display = '';
return;
}
if(p.phase === 'shares'){
text.textContent = 'Megosztott mappák mentése…';
bar.style.width = '100%';
box.className = 'alert alert-info'; box.style.display = '';
return;
}
var label = p.current_app ? ('Mentés: ' + p.current_app) : 'Mentés folyamatban'; var label = p.current_app ? ('Mentés: ' + p.current_app) : 'Mentés folyamatban';
if(p.bytes_done > 0){ if(p.bytes_done > 0){
/* Bytes are genuinely moving — the informative case. */ /* Bytes are genuinely moving — the informative case. */