Skip to content

Commit ba0ec61

Browse files
committed
fix(coding-agent): restore terminal on SIGTERM
closes earendil-works#5724
1 parent b5e13bc commit ba0ec61

4 files changed

Lines changed: 97 additions & 3 deletions

File tree

packages/coding-agent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
### Fixed
1010

11+
- Fixed SIGTERM/SIGHUP interactive shutdown to keep signal handlers installed until terminal cleanup completes, preventing `signal-exit` from re-sending the signal and leaving the terminal in raw/Kitty keyboard mode ([#5724](https://github.com/earendil-works/pi/issues/5724)).
1112
- Fixed extensions documentation to clarify that `pi.getActiveTools()` returns active tool names while `pi.getAllTools()` returns tool metadata ([#5729](https://github.com/earendil-works/pi/issues/5729)).
1213
- Fixed package commands such as `pi list`, `pi install`, and `pi update` to terminate after completing even if an extension leaves background handles open ([#5687](https://github.com/earendil-works/pi/issues/5687)).
1314
- Fixed `pi update` for pnpm global installs whose configured `global-bin-dir` no longer matches the active pnpm home ([#5689](https://github.com/earendil-works/pi/issues/5689)).

packages/coding-agent/src/modes/interactive/interactive-mode.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3362,7 +3362,9 @@ export class InteractiveMode {
33623362
private async shutdown(options?: { fromSignal?: boolean }): Promise<void> {
33633363
if (this.isShuttingDown) return;
33643364
this.isShuttingDown = true;
3365-
this.unregisterSignalHandlers();
3365+
// Keep signal handlers registered until terminal cleanup has completed.
3366+
// `signal-exit` checks the listener list during the same SIGTERM/SIGHUP
3367+
// dispatch and re-sends the signal if only its own listeners remain.
33663368

33673369
if (options?.fromSignal) {
33683370
// Signal-triggered shutdown (SIGTERM/SIGHUP). Emit extension cleanup
@@ -5742,7 +5744,6 @@ export class InteractiveMode {
57425744
}
57435745

57445746
stop(): void {
5745-
this.unregisterSignalHandlers();
57465747
if (this.settingsManager.getShowTerminalProgress()) {
57475748
this.ui.terminal.setProgress(false);
57485749
}
@@ -5760,5 +5761,6 @@ export class InteractiveMode {
57605761
this.ui.stop();
57615762
this.isInitialized = false;
57625763
}
5764+
this.unregisterSignalHandlers();
57635765
}
57645766
}

packages/coding-agent/test/suite/regressions/5080-signal-shutdown-extension-cleanup.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,6 @@ describe("InteractiveMode.shutdown ordering (#5080)", () => {
116116

117117
expect(order).toEqual(["dispose", "drainInput", "stop"]);
118118
expect(context.isShuttingDown).toBe(true);
119-
expect(context.unregisterSignalHandlers).toHaveBeenCalledTimes(1);
120119
});
121120

122121
test("interactive quit stops the TUI before emitting session_shutdown", async () => {
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { afterEach, describe, expect, test, vi } from "vitest";
2+
import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.ts";
3+
4+
// Regression for https://github.com/earendil-works/pi/issues/5724
5+
//
6+
// `proper-lockfile` installs `signal-exit`, whose signal listener re-sends
7+
// SIGTERM/SIGHUP when it observes no other process listeners during the same
8+
// signal dispatch. InteractiveMode must therefore keep its signal handlers
9+
// registered until async terminal cleanup has completed.
10+
11+
type ShutdownThis = {
12+
isShuttingDown: boolean;
13+
unregisterSignalHandlers: () => void;
14+
runtimeHost: { dispose: () => Promise<void> };
15+
ui: { terminal: { drainInput: (ms: number) => Promise<void> } };
16+
stop: () => void;
17+
};
18+
19+
type InteractiveModePrototypeWithShutdown = {
20+
shutdown(this: ShutdownThis, options?: { fromSignal?: boolean }): Promise<void>;
21+
};
22+
23+
const interactiveModePrototype = InteractiveMode.prototype as unknown;
24+
25+
class ProcessExitError extends Error {}
26+
27+
function deferred(): { promise: Promise<void>; resolve: () => void } {
28+
let resolve: (() => void) | undefined;
29+
const promise = new Promise<void>((res) => {
30+
resolve = res;
31+
});
32+
return {
33+
promise,
34+
resolve: () => resolve?.(),
35+
};
36+
}
37+
38+
async function callShutdown(context: ShutdownThis, options?: { fromSignal?: boolean }): Promise<void> {
39+
try {
40+
await (interactiveModePrototype as InteractiveModePrototypeWithShutdown).shutdown.call(context, options);
41+
} catch (error) {
42+
if (!(error instanceof ProcessExitError)) throw error;
43+
}
44+
}
45+
46+
describe("InteractiveMode SIGTERM shutdown with signal-exit (#5724)", () => {
47+
afterEach(() => {
48+
vi.restoreAllMocks();
49+
});
50+
51+
test("keeps signal handlers registered while signal-triggered cleanup is pending", async () => {
52+
vi.spyOn(process, "exit").mockImplementation((() => {
53+
throw new ProcessExitError();
54+
}) as typeof process.exit);
55+
56+
const order: string[] = [];
57+
const dispose = deferred();
58+
const context: ShutdownThis = {
59+
isShuttingDown: false,
60+
unregisterSignalHandlers: vi.fn(() => {
61+
order.push("unregister");
62+
}),
63+
runtimeHost: {
64+
dispose: vi.fn(() => {
65+
order.push("dispose");
66+
return dispose.promise;
67+
}),
68+
},
69+
ui: {
70+
terminal: {
71+
drainInput: vi.fn(async () => {
72+
order.push("drainInput");
73+
}),
74+
},
75+
},
76+
stop: vi.fn(() => {
77+
order.push("stop");
78+
}),
79+
};
80+
81+
const shutdownPromise = callShutdown(context, { fromSignal: true });
82+
await Promise.resolve();
83+
84+
expect(order).toEqual(["dispose"]);
85+
expect(context.unregisterSignalHandlers).not.toHaveBeenCalled();
86+
87+
dispose.resolve();
88+
await shutdownPromise;
89+
90+
expect(order).toEqual(["dispose", "drainInput", "stop"]);
91+
});
92+
});

0 commit comments

Comments
 (0)