TestDebounce (watch/watch_test.go:258) fails intermittently in CI and costs contributors time on unrelated PRs. Seen on #122, where the PR's only change near the debouncer is a comment reword.
Symptom
One job of twelve fails — Test (ubuntu-latest, 1.25) — while Go 1.24 and 1.26 pass on the same image, and all three macOS versions pass. A failure isolated to one Go minor with passing neighbours is a timing artifact, not a code difference.
Not reproducible locally: -count=12 passes, -count=6 -race passes.
Cause
The test coordinates with a live fsnotify daemon using fixed sleeps:
daemon.Start()
time.Sleep(100 * time.Millisecond) // assume the watcher is registered
for i := 0; i < 5; i++ { os.WriteFile(testFile, ...) }
time.Sleep(300 * time.Millisecond) // assume debounce flushed
events := daemon.GetEvents(100)
if writeCount == 0 || writeCount > 2 { t.Errorf(...) }
if lastWriteLines != 6 { t.Errorf(...) }
Two independent races on a contended runner:
- the 100 ms start-up window elapses before
addWatchDirs has actually registered, so some writes are never observed → writeCount == 0
- the 300 ms window doesn't cover debounce-flush (100 ms) plus event processing → the trailing event isn't recorded yet →
lastWriteLines != 6
Both are load-dependent, which matches the observed behaviour: it moves between runs, not between code versions.
Suggested fix
The repo already has the right primitive — waitForWatchCondition(t, timeout, cond) in watch/more_test.go, used by the newer watch tests. Converting this test to poll for the condition instead of sleeping toward it removes both races without weakening the assertion:
- replace the 100 ms start-up sleep with a poll until the daemon reports it is watching
- replace the 300 ms settle sleep with
waitForWatchCondition on writeCount >= 1 && lastWriteLines == 6, keeping the writeCount <= 2 bound as the real assertion
That preserves exactly what the test is for — a burst of writes must collapse to at most one trailing event carrying the latest contents — while letting a slow runner take the time it needs.
Worth auditing the other sleep-based tests in watch/ at the same time; this is the one that has surfaced, not necessarily the only one.
Why it matters beyond the noise
A red check that isn't the contributor's costs more than the minute it takes to re-run: it trains people to ignore red. On a repo where CI is the main signal for external contributions, that is expensive.
TestDebounce(watch/watch_test.go:258) fails intermittently in CI and costs contributors time on unrelated PRs. Seen on #122, where the PR's only change near the debouncer is a comment reword.Symptom
One job of twelve fails —
Test (ubuntu-latest, 1.25)— while Go 1.24 and 1.26 pass on the same image, and all three macOS versions pass. A failure isolated to one Go minor with passing neighbours is a timing artifact, not a code difference.Not reproducible locally:
-count=12passes,-count=6 -racepasses.Cause
The test coordinates with a live fsnotify daemon using fixed sleeps:
Two independent races on a contended runner:
addWatchDirshas actually registered, so some writes are never observed →writeCount == 0lastWriteLines != 6Both are load-dependent, which matches the observed behaviour: it moves between runs, not between code versions.
Suggested fix
The repo already has the right primitive —
waitForWatchCondition(t, timeout, cond)inwatch/more_test.go, used by the newer watch tests. Converting this test to poll for the condition instead of sleeping toward it removes both races without weakening the assertion:waitForWatchConditiononwriteCount >= 1 && lastWriteLines == 6, keeping thewriteCount <= 2bound as the real assertionThat preserves exactly what the test is for — a burst of writes must collapse to at most one trailing event carrying the latest contents — while letting a slow runner take the time it needs.
Worth auditing the other sleep-based tests in
watch/at the same time; this is the one that has surfaced, not necessarily the only one.Why it matters beyond the noise
A red check that isn't the contributor's costs more than the minute it takes to re-run: it trains people to ignore red. On a repo where CI is the main signal for external contributions, that is expensive.