fix(coding-agent): run extension cleanup and restore terminal on signal exits

Signal-triggered shutdown (SIGTERM/SIGHUP) now emits session_shutdown
before any terminal writes, and SIGHUP no longer hard-exits, so extension
resources (e.g. sockets) are released and the terminal is restored even
when the terminal is gone. A genuinely dead terminal still exits without
hot-spinning via the EIO error path.

closes #5080
This commit is contained in:
Mario Zechner
2026-05-28 23:13:20 +02:00
parent d1fb34bc8d
commit b64f3f5eae
3 changed files with 127 additions and 5 deletions

View File

@@ -0,0 +1,103 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
// Regression for https://github.com/earendil-works/pi/issues/5080
//
// On SIGTERM/SIGHUP the graceful shutdown must emit `session_shutdown`
// (runtimeHost.dispose) BEFORE touching the terminal. Extension teardown such
// as removing a socket does not write to the tty, so it must not be skipped if
// a later terminal-restore write fails on a dead or stalled terminal. The
// interactive quit path (Ctrl+D, /quit) keeps the opposite order to preserve
// the final TUI frame.
type ShutdownThis = {
isShuttingDown: boolean;
unregisterSignalHandlers: () => void;
runtimeHost: { dispose: () => Promise<void> };
ui: { terminal: { drainInput: (ms: number) => Promise<void> } };
stop: () => void;
};
type InteractiveModePrototypeWithShutdown = {
shutdown(this: ShutdownThis, options?: { fromSignal?: boolean }): Promise<void>;
};
const interactiveModePrototype = InteractiveMode.prototype as unknown;
class ProcessExitError extends Error {}
function createContext(order: string[]): ShutdownThis {
return {
isShuttingDown: false,
unregisterSignalHandlers: vi.fn(),
runtimeHost: {
dispose: vi.fn(async () => {
order.push("dispose");
}),
},
ui: {
terminal: {
drainInput: vi.fn(async () => {
order.push("drainInput");
}),
},
},
stop: vi.fn(() => {
order.push("stop");
}),
};
}
async function callShutdown(context: ShutdownThis, options?: { fromSignal?: boolean }): Promise<void> {
try {
await (interactiveModePrototype as InteractiveModePrototypeWithShutdown).shutdown.call(context, options);
} catch (error) {
if (!(error instanceof ProcessExitError)) throw error;
}
}
describe("InteractiveMode.shutdown ordering (#5080)", () => {
afterEach(() => {
vi.restoreAllMocks();
});
test("signal-triggered shutdown emits session_shutdown before terminal writes", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const order: string[] = [];
const context = createContext(order);
await callShutdown(context, { fromSignal: true });
expect(order).toEqual(["dispose", "drainInput", "stop"]);
expect(context.isShuttingDown).toBe(true);
expect(context.unregisterSignalHandlers).toHaveBeenCalledTimes(1);
});
test("interactive quit stops the TUI before emitting session_shutdown", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const order: string[] = [];
const context = createContext(order);
await callShutdown(context);
expect(order).toEqual(["drainInput", "stop", "dispose"]);
});
test("re-entrant shutdown is a no-op", async () => {
vi.spyOn(process, "exit").mockImplementation((() => {
throw new ProcessExitError();
}) as typeof process.exit);
const order: string[] = [];
const context = createContext(order);
context.isShuttingDown = true;
await callShutdown(context, { fromSignal: true });
expect(order).toEqual([]);
expect(context.runtimeHost.dispose).not.toHaveBeenCalled();
});
});