test(relay): ratchet every armed pool timer, not just the drain retry

Teardown here is a hand-maintained list of verbs against three helper classes that
each spell "disarm" differently, and that is the whole defect: `drainRetry.reset()`
sat beside `rotation.cancel()` looking symmetric while only zeroing a counter. Every
behavioural test stayed green, because the fire-time guard meant nothing reopened —
the leak was a retained timer with no behaviour at all. Counting timers is the only
assertion that can see it.

Turns the single drain-retry case into a table covering every timer the pool and its
broker can leave armed, each reached the way production reaches it: control rotation
and lease refresh (armed by openInitial and connect), the drain retry (armed by a
director refusal), and an origin retirement grace (armed by a rotation to a different
cell, with something still answering through the old origin so it is not retired on
the spot). Each case asserts something was actually armed first, so a case that armed
nothing cannot pass as 0 === 0 — which is how the original leak stayed invisible.

Mutation-verified per verb: dropping `rotation.cancel()` fails all three; reverting
`drainRetry.cancel()` to `reset()` fails only the drain-retry case; dropping
`retirement.clear()` fails only the retirement case.
This commit is contained in:
Neil
2026-09-16 23:38:08 -07:00
parent 468fbd5645
commit c2d6cf352a
@@ -13,7 +13,10 @@ const fakes = vi.hoisted(() => ({
}[],
controlConnect: vi.fn(),
exchange: vi.fn(),
assign: vi.fn()
assign: vi.fn(),
// Why mutable: a retiring origin only keeps its grace timer while something still answers
// through it; at zero, maybeClose retires it on the spot and there is no timer left to survive.
pendingRequestCount: 0
}))
vi.mock('./relay-http-client', async (importOriginal) => ({
@@ -27,7 +30,9 @@ vi.mock('./relay-control-client', () => ({
connect = fakes.controlConnect
closeNow = vi.fn()
isLive = vi.fn(() => true)
pendingRequestCount = 0
get pendingRequestCount(): number {
return fakes.pendingRequestCount
}
constructor(readonly options: (typeof fakes.controls)[number]['options']) {
fakes.controls.push(this)
}
@@ -70,45 +75,93 @@ function brokerOptions(
}
}
const ACK: RelayHostHelloAckMessage = {
type: 'host-hello-ack',
v: 1,
generation: 1,
controlResumeSecret: 'R'.repeat(43),
leaseExpiresAt: 1_000_000,
activeConnIds: [],
pendingConns: []
}
const ASSIGNMENT = {
cellUrl: 'https://relay.example.test',
assignmentEpoch: 1,
leaseExpiresAt: 1_000_000
}
function drainActiveOrigin(): void {
fakes.controls[0]!.options.onDrain({
type: 'drain',
graceMs: 5_000,
recovery: 'resolve-director'
})
}
/**
* Every timer the pool and its broker can leave armed, each reached the way production reaches it.
*
* Why a table and not one case: teardown here is a hand-maintained list of verbs — `rotation.cancel()`,
* `drainRetry.cancel()`, `retirement.clear()` — against three helper classes that each spell "disarm"
* differently. `drainRetry.reset()` sat next to `rotation.cancel()` looking symmetric while only
* zeroing a counter, and every behavioural test stayed green because the guard at fire time meant
* nothing reopened. Counting timers is the only assertion that sees a leak with no behaviour.
*/
const ARMED_STATES: { name: string; arm: () => Promise<void> }[] = [
{
// Rotation is armed by openInitial itself, and the broker arms its lease refresh on connect.
name: 'a live broker doing nothing else (control rotation + lease refresh)',
arm: async () => {}
},
{
name: 'a drain retry armed by a director refusal',
arm: async () => {
fakes.assign.mockRejectedValue(new Error('director_unavailable'))
drainActiveOrigin()
await vi.advanceTimersByTimeAsync(0)
}
},
{
name: 'an origin retirement grace armed by a rotation to a different cell',
arm: async () => {
// A different cellUrl skips the rebind arm and lands in activateTarget, which is what
// schedules the grace. Something must still answer through the old origin or it retires now.
fakes.pendingRequestCount = 1
fakes.assign.mockResolvedValue({
...ASSIGNMENT,
cellUrl: 'https://relay-2.example.test',
assignmentEpoch: 2
})
drainActiveOrigin()
await vi.advanceTimersByTimeAsync(0)
}
}
]
describe('RelayOriginPool teardown', () => {
beforeEach(() => {
fakes.controls.length = 0
fakes.controlConnect.mockReset()
fakes.exchange.mockReset().mockResolvedValue({ relayToken: 'relay-jwt', expiresAt: 1_000_000 })
fakes.assign.mockReset()
fakes.pendingRequestCount = 0
})
it('disarms the drain retry so no timer survives closeNow', async () => {
it.each(ARMED_STATES)('leaves no timer armed after closeNow: $name', async ({ arm }) => {
vi.useFakeTimers()
try {
const ack: RelayHostHelloAckMessage = {
type: 'host-hello-ack',
v: 1,
generation: 1,
controlResumeSecret: 'R'.repeat(43),
leaseExpiresAt: 1_000_000,
activeConnIds: [],
pendingConns: []
}
fakes.controlConnect.mockResolvedValue(ack)
fakes.assign
.mockResolvedValueOnce({
cellUrl: 'https://relay.example.test',
assignmentEpoch: 1,
leaseExpiresAt: 1_000_000
})
.mockRejectedValue(new Error('director_unavailable'))
fakes.controlConnect.mockResolvedValue(ACK)
fakes.assign.mockResolvedValueOnce(ASSIGNMENT)
const broker = await RelaySessionBroker.connect(brokerOptions())
fakes.controls[0]!.options.onDrain({
type: 'drain',
graceMs: 5_000,
recovery: 'resolve-director'
})
// The director refusal arms the drain retry.
await vi.advanceTimersByTimeAsync(0)
const armedWhileLive = vi.getTimerCount()
expect(armedWhileLive).toBeGreaterThan(0)
await arm()
// The census must be able to find things: a case that armed nothing would assert 0 === 0 and
// pass for the wrong reason, which is exactly how the drain-retry leak stayed invisible.
expect(vi.getTimerCount(), 'nothing was armed, so this case guards nothing').toBeGreaterThan(
0
)
broker.closeNow()
expect(vi.getTimerCount()).toBe(0)