Skip to content
29 changes: 24 additions & 5 deletions packages/server/src/server/streamableHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,15 +899,34 @@ export class WebStandardStreamableHTTPServerTransport implements Transport {
return undefined;
}

private _closing = false;

async close(): Promise<void> {
// Close all SSE connections
for (const { cleanup } of this._streamMapping.values()) {
cleanup();
// Guard against re-entrant calls. When onclose() triggers the
// Protocol layer to call close() again, this prevents infinite
// recursion that causes a stack overflow with many transports.
if (this._closing) {
return;
}
this._streamMapping.clear();
this._closing = true;

// Clear any pending responses
// Snapshot and clear before iterating to avoid issues with
// cleanup callbacks that modify the map during iteration.
const streams = [...this._streamMapping.values()];
this._streamMapping.clear();
this._requestResponseMap.clear();

// Close all SSE connections with error isolation so one
// failing cleanup doesn't prevent others from running.
for (const { cleanup } of streams) {
try {
cleanup();
} catch {
// Individual stream cleanup failures should not
// prevent other streams from being cleaned up.
}
}

this.onclose?.();
}

Expand Down
37 changes: 37 additions & 0 deletions packages/server/test/server/streamableHttp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -956,4 +956,41 @@ describe('Zod v4', () => {
expect(error?.message).toContain('Unsupported protocol version');
});
});

describe('close() re-entrancy guard', () => {
it('should not recurse when onclose triggers a second close()', async () => {
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID });

let closeCallCount = 0;
transport.onclose = () => {
closeCallCount++;
// Simulate the Protocol layer calling close() again from within onclose —
// the re-entrancy guard should prevent infinite recursion / stack overflow.
void transport.close();
};

// Should resolve without throwing RangeError: Maximum call stack size exceeded
await expect(transport.close()).resolves.toBeUndefined();
expect(closeCallCount).toBe(1);
});

it('should clean up all streams exactly once even when close() is called concurrently', async () => {
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID });

const cleanupCalls: string[] = [];

// Inject a fake stream entry to verify cleanup runs exactly once
// @ts-expect-error accessing private map for test purposes
transport._streamMapping.set('stream-1', {
cleanup: () => {
cleanupCalls.push('stream-1');
}
});

// Fire two concurrent close() calls — only the first should proceed
await Promise.all([transport.close(), transport.close()]);

expect(cleanupCalls).toEqual(['stream-1']);
});
});
});
Loading