Files
buzz/desktop/src/shared/api/relayReconnectController.test.mjs
T
499c5d349d fix(relay): preserve reconnect backoff (#2759)
## Why
Manual relay recovery polls every three seconds, repeatedly consuming
the session's exponential-backoff timer and leaving reconnect behavior
stuck or noisy on degraded networks.

## What
- Replace fixed-cadence Phase 3 polling with observation of the
RelayClient background reconnect loop
- Raise the fast-path deadline above the native websocket timeout and
enforce that contract in a regression test
- Keep the existing 120-second backstop as a soft UI timeout without
stopping background retries

## Risk Assessment
Medium — this changes live relay recovery timing, but removes a
competing retry loop rather than adding one. The existing
connection-state subscription remains the success signal.

## References
- Stacked on #2310 (`lazyjoe/reconnect-testability-refactor`)
- Investigation: `RESEARCH/BUG_RELAY_RECONNECT_HANG.md`
- `just ci`
- `cd desktop && pnpm typecheck && pnpm check && pnpm test` (3485 pass)
- `git diff --check`

Generated with Codex

Signed-off-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1x4hk035p3p9q39a3fcrd2fe30lpkrhr5dwe0cqzzjphxyyh8m0gsq4vqap <356f67c681884a0897b14e06d527317fc361dc746bb2fc0042906e6212e7dbd1@sprout-oss.stage.blox.sqprod.co>
2026-07-25 10:00:47 -07:00

512 lines
16 KiB
JavaScript

/**
* Unit tests for RelayReconnectController.
*
* Tests the production controller class with injected dependencies — no
* React, no Tauri, no DOM required. Covers the three-phase strategy,
* cancellation token correctness, single-flight guard, and state transitions.
*/
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import test, { mock } from "node:test";
import { fileURLToPath } from "node:url";
import {
DEFAULT_RECONNECT_TIMING_POLICY,
RelayReconnectController,
} from "./relayReconnectController.ts";
// ── Dep builder helpers ───────────────────────────────────────────────────────
/** Build a minimal deps object with controllable preconnect behaviour. */
function makeDeps({
preconnectResult = async () => {},
hookConfiguredResult = async () => false,
runHookResult = async () => {},
connectionStateListener = null,
} = {}) {
const timers = [];
let timerSeq = 0;
const deps = {
preconnect: mock.fn(preconnectResult),
hookConfigured: mock.fn(hookConfiguredResult),
runHook: mock.fn(runHookResult),
subscribeToConnectionState: mock.fn((listener) => {
if (connectionStateListener) connectionStateListener.set(listener);
return () => {};
}),
onSuccess: mock.fn(),
onBackstop: mock.fn(),
setTimeout: mock.fn((fn, ms) => {
const id = ++timerSeq;
timers.push({ id, fn, ms });
return id;
}),
clearTimeout: mock.fn((id) => {
const idx = timers.findIndex((t) => t.id === id);
if (idx !== -1) timers.splice(idx, 1);
}),
// Test helper to fire timers.
_fireTimer: (id) => timers.find((t) => t.id === id)?.fn(),
_timers: timers,
};
return deps;
}
// ── Timing policy ─────────────────────────────────────────────────────────────
const testDir = path.dirname(fileURLToPath(import.meta.url));
test("fast path outlives the native websocket connection timeout", () => {
const rustSource = readFileSync(
path.resolve(testDir, "../../../src-tauri/src/native_websocket.rs"),
"utf8",
);
const match = rustSource.match(
/const CONNECT_TIMEOUT: Duration = Duration::from_secs\((\d+)\);/,
);
assert.ok(match, "native websocket connect timeout is declared in seconds");
const nativeConnectTimeoutMs = Number(match[1]) * 1_000;
assert.ok(
DEFAULT_RECONNECT_TIMING_POLICY.fastPathTimeoutMs > nativeConnectTimeoutMs,
"fast path must wait for the native websocket attempt to settle",
);
});
test("default timing policy preserves current reconnect timings", () => {
assert.deepEqual(DEFAULT_RECONNECT_TIMING_POLICY, {
fastPathTimeoutMs: 11_000,
backstopMs: 120_000,
});
});
test("injected timing policy drives fast-path and backstop timers", async () => {
const ctrl = new RelayReconnectController({
fastPathTimeoutMs: 11,
backstopMs: 33,
});
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("relay unreachable");
},
hookConfiguredResult: async () => false,
});
await ctrl.start(deps);
assert.equal(deps.setTimeout.mock.calls[0].arguments[1], 11);
assert.equal(deps.setTimeout.mock.calls[1].arguments[1], 33);
});
// ── Phase 1: fast path ────────────────────────────────────────────────────────
test("fast-path success — hook never invoked, onSuccess fires, state resets", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({ preconnectResult: async () => {} });
const result = await ctrl.start(deps);
assert.equal(result, true, "start() returns true on fast-path success");
assert.equal(deps.hookConfigured.mock.calls.length, 0, "hook not queried");
assert.equal(deps.runHook.mock.calls.length, 0, "hook not run");
assert.equal(deps.onSuccess.mock.calls.length, 1, "onSuccess fired");
assert.deepEqual(ctrl.getState(), {
isPending: false,
isWaitingOnReconnectHook: false,
});
});
test("fast-path success — no backstop scheduled", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({ preconnectResult: async () => {} });
await ctrl.start(deps);
// The fast-path withDeadline schedules one timeout for the deadline, but
// that is cleared by finally(). After success no backstop should remain.
assert.equal(
deps._timers.length,
0,
"no outstanding timers after fast-path win",
);
});
// ── Phase 2: escalation ───────────────────────────────────────────────────────
test("escalation fires only when fast path fails and hook is configured", async () => {
const ctrl = new RelayReconnectController();
let callIdx = 0;
const deps = makeDeps({
preconnectResult: async () => {
// First call (fast path) fails.
if (++callIdx === 1) throw new Error("relay unreachable");
},
hookConfiguredResult: async () => true,
});
// Don't await — it enters phase 3 and returns false.
const promise = ctrl.start(deps);
// Let the async phases run.
await promise;
assert.equal(
deps.hookConfigured.mock.calls.length,
1,
"hook-configured checked once",
);
assert.equal(deps.runHook.mock.calls.length, 1, "hook run once");
});
test("escalation skipped when hook not configured", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("fail");
},
hookConfiguredResult: async () => false,
});
await ctrl.start(deps);
assert.equal(
deps.runHook.mock.calls.length,
0,
"hook not run when not configured",
);
});
test("hook failure is non-fatal — background wait still starts", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("fail");
},
hookConfiguredResult: async () => true,
runHookResult: async () => {
throw new Error("hook blew up");
},
});
// Should not throw.
let threw = false;
try {
await ctrl.start(deps);
} catch {
threw = true;
}
assert.equal(threw, false, "hook failure does not propagate");
assert.ok(deps._timers.length > 0, "phase 3 backstop scheduled");
});
// ── Phase 3: wait for background reconnect ───────────────────────────────────
test("connection-state emitter fires onSuccess and cancels backstop", async () => {
let capturedListener = null;
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("fail");
},
hookConfiguredResult: async () => false,
connectionStateListener: {
set: (l) => {
capturedListener = l;
},
},
});
await ctrl.start(deps);
// Emitter fires "connected" — should resolve without waiting for backstop.
assert.ok(capturedListener !== null, "connection-state listener registered");
capturedListener("connected");
assert.equal(
deps.onSuccess.mock.calls.length,
1,
"onSuccess fired via emitter",
);
assert.deepEqual(ctrl.getState(), {
isPending: false,
isWaitingOnReconnectHook: false,
});
assert.equal(deps._timers.length, 0, "backstop cancelled");
});
test("backstop fires onBackstop, not onSuccess, and resets state", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("always fails");
},
hookConfiguredResult: async () => false,
});
await ctrl.start(deps);
// Locate and fire the backstop timer (largest ms).
const backstopTimer = deps._timers.reduce(
(max, t) => (t.ms >= max.ms ? t : max),
{ ms: 0, id: -1, fn: null },
);
assert.ok(backstopTimer.id !== -1, "backstop timer exists");
backstopTimer.fn();
assert.equal(deps.onBackstop.mock.calls.length, 1, "onBackstop called");
assert.equal(deps.onSuccess.mock.calls.length, 0, "onSuccess NOT called");
assert.deepEqual(ctrl.getState(), {
isPending: false,
isWaitingOnReconnectHook: false,
});
});
// ── Cancellation token ────────────────────────────────────────────────────────
test("cancellation token — superseded attempt does not call onSuccess", async () => {
const ctrl = new RelayReconnectController();
let preconnectCallCount = 0;
let resolveFirstPreconnect;
const deps = makeDeps({
preconnectResult: async () => {
preconnectCallCount++;
if (preconnectCallCount === 1) {
// First fast-path call: stall so we can cancel mid-flight.
await new Promise((resolve) => {
resolveFirstPreconnect = resolve;
});
throw new Error("cancelled");
}
},
hookConfiguredResult: async () => false,
});
// Start attempt #1 — stalls in fast path.
const attempt1 = ctrl.start(deps);
// Cancel the attempt before the stalled preconnect resolves.
ctrl.cancel();
// Now resolve the stalled promise — it should be ignored.
resolveFirstPreconnect?.();
await attempt1;
// Start attempt #2 — clean state.
const deps2 = makeDeps({ preconnectResult: async () => {} });
const result2 = await ctrl.start(deps2);
assert.equal(result2, true, "second attempt succeeds on fast path");
assert.equal(
deps2.onSuccess.mock.calls.length,
1,
"only second attempt's onSuccess fires",
);
assert.equal(
deps.onSuccess.mock.calls.length,
0,
"first attempt's onSuccess never fires",
);
});
test("cancel mid fast-path — preconnect resolves SUCCESSFULLY after cancel but onSuccess never fires", async () => {
const ctrl = new RelayReconnectController();
let resolvePreconnect;
const deps = makeDeps({
// preconnect stalls then SUCCEEDS — worst case: cancel races a fast-path win.
preconnectResult: () =>
new Promise((resolve) => {
resolvePreconnect = resolve;
}),
hookConfiguredResult: async () => false,
});
const attempt = ctrl.start(deps);
// Cancel while fast-path is in flight.
ctrl.cancel();
// Let preconnect resolve successfully after cancel.
resolvePreconnect?.();
await attempt;
assert.equal(
deps.onSuccess.mock.calls.length,
0,
"onSuccess never fires after cancel",
);
assert.deepEqual(
ctrl.getState(),
{ isPending: false, isWaitingOnReconnectHook: false },
"state is idle",
);
assert.equal(deps._timers.length, 0, "no timers outstanding after cancel");
});
test("last subscriber unsubscribe cancels in-flight attempt", async () => {
const ctrl = new RelayReconnectController();
let resolvePreconnect;
const deps = makeDeps({
preconnectResult: () =>
new Promise((resolve) => {
resolvePreconnect = resolve;
}),
hookConfiguredResult: async () => false,
});
// Two subscribers.
const unsub1 = ctrl.subscribe(() => {});
const unsub2 = ctrl.subscribe(() => {});
const attempt = ctrl.start(deps);
// First unsub — still one subscriber, should NOT cancel.
unsub1();
assert.equal(
ctrl.getState().isPending,
true,
"still pending after first unsub",
);
// Last unsub — should cancel.
unsub2();
assert.equal(ctrl.getState().isPending, false, "cancelled after last unsub");
// Let preconnect resolve successfully — should be ignored.
resolvePreconnect?.();
await attempt;
assert.equal(
deps.onSuccess.mock.calls.length,
0,
"onSuccess never fires after last-subscriber cancel",
);
});
// ── Single-flight guard ───────────────────────────────────────────────────────
test("second start() while first is pending returns false immediately", async () => {
const ctrl = new RelayReconnectController();
let resolvePreconnect;
const deps = makeDeps({
preconnectResult: () =>
new Promise((resolve) => {
resolvePreconnect = resolve;
}),
});
const deps2 = makeDeps({ preconnectResult: async () => {} });
// Start attempt #1 — stalls in fast path.
const attempt1 = ctrl.start(deps);
// Second start should be rejected immediately.
const result2 = await ctrl.start(deps2);
assert.equal(
result2,
false,
"second start returns false while first pending",
);
assert.equal(
deps2.preconnect.mock.calls.length,
0,
"second attempt never calls preconnect",
);
// Finish the first attempt.
resolvePreconnect?.();
await attempt1;
});
// ── State listener ────────────────────────────────────────────────────────────
test("subscribers receive state transitions and can unsubscribe", async () => {
const ctrl = new RelayReconnectController();
const states = [];
const unsub = ctrl.subscribe((s) => states.push({ ...s }));
const deps = makeDeps({ preconnectResult: async () => {} });
await ctrl.start(deps);
unsub();
// Should have seen: initial idle, pending=true, pending=false (success).
assert.ok(states.length >= 2, "at least 2 state transitions observed");
assert.equal(
states[states.length - 1].isPending,
false,
"last state is not pending",
);
// After unsubscribe, no further updates.
const countBefore = states.length;
const deps2 = makeDeps({ preconnectResult: async () => {} });
await ctrl.start(deps2);
assert.equal(states.length, countBefore, "no updates after unsubscribe");
});
// ── OSS build boundary ────────────────────────────────────────────────────────
test("OSS build (hookConfigured returns false) — runHook never called", async () => {
const ctrl = new RelayReconnectController();
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("relay unreachable");
},
hookConfiguredResult: async () => false,
});
await ctrl.start(deps);
assert.equal(
deps.runHook.mock.calls.length,
0,
"runHook not called in OSS build",
);
});
// ── Synchronous connected emission ───────────────────────────────────────────
test("sync connected emission — onSuccess fires once, no backstop installed, subscription cleaned up", async () => {
const ctrl = new RelayReconnectController();
// subscribeToConnectionState fake that invokes the listener synchronously
// with "connected" BEFORE returning the cleanup handle. This models the
// production subscribeToConnectionState documented behaviour: it fires the
// listener with the current state before returning.
let cleanupCalled = false;
const deps = makeDeps({
preconnectResult: async () => {
throw new Error("relay unreachable");
},
hookConfiguredResult: async () => false,
});
// Override subscribeToConnectionState with the synchronous-emission fake.
deps.subscribeToConnectionState = mock.fn((listener) => {
// Invoke immediately — simulates "already connected" at subscribe time.
listener("connected");
// Return cleanup handle (production unsubscribe fn).
const cleanup = () => {
cleanupCalled = true;
};
return cleanup;
});
await ctrl.start(deps);
assert.equal(
deps.onSuccess.mock.calls.length,
1,
"onSuccess fires exactly once",
);
assert.equal(deps._timers.length, 0, "no backstop timer installed");
assert.equal(cleanupCalled, true, "subscription cleanup handle was called");
assert.deepEqual(
ctrl.getState(),
{ isPending: false, isWaitingOnReconnectHook: false },
"state is idle after sync success",
);
});