internal/jsonrpc2: plumb cancellation cause through request context - #1103
internal/jsonrpc2: plumb cancellation cause through request context#1103aha-jansen wants to merge 1 commit into
Conversation
handleAsync reported why a request was canceled by inspecting the connection-level s.writeErr, guarded by a TODO awaiting golang/go#51365. That API shipped as context.WithCancelCause/context.Cause in Go 1.21, and this module targets Go 1.25. Carry the cancellation cause on each request's context instead of inferring it from global state. Both the write-failure and read-side teardowns cancel each in-flight request with a cause wrapping ErrServerClosing; the remaining cancel sites (peer cancel, normal completion) pass nil. handleAsync now reads context.Cause(req.ctx) instead of guessing from s.writeErr. This also fixes a latent mis-attribution: s.writeErr is connection-level, so a request canceled for an unrelated reason was reported as ErrServerClosing whenever s.writeErr happened to be non-nil. ctx.Err() still returns context.Canceled in all cases, so external behavior for callers inspecting ctx.Err() is unchanged.
2e16971 to
04327e3
Compare
| for _, r := range s.incomingByID { | ||
| r.cancel() | ||
| r.cancel(fmt.Errorf("%w: %v", ErrServerClosing, err)) | ||
| } |
There was a problem hiding this comment.
we should not assume this is an ErrServerClosing, it could be ErrClientClosing for example
There was a problem hiding this comment.
Good catch. You're right that the cancel cause is sent back to the peer, so the sentinel choice carries protocol semantics. I confirmed the two sites actually have different semantics: at 779 (local write failure) ErrServerClosing is reasonable; at 557 (reader EOF, most likely the peer disconnecting) it's indeed closer to client-side.
Which do you prefer: (a) switch 557 to ErrClientClosing, or (b) use the neutral underlying err without the server/client sentinel?
| s.writeErr = err | ||
| for _, r := range s.incomingByID { | ||
| r.cancel() | ||
| r.cancel(fmt.Errorf("%w: %v", ErrServerClosing, err)) |
There was a problem hiding this comment.
this should be fair for the new protocolVersion and stateless=true, as server initiated requests are not allowed
| c.updateInFlight(func(s *inFlightState) { | ||
| if s.writeErr != nil { | ||
| // Assume that req.ctx was canceled due to s.writeErr. | ||
| // TODO(#51365): use a Context API to plumb this through req.ctx. |
There was a problem hiding this comment.
i don't think it is safe to remove the updateInFlight as it is performing shutdown-checks too
There was a problem hiding this comment.
I don't think the early shutting-down check is necessary here: the current incoming request is still being processed, so the connection isn't idle at that point, and the shutdown-advance branch (which only fires when s.idle() holds) can't trigger anyway. The effective check happens in processResult's own updateInFlight right after.
Closes #1100.
What
handleAsyncininternal/jsonrpc2/conn.goreported why an in-flight request had been canceled by inspecting the connection-levels.writeErr, guarded by a TODO waiting on golang/go#51365:That API shipped as
context.WithCancelCause/context.Causein Go 1.21, and this module already targets Go 1.25, so the TODO is now actionable.This PR carries the cancellation cause on each request's context instead of inferring it from global state:
incomingRequest.cancelbecomes acontext.CancelCauseFunc(built viacontext.WithCancelCause).ErrServerClosing.Cancel, normal completion) passnil.handleAsyncreadscontext.Cause(req.ctx)instead of looking ups.writeErr.Why
Besides resolving the TODO, this fixes a latent mis-attribution.
s.writeErris connection-level state, not per-request: a request canceled for an unrelated reason (e.g. a peer cancel notification viaCancel) was reported asErrServerClosingwhenevers.writeErrhappened to be non-nil at that moment. Tying the cause to the specific request's context removes that coupling.ctx.Err()still returnscontext.Canceledin all cases, so external behavior is unchanged; only the reported cause becomes accurate and per-request.Test plan
go build ./...go test ./internal/... ./mcp/(including the longermcpintegration tests) — all pass.context.WithCancelCausesemantics: with a cause,Err()stayscontext.CanceledwhileCause()returns the wrappedErrServerClosing; withnil, both resolve tocontext.Canceled— matching prior behavior.Note
Per the discussion in #1100, the read-side teardown also carries a descriptive cause (wrapping
ErrServerClosing) rather than a barecontext.Canceled.