Skip to content

fix(watch): order the lazy dependency-worker start against Stop - #220

Merged
JordanCoin merged 2 commits into
mainfrom
fix/217-daemon-stop-race
Oct 11, 2026
Merged

JordanCoin merged 2 commits into
mainfrom
fix/217-daemon-stop-race

Conversation

@JordanCoin

Copy link
Copy Markdown
Owner

The race

Stop read dependencyCancel and then waited on dependencyWorkerWG with nothing ordering it against the lazy startDependencyWorker on the event-loop goroutine. The issue's repro (eventLoop driven without Start, one Create event, Stop) reports the race on main, and the run then hangs in dependencyWorkerWG.Wait for the remaining -count iterations: a worker started after Stop read a nil cancel is never cancelled. That hang is the "worker outlives Stop" case made visible.

WARNING: DATA RACE
  Write: (*Daemon).startDependencyWorker.func1  watch/daemon.go:467   (event-loop goroutine)
  Read:  (*Daemon).Stop                          watch/daemon.go:273   (caller)
FAIL    codemap/watch   187.539s   (hung after the race; killed)

Fix: mutex, not eager start

startDependencyWorker and the cancel section of Stop share dependencyMu. A start that takes the lock first writes dependencyCancel before Stop reads it; a start that takes it after Stop sees done closed and refuses, so nothing is added to the WaitGroup after Stop has decided what to wait for. dependencyOnce is replaced by a dependencyStarted flag under the same lock. refreshDependencies already selects on done, so a refused start returns through that arm.

Eager start (the issue's first proposal) was tried and rejected: Start already calls startDependencyWorker before the event loop exists, so in production the lazy path runs on one goroutine ahead of any reader and the race only reaches Stop when eventLoop runs without Start; moving the start into NewDaemon broke every graph_state_test that builds a Daemon literal and relies on the lazy start (two of them hung the full suite at the 10-minute test timeout).

Proof

  • TestStopRacesLazyWorkerStart (new, watch/stop_race_test.go): fails with the race report on main; passes go test -race -run TestStopRacesLazyWorkerStart -count=50 ./watch/ after (ok codemap/watch 49.8s).
  • go build ./... && go test ./... && gofmt -l . clean.

Not changed: daemon ownership and reaping (#155), the burst handling on #215 (expect a trivial merge; this diff is inside daemon.go plus one new test file).

Fixes #217

🤖 Generated with Claude Code

https://claude.ai/code/session_01NRaBgKRAZMX9hjbkkefA7T

Stop read dependencyCancel and waited on dependencyWorkerWG while the
event loop could still be inside startDependencyWorker, so a worker
could start after Stop had read a nil cancel and outlive the daemon.
The race detector reports it on the issue's repro and the run then
hangs in dependencyWorkerWG.Wait, which is the outlive case made
visible.

The eager-start option was tried first and rejected: Start already
calls startDependencyWorker before the event loop exists, so in
production the lazy path runs on one goroutine ahead of any reader;
the race only reaches Stop when eventLoop runs without Start. Making
NewDaemon start the worker broke every graph_state test that builds a
Daemon literal and relies on the lazy start. The mutex keeps laziness:
start and the cancel read share one lock, and a start that sees done
closed refuses, so nothing can be added to the WaitGroup after Stop
has decided what to wait for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NRaBgKRAZMX9hjbkkefA7T
Copilot AI balanced review requested due to automatic review settings October 11, 2026 20:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@JordanCoin
JordanCoin merged commit 2dda7ef into main Oct 11, 2026
12 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

watch: data race between Daemon.Stop and lazy dependency-worker start

2 participants