From 21e5b219725468fa2b923ed6de28a0f64243badf Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 5 Nov 2026 12:11:11 +0000 Subject: [PATCH 1/7] fix: panic when a Mock made outside a synctest bubble is used inside A Mock created with the outer t and used inside synctest.Test could hang the test until go test's timeout. A goroutine the Mock starts in the bubble, such as the run loop from Ticker.Reset on a stopped ticker, belongs to the bubble but waits on channels created outside it. synctest does not count that as durably blocked, so it neither reports a deadlock nor returns: the goroutine exits only in NewMock's cleanup, which runs after synctest.Test returns. NewMock now records whether it ran in a bubble, and every call that would start a goroutine panics if it runs in a bubble and the Mock was created outside one. The bubble is detected by time.Now having no monotonic reading, which is runtime behavior rather than API. --- mock.go | 64 +++++++++++++++++++++++++++++++++-- mock_test.go | 94 ++++++++++++++++++++++++++++++++++++++++++++++++++++ ticker.go | 5 +++ timer.go | 3 ++ 4 files changed, 163 insertions(+), 3 deletions(-) diff --git a/mock.go b/mock.go index 7255aff..9aa8f2f 100644 --- a/mock.go +++ b/mock.go @@ -30,6 +30,8 @@ type Mock struct { logger Logger mu sync.Mutex testOver bool + // createdInBubble records whether NewMock ran in a testing/synctest bubble. + createdInBubble bool // cur is the current time cur time.Time @@ -64,6 +66,9 @@ func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, } m.all = append(m.all, t) m.recomputeNextLocked() + if m.inForeignBubble() { + panic(foreignBubblePanic) + } go t.waitForCtx() return t } @@ -99,6 +104,9 @@ func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { if d <= 0 { // zero or negative duration timer means we should immediately fire // it, rather than add it. + if m.inForeignBubble() { + panic(foreignBubblePanic) + } go t.fire(t.mock.cur) return t } @@ -120,6 +128,9 @@ func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer { if d <= 0 { // zero or negative duration timer means we should immediately fire // it, rather than add it. + if m.inForeignBubble() { + panic(foreignBubblePanic) + } go t.fire(t.mock.cur) return t } @@ -211,6 +222,9 @@ func (m *Mock) matchCallLocked(c *apiCall) { if len(traps) == 0 { return } + if m.inForeignBubble() { + panic(foreignBubblePanic) + } c.releases.Add(len(traps)) m.mu.Unlock() for _, t := range traps { @@ -290,6 +304,10 @@ func (m *Mock) Advance(d time.Duration) AdvanceWaiter { return w } + if m.inForeignBubble() { + m.mu.Unlock() + panic(foreignBubblePanic) + } m.cur = m.nextTime go m.advanceLocked(w) return w @@ -350,6 +368,10 @@ func (m *Mock) Set(t time.Time) AdvanceWaiter { return w } + if m.inForeignBubble() { + m.mu.Unlock() + panic(foreignBubblePanic) + } m.cur = m.nextTime go m.advanceLocked(w) return w @@ -372,6 +394,10 @@ func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) { return 0, w } d := m.nextTime.Sub(m.cur) + if m.inForeignBubble() { + m.mu.Unlock() + panic(foreignBubblePanic) + } m.cur = m.nextTime go m.advanceLocked(w) return d, w @@ -480,15 +506,19 @@ func (m *Mock) WithLogger(l Logger) *Mock { // NewMock creates a new Mock with the time set to midnight UTC on Jan 1, 2024. // You may re-set the time earlier than this, but only before timers or tickers // are created. +// +// In a testing/synctest bubble, call NewMock with the t that synctest.Test passes in. A Mock +// created outside the bubble panics in calls inside it that would start a goroutine. func NewMock(tb TestingT) *Mock { cur, err := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z") if err != nil { panic(err) } m := &Mock{ - tb: tb, - logger: tb, - cur: cur, + tb: tb, + logger: tb, + cur: cur, + createdInBubble: inBubble(), } tb.Cleanup(func() { m.mu.Lock() @@ -501,6 +531,34 @@ func NewMock(tb TestingT) *Mock { var _ Clock = &Mock{} +const foreignBubblePanic = "quartz: Mock used in a synctest bubble but created outside it; " + + "call NewMock with the t that synctest.Test passes in" + +// inForeignBubble reports whether the caller is in a testing/synctest bubble and m was created +// outside one. Every call that would start a goroutine panics with foreignBubblePanic instead. The +// goroutine would belong to the bubble, and synctest does not count a goroutine that waits on +// channels created outside the bubble as durably blocked, so synctest.Test would wait for it until +// go test times out. Goroutines that the Mock's goroutines start need no check: they belong to the +// bubble of the call that started the first one. +func (m *Mock) inForeignBubble() bool { + return !m.createdInBubble && inBubble() +} + +// inBubble reports whether the caller is in a testing/synctest bubble, where time.Now returns a +// Time without a monotonic clock reading (see time_runtimeNow in runtime/time.go). That is runtime +// behavior, not documented API: if bubble times gain a monotonic reading, inBubble reports false +// and the Mock no longer panics in a bubble. Outside a bubble, package time documents that +// time.Now has a monotonic reading, so inBubble does not report true there. +// +// The bubble clock's documented start, midnight UTC 2000-01-01, is not used as a second signal. +// Comparing time.Now with the Mock's creation time only adds detection if bubble times gain a +// monotonic reading, and Before then compares monotonic readings; comparing wall clocks instead +// would panic outside a bubble when the system clock steps back. +func inBubble() bool { + n := time.Now() + return n == n.Round(0) +} + type mockTickerFunc struct { ctx context.Context d time.Duration diff --git a/mock_test.go b/mock_test.go index 7bc822d..ce52650 100644 --- a/mock_test.go +++ b/mock_test.go @@ -9,6 +9,7 @@ import ( "runtime/pprof" "strings" "testing" + "testing/synctest" "time" "github.com/coder/quartz" @@ -622,6 +623,99 @@ func TestTickerStop_Go123(t *testing.T) { } } +// TestSynctestBubble checks that a Mock created outside a testing/synctest bubble panics in calls +// inside the bubble that would start a goroutine, and that a Mock created inside does not. +func TestSynctestBubble(t *testing.T) { + t.Parallel() + + const wantPanic = "quartz: Mock used in a synctest bubble but created outside it; " + + "call NewMock with the t that synctest.Test passes in" + + tests := []struct { + name string + // setup runs where the Mock is created, with the same t, and returns the call to make + // inside the bubble. + setup func(t *testing.T, mClock *quartz.Mock) func() + }{ + {"TickerFunc", func(t *testing.T, mClock *quartz.Mock) func() { + ctx := t.Context() + return func() { mClock.TickerFunc(ctx, time.Hour, func() error { return nil }) } + }}, + {"NewTimerZero", func(_ *testing.T, mClock *quartz.Mock) func() { + return func() { mClock.NewTimer(0) } + }}, + {"AfterFuncZero", func(_ *testing.T, mClock *quartz.Mock) func() { + return func() { mClock.AfterFunc(0, func() {}) } + }}, + {"TrappedCall", func(t *testing.T, mClock *quartz.Mock) func() { + trap := mClock.Trap().Now() + t.Cleanup(trap.Close) + go func() { + if c, err := trap.Wait(context.Background()); err == nil { + _ = c.Release(context.Background()) + } + }() + return func() { mClock.Now() } + }}, + {"Advance", func(_ *testing.T, mClock *quartz.Mock) func() { + mClock.NewTimer(time.Second) + return func() { mClock.Advance(time.Second) } + }}, + {"Set", func(_ *testing.T, mClock *quartz.Mock) func() { + mClock.NewTimer(time.Second) + next := mClock.Now().Add(time.Second) + return func() { mClock.Set(next) } + }}, + {"AdvanceNext", func(_ *testing.T, mClock *quartz.Mock) func() { + mClock.NewTimer(time.Second) + return func() { mClock.AdvanceNext() } + }}, + {"TimerResetZero", func(_ *testing.T, mClock *quartz.Mock) func() { + tmr := mClock.NewTimer(time.Hour) + return func() { tmr.Reset(0) } + }}, + {"TickerResetAfterStop", func(_ *testing.T, mClock *quartz.Mock) func() { + tkr := mClock.NewTicker(time.Hour) + tkr.Stop() + return func() { tkr.Reset(time.Hour) } + }}, + } + // callInBubble calls newCall in a bubble, makes the call it returns, and returns the value that + // newCall or the call panicked with, if any. Recovering inside the bubble keeps synctest.Test + // from panicking in the outer test. + callInBubble := func(t *testing.T, newCall func(*testing.T) func()) (got any) { + synctest.Test(t, func(t *testing.T) { + defer func() { got = recover() }() + newCall(t)() + // Let the goroutines the call started run before the bubble's cleanups. + synctest.Wait() + }) + return got + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + t.Run("CreatedOutside", func(t *testing.T) { + t.Parallel() + call := tt.setup(t, quartz.NewMock(t)) + got := callInBubble(t, func(*testing.T) func() { return call }) + if got != wantPanic { + t.Fatalf("got panic %v, want %q", got, wantPanic) + } + }) + t.Run("CreatedInside", func(t *testing.T) { + t.Parallel() + got := callInBubble(t, func(t *testing.T) func() { + return tt.setup(t, quartz.NewMock(t)) + }) + if got != nil { + t.Fatalf("got panic %v, want none", got) + } + }) + }) + } +} + func TestMain(m *testing.M) { verifyNoLeakTestMain(m) } diff --git a/ticker.go b/ticker.go index f4a4b06..9896208 100644 --- a/ticker.go +++ b/ticker.go @@ -121,6 +121,11 @@ func (t *Ticker) startRunLoopLocked() { if t.interrupt != nil { t.mock.tb.Error("called startRunLoopLocked when interrupt suggests we are already running") } + // Check before t.interrupt is set: the cleanup in newMockTickerLocked receives from it outside + // the bubble, which is fatal for a channel created in the bubble. + if t.mock.inForeignBubble() { + panic(foreignBubblePanic) + } interrupt := make(chan struct{}) t.interrupt = interrupt go t.runLoop(interrupt) diff --git a/timer.go b/timer.go index 0f5f733..5a7d63f 100644 --- a/timer.go +++ b/timer.go @@ -110,6 +110,9 @@ func (t *Timer) Reset(d time.Duration, tags ...string) bool { if d <= 0 { // zero or negative duration timer means we should immediately re-fire // it, rather than re-add it. + if t.mock.inForeignBubble() { + panic(foreignBubblePanic) + } t.stopped = false go t.fire(t.mock.cur) return result From 464f13ac5b54670061bca8b74061b2019c4e4feb Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 5 Nov 2026 12:23:11 +0000 Subject: [PATCH 2/7] chore: keep == in inBubble and silence staticcheck QF1009 QF1009 suggests Time.Equal, but Equal ignores the monotonic reading that inBubble tests for, so it would report true everywhere and the check would never panic. --- mock.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mock.go b/mock.go index 9aa8f2f..309cc12 100644 --- a/mock.go +++ b/mock.go @@ -556,7 +556,7 @@ func (m *Mock) inForeignBubble() bool { // would panic outside a bubble when the system clock steps back. func inBubble() bool { n := time.Now() - return n == n.Round(0) + return n == n.Round(0) //nolint:staticcheck // Equal ignores the monotonic reading. } type mockTickerFunc struct { From 0ea32f71ff13ceafac416da19b8ea0ef1fe2b994 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 5 Nov 2026 12:24:57 +0000 Subject: [PATCH 3/7] docs: rewrap inBubble comment to fill 100 columns --- mock.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mock.go b/mock.go index 309cc12..b5f7360 100644 --- a/mock.go +++ b/mock.go @@ -547,8 +547,8 @@ func (m *Mock) inForeignBubble() bool { // inBubble reports whether the caller is in a testing/synctest bubble, where time.Now returns a // Time without a monotonic clock reading (see time_runtimeNow in runtime/time.go). That is runtime // behavior, not documented API: if bubble times gain a monotonic reading, inBubble reports false -// and the Mock no longer panics in a bubble. Outside a bubble, package time documents that -// time.Now has a monotonic reading, so inBubble does not report true there. +// and the Mock no longer panics in a bubble. Outside a bubble, package time documents that time.Now +// has a monotonic reading, so inBubble does not report true there. // // The bubble clock's documented start, midnight UTC 2000-01-01, is not used as a second signal. // Comparing time.Now with the Mock's creation time only adds detection if bubble times gain a From 91bb65e9a4bf2684030610e71506099421044c3b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 5 Nov 2026 12:54:34 +0000 Subject: [PATCH 4/7] fix: check the synctest bubble in every public Mock call Checking only calls that start goroutines left a rule users could not predict, and a recovered panic could leave state behind: TickerFunc, NewTicker and Ticker.Reset added an event before panicking, and Timer.Reset(0) removed one, which changed how far Advance could go. matchCallLocked now checks first, before any caller changes state, and the entries that bypass it check at their top: Advance, Set, AdvanceNext, Peek, Trap, WithLogger, Trap.Wait, Trap.Close, Call.Release and AdvanceWaiter.Done, which Wait and MustWait now use. The checks next to goroutine starts are gone. --- mock.go | 83 ++++++++++++++++++++++------------------------------ mock_test.go | 71 +++++++++++++++++++++++++++++++++++++------- ticker.go | 5 ---- timer.go | 3 -- 4 files changed, 95 insertions(+), 67 deletions(-) diff --git a/mock.go b/mock.go index b5f7360..6f3a801 100644 --- a/mock.go +++ b/mock.go @@ -66,9 +66,6 @@ func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, } m.all = append(m.all, t) m.recomputeNextLocked() - if m.inForeignBubble() { - panic(foreignBubblePanic) - } go t.waitForCtx() return t } @@ -104,9 +101,6 @@ func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { if d <= 0 { // zero or negative duration timer means we should immediately fire // it, rather than add it. - if m.inForeignBubble() { - panic(foreignBubblePanic) - } go t.fire(t.mock.cur) return t } @@ -128,9 +122,6 @@ func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer { if d <= 0 { // zero or negative duration timer means we should immediately fire // it, rather than add it. - if m.inForeignBubble() { - panic(foreignBubblePanic) - } go t.fire(t.mock.cur) return t } @@ -210,6 +201,7 @@ func (m *Mock) removeEventLocked(e event) { } func (m *Mock) matchCallLocked(c *apiCall) { + m.checkBubble() var traps []*Trap for _, t := range m.traps { if t.matches(c) { @@ -222,9 +214,6 @@ func (m *Mock) matchCallLocked(c *apiCall) { if len(traps) == 0 { return } - if m.inForeignBubble() { - panic(foreignBubblePanic) - } c.releases.Add(len(traps)) m.mu.Unlock() for _, t := range traps { @@ -242,14 +231,14 @@ func (m *Mock) matchCallLocked(c *apiCall) { // If multiple timers or tickers trigger simultaneously, they are all run on separate // go routines. type AdvanceWaiter struct { - tb TestingT - ch chan struct{} + mock *Mock + ch chan struct{} } // Wait for all timers and ticks to complete, or until context expires. func (w AdvanceWaiter) Wait(ctx context.Context) error { select { - case <-w.ch: + case <-w.Done(): return nil case <-ctx.Done(): return ctx.Err() @@ -260,17 +249,18 @@ func (w AdvanceWaiter) Wait(ctx context.Context) error { // context completes first. MustWait must be called from the goroutine running the test or // benchmark, similar to `t.FailNow()`. func (w AdvanceWaiter) MustWait(ctx context.Context) { - w.tb.Helper() + w.mock.tb.Helper() select { - case <-w.ch: + case <-w.Done(): return case <-ctx.Done(): - w.tb.Fatalf("context expired while waiting for clock to advance: %s", ctx.Err()) + w.mock.tb.Fatalf("context expired while waiting for clock to advance: %s", ctx.Err()) } } // Done returns a channel that is closed when all timers and ticks complete. func (w AdvanceWaiter) Done() <-chan struct{} { + w.mock.checkBubble() return w.ch } @@ -282,8 +272,9 @@ func (w AdvanceWaiter) Done() <-chan struct{} { // If you need to advance exactly to the next event, and don't know or don't wish to calculate it, // consider AdvanceNext(). func (m *Mock) Advance(d time.Duration) AdvanceWaiter { + m.checkBubble() m.tb.Helper() - w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} + w := AdvanceWaiter{mock: m, ch: make(chan struct{})} m.mu.Lock() if !m.testOver { m.logger.Logf("Mock Clock - Advance(%s)", d) @@ -304,10 +295,6 @@ func (m *Mock) Advance(d time.Duration) AdvanceWaiter { return w } - if m.inForeignBubble() { - m.mu.Unlock() - panic(foreignBubblePanic) - } m.cur = m.nextTime go m.advanceLocked(w) return w @@ -336,8 +323,9 @@ func (m *Mock) advanceLocked(w AdvanceWaiter) { // Advance() with the difference. You may only Set the time earlier than the current time before // starting tickers and timers (e.g. at the start of your test case). func (m *Mock) Set(t time.Time) AdvanceWaiter { + m.checkBubble() m.tb.Helper() - w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} + w := AdvanceWaiter{mock: m, ch: make(chan struct{})} m.mu.Lock() if !m.testOver { m.logger.Logf("Mock Clock - Set(%s)", t) @@ -368,10 +356,6 @@ func (m *Mock) Set(t time.Time) AdvanceWaiter { return w } - if m.inForeignBubble() { - m.mu.Unlock() - panic(foreignBubblePanic) - } m.cur = m.nextTime go m.advanceLocked(w) return w @@ -381,12 +365,13 @@ func (m *Mock) Set(t time.Time) AdvanceWaiter { // none scheduled. It returns the duration the clock was advanced and a waiter that can be used to // wait for the timer/tick event(s) to finish. func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) { + m.checkBubble() m.mu.Lock() if !m.testOver { m.logger.Logf("Mock Clock - AdvanceNext()") } m.tb.Helper() - w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} + w := AdvanceWaiter{mock: m, ch: make(chan struct{})} if m.nextTime.IsZero() { defer close(w.ch) defer m.mu.Unlock() @@ -394,10 +379,6 @@ func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) { return 0, w } d := m.nextTime.Sub(m.cur) - if m.inForeignBubble() { - m.mu.Unlock() - panic(foreignBubblePanic) - } m.cur = m.nextTime go m.advanceLocked(w) return d, w @@ -407,6 +388,7 @@ func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) { // true, or, if there are no running tickers or timers, it returns zero and // false. func (m *Mock) Peek() (d time.Duration, ok bool) { + m.checkBubble() m.mu.Lock() defer m.mu.Unlock() if m.nextTime.IsZero() { @@ -475,6 +457,7 @@ func (m *Mock) Trap() Trapper { } func (m *Mock) newTrap(fn clockFunction, tags []string) *Trap { + m.checkBubble() m.mu.Lock() defer m.mu.Unlock() if !m.testOver { @@ -497,18 +480,18 @@ func (m *Mock) newTrap(fn clockFunction, tags []string) *Trap { // // quartz.NewMock(t).WithLogger(quartz.NoOpLogger) func (m *Mock) WithLogger(l Logger) *Mock { + m.checkBubble() m.mu.Lock() defer m.mu.Unlock() m.logger = l return m } -// NewMock creates a new Mock with the time set to midnight UTC on Jan 1, 2024. -// You may re-set the time earlier than this, but only before timers or tickers -// are created. +// NewMock creates a new Mock with the time set to midnight UTC on Jan 1, 2024. You may re-set the +// time earlier than this, but only before timers or tickers are created. // // In a testing/synctest bubble, call NewMock with the t that synctest.Test passes in. A Mock -// created outside the bubble panics in calls inside it that would start a goroutine. +// created outside the bubble panics when used inside it. func NewMock(tb TestingT) *Mock { cur, err := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z") if err != nil { @@ -531,17 +514,18 @@ func NewMock(tb TestingT) *Mock { var _ Clock = &Mock{} -const foreignBubblePanic = "quartz: Mock used in a synctest bubble but created outside it; " + - "call NewMock with the t that synctest.Test passes in" - -// inForeignBubble reports whether the caller is in a testing/synctest bubble and m was created -// outside one. Every call that would start a goroutine panics with foreignBubblePanic instead. The -// goroutine would belong to the bubble, and synctest does not count a goroutine that waits on -// channels created outside the bubble as durably blocked, so synctest.Test would wait for it until -// go test times out. Goroutines that the Mock's goroutines start need no check: they belong to the -// bubble of the call that started the first one. -func (m *Mock) inForeignBubble() bool { - return !m.createdInBubble && inBubble() +// checkBubble panics if the caller is in a testing/synctest bubble and m was created outside one. +// Such a Mock can hang the test: a goroutine it starts in the bubble belongs to the bubble, and +// synctest does not count a goroutine that waits on channels created outside the bubble as durably +// blocked, so synctest.Test waits for it until go test times out. Every public method of m and of +// the timers, tickers, traps, calls and waiters it returns calls checkBubble, directly or through +// matchCallLocked, before it changes any state, so the rule is the same for every call and a +// recovered panic leaves the Mock as it was. +func (m *Mock) checkBubble() { + if !m.createdInBubble && inBubble() { + panic("quartz: Mock used in a synctest bubble but created outside it; " + + "call NewMock with the t that synctest.Test passes in") + } } // inBubble reports whether the caller is in a testing/synctest bubble, where time.Now returns a @@ -750,6 +734,7 @@ type Call struct { // IMPORTANT: If a call is trapped by more than one trap, they all must release the call before it can complete, and // they must do so from different goroutines. func (c *Call) Release(ctx context.Context) error { + c.trap.mock.checkBubble() c.apiCall.releases.Done() select { case <-ctx.Done(): @@ -834,6 +819,7 @@ func (t *Trap) matches(c *apiCall) bool { } func (t *Trap) Close() { + t.mock.checkBubble() t.mock.mu.Lock() defer t.mock.mu.Unlock() select { @@ -863,6 +849,7 @@ func (t *Trap) callReleased() { var ErrTrapClosed = errors.New("trap closed") func (t *Trap) Wait(ctx context.Context) (*Call, error) { + t.mock.checkBubble() select { case <-ctx.Done(): return nil, ctx.Err() diff --git a/mock_test.go b/mock_test.go index ce52650..576cb88 100644 --- a/mock_test.go +++ b/mock_test.go @@ -623,8 +623,8 @@ func TestTickerStop_Go123(t *testing.T) { } } -// TestSynctestBubble checks that a Mock created outside a testing/synctest bubble panics in calls -// inside the bubble that would start a goroutine, and that a Mock created inside does not. +// TestSynctestBubble checks that a Mock created outside a testing/synctest bubble panics when used +// inside the bubble, without changing its state, and that a Mock created inside does not panic. func TestSynctestBubble(t *testing.T) { t.Parallel() @@ -641,26 +641,26 @@ func TestSynctestBubble(t *testing.T) { ctx := t.Context() return func() { mClock.TickerFunc(ctx, time.Hour, func() error { return nil }) } }}, + {"NewTicker", func(_ *testing.T, mClock *quartz.Mock) func() { + return func() { mClock.NewTicker(time.Hour) } + }}, {"NewTimerZero", func(_ *testing.T, mClock *quartz.Mock) func() { return func() { mClock.NewTimer(0) } }}, {"AfterFuncZero", func(_ *testing.T, mClock *quartz.Mock) func() { return func() { mClock.AfterFunc(0, func() {}) } }}, - {"TrappedCall", func(t *testing.T, mClock *quartz.Mock) func() { - trap := mClock.Trap().Now() - t.Cleanup(trap.Close) - go func() { - if c, err := trap.Wait(context.Background()); err == nil { - _ = c.Release(context.Background()) - } - }() + {"Now", func(_ *testing.T, mClock *quartz.Mock) func() { return func() { mClock.Now() } }}, {"Advance", func(_ *testing.T, mClock *quartz.Mock) func() { mClock.NewTimer(time.Second) return func() { mClock.Advance(time.Second) } }}, + {"AdvanceNothingDue", func(_ *testing.T, mClock *quartz.Mock) func() { + mClock.NewTimer(time.Hour) + return func() { mClock.Advance(time.Minute) } + }}, {"Set", func(_ *testing.T, mClock *quartz.Mock) func() { mClock.NewTimer(time.Second) next := mClock.Now().Add(time.Second) @@ -670,6 +670,12 @@ func TestSynctestBubble(t *testing.T) { mClock.NewTimer(time.Second) return func() { mClock.AdvanceNext() } }}, + {"Peek", func(_ *testing.T, mClock *quartz.Mock) func() { + return func() { mClock.Peek() } + }}, + {"WithLogger", func(_ *testing.T, mClock *quartz.Mock) func() { + return func() { mClock.WithLogger(quartz.NoOpLogger) } + }}, {"TimerResetZero", func(_ *testing.T, mClock *quartz.Mock) func() { tmr := mClock.NewTimer(time.Hour) return func() { tmr.Reset(0) } @@ -679,6 +685,41 @@ func TestSynctestBubble(t *testing.T) { tkr.Stop() return func() { tkr.Reset(time.Hour) } }}, + {"AdvanceWaiter", func(_ *testing.T, mClock *quartz.Mock) func() { + w := mClock.Advance(0) + return func() { w.MustWait(context.Background()) } + }}, + {"Trap", func(_ *testing.T, mClock *quartz.Mock) func() { + return func() { mClock.Trap().Now() } + }}, + {"TrapClose", func(_ *testing.T, mClock *quartz.Mock) func() { + trap := mClock.Trap().Now() + return func() { trap.Close() } + }}, + {"TrapWait", func(t *testing.T, mClock *quartz.Mock) func() { + trap := mClock.Trap().Now() + t.Cleanup(trap.Close) + go mClock.Now() + return func() { + trap.MustWait(context.Background()).MustRelease(context.Background()) + } + }}, + {"CallRelease", func(t *testing.T, mClock *quartz.Mock) func() { + trap := mClock.Trap().Now() + t.Cleanup(trap.Close) + go mClock.Now() + c := trap.MustWait(t.Context()) + released := false + t.Cleanup(func() { + if !released { + c.MustRelease(context.Background()) + } + }) + return func() { + c.MustRelease(context.Background()) + released = true + } + }}, } // callInBubble calls newCall in a bubble, makes the call it returns, and returns the value that // newCall or the call panicked with, if any. Recovering inside the bubble keeps synctest.Test @@ -697,11 +738,19 @@ func TestSynctestBubble(t *testing.T) { t.Parallel() t.Run("CreatedOutside", func(t *testing.T) { t.Parallel() - call := tt.setup(t, quartz.NewMock(t)) + mClock := quartz.NewMock(t) + call := tt.setup(t, mClock) + wantNext, wantOK := mClock.Peek() got := callInBubble(t, func(*testing.T) func() { return call }) if got != wantPanic { t.Fatalf("got panic %v, want %q", got, wantPanic) } + // A timer or ticker that the call added or removed before panicking would change + // how far Advance can go. + if next, ok := mClock.Peek(); next != wantNext || ok != wantOK { + t.Fatalf("Peek() = %s, %t after the panic, want %s, %t", + next, ok, wantNext, wantOK) + } }) t.Run("CreatedInside", func(t *testing.T) { t.Parallel() diff --git a/ticker.go b/ticker.go index 9896208..f4a4b06 100644 --- a/ticker.go +++ b/ticker.go @@ -121,11 +121,6 @@ func (t *Ticker) startRunLoopLocked() { if t.interrupt != nil { t.mock.tb.Error("called startRunLoopLocked when interrupt suggests we are already running") } - // Check before t.interrupt is set: the cleanup in newMockTickerLocked receives from it outside - // the bubble, which is fatal for a channel created in the bubble. - if t.mock.inForeignBubble() { - panic(foreignBubblePanic) - } interrupt := make(chan struct{}) t.interrupt = interrupt go t.runLoop(interrupt) diff --git a/timer.go b/timer.go index 5a7d63f..0f5f733 100644 --- a/timer.go +++ b/timer.go @@ -110,9 +110,6 @@ func (t *Timer) Reset(d time.Duration, tags ...string) bool { if d <= 0 { // zero or negative duration timer means we should immediately re-fire // it, rather than re-add it. - if t.mock.inForeignBubble() { - panic(foreignBubblePanic) - } t.stopped = false go t.fire(t.mock.cur) return result From 7f55975db2ea8d3bea20c8588103ff6a7f5b276e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 5 Nov 2026 12:54:34 +0000 Subject: [PATCH 5/7] docs: qualify the monotonic reading claim in inBubble time.Now has no monotonic reading when the wall clock is outside 1885 to 2157, and the rejected second signal needs one reason only. --- mock.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/mock.go b/mock.go index 6f3a801..89faa3c 100644 --- a/mock.go +++ b/mock.go @@ -531,12 +531,11 @@ func (m *Mock) checkBubble() { // inBubble reports whether the caller is in a testing/synctest bubble, where time.Now returns a // Time without a monotonic clock reading (see time_runtimeNow in runtime/time.go). That is runtime // behavior, not documented API: if bubble times gain a monotonic reading, inBubble reports false -// and the Mock no longer panics in a bubble. Outside a bubble, package time documents that time.Now -// has a monotonic reading, so inBubble does not report true there. +// and the Mock no longer panics in a bubble. Outside a bubble, time.Now has a monotonic reading +// while the wall clock is between 1885 and 2157 (see Now in time/time.go), so inBubble reports +// false there. // -// The bubble clock's documented start, midnight UTC 2000-01-01, is not used as a second signal. -// Comparing time.Now with the Mock's creation time only adds detection if bubble times gain a -// monotonic reading, and Before then compares monotonic readings; comparing wall clocks instead +// The bubble clock's start time (2000-01-01) is not a usable second signal: comparing wall clocks // would panic outside a bubble when the system clock steps back. func inBubble() bool { n := time.Now() From 161559c83863b2be1a3195d4e57a7389b21652f7 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 5 Nov 2026 13:16:30 +0000 Subject: [PATCH 6/7] fix: keep a zero AdvanceWaiter's Done and Wait working Done dereferenced the Mock for the bubble check, so Done and Wait on a zero AdvanceWaiter panicked instead of returning nil and ctx.Err(). The checkBubble comment now names the two public methods that do not check. --- mock.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/mock.go b/mock.go index 89faa3c..6111bd9 100644 --- a/mock.go +++ b/mock.go @@ -260,7 +260,10 @@ func (w AdvanceWaiter) MustWait(ctx context.Context) { // Done returns a channel that is closed when all timers and ticks complete. func (w AdvanceWaiter) Done() <-chan struct{} { - w.mock.checkBubble() + // A zero AdvanceWaiter has no Mock to check. + if w.mock != nil { + w.mock.checkBubble() + } return w.ch } @@ -517,10 +520,10 @@ var _ Clock = &Mock{} // checkBubble panics if the caller is in a testing/synctest bubble and m was created outside one. // Such a Mock can hang the test: a goroutine it starts in the bubble belongs to the bubble, and // synctest does not count a goroutine that waits on channels created outside the bubble as durably -// blocked, so synctest.Test waits for it until go test times out. Every public method of m and of -// the timers, tickers, traps, calls and waiters it returns calls checkBubble, directly or through -// matchCallLocked, before it changes any state, so the rule is the same for every call and a -// recovered panic leaves the Mock as it was. +// blocked, so synctest.Test waits for it until go test times out. The public methods of m and of +// the timers, tickers, traps, calls and waiters it returns call checkBubble, directly or through +// matchCallLocked or newTrap, before they change any state, so a recovered panic leaves the Mock +// as it was. The exceptions, Mock.Trap and Trap.String, change no state. func (m *Mock) checkBubble() { if !m.createdInBubble && inBubble() { panic("quartz: Mock used in a synctest bubble but created outside it; " + From 51d41b4642360ccf6f0949ab6952d53ddc3b57eb Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 5 Nov 2026 15:14:48 +0000 Subject: [PATCH 7/7] docs: rewrite the bubble check comments for their readers Keep NewMock's existing paragraph as it was, state the rule for test writers in one paragraph, and give maintainers the hang mechanism and the state invariant without listing exceptions or rejected alternatives. --- mock.go | 39 +++++++++++++++++---------------------- mock_test.go | 14 ++++++-------- 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/mock.go b/mock.go index 6111bd9..2445627 100644 --- a/mock.go +++ b/mock.go @@ -30,7 +30,7 @@ type Mock struct { logger Logger mu sync.Mutex testOver bool - // createdInBubble records whether NewMock ran in a testing/synctest bubble. + // createdInBubble is whether NewMock ran inside a testing/synctest bubble. createdInBubble bool // cur is the current time @@ -490,11 +490,12 @@ func (m *Mock) WithLogger(l Logger) *Mock { return m } -// NewMock creates a new Mock with the time set to midnight UTC on Jan 1, 2024. You may re-set the -// time earlier than this, but only before timers or tickers are created. +// NewMock creates a new Mock with the time set to midnight UTC on Jan 1, 2024. +// You may re-set the time earlier than this, but only before timers or tickers +// are created. // -// In a testing/synctest bubble, call NewMock with the t that synctest.Test passes in. A Mock -// created outside the bubble panics when used inside it. +// Inside a testing/synctest bubble, create the Mock with the t that synctest.Test passes to its +// function. A Mock created outside the bubble panics when used inside it. func NewMock(tb TestingT) *Mock { cur, err := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z") if err != nil { @@ -517,13 +518,13 @@ func NewMock(tb TestingT) *Mock { var _ Clock = &Mock{} -// checkBubble panics if the caller is in a testing/synctest bubble and m was created outside one. -// Such a Mock can hang the test: a goroutine it starts in the bubble belongs to the bubble, and -// synctest does not count a goroutine that waits on channels created outside the bubble as durably -// blocked, so synctest.Test waits for it until go test times out. The public methods of m and of -// the timers, tickers, traps, calls and waiters it returns call checkBubble, directly or through -// matchCallLocked or newTrap, before they change any state, so a recovered panic leaves the Mock -// as it was. The exceptions, Mock.Trap and Trap.String, change no state. +// checkBubble panics if m was created outside a testing/synctest bubble and is used inside one. +// Such use can hang the test: a goroutine that m starts in the bubble belongs to the bubble but +// waits on channels created outside it, which synctest does not count as blocked, so synctest.Test +// waits for it until go test times out. +// +// Public methods that change state call checkBubble before the change, directly or through +// matchCallLocked or newTrap, so a recovered panic leaves m unchanged. func (m *Mock) checkBubble() { if !m.createdInBubble && inBubble() { panic("quartz: Mock used in a synctest bubble but created outside it; " + @@ -531,18 +532,12 @@ func (m *Mock) checkBubble() { } } -// inBubble reports whether the caller is in a testing/synctest bubble, where time.Now returns a -// Time without a monotonic clock reading (see time_runtimeNow in runtime/time.go). That is runtime -// behavior, not documented API: if bubble times gain a monotonic reading, inBubble reports false -// and the Mock no longer panics in a bubble. Outside a bubble, time.Now has a monotonic reading -// while the wall clock is between 1885 and 2157 (see Now in time/time.go), so inBubble reports -// false there. -// -// The bubble clock's start time (2000-01-01) is not a usable second signal: comparing wall clocks -// would panic outside a bubble when the system clock steps back. +// inBubble reports whether the caller runs inside a testing/synctest bubble, where time.Now +// returns a Time without a monotonic reading (runtime.time_runtimeNow). This is runtime behavior, +// not documented API. If it changes, inBubble reports false and checkBubble never panics. func inBubble() bool { n := time.Now() - return n == n.Round(0) //nolint:staticcheck // Equal ignores the monotonic reading. + return n == n.Round(0) //nolint:staticcheck // Equal would ignore the monotonic reading. } type mockTickerFunc struct { diff --git a/mock_test.go b/mock_test.go index 576cb88..af9c602 100644 --- a/mock_test.go +++ b/mock_test.go @@ -624,7 +624,7 @@ func TestTickerStop_Go123(t *testing.T) { } // TestSynctestBubble checks that a Mock created outside a testing/synctest bubble panics when used -// inside the bubble, without changing its state, and that a Mock created inside does not panic. +// inside it, before the call changes the Mock, and that a Mock created inside does not panic. func TestSynctestBubble(t *testing.T) { t.Parallel() @@ -633,8 +633,8 @@ func TestSynctestBubble(t *testing.T) { tests := []struct { name string - // setup runs where the Mock is created, with the same t, and returns the call to make - // inside the bubble. + // setup prepares the Mock and returns the call to make in the bubble. It runs where the + // Mock was created: outside the bubble for CreatedOutside, inside for CreatedInside. setup func(t *testing.T, mClock *quartz.Mock) func() }{ {"TickerFunc", func(t *testing.T, mClock *quartz.Mock) func() { @@ -721,9 +721,8 @@ func TestSynctestBubble(t *testing.T) { } }}, } - // callInBubble calls newCall in a bubble, makes the call it returns, and returns the value that - // newCall or the call panicked with, if any. Recovering inside the bubble keeps synctest.Test - // from panicking in the outer test. + // callInBubble runs newCall and the call it returns in a bubble, and returns what either + // panicked with. Recovering inside the bubble keeps the panic from crashing the test binary. callInBubble := func(t *testing.T, newCall func(*testing.T) func()) (got any) { synctest.Test(t, func(t *testing.T) { defer func() { got = recover() }() @@ -745,8 +744,7 @@ func TestSynctestBubble(t *testing.T) { if got != wantPanic { t.Fatalf("got panic %v, want %q", got, wantPanic) } - // A timer or ticker that the call added or removed before panicking would change - // how far Advance can go. + // An event that the call added or removed before panicking would change Peek. if next, ok := mClock.Peek(); next != wantNext || ok != wantOK { t.Fatalf("Peek() = %s, %t after the panic, want %s, %t", next, ok, wantNext, wantOK)