Why does synctest.Test panic when a goroutine started inside the bubble is still alive at the end?
answer
- the bubble is a closed world
- settled is not the same as exited
- who stops the sweeper?
- cancel, then wait for confirmation
- a forgotten ticker loop becomes a test failure
basics
~20 sBecause the bubble owns every goroutine started inside it and must be empty when the test function returns. A sweeper or ticker loop nobody stopped cannot be left running on a fake clock, so synctest.Test panics instead of passing quietly.
solid answer
~50 sA bubble is a closed world: the fake clock, the durable-blocking rules and the isolation only make sense while every member is accounted for. So `synctest.Test` requires the bubble to be empty when the test function returns; a goroutine still alive at that point makes it panic rather than let a goroutine escape onto a clock that no longer exists. In practice that turns a forgotten shutdown into a hard failure — the TTL sweeper whose loop has no cancellation path, the refresh ticker never stopped. The fix is the same thing production code should already do: give the goroutine a cancellation signal, and have the test cancel it and wait for confirmation that it exited, usually a closed done channel or a `WaitGroup`, before the test body returns. Read next to what `synctest.Wait` was waiting on, the panic usually points straight at the loop.
code
go · 13 linesctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
c.sweepEvery(ctx, time.Minute)
}()
time.Sleep(6 * time.Minute)
synctest.Wait()
// assertions here
cancel()
<-done // without this, the bubble is not empty when the test body returnsgo deeper
Remember that whatever you start inside synctest.Test has to be finished before the test function returns, or the run panics. A background loop needs some way to be told to stop.
Explain the mechanics: the bubble is defined by its membership, so a goroutine outliving it would have no clock and no owner. Show the cancel-plus-wait-for-exit pattern and why cancelling alone is not enough.
Read the panic as a design defect in the code under test, not test friction: the loop has no shutdown path. Give the ticker loop a context and a done signal, and say why settling and exiting are different conditions.
Take the position that every goroutine a component starts must have a stated owner and a stated way to stop, and note that adopting bubbles enforces that on every run rather than leaving it to whoever reviews the next background worker.
## The rule `synctest.Test(t, f)` creates a bubble containing the goroutine running `f` and everything started from inside it. The bubble is finished only when it is empty. If `f` returns while a goroutine it started is still there, `synctest.Test` panics instead of letting the test pass. That is deliberate, and it is not merely bookkeeping. ## Why the bubble cannot just let it go Everything the bubble provides is defined over its membership. The clock advances when *every* member is durably blocked. `synctest.Wait` returns when *every other* member is durably blocked. Isolation from the rest of the test binary depends on knowing exactly who is inside. A goroutine that outlives the bubble breaks all three questions at once. Which clock does it read now — the virtual one whose bubble has ended, or the real one? Does the next test's bubble inherit it? Rather than invent an answer, the package refuses: the bubble must be empty, and a violation is loud. ## What it catches in real code This rule is why bubbled tests find goroutine leaks almost by accident. Take an in-memory cache with two background loops: a sweeper that evicts expired entries every minute and a refresher that re-reads a source every thirty seconds. Written carelessly, each is `for { <-ticker.C; ... }` with no exit path. In production that is a leak nobody notices, because the process runs forever anyway. In a bubbled test it is a panic on the first run, before the code ever ships. The complaint the bubble raises is really a design complaint: *this goroutine has no way to stop*. Fixing the test and fixing the code are the same edit. ## The shape of the fix ```go synctest.Test(t, func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) done := make(chan struct{}) c := newCache(5 * time.Minute) go func() { defer close(done) c.sweepEvery(ctx, time.Minute) // returns when ctx is done }() c.Set("k", "v") time.Sleep(6 * time.Minute) synctest.Wait() if _, ok := c.Get("k"); ok { t.Fatal("expired entry survived the sweeper") } cancel() <-done // the bubble must be empty when this function returns }) ``` Three elements matter. The goroutine takes a cancellation signal. The test cancels it. The test then *waits for confirmation that it exited* — cancelling is not the same as having stopped, and the bubble checks the second thing. A `sync.WaitGroup` serves equally well; Go 1.25's `WaitGroup.Go` makes the start-and-track pair a single call. A loop driven by `time.NewTicker` also needs `defer ticker.Stop()` inside it, and to select on the cancellation channel alongside the ticker channel, or it will never notice the cancellation at all. ## Reading the panic next to Wait When a bubble complains, the two useful facts sit side by side. The panic tells you the bubble was not empty. Whatever the last `synctest.Wait` was waiting for tells you what the leftover goroutine was parked on — a ticker channel, a work channel, a `Cond`. Together they usually identify the loop without opening a profiler: a goroutine parked on a ticker that the test never cancels is the whole story. ## Two mistakes to avoid **Calling `synctest.Wait` at the end and hoping.** `Wait` settles the bubble; it does not empty it. A goroutine parked on a ticker satisfies `Wait` and still fails the exit rule. They are different conditions and this confusion is common. **Deleting the goroutine from the test.** Starting the sweeper outside the bubble, or not starting it at all, makes the panic go away and removes the behaviour under test. The panic is asking for a shutdown path, not for less coverage. ## The wider point A bubble makes goroutine ownership a compile-of-the-mind requirement: every goroutine a component starts must have a stated way to stop. That is a good rule for production code regardless of testing, and a suite built on `synctest` enforces it on every run instead of leaving it to review.
- Is calling synctest.Wait as the last statement enough to satisfy the exit rule?No. `Wait` returns when the other goroutines are durably blocked, and a loop parked on a ticker channel is durably blocked while still very much alive. The bubble requires them to have exited, which is a strictly stronger condition and needs an actual shutdown path.
- Why is this rule useful beyond making synctest itself work?It converts a goroutine with no way to stop into a failing test on the first run. A background sweeper or refresher that leaks in production because the process never exits cannot leak quietly in a bubble, so the missing cancellation path is found at authoring time.
- How do you structure a ticker-driven loop so a bubbled test can shut it down?Take a `context.Context`, create the ticker inside the loop's function with `defer ticker.Stop()`, and `select` on both the ticker channel and `ctx.Done()`, returning on the latter. Then the test cancels and waits on a done channel or a `sync.WaitGroup` for the exit.
saying these in an interview costs you the question
- Says a final synctest.Wait satisfies the exit requirement
- Cancels the context but never waits for the goroutine to exit
- Moves the background loop outside the bubble to silence the panic
- Treats the panic as a synctest bug rather than a missing shutdown path
- Writes a ticker loop that only ever selects on the ticker channel