diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts index f2fb267c7fc..8a049f0afac 100644 --- a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts @@ -80,6 +80,8 @@ export class ControlDispatcher implements IPendingRequestRegistry { private pendingOutgoingRequests: Map = new Map(); + private abortHandler: (() => void) | null = null; + constructor(context: IControlContext) { this.context = context; @@ -102,9 +104,10 @@ export class ControlDispatcher implements IPendingRequestRegistry { // this.hookController = new HookController(context, this, 'HookController'); // Listen for main abort signal - this.context.abortSignal.addEventListener('abort', () => { + this.abortHandler = () => { this.shutdown(); - }); + }; + this.context.abortSignal.addEventListener('abort', this.abortHandler); } /** @@ -240,6 +243,12 @@ export class ControlDispatcher implements IPendingRequestRegistry { shutdown(): void { debugLogger.debug('[ControlDispatcher] Shutting down'); + // Remove abort listener to prevent memory leak + if (this.abortHandler) { + this.context.abortSignal.removeEventListener('abort', this.abortHandler); + this.abortHandler = null; + } + // Cancel all incoming requests for (const [ _requestId, diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts index ae04eb6421f..6b8c9b88038 100644 --- a/packages/cli/src/nonInteractive/session.ts +++ b/packages/cli/src/nonInteractive/session.ts @@ -408,7 +408,8 @@ class Session { private handleInterrupt(): void { debugLogger.info('[Session] Interrupt requested'); this.abortController.abort(); - this.abortController = new AbortController(); + // Do not create a new AbortController to prevent listener leaks. + // Subsequent queries will check signal.aborted and fail immediately. } private setupSignalHandlers(): void { diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 7d1a936a418..50c1db3bd3f 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -83,6 +83,7 @@ export class Query implements AsyncIterable { private firstResultReceivedResolve?: () => void; private readonly isSingleTurn: boolean; + private abortHandler: (() => void) | null = null; constructor( transport: Transport, @@ -125,12 +126,13 @@ export class Query implements AsyncIterable { logger.error('Error during abort cleanup:', err); }); } else { - this.abortController.signal.addEventListener('abort', () => { + this.abortHandler = () => { this.inputStream.error(new AbortError('Query aborted by user')); this.close().catch((err) => { logger.error('Error during abort cleanup:', err); }); - }); + }; + this.abortController.signal.addEventListener('abort', this.abortHandler); } this.initialized = this.initialize(); @@ -719,6 +721,15 @@ export class Query implements AsyncIterable { this.closed = true; + // Remove abort listener to prevent memory leak + if (this.abortHandler) { + this.abortController.signal.removeEventListener( + 'abort', + this.abortHandler, + ); + this.abortHandler = null; + } + for (const pending of this.pendingControlRequests.values()) { pending.abortController.abort(); clearTimeout(pending.timeout);