mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-21 21:56:46 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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("");
|
||||
|
||||
Reference in New Issue
Block a user