Skip to content

Drop context.Context implementation from Deadline - #398

Open
paulwe wants to merge 3 commits into
mainfrom
deadline-drop-context-iface
Open

Drop context.Context implementation from Deadline#398
paulwe wants to merge 3 commits into
mainfrom
deadline-drop-context-iface

Conversation

@paulwe

@paulwe paulwe commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Stacked on #397 — review that one first; this PR's diff is the single commit on top.

Deadline never satisfied the Context contract, for the reasons in #397: Set revives it, so Err() goes from non-nil back to nil and Done() returns a new channel while earlier holders still see the old one closed, and any context derived from it can panic inside context with missing cancel error.

var _ context.Context = (*Deadline)(nil) asserts a contract the type provably breaks. Now that #397 offers Deadline.Context() as the honest alternative, this drops the claim: deleting the no-op Value method breaks the interface, which turns every derivation from a Deadline into a compile error instead of a runtime panic.

Set, Done, Err and Deadline are all kept — across pion/transport, pion/dtls and pion/sctp the only methods ever invoked on a Deadline are Set and Done. Nothing in any of the three exposes a *deadline.Deadline in exported API; every holder is an unexported struct field.

Blast radius

Measured by building all three repos against this branch with -gcflags=-e, test packages included. Exactly two call sites break, both passing a Deadline to an unexported function that takes a context.Context:

repo site
pion/dtls conn.go:586c.contextWithClose(c.writeDeadline)
pion/sctp stream.go:330sendPayloadData(s.writeDeadline, chunks)

pion/transport itself needs no changes: builds, vets and passes all tests.

Both are mechanical, and both sites are already coping with the unsoundness:

  • contextWithClose (dtls conn.go:858) does context.WithCancelCause(context.WithoutCancel(ctx)) and then hand-rolls the propagation in a goroutine with err := ctx.Err(); if err == nil { err = context.DeadlineExceeded } — that nil guard is the non-monotonicity, already observed and patched at the symptom. It can take c.writeDeadline.Context() and drop both the WithoutCancel and the guard, or become its sibling contextWithCloseAndWriteDeadline, which already watches c.writeDeadline.Done() as a plain channel.
  • sctp's sendPayloadData does case <-ctx.Done(): return ctx.Err(). With a revived Deadline that returns nil, so an aborted write currently reports success — a silent wrong result on master today, which this change converts into a compile error.

Note on versioning

Removing a method from an exported type is formally breaking for transport/v4, so this may want a major bump — or the view that a type which never satisfied the contract was never legitimately a Context. Flagging for maintainers rather than assuming.

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.
@paulwe
paulwe force-pushed the deadline-drop-context-iface branch from 9f4abaf to abfbdd3 Compare September 9, 2026 12:35
@paulwe paulwe changed the title Remove context.Context implementation from Deadline Drop context.Context implementation from Deadline Sep 9, 2026
@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.41%. Comparing base (b2a362c) to head (fee5d42).

Additional details and impacted files
@@                 Coverage Diff                  @@
##           deadline-context     #398      +/-   ##
====================================================
+ Coverage             84.36%   84.41%   +0.04%     
====================================================
  Files                    41       41              
  Lines                  3416     3414       -2     
====================================================
  Hits                   2882     2882              
+ Misses                  395      393       -2     
  Partials                139      139              
Flag Coverage Δ
go 84.41% <ø> (+0.04%) ⬆️

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
Comment thread deadline/deadline.go
Comment on lines -162 to -164
func (d *Deadline) Value(any) any {
return 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.

we should do a major release when we merge this.

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.

Agreed -- removing a method from an exported type is breaking, so this wants a major bump. Flagged in the PR body too; happy to leave the sequencing to you.

Worth noting the two downstream call sites this turns into compile errors still need fixing before it can land: pion/dtls conn.go:586 and pion/sctp stream.go:330. Both become .Context() calls once #397 is in.

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

I think we can get more changes to pion/transport and release transport@v5 too. There are other stuff that we can improve.

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.

sgtm. do we want to hold on landing this 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.

I think yeah, so other people don't accidentally tag it in v4 by mistake

@JoTurk JoTurk left a comment

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.
Deadline never satisfied the Context contract. Set revives it, so Err
goes from non-nil back to nil and Done hands out a new channel while
earlier holders still see the old one closed: two observers of the
same "context" disagreeing permanently. Any context derived from it
can panic in the standard library, because both propagateCancel paths
read parent.Err() after observing Done, and cancelCtx.cancel panics
on a nil error. This reproduces today with no callback API involved.

Dropping the no-op Value method breaks the interface assertion and
turns those derivations into compile errors. Callers that want a
context should use Deadline.Context, which is monotonic. Set, Done,
Err and Deadline are untouched: across pion/transport, pion/dtls and
pion/sctp the only methods invoked on a Deadline are Set and Done.

Known downstream fallout, both passing a Deadline to an unexported
function that takes a context.Context:
  pion/dtls  conn.go:586    c.contextWithClose(c.writeDeadline)
  pion/sctp  stream.go:330  sendPayloadData(s.writeDeadline, chunks)
@paulwe
paulwe force-pushed the deadline-drop-context-iface branch from abfbdd3 to fee5d42 Compare September 10, 2026 10:25
Base automatically changed from deadline-context to main 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