mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 05:01:15 +00:00
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
Reference in New Issue
Block a user