Skip to content

Commit 4c3ae79

Browse files
committed
fix(tui): survive dead terminal during shutdown
1 parent e7ba120 commit 4c3ae79

3 files changed

Lines changed: 122 additions & 1 deletion

File tree

packages/tui/src/changes.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
# TUI delta rendering fork changes
22

3+
## 2026-08-05: dead-terminal raw-mode restoration is best-effort during shutdown
4+
5+
### What changed
6+
7+
- `ProcessTerminal.stop()` still restores the raw-mode state captured by `start()`, but now treats `EIO`, `EPIPE`,
8+
and `ENOTCONN` from the teardown-time `setRawMode()` call as a dead terminal instead of crashing the exiting CLI.
9+
- Unexpected raw-mode restoration errors still propagate so shutdown does not hide unrelated defects.
10+
- `test/terminal.test.ts` covers successful restoration, the dead-terminal `EIO` regression, and unexpected-error
11+
propagation.
12+
13+
### Why
14+
15+
- An SSH or PTY peer can disappear after input draining but before raw-mode restoration. Node/Bun then throws a
16+
synchronous stdin ioctl error, which bypasses the coding-agent's stdout/stderr error handlers and replaces the
17+
requested exit with an uncaught `setRawMode failed with errno: 5` stack.
18+
19+
### Why this cannot be expressed externally
20+
21+
- Raw-mode ownership and restoration are private `ProcessTerminal` lifecycle responsibilities. Extensions receive
22+
neither the saved raw-mode state nor a teardown hook around the stdin ioctl.
23+
24+
### Expected merge conflict zones
25+
26+
- LOW: `packages/tui/src/terminal.ts` around the terminal error classifier and `ProcessTerminal.stop()` raw-mode
27+
restoration.
28+
- LOW: `packages/tui/test/terminal.test.ts` around lifecycle coverage.
29+
330
## 2026-07-31: atomic visible-cursor frames for IME and animations
431

532
### What changed

packages/tui/src/terminal.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
1313
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
1414
const APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u";
1515
const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7;
16+
const DEAD_TERMINAL_ERROR_CODES = new Set(["EIO", "EPIPE", "ENOTCONN"]);
1617
const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150;
1718
const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`;
1819

@@ -52,6 +53,15 @@ export function keyboardEnhancementEnabled(): boolean {
5253
return !["0", "false", "no", "off"].includes(value.toLowerCase());
5354
}
5455

56+
function isDeadTerminalError(error: unknown): boolean {
57+
return (
58+
error instanceof Error &&
59+
"code" in error &&
60+
typeof error.code === "string" &&
61+
DEAD_TERMINAL_ERROR_CODES.has(error.code)
62+
);
63+
}
64+
5565
/**
5666
* Minimal terminal interface for TUI
5767
*/
@@ -529,7 +539,11 @@ export class ProcessTerminal implements Terminal {
529539

530540
// Restore raw mode state
531541
if (process.stdin.setRawMode) {
532-
process.stdin.setRawMode(this.wasRaw);
542+
try {
543+
process.stdin.setRawMode(this.wasRaw);
544+
} catch (error) {
545+
if (!isDeadTerminalError(error)) throw error;
546+
}
533547
}
534548

535549
this.removeExternalStdoutGuard();

packages/tui/test/terminal.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,33 @@ function resetFakeTimers(): void {
4747
mock.timers.reset();
4848
}
4949

50+
type TerminalStopHarness = {
51+
terminal: ProcessTerminal;
52+
cleanup(): void;
53+
};
54+
55+
function setupTerminalStopHarness(wasRaw: boolean, restoreRawMode: (mode: boolean) => void): TerminalStopHarness {
56+
const terminal = new ProcessTerminal();
57+
const previousPause = process.stdin.pause;
58+
const previousSetRawMode = process.stdin.setRawMode;
59+
60+
Reflect.set(terminal, "wasRaw", wasRaw);
61+
Reflect.set(terminal, "rawStdoutWrite", (_data: string) => {});
62+
Reflect.set(process.stdin, "pause", () => process.stdin);
63+
Reflect.set(process.stdin, "setRawMode", (mode: boolean) => {
64+
restoreRawMode(mode);
65+
return process.stdin;
66+
});
67+
68+
return {
69+
terminal,
70+
cleanup(): void {
71+
Reflect.set(process.stdin, "pause", previousPause);
72+
Reflect.set(process.stdin, "setRawMode", previousSetRawMode);
73+
},
74+
};
75+
}
76+
5077
describe("normalizeAppleTerminalInput", () => {
5178
it("rewrites Apple Terminal Return to CSI-u Shift+Enter when Shift is pressed", () => {
5279
assert.equal(normalizeAppleTerminalInput("\r", true, true), "\x1b[13;2u");
@@ -295,6 +322,59 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => {
295322
});
296323
});
297324

325+
describe("ProcessTerminal stop", () => {
326+
it("restores the previous raw mode during stop", () => {
327+
// Given
328+
const restoredModes: boolean[] = [];
329+
const harness = setupTerminalStopHarness(true, (mode) => {
330+
restoredModes.push(mode);
331+
});
332+
333+
try {
334+
// When
335+
harness.terminal.stop();
336+
337+
// Then
338+
assert.deepEqual(restoredModes, [true]);
339+
} finally {
340+
harness.cleanup();
341+
}
342+
});
343+
344+
it("does not throw when raw-mode restoration fails during stop", () => {
345+
// Given
346+
const eio = Object.assign(new Error("setRawMode failed"), { code: "EIO" });
347+
const harness = setupTerminalStopHarness(false, () => {
348+
throw eio;
349+
});
350+
351+
try {
352+
// When / Then
353+
assert.doesNotThrow(() => harness.terminal.stop());
354+
} finally {
355+
harness.cleanup();
356+
}
357+
});
358+
359+
it("rethrows unexpected raw-mode restoration failures", () => {
360+
// Given
361+
const invalidArgument = Object.assign(new Error("unexpected setRawMode failure"), { code: "EINVAL" });
362+
const harness = setupTerminalStopHarness(false, () => {
363+
throw invalidArgument;
364+
});
365+
366+
try {
367+
// When / Then
368+
assert.throws(
369+
() => harness.terminal.stop(),
370+
(error: unknown) => error === invalidArgument,
371+
);
372+
} finally {
373+
harness.cleanup();
374+
}
375+
});
376+
});
377+
298378
describe("ProcessTerminal dimensions", () => {
299379
it("falls back to COLUMNS and LINES before default dimensions", () => {
300380
const previousColumnsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns");

0 commit comments

Comments
 (0)