Skip to content

fix: prevent stack overflow in transport close with re-entrancy guard#1788

Open
claygeo wants to merge 3 commits intomodelcontextprotocol:mainfrom
claygeo:fix/transport-close-stack-overflow
Open

fix: prevent stack overflow in transport close with re-entrancy guard#1788
claygeo wants to merge 3 commits intomodelcontextprotocol:mainfrom
claygeo:fix/transport-close-stack-overflow

Conversation

@claygeo
Copy link
Copy Markdown

@claygeo claygeo commented Mar 27, 2026

Problem

When 15-25+ transports close simultaneously (e.g., server shutdown under load), close()onclose() triggers the Protocol layer to call close() again recursively, causing a RangeError: Maximum call stack size exceeded. The process stays alive but becomes completely unresponsive.

Root cause (3 issues in close())

  1. No re-entrancy guard: this.onclose?.() at line 911 can trigger the Protocol layer to call close() again, creating infinite recursion
  2. Mutation during iteration: Each cleanup() callback calls this._streamMapping.delete(...), mutating the map being iterated by for...of
  3. No error isolation: If one cleanup() throws, remaining streams are never cleaned up

Reproduction

Start an MCP server with 15+ concurrent SSE connections, then trigger a shutdown. The close cascade causes stack overflow:

RangeError: Maximum call stack size exceeded
    at WebStandardStreamableHTTPServerTransport.close (streamableHttp.ts:902)
    at WebStandardStreamableHTTPServerTransport.close (streamableHttp.ts:911)
    at WebStandardStreamableHTTPServerTransport.close (streamableHttp.ts:911)
    ...

Solution

private _closing = false;

async close(): Promise<void> {
    if (this._closing) return;       // 1. Re-entrancy guard
    this._closing = true;

    const streams = Array.from(...);  // 2. Snapshot before clearing
    this._streamMapping.clear();

    for (const { cleanup } of streams) {
        try { cleanup(); } catch {}   // 3. Error isolation
    }

    this.onclose?.();                 // Only after all cleanup
}

Why prior PRs (#1700, #1705) were insufficient

Changes

  • packages/server/src/server/streamableHttp.ts — Rewrote close() with re-entrancy guard, snapshot-before-clear, and try/catch error isolation

Test Plan

  • Single transport close → cleanup runs normally, onclose fires ✅
  • Re-entrant close (onclose triggers close again) → second call returns immediately ✅
  • 25 concurrent transport closes → no stack overflow, all streams cleaned up ✅
  • One cleanup() throws → remaining streams still cleaned up ✅

Closes #1699

When 15-25+ transports close simultaneously, close() -> onclose()
triggers the Protocol layer to call close() again recursively,
causing a stack overflow. The process stays alive but becomes
unresponsive.

Three problems in the original close():
1. No re-entrancy guard: onclose() can call back into close()
2. Iterating _streamMapping while cleanup callbacks delete from it
3. No error isolation: one failing cleanup prevents others

The fix:
- Adds _closing boolean guard to prevent recursive calls
- Snapshots stream values into an array and clears the map before
  iterating, avoiding mutation-during-iteration
- Wraps each cleanup() in try/catch so one failure doesn't block
  remaining stream cleanups
- Calls onclose() only after all cleanup is complete

Note: two prior PRs (modelcontextprotocol#1700, modelcontextprotocol#1705) attempted similar fixes but were
closed. This implementation combines the best elements of both:
the re-entrancy guard from modelcontextprotocol#1700 and the snapshot-before-clear
pattern from modelcontextprotocol#1705, with the addition of error isolation.

Closes modelcontextprotocol#1699
@claygeo claygeo requested a review from a team as a code owner March 27, 2026 15:31
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 27, 2026

⚠️ No Changeset found

Latest commit: 0a8e69e

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 27, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1788

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1788

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1788

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1788

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1788

commit: 0a8e69e

claygeo and others added 2 commits March 27, 2026 15:06
Replace Array.from(map.values()) with [...map.values()] to fix
the unicorn/prefer-spread ESLint rule.
Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger left a comment

Choose a reason for hiding this comment

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

Thanks - can you demonstrate this with a test?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RangeError: Maximum call stack size exceeded in webStandardStreamableHttp.js:639 when multiple transports close simultaneously

2 participants