#3716·ebiten

audio: use testing/synctest in the tests that wait on real time

Author: hajimehoshiCreated Sep 17, 2026Updated Sep 17, 2026
Labelsfeatureos:linuxos:windowsos:macosos:androidos:iosos:freebsdos:js

Operating System

  • Windows
  • macOS
  • Linux
  • FreeBSD
  • OpenBSD
  • Android
  • iOS
  • Nintendo Switch
  • PlayStation 5
  • Xbox
  • Web Browsers

What feature would you like to be added?

Use testing/synctest (stable since Go 1.25, which go.mod already requires) in the audio package tests that currently wait on real time, so that they run on synctest's fake clock instead of sleeping.

I surveyed every time.Sleep / time.After / timer use in _test.go files across the repository and classified them. The audio package is the only place where synctest pays off; the other waits are either not convertible or already cost nothing.

Candidates (all in audio/audio_test.go)

Test Real-time wait today
TestGC polls PlayersCountForTesting with 200 ms sleeps (carries TODO: This is a dirty hack. Would it be possible to use virtual time?)
TestDeferredDeviceCreation same 200 ms polling loop
TestSameSourcePlayers same 200 ms polling loop
TestPauseAndStopReading, TestPauseAndStopReadingWhilePaused waitForRead polling plus a 100 ms "must not be read" quiet window
TestPositionNotGrowingAfterFinished 5 ms polling until a 4 s source finishes, then asserts Position() is within a 100 ms window
TestPlayerErrorDoesNotStopOtherPlayers 5 ms polling plus a 100 ms window for Position() to advance

Everything these tests wait on lives inside the test process and only sleeps: the Context update goroutine (audio.go, time.Sleep(time.Second / 100)), the dummyPlayer read goroutine in export_test.go (time.Sleep(time.Millisecond)), and stopwatch.go (time.Now / time.Since), which synctest fakes as well. So wrapping the test body in synctest.Test and creating the context inside the bubble is enough to put all of them on the fake clock.

I verified this with a throwaway copy of TestPositionNotGrowingAfterFinished wrapped in synctest.Test: the 4 s source finished in 0.00 s of wall time and Position() came out as an exact, reproducible value. The same experiment on GOOS=js GOARCH=wasm confirmed synctest works there, which matters because CI runs the audio tests in a browser.

Prerequisite

The experiment ended with:

panic: deadlock: main bubble goroutine has exited but blocked goroutines remain

goroutine 40 [sleep (durable), synctest bubble 1]:
time.Sleep(0x989680)
github.com/hajimehoshi/ebiten/v2/audio.NewContext.func4()
	audio/audio.go:172

NewContext starts an update goroutine that loops forever, and ResetContextForTesting only drops the pointer, so every audio test today leaks a 100 Hz goroutine. synctest refuses to return while a bubble goroutine is still alive, so the goroutine needs a way to stop (for example a stop channel closed by ResetContextForTesting). Players with infinite sources (infiniteReader, countingSource) likewise keep their dummyPlayer goroutine alive and must be stopped in teardown. Both are small changes and the leak is worth fixing regardless.

Not candidates (left as they are)

  • TestRestartWhilePlayersAreSwept (2.0 s) and internal/gamepaddb TestConcurrentAccess (0.2 s) are stress tests that busy-loop on time.Now() from many goroutines. In a bubble the clock only advances when every goroutine is durably blocked, so a spinning loop never ends. They also depend on real scheduling to provoke the race they cover.
  • Waits for GC cleanups (internal/atlas ensureGC, vector waitForEmptyFillPathsStates, audio/internal/vmaudio reclaim loop, internal/beforemaintest TestGC): cleanup functions run outside any bubble, so a fake clock cannot wait for them. They cost 1 to 10 ms each anyway.
  • exp/vmhost (audioposition_test.go, two 500 ms sleeps) and internal/processtest: the guest is a separate process with its own wall clock.
  • time.After deadline guards on a select (audio/loop_test.go, internal/thread, internal/gamepaddb, exp/vmhost): they cost nothing when the test passes, so converting them gains nothing.

Why is this needed?

Speed: the candidates account for about 1.2 s of the audio package's 3.7 s, and essentially all of that package's non-compile time is spent sleeping. The gain across the whole repository is modest, which is why this proposal is scoped to audio rather than a repository-wide sweep.

Determinism is the bigger reason. The window assertions (want or a bit larger, Position() must advance within 100 ms) and the 10 s deadline that exists because time.Sleep has a multi-millisecond floor on browsers all go away, so these tests can no longer flake on a slow CI runner, and the long-standing TODO asking for virtual time is resolved.

Filed by Claude (Claude Code), on behalf of @hajimehoshi.