From 53c5486e523f1b4c2f18a16a557a68757c6b8ebc Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 7 May 2026 17:09:56 +0000 Subject: [PATCH 01/43] feat!: add Clock-backed context support This change lets users create context timeouts that are scheduled on mock time by introducing four new methods on Clock. The parent context cancellation is respected, and its timeout may still run on wall time, which allows use cases like setting maximum runtime limits for tests and keeps the model simple as Quartz does not translate wall time to mock time. Closes #14. BREAKING CHANGE: Clock gained four new methods, which is technically a breakage of the public API. Impact should be minimal considering Clock is not meant to be faked outside of this package. --- README.md | 24 +++++ clock.go | 20 ++++ mock.go | 213 ++++++++++++++++++++++++++++++++++++ mock_test.go | 298 +++++++++++++++++++++++++++++++++++++++++++++++++++ real.go | 16 +++ 5 files changed, 571 insertions(+) diff --git a/README.md b/README.md index b0da401..241224f 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,30 @@ gotBaz := <-baz // ?? never trapped, so races with Advance() Tags appear as an optional suffix on all `Clock` methods (type `...string`) and are ignored entirely by the real clock. They also appear on all methods on returned timers and tickers. +### Contexts + +`Clock` provides `WithDeadline`, `WithDeadlineCause`, `WithTimeout`, and `WithTimeoutCause` methods +that mirror the standard `context` package. With a `*Mock`, deadlines created through the clock +advance on mock time: + +```go +ctx, cancel := mClock.WithTimeout(context.Background(), 30*time.Second) +defer cancel() + +mClock.Advance(30 * time.Second).MustWait(testCtx) +if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatal("expected deadline exceeded") +} +``` + +Deadlines created through `mClock.WithDeadline` and `mClock.WithTimeout` use mock time. Parent +contexts keep standard `context` behavior. A parent created with `context.WithDeadline` or +`context.WithTimeout` can still cancel the child on wall time because Quartz does not convert +parent deadlines to mock time. + +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`. + ## Recommended Patterns ### Options diff --git a/clock.go b/clock.go index 729edfa..678fdaf 100644 --- a/clock.go +++ b/clock.go @@ -35,6 +35,26 @@ type Clock interface { Since(t time.Time, tags ...string) time.Duration // Until returns the duration until t. It is shorthand for t.Sub(Clock.Now()). Until(t time.Time, tags ...string) time.Duration + + // WithDeadline returns a copy of parent with a deadline governed by this Clock. When the + // deadline is reached, Err satisfies errors.Is(err, context.DeadlineExceeded). Parent + // cancellation propagates with stdlib semantics. Parent deadlines are not converted to mock + // time, so a parent created with context.WithDeadline or context.WithTimeout can cancel the + // child independently of this Clock. The returned cancel function is idempotent and must be + // called. + WithDeadline(parent context.Context, d time.Time, tags ...string) (context.Context, context.CancelFunc) + + // WithDeadlineCause returns a copy of parent with a deadline governed by this Clock and + // records cause when this Clock reaches the deadline. + WithDeadlineCause(parent context.Context, d time.Time, cause error, tags ...string) (context.Context, context.CancelFunc) + + // WithTimeout returns a copy of parent with a timeout governed by this Clock. It is shorthand + // for WithDeadline(parent, Clock.Now().Add(d)). + WithTimeout(parent context.Context, d time.Duration, tags ...string) (context.Context, context.CancelFunc) + + // WithTimeoutCause returns a copy of parent with a timeout governed by this Clock and records + // cause when this Clock reaches the timeout. + WithTimeoutCause(parent context.Context, d time.Duration, cause error, tags ...string) (context.Context, context.CancelFunc) } // Waiter can be waited on for an error. diff --git a/mock.go b/mock.go index 7255aff..304595b 100644 --- a/mock.go +++ b/mock.go @@ -23,6 +23,10 @@ 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. +var ErrMockClockTestEnded = errors.New("quartz: mock clock test ended") + // Mock is the testing implementation of Clock. It tracks a time that monotonically increases // during a test, triggering any timers or tickers automatically. type Mock struct { @@ -154,6 +158,71 @@ func (m *Mock) Until(t time.Time, tags ...string) time.Duration { return t.Sub(m.cur) } +func (m *Mock) WithDeadline(parent context.Context, d time.Time, tags ...string) (context.Context, context.CancelFunc) { + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionWithDeadline, tags, withTime(d)) + m.matchCallLocked(c) + defer close(c.complete) + return m.withDeadlineCauseLocked(parent, d, nil) +} + +func (m *Mock) WithDeadlineCause(parent context.Context, d time.Time, cause error, tags ...string) (context.Context, context.CancelFunc) { + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionWithDeadlineCause, tags, withTime(d), withCause(cause)) + m.matchCallLocked(c) + defer close(c.complete) + return m.withDeadlineCauseLocked(parent, d, cause) +} + +func (m *Mock) WithTimeout(parent context.Context, d time.Duration, tags ...string) (context.Context, context.CancelFunc) { + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionWithTimeout, tags, withDuration(d)) + m.matchCallLocked(c) + defer close(c.complete) + deadline := m.cur.Add(d) + return m.withDeadlineCauseLocked(parent, deadline, nil) +} + +func (m *Mock) WithTimeoutCause(parent context.Context, d time.Duration, cause error, tags ...string) (context.Context, context.CancelFunc) { + m.mu.Lock() + defer m.mu.Unlock() + c := newCall(clockFunctionWithTimeoutCause, tags, withDuration(d), withCause(cause)) + m.matchCallLocked(c) + defer close(c.complete) + deadline := m.cur.Add(d) + return m.withDeadlineCauseLocked(parent, deadline, cause) +} + +func (m *Mock) withDeadlineCauseLocked(parent context.Context, d time.Time, cause error) (context.Context, context.CancelFunc) { + inner, innerCancel := context.WithCancelCause(parent) + c := &mockCtx{ + inner: inner, + innerCancel: innerCancel, + deadline: d, + cause: cause, + mock: m, + done: make(chan struct{}), + } + if !d.After(m.cur) { + // Mirror NewTimer and AfterFunc behavior for past deadlines. + go c.fire(m.cur) + } else { + m.addEventLocked(c) + } + context.AfterFunc(inner, func() { + c.cancelFromInner(inner.Err()) + }) + m.tb.Cleanup(func() { + c.cancel(context.Canceled, ErrMockClockTestEnded) + }) + return c, func() { + c.cancel(context.Canceled, nil) + } +} + func (m *Mock) addEventLocked(e event) { m.all = append(m.all, e) m.recomputeNextLocked() @@ -188,6 +257,12 @@ func (m *Mock) removeTimerLocked(t *Timer) { m.removeEventLocked(t) } +func (m *Mock) removeEvent(e event) { + m.mu.Lock() + defer m.mu.Unlock() + m.removeEventLocked(e) +} + func (m *Mock) removeEventLocked(e event) { defer m.recomputeNextLocked() for i := range m.all { @@ -444,6 +519,22 @@ func (t Trapper) Until(tags ...string) *Trap { return t.mock.newTrap(clockFunctionUntil, tags) } +func (t Trapper) WithDeadline(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionWithDeadline, tags) +} + +func (t Trapper) WithDeadlineCause(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionWithDeadlineCause, tags) +} + +func (t Trapper) WithTimeout(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionWithTimeout, tags) +} + +func (t Trapper) WithTimeoutCause(tags ...string) *Trap { + return t.mock.newTrap(clockFunctionWithTimeoutCause, tags) +} + func (m *Mock) Trap() Trapper { return Trapper{m} } @@ -583,6 +674,99 @@ func (m *mockTickerFunc) Wait(tags ...string) error { var _ Waiter = &mockTickerFunc{} +type mockCtx struct { + // inner stores cancellation causes. Value delegates to it so context.Cause + // can find the stdlib cancelCtx. Done must return c.done, not inner.Done, + // or stdlib descendants attach to inner and inherit its context.Canceled + // Err when a mock deadline fires. + inner context.Context + innerCancel context.CancelCauseFunc + deadline time.Time + cause error + + mock *Mock + + mu sync.Mutex // Protects the following fields. + err error + done chan struct{} + doneClosed bool +} + +func (c *mockCtx) Deadline() (time.Time, bool) { + return c.deadline, true +} + +func (c *mockCtx) Done() <-chan struct{} { + return c.done +} + +func (c *mockCtx) Value(key any) any { + return c.inner.Value(key) +} + +func (c *mockCtx) Err() error { + c.mu.Lock() + defer c.mu.Unlock() + if !c.doneClosed { + return nil + } + return c.err +} + +func (c *mockCtx) next() time.Time { + return c.deadline +} + +func (c *mockCtx) fire(_ time.Time) { + c.cancel(context.DeadlineExceeded, c.cause) +} + +func (c *mockCtx) cancel(err, cause error) { + if innerErr := c.inner.Err(); innerErr != nil { + c.cancelFromInner(innerErr) + return + } + if cause == nil { + cause = err + } + c.cancelOnce(err, cause, true) +} + +func (c *mockCtx) cancelFromInner(err error) { + if err == nil { + err = context.Canceled + } + c.cancelOnce(err, nil, false) +} + +func (c *mockCtx) cancelOnce(err, cause error, cancelInner bool) { + c.mu.Lock() + if c.err != nil { + done := c.done + doneClosed := c.doneClosed + c.mu.Unlock() + if !doneClosed { + <-done + } + return + } + c.err = err + c.mu.Unlock() + + // Remove the scheduled deadline before closing Done, so callers + // waiting on Done can observe cancellation without seeing a stale + // event in Peek or AdvanceNext. + c.mock.removeEvent(c) + + c.mu.Lock() + if cancelInner { + c.innerCancel(cause) + } + c.doneClosed = true + close(c.done) + c.mu.Unlock() +} + type clockFunction int const ( @@ -598,6 +782,10 @@ const ( clockFunctionNow clockFunctionSince clockFunctionUntil + clockFunctionWithDeadline + clockFunctionWithDeadlineCause + clockFunctionWithTimeout + clockFunctionWithTimeoutCause ) func (c clockFunction) String() string { @@ -626,6 +814,14 @@ func (c clockFunction) String() string { return "Since" case clockFunctionUntil: return "Until" + case clockFunctionWithDeadline: + return "WithDeadline" + case clockFunctionWithDeadlineCause: + return "WithDeadlineCause" + case clockFunctionWithTimeout: + return "WithTimeout" + case clockFunctionWithTimeoutCause: + return "WithTimeoutCause" default: return fmt.Sprintf("Unknown clockFunction(%d)", c) } @@ -637,6 +833,7 @@ type callArg func(c *apiCall) type apiCall struct { Time time.Time Duration time.Duration + Cause error Tags []string fn clockFunction @@ -670,6 +867,14 @@ func (a *apiCall) String() string { return fmt.Sprintf("Since(%s, %v)", a.Time, a.Tags) case clockFunctionUntil: return fmt.Sprintf("Until(%s, %v)", a.Time, a.Tags) + case clockFunctionWithDeadline: + return fmt.Sprintf("WithDeadline(, %s, %v)", a.Time, a.Tags) + case clockFunctionWithDeadlineCause: + return fmt.Sprintf("WithDeadlineCause(, %s, cause=%v, %v)", a.Time, a.Cause, a.Tags) + case clockFunctionWithTimeout: + return fmt.Sprintf("WithTimeout(, %s, %v)", a.Duration, a.Tags) + case clockFunctionWithTimeoutCause: + return fmt.Sprintf("WithTimeoutCause(, %s, cause=%v, %v)", a.Duration, a.Cause, a.Tags) default: return fmt.Sprintf("Unknown clockFunction(%d)", a.fn) } @@ -679,6 +884,7 @@ func (a *apiCall) String() string { type Call struct { Time time.Time Duration time.Duration + Cause error // Cause passed to a cause variant, if applicable. Tags []string tb TestingT @@ -727,6 +933,12 @@ func withDuration(d time.Duration) callArg { } } +func withCause(err error) callArg { + return func(c *apiCall) { + c.Cause = err + } +} + func newCall(fn clockFunction, tags []string, args ...callArg) *apiCall { c := &apiCall{ fn: fn, @@ -814,6 +1026,7 @@ func (t *Trap) Wait(ctx context.Context) (*Call, error) { c := &Call{ Time: a.Time, Duration: a.Duration, + Cause: a.Cause, Tags: a.Tags, apiCall: a, trap: t, diff --git a/mock_test.go b/mock_test.go index 7bc822d..c19e5c7 100644 --- a/mock_test.go +++ b/mock_test.go @@ -325,6 +325,304 @@ func TestTickerFunc_LongCallback(t *testing.T) { w.MustWait(testCtx) } +func TestWithTimeout_FiresOnAdvance(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + ctx, cancel := mClock.WithTimeout(context.Background(), time.Second) + defer cancel() + child, childCancel := context.WithCancel(ctx) + defer childCancel() + + mClock.Advance(time.Second).MustWait(testCtx) + + select { + case <-ctx.Done(): + // OK + case <-testCtx.Done(): + t.Fatal("timeout waiting for context deadline") + } + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) + } + select { + case <-child.Done(): + // OK + case <-testCtx.Done(): + t.Fatal("timeout waiting for child context") + } + if !errors.Is(child.Err(), context.DeadlineExceeded) { + t.Fatalf("expected child DeadlineExceeded, got %v", child.Err()) + } +} + +func TestWithDeadline_Deadline(t *testing.T) { + t.Parallel() + + mClock := quartz.NewMock(t) + deadline := mClock.Now().Add(time.Hour) + ctx, cancel := mClock.WithDeadline(context.Background(), deadline) + defer cancel() + + got, ok := ctx.Deadline() + if !ok { + t.Fatal("expected deadline") + } + if !got.Equal(deadline) { + t.Fatalf("expected deadline %s, got %s", deadline, got) + } +} + +func TestWithTimeout_CancelIsIdempotentAndRemovesEvent(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour) + + cancel() + cancel() + select { + case <-ctx.Done(): + // OK + case <-testCtx.Done(): + t.Fatal("timeout waiting for canceled context") + } + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("expected Canceled, got %v", ctx.Err()) + } + if d, ok := mClock.Peek(); ok { + t.Fatalf("expected no pending events, got Peek()=%s", d) + } + mClock.Advance(2 * time.Hour).MustWait(testCtx) +} + +func TestWithTimeout_ParentCanceled(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + parent, parentCancel := context.WithCancel(context.Background()) + ctx, cancel := mClock.WithTimeout(parent, time.Hour) + defer cancel() + + parentCancel() + select { + case <-ctx.Done(): + // OK + case <-testCtx.Done(): + t.Fatal("timeout waiting for parent cancellation") + } + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("expected Canceled, got %v", ctx.Err()) + } + if d, ok := mClock.Peek(); ok { + t.Fatalf("expected no pending events, got Peek()=%s", d) + } +} + +func TestWithTimeout_ParentDeadlineExceeded(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + parent, parentCancel := context.WithTimeout(context.Background(), time.Nanosecond) + defer parentCancel() + ctx, cancel := mClock.WithTimeout(parent, time.Hour) + defer cancel() + + select { + case <-ctx.Done(): + // A wall-clock parent deadline must still cancel mock-clock children. + // This keeps test timeout guards effective when mock time is stalled. + case <-testCtx.Done(): + t.Fatal("timeout waiting for parent deadline") + } + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) + } + if d, ok := mClock.Peek(); ok { + t.Fatalf("expected no pending events, got Peek()=%s", d) + } +} + +func TestWithTimeoutCause_Cause(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + expectedErr := errors.New("deadline cause") + ctx, cancel := mClock.WithTimeoutCause(context.Background(), time.Second, expectedErr) + defer cancel() + child, childCancel := context.WithCancel(ctx) + defer childCancel() + + mClock.Advance(time.Second).MustWait(testCtx) + + select { + case <-child.Done(): + // OK + case <-testCtx.Done(): + t.Fatal("timeout waiting for child context") + } + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) + } + if !errors.Is(context.Cause(ctx), expectedErr) { + t.Fatalf("expected cause %v, got %v", expectedErr, context.Cause(ctx)) + } + if !errors.Is(context.Cause(child), expectedErr) { + t.Fatalf("expected child cause %v, got %v", expectedErr, context.Cause(child)) + } +} + +func TestWithTimeout_PastDeadline(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + for _, d := range []time.Duration{0, -time.Second} { + t.Run(d.String(), func(t *testing.T) { + mClock := quartz.NewMock(t) + ctx, cancel := mClock.WithTimeout(context.Background(), d) + defer cancel() + + select { + case <-ctx.Done(): + // OK + case <-testCtx.Done(): + t.Fatal("timeout waiting for past deadline") + } + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) + } + if d, ok := mClock.Peek(); ok { + t.Fatalf("expected no pending events, got Peek()=%s", d) + } + }) + } +} + +func TestWithTimeout_CancelRace(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + parent, parentCancel := context.WithCancel(context.Background()) + defer parentCancel() + ctx, cancel := mClock.WithTimeout(parent, time.Second) + defer cancel() + child, childCancel := context.WithCancel(ctx) + defer childCancel() + start := make(chan struct{}) + done := make(chan struct{}, 4) + + go func() { + <-start + mClock.Advance(time.Second).MustWait(testCtx) + done <- struct{}{} + }() + go func() { + <-start + cancel() + done <- struct{}{} + }() + go func() { + <-start + parentCancel() + done <- struct{}{} + }() + go func() { + <-start + for { + select { + case <-ctx.Done(): + _ = ctx.Err() + _ = child.Err() + done <- struct{}{} + return + default: + _ = ctx.Err() + _ = child.Err() + } + } + }() + + close(start) + for range 4 { + select { + case <-done: + case <-testCtx.Done(): + t.Fatal("timeout waiting for cancel race") + } + } +} + +func TestWithTimeout_Trap(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + trap := mClock.Trap().WithTimeout("ctx") + defer trap.Close() + ctxs := make(chan context.Context, 1) + cancels := make(chan context.CancelFunc, 1) + go func() { + ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour, "ctx") + ctxs <- ctx + cancels <- cancel + }() + + call := trap.MustWait(testCtx) + if call.Duration != time.Hour { + t.Fatalf("expected time.Hour, got %v", call.Duration) + } + call.MustRelease(testCtx) + ctx := <-ctxs + cancel := <-cancels + defer cancel() + mClock.Advance(time.Hour).MustWait(testCtx) + select { + case <-ctx.Done(): + // OK + case <-testCtx.Done(): + t.Fatal("timeout waiting for trapped context") + } + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) + } + + causeErr := errors.New("trap cause") + trapCause := mClock.Trap().WithTimeoutCause("cause") + defer trapCause.Close() + go func() { + ctx, cancel := mClock.WithTimeoutCause(context.Background(), time.Minute, causeErr, "cause") + ctxs <- ctx + cancels <- cancel + }() + + call = trapCause.MustWait(testCtx) + if call.Duration != time.Minute { + t.Fatalf("expected time.Minute, got %v", call.Duration) + } + if !errors.Is(call.Cause, causeErr) { + t.Fatalf("expected cause %v, got %v", causeErr, call.Cause) + } + call.MustRelease(testCtx) + ctx = <-ctxs + cancel = <-cancels + cancel() + <-ctx.Done() +} + func Test_MultipleTraps(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) diff --git a/real.go b/real.go index f39fb11..7118c4b 100644 --- a/real.go +++ b/real.go @@ -77,4 +77,20 @@ func (realClock) Until(t time.Time, _ ...string) time.Duration { return time.Until(t) } +func (realClock) WithDeadline(parent context.Context, d time.Time, _ ...string) (context.Context, context.CancelFunc) { + return context.WithDeadline(parent, d) +} + +func (realClock) WithDeadlineCause(parent context.Context, d time.Time, cause error, _ ...string) (context.Context, context.CancelFunc) { + return context.WithDeadlineCause(parent, d, cause) +} + +func (realClock) WithTimeout(parent context.Context, d time.Duration, _ ...string) (context.Context, context.CancelFunc) { + return context.WithTimeout(parent, d) +} + +func (realClock) WithTimeoutCause(parent context.Context, d time.Duration, cause error, _ ...string) (context.Context, context.CancelFunc) { + return context.WithTimeoutCause(parent, d, cause) +} + var _ Clock = realClock{} From 38f9e0ff89413a8dda848fc39a6a9e3924912331 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 21 May 2026 12:16:56 +0000 Subject: [PATCH 02/43] refactor(mock.go): simplify mockCtx synchronization Use mock.mu instead of a per-context mutex, dropping the doneClosed field and the lock release/reacquire around removeEvent. Collapse the three cancel paths into a single cancelLocked. Document the Deadline() vs. wall-clock footgun in godoc and README. Guard channel reads in TestWithTimeout_Trap with testCtx. Addresses review feedback on #30. --- README.md | 9 +++++ clock.go | 4 +++ mock.go | 98 +++++++++++++++++++--------------------------------- mock_test.go | 32 ++++++++++++++--- 4 files changed, 75 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 241224f..1f2995f 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,15 @@ parent deadlines to mock time. 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`. +#### Caveat: `Deadline()` returns mock time + +`ctx.Deadline()` on a mock-clock context returns mock time. Code that compares it against +`time.Now()` treats it as wall time and will misbehave. The `net/http` client does this in +`timeBeforeContextDeadline` to decide whether to apply per-request timeouts. + +Keep the mock-clock context inside the code under test. Pass a real-clock context across +package boundaries where Quartz's mocking is not intended. + ## Recommended Patterns ### Options diff --git a/clock.go b/clock.go index 678fdaf..694f9d8 100644 --- a/clock.go +++ b/clock.go @@ -42,6 +42,10 @@ type Clock interface { // time, so a parent created with context.WithDeadline or context.WithTimeout can cancel the // child independently of this Clock. The returned cancel function is idempotent and must be // called. + // + // With a Mock clock, ctx.Deadline() returns mock time. Code that compares it against + // time.Now() treats it as wall time and will misbehave. The net/http client does this in + // timeBeforeContextDeadline to decide whether to apply per-request timeouts. WithDeadline(parent context.Context, d time.Time, tags ...string) (context.Context, context.CancelFunc) // WithDeadlineCause returns a copy of parent with a deadline governed by this Clock and diff --git a/mock.go b/mock.go index 304595b..d9524c7 100644 --- a/mock.go +++ b/mock.go @@ -207,19 +207,26 @@ func (m *Mock) withDeadlineCauseLocked(parent context.Context, d time.Time, caus done: make(chan struct{}), } if !d.After(m.cur) { - // Mirror NewTimer and AfterFunc behavior for past deadlines. + // Past deadline: fire immediately, as NewTimer() does with d <= 0. go c.fire(m.cur) } else { m.addEventLocked(c) } context.AfterFunc(inner, func() { - c.cancelFromInner(inner.Err()) + // inner.Err() is non-nil per AfterFunc() contract. + c.mock.mu.Lock() + defer c.mock.mu.Unlock() + c.cancelLocked(inner.Err(), nil) }) m.tb.Cleanup(func() { - c.cancel(context.Canceled, ErrMockClockTestEnded) + c.mock.mu.Lock() + defer c.mock.mu.Unlock() + c.cancelLocked(context.Canceled, ErrMockClockTestEnded) }) return c, func() { - c.cancel(context.Canceled, nil) + c.mock.mu.Lock() + defer c.mock.mu.Unlock() + c.cancelLocked(context.Canceled, nil) } } @@ -257,12 +264,6 @@ func (m *Mock) removeTimerLocked(t *Timer) { m.removeEventLocked(t) } -func (m *Mock) removeEvent(e event) { - m.mu.Lock() - defer m.mu.Unlock() - m.removeEventLocked(e) -} - func (m *Mock) removeEventLocked(e event) { defer m.recomputeNextLocked() for i := range m.all { @@ -675,21 +676,23 @@ func (m *mockTickerFunc) Wait(tags ...string) error { var _ Waiter = &mockTickerFunc{} type mockCtx struct { - // inner stores cancellation causes. Value delegates to it so context.Cause - // can find the stdlib cancelCtx. Done must return c.done, not inner.Done, - // or stdlib descendants attach to inner and inherit its context.Canceled - // Err when a mock deadline fires. + mock *Mock + + // inner stores the cancellation cause so context.Cause() can find it via + // Value(&cancelCtxKey). Done() must NOT return inner.Done(): stdlib's + // parentCancelCtx fast-path would then attach descendants directly to + // inner, and they would read inner's context.Canceled Err() on deadline + // fire instead of ours. inner context.Context innerCancel context.CancelCauseFunc deadline time.Time - cause error - mock *Mock + // Cause recorded when the deadline fires. + cause error - mu sync.Mutex // Protects the following fields. - err error - done chan struct{} - doneClosed bool + // err and done are written together under mock.mu. + err error + done chan struct{} } func (c *mockCtx) Deadline() (time.Time, bool) { @@ -705,11 +708,8 @@ func (c *mockCtx) Value(key any) any { } func (c *mockCtx) Err() error { - c.mu.Lock() - defer c.mu.Unlock() - if !c.doneClosed { - return nil - } + c.mock.mu.Lock() + defer c.mock.mu.Unlock() return c.err } @@ -718,53 +718,25 @@ func (c *mockCtx) next() time.Time { } func (c *mockCtx) fire(_ time.Time) { - c.cancel(context.DeadlineExceeded, c.cause) + c.mock.mu.Lock() + defer c.mock.mu.Unlock() + c.cancelLocked(context.DeadlineExceeded, c.cause) } -func (c *mockCtx) cancel(err, cause error) { - if innerErr := c.inner.Err(); innerErr != nil { - c.cancelFromInner(innerErr) +// cancelLocked matches stdlib cancelCtx.cancel(): a nil cause defaults to err. +// +// Must be called with mock.mu held. +func (c *mockCtx) cancelLocked(err, cause error) { + if c.err != nil { return } if cause == nil { cause = err } - c.cancelOnce(err, cause, true) -} - -func (c *mockCtx) cancelFromInner(err error) { - if err == nil { - err = context.Canceled - } - c.cancelOnce(err, nil, false) -} - -func (c *mockCtx) cancelOnce(err, cause error, cancelInner bool) { - c.mu.Lock() - if c.err != nil { - done := c.done - doneClosed := c.doneClosed - c.mu.Unlock() - if !doneClosed { - <-done - } - return - } c.err = err - c.mu.Unlock() - - // Remove the scheduled deadline before closing Done, so callers - // waiting on Done can observe cancellation without seeing a stale - // event in Peek or AdvanceNext. - c.mock.removeEvent(c) - - c.mu.Lock() - if cancelInner { - c.innerCancel(cause) - } - c.doneClosed = true + c.mock.removeEventLocked(c) + c.innerCancel(cause) close(c.done) - c.mu.Unlock() } type clockFunction int diff --git a/mock_test.go b/mock_test.go index c19e5c7..a7fc494 100644 --- a/mock_test.go +++ b/mock_test.go @@ -586,8 +586,18 @@ func TestWithTimeout_Trap(t *testing.T) { t.Fatalf("expected time.Hour, got %v", call.Duration) } call.MustRelease(testCtx) - ctx := <-ctxs - cancel := <-cancels + var ctx context.Context + select { + case ctx = <-ctxs: + case <-testCtx.Done(): + t.Fatal("timeout waiting for trapped WithTimeout context") + } + var cancel context.CancelFunc + select { + case cancel = <-cancels: + case <-testCtx.Done(): + t.Fatal("timeout waiting for trapped WithTimeout cancel") + } defer cancel() mClock.Advance(time.Hour).MustWait(testCtx) select { @@ -617,10 +627,22 @@ func TestWithTimeout_Trap(t *testing.T) { t.Fatalf("expected cause %v, got %v", causeErr, call.Cause) } call.MustRelease(testCtx) - ctx = <-ctxs - cancel = <-cancels + select { + case ctx = <-ctxs: + case <-testCtx.Done(): + t.Fatal("timeout waiting for trapped WithTimeoutCause context") + } + select { + case cancel = <-cancels: + case <-testCtx.Done(): + t.Fatal("timeout waiting for trapped WithTimeoutCause cancel") + } cancel() - <-ctx.Done() + select { + case <-ctx.Done(): + case <-testCtx.Done(): + t.Fatal("timeout waiting for canceled WithTimeoutCause ctx") + } } func Test_MultipleTraps(t *testing.T) { From e816ef113831f1597852f136e2ca130bea0c14d6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 11:00:09 +0000 Subject: [PATCH 03/43] fix(mock.go): avoid deadlocks when reusing mock contexts Two paths called a mock context's Err() while holding the Mock mutex, which Err() also takes: - WithTimeout and friends with a canceled parent from the same Mock, because context.WithCancelCause(parent) calls parent.Err(). - TickerFunc exiting after its mock context was canceled. Both hung the Mock until the test binary timed out. --- mock.go | 42 ++++++++++++++++++------------------------ mock_test.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 24 deletions(-) diff --git a/mock.go b/mock.go index d9524c7..f5d4ba4 100644 --- a/mock.go +++ b/mock.go @@ -159,45 +159,37 @@ func (m *Mock) Until(t time.Time, tags ...string) time.Duration { } func (m *Mock) WithDeadline(parent context.Context, d time.Time, tags ...string) (context.Context, context.CancelFunc) { - m.mu.Lock() - defer m.mu.Unlock() c := newCall(clockFunctionWithDeadline, tags, withTime(d)) - m.matchCallLocked(c) - defer close(c.complete) - return m.withDeadlineCauseLocked(parent, d, nil) + return m.newMockCtx(parent, c, nil, func() time.Time { return d }) } func (m *Mock) WithDeadlineCause(parent context.Context, d time.Time, cause error, tags ...string) (context.Context, context.CancelFunc) { - m.mu.Lock() - defer m.mu.Unlock() c := newCall(clockFunctionWithDeadlineCause, tags, withTime(d), withCause(cause)) - m.matchCallLocked(c) - defer close(c.complete) - return m.withDeadlineCauseLocked(parent, d, cause) + return m.newMockCtx(parent, c, cause, func() time.Time { return d }) } func (m *Mock) WithTimeout(parent context.Context, d time.Duration, tags ...string) (context.Context, context.CancelFunc) { - m.mu.Lock() - defer m.mu.Unlock() c := newCall(clockFunctionWithTimeout, tags, withDuration(d)) - m.matchCallLocked(c) - defer close(c.complete) - deadline := m.cur.Add(d) - return m.withDeadlineCauseLocked(parent, deadline, nil) + return m.newMockCtx(parent, c, nil, func() time.Time { return m.cur.Add(d) }) } func (m *Mock) WithTimeoutCause(parent context.Context, d time.Duration, cause error, tags ...string) (context.Context, context.CancelFunc) { - m.mu.Lock() - defer m.mu.Unlock() c := newCall(clockFunctionWithTimeoutCause, tags, withDuration(d), withCause(cause)) - m.matchCallLocked(c) - defer close(c.complete) - deadline := m.cur.Add(d) - return m.withDeadlineCauseLocked(parent, deadline, cause) + return m.newMockCtx(parent, c, cause, func() time.Time { return m.cur.Add(d) }) } -func (m *Mock) withDeadlineCauseLocked(parent context.Context, d time.Time, cause error) (context.Context, context.CancelFunc) { +// newMockCtx creates the context for call. deadlineLocked is evaluated with m.mu held after any +// traps release the call. +func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, deadlineLocked func() time.Time) (context.Context, context.CancelFunc) { + // Created before taking m.mu: WithCancelCause calls parent.Err(), which takes m.mu when + // parent is a context from this Mock. inner, innerCancel := context.WithCancelCause(parent) + + m.mu.Lock() + defer m.mu.Unlock() + m.matchCallLocked(call) + defer close(call.complete) + d := deadlineLocked() c := &mockCtx{ inner: inner, innerCancel: innerCancel, @@ -653,12 +645,14 @@ func (m *mockTickerFunc) exitLocked(err error) { func (m *mockTickerFunc) waitForCtx() { <-m.ctx.Done() + // Read before taking mock.mu: Err() takes mock.mu when ctx is a context from this Mock. + err := m.ctx.Err() m.mock.mu.Lock() defer m.mock.mu.Unlock() for m.inProgress { m.cond.Wait() } - m.exitLocked(m.ctx.Err()) + m.exitLocked(err) } func (m *mockTickerFunc) Wait(tags ...string) error { diff --git a/mock_test.go b/mock_test.go index a7fc494..07935e6 100644 --- a/mock_test.go +++ b/mock_test.go @@ -645,6 +645,58 @@ func TestWithTimeout_Trap(t *testing.T) { } } +func TestWithTimeout_CanceledMockParent(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + parent, parentCancel := mClock.WithTimeout(context.Background(), time.Hour) + parentCancel() + + got := make(chan context.Context, 1) + go func() { + ctx, cancel := mClock.WithTimeout(parent, time.Hour) + t.Cleanup(cancel) + got <- ctx + }() + select { + case ctx := <-got: + select { + case <-ctx.Done(): + case <-testCtx.Done(): + t.Fatal("timeout waiting for context with canceled mock parent") + } + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("expected Canceled, got %v", ctx.Err()) + } + case <-testCtx.Done(): + t.Fatal("WithTimeout with a canceled mock parent did not return") + } +} + +func TestTickerFunc_MockContext(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour) + w := mClock.TickerFunc(ctx, time.Second, func() error { return nil }) + cancel() + + errCh := make(chan error, 1) + go func() { errCh <- w.Wait() }() + select { + case err := <-errCh: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected Canceled, got %v", err) + } + case <-testCtx.Done(): + t.Fatal("TickerFunc did not exit after its mock context was canceled") + } +} + func Test_MultipleTraps(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) From ebc18a0fefb84fea8a71ba9b37a6f1b30596a8b0 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 11:02:52 +0000 Subject: [PATCH 04/43] fix(mock.go): cancel mock contexts synchronously Stdlib contexts are done as soon as they are created with an expired deadline or a canceled parent, and as soon as the parent's cancel returns. Mock contexts did this in a goroutine, so Err() could still be nil, Advance failed the test on the stale event of a context whose parent was canceled, and a mock deadline firing before that goroutine ran reported DeadlineExceeded with a Canceled cause. inner no longer inherits parent cancellation, so cancel() alone picks the error and cause, preferring the parent's when it is done. Err(), Done(), and the Advance family check the parent instead of waiting for context.AfterFunc. --- mock.go | 125 ++++++++++++++++++++++++++++++++++----------------- mock_test.go | 60 ++++++++++++++++++++----- 2 files changed, 133 insertions(+), 52 deletions(-) diff --git a/mock.go b/mock.go index f5d4ba4..5175a26 100644 --- a/mock.go +++ b/mock.go @@ -181,44 +181,60 @@ func (m *Mock) WithTimeoutCause(parent context.Context, d time.Duration, cause e // newMockCtx creates the context for call. deadlineLocked is evaluated with m.mu held after any // traps release the call. func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, deadlineLocked func() time.Time) (context.Context, context.CancelFunc) { - // Created before taking m.mu: WithCancelCause calls parent.Err(), which takes m.mu when - // parent is a context from this Mock. - inner, innerCancel := context.WithCancelCause(parent) - - m.mu.Lock() - defer m.mu.Unlock() - m.matchCallLocked(call) - defer close(call.complete) - d := deadlineLocked() + // inner holds values and the cancellation cause. It does not inherit parent's cancellation, + // so only cancel() decides which error and cause the context reports. + inner, innerCancel := context.WithCancelCause(context.WithoutCancel(parent)) c := &mockCtx{ + parent: parent, inner: inner, innerCancel: innerCancel, - deadline: d, cause: cause, mock: m, done: make(chan struct{}), } - if !d.After(m.cur) { - // Past deadline: fire immediately, as NewTimer() does with d <= 0. - go c.fire(m.cur) - } else { + + m.mu.Lock() + m.matchCallLocked(call) + c.deadline = deadlineLocked() + pastDeadline := !c.deadline.After(m.cur) + if !pastDeadline { m.addEventLocked(c) } - context.AfterFunc(inner, func() { - // inner.Err() is non-nil per AfterFunc() contract. - c.mock.mu.Lock() - defer c.mock.mu.Unlock() - c.cancelLocked(inner.Err(), nil) - }) - m.tb.Cleanup(func() { - c.mock.mu.Lock() - defer c.mock.mu.Unlock() - c.cancelLocked(context.Canceled, ErrMockClockTestEnded) - }) - return c, func() { - c.mock.mu.Lock() - defer c.mock.mu.Unlock() - c.cancelLocked(context.Canceled, nil) + m.tb.Cleanup(func() { c.cancel(context.Canceled, ErrMockClockTestEnded) }) + close(call.complete) + m.mu.Unlock() + + // Outside m.mu: cancel() calls parent.Err(), which takes m.mu when parent is a context from + // this Mock. + if pastDeadline { + c.cancel(context.DeadlineExceeded, cause) + } else { + c.checkParent() + } + stop := context.AfterFunc(parent, c.checkParent) + c.mock.mu.Lock() + c.stopParent = stop + done := c.err != nil + c.mock.mu.Unlock() + if done { + stop() + } + return c, func() { c.cancel(context.Canceled, nil) } +} + +// checkDoneContexts cancels contexts whose parents are done, so their pending events do not +// block Advance or show up in Peek. +func (m *Mock) checkDoneContexts() { + m.mu.Lock() + 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() } } @@ -338,6 +354,7 @@ func (w AdvanceWaiter) Done() <-chan struct{} { func (m *Mock) Advance(d time.Duration) AdvanceWaiter { m.tb.Helper() w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} + m.checkDoneContexts() m.mu.Lock() if !m.testOver { m.logger.Logf("Mock Clock - Advance(%s)", d) @@ -388,6 +405,7 @@ func (m *Mock) advanceLocked(w AdvanceWaiter) { func (m *Mock) Set(t time.Time) AdvanceWaiter { m.tb.Helper() w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} + m.checkDoneContexts() m.mu.Lock() if !m.testOver { m.logger.Logf("Mock Clock - Set(%s)", t) @@ -427,6 +445,7 @@ 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.checkDoneContexts() m.mu.Lock() if !m.testOver { m.logger.Logf("Mock Clock - AdvanceNext()") @@ -449,6 +468,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.checkDoneContexts() m.mu.Lock() defer m.mu.Unlock() if m.nextTime.IsZero() { @@ -670,7 +690,8 @@ func (m *mockTickerFunc) Wait(tags ...string) error { var _ Waiter = &mockTickerFunc{} type mockCtx struct { - mock *Mock + mock *Mock + parent context.Context // inner stores the cancellation cause so context.Cause() can find it via // Value(&cancelCtxKey). Done() must NOT return inner.Done(): stdlib's @@ -684,16 +705,20 @@ type mockCtx struct { // Cause recorded when the deadline fires. cause error - // err and done are written together under mock.mu. - err error - done chan struct{} + // err, done, and stopParent are guarded by mock.mu. err and done are written together. + err error + done chan struct{} + stopParent func() bool } func (c *mockCtx) Deadline() (time.Time, bool) { return c.deadline, true } +// Done and Err check the parent first because context.AfterFunc reports parent cancellation +// asynchronously, and stdlib contexts are done as soon as their parent is. func (c *mockCtx) Done() <-chan struct{} { + c.checkParent() return c.done } @@ -702,6 +727,7 @@ func (c *mockCtx) Value(key any) any { } func (c *mockCtx) Err() error { + c.checkParent() c.mock.mu.Lock() defer c.mock.mu.Unlock() return c.err @@ -712,25 +738,42 @@ func (c *mockCtx) next() time.Time { } func (c *mockCtx) fire(_ time.Time) { - c.mock.mu.Lock() - defer c.mock.mu.Unlock() - c.cancelLocked(context.DeadlineExceeded, c.cause) + c.cancel(context.DeadlineExceeded, c.cause) +} + +// checkParent cancels c if its parent is done. +func (c *mockCtx) checkParent() { + if c.parent.Err() != nil { + c.cancel(context.Canceled, nil) + } } -// cancelLocked matches stdlib cancelCtx.cancel(): a nil cause defaults to err. +// cancel matches stdlib cancelCtx.cancel(): a nil cause defaults to err. If the parent is +// already done, its error and cause win, as they do when stdlib propagates cancellation. // -// Must be called with mock.mu held. -func (c *mockCtx) cancelLocked(err, cause error) { - if c.err != nil { - return +// Must be called without mock.mu held, because parent.Err() takes mock.mu when parent is a +// context from this Mock. +func (c *mockCtx) cancel(err, cause error) { + if perr := c.parent.Err(); perr != nil { + err, cause = perr, context.Cause(c.parent) } if cause == nil { cause = err } + c.mock.mu.Lock() + if c.err != nil { + c.mock.mu.Unlock() + return + } c.err = err c.mock.removeEventLocked(c) c.innerCancel(cause) close(c.done) + stop := c.stopParent + c.mock.mu.Unlock() + if stop != nil { + stop() + } } type clockFunction int diff --git a/mock_test.go b/mock_test.go index 07935e6..5a693cc 100644 --- a/mock_test.go +++ b/mock_test.go @@ -410,21 +410,66 @@ func TestWithTimeout_ParentCanceled(t *testing.T) { ctx, cancel := mClock.WithTimeout(parent, time.Hour) defer cancel() + // As with stdlib contexts, the child is done as soon as parentCancel returns. parentCancel() + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("expected Canceled, got %v", ctx.Err()) + } select { case <-ctx.Done(): - // OK - case <-testCtx.Done(): - t.Fatal("timeout waiting for parent cancellation") + default: + t.Fatal("expected Done to be closed") } + if d, ok := mClock.Peek(); ok { + t.Fatalf("expected no pending events, got Peek()=%s", d) + } + // The canceled context must not block advancing past its old deadline. + mClock.Advance(2 * time.Hour).MustWait(testCtx) +} + +func TestWithTimeout_ParentAlreadyCanceled(t *testing.T) { + t.Parallel() + + mClock := quartz.NewMock(t) + cause := errors.New("parent cause") + parent, parentCancel := context.WithCancelCause(context.Background()) + parentCancel(cause) + + ctx, cancel := mClock.WithTimeout(parent, time.Hour) + defer cancel() if !errors.Is(ctx.Err(), context.Canceled) { t.Fatalf("expected Canceled, got %v", ctx.Err()) } + if got := context.Cause(ctx); !errors.Is(got, cause) { + t.Fatalf("expected parent cause, got %v", got) + } if d, ok := mClock.Peek(); ok { t.Fatalf("expected no pending events, got Peek()=%s", d) } } +// The parent was canceled first, so its error and cause win even though the mock deadline is +// reached before the context observes the parent asynchronously. +func TestWithTimeout_ParentCanceledBeforeDeadline(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + parent, parentCancel := context.WithCancel(context.Background()) + ctx, cancel := mClock.WithTimeout(parent, time.Second) + defer cancel() + + parentCancel() + mClock.Advance(time.Second).MustWait(testCtx) + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("expected Canceled, got %v", ctx.Err()) + } + if got := context.Cause(ctx); !errors.Is(got, context.Canceled) { + t.Fatalf("expected Canceled cause, got %v", got) + } +} + func TestWithTimeout_ParentDeadlineExceeded(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -484,8 +529,6 @@ func TestWithTimeoutCause_Cause(t *testing.T) { func TestWithTimeout_PastDeadline(t *testing.T) { t.Parallel() - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() for _, d := range []time.Duration{0, -time.Second} { t.Run(d.String(), func(t *testing.T) { @@ -493,12 +536,7 @@ func TestWithTimeout_PastDeadline(t *testing.T) { ctx, cancel := mClock.WithTimeout(context.Background(), d) defer cancel() - select { - case <-ctx.Done(): - // OK - case <-testCtx.Done(): - t.Fatal("timeout waiting for past deadline") - } + // As with stdlib contexts, the context is done when WithTimeout returns. if !errors.Is(ctx.Err(), context.DeadlineExceeded) { t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) } From f27c0f68875bfd5b4c12543df125c991030b5acb Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 11:04:35 +0000 Subject: [PATCH 05/43] fix(mock.go): cancel derived contexts before returning Stdlib contexts derived from a mock context each started a goroutine waiting on Done, so they were often still live after Advance().MustWait() or the cancel function returned. mockCtx now implements the AfterFunc method that the context package uses for custom parents, and runs the registered functions before fire or cancel returns. Nested mock contexts register the same way. --- mock.go | 48 ++++++++++++++++++++++++++++++++++++++++++++++-- mock_test.go | 31 +++++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/mock.go b/mock.go index 5175a26..ff841f6 100644 --- a/mock.go +++ b/mock.go @@ -211,7 +211,13 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de } else { c.checkParent() } - stop := context.AfterFunc(parent, c.checkParent) + var stop func() bool + if p, ok := parent.(*mockCtx); ok { + // Run synchronously when the parent is canceled, as stdlib children do. + stop = p.AfterFunc(c.checkParent) + } else { + stop = context.AfterFunc(parent, c.checkParent) + } c.mock.mu.Lock() c.stopParent = stop done := c.err != nil @@ -705,12 +711,44 @@ type mockCtx struct { // Cause recorded when the deadline fires. cause error - // err, done, and stopParent are guarded by mock.mu. err and done are written together. + // err, done, afterFuncs, and stopParent are guarded by mock.mu. err and done are written + // together. err error done chan struct{} + afterFuncs []*afterFunc stopParent func() bool } +type afterFunc struct{ f func() } + +// AfterFunc lets stdlib contexts derived from c register for cancellation instead of each +// starting a goroutine that waits on Done (see context.afterFuncer). Registered functions run +// before fire or the cancel function returns, so derived contexts are done by the time +// Advance().MustWait() or cancel() returns. +func (c *mockCtx) AfterFunc(f func()) (stop func() bool) { + c.checkParent() + c.mock.mu.Lock() + defer c.mock.mu.Unlock() + if c.err != nil { + // The caller may hold locks that f takes, so f must not run on this goroutine. + go f() + return func() bool { return false } + } + a := &afterFunc{f: f} + c.afterFuncs = append(c.afterFuncs, a) + return func() bool { + c.mock.mu.Lock() + defer c.mock.mu.Unlock() + for i, x := range c.afterFuncs { + if x == a { + c.afterFuncs = append(c.afterFuncs[:i], c.afterFuncs[i+1:]...) + return true + } + } + return false + } +} + func (c *mockCtx) Deadline() (time.Time, bool) { return c.deadline, true } @@ -770,10 +808,16 @@ func (c *mockCtx) cancel(err, cause error) { c.innerCancel(cause) close(c.done) stop := c.stopParent + afterFuncs := c.afterFuncs + c.afterFuncs = nil c.mock.mu.Unlock() if stop != nil { stop() } + // Without mock.mu: derived contexts call c.Err() while canceling. + for _, a := range afterFuncs { + a.f() + } } type clockFunction int diff --git a/mock_test.go b/mock_test.go index 5a693cc..b1f341c 100644 --- a/mock_test.go +++ b/mock_test.go @@ -347,17 +347,36 @@ func TestWithTimeout_FiresOnAdvance(t *testing.T) { if !errors.Is(ctx.Err(), context.DeadlineExceeded) { t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) } - select { - case <-child.Done(): - // OK - case <-testCtx.Done(): - t.Fatal("timeout waiting for child context") - } + // Derived contexts are done by the time MustWait returns. if !errors.Is(child.Err(), context.DeadlineExceeded) { t.Fatalf("expected child DeadlineExceeded, got %v", child.Err()) } } +func TestWithTimeout_CancelCancelsDerived(t *testing.T) { + t.Parallel() + + mClock := quartz.NewMock(t) + ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour) + child, childCancel := context.WithCancel(ctx) + defer childCancel() + nested, nestedCancel := mClock.WithTimeout(ctx, time.Hour) + defer nestedCancel() + + cancel() + if !errors.Is(child.Err(), context.Canceled) { + t.Fatalf("expected stdlib child Canceled, got %v", child.Err()) + } + select { + case <-nested.Done(): + default: + t.Fatal("expected nested mock context Done to be closed") + } + if d, ok := mClock.Peek(); ok { + t.Fatalf("expected no pending events, got Peek()=%s", d) + } +} + func TestWithDeadline_Deadline(t *testing.T) { t.Parallel() From 673f173ec90c50ceb2e2ede47caf2105e721bceb Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 11:06:42 +0000 Subject: [PATCH 06/43] fix: report wall-clock deadlines from mock contexts Deadline() returned mock time, which starts in 2024. Every reader we found in coder/coder, its dependencies, and the standard library compares the deadline with time.Now() or passes it to a wall-clock API, so mock contexts failed net.Dialer dials and gRPC calls immediately, disabled http.Client.Timeout and x/time/rate waits, and made stdlib timeouts derived from them never fire. Deadline() now reports the parent's deadline, or year 9999 when the parent has none, so code that requires a deadline still finds one. Done() and Err() still follow mock time. Code that computes remaining mock time from ctx.Deadline() no longer works; we found no such code. --- README.md | 14 ++++++++------ clock.go | 8 +++++--- mock.go | 34 ++++++++++++++++++++++++++-------- mock_test.go | 48 +++++++++++++++++++++++++++++++++++++----------- 4 files changed, 76 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 1f2995f..3d48c9d 100644 --- a/README.md +++ b/README.md @@ -336,14 +336,16 @@ parent deadlines to mock time. 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`. -#### Caveat: `Deadline()` returns mock time +#### `Deadline()` reports wall time -`ctx.Deadline()` on a mock-clock context returns mock time. Code that compares it against -`time.Now()` treats it as wall time and will misbehave. The `net/http` client does this in -`timeBeforeContextDeadline` to decide whether to apply per-request timeouts. +With a `*Mock`, `ctx.Done()` and `ctx.Err()` follow mock time, but `ctx.Deadline()` reports the +parent's deadline, or a time in year 9999 if the parent has none. Code that reads `Deadline()` +compares it with `time.Now()` or passes it to wall-clock APIs such as `net.Dialer`, +`net.Conn.SetDeadline`, `http.Client`, and gRPC. Those would treat a mock deadline as already +expired. -Keep the mock-clock context inside the code under test. Pass a real-clock context across -package boundaries where Quartz's mocking is not intended. +Code that needs the remaining mock time must compute it from the deadline or timeout it passed +in, not from `ctx.Deadline()`. ## Recommended Patterns diff --git a/clock.go b/clock.go index 694f9d8..6082b01 100644 --- a/clock.go +++ b/clock.go @@ -43,9 +43,11 @@ type Clock interface { // child independently of this Clock. The returned cancel function is idempotent and must be // called. // - // With a Mock clock, ctx.Deadline() returns mock time. Code that compares it against - // time.Now() treats it as wall time and will misbehave. The net/http client does this in - // timeBeforeContextDeadline to decide whether to apply per-request timeouts. + // With a Mock clock, Done and Err follow mock time, but ctx.Deadline() reports the parent's + // deadline, or a time in year 9999 if the parent has none. Code outside the test compares + // Deadline() with wall time, for example in net.Dialer, http.Client, and gRPC, and would + // treat a mock deadline as already expired. Code that needs the remaining mock time must + // compute it from the deadline or timeout it passed in, not from ctx.Deadline(). WithDeadline(parent context.Context, d time.Time, tags ...string) (context.Context, context.CancelFunc) // WithDeadlineCause returns a copy of parent with a deadline governed by this Clock and diff --git a/mock.go b/mock.go index ff841f6..e80bb49 100644 --- a/mock.go +++ b/mock.go @@ -184,13 +184,18 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de // inner holds values and the cancellation cause. It does not inherit parent's cancellation, // so only cancel() decides which error and cause the context reports. inner, innerCancel := context.WithCancelCause(context.WithoutCancel(parent)) + wallDeadline, ok := parent.Deadline() + if !ok { + wallDeadline = noWallDeadline + } c := &mockCtx{ - parent: parent, - inner: inner, - innerCancel: innerCancel, - cause: cause, - mock: m, - done: make(chan struct{}), + parent: parent, + inner: inner, + innerCancel: innerCancel, + wallDeadline: wallDeadline, + cause: cause, + mock: m, + done: make(chan struct{}), } m.mu.Lock() @@ -706,7 +711,10 @@ type mockCtx struct { // fire instead of ours. inner context.Context innerCancel context.CancelCauseFunc - deadline time.Time + // deadline is in mock time and schedules the event. + deadline time.Time + // wallDeadline is what Deadline() reports; see noWallDeadline. + wallDeadline time.Time // Cause recorded when the deadline fires. cause error @@ -721,6 +729,16 @@ type mockCtx struct { type afterFunc struct{ f func() } +// noWallDeadline is what Deadline() reports for a mock context whose parent has no deadline. +// +// Code that reads ctx.Deadline() compares it with time.Now() or hands it to wall-clock APIs +// such as net.Dialer, net.Conn.SetDeadline, http.Client, gRPC, and x/time/rate. Mock time is +// unrelated to wall time, so reporting the mock deadline makes those consumers see an expired +// deadline, and converting it to wall time would make a mock timeout expire real I/O. Reporting +// the parent's deadline, or a deadline that never arrives, keeps ok == true for code that +// requires a deadline while leaving expiry to Done() and Err(), which follow mock time. +var noWallDeadline = time.Date(9999, time.December, 31, 0, 0, 0, 0, time.UTC) + // AfterFunc lets stdlib contexts derived from c register for cancellation instead of each // starting a goroutine that waits on Done (see context.afterFuncer). Registered functions run // before fire or the cancel function returns, so derived contexts are done by the time @@ -750,7 +768,7 @@ func (c *mockCtx) AfterFunc(f func()) (stop func() bool) { } func (c *mockCtx) Deadline() (time.Time, bool) { - return c.deadline, true + return c.wallDeadline, true } // Done and Err check the parent first because context.AfterFunc reports parent cancellation diff --git a/mock_test.go b/mock_test.go index b1f341c..dcf4636 100644 --- a/mock_test.go +++ b/mock_test.go @@ -380,18 +380,44 @@ func TestWithTimeout_CancelCancelsDerived(t *testing.T) { func TestWithDeadline_Deadline(t *testing.T) { t.Parallel() - mClock := quartz.NewMock(t) - deadline := mClock.Now().Add(time.Hour) - ctx, cancel := mClock.WithDeadline(context.Background(), deadline) - defer cancel() + t.Run("NoParentDeadline", func(t *testing.T) { + t.Parallel() + mClock := quartz.NewMock(t) + ctx, cancel := mClock.WithDeadline(context.Background(), mClock.Now().Add(time.Hour)) + defer cancel() - got, ok := ctx.Deadline() - if !ok { - t.Fatal("expected deadline") - } - if !got.Equal(deadline) { - t.Fatalf("expected deadline %s, got %s", deadline, got) - } + // Code that requires a deadline still finds one, and it is in the wall-clock future. + got, ok := ctx.Deadline() + if !ok { + t.Fatal("expected deadline") + } + if got.Year() != 9999 { + t.Fatalf("expected deadline in year 9999, got %s", got) + } + + // A stdlib timeout derived from the mock context keeps its own wall-clock deadline. + child, childCancel := context.WithTimeout(ctx, time.Minute) + defer childCancel() + childDeadline, _ := child.Deadline() + if !childDeadline.After(time.Now()) { + t.Fatalf("expected child deadline in the future, got %s", childDeadline) + } + }) + + t.Run("ParentDeadline", func(t *testing.T) { + t.Parallel() + mClock := quartz.NewMock(t) + parent, parentCancel := context.WithTimeout(context.Background(), time.Hour) + defer parentCancel() + want, _ := parent.Deadline() + ctx, cancel := mClock.WithDeadline(parent, mClock.Now().Add(time.Minute)) + defer cancel() + + got, ok := ctx.Deadline() + if !ok || !got.Equal(want) { + t.Fatalf("expected parent deadline %s, got %s, %v", want, got, ok) + } + }) } func TestWithTimeout_CancelIsIdempotentAndRemovesEvent(t *testing.T) { From 8b626074ac30a6a2ab22dd10a97cd6aecd291ce9 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 11:17:55 +0000 Subject: [PATCH 07/43] fix(mock.go): report parent cancellation through Value Before Go 1.26, context.Cause reads the cause through Value without calling Err first, so Cause returned nil right after the parent was canceled until something called Err or Done. The parent-cancellation tests now observe through one path each, since every path also cancels the mock context and hid gaps in the others. The AfterFunc comment now says which derived contexts it covers. --- mock.go | 18 ++++++++------ mock_test.go | 68 ++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 58 insertions(+), 28 deletions(-) diff --git a/mock.go b/mock.go index e80bb49..68d0c87 100644 --- a/mock.go +++ b/mock.go @@ -739,12 +739,13 @@ type afterFunc struct{ f func() } // requires a deadline while leaving expiry to Done() and Err(), which follow mock time. var noWallDeadline = time.Date(9999, time.December, 31, 0, 0, 0, 0, time.UTC) -// AfterFunc lets stdlib contexts derived from c register for cancellation instead of each -// starting a goroutine that waits on Done (see context.afterFuncer). Registered functions run -// before fire or the cancel function returns, so derived contexts are done by the time -// Advance().MustWait() or cancel() returns. +// AfterFunc lets stdlib contexts created directly from c register for cancellation instead of +// each starting a goroutine that waits on Done (see context.afterFuncer). Registered functions +// run before fire or the cancel function returns, so those contexts are done by the time +// Advance().MustWait() or cancel() returns. Contexts derived through another layer, such as +// context.WithValue, still use a goroutine because the context package does not forward +// AfterFunc. func (c *mockCtx) AfterFunc(f func()) (stop func() bool) { - c.checkParent() c.mock.mu.Lock() defer c.mock.mu.Unlock() if c.err != nil { @@ -771,14 +772,17 @@ func (c *mockCtx) Deadline() (time.Time, bool) { return c.wallDeadline, true } -// Done and Err check the parent first because context.AfterFunc reports parent cancellation -// asynchronously, and stdlib contexts are done as soon as their parent is. +// Done, Err, and Value check the parent first because context.AfterFunc reports parent +// cancellation asynchronously, and stdlib contexts are done as soon as their parent is. Value +// is included because context.Cause reads the cause through it. A Done channel obtained before +// the parent was canceled closes when the context.AfterFunc goroutine runs. func (c *mockCtx) Done() <-chan struct{} { c.checkParent() return c.done } func (c *mockCtx) Value(key any) any { + c.checkParent() return c.inner.Value(key) } diff --git a/mock_test.go b/mock_test.go index dcf4636..5f627a4 100644 --- a/mock_test.go +++ b/mock_test.go @@ -362,13 +362,16 @@ func TestWithTimeout_CancelCancelsDerived(t *testing.T) { defer childCancel() nested, nestedCancel := mClock.WithTimeout(ctx, time.Hour) defer nestedCancel() + // Obtained before cancel so the check below does not trigger the nested context's own + // parent check. + nestedDone := nested.Done() cancel() if !errors.Is(child.Err(), context.Canceled) { t.Fatalf("expected stdlib child Canceled, got %v", child.Err()) } select { - case <-nested.Done(): + case <-nestedDone: default: t.Fatal("expected nested mock context Done to be closed") } @@ -447,29 +450,52 @@ func TestWithTimeout_CancelIsIdempotentAndRemovesEvent(t *testing.T) { func TestWithTimeout_ParentCanceled(t *testing.T) { t.Parallel() - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() - mClock := quartz.NewMock(t) - parent, parentCancel := context.WithCancel(context.Background()) - ctx, cancel := mClock.WithTimeout(parent, time.Hour) - defer cancel() + // Each subtest observes the parent's cancellation through one path only, because every + // path also cancels the mock context and would hide a gap in the others. + observe := map[string]func(t *testing.T, mClock *quartz.Mock, ctx context.Context){ + "Err": func(t *testing.T, _ *quartz.Mock, ctx context.Context) { + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("expected Canceled, got %v", ctx.Err()) + } + }, + "Done": func(t *testing.T, _ *quartz.Mock, ctx context.Context) { + select { + case <-ctx.Done(): + default: + t.Fatal("expected Done to be closed") + } + }, + "Cause": func(t *testing.T, _ *quartz.Mock, ctx context.Context) { + if got := context.Cause(ctx); !errors.Is(got, context.Canceled) { + t.Fatalf("expected Canceled cause, got %v", got) + } + }, + "Peek": func(t *testing.T, mClock *quartz.Mock, _ context.Context) { + if d, ok := mClock.Peek(); ok { + t.Fatalf("expected no pending events, got Peek()=%s", d) + } + }, + "Advance": func(t *testing.T, mClock *quartz.Mock, _ context.Context) { + // The canceled context must not block advancing past its old deadline. + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + mClock.Advance(2 * time.Hour).MustWait(testCtx) + }, + } + for name, fn := range observe { + t.Run(name, func(t *testing.T) { + t.Parallel() + mClock := quartz.NewMock(t) + parent, parentCancel := context.WithCancel(context.Background()) + ctx, cancel := mClock.WithTimeout(parent, time.Hour) + defer cancel() - // As with stdlib contexts, the child is done as soon as parentCancel returns. - parentCancel() - if !errors.Is(ctx.Err(), context.Canceled) { - t.Fatalf("expected Canceled, got %v", ctx.Err()) - } - select { - case <-ctx.Done(): - default: - t.Fatal("expected Done to be closed") - } - if d, ok := mClock.Peek(); ok { - t.Fatalf("expected no pending events, got Peek()=%s", d) + // As with stdlib contexts, the cancellation is visible as soon as parentCancel returns. + parentCancel() + fn(t, mClock, ctx) + }) } - // The canceled context must not block advancing past its old deadline. - mClock.Advance(2 * time.Hour).MustWait(testCtx) } func TestWithTimeout_ParentAlreadyCanceled(t *testing.T) { From 2e1a2738ecb170ba815b5ac4b8540541e3612d91 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:10:44 +0000 Subject: [PATCH 08/43] docs: describe mock context expiry, not deadlines, as mock time These sentences predate reporting wall-clock deadlines from Deadline() and read as if ctx.Deadline() returned mock time. --- README.md | 7 ++++--- clock.go | 12 +++++------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 3d48c9d..612bb43 100644 --- a/README.md +++ b/README.md @@ -315,8 +315,8 @@ by the real clock. They also appear on all methods on returned timers and ticker ### Contexts `Clock` provides `WithDeadline`, `WithDeadlineCause`, `WithTimeout`, and `WithTimeoutCause` methods -that mirror the standard `context` package. With a `*Mock`, deadlines created through the clock -advance on mock time: +that mirror the standard `context` package. With a `*Mock`, contexts created through the clock +expire on mock time: ```go ctx, cancel := mClock.WithTimeout(context.Background(), 30*time.Second) @@ -328,7 +328,8 @@ if !errors.Is(ctx.Err(), context.DeadlineExceeded) { } ``` -Deadlines created through `mClock.WithDeadline` and `mClock.WithTimeout` use mock time. Parent +Contexts created through `mClock.WithDeadline` and `mClock.WithTimeout` expire when mock time +reaches the deadline. `ctx.Deadline()` does not report that time; see below. Parent contexts keep standard `context` behavior. A parent created with `context.WithDeadline` or `context.WithTimeout` can still cancel the child on wall time because Quartz does not convert parent deadlines to mock time. diff --git a/clock.go b/clock.go index 6082b01..c86fa38 100644 --- a/clock.go +++ b/clock.go @@ -36,8 +36,8 @@ type Clock interface { // Until returns the duration until t. It is shorthand for t.Sub(Clock.Now()). Until(t time.Time, tags ...string) time.Duration - // WithDeadline returns a copy of parent with a deadline governed by this Clock. When the - // deadline is reached, Err satisfies errors.Is(err, context.DeadlineExceeded). Parent + // WithDeadline returns a copy of parent that expires when this Clock reaches d. When it + // expires, Err satisfies errors.Is(err, context.DeadlineExceeded). Parent // cancellation propagates with stdlib semantics. Parent deadlines are not converted to mock // time, so a parent created with context.WithDeadline or context.WithTimeout can cancel the // child independently of this Clock. The returned cancel function is idempotent and must be @@ -50,16 +50,14 @@ type Clock interface { // compute it from the deadline or timeout it passed in, not from ctx.Deadline(). WithDeadline(parent context.Context, d time.Time, tags ...string) (context.Context, context.CancelFunc) - // WithDeadlineCause returns a copy of parent with a deadline governed by this Clock and - // records cause when this Clock reaches the deadline. + // WithDeadlineCause is like WithDeadline and records cause when this Clock reaches d. WithDeadlineCause(parent context.Context, d time.Time, cause error, tags ...string) (context.Context, context.CancelFunc) - // WithTimeout returns a copy of parent with a timeout governed by this Clock. It is shorthand + // WithTimeout returns a copy of parent that expires after d on this Clock. It is shorthand // for WithDeadline(parent, Clock.Now().Add(d)). WithTimeout(parent context.Context, d time.Duration, tags ...string) (context.Context, context.CancelFunc) - // WithTimeoutCause returns a copy of parent with a timeout governed by this Clock and records - // cause when this Clock reaches the timeout. + // WithTimeoutCause is like WithTimeout and records cause when this Clock reaches the timeout. WithTimeoutCause(parent context.Context, d time.Duration, cause error, tags ...string) (context.Context, context.CancelFunc) } From 03f624b7a78148dae80030067c78a61a875baa16 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:13:21 +0000 Subject: [PATCH 09/43] docs: reflow comments and README paragraphs to 100 columns --- README.md | 12 ++++++------ clock.go | 19 +++++++++---------- mock.go | 45 ++++++++++++++++++++++----------------------- mock_test.go | 8 ++++---- 4 files changed, 41 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 612bb43..fb91628 100644 --- a/README.md +++ b/README.md @@ -329,10 +329,10 @@ if !errors.Is(ctx.Err(), context.DeadlineExceeded) { ``` Contexts created through `mClock.WithDeadline` and `mClock.WithTimeout` expire when mock time -reaches the deadline. `ctx.Deadline()` does not report that time; see below. Parent -contexts keep standard `context` behavior. A parent created with `context.WithDeadline` or -`context.WithTimeout` can still cancel the child on wall time because Quartz does not convert -parent deadlines to mock time. +reaches the deadline. `ctx.Deadline()` does not report that time; see below. Parent contexts keep +standard `context` behavior. A parent created with `context.WithDeadline` or `context.WithTimeout` +can still cancel the child on wall time because Quartz does not convert parent deadlines to mock +time. 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`. @@ -345,8 +345,8 @@ compares it with `time.Now()` or passes it to wall-clock APIs such as `net.Diale `net.Conn.SetDeadline`, `http.Client`, and gRPC. Those would treat a mock deadline as already expired. -Code that needs the remaining mock time must compute it from the deadline or timeout it passed -in, not from `ctx.Deadline()`. +Code that needs the remaining mock time must compute it from the deadline or timeout it passed in, +not from `ctx.Deadline()`. ## Recommended Patterns diff --git a/clock.go b/clock.go index c86fa38..436845f 100644 --- a/clock.go +++ b/clock.go @@ -37,24 +37,23 @@ type Clock interface { Until(t time.Time, tags ...string) time.Duration // WithDeadline returns a copy of parent that expires when this Clock reaches d. When it - // expires, Err satisfies errors.Is(err, context.DeadlineExceeded). Parent - // cancellation propagates with stdlib semantics. Parent deadlines are not converted to mock - // time, so a parent created with context.WithDeadline or context.WithTimeout can cancel the - // child independently of this Clock. The returned cancel function is idempotent and must be - // called. + // expires, Err satisfies errors.Is(err, context.DeadlineExceeded). Parent cancellation + // propagates with stdlib semantics. Parent deadlines are not converted to mock time, so a + // parent created with context.WithDeadline or context.WithTimeout can cancel the child + // independently of this Clock. The returned cancel function is idempotent and must be called. // // With a Mock clock, Done and Err follow mock time, but ctx.Deadline() reports the parent's // deadline, or a time in year 9999 if the parent has none. Code outside the test compares - // Deadline() with wall time, for example in net.Dialer, http.Client, and gRPC, and would - // treat a mock deadline as already expired. Code that needs the remaining mock time must - // compute it from the deadline or timeout it passed in, not from ctx.Deadline(). + // Deadline() with wall time, for example in net.Dialer, http.Client, and gRPC, and would treat + // a mock deadline as already expired. Code that needs the remaining mock time must compute it + // from the deadline or timeout it passed in, not from ctx.Deadline(). WithDeadline(parent context.Context, d time.Time, tags ...string) (context.Context, context.CancelFunc) // WithDeadlineCause is like WithDeadline and records cause when this Clock reaches d. WithDeadlineCause(parent context.Context, d time.Time, cause error, tags ...string) (context.Context, context.CancelFunc) - // WithTimeout returns a copy of parent that expires after d on this Clock. It is shorthand - // for WithDeadline(parent, Clock.Now().Add(d)). + // WithTimeout returns a copy of parent that expires after d on this Clock. It is shorthand for + // WithDeadline(parent, Clock.Now().Add(d)). WithTimeout(parent context.Context, d time.Duration, tags ...string) (context.Context, context.CancelFunc) // WithTimeoutCause is like WithTimeout and records cause when this Clock reaches the timeout. diff --git a/mock.go b/mock.go index 68d0c87..bc552f1 100644 --- a/mock.go +++ b/mock.go @@ -181,8 +181,8 @@ func (m *Mock) WithTimeoutCause(parent context.Context, d time.Duration, cause e // newMockCtx creates the context for call. deadlineLocked is evaluated with m.mu held after any // traps release the call. func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, deadlineLocked func() time.Time) (context.Context, context.CancelFunc) { - // inner holds values and the cancellation cause. It does not inherit parent's cancellation, - // so only cancel() decides which error and cause the context reports. + // inner holds values and the cancellation cause. It does not inherit parent's cancellation, so + // only cancel() decides which error and cause the context reports. inner, innerCancel := context.WithCancelCause(context.WithoutCancel(parent)) wallDeadline, ok := parent.Deadline() if !ok { @@ -233,8 +233,8 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de return c, func() { c.cancel(context.Canceled, nil) } } -// checkDoneContexts cancels contexts whose parents are done, so their pending events do not -// block Advance or show up in Peek. +// checkDoneContexts cancels contexts whose parents are done, so their pending events do not block +// Advance or show up in Peek. func (m *Mock) checkDoneContexts() { m.mu.Lock() var ctxs []*mockCtx @@ -731,20 +731,19 @@ type afterFunc struct{ f func() } // noWallDeadline is what Deadline() reports for a mock context whose parent has no deadline. // -// Code that reads ctx.Deadline() compares it with time.Now() or hands it to wall-clock APIs -// such as net.Dialer, net.Conn.SetDeadline, http.Client, gRPC, and x/time/rate. Mock time is -// unrelated to wall time, so reporting the mock deadline makes those consumers see an expired -// deadline, and converting it to wall time would make a mock timeout expire real I/O. Reporting -// the parent's deadline, or a deadline that never arrives, keeps ok == true for code that -// requires a deadline while leaving expiry to Done() and Err(), which follow mock time. +// Code that reads ctx.Deadline() compares it with time.Now() or hands it to wall-clock APIs such as +// net.Dialer, net.Conn.SetDeadline, http.Client, gRPC, and x/time/rate. Mock time is unrelated to +// wall time, so reporting the mock deadline makes those consumers see an expired deadline, and +// converting it to wall time would make a mock timeout expire real I/O. Reporting the parent's +// deadline, or a deadline that never arrives, keeps ok == true for code that requires a deadline +// while leaving expiry to Done() and Err(), which follow mock time. var noWallDeadline = time.Date(9999, time.December, 31, 0, 0, 0, 0, time.UTC) -// AfterFunc lets stdlib contexts created directly from c register for cancellation instead of -// each starting a goroutine that waits on Done (see context.afterFuncer). Registered functions -// run before fire or the cancel function returns, so those contexts are done by the time +// AfterFunc lets stdlib contexts created directly from c register for cancellation instead of each +// starting a goroutine that waits on Done (see context.afterFuncer). Registered functions run +// before fire or the cancel function returns, so those contexts are done by the time // Advance().MustWait() or cancel() returns. Contexts derived through another layer, such as -// context.WithValue, still use a goroutine because the context package does not forward -// AfterFunc. +// context.WithValue, still use a goroutine because the context package does not forward AfterFunc. func (c *mockCtx) AfterFunc(f func()) (stop func() bool) { c.mock.mu.Lock() defer c.mock.mu.Unlock() @@ -772,10 +771,10 @@ func (c *mockCtx) Deadline() (time.Time, bool) { return c.wallDeadline, true } -// Done, Err, and Value check the parent first because context.AfterFunc reports parent -// cancellation asynchronously, and stdlib contexts are done as soon as their parent is. Value -// is included because context.Cause reads the cause through it. A Done channel obtained before -// the parent was canceled closes when the context.AfterFunc goroutine runs. +// Done, Err, and Value check the parent first because context.AfterFunc reports parent cancellation +// asynchronously, and stdlib contexts are done as soon as their parent is. Value is included +// because context.Cause reads the cause through it. A Done channel obtained before the parent was +// canceled closes when the context.AfterFunc goroutine runs. func (c *mockCtx) Done() <-chan struct{} { c.checkParent() return c.done @@ -808,11 +807,11 @@ func (c *mockCtx) checkParent() { } } -// cancel matches stdlib cancelCtx.cancel(): a nil cause defaults to err. If the parent is -// already done, its error and cause win, as they do when stdlib propagates cancellation. +// cancel matches stdlib cancelCtx.cancel(): a nil cause defaults to err. If the parent is already +// done, its error and cause win, as they do when stdlib propagates cancellation. // -// Must be called without mock.mu held, because parent.Err() takes mock.mu when parent is a -// context from this Mock. +// Must be called without mock.mu held, because parent.Err() takes mock.mu when parent is a context +// from this Mock. func (c *mockCtx) cancel(err, cause error) { if perr := c.parent.Err(); perr != nil { err, cause = perr, context.Cause(c.parent) diff --git a/mock_test.go b/mock_test.go index 5f627a4..f4949ef 100644 --- a/mock_test.go +++ b/mock_test.go @@ -362,8 +362,8 @@ func TestWithTimeout_CancelCancelsDerived(t *testing.T) { defer childCancel() nested, nestedCancel := mClock.WithTimeout(ctx, time.Hour) defer nestedCancel() - // Obtained before cancel so the check below does not trigger the nested context's own - // parent check. + // Obtained before cancel so the check below does not trigger the nested context's own parent + // check. nestedDone := nested.Done() cancel() @@ -451,8 +451,8 @@ func TestWithTimeout_CancelIsIdempotentAndRemovesEvent(t *testing.T) { func TestWithTimeout_ParentCanceled(t *testing.T) { t.Parallel() - // Each subtest observes the parent's cancellation through one path only, because every - // path also cancels the mock context and would hide a gap in the others. + // Each subtest observes the parent's cancellation through one path only, because every path + // also cancels the mock context and would hide a gap in the others. observe := map[string]func(t *testing.T, mClock *quartz.Mock, ctx context.Context){ "Err": func(t *testing.T, _ *quartz.Mock, ctx context.Context) { if !errors.Is(ctx.Err(), context.Canceled) { From 36e287a68bf8fc11d184c2921e07f507b369000d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 14:41:43 +0000 Subject: [PATCH 10/43] docs: say Deadline() does not report mock time The heading "Deadline() reports wall time" read as if the mock deadline were converted to wall time, which it is not. The README paragraph before it repeated the intro, and "Code outside the test" in the godoc was vague: net.Dialer and gRPC run inside the code under test. --- README.md | 21 ++++++++------------- clock.go | 8 ++++---- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index fb91628..017698c 100644 --- a/README.md +++ b/README.md @@ -328,25 +328,20 @@ if !errors.Is(ctx.Err(), context.DeadlineExceeded) { } ``` -Contexts created through `mClock.WithDeadline` and `mClock.WithTimeout` expire when mock time -reaches the deadline. `ctx.Deadline()` does not report that time; see below. Parent contexts keep -standard `context` behavior. A parent created with `context.WithDeadline` or `context.WithTimeout` -can still cancel the child on wall time because Quartz does not convert parent deadlines to mock -time. +Parent contexts keep standard `context` behavior. A parent created with `context.WithDeadline` or +`context.WithTimeout` can still cancel the child on wall time because Quartz does not convert parent +deadlines to mock time. 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`. -#### `Deadline()` reports wall time +#### `Deadline()` does not report mock time With a `*Mock`, `ctx.Done()` and `ctx.Err()` follow mock time, but `ctx.Deadline()` reports the -parent's deadline, or a time in year 9999 if the parent has none. Code that reads `Deadline()` -compares it with `time.Now()` or passes it to wall-clock APIs such as `net.Dialer`, -`net.Conn.SetDeadline`, `http.Client`, and gRPC. Those would treat a mock deadline as already -expired. - -Code that needs the remaining mock time must compute it from the deadline or timeout it passed in, -not from `ctx.Deadline()`. +parent's deadline, or a time in year 9999 if the parent has none. Libraries such as `net.Dialer`, +`http.Client`, and gRPC compare the deadline with `time.Now()` and would otherwise treat the context +as already expired. If your code needs the remaining mock time, compute it from the deadline or +timeout you passed in. ## Recommended Patterns diff --git a/clock.go b/clock.go index 436845f..43254e6 100644 --- a/clock.go +++ b/clock.go @@ -43,10 +43,10 @@ type Clock interface { // independently of this Clock. The returned cancel function is idempotent and must be called. // // With a Mock clock, Done and Err follow mock time, but ctx.Deadline() reports the parent's - // deadline, or a time in year 9999 if the parent has none. Code outside the test compares - // Deadline() with wall time, for example in net.Dialer, http.Client, and gRPC, and would treat - // a mock deadline as already expired. Code that needs the remaining mock time must compute it - // from the deadline or timeout it passed in, not from ctx.Deadline(). + // deadline, or a time in year 9999 if the parent has none. Libraries such as net.Dialer, + // http.Client, and gRPC compare Deadline() with wall time and would treat a mock deadline as + // already expired. Code that needs the remaining mock time must compute it from the deadline or + // timeout it passed in, not from ctx.Deadline(). WithDeadline(parent context.Context, d time.Time, tags ...string) (context.Context, context.CancelFunc) // WithDeadlineCause is like WithDeadline and records cause when this Clock reaches d. From 047d896d8f6641e33f636d5a81739c212699c9b1 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 14:42:41 +0000 Subject: [PATCH 11/43] docs: give the actual reasons in mockCtx comments Two comments said cancel() must run without Mock.mu because parent.Err() takes it, but cancel() takes Mock.mu itself whatever the parent is. The Done/Err/Value comment blamed context.AfterFunc alone, but a mock parent canceled before the child registered also notifies from a goroutine. The AfterFunc comment listed only fire and the cancel function as cancelers, leaving out parent cancellation and test cleanup. Also reflow the two comments still wrapped at about 80 columns. --- mock.go | 29 ++++++++++++++--------------- mock_test.go | 4 ++-- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/mock.go b/mock.go index bc552f1..7a8d329 100644 --- a/mock.go +++ b/mock.go @@ -209,8 +209,6 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de close(call.complete) m.mu.Unlock() - // Outside m.mu: cancel() calls parent.Err(), which takes m.mu when parent is a context from - // this Mock. if pastDeadline { c.cancel(context.DeadlineExceeded, cause) } else { @@ -704,10 +702,9 @@ type mockCtx struct { mock *Mock parent context.Context - // inner stores the cancellation cause so context.Cause() can find it via - // Value(&cancelCtxKey). Done() must NOT return inner.Done(): stdlib's - // parentCancelCtx fast-path would then attach descendants directly to - // inner, and they would read inner's context.Canceled Err() on deadline + // inner stores the cancellation cause so context.Cause() can find it via Value(&cancelCtxKey). + // Done() must NOT return inner.Done(): stdlib's parentCancelCtx fast-path would then attach + // descendants directly to inner, and they would read inner's context.Canceled Err() on deadline // fire instead of ours. inner context.Context innerCancel context.CancelCauseFunc @@ -727,6 +724,7 @@ type mockCtx struct { stopParent func() bool } +// afterFunc gives each registration a pointer identity for stop to find; funcs are not comparable. type afterFunc struct{ f func() } // noWallDeadline is what Deadline() reports for a mock context whose parent has no deadline. @@ -741,9 +739,9 @@ var noWallDeadline = time.Date(9999, time.December, 31, 0, 0, 0, 0, time.UTC) // AfterFunc lets stdlib contexts created directly from c register for cancellation instead of each // starting a goroutine that waits on Done (see context.afterFuncer). Registered functions run -// before fire or the cancel function returns, so those contexts are done by the time -// Advance().MustWait() or cancel() returns. Contexts derived through another layer, such as -// context.WithValue, still use a goroutine because the context package does not forward AfterFunc. +// before the call that cancels c returns, so those contexts are done by the time cancel() or +// Advance().MustWait() returns. Contexts derived through another layer, such as context.WithValue, +// still use a goroutine because the context package does not forward AfterFunc. func (c *mockCtx) AfterFunc(f func()) (stop func() bool) { c.mock.mu.Lock() defer c.mock.mu.Unlock() @@ -771,10 +769,11 @@ func (c *mockCtx) Deadline() (time.Time, bool) { return c.wallDeadline, true } -// Done, Err, and Value check the parent first because context.AfterFunc reports parent cancellation -// asynchronously, and stdlib contexts are done as soon as their parent is. Value is included -// because context.Cause reads the cause through it. A Done channel obtained before the parent was -// canceled closes when the context.AfterFunc goroutine runs. +// Done, Err, and Value check the parent first because stdlib contexts are done as soon as their +// parent is, but c may be notified from a goroutine: always when the parent is not a mock context, +// and when a mock parent was canceled before c registered with it. Value is included because +// context.Cause reads the cause through it. A Done channel obtained before the parent was canceled +// closes when that goroutine runs. func (c *mockCtx) Done() <-chan struct{} { c.checkParent() return c.done @@ -810,8 +809,8 @@ func (c *mockCtx) checkParent() { // cancel matches stdlib cancelCtx.cancel(): a nil cause defaults to err. If the parent is already // done, its error and cause win, as they do when stdlib propagates cancellation. // -// Must be called without mock.mu held, because parent.Err() takes mock.mu when parent is a context -// from this Mock. +// cancel takes mock.mu. It reads the parent's error and cause before that, because parent.Err() +// also takes mock.mu when parent is a context from this Mock. func (c *mockCtx) cancel(err, cause error) { if perr := c.parent.Err(); perr != nil { err, cause = perr, context.Cause(c.parent) diff --git a/mock_test.go b/mock_test.go index f4949ef..73646b5 100644 --- a/mock_test.go +++ b/mock_test.go @@ -554,8 +554,8 @@ func TestWithTimeout_ParentDeadlineExceeded(t *testing.T) { select { case <-ctx.Done(): - // A wall-clock parent deadline must still cancel mock-clock children. - // This keeps test timeout guards effective when mock time is stalled. + // A wall-clock parent deadline must still cancel mock-clock children. This keeps test + // timeout guards effective when mock time is stalled. case <-testCtx.Done(): t.Fatal("timeout waiting for parent deadline") } From d355c19f2eccc23feebbc56629c070f7dffc3681 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 14:45:41 +0000 Subject: [PATCH 12/43] test(mock_test.go): merge the already-done parent tests into a table ParentAlreadyCanceled and CanceledMockParent tested one contract with two kinds of parent. CanceledMockParent's goroutine and timeout guard could not report the deadlock it guards against: the test reached t.Fatal and then hung in the Mock cleanup, which needs the deadlocked Mock.mu, so the run still ended in go test's timeout panic. Calling WithTimeout directly gives the same panic, and it names the subtest. The mock parent now carries its own cause, so that case also fails when the parent's error and cause stop winning. The Peek check could not fail, because Err() and Peek both cancel the context first. --- mock_test.go | 82 ++++++++++++++++++++++++---------------------------- 1 file changed, 38 insertions(+), 44 deletions(-) diff --git a/mock_test.go b/mock_test.go index 73646b5..f48d51f 100644 --- a/mock_test.go +++ b/mock_test.go @@ -498,24 +498,48 @@ func TestWithTimeout_ParentCanceled(t *testing.T) { } } -func TestWithTimeout_ParentAlreadyCanceled(t *testing.T) { +// A context created from a done parent reports the parent's error and cause, as a stdlib child does. +func TestWithTimeout_ParentAlreadyDone(t *testing.T) { t.Parallel() - mClock := quartz.NewMock(t) cause := errors.New("parent cause") - parent, parentCancel := context.WithCancelCause(context.Background()) - parentCancel(cause) - - ctx, cancel := mClock.WithTimeout(parent, time.Hour) - defer cancel() - if !errors.Is(ctx.Err(), context.Canceled) { - t.Fatalf("expected Canceled, got %v", ctx.Err()) - } - if got := context.Cause(ctx); !errors.Is(got, cause) { - t.Fatalf("expected parent cause, got %v", got) + tests := map[string]struct { + newParent func(t *testing.T, mClock *quartz.Mock) context.Context + wantErr error + }{ + "Stdlib": { + newParent: func(_ *testing.T, _ *quartz.Mock) context.Context { + ctx, cancel := context.WithCancelCause(context.Background()) + cancel(cause) + return ctx + }, + wantErr: context.Canceled, + }, + // If creating a context from a done context of the same Mock deadlocks, go test -timeout + // reports it by naming this subtest. + "Mock": { + newParent: func(t *testing.T, mClock *quartz.Mock) context.Context { + ctx, cancel := mClock.WithTimeoutCause(context.Background(), 0, cause) + t.Cleanup(cancel) + return ctx + }, + wantErr: context.DeadlineExceeded, + }, } - if d, ok := mClock.Peek(); ok { - t.Fatalf("expected no pending events, got Peek()=%s", d) + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + mClock := quartz.NewMock(t) + ctx, cancel := mClock.WithTimeout(tt.newParent(t, mClock), time.Hour) + defer cancel() + + if !errors.Is(ctx.Err(), tt.wantErr) { + t.Fatalf("expected %v, got %v", tt.wantErr, ctx.Err()) + } + if got := context.Cause(ctx); !errors.Is(got, cause) { + t.Fatalf("expected parent cause, got %v", got) + } + }) } } @@ -754,36 +778,6 @@ func TestWithTimeout_Trap(t *testing.T) { } } -func TestWithTimeout_CanceledMockParent(t *testing.T) { - t.Parallel() - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() - - mClock := quartz.NewMock(t) - parent, parentCancel := mClock.WithTimeout(context.Background(), time.Hour) - parentCancel() - - got := make(chan context.Context, 1) - go func() { - ctx, cancel := mClock.WithTimeout(parent, time.Hour) - t.Cleanup(cancel) - got <- ctx - }() - select { - case ctx := <-got: - select { - case <-ctx.Done(): - case <-testCtx.Done(): - t.Fatal("timeout waiting for context with canceled mock parent") - } - if !errors.Is(ctx.Err(), context.Canceled) { - t.Fatalf("expected Canceled, got %v", ctx.Err()) - } - case <-testCtx.Done(): - t.Fatal("WithTimeout with a canceled mock parent did not return") - } -} - func TestTickerFunc_MockContext(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) From b19149706e1545a66fc842bbb1fee7dc3d860e3b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 14:47:02 +0000 Subject: [PATCH 13/43] test(mock_test.go): let go test -timeout report the TickerFunc deadlock The goroutine and timeout guard could not report the deadlock: the test reached t.Fatal and then hung in the Mock cleanup, which needs the deadlocked Mock.mu, so the run ended in go test's timeout panic either way. Calling Wait directly gives the same panic with less code. The test moves next to the other TickerFunc tests, and its name now says what it covers. --- mock_test.go | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/mock_test.go b/mock_test.go index f48d51f..7225486 100644 --- a/mock_test.go +++ b/mock_test.go @@ -325,6 +325,20 @@ func TestTickerFunc_LongCallback(t *testing.T) { w.MustWait(testCtx) } +// TestTickerFunc_MockContextCanceled tests that TickerFunc exits when its context, created by the +// same Mock, is canceled. If it deadlocks, go test -timeout reports it by naming this test. +func TestTickerFunc_MockContextCanceled(t *testing.T) { + t.Parallel() + mClock := quartz.NewMock(t) + + ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour) + w := mClock.TickerFunc(ctx, time.Second, func() error { return nil }) + cancel() + if err := w.Wait(); !errors.Is(err, context.Canceled) { + t.Fatalf("expected Canceled, got %v", err) + } +} + func TestWithTimeout_FiresOnAdvance(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -778,28 +792,6 @@ func TestWithTimeout_Trap(t *testing.T) { } } -func TestTickerFunc_MockContext(t *testing.T) { - t.Parallel() - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() - - mClock := quartz.NewMock(t) - ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour) - w := mClock.TickerFunc(ctx, time.Second, func() error { return nil }) - cancel() - - errCh := make(chan error, 1) - go func() { errCh <- w.Wait() }() - select { - case err := <-errCh: - if !errors.Is(err, context.Canceled) { - t.Fatalf("expected Canceled, got %v", err) - } - case <-testCtx.Done(): - t.Fatal("TickerFunc did not exit after its mock context was canceled") - } -} - func Test_MultipleTraps(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) From 4d0c7e56478f4508ef1424b731c3fe5da17b8402 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 14:47:27 +0000 Subject: [PATCH 14/43] test(mock_test.go): cover the parent check in Set and AdvanceNext Both check the parents of mock contexts before reading the schedule, as Advance and Peek do, but no test failed when either check was removed. --- mock_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/mock_test.go b/mock_test.go index 7225486..30193a6 100644 --- a/mock_test.go +++ b/mock_test.go @@ -496,6 +496,21 @@ func TestWithTimeout_ParentCanceled(t *testing.T) { defer testCancel() mClock.Advance(2 * time.Hour).MustWait(testCtx) }, + "Set": func(t *testing.T, mClock *quartz.Mock, _ context.Context) { + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + mClock.Set(mClock.Now().Add(2 * time.Hour)).MustWait(testCtx) + }, + "AdvanceNext": func(t *testing.T, mClock *quartz.Mock, _ context.Context) { + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + mClock.NewTimer(2 * time.Hour) // AdvanceNext fails the test when nothing is scheduled. + d, w := mClock.AdvanceNext() + w.MustWait(testCtx) + if d != 2*time.Hour { + t.Fatalf("expected AdvanceNext to skip the canceled context, advanced %s", d) + } + }, } for name, fn := range observe { t.Run(name, func(t *testing.T) { From 8ec043f0f6258a4ba184bc910df8c12b4ef50819 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 14:47:58 +0000 Subject: [PATCH 15/43] test(mock_test.go): check contexts directly after MustWait Mock contexts and stdlib contexts derived from them are done by the time Advance().MustWait() returns, but FiresOnAdvance and WithTimeoutCause_Cause still waited on Done with a timeout. The wait hid a deadline or derived cancellation that lands after MustWait returns: with either made asynchronous, the old WithTimeoutCause_Cause passed in 200 of 200 runs. --- mock_test.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/mock_test.go b/mock_test.go index 30193a6..be7edfd 100644 --- a/mock_test.go +++ b/mock_test.go @@ -352,12 +352,6 @@ func TestWithTimeout_FiresOnAdvance(t *testing.T) { mClock.Advance(time.Second).MustWait(testCtx) - select { - case <-ctx.Done(): - // OK - case <-testCtx.Done(): - t.Fatal("timeout waiting for context deadline") - } if !errors.Is(ctx.Err(), context.DeadlineExceeded) { t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) } @@ -634,12 +628,6 @@ func TestWithTimeoutCause_Cause(t *testing.T) { mClock.Advance(time.Second).MustWait(testCtx) - select { - case <-child.Done(): - // OK - case <-testCtx.Done(): - t.Fatal("timeout waiting for child context") - } if !errors.Is(ctx.Err(), context.DeadlineExceeded) { t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) } From bbe8755f60d716b2eae28f4539e1573dd8cc2d97 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 14:48:29 +0000 Subject: [PATCH 16/43] test(mock_test.go): drop checks that other checks already cover - CancelCancelsDerived's Peek check fails only when cancel leaves the event scheduled, which CancelIsIdempotentAndRemovesEvent and ParentCanceled/Peek also catch. The same holds for the Peek check dropped from ParentAlreadyDone. - ParentCanceledBeforeDeadline failed only with both the parent check in Advance and the parent's precedence in cancel() removed. ParentCanceled/Advance fails without the first and ParentAlreadyDone without the second. - The stdlib child deadline in WithDeadline_Deadline is in the past only if the mock context's deadline is, which the year check catches. --- mock_test.go | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/mock_test.go b/mock_test.go index be7edfd..9e3f352 100644 --- a/mock_test.go +++ b/mock_test.go @@ -383,9 +383,6 @@ func TestWithTimeout_CancelCancelsDerived(t *testing.T) { default: t.Fatal("expected nested mock context Done to be closed") } - if d, ok := mClock.Peek(); ok { - t.Fatalf("expected no pending events, got Peek()=%s", d) - } } func TestWithDeadline_Deadline(t *testing.T) { @@ -405,14 +402,6 @@ func TestWithDeadline_Deadline(t *testing.T) { if got.Year() != 9999 { t.Fatalf("expected deadline in year 9999, got %s", got) } - - // A stdlib timeout derived from the mock context keeps its own wall-clock deadline. - child, childCancel := context.WithTimeout(ctx, time.Minute) - defer childCancel() - childDeadline, _ := child.Deadline() - if !childDeadline.After(time.Now()) { - t.Fatalf("expected child deadline in the future, got %s", childDeadline) - } }) t.Run("ParentDeadline", func(t *testing.T) { @@ -566,28 +555,6 @@ func TestWithTimeout_ParentAlreadyDone(t *testing.T) { } } -// The parent was canceled first, so its error and cause win even though the mock deadline is -// reached before the context observes the parent asynchronously. -func TestWithTimeout_ParentCanceledBeforeDeadline(t *testing.T) { - t.Parallel() - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() - - mClock := quartz.NewMock(t) - parent, parentCancel := context.WithCancel(context.Background()) - ctx, cancel := mClock.WithTimeout(parent, time.Second) - defer cancel() - - parentCancel() - mClock.Advance(time.Second).MustWait(testCtx) - if !errors.Is(ctx.Err(), context.Canceled) { - t.Fatalf("expected Canceled, got %v", ctx.Err()) - } - if got := context.Cause(ctx); !errors.Is(got, context.Canceled) { - t.Fatalf("expected Canceled cause, got %v", got) - } -} - func TestWithTimeout_ParentDeadlineExceeded(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) From 1761431733589024a22482d786a2814518d77216 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 14:49:30 +0000 Subject: [PATCH 17/43] refactor(mock.go): drop the parent check at context creation Every way to observe a mock context checks its parent first: Err, Done, and Value through checkParent, and Advance, Set, AdvanceNext, and Peek through checkDoneContexts. Stdlib children call Done before they register through AfterFunc. Checking once more at creation had no observable effect, and every test passes without it. --- mock.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/mock.go b/mock.go index 7a8d329..00b6927 100644 --- a/mock.go +++ b/mock.go @@ -211,8 +211,6 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de if pastDeadline { c.cancel(context.DeadlineExceeded, cause) - } else { - c.checkParent() } var stop func() bool if p, ok := parent.(*mockCtx); ok { From e72b8dd140b58ffd129bc9fef6798ee73d91430a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 14:57:07 +0000 Subject: [PATCH 18/43] style(mock_test.go): fit the ParentAlreadyDone comment in 100 columns --- mock_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mock_test.go b/mock_test.go index 9e3f352..a5513f9 100644 --- a/mock_test.go +++ b/mock_test.go @@ -510,7 +510,7 @@ func TestWithTimeout_ParentCanceled(t *testing.T) { } } -// A context created from a done parent reports the parent's error and cause, as a stdlib child does. +// A context created from a done parent reports the parent's error and cause, as stdlib children do. func TestWithTimeout_ParentAlreadyDone(t *testing.T) { t.Parallel() From a54d043757da3883127d5a8239e2af224ea0fed5 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 11:24:02 +0000 Subject: [PATCH 19/43] fix: cancel contexts derived through WithValue synchronously Logging and tracing code often adds a context.WithValue layer, or a struct that embeds the context, between a mock context and the context.WithCancel that code under test derives from it. The context package forwards AfterFunc only from the direct parent, so such descendants waited for a goroutine and were usually still live after cancel() or Advance().MustWait() returned: 2000 of 2000 runs after cancel(), 1963 of 2000 after MustWait. Mock contexts now return a view whose Done and Value come from a stdlib context.WithCancel of the internal context. Stdlib finds that cancelCtx through any layer that forwards Done and Value and attaches descendants to it directly, so they are done when it is. cancel() also cancels it, so it returns after descendants are done when an expiry on another goroutine is still in progress. A stdlib parent's cancellation still reaches stdlib descendants of a mock context through a goroutine. The context package notifies non-stdlib children only that way. --- mock.go | 69 +++++++++++++++++++++++++++++++------ mock_test.go | 97 +++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 147 insertions(+), 19 deletions(-) diff --git a/mock.go b/mock.go index 00b6927..15b3a24 100644 --- a/mock.go +++ b/mock.go @@ -213,9 +213,9 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de c.cancel(context.DeadlineExceeded, cause) } var stop func() bool - if p, ok := parent.(*mockCtx); ok { + if p, ok := parent.(*mockCtxView); ok { // Run synchronously when the parent is canceled, as stdlib children do. - stop = p.AfterFunc(c.checkParent) + stop = p.c.AfterFunc(c.checkParent) } else { stop = context.AfterFunc(parent, c.checkParent) } @@ -226,7 +226,14 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de if done { stop() } - return c, func() { c.cancel(context.Canceled, nil) } + std, stdCancel := context.WithCancel(c) + return &mockCtxView{std: std, c: c}, func() { + c.cancel(context.Canceled, nil) + // c.cancel has canceled std unless another goroutine canceled c first and has not reached + // std yet. stdCancel makes std and its descendants done before cancel returns, as stdlib's + // cancel does. + stdCancel() + } } // checkDoneContexts cancels contexts whose parents are done, so their pending events do not block @@ -735,11 +742,10 @@ type afterFunc struct{ f func() } // while leaving expiry to Done() and Err(), which follow mock time. var noWallDeadline = time.Date(9999, time.December, 31, 0, 0, 0, 0, time.UTC) -// AfterFunc lets stdlib contexts created directly from c register for cancellation instead of each -// starting a goroutine that waits on Done (see context.afterFuncer). Registered functions run -// before the call that cancels c returns, so those contexts are done by the time cancel() or -// Advance().MustWait() returns. Contexts derived through another layer, such as context.WithValue, -// still use a goroutine because the context package does not forward AfterFunc. +// AfterFunc lets the stdlib context behind c's mockCtxView, and mock contexts created from that +// view, register for cancellation instead of each starting a goroutine that waits on Done (see +// context.afterFuncer). Registered functions run before the call that cancels c returns, so those +// contexts are done by the time cancel() or Advance().MustWait() returns. func (c *mockCtx) AfterFunc(f func()) (stop func() bool) { c.mock.mu.Lock() defer c.mock.mu.Unlock() @@ -767,23 +773,64 @@ func (c *mockCtx) Deadline() (time.Time, bool) { return c.wallDeadline, true } +// mockCtxView is the context that WithDeadline, WithTimeout, and their Cause variants return. Its +// Done, Err, and Value come from std, the context.WithCancel(c) that newMockCtx creates. std +// registers with c through c.AfterFunc, so it is canceled with c's error and cause before the +// call that cancels c returns. Stdlib finds std through Done and Value and attaches derived +// contexts to it directly, also through layers that do not cancel, such as context.WithValue, so +// they are done when std is. +type mockCtxView struct { + std context.Context + c *mockCtx +} + +func (v *mockCtxView) Deadline() (time.Time, bool) { + return v.c.Deadline() +} + // Done, Err, and Value check the parent first because stdlib contexts are done as soon as their // parent is, but c may be notified from a goroutine: always when the parent is not a mock context, // and when a mock parent was canceled before c registered with it. Value is included because // context.Cause reads the cause through it. A Done channel obtained before the parent was canceled // closes when that goroutine runs. +func (v *mockCtxView) Done() <-chan struct{} { + v.checkParent() + return v.std.Done() +} + +func (v *mockCtxView) Value(key any) any { + v.checkParent() + return v.std.Value(key) +} + +func (v *mockCtxView) Err() error { + v.checkParent() + return v.std.Err() +} + +// checkParent cancels c if its parent is done, then waits for std if c is done. std is canceled +// after c: by the goroutine that canceled c, or by a new goroutine if c was canceled while std was +// registering with it. Without the wait, Err could return nil after the parent's cancel returned, +// because c.checkParent returns at once while the parent's AfterFunc goroutine is canceling c. +func (v *mockCtxView) checkParent() { + v.c.checkParent() + select { + case <-v.c.done: + <-v.std.Done() + default: + } +} + +// Done, Err, and Value report c's own state without checking the parent; mockCtxView checks it. func (c *mockCtx) Done() <-chan struct{} { - c.checkParent() return c.done } func (c *mockCtx) Value(key any) any { - c.checkParent() return c.inner.Value(key) } func (c *mockCtx) Err() error { - c.checkParent() c.mock.mu.Lock() defer c.mock.mu.Unlock() return c.err diff --git a/mock_test.go b/mock_test.go index a5513f9..6896f87 100644 --- a/mock_test.go +++ b/mock_test.go @@ -366,8 +366,7 @@ func TestWithTimeout_CancelCancelsDerived(t *testing.T) { mClock := quartz.NewMock(t) ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour) - child, childCancel := context.WithCancel(ctx) - defer childCancel() + derived := stdlibDescendants(t, ctx) nested, nestedCancel := mClock.WithTimeout(ctx, time.Hour) defer nestedCancel() // Obtained before cancel so the check below does not trigger the nested context's own parent @@ -375,8 +374,13 @@ func TestWithTimeout_CancelCancelsDerived(t *testing.T) { nestedDone := nested.Done() cancel() - if !errors.Is(child.Err(), context.Canceled) { - t.Fatalf("expected stdlib child Canceled, got %v", child.Err()) + for name, child := range derived { + if !errors.Is(child.Err(), context.Canceled) { + t.Errorf("%s: expected Canceled, got %v", name, child.Err()) + } + if got := context.Cause(child); !errors.Is(got, context.Canceled) { + t.Errorf("%s: expected Canceled cause, got %v", name, got) + } } select { case <-nestedDone: @@ -385,6 +389,79 @@ func TestWithTimeout_CancelCancelsDerived(t *testing.T) { } } +type ctxKey struct{} + +// embeddingCtx is a user-defined context that embeds its parent, as wrappers in logging and tracing +// libraries do. +type embeddingCtx struct{ context.Context } + +// stdlibDescendants returns contexts that context.WithCancel derives from ctx directly and through +// layers that do not cancel. Stdlib cancels each of them before the call that cancels ctx returns. +func stdlibDescendants(t *testing.T, ctx context.Context) map[string]context.Context { + derive := func(parent context.Context) context.Context { + child, cancel := context.WithCancel(parent) + t.Cleanup(cancel) + return child + } + return map[string]context.Context{ + "Direct": derive(ctx), + "WithValue": derive(context.WithValue(ctx, ctxKey{}, "v")), + "Embedding": derive(embeddingCtx{ctx}), + } +} + +// stallingParent is a parent that is never canceled and whose AfterFunc stop function blocks until +// release is closed. An expiring mock context calls that stop function after it is done and before +// its derived contexts are, so a test can act in between. +type stallingParent struct { + context.Context + stopping chan struct{} + release chan struct{} +} + +// Done is not nil, so contexts derived from p register through AfterFunc. +func (*stallingParent) Done() <-chan struct{} { return make(chan struct{}) } + +func (p *stallingParent) AfterFunc(func()) func() bool { + return func() bool { + close(p.stopping) + <-p.release + return true + } +} + +// Like stdlib's cancel, cancel returns after derived contexts are done, also when an expiry on +// another goroutine has marked the context done and not yet reached them. +func TestWithTimeout_CancelDuringExpiry(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + parent := &stallingParent{ + Context: context.Background(), + stopping: make(chan struct{}), + release: make(chan struct{}), + } + ctx, cancel := mClock.WithTimeout(parent, time.Second) + child, childCancel := context.WithCancel(ctx) + defer childCancel() + + w := mClock.Advance(time.Second) + select { + case <-parent.stopping: + case <-testCtx.Done(): + t.Fatal("timed out waiting for the expiry to stop the parent registration") + } + cancel() + err := child.Err() + close(parent.release) + w.MustWait(testCtx) + if err == nil { + t.Fatal("expected the derived context to be done when cancel returned") + } +} + func TestWithDeadline_Deadline(t *testing.T) { t.Parallel() @@ -590,8 +667,7 @@ func TestWithTimeoutCause_Cause(t *testing.T) { expectedErr := errors.New("deadline cause") ctx, cancel := mClock.WithTimeoutCause(context.Background(), time.Second, expectedErr) defer cancel() - child, childCancel := context.WithCancel(ctx) - defer childCancel() + derived := stdlibDescendants(t, ctx) mClock.Advance(time.Second).MustWait(testCtx) @@ -601,8 +677,13 @@ func TestWithTimeoutCause_Cause(t *testing.T) { if !errors.Is(context.Cause(ctx), expectedErr) { t.Fatalf("expected cause %v, got %v", expectedErr, context.Cause(ctx)) } - if !errors.Is(context.Cause(child), expectedErr) { - t.Fatalf("expected child cause %v, got %v", expectedErr, context.Cause(child)) + for name, child := range derived { + if !errors.Is(child.Err(), context.DeadlineExceeded) { + t.Errorf("%s: expected DeadlineExceeded, got %v", name, child.Err()) + } + if !errors.Is(context.Cause(child), expectedErr) { + t.Errorf("%s: expected cause %v, got %v", name, expectedErr, context.Cause(child)) + } } } From a78441b1653ef571771ffa949f014735af3b9e78 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 11:24:22 +0000 Subject: [PATCH 20/43] fix: cancel mock contexts created after the test ended A goroutine that outlives its test can still call WithTimeout. The context registered its test-end cleanup with a test whose cleanups had already run, so nothing canceled it and the goroutine could block on it forever. The context is now returned already canceled with cause ErrMockClockTestEnded. --- mock.go | 11 ++++++++--- mock_test.go | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/mock.go b/mock.go index 15b3a24..c2f2917 100644 --- a/mock.go +++ b/mock.go @@ -23,8 +23,9 @@ 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. +// ErrMockClockTestEnded is used as the cancellation cause when test cleanup cancels a context that +// was not canceled by its caller. A context created after the Mock's test ended 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 @@ -202,6 +203,8 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de m.matchCallLocked(call) c.deadline = deadlineLocked() pastDeadline := !c.deadline.After(m.cur) + // Once NewMock's cleanup has run, the test may be over and the cleanup below may never run. + testOver := m.testOver if !pastDeadline { m.addEventLocked(c) } @@ -209,7 +212,9 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de close(call.complete) m.mu.Unlock() - if pastDeadline { + if testOver { + c.cancel(context.Canceled, ErrMockClockTestEnded) + } else if pastDeadline { c.cancel(context.DeadlineExceeded, cause) } var stop func() bool diff --git a/mock_test.go b/mock_test.go index 6896f87..614b6c2 100644 --- a/mock_test.go +++ b/mock_test.go @@ -707,6 +707,27 @@ func TestWithTimeout_PastDeadline(t *testing.T) { } } +// A goroutine that outlives its test can still create mock contexts. The test's cleanups have +// already run, so the context must be done when it is returned. +func TestWithTimeout_CreatedAfterTestEnd(t *testing.T) { + t.Parallel() + + var mClock *quartz.Mock + // Not parallel, so Inner and its cleanups finish before t.Run returns. + t.Run("Inner", func(t *testing.T) { + mClock = quartz.NewMock(t) + }) + ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour) + defer cancel() + + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("expected Canceled, got %v", ctx.Err()) + } + if got := context.Cause(ctx); !errors.Is(got, quartz.ErrMockClockTestEnded) { + t.Fatalf("expected cause ErrMockClockTestEnded, got %v", got) + } +} + func TestWithTimeout_CancelRace(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) From 0adf429a9986aede2bae34e381de35809a7e4fb8 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:00:32 +0000 Subject: [PATCH 21/43] fix: keep the error and cause of a cancellation in progress When cancel() ran while another goroutine was canceling the context, c.cancel returned early and stdCancel canceled std with context.Canceled before that goroutine reached std. The context and its descendants then lost the expiry's DeadlineExceeded and custom cause, or a canceled parent's cause. c.cancel now cancels derived contexts, std first, before it calls into the parent to unregister, as stdlib does. A cancel() that loses the race waits for std instead of overriding it, so the wait never depends on a slow parent. TestWithTimeout_CancelDuringExpiry is now TestWithTimeout_CancelWhileCanceling and checks the exact error and cause for an expiry and for a canceled parent. --- mock.go | 19 +++++---- mock_test.go | 112 ++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 96 insertions(+), 35 deletions(-) diff --git a/mock.go b/mock.go index c2f2917..d28bf45 100644 --- a/mock.go +++ b/mock.go @@ -234,9 +234,11 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de std, stdCancel := context.WithCancel(c) return &mockCtxView{std: std, c: c}, func() { c.cancel(context.Canceled, nil) - // c.cancel has canceled std unless another goroutine canceled c first and has not reached - // std yet. stdCancel makes std and its descendants done before cancel returns, as stdlib's - // cancel does. + // If another goroutine canceled c first, it cancels std with its error and cause. Wait for + // that, as a stdlib cancel that loses a race does. The wait does not depend on user code: + // c.cancel cancels std before calling into the parent. stdCancel is then a no-op, called + // because WithCancel requires it. + <-std.Done() stdCancel() } } @@ -881,13 +883,16 @@ func (c *mockCtx) cancel(err, cause error) { afterFuncs := c.afterFuncs c.afterFuncs = nil c.mock.mu.Unlock() - if stop != nil { - stop() - } - // Without mock.mu: derived contexts call c.Err() while canceling. + // Without mock.mu: derived contexts call c.Err() while canceling. They run before stop, which + // calls into the parent, so a slow parent cannot delay std, which mockCtxView and a cancel() + // that lost a race wait for. Stdlib also cancels children before it removes a context from its + // parent. for _, a := range afterFuncs { a.f() } + if stop != nil { + stop() + } } type clockFunction int diff --git a/mock_test.go b/mock_test.go index 614b6c2..bb4d75c 100644 --- a/mock_test.go +++ b/mock_test.go @@ -410,9 +410,10 @@ func stdlibDescendants(t *testing.T, ctx context.Context) map[string]context.Con } } -// stallingParent is a parent that is never canceled and whose AfterFunc stop function blocks until -// release is closed. An expiring mock context calls that stop function after it is done and before -// its derived contexts are, so a test can act in between. +// stallingParent is a parent whose Done never closes and whose AfterFunc stop function blocks until +// release is closed. A mock context calls that stop function while it is being canceled, so a test +// can act while another goroutine is canceling it. Canceling the embedded context makes Err and +// Cause report the parent done without notifying the mock context. type stallingParent struct { context.Context stopping chan struct{} @@ -430,35 +431,90 @@ func (p *stallingParent) AfterFunc(func()) func() bool { } } -// Like stdlib's cancel, cancel returns after derived contexts are done, also when an expiry on -// another goroutine has marked the context done and not yet reached them. -func TestWithTimeout_CancelDuringExpiry(t *testing.T) { +// When cancel is called while another goroutine is canceling the context, cancel returns after +// derived contexts are done, and they report that goroutine's error and cause, as with stdlib. +func TestWithTimeout_CancelWhileCanceling(t *testing.T) { t.Parallel() - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() - mClock := quartz.NewMock(t) - parent := &stallingParent{ - Context: context.Background(), - stopping: make(chan struct{}), - release: make(chan struct{}), + deadlineCause := errors.New("deadline cause") + parentCause := errors.New("parent cause") + tests := map[string]struct { + // startCanceling starts canceling the context on another goroutine and returns a function + // that waits for that goroutine. + startCanceling func(testCtx context.Context, mClock *quartz.Mock, + cancel context.CancelFunc, cancelParent context.CancelCauseFunc) (wait func()) + wantErr error + wantCause error + }{ + "Expiry": { + startCanceling: func(testCtx context.Context, mClock *quartz.Mock, + _ context.CancelFunc, _ context.CancelCauseFunc, + ) func() { + w := mClock.Advance(time.Second) + return func() { w.MustWait(testCtx) } + }, + wantErr: context.DeadlineExceeded, + wantCause: deadlineCause, + }, + // The first cancel finds the parent done and takes its error and cause. + "ParentCanceled": { + startCanceling: func(_ context.Context, _ *quartz.Mock, + cancel context.CancelFunc, cancelParent context.CancelCauseFunc, + ) func() { + cancelParent(parentCause) + done := make(chan struct{}) + go func() { + cancel() + close(done) + }() + return func() { <-done } + }, + wantErr: context.Canceled, + wantCause: parentCause, + }, } - ctx, cancel := mClock.WithTimeout(parent, time.Second) - child, childCancel := context.WithCancel(ctx) - defer childCancel() + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() - w := mClock.Advance(time.Second) - select { - case <-parent.stopping: - case <-testCtx.Done(): - t.Fatal("timed out waiting for the expiry to stop the parent registration") - } - cancel() - err := child.Err() - close(parent.release) - w.MustWait(testCtx) - if err == nil { - t.Fatal("expected the derived context to be done when cancel returned") + mClock := quartz.NewMock(t) + root, cancelRoot := context.WithCancelCause(context.Background()) + defer cancelRoot(nil) + parent := &stallingParent{ + Context: root, + stopping: make(chan struct{}), + release: make(chan struct{}), + } + ctx, cancel := mClock.WithTimeoutCause(parent, time.Second, deadlineCause) + child, childCancel := context.WithCancel(ctx) + defer childCancel() + + wait := tt.startCanceling(testCtx, mClock, cancel, cancelRoot) + select { + case <-parent.stopping: + case <-testCtx.Done(): + t.Fatal("timed out waiting for the cancellation to stop the parent registration") + } + // If cancel waits for the stalled goroutine, go test -timeout reports it by naming this + // test. + cancel() + // Read while the other goroutine is still in the parent's stop function. + ctxErr, ctxCause := ctx.Err(), context.Cause(ctx) + childErr, childCause := child.Err(), context.Cause(child) + close(parent.release) + wait() + + if !errors.Is(ctxErr, tt.wantErr) || !errors.Is(ctxCause, tt.wantCause) { + t.Errorf("expected context %v with cause %v, got %v with cause %v", + tt.wantErr, tt.wantCause, ctxErr, ctxCause) + } + if !errors.Is(childErr, tt.wantErr) || !errors.Is(childCause, tt.wantCause) { + t.Errorf("expected derived context %v with cause %v, got %v with cause %v", + tt.wantErr, tt.wantCause, childErr, childCause) + } + }) } } From ecde549da02807b119fddb0c88ae2d66e6bc6baa Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:00:40 +0000 Subject: [PATCH 22/43] docs(mock.go): say when a mock context is returned already canceled "After the Mock's test ended" did not cover a cleanup registered before NewMock. Such a cleanup runs after NewMock's, so a context it creates is also returned canceled. The wording now names NewMock's cleanup, as the timer and ticker docs in #37 and #38 do. --- mock.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/mock.go b/mock.go index d28bf45..33d5d3a 100644 --- a/mock.go +++ b/mock.go @@ -24,8 +24,9 @@ type TestingT interface { } // ErrMockClockTestEnded is used as the cancellation cause when test cleanup cancels a context that -// was not canceled by its caller. A context created after the Mock's test ended is returned -// already canceled with this cause. +// was not canceled by its caller. A context created after the cleanup registered by NewMock has +// run, including in a cleanup registered before NewMock, 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 @@ -203,7 +204,8 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de m.matchCallLocked(call) c.deadline = deadlineLocked() pastDeadline := !c.deadline.After(m.cur) - // Once NewMock's cleanup has run, the test may be over and the cleanup below may never run. + // Once the cleanup registered by NewMock has run, the Mock is finished, and the cleanup below + // may never run. testOver := m.testOver if !pastDeadline { m.addEventLocked(c) From bed3fece64e734ff853ae4888359d89e6908076b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:00:57 +0000 Subject: [PATCH 23/43] test(mock_test.go): cover a cleanup registered before NewMock The test is now a table over the two ways to create a context after NewMock's cleanup has run. The new case checks the context inside the cleanup: without the fix, the context's own cleanup cancels it later in the same pass, so a check after the subtest returns would pass anyway. --- mock_test.go | 57 ++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/mock_test.go b/mock_test.go index bb4d75c..823b154 100644 --- a/mock_test.go +++ b/mock_test.go @@ -763,24 +763,51 @@ func TestWithTimeout_PastDeadline(t *testing.T) { } } -// A goroutine that outlives its test can still create mock contexts. The test's cleanups have -// already run, so the context must be done when it is returned. -func TestWithTimeout_CreatedAfterTestEnd(t *testing.T) { +// 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) { t.Parallel() - var mClock *quartz.Mock - // Not parallel, so Inner and its cleanups finish before t.Run returns. - t.Run("Inner", func(t *testing.T) { - mClock = quartz.NewMock(t) - }) - ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour) - defer cancel() - - if !errors.Is(ctx.Err(), context.Canceled) { - t.Fatalf("expected Canceled, got %v", ctx.Err()) + // Each case creates a context after that cleanup has run and returns the error and cause the + // context had when WithTimeout returned. Inner is not parallel, so it and its cleanups finish + // before t.Run returns. + tests := map[string]func(t *testing.T) (err, cause error){ + // A goroutine that outlives its test. + "AfterTest": func(t *testing.T) (err, cause error) { + var mClock *quartz.Mock + t.Run("Inner", func(t *testing.T) { + mClock = quartz.NewMock(t) + }) + ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour) + defer cancel() + return ctx.Err(), context.Cause(ctx) + }, + // Cleanups run last added, first called, so this cleanup runs after the one NewMock + // registered. + "InCleanupRegisteredBeforeNewMock": func(t *testing.T) (err, cause error) { + t.Run("Inner", func(t *testing.T) { + var mClock *quartz.Mock + t.Cleanup(func() { + ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour) + defer cancel() + err, cause = ctx.Err(), context.Cause(ctx) + }) + mClock = quartz.NewMock(t) + }) + return err, cause + }, } - if got := context.Cause(ctx); !errors.Is(got, quartz.ErrMockClockTestEnded) { - t.Fatalf("expected cause ErrMockClockTestEnded, got %v", got) + for name, createAfterEnd := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + err, cause := createAfterEnd(t) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected Canceled, got %v", err) + } + if !errors.Is(cause, quartz.ErrMockClockTestEnded) { + t.Fatalf("expected cause ErrMockClockTestEnded, got %v", cause) + } + }) } } From 7aa1c9dc8bc28d24fb91cbae7f58feaadfaae34d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:25:28 +0000 Subject: [PATCH 24/43] fix: notify mock contexts created through WithValue synchronously A mock context recognized a mock parent only when the parent was the direct one. Behind a context.WithValue layer or an embedding wrapper it registered through context.AfterFunc and learned of the parent's cancellation on a goroutine, so after the outer cancel() returned it and its descendants were still live in 2000 of 2000 runs. newMockCtx now finds the nearest mock context through a private Value key and registers with it when Done is the same channel. A layer with its own Done, such as context.WithCancel, can be canceled on its own, so a context behind it still registers with that layer. --- mock.go | 21 ++++++++++---- mock_test.go | 82 ++++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 79 insertions(+), 24 deletions(-) diff --git a/mock.go b/mock.go index 33d5d3a..5bff078 100644 --- a/mock.go +++ b/mock.go @@ -220,8 +220,11 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de c.cancel(context.DeadlineExceeded, cause) } var stop func() bool - if p, ok := parent.(*mockCtxView); ok { - // Run synchronously when the parent is canceled, as stdlib children do. + // Register with the nearest mock context, also behind layers that forward Done and Value such + // as context.WithValue, so c is canceled before that context's cancellation returns, as stdlib + // children are. A different Done channel means a layer between them can be canceled on its own, + // as in stdlib's parentCancelCtx, so c registers with parent instead. + if p, ok := parent.Value(mockCtxViewKey{}).(*mockCtxView); ok && parent.Done() == p.std.Done() { stop = p.c.AfterFunc(c.checkParent) } else { stop = context.AfterFunc(parent, c.checkParent) @@ -752,9 +755,10 @@ type afterFunc struct{ f func() } var noWallDeadline = time.Date(9999, time.December, 31, 0, 0, 0, 0, time.UTC) // AfterFunc lets the stdlib context behind c's mockCtxView, and mock contexts created from that -// view, register for cancellation instead of each starting a goroutine that waits on Done (see -// context.afterFuncer). Registered functions run before the call that cancels c returns, so those -// contexts are done by the time cancel() or Advance().MustWait() returns. +// view directly or through layers that forward Done and Value, register for cancellation instead +// of each starting a goroutine that waits on Done (see context.afterFuncer). Registered functions +// run before the call that cancels c returns, so those contexts are done by the time cancel() or +// Advance().MustWait() returns. func (c *mockCtx) AfterFunc(f func()) (stop func() bool) { c.mock.mu.Lock() defer c.mock.mu.Unlock() @@ -793,6 +797,10 @@ type mockCtxView struct { c *mockCtx } +// mockCtxViewKey is the Value key under which a mockCtxView returns itself, so newMockCtx can find +// the nearest mock context behind layers such as context.WithValue. +type mockCtxViewKey struct{} + func (v *mockCtxView) Deadline() (time.Time, bool) { return v.c.Deadline() } @@ -808,6 +816,9 @@ func (v *mockCtxView) Done() <-chan struct{} { } func (v *mockCtxView) Value(key any) any { + if key == (mockCtxViewKey{}) { + return v + } v.checkParent() return v.std.Value(key) } diff --git a/mock_test.go b/mock_test.go index 823b154..874e13a 100644 --- a/mock_test.go +++ b/mock_test.go @@ -366,12 +366,7 @@ func TestWithTimeout_CancelCancelsDerived(t *testing.T) { mClock := quartz.NewMock(t) ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour) - derived := stdlibDescendants(t, ctx) - nested, nestedCancel := mClock.WithTimeout(ctx, time.Hour) - defer nestedCancel() - // Obtained before cancel so the check below does not trigger the nested context's own parent - // check. - nestedDone := nested.Done() + derived := descendants(t, mClock, ctx) cancel() for name, child := range derived { @@ -382,11 +377,6 @@ func TestWithTimeout_CancelCancelsDerived(t *testing.T) { t.Errorf("%s: expected Canceled cause, got %v", name, got) } } - select { - case <-nestedDone: - default: - t.Fatal("expected nested mock context Done to be closed") - } } type ctxKey struct{} @@ -395,18 +385,28 @@ type ctxKey struct{} // libraries do. type embeddingCtx struct{ context.Context } -// stdlibDescendants returns contexts that context.WithCancel derives from ctx directly and through -// layers that do not cancel. Stdlib cancels each of them before the call that cancels ctx returns. -func stdlibDescendants(t *testing.T, ctx context.Context) map[string]context.Context { - derive := func(parent context.Context) context.Context { +// descendants returns contexts derived from ctx directly and through layers that do not cancel, +// each done before the call that cancels ctx returns. The Mock cases derive a mock context and +// return a stdlib context derived from it: reading the mock context itself would check its parent +// and cancel it, hiding a missed notification. +func descendants(t *testing.T, mClock *quartz.Mock, ctx context.Context) map[string]context.Context { + withCancel := func(parent context.Context) context.Context { child, cancel := context.WithCancel(parent) t.Cleanup(cancel) return child } + throughMock := func(parent context.Context) context.Context { + nested, cancel := mClock.WithTimeout(parent, time.Hour) + t.Cleanup(cancel) + return withCancel(nested) + } return map[string]context.Context{ - "Direct": derive(ctx), - "WithValue": derive(context.WithValue(ctx, ctxKey{}, "v")), - "Embedding": derive(embeddingCtx{ctx}), + "Direct": withCancel(ctx), + "WithValue": withCancel(context.WithValue(ctx, ctxKey{}, "v")), + "Embedding": withCancel(embeddingCtx{ctx}), + "Mock": throughMock(ctx), + "MockWithValue": throughMock(context.WithValue(ctx, ctxKey{}, "v")), + "MockEmbedding": throughMock(embeddingCtx{ctx}), } } @@ -643,6 +643,50 @@ func TestWithTimeout_ParentCanceled(t *testing.T) { } } +// When a parent that is not a mock context is canceled, a mock context learns of it on another +// goroutine, so a Done channel obtained before, and the Done of a derived context, close a moment +// later. +func TestWithTimeout_ParentCanceledClosesDone(t *testing.T) { + t.Parallel() + + tests := map[string]func(t *testing.T, mClock *quartz.Mock) (context.Context, context.CancelFunc){ + "Stdlib": func(*testing.T, *quartz.Mock) (context.Context, context.CancelFunc) { + return context.WithCancel(context.Background()) + }, + // The stdlib layer can be canceled without the mock context behind it, so the child must + // not register with that mock context. + "WithCancelOfMock": func(t *testing.T, mClock *quartz.Mock) (context.Context, context.CancelFunc) { + mockParent, cancel := mClock.WithTimeout(context.Background(), time.Hour) + t.Cleanup(cancel) + return context.WithCancel(mockParent) + }, + } + for name, newParent := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + parent, parentCancel := newParent(t, mClock) + ctx, cancel := mClock.WithTimeout(parent, time.Hour) + defer cancel() + child, childCancel := context.WithCancel(ctx) + defer childCancel() + ctxDone := ctx.Done() + + parentCancel() + for name, done := range map[string]<-chan struct{}{"ctx": ctxDone, "child": child.Done()} { + select { + case <-done: + case <-testCtx.Done(): + t.Fatalf("%s: Done did not close after the parent was canceled", name) + } + } + }) + } +} + // A context created from a done parent reports the parent's error and cause, as stdlib children do. func TestWithTimeout_ParentAlreadyDone(t *testing.T) { t.Parallel() @@ -723,7 +767,7 @@ func TestWithTimeoutCause_Cause(t *testing.T) { expectedErr := errors.New("deadline cause") ctx, cancel := mClock.WithTimeoutCause(context.Background(), time.Second, expectedErr) defer cancel() - derived := stdlibDescendants(t, ctx) + derived := descendants(t, mClock, ctx) mClock.Advance(time.Second).MustWait(testCtx) From 2d40098fc5994d6f097a0f22dff665df92648e97 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:26:19 +0000 Subject: [PATCH 25/43] test(mock_test.go): check expiry by constructor in one table TestWithTimeout_FiresOnAdvance and TestWithTimeoutCause_Cause set up the same expiry and differed only in the constructor and the cause they expected. FiresOnAdvance is now a table over WithTimeout and WithTimeoutCause that checks the context and every descendant for the error and the cause. --- mock_test.go | 84 +++++++++++++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 43 deletions(-) diff --git a/mock_test.go b/mock_test.go index 874e13a..1eb3c90 100644 --- a/mock_test.go +++ b/mock_test.go @@ -339,25 +339,52 @@ func TestTickerFunc_MockContextCanceled(t *testing.T) { } } +// A mock context expires when Advance reaches its deadline. By the time MustWait returns, it and the +// contexts derived from it report DeadlineExceeded and the deadline's cause. func TestWithTimeout_FiresOnAdvance(t *testing.T) { t.Parallel() - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() - mClock := quartz.NewMock(t) - ctx, cancel := mClock.WithTimeout(context.Background(), time.Second) - defer cancel() - child, childCancel := context.WithCancel(ctx) - defer childCancel() + deadlineCause := errors.New("deadline cause") + tests := map[string]struct { + newCtx func(mClock *quartz.Mock) (context.Context, context.CancelFunc) + wantCause error + }{ + "WithTimeout": { + newCtx: func(mClock *quartz.Mock) (context.Context, context.CancelFunc) { + return mClock.WithTimeout(context.Background(), time.Second) + }, + wantCause: context.DeadlineExceeded, + }, + "WithTimeoutCause": { + newCtx: func(mClock *quartz.Mock) (context.Context, context.CancelFunc) { + return mClock.WithTimeoutCause(context.Background(), time.Second, deadlineCause) + }, + wantCause: deadlineCause, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() - mClock.Advance(time.Second).MustWait(testCtx) + mClock := quartz.NewMock(t) + ctx, cancel := tt.newCtx(mClock) + defer cancel() + ctxs := descendants(t, mClock, ctx) + ctxs["Self"] = ctx - if !errors.Is(ctx.Err(), context.DeadlineExceeded) { - t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) - } - // Derived contexts are done by the time MustWait returns. - if !errors.Is(child.Err(), context.DeadlineExceeded) { - t.Fatalf("expected child DeadlineExceeded, got %v", child.Err()) + mClock.Advance(time.Second).MustWait(testCtx) + + for name, c := range ctxs { + if !errors.Is(c.Err(), context.DeadlineExceeded) { + t.Errorf("%s: expected DeadlineExceeded, got %v", name, c.Err()) + } + if !errors.Is(context.Cause(c), tt.wantCause) { + t.Errorf("%s: expected cause %v, got %v", name, tt.wantCause, context.Cause(c)) + } + } + }) } } @@ -758,35 +785,6 @@ func TestWithTimeout_ParentDeadlineExceeded(t *testing.T) { } } -func TestWithTimeoutCause_Cause(t *testing.T) { - t.Parallel() - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() - - mClock := quartz.NewMock(t) - expectedErr := errors.New("deadline cause") - ctx, cancel := mClock.WithTimeoutCause(context.Background(), time.Second, expectedErr) - defer cancel() - derived := descendants(t, mClock, ctx) - - mClock.Advance(time.Second).MustWait(testCtx) - - if !errors.Is(ctx.Err(), context.DeadlineExceeded) { - t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) - } - if !errors.Is(context.Cause(ctx), expectedErr) { - t.Fatalf("expected cause %v, got %v", expectedErr, context.Cause(ctx)) - } - for name, child := range derived { - if !errors.Is(child.Err(), context.DeadlineExceeded) { - t.Errorf("%s: expected DeadlineExceeded, got %v", name, child.Err()) - } - if !errors.Is(context.Cause(child), expectedErr) { - t.Errorf("%s: expected cause %v, got %v", name, expectedErr, context.Cause(child)) - } - } -} - func TestWithTimeout_PastDeadline(t *testing.T) { t.Parallel() From 6f20e324456305ba02c215640b63bae683c973a3 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 12:27:46 +0000 Subject: [PATCH 26/43] docs: say when mock contexts and their descendants are done "Parent cancellation propagates with stdlib semantics" promised more than mock contexts deliver: when a parent from the context package is canceled, Err reports it at once, but derived contexts and an earlier Done channel close only when a goroutine runs. The Clock and README docs now say what is done when cancel() or MustWait() returns, name that exception, and say how the test's end cancels contexts. The ErrMockClockTestEnded godoc now says which cleanup cancels a live context. A context is canceled by a cleanup registered when it was created, not by NewMock's, so it is canceled before cleanups registered between NewMock and its creation run. --- README.md | 14 +++++++++++--- clock.go | 14 ++++++++++---- mock.go | 13 +++++++------ mock_test.go | 5 +++-- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 017698c..5f83abb 100644 --- a/README.md +++ b/README.md @@ -328,9 +328,17 @@ if !errors.Is(ctx.Err(), context.DeadlineExceeded) { } ``` -Parent contexts keep standard `context` behavior. A parent created with `context.WithDeadline` or -`context.WithTimeout` can still cancel the child on wall time because Quartz does not convert parent -deadlines to mock time. +A canceled parent cancels the child. A parent created with `context.WithDeadline` or +`context.WithTimeout` can still cancel the child on wall time because Quartz does not convert +parent deadlines to mock time. + +When `cancel()` returns, or `Advance(...).MustWait()` returns after the clock reaches the deadline, +the context and contexts derived from it are done, so the test can check them right away. When a +parent from the `context` package is canceled, `ctx.Err()` reports it at once, but contexts derived +from `ctx` are canceled a moment later on another goroutine, and a `ctx.Done()` channel obtained +before closes only then; wait on `Done()` instead of checking `Err()`. Contexts that are 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`. diff --git a/clock.go b/clock.go index 43254e6..535f510 100644 --- a/clock.go +++ b/clock.go @@ -37,16 +37,22 @@ type Clock interface { Until(t time.Time, tags ...string) time.Duration // WithDeadline returns a copy of parent that expires when this Clock reaches d. When it - // expires, Err satisfies errors.Is(err, context.DeadlineExceeded). Parent cancellation - // propagates with stdlib semantics. Parent deadlines are not converted to mock time, so a - // parent created with context.WithDeadline or context.WithTimeout can cancel the child - // independently of this Clock. The returned cancel function is idempotent and must be called. + // expires, Err satisfies errors.Is(err, context.DeadlineExceeded). Parent deadlines are not + // converted to mock time, so a parent created with context.WithDeadline or context.WithTimeout + // can cancel the child independently of this Clock. The returned cancel function is idempotent + // and must be called. // // With a Mock clock, Done and Err follow mock time, but ctx.Deadline() reports the parent's // deadline, or a time in year 9999 if the parent has none. Libraries such as net.Dialer, // http.Client, and gRPC compare Deadline() with wall time and would treat a mock deadline as // already expired. Code that needs the remaining mock time must compute it from the deadline or // timeout it passed in, not from ctx.Deadline(). + // + // With a Mock clock, the context and contexts derived from it are done when the cancel function + // returns, or when Advance(...).MustWait() returns after the clock reached d. When a parent + // from the context package is canceled, Err reports it at once, but contexts derived from the + // returned context are canceled a moment later on another goroutine, and a Done channel + // obtained before closes only then; wait on Done instead of checking Err. WithDeadline(parent context.Context, d time.Time, tags ...string) (context.Context, context.CancelFunc) // WithDeadlineCause is like WithDeadline and records cause when this Clock reaches d. diff --git a/mock.go b/mock.go index 5bff078..66c2943 100644 --- a/mock.go +++ b/mock.go @@ -23,10 +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. A context created after the cleanup registered by NewMock has -// run, including in a cleanup registered before NewMock, is returned already canceled with this -// cause. +// 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. var ErrMockClockTestEnded = errors.New("quartz: mock clock test ended") // Mock is the testing implementation of Clock. It tracks a time that monotonically increases @@ -204,8 +205,8 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de m.matchCallLocked(call) c.deadline = deadlineLocked() pastDeadline := !c.deadline.After(m.cur) - // Once the cleanup registered by NewMock has run, the Mock is finished, and the cleanup below - // may never run. + // After the cleanup registered by NewMock has run, the cleanup below runs only after the + // caller's cleanup returns, or never if the test has finished, so cancel the context now. testOver := m.testOver if !pastDeadline { m.addEventLocked(c) diff --git a/mock_test.go b/mock_test.go index 1eb3c90..63facf0 100644 --- a/mock_test.go +++ b/mock_test.go @@ -458,8 +458,9 @@ func (p *stallingParent) AfterFunc(func()) func() bool { } } -// When cancel is called while another goroutine is canceling the context, cancel returns after -// derived contexts are done, and they report that goroutine's error and cause, as with stdlib. +// When cancel is called while another goroutine is canceling the context and is blocked in the +// parent, cancel does not wait for the parent, and the context and its derived contexts report that +// goroutine's error and cause. func TestWithTimeout_CancelWhileCanceling(t *testing.T) { t.Parallel() From a33a2152e6e9f6e24847ed7355c471226f6a83c9 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 15:50:55 +0000 Subject: [PATCH 27/43] fix: wait for mock descendants a cancellation in progress cancels A cancel() that lost the race to an expiry or a canceled parent waited for std only. Mock contexts created from the context were notified after std, on the goroutine that was still canceling, so cancel() returned with their descendants still live in up to 198 of 200 runs. Mock children now register in their own list. The goroutine that cancels a context cancels them as one tree, closes finished, and only then unregisters the tree from its parents, which can call into user code. cancel() and fire, which Advance().MustWait() waits for, wait for finished. A child that another goroutine is already canceling is pending rather than waited for, because the tree can reach an ancestor it is already canceling. --- mock.go | 122 +++++++++++++++++++++++++++++++++++++++++++-------- mock_test.go | 60 +++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 18 deletions(-) diff --git a/mock.go b/mock.go index 66c2943..d0ab0c8 100644 --- a/mock.go +++ b/mock.go @@ -196,6 +196,7 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de inner: inner, innerCancel: innerCancel, wallDeadline: wallDeadline, + finished: make(chan struct{}), cause: cause, mock: m, done: make(chan struct{}), @@ -226,7 +227,7 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de // children are. A different Done channel means a layer between them can be canceled on its own, // as in stdlib's parentCancelCtx, so c registers with parent instead. if p, ok := parent.Value(mockCtxViewKey{}).(*mockCtxView); ok && parent.Done() == p.std.Done() { - stop = p.c.AfterFunc(c.checkParent) + stop = p.c.registerChild(c) } else { stop = context.AfterFunc(parent, c.checkParent) } @@ -240,10 +241,12 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de std, stdCancel := context.WithCancel(c) return &mockCtxView{std: std, c: c}, func() { c.cancel(context.Canceled, nil) - // If another goroutine canceled c first, it cancels std with its error and cause. Wait for - // that, as a stdlib cancel that loses a race does. The wait does not depend on user code: - // c.cancel cancels std before calling into the parent. stdCancel is then a no-op, called + // If another goroutine canceled c first, it may still be canceling std and the mock + // contexts registered with c. Wait for that, as a stdlib cancel that loses a race does. The + // wait runs no function that unregisters a context from its parent, which can block in user + // code: cancel calls those after closing finished. stdCancel is then a no-op, called // because WithCancel requires it. + <-c.finished <-std.Done() stdCancel() } @@ -730,6 +733,11 @@ type mockCtx struct { deadline time.Time // wallDeadline is what Deadline() reports; see noWallDeadline. wallDeadline time.Time + // children holds the mock contexts registered through registerChild and is guarded by + // mock.mu. finished is closed once c and every mock context canceled with it are canceled, + // before cancel unregisters them from their parents. + children []*mockCtx + finished chan struct{} // Cause recorded when the deadline fires. cause error @@ -755,11 +763,10 @@ type afterFunc struct{ f func() } // while leaving expiry to Done() and Err(), which follow mock time. var noWallDeadline = time.Date(9999, time.December, 31, 0, 0, 0, 0, time.UTC) -// AfterFunc lets the stdlib context behind c's mockCtxView, and mock contexts created from that -// view directly or through layers that forward Done and Value, register for cancellation instead -// of each starting a goroutine that waits on Done (see context.afterFuncer). Registered functions -// run before the call that cancels c returns, so those contexts are done by the time cancel() or -// Advance().MustWait() returns. +// AfterFunc lets the stdlib context behind c's mockCtxView register for cancellation instead of +// starting a goroutine that waits on Done (see context.afterFuncer). Registered functions run +// before the call that cancels c returns, so that context and its descendants are done by the time +// cancel() or Advance().MustWait() returns. func (c *mockCtx) AfterFunc(f func()) (stop func() bool) { c.mock.mu.Lock() defer c.mock.mu.Unlock() @@ -783,6 +790,32 @@ func (c *mockCtx) AfterFunc(f func()) (stop func() bool) { } } +// registerChild registers child, a mock context created from c's mockCtxView directly or behind +// other layers, so c's cancellation cancels it before the call that cancels c returns. Unlike an +// AfterFunc registration, cancel unregisters child from its parents only after it has canceled +// every context registered with c, directly or below. +func (c *mockCtx) registerChild(child *mockCtx) (stop func() bool) { + c.mock.mu.Lock() + defer c.mock.mu.Unlock() + if c.err != nil { + // checkParent takes mock.mu, which this goroutine holds. + go child.checkParent() + return func() bool { return false } + } + c.children = append(c.children, child) + return func() bool { + c.mock.mu.Lock() + defer c.mock.mu.Unlock() + i := slices.Index(c.children, child) + if i < 0 { + return false + } + // slices.Delete zeroes the vacated slot, so the backing array stops referencing child. + c.children = slices.Delete(c.children, i, i+1) + return true + } +} + func (c *mockCtx) Deadline() (time.Time, bool) { return c.wallDeadline, true } @@ -863,6 +896,9 @@ func (c *mockCtx) next() time.Time { func (c *mockCtx) fire(_ time.Time) { c.cancel(context.DeadlineExceeded, c.cause) + // Advance().MustWait() waits for fire, so wait for mock descendants that other goroutines are + // still canceling. + <-c.finished } // checkParent cancels c if its parent is done. @@ -872,12 +908,25 @@ func (c *mockCtx) checkParent() { } } -// cancel matches stdlib cancelCtx.cancel(): a nil cause defaults to err. If the parent is already -// done, its error and cause win, as they do when stdlib propagates cancellation. +// cancel cancels c and the contexts registered with it, then unregisters them from their parents. +// It matches stdlib cancelCtx.cancel(): a nil cause defaults to err. If the parent is already done, +// its error and cause win, as they do when stdlib propagates cancellation. +func (c *mockCtx) cancel(err, cause error) { + var stops []func() bool + c.cancelTree(err, cause, &stops) + for _, stop := range stops { + stop() + } +} + +// cancelTree cancels c, then the contexts registered with it, and closes finished once they are +// done. It appends the functions that unregister c and its mock descendants from their parents to +// stops instead of calling them: they call into the parents, which can block in user code, and +// callers of cancel() and fire wait for finished. // -// cancel takes mock.mu. It reads the parent's error and cause before that, because parent.Err() +// cancelTree takes mock.mu. It reads the parent's error and cause before that, because parent.Err() // also takes mock.mu when parent is a context from this Mock. -func (c *mockCtx) cancel(err, cause error) { +func (c *mockCtx) cancelTree(err, cause error, stops *[]func() bool) { if perr := c.parent.Err(); perr != nil { err, cause = perr, context.Cause(c.parent) } @@ -896,16 +945,53 @@ func (c *mockCtx) cancel(err, cause error) { stop := c.stopParent afterFuncs := c.afterFuncs c.afterFuncs = nil + children := c.children + c.children = nil c.mock.mu.Unlock() - // Without mock.mu: derived contexts call c.Err() while canceling. They run before stop, which - // calls into the parent, so a slow parent cannot delay std, which mockCtxView and a cancel() - // that lost a race wait for. Stdlib also cancels children before it removes a context from its - // parent. + // Without mock.mu: derived contexts call c.Err() while canceling. std goes first: children + // read c through its view, which waits for std. for _, a := range afterFuncs { a.f() } + // A child that another goroutine is canceling, for example through its own cancel function, is + // pending: finished waits for it. Waiting here instead could deadlock, because reading a + // child's parent can cancel an ancestor whose tree this goroutine is already running. + var pending []*mockCtx + for _, child := range children { + if child.parent.Err() != nil { + child.cancelTree(context.Canceled, nil, stops) + } + if child.canceling() { + pending = append(pending, child) + } + } + if len(pending) == 0 { + close(c.finished) + } else { + go func() { + for _, child := range pending { + <-child.finished + } + close(c.finished) + }() + } if stop != nil { - stop() + *stops = append(*stops, stop) + } +} + +// canceling reports whether c is canceled and its finished channel is still open. +func (c *mockCtx) canceling() bool { + select { + case <-c.finished: + return false + default: + } + select { + case <-c.done: + return true + default: + return false } } diff --git a/mock_test.go b/mock_test.go index 63facf0..2735325 100644 --- a/mock_test.go +++ b/mock_test.go @@ -546,6 +546,66 @@ func TestWithTimeout_CancelWhileCanceling(t *testing.T) { } } +// When cancel is called while an expiry is canceling the mock contexts created from the context, +// cancel returns after they and their descendants are done. +func TestWithTimeout_CancelDuringExpiryWaitsForMockContexts(t *testing.T) { + t.Parallel() + + // Each case derives contexts from ctx and returns them, a channel that closes while the expiry + // is still running, and a function that lets the expiry finish. + tests := map[string]func(t *testing.T, mClock *quartz.Mock, ctx context.Context) ( + derived []context.Context, expiring <-chan struct{}, release func()){ + // ctx.Done closes before the expiry cancels the mock contexts, and with many of them the + // expiry is still canceling when cancel is called. + "ManyMockContexts": func(t *testing.T, mClock *quartz.Mock, ctx context.Context) ( + []context.Context, <-chan struct{}, func(), + ) { + var derived []context.Context + for range 200 { + nested, cancel := mClock.WithTimeout(ctx, time.Hour) + t.Cleanup(cancel) + child, childCancel := context.WithCancel(nested) + t.Cleanup(childCancel) + derived = append(derived, child) + } + return derived, ctx.Done(), func() {} + }, + } + for name, derive := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + deadlineCause := errors.New("deadline cause") + ctx, cancel := mClock.WithTimeoutCause(context.Background(), time.Second, deadlineCause) + derived, expiring, release := derive(t, mClock, ctx) + + w := mClock.Advance(time.Second) + select { + case <-expiring: + case <-testCtx.Done(): + t.Fatal("timed out waiting for the expiry") + } + cancel() + var live int + for _, c := range derived { + err, cause := c.Err(), context.Cause(c) + if !errors.Is(err, context.DeadlineExceeded) || !errors.Is(cause, deadlineCause) { + live++ + } + } + release() + w.MustWait(testCtx) + if live > 0 { + t.Fatalf("%d of %d derived contexts lacked DeadlineExceeded or the deadline cause "+ + "when cancel returned", live, len(derived)) + } + }) + } +} + func TestWithDeadline_Deadline(t *testing.T) { t.Parallel() From 39d89a27e00071c2304e7284164ead1c68a43e04 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 15:51:55 +0000 Subject: [PATCH 28/43] fix: cancel mock contexts behind a stdlib cancel layer synchronously A mock context created from context.WithCancel of a mock context registered only with that layer. Canceling the outer mock context cancels the layer synchronously, but stdlib notifies the inner mock context on a goroutine, so after cancel() or Advance().MustWait() its descendants were still live in about 1990 of 2000 runs. The inner context now also registers with the mock context it finds through Value, so that context's cancellation reaches it on the same goroutine after std has canceled the layer between them. The layer can still be canceled on its own, so the context keeps its registration with the layer too. The stdlib goroutine can claim the inner context first; the previous commit's pending children cover that. --- mock.go | 41 +++++++++++++++++++++++++++-------------- mock_test.go | 41 ++++++++++++++++++++++++++++++++--------- 2 files changed, 59 insertions(+), 23 deletions(-) diff --git a/mock.go b/mock.go index d0ab0c8..95cdcc8 100644 --- a/mock.go +++ b/mock.go @@ -222,13 +222,22 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de c.cancel(context.DeadlineExceeded, cause) } var stop func() bool - // Register with the nearest mock context, also behind layers that forward Done and Value such - // as context.WithValue, so c is canceled before that context's cancellation returns, as stdlib - // children are. A different Done channel means a layer between them can be canceled on its own, - // as in stdlib's parentCancelCtx, so c registers with parent instead. - if p, ok := parent.Value(mockCtxViewKey{}).(*mockCtxView); ok && parent.Done() == p.std.Done() { + // Register with the nearest mock context, also behind layers that forward Value such as + // context.WithValue, so c is canceled before that context's cancellation returns, as stdlib + // children are. When Done differs, a layer between them, such as context.WithCancel, can + // also be canceled on its own, as in stdlib's parentCancelCtx, so c registers with parent as + // well. + p, ok := parent.Value(mockCtxViewKey{}).(*mockCtxView) + switch { + case ok && parent.Done() == p.std.Done(): stop = p.c.registerChild(c) - } else { + case ok: + stopChild, stopAfterFunc := p.c.registerChild(c), context.AfterFunc(parent, c.checkParent) + stop = func() bool { + stopped := stopChild() + return stopAfterFunc() || stopped + } + default: stop = context.AfterFunc(parent, c.checkParent) } c.mock.mu.Lock() @@ -840,10 +849,12 @@ func (v *mockCtxView) Deadline() (time.Time, bool) { } // Done, Err, and Value check the parent first because stdlib contexts are done as soon as their -// parent is, but c may be notified from a goroutine: always when the parent is not a mock context, -// and when a mock parent was canceled before c registered with it. Value is included because -// context.Cause reads the cause through it. A Done channel obtained before the parent was canceled -// closes when that goroutine runs. +// parent is, but c may learn of its parent's cancellation from a goroutine. Only the cancellation of +// the mock context that c registered with reaches c on the canceling goroutine. A cancellation that +// starts anywhere else, such as at a parent from the context package or at a layer between c and +// that mock context, reaches c from a goroutine, as does one that happened before c registered. +// Value is included because context.Cause reads the cause through it. A Done channel obtained +// before the parent was canceled closes when that goroutine runs. func (v *mockCtxView) Done() <-chan struct{} { v.checkParent() return v.std.Done() @@ -949,13 +960,15 @@ func (c *mockCtx) cancelTree(err, cause error, stops *[]func() bool) { c.children = nil c.mock.mu.Unlock() // Without mock.mu: derived contexts call c.Err() while canceling. std goes first: children - // read c through its view, which waits for std. + // read c through its view, which waits for std, and a stdlib layer between c and a child is + // canceled through std. for _, a := range afterFuncs { a.f() } - // A child that another goroutine is canceling, for example through its own cancel function, is - // pending: finished waits for it. Waiting here instead could deadlock, because reading a - // child's parent can cancel an ancestor whose tree this goroutine is already running. + // A child that another goroutine is canceling, for example stdlib's AfterFunc goroutine for a + // layer that std just canceled, is pending: finished waits for it. Waiting here instead could + // deadlock, because reading a child's parent can cancel an ancestor whose tree this goroutine + // is already running. var pending []*mockCtx for _, child := range children { if child.parent.Err() != nil { diff --git a/mock_test.go b/mock_test.go index 2735325..04ad071 100644 --- a/mock_test.go +++ b/mock_test.go @@ -8,6 +8,7 @@ import ( "os" "runtime/pprof" "strings" + "sync" "testing" "time" @@ -428,12 +429,13 @@ func descendants(t *testing.T, mClock *quartz.Mock, ctx context.Context) map[str return withCancel(nested) } return map[string]context.Context{ - "Direct": withCancel(ctx), - "WithValue": withCancel(context.WithValue(ctx, ctxKey{}, "v")), - "Embedding": withCancel(embeddingCtx{ctx}), - "Mock": throughMock(ctx), - "MockWithValue": throughMock(context.WithValue(ctx, ctxKey{}, "v")), - "MockEmbedding": throughMock(embeddingCtx{ctx}), + "Direct": withCancel(ctx), + "WithValue": withCancel(context.WithValue(ctx, ctxKey{}, "v")), + "Embedding": withCancel(embeddingCtx{ctx}), + "Mock": throughMock(ctx), + "MockWithValue": throughMock(context.WithValue(ctx, ctxKey{}, "v")), + "MockEmbedding": throughMock(embeddingCtx{ctx}), + "MockBehindWithCancel": throughMock(withCancel(ctx)), } } @@ -547,12 +549,13 @@ func TestWithTimeout_CancelWhileCanceling(t *testing.T) { } // When cancel is called while an expiry is canceling the mock contexts created from the context, -// cancel returns after they and their descendants are done. +// cancel returns after they and their descendants are done, without waiting for the expiry to +// unregister them from their parents, which can block. func TestWithTimeout_CancelDuringExpiryWaitsForMockContexts(t *testing.T) { t.Parallel() // Each case derives contexts from ctx and returns them, a channel that closes while the expiry - // is still running, and a function that lets the expiry finish. + // is still running, and an idempotent function that lets the expiry finish. tests := map[string]func(t *testing.T, mClock *quartz.Mock, ctx context.Context) ( derived []context.Context, expiring <-chan struct{}, release func()){ // ctx.Done closes before the expiry cancels the mock contexts, and with many of them the @@ -570,6 +573,22 @@ func TestWithTimeout_CancelDuringExpiryWaitsForMockContexts(t *testing.T) { } return derived, ctx.Done(), func() {} }, + // The expiry blocks while it unregisters the mock context from parent. + "BlockingUnregister": func(t *testing.T, mClock *quartz.Mock, ctx context.Context) ( + []context.Context, <-chan struct{}, func(), + ) { + parent := &stallingParent{ + Context: ctx, + stopping: make(chan struct{}), + release: make(chan struct{}), + } + nested, cancel := mClock.WithTimeout(parent, time.Hour) + t.Cleanup(cancel) + child, childCancel := context.WithCancel(nested) + t.Cleanup(childCancel) + release := sync.OnceFunc(func() { close(parent.release) }) + return []context.Context{child}, parent.stopping, release + }, } for name, derive := range tests { t.Run(name, func(t *testing.T) { @@ -581,6 +600,8 @@ func TestWithTimeout_CancelDuringExpiryWaitsForMockContexts(t *testing.T) { deadlineCause := errors.New("deadline cause") ctx, cancel := mClock.WithTimeoutCause(context.Background(), time.Second, deadlineCause) derived, expiring, release := derive(t, mClock, ctx) + // Without the release, a failure before it would leave the test's cleanup blocked. + defer release() w := mClock.Advance(time.Second) select { @@ -588,6 +609,8 @@ func TestWithTimeout_CancelDuringExpiryWaitsForMockContexts(t *testing.T) { case <-testCtx.Done(): t.Fatal("timed out waiting for the expiry") } + // If cancel waits for the blocked expiry, go test -timeout reports it by naming this + // test. cancel() var live int for _, c := range derived { @@ -742,7 +765,7 @@ func TestWithTimeout_ParentCanceledClosesDone(t *testing.T) { return context.WithCancel(context.Background()) }, // The stdlib layer can be canceled without the mock context behind it, so the child must - // not register with that mock context. + // also register with the layer. "WithCancelOfMock": func(t *testing.T, mClock *quartz.Mock) (context.Context, context.CancelFunc) { mockParent, cancel := mClock.WithTimeout(context.Background(), time.Hour) t.Cleanup(cancel) From c79c6f5d3d14b68e70540ec6a6d309664431761c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 15:52:18 +0000 Subject: [PATCH 29/43] docs: name what to wait on when a stdlib parent cancels "Wait on Done instead of checking Err" read as advice about the mock context itself, whose Err reports a canceled parent at once. The Clock and README docs now name the derived context's Done and a Done channel obtained earlier, and say the delay applies when the cancellation starts at a parent from the context package, not when it starts at a mock context. Lines added on this branch now fit 100 columns with tabs at width 4. --- README.md | 12 +++++++----- clock.go | 10 ++++++---- mock.go | 12 ++++++------ mock_test.go | 17 +++++++++++------ 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 5f83abb..7890bdd 100644 --- a/README.md +++ b/README.md @@ -334,11 +334,13 @@ parent deadlines to mock time. When `cancel()` returns, or `Advance(...).MustWait()` returns after the clock reaches the deadline, the context and contexts derived from it are done, so the test can check them right away. When a -parent from the `context` package is canceled, `ctx.Err()` reports it at once, but contexts derived -from `ctx` are canceled a moment later on another goroutine, and a `ctx.Done()` channel obtained -before closes only then; wait on `Done()` instead of checking `Err()`. Contexts that are 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. +cancellation starts at a parent from the `context` package, for example through its cancel function +or its wall-clock deadline, `ctx.Err()` reports it at once, but contexts derived from `ctx` are +canceled a moment later on another goroutine, and a `ctx.Done()` channel obtained earlier closes +only then. Wait on the derived context's `Done()` or on that earlier channel instead of checking the +derived context's `Err()`. Contexts that are 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`. diff --git a/clock.go b/clock.go index 535f510..57dc2b5 100644 --- a/clock.go +++ b/clock.go @@ -49,10 +49,12 @@ type Clock interface { // timeout it passed in, not from ctx.Deadline(). // // With a Mock clock, the context and contexts derived from it are done when the cancel function - // returns, or when Advance(...).MustWait() returns after the clock reached d. When a parent - // from the context package is canceled, Err reports it at once, but contexts derived from the - // returned context are canceled a moment later on another goroutine, and a Done channel - // obtained before closes only then; wait on Done instead of checking Err. + // returns, or when Advance(...).MustWait() returns after the clock reached d. When a + // cancellation starts at a parent from the context package, for example through its cancel + // function or its deadline, Err reports it at once, but contexts derived from the returned + // context are canceled a moment later on another goroutine, and a Done channel obtained + // earlier closes only then. Wait on the derived context's Done or on that earlier channel + // instead of checking the derived context's Err. WithDeadline(parent context.Context, d time.Time, tags ...string) (context.Context, context.CancelFunc) // WithDeadlineCause is like WithDeadline and records cause when this Clock reaches d. diff --git a/mock.go b/mock.go index 95cdcc8..20035d1 100644 --- a/mock.go +++ b/mock.go @@ -849,12 +849,12 @@ func (v *mockCtxView) Deadline() (time.Time, bool) { } // Done, Err, and Value check the parent first because stdlib contexts are done as soon as their -// parent is, but c may learn of its parent's cancellation from a goroutine. Only the cancellation of -// the mock context that c registered with reaches c on the canceling goroutine. A cancellation that -// starts anywhere else, such as at a parent from the context package or at a layer between c and -// that mock context, reaches c from a goroutine, as does one that happened before c registered. -// Value is included because context.Cause reads the cause through it. A Done channel obtained -// before the parent was canceled closes when that goroutine runs. +// parent is, but c may learn of its parent's cancellation from a goroutine. Only the cancellation +// of the mock context that c registered with reaches c on the canceling goroutine. A cancellation +// that starts anywhere else, such as at a parent from the context package or at a layer between c +// and that mock context, reaches c from a goroutine, as does one that happened before c +// registered. Value is included because context.Cause reads the cause through it. A Done channel +// obtained before the parent was canceled closes when that goroutine runs. func (v *mockCtxView) Done() <-chan struct{} { v.checkParent() return v.std.Done() diff --git a/mock_test.go b/mock_test.go index 04ad071..bbf5939 100644 --- a/mock_test.go +++ b/mock_test.go @@ -340,8 +340,8 @@ func TestTickerFunc_MockContextCanceled(t *testing.T) { } } -// A mock context expires when Advance reaches its deadline. By the time MustWait returns, it and the -// contexts derived from it report DeadlineExceeded and the deadline's cause. +// A mock context expires when Advance reaches its deadline. By the time MustWait returns, it and +// the contexts derived from it report DeadlineExceeded and the deadline's cause. func TestWithTimeout_FiresOnAdvance(t *testing.T) { t.Parallel() @@ -417,7 +417,9 @@ type embeddingCtx struct{ context.Context } // each done before the call that cancels ctx returns. The Mock cases derive a mock context and // return a stdlib context derived from it: reading the mock context itself would check its parent // and cancel it, hiding a missed notification. -func descendants(t *testing.T, mClock *quartz.Mock, ctx context.Context) map[string]context.Context { +func descendants( + t *testing.T, mClock *quartz.Mock, ctx context.Context, +) map[string]context.Context { withCancel := func(parent context.Context) context.Context { child, cancel := context.WithCancel(parent) t.Cleanup(cancel) @@ -760,13 +762,15 @@ func TestWithTimeout_ParentCanceled(t *testing.T) { func TestWithTimeout_ParentCanceledClosesDone(t *testing.T) { t.Parallel() - tests := map[string]func(t *testing.T, mClock *quartz.Mock) (context.Context, context.CancelFunc){ + tests := map[string]func(*testing.T, *quartz.Mock) (context.Context, context.CancelFunc){ "Stdlib": func(*testing.T, *quartz.Mock) (context.Context, context.CancelFunc) { return context.WithCancel(context.Background()) }, // The stdlib layer can be canceled without the mock context behind it, so the child must // also register with the layer. - "WithCancelOfMock": func(t *testing.T, mClock *quartz.Mock) (context.Context, context.CancelFunc) { + "WithCancelOfMock": func( + t *testing.T, mClock *quartz.Mock, + ) (context.Context, context.CancelFunc) { mockParent, cancel := mClock.WithTimeout(context.Background(), time.Hour) t.Cleanup(cancel) return context.WithCancel(mockParent) @@ -787,7 +791,8 @@ func TestWithTimeout_ParentCanceledClosesDone(t *testing.T) { ctxDone := ctx.Done() parentCancel() - for name, done := range map[string]<-chan struct{}{"ctx": ctxDone, "child": child.Done()} { + dones := map[string]<-chan struct{}{"ctx": ctxDone, "child": child.Done()} + for name, done := range dones { select { case <-done: case <-testCtx.Done(): From ce79084855f4290bcf45b93c0d01a23d2c0d73a5 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 16:29:25 +0000 Subject: [PATCH 30/43] fix: do not wait for detached mock contexts when canceling context.WithoutCancel forwards Value, so a mock context created behind it registers with the original mock context. Canceling that context correctly left the detached child alone, but still added the child to pending when another goroutine was canceling it, so cancel() waited for independent work. The reproducer stalled 20 of 20 runs. Only a child whose parent is canceled is part of the cancellation, so only such a child can be pending. --- mock.go | 15 ++++++++----- mock_test.go | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/mock.go b/mock.go index 20035d1..95b7813 100644 --- a/mock.go +++ b/mock.go @@ -965,15 +965,18 @@ func (c *mockCtx) cancelTree(err, cause error, stops *[]func() bool) { for _, a := range afterFuncs { a.f() } - // A child that another goroutine is canceling, for example stdlib's AfterFunc goroutine for a - // layer that std just canceled, is pending: finished waits for it. Waiting here instead could - // deadlock, because reading a child's parent can cancel an ancestor whose tree this goroutine - // is already running. + // A child whose parent is canceled is part of this cancellation. If another goroutine is + // already canceling it, for example stdlib's AfterFunc goroutine for a layer that std just + // canceled, it is pending: finished waits for it. Waiting here instead could deadlock, because + // reading a child's parent can cancel an ancestor whose tree this goroutine is already running. + // A child behind a layer that does not cancel with c, such as context.WithoutCancel, is not + // part of it even when it is being canceled. var pending []*mockCtx for _, child := range children { - if child.parent.Err() != nil { - child.cancelTree(context.Canceled, nil, stops) + if child.parent.Err() == nil { + continue } + child.cancelTree(context.Canceled, nil, stops) if child.canceling() { pending = append(pending, child) } diff --git a/mock_test.go b/mock_test.go index bbf5939..4853052 100644 --- a/mock_test.go +++ b/mock_test.go @@ -631,6 +631,69 @@ func TestWithTimeout_CancelDuringExpiryWaitsForMockContexts(t *testing.T) { } } +// blockingErrCtx is a layer whose Err blocks after a token is sent on arm: the next call closes +// entered and returns only after release is closed, so a test can hold a cancellation in progress +// where it reads the layer. +type blockingErrCtx struct { + context.Context + arm chan struct{} + entered chan struct{} + release chan struct{} +} + +func (p *blockingErrCtx) Err() error { + select { + case <-p.arm: + close(p.entered) + <-p.release + default: + } + return p.Context.Err() +} + +// A mock context behind context.WithoutCancel is not part of its ancestor's cancellation, so +// canceling the ancestor does not wait for it, even while another goroutine is canceling it. +func TestWithTimeout_CancelDoesNotWaitForDetachedContexts(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + + mClock := quartz.NewMock(t) + root, cancelRoot := mClock.WithTimeout(context.Background(), time.Hour) + detached, cancelDetached := mClock.WithTimeout(context.WithoutCancel(root), time.Hour) + layer := &blockingErrCtx{ + Context: detached, + arm: make(chan struct{}, 1), + entered: make(chan struct{}), + release: make(chan struct{}), + } + _, cancelLast := mClock.WithTimeout(layer, time.Hour) + defer cancelLast() + kept, cancelKept := mClock.WithTimeout(context.WithoutCancel(root), time.Hour) + defer cancelKept() + + // Hold the detached context's cancellation where it reads layer. + layer.arm <- struct{}{} + detachedDone := make(chan struct{}) + go func() { + cancelDetached() + close(detachedDone) + }() + select { + case <-layer.entered: + case <-testCtx.Done(): + t.Fatal("timed out waiting for the detached cancellation to read the layer") + } + // If cancelRoot waits for the detached cancellation, go test -timeout reports it by naming this + // test. + cancelRoot() + close(layer.release) + <-detachedDone + if err := kept.Err(); err != nil { + t.Fatalf("expected the other context behind WithoutCancel to stay live, got %v", err) + } +} + func TestWithDeadline_Deadline(t *testing.T) { t.Parallel() From 2b3ae44ff41f4f03b695c136fd3b47b3276a7271 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 16:29:38 +0000 Subject: [PATCH 31/43] docs(mock.go): require non-blocking Err from layers between contexts The tree reads each child's parent on the canceling goroutine, which calls Err of every layer between the two mock contexts, including user types. The context package makes the same assumption. --- mock.go | 1 + 1 file changed, 1 insertion(+) diff --git a/mock.go b/mock.go index 95b7813..4dcdb13 100644 --- a/mock.go +++ b/mock.go @@ -973,6 +973,7 @@ func (c *mockCtx) cancelTree(err, cause error, stops *[]func() bool) { // part of it even when it is being canceled. var pending []*mockCtx for _, child := range children { + // As in the context package, Err of the layers between c and child must not block. if child.parent.Err() == nil { continue } From 57e1da59dbeb1582f23fa8a4969e50ce902d8d37 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 16:50:02 +0000 Subject: [PATCH 32/43] fix(mock.go): read a canceled mock context's parent only once checkParent read the parent even when c was already canceled, and the cancel that followed read it twice more through Err and Cause. Each of those reads did the same one level up, so Err on a canceled chain of mock contexts cost about 4^k parent reads: 56ms at depth 10, and a canceled chain of 30 never finished. A canceled c now returns before reading the parent, which makes the read constant time at any depth. cancelTree still reads the parent of a canceled c, but those reads are now constant time too. --- mock.go | 9 ++++++++- mock_test.go | 24 +++++++++++++++--------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/mock.go b/mock.go index 4dcdb13..f9e88bb 100644 --- a/mock.go +++ b/mock.go @@ -912,8 +912,15 @@ func (c *mockCtx) fire(_ time.Time) { <-c.finished } -// checkParent cancels c if its parent is done. +// checkParent cancels c if its parent is done. A canceled c returns without reading the parent: +// cancel reads the parent again, and each read checks the grandparent the same way, so reading a +// canceled chain of mock contexts would cost parent reads exponential in its depth. func (c *mockCtx) checkParent() { + select { + case <-c.done: + return + default: + } if c.parent.Err() != nil { c.cancel(context.Canceled, nil) } diff --git a/mock_test.go b/mock_test.go index 4853052..287ef64 100644 --- a/mock_test.go +++ b/mock_test.go @@ -416,7 +416,9 @@ type embeddingCtx struct{ context.Context } // descendants returns contexts derived from ctx directly and through layers that do not cancel, // each done before the call that cancels ctx returns. The Mock cases derive a mock context and // return a stdlib context derived from it: reading the mock context itself would check its parent -// and cancel it, hiding a missed notification. +// and cancel it, hiding a missed notification. If reads of a canceled chain of mock contexts cost +// time exponential in its depth again, canceling ctx hangs on DeepMockChain, and go test -timeout +// reports it by naming the test. func descendants( t *testing.T, mClock *quartz.Mock, ctx context.Context, ) map[string]context.Context { @@ -425,19 +427,23 @@ func descendants( t.Cleanup(cancel) return child } - throughMock := func(parent context.Context) context.Context { - nested, cancel := mClock.WithTimeout(parent, time.Hour) - t.Cleanup(cancel) - return withCancel(nested) + throughMocks := func(parent context.Context, depth int) context.Context { + for range depth { + nested, cancel := mClock.WithTimeout(parent, time.Hour) + t.Cleanup(cancel) + parent = nested + } + return withCancel(parent) } return map[string]context.Context{ "Direct": withCancel(ctx), "WithValue": withCancel(context.WithValue(ctx, ctxKey{}, "v")), "Embedding": withCancel(embeddingCtx{ctx}), - "Mock": throughMock(ctx), - "MockWithValue": throughMock(context.WithValue(ctx, ctxKey{}, "v")), - "MockEmbedding": throughMock(embeddingCtx{ctx}), - "MockBehindWithCancel": throughMock(withCancel(ctx)), + "Mock": throughMocks(ctx, 1), + "MockWithValue": throughMocks(context.WithValue(ctx, ctxKey{}, "v"), 1), + "MockEmbedding": throughMocks(embeddingCtx{ctx}, 1), + "MockBehindWithCancel": throughMocks(withCancel(ctx), 1), + "DeepMockChain": throughMocks(ctx, 30), } } From 837afeff7d9ed2b33d4a1a4207c54c1eb32afc0a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 17:00:26 +0000 Subject: [PATCH 33/43] docs(mock.go): say which stdlib path reads a parent while canceling The comment said the context package makes the same assumption about non-blocking layers. Stdlib calls a parent's Err and Value on the canceling goroutine only for a parent with its own AfterFunc method; children of a layer over a cancelCtx attach to that cancelCtx. The rule now also names Value, which Cause calls. --- mock.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/mock.go b/mock.go index f9e88bb..4ea6eec 100644 --- a/mock.go +++ b/mock.go @@ -980,7 +980,10 @@ func (c *mockCtx) cancelTree(err, cause error, stops *[]func() bool) { // part of it even when it is being canceled. var pending []*mockCtx for _, child := range children { - // As in the context package, Err of the layers between c and child must not block. + // This goroutine calls Err and Value of the layers between c and child, here and through + // Cause in child.cancelTree, so they must not block. Stdlib calls a parent's Err and Value + // on the canceling goroutine only for a parent with its own AfterFunc method; it attaches a + // child of a layer over a cancelCtx to that cancelCtx instead. if child.parent.Err() == nil { continue } From 095ac9e03d79cded58b64565eb1e9451390438c0 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 17:00:26 +0000 Subject: [PATCH 34/43] test(mock_test.go): say what the detached-context test protects The test blocks in a layer's Err, which mock contexts do not support. The blocking only holds the detached cancellation in progress; the property under test is that canceling the ancestor never reads that layer, so a stalled detached subtree does not delay it. --- mock_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mock_test.go b/mock_test.go index 287ef64..422984a 100644 --- a/mock_test.go +++ b/mock_test.go @@ -657,8 +657,10 @@ func (p *blockingErrCtx) Err() error { return p.Context.Err() } -// A mock context behind context.WithoutCancel is not part of its ancestor's cancellation, so -// canceling the ancestor does not wait for it, even while another goroutine is canceling it. +// A mock context behind context.WithoutCancel is not part of its ancestor's cancellation, so a +// stalled cancellation of that detached subtree does not delay canceling the ancestor. The layer's +// Err blocks, which mock contexts do not support, only to hold the detached cancellation in +// progress: canceling the ancestor never reads that layer. func TestWithTimeout_CancelDoesNotWaitForDetachedContexts(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) From ce01780c20f5822cba218461dc83e0cf16bdec1d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 17:42:38 +0000 Subject: [PATCH 35/43] test(mock_test.go): cover MustWait waiting for mock contexts in progress Removing fire's wait for finished failed no test, although Advance().MustWait() relies on it: when the expiry cancels a context.WithCancel layer, stdlib starts a goroutine that cancels the mock contexts behind it, and MustWait returned while those were still being canceled. TestWithTimeout_WaitsForMockContextsBeingCanceled now has a Cancel case and a MustWait case over chains of 20 mock contexts, long enough that removing fire's wait fails the MustWait case in 92 to 96 of 100 runs and removing cancel's wait fails the Cancel case in 100 of 100. The blocking unregister case checked that cancel does not wait for a parent's stop function, which TestWithTimeout_CancelWhileCanceling checks, so it moves there as ExpiryUnregisteringDescendant. --- mock_test.go | 168 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 100 insertions(+), 68 deletions(-) diff --git a/mock_test.go b/mock_test.go index 422984a..05be651 100644 --- a/mock_test.go +++ b/mock_test.go @@ -450,7 +450,7 @@ func descendants( // stallingParent is a parent whose Done never closes and whose AfterFunc stop function blocks until // release is closed. A mock context calls that stop function while it is being canceled, so a test // can act while another goroutine is canceling it. Canceling the embedded context makes Err and -// Cause report the parent done without notifying the mock context. +// Cause report the parent done, but p never calls the functions passed to its AfterFunc. type stallingParent struct { context.Context stopping chan struct{} @@ -469,30 +469,40 @@ func (p *stallingParent) AfterFunc(func()) func() bool { } // When cancel is called while another goroutine is canceling the context and is blocked in the -// parent, cancel does not wait for the parent, and the context and its derived contexts report that -// goroutine's error and cause. +// parent of the context or of a mock context derived from it, cancel does not wait for that parent, +// and the context and its derived contexts report that goroutine's error and cause. func TestWithTimeout_CancelWhileCanceling(t *testing.T) { t.Parallel() deadlineCause := errors.New("deadline cause") parentCause := errors.New("parent cause") + expire := func(testCtx context.Context, mClock *quartz.Mock, + _ context.CancelFunc, _ context.CancelCauseFunc, + ) func() { + w := mClock.Advance(time.Second) + return func() { w.MustWait(testCtx) } + } tests := map[string]struct { // startCanceling starts canceling the context on another goroutine and returns a function // that waits for that goroutine. startCanceling func(testCtx context.Context, mClock *quartz.Mock, cancel context.CancelFunc, cancelParent context.CancelCauseFunc) (wait func()) - wantErr error - wantCause error + // stallDescendant puts the stalling parent between the context and a mock context derived + // from it, so the cancellation blocks while it unregisters that mock context instead. + stallDescendant bool + wantErr error + wantCause error }{ "Expiry": { - startCanceling: func(testCtx context.Context, mClock *quartz.Mock, - _ context.CancelFunc, _ context.CancelCauseFunc, - ) func() { - w := mClock.Advance(time.Second) - return func() { w.MustWait(testCtx) } - }, - wantErr: context.DeadlineExceeded, - wantCause: deadlineCause, + startCanceling: expire, + wantErr: context.DeadlineExceeded, + wantCause: deadlineCause, + }, + "ExpiryUnregisteringDescendant": { + startCanceling: expire, + stallDescendant: true, + wantErr: context.DeadlineExceeded, + wantCause: deadlineCause, }, // The first cancel finds the parent done and takes its error and cause. "ParentCanceled": { @@ -521,12 +531,26 @@ func TestWithTimeout_CancelWhileCanceling(t *testing.T) { root, cancelRoot := context.WithCancelCause(context.Background()) defer cancelRoot(nil) parent := &stallingParent{ - Context: root, stopping: make(chan struct{}), release: make(chan struct{}), } - ctx, cancel := mClock.WithTimeoutCause(parent, time.Second, deadlineCause) - child, childCancel := context.WithCancel(ctx) + release := sync.OnceFunc(func() { close(parent.release) }) + // Without the release, a failure before it would leave the test's cleanup blocked. + defer release() + var ctx, derivedParent context.Context + var cancel context.CancelFunc + if tt.stallDescendant { + ctx, cancel = mClock.WithTimeoutCause(root, time.Second, deadlineCause) + parent.Context = ctx + nested, nestedCancel := mClock.WithTimeout(parent, time.Hour) + t.Cleanup(nestedCancel) + derivedParent = nested + } else { + parent.Context = root + ctx, cancel = mClock.WithTimeoutCause(parent, time.Second, deadlineCause) + derivedParent = ctx + } + child, childCancel := context.WithCancel(derivedParent) defer childCancel() wait := tt.startCanceling(testCtx, mClock, cancel, cancelRoot) @@ -541,7 +565,7 @@ func TestWithTimeout_CancelWhileCanceling(t *testing.T) { // Read while the other goroutine is still in the parent's stop function. ctxErr, ctxCause := ctx.Err(), context.Cause(ctx) childErr, childCause := child.Err(), context.Cause(child) - close(parent.release) + release() wait() if !errors.Is(ctxErr, tt.wantErr) || !errors.Is(ctxCause, tt.wantCause) { @@ -556,49 +580,54 @@ func TestWithTimeout_CancelWhileCanceling(t *testing.T) { } } -// When cancel is called while an expiry is canceling the mock contexts created from the context, -// cancel returns after they and their descendants are done, without waiting for the expiry to -// unregister them from their parents, which can block. -func TestWithTimeout_CancelDuringExpiryWaitsForMockContexts(t *testing.T) { +// When cancel or the expiry's MustWait returns, the mock contexts derived from the context and their +// descendants are done, also those that another goroutine is still canceling. +func TestWithTimeout_WaitsForMockContextsBeingCanceled(t *testing.T) { t.Parallel() - // Each case derives contexts from ctx and returns them, a channel that closes while the expiry - // is still running, and an idempotent function that lets the expiry finish. - tests := map[string]func(t *testing.T, mClock *quartz.Mock, ctx context.Context) ( - derived []context.Context, expiring <-chan struct{}, release func()){ + tests := map[string]struct { + // layer returns the parent of one chain of mock contexts derived from ctx. + layer func(t *testing.T, ctx context.Context) context.Context + // expire advances the clock to ctx's deadline and returns the expiry's waiter once the call + // under test has returned. + expire func(t *testing.T, testCtx context.Context, mClock *quartz.Mock, + ctx context.Context, cancel context.CancelFunc) quartz.AdvanceWaiter + }{ // ctx.Done closes before the expiry cancels the mock contexts, and with many of them the // expiry is still canceling when cancel is called. - "ManyMockContexts": func(t *testing.T, mClock *quartz.Mock, ctx context.Context) ( - []context.Context, <-chan struct{}, func(), - ) { - var derived []context.Context - for range 200 { - nested, cancel := mClock.WithTimeout(ctx, time.Hour) - t.Cleanup(cancel) - child, childCancel := context.WithCancel(nested) - t.Cleanup(childCancel) - derived = append(derived, child) - } - return derived, ctx.Done(), func() {} + "Cancel": { + layer: func(_ *testing.T, ctx context.Context) context.Context { return ctx }, + expire: func(t *testing.T, testCtx context.Context, mClock *quartz.Mock, + ctx context.Context, cancel context.CancelFunc, + ) quartz.AdvanceWaiter { + w := mClock.Advance(time.Second) + select { + case <-ctx.Done(): + case <-testCtx.Done(): + t.Fatal("timed out waiting for the expiry") + } + cancel() + return w + }, }, - // The expiry blocks while it unregisters the mock context from parent. - "BlockingUnregister": func(t *testing.T, mClock *quartz.Mock, ctx context.Context) ( - []context.Context, <-chan struct{}, func(), - ) { - parent := &stallingParent{ - Context: ctx, - stopping: make(chan struct{}), - release: make(chan struct{}), - } - nested, cancel := mClock.WithTimeout(parent, time.Hour) - t.Cleanup(cancel) - child, childCancel := context.WithCancel(nested) - t.Cleanup(childCancel) - release := sync.OnceFunc(func() { close(parent.release) }) - return []context.Context{child}, parent.stopping, release + // The expiry cancels each layer, which starts a stdlib goroutine that cancels the chain + // derived from the layer, so the expiry finds some chains already being canceled. + "MustWait": { + layer: func(t *testing.T, ctx context.Context) context.Context { + layer, cancel := context.WithCancel(ctx) + t.Cleanup(cancel) + return layer + }, + expire: func(_ *testing.T, testCtx context.Context, mClock *quartz.Mock, + _ context.Context, _ context.CancelFunc, + ) quartz.AdvanceWaiter { + w := mClock.Advance(time.Second) + w.MustWait(testCtx) + return w + }, }, } - for name, derive := range tests { + for name, tt := range tests { t.Run(name, func(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -607,19 +636,23 @@ func TestWithTimeout_CancelDuringExpiryWaitsForMockContexts(t *testing.T) { mClock := quartz.NewMock(t) deadlineCause := errors.New("deadline cause") ctx, cancel := mClock.WithTimeoutCause(context.Background(), time.Second, deadlineCause) - derived, expiring, release := derive(t, mClock, ctx) - // Without the release, a failure before it would leave the test's cleanup blocked. - defer release() - - w := mClock.Advance(time.Second) - select { - case <-expiring: - case <-testCtx.Done(): - t.Fatal("timed out waiting for the expiry") + defer cancel() + // The chains are long enough that the call under test usually returns while one is still + // being canceled if it does not wait for it. + var derived []context.Context + for range 20 { + parent := tt.layer(t, ctx) + for range 20 { + nested, nestedCancel := mClock.WithTimeout(parent, time.Hour) + t.Cleanup(nestedCancel) + parent = nested + } + child, childCancel := context.WithCancel(parent) + t.Cleanup(childCancel) + derived = append(derived, child) } - // If cancel waits for the blocked expiry, go test -timeout reports it by naming this - // test. - cancel() + + w := tt.expire(t, testCtx, mClock, ctx, cancel) var live int for _, c := range derived { err, cause := c.Err(), context.Cause(c) @@ -627,11 +660,10 @@ func TestWithTimeout_CancelDuringExpiryWaitsForMockContexts(t *testing.T) { live++ } } - release() w.MustWait(testCtx) if live > 0 { - t.Fatalf("%d of %d derived contexts lacked DeadlineExceeded or the deadline cause "+ - "when cancel returned", live, len(derived)) + t.Fatalf("%d of %d derived contexts lacked DeadlineExceeded or the deadline cause", + live, len(derived)) } }) } From 4049374642f7c5ab43680a5d67499f86e14e1028 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 17:43:44 +0000 Subject: [PATCH 36/43] test(mock_test.go): check deep mock chains in their own test The 30-deep chain was a row in descendants, which also feeds checks of which contexts are done, and its comment described the regression it guards as something that had happened before. It now has its own test that cancels the root of the chain: if reading a canceled chain costs parent reads exponential in its depth, cancel hangs, and go test -timeout names the test. --- mock_test.go | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/mock_test.go b/mock_test.go index 05be651..20262cf 100644 --- a/mock_test.go +++ b/mock_test.go @@ -407,6 +407,27 @@ func TestWithTimeout_CancelCancelsDerived(t *testing.T) { } } +// Canceling a chain of mock contexts reads each context's parent a bounded number of times. If the +// reads grow exponentially with the chain's depth, cancel hangs, and go test -timeout reports it by +// naming this test. +func TestWithTimeout_CancelDeepMockChain(t *testing.T) { + t.Parallel() + + mClock := quartz.NewMock(t) + root, cancel := mClock.WithTimeout(context.Background(), time.Hour) + ctx := root + for range 30 { + nested, nestedCancel := mClock.WithTimeout(ctx, time.Hour) + t.Cleanup(nestedCancel) + ctx = nested + } + + cancel() + if !errors.Is(ctx.Err(), context.Canceled) { + t.Errorf("expected the deepest context Canceled, got %v", ctx.Err()) + } +} + type ctxKey struct{} // embeddingCtx is a user-defined context that embeds its parent, as wrappers in logging and tracing @@ -416,9 +437,7 @@ type embeddingCtx struct{ context.Context } // descendants returns contexts derived from ctx directly and through layers that do not cancel, // each done before the call that cancels ctx returns. The Mock cases derive a mock context and // return a stdlib context derived from it: reading the mock context itself would check its parent -// and cancel it, hiding a missed notification. If reads of a canceled chain of mock contexts cost -// time exponential in its depth again, canceling ctx hangs on DeepMockChain, and go test -timeout -// reports it by naming the test. +// and cancel it, hiding a missed notification. func descendants( t *testing.T, mClock *quartz.Mock, ctx context.Context, ) map[string]context.Context { @@ -427,23 +446,19 @@ func descendants( t.Cleanup(cancel) return child } - throughMocks := func(parent context.Context, depth int) context.Context { - for range depth { - nested, cancel := mClock.WithTimeout(parent, time.Hour) - t.Cleanup(cancel) - parent = nested - } - return withCancel(parent) + throughMock := func(parent context.Context) context.Context { + nested, cancel := mClock.WithTimeout(parent, time.Hour) + t.Cleanup(cancel) + return withCancel(nested) } return map[string]context.Context{ "Direct": withCancel(ctx), "WithValue": withCancel(context.WithValue(ctx, ctxKey{}, "v")), "Embedding": withCancel(embeddingCtx{ctx}), - "Mock": throughMocks(ctx, 1), - "MockWithValue": throughMocks(context.WithValue(ctx, ctxKey{}, "v"), 1), - "MockEmbedding": throughMocks(embeddingCtx{ctx}, 1), - "MockBehindWithCancel": throughMocks(withCancel(ctx), 1), - "DeepMockChain": throughMocks(ctx, 30), + "Mock": throughMock(ctx), + "MockWithValue": throughMock(context.WithValue(ctx, ctxKey{}, "v")), + "MockEmbedding": throughMock(embeddingCtx{ctx}), + "MockBehindWithCancel": throughMock(withCancel(ctx)), } } From 31f356ec4dfff8b5e4ebb3ed6105c78d1e539a60 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 17:44:02 +0000 Subject: [PATCH 37/43] fix(mock.go): clear the slot of a stopped AfterFunc Removing a registration with append(s[:i], s[i+1:]...) left a copy of the last element in the backing array, so a mock context kept the most recently stopped function, and any context it references, reachable until a later registration overwrote the slot. slices.Delete zeroes the vacated slot, as registerChild's stop function already does. --- mock.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mock.go b/mock.go index 4ea6eec..6523675 100644 --- a/mock.go +++ b/mock.go @@ -791,7 +791,9 @@ func (c *mockCtx) AfterFunc(f func()) (stop func() bool) { defer c.mock.mu.Unlock() for i, x := range c.afterFuncs { if x == a { - c.afterFuncs = append(c.afterFuncs[:i], c.afterFuncs[i+1:]...) + // slices.Delete zeroes the vacated slot, so the backing array stops referencing + // the stopped function and the done child context it may hold. + c.afterFuncs = slices.Delete(c.afterFuncs, i, i+1) return true } } From fa34cbce85d13059722173a0c26aa87febbd3df4 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 17:45:36 +0000 Subject: [PATCH 38/43] docs: say what finished waits for and split the cancellation paragraph The cancel closure and the mockCtx struct comments said finished closes before cancel unregisters contexts from their parents. That is false when a mock child is still being canceled by another goroutine: cancel runs the unregister functions while finished is still open. They now say that finished does not wait for those functions. The view's checkParent comment said std is canceled by a new goroutine "while std was registering"; it now names the window: c is canceled after context.WithCancel found c's Done open and before it called c.AfterFunc. The README paragraph mixed what is done when cancel() or MustWait() returns, the exception for parents from the context package, and the test-end cancellation. Each is now its own paragraph, and the exception shows how to wait on a derived context. --- README.md | 22 +++++++++++++++++----- mock.go | 19 ++++++++++--------- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7890bdd..448f744 100644 --- a/README.md +++ b/README.md @@ -333,12 +333,24 @@ A canceled parent cancels the child. A parent created with `context.WithDeadline parent deadlines to mock time. When `cancel()` returns, or `Advance(...).MustWait()` returns after the clock reaches the deadline, -the context and contexts derived from it are done, so the test can check them right away. When a -cancellation starts at a parent from the `context` package, for example through its cancel function -or its wall-clock deadline, `ctx.Err()` reports it at once, but contexts derived from `ctx` are -canceled a moment later on another goroutine, and a `ctx.Done()` channel obtained earlier closes +the context and contexts derived from it are done, so the test can check them right away. + +When a cancellation starts at a parent from the `context` package, for example through its cancel +function or its wall-clock deadline, `ctx.Err()` reports it at once, but contexts derived from `ctx` +are canceled a moment later on another goroutine, and a `ctx.Done()` channel obtained earlier closes only then. Wait on the derived context's `Done()` or on that earlier channel instead of checking the -derived context's `Err()`. Contexts that are still live when the test ends are canceled with cause +derived context's `Err()`: + +```go +cancelParent() +select { +case <-child.Done(): +case <-testCtx.Done(): + t.Fatal("timed out waiting for the derived context") +} +``` + +Contexts that are 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. diff --git a/mock.go b/mock.go index 6523675..5e9a4bb 100644 --- a/mock.go +++ b/mock.go @@ -251,10 +251,10 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de return &mockCtxView{std: std, c: c}, func() { c.cancel(context.Canceled, nil) // If another goroutine canceled c first, it may still be canceling std and the mock - // contexts registered with c. Wait for that, as a stdlib cancel that loses a race does. The - // wait runs no function that unregisters a context from its parent, which can block in user - // code: cancel calls those after closing finished. stdCancel is then a no-op, called - // because WithCancel requires it. + // contexts registered with c. Wait for that, as a stdlib cancel that loses a race does. + // finished does not wait for the functions that unregister those contexts from their + // parents, which can block in user code. stdCancel is then a no-op, called because + // WithCancel requires it. <-c.finished <-std.Done() stdCancel() @@ -743,8 +743,8 @@ type mockCtx struct { // wallDeadline is what Deadline() reports; see noWallDeadline. wallDeadline time.Time // children holds the mock contexts registered through registerChild and is guarded by - // mock.mu. finished is closed once c and every mock context canceled with it are canceled, - // before cancel unregisters them from their parents. + // mock.mu. finished is closed once c and every mock context canceled with it are canceled. It + // does not wait for cancel to unregister them from their parents. children []*mockCtx finished chan struct{} @@ -876,9 +876,10 @@ func (v *mockCtxView) Err() error { } // checkParent cancels c if its parent is done, then waits for std if c is done. std is canceled -// after c: by the goroutine that canceled c, or by a new goroutine if c was canceled while std was -// registering with it. Without the wait, Err could return nil after the parent's cancel returned, -// because c.checkParent returns at once while the parent's AfterFunc goroutine is canceling c. +// after c: by the goroutine that canceled c, or by a goroutine that c.AfterFunc starts if c was +// canceled after context.WithCancel found c's Done open but before it called c.AfterFunc. Without +// the wait, Err could return nil after the parent's cancel returned, because c.checkParent returns +// at once while the parent's AfterFunc goroutine is canceling c. func (v *mockCtxView) checkParent() { v.c.checkParent() select { From 1e63148f9c54c81e49c29a3990beaa24e78640fd Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 17:45:46 +0000 Subject: [PATCH 39/43] style(mock_test.go): wrap the new wait test's comments to 100 columns --- mock_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mock_test.go b/mock_test.go index 20262cf..be1cb2f 100644 --- a/mock_test.go +++ b/mock_test.go @@ -595,8 +595,8 @@ func TestWithTimeout_CancelWhileCanceling(t *testing.T) { } } -// When cancel or the expiry's MustWait returns, the mock contexts derived from the context and their -// descendants are done, also those that another goroutine is still canceling. +// When cancel or the expiry's MustWait returns, the mock contexts derived from the context and +// their descendants are done, also those that another goroutine is still canceling. func TestWithTimeout_WaitsForMockContextsBeingCanceled(t *testing.T) { t.Parallel() @@ -652,8 +652,8 @@ func TestWithTimeout_WaitsForMockContextsBeingCanceled(t *testing.T) { deadlineCause := errors.New("deadline cause") ctx, cancel := mClock.WithTimeoutCause(context.Background(), time.Second, deadlineCause) defer cancel() - // The chains are long enough that the call under test usually returns while one is still - // being canceled if it does not wait for it. + // The chains are long enough that the call under test usually returns while one is + // still being canceled if it does not wait for it. var derived []context.Context for range 20 { parent := tt.layer(t, ctx) From ee68af1cc691f4b982482e860d76a866ed6a81e1 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:00:55 +0000 Subject: [PATCH 40/43] test(mock_test.go): observe a parent's cancel on many mock contexts Removing the view's wait for std in checkParent failed no test in 3000 runs. With one mock context, the read right after the parent's cancel usually cancels the context itself, before the goroutine that the parent's cancel started can, so the read never lands while that goroutine is between canceling the context and canceling std. TestWithTimeout_ParentCanceled now derives 200 mock contexts from the parent, and its Err, Done, and Cause rows check each of them. Removing the wait fails at least one row in 191 to 196 of 200 runs. --- mock_test.go | 56 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/mock_test.go b/mock_test.go index be1cb2f..cfff0e9 100644 --- a/mock_test.go +++ b/mock_test.go @@ -813,49 +813,55 @@ func TestWithTimeout_ParentCanceled(t *testing.T) { t.Parallel() // Each subtest observes the parent's cancellation through one path only, because every path - // also cancels the mock context and would hide a gap in the others. - observe := map[string]func(t *testing.T, mClock *quartz.Mock, ctx context.Context){ - "Err": func(t *testing.T, _ *quartz.Mock, ctx context.Context) { - if !errors.Is(ctx.Err(), context.Canceled) { - t.Fatalf("expected Canceled, got %v", ctx.Err()) + // also cancels the mock contexts and would hide a gap in the others. + observe := map[string]func(t *testing.T, mClock *quartz.Mock, ctxs []context.Context){ + "Err": func(t *testing.T, _ *quartz.Mock, ctxs []context.Context) { + for i, ctx := range ctxs { + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("context %d: expected Canceled, got %v", i, ctx.Err()) + } } }, - "Done": func(t *testing.T, _ *quartz.Mock, ctx context.Context) { - select { - case <-ctx.Done(): - default: - t.Fatal("expected Done to be closed") + "Done": func(t *testing.T, _ *quartz.Mock, ctxs []context.Context) { + for i, ctx := range ctxs { + select { + case <-ctx.Done(): + default: + t.Fatalf("context %d: expected Done to be closed", i) + } } }, - "Cause": func(t *testing.T, _ *quartz.Mock, ctx context.Context) { - if got := context.Cause(ctx); !errors.Is(got, context.Canceled) { - t.Fatalf("expected Canceled cause, got %v", got) + "Cause": func(t *testing.T, _ *quartz.Mock, ctxs []context.Context) { + for i, ctx := range ctxs { + if got := context.Cause(ctx); !errors.Is(got, context.Canceled) { + t.Fatalf("context %d: expected Canceled cause, got %v", i, got) + } } }, - "Peek": func(t *testing.T, mClock *quartz.Mock, _ context.Context) { + "Peek": func(t *testing.T, mClock *quartz.Mock, _ []context.Context) { if d, ok := mClock.Peek(); ok { t.Fatalf("expected no pending events, got Peek()=%s", d) } }, - "Advance": func(t *testing.T, mClock *quartz.Mock, _ context.Context) { - // The canceled context must not block advancing past its old deadline. + "Advance": func(t *testing.T, mClock *quartz.Mock, _ []context.Context) { + // The canceled contexts must not block advancing past their old deadline. testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) defer testCancel() mClock.Advance(2 * time.Hour).MustWait(testCtx) }, - "Set": func(t *testing.T, mClock *quartz.Mock, _ context.Context) { + "Set": func(t *testing.T, mClock *quartz.Mock, _ []context.Context) { testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) defer testCancel() mClock.Set(mClock.Now().Add(2 * time.Hour)).MustWait(testCtx) }, - "AdvanceNext": func(t *testing.T, mClock *quartz.Mock, _ context.Context) { + "AdvanceNext": func(t *testing.T, mClock *quartz.Mock, _ []context.Context) { testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) defer testCancel() mClock.NewTimer(2 * time.Hour) // AdvanceNext fails the test when nothing is scheduled. d, w := mClock.AdvanceNext() w.MustWait(testCtx) if d != 2*time.Hour { - t.Fatalf("expected AdvanceNext to skip the canceled context, advanced %s", d) + t.Fatalf("expected AdvanceNext to skip the canceled contexts, advanced %s", d) } }, } @@ -864,12 +870,18 @@ func TestWithTimeout_ParentCanceled(t *testing.T) { t.Parallel() mClock := quartz.NewMock(t) parent, parentCancel := context.WithCancel(context.Background()) - ctx, cancel := mClock.WithTimeout(parent, time.Hour) - defer cancel() + // The parent's cancel starts a goroutine for each mock context, so with many of them the + // observer reads some while their goroutine is still canceling them. + var ctxs []context.Context + for range 200 { + ctx, cancel := mClock.WithTimeout(parent, time.Hour) + t.Cleanup(cancel) + ctxs = append(ctxs, ctx) + } // As with stdlib contexts, the cancellation is visible as soon as parentCancel returns. parentCancel() - fn(t, mClock, ctx) + fn(t, mClock, ctxs) }) } } From dbbfcda8f294aaabf97fb680e6d2f47a6a4bfd4e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:03:00 +0000 Subject: [PATCH 41/43] style(mock_test.go): wrap the parent-cancel test comment to 100 columns --- mock_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mock_test.go b/mock_test.go index cfff0e9..37a3fe1 100644 --- a/mock_test.go +++ b/mock_test.go @@ -870,8 +870,8 @@ func TestWithTimeout_ParentCanceled(t *testing.T) { t.Parallel() mClock := quartz.NewMock(t) parent, parentCancel := context.WithCancel(context.Background()) - // The parent's cancel starts a goroutine for each mock context, so with many of them the - // observer reads some while their goroutine is still canceling them. + // The parent's cancel starts a goroutine for each mock context, so with many of them + // the observer reads some while their goroutine is still canceling them. var ctxs []context.Context for range 200 { ctx, cancel := mClock.WithTimeout(parent, time.Hour) From 780dd80fe734f538e4ea0c9e1ca24187476eae25 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:12:09 +0000 Subject: [PATCH 42/43] docs(README.md): show the setup of the parent-cancel snippet The snippet called cancelParent and waited on child, neither of which the prose names, so a reader had to guess how they relate to ctx. It now creates the stdlib parent, the mock context, and the derived child. The test-end sentence now fills its lines to 100 columns. --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 448f744..c868e39 100644 --- a/README.md +++ b/README.md @@ -342,6 +342,12 @@ only then. Wait on the derived context's `Done()` or on that earlier channel ins derived context's `Err()`: ```go +parent, cancelParent := context.WithCancel(context.Background()) +ctx, cancel := mClock.WithTimeout(parent, time.Minute) +defer cancel() +child, cancelChild := context.WithCancel(ctx) +defer cancelChild() + cancelParent() select { case <-child.Done(): @@ -350,9 +356,8 @@ case <-testCtx.Done(): } ``` -Contexts that are 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. +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`. From 044f3de8cd73ff14bb177d15bc3bbd4404895f1c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 19:00:05 +0000 Subject: [PATCH 43/43] docs: name the derived contexts that cancel and MustWait wait for Clock.WithDeadline and the README said the context and the contexts derived from it are done when cancel() or Advance().MustWait() returns. In 300 runs under -race, a context from context.WithoutCancel was live after both in all 300, as designed, and context.WithCancel over a Context type with its own Done channel was live in 295 after cancel() and 182 after MustWait, because the context package cancels it from a goroutine. Contexts from the other context.With functions, also over WithValue or a struct that embeds the context, and from the Mock's With methods, also over that Done-owning type, were done in every run. Both now name those contexts and the Done-owning exception, worded as in the AdvanceWaiter godoc. --- README.md | 7 ++++++- clock.go | 20 +++++++++++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c868e39..1f9dc0f 100644 --- a/README.md +++ b/README.md @@ -333,7 +333,12 @@ A canceled parent cancels the child. A parent created with `context.WithDeadline parent deadlines to mock time. When `cancel()` returns, or `Advance(...).MustWait()` returns after the clock reaches the deadline, -the context and contexts derived from it are done, so the test can check them right away. +the context is done, along with the contexts derived from it with the `context` package's `With` +functions, other than `WithoutCancel`, or with the `With` methods of the `*Mock`, so the test can +check them right away. This includes contexts derived through a wrapper that forwards `Done` and +`Value`, such as a struct that embeds the context. A context derived through a `Context` +implementation with its own `Done` channel may still be live, because the `context` package cancels +it on another goroutine. When a cancellation starts at a parent from the `context` package, for example through its cancel function or its wall-clock deadline, `ctx.Err()` reports it at once, but contexts derived from `ctx` diff --git a/clock.go b/clock.go index 57dc2b5..d9114b9 100644 --- a/clock.go +++ b/clock.go @@ -48,13 +48,19 @@ type Clock interface { // already expired. Code that needs the remaining mock time must compute it from the deadline or // timeout it passed in, not from ctx.Deadline(). // - // With a Mock clock, the context and contexts derived from it are done when the cancel function - // returns, or when Advance(...).MustWait() returns after the clock reached d. When a - // cancellation starts at a parent from the context package, for example through its cancel - // function or its deadline, Err reports it at once, but contexts derived from the returned - // context are canceled a moment later on another goroutine, and a Done channel obtained - // earlier closes only then. Wait on the derived context's Done or on that earlier channel - // instead of checking the derived context's Err. + // With a Mock clock, when the cancel function returns, or when Advance(...).MustWait() returns + // after the clock reached d, the context is done, along with the contexts derived from it with + // the context package's With functions, other than WithoutCancel, or with the Mock's With + // methods. This includes contexts derived through a wrapper that forwards Done and Value, such + // as a struct that embeds the context. A context derived through a Context implementation with + // its own Done channel may still be live, because the context package cancels it on another + // goroutine. + // + // When a cancellation starts at a parent from the context package, for example through its + // cancel function or its deadline, Err reports it at once, but contexts derived from the + // returned context are canceled a moment later on another goroutine, and a Done channel + // obtained earlier closes only then. Wait on the derived context's Done or on that earlier + // channel instead of checking the derived context's Err. WithDeadline(parent context.Context, d time.Time, tags ...string) (context.Context, context.CancelFunc) // WithDeadlineCause is like WithDeadline and records cause when this Clock reaches d.