mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49: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:
@@ -26,6 +26,7 @@ import {
|
||||
decideReconnect,
|
||||
} from '../src/remote-reconnect.js';
|
||||
import type { ReconnectSessionView } from '../src/remote-reconnect.js';
|
||||
import { buildRemoteSessionAliveCommand, classifyRemoteAliveExit } from '../src/remote-hosts.js';
|
||||
import { TmuxManager } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
@@ -220,6 +221,34 @@ describe('decideReconnect (pure eligibility)', () => {
|
||||
// (c) MANAGER integration — drive ticks with a stubbed pane-death + clock
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('remote has-session probe (pure)', () => {
|
||||
it('builds the probe through the shared ssh connection args, has-session by name', () => {
|
||||
const cmd = buildRemoteSessionAliveCommand({ username: 'dev', host: 'box', port: 2222 }, 'codeman-ssh-abc');
|
||||
// Literal pin: the session name is shellescaped inside the remote command,
|
||||
// which is itself one shellescaped ssh argument.
|
||||
expect(cmd).toBe(
|
||||
"ssh -o BatchMode=yes -o ConnectTimeout=10 -p 2222 dev@box 'tmux -L codeman-remote has-session -t '\\''codeman-ssh-abc'\\'' 2>/dev/null'"
|
||||
);
|
||||
});
|
||||
|
||||
// `tmux has-session` prints NOTHING on success (exit 0), so the exit status is
|
||||
// the only signal; reading stdout classified every live session as gone.
|
||||
it('exit 0 = alive', () => {
|
||||
expect(classifyRemoteAliveExit(0, false)).toBe(true);
|
||||
});
|
||||
|
||||
it("tmux's 1 (missing session) and 127 (no tmux on the remote) = gone", () => {
|
||||
expect(classifyRemoteAliveExit(1, false)).toBe(false);
|
||||
expect(classifyRemoteAliveExit(127, false)).toBe(false);
|
||||
});
|
||||
|
||||
it("ssh's 255, a timeout, and a spawn failure = unknown (never revive)", () => {
|
||||
expect(classifyRemoteAliveExit(255, false)).toBeUndefined();
|
||||
expect(classifyRemoteAliveExit(null, true)).toBeUndefined();
|
||||
expect(classifyRemoteAliveExit(null, false)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TmuxManager remote reconnect watcher (integration)', () => {
|
||||
let manager: TmuxManager;
|
||||
|
||||
@@ -315,6 +344,38 @@ describe('TmuxManager remote reconnect watcher (integration)', () => {
|
||||
expect(dropped).toEqual([]);
|
||||
});
|
||||
|
||||
it('forgets the cached liveness once the pane is alive again, so a later dead pane is probed afresh', async () => {
|
||||
registerRemote('ffff6666');
|
||||
const cache = (manager as unknown as { remoteAliveCache: Map<string, boolean | undefined> }).remoteAliveCache;
|
||||
// A clean exit was observed earlier (remote gone) ...
|
||||
cache.set('ffff6666', false);
|
||||
// ... then the user restarted the session by hand: the pane is alive.
|
||||
const paneDead = vi.spyOn(manager, 'isPaneDead').mockReturnValue(false);
|
||||
manager.runRemoteReconnectTick(0, true);
|
||||
expect(cache.has('ffff6666')).toBe(false);
|
||||
|
||||
// Now a transport drop. The first dead-pane tick only fires the probe
|
||||
// (stubbed alive under VITEST); the tick after it sees the fresh answer.
|
||||
paneDead.mockReturnValue(true);
|
||||
const dropped: unknown[] = [];
|
||||
manager.on('remoteSessionDropped', (d) => dropped.push(d));
|
||||
manager.runRemoteReconnectTick(1000, true);
|
||||
expect(dropped).toEqual([]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(cache.get('ffff6666')).toBe(true);
|
||||
manager.runRemoteReconnectTick(2000, true);
|
||||
expect(dropped).toEqual([{ sessionId: 'ffff6666', attempt: 1 }]);
|
||||
});
|
||||
|
||||
it('never revives from a stale "alive" answer after the pane came back: a later clean exit re-probes', () => {
|
||||
registerRemote('abab7777');
|
||||
const cache = (manager as unknown as { remoteAliveCache: Map<string, boolean | undefined> }).remoteAliveCache;
|
||||
cache.set('abab7777', true); // learned during a transport drop
|
||||
vi.spyOn(manager, 'isPaneDead').mockReturnValue(false); // reattach succeeded
|
||||
manager.runRemoteReconnectTick(0, true);
|
||||
expect(cache.has('abab7777')).toBe(false);
|
||||
});
|
||||
|
||||
it('clears per-session reconnect/guard state when the session is removed', () => {
|
||||
registerRemote('eeee5555');
|
||||
manager.guardRemoteReconnect('eeee5555');
|
||||
|
||||
Reference in New Issue
Block a user