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 { 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<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 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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user