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
- Open the Shell tab on an iPhone and start a session (e.g.
claude).
- 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.
- Observe: Shell output stops updating; Chat for the same session continues to update.
- 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):
- Open Shell → socket WS1 attaches:
session.ws = WS1.
- Switch tabs / lock screen → WS1 starts dying; its
close is delayed.
- Return to Shell → WS2 attaches via the reconnect branch:
session.ws = WS2, buffer replayed.
- 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 .jsonl → Chat 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
- 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.
- 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().
- (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.
Environment
d8dfb2c)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
/wssocket and by file-watching the CLI's~/.claude/projects/**/*.jsonltranscripts, so it survives a broken shell socket. Shell streams raw PTY output over a dedicated/shellWebSocket. 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
/shellPTY lifecycle, either of which produces exactly this behavior, plus the absence of any client-side dead-socket recovery.Steps to reproduce
claude).Root cause 1 — stale
closehandler detaches the current socket (race)server/modules/websocket/services/shell-websocket.service.tsL525-544:The Shell tab is unmounted/remounted on every tab switch (
src/components/main-content/view/MainContent.tsx:189renders it with a truthy-&&conditional, unlike Chat at:158which 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
closeis delivered late):session.ws = WS1.closeis delayed.session.ws = WS2, buffer replayed.closefinally 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 tosession.buffer(5000-chunk FIFO, L378-383). WS2 is a perfectly healthy socket, so the client never receives acloseand never auto-reconnects → Shell is frozen while the agent keeps writing its.jsonl→ Chat 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.tsL39-51:The server pings but never listens for
pong, never tracks anisAliveflag, and never callsws.terminate(). When iOS backgrounds the tab and the TCP connection dies silently (no FIN),session.wskeepsreadyState === OPEN, output is "sent" into a dead socket (so it isn't even buffered — the send branch is taken), and nocloseevent ever fires.The client has no heartbeat either:
src/components/shell/hooks/useShellConnection.tsregisters onlyonopen/onmessage/onclose/onerror(L124-172), with no keepalive timer. Reconnection is gated entirely onisConnected/isConnectingflipping tofalse, which only happens insideonclose(L161-166) oronerror(L168-172). For a silently half-open socket neither fires, soconnectToShelland 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
closehandler, guard withif (session.ws === ws)beforesession.ws = nulland before arming the kill timer, andclearTimeout(session.timeoutId)before reassigning it. This closes the race in root cause 1.isAlive, set it in anws.on('pong', ...)handler, andws.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-runconnectToShell().pty.resize(cols, rows)with the client's new dimensions so a resumed TUI repaints at the correct size.Notes
WebglAddon'sonContextLossis 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.