fix: retry the relay instance probe before reporting it unreachable
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, which retries for itself, but it made the 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. The probe now spans the tunnel's own reconnects within a 15s budget, and ends immediately on a terminal tunnel state (auth failed, duplicate client, limit), which waiting cannot resolve.
This commit is contained in:
@@ -1,5 +1,24 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
import { desktopHostProbe, desktopHostsGet, desktopHostsSet, importDesktopHostPairing, redactSensitiveUrl, resolveDesktopHostUrl } from './desktopHosts';
|
import type { RelayTunnelStatus } from '@/lib/relay/tunnel-client';
|
||||||
|
import type { DesktopHostRelay } from './desktopHosts';
|
||||||
|
|
||||||
|
type TunnelStub = {
|
||||||
|
fetch: (path: string, init?: RequestInit) => Promise<Response>;
|
||||||
|
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 <T>(handler: (cmd: string, args: Record<string, unknown>) => unknown | Promise<unknown>, run: () => Promise<T>): Promise<T> => {
|
const withDesktopBridge = async <T>(handler: (cmd: string, args: Record<string, unknown>) => unknown | Promise<unknown>, run: () => Promise<T>): Promise<T> => {
|
||||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
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 <T>(run: () => Promise<T>): Promise<T> => {
|
||||||
|
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<Response | Error>,
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -408,6 +408,9 @@ export const desktopInstallIdGet = async (): Promise<string> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RELAY_PROBE_TIMEOUT_MS = 8_000;
|
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 (
|
const fetchRelayProbe = async (
|
||||||
tunnel: ReturnType<typeof createRelayTunnelClient>,
|
tunnel: ReturnType<typeof createRelayTunnelClient>,
|
||||||
@@ -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<typeof createRelayTunnelClient>,
|
||||||
|
path: string,
|
||||||
|
deadline: number,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<Response> => {
|
||||||
|
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
|
* Reachability and client-auth check for a relay host: open a throwaway E2EE
|
||||||
* tunnel, verify `/health`, then verify `/auth/session` with the saved bearer.
|
* tunnel, verify `/health`, then verify `/auth/session` with the saved bearer.
|
||||||
* Relay hosts have no HTTP address for `desktopHostProbe`. Hard timeout: a
|
* Relay hosts have no HTTP address for `desktopHostProbe`. Bounded by
|
||||||
* ghost relay registration (relay lost the host, host doesn't know) leaves the
|
* `RELAY_PROBE_DEADLINE_MS`: a ghost relay registration (relay lost the host,
|
||||||
* tunnel in `connecting` forever — the probe must report unreachable instead
|
* host doesn't know) leaves the tunnel reconnecting forever — the probe must
|
||||||
* of hanging every status/switch flow with it.
|
* 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 (
|
export const probeRelayDesktopHost = async (
|
||||||
relay: DesktopHostRelay,
|
relay: DesktopHostRelay,
|
||||||
@@ -444,9 +481,10 @@ export const probeRelayDesktopHost = async (
|
|||||||
hostEncPubJwk: relay.hostEncPubJwk,
|
hostEncPubJwk: relay.hostEncPubJwk,
|
||||||
});
|
});
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
const deadline = startedAt + RELAY_PROBE_DEADLINE_MS;
|
||||||
let keep = false;
|
let keep = false;
|
||||||
try {
|
try {
|
||||||
const response = await fetchRelayProbe(tunnel, '/health');
|
const response = await fetchRelayProbeUntilDeadline(tunnel, '/health', deadline);
|
||||||
if (!response.ok) return { status: 'unreachable', latencyMs: 0 };
|
if (!response.ok) return { status: 'unreachable', latencyMs: 0 };
|
||||||
const headers = new Headers({ Accept: 'application/json' });
|
const headers = new Headers({ Accept: 'application/json' });
|
||||||
for (const [name, value] of Object.entries(options?.requestHeaders || {})) {
|
for (const [name, value] of Object.entries(options?.requestHeaders || {})) {
|
||||||
@@ -454,7 +492,7 @@ export const probeRelayDesktopHost = async (
|
|||||||
}
|
}
|
||||||
const clientToken = options?.clientToken?.trim();
|
const clientToken = options?.clientToken?.trim();
|
||||||
if (clientToken) headers.set('Authorization', `Bearer ${clientToken}`);
|
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) {
|
if (sessionResponse.status === 401 || sessionResponse.status === 403) {
|
||||||
return { status: 'auth', latencyMs: Math.max(0, Date.now() - startedAt) };
|
return { status: 'auth', latencyMs: Math.max(0, Date.now() - startedAt) };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user