This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/reconnect.test.ts
T
Miguel PalhasandClaude Opus 5 f31377c34f
ci / web (pull_request) Successful in 28s
ci / rust (pull_request) Successful in 1m58s
ci / web (push) Successful in 34s
ci / rust (push) Successful in 2m44s
fix(maestro): three defects the review flagged and left open
SPA attach had no reconnect at all. Any dropped frame closed the socket,
`onClosed` fired, and the pane was destroyed permanently — the TUI
reconnects forever on the same endpoint. Session death also reached the UI
on two uncoordinated channels: the row from the events socket, the pane
from `ws.onclose`, with nothing reconciling them, so either could happen
without the other.

The catalog is now the authority. `shouldRetryClose` decides from session
status: Running means the link dropped, so retry with 500ms-to-10s
backoff; Dead means the PTY stream was sealed and nothing more is coming;
absent means it is gone. Application closes (4000+, a relayed daemon
rejection) and clean closes are never retried. The shell also closes any
pane whose session has left the catalog. The policy lives in reconnect.ts
so it is tested without a DOM.

kill_session checked `has()` before `kill()`, which only opened a window
for the liveness watcher to reap in between — turning a 404 into a 502.
`kill` reports the miss itself.

with_spawn_history logged and carried on when `Arc::get_mut` failed,
producing a daemon whose recent-dirs picker is permanently empty with
nothing to show for it. It panics now: that is a construction-order
mistake, at startup, in code that runs once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:31:54 +01:00

64 lines
2.1 KiB
TypeScript

// The reconnect *policy* — which closes are retried and which end the pane.
// `createAttachConnection` itself needs a DOM and xterm, so the decision is
// tested here as the predicate the shell supplies, plus the backoff schedule
// it drives.
import { describe, expect, it } from "vitest";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS, shouldRetryClose } from "./reconnect.js";
import type { Session } from "./types.js";
function session(id: string, status: Session["status"]): Session {
return {
id,
cwd: "/home/n",
command: "bash",
env: {},
metadata: {},
status,
created_at: "2026-08-22T00:00:00Z",
} as Session;
}
describe("shouldRetryClose", () => {
const running = new Map([["a", session("a", "Running")]]);
const dead = new Map([["a", session("a", "Dead")]]);
const gone = new Map<string, Session>();
it("retries a dropped link to a session that is still running", () => {
expect(shouldRetryClose("a", 1006, running)).toBe(true);
expect(shouldRetryClose("a", 1001, running)).toBe(true);
});
it("does not retry a session the daemon has marked dead", () => {
// Its stream is sealed; nothing further will ever arrive.
expect(shouldRetryClose("a", 1006, dead)).toBe(false);
});
it("does not retry a session that is no longer in the catalog", () => {
expect(shouldRetryClose("a", 1006, gone)).toBe(false);
});
it("never retries an application close, which carries a daemon rejection", () => {
// 4000+ means the proxy relayed a refusal — retrying would loop on it.
expect(shouldRetryClose("a", 4502, running)).toBe(false);
});
it("never retries a clean close", () => {
expect(shouldRetryClose("a", 1000, running)).toBe(false);
});
});
describe("backoff bounds", () => {
it("starts well under a second and caps at ten", () => {
expect(INITIAL_RECONNECT_MS).toBeLessThan(1000);
expect(MAX_RECONNECT_MS).toBe(10_000);
});
it("reaches the cap by doubling and never exceeds it", () => {
let ms = INITIAL_RECONNECT_MS;
for (let i = 0; i < 20; i++) ms = Math.min(ms * 2, MAX_RECONNECT_MS);
expect(ms).toBe(MAX_RECONNECT_MS);
});
});