Skip to content

Shell terminal silently freezes on mobile while Chat keeps updating — stale WebSocket close detaches the live PTY socket; half-open sockets never reaped #953

Description

@wideplain

Environment

  • Version: v1.35.1 (d8dfb2c)
  • Reproduced on: iPhone / iOS Safari, and any setup where an idle TCP connection is dropped by the network or a reverse proxy.

Summary

On mobile the Shell tab silently freezes — the terminal stops rendering new output even though the underlying agent keeps running. The clearest signature is that the Chat view keeps advancing while the Shell view is stuck.

This asymmetry is diagnostic: Chat and Shell use different transports. Chat is driven by the /ws socket and by file-watching the CLI's ~/.claude/projects/**/*.jsonl transcripts, so it survives a broken shell socket. Shell streams raw PTY output over a dedicated /shell WebSocket. When that socket breaks in one of the two ways below, the terminal freezes while the agent (and therefore Chat) keeps going.

Code review of v1.35.1 found two independent server-side defects in the /shell PTY lifecycle, either of which produces exactly this behavior, plus the absence of any client-side dead-socket recovery.

Steps to reproduce

  1. Open the Shell tab on an iPhone and start a session (e.g. claude).
  2. Switch to another tab (or lock the screen / background Safari) for a few seconds, then return to Shell. Repeat a couple of times — or just leave it backgrounded on a flaky mobile network.
  3. Observe: Shell output stops updating; Chat for the same session continues to update.
  4. Pressing Restart/Disconnect recovers it; nothing else does.

Root cause 1 — stale close handler detaches the current socket (race)

server/modules/websocket/services/shell-websocket.service.ts L525-544:

ws.on('close', () => {
  if (!ptySessionKey) return;
  const session = ptySessionsMap.get(ptySessionKey);
  if (!session) return;
  session.ws = null;                       // unconditional — no `session.ws === ws` check
  session.timeoutId = setTimeout(() => {   // also overwrites a pending timeoutId without clearing it
    if (ptySessionsMap.get(ptySessionKey) !== session) return;
    session.pty.kill();
    ptySessionsMap.delete(ptySessionKey);
  }, PTY_SESSION_TIMEOUT);                  // 30 min
});

The Shell tab is unmounted/remounted on every tab switch (src/components/main-content/view/MainContent.tsx:189 renders it with a truthy-&& conditional, unlike Chat at :158 which stays mounted and is only CSS-hidden). On remount a new socket reattaches to the same PTY via the reconnect branch — existingSession.ws = ws; at L315, after replaying the buffer.

Race timeline (common on mobile, where a dead TCP close is delivered late):

  1. Open Shell → socket WS1 attaches: session.ws = WS1.
  2. Switch tabs / lock screen → WS1 starts dying; its close is delayed.
  3. Return to Shell → WS2 attaches via the reconnect branch: session.ws = WS2, buffer replayed.
  4. WS1's close finally arrives → session.ws = null, and a fresh 30-min kill timer is armed against the now-live session.

From step 4 on, shellProcess.onData (guard at L385: if (session.ws && session.ws.readyState === WebSocket.OPEN)) skips the send block and only appends to session.buffer (5000-chunk FIFO, L378-383). WS2 is a perfectly healthy socket, so the client never receives a close and never auto-reconnects → Shell is frozen while the agent keeps writing its .jsonlChat keeps advancing.

Note the kill timer's only guard is map-object identity (L537); it never re-checks session.ws, so once armed it will kill a live, attached PTY after the timeout.

Root cause 2 — half-open sockets are never detected or reaped

server/modules/websocket/services/websocket-server.service.ts L39-51:

const heartbeat = setInterval(() => {
  if (ws.readyState === ws.OPEN) {
    try { ws.ping(); } catch {}
  }
}, HEARTBEAT_INTERVAL_MS); // 30s

The server pings but never listens for pong, never tracks an isAlive flag, and never calls ws.terminate(). When iOS backgrounds the tab and the TCP connection dies silently (no FIN), session.ws keeps readyState === OPEN, output is "sent" into a dead socket (so it isn't even buffered — the send branch is taken), and no close event ever fires.

The client has no heartbeat either: src/components/shell/hooks/useShellConnection.ts registers only onopen/onmessage/onclose/onerror (L124-172), with no keepalive timer. Reconnection is gated entirely on isConnected/isConnecting flipping to false, which only happens inside onclose (L161-166) or onerror (L168-172). For a silently half-open socket neither fires, so connectToShell and the autoConnect effect (L220-232) short-circuit on || isConnected. The Shell therefore sits with a live-looking-but-dead socket and cannot self-recover — only a manual Restart/Disconnect or unmounting the tab fixes it.

Suggested fixes

  1. In the close handler, guard with if (session.ws === ws) before session.ws = null and before arming the kill timer, and clearTimeout(session.timeoutId) before reassigning it. This closes the race in root cause 1.
  2. Implement real heartbeat reaping: on the server track isAlive, set it in an ws.on('pong', ...) handler, and ws.terminate() sockets that miss a pong before the next tick. Optionally add a client-side keepalive/watchdog so the UI can detect a dead socket and re-run connectToShell().
  3. (Optional, quality-of-life) On the reconnect branch, call pty.resize(cols, rows) with the client's new dimensions so a resumed TUI repaints at the correct size.

Notes

  • A separate client-side path can also freeze rendering (distinct from these socket bugs): WebglAddon's onContextLoss is never subscribed (src/components/shell/hooks/useShellTerminal.ts:101-105), so a reclaimed GPU context (frequent on iOS) leaves the canvas frozen even though data still arrives. Filed/tracked separately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Fields

    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