hashPhase returns immediately when recordRun fails (scan.go:399-402), abandoning the remaining runs. The feeder goroutine (scan.go:375-381) then blocks forever sending on jobs, and every hash worker (scan.go:791-800) blocks forever sending on results once its 1024-slot buffer fills.
Today this is masked: the caller calls fatalf and the process dies with the goroutines still parked. It becomes a real leak the moment the scan runs in-process — which is exactly what #5 (graceful interrupt), #16 (CLI-surface tests) and #18 (race-detector target) all require.
Definition of done
hashPhase cancels the feeder and drains results before returning an error, or the pool becomes context-aware and unwinds through the same cancellation path as #5.
A test injects a database write failure partway through the hash phase and asserts the scan entrypoint returns an error rather than hanging.
The same test asserts runtime.NumGoroutine() returns to its pre-scan baseline, allowing a short settle window.
make check green.
`hashPhase` returns immediately when `recordRun` fails (`scan.go:399-402`), abandoning the remaining runs. The feeder goroutine (`scan.go:375-381`) then blocks forever sending on `jobs`, and every hash worker (`scan.go:791-800`) blocks forever sending on `results` once its 1024-slot buffer fills.
Today this is masked: the caller calls `fatalf` and the process dies with the goroutines still parked. It becomes a real leak the moment the scan runs in-process — which is exactly what #5 (graceful interrupt), #16 (CLI-surface tests) and #18 (race-detector target) all require.
## Definition of done
1. `hashPhase` cancels the feeder and drains `results` before returning an error, or the pool becomes context-aware and unwinds through the same cancellation path as #5.
2. A test injects a database write failure partway through the hash phase and asserts the scan entrypoint returns an error rather than hanging.
3. The same test asserts `runtime.NumGoroutine()` returns to its pre-scan baseline, allowing a short settle window.
4. `make check` green.
clawbot
added this to the 1.0.0 milestone 2026-08-09 03:43:45 +02:00
Implementation plan (branch hash-pool-cleanup, from main at 2a055c0):
1. Make the scan context-aware, end to end. ctx context.Context becomes the first parameter of runScan and syncScan, sourced from cmd.Context() in the cobra RunE wrapper
(runE grows a ctx parameter). Nothing installs a signal handler
here — that is #5's job; this PR only builds the cancellation path #5
will hook into, plus uses it for the error path that is broken today.
2. Replace the ad-hoc hash pool with an owned, cancellable one.
A small hashPool type owns the feeder goroutine and the worker
goroutines. Its context is derived from the scan's, and every blocking
send inside the pool (jobs <- run in the feeder, results <- ... in
the workers) becomes a select against ctx.Done(). The feeder always closes jobs on the way out, so the workers' range jobs always
terminates. hashPhase does defer pool.stop(), and stop cancels,
then drains results until every pool goroutine has exited — draining
is the part that matters, because a worker already parked on a send
will not observe the cancellation until someone reads. The loop over
results also selects on ctx.Done() so an externally cancelled scan
(i.e. #5) unwinds the same way. Net effect: hashPhase returning early
for any reason leaves zero goroutines behind (DoD 1).
3. Walk pool.
It has the same unbounded-blocking-send shape (events <- ... from the
walk workers and seedRoot, subdirs <- ..., jobs <- ... from the
dispatcher) but it does not leak today, because walkPhase has no
early return: it always drains events to close. #5 will introduce
exactly such an early return, so the same select-on-ctx.Done()
treatment is applied there, and dispatchDirs grows a defer close(jobs) so the workers can never be stranded. One
correctness guard comes with it: syncScan checks ctx.Err() after
the walk, because a cancelled walk yields a truncated size census and
the update phase would read every unreached file as vanished and delete
its record. Zero behaviour change today (the context is never cancelled
in production yet); it is what keeps #5 from having to re-plumb this.
4. Test (DoD 2 and 3), scan_test.go.
Injection is a real database write failure, not a fake: the test
pre-creates the database at SFDUPES_DATABASE with the normal schema
plus CREATE TRIGGER ... BEFORE INSERT ON files BEGIN SELECT RAISE(ABORT, 'injected write failure'); END. Reads (the record index
load) still work; the first batch commit inside the hash phase fails,
which is exactly the recordRun error path in question. The tree is a
few thousand more empty files than updateBatchSize, so the failure
lands partway through the phase with well over workQueueDepth runs
still queued — that is what guarantees parked workers if the fix is
absent. Empty files are never opened by the hasher, so the fixture
costs directory entries and no I/O. The test drives the real entry
point (run([]string{cmdScan, dir}, ...)), asserts exitFatal, and
then polls runtime.NumGoroutine() back to its pre-scan baseline with
a bounded settle loop (poll, not a fixed sleep). I will verify the test
genuinely fails with the production change reverted.
5.TODO.md Completed Steps entry in the same commit; make fmt,
then both make check and make docker green before pushing; PR
against main titled ... (closes #6), labelled needs-review,
assigned to clawbot.
Correction to step 3, recorded during the rework of #31. The claim
above that "the update phase would read every unreached file as
vanished and delete its record" overstates what happens today. With the
guard deleted, updatePhase deletes nothing: its first BeginTx fails
on the same cancelled context first. The guard is defence in depth, not
the sole barrier against data loss. It is still the right thing to
have — it is the barrier that survives #5 deciding an interrupted scan
may commit what it has, and it discards the partial census at the phase
boundary instead of letting a confusing begin transaction: context canceled surface from deep inside the
update phase.
Implementation plan (branch `hash-pool-cleanup`, from `main` at `2a055c0`):
**1. Make the scan context-aware, end to end.**
`ctx context.Context` becomes the first parameter of `runScan` and
`syncScan`, sourced from `cmd.Context()` in the cobra `RunE` wrapper
(`runE` grows a `ctx` parameter). Nothing installs a signal handler
here — that is #5's job; this PR only builds the cancellation path #5
will hook into, plus uses it for the error path that is broken today.
**2. Replace the ad-hoc hash pool with an owned, cancellable one.**
A small `hashPool` type owns the feeder goroutine and the worker
goroutines. Its context is derived from the scan's, and every blocking
send inside the pool (`jobs <- run` in the feeder, `results <- ...` in
the workers) becomes a `select` against `ctx.Done()`. The feeder always
`close`s `jobs` on the way out, so the workers' `range jobs` always
terminates. `hashPhase` does `defer pool.stop()`, and `stop` cancels,
then drains `results` until every pool goroutine has exited — draining
is the part that matters, because a worker already parked on a send
will not observe the cancellation until someone reads. The loop over
results also selects on `ctx.Done()` so an externally cancelled scan
(i.e. #5) unwinds the same way. Net effect: `hashPhase` returning early
for any reason leaves zero goroutines behind (DoD 1).
**3. Walk pool.**
It has the same unbounded-blocking-send shape (`events <- ...` from the
walk workers and `seedRoot`, `subdirs <- ...`, `jobs <- ...` from the
dispatcher) but it does not leak today, because `walkPhase` has no
early return: it always drains `events` to close. #5 will introduce
exactly such an early return, so the same `select`-on-`ctx.Done()`
treatment is applied there, and `dispatchDirs` grows a
`defer close(jobs)` so the workers can never be stranded. One
correctness guard comes with it: `syncScan` checks `ctx.Err()` after
the walk, because a cancelled walk yields a truncated size census and
the update phase would read every unreached file as vanished and delete
its record. Zero behaviour change today (the context is never cancelled
in production yet); it is what keeps #5 from having to re-plumb this.
**4. Test (DoD 2 and 3), `scan_test.go`.**
Injection is a real database write failure, not a fake: the test
pre-creates the database at `SFDUPES_DATABASE` with the normal schema
plus `CREATE TRIGGER ... BEFORE INSERT ON files BEGIN SELECT
RAISE(ABORT, 'injected write failure'); END`. Reads (the record index
load) still work; the first batch commit inside the hash phase fails,
which is exactly the `recordRun` error path in question. The tree is a
few thousand more empty files than `updateBatchSize`, so the failure
lands partway through the phase with well over `workQueueDepth` runs
still queued — that is what guarantees parked workers if the fix is
absent. Empty files are never opened by the hasher, so the fixture
costs directory entries and no I/O. The test drives the real entry
point (`run([]string{cmdScan, dir}, ...)`), asserts `exitFatal`, and
then polls `runtime.NumGoroutine()` back to its pre-scan baseline with
a bounded settle loop (poll, not a fixed sleep). I will verify the test
genuinely fails with the production change reverted.
**5.** `TODO.md` Completed Steps entry in the same commit; `make fmt`,
then both `make check` and `make docker` green before pushing; PR
against `main` titled `... (closes #6)`, labelled `needs-review`,
assigned to `clawbot`.
---
**Correction to step 3, recorded during the rework of #31.** The claim
above that "the update phase would read every unreached file as
vanished and delete its record" overstates what happens today. With the
guard deleted, `updatePhase` deletes nothing: its first `BeginTx` fails
on the same cancelled context first. The guard is defence in depth, not
the sole barrier against data loss. It is still the right thing to
have — it is the barrier that survives #5 deciding an interrupted scan
may commit what it has, and it discards the partial census at the phase
boundary instead of letting a confusing
`begin transaction: context canceled` surface from deep inside the
update phase.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
hashPhasereturns immediately whenrecordRunfails (scan.go:399-402), abandoning the remaining runs. The feeder goroutine (scan.go:375-381) then blocks forever sending onjobs, and every hash worker (scan.go:791-800) blocks forever sending onresultsonce its 1024-slot buffer fills.Today this is masked: the caller calls
fatalfand the process dies with the goroutines still parked. It becomes a real leak the moment the scan runs in-process — which is exactly what #5 (graceful interrupt), #16 (CLI-surface tests) and #18 (race-detector target) all require.Definition of done
hashPhasecancels the feeder and drainsresultsbefore returning an error, or the pool becomes context-aware and unwinds through the same cancellation path as #5.runtime.NumGoroutine()returns to its pre-scan baseline, allowing a short settle window.make checkgreen.Implementation plan (branch
hash-pool-cleanup, frommainat2a055c0):1. Make the scan context-aware, end to end.
ctx context.Contextbecomes the first parameter ofrunScanandsyncScan, sourced fromcmd.Context()in the cobraRunEwrapper(
runEgrows actxparameter). Nothing installs a signal handlerhere — that is #5's job; this PR only builds the cancellation path #5
will hook into, plus uses it for the error path that is broken today.
2. Replace the ad-hoc hash pool with an owned, cancellable one.
A small
hashPooltype owns the feeder goroutine and the workergoroutines. Its context is derived from the scan's, and every blocking
send inside the pool (
jobs <- runin the feeder,results <- ...inthe workers) becomes a
selectagainstctx.Done(). The feeder alwaysclosesjobson the way out, so the workers'range jobsalwaysterminates.
hashPhasedoesdefer pool.stop(), andstopcancels,then drains
resultsuntil every pool goroutine has exited — drainingis the part that matters, because a worker already parked on a send
will not observe the cancellation until someone reads. The loop over
results also selects on
ctx.Done()so an externally cancelled scan(i.e. #5) unwinds the same way. Net effect:
hashPhasereturning earlyfor any reason leaves zero goroutines behind (DoD 1).
3. Walk pool.
It has the same unbounded-blocking-send shape (
events <- ...from thewalk workers and
seedRoot,subdirs <- ...,jobs <- ...from thedispatcher) but it does not leak today, because
walkPhasehas noearly return: it always drains
eventsto close. #5 will introduceexactly such an early return, so the same
select-on-ctx.Done()treatment is applied there, and
dispatchDirsgrows adefer close(jobs)so the workers can never be stranded. Onecorrectness guard comes with it:
syncScanchecksctx.Err()afterthe walk, because a cancelled walk yields a truncated size census and
the update phase would read every unreached file as vanished and delete
its record. Zero behaviour change today (the context is never cancelled
in production yet); it is what keeps #5 from having to re-plumb this.
4. Test (DoD 2 and 3),
scan_test.go.Injection is a real database write failure, not a fake: the test
pre-creates the database at
SFDUPES_DATABASEwith the normal schemaplus
CREATE TRIGGER ... BEFORE INSERT ON files BEGIN SELECT RAISE(ABORT, 'injected write failure'); END. Reads (the record indexload) still work; the first batch commit inside the hash phase fails,
which is exactly the
recordRunerror path in question. The tree is afew thousand more empty files than
updateBatchSize, so the failurelands partway through the phase with well over
workQueueDepthrunsstill queued — that is what guarantees parked workers if the fix is
absent. Empty files are never opened by the hasher, so the fixture
costs directory entries and no I/O. The test drives the real entry
point (
run([]string{cmdScan, dir}, ...)), assertsexitFatal, andthen polls
runtime.NumGoroutine()back to its pre-scan baseline witha bounded settle loop (poll, not a fixed sleep). I will verify the test
genuinely fails with the production change reverted.
5.
TODO.mdCompleted Steps entry in the same commit;make fmt,then both
make checkandmake dockergreen before pushing; PRagainst
maintitled... (closes #6), labelledneeds-review,assigned to
clawbot.Correction to step 3, recorded during the rework of #31. The claim
above that "the update phase would read every unreached file as
vanished and delete its record" overstates what happens today. With the
guard deleted,
updatePhasedeletes nothing: its firstBeginTxfailson the same cancelled context first. The guard is defence in depth, not
the sole barrier against data loss. It is still the right thing to
have — it is the barrier that survives #5 deciding an interrupted scan
may commit what it has, and it discards the partial census at the phase
boundary instead of letting a confusing
begin transaction: context canceledsurface from deep inside theupdate phase.