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 <steipete@gmail.com>
This commit is contained in:
Zennn
2026-05-12 02:27:54 -04:00
committed by GitHub
parent 14cb165ddd
commit 39005e6aa7
4 changed files with 81 additions and 3 deletions

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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"]);
});

View File

@@ -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<void> {
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<void> {
}
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<TuiResult> {
const isLocalMode = opts.local === true || opts.backend !== undefined;
const config = opts.config ?? getRuntimeConfig();
@@ -1086,8 +1108,17 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
});
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<TuiResult>) => {
if (exitRequested) {
forceExit();
return;
}
exitRequested = true;
@@ -1095,6 +1126,8 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
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<TuiResult> {
}
})
.finally(() => {
clearTimeout(hardExitTimer);
deferredFinish.requestFinish();
});
};
@@ -1169,11 +1203,17 @@ export async function runTui(opts: RunTuiOptions): Promise<TuiResult> {
};
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("");