Skip to content

fix: a remote SSH chat must never become permanently unusable (#67) - #68

Merged
Lexus2016 merged 2 commits into
mainfrom
fix/67-remote-session-recovery
Aug 25, 2026
Merged

Lexus2016 merged 2 commits into
mainfrom
fix/67-remote-session-recovery

Conversation

@Lexus2016

Copy link
Copy Markdown
Owner

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 from onDone. Four paths in ClaudeSSH.send() ended a run without ever reaching it:

  1. A socket that closes cleanly emits no 'error'. sshd's ClientAliveCountMax/LoginGraceTime drop, a NAT idle-reap, and our own conn.end() all land on 'close' — and there was no conn.on('close') handler at all.
  2. The idle watchdog and the hard cap called conn.end() and nothing else. That settles a run only when a channel exists for ssh2 to tear down with it. Fire between 'ready' and exec and nothing else ever emits.
  3. The abort listener was registered inside the conn.exec callback. A Stop pressed during the handshake had no listener — it neither stopped the run nor settled the promise.
  4. conn.on('error') called h.onDone directly, bypassing the finished guard, so the socket close behind the error re-emitted the stderr block and fired onDone a second time.

The consequence is out of proportion to the cause: 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. So the chat refuses every new message and restart_session answered "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 through finish().

  • conn.on('close') is the backstop. It defers via setImmediate, so with a channel open the channel's own close (nextTick) still wins and the run reports its real exit code.
  • Both watchdogs call finish() after conn.end().
  • The abort listener moves to send() entry; sshStream is handed to it once the channel opens.
  • conn.on('error') routes through finish() too, so the close behind it cannot double-fire.
  • stderr noise is no longer reported as a failure on abort (matching what 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 finally to 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 drops activeChatSessions + every socket's _tabBusy with 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_available frame out — and the run that needs recovering is precisely the one that never sends one. Added #sessBarRestartBtn to the session bar, shown whenever the session has a claude_session_id, behind a confirm. Clearing that id is the whole recovery: shouldReplaySessionHistory is !!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 bare break with 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 — drives ClaudeSSH.send() through a fake ssh2 Client injected into require.cache (no network, no remote host) and asserts onDone fires 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 fake claude that 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 leaves activeTasks, that claude_session_id is 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

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>
@Lexus2016
Lexus2016 merged commit 3ca4981 into main Aug 25, 2026
2 checks passed
@Lexus2016
Lexus2016 deleted the fix/67-remote-session-recovery branch August 25, 2026 09:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Remote SSH Workspace becomes unusable after auto-continue limit is reached

1 participant