Skip to content

Commit 214e8a0

Browse files
refactor(core): consolidate per-request cleanup into .finally()
Moves _responseHandlers.delete and _cleanupTimeout from four scattered exit paths into the .finally() block alongside the abort listener removal. This also fixes two latent bugs where the taskManager error callback and transport.send().catch() paths were only calling _cleanupTimeout, leaking entries in _responseHandlers. _progressHandlers.delete stays at the error-path call sites because _onresponse deletes it conditionally (preserveProgress for task flows) and putting it in .finally() would override that.
1 parent 8fff871 commit 214e8a0

2 files changed

Lines changed: 14 additions & 9 deletions

File tree

.changeset/fix-abort-listener-leak.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@modelcontextprotocol/core': patch
33
---
44

5-
Fix abort signal listener leak in outbound requests. When a caller reuses a single `AbortSignal` across multiple requests (common for session-scoped cancellation), the SDK previously attached a new listener per request without ever removing it. The listener is now detached when the request settles.
5+
Consolidate per-request cleanup in `_requestWithSchema` into a single `.finally()` block. This fixes an abort signal listener leak (listeners accumulated when a caller reused one `AbortSignal` across requests) and two cases where `_responseHandlers` entries leaked on send-failure paths.

packages/core/src/shared/protocol.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
801801
const { relatedRequestId, resumptionToken, onresumptiontoken } = options ?? {};
802802

803803
let onAbort: (() => void) | undefined;
804+
let cleanupMessageId: number | undefined;
804805

805806
// Send the request
806807
return new Promise<SchemaOutput<T>>((resolve, reject) => {
@@ -825,6 +826,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
825826
options?.signal?.throwIfAborted();
826827

827828
const messageId = this._requestMessageId++;
829+
cleanupMessageId = messageId;
828830
const jsonrpcRequest: JSONRPCRequest = {
829831
...request,
830832
jsonrpc: '2.0',
@@ -843,9 +845,7 @@ export abstract class Protocol<ContextT extends BaseContext> {
843845
}
844846

845847
const cancel = (reason: unknown) => {
846-
this._responseHandlers.delete(messageId);
847848
this._progressHandlers.delete(messageId);
848-
this._cleanupTimeout(messageId);
849849

850850
this._transport
851851
?.send(
@@ -908,33 +908,38 @@ export abstract class Protocol<ContextT extends BaseContext> {
908908
let outboundQueued = false;
909909
try {
910910
const taskResult = this._taskManager.processOutboundRequest(jsonrpcRequest, options, messageId, responseHandler, error => {
911-
this._cleanupTimeout(messageId);
911+
this._progressHandlers.delete(messageId);
912912
reject(error);
913913
});
914914
if (taskResult.queued) {
915915
outboundQueued = true;
916916
}
917917
} catch (error) {
918-
this._responseHandlers.delete(messageId);
919918
this._progressHandlers.delete(messageId);
920-
this._cleanupTimeout(messageId);
921919
reject(error);
922920
return;
923921
}
924922

925923
if (!outboundQueued) {
926924
// No related task or no module - send through transport normally
927925
this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => {
928-
this._cleanupTimeout(messageId);
926+
this._progressHandlers.delete(messageId);
929927
reject(error);
930928
});
931929
}
932930
}).finally(() => {
933-
// Detach the abort listener once the request settles so it doesn't
934-
// accumulate on a caller-supplied signal reused across requests.
931+
// Per-request cleanup that must run on every exit path. Consolidated
932+
// here so new exit paths added to the promise body can't forget it.
933+
// _progressHandlers is NOT cleaned up here: _onresponse deletes it
934+
// conditionally (preserveProgress for task flows), and error paths
935+
// above delete it inline since no task exists in those cases.
935936
if (onAbort) {
936937
options?.signal?.removeEventListener('abort', onAbort);
937938
}
939+
if (cleanupMessageId !== undefined) {
940+
this._responseHandlers.delete(cleanupMessageId);
941+
this._cleanupTimeout(cleanupMessageId);
942+
}
938943
});
939944
}
940945

0 commit comments

Comments
 (0)