fix(coding-agent): restore terminal on uncaught exception
When an uncaught exception fires in interactive mode, the process dies with stdin still in raw mode and the cursor hidden, leaving the user with a "borked" terminal that needs `stty sane && reset` to recover. The most common trigger is an extension's async ChildProcess `exit` callback that throws (e.g. from accessing a stale ctx after session replacement), but this affects any uncaught throw from anywhere in pi. Add an uncaughtException handler in registerSignalHandlers that calls ui.stop() before exiting, mirroring the existing emergencyTerminalExit pattern. The handler is registered with prependListener and tracked in signalCleanupHandlers, so it is removed on graceful shutdown the same way the other handlers are. Unlike emergencyTerminalExit (used for SIGHUP / dead-terminal EIO), the terminal is still alive on uncaughtException, so we run the normal ui.stop() to restore cooked mode, the cursor, bracketed paste mode, and Kitty / modifyOtherKeys sequences.
This commit is contained in:
@@ -3244,6 +3244,36 @@ export class InteractiveMode {
|
||||
process.exit(129);
|
||||
}
|
||||
|
||||
/**
|
||||
* Last-resort handler for uncaught exceptions. The TUI puts stdin into raw
|
||||
* mode and hides the cursor; without this handler, an uncaught throw from
|
||||
* anywhere (e.g. an extension's async `ChildProcess.on("exit")` callback)
|
||||
* tears down the process while leaving the terminal in raw mode with no
|
||||
* cursor, requiring `stty sane && reset` to recover.
|
||||
*
|
||||
* Unlike emergencyTerminalExit, the terminal is still alive here, so we
|
||||
* call ui.stop() to restore cooked mode, the cursor, and disable bracketed
|
||||
* paste / Kitty / modifyOtherKeys sequences.
|
||||
*/
|
||||
private uncaughtCrash(error: Error): never {
|
||||
if (this.isShuttingDown) {
|
||||
process.exit(1);
|
||||
}
|
||||
this.isShuttingDown = true;
|
||||
try {
|
||||
this.unregisterSignalHandlers();
|
||||
} catch {}
|
||||
try {
|
||||
killTrackedDetachedChildren();
|
||||
} catch {}
|
||||
try {
|
||||
this.ui.stop();
|
||||
} catch {}
|
||||
console.error("pi exiting due to uncaughtException:");
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if shutdown was requested and perform shutdown if so.
|
||||
*/
|
||||
@@ -3282,6 +3312,13 @@ export class InteractiveMode {
|
||||
process.stderr.on("error", terminalErrorHandler);
|
||||
this.signalCleanupHandlers.push(() => process.stdout.off("error", terminalErrorHandler));
|
||||
this.signalCleanupHandlers.push(() => process.stderr.off("error", terminalErrorHandler));
|
||||
|
||||
// Restore the terminal before the process dies on any uncaught throw.
|
||||
// Without this, an unhandled exception from extension code (or anywhere
|
||||
// in pi) leaves the terminal in raw mode with no cursor.
|
||||
const uncaughtExceptionHandler = (error: Error) => this.uncaughtCrash(error);
|
||||
process.prependListener("uncaughtException", uncaughtExceptionHandler);
|
||||
this.signalCleanupHandlers.push(() => process.off("uncaughtException", uncaughtExceptionHandler));
|
||||
}
|
||||
|
||||
private unregisterSignalHandlers(): void {
|
||||
|
||||
Reference in New Issue
Block a user