Skip to content

fix: prevent stack overflow on concurrent transport close#1700

Closed
ctonneslan wants to merge 1 commit intomodelcontextprotocol:mainfrom
ctonneslan:fix/prevent-recursive-close-overflow
Closed

fix: prevent stack overflow on concurrent transport close#1700
ctonneslan wants to merge 1 commit intomodelcontextprotocol:mainfrom
ctonneslan:fix/prevent-recursive-close-overflow

Conversation

@ctonneslan
Copy link

Problem

When multiple MCP transport sessions close simultaneously (server restart, network partition, mass client disconnect), a recursive promise rejection cascade in close() causes a stack overflow that crashes the Node.js process and makes it completely unresponsive.

RangeError: Maximum call stack size exceeded

The process doesn't exit — it stays alive but hangs. All new connections time out. The only recovery is a full restart. This happens 2-3 times per day in production with 100+ concurrent sessions.

Root Cause

close() calls this.onclose?.() which can trigger the Protocol layer to call close() again on the same transport. With multiple transports closing simultaneously, the callbacks cascade recursively until the stack overflows.

Fix

Added a _closing guard flag to close():

async close(): Promise<void> {
    if (this._closing) {
        return; // Prevent re-entrant close
    }
    this._closing = true;
    // ... existing cleanup
}

This is the standard pattern for preventing re-entrant async operations.

Fixes #1699

When multiple MCP transport sessions close simultaneously (server
restart, network partition, mass disconnect), the onclose callbacks
can trigger recursive close operations that overflow the call stack.
The process stays alive but becomes completely unresponsive.

Added a _closing guard flag that prevents re-entrant close() calls.
Once close() is entered, subsequent calls return immediately.

Fixes modelcontextprotocol#1699
@ctonneslan ctonneslan requested a review from a team as a code owner March 18, 2026 18:12
@changeset-bot
Copy link

changeset-bot bot commented Mar 18, 2026

🦋 Changeset detected

Latest commit: e750b28

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@modelcontextprotocol/server Patch
@modelcontextprotocol/express Patch
@modelcontextprotocol/hono Patch
@modelcontextprotocol/node Patch

Not sure what this means? Click here to learn what changesets are.

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

@pkg-pr-new
Copy link

pkg-pr-new bot commented Mar 18, 2026

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/server

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

@modelcontextprotocol/express

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: e750b28

Copy link

@travisbreaks travisbreaks left a comment

Choose a reason for hiding this comment

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

The re-entrancy guard is the right pattern for this bug. Simple and effective.

However, note that PR #1705 by the same author targets the same method with a more comprehensive fix: it snapshots the stream mapping before clearing, wraps individual cleanup calls in try/catch, and moves onclose into a finally-adjacent position.

Specifically, #1705 also adds onerror callback invocations across multiple transport send() methods (stdio, websocket, streamableHttp), which makes it a superset of this PR plus #1704.

Recommendation for maintainers: These three PRs (#1700, #1704, #1705) overlap significantly. It may be worth consolidating. If only one lands, #1705 covers the most ground, though it also has the largest blast radius.

One note on #1705's close(): onclose?.() is called after the try/finally block. If _requestResponseMap.clear() throws (unlikely but possible), onclose would never fire. Moving it into the finally block would be safer:

} finally {
    this.onclose?.();
}

@ctonneslan
Copy link
Author

Closing in favor of #1705 which covers the same re-entrancy guard plus snapshot-before-clear and individual try/catch on cleanup calls.

@ctonneslan ctonneslan closed this Mar 19, 2026
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