check / check (push) Failing after 0s
Cleanup of the non-blocking findings from the re-review of #6. The tests were never wrong; only comments that misdescribed the mechanism they document, and one constant whose name claimed the wrong quantity. - State the property the walkClock tests rely on (the index load's Done cost is bounded and independent of record count) instead of the wrong "three consultations" figure. - Stop the poolUnwind framing from implying every test is bounded at two seconds; the three tests that catch their regression only as the test binary's timeout now say so. - Record that hashWorker's results-send abandon branch is reachable from the scan path and is covered by TestScanHashWriteFailureUnwindsPool, so every cancellation branch has a test. - Rename walkCancelInFlightDirs to walkCancelInFlightFiles: it is a file count (same value). - Note the deliberate departure from the one-test-file-per-source-file convention at the top of the file. Model: opus-4-8
529 lines
17 KiB
Go
529 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// This file gathers the tests for scan cancellation and worker-pool
|
|
// unwinding. Everything it exercises lives in scan.go, so by the repo's
|
|
// convention of one test file per source file it would belong in
|
|
// scan_test.go. It is kept separate on purpose: cancellation behaviour
|
|
// cuts across both the walk pool and the hash pool as a single concern,
|
|
// and scan_test.go is already 900+ lines. That is the deliberate
|
|
// exception the convention otherwise expects to be stated.
|
|
|
|
// poolUnwind bounds how long a goroutine is given to leave a pool after
|
|
// its context is cancelled. The tests that use it turn a pool that
|
|
// ignored its cancellation — and so parks forever — into a failed
|
|
// assertion within this bound instead of a hang. Not every test in this
|
|
// file has that property: a few catch their regression only as the test
|
|
// binary's own timeout, and each of those says so.
|
|
const poolUnwind = 2 * time.Second
|
|
|
|
// walkClock is a context whose cancellation is driven by the scan's
|
|
// own progress rather than by the wall clock: it cancels itself the
|
|
// moment its Done method has been consulted n times. That is what
|
|
// makes "cancel in the middle of the walk" reproducible instead of a
|
|
// race against a timer.
|
|
//
|
|
// The accounting behind the n chosen by each test: every blocking
|
|
// channel operation in the walk selects on Done, so the walk spends
|
|
// one consultation per file event plus a couple per directory. The
|
|
// index load that runs ahead of it also consults Done, but a bounded
|
|
// number of times that does not grow with the record count. The tests
|
|
// depend on that property, not on the bound's exact value: each picks
|
|
// an n comfortably above it and well short of the walk's total, so the
|
|
// cancellation lands deep inside the walk whatever the record count.
|
|
type walkClock struct {
|
|
n int64
|
|
seen atomic.Int64
|
|
once sync.Once
|
|
done chan struct{}
|
|
}
|
|
|
|
// newWalkClock returns a context that cancels itself on the nth
|
|
// consultation of its Done method.
|
|
func newWalkClock(n int64) *walkClock {
|
|
return &walkClock{n: n, done: make(chan struct{})}
|
|
}
|
|
|
|
// Done returns the cancellation channel, cancelling the context on the
|
|
// nth call and on every call after it. The same channel is returned
|
|
// throughout, so a caller that took it before the cancellation still
|
|
// observes the close.
|
|
func (c *walkClock) Done() <-chan struct{} {
|
|
if c.seen.Add(1) >= c.n {
|
|
c.once.Do(func() { close(c.done) })
|
|
}
|
|
|
|
return c.done
|
|
}
|
|
|
|
// Err reports the cancellation without consuming a consultation, which
|
|
// is what lets the post-walk guard read it without disturbing the
|
|
// count.
|
|
func (c *walkClock) Err() error {
|
|
select {
|
|
case <-c.done:
|
|
return context.Canceled
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Deadline reports no deadline: this context is cancelled by progress,
|
|
// never by time.
|
|
func (c *walkClock) Deadline() (time.Time, bool) {
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// Value carries nothing.
|
|
func (c *walkClock) Value(_ any) any {
|
|
return nil
|
|
}
|
|
|
|
// walkCancelDirs and walkCancelFilesPerDir shape the fixture for the
|
|
// mid-walk cancellation test. Spreading the files over directories is
|
|
// load-bearing: it is what bounds how much of the tree can still be
|
|
// walked after the cancellation, since the workers drop every
|
|
// directory still queued and only the handful already in flight can
|
|
// emit anything more.
|
|
const (
|
|
walkCancelDirs = 100
|
|
walkCancelFilesPerDir = 20
|
|
walkCancelFiles = walkCancelDirs * walkCancelFilesPerDir
|
|
walkCancelWorkers = 4
|
|
// The files carried by the walkCancelWorkers directories already in
|
|
// flight when the scan is cancelled: each such directory can still
|
|
// emit its walkCancelFilesPerDir files. This is a file count, not a
|
|
// directory count.
|
|
walkCancelInFlightFiles = walkCancelWorkers * walkCancelFilesPerDir
|
|
)
|
|
|
|
// walkCancelAtDone is the consultation on which the fixture's context
|
|
// cancels itself. A quarter of the file count is far past the index
|
|
// load's fixed handful and far short of the walk's total, so the
|
|
// cancellation lands deep inside the walk and nowhere near either end
|
|
// of it.
|
|
const walkCancelAtDone = walkCancelFiles / 4
|
|
|
|
// buildWalkCancelTree writes walkCancelFiles empty files spread over
|
|
// walkCancelDirs subdirectories. Zero-length files are never opened by
|
|
// the hasher, so the fixture costs directory entries and no read I/O
|
|
// while still giving the walk thousands of events to emit.
|
|
func buildWalkCancelTree(t *testing.T) string {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
|
|
for i := range walkCancelDirs {
|
|
sub := filepath.Join(dir, "d"+strconv.Itoa(i))
|
|
|
|
err := os.Mkdir(sub, 0o750)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
writeEmptyFiles(t, sub, walkCancelFilesPerDir)
|
|
}
|
|
|
|
return dir
|
|
}
|
|
|
|
// assertRecordsIntact fails when the database no longer holds exactly
|
|
// the records it held before, reporting the first difference rather
|
|
// than dumping thousands of paths.
|
|
func assertRecordsIntact(t *testing.T, db *sql.DB, before []string) {
|
|
t.Helper()
|
|
|
|
got := recordPaths(dbRecords(t, db))
|
|
if len(got) != len(before) {
|
|
t.Fatalf("%d records after the cancelled scan, want %d",
|
|
len(got), len(before))
|
|
}
|
|
|
|
for i := range got {
|
|
if got[i] != before[i] {
|
|
t.Fatalf("record %d = %q after the cancelled scan, want %q",
|
|
i, got[i], before[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSyncScanCancelledMidWalkKeepsRecords is the regression net under
|
|
// the post-walk guard. The scan is cancelled part-way through the
|
|
// walk, so it reaches the guard holding a genuinely partial size
|
|
// census and a still-populated index of records the walk never got to.
|
|
// Every one of those records would look vanished to the update phase.
|
|
// The guard is what stops the scan there, and this test is what
|
|
// notices if it stops doing so: deleting the guard, or making it
|
|
// unreachable, makes the scan carry its truncated view into a later
|
|
// phase and fail there instead, with a wrapped error rather than the
|
|
// bare cancellation.
|
|
//
|
|
// The syncScan call here is not bounded by poolUnwind: a regression
|
|
// that left a worker pool parked would hang it, and that regression is
|
|
// caught only by the test binary's own timeout, not by a quick
|
|
// assertion.
|
|
//
|
|
//nolint:paralleltest // counts goroutines: must not run beside others
|
|
func TestSyncScanCancelledMidWalkKeepsRecords(t *testing.T) {
|
|
dir := buildWalkCancelTree(t)
|
|
db := openTestDB(t)
|
|
|
|
st := syncTree(t, db, dir)
|
|
if st.added != walkCancelFiles {
|
|
t.Fatalf("setup scan added %d records, want %d",
|
|
st.added, walkCancelFiles)
|
|
}
|
|
|
|
before := recordPaths(dbRecords(t, db))
|
|
base := baselineGoroutines(t)
|
|
|
|
st, err := syncScan(newWalkClock(walkCancelAtDone), db,
|
|
[]string{dir}, walkCancelWorkers, false)
|
|
|
|
assertWalkGuardAborted(t, st, err)
|
|
assertRecordsIntact(t, db, before)
|
|
|
|
if got := settledGoroutines(t, base); got > base {
|
|
t.Errorf("goroutines = %d after the cancelled scan, want %d back",
|
|
got, base)
|
|
}
|
|
}
|
|
|
|
// assertWalkGuardAborted checks that the scan stopped at the post-walk
|
|
// guard: with a census that is neither empty (the walk really ran)
|
|
// nor complete (it really was cut short), and with the guard's own
|
|
// bare cancellation as the error. A wrapped error means the partial
|
|
// census was carried past the guard into the hash or update phase,
|
|
// which is the failure this test exists to catch.
|
|
func assertWalkGuardAborted(t *testing.T, st scanStats, err error) {
|
|
t.Helper()
|
|
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("syncScan cancelled mid-walk = %v, want %v",
|
|
err, context.Canceled)
|
|
}
|
|
|
|
if errors.Unwrap(err) != nil {
|
|
t.Errorf("syncScan reported %q, want the guard's bare "+
|
|
"cancellation: a wrapped error means the truncated census "+
|
|
"reached a later phase", err)
|
|
}
|
|
|
|
if st.unchanged == 0 {
|
|
t.Fatalf("stats = %+v: the census is empty, so the walk never "+
|
|
"ran and the guard was reached for the wrong reason", st)
|
|
}
|
|
|
|
if st.unchanged >= walkCancelFiles {
|
|
t.Fatalf("stats = %+v: the census covers the whole tree, so the "+
|
|
"walk was not cut short", st)
|
|
}
|
|
|
|
// The workers drop every directory still queued once the scan is
|
|
// cancelled, so only the files in the directories already in flight
|
|
// can add to the census after the fact. A census beyond that bound
|
|
// would mean the cancellation was not observed where it should have
|
|
// been.
|
|
limit := walkCancelAtDone + walkCancelInFlightFiles
|
|
if st.unchanged > limit {
|
|
t.Errorf("census covers %d files, want at most %d: the walk kept "+
|
|
"taking directories off the queue after cancellation",
|
|
st.unchanged, limit)
|
|
}
|
|
|
|
if st.removed != 0 {
|
|
t.Errorf("stats = %+v: the scan counted records for removal from "+
|
|
"a partial census", st)
|
|
}
|
|
}
|
|
|
|
// TestSyncScanCancelledBeforeLoadIndex covers the trivial end of the
|
|
// cancellation path: a scan handed a context that is already cancelled
|
|
// fails in the index load, before the walk pool is ever started. It
|
|
// says nothing about the post-walk guard — nothing downstream of
|
|
// loadIndex runs at all — only that the failure surfaces as a
|
|
// cancellation and that no record is touched on the way out.
|
|
func TestSyncScanCancelledBeforeLoadIndex(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := buildSmokeTree(t)
|
|
db := openTestDB(t)
|
|
|
|
syncTree(t, db, dir)
|
|
|
|
before := recordPaths(dbRecords(t, db))
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
|
|
st, err := syncScan(ctx, db, []string{dir}, walkCancelWorkers, false)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("syncScan on a cancelled context = %v, want %v",
|
|
err, context.Canceled)
|
|
}
|
|
|
|
if st != (scanStats{}) {
|
|
t.Errorf("stats = %+v, want none: the scan gave up in the index "+
|
|
"load, before any phase ran", st)
|
|
}
|
|
|
|
assertRecordsIntact(t, db, before)
|
|
}
|
|
|
|
// drainClosed counts the values received from ch until it closes,
|
|
// failing the test if it does not close within poolUnwind. A pool that
|
|
// ignored its cancellation leaves its channel open with its goroutines
|
|
// parked, and this is what reports that as an assertion.
|
|
func drainClosed[T any](t *testing.T, ch <-chan T, what string) int {
|
|
t.Helper()
|
|
|
|
counted := make(chan int, 1)
|
|
|
|
go func() {
|
|
n := 0
|
|
for range ch {
|
|
n++
|
|
}
|
|
|
|
counted <- n
|
|
}()
|
|
|
|
select {
|
|
case n := <-counted:
|
|
return n
|
|
case <-time.After(poolUnwind):
|
|
t.Fatalf("%s stayed open after cancellation", what)
|
|
|
|
return 0
|
|
}
|
|
}
|
|
|
|
// awaitReturn fails the test if done is not closed within poolUnwind.
|
|
func awaitReturn(t *testing.T, done <-chan struct{}, what string) {
|
|
t.Helper()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(poolUnwind):
|
|
t.Fatalf("%s did not return after cancellation", what)
|
|
}
|
|
}
|
|
|
|
// cancelledContext returns a context that is already cancelled.
|
|
func cancelledContext(t *testing.T) context.Context {
|
|
t.Helper()
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
|
|
return ctx
|
|
}
|
|
|
|
// TestSendEventAbandonsBlockedSend checks that a walk goroutine with an
|
|
// event to deliver and nobody to deliver it to leaves on cancellation
|
|
// instead of holding the pool open. The channel here is unbuffered and
|
|
// unread, so the send can never complete.
|
|
func TestSendEventAbandonsBlockedSend(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
done := make(chan struct{})
|
|
events := make(chan walkEvent)
|
|
|
|
go func() {
|
|
defer close(done)
|
|
|
|
sendEvent(cancelledContext(t), events, walkEvent{})
|
|
}()
|
|
|
|
awaitReturn(t, done, "sendEvent")
|
|
}
|
|
|
|
// TestWalkWorkersDropQueuedDirs checks that cancelled walk workers keep
|
|
// reading jobs and drop the directories rather than stopping their
|
|
// read: the range over jobs has to run out for the pool to tear down
|
|
// and close its event stream.
|
|
func TestWalkWorkersDropQueuedDirs(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
writeEmptyFiles(t, dir, walkCancelFilesPerDir)
|
|
|
|
jobs, _, events := startWalkWorkers(cancelledContext(t), 2, false)
|
|
|
|
for range 4 {
|
|
jobs <- dirJob{path: dir}
|
|
}
|
|
|
|
close(jobs)
|
|
|
|
if n := drainClosed(t, events, "the walk event stream"); n != 0 {
|
|
t.Errorf("cancelled walk workers emitted %d events, want none", n)
|
|
}
|
|
}
|
|
|
|
// TestWalkWorkerAbandonsSubdirHandoff checks the other blocking send a
|
|
// walk worker makes: handing discovered subdirectories back to the
|
|
// dispatcher. Once the dispatcher has left, nothing drains that
|
|
// channel, and a worker parked on it would hold the pool open forever.
|
|
func TestWalkWorkerAbandonsSubdirHandoff(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dir := t.TempDir()
|
|
writeEmptyFiles(t, dir, 1)
|
|
|
|
err := os.Mkdir(filepath.Join(dir, "sub"), 0o750)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
defer cancel()
|
|
|
|
jobs, subdirs, events := startWalkWorkers(ctx, 1, false)
|
|
|
|
// Fill the hand-back channel to its capacity — one slot per worker
|
|
// — so the worker's own hand-back is certain to block.
|
|
subdirs <- nil
|
|
|
|
jobs <- dirJob{path: dir}
|
|
|
|
// The file event proves the worker has read the directory and has
|
|
// nothing left to do but the blocked hand-back.
|
|
ev := <-events
|
|
if ev.fail {
|
|
t.Fatalf("walk event = %+v, want the fixture file", ev)
|
|
}
|
|
|
|
cancel()
|
|
close(jobs)
|
|
drainClosed(t, events, "the walk event stream")
|
|
}
|
|
|
|
// TestDispatchDirsClosesJobsWhenCancelled checks that a dispatcher
|
|
// leaving on cancellation closes the job channel on its way out. The
|
|
// workers range over that channel; a dispatcher that returned without
|
|
// closing it would strand every one of them.
|
|
func TestDispatchDirsClosesJobsWhenCancelled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// Unbuffered and unread: with no worker pool behind it, the
|
|
// dispatcher can only leave through its cancellation case.
|
|
jobs := make(chan dirJob)
|
|
subdirs := make(chan []dirJob)
|
|
initial := []dirJob{{path: "/a"}, {path: "/b"}}
|
|
|
|
dispatchDirs(cancelledContext(t), initial, jobs, subdirs)
|
|
|
|
if n := drainClosed(t, jobs, "the walk job queue"); n > len(initial) {
|
|
t.Errorf("dispatcher queued %d jobs, want at most %d",
|
|
n, len(initial))
|
|
}
|
|
}
|
|
|
|
// TestFeedHashJobsClosesJobsWhenCancelled checks that the hash feeder
|
|
// abandons the runs it has not queued yet and still closes the job
|
|
// channel, which is what lets the workers' range terminate. The
|
|
// receive on jobs below is not bounded: a feeder that returned without
|
|
// closing jobs would leave that receive with no sender and no close, so
|
|
// this regression is caught by the test binary's timeout rather than by
|
|
// a bounded assertion.
|
|
func TestFeedHashJobsClosesJobsWhenCancelled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
done := make(chan struct{})
|
|
// Unbuffered and unread until the feeder has returned, so the only
|
|
// way out of the feeder is its cancellation case.
|
|
jobs := make(chan []fileRec)
|
|
runs := [][]fileRec{{{path: "a"}}, {{path: "b"}}}
|
|
|
|
go func() {
|
|
defer close(done)
|
|
|
|
feedHashJobs(cancelledContext(t), runs, jobs)
|
|
}()
|
|
|
|
awaitReturn(t, done, "feedHashJobs")
|
|
|
|
if _, ok := <-jobs; ok {
|
|
t.Error("the hash job channel was left open after cancellation")
|
|
}
|
|
}
|
|
|
|
// TestHashWorkerDropsQueuedRuns checks that a cancelled hash worker
|
|
// keeps reading jobs and drops the runs rather than reading files
|
|
// nobody wants the hashes of — while still letting the range run out
|
|
// so the pool tears down. The queued run names a file that does not
|
|
// exist, so a worker that hashed it anyway would produce a result.
|
|
//
|
|
// hashWorker has a second cancellation exit: the send of a completed
|
|
// result on the results channel. That branch is reachable from the
|
|
// production scan path, not dead code — pool.stop() cancels the context
|
|
// before it starts draining results, so a worker parked on that send
|
|
// leaves through this case, freed by the drain rather than by an empty
|
|
// jobs channel. It is exercised by TestScanHashWriteFailureUnwindsPool,
|
|
// which strands every worker on a full results channel until stop()
|
|
// unwinds the pool. With both branches covered, every cancellation
|
|
// branch of the walk and hash pools has a test.
|
|
func TestHashWorkerDropsQueuedRuns(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
done := make(chan struct{})
|
|
jobs := make(chan []fileRec, 1)
|
|
results := make(chan hashResult, 1)
|
|
|
|
run := []fileRec{{path: filepath.Join(t.TempDir(), "missing"), size: 1}}
|
|
|
|
jobs <- run
|
|
|
|
close(jobs)
|
|
|
|
go func() {
|
|
defer close(done)
|
|
|
|
hashWorker(cancelledContext(t), jobs, results)
|
|
}()
|
|
|
|
awaitReturn(t, done, "hashWorker")
|
|
|
|
select {
|
|
case r := <-results:
|
|
t.Errorf("cancelled hash worker produced %+v, want the run dropped",
|
|
r)
|
|
default:
|
|
}
|
|
}
|
|
|
|
// TestHashPhaseCancelledReturnsContextError checks the result loop's
|
|
// own exit: with the pool cancelled, no result will ever arrive, and
|
|
// the loop must leave through the cancellation rather than wait for a
|
|
// receive that cannot happen. This call is not bounded by poolUnwind: a
|
|
// loop that dropped its cancellation case would block on that receive,
|
|
// so the regression surfaces as the test binary's timeout rather than
|
|
// as a bounded assertion.
|
|
func TestHashPhaseCancelledReturnsContextError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
s := &scanState{
|
|
db: openTestDB(t),
|
|
toHash: []fileRec{{path: "a", size: 1, dev: 1, ino: 1}},
|
|
}
|
|
|
|
err := s.hashPhase(cancelledContext(t), 2)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("hashPhase on a cancelled context = %v, want %v",
|
|
err, context.Canceled)
|
|
}
|
|
}
|