fix: restore terminal keyboard state on tui exit (#49130) (thanks @biefan) (#49130)

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
biefan
2026-04-06 22:14:08 +08:00
committed by GitHub
parent 15114a9279
commit 0f075e1b8a
10 changed files with 184 additions and 14 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Agents/history: use one shared assistant-visible sanitizer across embedded delivery and chat-history extraction so leaked `<tool_call>` and `<tool_result>` XML blocks stay hidden from user-facing replies. (#61729) Thanks @openperf.
- Gateway/TUI: defer terminal chat finalization for per-attempt lifecycle errors so fallback retries keep streaming before the run is marked failed. (#60043) Thanks @jwchmodx.
- TUI/command messages: strip inbound envelope metadata before rendering command/system messages so async completion notices stop leaking raw wrappers into the operator terminal. (#59985) Thanks @MoerAI.
- TUI/terminal: restore Kitty keyboard protocol and `modifyOtherKeys` state on TUI exit and fatal CLI crashes so parent shells stop inheriting broken keyboard input after `openclaw tui` exits. (#49130) Thanks @biefan.
## 2026.4.5

View File

@@ -18,6 +18,7 @@ const buildProgramMock = vi.hoisted(() => vi.fn());
const getProgramContextMock = vi.hoisted(() => vi.fn(() => null));
const registerCoreCliByNameMock = vi.hoisted(() => vi.fn());
const registerSubCliByNameMock = vi.hoisted(() => vi.fn());
const restoreTerminalStateMock = vi.hoisted(() => vi.fn());
const maybeRunCliInContainerMock = vi.hoisted(() =>
vi.fn<
(argv: string[]) => { handled: true; exitCode: number } | { handled: false; argv: string[] }
@@ -89,6 +90,10 @@ vi.mock("./program/register.subclis.js", () => ({
registerSubCliByName: registerSubCliByNameMock,
}));
vi.mock("../terminal/restore.js", () => ({
restoreTerminalState: restoreTerminalStateMock,
}));
describe("runCli exit behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -184,4 +189,42 @@ describe("runCli exit behavior", () => {
expect(process.exitCode).toBe(1);
process.exitCode = exitCode;
});
it("restores terminal state before uncaught CLI exits", async () => {
buildProgramMock.mockReturnValueOnce({
commands: [{ name: () => "status" }],
parseAsync: vi.fn().mockResolvedValueOnce(undefined),
});
const processOnSpy = vi.spyOn(process, "on");
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit(${String(code)})`);
}) as typeof process.exit);
await runCli(["node", "openclaw", "status"]);
const handler = processOnSpy.mock.calls.find(([event]) => event === "uncaughtException")?.[1];
expect(typeof handler).toBe("function");
try {
expect(() => (handler as (error: unknown) => void)(new Error("boom"))).toThrow(
"process.exit(1)",
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[openclaw] Uncaught exception:",
expect.stringContaining("boom"),
);
expect(restoreTerminalStateMock).toHaveBeenCalledWith("uncaught exception", {
resumeStdinIfPaused: false,
});
} finally {
if (typeof handler === "function") {
process.off("uncaughtException", handler);
}
consoleErrorSpy.mockRestore();
exitSpy.mockRestore();
processOnSpy.mockRestore();
}
});
});

View File

@@ -153,9 +153,13 @@ export async function runCli(argv: string[] = process.argv) {
// Capture all console output into structured logs while keeping stdout/stderr behavior.
enableConsoleCapture();
const { buildProgram } = await import("./program.js");
const [{ buildProgram }, { installUnhandledRejectionHandler }, { restoreTerminalState }] =
await Promise.all([
import("./program.js"),
import("../infra/unhandled-rejections.js"),
import("../terminal/restore.js"),
]);
const program = buildProgram();
const { installUnhandledRejectionHandler } = await import("../infra/unhandled-rejections.js");
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
// These log the error and exit gracefully instead of crashing without trace.
@@ -163,6 +167,7 @@ export async function runCli(argv: string[] = process.argv) {
process.on("uncaughtException", (error) => {
console.error("[openclaw] Uncaught exception:", formatUncaughtError(error));
restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false });
process.exit(1);
});

View File

@@ -83,17 +83,21 @@ if (!isMain) {
}
if (isMain) {
const { restoreTerminalState } = await import("./terminal/restore.js");
// Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
// These log the error and exit gracefully instead of crashing without trace.
installUnhandledRejectionHandler();
process.on("uncaughtException", (error) => {
console.error("[openclaw] Uncaught exception:", formatUncaughtError(error));
restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false });
process.exit(1);
});
void runLegacyCliEntry(process.argv).catch((err) => {
console.error("[openclaw] CLI failed:", formatUncaughtError(err));
restoreTerminalState("legacy cli failure", { resumeStdinIfPaused: false });
process.exit(1);
});
}

View File

@@ -1,5 +1,12 @@
import process from "node:process";
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
const restoreTerminalStateMock = vi.hoisted(() => vi.fn());
vi.mock("../terminal/restore.js", () => ({
restoreTerminalState: restoreTerminalStateMock,
}));
import { installUnhandledRejectionHandler } from "./unhandled-rejections.js";
describe("installUnhandledRejectionHandler - fatal detection", () => {
@@ -41,10 +48,22 @@ describe("installUnhandledRejectionHandler - fatal detection", () => {
process.emit("unhandledRejection", reason, Promise.resolve());
}
function expectExitCodeFromUnhandled(reason: unknown, expected: number[]): void {
function expectExitCodeFromUnhandled(
reason: unknown,
expected: number[],
expectedRestoreReason?: string,
): void {
exitCalls = [];
restoreTerminalStateMock.mockClear();
emitUnhandled(reason);
expect(exitCalls).toEqual(expected);
if (expectedRestoreReason) {
expect(restoreTerminalStateMock).toHaveBeenCalledWith(expectedRestoreReason, {
resumeStdinIfPaused: false,
});
return;
}
expect(restoreTerminalStateMock).not.toHaveBeenCalled();
}
describe("fatal errors", () => {
@@ -56,7 +75,11 @@ describe("installUnhandledRejectionHandler - fatal detection", () => {
] as const;
for (const { code, message } of fatalCases) {
expectExitCodeFromUnhandled(Object.assign(new Error(message), { code }), [1]);
expectExitCodeFromUnhandled(
Object.assign(new Error(message), { code }),
[1],
"fatal unhandled rejection",
);
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
@@ -74,7 +97,11 @@ describe("installUnhandledRejectionHandler - fatal detection", () => {
] as const;
for (const { code, message } of configurationCases) {
expectExitCodeFromUnhandled(Object.assign(new Error(message), { code }), [1]);
expectExitCodeFromUnhandled(
Object.assign(new Error(message), { code }),
[1],
"configuration error",
);
}
expect(consoleErrorSpy).toHaveBeenCalledWith(
@@ -152,7 +179,7 @@ describe("installUnhandledRejectionHandler - fatal detection", () => {
it("exits on generic errors without code", () => {
const genericErr = new Error("Something went wrong");
expectExitCodeFromUnhandled(genericErr, [1]);
expectExitCodeFromUnhandled(genericErr, [1], "unhandled rejection");
expect(consoleErrorSpy).toHaveBeenCalledWith(
"[openclaw] Unhandled promise rejection:",
expect.stringContaining("Something went wrong"),
@@ -167,7 +194,7 @@ describe("installUnhandledRejectionHandler - fatal detection", () => {
},
);
expectExitCodeFromUnhandled(slackErr, [1]);
expectExitCodeFromUnhandled(slackErr, [1], "unhandled rejection");
});
it("does not exit on AbortError and logs suppression warning", () => {

View File

@@ -1,4 +1,5 @@
import process from "node:process";
import { restoreTerminalState } from "../terminal/restore.js";
import {
collectErrorGraphCandidates,
extractErrorCode,
@@ -340,6 +341,11 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean {
}
export function installUnhandledRejectionHandler(): void {
const exitWithTerminalRestore = (reason: string) => {
restoreTerminalState(reason, { resumeStdinIfPaused: false });
process.exit(1);
};
process.on("unhandledRejection", (reason, _promise) => {
if (isUnhandledRejectionHandled(reason)) {
return;
@@ -354,13 +360,13 @@ export function installUnhandledRejectionHandler(): void {
if (isFatalError(reason)) {
console.error("[openclaw] FATAL unhandled rejection:", formatUncaughtError(reason));
process.exit(1);
exitWithTerminalRestore("fatal unhandled rejection");
return;
}
if (isConfigError(reason)) {
console.error("[openclaw] CONFIGURATION ERROR - requires fix:", formatUncaughtError(reason));
process.exit(1);
exitWithTerminalRestore("configuration error");
return;
}
@@ -373,6 +379,6 @@ export function installUnhandledRejectionHandler(): void {
}
console.error("[openclaw] Unhandled promise rejection:", formatUncaughtError(reason));
process.exit(1);
exitWithTerminalRestore("unhandled rejection");
});
}

View File

@@ -94,4 +94,20 @@ describe("restoreTerminalState", () => {
expect(setRawMode).not.toHaveBeenCalled();
expect(resume).not.toHaveBeenCalled();
});
it("writes kitty and modifyOtherKeys reset sequences to stdout", () => {
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
configureTerminalIO({
stdinIsTTY: false,
stdoutIsTTY: true,
});
restoreTerminalState("test");
expect(writeSpy).toHaveBeenCalled();
const output = writeSpy.mock.calls.map(([chunk]) => String(chunk)).join("");
expect(output).toContain("\x1b[<u");
expect(output).toContain("\x1b[>4;0m");
});
});

View File

@@ -1,6 +1,7 @@
import { clearActiveProgressLine } from "./progress-line.js";
const RESET_SEQUENCE = "\x1b[0m\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l";
const RESET_SEQUENCE =
"\x1b[0m\x1b[?25h\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?2004l\x1b[<u\x1b[>4;0m";
type RestoreTerminalStateOptions = {
/**

View File

@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { getSlashCommands, parseCommand } from "./commands.js";
import {
createBackspaceDeduper,
drainAndStopTuiSafely,
isIgnorableTuiStopError,
resolveCtrlCAction,
resolveFinalAssistantText,
@@ -231,6 +232,53 @@ describe("resolveCtrlCAction", () => {
});
describe("TUI shutdown safety", () => {
it("drains terminal input before stopping the TUI", async () => {
const calls: string[] = [];
const drainInput = vi.fn(async () => {
calls.push("drain");
});
const stop = vi.fn(() => {
calls.push("stop");
});
await drainAndStopTuiSafely({
stop,
terminal: { drainInput },
});
expect(drainInput).toHaveBeenCalledOnce();
expect(stop).toHaveBeenCalledOnce();
expect(calls).toEqual(["drain", "stop"]);
});
it("still stops when the terminal does not support drainInput", async () => {
const stop = vi.fn();
await drainAndStopTuiSafely({
stop,
terminal: {},
});
expect(stop).toHaveBeenCalledOnce();
});
it("rethrows non-ignorable stop errors after draining", async () => {
const drainInput = vi.fn(async () => {});
const stop = vi.fn(() => {
throw new Error("boom");
});
await expect(
drainAndStopTuiSafely({
stop,
terminal: { drainInput },
}),
).rejects.toThrow("boom");
expect(drainInput).toHaveBeenCalledOnce();
expect(stop).toHaveBeenCalledOnce();
});
it("treats setRawMode EBADF errors as ignorable", () => {
expect(isIgnorableTuiStopError(new Error("setRawMode EBADF"))).toBe(true);
expect(

View File

@@ -158,6 +158,24 @@ export function stopTuiSafely(stop: () => void): void {
}
}
type DrainableTui = {
stop: () => void;
terminal?: {
drainInput?: (maxMs?: number, idleMs?: number) => Promise<void>;
};
};
export async function drainAndStopTuiSafely(tui: DrainableTui): Promise<void> {
if (typeof tui.terminal?.drainInput === "function") {
try {
await tui.terminal.drainInput();
} catch {
// Best-effort only. A failed drain should not skip terminal shutdown.
}
}
stopTuiSafely(() => tui.stop());
}
type CtrlCAction = "clear" | "warn" | "exit";
export function resolveCtrlCAction(params: {
@@ -734,8 +752,9 @@ export async function runTui(opts: TuiOptions) {
}
exitRequested = true;
client.stop();
stopTuiSafely(() => tui.stop());
process.exit(0);
void drainAndStopTuiSafely(tui).then(() => {
process.exit(0);
});
};
const { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector } =