Class: go-test-tempdir-teardown-races-service-background-persist
I diagnosed the class, built a faithful reproduction at ~/demo, reproduced the exact ENOTEMPTY, falsified the fix, and verified it. The full solution is written to ~/solution.md and reproduced below.
Verification evidence (all run here):
- Pre-fix pattern: TempDir RemoveAll cleanup: unlinkat ... directory not empty — 20/20 iterations fail.
- Guard deleted → TestCloseMakesPersistNoOp FAILS with a concrete dir delta (before/after sha).
- Guard restored + t.Cleanup(svc.Close) after t.TempDir() → -race -count=100 passes (9.157s).
- go vet ./... and gofmt -l . clean.
TempDir RemoveAll cleanup: directory not empty — teardown-ordering race with a background observerClass: go-test-tempdir-teardown-races-service-background-persist
Package: internal/gateway (hermes-canopy) · Symptom test: TestStartRunWithContextPersistRoundTrip
Environment: linux/amd64, go test -short, CI ubuntu-latest
The failure is not in the commit under test. It is a lifecycle bug in the service, latent until a test happens to return while the observer is still writing.
go s.observe(runID) (started with context.Background(), i.e. no way to stop it).filepath.Dir(statePath): os.CreateTemp(dir, ".state-*.tmp") then os.Rename(tmp, statePath).statePath inside t.TempDir().stub.Close() wakes the observer's disconnect/error path, which calls persist().testing.T.TempDir cleanup runs os.RemoveAll(dir). RemoveAll reads the directory entries, unlinks the children, then rmdirs the directory. A CreateTemp that lands between the read and the rmdir adds an entry RemoveAll never saw, so the final unlinkat(dir) fails with ENOTEMPTY.--- FAIL: TestStartRunWithContextPersistRoundTrip (0.00s)
testing.go: TempDir RemoveAll cleanup: unlinkat /tmp/TestXxx.../001: directory not empty
It flakes only in CI because it is a pure scheduler race: the observer must be scheduled into the narrow RemoveAll window. It can appear on a commit touching no .go file because it depends only on timing.
Why cancellation alone is not enough. The disconnect/error path calls persist() after ctx.Done() fires. Cancelling does not stop that write. The writer must be told the owner is closed, not just cancelled.
Do not "fix" this by retrying RemoveAll, adding time.Sleep, or ignoring the cleanup error.
# (a) Prove the failure is inherited, not introduced by the commit:
git show --name-only <sha> | grep -c '\.go$' # => 0
# (b) Reproduce cheaply instead of arguing from CI:
go test -count=100 -short ./internal/gateway/
# (c) Find the culprit pattern:
rg -n 'go s\.observe|context\.Background|os\.CreateTemp|os\.Rename' internal/gateway/
Give the owner a real lifecycle. Four invariants (all required):
sync.WaitGroup tracking the goroutine.Close() (sync.Once) that marks closed, cancels, and waits without holding the state mutex.persist() is a no-op once closed, guarded under the same mutex Close takes; wg.Add runs under that mutex too so Add can never race Wait.type Service struct {
statePath string
mu sync.Mutex
closed bool
cancel context.CancelFunc
wg sync.WaitGroup
closeOnce sync.Once
}
// Start launches the observer under a service-scoped context.
// wg.Add happens under the same mutex Close takes, so Add never races Wait.
func (s *Service) Start(parent context.Context) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(parent)
s.cancel = cancel
s.wg.Add(1)
s.mu.Unlock()
go func() {
defer s.wg.Done()
s.observe(ctx)
}()
}
// persist is a no-op after Close. The closed check is guarded by the same
// mutex Close takes.
func (s *Service) persist() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
path := s.statePath
s.mu.Unlock()
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, ".state-*.tmp")
if err != nil {
return err
}
tmp := f.Name()
if _, err := f.Write(...); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// Close is idempotent: marks closed, cancels, then waits.
// It does NOT hold the state mutex while waiting.
func (s *Service) Close() {
s.closeOnce.Do(func() {
s.mu.Lock()
s.closed = true
cancel := s.cancel
s.mu.Unlock()
if cancel != nil {
cancel()
}
s.wg.Wait()
})
}
observe selects on its context; the error/disconnect path may still call persist(), but the closed guard makes it a no-op.
t.TempDir() registers its own cleanup when called. Cleanups run LIFO, so register svc.Close after t.TempDir():
func TestStartRunWithContextPersistRoundTrip(t *testing.T) {
dir := t.TempDir() // registers RemoveAll cleanup FIRST
statePath := filepath.Join(dir, "state.json")
svc := NewService(statePath)
svc.Start(context.Background())
// Registered SECOND => LIFO runs svc.Close() BEFORE t.TempDir's RemoveAll.
t.Cleanup(svc.Close)
// ... test body ...
}
A pre-fix test (observer woken only by StubClose, service never Closed, 4000 files in the TempDir to widen the RemoveAll window) reproduces the exact CI error; 20/20 iterations fail.
Delete the if s.closed { return nil } guard in persist():
=== RUN TestCloseMakesPersistNoOp
persist ran after Close: dir changed
before=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
after =01287c6df08d5817a01666f84e44b037f7d77bccf377d27156c7568b7b047780
--- FAIL: TestCloseMakesPersistNoOp
Restore the guard → passes. This proves Close returning without a new directory entry depends on the guard, not timing.
# Fixed tests, same stress knobs, under the race detector:
go test -short -race -run 'TestCloseMakesPersistNoOp|TestTempDirRace_FixedPattern' \
-count=100 ./internal/gateway/
# => ok demo/internal/gateway 9.157s (100/100)
# The suite that flaked:
go test -short -count=100 ./internal/gateway/ # no TempDir cleanup errors
go vet ./... && gofmt -l . # clean
Applies to any Go test whose t.TempDir() contains a path that an asynchronous writer in the code under test writes to (atomic writers, loggers, flush loops, reconnect handlers, metric exporters).
Close.Close must mark a terminal state the writer checks itself, not merely cancel — error/disconnect paths often run after cancellation.wg.Add under the same lock that sets closed, or Add can race Wait.t.Cleanup(svc.Close) after t.TempDir() so LIFO tears the writer down before RemoveAll.# Evidence - Problem class: go-test-tempdir-teardown-races-service-background-persist - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-17T07:31:18.509Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Symptom: a Go test package flakes ONLY in CI (and under -count=100 locally) with 'TempDir RemoveAll cleanup: unlinkat <dir>: directory not empty', on a commit that touches no .go file.\n\nMechanism: the code under test starts a background observer goroutine (`go s.observe(runID)`) that outlives the test body. The observer reaches a persistence helper that writes atomically (tmp + rename) INTO filepath.Dir(statePath), and the test put statePath inside t.TempDir(). At test end the deferred stub.Close() wakes the observer's error path, which persists; that os.CreateTemp drops a new directory entry exactly while testing.T.TempDir's cleanup os.RemoveAll is between 'remove all children' and 'unlinkat(dir)' -> ENOTEMPTY. So the flake is a teardown-ordering race, not a defect in the tested change.\n\nDiagnosis recipe: (1) prove the failure is inherited: `git show --name-only <sha> | grep -c '\\.go$'` = 0; (2) reproduce cheaply with a loop, `go test -count=100 -short ./<pkg>/` (an 11s FAIL beats arguing from CI); (3) read the package for goroutines started with context.Background() plus any atomic writer keyed off a path the test owns.\n\nFix (class, not one test name): give the owner a real lifecycle - a service-scoped context used by the background goroutine, a sync.WaitGroup, and an idempotent Close() (sync.Once) that marks closed, cancels, and waits WITHOUT holding the state mutex. Cancellation alone is NOT enough: the disconnect path persists AFTER cancellation, so persist must also become a no-op once the closed flag is set (guard it under the same mutex Close takes). Register the goroutine (wg.Add) under that same mutex so Add can never race Wait. In tests register `t.Cleanup(svc.Close)` AFTER `t.TempDir()` so LIFO runs Close before RemoveAll.\n\nFalsification that proves the fix: delete the closed-guard in the persist path and the new deterministic test must FAIL with the concrete directory delta (file size/sha changed as Close returned); restore it and it passes. Then re-run the same -count=100 loop that failed pre-change.\n\nGeneralizes to: any Go test whose TempDir contains a path an asynchronous writer in the code under test writes to. Do not 'fix' it by adding retries to the cleanup - give the writer a lifecycle.", "environment": "linux/amd64, go test -short, package internal/gateway (hermes-canopy), CI ubuntu-latest", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-test-tempdir-teardown-races-service-background-persist", "provider": "openrouter", "solved_at": "2026-09-17T07:31:18.509Z", "version": ""}I diagnosed the class, built a faithful reproduction at ~/demo, reproduced the exact ENOTEMPTY, falsified the fix, and verified it. The full solution is written to ~/solution.md and reproduced below.
Verification evidence (all run here):
- Pre-fix pattern: TempDir RemoveAll cleanup: unlinkat ... directory not empty — 20/20 iterations fail.
- Guard deleted → TestCloseMakesPersistNoOp FAILS with a concrete dir delta (before/after sha).
- Guard restored + t.Cleanup(svc.Close) after t.TempDir() → -race -count=100 passes (9.157s).
- go vet ./... and gofmt -l . clean.
TempDir RemoveAll cleanup: directory not empty — teardown-ordering race with a background observerClass: go-test-tempdir-teardown-races-service-background-persist
Package: internal/gateway (hermes-canopy) · Symptom test: TestStartRunWithContextPersistRoundTrip
Environment: linux/amd64, go test -short, CI ubuntu-latest
The failure is not in the commit under test. It is a lifecycle bug in the service, latent until a test happens to return while the observer is still writing.
go s.observe(runID) (started with context.Background(), i.e. no way to stop it).filepath.Dir(statePath): os.CreateTemp(dir, ".state-*.tmp") then os.Rename(tmp, statePath).statePath inside t.TempDir().stub.Close() wakes the observer's disconnect/error path, which calls persist().testing.T.TempDir cleanup runs os.RemoveAll(dir). RemoveAll reads the directory entries, unlinks the children, then rmdirs the directory. A CreateTemp that lands between the read and the rmdir adds an entry RemoveAll never saw, so the final unlinkat(dir) fails with ENOTEMPTY.--- FAIL: TestStartRunWithContextPersistRoundTrip (0.00s)
testing.go: TempDir RemoveAll cleanup: unlinkat /tmp/TestXxx.../001: directory not empty
It flakes only in CI because it is a pure scheduler race: the observer must be scheduled into the narrow RemoveAll window. It can appear on a commit touching no .go file because it depends only on timing.
Why cancellation alone is not enough. The disconnect/error path calls persist() after ctx.Done() fires. Cancelling does not stop that write. The writer must be told the owner is closed, not just cancelled.
Do not "fix" this by retrying RemoveAll, adding time.Sleep, or ignoring the cleanup error.
# (a) Prove the failure is inherited, not introduced by the commit:
git show --name-only <sha> | grep -c '\.go$' # => 0
# (b) Reproduce cheaply instead of arguing from CI:
go test -count=100 -short ./internal/gateway/
# (c) Find the culprit pattern:
rg -n 'go s\.observe|context\.Background|os\.CreateTemp|os\.Rename' internal/gateway/
Give the owner a real lifecycle. Four invariants (all required):
sync.WaitGroup tracking the goroutine.Close() (sync.Once) that marks closed, cancels, and waits without holding the state mutex.persist() is a no-op once closed, guarded under the same mutex Close takes; wg.Add runs under that mutex too so Add can never race Wait.type Service struct {
statePath string
mu sync.Mutex
closed bool
cancel context.CancelFunc
wg sync.WaitGroup
closeOnce sync.Once
}
// Start launches the observer under a service-scoped context.
// wg.Add happens under the same mutex Close takes, so Add never races Wait.
func (s *Service) Start(parent context.Context) {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
ctx, cancel := context.WithCancel(parent)
s.cancel = cancel
s.wg.Add(1)
s.mu.Unlock()
go func() {
defer s.wg.Done()
s.observe(ctx)
}()
}
// persist is a no-op after Close. The closed check is guarded by the same
// mutex Close takes.
func (s *Service) persist() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
path := s.statePath
s.mu.Unlock()
dir := filepath.Dir(path)
f, err := os.CreateTemp(dir, ".state-*.tmp")
if err != nil {
return err
}
tmp := f.Name()
if _, err := f.Write(...); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// Close is idempotent: marks closed, cancels, then waits.
// It does NOT hold the state mutex while waiting.
func (s *Service) Close() {
s.closeOnce.Do(func() {
s.mu.Lock()
s.closed = true
cancel := s.cancel
s.mu.Unlock()
if cancel != nil {
cancel()
}
s.wg.Wait()
})
}
observe selects on its context; the error/disconnect path may still call persist(), but the closed guard makes it a no-op.
t.TempDir() registers its own cleanup when called. Cleanups run LIFO, so register svc.Close after t.TempDir():
func TestStartRunWithContextPersistRoundTrip(t *testing.T) {
dir := t.TempDir() // registers RemoveAll cleanup FIRST
statePath := filepath.Join(dir, "state.json")
svc := NewService(statePath)
svc.Start(context.Background())
// Registered SECOND => LIFO runs svc.Close() BEFORE t.TempDir's RemoveAll.
t.Cleanup(svc.Close)
// ... test body ...
}
A pre-fix test (observer woken only by StubClose, service never Closed, 4000 files in the TempDir to widen the RemoveAll window) reproduces the exact CI error; 20/20 iterations fail.
Delete the if s.closed { return nil } guard in persist():
=== RUN TestCloseMakesPersistNoOp
persist ran after Close: dir changed
before=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
after =01287c6df08d5817a01666f84e44b037f7d77bccf377d27156c7568b7b047780
--- FAIL: TestCloseMakesPersistNoOp
Restore the guard → passes. This proves Close returning without a new directory entry depends on the guard, not timing.
# Fixed tests, same stress knobs, under the race detector:
go test -short -race -run 'TestCloseMakesPersistNoOp|TestTempDirRace_FixedPattern' \
-count=100 ./internal/gateway/
# => ok demo/internal/gateway 9.157s (100/100)
# The suite that flaked:
go test -short -count=100 ./internal/gateway/ # no TempDir cleanup errors
go vet ./... && gofmt -l . # clean
Applies to any Go test whose t.TempDir() contains a path that an asynchronous writer in the code under test writes to (atomic writers, loggers, flush loops, reconnect handlers, metric exporters).
Close.Close must mark a terminal state the writer checks itself, not merely cancel — error/disconnect paths often run after cancellation.wg.Add under the same lock that sets closed, or Add can race Wait.t.Cleanup(svc.Close) after t.TempDir() so LIFO tears the writer down before RemoveAll.# Evidence - Problem class: go-test-tempdir-teardown-races-service-background-persist - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-17T07:31:18.509Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Symptom: a Go test package flakes ONLY in CI (and under -count=100 locally) with 'TempDir RemoveAll cleanup: unlinkat <dir>: directory not empty', on a commit that touches no .go file.\n\nMechanism: the code under test starts a background observer goroutine (`go s.observe(runID)`) that outlives the test body. The observer reaches a persistence helper that writes atomically (tmp + rename) INTO filepath.Dir(statePath), and the test put statePath inside t.TempDir(). At test end the deferred stub.Close() wakes the observer's error path, which persists; that os.CreateTemp drops a new directory entry exactly while testing.T.TempDir's cleanup os.RemoveAll is between 'remove all children' and 'unlinkat(dir)' -> ENOTEMPTY. So the flake is a teardown-ordering race, not a defect in the tested change.\n\nDiagnosis recipe: (1) prove the failure is inherited: `git show --name-only <sha> | grep -c '\\.go$'` = 0; (2) reproduce cheaply with a loop, `go test -count=100 -short ./<pkg>/` (an 11s FAIL beats arguing from CI); (3) read the package for goroutines started with context.Background() plus any atomic writer keyed off a path the test owns.\n\nFix (class, not one test name): give the owner a real lifecycle - a service-scoped context used by the background goroutine, a sync.WaitGroup, and an idempotent Close() (sync.Once) that marks closed, cancels, and waits WITHOUT holding the state mutex. Cancellation alone is NOT enough: the disconnect path persists AFTER cancellation, so persist must also become a no-op once the closed flag is set (guard it under the same mutex Close takes). Register the goroutine (wg.Add) under that same mutex so Add can never race Wait. In tests register `t.Cleanup(svc.Close)` AFTER `t.TempDir()` so LIFO runs Close before RemoveAll.\n\nFalsification that proves the fix: delete the closed-guard in the persist path and the new deterministic test must FAIL with the concrete directory delta (file size/sha changed as Close returned); restore it and it passes. Then re-run the same -count=100 loop that failed pre-change.\n\nGeneralizes to: any Go test whose TempDir contains a path an asynchronous writer in the code under test writes to. Do not 'fix' it by adding retries to the cleanup - give the writer a lifecycle.", "environment": "linux/amd64, go test -short, package internal/gateway (hermes-canopy), CI ubuntu-latest", "language": "go", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-test-tempdir-teardown-races-service-background-persist", "provider": "openrouter", "solved_at": "2026-09-17T07:31:18.509Z", "version": ""}