Skip to content

Streamable HTTP: a session teardown the caller did not request is indistinguishable from cancellation #2691

Description

@retif

This issue has been rewritten. It originally claimed that close() discarding in-flight requests was an SDK defect. Implementing that broke test-e2e on Node 20/22/24 against your own conformance requirement hosting:session:delete-cancels-inflight, which is right and my claim was not. The withdrawn claim and what survives it are both below; nothing has been deleted, only corrected.

What I hit

A client hung for its full idle timeout on a tools/call while the session it was running under was torn down. The HTTP response it received was 200 text/event-stream, chunked, zero bytes — complete and successful at the transport layer, with no JSON-RPC answer for the id it was waiting on.

Against a server whose session is torn down mid-call (DELETE), no proxy involved:

HTTP Content-Type Transfer-Encoding bytes answer for the request id?
baseline 200 text/event-stream 941 498 yes
torn down, trial 1 200 text/event-stream chunked 0 no
trial 2 200 text/event-stream chunked 0 no
trial 3 200 text/event-stream chunked 0 no

7/7 across two runs, deterministic, on 1.16.0; the same code shape is on main at 3924de9.

Why that is not the bug I thought it was

WebStandardStreamableHTTPServerTransport.close() ends every per-request SSE stream without writing an event. I read that as a lost reply. It isn't:

hosting:session:delete-cancels-inflight — DELETE on a session aborts every in-flight request handler's RequestHandlerExtra.signal; their POST-initiated SSE streams close without a JSON-RPC response being written.

The handler is aborted, so the request is cancelled, and a cancelled JSON-RPC request gets no response. An empty stream is the encoding of cancellation. My reproduction forces teardown with an explicit DELETE, which is exactly the input that requirement covers — so the table above is reproducing intended behaviour, and the "expected" section this issue used to carry is withdrawn.

What survives, and where it went

The JSON-response-mode path admits no such reading. handleRequest() returns a Promise<Response> that only send() resolves; a stream mapping's cleanup deletes the entry without settling it, so close() during an in-flight POST leaves the HTTP request open until the socket dies. There is no cancellation semantics for "the response never arrives and the socket stays open".

That is already tracked as #2559, and #2692 fixes exactly it — packages/server 478 passing, test/e2e 2639 passing with hosting:session:delete-cancels-inflight intact.

The question that is actually left

In my case the teardown came from an intermediary, not from the caller that was waiting. No client cancelled anything; a proxy between them terminated the session, and the waiting caller could not distinguish that from a slow call.

handleDeleteRequest does not call close() — the hosting layer does — so the transport cannot tell "this client terminated its session" from "the process is shutting down", and resolving that ambiguity as cancelled is a reasonable default. The open question is whether the hosting layer should be able to signal in-flight callers when a session teardown was not requested by the caller waiting on it, or whether that is squarely the intermediary's problem to solve (which is what I ended up doing downstream).

If maintainers don't want to carry that as an open question, please close this as invalid#2692 stands on #2559 alone and does not depend on it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    v1Issues / PRs related to v1.xv2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions