Skip to content

Add Deadline.Context for derived contexts - #397

Merged
paulwe merged 2 commits into
mainfrom
deadline-context
Sep 10, 2026
Merged

Add Deadline.Context for derived contexts#397
paulwe merged 2 commits into
mainfrom
deadline-context

Conversation

@paulwe

@paulwe paulwe commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

deadline.Deadline is revivable via Set, so it is not monotonic, and context.Context requires that it be:

If Done is not yet closed, Err returns nil.
If Done is closed, Err returns a non-nil error explaining why.
After Err returns a non-nil error, successive calls to Err return the same error.

After a Deadline expires and is set again, Err() goes from DeadlineExceeded back to nil and Done() hands out a fresh open channel while earlier holders still see the old one closed — two observers of the same "context" disagreeing permanently.

That makes deriving a context from a Deadline unsafe. Both propagation paths in context read parent.Err() after observing Done:

case <-parent.Done():
    child.cancel(false, parent.Err(), Cause(parent))

and cancelCtx.cancel panics on a nil error, before its already-cancelled early return. A Set landing in that window panics inside the standard library:

panic: context: internal error: missing cancel error
  context.(*cancelCtx).cancel(...)              context/context.go:552
  context.(*cancelCtx).propagateCancel.func2()  context/context.go:526

This reproduces on main today with no callback API in the tree.

This PR

Context() returns a real cancel context scoped to the current deadline generation, so what callers hold is monotonic even though Deadline is not:

  • memoized while the deadline is live — extending it keeps the same context
  • cancelled when the deadline fires, with context.DeadlineExceeded as its context.Cause
  • replaced on the next call after expiry, so a retired context stays cancelled forever

Because the returned value is a *cancelCtx, parentCancelCtx finds it and children register in its map — deriving 100 contexts spawns 0 goroutines, versus one watchdog goroutine per child for any parent context cannot recognise.

timeout and Set now share a fire helper, which also closes done inside the critical section and removes the pre-existing window where Err() reported DeadlineExceeded while Done() was still open.

Purely additive — Set, Done, Err and Deadline are untouched and the context.Context implementation is left in place. #398 removes that separately.

Testing

TestDeadlineContext covers memoization, extend, stop, timer and past-deadline expiry, generation replacement with the retired context staying cancelled, derived-context cancellation, the zero-goroutine property, and concurrent Context/Set. Green under -race.

Deadline is revivable via Set, so it is not monotonic: Err goes from
non-nil back to nil and Done hands out a fresh channel, both of which
context.Context forbids. Deriving a context from a Deadline is therefore
unsafe -- propagateCancel reads parent.Err() after observing Done, and
cancelCtx.cancel panics on a nil error.

Context returns a real cancel context scoped to the current deadline
generation instead. It is memoized while the deadline is live, cancelled
with context.DeadlineExceeded as its cause when the deadline fires, and
replaced on the next call after that, so what callers hold is monotonic
even though Deadline is not. Because it is a *cancelCtx, children
register in its map rather than each spawning a watchdog goroutine.

timeout now closes done inside the critical section via the shared fire
helper, which also removes the window where Err reported
DeadlineExceeded while Done was still open.
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 84.36%. Comparing base (af01cdd) to head (b2a362c).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #397      +/-   ##
==========================================
+ Coverage   84.18%   84.36%   +0.18%     
==========================================
  Files          41       41              
  Lines        3396     3416      +20     
==========================================
+ Hits         2859     2882      +23     
+ Misses        398      395       -3     
  Partials      139      139              
Flag Coverage Δ
go 84.36% <100.00%> (+0.18%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@paulwe
paulwe marked this pull request as ready for review September 9, 2026 12:43
@paulwe
paulwe requested a review from JoTurk September 9, 2026 12:44

@noboruma noboruma left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM - actually makes good sense to have this Context() function out of the deadline.
I will rebase #396 as soon as this PR is merged

Comment thread deadline/deadline.go Outdated
@@ -74,7 +108,7 @@
d.pending--
d.state = deadlineStopped

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit here, could we rename this deadlineSuspended?
I feel Stop could be misinterpreted as cancel, whereas suspended implies the context returned by Context() is still alive

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in b2a362c -- renamed to deadlineSuspended, and the matching test is now SuspendKeepsContextLive.

Agreed on the reasoning: with Context() in the picture the distinction actually matters, since suspending leaves the context live while expiry cancels it. SuspendAfterExpiryGivesLiveContext pins that.

Comment thread deadline/deadline.go
Comment on lines +68 to +78
d.ctx, d.cancel = nil, nil

return cancel
}

// Context returns a context for the current deadline, canceled with
// context.DeadlineExceeded as its Cause.
func (d *Deadline) Context() context.Context {
d.mu.Lock()
defer d.mu.Unlock()
if d.ctx == nil {

@JoTurk JoTurk Sep 10, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Does this mean we'll return a new context if the context cancelled by a deadline?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I believe this can break SCTP if we migrate to this API. If stream's Write calls Context after the deadline has already expired, it will create a new non-canceled context. If the SCTP write is currently blocked it will then then potentially block forever....

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it will be a bigger problem for DTLS if we merge @noboruma PR's pion/dtls#1096 because we'll have multiple Context calls and the deadline can expire between handshake for example and contextWithClose then we'll just get an unbounded timeout. unless we create a single context and store it in conn but then Set will not work (we can make Conn reset the context everytime it calls Set but that will be an anti pattern imo).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we can just check for if d.state == deadlineExceeded { and return a cancelled context.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, and you're right about both consequences. Fixed in b2a362c.

Context() now short-circuits on the state, as you suggested:

if d.state == deadlineExceeded {
    return exceededContext
}

exceededContext is a single package-level context canceled with context.DeadlineExceeded at init. Cancellation is immutable, so one instance is safe to share with every caller and costs no allocation on a path that only runs after a deadline has already blown.

Three regression tests cover it, and all three hang for the full assertion timeout against the old code, which is exactly the symptom you described:

  • ExceededReturnsCanceledContext -- Set(past) then Context()
  • ExceededByTimerReturnsCanceledContext -- same via the timer
  • DerivedFromExceededIsCanceled -- context.WithCancel(d.Context()) is canceled immediately, with the cause propagating through as DeadlineExceeded

SuspendAfterExpiryGivesLiveContext pins the other direction: once Set re-arms or suspends, Context() goes back to handing out a live one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

thank you for the quick fix

Context() minted a fresh, live context whenever the deadline had
already fired, because fire() clears the memoized one. A caller that
asks for a context after expiry and only consults it while blocked
would then wait forever: an SCTP stream Write, or a DTLS handshake
whose deadline elapses before contextWithClose, would see an
unbounded timeout instead of a cancellation.

Short-circuit on the exceeded state and return a shared, pre-canceled
context. Cancellation is immutable, so one package-level instance is
safe to hand to every caller and costs no allocation on a path that
only runs after a deadline has already blown.

Also rename deadlineStopped to deadlineSuspended: "stopped" reads like
"cancelled", where the point is that Context() stays live in that
state.
@paulwe
paulwe merged commit 65248b8 into main Sep 10, 2026
19 checks passed
@paulwe
paulwe deleted the deadline-context branch September 10, 2026 10:37
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.

3 participants