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({