Skip to content

TUI fully unresponsive to Ctrl+C / Ctrl+D / SIGINT after gateway WebSocket close #75379

@hexsprite

Description

@hexsprite

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

  1. openclaw tui — connect normally
  2. From another terminal: openclaw gateway restart (or launchctl kickstart -k gui/$UID/ai.openclaw.gateway)
  3. TUI footer shows:
    connecting | idle
    gateway disconnected: closed | idle
    ───
    gateway connect failed: Error: gateway closed (1000):
    
  4. Press Ctrl+C → nothing
  5. Press Ctrl+C again → nothing
  6. Press Ctrl+D → nothing
  7. kill -INT <pid> from another shell → nothing
  8. 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:

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

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

  3. 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.
  4. 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:

  • When wasDisconnected === true, treat single Ctrl+C as immediate exit — skip the double-press gate. Disconnected has no useful "clear input" or "warn" semantics.
  • Pass explicit bounds to drainInput(500, 100) in drainAndStopTuiSafely so it can never hang unboundedly.
  • In requestExit, set a 2s safety timer:
    const hardKill = setTimeout(() => process.exit(130), 2000).unref();
    void drainAndStopTuiSafely(tui).then(() => { clearTimeout(hardKill); finishTui?.(); });
  • Ensure the editor key reader keeps pumping during onDisconnected. Audit whether client.stop()-induced state changes pause the key loop.
  • On second Ctrl+C, log to stderr forcing exit so users know it registered even if render is frozen.
  • Consider process.on("SIGTERM", () => process.exit(143)) already exists at line 1135 — add a kill -INT escape 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 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions