Unwind the hash worker pool instead of abandoning it (closes #6)
All checks were successful
check / check (push) Successful in 1m2s

hashPhase returned the moment recordRun failed and left the pool
running: the feeder parked forever on a full jobs channel and every
worker on a full results channel. Until #4 landed the process exited
before that mattered; now that runScan returns an error and unwinds,
the goroutines are a real leak.

The pool is now an owned hashPool. Its context is derived from the
scan's, every blocking send in the feeder and the workers selects on
ctx.Done(), the feeder closes jobs on every path out so the workers'
range always terminates, and hashPhase defers pool.stop(), which
cancels and then drains results until the last goroutine has exited.
Draining is the half that matters: a worker already parked on a send
cannot observe the cancellation until a receiver frees it.

ctx comes from cmd.Context() and is threaded through runScan,
syncScan, both worker pools and the database layer as the first
parameter throughout, so graceful interrupt handling has a path to
hook into rather than a pool to rewrite.

The walk pool never leaked, because walkPhase always drains its
events to close, but it has the same unbounded-send shape and gets
the same treatment, together with a ctx.Err() guard after the walk: a
cancelled walk leaves a partial size census, and the update phase
would read every file it never reached as vanished and delete its
record.

Tests drive the scan entry point against a database whose insert
trigger aborts, with a fixture large enough that the failure lands
partway through the hash phase with more runs queued than either pool
channel can hold, and assert that the scan fails instead of hanging
and that runtime.NumGoroutine polls back to its pre-scan baseline.
This commit is contained in:
2026-08-09 03:00:01 +00:00
parent 2a055c0104
commit 1399249957
9 changed files with 489 additions and 141 deletions

287
scan.go
View File

@@ -2,6 +2,7 @@ package main
import (
"cmp"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
@@ -51,7 +52,11 @@ type fileMeta struct {
// duplicate. Flag parsing and the at-least-one-operand check are done
// by cobra. Errors are returned rather than exiting, so that the
// deferred close — which checkpoints the SQLite WAL — always runs.
func runScan(roots []string, workers int, oneFS bool) error {
// Cancelling ctx unwinds the worker pools and aborts the scan with the
// context's error.
func runScan(ctx context.Context, roots []string, workers int,
oneFS bool,
) error {
if workers < 1 {
workers = 1
}
@@ -63,14 +68,14 @@ func runScan(roots []string, workers int, oneFS bool) error {
dbPath := databasePath()
db, err := openScanDatabase(dbPath)
db, err := openScanDatabase(ctx, dbPath)
if err != nil {
return err
}
defer func() { _ = db.Close() }()
st, err := syncScan(db, roots, workers, oneFS)
st, err := syncScan(ctx, db, roots, workers, oneFS)
if err != nil {
return fmt.Errorf("update database %s: %w", dbPath, err)
}
@@ -168,28 +173,37 @@ type scanState struct {
// and update (record the size-unique files without reading them, and
// delete the records the scan no longer verifies). Records outside
// the roots are never touched.
func syncScan(db *sql.DB, roots []string, workers int,
oneFS bool,
func syncScan(ctx context.Context, db *sql.DB, roots []string,
workers int, oneFS bool,
) (scanStats, error) {
roots = pruneRoots(roots)
s := &scanState{db: db}
err := s.loadIndex(roots)
err := s.loadIndex(ctx, roots)
if err != nil {
return s.st, err
}
changed, unhashed := s.walkPhase(startWalk(roots, oneFS, workers))
changed, unhashed := s.walkPhase(startWalk(ctx, roots, oneFS, workers))
// A cancelled walk stops early, so its size census covers only part
// of the roots. Every file it never reached would look vanished to
// the update phase, which would then delete a perfectly good record
// for it: abort instead of writing that.
err = ctx.Err()
if err != nil {
return s.st, err
}
s.partition(changed, unhashed)
err = s.hashPhase(workers)
err = s.hashPhase(ctx, workers)
if err != nil {
return s.st, err
}
return s.st, s.updatePhase()
return s.st, s.updatePhase(ctx)
}
// loadIndex indexes the database records under the scan roots for
@@ -197,7 +211,7 @@ func syncScan(db *sql.DB, roots []string, workers int,
// them: out-of-scope records join the size census so a scanned file
// can be recognized as a possible duplicate of a tree scanned
// separately into the same database.
func (s *scanState) loadIndex(roots []string) error {
func (s *scanState) loadIndex(ctx context.Context, roots []string) error {
// Indexing tens of millions of records takes real time; without a
// display the scan looks hung before the walk begins.
prog := newProgress("load", -1)
@@ -205,7 +219,7 @@ func (s *scanState) loadIndex(roots []string) error {
s.existing = make(map[string]fileMeta)
return loadFileMeta(s.db,
return loadFileMeta(ctx, s.db,
func(path string, size, mtime int64, hashed bool) {
prog.increment()
@@ -370,28 +384,29 @@ func sameInode(a, b fileRec) bool {
// reads, so the bar shows a real ETA. A run that fails to hash is
// warned about and skipped; stale records for its paths, if any, are
// deleted by the update phase.
func (s *scanState) hashPhase(workers int) error {
//
// Returning early — a failed database write, or a cancelled scan — must
// not strand the pool: the feeder would park forever on a full jobs
// channel and every worker on a full results channel. The deferred stop
// is what prevents that.
func (s *scanState) hashPhase(ctx context.Context, workers int) error {
runs := hashRuns(s.toHash)
s.toHash = nil
jobs := make(chan []fileRec, workQueueDepth)
results := make(chan hashResult, workQueueDepth)
startHashWorkers(jobs, results, workers)
go func() {
for _, run := range runs {
jobs <- run
}
close(jobs)
}()
pool := startHashPool(ctx, runs, workers)
defer pool.stop()
prog := newProgress("hash", int64(len(runs)))
defer prog.finish()
for range runs {
r := <-results
var r hashResult
select {
case r = <-pool.results:
case <-ctx.Done():
return ctx.Err()
}
prog.increment()
@@ -403,7 +418,7 @@ func (s *scanState) hashPhase(workers int) error {
continue
}
err := s.recordRun(r)
err := s.recordRun(ctx, r)
if err != nil {
return err
}
@@ -415,7 +430,7 @@ func (s *scanState) hashPhase(workers int) error {
// recordRun folds one hash result into the running batch: every path
// in the run (one file, or several hard links to it) gets a record
// with the shared hashes.
func (s *scanState) recordRun(r hashResult) error {
func (s *scanState) recordRun(ctx context.Context, r hashResult) error {
for _, rec := range r.run {
s.resolve(rec.path)
@@ -432,7 +447,7 @@ func (s *scanState) recordRun(r hashResult) error {
return nil
}
err := applyBatch(s.db, s.batch, nil, nil)
err := applyBatch(ctx, s.db, s.batch, nil, nil)
s.batch = s.batch[:0]
return err
@@ -443,7 +458,7 @@ func (s *scanState) recordRun(r hashResult) error {
// size-unique new or changed file, and deletions for every record the
// scan did not verify (vanished files, plus paths that failed to stat
// or hash).
func (s *scanState) updatePhase() error {
func (s *scanState) updatePhase(ctx context.Context) error {
deletes := make([]string, 0, len(s.existing))
for path := range s.existing {
deletes = append(deletes, path)
@@ -459,7 +474,7 @@ func (s *scanState) updatePhase() error {
defer prog.finish()
err := applyChanges(s.db, s.batch, nil, prog)
err := applyChanges(ctx, s.db, s.batch, nil, prog)
if err != nil {
return err
}
@@ -476,13 +491,13 @@ func (s *scanState) updatePhase() error {
})
}
err = applyBatch(s.db, recs, nil, prog)
err = applyBatch(ctx, s.db, recs, nil, prog)
if err != nil {
return err
}
}
return applyChanges(s.db, nil, deletes, prog)
return applyChanges(ctx, s.db, nil, deletes, prog)
}
// underAnyRoot reports whether path is any of the roots or lies under
@@ -532,34 +547,53 @@ type walkEvent struct {
// startWalk seeds every root into the shared walk worker pool and
// returns the event stream: one record per regular file, one warning
// event per per-path error. The channel is closed when the walk
// completes.
func startWalk(roots []string, oneFS bool, workers int) <-chan walkEvent {
jobs, subdirs, events := startWalkWorkers(workers, oneFS)
// completes, and also when ctx is cancelled — every goroutine in the
// pool abandons its blocking send in that case, so the consumer sees a
// truncated but properly terminated stream instead of a stalled one.
func startWalk(ctx context.Context, roots []string, oneFS bool,
workers int,
) <-chan walkEvent {
jobs, subdirs, events := startWalkWorkers(ctx, workers, oneFS)
go func() {
initial := make([]dirJob, 0, len(roots))
for _, root := range roots {
initial = append(initial, seedRoot(root, events)...)
initial = append(initial, seedRoot(ctx, root, events)...)
}
dispatchDirs(initial, jobs, subdirs)
dispatchDirs(ctx, initial, jobs, subdirs)
}()
return events
}
// sendEvent delivers one walk event, abandoning the send when the scan
// is cancelled. Every walk goroutine reaches the consumer through this
// one channel, so this is where a cancelled walk unwinds rather than
// parking on a buffer nobody is draining.
func sendEvent(ctx context.Context, events chan<- walkEvent,
ev walkEvent,
) {
select {
case events <- ev:
case <-ctx.Done():
}
}
// seedRoot turns one PATH operand into the walk's starting state: a
// regular-file operand is statted and emitted directly, a directory
// operand becomes an initial job, and a symlink or other non-regular
// operand yields nothing (symlinks are never followed, including as
// operands).
func seedRoot(root string, events chan<- walkEvent) []dirJob {
func seedRoot(ctx context.Context, root string,
events chan<- walkEvent,
) []dirJob {
fi, err := os.Lstat(root)
if err != nil {
events <- walkEvent{
sendEvent(ctx, events, walkEvent{
warn: fmt.Sprintf("walk %s: %v", root, err),
fail: true,
}
})
return nil
}
@@ -576,13 +610,13 @@ func seedRoot(root string, events chan<- walkEvent) []dirJob {
case fi.Mode().IsRegular():
dev, ino := inodeOfInfo(fi)
events <- walkEvent{rec: fileRec{
sendEvent(ctx, events, walkEvent{rec: fileRec{
path: root,
size: fi.Size(),
mtime: fi.ModTime().Unix(),
dev: dev,
ino: ino,
}}
}})
return nil
default:
@@ -593,8 +627,10 @@ func seedRoot(root string, events chan<- walkEvent) []dirJob {
// startWalkWorkers starts the walk worker pool. Each worker processes
// one directory at a time, emitting an event per regular file and
// handing discovered subdirectories back to the dispatcher; events is
// closed once every worker has finished.
func startWalkWorkers(workers int,
// closed once every worker has finished. A cancelled scan makes the
// workers drop the directories still queued rather than stop reading
// jobs, so the range always runs out and the pool always tears down.
func startWalkWorkers(ctx context.Context, workers int,
oneFS bool,
) (chan dirJob, chan []dirJob, chan walkEvent) {
jobs := make(chan dirJob, workQueueDepth)
@@ -606,7 +642,14 @@ func startWalkWorkers(workers int,
for range workers {
wg.Go(func() {
for job := range jobs {
subdirs <- walkOneDir(job, oneFS, events)
if ctx.Err() != nil {
continue
}
select {
case subdirs <- walkOneDir(ctx, job, oneFS, events):
case <-ctx.Done():
}
}
})
}
@@ -622,11 +665,15 @@ func startWalkWorkers(workers int,
// dispatchDirs feeds directory jobs to the walk workers, queueing
// newly discovered subdirectories (newest first, which keeps the
// frontier small) until every directory has been processed, then
// closes jobs.
func dispatchDirs(initial []dirJob, jobs chan<- dirJob,
subdirs <-chan []dirJob,
// closes jobs. jobs is closed on every path out, cancellation
// included: the workers range over it, and a dispatcher that returned
// without closing would strand all of them.
func dispatchDirs(ctx context.Context, initial []dirJob,
jobs chan<- dirJob, subdirs <-chan []dirJob,
) {
go func() {
defer close(jobs)
queue := slices.Clone(initial)
pending := len(queue)
@@ -647,23 +694,25 @@ func dispatchDirs(initial []dirJob, jobs chan<- dirJob,
case subs := <-subdirs:
pending += len(subs) - 1
queue = append(queue, subs...)
case <-ctx.Done():
return
}
}
close(jobs)
}()
}
// walkOneDir reads one directory, emitting an event per regular-file
// entry and a warning event per unreadable one, and returns the
// subdirectories to descend into.
func walkOneDir(job dirJob, oneFS bool, events chan<- walkEvent) []dirJob {
func walkOneDir(ctx context.Context, job dirJob, oneFS bool,
events chan<- walkEvent,
) []dirJob {
entries, err := os.ReadDir(job.path)
if err != nil {
events <- walkEvent{
sendEvent(ctx, events, walkEvent{
warn: fmt.Sprintf("walk %s: %v", job.path, err),
fail: true,
}
})
return nil
}
@@ -674,7 +723,7 @@ func walkOneDir(job dirJob, oneFS bool, events chan<- walkEvent) []dirJob {
p := filepath.Join(job.path, e.Name())
if e.IsDir() {
if sub, ok := subdirJob(p, e, job, oneFS, events); ok {
if sub, ok := subdirJob(ctx, p, e, job, oneFS, events); ok {
subs = append(subs, sub)
}
@@ -686,7 +735,7 @@ func walkOneDir(job dirJob, oneFS bool, events chan<- walkEvent) []dirJob {
continue
}
emitFile(p, e, events)
emitFile(ctx, p, e, events)
}
return subs
@@ -697,13 +746,15 @@ func walkOneDir(job dirJob, oneFS bool, events chan<- walkEvent) []dirJob {
// metadata is still hot; a path that fails to stat (or stops being a
// regular file) between the directory read and the lstat is warned
// about and skipped.
func emitFile(p string, e fs.DirEntry, events chan<- walkEvent) {
func emitFile(ctx context.Context, p string, e fs.DirEntry,
events chan<- walkEvent,
) {
info, err := e.Info()
if err != nil {
events <- walkEvent{
sendEvent(ctx, events, walkEvent{
warn: fmt.Sprintf("stat %s: %v", p, err),
fail: true,
}
})
return
}
@@ -714,21 +765,21 @@ func emitFile(p string, e fs.DirEntry, events chan<- walkEvent) {
dev, ino := inodeOfInfo(info)
events <- walkEvent{rec: fileRec{
sendEvent(ctx, events, walkEvent{rec: fileRec{
path: p,
size: info.Size(),
mtime: info.ModTime().Unix(),
dev: dev,
ino: ino,
}}
}})
}
// subdirJob applies the descent rules to directory p: never enter
// .zfs (ZFS snapshot pseudo-dirs would list every file once per
// snapshot), and with -x never enter a directory on a different
// filesystem than its operand.
func subdirJob(p string, e fs.DirEntry, parent dirJob, oneFS bool,
events chan<- walkEvent,
func subdirJob(ctx context.Context, p string, e fs.DirEntry,
parent dirJob, oneFS bool, events chan<- walkEvent,
) (dirJob, bool) {
if e.Name() == ".zfs" {
return dirJob{}, false
@@ -741,10 +792,10 @@ func subdirJob(p string, e fs.DirEntry, parent dirJob, oneFS bool,
info, err := e.Info()
if err != nil {
events <- walkEvent{
sendEvent(ctx, events, walkEvent{
warn: fmt.Sprintf("walk %s: %v", p, err),
fail: true,
}
})
return dirJob{}, false
}
@@ -788,22 +839,102 @@ type hashResult struct {
err error
}
// startHashWorkers starts the hash worker pool: workers read inode
// runs, hash each run's first path (all paths in a run are hard links
// to the same inode), write one result per run, and exit when jobs is
// closed.
func startHashWorkers(jobs <-chan []fileRec, results chan<- hashResult,
// hashPool owns every goroutine of the hash worker pool: the feeder
// that queues the inode runs and the workers that read them. Both block
// on channel sends, so both are cancellable — the pool's context is
// derived from the scan's, and stop cancels it and waits the goroutines
// out. The consumer must call stop on every path out of the phase, not
// just the happy one.
type hashPool struct {
results <-chan hashResult
cancel context.CancelFunc
done <-chan struct{}
}
// startHashPool starts the feeder and the workers over runs. Workers
// hash each run's first path (all paths in a run are hard links to the
// same inode) and write one result per run.
func startHashPool(ctx context.Context, runs [][]fileRec,
workers int,
) {
) *hashPool {
ctx, cancel := context.WithCancel(ctx)
jobs := make(chan []fileRec, workQueueDepth)
results := make(chan hashResult, workQueueDepth)
var wg sync.WaitGroup
wg.Go(func() { feedHashJobs(ctx, runs, jobs) })
for range workers {
go func() {
for run := range jobs {
head, tail, err := hashHeadTail(run[0].path, run[0].size)
results <- hashResult{
run: run, head: head, tail: tail, err: err,
}
}
}()
wg.Go(func() { hashWorker(ctx, jobs, results) })
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
return &hashPool{results: results, cancel: cancel, done: done}
}
// stop cancels the pool and blocks until every one of its goroutines
// has exited, draining results while it waits: a worker already parked
// on a send observes the cancellation only once a receiver frees it.
// Calling stop more than once is safe.
func (p *hashPool) stop() {
p.cancel()
for {
select {
case <-p.results:
case <-p.done:
return
}
}
}
// feedHashJobs queues every run for the workers, closing jobs on the
// way out — including when the scan is cancelled mid-queue, so that the
// workers' range over jobs always terminates.
func feedHashJobs(ctx context.Context, runs [][]fileRec,
jobs chan<- []fileRec,
) {
defer close(jobs)
for _, run := range runs {
select {
case jobs <- run:
case <-ctx.Done():
return
}
}
}
// hashWorker hashes one inode run at a time until jobs is closed or the
// scan is cancelled. A cancelled worker drops the runs still queued
// instead of stopping its reads of jobs: the range must run out for the
// pool to tear down, and reading a file nobody wants the hash of only
// delays that.
func hashWorker(ctx context.Context, jobs <-chan []fileRec,
results chan<- hashResult,
) {
for run := range jobs {
if ctx.Err() != nil {
continue
}
head, tail, err := hashHeadTail(run[0].path, run[0].size)
select {
case results <- hashResult{
run: run, head: head, tail: tail, err: err,
}:
case <-ctx.Done():
return
}
}
}