Repository navigation
fix: a remote SSH chat must never become permanently unusable (#67) - #68
Merged
Merged
Conversation
Reported: after a remote run exhausts its auto-continues the chat stops
responding, and "Restart Session" does not recover it.
Root cause is a missed termination, not the auto-continue budget.
`runSshSingle()` awaits a promise that resolves ONLY from `onDone`, and
four paths in `ClaudeSSH.send()` ended a run without reaching it:
1. a socket closing with no 'error' and no channel open (sshd's
ClientAlive/LoginGraceTime drop, a NAT idle-reap) — there was no
`conn.on('close')` handler at all
2. the idle watchdog and the hard cap, which called `conn.end()` and
nothing else; that settles a run only if a channel exists to be torn
down with it
3. an abort arriving before `conn.exec` — the listener lived INSIDE the
exec callback, so Stop during the handshake was silently ignored
4. `conn.on('error')` calling `h.onDone` directly, bypassing the
`finished` guard, so the socket close behind it fired onDone twice
A pending await keeps the session's `activeTasks` entry alive, and nothing
reaps it: the 15s orphan sweeper opens with `if (activeTasks.has(sid))
continue`. The chat then refuses every new message AND `restart_session`
answered "Task is still running" — no way out but a server restart.
Every ending now goes through `finish()`, and the abort listener is
registered at `send()` entry.
Restart Session becomes a recovery action instead of a refusal: it aborts
the live turn, waits up to 5s for that turn's own `finally` to release the
session (which also flushes partial output to SQLite), identity-compares
before reaping so a NEW turn's entry is never removed, and drops
`activeChatSessions` + `_tabBusy` with it. It is also now reachable from
the session bar — the in-message button only renders when the server got a
`session_restart_available` frame out, and the run that needs recovering is
precisely the one that never sends one.
Failure notices name the root cause (`describeStopReason`), so "hit the
turn limit three times" is distinguishable from "the link died three
times", and the session-reset path no longer ends on a bare `break` with
no word that it gave up.
Tests: `ssh-termination.test.js` drives `ClaudeSSH.send()` through a fake
ssh2 Client and asserts onDone fires exactly once on eight endings (the
first scenario hangs on the pre-fix code); `session-restart.test.js` boots
a real server against a `claude` that never exits and pins that a restart
aborts and releases the session (3 assertions fail pre-fix).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3994 was already session-liveness.test.js's. They never run at the same time (npm test is serial), but a server that has not released the port yet fails THIS file's preflight rather than its own — a flake with a misleading name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #67.
What was reported
On a Remote SSH workspace, after the agent exhausts its auto-continues the chat stops responding, and Restart Session does not recover it. The conversation has to be recreated from scratch.
Root cause
Not the auto-continue budget — a missed termination.
runSshSingle()awaits a promise that resolves only fromonDone. Four paths inClaudeSSH.send()ended a run without ever reaching it:'error'. sshd'sClientAliveCountMax/LoginGraceTimedrop, a NAT idle-reap, and our ownconn.end()all land on'close'— and there was noconn.on('close')handler at all.conn.end()and nothing else. That settles a run only when a channel exists for ssh2 to tear down with it. Fire between'ready'andexecand nothing else ever emits.conn.execcallback. A Stop pressed during the handshake had no listener — it neither stopped the run nor settled the promise.conn.on('error')calledh.onDonedirectly, bypassing thefinishedguard, so the socket close behind the error re-emitted the stderr block and firedonDonea second time.The consequence is out of proportion to the cause: a pending await keeps the session's
activeTasksentry alive, and nothing reaps it — the 15s orphan sweeper opens withif (activeTasks.has(sid)) continue. So the chat refuses every new message andrestart_sessionanswered"Task is still running". Only a server restart cleared it. That is both reported symptoms from one defect.The fix
claude-ssh.js— every ending goes throughfinish().conn.on('close')is the backstop. It defers viasetImmediate, so with a channel open the channel's own close (nextTick) still wins and the run reports its real exit code.finish()afterconn.end().send()entry;sshStreamis handed to it once the channel opens.conn.on('error')routes throughfinish()too, so the close behind it cannot double-fire.stream.on('error')already did).server.js— Restart Session recovers instead of refusing.It aborts the live turn (the same signal Stop sends), waits up to 5s for that turn's own
finallyto release the session — that path also flushes partial output to SQLite, which a forced delete skips — identity-compares before reaping so a new turn's entry is never the one removed, and dropsactiveChatSessions+ every socket's_tabBusywith it. Without that last part the next message is queued instead of run and the chat still looks dead after a restart that worked.The button has to be reachable without a working turn. The in-message restart button only renders when the server got a
session_restart_availableframe out — and the run that needs recovering is precisely the one that never sends one. Added#sessBarRestartBtnto the session bar, shown whenever the session has aclaude_session_id, behind a confirm. Clearing that id is the whole recovery:shouldReplaySessionHistoryis!!existSess && !localClaudeId, so the next message replays the chat's history into a fresh Claude session rather than losing it.Root cause is now named.
describeStopReason()makes the failure notice actionable — "it kept hitting the per-run turn limit (raise Max turns)" reads differently from "the SSH connection to the remote host kept failing (...)". The session-reset path also no longer ends on a barebreakwith no word that it gave up and no restart affordance.Tests
Two new suites, both wired into
npm test(68 files now).test/ssh-termination.test.js— drivesClaudeSSH.send()through a fake ssh2Clientinjected intorequire.cache(no network, no remote host) and assertsonDonefires exactly once across eight endings, plus that a normal run's sid/text/exit-code path is unchanged. Against the pre-fix code the very first scenario hangs:FAIL onDone never fired — the run would hang the chat forever.test/session-restart.test.js— boots a real server against a throwaway data dir and a fakeclaudethat streams once then sleeps 600s, i.e. a turn that will not end on its own. Pins that a restart aborts it, that the session actually leavesactiveTasks, thatclaude_session_idis cleared, and that an unknown session id is still refused. Three assertions fail pre-fix.npm test— 68 files, exit 0.Not covered
Remote SSH is exercised through a fake ssh2 client, not a live host; the browser/WebSocket path has no automated coverage in this repo either way. The session-bar button and the new notice text were reviewed by reading, not clicked.
🤖 Generated with Claude Code