fix(coding-agent): fire session shutdown on TERM and HUP closes #3212

This commit is contained in:
Mario Zechner
2026-04-15 16:36:30 +02:00
parent 3a13fa80c8
commit 5d440b055c
6 changed files with 92 additions and 7 deletions

View File

@@ -4,6 +4,7 @@
### Fixed
- Fixed `session_shutdown` to fire on `SIGHUP` and `SIGTERM` in interactive, print, and RPC modes so extensions can run shutdown cleanup on those signal-driven exits ([#3212](https://github.com/badlogic/pi-mono/issues/3212))
- Fixed screenshot path parsing to handle lower case am/pm in macOS screenshot filenames ([#3194](https://github.com/badlogic/pi-mono/pull/3194) by [@jay-aye-see-kay](https://github.com/jay-aye-see-kay))
- Fixed interactive auto-retry status updates to show a live countdown during backoff instead of a static retry delay message ([#3187](https://github.com/badlogic/pi-mono/issues/3187))

View File

@@ -283,7 +283,7 @@ user sends another prompt ◄─────────────────
/model or Ctrl+P (model selection/cycling)
└─► model_select
exit (Ctrl+C, Ctrl+D)
exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM)
└─► session_shutdown
```
@@ -403,7 +403,7 @@ pi.on("session_tree", async (event, ctx) => {
#### session_shutdown
Fired on exit (Ctrl+C, Ctrl+D, SIGTERM).
Fired on exit (Ctrl+C, Ctrl+D, SIGHUP, SIGTERM).
```typescript
pi.on("session_shutdown", async (_event, ctx) => {

View File

@@ -479,7 +479,7 @@ export interface SessionCompactEvent {
fromExtension: boolean;
}
/** Fired on process exit */
/** Fired on graceful process shutdown paths such as Ctrl+C, Ctrl+D, SIGHUP, and SIGTERM. */
export interface SessionShutdownEvent {
type: "session_shutdown";
}

View File

@@ -212,6 +212,7 @@ export class InteractiveMode {
// Agent subscription unsubscribe function
private unsubscribe?: () => void;
private signalCleanupHandlers: Array<() => void> = [];
// Track if editor is in bash mode (text starts with !)
private isBashMode = false;
@@ -477,6 +478,8 @@ export class InteractiveMode {
async init(): Promise<void> {
if (this.isInitialized) return;
this.registerSignalHandlers();
// Load changelog (only show new entries, skip for resumed sessions)
this.changelogMarkdown = this.getChangelogForDisplay();
@@ -2905,6 +2908,7 @@ export class InteractiveMode {
private async shutdown(): Promise<void> {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
this.unregisterSignalHandlers();
await this.runtimeHost.dispose();
// Wait for any pending renders to complete
@@ -2927,6 +2931,30 @@ export class InteractiveMode {
await this.shutdown();
}
private registerSignalHandlers(): void {
this.unregisterSignalHandlers();
const signals: NodeJS.Signals[] = ["SIGTERM"];
if (process.platform !== "win32") {
signals.push("SIGHUP");
}
for (const signal of signals) {
const handler = () => {
void this.shutdown();
};
process.on(signal, handler);
this.signalCleanupHandlers.push(() => process.off(signal, handler));
}
}
private unregisterSignalHandlers(): void {
for (const cleanup of this.signalCleanupHandlers) {
cleanup();
}
this.signalCleanupHandlers = [];
}
private handleCtrlZ(): void {
// Keep the event loop alive while suspended. Without this, stopping the TUI
// can leave Node with no ref'ed handles, causing the process to exit on fg
@@ -4785,6 +4813,7 @@ export class InteractiveMode {
}
stop(): void {
this.unregisterSignalHandlers();
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;

View File

@@ -33,6 +33,34 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr
let exitCode = 0;
let session = runtimeHost.session;
let unsubscribe: (() => void) | undefined;
let disposed = false;
const signalCleanupHandlers: Array<() => void> = [];
const disposeRuntime = async (): Promise<void> => {
if (disposed) return;
disposed = true;
unsubscribe?.();
await runtimeHost.dispose();
};
const registerSignalHandlers = (): void => {
const signals: NodeJS.Signals[] = ["SIGTERM"];
if (process.platform !== "win32") {
signals.push("SIGHUP");
}
for (const signal of signals) {
const handler = () => {
void disposeRuntime().finally(() => {
process.exit(signal === "SIGHUP" ? 129 : 143);
});
};
process.on(signal, handler);
signalCleanupHandlers.push(() => process.off(signal, handler));
}
};
registerSignalHandlers();
const rebindSession = async (): Promise<void> => {
session = runtimeHost.session;
@@ -128,8 +156,10 @@ export async function runPrintMode(runtimeHost: AgentSessionRuntime, options: Pr
console.error(error instanceof Error ? error.message : String(error));
return 1;
} finally {
unsubscribe?.();
await runtimeHost.dispose();
for (const cleanup of signalCleanupHandlers) {
cleanup();
}
await disposeRuntime();
await flushRawStdout();
}
}

View File

@@ -75,6 +75,8 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
// Shutdown request flag
let shutdownRequested = false;
let shuttingDown = false;
const signalCleanupHandlers: Array<() => void> = [];
/** Helper for dialog methods with signal/timeout support */
function createDialogPromise<T>(
@@ -336,7 +338,23 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
});
};
const registerSignalHandlers = (): void => {
const signals: NodeJS.Signals[] = ["SIGTERM"];
if (process.platform !== "win32") {
signals.push("SIGHUP");
}
for (const signal of signals) {
const handler = () => {
void shutdown(signal === "SIGHUP" ? 129 : 143);
};
process.on(signal, handler);
signalCleanupHandlers.push(() => process.off(signal, handler));
}
};
await rebindSession();
registerSignalHandlers();
// Handle a single command
const handleCommand = async (command: RpcCommand): Promise<RpcResponse> => {
@@ -614,12 +632,19 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
*/
let detachInput = () => {};
async function shutdown(): Promise<never> {
async function shutdown(exitCode = 0): Promise<never> {
if (shuttingDown) {
process.exit(exitCode);
}
shuttingDown = true;
for (const cleanup of signalCleanupHandlers) {
cleanup();
}
unsubscribe?.();
await runtimeHost.dispose();
detachInput();
process.stdin.pause();
process.exit(0);
process.exit(exitCode);
}
async function checkShutdownRequested(): Promise<void> {