diff --git a/README.md b/README.md index b0da401..1f9dc0f 100644 --- a/README.md +++ b/README.md @@ -312,6 +312,69 @@ 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`, contexts created through the clock +expire 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") +} +``` + +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 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` +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()`: + +```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(): +case <-testCtx.Done(): + t.Fatal("timed out waiting for the derived context") +} +``` + +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`. + +#### `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. 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 ### Options diff --git a/clock.go b/clock.go index 729edfa..d9114b9 100644 --- a/clock.go +++ b/clock.go @@ -35,6 +35,43 @@ 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 that expires when this Clock reaches d. When it + // 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, 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. + 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(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. + 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..5e9a4bb 100644 --- a/mock.go +++ b/mock.go @@ -23,6 +23,13 @@ type TestingT interface { Cleanup(func()) } +// ErrMockClockTestEnded is the context.Cause of a mock context that was still live when the test +// ended; its Err is context.Canceled. Each context is canceled by a cleanup registered when the +// context was created, so it is still live in cleanups registered after that, which run first. A +// context created after the cleanup registered by NewMock has run, in a cleanup registered before +// NewMock or by a goroutine that outlived the test, is returned already canceled with this cause. +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 +161,122 @@ 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) { + c := newCall(clockFunctionWithDeadline, tags, withTime(d)) + 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) { + c := newCall(clockFunctionWithDeadlineCause, tags, withTime(d), withCause(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) { + c := newCall(clockFunctionWithTimeout, tags, withDuration(d)) + 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) { + c := newCall(clockFunctionWithTimeoutCause, tags, withDuration(d), withCause(cause)) + return m.newMockCtx(parent, c, cause, func() time.Time { return m.cur.Add(d) }) +} + +// 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, innerCancel := context.WithCancelCause(context.WithoutCancel(parent)) + wallDeadline, ok := parent.Deadline() + if !ok { + wallDeadline = noWallDeadline + } + c := &mockCtx{ + parent: parent, + inner: inner, + innerCancel: innerCancel, + wallDeadline: wallDeadline, + finished: make(chan struct{}), + cause: cause, + mock: m, + done: make(chan struct{}), + } + + m.mu.Lock() + m.matchCallLocked(call) + c.deadline = deadlineLocked() + pastDeadline := !c.deadline.After(m.cur) + // 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) + } + m.tb.Cleanup(func() { c.cancel(context.Canceled, ErrMockClockTestEnded) }) + close(call.complete) + m.mu.Unlock() + + if testOver { + c.cancel(context.Canceled, ErrMockClockTestEnded) + } else if pastDeadline { + c.cancel(context.DeadlineExceeded, cause) + } + var stop func() bool + // 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) + 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() + c.stopParent = stop + done := c.err != nil + c.mock.mu.Unlock() + if done { + stop() + } + std, stdCancel := context.WithCancel(c) + 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. + // 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() + } +} + +// 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() + } +} + func (m *Mock) addEventLocked(e event) { m.all = append(m.all, e) m.recomputeNextLocked() @@ -270,6 +393,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) @@ -320,6 +444,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) @@ -359,6 +484,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()") @@ -381,6 +507,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() { @@ -444,6 +571,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} } @@ -561,12 +704,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 { @@ -583,6 +728,303 @@ func (m *mockTickerFunc) Wait(tags ...string) error { var _ Waiter = &mockTickerFunc{} +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 + // fire instead of ours. + inner context.Context + innerCancel context.CancelCauseFunc + // deadline is in mock time and schedules the event. + 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. It + // does not wait for cancel to unregister them from their parents. + children []*mockCtx + finished chan struct{} + + // Cause recorded when the deadline fires. + cause error + + // err, done, afterFuncs, and stopParent are guarded by mock.mu. err and done are written + // together. + err error + done chan struct{} + afterFuncs []*afterFunc + 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. +// +// 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 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() + 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 { + // 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 + } + } + return false + } +} + +// 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 +} + +// 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 +} + +// 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() +} + +// 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. +func (v *mockCtxView) Done() <-chan struct{} { + v.checkParent() + return v.std.Done() +} + +func (v *mockCtxView) Value(key any) any { + if key == (mockCtxViewKey{}) { + return v + } + 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 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 { + 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{} { + return c.done +} + +func (c *mockCtx) Value(key any) any { + return c.inner.Value(key) +} + +func (c *mockCtx) Err() error { + c.mock.mu.Lock() + defer c.mock.mu.Unlock() + return c.err +} + +func (c *mockCtx) next() time.Time { + return c.deadline +} + +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. 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) + } +} + +// 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. +// +// 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) cancelTree(err, cause error, stops *[]func() bool) { + 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 + 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. std goes first: children + // 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 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 { + // 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 + } + 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 { + *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 + } +} + type clockFunction int const ( @@ -598,6 +1040,10 @@ const ( clockFunctionNow clockFunctionSince clockFunctionUntil + clockFunctionWithDeadline + clockFunctionWithDeadlineCause + clockFunctionWithTimeout + clockFunctionWithTimeoutCause ) func (c clockFunction) String() string { @@ -626,6 +1072,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 +1091,7 @@ type callArg func(c *apiCall) type apiCall struct { Time time.Time Duration time.Duration + Cause error Tags []string fn clockFunction @@ -670,6 +1125,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 +1142,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 +1191,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 +1284,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..37a3fe1 100644 --- a/mock_test.go +++ b/mock_test.go @@ -8,6 +8,7 @@ import ( "os" "runtime/pprof" "strings" + "sync" "testing" "time" @@ -325,6 +326,888 @@ 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) + } +} + +// 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() + + 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 := quartz.NewMock(t) + ctx, cancel := tt.newCtx(mClock) + defer cancel() + ctxs := descendants(t, mClock, ctx) + ctxs["Self"] = ctx + + 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)) + } + } + }) + } +} + +func TestWithTimeout_CancelCancelsDerived(t *testing.T) { + t.Parallel() + + mClock := quartz.NewMock(t) + ctx, cancel := mClock.WithTimeout(context.Background(), time.Hour) + derived := descendants(t, mClock, ctx) + + cancel() + 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) + } + } +} + +// 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 +// libraries do. +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. +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": 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)), + } +} + +// 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, but p never calls the functions passed to its AfterFunc. +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 + } +} + +// When cancel is called while another goroutine is canceling the context and is blocked in the +// 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()) + // 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: 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": { + 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, + }, + } + 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 := quartz.NewMock(t) + root, cancelRoot := context.WithCancelCause(context.Background()) + defer cancelRoot(nil) + parent := &stallingParent{ + stopping: make(chan struct{}), + release: make(chan struct{}), + } + 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) + 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) + 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) + } + }) + } +} + +// 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() + + 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. + "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 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, tt := 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) + 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) + } + + w := tt.expire(t, testCtx, mClock, ctx, 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++ + } + } + w.MustWait(testCtx) + if live > 0 { + t.Fatalf("%d of %d derived contexts lacked DeadlineExceeded or the deadline cause", + live, len(derived)) + } + }) + } +} + +// 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 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) + 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() + + 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() + + // 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) + } + }) + + 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) { + 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() + + // Each subtest observes the parent's cancellation through one path only, because every path + // 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, 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, 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) { + 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 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) { + 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 contexts, advanced %s", d) + } + }, + } + for name, fn := range observe { + t.Run(name, func(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. + 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, ctxs) + }) + } +} + +// 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(*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) { + 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() + dones := map[string]<-chan struct{}{"ctx": ctxDone, "child": child.Done()} + for name, done := range dones { + 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() + + cause := errors.New("parent cause") + 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, + }, + } + 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) + } + }) + } +} + +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 TestWithTimeout_PastDeadline(t *testing.T) { + t.Parallel() + + 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() + + // 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()) + } + if d, ok := mClock.Peek(); ok { + t.Fatalf("expected no pending events, got Peek()=%s", d) + } + }) + } +} + +// 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() + + // 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 + }, + } + 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) + } + }) + } +} + +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) + 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 { + 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) + 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() + select { + case <-ctx.Done(): + case <-testCtx.Done(): + t.Fatal("timeout waiting for canceled WithTimeoutCause ctx") + } +} + 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{}