mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
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:
+31
-6
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user