fix(relay): give the catalog request the warm-up budget and report reachability before catalog shape

- the catalog fetch is the coldest request of the sequence (DNS, TCP, TLS to the director) and
  ran on the 1.5 s sample clock; a timeout withheld the hint for an hour
- a lone region that is unreachable or flapping now logs all-unreachable / all-rejected, so a
  census of client-side network breakage is not hidden by a roll wave
- tests pin the budget sequence, the requestTimeoutMs override, and the aggregated warning text
This commit is contained in:
Jinwoo-H
2026-09-07 15:23:51 -04:00
parent 190f48d832
commit 27e685a8d4
5 changed files with 81 additions and 12 deletions
@@ -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()
})
@@ -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 () => {
@@ -215,10 +215,12 @@ export class RelayRegionPreferenceResolver {
): Promise<RelayRegionProbeReport[]> {
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?.()
@@ -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)
@@ -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'
}