fix(tui): restore terminal modes when the process exits without stop()

Registers a process exit fallback that synchronously writes mouse, focus,
bracketed paste, Kitty keyboard, and alternate-screen resets.

closes #11170
This commit is contained in:
Mario Zechner 2026-10-20 19:51:55 +02:00
parent 2ed3118b00
commit 9df819de39
7 changed files with 108 additions and 0 deletions

View file

@ -6,6 +6,7 @@
- Fixed an unclosed `$$` display-math block swallowing or corrupting every Markdown table after it. A block-math token no longer spans a GFM table, and the streaming pending-math fallback no longer treats a following table as pending math ([#11159](https://github.com/earendil-works/pi/issues/11159))
- Fixed Markdown bold and italic not rendering next to CJK punctuation ([#10730](https://github.com/earendil-works/pi/pull/10730) by [@theBucky](https://github.com/theBucky))
- Fixed terminal modes (mouse tracking, focus reporting, Kitty keyboard protocol, bracketed paste, alternate screen) staying enabled when the process exits without calling `stop()`. A process `exit` handler now restores them synchronously ([#11170](https://github.com/earendil-works/pi/issues/11170))
## [1.3.0] - 2026-10-15

View file

@ -82,6 +82,12 @@ export interface Terminal {
// Stop the terminal and restore state
stop(): void;
/**
* Synchronously restore terminal modes from a process `exit` handler when stop() did not run.
* Writes `before`, then this terminal's own mode resets, then `after`.
*/
restoreOnExit?(before: string, after: string): void;
/**
* Drain stdin before exiting to prevent Kitty key release events from
* leaking to the parent shell over slow SSH connections.
@ -518,6 +524,21 @@ export class ProcessTerminal implements Terminal {
}
}
restoreOnExit(before: string, after: string): void {
let sequence = before;
if (this.progressInterval) sequence += TERMINAL_PROGRESS_CLEAR_SEQUENCE;
sequence += "\x1b[?2004l";
if (this.keyboardProtocolPushed || this._kittyProtocolActive) sequence += "\x1b[<u";
if (this._modifyOtherKeysActive) sequence += "\x1b[>4;0m";
sequence += after;
try {
// process.stdout.write() may be asynchronous (e.g. Windows TTYs) and would not flush during exit.
fs.writeSync(process.stdout.fd, sequence);
} catch {
// The terminal may already be gone.
}
}
write(data: string): void {
process.stdout.write(data);
if (this.writeLogPath) {

View file

@ -427,6 +427,17 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
}
}
protected override getExitRestorePrefix(): string {
const prefix = super.getExitRestorePrefix();
if (!this.altScreenActive) return prefix;
return `${END_SYNCHRONIZED_OUTPUT}${this.mouseEnabled ? DISABLE_MOUSE : ""}${ENABLE_AUTOWRAP}${prefix}`;
}
protected override getExitRestoreSuffix(): string {
// Kitty keyboard flags are per screen, so the terminal pops them before the alternate screen is left.
return `${super.getExitRestoreSuffix()}${this.altScreenActive ? `${EXIT_ALT_SCREEN}\x1b[?25h` : ""}`;
}
private deleteKittyImages(): string {
return this.imageProtocol === "kitty" ? deleteAllKittyImages() : "";
}

View file

@ -561,6 +561,29 @@ export abstract class TuiBase extends Container implements TUI {
protected afterTerminalStop(_options: TuiStopOptions): void {}
/** Mode resets written before the terminal's own resets when the process exits without stop(). */
protected getExitRestorePrefix(): string {
return `${this.terminalColorSchemeNotificationsEnabled ? "\x1b[?2031l" : ""}\x1b[?25h`;
}
/** Mode resets written after the terminal's own resets when the process exits without stop(). */
protected getExitRestoreSuffix(): string {
return "";
}
/**
* Last-resort restore for exit paths that skip stop(), e.g. a direct process.exit() (#11170).
* Hard kills and native aborts run no JavaScript and cannot be covered.
*/
private readonly exitRestoreHandler = (): void => {
if (this.stopped) return;
try {
this.terminal.restoreOnExit?.(this.getExitRestorePrefix(), this.getExitRestoreSuffix());
} catch {
// Never throw from an exit handler.
}
};
get fullRedraws(): number {
return this.fullRedrawCount;
}
@ -926,6 +949,8 @@ export abstract class TuiBase extends Container implements TUI {
start(): void {
this.stopped = false;
process.off("exit", this.exitRestoreHandler);
process.on("exit", this.exitRestoreHandler);
this.beforeTerminalStart();
this.terminal.start(
(data) => this.handleTerminalInput(data),
@ -980,6 +1005,7 @@ export abstract class TuiBase extends Container implements TUI {
stop(options: TuiStopOptions = {}): void {
this.stopped = true;
process.off("exit", this.exitRestoreHandler);
this.cancelRenderTimer();
if (this.terminalColorSchemeNotificationsEnabled) {
this.terminal.write("\x1b[?2031l");

View file

@ -0,0 +1,41 @@
import assert from "node:assert";
import { spawnSync } from "node:child_process";
import { describe, it } from "node:test";
import { fileURLToPath } from "node:url";
const fixture = fileURLToPath(new URL("./fixtures/exit-without-stop.ts", import.meta.url));
function runFixture(mode: "exit" | "stop"): string {
const result = spawnSync(process.execPath, [fixture, mode], {
encoding: "utf8",
env: { ...process.env, TMUX: undefined, STY: undefined, ZELLIJ: undefined, TERM: "xterm-256color" },
stdio: ["pipe", "pipe", "pipe"],
timeout: 10_000,
});
assert.equal(result.status, 0, result.stderr);
return result.stdout;
}
function count(haystack: string, needle: string): number {
return haystack.split(needle).length - 1;
}
// Regression for #11170: exit paths that skip stop() left mouse tracking, focus reporting,
// the Kitty keyboard protocol, bracketed paste, and the alternate screen enabled.
describe("terminal restore on process exit", () => {
it("restores terminal modes when the process exits without stop()", () => {
const output = runFixture("exit");
for (const sequence of ["\x1b[?1006l", "\x1b[?1004l", "\x1b[?1003l", "\x1b[?1000l", "\x1b[<u", "\x1b[?2004l"]) {
assert.equal(count(output, sequence), 1, JSON.stringify(sequence));
}
assert.ok(output.indexOf("\x1b[<u") < output.indexOf("\x1b[?1049l"), "Kitty pop must precede leaving alt screen");
assert.ok(output.endsWith("\x1b[?1049l\x1b[?25h"));
});
it("does not restore twice after a normal stop()", () => {
const output = runFixture("stop");
for (const sequence of ["\x1b[?1006l", "\x1b[?1004l", "\x1b[<u", "\x1b[?2004l", "\x1b[?1049l"]) {
assert.equal(count(output, sequence), 1, JSON.stringify(sequence));
}
});
});

View file

@ -0,0 +1,7 @@
import { ProcessTerminal } from "../../src/terminal.ts";
import { TuiAltScreen } from "../../src/tui-alt-screen.ts";
const tui = new TuiAltScreen(new ProcessTerminal(), false, undefined, { mouse: true });
tui.start();
if (process.argv[2] === "stop") tui.stop({ preserveScreen: true });
process.exit(0);