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.
71 lines
2.5 KiB
TypeScript
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);
|
|
});
|
|
});
|