Skip to content

fix: panic when a Mock made outside a synctest bubble is used inside - #43

Open
mafredri wants to merge 7 commits into
mafredri/go-1.26from
mafredri/bubble-misuse-panic
Open

mafredri wants to merge 7 commits into
mafredri/go-1.26from
mafredri/bubble-misuse-panic

Conversation

@mafredri

@mafredri mafredri commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

Stacked on #41.

A *Mock created with the outer t and used inside synctest.Test can hang the test until go test times out: a goroutine it starts in the bubble waits on channels created outside and exits only in NewMock's cleanup, after synctest.Test returns.

In a bubble, call NewMock with the t that synctest.Test passes in. A Mock created outside the bubble now panics when used inside it:

quartz: Mock used in a synctest bubble but created outside it; call NewMock with the t that synctest.Test passes in

With this change, the public methods of the Mock and of the timers, tickers, traps, calls and waiters it returns check first (Trap() and Trap.String change nothing and do not), so a recovered panic leaves no state behind. Misuse that passed by chance now panics, such as AfterFunc(0) or Advance with nothing due.

Detection relies on undocumented runtime behavior: in a bubble, time.Now has no monotonic reading. Outside one, every port's time.Now has one, from the runtime's monotonic clock (Plan 9 kernels without it fall back to Unix time, which is also nonzero), between the years 1885 and 2157. If Go changes this, the check stops firing; it cannot panic unless the Mock was created outside a bubble and used inside one. Run on linux/amd64 and linux/386; other ports checked by reading the runtime source.

The check costs about 50 ns per Mock call; allocations are unchanged, and Now with the default logger is about 4% slower.

Question for Spike

Should there be a way to turn the check off?

  • (a) A NewMock option that disables it: saves the cost, but adds API and lets the hang back.
  • (b) An opt-in build tag that enables it: free by default and users can validate tests against bubbles, but untagged runs keep the hang.
  • (c) None, always on (this PR): one rule, at the cost above.

Follow-ups

Behavior on #41: Mock created outside, call made inside the bubble

"Crashes" means the test binary dies with a synctest deadlock panic or a runtime fatal error such as receive on synctest channel from outside bubble.

Call On #41
TickerFunc Hangs if the context's Done channel was created outside the bubble, crashes if inside; works with a context from the bubble
NewTicker, NewTimer(0) Crashes
AfterFunc(0), Now, Peek, WithLogger, Trap().Now(), Trap.Close, AdvanceWaiter.MustWait Works
Call matched by a trap Hangs if the trap is not waited on inside the bubble; works if it is
Advance, Set, AdvanceNext with an event due Hang with an unread channel timer created outside, crash if it is read; work if only AfterFunc timers or outside tickers fire
Advance with nothing due Works
Timer.Reset(0) Hangs if unread, crashes if read; works for an AfterFunc timer
Ticker.Reset after Stop Hangs
Trap.Wait then Call.Release Crashes: sync: WaitGroup.Add called from inside and outside synctest bubble

coder/coder's only test with a Mock in a bubble (agent/x/agentacp) creates it inside and passes.

When squash merging, please replace the commit body with this description.

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

A Mock created with the outer t and used inside synctest.Test could hang
the test until go test's timeout. A goroutine the Mock starts in the
bubble, such as the run loop from Ticker.Reset on a stopped ticker,
belongs to the bubble but waits on channels created outside it. synctest
does not count that as durably blocked, so it neither reports a deadlock
nor returns: the goroutine exits only in NewMock's cleanup, which runs
after synctest.Test returns.

NewMock now records whether it ran in a bubble, and every call that
would start a goroutine panics if it runs in a bubble and the Mock was
created outside one. The bubble is detected by time.Now having no
monotonic reading, which is runtime behavior rather than API.
QF1009 suggests Time.Equal, but Equal ignores the monotonic reading that
inBubble tests for, so it would report true everywhere and the check
would never panic.
Checking only calls that start goroutines left a rule users could not
predict, and a recovered panic could leave state behind: TickerFunc,
NewTicker and Ticker.Reset added an event before panicking, and
Timer.Reset(0) removed one, which changed how far Advance could go.

matchCallLocked now checks first, before any caller changes state, and
the entries that bypass it check at their top: Advance, Set,
AdvanceNext, Peek, Trap, WithLogger, Trap.Wait, Trap.Close, Call.Release
and AdvanceWaiter.Done, which Wait and MustWait now use. The checks next
to goroutine starts are gone.
time.Now has no monotonic reading when the wall clock is outside 1885 to
2157, and the rejected second signal needs one reason only.
Done dereferenced the Mock for the bubble check, so Done and Wait on a
zero AdvanceWaiter panicked instead of returning nil and ctx.Err(). The
checkBubble comment now names the two public methods that do not check.
Keep NewMock's existing paragraph as it was, state the rule for test
writers in one paragraph, and give maintainers the hang mechanism and
the state invariant without listing exceptions or rejected alternatives.
@mafredri
mafredri marked this pull request as ready for review November 5, 2026 15:20
@mafredri
mafredri requested a review from spikecurtis November 5, 2026 15:21
@mafredri

mafredri commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

@spikecurtis LMK if this is too controversial (also see question above). Thought it would be a nice way to help protect against mis-use.

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