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 }; ui: { terminal: { drainInput: (ms: number) => Promise } }; stop: () => void; }; type InteractiveModePrototypeWithShutdown = { shutdown(this: ShutdownThis, options?: { fromSignal?: boolean }): Promise; }; 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 { 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(); }); });