space: the diff's clock runs inside sudo, so zfs is killed too

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-23 01:19:19 -07:00
co-authored by Claude Fable 5.1
parent 314a021a3e
commit ad88a48c01
2 changed files with 16 additions and 11 deletions
+6 -4
View File
@@ -356,10 +356,12 @@ something long in one, switch away and back, refresh — it's all still there.
snapshots, the scan records the newest snapshot of each dataset at start (`bases`), and the snapshots, the scan records the newest snapshot of each dataset at start (`bases`), and the
next one runs `zfs diff -H -F <base>` per dataset: only the directories it names are next one runs `zfs diff -H -F <base>` per dataset: only the directories it names are
read, their ancestors kept but looked into, everything else kept wholesale without a read, their ancestors kept but looked into, everything else kept wholesale without a
stat. The diffs run side by side and one is given four minutes (its process group is stat. The diffs run side by side and one is given three minutes — `timeout -s KILL`
killed, sudo and zfs both), after which that dataset is walked by mtime instead — on inside sudo, since Debian's sudo gives the command its own session and a process-group
`tank`, `immich` changes constantly and its diff ran past twenty minutes the first time, kill from outside never reached zfs — after which that dataset is walked by mtime
longer than walking it. Elsewhere (ext4, or a dataset with no snapshot) a directory whose mtime — to the instead. On `tank` a diff is metadata-bound like the walk: 8 s to 3 min per dataset for
a handful of changes, and `immich`, which changes constantly, ran past twenty minutes
the first time. A refresh of `tank` took 4 min 19 s against 7 min for the full scan. Elsewhere (ext4, or a dataset with no snapshot) a directory whose mtime — to the
nanosecond — is unchanged keeps its list and only its kept files are stat'ed again, since nanosecond — is unchanged keeps its list and only its kept files are stat'ed again, since
a file can grow without its directory noticing. A **cancelled** scan keeps what it walked, a file can grow without its directory noticing. A **cancelled** scan keeps what it walked,
marks the folders it never got to (`partial`, "not yet read" in the tree) and **Resume** marks the folders it never got to (`partial`, "not yet read" in the tree) and **Resume**
+10 -7
View File
@@ -571,16 +571,19 @@ func newestSnapshot(ctx context.Context, dataset string) string {
// zfsDiff lists what changed in a dataset since a snapshot: every changed, added, removed // zfsDiff lists what changed in a dataset since a snapshot: every changed, added, removed
// or renamed path, with the directory whose listing that touches. // or renamed path, with the directory whose listing that touches.
func zfsDiff(ctx context.Context, snapshot, mount string) ([]change, error) { func zfsDiff(ctx context.Context, snapshot, mount string) ([]change, error) {
// a diff slower than this is not worth waiting for: the walk by mtime is bounded // A diff slower than this is not worth waiting for: the walk by mtime is bounded. The
ctx, cancel := context.WithTimeout(ctx, 4*time.Minute) // clock runs inside sudo — `timeout` is zfs's parent and kills it as root — because sudo
// on Debian gives the command a session of its own, out of reach of our process group;
// the context is only a backstop.
const patience = 3 * time.Minute
ctx, cancel := context.WithTimeout(ctx, patience+30*time.Second)
defer cancel() defer cancel()
args := priv.Wrap("zfs", "diff", "-H", "-F", snapshot) args := priv.Wrap("timeout", "-s", "KILL", strconv.Itoa(int(patience.Seconds())), "zfs", "diff", "-H", "-F", snapshot)
cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd := exec.CommandContext(ctx, args[0], args[1:]...)
// on timeout the whole process group goes, not just sudo: zfs itself would otherwise run
// on holding the pipe, and Wait would sit there with it
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
cmd.WaitDelay = 5 * time.Second cmd.WaitDelay = 5 * time.Second
t0 := time.Now()
var stderr strings.Builder var stderr strings.Builder
cmd.Stderr = &stderr cmd.Stderr = &stderr
out, err := cmd.StdoutPipe() out, err := cmd.StdoutPipe()
@@ -609,8 +612,8 @@ func zfsDiff(ctx context.Context, snapshot, mount string) ([]change, error) {
} }
if err := cmd.Wait(); err != nil { if err := cmd.Wait(); err != nil {
msg := strings.TrimSpace(stderr.String()) msg := strings.TrimSpace(stderr.String())
if errors.Is(ctx.Err(), context.DeadlineExceeded) { if errors.Is(ctx.Err(), context.DeadlineExceeded) || time.Since(t0) >= patience {
msg = "gave up after 4 min" msg = "gave up after " + patience.String()
} }
if msg == "" { if msg == "" {
msg = err.Error() msg = err.Error()