From 39005e6aa76090698291cbce15e6ab3882a0a28c Mon Sep 17 00:00:00 2001 From: Zennn <89544177+udaymanish6@users.noreply.github.com> Date: Tue, 12 May 2026 02:27:54 -0400 Subject: [PATCH] Fix TUI exit after gateway disconnect (#75381) * fix(tui): exit after gateway disconnect * test(gateway): avoid uuid lint false positive * test(extensions): avoid core ansi helper imports * test: fix strip ansi helper conflicts --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + .../server-methods/nodes.invoke-wake.test.ts | 7 ++- src/tui/tui.test.ts | 32 ++++++++++++++ src/tui/tui.ts | 44 ++++++++++++++++++- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8d60cc0ab6..7c69df1df98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Docs: https://docs.openclaw.ai - Plugins/channels: explain bundled channel entry files that reach the legacy plugin loader as setup-runtime loader mismatches instead of generic missing-register failures. Thanks @chinar-amrutkar. - Plugins/session-end: fire a typed `session_end` plugin hook with reason `shutdown` (or `restart` when a restart is expected) for every session that was still active when the gateway process stops. Previously SIGTERM/SIGINT/restart paths closed the gateway without enumerating active sessions, leaving downstream `session_end` plugins (e.g. claude-mem) with ghost rows accumulating across restarts. The new shutdown finalizer drains an in-memory tracker that is populated by `session_start` and forgotten by replace / reset / delete / compaction emitters, so previously-finalized sessions are never double-fired. The drain is bounded to a 2 s total budget so a slow plugin cannot block process exit. Adds `"shutdown"` and `"restart"` to `PluginHookSessionEndReason`. Fixes #57790. Thanks @pandadev66. - Codex app-server: clamp Codex code-mode sandboxing to workspace-write when an OpenClaw sandbox is active, preventing Docker gateway socket access from becoming a danger-full-access Codex turn. +- TUI: exit immediately on Ctrl+C/SIGINT after gateway disconnect and bound shutdown drain so terminal teardown cannot strand sessions. Fixes #75379. (#75381) Thanks @udaymanish6. - Bonjour/Gateway: treat active ciao probing and fresh name-conflict renames as in-progress so the mDNS watchdog waits for probe settlement before retrying, preventing rapid re-advertise loops on Windows, WSL, and other multicast-hostile hosts. (#74778) Refs #74242. Thanks @fuller-stack-dev. - Providers/MiniMax: send a minimal Anthropic-compatible user fallback when message conversion filters a turn to an empty payload, so MiniMax M2.7 no longer returns `chat content is empty` after tool-heavy sessions. Fixes #74589. Thanks @neeravmakwana and @DerekEXS. - Tools/media: preserve implicit allow-all semantics from `tools.alsoAllow`-only policies when preconstructing built-in media generation and PDF tools, so configured media tools become live without forcing `tools.allow: ["*", ...]`. Fixes #77841. Thanks @trialanderrorstudios. diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index e54aeadec6f..7ed1c77c74a 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -129,7 +129,12 @@ function isUuidV4(value: string): boolean { if (part2[0] !== "4" || !part3[0] || !"89ab".includes(part3[0])) { return false; } - return parts.every(isLowerHex); + for (const part of parts) { + if (!isLowerHex(part)) { + return false; + } + } + return true; } function requireRespondPayload(call: RespondCall | undefined, label: string) { diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index d11de6c7b32..8a97d2f79ab 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -18,6 +18,7 @@ import { resolveLocalAuthCliInvocation, resolveLocalAuthSpawnCwd, resolveLocalAuthSpawnOptions, + resolveTuiCtrlCAction, resolveTuiSessionKey, stopTuiSafely, } from "./tui.js"; @@ -264,6 +265,36 @@ describe("resolveCtrlCAction", () => { }); }); +describe("resolveTuiCtrlCAction", () => { + it("exits immediately after a gateway disconnect", () => { + expect( + resolveTuiCtrlCAction({ + hasInput: true, + now: 2000, + lastCtrlCAt: 0, + wasDisconnected: true, + }), + ).toEqual({ + action: "exit", + nextLastCtrlCAt: 0, + }); + }); + + it("forces exit when shutdown is already in progress", () => { + expect( + resolveTuiCtrlCAction({ + hasInput: false, + now: 2000, + lastCtrlCAt: 1000, + exitRequested: true, + }), + ).toEqual({ + action: "force-exit", + nextLastCtrlCAt: 1000, + }); + }); +}); + describe("TUI shutdown safety", () => { it("drains terminal input before stopping the TUI", async () => { const calls: string[] = []; @@ -280,6 +311,7 @@ describe("TUI shutdown safety", () => { }); expect(drainInput).toHaveBeenCalledOnce(); + expect(drainInput).toHaveBeenCalledWith(500, 100); expect(stop).toHaveBeenCalledOnce(); expect(calls).toEqual(["drain", "stop"]); }); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index ccfceb4b1be..883811364b9 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -343,10 +343,14 @@ type DrainableTui = { }; }; +const TUI_SHUTDOWN_DRAIN_MAX_MS = 500; +const TUI_SHUTDOWN_DRAIN_IDLE_MS = 100; +const TUI_SHUTDOWN_HARD_EXIT_MS = 2000; + export async function drainAndStopTuiSafely(tui: DrainableTui): Promise { if (typeof tui.terminal?.drainInput === "function") { try { - await tui.terminal.drainInput(); + await tui.terminal.drainInput(TUI_SHUTDOWN_DRAIN_MAX_MS, TUI_SHUTDOWN_DRAIN_IDLE_MS); } catch { // Best-effort only. A failed drain should not skip terminal shutdown. } @@ -355,6 +359,7 @@ export async function drainAndStopTuiSafely(tui: DrainableTui): Promise { } type CtrlCAction = "clear" | "warn" | "exit"; +type TuiCtrlCAction = CtrlCAction | "force-exit"; export function resolveCtrlCAction(params: { hasInput: boolean; @@ -381,6 +386,23 @@ export function resolveCtrlCAction(params: { }; } +export function resolveTuiCtrlCAction(params: { + hasInput: boolean; + now: number; + lastCtrlCAt: number; + exitRequested?: boolean; + wasDisconnected?: boolean; + exitWindowMs?: number; +}): { action: TuiCtrlCAction; nextLastCtrlCAt: number } { + if (params.exitRequested === true) { + return { action: "force-exit", nextLastCtrlCAt: params.lastCtrlCAt }; + } + if (params.wasDisconnected === true) { + return { action: "exit", nextLastCtrlCAt: params.lastCtrlCAt }; + } + return resolveCtrlCAction(params); +} + export async function runTui(opts: RunTuiOptions): Promise { const isLocalMode = opts.local === true || opts.backend !== undefined; const config = opts.config ?? getRuntimeConfig(); @@ -1086,8 +1108,17 @@ export async function runTui(opts: RunTuiOptions): Promise { }); const deferredFinish = createDeferredTuiFinish(); + const forceExit = () => { + try { + process.stderr.write("openclaw tui forcing exit\n"); + } catch { + // Best effort only; force exit must not depend on stderr. + } + process.exit(130); + }; const requestExit = (result?: Partial) => { if (exitRequested) { + forceExit(); return; } exitRequested = true; @@ -1095,6 +1126,8 @@ export async function runTui(opts: RunTuiOptions): Promise { exitReason: result?.exitReason ?? "exit", ...(result?.crestodianMessage ? { crestodianMessage: result.crestodianMessage } : {}), }; + const hardExitTimer = setTimeout(forceExit, TUI_SHUTDOWN_HARD_EXIT_MS); + hardExitTimer.unref?.(); client.stop(); void drainAndStopTuiSafely(tui) .catch((err) => { @@ -1107,6 +1140,7 @@ export async function runTui(opts: RunTuiOptions): Promise { } }) .finally(() => { + clearTimeout(hardExitTimer); deferredFinish.requestFinish(); }); }; @@ -1169,11 +1203,17 @@ export async function runTui(opts: RunTuiOptions): Promise { }; const handleCtrlC = () => { const now = Date.now(); - const decision = resolveCtrlCAction({ + const decision = resolveTuiCtrlCAction({ hasInput: editor.getText().trim().length > 0, now, lastCtrlCAt, + exitRequested, + wasDisconnected, }); + if (decision.action === "force-exit") { + forceExit(); + return; + } lastCtrlCAt = decision.nextLastCtrlCAt; if (decision.action === "clear") { editor.setText("");