Repository navigation
Conversation
ef5fe91 to
55350a9
Compare
|
Rebased onto current Two of the three resolutions are worth stating, because they are decisions rather than mechanics.
|
| if data != nil { | ||
| s.pendingJSONMessages = append(s.pendingJSONMessages, data) | ||
| } | ||
| if done && len(s.pendingJSONMessages) > 0 { |
There was a problem hiding this comment.
i think in case of empty data, so peer cancelled request, it should return 204 as code
There was a problem hiding this comment.
Agreed, and done in 1faac54, rebased onto main.
A stream that ends with nothing written on it now answers the POST with 204 No Content and no Content-Type: in JSON mode there is nothing to flush, and in SSE mode the header is likewise still unsent when no event was ever written (lastIdx < 0), so both get the 204 rather than an empty 200 under a Content-Type; a stream that already carried something ends as before. TestStreamableCancelledCallGetsNoResponse runs in both modes and asserts the status, the missing Content-Type and the empty body; the protocol doc says so too.
The SDK client itself never reads it, since its POST is cancelled together with the call. A client that keeps the POST open while cancelling from another request reads a 204 with no body, which is what "no response" looks like on the wire.
55350a9 to
1faac54
Compare
The MCP specification says a receiver of a cancellation notification
SHOULD stop processing the request, free its resources and "not send a
response for the cancelled request". The SDK always sends one.
`processResult` in `internal/jsonrpc2/conn.go` writes the response with
`c.write(notDone{req.ctx}, response)`, and `notDone` strips the
cancellation from the context so the write goes ahead. Nothing at
application level runs between a handler returning and that write, so no
server built on this SDK could satisfy the clause. Captured on stdio: a
client's `notifications/cancelled` at 6.306s, and the server's response
to that same request id at 6.307s.
`notDone` is there on purpose and this does not change it. A response
must still be written when the handler's context ended for some other
reason, and until now the jsonrpc2 layer could not tell the two apart:
`Connection.Cancel` is called both by the preempter that saw the peer's
notification and by `ServerSession.Close`, which cancels in-flight
`subscriptions/listen` handlers to unblock them. So the distinction is
recorded rather than guessed. `CancelFromPeer` marks the incoming
request as cancelled by the peer, and `processResult` writes no response
for such a call. `Cancel` keeps its meaning, and the listen result a
session close produces is unaffected. The flag is written and read under
the connection's `stateMu`, in the same critical section that removes
the request from `incomingByID`, so a cancellation arriving once the
response is already on its way finds nothing to mark, and no response is
ever retracted.
Suppressing the write alone would have traded a spec deviation for a
hang. The streamable HTTP transport keeps a POST's stream open until
every call it carried has been answered, so a response that never comes
leaves that request open until the client goes away. A writer that holds
per-call state can now implement `jsonrpc2.ResponseDropper` and be told
the response is not coming; `streamableServerConn` implements it by
retiring the request through the same accounting a real response goes
through, so the stream completes exactly as it would have. Writers with
no such state, stdio and the in-memory transport among them, implement
nothing and are unaffected. A stream that ends with nothing written on
it, because every call it carried was cancelled, answers the POST with
204 No Content: in JSON response mode there is nothing to flush, and in
SSE mode no event was written, so in both the header is still unsent
and an empty 200 under a Content-Type would claim a body that is not
there. A stream that already carried an event or a buffered message
ends as before.
`TestStreamableCancelledCallGetsNoResponse` in `mcp` drives the whole
path over raw HTTP, in SSE and in JSON response mode: initialize, a
`tools/call` whose tool parks on `ctx.Done`, `notifications/cancelled`
for that id on a second request, then a read of the call's stream to
EOF. It asserts that the POST is answered 204 with no Content-Type and
no body, and that it ends. On unmodified main it fails with the
response the server sent:
event: message
data: {"jsonrpc":"2.0","id":2,"error":{"code":0,"message":"context canceled"}}
It fakes the client with raw HTTP rather than using a `ClientSession`
deliberately: an SDK client abandons the POST as soon as it cancels, so
it never sees what the server wrote on that stream, and a stream that
never completes looks to it exactly like one that did. With the
suppression in place but `DropResponse` removed, the same test fails the
other way, on the POST never returning.
`TestCancelFromPeerSuppressesResponse` in `internal/jsonrpc2` pins the
distinction itself. A second call acts as the barrier, since handlers
run one at a time: a peer-cancelled call is answered only by the
barrier's response and is reported to the dropper, while a locally
cancelled one is still answered.
Verification on go1.27.1: `gofmt -l .` clean, `go vet ./...` clean,
`go test ./...` ok, `go test -race ./internal/jsonrpc2/ ./mcp/` ok,
staticcheck clean.
1faac54 to
9402582
Compare
|
Rebased onto current |
The top of the stack: what the whole-stack review still held open after two fix rounds, one CI defect the stack itself ran into, the register brought up to date, and every generated artifact the layers below moved, regenerated once here. **The review's last two findings.** - The test that holds the individual tool Descriptions assembled at run time (the switches in deploykeys and deploytokens, the map in pages) checked only the tool names they spell. The rule `cmd/audit_action_ids` applies to a constant Description also refuses a dotted ID that resolves nowhere or only as an alias, so a misspelt ID in one of those switches passed every gate; the review reproduced it by changing `access.deploy_key_list_project` to `list_projekt`. The test now offers a dotted token as an ID on the same condition as the rule, one of its halves being one a canonical ID uses, leaves a `.git` tail alone, and reports an alias with the canonical ID it stands for. The planted misspelling fails it on both catalog classes. The limits paragraph in the cmd utilities page said "those are held by a test" of two holes when the test holds one, and says which now. - The comment on the temporary-file refusal of a download said the file it names is `output_path` unless the path was relative or its leaf a link. `canonicalDownloadOutputPath` resolves every existing component through symlinks, so a linked directory above the leaf renames it too, the macOS temp root among them. The comment and a test row say so. **Lint from the tree, not from a restored analysis.** The golangci-lint job restored `~/.cache/golangci-lint` with a prefix key, and a pull request reads its own ref's cache first. After a rebase that cache holds the analysis of the branch's previous tree, and on 2026-09-24 it failed a cascaded layer of this stack twice on an unused `//nolint:contextcheck` in a file the stack never touched, until that ref's caches were deleted. A reused result can hide a finding as easily as it invents one. The analysis cache is no longer restored, so a lint result depends on the tree alone; setup-go's build and module caches stay, since their keys are content hashes. On a 4-CPU host a cold analysis of main took 101 s against 29 s warm on the identical tree; in CI the restored cache never matched the tree anyway, and over twenty runs on 2026-09-24 the make step took 110 to 300 s while the cross-platform matrix that sets the wall clock took 800 to 1150 s. The static analysis page says the cache is not kept, and why. **The register.** `modelcontextprotocol/go-sdk#1232` merged on 2026-09-21, so the write-deadline fix issue 1262 asked for was opened as [modelcontextprotocol/go-sdk#1293](modelcontextprotocol/go-sdk#1293); row 8, its section and the re-verification paragraph name it. Row 12's section says what `modelcontextprotocol/go-sdk#1267` answers a POST whose every call was cancelled (204, since the 20th) and that it was rebased after `modelcontextprotocol/go-sdk#1232` conflicted in `deliverLocked`. The client-go paragraph named two gitlab.com reviewers with pronouns nobody here was told, and names them by handle and role instead. **Regenerated.** The tool snapshots (the served-prose rewrite of issue 910: tool names outside a See also clause replaced by canonical IDs), the token footprint, the llms files, the testing reference and the README stats. Over this tree every `check-*` target the generated-artifacts job runs passes, as do the whole unit suite with `-shuffle=on` and snapshot parity checked, and golangci-lint over every build tag. **Recorded.** The request inventory, which CI records during the coverage job and found 47 rows short: the layers below added tests that drive group access tokens, SAML group links, group security settings and the other refusals the permission hints describe. Recorded again over this tree it holds 1604 rows over 956 distinct paths in 177 packages, every one of the 1082 catalog actions owned by a package that issued a request, and R-PATH passes over it. And the e2e coverage, from both Docker runs against this tree on truenas: the CE suite ran 1024 tests with 9 skipped and none failed, the licensed EE suite 1282 with 8 skipped and none failed, and action coverage holds where it stood (CE L1 821 of 869, L2 721, L3 705; EE L1 1016 of 1089, L2 917, L3 901). Closes #945.
I read every go-sdk issue and pull request `docs/development/upstream-bugs.md` links, and this records what moved. Nothing there asks anything of us, and go-sdk has cut no release since v1.8.0, so every merge recorded is still unreleased; the maintainer's estimate of the next one, about two months after v1.8.0, is now cited. - **Row 11, [modelcontextprotocol/go-sdk#1266](modelcontextprotocol/go-sdk#1266 A maintainer updated the branch from `main` on 2026-09-29 and all nine checks passed; `main` gained two commits after it, and I updated it again the same day with GitHub's Update branch, which kept the maintainer's merge commit, and the checks passed again. It has had no review since 2026-09-15. - **Rows 8 and 12.** Both were six commits behind `main`; I updated them from `main` on 2026-09-29 and their checks passed. [modelcontextprotocol/go-sdk#1293](modelcontextprotocol/go-sdk#1293) has had no review, and the one review thread on [modelcontextprotocol/go-sdk#1267](modelcontextprotocol/go-sdk#1267) still waits on the reviewer. - **Row 68.** [modelcontextprotocol/go-sdk#1299](modelcontextprotocol/go-sdk#1299) was triaged P2 on 2026-09-28. [modelcontextprotocol/go-sdk#1300](modelcontextprotocol/go-sdk#1300), which another contributor opened, now conflicts with `main` and its checks have never run. The dates of my comment and review, and of the author's follow-up, are corrected to UTC. - **Row 48.** The case is named under "Known limitations" of [modelcontextprotocol/go-sdk#1275](modelcontextprotocol/go-sdk#1275), which another contributor opened and a maintainer merged on 2026-09-18, so the summary row and the entry say so, with the maintainer's position on that pull request. Its discussion also showed the entry's transport claim too wide: v1.8.0's stateful HTTP handler refuses every 2026-07-28 request but `server/discover`, so only stdio reaches the case. ## knowledge-graph - **Rows 75 and 76, [gitlab-org/orbit/knowledge-graph!2650](https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/merge_requests/2650) and [gitlab-org/orbit/knowledge-graph!2651](https://gitlab.com/gitlab-org/orbit/knowledge-graph/-/merge_requests/2651).** Both descriptions now carry `[skip pinned-version-check]`, which the project's check asks for when a change does not affect the shape, so the merged-results pipeline a maintainer runs will not refuse either for want of a `query_dsl` bump. The marker sits at the top of each description's collapsed details block, where the project's description lint does not count it and the 2700 characters a pipeline reads still hold it. Each merge request also offers the bump instead: row 75's a patch bump, so clients that cache the schema by version fetch the new text, and row 76's should the maintainers read the declared default that branch changes as part of the contract. Both Files lists now name the test pins. ## Checks - `npx markdownlint-cli2 docs/development/upstream-bugs.md` passes. - `go run ./cmd/format_md_tables/ --check` passes. - No em dashes in the added lines.
|
@jmrplens i added a commit on top of you code, changing the approach of your implementation. Let me know what you think of this |
|
Thanks for taking this on. I read 436501f against my version and against I agree with the direction. Releasing the POST when the notification arrives, instead of when the handler returns, is better than what I had: with mine, a handler that ignored its context kept the POST open for as long as it ran. Yours also fixes a bug in mine that I had missed: I found four problems, each with a test that fails at 436501f:
One question about scope. If you agree, I can push one commit on top of yours with the fixes for 1 to 4 and their tests, with The tests (each fails at 436501f)For 1, in s.cancel(2)
+ <-cancelled
+ releaseHandler()
got := <-call
if got.err != nil {
t.Fatalf("the batch's POST: %v", got.err)
}
+ if !strings.Contains(got.body, "gated result") {
+ t.Errorf("the batch's POST lost the response to the call that was not cancelled:\n%s", got.body)
+ }At 436501f this fails in three of the four cases, every one with a 204 and an empty body: cancelled first in SSE and JSON mode, and answered first in JSON mode. For 2, in // TestStreamableCancelledCallResumed checks that a GET resuming the stream of
// a call cancelled while it was disconnected ends without the response.
func TestStreamableCancelledCallResumed(t *testing.T) {
sawCancel := make(chan struct{})
release := make(chan struct{})
server := NewServer(testImpl, nil)
server.AddTool(&Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}},
func(ctx context.Context, req *CallToolRequest) (*CallToolResult, error) {
if err := req.Session.NotifyProgress(ctx, &ProgressNotificationParams{ProgressToken: "tok", Progress: 1}); err != nil {
return nil, err
}
<-ctx.Done()
close(sawCancel)
<-release
return nil, ctx.Err()
})
s := newCancelTestSession(t, server, &StreamableHTTPOptions{EventStore: NewMemoryEventStore(nil)}, protocolVersion20250618)
releaseHandler := sync.OnceFunc(func() { close(release) })
t.Cleanup(releaseHandler)
stream2 := func(c *streamableServerConn) *stream { return c.streams[c.requestStreams[jsonrpc2.Int64ID(2)]] }
// Read the call's stream up to its first event, then drop the connection.
body, err := jsonrpc2.EncodeMessage(req(2, "tools/call", &CallToolParams{Name: "slow"}))
if err != nil {
t.Fatal(err)
}
postCtx, abort := context.WithCancel(s.ctx)
httpReq, err := http.NewRequestWithContext(postCtx, http.MethodPost, s.url, bytes.NewReader(body))
if err != nil {
t.Fatal(err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "application/json, text/event-stream")
httpReq.Header.Set(sessionIDHeader, s.sessionID)
httpReq.Header.Set(protocolVersionHeader, s.version)
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
t.Fatal(err)
}
var lastEventID string
for scanner := bufio.NewScanner(resp.Body); lastEventID == "" && scanner.Scan(); {
if id, ok := strings.CutPrefix(scanner.Text(), "id: "); ok {
lastEventID = id
}
}
abort()
resp.Body.Close()
s.waitFor("the stream to be released", func(c *streamableServerConn) bool {
st := stream2(c)
if st == nil {
return false
}
st.mu.Lock()
defer st.mu.Unlock()
return st.w == nil
})
s.cancel(2)
<-sawCancel
getReq, err := http.NewRequestWithContext(s.ctx, http.MethodGet, s.url, nil)
if err != nil {
t.Fatal(err)
}
getReq.Header.Set("Accept", "text/event-stream")
getReq.Header.Set(sessionIDHeader, s.sessionID)
getReq.Header.Set(protocolVersionHeader, s.version)
getReq.Header.Set(lastEventIDHeader, lastEventID)
resumed := make(chan postResult, 1)
go func() {
resp, err := http.DefaultClient.Do(getReq)
if err != nil {
resumed <- postResult{err: err}
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
resumed <- postResult{status: resp.StatusCode, body: string(body), err: err}
}()
s.waitFor("the GET to resume the stream or end", func(c *streamableServerConn) bool {
st := stream2(c)
if len(resumed) > 0 || st == nil {
return true
}
st.mu.Lock()
defer st.mu.Unlock()
return st.w != nil
})
releaseHandler()
select {
case got := <-resumed:
if got.err != nil || strings.Contains(got.body, `"id":2`) {
t.Errorf("resuming the stream: %v, body:\n%s", got.err, got.body)
}
case <-time.After(5 * time.Second):
t.Fatal("the GET resuming the cancelled call's stream never ended")
}
}For 3, in type fixedTransport struct{ conn Connection }
func (t fixedTransport) Connect(context.Context) (Connection, error) { return t.conn, nil }
// TestIOConnDropResponseRace checks that a cancellation racing with the
// response leaves nothing in ioConn.dropped once the response is written.
func TestIOConnDropResponseRace(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server := NewServer(testImpl, nil)
server.AddTool(&Tool{Name: "fast", InputSchema: &jsonschema.Schema{Type: "object"}},
func(context.Context, *CallToolRequest) (*CallToolResult, error) { return &CallToolResult{}, nil })
serverT, clientT := NewInMemoryTransports()
conn := newIOConn(serverT.rwc)
ss, err := server.Connect(ctx, fixedTransport{conn}, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()
const n = 5000
pinged := make(chan struct{})
var answeredMu sync.Mutex
answered := map[any]bool{} // the IDs whose response is on the wire
go func() {
for scanner := bufio.NewScanner(clientT.rwc); scanner.Scan(); {
if msg, err := jsonrpc2.DecodeMessage(scanner.Bytes()); err == nil {
if r, ok := msg.(*jsonrpc.Response); ok {
answeredMu.Lock()
answered[r.ID.Raw()] = true
answeredMu.Unlock()
if r.ID.Raw() == int64(n) {
close(pinged)
}
}
}
}
}()
send := func(msg jsonrpc.Message) {
data, err := jsonrpc2.EncodeMessage(msg)
if err != nil {
t.Fatal(err)
}
if _, err := clientT.rwc.Write(append(data, '\n')); err != nil {
t.Fatal(err)
}
}
send(req(1, methodInitialize, &InitializeParams{ProtocolVersion: protocolVersion20250618}))
send(req(0, notificationInitialized, &InitializedParams{}))
for i := int64(2); i < n; i++ {
send(req(i, "tools/call", &CallToolParams{Name: "fast"}))
send(req(0, notificationCancelled, &CancelledParams{RequestID: i}))
}
send(req(n, "ping", &PingParams{}))
select {
case <-pinged:
case <-ctx.Done():
t.Fatal("ping never answered")
}
time.Sleep(100 * time.Millisecond) // let the last cancellations finish
conn.batchMu.Lock()
defer conn.batchMu.Unlock()
answeredMu.Lock()
defer answeredMu.Unlock()
var stale []any
for id := range conn.dropped {
if answered[id.Raw()] {
stale = append(stale, id.Raw())
}
}
if len(stale) > 0 {
t.Errorf("ioConn.dropped still holds %v, whose responses were already written", stale)
}
}For 4, in // TestStreamableCancelledCallPrimedStream checks that a cancelled call whose
// stream already carried a priming event does not try to answer 204.
func TestStreamableCancelledCallPrimedStream(t *testing.T) {
started := make(chan struct{})
server := NewServer(testImpl, nil)
server.AddTool(&Tool{Name: "slow", InputSchema: &jsonschema.Schema{Type: "object"}},
func(ctx context.Context, _ *CallToolRequest) (*CallToolResult, error) {
close(started)
<-ctx.Done()
return nil, ctx.Err()
})
handler := NewStreamableHTTPHandler(func(*http.Request) *Server { return server }, &StreamableHTTPOptions{EventStore: NewMemoryEventStore(nil)})
t.Cleanup(handler.closeAll)
var serverLog safeBuffer
httpServer := httptest.NewUnstartedServer(handler)
httpServer.Config.ErrorLog = log.New(&serverLog, "", 0)
httpServer.Start()
t.Cleanup(httpServer.Close)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
t.Cleanup(cancel)
s := &cancelTestSession{t: t, ctx: ctx, handler: handler, url: httpServer.URL, version: protocolVersion20251125}
// The priming event commits the headers before initialize is answered,
// so read the response to its end before going on.
initResp, err := s.post(req(1, methodInitialize, &InitializeParams{ProtocolVersion: s.version}))
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, initResp.Body)
initResp.Body.Close()
s.sessionID = initResp.Header.Get(sessionIDHeader)
s.mustPost(req(0, notificationInitialized, &InitializedParams{}))
call := s.postAsync(req(2, "tools/call", &CallToolParams{Name: "slow"}))
<-started
s.cancel(2)
if got := <-call; got.err != nil || strings.Contains(got.body, `"id":2`) {
t.Errorf("the cancelled call's POST: %v, body:\n%s", got.err, got.body)
}
if msg := string(serverLog.Bytes()); strings.Contains(msg, "superfluous") {
t.Errorf("the server logged:\n%s", msg)
}
} |
|
@jmrplens you are right, and i am noticing how this PR is requiring a lot of changes for a behavior that exists only in legacyProtocol (<20260728), unless it is used the stdio transport. |
…o simplify test suite
|
I pushed a new change that target only stdioConn on new protocolVersion, let me know if this is enough for your use case |
|
Yes, this scope works for us. stdio is our main transport, and on 2026-07-28 it is the one transport that carries Of the four points in my last comment, only number 3 still applies to what is left, and the narrower scope makes it worse. I moved the stress test to a 2026-07-28 session (each request carrying the #1390 changes the same line in Two smaller things:
The reuse test (a stress test like the last one; in transport_test.go, needs `sync` in the imports)type fixedTransport struct{ conn Connection }
func (t fixedTransport) Connect(context.Context) (Connection, error) { return t.conn, nil }
// TestIOConnDroppedEntryAndReusedID checks, on a 2026-07-28 session, that a
// cancellation racing with the response does not make Write discard the
// answer to a later request that reuses the ID.
func TestIOConnDroppedEntryAndReusedID(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
server := NewServer(testImpl, nil)
AddTool(server, &Tool{Name: "fast"}, func(context.Context, *CallToolRequest, struct{}) (*CallToolResult, any, error) {
return &CallToolResult{}, nil, nil
})
serverT, clientT := NewInMemoryTransports()
conn := newIOConn(serverT.rwc)
ss, err := server.Connect(ctx, fixedTransport{conn}, nil)
if err != nil {
t.Fatal(err)
}
defer ss.Close()
var mu sync.Mutex
answered := map[int64]int{}
got := make(chan int64, 1<<16)
go func() {
for scanner := bufio.NewScanner(clientT.rwc); scanner.Scan(); {
msg, err := jsonrpc2.DecodeMessage(scanner.Bytes())
if r, ok := msg.(*jsonrpc.Response); ok && err == nil {
id, _ := r.ID.Raw().(int64)
mu.Lock()
answered[id]++
mu.Unlock()
got <- id
}
}
}()
send := func(msg jsonrpc.Message) {
data, err := jsonrpc2.EncodeMessage(msg)
if err != nil {
t.Fatal(err)
}
if _, err := clientT.rwc.Write(append(data, '\n')); err != nil {
t.Fatal(err)
}
}
waitFor := func(id int64) {
for {
select {
case v := <-got:
if v == id {
return
}
case <-ctx.Done():
t.Fatalf("no answer to %d", id)
}
}
}
meta := newProtocolMeta("")
next := int64(1)
for round := 0; round < 20; round++ {
for range 5000 {
send(req(next, "tools/call", &CallToolParams{Meta: meta, Name: "fast"}))
send(req(0, notificationCancelled, &CancelledParams{RequestID: next}))
next++
}
send(req(next, "ping", &PingParams{Meta: meta}))
waitFor(next)
next++
time.Sleep(100 * time.Millisecond) // let the last cancellations finish
var stale int64
conn.sessionMu.Lock()
mu.Lock()
for id := range conn.dropped {
if n, _ := id.Raw().(int64); answered[n] > 0 {
stale = n
}
}
mu.Unlock()
conn.sessionMu.Unlock()
if stale == 0 {
continue
}
// The client has the response to stale, so 2026-07-28 lets it reuse the ID.
send(req(stale, "tools/call", &CallToolParams{Meta: meta, Name: "fast"}))
send(req(next, "ping", &PingParams{Meta: meta}))
waitFor(next)
time.Sleep(time.Second)
mu.Lock()
defer mu.Unlock()
if answered[stale] < 2 {
t.Errorf("the request that reused ID %d was never answered", stale)
}
return
}
} |
A peer cancellation is currently scheduled in a goroutine before looking up its request. If the old invocation finishes and the read loop admits a replacement with the same ID first, that lookup can cancel the replacement. Call `CancelCause` directly from `canceller.Preempt`, binding cancellation to the active invocation before the read loop advances. `CancelCause` captures the request under the existing connection lock and cancels its context; it does not wait for handler completion or response delivery. The peer's cancellation reason is preserved. No public API, numeric-ID behavior, or response-suppression behavior changes. This PR changes only `mcp/transport.go`. The dedicated cancellation-reuse test harness is retained downstream. Validation on Go 1.27.1, darwin/arm64: - After removing the dedicated test: `go test -race ./mcp -run TestCancellation -count=1 -timeout=180s` and `git diff --check` passed. - The unchanged production fix previously passed `go test ./... -timeout=10m`, cancellation race tests, `go vet ./...`, and `go build ./...`. - Separate protocol conformance scripts were not run. Related: #1267 changes this same call to `CancelFromPeer`; if that lands first, the invocation lookup should still happen before returning from preemption. This fix is independent of #1375 and contains no numeric decoding or transport hardening changes. Fixes #1389. --------- Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
|
Agreed, all your points should been addressed now and ready to merge |
|
Thanks. I checked ffe0f81 and the race is closed:
Nothing in the suite holds the new lock discipline: with On #1259: the reproduction I gave there is a 2025-06-18 stdio session and a streamable HTTP POST, and both still get the response after this change, by design. What this fixes is the case #1235 reported, which quotes the 2026-07-28 stdio text and which you closed as a duplicate of #1259. Anyone rerunning #1235's snippet will still see The test (in internal/jsonrpc2/conn_test.go, needs fmt, slices and time in the imports)// slowDropper is a ResponseDropper whose DropResponse waits for proceed. It
// records the drops and the responses it is given, in order.
type slowDropper struct {
entered, proceed chan struct{}
written chan struct{}
mu sync.Mutex
events []string
}
func (w *slowDropper) DropResponse(id ID) {
close(w.entered)
<-w.proceed
w.record(fmt.Sprintf("drop %v", id.Raw()))
}
func (w *slowDropper) Write(_ context.Context, msg Message) error {
if resp, ok := msg.(*Response); ok {
w.record(fmt.Sprintf("write %v", resp.ID.Raw()))
w.written <- struct{}{}
}
return nil
}
func (w *slowDropper) record(event string) {
w.mu.Lock()
defer w.mu.Unlock()
w.events = append(w.events, event)
}
// TestCancelFromPeerDropsBeforeTheResponse checks that a writer is told to
// drop a response before it is handed that response, even when the handler
// returns while the writer is still being told.
func TestCancelFromPeerDropsBeforeTheResponse(t *testing.T) {
w := &slowDropper{entered: make(chan struct{}), proceed: make(chan struct{}), written: make(chan struct{}, 2)}
started, release := make(chan struct{}), make(chan struct{})
conn, incoming := newCancelTestConn(t, w, func(context.Context) (any, error) {
close(started)
<-release
return struct{}{}, nil
})
incoming <- mustCall(t, 1, "slow")
<-started
cancelled := make(chan struct{})
go func() {
conn.CancelFromPeer(Int64ID(1), errPeerAskedToStop)
close(cancelled)
}()
<-w.entered
close(release) // the handler returns while DropResponse runs
writes := 0
select {
case <-w.written:
writes++
case <-time.After(100 * time.Millisecond):
}
close(w.proceed)
<-cancelled
incoming <- mustCall(t, 2, "barrier")
for ; writes < 2; writes++ {
<-w.written
}
w.mu.Lock()
events := slices.Clone(w.events)
w.mu.Unlock()
if want := []string{"drop 1", "write 1", "write 2"}; !slices.Equal(events, want) {
t.Errorf("the writer saw %q, want %q", events, want)
}
if err := conn.Close(); err != nil {
t.Errorf("Close() = %v", err)
}
}At 746a459 it fails with |
|
Yes I would close #1259 with a comment specifying only stdio in new protocolVersion has been targeted, and no extra changes added to the PR |
The cancellation utility of 2026-07-28 says that a server receiving
notifications/cancelledSHOULD "Not send a response for the cancelled request". On that revision stdio is the transport that carries the notification: over streamable HTTP, closing the response stream is the cancellation and no notification is expected (seeStreamableHTTPOptions.PropagateRequestCancellation). This SDK answered the cancelled request anyway, becauseprocessResultininternal/jsonrpc2/conn.gowrites the response withnotDone{req.ctx}and no application code runs in between.This change drops that response on the stdio-style transports (
StdioTransport,CommandTransport,IOTransportandInMemoryTransport) for sessions on 2026-07-28 or later. Sessions on earlier versions, the SSE transport and the streamable transport keep answering a cancelled request, which is why this updates #1259 rather than fixing it.internal/jsonrpc2:Connection.CancelFromPeerisCancelCausefor a cancellation the peer asked for. Before it cancels the call's context, it tells a writer that implements the new optionalResponseDropperinterface which call's response to drop. It does so under the connection's lock, in the critical section that finds the request, andprocessResultremoves the request under the same lock before it writes the response, so the writer is told either before it is handed the response or not at all;ResponseDropperdocuments thatDropResponseruns with the lock held. The connection still writes the response once the handler returns, and dropping it is the writer's decision.CancelandCancelCausekeep their meaning, so the listensServerSession.Closecancels still get their results. Asubscriptions/listenthat the client cancels withnotifications/cancelledis treated like any other cancelled request: on these sessions it no longer gets itsSubscriptionsListenResult.mcp:canceller.PreemptcallsCancelFromPeerwhere mcp: bind peer cancellation before request ID reuse #1390 callsCancelCause, directly rather than in a goroutine.ioConnimplementsDropResponseand discards the response to a dropped call when the negotiated version is 2026-07-28 or later.loggingConnforwardsDropResponseandsessionUpdatedto its delegate. The jsonrpc2 connection asks its writer forResponseDropper, andServerSessionasks its connection forsessionUpdated, which is howioConnlearns the versionDropResponsechecks. Without the forwarders, aLoggingTransportwould hide both fromioConn. That also gives a server-sideLoggingTransportthe session updates mcp: LoggingTransport drops session state updates, so a wrapped transport behaves differently #1328 reports missing; the client side of mcp: LoggingTransport drops session state updates, so a wrapped transport behaves differently #1328 is not covered.docs/protocol.mdsays which transports and versions send no response.Tests:
TestCancelFromPeerTellsWriterandTestCancelFromPeerHandlerIgnoresContextininternal/jsonrpc2;TestIOConnDropResponse,TestIOConnCancelledCallGetsNoResponse(end to end over an in-memory transport wrapped inLoggingTransport) andTestLoggingConnDropResponseinmcp.Updates #1259.