diff --git a/cloud/apps/relay/src/host-session-client-accept.test.ts b/cloud/apps/relay/src/host-session-client-accept.test.ts index bbffb55e725..59bd5bad551 100644 --- a/cloud/apps/relay/src/host-session-client-accept.test.ts +++ b/cloud/apps/relay/src/host-session-client-accept.test.ts @@ -296,24 +296,28 @@ describe('control lease jitter', () => { vi.useRealTimers() }) - it('grants a lease inside [55min - jitter, 55min] so cohorts drift apart', async () => { + it('grants a lease uniformly around 55min so cohorts drift apart at the same mean rate', async () => { const now = 1_700_000_000_000 const helloAck = (socket: FakeSocket) => JSON.parse( String(socket.send.mock.calls.find((call) => String(call[0]).includes('host-hello-ack'))![0]) ) as { leaseExpiresAt: number } - const shortest = harness({ now: () => now, random: () => 0.999999 }) + const shortest = harness({ now: () => now, random: () => 0 }) const shortestAck = helloAck(await activeHost(shortest)) - const longest = harness({ now: () => now, random: () => 0 }) + const centered = harness({ now: () => now, random: () => 0.5 }) + const centeredAck = helloAck(await activeHost(centered)) + const longest = harness({ now: () => now, random: () => 0.999999 }) const longestAck = helloAck(await activeHost(longest)) - expect(longestAck.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS) - expect(shortestAck.leaseExpiresAt).toBeGreaterThan(now + CONTROL_LEASE_MS - CONTROL_LEASE_JITTER_MS) - expect(shortestAck.leaseExpiresAt).toBeLessThan(longestAck.leaseExpiresAt) - // Nine of ten hosts that connected together now differ by minutes, not zero. + expect(shortestAck.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS - CONTROL_LEASE_JITTER_MS) + expect(centeredAck.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS) + expect(longestAck.leaseExpiresAt).toBeLessThan(now + CONTROL_LEASE_MS + CONTROL_LEASE_JITTER_MS) + // The mean grant stays at 55 min, so steady-state rebind load is unchanged; + // hosts that connected together now differ by minutes, not zero. expect(longestAck.leaseExpiresAt - shortestAck.leaseExpiresAt).toBeGreaterThan(9 * 60 * 1000) shortest.registry.drain(0) + centered.registry.drain(0) longest.registry.drain(0) vi.advanceTimersByTime(0) }) @@ -325,14 +329,14 @@ describe('control lease jitter', () => { const first = await activeHost(h) const session = h.registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })! const firstLease = session.leaseExpiresAt - roll = 0.5 + roll = 0.75 const rebind = new FakeSocket() await ( h.registry as unknown as { activate: (...args: unknown[]) => Promise } ).activate(rebind as unknown as WebSocket, identity, session, 1, true, 1, '1.4.197') - expect(session.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS - CONTROL_LEASE_JITTER_MS / 2) + expect(session.leaseExpiresAt).toBe(now + CONTROL_LEASE_MS + CONTROL_LEASE_JITTER_MS / 2) expect(session.leaseExpiresAt).not.toBe(firstLease) expect(first.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.PEER_DROPPED, 'control rebound') h.registry.drain(0) diff --git a/cloud/apps/relay/src/host-session-registry.ts b/cloud/apps/relay/src/host-session-registry.ts index c1d2a80303f..c0fa3ecffc8 100644 --- a/cloud/apps/relay/src/host-session-registry.ts +++ b/cloud/apps/relay/src/host-session-registry.ts @@ -127,12 +127,15 @@ function send(socket: WebSocket, type: string, message: object): void { // stalled predecessor only accumulates doomed sockets. const ACTIVATION_QUEUE_WAIT_MS = 30_000 -// Why: hosts rebind 1-2 min before this expires, so every host that (re)connected -// in the same minute (a cell recreate dumps hundreds at once) rebinds as one -// cohort every cycle, forever, and each cohort lands on the cell-inventory lock -// as a single wave. Jittering the grant walks the cohort apart across cycles. +// Why: hosts rebind a few minutes before this expires, so every host that +// (re)connected in the same minute (a cell recreate dumps hundreds at once) +// rebinds as one cohort every cycle, forever, and each cohort lands on the +// cell-inventory lock as a single wave. A symmetric jitter walks the cohort +// apart across cycles without raising the mean rebind rate. The lease only +// drives the desktop's rotation timer; auth expiry (5 min token) and the 75 s +// silence watchdog are enforced separately, so a longer grant risks nothing. export const CONTROL_LEASE_MS = 55 * 60 * 1000 -export const CONTROL_LEASE_JITTER_MS = 10 * 60 * 1000 +export const CONTROL_LEASE_JITTER_MS = 5 * 60 * 1000 export class HostSessionRegistry { private readonly sessions = new Map() @@ -150,8 +153,10 @@ export class HostSessionRegistry { private readonly random: () => number = Math.random ) {} + // Uniform over [CONTROL_LEASE_MS - jitter, CONTROL_LEASE_MS + jitter). private controlLeaseExpiresAt(): number { - return this.now() + CONTROL_LEASE_MS - Math.floor(this.random() * CONTROL_LEASE_JITTER_MS) + const offset = Math.floor((this.random() * 2 - 1) * CONTROL_LEASE_JITTER_MS) + return this.now() + CONTROL_LEASE_MS + offset } async acceptClient(