From 56ecf7276c882fe4346eb68c2b1e8dfc0e0bc9b5 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:25:58 +0000 Subject: [PATCH 01/22] test(mock_test.go): cover test-end cancellation of live contexts Nothing checked that test cleanup cancels a mock context the caller never canceled with ErrMockClockTestEnded, or that it leaves contexts that were already done alone. The next change moves that cancellation. --- mock_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/mock_test.go b/mock_test.go index 37a3fe1..cefb912 100644 --- a/mock_test.go +++ b/mock_test.go @@ -1208,6 +1208,45 @@ func TestWithTimeout_Trap(t *testing.T) { } } +func TestWithTimeout_TestEndCancelsLiveContexts(t *testing.T) { + t.Parallel() + + var live, canceled, expired context.Context + // Not parallel, so t.Run returns after the subtest's cleanup has run. + t.Run("Subtest", func(t *testing.T) { + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + // The cancel function is not called, so the context is live when the test ends. + live, _ = mClock.WithTimeout(context.Background(), time.Hour) + var cancel context.CancelFunc + canceled, cancel = mClock.WithTimeout(context.Background(), time.Hour) + cancel() + expired, _ = mClock.WithTimeout(context.Background(), time.Minute) + mClock.Advance(time.Minute).MustWait(testCtx) + }) + + select { + case <-live.Done(): + default: + t.Fatal("expected live context to be done after the test ended") + } + if !errors.Is(live.Err(), context.Canceled) { + t.Fatalf("expected live context Canceled, got %v", live.Err()) + } + if !errors.Is(context.Cause(live), quartz.ErrMockClockTestEnded) { + t.Fatalf("expected live context cause ErrMockClockTestEnded, got %v", context.Cause(live)) + } + // Contexts that were done before the test ended keep their error and cause. + if !errors.Is(context.Cause(canceled), context.Canceled) { + t.Fatalf("expected canceled context cause Canceled, got %v", context.Cause(canceled)) + } + if !errors.Is(context.Cause(expired), context.DeadlineExceeded) { + t.Fatalf("expected expired context cause DeadlineExceeded, got %v", context.Cause(expired)) + } +} + func Test_MultipleTraps(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) From 53ad7a4d5284e69c0214181f1e07679c3c42e33f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:26:31 +0000 Subject: [PATCH 02/22] fix(mock.go): stop retaining mock contexts until test end Each mock context registered its own t.Cleanup, and that closure kept the context reachable until the test ended, even after it was canceled or expired. 100,000 create and cancel cycles retained about 1 KB per context. The cleanup that NewMock registers now cancels the contexts that are still live, found through their pending events. Those contexts are now canceled after, not before, cleanups registered between NewMock and the context's creation. --- mock.go | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/mock.go b/mock.go index 5e9a4bb..f6b9c83 100644 --- a/mock.go +++ b/mock.go @@ -23,11 +23,11 @@ type TestingT interface { Cleanup(func()) } -// ErrMockClockTestEnded is the context.Cause of a mock context that was still live when the test -// ended; its Err is context.Canceled. Each context is canceled by a cleanup registered when the -// context was created, so it is still live in cleanups registered after that, which run first. A -// context created after the cleanup registered by NewMock has run, in a cleanup registered before -// NewMock or by a goroutine that outlived the test, is returned already canceled with this cause. +// ErrMockClockTestEnded is used as the cancellation cause when test cleanup cancels a context that +// was not canceled by its caller. +// +// The cleanup that NewMock registers cancels these contexts. Cleanups run in last added, first +// called order, so cleanups registered after NewMock still see them live. var ErrMockClockTestEnded = errors.New("quartz: mock clock test ended") // Mock is the testing implementation of Clock. It tracks a time that monotonically increases @@ -212,7 +212,6 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de if !pastDeadline { m.addEventLocked(c) } - m.tb.Cleanup(func() { c.cancel(context.Canceled, ErrMockClockTestEnded) }) close(call.complete) m.mu.Unlock() @@ -264,17 +263,23 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de // checkDoneContexts cancels contexts whose parents are done, so their pending events do not block // Advance or show up in Peek. func (m *Mock) checkDoneContexts() { + for _, c := range m.liveContexts() { + c.checkParent() + } +} + +// liveContexts returns the contexts that are not done. Each one is an event in m.all from when +// newMockCtx returns it until cancel removes it. +func (m *Mock) liveContexts() []*mockCtx { m.mu.Lock() + defer m.mu.Unlock() var ctxs []*mockCtx for _, e := range m.all { if c, ok := e.(*mockCtx); ok { ctxs = append(ctxs, c) } } - m.mu.Unlock() - for _, c := range ctxs { - c.checkParent() - } + return ctxs } func (m *Mock) addEventLocked(e event) { @@ -634,6 +639,11 @@ func NewMock(tb TestingT) *Mock { cur: cur, } tb.Cleanup(func() { + // One cleanup for all contexts, rather than one per context, so a done context is not kept + // reachable until the test ends. + for _, c := range m.liveContexts() { + c.cancel(context.Canceled, ErrMockClockTestEnded) + } m.mu.Lock() defer m.mu.Unlock() m.testOver = true From 56c2459deb1dc7c6f4053b0f7f0875981f024341 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:26:48 +0000 Subject: [PATCH 03/22] fix(mock.go): clear removed event and AfterFunc slots Removing an element with append(s[:i], s[i+1:]...) left a copy of the last element in the backing array, so the Mock kept the most recently removed events, including done mock contexts, reachable. A mock parent did the same with the AfterFunc of each child that was done. slices.Delete zeroes the vacated slot. --- mock.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/mock.go b/mock.go index f6b9c83..bb4f7b5 100644 --- a/mock.go +++ b/mock.go @@ -320,7 +320,8 @@ func (m *Mock) removeEventLocked(e event) { defer m.recomputeNextLocked() for i := range m.all { if m.all[i] == e { - m.all = append(m.all[:i], m.all[i+1:]...) + // slices.Delete zeroes the vacated slot, so the backing array does not keep e reachable. + m.all = slices.Delete(m.all, i, i+1) return } } From 9906e05c8fa3dc9eafa7c896338a78aa5b270406 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:27:36 +0000 Subject: [PATCH 04/22] test(mock_go124_test.go): check that done mock contexts are collectable The Mock kept canceled and expired contexts reachable through a cleanup closure and through stale slice slots, and no test noticed. A weak pointer to a cause that only the context references is nil after one runtime.GC once nothing keeps the context. The file needs Go 1.24 for the weak package, and go.mod still allows Go 1.23. --- mock_go124_test.go | 74 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 mock_go124_test.go diff --git a/mock_go124_test.go b/mock_go124_test.go new file mode 100644 index 0000000..fb170f1 --- /dev/null +++ b/mock_go124_test.go @@ -0,0 +1,74 @@ +//go:build go1.24 + +// The weak package was added in Go 1.24. + +package quartz_test + +import ( + "context" + "runtime" + "testing" + "time" + "weak" + + "github.com/coder/quartz" +) + +// collectSentinel is the cause given to the context under test, so it is reachable only through +// that context. The pointer field keeps the allocator from batching it with other tiny objects, +// which would keep it reachable through a neighbor. +type collectSentinel struct{ _ *int } + +func (*collectSentinel) Error() string { return "collect sentinel" } + +func TestWithTimeout_DoneContextIsCollectable(t *testing.T) { + t.Parallel() + + // Each case ends a context whose cause is the returned sentinel. + tests := map[string]func(t *testing.T, mClock *quartz.Mock) weak.Pointer[collectSentinel]{ + "Canceled": func(_ *testing.T, mClock *quartz.Mock) weak.Pointer[collectSentinel] { + cause := &collectSentinel{} + _, cancel := mClock.WithTimeoutCause(context.Background(), time.Hour, cause) + cancel() + return weak.Make(cause) + }, + "Expired": func(_ *testing.T, mClock *quartz.Mock) weak.Pointer[collectSentinel] { + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + cause := &collectSentinel{} + // The cancel function is dropped: an expired context must not need it to be released. + _, _ = mClock.WithTimeoutCause(context.Background(), time.Second, cause) + mClock.Advance(time.Second).MustWait(testCtx) + return weak.Make(cause) + }, + "CanceledWithLiveMockParent": func(t *testing.T, mClock *quartz.Mock) weak.Pointer[collectSentinel] { + parent, parentCancel := mClock.WithTimeout(context.Background(), time.Hour) + t.Cleanup(parentCancel) + cause := &collectSentinel{} + _, cancel := mClock.WithTimeoutCause(parent, time.Hour, cause) + cancel() + return weak.Make(cause) + }, + "CanceledWithLiveStdlibParent": func(t *testing.T, mClock *quartz.Mock) weak.Pointer[collectSentinel] { + parent, parentCancel := context.WithCancel(context.Background()) + t.Cleanup(parentCancel) + cause := &collectSentinel{} + _, cancel := mClock.WithTimeoutCause(parent, time.Hour, cause) + cancel() + return weak.Make(cause) + }, + } + for name, endContext := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + mClock := quartz.NewMock(t) + cause := endContext(t, mClock) + // runtime.GC returns after a full cycle, including sweeping, so an unreachable sentinel + // is already reclaimed. + runtime.GC() + if cause.Value() != nil { + t.Fatal("expected the done mock context to be unreachable") + } + }) + } +} From 99088c5e95be60c3358ca216a03f7b4b8a0105e2 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:32:25 +0000 Subject: [PATCH 05/22] docs(mock.go): wrap slices.Delete comments to 100 columns --- mock.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mock.go b/mock.go index bb4f7b5..760d142 100644 --- a/mock.go +++ b/mock.go @@ -320,7 +320,7 @@ func (m *Mock) removeEventLocked(e event) { defer m.recomputeNextLocked() for i := range m.all { if m.all[i] == e { - // slices.Delete zeroes the vacated slot, so the backing array does not keep e reachable. + // slices.Delete zeroes the vacated slot, so the backing array stops referencing e. m.all = slices.Delete(m.all, i, i+1) return } From 581198b7bc92dcc43da8661a56037952edcb5fee Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:32:25 +0000 Subject: [PATCH 06/22] test(mock_go124_test.go): keep case signatures within 100 columns Each case now creates its own Mock, so the case functions take only t. --- mock_go124_test.go | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/mock_go124_test.go b/mock_go124_test.go index fb170f1..79c5bc1 100644 --- a/mock_go124_test.go +++ b/mock_go124_test.go @@ -24,24 +24,28 @@ func (*collectSentinel) Error() string { return "collect sentinel" } func TestWithTimeout_DoneContextIsCollectable(t *testing.T) { t.Parallel() - // Each case ends a context whose cause is the returned sentinel. - tests := map[string]func(t *testing.T, mClock *quartz.Mock) weak.Pointer[collectSentinel]{ - "Canceled": func(_ *testing.T, mClock *quartz.Mock) weak.Pointer[collectSentinel] { + // Each case ends a context whose cause is the returned sentinel. The Mock stays reachable + // through the cleanup that NewMock registers on t. + tests := map[string]func(t *testing.T) weak.Pointer[collectSentinel]{ + "Canceled": func(t *testing.T) weak.Pointer[collectSentinel] { + mClock := quartz.NewMock(t) cause := &collectSentinel{} _, cancel := mClock.WithTimeoutCause(context.Background(), time.Hour, cause) cancel() return weak.Make(cause) }, - "Expired": func(_ *testing.T, mClock *quartz.Mock) weak.Pointer[collectSentinel] { + "Expired": func(t *testing.T) weak.Pointer[collectSentinel] { testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) defer testCancel() + mClock := quartz.NewMock(t) cause := &collectSentinel{} // The cancel function is dropped: an expired context must not need it to be released. _, _ = mClock.WithTimeoutCause(context.Background(), time.Second, cause) mClock.Advance(time.Second).MustWait(testCtx) return weak.Make(cause) }, - "CanceledWithLiveMockParent": func(t *testing.T, mClock *quartz.Mock) weak.Pointer[collectSentinel] { + "CanceledWithLiveMockParent": func(t *testing.T) weak.Pointer[collectSentinel] { + mClock := quartz.NewMock(t) parent, parentCancel := mClock.WithTimeout(context.Background(), time.Hour) t.Cleanup(parentCancel) cause := &collectSentinel{} @@ -49,7 +53,8 @@ func TestWithTimeout_DoneContextIsCollectable(t *testing.T) { cancel() return weak.Make(cause) }, - "CanceledWithLiveStdlibParent": func(t *testing.T, mClock *quartz.Mock) weak.Pointer[collectSentinel] { + "CanceledWithLiveStdlibParent": func(t *testing.T) weak.Pointer[collectSentinel] { + mClock := quartz.NewMock(t) parent, parentCancel := context.WithCancel(context.Background()) t.Cleanup(parentCancel) cause := &collectSentinel{} @@ -61,8 +66,7 @@ func TestWithTimeout_DoneContextIsCollectable(t *testing.T) { for name, endContext := range tests { t.Run(name, func(t *testing.T) { t.Parallel() - mClock := quartz.NewMock(t) - cause := endContext(t, mClock) + cause := endContext(t) // runtime.GC returns after a full cycle, including sweeping, so an unreachable sentinel // is already reclaimed. runtime.GC() From 17da4f8ebf7554f0935b86cc044d3e8cabd27db9 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:36:13 +0000 Subject: [PATCH 07/22] fix(mock.go): cancel each context from its own cleanup again With one cleanup from NewMock, a cleanup registered after NewMock but before a context was created ran while that context was still live. t.Cleanup(srv.Close) then hung when a server goroutine blocked on a mock context whose parent never cancels. Each context again registers its own cleanup, but the closure holds only a ref that cancel clears, so a done context stays collectable. testing.T.Cleanup still keeps about 500 B per call until the test ends. --- mock.go | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/mock.go b/mock.go index 760d142..73136f5 100644 --- a/mock.go +++ b/mock.go @@ -26,8 +26,8 @@ type TestingT interface { // ErrMockClockTestEnded is used as the cancellation cause when test cleanup cancels a context that // was not canceled by its caller. // -// The cleanup that NewMock registers cancels these contexts. Cleanups run in last added, first -// called order, so cleanups registered after NewMock still see them live. +// Each context registers its cleanup when it is created. Cleanups run in last added, first called +// order, so the context is canceled before cleanups registered before its creation run. var ErrMockClockTestEnded = errors.New("quartz: mock clock test ended") // Mock is the testing implementation of Clock. It tracks a time that monotonically increases @@ -201,6 +201,7 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de mock: m, done: make(chan struct{}), } + c.testEndRef = &mockCtxRef{c: c} m.mu.Lock() m.matchCallLocked(call) @@ -212,6 +213,17 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de if !pastDeadline { m.addEventLocked(c) } + // The cleanup holds only the ref, which cancel clears, so a done context is not kept reachable + // until the test ends. + ref := c.testEndRef + m.tb.Cleanup(func() { + m.mu.Lock() + live := ref.c + m.mu.Unlock() + if live != nil { + live.cancel(context.Canceled, ErrMockClockTestEnded) + } + }) close(call.complete) m.mu.Unlock() @@ -263,23 +275,17 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de // checkDoneContexts cancels contexts whose parents are done, so their pending events do not block // Advance or show up in Peek. func (m *Mock) checkDoneContexts() { - for _, c := range m.liveContexts() { - c.checkParent() - } -} - -// liveContexts returns the contexts that are not done. Each one is an event in m.all from when -// newMockCtx returns it until cancel removes it. -func (m *Mock) liveContexts() []*mockCtx { m.mu.Lock() - defer m.mu.Unlock() var ctxs []*mockCtx for _, e := range m.all { if c, ok := e.(*mockCtx); ok { ctxs = append(ctxs, c) } } - return ctxs + m.mu.Unlock() + for _, c := range ctxs { + c.checkParent() + } } func (m *Mock) addEventLocked(e event) { @@ -640,11 +646,6 @@ func NewMock(tb TestingT) *Mock { cur: cur, } tb.Cleanup(func() { - // One cleanup for all contexts, rather than one per context, so a done context is not kept - // reachable until the test ends. - for _, c := range m.liveContexts() { - c.cancel(context.Canceled, ErrMockClockTestEnded) - } m.mu.Lock() defer m.mu.Unlock() m.testOver = true @@ -762,14 +763,18 @@ type mockCtx struct { // Cause recorded when the deadline fires. cause error - // err, done, afterFuncs, and stopParent are guarded by mock.mu. err and done are written - // together. + // err, done, afterFuncs, stopParent, and testEndRef.c are guarded by mock.mu. err and done are + // written together. err error done chan struct{} afterFuncs []*afterFunc stopParent func() bool + testEndRef *mockCtxRef } +// mockCtxRef refers to a mock context until cancel clears it. +type mockCtxRef struct{ c *mockCtx } + // afterFunc gives each registration a pointer identity for stop to find; funcs are not comparable. type afterFunc struct{ f func() } @@ -971,6 +976,7 @@ func (c *mockCtx) cancelTree(err, cause error, stops *[]func() bool) { return } c.err = err + c.testEndRef.c = nil c.mock.removeEventLocked(c) c.innerCancel(cause) close(c.done) From 54209be8992dc1a79491c10c93d9fa7cc441e610 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:36:14 +0000 Subject: [PATCH 08/22] test(mock_test.go): check test-end cancellation order A cleanup registered before a context is created must see that context canceled, so a cleanup that waits for a goroutine blocked on it does not hang. The wait is bounded so a regression fails instead of hanging the test binary. --- mock_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/mock_test.go b/mock_test.go index cefb912..65be6b6 100644 --- a/mock_test.go +++ b/mock_test.go @@ -1247,6 +1247,33 @@ func TestWithTimeout_TestEndCancelsLiveContexts(t *testing.T) { } } +func TestWithTimeout_TestEndCancelsBeforeEarlierCleanups(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + // Not parallel, so the subtest's cleanups run before testCancel. + t.Run("Subtest", func(t *testing.T) { + mClock := quartz.NewMock(t) + exited := make(chan struct{}) + // Registered before the context is created, like t.Cleanup(srv.Close) for a server whose + // goroutine later blocks on a mock context. + t.Cleanup(func() { + select { + case <-exited: + case <-testCtx.Done(): + t.Error("mock context was not canceled before a cleanup registered before it ran") + } + }) + // The parent never cancels, so only test cleanup ends the context. + ctx, _ := mClock.WithTimeout(context.Background(), time.Hour) + go func() { + <-ctx.Done() + close(exited) + }() + }) +} + func Test_MultipleTraps(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) From 38bcfe7067ce20b42522343d8b7fe9ee2706bc9b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:36:14 +0000 Subject: [PATCH 09/22] test(mock_go124_test.go): note that per-context cleanups stay registered --- mock_go124_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mock_go124_test.go b/mock_go124_test.go index 79c5bc1..97b637a 100644 --- a/mock_go124_test.go +++ b/mock_go124_test.go @@ -24,8 +24,9 @@ func (*collectSentinel) Error() string { return "collect sentinel" } func TestWithTimeout_DoneContextIsCollectable(t *testing.T) { t.Parallel() - // Each case ends a context whose cause is the returned sentinel. The Mock stays reachable - // through the cleanup that NewMock registers on t. + // Each case ends a context whose cause is the returned sentinel. The Mock and the context's own + // test-end cleanup stay reachable through t, so each case checks that neither keeps the + // context. tests := map[string]func(t *testing.T) weak.Pointer[collectSentinel]{ "Canceled": func(t *testing.T) weak.Pointer[collectSentinel] { mClock := quartz.NewMock(t) From 8016f55009e161c0c081eb6dfb4b39820d832fb0 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:08:28 +0000 Subject: [PATCH 10/22] test(mock_test.go): check test-end cancellation from an earlier cleanup A cleanup registered right after NewMock runs after the contexts' own cleanups, so it can check both the cancellation order and the error and cause in one test. This replaces the separate order test, its goroutine, its 10s bound, and the subtest that only existed to observe the contexts after cleanup. --- mock_test.go | 84 ++++++++++++++++------------------------------------ 1 file changed, 26 insertions(+), 58 deletions(-) diff --git a/mock_test.go b/mock_test.go index 65be6b6..06c621d 100644 --- a/mock_test.go +++ b/mock_test.go @@ -1210,68 +1210,36 @@ func TestWithTimeout_Trap(t *testing.T) { func TestWithTimeout_TestEndCancelsLiveContexts(t *testing.T) { t.Parallel() - - var live, canceled, expired context.Context - // Not parallel, so t.Run returns after the subtest's cleanup has run. - t.Run("Subtest", func(t *testing.T) { - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() - - mClock := quartz.NewMock(t) - // The cancel function is not called, so the context is live when the test ends. - live, _ = mClock.WithTimeout(context.Background(), time.Hour) - var cancel context.CancelFunc - canceled, cancel = mClock.WithTimeout(context.Background(), time.Hour) - cancel() - expired, _ = mClock.WithTimeout(context.Background(), time.Minute) - mClock.Advance(time.Minute).MustWait(testCtx) - }) - - select { - case <-live.Done(): - default: - t.Fatal("expected live context to be done after the test ended") - } - if !errors.Is(live.Err(), context.Canceled) { - t.Fatalf("expected live context Canceled, got %v", live.Err()) - } - if !errors.Is(context.Cause(live), quartz.ErrMockClockTestEnded) { - t.Fatalf("expected live context cause ErrMockClockTestEnded, got %v", context.Cause(live)) - } - // Contexts that were done before the test ended keep their error and cause. - if !errors.Is(context.Cause(canceled), context.Canceled) { - t.Fatalf("expected canceled context cause Canceled, got %v", context.Cause(canceled)) - } - if !errors.Is(context.Cause(expired), context.DeadlineExceeded) { - t.Fatalf("expected expired context cause DeadlineExceeded, got %v", context.Cause(expired)) - } -} - -func TestWithTimeout_TestEndCancelsBeforeEarlierCleanups(t *testing.T) { - t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) defer testCancel() - // Not parallel, so the subtest's cleanups run before testCancel. - t.Run("Subtest", func(t *testing.T) { - mClock := quartz.NewMock(t) - exited := make(chan struct{}) - // Registered before the context is created, like t.Cleanup(srv.Close) for a server whose - // goroutine later blocks on a mock context. - t.Cleanup(func() { - select { - case <-exited: - case <-testCtx.Done(): - t.Error("mock context was not canceled before a cleanup registered before it ran") - } - }) - // The parent never cancels, so only test cleanup ends the context. - ctx, _ := mClock.WithTimeout(context.Background(), time.Hour) - go func() { - <-ctx.Done() - close(exited) - }() + mClock := quartz.NewMock(t) + var live, canceled, expired context.Context + // Registered before the contexts are created, like t.Cleanup(srv.Close) for a server whose + // goroutines block on a mock context, so it must see them already done. + t.Cleanup(func() { + if !errors.Is(live.Err(), context.Canceled) { + t.Errorf("expected live context Canceled, got %v", live.Err()) + } + if !errors.Is(context.Cause(live), quartz.ErrMockClockTestEnded) { + t.Errorf("expected live context cause ErrMockClockTestEnded, got %v", context.Cause(live)) + } + // Contexts that were done before the test ended keep their error and cause. + if !errors.Is(context.Cause(canceled), context.Canceled) { + t.Errorf("expected canceled context cause Canceled, got %v", context.Cause(canceled)) + } + if !errors.Is(context.Cause(expired), context.DeadlineExceeded) { + t.Errorf("expected expired context cause DeadlineExceeded, got %v", context.Cause(expired)) + } }) + + // The cancel function is not called, so the context is live when the test ends. + live, _ = mClock.WithTimeout(context.Background(), time.Hour) + var cancel context.CancelFunc + canceled, cancel = mClock.WithTimeout(context.Background(), time.Hour) + cancel() + expired, _ = mClock.WithTimeout(context.Background(), time.Minute) + mClock.Advance(time.Minute).MustWait(testCtx) } func Test_MultipleTraps(t *testing.T) { From 161c124e6c2b2c88b6bdb86f956d550631dd093d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:08:52 +0000 Subject: [PATCH 11/22] test(mock_test.go): check Err of contexts done before test end The comment says they keep their error and cause, but only the cause was checked. A test-end cleanup that overwrote Err with Canceled while leaving the cause alone passed. --- mock_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/mock_test.go b/mock_test.go index 06c621d..e61be74 100644 --- a/mock_test.go +++ b/mock_test.go @@ -1225,9 +1225,15 @@ func TestWithTimeout_TestEndCancelsLiveContexts(t *testing.T) { t.Errorf("expected live context cause ErrMockClockTestEnded, got %v", context.Cause(live)) } // Contexts that were done before the test ended keep their error and cause. + if !errors.Is(canceled.Err(), context.Canceled) { + t.Errorf("expected canceled context Canceled, got %v", canceled.Err()) + } if !errors.Is(context.Cause(canceled), context.Canceled) { t.Errorf("expected canceled context cause Canceled, got %v", context.Cause(canceled)) } + if !errors.Is(expired.Err(), context.DeadlineExceeded) { + t.Errorf("expected expired context DeadlineExceeded, got %v", expired.Err()) + } if !errors.Is(context.Cause(expired), context.DeadlineExceeded) { t.Errorf("expected expired context cause DeadlineExceeded, got %v", context.Cause(expired)) } From fb75e20b3037a8d2a7e0f2c06e74bcdb2e583243 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:09:23 +0000 Subject: [PATCH 12/22] style(mock_test.go): wrap test-end Errorf calls to 100 columns --- mock_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mock_test.go b/mock_test.go index e61be74..a82fae0 100644 --- a/mock_test.go +++ b/mock_test.go @@ -1222,7 +1222,8 @@ func TestWithTimeout_TestEndCancelsLiveContexts(t *testing.T) { t.Errorf("expected live context Canceled, got %v", live.Err()) } if !errors.Is(context.Cause(live), quartz.ErrMockClockTestEnded) { - t.Errorf("expected live context cause ErrMockClockTestEnded, got %v", context.Cause(live)) + t.Errorf("expected live context cause ErrMockClockTestEnded, got %v", + context.Cause(live)) } // Contexts that were done before the test ended keep their error and cause. if !errors.Is(canceled.Err(), context.Canceled) { @@ -1235,7 +1236,8 @@ func TestWithTimeout_TestEndCancelsLiveContexts(t *testing.T) { t.Errorf("expected expired context DeadlineExceeded, got %v", expired.Err()) } if !errors.Is(context.Cause(expired), context.DeadlineExceeded) { - t.Errorf("expected expired context cause DeadlineExceeded, got %v", context.Cause(expired)) + t.Errorf("expected expired context cause DeadlineExceeded, got %v", + context.Cause(expired)) } }) From 756bf6a889f871c553c0c48cdb925266fee45564 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:09:23 +0000 Subject: [PATCH 13/22] docs(mock.go): describe ErrMockClockTestEnded for its users The old text said when the cause is used but not what it means for the test's own cleanups: which ones see the context canceled, and which see it live. --- mock.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/mock.go b/mock.go index 73136f5..960a0a2 100644 --- a/mock.go +++ b/mock.go @@ -23,11 +23,11 @@ type TestingT interface { Cleanup(func()) } -// ErrMockClockTestEnded is used as the cancellation cause when test cleanup cancels a context that -// was not canceled by its caller. -// -// Each context registers its cleanup when it is created. Cleanups run in last added, first called -// order, so the context is canceled before cleanups registered before its creation run. +// ErrMockClockTestEnded is the context.Cause of a mock context that was not yet canceled or expired +// when the test ended; its Err is context.Canceled. The context is canceled before any cleanup +// registered before the context was created runs, so a cleanup such as t.Cleanup(srv.Close) that +// waits for goroutines blocked on the context does not hang. Cleanups registered after the context +// was created run while it is still live. var ErrMockClockTestEnded = errors.New("quartz: mock clock test ended") // Mock is the testing implementation of Clock. It tracks a time that monotonically increases From f762f853ff0a5e0372199202a17618f429e766ac Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:09:29 +0000 Subject: [PATCH 14/22] docs(mock.go): give both reasons for the per-context cleanup The comment explained why the closure holds only a ref but not why each context registers its own cleanup instead of sharing the one from NewMock. --- mock.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mock.go b/mock.go index 960a0a2..63f6dfd 100644 --- a/mock.go +++ b/mock.go @@ -213,8 +213,9 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de if !pastDeadline { m.addEventLocked(c) } - // The cleanup holds only the ref, which cancel clears, so a done context is not kept reachable - // until the test ends. + // Each context registers its own cleanup so it is canceled before cleanups registered before it + // run (see ErrMockClockTestEnded). The cleanup holds only the ref, which cancel clears, so a + // done context is not kept reachable until the test ends. ref := c.testEndRef m.tb.Cleanup(func() { m.mu.Lock() From 64e62d494aa46d4afd8966e80885b0f6922f7a23 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:27:47 +0000 Subject: [PATCH 15/22] docs(mock.go): tie ErrMockClockTestEnded to the TestingT of NewMock The godoc said "when the test ended", but each context's cleanup is registered on the TestingT passed to NewMock. With a Mock created from a parent test's t, a probe showed the context still live in all of a subtest's cleanups and after t.Run returned, so a subtest's t.Cleanup(srv.Close) waiting on a handler blocked on it hung. It also dropped #30's sentence on contexts created after the test ended. --- mock.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/mock.go b/mock.go index 63f6dfd..66639e3 100644 --- a/mock.go +++ b/mock.go @@ -23,11 +23,14 @@ type TestingT interface { Cleanup(func()) } -// ErrMockClockTestEnded is the context.Cause of a mock context that was not yet canceled or expired -// when the test ended; its Err is context.Canceled. The context is canceled before any cleanup -// registered before the context was created runs, so a cleanup such as t.Cleanup(srv.Close) that -// waits for goroutines blocked on the context does not hang. Cleanups registered after the context -// was created run while it is still live. +// ErrMockClockTestEnded is the context.Cause of a mock context that was still live when the +// TestingT passed to NewMock ran its cleanups; its Err is context.Canceled. Each context is +// canceled by a cleanup that it registers on that TestingT when it is created, so cleanups +// registered on that TestingT before the context was created see it canceled, and cleanups +// registered after see it live. A subtest's cleanups run before its parent's, so when the TestingT +// passed to NewMock is the parent, the subtest's cleanups see the context live. A context created +// after the cleanup registered by NewMock has run, in a cleanup registered before NewMock or by a +// goroutine that outlived the test, is returned already canceled with this cause. var ErrMockClockTestEnded = errors.New("quartz: mock clock test ended") // Mock is the testing implementation of Clock. It tracks a time that monotonically increases From e60d33b0222fb4fac0b744dd3eaf583e9e6a32e9 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:28:49 +0000 Subject: [PATCH 16/22] docs(README.md): say what a test can rely on at test end The README said only that live contexts are canceled at test end. A test writer also needs the order: which of their cleanups see a context canceled, what a subtest using its parent's Mock sees, and that timers and tickers stop delivering instead of being canceled. Each claim was checked by a probe on this branch; the timer and ticker behavior is main's. --- README.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1f9dc0f..dade1b1 100644 --- a/README.md +++ b/README.md @@ -361,12 +361,28 @@ case <-testCtx.Done(): } ``` -Contexts still live when the test ends are canceled with cause `quartz.ErrMockClockTestEnded`, and -contexts created after the test ended are returned already canceled with that cause. - Call the returned cancel function as you would for `context.WithTimeout`. It is idempotent and removes the pending mock event. Cause variants preserve the cause for `context.Cause`. +#### At test end + +When the test ends, each mock context that is still live is canceled with cause +`quartz.ErrMockClockTestEnded`, so code blocked on it can return. The cancellation is a cleanup that +the context registers on the `t` passed to `NewMock` when it is created, and cleanups run last +registered first. So a cleanup registered before the context was created, such as a +`t.Cleanup(srv.Close)` that waits for a handler blocked on the context, sees it canceled and does +not hang, while cleanups registered after see it live. A subtest's cleanups run before its parent's, +so if a subtest uses a `*Mock` created with the parent's `t`, its contexts are still live there and +such a `srv.Close` hangs; create the `*Mock` with the subtest's `t`. A context created after the +test ended, for example by a goroutine that outlived it, is returned already canceled with the same +cause. + +Mock timers and tickers are not canceled. A ticker from `NewTicker` stops sending on `C` in a +cleanup that it registers when it is created, and a timer from `NewTimer` drops its unread value in +a cleanup that it registers when it fires. A cleanup registered before those cannot receive from +`C`, so if it waits for code that reads `C`, have that code also select on a context that is done by +then, such as a mock context created after the cleanup was registered. + #### `Deadline()` does not report mock time With a `*Mock`, `ctx.Done()` and `ctx.Err()` follow mock time, but `ctx.Deadline()` reports the From d38da48fa4797b5168c571e717c3e21c7077b5c2 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:54:40 +0000 Subject: [PATCH 17/22] docs(mock.go): except running cleanups from the test-end order A cleanup that is already running when a mock context is created sees the context live until it returns, because the context's cleanup is registered after it. A probe reproduced this: a handler that creates a mock context after t.Cleanup(srv.Close) started kept srv.Close blocked for a second with the context live. --- mock.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/mock.go b/mock.go index 66639e3..469bda5 100644 --- a/mock.go +++ b/mock.go @@ -25,12 +25,13 @@ type TestingT interface { // ErrMockClockTestEnded is the context.Cause of a mock context that was still live when the // TestingT passed to NewMock ran its cleanups; its Err is context.Canceled. Each context is -// canceled by a cleanup that it registers on that TestingT when it is created, so cleanups -// registered on that TestingT before the context was created see it canceled, and cleanups -// registered after see it live. A subtest's cleanups run before its parent's, so when the TestingT -// passed to NewMock is the parent, the subtest's cleanups see the context live. A context created -// after the cleanup registered by NewMock has run, in a cleanup registered before NewMock or by a -// goroutine that outlived the test, is returned already canceled with this cause. +// canceled by a cleanup that it registers on that TestingT when it is created. Cleanups run last +// registered first, so cleanups registered on that TestingT after the context see it live, and +// cleanups registered before it see it canceled, unless they were already running when it was +// created. A subtest's cleanups run before its parent's, so when the TestingT passed to NewMock is +// the parent, the subtest's cleanups see the context live. A context created after the cleanup +// registered by NewMock has run, in a cleanup registered before NewMock or by a goroutine that +// outlived the test, is returned already canceled with this cause. var ErrMockClockTestEnded = errors.New("quartz: mock clock test ended") // Mock is the testing implementation of Clock. It tracks a time that monotonically increases From 5d8e558840f4a783faf9744c2a3f37259b3a6e0a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:54:41 +0000 Subject: [PATCH 18/22] docs(README.md): cover in-flight handlers and late timer fires The test-end order holds only for cleanups that have not started when the context is created, or when the ticker is created or the timer fires. Probes showed srv.Close blocked on a handler that created its context after Close started, and returning once that context was derived from t.Context() through srv.Config.BaseContext. A timer that fired while a cleanup ran was still received in that cleanup, and a ticker reader that also selected on t.Context() exited before an earlier cleanup gave up on it. --- README.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index dade1b1..33c78bf 100644 --- a/README.md +++ b/README.md @@ -369,19 +369,26 @@ removes the pending mock event. Cause variants preserve the cause for `context.C When the test ends, each mock context that is still live is canceled with cause `quartz.ErrMockClockTestEnded`, so code blocked on it can return. The cancellation is a cleanup that the context registers on the `t` passed to `NewMock` when it is created, and cleanups run last -registered first. So a cleanup registered before the context was created, such as a -`t.Cleanup(srv.Close)` that waits for a handler blocked on the context, sees it canceled and does -not hang, while cleanups registered after see it live. A subtest's cleanups run before its parent's, -so if a subtest uses a `*Mock` created with the parent's `t`, its contexts are still live there and -such a `srv.Close` hangs; create the `*Mock` with the subtest's `t`. A context created after the -test ended, for example by a goroutine that outlived it, is returned already canceled with the same -cause. +registered first. So a cleanup that was registered before the context and had not started when the +context was created, such as a `t.Cleanup(srv.Close)` that waits for a handler blocked on the +context, sees it canceled and does not hang, while cleanups registered after the context see it +live. A context created after the test ended, for example by a goroutine that outlived it, is +returned already canceled with the same cause. + +Such a `srv.Close` still hangs in two cases: + +- The handler creates the context after `srv.Close` started. The context is canceled only after that + cleanup returns. Derive the context from `t.Context()` (Go 1.24 or later), which is canceled + before any cleanup runs; for a server, return it from `srv.Config.BaseContext` and derive from + `r.Context()`. +- A subtest uses a `*Mock` created with its parent's `t`. A subtest's cleanups run before its + parent's, so the context is still live in them. Create the `*Mock` with the subtest's `t`. Mock timers and tickers are not canceled. A ticker from `NewTicker` stops sending on `C` in a cleanup that it registers when it is created, and a timer from `NewTimer` drops its unread value in -a cleanup that it registers when it fires. A cleanup registered before those cannot receive from -`C`, so if it waits for code that reads `C`, have that code also select on a context that is done by -then, such as a mock context created after the cleanup was registered. +a cleanup that it registers when it fires. So if the ticker was created, or the timer fired, after a +cleanup was registered and before it started, code that the cleanup waits for cannot receive from +`C`. Have that code also select on a context that is done by then, such as `t.Context()`. #### `Deadline()` does not report mock time From d20e78547832ed1b6d5fbd0d00ffa4f058044276 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:55:00 +0000 Subject: [PATCH 19/22] docs(mock.go): scope the per-context cleanup comment to unstarted ones --- mock.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mock.go b/mock.go index 469bda5..fd1bd68 100644 --- a/mock.go +++ b/mock.go @@ -217,9 +217,9 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de if !pastDeadline { m.addEventLocked(c) } - // Each context registers its own cleanup so it is canceled before cleanups registered before it - // run (see ErrMockClockTestEnded). The cleanup holds only the ref, which cancel clears, so a - // done context is not kept reachable until the test ends. + // Each context registers its own cleanup so it is canceled before the cleanups registered + // before it that have not started yet (see ErrMockClockTestEnded). The cleanup holds only the + // ref, which cancel clears, so a done context is not kept reachable until the test ends. ref := c.testEndRef m.tb.Cleanup(func() { m.mu.Lock() From c32c55ebea92f780ff5d8821d4a646329e75efee Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 19:12:51 +0000 Subject: [PATCH 20/22] docs(README.md): tighten the test-end section and move it last The section now follows the Deadline() subsection, so it no longer inserts text where #31 and #32 do. The timer and ticker paragraph says what to do instead of how their cleanups work, and the BaseContext advice says to set it on an unstarted server before Start, as the probe that showed srv.Close returning did. On main's timer and ticker code, a reader of C got no tick or value in a cleanup registered before the ticker or the fire, and a reader that also selected on t.Context() exited. --- README.md | 49 +++++++++++++++++++++++-------------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 33c78bf..e041fdb 100644 --- a/README.md +++ b/README.md @@ -364,32 +364,6 @@ case <-testCtx.Done(): Call the returned cancel function as you would for `context.WithTimeout`. It is idempotent and removes the pending mock event. Cause variants preserve the cause for `context.Cause`. -#### At test end - -When the test ends, each mock context that is still live is canceled with cause -`quartz.ErrMockClockTestEnded`, so code blocked on it can return. The cancellation is a cleanup that -the context registers on the `t` passed to `NewMock` when it is created, and cleanups run last -registered first. So a cleanup that was registered before the context and had not started when the -context was created, such as a `t.Cleanup(srv.Close)` that waits for a handler blocked on the -context, sees it canceled and does not hang, while cleanups registered after the context see it -live. A context created after the test ended, for example by a goroutine that outlived it, is -returned already canceled with the same cause. - -Such a `srv.Close` still hangs in two cases: - -- The handler creates the context after `srv.Close` started. The context is canceled only after that - cleanup returns. Derive the context from `t.Context()` (Go 1.24 or later), which is canceled - before any cleanup runs; for a server, return it from `srv.Config.BaseContext` and derive from - `r.Context()`. -- A subtest uses a `*Mock` created with its parent's `t`. A subtest's cleanups run before its - parent's, so the context is still live in them. Create the `*Mock` with the subtest's `t`. - -Mock timers and tickers are not canceled. A ticker from `NewTicker` stops sending on `C` in a -cleanup that it registers when it is created, and a timer from `NewTimer` drops its unread value in -a cleanup that it registers when it fires. So if the ticker was created, or the timer fired, after a -cleanup was registered and before it started, code that the cleanup waits for cannot receive from -`C`. Have that code also select on a context that is done by then, such as `t.Context()`. - #### `Deadline()` does not report mock time With a `*Mock`, `ctx.Done()` and `ctx.Err()` follow mock time, but `ctx.Deadline()` reports the @@ -398,6 +372,29 @@ parent's deadline, or a time in year 9999 if the parent has none. Libraries such as already expired. If your code needs the remaining mock time, compute it from the deadline or timeout you passed in. +#### At test end + +When the test ends, each mock context that is still live is canceled with cause +`quartz.ErrMockClockTestEnded`. Each context does this in a cleanup it registers on the `t` passed +to `NewMock` when it is created. Cleanups run last registered first, so a cleanup registered before +the context, such as a `t.Cleanup(srv.Close)` waiting for a handler blocked on the context, sees it +canceled, unless the cleanup was already running when the context was created. Cleanups registered +after the context see it live. A context created after the test ended is returned already canceled +with the same cause. + +`srv.Close` still waits forever in two cases: + +- The handler creates the context after `srv.Close` started. With `go 1.24` or later in `go.mod`, + derive the context from `t.Context()`, which is canceled before any cleanup runs: create the + server with `httptest.NewUnstartedServer`, set `srv.Config.BaseContext` to return `t.Context()`, + call `srv.Start()`, and derive the handler's context from `r.Context()`. +- A subtest uses a `*Mock` created with its parent's `t`. The subtest's cleanups run before the + parent's, so the context is still live in them. Create the `*Mock` with the subtest's `t`. + +If a cleanup waits for code that reads a mock ticker's or timer's `C`, that code may never receive: +a ticker stops ticking, and a fired timer drops its unread value, in cleanups that can run before +yours. Have that code also select on a context that is done by then, such as `t.Context()`. + ## Recommended Patterns ### Options From 847039886e08a72759a18204dcf4c26cc016c90a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 19:13:05 +0000 Subject: [PATCH 21/22] docs(mock.go): tighten the ErrMockClockTestEnded godoc --- mock.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/mock.go b/mock.go index fd1bd68..dcc9324 100644 --- a/mock.go +++ b/mock.go @@ -25,13 +25,12 @@ type TestingT interface { // ErrMockClockTestEnded is the context.Cause of a mock context that was still live when the // TestingT passed to NewMock ran its cleanups; its Err is context.Canceled. Each context is -// canceled by a cleanup that it registers on that TestingT when it is created. Cleanups run last -// registered first, so cleanups registered on that TestingT after the context see it live, and -// cleanups registered before it see it canceled, unless they were already running when it was -// created. A subtest's cleanups run before its parent's, so when the TestingT passed to NewMock is -// the parent, the subtest's cleanups see the context live. A context created after the cleanup -// registered by NewMock has run, in a cleanup registered before NewMock or by a goroutine that -// outlived the test, is returned already canceled with this cause. +// canceled in a cleanup that it registers on that TestingT when it is created. Cleanups run last +// registered first, so cleanups registered before the context see it canceled, unless they were +// already running when it was created, and cleanups registered after it see it live. A subtest's +// cleanups run before its parent's, so they see the context live when that TestingT is the +// parent's. A context created after the cleanup registered by NewMock has run is returned already +// canceled with this cause. var ErrMockClockTestEnded = errors.New("quartz: mock clock test ended") // Mock is the testing implementation of Clock. It tracks a time that monotonically increases From 8bc3199dc6c6b6b96e144dcbbe613c32b8393e4f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 19:13:28 +0000 Subject: [PATCH 22/22] test(mock_test.go): move the test-end order test next to its sibling TestWithTimeout_TestEndCancelsLiveContexts becomes TestWithTimeout_TestEndCancelsBeforeEarlierCleanups, named for the order it checks, and sits next to TestWithTimeout_CreatedAfterMockCleanup, which covers the other test-end case. --- mock_test.go | 86 +++++++++++++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 42 deletions(-) diff --git a/mock_test.go b/mock_test.go index a82fae0..e6cf61e 100644 --- a/mock_test.go +++ b/mock_test.go @@ -1024,6 +1024,50 @@ func TestWithTimeout_PastDeadline(t *testing.T) { } } +// At test end, a live mock context is canceled before the cleanups registered before it run, and a +// context that was done earlier keeps its error and cause. +func TestWithTimeout_TestEndCancelsBeforeEarlierCleanups(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + var live, canceled, expired context.Context + // Registered before the contexts are created, like t.Cleanup(srv.Close) for a server whose + // goroutines block on a mock context, so it must see them already done. + t.Cleanup(func() { + if !errors.Is(live.Err(), context.Canceled) { + t.Errorf("expected live context Canceled, got %v", live.Err()) + } + if !errors.Is(context.Cause(live), quartz.ErrMockClockTestEnded) { + t.Errorf("expected live context cause ErrMockClockTestEnded, got %v", + context.Cause(live)) + } + // Contexts that were done before the test ended keep their error and cause. + if !errors.Is(canceled.Err(), context.Canceled) { + t.Errorf("expected canceled context Canceled, got %v", canceled.Err()) + } + if !errors.Is(context.Cause(canceled), context.Canceled) { + t.Errorf("expected canceled context cause Canceled, got %v", context.Cause(canceled)) + } + if !errors.Is(expired.Err(), context.DeadlineExceeded) { + t.Errorf("expected expired context DeadlineExceeded, got %v", expired.Err()) + } + if !errors.Is(context.Cause(expired), context.DeadlineExceeded) { + t.Errorf("expected expired context cause DeadlineExceeded, got %v", + context.Cause(expired)) + } + }) + + // The cancel function is not called, so the context is live when the test ends. + live, _ = mClock.WithTimeout(context.Background(), time.Hour) + var cancel context.CancelFunc + canceled, cancel = mClock.WithTimeout(context.Background(), time.Hour) + cancel() + expired, _ = mClock.WithTimeout(context.Background(), time.Minute) + mClock.Advance(time.Minute).MustWait(testCtx) +} + // A context created after the cleanup registered by NewMock has run might never be canceled by a // cleanup, so it must be done when it is returned. func TestWithTimeout_CreatedAfterMockCleanup(t *testing.T) { @@ -1208,48 +1252,6 @@ func TestWithTimeout_Trap(t *testing.T) { } } -func TestWithTimeout_TestEndCancelsLiveContexts(t *testing.T) { - t.Parallel() - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() - - mClock := quartz.NewMock(t) - var live, canceled, expired context.Context - // Registered before the contexts are created, like t.Cleanup(srv.Close) for a server whose - // goroutines block on a mock context, so it must see them already done. - t.Cleanup(func() { - if !errors.Is(live.Err(), context.Canceled) { - t.Errorf("expected live context Canceled, got %v", live.Err()) - } - if !errors.Is(context.Cause(live), quartz.ErrMockClockTestEnded) { - t.Errorf("expected live context cause ErrMockClockTestEnded, got %v", - context.Cause(live)) - } - // Contexts that were done before the test ended keep their error and cause. - if !errors.Is(canceled.Err(), context.Canceled) { - t.Errorf("expected canceled context Canceled, got %v", canceled.Err()) - } - if !errors.Is(context.Cause(canceled), context.Canceled) { - t.Errorf("expected canceled context cause Canceled, got %v", context.Cause(canceled)) - } - if !errors.Is(expired.Err(), context.DeadlineExceeded) { - t.Errorf("expected expired context DeadlineExceeded, got %v", expired.Err()) - } - if !errors.Is(context.Cause(expired), context.DeadlineExceeded) { - t.Errorf("expected expired context cause DeadlineExceeded, got %v", - context.Cause(expired)) - } - }) - - // The cancel function is not called, so the context is live when the test ends. - live, _ = mClock.WithTimeout(context.Background(), time.Hour) - var cancel context.CancelFunc - canceled, cancel = mClock.WithTimeout(context.Background(), time.Hour) - cancel() - expired, _ = mClock.WithTimeout(context.Background(), time.Minute) - mClock.Advance(time.Minute).MustWait(testCtx) -} - func Test_MultipleTraps(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second)