diff --git a/src/main/runtime/relay/desktop-relay-service.test.ts b/src/main/runtime/relay/desktop-relay-service.test.ts index a69c5a74fa6..508b3fb64d7 100644 --- a/src/main/runtime/relay/desktop-relay-service.test.ts +++ b/src/main/runtime/relay/desktop-relay-service.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { MobilePairingConnectionContext } from '../runtime-rpc' import { DesktopRelayService, pairingAuthorizationForContext } from './desktop-relay-service' @@ -61,6 +61,47 @@ describe('pairingAuthorizationForContext', () => { }) }) +describe('liveness safety net lifecycle', () => { + afterEach(() => vi.useRealTimers()) + + it('fences hard: the tick stops on fence and re-arms on the next auth mutation', () => { + // Why: a tick surviving the pre-sign-out fence could catch the window + // before the profile wipe and briefly resurrect a broker. + vi.useFakeTimers() + const coordinator = { + reconcile: vi.fn(), + ensureLive: vi.fn(), + fenceAndCloseNow: vi.fn(), + stop: vi.fn() + } + const service = Object.create(DesktopRelayService.prototype) as DesktopRelayService + Object.assign(service, { + coordinator, + demandLedger: { nextPendingExpiry: () => null }, + stopped: false, + livenessTimer: null, + demandExpiryTimer: null + }) + + service.start() + vi.advanceTimersByTime(5 * 60_000) + expect(coordinator.ensureLive).toHaveBeenCalledTimes(1) + + service.fenceAndCloseNow() + expect(coordinator.fenceAndCloseNow).toHaveBeenCalledOnce() + vi.advanceTimersByTime(30 * 60_000) + expect(coordinator.ensureLive).toHaveBeenCalledTimes(1) + + service.authMutated() + vi.advanceTimersByTime(5 * 60_000) + expect(coordinator.ensureLive).toHaveBeenCalledTimes(2) + + service.stop() + vi.advanceTimersByTime(30 * 60_000) + expect(coordinator.ensureLive).toHaveBeenCalledTimes(2) + }) +}) + describe('local-only mobile pairing', () => { it('refuses endpoint discovery and provisioning without opening Relay demand', async () => { const registry = { diff --git a/src/main/runtime/relay/desktop-relay-service.ts b/src/main/runtime/relay/desktop-relay-service.ts index 04236fea2b7..575cbe23bbc 100644 --- a/src/main/runtime/relay/desktop-relay-service.ts +++ b/src/main/runtime/relay/desktop-relay-service.ts @@ -96,9 +96,6 @@ export class DesktopRelayService { start(): void { this.refreshDemand() - if (!this.livenessTimer) { - this.livenessTimer = setInterval(() => this.ensureLive(), RELAY_LIVENESS_INTERVAL_MS) - } } // Safe to call from any wake signal (power resume, network change). @@ -113,6 +110,13 @@ export class DesktopRelayService { } fenceAndCloseNow(): void { + // Why: a fence must be hard — a surviving liveness tick could catch the + // window between the pre-sign-out fence and the profile wipe and briefly + // resurrect a broker. The next auth mutation re-arms via refreshDemand. + if (this.livenessTimer) { + clearInterval(this.livenessTimer) + this.livenessTimer = null + } this.coordinator.fenceAndCloseNow() } @@ -307,6 +311,9 @@ export class DesktopRelayService { if (this.stopped) { return } + if (!this.livenessTimer) { + this.livenessTimer = setInterval(() => this.ensureLive(), RELAY_LIVENESS_INTERVAL_MS) + } if (this.demandExpiryTimer) { clearTimeout(this.demandExpiryTimer) this.demandExpiryTimer = null