diff --git a/packages/ui/src/lib/desktopHosts.test.ts b/packages/ui/src/lib/desktopHosts.test.ts index 114c8f87..5ac8d7c7 100644 --- a/packages/ui/src/lib/desktopHosts.test.ts +++ b/packages/ui/src/lib/desktopHosts.test.ts @@ -1,5 +1,24 @@ -import { describe, expect, test } from 'bun:test'; -import { desktopHostProbe, desktopHostsGet, desktopHostsSet, importDesktopHostPairing, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts'; +import { describe, expect, mock, test } from 'bun:test'; +import type { RelayTunnelStatus } from '@/lib/relay/tunnel-client'; +import type { DesktopHostRelay } from './desktopHosts'; + +type TunnelStub = { + fetch: (path: string, init?: RequestInit) => Promise; + getStatus: () => RelayTunnelStatus; + close: () => void; +}; + +let nextTunnel: (() => TunnelStub) | null = null; +const tunnelModule = await import('@/lib/relay/tunnel-client'); +mock.module('@/lib/relay/tunnel-client', () => ({ + ...tunnelModule, + createRelayTunnelClient: () => { + if (!nextTunnel) throw new Error('no tunnel stub registered'); + return nextTunnel(); + }, +})); + +const { desktopHostProbe, desktopHostsGet, desktopHostsSet, importDesktopHostPairing, probeRelayDesktopHost, redactSensitiveUrl, resolveDesktopHostUrl } = await import('./desktopHosts'); const withDesktopBridge = async (handler: (cmd: string, args: Record) => unknown | Promise, run: () => Promise): Promise => { const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); @@ -121,3 +140,86 @@ describe('desktop host runtime headers', () => { }); }); }); + +describe('probeRelayDesktopHost', () => { + const relay: DesktopHostRelay = { + relayUrl: 'wss://relay.example', + serverId: 'server-a', + hostEncPubJwk: { kty: 'EC', crv: 'P-256', x: 'x', y: 'y' }, + }; + + const withTimerWindow = async (run: () => Promise): Promise => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { setTimeout: setTimeout.bind(globalThis), clearTimeout: clearTimeout.bind(globalThis) }, + }); + try { + return await run(); + } finally { + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } + }; + + const stubTunnel = ( + responses: Array, + state: RelayTunnelStatus['state'] = 'reconnecting', + ) => { + const calls: string[] = []; + let closed = false; + nextTunnel = () => ({ + fetch: async (path) => { + calls.push(path); + const next = responses.shift(); + if (!next) throw new Error('relay tunnel reset'); + if (next instanceof Error) throw next; + return next; + }, + getStatus: () => ({ state }), + close: () => { closed = true; }, + }); + return { calls, isClosed: () => closed }; + }; + + test('a cold first attempt is retried instead of reported unreachable', async () => { + // The tunnel rejects waiters on its first failed connect and then + // reconnects; the probe must span that, not read it as an unreachable host. + const tunnel = stubTunnel([ + new Error('relay tunnel reset: connection failed'), + new Response('{}', { status: 200 }), + new Response('{}', { status: 200 }), + ]); + + const result = await withTimerWindow(() => probeRelayDesktopHost(relay, { clientToken: 'token' })); + + expect(result.status).toBe('ok'); + expect(tunnel.calls).toEqual(['/health', '/health', '/auth/session']); + expect(tunnel.isClosed()).toBe(true); + }); + + test('a terminal tunnel state ends the probe without retrying', async () => { + // Auth failed / duplicate client / limit reached will not resolve by waiting. + const tunnel = stubTunnel([new Error('relay connection replaced by another client')], 'error'); + + const result = await withTimerWindow(() => probeRelayDesktopHost(relay, { clientToken: 'token' })); + + expect(result.status).toBe('unreachable'); + expect(tunnel.calls).toEqual(['/health']); + }); + + test('a rejected client token is reported as auth, not unreachable', async () => { + const tunnel = stubTunnel([ + new Response('{}', { status: 200 }), + new Response('{}', { status: 401 }), + ]); + + const result = await withTimerWindow(() => probeRelayDesktopHost(relay, { clientToken: 'stale' })); + + expect(result.status).toBe('auth'); + expect(tunnel.calls).toEqual(['/health', '/auth/session']); + }); +}); diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts index ddcc4787..da846415 100644 --- a/packages/ui/src/lib/desktopHosts.ts +++ b/packages/ui/src/lib/desktopHosts.ts @@ -408,6 +408,9 @@ export const desktopInstallIdGet = async (): Promise => { }; const RELAY_PROBE_TIMEOUT_MS = 8_000; +// Whole-probe budget, spanning the tunnel's own reconnect attempts. +const RELAY_PROBE_DEADLINE_MS = 15_000; +const RELAY_PROBE_RETRY_DELAY_MS = 400; const fetchRelayProbe = async ( tunnel: ReturnType, @@ -423,13 +426,47 @@ const fetchRelayProbe = async ( } }; +/** + * Reach the host, letting the tunnel's own reconnect do the work. + * + * The tunnel rejects everything waiting on its channel the moment ONE connect + * attempt fails, even though it has already scheduled the next one with + * backoff. That is right for app traffic — `runtime-fetch` retries for itself — + * but it made a one-shot probe report a durable red "Unreachable" for a host + * that answers when the user presses refresh a second later. A cold start is + * exactly when that first attempt loses: DNS and TLS to the relay are cold, the + * remote host may still be re-establishing its control connection, and the + * probe competes with the app's own bootstrap traffic. + * + * A terminal tunnel state (auth failed, duplicate client, limit reached) will + * not resolve by waiting, so it ends the probe immediately. + */ +const fetchRelayProbeUntilDeadline = async ( + tunnel: ReturnType, + path: string, + deadline: number, + init?: RequestInit, +): Promise => { + for (;;) { + try { + return await fetchRelayProbe(tunnel, path, init); + } catch (error) { + if (tunnel.getStatus().state === 'error') throw error; + if (Date.now() >= deadline) throw error; + await new Promise((resolve) => window.setTimeout(resolve, RELAY_PROBE_RETRY_DELAY_MS)); + } + } +}; + /** * Reachability and client-auth check for a relay host: open a throwaway E2EE * tunnel, verify `/health`, then verify `/auth/session` with the saved bearer. - * Relay hosts have no HTTP address for `desktopHostProbe`. Hard timeout: a - * ghost relay registration (relay lost the host, host doesn't know) leaves the - * tunnel in `connecting` forever — the probe must report unreachable instead - * of hanging every status/switch flow with it. + * Relay hosts have no HTTP address for `desktopHostProbe`. Bounded by + * `RELAY_PROBE_DEADLINE_MS`: a ghost relay registration (relay lost the host, + * host doesn't know) leaves the tunnel reconnecting forever — the probe must + * report unreachable rather than hang every status/switch flow with it — while + * still spanning enough reconnect attempts that a cold first attempt is not + * mistaken for an unreachable instance. */ export const probeRelayDesktopHost = async ( relay: DesktopHostRelay, @@ -444,9 +481,10 @@ export const probeRelayDesktopHost = async ( hostEncPubJwk: relay.hostEncPubJwk, }); const startedAt = Date.now(); + const deadline = startedAt + RELAY_PROBE_DEADLINE_MS; let keep = false; try { - const response = await fetchRelayProbe(tunnel, '/health'); + const response = await fetchRelayProbeUntilDeadline(tunnel, '/health', deadline); if (!response.ok) return { status: 'unreachable', latencyMs: 0 }; const headers = new Headers({ Accept: 'application/json' }); for (const [name, value] of Object.entries(options?.requestHeaders || {})) { @@ -454,7 +492,7 @@ export const probeRelayDesktopHost = async ( } const clientToken = options?.clientToken?.trim(); if (clientToken) headers.set('Authorization', `Bearer ${clientToken}`); - const sessionResponse = await fetchRelayProbe(tunnel, '/auth/session', { headers }); + const sessionResponse = await fetchRelayProbeUntilDeadline(tunnel, '/auth/session', deadline, { headers }); if (sessionResponse.status === 401 || sessionResponse.status === 403) { return { status: 'auth', latencyMs: Math.max(0, Date.now() - startedAt) }; }