Skip to content

feat!: add Clock-backed context support - #30

Open
mafredri wants to merge 43 commits into
mainfrom
mafredri/context
Open

mafredri wants to merge 43 commits into
mainfrom
mafredri/context

Conversation

@mafredri

@mafredri mafredri commented May 7, 2026 •

Copy link
Copy Markdown
Member

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.

With a *Mock, Done() and Err() follow mock time, but ctx.Deadline() reports the parent's deadline, or year 9999 when the parent has none. When cancel() returns, or the expiry's Advance().MustWait() returns, 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, also through context.WithValue and wrappers that embed 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. A cancellation that starts at a parent from the context package is also an exception: ctx.Err() reports it at once, but derived contexts, and a ctx.Done() channel obtained earlier, follow a moment later, because a context outside the context package learns of a stdlib parent's cancellation only through context.AfterFunc, which runs on a new goroutine. Contexts still live when the test ends are canceled with cause quartz.ErrMockClockTestEnded, and contexts created after it are returned already canceled with that cause.

Why Deadline() does not report mock time

Code that reads ctx.Deadline() almost always works in wall time. We listed every non-test reader in coder/coder, in the 2,855 packages it depends on, and in the standard library. Each one does one of the following:

  • checks only ok, for example to require a deadline or to add a default timeout;
  • passes the value to a wall-clock API such as net.Dialer, net.Conn.SetDeadline, or a DNS or websocket client;
  • compares it with time.Now(), as http.Client, gRPC, x/time/rate, and the Anthropic, OpenAI, and Google GenAI SDKs do.

None of them compares it with an injected clock. The mock starts in 2024, so a mock deadline looks expired to all of them: dials and gRPC calls fail immediately, http.Client.Timeout and stdlib timeouts derived from the context are skipped, and rate.Wait refuses to wait.

We tested six behaviors for Deadline() against those readers (table below). Reporting the parent's deadline, or year 9999 when there is none, is the only one that:

  • works with every wall-clock reader;
  • keeps ok == true for code that requires a deadline;
  • does not let a mock duration expire real I/O.

Converting the mock deadline to wall time (time.Now() plus the mock time left) fails that last check: a 100ms mock timeout made a real 300ms read fail on wall time without any Advance.

The cost: code that computes remaining mock time from ctx.Deadline() gets a meaningless result. We found no such code.

Deadline() options tested against consumers (parent without a deadline)
Check Mock time Parent's deadline None Now + mock time left, each call Same, fixed at creation Parent's, else year 9999
Same value on every call (context docs) ok ok ok fail ok ok
ok == true ok fail fail ok ok ok
net.Dialer.DialContext fail ok ok ok ok ok
http.Client.Timeout applies fail ok ok ok ok ok
stdlib WithTimeout child fires fail ok ok ok ok ok
Copied into context.WithDeadline fail n/a n/a ok ok ok
x/time/rate.Wait fail ok ok ok ok ok
gRPC call fail ok ok ok ok ok
100ms mock timeout, 300ms real read via SetReadDeadline fail ok ok fail fail ok
clock.Until(deadline) gives mock time left ok fail fail fail fail fail

Closes #14
Part of ENG-2715

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.

🤖 This PR was created with the help of Coder Agents.

@mafredri
mafredri force-pushed the mafredri/context branch 2 times, most recently from 8122ed5 to c7c5550 Compare May 7, 2026 18:01
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.
@mafredri
mafredri force-pushed the mafredri/context branch from c7c5550 to 53c5486 Compare May 7, 2026 18:12
@mafredri
mafredri requested a review from spikecurtis May 7, 2026 18:23
Comment thread mock_test.go
}
}

func TestWithTimeout_CancelRace(t *testing.T) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: While this test doesn't assert anything, its purpose is more-so to affirm that there are no data races when multiple parts are moving simultaneously (relies on race detector).

  • The clock advancement triggers ctx timeout
  • Manual ctx cancel (so as to not cause data race with timeout)
  • Parent cancel (context.AfterFunc cancellation path)
  • Checking .Err async doesn't cause data race

Comment thread mock.go Outdated

mock *Mock

mu sync.Mutex // Protects the following fields.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could switch to rw mutex here as there are r/o paths. 🤔 I also considered atomic values but single mutex design felt simpler.

Comment thread mock.go Outdated

mock *Mock

mu sync.Mutex // Protects the following fields.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

most of the other primitives use the mutex of the parent *Mock. Why is that inappropriate here?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It felt a bit weird to guard the context state via *Mock since it's a bit of a standalone entity that reacts to events coming from outside the mock clock. I don't think there's anything that would make it inappropriate per-se. I can look into refactoring this.

Comment thread mock.go Outdated

func (c *mockCtx) cancel(err, cause error) {
if innerErr := c.inner.Err(); innerErr != nil {
c.cancelFromInner(innerErr)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Smells like a time-of-check; time-of-use bug, where the inner context can be still active at the time the previous line executes, but then canceled before we call cancelOnce?

Also, why do we even need this check at all, given that we register an AfterFunc that calls cancelFromInner()on the inner context?

@mafredri mafredri May 21, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can eliminate the inherent race between two contexts cancelling simultaneously (inner via chain/cancel, and mockCtx via cancel or deadline). Given that, I don't see TOCTOU being a factor as it is best effort (the result doesn't really matter in a race). That said, you're right in that this shouldn't be needed due to our use of AfterFunc. I'll simplify.

Comment thread mock.go Outdated
// 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like if we used the parent (*Mock).muinstead of having our own separate mutex, then we wouldn't have to release the lock while removing the event (call removeEventLocked() -- that would allow us to synchronize setting the error and closing c.done, and so we wouldn't need the extra doneClosed field at all.

Comment thread mock.go
c.cancelFromInner(innerErr)
return
}
if cause == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found this confusing, so I think a comment here would be helpful.

Alternatively, it might be clearer to put the logic that selects the correct cause into the calling functions and always pass a non-nil cause. I realize that duplicates the logic, but my reading is that there are several different reasons why copying the error to the cause is correct.

Comment thread mock.go Outdated
}

func (c *mockCtx) Deadline() (time.Time, bool) {
return c.deadline, true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bit has me a little worried, because contexts are often passed across package boundaries, and so the downstream package might call Deadline() and then compare the returned time against the real wall clock. This is certainly what the default round-tripper in the HTTP package does.

Maybe it's worth the sharp edge, but I think we should describe this limitation and have a warning in the README.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair callout. I did consider the scenario but I couldn't think of an alternative. Calling this out explicitly in godoc/README is a good idea. 👍🏻

Comment thread mock_test.go Outdated
}
call.MustRelease(testCtx)
ctx := <-ctxs
cancel := <-cancels

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these should be selected against the test timeout

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.
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.
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.
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.
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.
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.
@mafredri

mafredri commented Nov 3, 2026 •

Copy link
Copy Markdown
Member Author

@spikecurtis, here are the changes since your approval at 38f9e0f. Each fix commit adds tests that fail without it.

  • e816ef1, deadlocks. mockCtx.Err() takes Mock.mu, and two paths called it while already holding that lock. Both hung the test binary until go test timed out:
    • WithTimeout with an already-canceled mock parent, because context.WithCancelCause(parent) calls parent.Err().
    • TickerFunc exiting after its mock context was canceled. This needed a one-line change to existing waitForCtx code: it now reads ctx.Err() before taking the lock.
  • ebc18a0, synchronous cancellation. These situations now cancel the context immediately, as stdlib does:
    • An expired deadline or a canceled parent at creation. Before, Err() was almost always still nil right after the call.
    • A parent canceled later. It now shows up in Err, Done, Cause, and in Advance, Set, AdvanceNext and Peek. Before, Advance past the old deadline almost always failed the test.
    • A parent canceled before the mock deadline fired. Before, the context almost always reported DeadlineExceeded with a Canceled cause. inner no longer inherits the parent's cancellation, so cancel() alone picks the error and cause, and the parent's win when it is done.
  • f27c0f6, derived contexts. mockCtx implements the AfterFunc method that the context package uses for custom parents.
    • Stdlib contexts created directly from a mock context, other than with context.WithoutCancel, are now done before Advance().MustWait() or cancel() returns. Before, they were almost always still live.
    • They also no longer start a goroutine each.
    • Contexts created behind a context.WithValue layer still use a goroutine, because the context package does not forward AfterFunc.
  • 673f173, Deadline(). It now reports the parent's deadline, or year 9999 when the parent has none, instead of mock time. This replaces the README caveat you asked for. The reasoning and the options we tested are in the PR description.
  • 8b62607, Value. It now checks the parent as well. Before Go 1.26, context.Cause reads through Value without calling Err, so it returned nil right after the parent was canceled. A test caught this on go1.23.9, the go.mod minimum. CI will not catch a regression here, or anywhere: .github/ci.yaml is outside .github/workflows/, so it never runs.
  • 2e1a273 and 03f624b, docs. README and godoc sentences that said these contexts' deadlines "use mock time" now say the contexts expire on mock time, so they no longer contradict Deadline().
  • 36e287a to e72b8dd, cleanup. 1761431 is the only code change: it drops the parent check right after creation, because every way to observe a context already checks the parent.
    • The README heading is now "Deadline() does not report mock time", and the mock.go comments give the real reason for each lock rule.
    • The two deadlock tests call the API directly. Their timeout guards could not report: after t.Fatal, the test hung in the Mock cleanup on the same lock, so go test -timeout reported them anyway.
    • The two tests for an already-done parent are one table over a stdlib and a mock parent. Set and AdvanceNext gained parent-cancellation cases. Waits on Done after MustWait are now direct checks, and checks that other tests already cover are gone.

Follow-up PRs stacked on this one: #31 (traps catch the ...Cause variants), #32 (Advance and Set errors name the blocking events), #33 (done contexts can be garbage collected), #34 (README section on testing/synctest).

Verification:

  • At e72b8dd, go test -race -count=10 ./... passes on go1.26.5, and -count=3 passes on go1.23.9. Each changed test fails when the behavior it covers is broken in a scratch copy.
  • coder/coder main with a replace to this branch: at 8b62607, go build ./... and the 76 packages that import quartz passed, including the database-backed ones. Failures under machine load passed on rerun, and TestDotfiles* fails the same way without the replace. At e72b8dd, go build ./... and seven non-database packages pass.

Since e72b8dd

Run counts and timings below are from one 96-CPU workspace and vary by machine.

  • Stdlib contexts behind context.WithValue or an embedding wrapper (a54d043). At e72b8dd they learned of a mock context's cancellation on a goroutine, so they were still live after cancel() in 2000 of 2000 runs and after the expiry's Advance().MustWait() in 1973 of 2000. A mock context now returns a view whose Done, Err, and Value come from a stdlib context.WithCancel of the internal context, which the cancellation cancels synchronously. Stdlib finds it through any layer that forwards Done and Value, so these contexts are done when either call returns (0 of 2000).
  • Mock contexts behind context.WithValue, an embedding wrapper, or context.WithCancel of a mock context (7aa1c9d, 39d89a2). At e72b8dd they registered only with the layer and learned of the cancellation on a goroutine, so their descendants were still live after cancel() in 2000 of 2000 runs and after MustWait() in 1981 to 1997 of 2000. A mock context now finds the nearest mock context through a private Value key and registers with it (0 of 2000). Behind context.WithCancel it also stays registered with the layer, which can be canceled on its own.
  • cancel() while another goroutine is canceling (0adf429, a33a215, ce79084). At e72b8dd, a cancel() that ran while an expiry or a canceled parent was canceling the context returned at once, before the contexts derived from it were done, and MustWait() did the same when the expiry lost that race. Both now wait for the same derived contexts as a cancel() that wins the race, including mock contexts below. They do not wait for the functions that unregister contexts from their parents, which can block in user code, nor for mock contexts detached through context.WithoutCancel. The canceling goroutine calls Err and Value of the layers between mock contexts, so those must not block.
  • Deep chains of mock contexts (57e1da5). At e72b8dd, Err on the deepest context of a canceled chain of k mock contexts cost about 4^k parent reads: 10 ms at depth 10, 2.6 s at depth 14, 43 s at depth 16. A canceled mock context now returns before reading its parent: under 120 ns at depths 10 to 30.
  • Contexts created after the test ended (a78441b, ecde549, bed3fec). At e72b8dd, a context created in a cleanup registered before NewMock was still live inside that cleanup, and one created after the test ended was never canceled. Both are now returned already canceled with cause ErrMockClockTestEnded.
  • Docs (6f20e32, c79c6f5, fa34cbc, 780dd80, 044f3de). Clock.WithDeadline and the README no longer say parent cancellation "propagates with stdlib semantics". They say which derived contexts are done when cancel() or MustWait() returns: those from the context package's With functions other than WithoutCancel and from the Mock's With methods, also through context.WithValue and embedding wrappers, but not necessarily one derived through a type with its own Done channel (live in 295 of 300 runs after cancel()). They also name the exception for a cancellation that starts at a parent from the context package, and say to wait on the derived context's Done() then; the README shows that wait. The README and ErrMockClockTestEnded say how the test's end cancels contexts.
  • Tests (2d40098, ee68af1). TestWithTimeout_FiresOnAdvance and TestWithTimeoutCause_Cause are one table over the two constructors that checks the context and every descendant for the error and cause. TestWithTimeout_ParentCanceled derives 200 mock contexts from the stdlib parent instead of one, so a read can land while the parent's goroutine is still canceling a context.

WithTimeout plus cancel() now costs 26 allocations and 1772 B, up from 16 and 1356 B at e72b8dd.

Each fix has a test that fails without it. Three of those tests fail in some runs, not every run. On this workspace, removing the view's wait for std in checkParent fails TestWithTimeout_ParentCanceled in 191 to 196 of 200 runs (111 of 200 at GOMAXPROCS=2). Removing fire's wait fails TestWithTimeout_WaitsForMockContextsBeingCanceled/MustWait in 92 to 96 of 100 (90 at GOMAXPROCS=2), and closing finished without waiting for children that another goroutine is canceling fails it in 48 of 50 (94 of 100 at GOMAXPROCS=2). A review run on another machine saw the view-wait removal fail about 50 of 200 runs at GOMAXPROCS=2.

Verified at 044f3de: gofmt -l, go vet ./..., and go test -race -count=10 ./... with Go 1.26.5; go vet ./... and go test -race -count=3 ./... with Go 1.23.9. coder/coder main (8efd1fc) builds with this branch, and the seven packages that use the quartz Mock pass. coder/coder pins v0.3.0 and does not call the new methods, so this shows only that nothing else changed.

🤖 This comment was generated with the help of Coder Agents.

These sentences predate reporting wall-clock deadlines from Deadline()
and read as if ctx.Deadline() returned 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.
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.
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.
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.
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 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.
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.
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.
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.
"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.
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.
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.
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.
"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.
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.
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.
"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.
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.
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.
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.
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.
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.
mafredri added a commit that referenced this pull request Nov 4, 2026
Preferring completion turned a wrong wait context into a flake. With
that the same Advance expires failed 10000/10000 without this PR, but
only 23/10000 with it (about 6000/10000 under -race). The same holds for
t.Context() used in a cleanup and for a test timeout used up by
time.Sleep in a synctest bubble.

Wait now returns ctx.Err() whenever the context is done, so the mistake
fails every time at the line that made it. Done still reports completion
without a deadline.

#30's mock contexts, mClock.Advance(time.Second).Wait(ctx) on a context
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.
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.
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.
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.
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.
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.
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.
mafredri added a commit that referenced this pull request Nov 4, 2026
Since #30, contexts derived through context.WithValue are canceled on
the canceling goroutine, so they are done when Wait returns like every
other derived context. In 500 runs under -race, none of the derived
contexts tried (WithValue, WithCancel or WithTimeout over WithValue, a
struct embedding the context) was live after MustWait; before #30's
changes, the WithValue ones were live in about a quarter of runs.
mafredri added a commit that referenced this pull request Nov 4, 2026
Clock.WithDeadline on #30 now lists which derived contexts are done when
MustWait returns, so AdvanceWaiter refers to that list instead of
keeping its own copy, which had also said "this Mock's" where contexts
from another Mock's With methods are done too.
mafredri added a commit that referenced this pull request Nov 4, 2026
The godoc said "when the test ended", but each context's cleanup is
registered on the TestingT passed to NewMock. With a Mock created from a
parent test's t, a probe showed the context still live in all of a
subtest's cleanups and after t.Run returned, so a subtest's
t.Cleanup(srv.Close) waiting on a handler blocked on it hung. It also
dropped #30's sentence on contexts created after the test ended.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add support for contexts

2 participants