From 1430ae20db68f8888693afdd1bb3771b36e060c9 Mon Sep 17 00:00:00 2001 From: Aditya Jain Date: Thu, 20 Aug 2026 03:13:35 -0700 Subject: [PATCH] fix(aws-cdk): remove SIGINT listener leak in TelemetrySession fixes #1862 TelemetrySession.begin() registered a listener on the process-global SIGINT event on every call, and nothing ever removed it -- not in begin(), not in end(), not anywhere else. Each closure retains the full TelemetrySession (ioHost, telemetry client, sanitized session info), so this is a real leak, not just a stray function reference: every begin() call in a long-running process (e.g. calling exec() more than once without exiting, as this repo's own cli.test.ts / cli-commands.test.ts / deploy-options.test.ts etc. do) permanently pins another retained object graph, and past 10 accumulated listeners Node prints MaxListenersExceededWarning. Store the listener reference on the instance and remove it in end(), which is already called on both the normal-completion path (cli.ts's .finally()) and the SIGINT path itself. Added a regression test that asserts process.listenerCount('SIGINT') does not grow across 20 begin()/end() cycles. Co-Authored-By: Claude Sonnet 5 --- packages/aws-cdk/lib/cli/telemetry/session.ts | 34 +++++++++++++------ .../test/cli/telemetry/session.test.ts | 33 ++++++++++++++++++ 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/packages/aws-cdk/lib/cli/telemetry/session.ts b/packages/aws-cdk/lib/cli/telemetry/session.ts index 5b2b77056..683e8c8b8 100644 --- a/packages/aws-cdk/lib/cli/telemetry/session.ts +++ b/packages/aws-cdk/lib/cli/telemetry/session.ts @@ -61,6 +61,7 @@ export class TelemetrySession { private client: ITelemetrySink; private _sessionInfo?: SessionSchema; private _commandSpan?: IMessageSpan; + private sigintListener?: () => void; private _nextEventCounters?: Record; private count = 0; private loadTime?: number; @@ -119,17 +120,24 @@ export class TelemetrySession { // If SIGINT has a listener installed, its default behavior will be removed (Node.js will no longer exit). // This ensures that on SIGINT we process safely close the telemetry session before exiting. - process.on('SIGINT', async () => { - try { - await this.end({ - name: USER_INTERRUPTED_CODE, - message: ABORTED_ERROR_MESSAGE, - }); - } catch (e: any) { - await this.ioHost.defaults.trace(`Ending Telemetry failed: ${e.message}`); - } - process.exit(1); - }); + // The listener is removed in end() -- without that, every begin() call (e.g. one per + // exec() invocation in a long-running host process) would permanently add another + // listener to the process-global SIGINT emitter, leaking the retained TelemetrySession + // (and everything it references) for the lifetime of the process. + this.sigintListener = () => { + void (async () => { + try { + await this.end({ + name: USER_INTERRUPTED_CODE, + message: ABORTED_ERROR_MESSAGE, + }); + } catch (e: any) { + await this.ioHost.defaults.trace(`Ending Telemetry failed: ${e.message}`); + } + process.exit(1); + })(); + }; + process.on('SIGINT', this.sigintListener); // Begin the session span this._commandSpan = await this.ioHost.asIoHelper().span(CLI_PRIVATE_SPAN.COMMAND).begin({}); @@ -221,6 +229,10 @@ export class TelemetrySession { * and notifies with an optional error message in the data. */ public async end(error?: ErrorDetails) { + if (this.sigintListener) { + process.removeListener('SIGINT', this.sigintListener); + this.sigintListener = undefined; + } await this._commandSpan?.end({ error }); // Ideally span.end() should no-op if called twice, but that is not the case right now this._commandSpan = undefined; diff --git a/packages/aws-cdk/test/cli/telemetry/session.test.ts b/packages/aws-cdk/test/cli/telemetry/session.test.ts index 0df0d55ca..556c9ceab 100644 --- a/packages/aws-cdk/test/cli/telemetry/session.test.ts +++ b/packages/aws-cdk/test/cli/telemetry/session.test.ts @@ -127,6 +127,39 @@ describe('TelemetrySession', () => { expect(spanEndSpy).toHaveBeenCalledTimes(1); }); + test('begin() registers exactly one SIGINT listener, and end() removes it', async () => { + // GIVEN begin() was already called once in beforeEach + const before = process.listenerCount('SIGINT'); + + // WHEN + await session.end(); + + // THEN + expect(process.listenerCount('SIGINT')).toBe(before - 1); + }); + + test('repeated begin()/end() cycles do not accumulate SIGINT listeners', async () => { + // GIVEN begin() was already called once in beforeEach; end it to get to a clean baseline + await session.end(); + const baseline = process.listenerCount('SIGINT'); + + // WHEN -- simulate many exec() invocations in a single long-running process + for (let i = 0; i < 20; i++) { + const client = new IoHostTelemetrySink({ ioHost }); + const s = new TelemetrySession({ + ioHost, + client, + arguments: { _: ['deploy'], STACKS: ['MyStack'] }, + context: new Context(), + }); + await s.begin(); + await s.end(); + } + + // THEN -- no net growth, and nowhere near Node's default max-listener warning threshold (10) + expect(process.listenerCount('SIGINT')).toBe(baseline); + }); + test('end flushes events', async () => { // GIVEN await session.emit({