fix(remote): classify the has-session probe by exit status, and forget it once the pane is back

#355 made the remote auto-reconnect watcher revive a dead pane only when the
durable remote tmux session is verifiably still alive, which is the right rule:
a clean Ctrl-C / Ctrl-D / exit tears that session down and must never relaunch
a fresh agent. Its probe, though, read `has-session`'s stdout and treated an
empty string as "gone". `tmux has-session` prints NOTHING on success (measured
on a scratch socket: exit 0, empty stdout, the failure message goes to stderr),
so every live remote session classified as gone and transport-drop reconnects
were silently disabled along with the clean-exit revives.

The probe now goes by exit status through a pure, unit-tested mapping
(`classifyRemoteAliveExit`): 0 is alive; ssh's own 255, a timeout (`killed`,
no numeric code) and a spawn failure are unknown, which the watcher already
treats as do-not-revive; any other status is the remote command's and means
gone (tmux's 1 for a missing session, 127 when tmux is not installed there).

Two smaller things in the same area:

- The cached answer was never invalidated, so after one successful reattach a
  stale `true` would have revived the NEXT clean exit (the original bug back
  after the first transport drop), and a cached `false` from a clean exit would
  have left a manually restarted session with auto-reconnect permanently off.
  The tick now forgets the cache entry whenever the pane is seen alive.
- The fire-and-forget probe has a 15s timeout against a 5s tick, so an
  unreachable host stacked up to three ssh processes per dead session. An
  in-flight set caps it at one.

The probe command is pinned as a literal string, and the reattach-then-clean-exit
sequence is driven through the watcher in the tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qg6bcATm1pNNY4kQWGwzgu
This commit is contained in:
Codeman maintainer
2026-09-04 13:50:22 +02:00
parent 96960785d2
commit 28b44237ae
4 changed files with 116 additions and 8 deletions
+31 -6
View File
@@ -390,15 +390,40 @@ export async function remoteTmuxSessionAlive(
if (process.env.VITEST) return true;
const command = buildRemoteSessionAliveCommand(remote, remoteSessionName);
try {
const { stdout } = await execAsync(command, { timeout: 15_000 });
// has-session prints the session name on success (exit 0). Anything else is
// a non-zero exit → the session is gone.
return stdout.trim().length > 0;
} catch {
return undefined;
await execAsync(command, { timeout: 15_000 });
return classifyRemoteAliveExit(0, false);
} catch (err) {
const e = err as { code?: unknown; killed?: boolean };
return classifyRemoteAliveExit(typeof e.code === 'number' ? e.code : null, e.killed === true);
}
}
/**
* Map the `has-session` probe's exit status onto the tri-state the watcher
* reads. Pure, so the mapping is unit-tested even though the probe itself is
* VITEST-guarded.
*
* ⚠️ `tmux has-session` prints NOTHING on success (measured: exit 0, empty
* stdout; the failure message goes to stderr), so the exit status is the ONLY
* signal. An earlier version read stdout and therefore classified every live
* remote session as gone, which silently disabled transport-drop reconnects.
*
* - exit 0 → the durable remote session exists → `true`.
* - exit 255 is ssh's own failure (unreachable host, auth, proxy/jump error)
* and a timeout arrives as `killed` with no numeric code: we learned
* nothing about the session → `undefined`, which the watcher treats as
* "do not revive".
* - any other non-zero status is the REMOTE command's: tmux's 1 for a missing
* session, or 127 when tmux is not installed there (no durable session can
* exist without it) → `false`.
*/
export function classifyRemoteAliveExit(code: number | null, killed: boolean): boolean | undefined {
if (killed) return undefined;
if (code === 0) return true;
if (code === null || code === 255) return undefined;
return false;
}
/**
* COD-105 — build the SSH command that lists `codeman-*` tmux sessions on a
* remote host's canonical `-L codeman` socket.
+23 -1
View File
@@ -1368,6 +1368,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* transport drop).
*/
private remoteAliveCache: Map<string, boolean | undefined> = new Map();
/**
* Sessions with a `has-session` probe currently in flight. The probe is a
* fire-and-forget ssh round-trip with a 15s timeout against a 5s tick, so
* without this an unreachable host would accumulate three overlapping ssh
* processes per dead session.
*/
private remoteAliveInFlight: Set<string> = new Set();
private trueColorConfigured = false;
/** tmux 3.7+ can resize pane history after creation; older releases cannot. */
@@ -2732,12 +2739,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
*/
private async refreshRemoteAlive(session: MuxSession): Promise<void> {
if (!session.remote) return;
if (this.remoteAliveInFlight.has(session.sessionId)) return;
this.remoteAliveInFlight.add(session.sessionId);
const remoteName = session.remote.remoteSessionName || remoteTmuxSessionName(session.sessionId);
try {
const alive = await remoteTmuxSessionAlive(session.remote, remoteName);
this.remoteAliveCache.set(session.sessionId, alive);
} catch {
this.remoteAliveCache.set(session.sessionId, undefined);
} finally {
this.remoteAliveInFlight.delete(session.sessionId);
}
}
@@ -2751,7 +2762,15 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// refreshed lazily so a clean exit (remote tmux gone) flips it to false
// on the next tick and stops the auto-revive.
const paneDead = this.isPaneDead(session.muxName);
if (paneDead && this.remoteAliveCache.get(sessionId) === undefined) {
if (!paneDead) {
// A live pane makes whatever the probe last said STALE, so forget it:
// after a successful reattach (or a manual restart) the next dead pane
// must be probed afresh. A cached `true` from the transport drop would
// otherwise revive a later CLEAN exit, the exact bug this cache exists
// to prevent, and a cached `false` from a clean exit would leave a
// manually restarted session with auto-reconnect permanently off.
this.remoteAliveCache.delete(sessionId);
} else if (this.remoteAliveCache.get(sessionId) === undefined) {
void this.refreshRemoteAlive(session);
}
const action = decideReconnect({
@@ -2808,6 +2827,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.reconnectGuard.add(sessionId);
this.reconnectState.delete(sessionId);
this.remoteAliveCache.delete(sessionId);
this.remoteAliveInFlight.delete(sessionId);
}
/** Clear all per-session reconnect + guard state (e.g. when a session is removed). */
@@ -2815,6 +2835,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.reconnectState.delete(sessionId);
this.reconnectGuard.delete(sessionId);
this.remoteAliveCache.delete(sessionId);
this.remoteAliveInFlight.delete(sessionId);
}
destroy(): void {
@@ -2824,6 +2845,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
this.reconnectState.clear();
this.reconnectGuard.clear();
this.remoteAliveCache.clear();
this.remoteAliveInFlight.clear();
}
registerSession(session: MuxSession): void {