Why would a test using testing/synctest hang until the test timeout instead of its clock advancing?
answer
- the clock waits for everybody to settle
- one goroutine is looking outward
- a hang and a panic mean different things
- real sockets are never durably blocked
- bring the collaborator inside the bubble
basics
~20 sBecause a goroutine in the bubble is blocked on something outside it — real I/O or a channel created before the bubble — so it is never durably blocked, the bubble never settles, and the fake clock will not move.
solid answer
~50 sThe bubble only advances its clock when every goroutine inside it is durably blocked, meaning only a bubble-mate could wake it. A goroutine blocked on a real socket, a file read, a database driver, or a channel created outside the test function fails that test: the outside world might wake it at any moment, so the bubble cannot safely jump time forward. Nothing panics — the test simply hangs until `go test -timeout` kills it. That distinction is the diagnostic: a genuine bubble deadlock, where everyone *is* durably blocked and no timer is pending, panics and tells you; a plain hang means something is not bubbled. The fix is to bring the dependency inside — an in-memory transport or fake collaborator, channels created within the bubble — rather than to wrap the wait in a timeout.
code
go · 11 linesvar external = make(chan int) // package level: outside every bubble
func TestSweeper(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
go func() {
<-external // a goroutine outside could send at any time
}()
synctest.Wait() // never returns; the test dies at -timeout
})
}go deeper
Know the headline: the fake clock only moves when everything in the bubble is waiting on something inside the bubble. A test that talks to a real server or file will hang rather than run fast.
Explain the three ways a goroutine fails the durable-blocking condition — system calls, a channel created outside the bubble, and simply still running — and which of them applies to a given test.
Lead with the hang-versus-panic distinction, then name the unbubbled collaborator and fix it by bringing it inside. Resisting the timeout patch, and saying why it is a regression, is the part that reads as experience.
Frame it as a design constraint you are choosing: a suite that relies on bubbles requires code whose collaborators can be supplied in memory. Decide which paths stay integration tests on the real clock rather than forcing every one into a bubble.
## The failure A cache with a TTL sweeper and a refresh ticker is moved onto `synctest.Test`. The virtual sleeps should make the test instant. Instead it sits there and the package eventually dies at the `go test` timeout. This has one family of causes, and the reasoning that finds it is short. ## The rule being violated The bubble's clock advances only when **every** goroutine in the bubble is *durably* blocked — blocked such that only another goroutine in the same bubble could unblock it. `synctest.Wait` uses the same condition. So the clock stands still whenever at least one bubbled goroutine is: - blocked on real network or file I/O, or any other system call; - blocked on a channel created outside the bubble (something outside could send); - running — spinning on a flag, or doing a long computation. In every case the bubble cannot prove that jumping time forward would skip nothing, so it does not jump. ## The diagnostic: hang versus panic This is the piece worth carrying into an interview. `synctest` has two very different bad endings and they mean opposite things. - **It panics reporting a deadlock.** Every goroutine in the bubble *is* durably blocked and there is no timer left to fire. That is a real deadlock in the code under test — a receive nobody will ever send to, a `WaitGroup` counter that will never reach zero. The bubble found a bug for you and named it immediately, at virtual time, without a ten-second timeout. - **It hangs to the `go test` timeout instead.** Then somebody is *not* durably blocked, and the only ways to fail that condition are the three above. Nothing about the code's own logic is necessarily wrong; the test's environment is leaking outside the bubble. Asking "did it panic or did it hang?" narrows the search before reading a single stack. ## Working out which goroutine Once you know it is the second case, the suspects are the collaborators the test did not fake. A sweeper that calls out to a real store. A refresher that fetches over HTTP from a server the test started before entering the bubble. A helper that reads from a channel the test package created at init time. In each case the goroutine sits in a syscall or on a foreign channel and the bubble waits for a settlement that never comes. A useful sanity check is to shrink the bubble: run the same test body with the background goroutine not started. If it completes instantly, the goroutine is the one holding the bubble open. ## Fixes **Bring the dependency inside.** Anything the bubbled code waits on must be something a bubbled goroutine drives: a channel created within `f`, a fake collaborator implemented in memory, a `sync.Cond` or `WaitGroup` used inside. Go 1.27 added `net/http/httptest.NewTestServer`, an in-memory test server specifically so HTTP-shaped code can be exercised inside a bubble. **Do not wrap the wait in a timeout.** A timeout around `synctest.Wait` turns a precise structural complaint into a slow, flaky test again, and hides the one thing the bubble was telling you. **Do not push the goroutine outside the bubble** to make the hang go away. A goroutine started before `synctest.Test` is not bubbled at all: it reads the real clock, its sleeps really sleep, and the behaviour you meant to test is no longer under virtual time. **Split the test if the dependency is genuinely external.** Some code paths really do need a socket; those belong in an integration test that does not use a bubble. Bubbles are for the timing logic, not for everything. ## The review angle Once a team bans real sleeps from its suite, this failure mode becomes the price of admission, and it lands on whoever is reviewing the change. The useful review question is not "why does this hang?" but "what does this code wait on, and can a bubble drive it?". Code that takes its collaborators as interfaces or channels passes; code that dials inside a constructor does not, and that is a design observation, not a test problem. ## Summary A hanging bubble is not mysterious behaviour from `synctest`; it is the package declining to fake time while something real might still happen. Panic means deadlock in your logic. Hang means something is outside the bubble. Fix the second by bringing the dependency in, and never by adding a timeout.
- How do you tell a bubble deadlock apart from a bubble that simply cannot settle?A deadlock panics: every bubbled goroutine is durably blocked and no timer is pending, so the runtime knows nothing will ever happen and says so. A bubble that cannot settle hangs to the `go test` timeout instead, which means at least one goroutine is blocked on something outside the bubble or is still running.
- Would starting the offending goroutine before synctest.Test fix it?It stops the hang and ruins the test. A goroutine started before the call is outside the bubble entirely: it reads the real clock and its sleeps really sleep, so the timing behaviour you wanted to exercise is no longer virtualised. The dependency has to come inside instead.
- What if the code under test genuinely needs a real network connection?Then it is not a bubble test. Either give the code a seam so an in-memory collaborator can be supplied — Go 1.27's in-memory `httptest.NewTestServer` covers the HTTP case — or keep that path in an integration test that runs on the real clock and test the timing logic separately.
saying these in an interview costs you the question
- Adds a timeout around synctest.Wait to stop the hang
- Blames synctest rather than the unbubbled dependency
- Cannot distinguish the deadlock panic from a plain hang
- Moves the goroutine outside the bubble and calls it fixed
- Assumes any blocked goroutine lets the fake clock advance