Skip to content

Commit bcdc842

Browse files
authored
Recover from unhandled exceptions in the cli (#11750)
1 parent 4e8cc6e commit bcdc842

3 files changed

Lines changed: 136 additions & 18 deletions

File tree

apps/cli/src/commands/cli/run.ts

Lines changed: 130 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ import { runStdinStreamMode } from "./stdin-stream.js"
3232

3333
const __dirname = path.dirname(fileURLToPath(import.meta.url))
3434
const ROO_MODEL_WARMUP_TIMEOUT_MS = 10_000
35+
const SIGNAL_ONLY_EXIT_KEEPALIVE_MS = 60_000
36+
37+
function normalizeError(error: unknown): Error {
38+
return error instanceof Error ? error : new Error(String(error))
39+
}
3540

3641
async function warmRooModels(host: ExtensionHost): Promise<void> {
3742
await new Promise<void>((resolve, reject) => {
@@ -251,6 +256,12 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
251256
process.exit(1)
252257
}
253258

259+
if (flagOptions.signalOnlyExit && !flagOptions.stdinPromptStream) {
260+
console.error("[CLI] Error: --signal-only-exit requires --stdin-prompt-stream")
261+
console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream --signal-only-exit")
262+
process.exit(1)
263+
}
264+
254265
if (flagOptions.stdinPromptStream && outputFormat !== "stream-json") {
255266
console.error("[CLI] Error: --stdin-prompt-stream requires --output-format=stream-json")
256267
console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]")
@@ -323,11 +334,15 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
323334
}
324335
} else {
325336
const useJsonOutput = outputFormat === "json" || outputFormat === "stream-json"
337+
const signalOnlyExit = flagOptions.signalOnlyExit
326338

327339
extensionHostOptions.disableOutput = useJsonOutput
328340

329341
const host = new ExtensionHost(extensionHostOptions)
330342
let streamRequestId: string | undefined
343+
let keepAliveInterval: NodeJS.Timeout | undefined
344+
let isShuttingDown = false
345+
let hostDisposed = false
331346

332347
const jsonEmitter = useJsonOutput
333348
? new JsonEventEmitter({
@@ -336,17 +351,110 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
336351
})
337352
: null
338353

354+
const emitRuntimeError = (error: Error, source?: string) => {
355+
const errorMessage = source ? `${source}: ${error.message}` : error.message
356+
357+
if (useJsonOutput) {
358+
const errorEvent = { type: "error", id: Date.now(), content: errorMessage }
359+
process.stdout.write(JSON.stringify(errorEvent) + "\n")
360+
return
361+
}
362+
363+
console.error("[CLI] Error:", errorMessage)
364+
console.error(error.stack)
365+
}
366+
367+
const clearKeepAliveInterval = () => {
368+
if (!keepAliveInterval) {
369+
return
370+
}
371+
372+
clearInterval(keepAliveInterval)
373+
keepAliveInterval = undefined
374+
}
375+
376+
const ensureKeepAliveInterval = () => {
377+
if (!signalOnlyExit || keepAliveInterval) {
378+
return
379+
}
380+
381+
keepAliveInterval = setInterval(() => {}, SIGNAL_ONLY_EXIT_KEEPALIVE_MS)
382+
}
383+
384+
const disposeHost = async () => {
385+
if (hostDisposed) {
386+
return
387+
}
388+
389+
hostDisposed = true
390+
jsonEmitter?.detach()
391+
await host.dispose()
392+
}
393+
394+
const onSigint = () => {
395+
void shutdown("SIGINT", 130)
396+
}
397+
398+
const onSigterm = () => {
399+
void shutdown("SIGTERM", 143)
400+
}
401+
402+
const onUncaughtException = (error: Error) => {
403+
emitRuntimeError(error, "uncaughtException")
404+
405+
if (signalOnlyExit) {
406+
return
407+
}
408+
409+
void shutdown("uncaughtException", 1)
410+
}
411+
412+
const onUnhandledRejection = (reason: unknown) => {
413+
const error = normalizeError(reason)
414+
emitRuntimeError(error, "unhandledRejection")
415+
416+
if (signalOnlyExit) {
417+
return
418+
}
419+
420+
void shutdown("unhandledRejection", 1)
421+
}
422+
423+
const parkUntilSignal = async (reason: string): Promise<never> => {
424+
ensureKeepAliveInterval()
425+
426+
if (!useJsonOutput) {
427+
console.error(`[CLI] ${reason} (--signal-only-exit active; waiting for SIGINT/SIGTERM).`)
428+
}
429+
430+
await new Promise<void>(() => {})
431+
throw new Error("unreachable")
432+
}
433+
339434
async function shutdown(signal: string, exitCode: number): Promise<void> {
435+
if (isShuttingDown) {
436+
return
437+
}
438+
439+
isShuttingDown = true
440+
process.off("SIGINT", onSigint)
441+
process.off("SIGTERM", onSigterm)
442+
process.off("uncaughtException", onUncaughtException)
443+
process.off("unhandledRejection", onUnhandledRejection)
444+
clearKeepAliveInterval()
445+
340446
if (!useJsonOutput) {
341447
console.log(`\n[CLI] Received ${signal}, shutting down...`)
342448
}
343-
jsonEmitter?.detach()
344-
await host.dispose()
449+
450+
await disposeHost()
345451
process.exit(exitCode)
346452
}
347453

348-
process.on("SIGINT", () => shutdown("SIGINT", 130))
349-
process.on("SIGTERM", () => shutdown("SIGTERM", 143))
454+
process.on("SIGINT", onSigint)
455+
process.on("SIGTERM", onSigterm)
456+
process.on("uncaughtException", onUncaughtException)
457+
process.on("unhandledRejection", onUnhandledRejection)
350458

351459
try {
352460
await host.activate()
@@ -381,25 +489,29 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption
381489
await host.runTask(prompt!)
382490
}
383491

384-
jsonEmitter?.detach()
385-
await host.dispose()
492+
await disposeHost()
493+
494+
if (signalOnlyExit) {
495+
await parkUntilSignal("Task loop completed")
496+
}
497+
498+
process.off("SIGINT", onSigint)
499+
process.off("SIGTERM", onSigterm)
500+
process.off("uncaughtException", onUncaughtException)
501+
process.off("unhandledRejection", onUnhandledRejection)
386502
process.exit(0)
387503
} catch (error) {
388-
const errorMessage = error instanceof Error ? error.message : String(error)
504+
emitRuntimeError(normalizeError(error))
505+
await disposeHost()
389506

390-
if (useJsonOutput) {
391-
const errorEvent = { type: "error", id: Date.now(), content: errorMessage }
392-
process.stdout.write(JSON.stringify(errorEvent) + "\n")
393-
} else {
394-
console.error("[CLI] Error:", errorMessage)
395-
396-
if (error instanceof Error) {
397-
console.error(error.stack)
398-
}
507+
if (signalOnlyExit) {
508+
await parkUntilSignal("Task loop failed")
399509
}
400510

401-
jsonEmitter?.detach()
402-
await host.dispose()
511+
process.off("SIGINT", onSigint)
512+
process.off("SIGTERM", onSigterm)
513+
process.off("uncaughtException", onUncaughtException)
514+
process.off("unhandledRejection", onUnhandledRejection)
403515
process.exit(1)
404516
}
405517
}

apps/cli/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ program
2121
"Read NDJSON commands from stdin (requires --print and --output-format stream-json)",
2222
false,
2323
)
24+
.option(
25+
"--signal-only-exit",
26+
"Do not exit from normal completion/errors; only terminate on SIGINT/SIGTERM (intended for stdin stream harnesses)",
27+
false,
28+
)
2429
.option("-e, --extension <path>", "Path to the extension bundle directory")
2530
.option("-d, --debug", "Enable debug output (includes detailed debug information)", false)
2631
.option("-a, --require-approval", "Require manual approval for actions", false)

apps/cli/src/types/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export type FlagOptions = {
2323
workspace?: string
2424
print: boolean
2525
stdinPromptStream: boolean
26+
signalOnlyExit: boolean
2627
extension?: string
2728
debug: boolean
2829
requireApproval: boolean

0 commit comments

Comments
 (0)