Skip to content

proposal: testing/synctest: create bubbles with Start rather than Run #73062

@neild

Description

@neild

This is a revised version of #67434. I'm creating it as a separate proposal to make it easier to discuss the proposed changes.

In Go 1.24, we added the testing/synctest package as an experiment, enabled by setting GOEXPERIMENT=synctest. See #67434 and https://go.dev/blog/synctest for more details. So far, response has been reasonable favorable.

A few issues have been consistently raised as pain points in the existing API, notably:

The first concern (long-lived background pollers) may be addressed by advancing time within the bubble only so long as the bubble's ancestor goroutine exists. After it exits, we will stop advancing time. If any goroutines are still alive and sleeping or reading from a ticker, we will report a deadlock and panic. This requires no changes to the existing API.

The second two concerns (T.Cleanup and T.Context) point at a need to make testing.T (and B and F) bubble-aware. There are a variety of possible approaches to doing this, but so far as I can tell they all require changes to the testing/synctest API. This is one of them.

// Package synctest provides support for testing concurrent code. package synctest // Start places the current goroutine into an isolated "bubble". // The current goroutine must not be part of a bubble. // // Goroutines inherit the bubble of their creating goroutine. // // Goroutines in the bubble use a synthetic time implementation. // The initial time is midnight UTC 2000-01-01. // // Time advances when every goroutine in the bubble is blocked. // For example, a call to time.Sleep will block until all other // goroutines are blocked and return after the bubble's clock has // advanced. See [Wait] for the specific definition of blocked. // // Time no longer advances within the bubble after the goroutine // which called Start exits. // // If every goroutine in the bubble is blocked and there are no timers scheduled, // a fatal panic occurs. // // Channels, time.Timers, and time.Tickers created within the bubble // are associated with it. Operating on a bubbled channel, timer, or ticker // from outside the bubble panics. func Start() // Wait is unchanged from the existing proposal. func Wait()

This proposal changes the Run function to a Start function.

The Run function has some nice properties that we lose: Passing a function to Run makes the bubble lifetime very clear in a way that Start does not, and Run provides a good place to produce a panic when a bubble deadlocks. (In the revised proposal with Start, a deadlocked bubble is now a fatal panic.)

However, Start has the advantage of allowing us to move the point of bubble cleanup into the testing package. I additionally propose that:

  1. When T.Context is called within a bubble, it returns a context with a Done channel that belongs to the bubble.
  2. When T.Cleanup is called within a bubble, the cleanup function is run inside the bubble.
  3. When a test function returns, if the test called synctest.Start the testing package arranges to wait for goroutines in the bubble to exit before finishing the test.

As an example, converting one of the examples from https://go.dev/blog/synctest:

func TestAfterFunc(t *testing.T) { synctest.Start() ctx, cancel := context.WithCancel(context.Background()) funcCalled := false context.AfterFunc(ctx, func() { funcCalled = true }) cancel() synctest.Wait() if !funcCalled { t.Fatalf("AfterFunc function not called after context is canceled") } }

Migration

The testing/synctest package is experimental, so we are free to make incompatible changes to it. However, it would be good to avoid causing problems for anyone kind enough to try out the package in its current experimental state.

I propose that we add synctest.Start and leave synctest.Run in place for now. If we promote synctest out of its experimental status, the Run function will continue exist only when GOEXPERIMENT=synctest is enabled. If synctest continues to exist in Go 1.26 (as an experiment or otherwise), we will remove the Run function in that version.

Alternatives

These are some alternative APIs that satisfy the same goals as this proposal. I don't have a strong preference for any of them; I chose the above proposal because it keeps the synctest API small (still only two functions) and contained within a single package.

Make bubbles a feature of the testing package:

package testing // Isolate is equivalent to synctest.Start: It places the current goroutine into a bubble. func (t *T) Isolate() *Bubble func (b *B) Isolate() *Bubble func (f *F) Isolate() *Bubble // For organizational purposes, we move synctest.Wait to a method of a Bubble type. // If we find a need to add additional bubble operations in the future, // this will keep them grouped together in the documentation. type Bubble func (b *Bubble) Wait()

Make bubbles a feature of the testing package, but keep the Run function:

package testing // RunIsolated is synctest.Run, but the T, B, or F has bubble-aware Context and Cleanup methods. func (t *T) RunIsolated(name string, func(*testing.T, *Bubble)) func (b *B) RunIsolated(name string, func(*testing.B, *Bubble)) func (f *F) RunIsolated(name string, func(*testing.F, *Bubble)) type Bubble func (b *Bubble) Wait()

Same as one of the above, but make the Wait operation a method of testing.TB:

package testing // One of these: func (t *T) Isolate() *Bubble func (t *T) RunIsolated(name string, func(*testing.T, *Bubble)) // Sync replaces synctest.Wait. func (t *T) Sync() func (t *B) Sync() func (t *F) Sync()

Keep the synctest package, but allow it to create a bubble-aware testing.T/B/F:

package synctest // Test is synctest.Run, but it provides the function with a T, B, or F with bubble-aware Context/Cleanup. func Test[T *testing.T | *testing.B | *testing.F](t T, func(T))

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions