Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions packages/aws-cdk/lib/cli/telemetry/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class TelemetrySession {
private client: ITelemetrySink;
private _sessionInfo?: SessionSchema;
private _commandSpan?: IMessageSpan<EventResult>;
private sigintListener?: () => void;
private _nextEventCounters?: Record<string, number>;
private count = 0;
private loadTime?: number;
Expand Down Expand Up @@ -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({});
Expand Down Expand Up @@ -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;
Expand Down
33 changes: 33 additions & 0 deletions packages/aws-cdk/test/cli/telemetry/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading