v0.289.1: strip the provider rclone notice; an unreadable snapshot count is never a measured zero (found live on demo-felhom)
gates / gates (push) Successful in 29s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-03 17:15:10 +02:00
parent 55bb6c3d32
commit 2a2af2018d
4 changed files with 89 additions and 12 deletions
+43 -11
View File
@@ -1,6 +1,7 @@
package backup
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
@@ -425,10 +426,32 @@ func (m *Manager) offboxSize() func(string) int64 {
}
func (m *Manager) runner() offboxRunner {
r := defaultOffboxRunner
if m.offboxRunner != nil {
return m.offboxRunner
r = m.offboxRunner
}
return defaultOffboxRunner
return func(ctx context.Context, env []string, args ...string) ([]byte, error) {
out, err := r(ctx, env, args...)
return stripRcloneNotice(out), err
}
}
// rcloneNoticeRe is the ONE line the provider's rclone prints on every connection of the pinned
// transport (v0.289.0) — restic forwards it with an "rclone: " prefix into the combined output:
//
// rclone: 2026/10/03 15:05:42 NOTICE: Config file "/home/.config/rclone/rclone.conf" not found - using defaults
//
// MEASURED LIVE on demo-felhom 2026-10-03: left in, every `--json` parse failed ("invalid character
// 'r'"), so the box recorded 0 snapshots AS MEASURED (StatsKnown) over a store holding 12 — the exact
// shape R-431's detector reads as a mass deletion. Only this exact notice is removed; any other rclone
// line (an error) stays in the output. Pinned by TestStripRcloneNotice.
var rcloneNoticeRe = regexp.MustCompile(`(?m)^rclone: \d{4}/\d\d/\d\d \d\d:\d\d:\d\d NOTICE: Config file "[^"]*rclone\.conf" not found - using defaults\r?\n?`)
func stripRcloneNotice(out []byte) []byte {
if len(out) == 0 || !bytes.Contains(out, []byte("rclone: ")) {
return out
}
return rcloneNoticeRe.ReplaceAll(out, nil)
}
func (m *Manager) offboxDir() string { return filepath.Join(m.cfg.Paths.DataDir, "offbox") }
@@ -1034,9 +1057,9 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
}
dur := time.Since(start)
snapshots := 0
snapshots, countKnown := 0, false
if runErr == nil {
snapshots = m.offboxRecordStats(ctx, base, env)
snapshots, countKnown = m.offboxRecordStats(ctx, base, env)
}
// THE LANGUAGE IS RESOLVED HERE, OUTSIDE THE CALLBACK, AND IT IS NOT A STYLE CHOICE.
//
@@ -1114,8 +1137,15 @@ func (m *Manager) runOffboxBackup(ctx context.Context, withProgress bool) error
o.LastStatus = "ok"
}
o.LastError = ""
o.SnapshotCount = snapshots
o.StatsKnown = true // R-225: measured, even if the answer is zero
if countKnown {
o.SnapshotCount = snapshots
o.StatsKnown = true // R-225: measured, even if the answer is zero
} else {
// v0.289.0: the listing FAILED — that is not a measurement of zero (R-331). Keep the
// last measured count and say it is not known now; a reported 0 here reads as a mass
// deletion to the hub (R-431). Pinned by TestRunOffbox_UnreadableCountIsNotZero.
o.StatsKnown = false
}
o.EnlargedBlocked = blockedNames // replace each run (sorted); empty slice clears it
var warns []string
warnKind := ""
@@ -1832,18 +1862,20 @@ func (m *Manager) offboxPruneOnly(ctx context.Context, base, env []string) {
}
// offboxRecordStats reads the snapshot count (best-effort) for the UI; also fills repo size when stats works.
func (m *Manager) offboxRecordStats(ctx context.Context, base, env []string) int {
func (m *Manager) offboxRecordStats(ctx context.Context, base, env []string) (int, bool) {
sctx, cancel := context.WithTimeout(ctx, offboxProbeTimeout)
defer cancel()
out, err := m.runner()(sctx, env, append(append([]string{}, base...), "snapshots", "--json")...)
if err != nil {
return 0
m.logger.Printf("[WARN] [offbox] snapshot count unreadable (kept the last measured value): %v", err)
return 0, false
}
var snaps []struct {
ID string `json:"id"`
}
if json.Unmarshal(out, &snaps) != nil {
return 0
if jerr := json.Unmarshal(out, &snaps); jerr != nil {
m.logger.Printf("[WARN] [offbox] snapshot count unparseable (kept the last measured value): %v", jerr)
return 0, false
}
// Repo size (best-effort, RAW-DATA mode). SP-1: `--mode raw-data` reports the actual
// deduplicated+compressed repo bytes (what the Storage Box really fills), not the modeless
@@ -1863,7 +1895,7 @@ func (m *Manager) offboxRecordStats(ctx context.Context, base, env []string) int
})
}
}
return len(snaps)
return len(snaps), true
}
// RestoreOffbox restores an app's latest off-box snapshot to destDir (a scratch/verify location — it does