mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## 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>
512 lines
16 KiB
JavaScript
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",
|
|
);
|
|
});
|