Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
53c5486
feat!: add Clock-backed context support
mafredri May 7, 2026
38f9e0f
refactor(mock.go): simplify mockCtx synchronization
mafredri May 21, 2026
e816ef1
fix(mock.go): avoid deadlocks when reusing mock contexts
mafredri Nov 3, 2026
ebc18a0
fix(mock.go): cancel mock contexts synchronously
mafredri Nov 3, 2026
f27c0f6
fix(mock.go): cancel derived contexts before returning
mafredri Nov 3, 2026
673f173
fix: report wall-clock deadlines from mock contexts
mafredri Nov 3, 2026
8b62607
fix(mock.go): report parent cancellation through Value
mafredri Nov 3, 2026
2e1a273
docs: describe mock context expiry, not deadlines, as mock time
mafredri Nov 3, 2026
03f624b
docs: reflow comments and README paragraphs to 100 columns
mafredri Nov 3, 2026
36e287a
docs: say Deadline() does not report mock time
mafredri Nov 3, 2026
047d896
docs: give the actual reasons in mockCtx comments
mafredri Nov 3, 2026
d355c19
test(mock_test.go): merge the already-done parent tests into a table
mafredri Nov 3, 2026
b191497
test(mock_test.go): let go test -timeout report the TickerFunc deadlock
mafredri Nov 3, 2026
4d0c7e5
test(mock_test.go): cover the parent check in Set and AdvanceNext
mafredri Nov 3, 2026
8ec043f
test(mock_test.go): check contexts directly after MustWait
mafredri Nov 3, 2026
bbe8755
test(mock_test.go): drop checks that other checks already cover
mafredri Nov 3, 2026
1761431
refactor(mock.go): drop the parent check at context creation
mafredri Nov 3, 2026
e72b8dd
style(mock_test.go): fit the ParentAlreadyDone comment in 100 columns
mafredri Nov 3, 2026
a54d043
fix: cancel contexts derived through WithValue synchronously
mafredri Nov 4, 2026
a78441b
fix: cancel mock contexts created after the test ended
mafredri Nov 4, 2026
0adf429
fix: keep the error and cause of a cancellation in progress
mafredri Nov 4, 2026
ecde549
docs(mock.go): say when a mock context is returned already canceled
mafredri Nov 4, 2026
bed3fec
test(mock_test.go): cover a cleanup registered before NewMock
mafredri Nov 4, 2026
7aa1c9d
fix: notify mock contexts created through WithValue synchronously
mafredri Nov 4, 2026
2d40098
test(mock_test.go): check expiry by constructor in one table
mafredri Nov 4, 2026
6f20e32
docs: say when mock contexts and their descendants are done
mafredri Nov 4, 2026
a33a215
fix: wait for mock descendants a cancellation in progress cancels
mafredri Nov 4, 2026
39d89a2
fix: cancel mock contexts behind a stdlib cancel layer synchronously
mafredri Nov 4, 2026
c79c6f5
docs: name what to wait on when a stdlib parent cancels
mafredri Nov 4, 2026
ce79084
fix: do not wait for detached mock contexts when canceling
mafredri Nov 4, 2026
2b3ae44
docs(mock.go): require non-blocking Err from layers between contexts
mafredri Nov 4, 2026
57e1da5
fix(mock.go): read a canceled mock context's parent only once
mafredri Nov 4, 2026
837afef
docs(mock.go): say which stdlib path reads a parent while canceling
mafredri Nov 4, 2026
095ac9e
test(mock_test.go): say what the detached-context test protects
mafredri Nov 4, 2026
ce01780
test(mock_test.go): cover MustWait waiting for mock contexts in progress
mafredri Nov 4, 2026
4049374
test(mock_test.go): check deep mock chains in their own test
mafredri Nov 4, 2026
31f356e
fix(mock.go): clear the slot of a stopped AfterFunc
mafredri Nov 4, 2026
fa34cbc
docs: say what finished waits for and split the cancellation paragraph
mafredri Nov 4, 2026
1e63148
style(mock_test.go): wrap the new wait test's comments to 100 columns
mafredri Nov 4, 2026
ee68af1
test(mock_test.go): observe a parent's cancel on many mock contexts
mafredri Nov 4, 2026
dbbfcda
style(mock_test.go): wrap the parent-cancel test comment to 100 columns
mafredri Nov 4, 2026
780dd80
docs(README.md): show the setup of the parent-cancel snippet
mafredri Nov 4, 2026
044f3de
docs: name the derived contexts that cancel and MustWait wait for
mafredri Nov 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions clock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading