Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/streamable-http-sse-keepalive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': patch
---

`WebStandardStreamableHTTPServerTransport` now writes SSE keep-alive comment frames (`: keepalive`) to open SSE streams so idle connections (e.g. the standalone GET stream, or a POST stream during a long-running tool call) are not killed by intermediaries or server idle timeouts. Configurable via the new `keepAliveMs` option (default 15000; set 0 to disable).
Comment thread
claude[bot] marked this conversation as resolved.
8 changes: 8 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,14 @@

The Resource Server helpers did not move there: `requireBearerAuth`, `mcpAuthMetadataRouter` and `OAuthTokenVerifier` are first-class in `@modelcontextprotocol/express` — see [Authorization](./serving/authorization.md). `@modelcontextprotocol/server-legacy` is frozen and receives no new features; serve new code over [Streamable HTTP](./serving/http.md), which still reaches 2025-era clients through [legacy client support](./serving/legacy-clients.md). A client limited to the HTTP+SSE transport is the one case that still needs the frozen `@modelcontextprotocol/server-legacy/sse` import above.

## `SSE stream disconnected: TypeError: terminated`

An idle SSE stream was killed by an intermediary or an idle-connection timeout — Node's `server.requestTimeout` defaults to 300 seconds, and reverse proxies and cloud load balancers have similar watchdogs. The client observes the dropped socket as this error (typically every ~5 minutes) and reconnects in a loop.

`WebStandardStreamableHTTPServerTransport` prevents this by writing an SSE comment frame (`: keepalive`) to every open SSE stream every 15 seconds by default. Comment frames are dropped by SSE parsers before event dispatch, so they never surface as protocol messages. Tune or disable the interval with the transport's `keepAliveMs` option (`0` disables); `createMcpHandler`'s `keepAliveMs` option covers both its `subscriptions/listen` streams and the legacy fallback's per-request transport.

If you still see this error, either keep-alive is disabled (`keepAliveMs: 0`) or an intermediary between client and server buffers or strips SSE data — check for proxies that buffer streaming responses (e.g. nginx without `proxy_buffering off`).

Check failure on line 163 in docs/troubleshooting.md

View check run for this annotation

Claude / Claude Code Review

Modern per-request SSE streams still have no keep-alive; new troubleshooting entry rules out the real cause

createMcpHandler's modern (2026-07-28) leg serves request exchanges via PerRequestHTTPServerTransport, which has no keep-alive at all — writeCommentFrame() (perRequestTransport.ts:326) has zero production callers — so a long-running modern tools/call with responseMode 'sse' (or 'auto' upgraded by a mid-call notification) still idles past Node's 300s requestTimeout and dies with 'SSE stream disconnected: TypeError: terminated'. The new troubleshooting entry then affirmatively rules out this cause
Comment on lines +157 to +163

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 createMcpHandler's modern (2026-07-28) leg serves request exchanges via PerRequestHTTPServerTransport, which has no keep-alive at all — writeCommentFrame() (perRequestTransport.ts:326) has zero production callers — so a long-running modern tools/call with responseMode 'sse' (or 'auto' upgraded by a mid-call notification) still idles past Node's 300s requestTimeout and dies with 'SSE stream disconnected: TypeError: terminated'. The new troubleshooting entry then affirmatively rules out this cause ('either keep-alive is disabled (keepAliveMs: 0) or an intermediary... buffers or strips SSE data'), sending modern-leg users to chase proxy buffering that isn't the problem. Either drive writeCommentFrame() from a keepAliveMs interval in PerRequestHTTPServerTransport, or scope the troubleshooting prose to the streams that actually have keep-alive.

Extended reasoning...

The remaining gap. This PR (with follow-up 6d20a1f) adds keep-alive to WebStandardStreamableHTTPServerTransport's SSE streams and threads keepAliveMs through createMcpHandler's subscriptions/listen router and legacy stateless fallback. But createMcpHandler's PRIMARY modern (2026-07-28) leg serves every request exchange through invoke()PerRequestHTTPServerTransport (invoke.ts:59, constructed with only {classification, responseMode}), and that transport has no keep-alive whatsoever. It even ships a purpose-built heartbeat method — writeCommentFrame() at packages/server/src/server/perRequestTransport.ts:326, whose JSDoc calls it "a keep-alive heartbeat" — but a grep across packages/server/src finds zero production callers (only two calls in test/server/perRequestStreaming.test.ts). No setInterval/keep-alive wiring exists in invoke.ts or perRequestTransport.ts.\n\nThe idle scenario is real on this leg. With responseMode: 'sse', the transport eagerly opens the SSE stream immediately after dispatch (perRequestTransport.ts ~316: "Forced-SSE exchanges open their stream as soon as the request has passed the pre-dispatch gates"), so a long-running tools/call sits on an open, frameless SSE stream until the result arrives. With the default 'auto', a single mid-call notification (e.g. one progress update early in a long tool call) upgrades the exchange to SSE, after which the stream idles the same way. Either way, this is exactly the failure mode the PR is titled to fix — an idle SSE stream reaped by Node's 300s server.requestTimeout or a proxy/LB watchdog, surfacing client-side as SSE stream disconnected: TypeError: terminated. This matches the repo's Completeness recurring catch: a sibling code path is left with the very bug the PR claims to fix.\n\nThe doc misstatement introduced by this diff. The new docs/troubleshooting.md entry closes with: "If you still see this error, either keep-alive is disabled (keepAliveMs: 0) or an intermediary between client and server buffers or strips SSE data." For the modern createMcpHandler leg, neither is true: keep-alive simply does not exist there, and the handler-level keepAliveMs option — which the same paragraph correctly scopes to listen streams and the legacy fallback — does not govern per-request exchange streams. The sentence affirmatively rules out the actual remaining cause, matching the Documentation recurring catch (prose promising behavior the diff doesn't back).\n\nStep-by-step proof.\n1. Deploy createMcpHandler(factory, { responseMode: 'sse' }) behind Node's default server.requestTimeout (300s).\n2. A modern (2026-07-28) client calls a tool that takes >5 minutes and emits no mid-call messages.\n3. serveModerninvoke() constructs PerRequestHTTPServerTransport (no keep-alive option exists on it); forced-SSE settles the 200 SSE Response immediately after dispatch, before the handler result.\n4. The stream carries zero bytes for 300s; Node reaps the connection; the client throws SSE stream disconnected: TypeError: terminated and enters a reconnect loop.\n5. The user opens docs/troubleshooting.md, matches the heading, confirms they never set keepAliveMs: 0, and — per the entry's closing sentence — goes hunting for a buffering proxy that does not exist.\n\nWhy nothing prevents it. keepAliveMs is wired into streamableHttp.ts, listenRouter.ts, and (post-6d20a1f) legacyStatelessFallback — but invoke() has no options plumbing for it, and PerRequestHTTPServerTransport accepts no such option, so nothing fails at compile time; the gap is silent.\n\nHow to fix. The full fix: give PerRequestHTTPServerTransport a keepAliveMs option that drives writeCommentFrame() from an interval while an SSE exchange is open (cleared on close/settle), and thread createMcpHandler's keepAliveMs through invoke() — this also gives the orphaned heartbeat method its intended caller. The minimal pre-merge fix: scope the troubleshooting entry's closing sentence to the streams that actually have keep-alive (WebStandard transport streams, listen streams, legacy fallback) and note that modern per-request exchange streams do not yet emit keep-alive frames, so the entry stops ruling out the real cause. Severity is driven by the doc half: the runtime gap is pre-existing in perRequestTransport, but the troubleshooting prose ships in this diff and actively misdirects users on the SDK's flagship modern serving path.


## Recap

- Every heading on this page is the exact message you searched for.
Expand Down
23 changes: 18 additions & 5 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,9 @@
*/
maxSubscriptions?: number;
/**
* SSE comment-frame keepalive interval for `subscriptions/listen` streams,
* in milliseconds. Set to `0` to disable.
* SSE comment-frame keepalive interval, in milliseconds, applied to
* `subscriptions/listen` streams and to the SSE streams of the legacy
* stateless fallback's per-request transport. Set to `0` to disable.
* @default 15000
*/
keepAliveMs?: number;
Expand Down Expand Up @@ -303,13 +304,17 @@
*
* The optional `onerror` callback receives factory and serving failures on
* this leg (reporting only — the response stays the 500 internal-error body).
* The entry passes its own `onerror` here when expanding the default, so
* legacy-leg failures are never silently swallowed.
*/
export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (error: Error) => void): LegacyHttpHandler {
export function legacyStatelessFallback(
factory: McpServerFactory,
onerror?: (error: Error) => void,
transportOptions?: { keepAliveMs?: number }
): LegacyHttpHandler {
return async (request, options) => {
if (request.method.toUpperCase() !== 'POST') {
return jsonRpcErrorResponse(405, -32_000, 'Method not allowed.');

Check warning on line 317 in packages/server/src/server/createMcpHandler.ts

View check run for this annotation

Claude / Claude Code Review

Stale JSDoc: two doc blocks still claim the legacy transport is constructed 'with only sessionIdGenerator: undefined'

Two doc blocks in this file still claim the legacy fallback transport is constructed "with only `sessionIdGenerator: undefined`" — the `CreateMcpHandlerOptions.legacy` JSDoc (line 148) and `legacyStatelessFallback`'s own doc block (lines 298–300, which also says "unchanged" directly above the signature this PR modified). Both now contradict the updated `keepAliveMs` JSDoc in the same interface, and the new `transportOptions` parameter on this public export is undocumented; reword both sites and
Comment on lines 307 to 317

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Two doc blocks in this file still claim the legacy fallback transport is constructed "with only sessionIdGenerator: undefined" — the CreateMcpHandlerOptions.legacy JSDoc (line 148) and legacyStatelessFallback's own doc block (lines 298–300, which also says "unchanged" directly above the signature this PR modified). Both now contradict the updated keepAliveMs JSDoc in the same interface, and the new transportOptions parameter on this public export is undocumented; reword both sites and document the parameter.

Extended reasoning...

What's stale. Commit 6d20a1f (addressing the earlier review round) threads keepAliveMs into the legacy fallback: legacyStatelessFallback gained a third transportOptions?: { keepAliveMs?: number } parameter, and its per-request transport is now constructed as new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, ...(transportOptions?.keepAliveMs !== undefined && { keepAliveMs: ... }) }) (lines 325–328), with createMcpHandler forwarding options.keepAliveMs into it (lines 643–648). Two prose sites in the same file were left describing the pre-fix construction:

  1. Line 148 (CreateMcpHandlerOptions.legacy JSDoc): "…over a streamable HTTP transport constructed with only sessionIdGenerator: undefined (the established stateless idiom)".
  2. Lines 298–300 (legacyStatelessFallback's doc block): "…constructed with only sessionIdGenerator: undefined — the established stateless idiom, unchanged" — directly above the signature this PR changed.

Why it's wrong now. The keepAliveMs option JSDoc updated by this very PR (lines 196–201) promises the option is "applied to subscriptions/listen streams and to the SSE streams of the legacy stateless fallback's per-request transport", and docs/troubleshooting.md (also added in this PR) repeats that promise. So the options interface now contradicts itself: the legacy option's JSDoc says the transport gets only sessionIdGenerator, while the keepAliveMs JSDoc two options down says it reaches that same transport. The word "unchanged" at line 300 is falsified by the diff immediately below it. Additionally, legacyStatelessFallback is a public export ("Exported as a standalone building block for hand-wired compositions"), and its new third parameter has no JSDoc at all — a consumer hand-wiring it has no documented way to discover the keep-alive plumbing.

Concrete walk-through. (1) A consumer deploys createMcpHandler(factory, { keepAliveMs: 0 }) to silence comment frames. (2) They read the legacy option's JSDoc at line 148 to understand the legacy leg and conclude the fallback transport is built with only sessionIdGenerator: undefined — i.e. the transport-level default of 15 s applies and their 0 can't reach it. (3) They rewrap or fork the legacy leg to suppress the frames — exactly the confusion 6d20a1f was made to eliminate, since the code actually does thread their 0 through. Conversely, a hand-wirer calling legacyStatelessFallback(factory, onerror) directly sees no documented transportOptions parameter and assumes the leg is untunable.

Why nothing catches it. JSDoc is prose; nothing at compile or test time checks it against the construction site, and the repo's sync:snippets machinery only covers fenced @example regions, not free prose.

This matches the repo's Documentation recurring catch (REVIEW.md #1718, #1838: prose in the same diff promising behavior the code no longer ships), making it a repo-requested check rather than a style preference — but that rule flags the finding; it doesn't make it blocking, and there is no runtime effect.

Fix. Reword both sites, e.g. "constructed with sessionIdGenerator: undefined (stateless) plus the handler's keepAliveMs when provided", drop "unchanged" from the legacyStatelessFallback doc block, and add a short JSDoc line documenting the transportOptions parameter (transport options threaded into the per-request transport; currently just keepAliveMs).

}
try {
const product = await factory({
Expand All @@ -317,7 +322,10 @@
...(options?.authInfo !== undefined && { authInfo: options.authInfo }),
requestInfo: request
});
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined,
...(transportOptions?.keepAliveMs !== undefined && { keepAliveMs: transportOptions.keepAliveMs })
});
await product.connect(transport);

const teardown = () => {
Expand Down Expand Up @@ -632,7 +640,12 @@

// The default posture is the stateless fallback; 'reject' is the only way
// to turn legacy serving off (modern-only strict).
const legacyHandler: LegacyHttpHandler | undefined = legacy === 'reject' ? undefined : legacyStatelessFallback(factory, reportError);
const legacyHandler: LegacyHttpHandler | undefined =
legacy === 'reject'
? undefined
: legacyStatelessFallback(factory, reportError, {
...(options.keepAliveMs !== undefined && { keepAliveMs: options.keepAliveMs })
});

async function serveModern(route: InboundModernRoute, request: Request, authInfo: AuthInfo | undefined): Promise<Response> {
const claimedRevision = route.classification.revision;
Expand Down
89 changes: 89 additions & 0 deletions packages/server/src/server/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,19 @@
*/
retryInterval?: number;

/**
* Interval in milliseconds between SSE keep-alive comment frames (`: keepalive`)
* written to open SSE streams. Keep-alive frames prevent idle streams (e.g. the
* standalone `GET` stream, or a `POST` stream during a long-running tool call)
* from being killed by intermediaries and server idle timeouts, which clients
* observe as `SSE stream disconnected: TypeError: terminated`.
*
* Comment frames are ignored by SSE parsers and never surface as messages.
* Defaults to `15000` (per the WHATWG SSE spec recommendation of roughly every
* 15 seconds). Set to `0` to disable keep-alive frames.
*/
keepAliveMs?: number;

/**
* List of protocol versions that this transport will accept.
* Used to validate the `mcp-protocol-version` header in incoming requests.
Expand All @@ -161,6 +174,9 @@
supportedProtocolVersions?: string[];
}

/** Default interval between SSE keep-alive comment frames. */
const DEFAULT_KEEP_ALIVE_MS = 15_000;

/**
* Options for handling a request
*/
Expand Down Expand Up @@ -247,6 +263,8 @@
private _enableDnsRebindingProtection: boolean;
private _retryInterval?: number;
private _supportedProtocolVersions: string[];
private _keepAliveMs: number;
private _keepAliveTimers: Map<string, ReturnType<typeof setInterval>> = new Map();

sessionId?: string;
onclose?: () => void;
Expand All @@ -264,6 +282,49 @@
this._enableDnsRebindingProtection = options.enableDnsRebindingProtection ?? false;
this._retryInterval = options.retryInterval;
this._supportedProtocolVersions = options.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS;
this._keepAliveMs = options.keepAliveMs ?? DEFAULT_KEEP_ALIVE_MS;
Comment thread
claude[bot] marked this conversation as resolved.
}

/**
* Arms a keep-alive interval for an SSE stream that periodically writes an SSE
* comment frame so intermediaries and idle timeouts don't kill the connection.
* Replaces any timer already armed for the same stream id (a resumed stream
* re-registered under the same id supersedes its predecessor's timer). The
* timer is cleared via {@linkcode stopKeepAlive} when the stream is cleaned up,
* and clears itself if a write fails (stream already closed/cancelled).
*/
private startKeepAlive(
streamId: string,
controller: ReadableStreamDefaultController<Uint8Array>,
encoder: InstanceType<typeof TextEncoder>
): void {
// A deferred arm (e.g. after an event-store await) must not outlive the
// transport: close()'s timer sweep has already run and never runs again.
if (this._keepAliveMs <= 0 || this._closed) {
return;
}
this.stopKeepAlive(streamId);
const timer = setInterval(() => {
try {
controller.enqueue(encoder.encode(': keepalive\n\n'));
} catch {
this.stopKeepAlive(streamId);
}
}, this._keepAliveMs);
// Don't let the keep-alive timer hold the process open (Node.js only)
(timer as { unref?: () => void }).unref?.();

Check warning on line 315 in packages/server/src/server/streamableHttp.ts

View check run for this annotation

Claude / Claude Code Review

keepAliveMs guard polarity lets NaN/oversized values flood streams at ~1ms while listenRouter disables

The guard 'if (this._keepAliveMs <= 0 || this._closed) return;' passes NaN (NaN <= 0 is false), and Node clamps setInterval delays of NaN/Infinity/>2^31-1 to ~1ms — so a non-finite keepAliveMs (e.g. parseInt of an unset env var, or Infinity as a "never" sentinel) floods every SSE stream with ~1000 ': keepalive' frames/sec, while listenRouter.ts:221 uses the opposite polarity ('if (keepAliveMs > 0)') and safely disables for the same value — one createMcpHandler option, opposite behaviors on the t
Comment on lines +300 to +315

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The guard 'if (this._keepAliveMs <= 0 || this._closed) return;' passes NaN (NaN <= 0 is false), and Node clamps setInterval delays of NaN/Infinity/>2^31-1 to ~1ms — so a non-finite keepAliveMs (e.g. parseInt of an unset env var, or Infinity as a "never" sentinel) floods every SSE stream with ~1000 ': keepalive' frames/sec, while listenRouter.ts:221 uses the opposite polarity ('if (keepAliveMs > 0)') and safely disables for the same value — one createMcpHandler option, opposite behaviors on the two legs it now covers. One-line fix: match listenRouter's polarity ('if (!(this._keepAliveMs > 0) || this._closed)') and/or normalize with Number.isFinite in the constructor.

Extended reasoning...

What the bug is. startKeepAlive() guards with if (this._keepAliveMs <= 0 || this._closed) return;. NaN <= 0 evaluates false, so a NaN value passes the guard and reaches setInterval(fn, NaN) — which Node clamps to a ~1ms delay (empirically verified on this repo's Node: ~40 fires in 50ms; only a process-level warning is emitted for the out-of-range case). Values above 2^31 - 1 (Infinity, Number.MAX_SAFE_INTEGER, or any "effectively never" sentinel) hit Node's TimeoutOverflowWarning and are likewise clamped to 1ms. The option is stored unvalidated in the constructor (this._keepAliveMs = options.keepAliveMs ?? DEFAULT_KEEP_ALIVE_MS), so nothing upstream catches it.\n\nThe intra-PR divergence. listenRouter.ts:221 guards the same-named option with the opposite polarity — if (keepAliveMs > 0) — under which NaN > 0 is false and keep-alive is safely disabled. This PR now threads one keepAliveMs value from createMcpHandler to both the listen router and the legacy-leg transport (createMcpHandler.ts:645–648, added in 6d20a1f to unify the option's scope, with JSDoc claiming uniform coverage). So createMcpHandler(factory, { keepAliveMs: NaN }) silently disables keep-alive on subscriptions/listen streams while flooding legacy-leg SSE streams at ~1kHz — same option, same value, opposite behaviors on the two legs the option's own JSDoc says it covers.\n\nStep-by-step proof.\n1. Operator configures new WebStandardStreamableHTTPServerTransport({ keepAliveMs: parseInt(process.env.MCP_KEEPALIVE_MS!) }) with the env var unset → parseInt(undefined) is NaN.\n2. A client opens the standalone GET stream. startKeepAlive('_GET_stream', ...) runs; NaN <= 0 is false, this._closed is false → guard passes.\n3. setInterval(fn, NaN) arms; Node coerces the invalid delay to 1ms.\n4. Every ~1ms the timer enqueues ': keepalive\n\n' — ~13 KB/s of junk per open stream, constant timer churn, and unbounded controller-queue growth whenever the consumer stalls. Compliant SSE parsers drop comment frames before event dispatch, so the flood is invisible client-side and hard to diagnose.\n5. Meanwhile the same NaN fed through createMcpHandler reaches listenRouter.ts:221, where NaN > 0 is false → listen streams get no keep-alive at all. The operator intended one thing and got two different wrong things.\n\nWhy existing code doesn't prevent it. TypeScript's number type admits NaN/Infinity; the <= 0 polarity was chosen to disable on 0/negative but NaN fails all comparisons, so it slips through only this polarity; and Node clamps out-of-range setInterval delays to 1 instead of throwing.\n\nAddressing the refutation. One verifier argued this is garbage-in-garbage-out on operator-controlled config: the documented domain is 0 or a positive finite number, sibling options (retryInterval, maxSubscriptions) are equally unvalidated per repo convention, and both legs agree for every in-domain value. All of that is fair — which is why this is a nit, not blocking: no valid configuration is affected, the input is never attacker-controlled, and the trigger is a caller error. But the finding doesn't dissolve: the polarity divergence is within code this PR touches, against the sibling implementation the option was explicitly modeled on (PR description: "naming matches createMcpHandler's existing keepAliveMs"), and the two failure modes for the same garbage value point in opposite directions (silent flood vs. silent disable). The listenRouter polarity makes garbage fail safe; the new transport polarity makes it fail loud-but-invisible. Matching the safer polarity costs one character of logic and requires no new validation convention.\n\nHow to fix. Invert the guard to if (!(this._keepAliveMs > 0) || this._closed) return; so NaN (and any non-comparable value) disables rather than floods, matching listenRouter.ts:221. Optionally also normalize in the constructor — this._keepAliveMs = Number.isFinite(options.keepAliveMs) ? options.keepAliveMs! : DEFAULT_KEEP_ALIVE_MS — and cap at 2^31 - 1 if Infinity-as-never should mean "disabled" rather than "default".

this._keepAliveTimers.set(streamId, timer);
}

/**
* Clears the keep-alive interval for a stream, if one is armed.
*/
private stopKeepAlive(streamId: string): void {
const timer = this._keepAliveTimers.get(streamId);
if (timer !== undefined) {
clearInterval(timer);
this._keepAliveTimers.delete(streamId);
}
}

/**
Expand Down Expand Up @@ -473,6 +534,7 @@
// it still points at THIS controller — a stale cancel must not
// delete a successor stream registered by a later GET/resume.
if (this._streamMapping.get(this._standaloneSseStreamId)?.controller === streamController) {
this.stopKeepAlive(this._standaloneSseStreamId);
this._streamMapping.delete(this._standaloneSseStreamId);
}
}
Expand All @@ -494,6 +556,7 @@
controller: streamController!,
encoder,
cleanup: () => {
this.stopKeepAlive(this._standaloneSseStreamId);
this._streamMapping.delete(this._standaloneSseStreamId);
try {
streamController!.close();
Expand All @@ -503,6 +566,8 @@
}
});

this.startKeepAlive(this._standaloneSseStreamId, streamController!, encoder);

return new Response(readable, { headers });
}

Expand Down Expand Up @@ -564,6 +629,7 @@
// a stale cancel from an earlier resume must not delete a
// successor resumed stream a re-poll has since registered.
if (replayedStreamId !== undefined && this._streamMapping.get(replayedStreamId)?.controller === streamController) {
this.stopKeepAlive(replayedStreamId);
this._streamMapping.delete(replayedStreamId);
}
}
Expand All @@ -590,6 +656,7 @@
encoder,
replayedEventIds,
cleanup: () => {
this.stopKeepAlive(replayedStreamId!);
this._streamMapping.delete(replayedStreamId!);
try {
streamController!.close();
Expand Down Expand Up @@ -618,6 +685,12 @@
}
}

// Only arm keep-alive if the stream is still registered — the
// no-in-flight-request path above may have already closed it.
if (this._streamMapping.get(replayedStreamId)?.controller === streamController!) {
this.startKeepAlive(replayedStreamId, streamController!, encoder);
}
Comment thread
claude[bot] marked this conversation as resolved.

return new Response(readable, { headers });
} catch (error) {
this.onerror?.(error as Error);
Expand Down Expand Up @@ -830,6 +903,7 @@
// resumed stream under the same streamId) must not delete
// the successor.
if (this._streamMapping.get(streamId)?.controller === streamController) {
this.stopKeepAlive(streamId);
this._streamMapping.delete(streamId);
}
}
Expand All @@ -854,6 +928,7 @@
controller: streamController!,
encoder,
cleanup: () => {
this.stopKeepAlive(streamId);
this._streamMapping.delete(streamId);
try {
streamController!.close();
Expand Down Expand Up @@ -891,6 +966,14 @@
// The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses
// This will be handled by the send() method when responses are ready

// Arm keep-alive only after the fallible awaits above — an error
// path returning 400 discards the Response, so nothing could ever
// cancel the stream and clear an already-armed timer. Skip if the
// responses already completed and cleaned the stream up.
if (this._streamMapping.get(streamId)?.controller === streamController!) {
this.startKeepAlive(streamId, streamController!, encoder);
}

return new Response(readable, { status: 200, headers });
} catch (error) {
// return JSON-RPC formatted error
Expand Down Expand Up @@ -986,6 +1069,12 @@
}
this._streamMapping.clear();

// Clear any keep-alive timers not already cleared by stream cleanup
for (const timer of this._keepAliveTimers.values()) {
clearInterval(timer);
}
this._keepAliveTimers.clear();

// Clear any pending responses
this._requestResponseMap.clear();
this.onclose?.();
Expand Down
Loading
Loading