diff --git a/src/main/runtime/relay/relay-control-origin.test.ts b/src/main/runtime/relay/relay-control-origin.test.ts index 2c7053f285c..f8a402c3224 100644 --- a/src/main/runtime/relay/relay-control-origin.test.ts +++ b/src/main/runtime/relay/relay-control-origin.test.ts @@ -203,7 +203,13 @@ describe('RelayControlOrigin pending-connection replay', () => { // both the pairing authority and the E2EE device binding. const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) fakes.controlConnect.mockResolvedValue( - ack({ pendingConns: [{ connId: 'conn-unknown', connTicket: TICKET }] }) + ack({ + pendingConns: [ + { connId: 'conn-unknown', connTicket: TICKET }, + { connId: 'conn-unknown-2', connTicket: TICKET }, + { connId: 'conn-unknown-3', connTicket: TICKET } + ] + }) ) const { origin, owned } = createOrigin() @@ -211,7 +217,11 @@ describe('RelayControlOrigin pending-connection replay', () => { expect(fakes.transports[0]!.openConnection).not.toHaveBeenCalled() expect(owned).toEqual([]) + // One aggregated line per ack, not one per entry. expect(warn).toHaveBeenCalledOnce() + expect(warn.mock.calls[0]![0]).toBe( + '[relay] 3 pending connection(s) not replayable: relay stated no kind/device' + ) warn.mockRestore() }) diff --git a/src/main/runtime/relay/relay-region-preference.test.ts b/src/main/runtime/relay/relay-region-preference.test.ts index 360490d54f5..33b2b2643cc 100644 --- a/src/main/runtime/relay/relay-region-preference.test.ts +++ b/src/main/runtime/relay/relay-region-preference.test.ts @@ -459,9 +459,34 @@ describe('Relay region preference', () => { } finally { timeout.mockRestore() } - // One catalog request, then per origin one warm-up and three samples. - expect(budgets.filter((ms) => ms === 4_500)).toHaveLength(2) - expect(budgets.filter((ms) => ms === 1_500)).toHaveLength(7) + // The catalog request, then per origin one warm-up and three samples. The catalog + // and the warm-ups are the cold requests; only the samples run on the short clock. + // Regions measure in parallel, so the two warm-ups are issued before any sample. + expect(budgets).toEqual([4_500, 4_500, 4_500, 1_500, 1_500, 1_500, 1_500, 1_500, 1_500]) + }) + + it('lets requestTimeoutMs override every budget, including the warm-up', async () => { + const budgets: number[] = [] + const timeout = vi.spyOn(AbortSignal, 'timeout').mockImplementation((ms) => { + budgets.push(ms) + return new AbortController().signal + }) + try { + await new RelayRegionPreferenceResolver({ + directorUrl: DIRECTOR, + userDataPath: userDataPath(), + fetch: async (url) => + String(url).endsWith('/v1/regions') + ? Response.json({ v: 1, regions: BOTH_REGIONS }) + : cancelTrackingResponse(200, () => {}), + requestTimeoutMs: 250, + now: () => 1_000 + }).resolve() + } finally { + timeout.mockRestore() + } + expect(budgets).toHaveLength(9) + expect(new Set(budgets)).toEqual(new Set([250])) }) it('probes only the canonical health path and cancels its body', async () => { diff --git a/src/main/runtime/relay/relay-region-preference.ts b/src/main/runtime/relay/relay-region-preference.ts index 7f982d7df41..e84c1f04a23 100644 --- a/src/main/runtime/relay/relay-region-preference.ts +++ b/src/main/runtime/relay/relay-region-preference.ts @@ -215,10 +215,12 @@ export class RelayRegionPreferenceResolver { ): Promise { let catalog: RelayRegionCatalog try { + // The catalog request is the coldest of the sequence: it pays DNS, TCP, + // and TLS to the director, so it gets the warm-up budget, not the sample one. catalog = await fetchRelayRegionCatalog( this.options.directorUrl, fetch, - this.options.requestTimeoutMs ?? PROBE_TIMEOUT_MS + this.options.requestTimeoutMs ?? WARMUP_TIMEOUT_MS ) } catch (error) { onCatalogFailure?.() diff --git a/src/main/runtime/relay/relay-region-probe-log.test.ts b/src/main/runtime/relay/relay-region-probe-log.test.ts index eb62939d9c7..4c1de3ac475 100644 --- a/src/main/runtime/relay/relay-region-probe-log.test.ts +++ b/src/main/runtime/relay/relay-region-probe-log.test.ts @@ -172,6 +172,38 @@ describe('Relay region probe log', () => { ]) }) + it('reports a lone unreachable region as all-unreachable, not catalog-incomplete', async () => { + // Why: a support census counts all-unreachable to spot client-side network breakage; + // a roll wave that shortens the catalog must not hide those runs. + const path = userDataPath() + const { resolver, events } = resolverWithLog({ + path, + fetch: catalogFetch([{ region: 'asia-east2', probeOrigins: [ASIA] }]), + probe: sampledProbe({}) + }) + await expect(resolver.resolve()).resolves.toBeUndefined() + expect(probeEvents(events)[0]!.reason).toBe('all-unreachable') + }) + + it('reports a lone flapping region as all-rejected, and a lone healthy one as catalog-incomplete', async () => { + const path = userDataPath() + const flapping = resolverWithLog({ + path, + fetch: catalogFetch([{ region: 'asia-east2', probeOrigins: [ASIA] }]), + probe: sampledProbe({ [ASIA]: [90, 30, 40, 900] }) + }) + await expect(flapping.resolver.resolve()).resolves.toBeUndefined() + expect(probeEvents(flapping.events)[0]!.reason).toBe('all-rejected') + + const healthy = resolverWithLog({ + path: userDataPath(), + fetch: catalogFetch([{ region: 'asia-east2', probeOrigins: [ASIA] }]), + probe: sampledProbe({ [ASIA]: [90, 30, 32, 34] }) + }) + await expect(healthy.resolver.resolve()).resolves.toBeUndefined() + expect(probeEvents(healthy.events)[0]!.reason).toBe('catalog-incomplete') + }) + it('names a held incumbent apart from a fresh measurement', async () => { const path = userDataPath() writeCache(path, 'us-central1', 500) diff --git a/src/main/runtime/relay/relay-region-probe-log.ts b/src/main/runtime/relay/relay-region-probe-log.ts index 2db9289b96b..9e7295ab38d 100644 --- a/src/main/runtime/relay/relay-region-probe-log.ts +++ b/src/main/runtime/relay/relay-region-probe-log.ts @@ -130,13 +130,13 @@ function refreshReason( // a selection that is not the fastest reading is a deliberate hold. return best && selected.region !== best.region ? 'held-previous' : 'measured' } - if (reports.length < RELAY_REGIONS.length) { - return 'catalog-incomplete' + // Reachability first: a support census counting all-unreachable to spot + // client-side network breakage must not lose those runs to a roll wave. + if (reports.every((report) => report.verdict === 'unreachable')) { + return 'all-unreachable' } - if (reports.some((report) => report.verdict === 'measured')) { - return 'sole-survivor-forbidden' + if (!reports.some((report) => report.verdict === 'measured')) { + return 'all-rejected' } - return reports.every((report) => report.verdict === 'unreachable') - ? 'all-unreachable' - : 'all-rejected' + return reports.length < RELAY_REGIONS.length ? 'catalog-incomplete' : 'sole-survivor-forbidden' }