skip to content

Why does synctest.Test panic when a goroutine started inside the bubble is still alive at the end?

level: seniorimportance: should knowfreq 30%

answer

  1. the bubble is a closed world
  2. settled is not the same as exited
  3. who stops the sweeper?
  4. cancel, then wait for confirmation
  5. a forgotten ticker loop becomes a test failure

basics

~20 s

Because 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 s

A 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 lines
go
ctx, 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 returns

go deeper

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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