| 1 |
import assert from "node:assert/strict"; |
| 2 |
import { test } from "node:test"; |
| 3 |
|
| 4 |
import { |
| 5 |
MAX_RECONNECT_ATTEMPTS, |
| 6 |
MAX_RECONNECT_DELAY_MS, |
| 7 |
ReconnectPolicy, |
| 8 |
reconnectDelayMs, |
| 9 |
} from "./reconnect.logic.ts"; |
| 10 |
|
| 11 |
/** Pin the jitter to the middle of its range, giving the base delay exactly. */ |
| 12 |
const noJitter = () => 0.5; |
| 13 |
|
| 14 |
test("delay doubles per attempt", () => { |
| 15 |
assert.equal(reconnectDelayMs(0, noJitter), 1_000); |
| 16 |
assert.equal(reconnectDelayMs(1, noJitter), 2_000); |
| 17 |
assert.equal(reconnectDelayMs(2, noJitter), 4_000); |
| 18 |
assert.equal(reconnectDelayMs(3, noJitter), 8_000); |
| 19 |
}); |
| 20 |
|
| 21 |
test("delay is capped", () => { |
| 22 |
assert.equal(reconnectDelayMs(6, noJitter), MAX_RECONNECT_DELAY_MS); |
| 23 |
assert.equal(reconnectDelayMs(20, noJitter), MAX_RECONNECT_DELAY_MS); |
| 24 |
// The exponent ceiling exists so a large attempt count cannot overflow into |
| 25 |
// Infinity and produce a NaN delay. |
| 26 |
assert.equal(reconnectDelayMs(1e6, noJitter), MAX_RECONNECT_DELAY_MS); |
| 27 |
assert.ok(Number.isFinite(reconnectDelayMs(1e6, noJitter))); |
| 28 |
}); |
| 29 |
|
| 30 |
test("jitter stays within twenty percent either side", () => { |
| 31 |
for (const attempt of [0, 1, 2, 3, 4, 5, 6, 10]) { |
| 32 |
const base = Math.min(1000 * 2 ** Math.min(attempt, 6), MAX_RECONNECT_DELAY_MS); |
| 33 |
for (const r of [0, 0.25, 0.5, 0.75, 0.999]) { |
| 34 |
const delay = reconnectDelayMs(attempt, () => r); |
| 35 |
assert.ok( |
| 36 |
delay >= base * 0.8 - 1 && delay <= base * 1.2 + 1, |
| 37 |
`attempt ${attempt} with random ${r} gave ${delay}, outside 20% of ${base}`, |
| 38 |
); |
| 39 |
} |
| 40 |
} |
| 41 |
}); |
| 42 |
|
| 43 |
test("jitter never pushes the delay past the documented cap", () => { |
| 44 |
// +20% on a capped base is ~72s; the result must still honor MAX_RECONNECT_DELAY_MS. |
| 45 |
for (const attempt of [6, 10, 20, 1e6]) { |
| 46 |
assert.equal(reconnectDelayMs(attempt, () => 0.999), MAX_RECONNECT_DELAY_MS); |
| 47 |
} |
| 48 |
}); |
| 49 |
|
| 50 |
test("jitter actually spreads clients out", () => { |
| 51 |
// The point of jitter is that a fleet reconnecting after one deploy does not |
| 52 |
// arrive in lockstep. Identical delays would defeat it. |
| 53 |
const low = reconnectDelayMs(3, () => 0); |
| 54 |
const high = reconnectDelayMs(3, () => 0.999); |
| 55 |
assert.ok(high - low > 1_000, `spread was only ${high - low}ms`); |
| 56 |
}); |
| 57 |
|
| 58 |
test("delay is never negative", () => { |
| 59 |
assert.ok(reconnectDelayMs(0, () => 0) >= 0); |
| 60 |
}); |
| 61 |
|
| 62 |
test("policy backs off then gives up", () => { |
| 63 |
const policy = new ReconnectPolicy(noJitter); |
| 64 |
|
| 65 |
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) { |
| 66 |
const action = policy.fail(); |
| 67 |
assert.equal(action.kind, "retry", `attempt ${i} should still retry`); |
| 68 |
} |
| 69 |
|
| 70 |
assert.equal(policy.fail().kind, "give-up"); |
| 71 |
}); |
| 72 |
|
| 73 |
test("a success resets the streak", () => { |
| 74 |
// A long-lived tab reconnects fine at every deploy. It must never accumulate |
| 75 |
// its way to the give-up ceiling across weeks of successful reconnects. |
| 76 |
const policy = new ReconnectPolicy(noJitter); |
| 77 |
|
| 78 |
for (let deploy = 0; deploy < 50; deploy++) { |
| 79 |
const action = policy.fail(); |
| 80 |
assert.equal(action.kind, "retry"); |
| 81 |
assert.equal(action.kind === "retry" && action.delayMs, 1_000, "always the first-step delay"); |
| 82 |
policy.succeed(); |
| 83 |
} |
| 84 |
|
| 85 |
assert.equal(policy.attempt, 0); |
| 86 |
}); |
| 87 |
|
| 88 |
test("delays escalate across consecutive failures", () => { |
| 89 |
const policy = new ReconnectPolicy(noJitter); |
| 90 |
const delays: number[] = []; |
| 91 |
for (let i = 0; i < 4; i++) { |
| 92 |
const action = policy.fail(); |
| 93 |
if (action.kind === "retry") delays.push(action.delayMs); |
| 94 |
} |
| 95 |
assert.deepEqual(delays, [1_000, 2_000, 4_000, 8_000]); |
| 96 |
}); |
| 97 |
|
| 98 |
test("regaining focus or network skips the accumulated wait", () => { |
| 99 |
// Waiting out a 60s backoff after the laptop opens makes chat look broken for |
| 100 |
// a minute for a reason that has nothing to do with the server. |
| 101 |
const policy = new ReconnectPolicy(noJitter); |
| 102 |
for (let i = 0; i < 5; i++) policy.fail(); |
| 103 |
assert.ok(policy.attempt > 0); |
| 104 |
|
| 105 |
policy.resetForImmediateRetry(); |
| 106 |
|
| 107 |
assert.equal(policy.attempt, 0); |
| 108 |
const action = policy.fail(); |
| 109 |
assert.equal(action.kind === "retry" && action.delayMs, 1_000); |
| 110 |
}); |
| 111 |
|
| 112 |
test("giving up is sticky until something resets it", () => { |
| 113 |
const policy = new ReconnectPolicy(noJitter); |
| 114 |
for (let i = 0; i < MAX_RECONNECT_ATTEMPTS; i++) policy.fail(); |
| 115 |
|
| 116 |
assert.equal(policy.fail().kind, "give-up"); |
| 117 |
assert.equal(policy.fail().kind, "give-up", "still given up"); |
| 118 |
|
| 119 |
policy.resetForImmediateRetry(); |
| 120 |
assert.equal(policy.fail().kind, "retry", "user action can revive it"); |
| 121 |
}); |
| 122 |
|