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 Palhas 938dd62648 fix(maestro): stop retrying a WS attach that never opened
Integration review (#168): a rejected attach handshake — a dormant
session's 409 not_attachable, most likely — looks identical to a
dropped stream from the WebSocket API (close code 1006, no reason).
shouldRetryClose kept retrying it on the catalog's Running/Dead check
alone, which loops for as long as the catalog hasn't caught up with
the session going dormant.

Track whether the socket ever reached `open`; a handshake that never
did is a rejection, not a blip, so it is never retried regardless of
catalog status. The caller (wired up in the next commit) gets a
distinct onNeverAttached callback instead of onClosed, so it can offer
the resume affordance rather than assuming the session is gone.
2026-09-04 09:19:09 +01:00

71 lines
2.5 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);
});
// #168 review: a handshake that never opened is a rejection (e.g. a
// dormant session's 409 not_attachable), not a dropped stream. Retrying it
// loops forever if the catalog status hasn't caught up yet.
it("never retries a socket that never opened, even if the catalog still says running", () => {
expect(shouldRetryClose("a", 1006, running, false)).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);
});
});