From 45c07f87f6b32d847187647638a3cd4536bcdce8 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Sun, 25 Oct 2026 10:37:30 +0800 Subject: [PATCH] fix(tui): drain keyboard input before terminal teardown --- package.json | 2 +- packages/zcode-tui/src/index.ts | 25 ++--- packages/zcode-tui/src/startup.ts | 19 ++-- packages/zcode-tui/src/terminal-cleanup.ts | 11 ++ test/tui/fixtures/startup.ts | 1 + test/tui/fixtures/terminal-exit.ts | 37 +++++++ test/tui/startup.test.ts | 13 +++ test/tui/terminal-exit.test.ts | 114 +++++++++++++++++++++ 8 files changed, 201 insertions(+), 21 deletions(-) create mode 100644 packages/zcode-tui/src/terminal-cleanup.ts create mode 100644 test/tui/fixtures/terminal-exit.ts create mode 100644 test/tui/terminal-exit.test.ts diff --git a/package.json b/package.json index 96e445f..8d0bb4d 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "test:node": "node --test test/node/*.test.cjs", "test:tui": "bun run test:tui:component && bun run test:tui:e2e", "test:tui:component": "bun test test/tui/scenario-http.test.ts test/tui/scenario-runtime.test.ts test/tui/scenario-shell.test.ts test/tui/scenario-workspace.test.ts test/tui/terminal-screen.test.ts", - "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/input-actions.test.ts test/tui/model-resume.test.ts test/tui/model-status.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-lifecycle.test.ts test/tui/session-rename.test.ts test/tui/startup.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", + "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/input-actions.test.ts test/tui/model-resume.test.ts test/tui/model-status.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-lifecycle.test.ts test/tui/session-rename.test.ts test/tui/startup.test.ts test/tui/terminal-exit.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", "test:tui:host": "bun test test/tui/scenario-mountx.test.ts", "test:tui:manual": "bun scripts/tui-scenario.ts --manual", "test:tui-scenario": "bun scripts/tui-scenario.ts", diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 8413885..7cb036f 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -139,6 +139,7 @@ import { type TurnNotificationKind } from "./notifications.ts"; import { watchStreamErrors } from "./stream-error-guard.ts"; +import { stopTui } from "./terminal-cleanup.ts"; import { readTuiMode, resolveTuiMode, @@ -883,9 +884,9 @@ class ZCodeTui { const onSigterm = () => this.handleSignal("SIGTERM"); const onSighup = () => this.handleSignal("SIGHUP"); this.installStreamErrorGuards(); - process.once("SIGINT", onSigint); - process.once("SIGTERM", onSigterm); - if (process.platform !== "win32") process.once("SIGHUP", onSighup); + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + if (process.platform !== "win32") process.on("SIGHUP", onSighup); let startAttempted = false; try { if (this.stopped) { @@ -945,11 +946,8 @@ class ZCodeTui { if (this.sessionModelIssue) void this.recoverSessionModel(); await this.done; } finally { - process.off("SIGINT", onSigint); - process.off("SIGTERM", onSigterm); - if (process.platform !== "win32") process.off("SIGHUP", onSighup); try { - if (startAttempted && !this.stopped) { + if (startAttempted) { this.stop(); await this.done; } @@ -957,6 +955,9 @@ class ZCodeTui { // Leave the guards installed through the I/O turn containing the final // terminal restore writes, which can report stream errors asynchronously. await new Promise((resolve) => setImmediate(resolve)); + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + if (process.platform !== "win32") process.off("SIGHUP", onSighup); this.removeStreamErrorGuards?.(); this.removeStreamErrorGuards = undefined; } @@ -6105,16 +6106,16 @@ class ZCodeTui { } catch { // Notification cleanup must not prevent terminal restoration. } - try { - this.ui.stop(); - } catch { - // Keep resolving the run even if a terminal implementation fails. - } void this.finishStop(elapsedMilliseconds); } private async finishStop(elapsedMilliseconds: number): Promise { try { + try { + await stopTui(this.ui); + } catch { + // Keep resolving the run even if a terminal implementation fails. + } const [, resumeTarget] = await Promise.all([this.refreshExitUsage(), this.readExitResumeTarget()]); const summary = buildExitSummary({ elapsedMilliseconds, diff --git a/packages/zcode-tui/src/startup.ts b/packages/zcode-tui/src/startup.ts index f824c98..807fc29 100644 --- a/packages/zcode-tui/src/startup.ts +++ b/packages/zcode-tui/src/startup.ts @@ -2,6 +2,7 @@ import { constants as osConstants } from "node:os"; import { isKeyRelease, matchesKey, ProcessTerminal, Text, TuiMainScreen } from "@earendil-works/pi-tui"; import { watchStreamErrors } from "./stream-error-guard.ts"; +import { stopTui } from "./terminal-cleanup.ts"; import type { TuiOptions } from "./types.ts"; export async function loadTuiStartupOptions(options: TuiOptions): Promise { @@ -35,16 +36,17 @@ export async function loadTuiStartupOptions(options: TuiOptions): Promise cancel(1)); - process.once("SIGINT", onSigint); - process.once("SIGTERM", onSigterm); - if (process.platform !== "win32") process.once("SIGHUP", onSighup); + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + if (process.platform !== "win32") process.on("SIGHUP", onSighup); + let readyOptions: TuiOptions | undefined; try { ui.start(); ui.renderNow(); await new Promise((resolve) => setImmediate(resolve)); if (cancelled) return undefined; - return await Promise.race([ + readyOptions = await Promise.race([ Promise.resolve().then(async () => { if (cancelled) return undefined; const startup = await loadStartupOptions(); @@ -53,19 +55,20 @@ export async function loadTuiStartupOptions(options: TuiOptions): Promise((resolve) => setImmediate(resolve)); + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + if (process.platform !== "win32") process.off("SIGHUP", onSighup); removeStreamErrorGuards(); } } } + return cancelled ? undefined : readyOptions; } diff --git a/packages/zcode-tui/src/terminal-cleanup.ts b/packages/zcode-tui/src/terminal-cleanup.ts new file mode 100644 index 0000000..2291b7c --- /dev/null +++ b/packages/zcode-tui/src/terminal-cleanup.ts @@ -0,0 +1,11 @@ +import type { TUI } from "@earendil-works/pi-tui"; + +/** Disable enhanced keyboard reporting and consume in-flight releases before restoring the terminal. */ +export async function stopTui(ui: TUI): Promise { + try { + await ui.terminal.drainInput(); + } catch { + // A disconnected terminal can fail to drain; still restore its modes and listeners. + } + ui.stop(); +} diff --git a/test/tui/fixtures/startup.ts b/test/tui/fixtures/startup.ts index 73a78e4..3937e8e 100644 --- a/test/tui/fixtures/startup.ts +++ b/test/tui/fixtures/startup.ts @@ -47,6 +47,7 @@ else options.loadStartupOptions = async () => { if (mode === "cancel-reject") throw new Error("LATE_STARTUP_FAILURE"); } if (mode === "failure") throw new Error("STORAGE_STARTUP_FAILURE"); + if (mode === "cancel-after-load") setTimeout(() => process.kill(process.pid, "SIGTERM"), 10); return startup; }; diff --git a/test/tui/fixtures/terminal-exit.ts b/test/tui/fixtures/terminal-exit.ts new file mode 100644 index 0000000..17478e7 --- /dev/null +++ b/test/tui/fixtures/terminal-exit.ts @@ -0,0 +1,37 @@ +import { runTui } from "../../../packages/zcode-tui/src/index.ts"; + +if (process.argv[2] === "parent") { + // A fresh process reads the same PTY after the TUI exits, like the parent shell. + const input = await new Promise((resolve) => { + let received = ""; + process.stdin.setRawMode(true); + process.stdin.setEncoding("utf8"); + const onData = (data: string) => { + received += data; + const end = received.indexOf("\r"); + if (end === -1) return; + process.stdin.off("data", onData); + process.stdin.pause(); + process.stdin.setRawMode(false); + resolve(received.slice(0, end)); + }; + process.stdin.on("data", onData); + process.stdin.resume(); + console.log("EXIT_PARENT_READY"); + }); + console.log(`EXIT_PARENT_INPUT ${JSON.stringify(input)}`); + console.log(`EXIT_TUI_STATUS ${process.argv[3]}`); +} else { + console.log(`EXIT_TUI_PID ${process.pid}`); + await runTui({ + initialModel: "scenario/model", + theme: "dark", + workspaceDirectory: process.cwd(), + loadStartupOptions: process.argv[2] === "startup" + ? () => new Promise(() => {}) + : undefined, + loadSessionTranscript: async () => [{ role: "agent", content: "EXIT_READY" }], + submitPrompt: async () => ({ response: "Unexpected submission" }) + }); + console.log(`EXIT_RAW_MODE ${Boolean(process.stdin.isRaw)}`); +} diff --git a/test/tui/startup.test.ts b/test/tui/startup.test.ts index 2b1f3e5..0c32bf9 100644 --- a/test/tui/startup.test.ts +++ b/test/tui/startup.test.ts @@ -69,3 +69,16 @@ test("TUI propagates startup failures after restoring terminal raw mode", async await expect(session.exit()).rejects.toThrow("code 1"); expect(await workspace.readRuntimeJournal()).not.toMatch(/transcript-read|mcp-read/u); }, 10_000); + +test("TUI honors cancellation while draining input after startup options resolve", async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-startup-drain-cancel-" }); + await using session = TerminalSession.start({ + command, workspace, env: { ZCODE_TEST_STARTUP_MODE: "cancel-after-load" } + }); + await session.waitForScreen("startup screen", /Starting ZCode/u); + await workspace.write("release-startup", "ready"); + await session.waitForHistory("startup cancelled during input drain", /STARTUP_CANCELLED/u); + await session.waitForHistory("raw mode restored", /STARTUP_RAW_MODE false/u); + await expect(session.exit()).rejects.toThrow("code 143"); + expect(await workspace.readRuntimeJournal()).not.toMatch(/transcript-read|mcp-read/u); +}, 10_000); diff --git a/test/tui/terminal-exit.test.ts b/test/tui/terminal-exit.test.ts new file mode 100644 index 0000000..c8f8ed8 --- /dev/null +++ b/test/tui/terminal-exit.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; + +import { runtimeTestEnv } from "../../scripts/runtime-test-env.ts"; +import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; + +const fixture = join(import.meta.dir, "fixtures/terminal-exit.ts"); +const ctrlCRelease = "\x1b[99;5:3u"; +const cases: { + name: string; + mode: "regular" | "fullscreen"; + input?: string; + release: string; + status: number; + startup?: boolean; + signal?: NodeJS.Signals; +}[] = [ + { name: "Ctrl+C in regular mode", mode: "regular", input: "\x1b[99;5u", release: ctrlCRelease, status: 0 }, + { name: "Ctrl+C in fullscreen mode", mode: "fullscreen", input: "\x1b[99;5u", release: ctrlCRelease, status: 0 }, + { name: "Ctrl+D", mode: "regular", input: "\x1b[100;5u", release: "\x1b[100;5:3u", status: 0 }, + { name: "/exit", mode: "regular", input: "/exit\r", release: "\x1b[13;1:3u", status: 0 }, + { name: "startup cancellation", mode: "regular", startup: true, input: "\x1b[99;5u", release: ctrlCRelease, status: 130 }, + { name: "repeated SIGTERM", mode: "fullscreen", signal: "SIGTERM", release: ctrlCRelease, status: 143 }, + { name: "repeated SIGTERM during startup", mode: "regular", startup: true, signal: "SIGTERM", release: ctrlCRelease, status: 143 } +]; + +for (const scenario of cases) { + test.skipIf(process.platform === "win32")(`${scenario.name} drains late key releases before returning to the shell`, async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-terminal-exit-" }); + const decoder = new TextDecoder(); + let output = ""; + let negotiated = false; + let exiting = false; + let releaseSent = false; + let releaseTimer: ReturnType | undefined; + const terminal = new Bun.Terminal({ + cols: 100, + rows: 32, + name: "xterm-256color", + data(pty, data) { + output += decoder.decode(data, { stream: true }); + if (!negotiated && output.includes("\x1b[?u")) { + negotiated = true; + pty.write("\x1b[?7u\x1b[?1;2c"); + } + if (exiting && !releaseTimer && output.includes("\x1b[ { + if (scenario.signal) process.kill(tuiPid(), scenario.signal); + pty.write(scenario.release); + releaseSent = true; + }, 10); + } + } + }); + const child = Bun.spawn([ + "/bin/sh", "-c", + '"$@"; tui_status=$?; exec "$1" "$2" parent "$tui_status"', + "zcode-terminal-exit", process.execPath, fixture, + scenario.startup ? "startup" : "ready" + ], { + cwd: workspace.directory, + env: { + ...runtimeTestEnv(workspace.home), + CI: "1", + NO_COLOR: "1", + TERM: "xterm-256color", + PI_PROGRAM_STATUS: "0", + ZCODE_TUI_MODE: scenario.mode, + ZCODE_TUI_NOTIFICATION_METHOD: "off" + }, + terminal + }); + const tuiPid = () => Number(/EXIT_TUI_PID (\d+)/u.exec(output)?.[1]); + const waitFor = async (label: string, ready: () => boolean) => { + const deadline = Date.now() + 5_000; + while (!ready()) { + if (Date.now() >= deadline || child.exitCode !== null) { + throw new Error(`Timed out waiting for ${label}.\n${JSON.stringify(output)}`); + } + await Bun.sleep(5); + } + }; + try { + await waitFor("interactive TUI", () => negotiated && output.includes( + scenario.startup ? "Starting ZCode" : "EXIT_READY" + )); + await Bun.sleep(30); + const exitStart = output.length; + exiting = true; + if (scenario.signal) process.kill(tuiPid(), scenario.signal); + else terminal.write(scenario.input!); + await waitFor("release delivered and parent reading", () => releaseSent && output.includes("EXIT_PARENT_READY")); + terminal.write("next-command\r"); + await waitFor("parent input report", () => output.includes("EXIT_TUI_STATUS")); + expect(output).toContain('EXIT_PARENT_INPUT "next-command"'); + expect(output).toContain("EXIT_RAW_MODE false"); + expect(output).toContain(`EXIT_TUI_STATUS ${scenario.status}`); + expect(output.slice(exitStart).match(/\x1b\[