From fca0467082cba7004ad80503b5c141ea2bbfae59 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 28 Feb 2026 14:56:01 -0800 Subject: [PATCH] TUI: guard SIGTERM shutdown against setRawMode EBADF --- CHANGELOG.md | 1 + src/tui/tui.test.ts | 35 +++++++++++++++++++++++++++++++++++ src/tui/tui.ts | 26 +++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acd0e05a234..5989b08d735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ Docs: https://docs.openclaw.ai - Web UI/Assistant text: strip internal `...` scaffolding from rendered assistant messages (while preserving code-fence literals), preventing memory-context leakage in chat output for models that echo internal blocks. (#29851) Thanks @Valkster70. - Dashboard/Sessions: allow authenticated Control UI clients to delete and patch sessions while still blocking regular webchat clients from session mutation RPCs, fixing Dashboard session delete failures. (#21264) Thanks @jskoiz. - TUI/Session model status: clear stale runtime model identity when model overrides change so `/model` updates are reflected immediately in `sessions.patch` responses and `sessions.list` status surfaces. (#28619) Thanks @lejean2000. +- TUI/SIGTERM shutdown: ignore `setRawMode EBADF` teardown errors during `SIGTERM` exit so long-running TUI sessions do not crash on terminal shutdown races, while still rethrowing unrelated stop errors. (#29430) Thanks @Cormazabal. - Memory/Hybrid recall: when strict hybrid scoring yields no hits, preserve keyword-backed matches using a text-weight floor so freshly indexed lexical canaries no longer disappear behind `minScore` filtering. (#29112) Thanks @ceo-nada. - Cron/Reminder session routing: preserve `job.sessionKey` for `sessionTarget="main"` runs so queued reminders wake and deliver in the originating scoped session/channel instead of being forced to the agent main session. - Agents/Sessions list transcript paths: resolve `sessions_list` `transcriptPath` via agent-aware session path options and ignore combined-store sentinel paths (`(multiple)`) so listed transcript paths always point to the state directory. (#28379) Thanks @fafuzuoluo. diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index afacc683133..9b46da66a99 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest"; import { getSlashCommands, parseCommand } from "./commands.js"; import { createBackspaceDeduper, + isIgnorableTuiStopError, resolveCtrlCAction, resolveFinalAssistantText, resolveGatewayDisconnectState, resolveTuiSessionKey, + stopTuiSafely, } from "./tui.js"; describe("resolveFinalAssistantText", () => { @@ -150,3 +152,36 @@ describe("resolveCtrlCAction", () => { }); }); }); + +describe("TUI shutdown safety", () => { + it("treats setRawMode EBADF errors as ignorable", () => { + expect(isIgnorableTuiStopError(new Error("setRawMode EBADF"))).toBe(true); + expect( + isIgnorableTuiStopError({ + code: "EBADF", + syscall: "setRawMode", + }), + ).toBe(true); + }); + + it("does not ignore unrelated stop errors", () => { + expect(isIgnorableTuiStopError(new Error("something else failed"))).toBe(false); + expect(isIgnorableTuiStopError({ code: "EIO", syscall: "write" })).toBe(false); + }); + + it("swallows only ignorable stop errors", () => { + expect(() => { + stopTuiSafely(() => { + throw new Error("setRawMode EBADF"); + }); + }).not.toThrow(); + }); + + it("rethrows non-ignorable stop errors", () => { + expect(() => { + stopTuiSafely(() => { + throw new Error("boom"); + }); + }).toThrow("boom"); + }); +}); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 4474267af5b..847245b3b67 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -246,6 +246,30 @@ export function createBackspaceDeduper(params?: { dedupeWindowMs?: number; now?: }; } +export function isIgnorableTuiStopError(error: unknown): boolean { + if (!error || typeof error !== "object") { + return false; + } + const err = error as { code?: unknown; syscall?: unknown; message?: unknown }; + const code = typeof err.code === "string" ? err.code : ""; + const syscall = typeof err.syscall === "string" ? err.syscall : ""; + const message = typeof err.message === "string" ? err.message : ""; + if (code === "EBADF" && syscall === "setRawMode") { + return true; + } + return /setRawMode/i.test(message) && /EBADF/i.test(message); +} + +export function stopTuiSafely(stop: () => void): void { + try { + stop(); + } catch (error) { + if (!isIgnorableTuiStopError(error)) { + throw error; + } + } +} + type CtrlCAction = "clear" | "warn" | "exit"; export function resolveCtrlCAction(params: { @@ -770,7 +794,7 @@ export async function runTui(opts: TuiOptions) { } exitRequested = true; client.stop(); - tui.stop(); + stopTuiSafely(() => tui.stop()); process.exit(0); };