Summary
When the gateway WebSocket closes mid-session (e.g. gateway restart, stale build mismatch, or any 1000 close), the TUI enters a "gateway disconnected: closed" state and stops responding to Ctrl+C, Ctrl+D, and SIGINT. The only way out is kill -9 from another terminal.
This is distinct from #38501 (which is about Ctrl+C wording during an active run) — here the TUI is fully unresponsive to all exit affordances.
Repro
openclaw tui — connect normally
- From another terminal:
openclaw gateway restart (or launchctl kickstart -k gui/$UID/ai.openclaw.gateway)
- TUI footer shows:
connecting | idle
gateway disconnected: closed | idle
───
gateway connect failed: Error: gateway closed (1000):
- Press Ctrl+C → nothing
- Press Ctrl+C again → nothing
- Press Ctrl+D → nothing
kill -INT <pid> from another shell → nothing
kill -9 <pid> is the only way to recover
Reproduces every time on main (commit e3f84fa, 2026.4.30) on macOS 15.4 / Node 24.14.0.
Why it happens (code refs)
src/tui/tui.ts:
-
Raw mode suppresses kernel SIGINT. Both paths exist:
process.on("SIGINT", sigintHandler) at line 1136
editor.onCtrlC = handleCtrlC at ~line 1019
In raw mode, the kernel does not deliver SIGINT — only the keyhandler path can fire. If the editor is not the active key reader during the disconnected state, Ctrl+C is silently swallowed.
-
Double-press gate with no visible feedback. resolveCtrlCAction (line 268) requires two Ctrl+C within exitWindowMs=1000ms. First press only calls setActivityStatus("press ctrl+c again to exit") and tui.requestRender(). If the TUI render is paused or the status line is overwritten by the disconnect state (setConnectionStatus(disconnectState.connectionStatus, 5000) at line 1112), the user gets no feedback that the first press registered, so they don't press again within the window.
-
requestExit() await chain can hang (line 928):
client.stop();
void drainAndStopTuiSafely(tui).then(() => { finishTui?.(); });
client.stop() on an already-half-closed WS may never complete graceful close
drainAndStopTuiSafely (line 255) calls tui.terminal.drainInput() with no maxMs/idleMs bounds — relies on the underlying lib's defaults. If stdin keeps getting bytes (e.g. from terminal escape sequences emitted by the disconnect render), drain never goes idle.
-
No hard-exit fallback. Once requestExit is in flight, there is no timeout that escalates to process.exit(130). If the promise chain hangs, the only signal handler that could rescue (SIGINT) was never reattached for force-kill.
Suggested fix
Layered defenses, not just one:
Surrounding context
In my repro, the gateway disconnect was caused by overlapping pnpm build runs leaving stale chunks in dist/ (e.g. four pi-tools.before-tool-call-*.js hashed siblings); the running gateway then failed import() for moved chunks and the WS closed. That root cause is its own issue — but the unrecoverable TUI state on any WS close is the bug filed here.
Environment
- OpenClaw
2026.4.30 commit e3f84fa on main
- macOS 15.4 (Darwin 25.4.0 arm64)
- Node 24.14.0
- Terminal: kitty / iTerm2 (both repro)
Summary
When the gateway WebSocket closes mid-session (e.g. gateway restart, stale build mismatch, or any 1000 close), the TUI enters a "gateway disconnected: closed" state and stops responding to Ctrl+C, Ctrl+D, and SIGINT. The only way out is
kill -9from another terminal.This is distinct from #38501 (which is about Ctrl+C wording during an active run) — here the TUI is fully unresponsive to all exit affordances.
Repro
openclaw tui— connect normallyopenclaw gateway restart(orlaunchctl kickstart -k gui/$UID/ai.openclaw.gateway)kill -INT <pid>from another shell → nothingkill -9 <pid>is the only way to recoverReproduces every time on
main(commit e3f84fa, 2026.4.30) on macOS 15.4 / Node 24.14.0.Why it happens (code refs)
src/tui/tui.ts:Raw mode suppresses kernel SIGINT. Both paths exist:
process.on("SIGINT", sigintHandler)at line 1136editor.onCtrlC = handleCtrlCat ~line 1019In raw mode, the kernel does not deliver SIGINT — only the keyhandler path can fire. If the editor is not the active key reader during the disconnected state, Ctrl+C is silently swallowed.
Double-press gate with no visible feedback.
resolveCtrlCAction(line 268) requires two Ctrl+C withinexitWindowMs=1000ms. First press only callssetActivityStatus("press ctrl+c again to exit")andtui.requestRender(). If the TUI render is paused or the status line is overwritten by the disconnect state (setConnectionStatus(disconnectState.connectionStatus, 5000)at line 1112), the user gets no feedback that the first press registered, so they don't press again within the window.requestExit()await chain can hang (line 928):client.stop()on an already-half-closed WS may never complete graceful closedrainAndStopTuiSafely(line 255) callstui.terminal.drainInput()with nomaxMs/idleMsbounds — relies on the underlying lib's defaults. If stdin keeps getting bytes (e.g. from terminal escape sequences emitted by the disconnect render), drain never goes idle.No hard-exit fallback. Once
requestExitis in flight, there is no timeout that escalates toprocess.exit(130). If the promise chain hangs, the only signal handler that could rescue (SIGINT) was never reattached for force-kill.Suggested fix
Layered defenses, not just one:
wasDisconnected === true, treat single Ctrl+C as immediate exit — skip the double-press gate. Disconnected has no useful "clear input" or "warn" semantics.drainInput(500, 100)indrainAndStopTuiSafelyso it can never hang unboundedly.requestExit, set a 2s safety timer:onDisconnected. Audit whetherclient.stop()-induced state changes pause the key loop.forcing exitso users know it registered even if render is frozen.process.on("SIGTERM", () => process.exit(143))already exists at line 1135 — add akill -INTescape hatch by re-enabling SIGINT delivery briefly when in disconnected state (toggle raw mode off).Surrounding context
In my repro, the gateway disconnect was caused by overlapping
pnpm buildruns leaving stale chunks indist/(e.g. fourpi-tools.before-tool-call-*.jshashed siblings); the running gateway then failedimport()for moved chunks and the WS closed. That root cause is its own issue — but the unrecoverable TUI state on any WS close is the bug filed here.Environment
2026.4.30commite3f84faonmain