Skip to content

Commit 4cab2c3

Browse files
fix(cli): implement signal handlers for graceful shutdown (RR-655)
Rebased onto current develop, dropping changes already merged separately (redaction regex, CSS comment fix, PageStatus UI, upload math fix). Only the signal handler work remains: - SIGINT/SIGTERM handlers with proper exit codes (130/143) - Double-signal force exit, 5s cleanup timeout - Idempotent cleanupClient via dedup promise - isCancelled guards on command actions to prevent exit races - run()/main() shutdown awareness via awaitShutdown() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5562138 commit 4cab2c3

1 file changed

Lines changed: 102 additions & 27 deletions

File tree

packages/client-typescript/src/cli/rocketride.ts

Lines changed: 102 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,7 @@ export class RocketRideCLI {
801801
private connected: boolean = false;
802802
private attempt: number = 0;
803803
private cancelled: boolean = false;
804+
private signalShutdownPromise?: Promise<never>;
804805

805806
constructor() {
806807
this.setupSignalHandlers();
@@ -814,13 +815,53 @@ export class RocketRideCLI {
814815
return this.cancelled;
815816
}
816817

818+
isShuttingDown(): boolean {
819+
return this.signalShutdownPromise !== undefined;
820+
}
821+
822+
// Resolves only when the signal handler calls process.exit, so any
823+
// command/run/main flow that awaits this will stand down and let the
824+
// signal handler own the exit code.
825+
awaitShutdown(): Promise<never> {
826+
return this.signalShutdownPromise ?? new Promise<never>(() => {});
827+
}
828+
817829
private setupSignalHandlers(): void {
818-
// TODO: Enable proper signal handling
819-
// const signalHandler = () => {
820-
// this.cancel();
821-
// };
822-
// process.on('SIGINT', signalHandler);
823-
// process.on('SIGTERM', signalHandler);
830+
const FORCE_EXIT_TIMEOUT_MS = 5000;
831+
832+
const signalHandler = async (signal: string) => {
833+
const exitCode = 128 + (signal === 'SIGINT' ? 2 : 15);
834+
835+
if (this.signalShutdownPromise) {
836+
// Second signal: force exit immediately
837+
process.exit(exitCode);
838+
}
839+
840+
// Park a promise that never resolves; other flows await it to
841+
// stand down while the signal handler drives the exit.
842+
this.signalShutdownPromise = new Promise<never>(() => {});
843+
844+
this.cancel();
845+
846+
// Force exit if cleanup hangs
847+
const forceExitTimer = setTimeout(() => {
848+
console.error(`\nCleanup timed out after ${FORCE_EXIT_TIMEOUT_MS}ms, forcing exit`);
849+
process.exit(exitCode);
850+
}, FORCE_EXIT_TIMEOUT_MS);
851+
852+
try {
853+
await this.cleanupClient();
854+
} catch {
855+
// Ignore cleanup errors during signal handling
856+
} finally {
857+
clearTimeout(forceExitTimer);
858+
}
859+
860+
process.exit(exitCode);
861+
};
862+
863+
process.on('SIGINT', () => signalHandler('SIGINT'));
864+
process.on('SIGTERM', () => signalHandler('SIGTERM'));
824865
}
825866

826867
private createProgram(): Command {
@@ -858,10 +899,14 @@ export class RocketRideCLI {
858899

859900
try {
860901
const exitCode = await this.cmdStart();
861-
process.exit(exitCode);
902+
if (!this.isCancelled()) {
903+
process.exit(exitCode);
904+
}
862905
} finally {
863-
this.cancel();
864-
await this.cleanupClient();
906+
if (!this.isCancelled()) {
907+
this.cancel();
908+
await this.cleanupClient();
909+
}
865910
}
866911
});
867912

@@ -896,10 +941,14 @@ export class RocketRideCLI {
896941

897942
try {
898943
const exitCode = await this.cmdUpload();
899-
process.exit(exitCode);
944+
if (!this.isCancelled()) {
945+
process.exit(exitCode);
946+
}
900947
} finally {
901-
this.cancel();
902-
await this.cleanupClient();
948+
if (!this.isCancelled()) {
949+
this.cancel();
950+
await this.cleanupClient();
951+
}
903952
}
904953
});
905954

@@ -925,10 +974,14 @@ export class RocketRideCLI {
925974

926975
try {
927976
const exitCode = await this.cmdStatus();
928-
process.exit(exitCode);
977+
if (!this.isCancelled()) {
978+
process.exit(exitCode);
979+
}
929980
} finally {
930-
this.cancel();
931-
await this.cleanupClient();
981+
if (!this.isCancelled()) {
982+
this.cancel();
983+
await this.cleanupClient();
984+
}
932985
}
933986
});
934987

@@ -954,10 +1007,14 @@ export class RocketRideCLI {
9541007

9551008
try {
9561009
const exitCode = await this.cmdStop();
957-
process.exit(exitCode);
1010+
if (!this.isCancelled()) {
1011+
process.exit(exitCode);
1012+
}
9581013
} finally {
959-
this.cancel();
960-
await this.cleanupClient();
1014+
if (!this.isCancelled()) {
1015+
this.cancel();
1016+
await this.cleanupClient();
1017+
}
9611018
}
9621019
});
9631020

@@ -1188,16 +1245,20 @@ export class RocketRideCLI {
11881245
}
11891246
}
11901247

1248+
private _cleanupPromise?: Promise<void>;
1249+
11911250
private async cleanupClient(): Promise<void> {
1192-
if (this.client) {
1193-
try {
1194-
await this.client.disconnect();
1195-
} catch {
1196-
// Ignore cleanup errors
1197-
} finally {
1198-
this.client = undefined;
1199-
}
1251+
if (this._cleanupPromise) {
1252+
return this._cleanupPromise;
12001253
}
1254+
const client = this.client;
1255+
if (!client) {
1256+
return;
1257+
}
1258+
this.client = undefined;
1259+
this._cleanupPromise = client.disconnect().catch(() => {});
1260+
await this._cleanupPromise;
1261+
this._cleanupPromise = undefined;
12011262
}
12021263

12031264
private loadPipelineConfig(pipelineFile: string): PipelineConfig {
@@ -1693,8 +1754,15 @@ export class RocketRideCLI {
16931754
// Parse command line arguments - commander will handle command routing
16941755
try {
16951756
await program.parseAsync(process.argv);
1757+
if (this.isShuttingDown()) {
1758+
// Signal handler owns the exit; park until it calls process.exit.
1759+
await this.awaitShutdown();
1760+
}
16961761
return 0; // If we get here, a command was executed successfully
16971762
} catch (error) {
1763+
if (this.isShuttingDown()) {
1764+
await this.awaitShutdown();
1765+
}
16981766
if (error instanceof Error && error.message.includes('interrupted')) {
16991767
console.log('\nOperation interrupted by user');
17001768
return 1;
@@ -1721,11 +1789,18 @@ function formatError(e: Error): string {
17211789
}
17221790
17231791
export async function main(): Promise<void> {
1792+
const cli = new RocketRideCLI();
17241793
try {
1725-
const cli = new RocketRideCLI();
17261794
const exitCode = await cli.run();
1795+
if (cli.isShuttingDown()) {
1796+
// Signal handler owns the exit code; never race it to process.exit.
1797+
await cli.awaitShutdown();
1798+
}
17271799
process.exit(exitCode);
17281800
} catch (error) {
1801+
if (cli.isShuttingDown()) {
1802+
await cli.awaitShutdown();
1803+
}
17291804
if (error instanceof Error && error.message.includes('interrupted')) {
17301805
console.log('\n\nOperation interrupted by user');
17311806
} else {

0 commit comments

Comments
 (0)