["actionProps"];
- copyText: string | undefined;
+ copyLabel: string | undefined;
+ copyText: string | (() => string) | undefined;
secondaryActionProps: ThreadToastData["secondaryActionProps"];
}) {
- const { copyToClipboard, isCopied } = useCopyToClipboard();
+ const { copyToClipboard, isCopied } = useCopyToClipboard({
+ onError: (error) => {
+ toastManager.add({
+ type: "error",
+ title: copyLabel ? `Could not copy ${copyLabel}` : "Could not copy text",
+ description: error.message,
+ });
+ },
+ });
if (!actionProps && !copyText && !secondaryActionProps) return null;
+ const copyActionLabel = copyLabel ? `Copy ${copyLabel}` : "Copy error message";
+
return (
{copyText && (
)}
{actionProps && (
@@ -436,6 +455,7 @@ function ToastSurface({
{!compact ? (
diff --git a/apps/web/src/connectionRecoveryNotice.test.ts b/apps/web/src/connectionRecoveryNotice.test.ts
new file mode 100644
index 000000000..1b7e5c0e0
--- /dev/null
+++ b/apps/web/src/connectionRecoveryNotice.test.ts
@@ -0,0 +1,122 @@
+// FILE: connectionRecoveryNotice.test.ts
+// Purpose: Verifies recovery-notice timing and privacy-safe diagnostic copy.
+// Layer: Web connection recovery presentation tests
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ CONNECTION_DETAILS_DELAY_MS,
+ CONNECTION_NOTICE_DELAY_MS,
+ ConnectionRecoveryNoticeController,
+ formatConnectionRecoveryDiagnostics,
+} from "./connectionRecoveryNotice";
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("connection recovery notice", () => {
+ it("waits before notifying and reserves detailed help for sustained outages", () => {
+ expect(CONNECTION_NOTICE_DELAY_MS).toBeGreaterThanOrEqual(1_000);
+ expect(CONNECTION_DETAILS_DELAY_MS).toBeGreaterThan(CONNECTION_NOTICE_DELAY_MS);
+ });
+
+ it("stays silent for initial connection and brief reconnects", async () => {
+ vi.useFakeTimers();
+ const callbacks = {
+ onClear: vi.fn(),
+ onRecovered: vi.fn(),
+ onShow: vi.fn(),
+ onShowDetails: vi.fn(),
+ };
+ const controller = new ConnectionRecoveryNoticeController(callbacks);
+
+ controller.handleState("connecting");
+ controller.handleState("open");
+ controller.handleState("reconnecting");
+ await vi.advanceTimersByTimeAsync(CONNECTION_NOTICE_DELAY_MS - 1);
+ controller.handleState("open");
+
+ expect(callbacks.onShow).not.toHaveBeenCalled();
+ expect(callbacks.onShowDetails).not.toHaveBeenCalled();
+ expect(callbacks.onRecovered).not.toHaveBeenCalled();
+ });
+
+ it("uses one notice through delayed details and recovery", async () => {
+ vi.useFakeTimers();
+ const callbacks = {
+ onClear: vi.fn(),
+ onRecovered: vi.fn(),
+ onShow: vi.fn(),
+ onShowDetails: vi.fn(),
+ };
+ const controller = new ConnectionRecoveryNoticeController(callbacks);
+
+ controller.handleState("reconnecting");
+ await vi.advanceTimersByTimeAsync(CONNECTION_NOTICE_DELAY_MS);
+ expect(callbacks.onShow).toHaveBeenCalledOnce();
+ await vi.advanceTimersByTimeAsync(CONNECTION_DETAILS_DELAY_MS - CONNECTION_NOTICE_DELAY_MS);
+ expect(callbacks.onShowDetails).toHaveBeenCalledOnce();
+
+ controller.handleState("open");
+ expect(callbacks.onRecovered).toHaveBeenCalledOnce();
+ });
+
+ it("cancels stale timers and respects manual dismissal across repeated cycles", async () => {
+ vi.useFakeTimers();
+ const callbacks = {
+ onClear: vi.fn(),
+ onRecovered: vi.fn(),
+ onShow: vi.fn(),
+ onShowDetails: vi.fn(),
+ };
+ const controller = new ConnectionRecoveryNoticeController(callbacks);
+
+ controller.handleState("reconnecting");
+ await vi.advanceTimersByTimeAsync(CONNECTION_NOTICE_DELAY_MS);
+ controller.dismissCurrentOutage();
+ await vi.advanceTimersByTimeAsync(CONNECTION_DETAILS_DELAY_MS);
+ controller.handleState("open");
+ expect(callbacks.onShowDetails).not.toHaveBeenCalled();
+ expect(callbacks.onRecovered).not.toHaveBeenCalled();
+
+ controller.handleState("reconnecting");
+ controller.handleState("open");
+ await vi.advanceTimersByTimeAsync(CONNECTION_DETAILS_DELAY_MS);
+ expect(callbacks.onShow).toHaveBeenCalledOnce();
+ });
+
+ it("formats bounded local diagnostics without project, URL, command, or content fields", () => {
+ const diagnostics = formatConnectionRecoveryDiagnostics({
+ appVersion: "0.5.7",
+ desktopApp: true,
+ generatedAt: new Date("2026-07-21T00:00:12.000Z"),
+ navigatorOnline: true,
+ platform: "Linux x86_64",
+ state: "reconnecting",
+ stateStartedAt: new Date("2026-07-21T00:00:00.000Z"),
+ visibility: "visible",
+ });
+
+ expect(diagnostics).toContain("Transport state: reconnecting");
+ expect(diagnostics).toContain("Elapsed: 12s");
+ expect(diagnostics).toContain("Platform: Linux x86_64");
+ expect(diagnostics).not.toMatch(/project|conversation|command line|websocket url|token/i);
+ });
+
+ it("never reports a negative elapsed duration when clocks move backwards", () => {
+ const diagnostics = formatConnectionRecoveryDiagnostics({
+ appVersion: "0.5.7",
+ desktopApp: false,
+ generatedAt: new Date("2026-07-21T00:00:00.000Z"),
+ navigatorOnline: null,
+ platform: "",
+ state: "connecting",
+ stateStartedAt: new Date("2026-07-21T00:00:03.000Z"),
+ visibility: "",
+ });
+
+ expect(diagnostics).toContain("Elapsed: 0s");
+ expect(diagnostics).toContain("Browser online: unknown");
+ });
+});
diff --git a/apps/web/src/connectionRecoveryNotice.ts b/apps/web/src/connectionRecoveryNotice.ts
new file mode 100644
index 000000000..3d1da28e9
--- /dev/null
+++ b/apps/web/src/connectionRecoveryNotice.ts
@@ -0,0 +1,139 @@
+// FILE: connectionRecoveryNotice.ts
+// Purpose: Owns privacy-safe copy and timing policy for local-service recovery notices.
+// Layer: Web connection recovery presentation logic
+
+import type { WsTransportState } from "./wsTransportEvents";
+
+export const CONNECTION_NOTICE_DELAY_MS = 1_500;
+export const CONNECTION_DETAILS_DELAY_MS = 10_000;
+
+export interface ConnectionRecoveryNoticeCallbacks {
+ readonly onClear: () => void;
+ readonly onRecovered: () => void;
+ readonly onShow: (stateStartedAt: Date) => void;
+ readonly onShowDetails: (stateStartedAt: Date) => void;
+}
+
+export interface ConnectionRecoveryNoticeClock {
+ readonly clearTimeout: (timer: ReturnType) => void;
+ readonly now: () => Date;
+ readonly setTimeout: (callback: () => void, delayMs: number) => ReturnType;
+}
+
+const systemClock: ConnectionRecoveryNoticeClock = {
+ clearTimeout: (timer) => globalThis.clearTimeout(timer),
+ now: () => new Date(),
+ setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
+};
+
+/**
+ * Owns one post-ready reconnect notice. Initial connection remains on the
+ * existing startup surface, and every transition cancels stale timers.
+ */
+export class ConnectionRecoveryNoticeController {
+ readonly #callbacks: ConnectionRecoveryNoticeCallbacks;
+ readonly #clock: ConnectionRecoveryNoticeClock;
+ #detailsTimer: ReturnType | null = null;
+ #dismissed = false;
+ #noticeTimer: ReturnType | null = null;
+ #reconnecting = false;
+ #visible = false;
+
+ constructor(
+ callbacks: ConnectionRecoveryNoticeCallbacks,
+ clock: ConnectionRecoveryNoticeClock = systemClock,
+ ) {
+ this.#callbacks = callbacks;
+ this.#clock = clock;
+ }
+
+ handleState(state: WsTransportState): void {
+ if (state === "reconnecting") {
+ if (this.#reconnecting) return;
+ this.#reset();
+ this.#callbacks.onClear();
+ this.#reconnecting = true;
+ const stateStartedAt = this.#clock.now();
+ this.#noticeTimer = this.#clock.setTimeout(() => {
+ this.#noticeTimer = null;
+ if (!this.#reconnecting || this.#dismissed) return;
+ this.#visible = true;
+ this.#callbacks.onShow(stateStartedAt);
+ }, CONNECTION_NOTICE_DELAY_MS);
+ this.#detailsTimer = this.#clock.setTimeout(() => {
+ this.#detailsTimer = null;
+ if (!this.#reconnecting || this.#dismissed || !this.#visible) return;
+ this.#callbacks.onShowDetails(stateStartedAt);
+ }, CONNECTION_DETAILS_DELAY_MS);
+ return;
+ }
+
+ const shouldAnnounceRecovery = state === "open" && this.#visible && !this.#dismissed;
+ this.#reset();
+ if (shouldAnnounceRecovery) this.#callbacks.onRecovered();
+ else this.#callbacks.onClear();
+ }
+
+ dismissCurrentOutage(): void {
+ if (!this.#reconnecting) return;
+ this.#dismissed = true;
+ this.#visible = false;
+ this.#cancelTimer("details");
+ }
+
+ dispose(): void {
+ this.#reset();
+ this.#callbacks.onClear();
+ }
+
+ #cancelTimer(kind: "details" | "notice"): void {
+ const timer = kind === "details" ? this.#detailsTimer : this.#noticeTimer;
+ if (timer !== null) this.#clock.clearTimeout(timer);
+ if (kind === "details") this.#detailsTimer = null;
+ else this.#noticeTimer = null;
+ }
+
+ #reset(): void {
+ this.#cancelTimer("notice");
+ this.#cancelTimer("details");
+ this.#dismissed = false;
+ this.#reconnecting = false;
+ this.#visible = false;
+ }
+}
+
+export interface ConnectionRecoveryDiagnosticsInput {
+ readonly appVersion: string;
+ readonly desktopApp: boolean;
+ readonly generatedAt: Date;
+ readonly navigatorOnline: boolean | null;
+ readonly platform: string;
+ readonly state: WsTransportState;
+ readonly stateStartedAt: Date;
+ readonly visibility: string;
+}
+
+/**
+ * Produces a bounded local summary that intentionally excludes URLs, paths,
+ * project names, conversation content, process command lines, and credentials.
+ */
+export function formatConnectionRecoveryDiagnostics(
+ input: ConnectionRecoveryDiagnosticsInput,
+): string {
+ const elapsedSeconds = Math.max(
+ 0,
+ Math.round((input.generatedAt.getTime() - input.stateStartedAt.getTime()) / 1_000),
+ );
+ return [
+ "Scient connection diagnostics",
+ `Generated: ${input.generatedAt.toISOString()}`,
+ `App version: ${input.appVersion}`,
+ `Transport state: ${input.state}`,
+ `State started: ${input.stateStartedAt.toISOString()}`,
+ `Elapsed: ${elapsedSeconds}s`,
+ `Platform: ${input.platform || "unknown"}`,
+ `Desktop app: ${input.desktopApp ? "yes" : "no"}`,
+ `Browser online: ${input.navigatorOnline === null ? "unknown" : input.navigatorOnline ? "yes" : "no"}`,
+ `Window visibility: ${input.visibility || "unknown"}`,
+ ].join("\n");
+}
diff --git a/apps/web/src/connectionSupervisor.test.ts b/apps/web/src/connectionSupervisor.test.ts
new file mode 100644
index 000000000..8ed774cfc
--- /dev/null
+++ b/apps/web/src/connectionSupervisor.test.ts
@@ -0,0 +1,328 @@
+// FILE: connectionSupervisor.test.ts
+// Purpose: Locks single-owner connection retry, generation, and wake-probe behavior.
+// Layer: Web transport lifecycle tests
+// Depends on: ConnectionSupervisor and deterministic timers.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { ConnectionSupervisor, type ConnectionSupervisorSession } from "./connectionSupervisor";
+
+interface TestSession {
+ readonly id: number;
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ let reject!: (error: unknown) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, reject, resolve };
+}
+
+function makeHarness(
+ connect: (generation: number, signal: AbortSignal) => Promise = async (
+ generation,
+ ) => ({
+ id: generation,
+ }),
+ timing?: { readonly retryResetAfterMs?: number },
+) {
+ const closed: Array> = [];
+ const ready: Array> = [];
+ const retries: Array<{ attempt: number; delayMs: number; reason: string }> = [];
+ const probe = vi.fn(async () => undefined);
+ const supervisor = new ConnectionSupervisor({
+ connect,
+ close: (session) => {
+ closed.push(session);
+ },
+ probe,
+ random: () => 0.5,
+ ...timing,
+ onReady: (session) => ready.push(session),
+ onRetryScheduled: (retry) => retries.push(retry),
+ });
+ return { closed, probe, ready, retries, supervisor };
+}
+
+beforeEach(() => {
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("ConnectionSupervisor", () => {
+ it("shares one validated generation across concurrent waiters", async () => {
+ const first = deferred();
+ const connect = vi.fn(() => first.promise);
+ const harness = makeHarness(connect);
+
+ const left = harness.supervisor.waitForSession();
+ const right = harness.supervisor.waitForSession();
+ expect(connect).toHaveBeenCalledOnce();
+
+ first.resolve({ id: 10 });
+
+ await expect(left).resolves.toEqual({ generation: 1, value: { id: 10 } });
+ await expect(right).resolves.toEqual({ generation: 1, value: { id: 10 } });
+ expect(harness.ready).toHaveLength(1);
+ expect(harness.supervisor.snapshot.phase).toBe("ready");
+ });
+
+ it("backs off 1, 2, 4, 8, and 16 seconds with one retry owner", async () => {
+ const connect = vi.fn(async () => {
+ throw new Error("offline");
+ });
+ const harness = makeHarness(connect);
+
+ harness.supervisor.start();
+ await vi.advanceTimersByTimeAsync(0);
+ for (const delay of [1_000, 2_000, 4_000, 8_000]) {
+ await vi.advanceTimersByTimeAsync(delay);
+ }
+
+ expect(harness.retries.map(({ delayMs }) => delayMs)).toEqual([
+ 1_000, 2_000, 4_000, 8_000, 16_000,
+ ]);
+ expect(connect).toHaveBeenCalledTimes(5);
+ expect(vi.getTimerCount()).toBe(1);
+ harness.supervisor.dispose();
+ });
+
+ it("never lets positive jitter exceed the configured retry ceiling", async () => {
+ const supervisor = new ConnectionSupervisor({
+ connect: async () => {
+ throw new Error("offline");
+ },
+ close: () => undefined,
+ probe: async () => undefined,
+ random: () => 1,
+ retryBaseDelayMs: 16_000,
+ retryJitterRatio: 0.2,
+ retryMaxDelayMs: 16_000,
+ });
+
+ supervisor.start();
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(supervisor.snapshot.retryDelayMs).toBe(16_000);
+ supervisor.dispose();
+ });
+
+ it("keeps escalating across short-lived ready connections", async () => {
+ const harness = makeHarness();
+ const first = await harness.supervisor.waitForSession();
+
+ harness.supervisor.invalidate(first.generation, "first short-lived socket");
+ await vi.advanceTimersByTimeAsync(1_000);
+ const second = await harness.supervisor.waitForSession();
+ harness.supervisor.invalidate(second.generation, "second short-lived socket");
+
+ expect(harness.retries.map(({ delayMs }) => delayMs)).toEqual([1_000, 2_000]);
+ });
+
+ it("forgives earlier failures after the injectable stable-readiness window", async () => {
+ const harness = makeHarness(undefined, { retryResetAfterMs: 25 });
+ const first = await harness.supervisor.waitForSession();
+
+ harness.supervisor.invalidate(first.generation, "brief outage");
+ await vi.advanceTimersByTimeAsync(1_000);
+ const stable = await harness.supervisor.waitForSession();
+ await vi.advanceTimersByTimeAsync(25);
+ harness.supervisor.invalidate(stable.generation, "later outage");
+
+ expect(harness.retries.map(({ delayMs }) => delayMs)).toEqual([1_000, 1_000]);
+ });
+
+ it("times out a wedged connect and closes its late result", async () => {
+ const pending = deferred();
+ let connectSignal: AbortSignal | undefined;
+ const harness = makeHarness((_generation, signal) => {
+ connectSignal = signal;
+ return pending.promise;
+ });
+
+ harness.supervisor.start();
+ await vi.advanceTimersByTimeAsync(15_000);
+
+ expect(harness.retries).toEqual([
+ {
+ attempt: 0,
+ delayMs: 1_000,
+ reason: "Connection generation 1 timed out after 15000ms",
+ },
+ ]);
+ expect(connectSignal?.aborted).toBe(true);
+
+ pending.resolve({ id: 1 });
+ await vi.advanceTimersByTimeAsync(0);
+ expect(harness.closed).toContainEqual({ generation: 1, value: { id: 1 } });
+ });
+
+ it("bounds the whole replacement attempt while old-session cleanup is still pending", async () => {
+ const closeFinished = deferred();
+ const connect = vi.fn(async (generation: number) => ({ id: generation }));
+ const retries: Array<{ attempt: number; delayMs: number; reason: string }> = [];
+ const supervisor = new ConnectionSupervisor({
+ connect,
+ close: () => closeFinished.promise,
+ closeTimeoutMs: 5_000,
+ connectTimeoutMs: 250,
+ probe: async () => undefined,
+ random: () => 0.5,
+ onRetryScheduled: (retry) => retries.push(retry),
+ });
+ const first = await supervisor.waitForSession();
+
+ supervisor.invalidate(first.generation, "replace");
+ await vi.advanceTimersByTimeAsync(1_250);
+
+ expect(connect).toHaveBeenCalledOnce();
+ expect(retries.at(-1)).toMatchObject({
+ delayMs: 2_000,
+ reason: "Connection generation 2 timed out after 250ms",
+ });
+ supervisor.dispose();
+ closeFinished.resolve();
+ });
+
+ it("settles a caller waiting on an unavailable connection without stopping recovery", async () => {
+ const neverConnects = deferred();
+ const harness = makeHarness(() => neverConnects.promise);
+ const waiting = harness.supervisor.waitForSession({ timeoutMs: 250 });
+ const rejection = expect(waiting).rejects.toThrow("Connection unavailable after 250ms");
+
+ await vi.advanceTimersByTimeAsync(250);
+
+ await rejection;
+ expect(harness.supervisor.snapshot.phase).toBe("connecting");
+ harness.supervisor.dispose();
+ neverConnects.resolve({ id: 1 });
+ });
+
+ it("ignores stale failures after a replacement generation becomes ready", async () => {
+ const harness = makeHarness();
+ const first = await harness.supervisor.waitForSession();
+
+ expect(harness.supervisor.invalidate(first.generation, "socket closed")).toBe(true);
+ await vi.advanceTimersByTimeAsync(1_000);
+ const second = await harness.supervisor.waitForSession();
+
+ expect(second.generation).toBe(2);
+ expect(harness.supervisor.invalidate(first.generation, "late stream exit")).toBe(false);
+ expect(harness.supervisor.currentSession).toEqual(second);
+ expect(harness.retries).toHaveLength(1);
+ });
+
+ it("waits for the old session to close before opening its replacement", async () => {
+ const closeFinished = deferred();
+ const connect = vi.fn(async (generation: number) => ({ id: generation }));
+ const supervisor = new ConnectionSupervisor({
+ connect,
+ close: () => closeFinished.promise,
+ probe: async () => undefined,
+ random: () => 0.5,
+ });
+ const first = await supervisor.waitForSession();
+
+ supervisor.invalidate(first.generation, "socket closed");
+ await vi.advanceTimersByTimeAsync(1_000);
+ expect(connect).toHaveBeenCalledOnce();
+
+ closeFinished.resolve();
+ await vi.advanceTimersByTimeAsync(0);
+ const second = await supervisor.waitForSession();
+ expect(second.generation).toBe(2);
+ expect(connect).toHaveBeenCalledTimes(2);
+ supervisor.dispose();
+ });
+
+ it("recovers after bounded teardown when an old session never disposes", async () => {
+ const neverCloses = deferred();
+ const onError = vi.fn();
+ const connect = vi.fn(async (generation: number) => ({ id: generation }));
+ const supervisor = new ConnectionSupervisor({
+ connect,
+ close: () => neverCloses.promise,
+ closeTimeoutMs: 250,
+ probe: async () => undefined,
+ random: () => 0.5,
+ onError,
+ });
+ const first = await supervisor.waitForSession();
+
+ supervisor.invalidate(first.generation, "socket closed");
+ await vi.advanceTimersByTimeAsync(249);
+ expect(connect).toHaveBeenCalledOnce();
+
+ await vi.advanceTimersByTimeAsync(751);
+ const second = await supervisor.waitForSession();
+ expect(second.generation).toBe(2);
+ expect(connect).toHaveBeenCalledTimes(2);
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: expect.stringContaining("disposal timed out") }),
+ "generation 1 invalidation",
+ );
+
+ supervisor.dispose();
+ neverCloses.resolve();
+ });
+
+ it("probes a ready session and reconnects when the probe fails", async () => {
+ const harness = makeHarness();
+ const first = await harness.supervisor.waitForSession();
+ harness.probe.mockRejectedValueOnce(new Error("stale socket"));
+
+ await harness.supervisor.probe("resume");
+
+ expect(harness.closed).toEqual([first]);
+ expect(harness.supervisor.snapshot).toMatchObject({
+ phase: "reconnecting",
+ retryDelayMs: 1_000,
+ });
+ await harness.supervisor.probe("window focus");
+ const second = await harness.supervisor.waitForSession();
+ expect(second.generation).toBe(2);
+ });
+
+ it("does not let an old generation's probe suppress probing its replacement", async () => {
+ const harness = makeHarness();
+ const first = await harness.supervisor.waitForSession();
+ const oldProbe = deferred();
+ harness.probe.mockImplementationOnce(() => oldProbe.promise).mockResolvedValueOnce(undefined);
+
+ const firstProbe = harness.supervisor.probe("first focus");
+ harness.supervisor.invalidate(first.generation, "stream closed");
+ await vi.advanceTimersByTimeAsync(1_000);
+ const second = await harness.supervisor.waitForSession();
+ await harness.supervisor.probe("second focus");
+
+ expect(second.generation).toBe(2);
+ expect(harness.probe).toHaveBeenCalledTimes(2);
+ oldProbe.resolve(undefined);
+ await firstProbe;
+ });
+
+ it("closes a connect result that arrives after disposal", async () => {
+ const pending = deferred();
+ let connectSignal: AbortSignal | undefined;
+ const harness = makeHarness((_generation, signal) => {
+ connectSignal = signal;
+ return pending.promise;
+ });
+ const waiting = harness.supervisor.waitForSession();
+
+ harness.supervisor.dispose();
+ expect(connectSignal?.aborted).toBe(true);
+ pending.resolve({ id: 1 });
+
+ await expect(waiting).rejects.toThrow("disposed");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(harness.closed).toEqual([{ generation: 1, value: { id: 1 } }]);
+ expect(harness.supervisor.snapshot.phase).toBe("disposed");
+ });
+});
diff --git a/apps/web/src/connectionSupervisor.ts b/apps/web/src/connectionSupervisor.ts
new file mode 100644
index 000000000..dff12c4dc
--- /dev/null
+++ b/apps/web/src/connectionSupervisor.ts
@@ -0,0 +1,422 @@
+// FILE: connectionSupervisor.ts
+// Purpose: Owns one desired browser-to-server connection across retries and wake probes.
+// Layer: Web transport lifecycle
+// Exports: ConnectionSupervisor and its observable lifecycle snapshot.
+
+export type ConnectionSupervisorPhase = "connecting" | "ready" | "reconnecting" | "disposed";
+
+export interface ConnectionSupervisorSession {
+ readonly generation: number;
+ readonly value: T;
+}
+
+export interface ConnectionSupervisorSnapshot {
+ readonly phase: ConnectionSupervisorPhase;
+ readonly generation: number | null;
+ readonly retryAttempt: number;
+ readonly retryDelayMs: number | null;
+}
+
+export interface ConnectionSupervisorOptions {
+ readonly connect: (generation: number, signal: AbortSignal) => Promise;
+ readonly close: (session: ConnectionSupervisorSession) => Promise | void;
+ readonly probe: (session: ConnectionSupervisorSession) => Promise;
+ readonly onReady?: (session: ConnectionSupervisorSession) => void;
+ readonly onInvalidated?: (session: ConnectionSupervisorSession, reason: string) => void;
+ readonly onSnapshot?: (snapshot: ConnectionSupervisorSnapshot) => void;
+ readonly onError?: (error: unknown, context: string) => void;
+ readonly onRetryScheduled?: (input: {
+ readonly attempt: number;
+ readonly delayMs: number;
+ readonly reason: string;
+ }) => void;
+ readonly setTimer?: typeof setTimeout;
+ readonly clearTimer?: typeof clearTimeout;
+ readonly random?: () => number;
+ readonly retryBaseDelayMs?: number;
+ readonly retryMaxDelayMs?: number;
+ readonly retryJitterRatio?: number;
+ /** Maximum duration of one complete connection creation attempt. */
+ readonly connectTimeoutMs?: number;
+ /** Healthy time required before prior retry failures are forgiven. */
+ readonly retryResetAfterMs?: number;
+ /**
+ * Maximum time replacement creation waits for an old session to dispose.
+ * A timed-out session remains stale by generation and may finish disposing in
+ * the background, but it cannot indefinitely block recovery.
+ */
+ readonly closeTimeoutMs?: number;
+}
+
+interface SessionWaiter {
+ readonly resolve: (session: ConnectionSupervisorSession) => void;
+ readonly reject: (error: Error) => void;
+}
+
+const DEFAULT_RETRY_BASE_DELAY_MS = 1_000;
+const DEFAULT_RETRY_MAX_DELAY_MS = 16_000;
+const DEFAULT_RETRY_JITTER_RATIO = 0.2;
+const DEFAULT_CLOSE_TIMEOUT_MS = 5_000;
+const DEFAULT_CONNECT_TIMEOUT_MS = 15_000;
+const DEFAULT_RETRY_RESET_AFTER_MS = 30_000;
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+/**
+ * Serializes connection creation, invalidation, retry, and wake probing. Callers
+ * may report the same broken generation more than once; only the current generation
+ * can change state, so late stream exits cannot replace a healthy session.
+ */
+export class ConnectionSupervisor {
+ readonly #options: ConnectionSupervisorOptions;
+ readonly #setTimer: typeof setTimeout;
+ readonly #clearTimer: typeof clearTimeout;
+ readonly #random: () => number;
+
+ #desiredRunning = false;
+ #disposed = false;
+ #generation = 0;
+ #active: ConnectionSupervisorSession | null = null;
+ #connectInFlight: Promise | null = null;
+ #connectAbort: { readonly generation: number; readonly controller: AbortController } | null =
+ null;
+ #closeInFlight: Promise | null = null;
+ #probeInFlight: { readonly generation: number; readonly promise: Promise } | null = null;
+ #retryTimer: ReturnType | null = null;
+ #retryResetTimer: ReturnType | null = null;
+ #retryAttempt = 0;
+ #hasBeenReady = false;
+ #snapshot: ConnectionSupervisorSnapshot = {
+ phase: "connecting",
+ generation: null,
+ retryAttempt: 0,
+ retryDelayMs: null,
+ };
+ readonly #waiters = new Set>();
+
+ constructor(options: ConnectionSupervisorOptions) {
+ this.#options = options;
+ this.#setTimer = options.setTimer ?? globalThis.setTimeout.bind(globalThis);
+ this.#clearTimer = options.clearTimer ?? globalThis.clearTimeout.bind(globalThis);
+ this.#random = options.random ?? Math.random;
+ }
+
+ get snapshot(): ConnectionSupervisorSnapshot {
+ return this.#snapshot;
+ }
+
+ get currentSession(): ConnectionSupervisorSession | null {
+ return this.#active;
+ }
+
+ start(): void {
+ if (this.#disposed) return;
+ this.#desiredRunning = true;
+ if (!this.#active && !this.#connectInFlight && !this.#retryTimer) {
+ this.#beginConnect();
+ }
+ }
+
+ waitForSession(options?: {
+ readonly timeoutMs?: number;
+ }): Promise> {
+ if (this.#disposed) {
+ return Promise.reject(new Error("Connection supervisor disposed"));
+ }
+ if (this.#active) return Promise.resolve(this.#active);
+ this.start();
+ return new Promise((resolve, reject) => {
+ let timeout: ReturnType | null = null;
+ const waiter: SessionWaiter = {
+ resolve: (session) => {
+ if (timeout !== null) this.#clearTimer(timeout);
+ this.#waiters.delete(waiter);
+ resolve(session);
+ },
+ reject: (error) => {
+ if (timeout !== null) this.#clearTimer(timeout);
+ this.#waiters.delete(waiter);
+ reject(error);
+ },
+ };
+ this.#waiters.add(waiter);
+ const timeoutMs = options?.timeoutMs;
+ if (timeoutMs !== undefined) {
+ timeout = this.#setTimer(
+ () => {
+ waiter.reject(new Error(`Connection unavailable after ${Math.max(0, timeoutMs)}ms`));
+ },
+ Math.max(0, timeoutMs),
+ );
+ }
+ });
+ }
+
+ invalidate(generation: number, reason: string): boolean {
+ const active = this.#active;
+ if (this.#disposed || !active || active.generation !== generation) return false;
+
+ this.#active = null;
+ this.#clearRetryResetTimer();
+ this.#options.onInvalidated?.(active, reason);
+ this.#close(active, `generation ${generation} invalidation`);
+ this.#scheduleRetry(reason);
+ return true;
+ }
+
+ probe(reason: string): Promise {
+ if (this.#disposed) return Promise.resolve();
+ this.start();
+ if (!this.#active) {
+ this.#retryNow();
+ return this.#connectInFlight ?? Promise.resolve();
+ }
+ if (this.#probeInFlight?.generation === this.#active.generation) {
+ return this.#probeInFlight.promise;
+ }
+
+ const session = this.#active;
+ const probe = this.#options
+ .probe(session)
+ .catch((error: unknown) => {
+ if (this.#active?.generation !== session.generation) return;
+ this.#options.onError?.(error, `generation ${session.generation} wake probe`);
+ this.invalidate(session.generation, `${reason}: ${errorMessage(error)}`);
+ })
+ .finally(() => {
+ if (this.#probeInFlight?.promise === probe) this.#probeInFlight = null;
+ });
+ this.#probeInFlight = { generation: session.generation, promise: probe };
+ return probe;
+ }
+
+ dispose(): void {
+ if (this.#disposed) return;
+ this.#disposed = true;
+ this.#desiredRunning = false;
+ this.#clearRetryTimer();
+ this.#clearRetryResetTimer();
+ this.#connectAbort?.controller.abort(new Error("Connection supervisor disposed"));
+ this.#connectAbort = null;
+ this.#generation += 1;
+
+ const active = this.#active;
+ this.#active = null;
+ if (active) {
+ this.#options.onInvalidated?.(active, "disposed");
+ this.#close(active, `generation ${active.generation} disposal`);
+ }
+ const error = new Error("Connection supervisor disposed");
+ for (const waiter of this.#waiters) waiter.reject(error);
+ this.#waiters.clear();
+ this.#publish({
+ phase: "disposed",
+ generation: null,
+ retryAttempt: this.#retryAttempt,
+ retryDelayMs: null,
+ });
+ }
+
+ #beginConnect(): void {
+ if (this.#disposed || !this.#desiredRunning || this.#active || this.#connectInFlight) {
+ return;
+ }
+ this.#clearRetryTimer();
+ const generation = ++this.#generation;
+ const controller = new AbortController();
+ this.#connectAbort = { generation, controller };
+ this.#publish({
+ phase: this.#hasBeenReady ? "reconnecting" : "connecting",
+ generation,
+ retryAttempt: this.#retryAttempt,
+ retryDelayMs: null,
+ });
+
+ const connectResult = this.#connectWithTimeout(generation, controller);
+ const connecting = connectResult
+ .then((value) => {
+ const session = { generation, value } satisfies ConnectionSupervisorSession;
+ if (this.#disposed || !this.#desiredRunning || generation !== this.#generation) {
+ this.#close(session, `stale generation ${generation}`);
+ return;
+ }
+ this.#active = session;
+ this.#hasBeenReady = true;
+ this.#publish({
+ phase: "ready",
+ generation,
+ retryAttempt: this.#retryAttempt,
+ retryDelayMs: null,
+ });
+ this.#armRetryReset(session);
+ for (const waiter of this.#waiters) waiter.resolve(session);
+ this.#waiters.clear();
+ this.#options.onReady?.(session);
+ })
+ .catch((error: unknown) => {
+ if (this.#disposed || !this.#desiredRunning || generation !== this.#generation) return;
+ this.#options.onError?.(error, `generation ${generation} connect`);
+ this.#scheduleRetry(errorMessage(error));
+ })
+ .finally(() => {
+ if (this.#connectAbort?.generation === generation) this.#connectAbort = null;
+ if (this.#connectInFlight === connecting) {
+ this.#connectInFlight = null;
+ if (!this.#disposed && this.#desiredRunning && !this.#active && !this.#retryTimer) {
+ this.#beginConnect();
+ }
+ }
+ });
+ this.#connectInFlight = connecting;
+ }
+
+ async #connectWithTimeout(generation: number, controller: AbortController): Promise {
+ const timeoutMs = Math.max(0, this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);
+ let acceptResult = true;
+ let timeout: ReturnType | null = null;
+ let removeAbortListener: () => void = () => undefined;
+ const connect = (async () => {
+ if (this.#closeInFlight) await this.#closeInFlight;
+ if (controller.signal.aborted) throw controller.signal.reason;
+ return this.#options.connect(generation, controller.signal);
+ })().then((value) => {
+ if (!acceptResult || controller.signal.aborted) {
+ this.#close({ generation, value }, `late abandoned generation ${generation}`);
+ throw (
+ controller.signal.reason ?? new Error(`Connection generation ${generation} abandoned`)
+ );
+ }
+ return value;
+ });
+ const aborted = new Promise((_, reject) => {
+ const onAbort = () =>
+ reject(
+ controller.signal.reason ?? new Error(`Connection generation ${generation} aborted`),
+ );
+ if (controller.signal.aborted) {
+ onAbort();
+ return;
+ }
+ controller.signal.addEventListener("abort", onAbort, { once: true });
+ removeAbortListener = () => controller.signal.removeEventListener("abort", onAbort);
+ });
+ timeout = this.#setTimer(() => {
+ controller.abort(
+ new Error(`Connection generation ${generation} timed out after ${timeoutMs}ms`),
+ );
+ }, timeoutMs);
+ try {
+ return await Promise.race([connect, aborted]);
+ } finally {
+ acceptResult = false;
+ removeAbortListener();
+ if (timeout !== null) this.#clearTimer(timeout);
+ }
+ }
+
+ #armRetryReset(session: ConnectionSupervisorSession): void {
+ this.#clearRetryResetTimer();
+ if (this.#retryAttempt === 0) return;
+ const delayMs = Math.max(0, this.#options.retryResetAfterMs ?? DEFAULT_RETRY_RESET_AFTER_MS);
+ this.#retryResetTimer = this.#setTimer(() => {
+ this.#retryResetTimer = null;
+ if (this.#disposed || this.#active?.generation !== session.generation) return;
+ this.#retryAttempt = 0;
+ this.#publish({
+ phase: "ready",
+ generation: session.generation,
+ retryAttempt: 0,
+ retryDelayMs: null,
+ });
+ }, delayMs);
+ }
+
+ #scheduleRetry(reason: string): void {
+ if (this.#disposed || !this.#desiredRunning || this.#retryTimer) return;
+ const attempt = this.#retryAttempt;
+ const baseDelay = this.#options.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS;
+ const maxDelay = this.#options.retryMaxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS;
+ const jitterRatio = Math.max(
+ 0,
+ Math.min(this.#options.retryJitterRatio ?? DEFAULT_RETRY_JITTER_RATIO, 1),
+ );
+ const exponentialDelay = Math.min(baseDelay * 2 ** attempt, maxDelay);
+ const jitterMultiplier = 1 + (this.#random() * 2 - 1) * jitterRatio;
+ const delayMs = Math.min(
+ maxDelay,
+ Math.max(0, Math.round(exponentialDelay * jitterMultiplier)),
+ );
+ this.#retryAttempt += 1;
+ this.#publish({
+ phase: this.#hasBeenReady ? "reconnecting" : "connecting",
+ generation: null,
+ retryAttempt: this.#retryAttempt,
+ retryDelayMs: delayMs,
+ });
+ this.#options.onRetryScheduled?.({ attempt, delayMs, reason });
+ this.#retryTimer = this.#setTimer(() => {
+ this.#retryTimer = null;
+ this.#beginConnect();
+ }, delayMs);
+ }
+
+ #retryNow(): void {
+ if (this.#disposed || !this.#desiredRunning || this.#active) return;
+ if (this.#retryTimer) {
+ this.#clearRetryTimer();
+ }
+ this.#beginConnect();
+ }
+
+ #clearRetryTimer(): void {
+ if (!this.#retryTimer) return;
+ this.#clearTimer(this.#retryTimer);
+ this.#retryTimer = null;
+ }
+
+ #clearRetryResetTimer(): void {
+ if (!this.#retryResetTimer) return;
+ this.#clearTimer(this.#retryResetTimer);
+ this.#retryResetTimer = null;
+ }
+
+ #close(session: ConnectionSupervisorSession, context: string): void {
+ const previousClose = this.#closeInFlight ?? Promise.resolve();
+ const closing = previousClose
+ .then(() => this.#closeWithTimeout(session))
+ .catch((error: unknown) => {
+ this.#options.onError?.(error, context);
+ })
+ .finally(() => {
+ if (this.#closeInFlight === closing) this.#closeInFlight = null;
+ });
+ this.#closeInFlight = closing;
+ }
+
+ async #closeWithTimeout(session: ConnectionSupervisorSession): Promise {
+ const timeoutMs = Math.max(0, this.#options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS);
+ let timeout: ReturnType | null = null;
+ const close = Promise.resolve().then(() => this.#options.close(session));
+ const timedOut = new Promise