You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(core,client): honor maxTotalTimeout 0; no EventSource resurrection after close() mid-refresh
Two review findings:
- protocol.ts _resetTimeout gated the total-budget check on truthiness,
so maxTotalTimeout: 0 — the strictest possible budget — silently
disabled the check and left the request indefinitely
progress-extendable, while 1 rejected after 1ms. Now gated on
!== undefined, mirroring the falsy-vs-legitimate-zero sweep already
applied to _oncancel's requestId and the debounce relatedRequestId
gate. Boundary test: maxTotalTimeout 0 rejects on the first progress
check.
- sse.ts's mid-session 401 recovery ran its success continuation
(_startOrAuth) after an arbitrarily long onUnauthorized await with no
closed-state check: a close() landing during the pending token
refresh let the continuation resurrect a live EventSource nothing
could tear down (the ES wrapper fetch never carries the
transport-lifetime signal, and close() had already run against the
old instance). The continuation now bails when the transport-lifetime
controller is aborted, rejecting start() with
UnauthorizedError('Transport closed during re-authentication').
Regression test: close() during a pending onUnauthorized leaves the
GET-attempt count flat and start() rejects.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HY68ke5euCq6nJ1kFePRz7
Copy file name to clipboardExpand all lines: .changeset/legacy-sse-reconnect-after-timeout.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -5,4 +5,4 @@
5
5
6
6
Stop the legacy-era (2025-11-25) Streamable HTTP SSE reconnect chain when its originating request settles. Previously, when a request on a legacy session timed out or was aborted, the client POSTed `notifications/cancelled` but the transport kept resuming the request's SSE stream via GET + Last-Event-ID indefinitely (each successful resume reset the retry counter), and a late resumed response surfaced as "Received a response for an unknown message ID". The protocol layer now threads a request-scoped abort signal to per-request-stream transports on the legacy era too, and aborts it on EVERY settlement path — cancel/timeout, `maxTotalTimeout` exceeded, and successful completion (which also releases the per-request abort listener otherwise leaked on Node 20.0–20.2) — the wire cancellation mechanism per era is unchanged (legacy connections still POST `notifications/cancelled`; modern 2026-07-28 connections still cancel via per-request stream close), and `maxTotalTimeout` settlements — which previously settled without any wire cancel signal at either era — now route through the request's cancel path and emit the era's signal (the `notifications/cancelled` POST on legacy connections and modern single-channel transports, the stream-close cancel on modern per-request-stream connections) while the caller still sees the original maxTotalTimeout error. This lives in the shared `Protocol` base, so server-initiated requests (`createMessage`, `elicitInput`) gain the same maxTotalTimeout cancellation signal. Additionally, the `notifications/cancelled` send no longer inherits the original request's `resumptionToken`, which on Streamable HTTP short-circuited the send into an SSE resume and silently swallowed the cancellation. For a request re-issued with `resumptionToken` on the SDK's own Streamable HTTP transport that POST is best-effort: the re-issued request's JSON-RPC id never reached the server (the send resumed via GET instead of POSTing), so the server cannot correlate the cancellation and such requests are only torn down locally; custom per-request-stream transports that POST the re-issued body normally give the server a correlatable id. (Same asymmetry inbound, pre-existing: progress notifications replayed on a resumed stream carry the original request's `progressToken`, so `onprogress`/`resetTimeoutOnProgress` do not survive a `resumptionToken` re-issue on this transport.) The client transport's `onerror` contract is also tightened: each failed SSE reconnect leg now reports exactly once with the underlying error (the `"Failed to reconnect SSE stream:"` wrapper message is gone), deliberate teardown (`close()` landing mid-POST/mid-GET/mid-DELETE, or a settled request's signal aborting its resume) no longer surfaces an `AbortError` through `onerror`, and `onRequestStreamEnd` now fires when a `resumptionToken` resume fails to open (a terminal outcome that previously reported only through `onerror`).
7
7
8
-
Two hardening details in the same machinery: `close()` now disarms every pending reconnect chain even when a custom `ReconnectionScheduler` cancel throws (the first error still propagates, after all chains are disarmed) and releases each chain's settlement listener, so a user-supplied cancel runs at most once across `close()` and the request's own settlement; and each request chain's reconnect legs now reuse one composed transport+request abort signal instead of composing a fresh one per leg (on Node 20.0-20.2, where `AbortSignal.any` is unavailable, per-leg composition stranded one abort-listener pair per completed resume leg on both input signals until the request settled).
8
+
Two hardening details in the same machinery: `close()` now disarms every pending reconnect chain even when a custom `ReconnectionScheduler` cancel throws (the first error still propagates, after all chains are disarmed) and releases each chain's settlement listener, so a user-supplied cancel runs at most once across `close()` and the request's own settlement; and each request chain's reconnect legs now reuse one composed transport+request abort signal instead of composing a fresh one per leg (on Node 20.0-20.2, where `AbortSignal.any` is unavailable, per-leg composition stranded one abort-listener pair per completed resume leg on both input signals until the request settled). Two adjacent lifecycle/boundary fixes from the same review sweep: `maxTotalTimeout: 0` is now honored as the strictest budget (rejecting on the first progress-driven timeout reset) instead of being silently disabled by a falsy check; and on the legacy HTTP+SSE transport (`SSEClientTransport`), a `close()` that lands while a mid-session 401 token refresh (`onUnauthorized`) is pending no longer lets the recovery continuation open a new EventSource that nothing can tear down — the continuation now rejects with `UnauthorizedError('Transport closed during re-authentication')`.
0 commit comments