Skip to content

fix: keep fired mock timers readable until NewMock's cleanup - #37

Draft
mafredri wants to merge 33 commits into
mafredri/go-1.26from
mafredri/timer-fire-cleanup
Draft

mafredri wants to merge 33 commits into
mafredri/go-1.26from
mafredri/timer-fire-cleanup

Conversation

@mafredri

@mafredri mafredri commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Stacked on #41.

A test can hang at shutdown when a worker reads a mock timer during the test's own cleanup. The usual setup: the test calls NewMock, then t.Cleanup(srv.Close); the timer fires while the worker is busy; Close unblocks the worker, which then reads tmr.C. Timer.fire registered a cleanup on every fire, which ran before Close and ended the write, so the worker and Close blocked forever. That cleanup also leaked when a fire registered it after the test's cleanups had run, as after an Advance the test stopped waiting on. Inside a testing/synctest bubble the test then panicked with deadlock: main bubble goroutine has exited but blocked goroutines remain.

This PR:

  • Ends pending timer writes in NewMock's cleanup instead, so a fired timer stays readable until then, and a later fire sends only to a reader already waiting on C. The NewMock doc says to call NewMock before registering cleanups that wait for code reading its timers.
  • Makes Stop and Reset return true for a timer that has fired but whose value was not received, and discard the value, as a time.Timer does on Go 1.26. The mock returned false, so if !t.Stop() { <-t.C } blocked forever after a fire, as in coder/coder's coderd/database/pubsub/watchdog.go and enterprise/coderd/usage/generator.go.
  • Drops a fire scheduled before Stop or Reset that gets the lock after they return. Before, Stop or Reset right after NewTimer(0) or Advance was followed by a stale value in almost every run, and AfterFunc(0, f) ran f after Stop returned true.

Not fixed:

  • A non-blocking receive right after Advance(...).MustWait misses the value in almost every run, and in over a third of runs with -race, because the goroutine that sends it may not be waiting yet; a blocking receive or synctest.Wait gets it.
  • Tickers: fix: keep mock tickers working until NewMock's cleanup #38, stacked on this PR.

Please use this description as the squash commit body.

🤖 This PR was created with the help of Coder Agents.

@mafredri
mafredri added this pull request to stack #39 November 3, 2026 19:36
@mafredri
mafredri marked this pull request as ready for review November 3, 2026 20:29
@mafredri
mafredri marked this pull request as draft November 3, 2026 21:03
Timer.fire registered a test cleanup each time a channel timer fired,
from the goroutine that Advance starts. If the test stopped waiting on
the Advance, for example because MustWait's context had expired, the
fire could run after the test's cleanups, so its cleanup never ran and
the goroutine writing to C leaked. Inside a testing/synctest bubble that
panics with "main bubble goroutine has exited but blocked goroutines
remain". Each fire also grew the test's cleanup list.

The cleanup that NewMock registers now closes a channel that every
pending write also selects on, and a timer that fires after it skips the
write, so timer channels still cannot be read after the test ends.
Pending writes now end when that cleanup runs, so cleanups registered
after NewMock can still read a timer that has fired.
The comment in Timer.fire said pending writes end when the test ends.
They end when the cleanup registered by NewMock runs, so cleanups
registered before NewMock cannot read a fired timer, while cleanups
registered after it can. The Mock.done comment also explained why there
is no per-fire cleanup; it now only says what done does.
pprof.Do adds a frame that testing cannot skip, so the mock's Advance
log line pointed at the closure inside labeledAdvance, or at
runtime/pprof when the closure was marked as a helper. Setting the
labels with SetGoroutineLabels and marking labeledAdvance as a helper
makes the log line point at the test.
The write goroutine may not have reached its select when NewMock's
cleanup closes done, so a reader waiting at that moment can still get
the value. The previous comment said channels cannot be read after the
cleanup runs.
The pprof label helpers, cleanupTB, and the cleanup registration count
tested how the fix works. verifyNoLeakTestMain already reports a write
goroutine that outlives the tests, so TestNoLeak_Go123 now also fires a
timer after the test that owns the Mock has ended. On 9591439 that
subtest leaks the write goroutine and the leak check fails. Without the
done case in the write select, FiredDuringTest fails the same way.
done already ends a write that starts after NewMock's cleanup has run,
so the skip only decided whether a reader already waiting on C at that
moment gets the value. No test covered it, and the leak tests pass, and
fail when the done case is removed, without it.
The note that a write can still reach a reader already waiting on C
describes done, so it now sits on the done field instead of in
Timer.fire. The interrupt paragraph is rewrapped to 100 columns and
names what ends the write goroutine.
Tests often stop a server in a cleanup registered after NewMock, and the
server's workers may read a timer that fired while they were busy. On
main, the cleanup that each fire registered ran before that cleanup and
ended the write, so the worker blocked and the shutdown hung.
TestTimer_ReadableDuringCleanup fails on main and passes here.
A timer write that reaches its select after NewMock's cleanup has closed
done, while a reader is already waiting on C, has two ready cases, and
select picks one at random. In a probe, the waiting reader got the value
in 93 of 200 runs. The write now tries a non-blocking send first, so a
waiting reader always gets the value (200 of 200).

A write with no waiting reader still ends on done, Stop or Reset, so it
does not outlive the test, and no value can be received after Stop or
Reset returns.
Without synctest, a test cannot know that a reader is waiting on C
before the write starts, so the check would be flaky. synctest.Test is
stable since Go 1.25 and go.mod says 1.23, hence the build tag.
Mock.done was created by NewMock, outside any synctest bubble. When a
Mock created with the outer test's t had a timer created and fired
inside a bubble, the write goroutine also waited on done, which synctest
does not count as durably blocked, so the test hung until go test's
timeout instead of panicking at once as on main.

NewMock's cleanup now ends each pending write by receiving on that
timer's interrupt channel, as Stop does. That channel is created where
the timer fires, so it belongs to the bubble. The Mock tracks timers
with a write in flight, and a write that delivers its value removes its
timer. A timer that fires after the cleanup starts no goroutine and only
reaches a reader that is already waiting.
Cleanups run in reverse order, so a cleanup registered before NewMock
runs after the pending timer writes have ended. If it waits for code
that reads a fired timer, that code never gets the value and the wait
hangs. The NewMock doc now says what is dropped and which order works.
A timer that fires after NewMock's cleanup no longer starts a write
goroutine, so this test stopped reaching the send that tries a waiting
reader first, and removing that send passed. The FiredBeforeCleanup row
fires the timer as the test's last step: in a bubble the write goroutine
often starts after the cleanup waits on interrupt. Without the first
send it fails 200 of 200 runs, and about half of them with -race.
FiredAfterCleanup fails every run when fire sends nothing after the
cleanup.

Each round is a new bubble with one timer. Ten timers in one bubble
failed only 78 of 200 runs without the first send, because only the
write the cleanup ends first meets a waiting interrupt.
One line was 101 columns. The comment above the interrupt check started
with "Otherwise", which reads as a note on the check's else branch; it
now names the condition it describes.
NewTimer and Reset fire a timer with a non-positive duration from a new
goroutine, so NewTimer(0) followed by Reset(0) often fires the timer
twice before the first write ends. The second fire replaced interrupt,
and the Mock tracks one write per timer, so nothing ended the first
write and it leaked: 93 to 99 of 100 rounds in the new TimerFiredTwice
subtest. fire now ends the earlier write first, as Reset does.
go.mod now requires Go 1.26, so testing/synctest is always available and
the go1.25 build tag no longer selects anything.
TestTickerFunc_ContextDoneDuringTick checked that Wait had not returned
by waiting 100ms of wall time, which passes when Wait is merely slow. In
a synctest bubble, synctest.Wait returns once every goroutine is
blocked, so the check runs when Wait has either returned or is waiting
for the callback.

TestTimer_ReadableDuringCleanup waited 10 seconds before reporting a
worker that never received. Bubble time moves as soon as every goroutine
is blocked, so the failure is reported at once, and a quit channel lets
the stuck worker exit so the bubble can end.
A time.Timer whose value has not been received counts as active since Go
1.23: Stop and Reset discard the value and return true. The mock
returned false, so the idiom `if !t.Stop() { <-t.C }` blocked forever
after a fire, because Stop had already ended the write. coder/coder uses
that idiom in coderd/database/pubsub/watchdog.go and
enterprise/coderd/usage/generator.go.

Stop and Reset now return true when they end a write that had not sent
the value. TestTimerStop_Go123 and TestTimerReset_Go123 asserted the old
results; TestTimer_StopAndReset replaces them with the results of a
time.Timer on Go 1.26 and adds a row for the idiom.
NewTimer and Reset fire a timer with a non-positive duration from a new
goroutine. When Stop or Reset ran before that goroutine, the fire still
sent its value after they returned, and a Reset's new schedule was
removed: NewTimer(0) then Reset(time.Hour) delivered a value and left no
event in 200 of 200 synctest runs. A fire that Advance started has the
same race with a Stop or Reset that gets the lock first.

fire now returns without effect when the timer was stopped or nxt no
longer matches the fire's time, and NewTimer, AfterFunc and Reset set
nxt to the current time for a non-positive duration so their own fire
still matches. Two fires can no longer both start a write, so fire no
longer ends an earlier one. The new StopZeroDuration and
ResetZeroDuration rows fail without the check.
A Stop or Reset right after Advance returns usually gets the lock before
the fire that Advance started, and before the previous commit that fire
still sent its value afterward: 1998 of 2000 runs after Stop and 1996 of
2000 after Reset. Without the check in fire, the new rows fail 100 of
100 runs, and about 63 of 100 with -race.
@mafredri
mafredri removed this pull request from stack #39 November 4, 2026 15:17
The check that drops replaced fires also covers AfterFunc: with
AfterFunc(0, f) and an immediate Stop, Stop returned true and f still
ran in 200 of 200 synctest runs before that check, and in 0 of 200 after
it. A time.AfterFunc timer never runs f after Stop returns true.

The new StopAfterFuncZeroDuration row checks that Stop's result agrees
with whether f ran, because f's goroutine can run before or after Stop.
Without the check in fire it fails 100 of 100 runs. The start functions
now also return the channel the fire sends on: C, or one that f sends
on.
"The Mock's timers" can be read to include tickers, but a ticker still
stops when the cleanup that NewTicker registers runs, so calling NewMock
first does not keep its ticks readable. The NewMock doc now says so and
points to NewTicker.
The start function comment said each returns the channel its fire sends
on, but for the AfterFunc row f sends on it and fire sends nothing.

The AfterFunc row checks that Stop's result agrees with whether f ran
instead of expecting true, and the comment did not say why. Stop gets
the Mock's lock before f's goroutine in practice, but that goroutine got
it first in 19 of 32000 runs with -race, and no Mock call can hold it
back, so a row that expected true would fail in those runs.
"This does not cover tickers; see NewTicker" did not say what happens to
a ticker. Its ticks stop when the cleanup that NewTicker registers runs,
which is before any cleanup registered earlier in the test, so the
NewMock doc now says that.
The NewTicker doc said a ticker sends no ticks after the test ends, and
the NewMock doc said a ticker stops when its NewTicker cleanup runs.
Both are false once Reset is called after that cleanup: Reset starts a
new runLoop, ticks are sent again, and only Stop ends it. Both docs now
say so, and the NewMock doc names NewTicker tickers, because a
TickerFunc keeps calling its function until its context ends.
The comment gave one measured count, but repeated measurements vary: f's
goroutine got the lock first in 19 of 32000 runs with -race here and in
28 to 45 of 32000 in a review. "A few in ten thousand" holds for all of
them.
The NewMock doc now says first what a cleanup registered after NewMock
can rely on, then what NewMock's cleanup drops and what a later fire
sends, and points to NewTicker for ticks. NewTicker no longer describes
a Reset after its cleanup: #38 removes that cleanup, so the sentence
would only be churn.
The comment named Stop and Reset as the reasons a fire is dropped, but
the check also drops the second fire after NewTimer(0) and Reset(0),
where the first fire has already removed the timer.
e6cbe10 moved this test into a synctest bubble so that it no longer
waits 100ms of wall time. That change is unrelated to the timer fixes
here and will be done separately.
StopAfterFuncZeroDuration failed with "got false, want true", which did
not say whether f ran after Stop returned true or Stop returned false
before f ran. It now runs after the table as its own subtest that names
the disagreement, and the AfterFunc race is explained once there. With
the AfterFunc row gone, the start functions return only the timer.

StopAndDrainFiredUnread is gone: it passes whenever StopFiredUnread
does, and a mutation that breaks Stop's result for a fired timer fails
StopFiredUnread alone. StopFiredUnread now says that its result keeps if
!t.Stop() { <-t.C } from blocking.
TimerFiredTwice named a result that the fix prevents. The new name says
what the subtest does.
On this branch a Reset after the ticker's own cleanup starts its runLoop
again, and ticks then arrive until Stop, also after NewMock's cleanup.
09613b7 dropped that from the NewTicker doc because #38 removes the
per-ticker cleanup, but this branch can merge first, so its docs must
hold without #38. NewMock's pointer to NewTicker now names the exception
too.
mafredri added a commit that referenced this pull request Nov 4, 2026
"After the Mock's test ended" did not cover a cleanup registered before
NewMock. Such a cleanup runs after NewMock's, so a context it creates is
also returned canceled. The wording now names NewMock's cleanup, as the
timer and ticker docs in #37 and #38 do.
@mafredri
mafredri force-pushed the mafredri/timer-fire-cleanup branch from 5eaccd7 to 65aed39 Compare November 4, 2026 18:16
@mafredri mafredri changed the title fix: stop registering a cleanup per timer fire fix: keep fired mock timers readable until NewMock's cleanup Nov 4, 2026
@mafredri
mafredri changed the base branch from main to mafredri/go-1.26 November 4, 2026 18:16
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.

1 participant