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:
Bohdan Triapitsyn
2026-09-03 11:49:34 +03:00
parent 6fa2cb66d0
commit 8f1e0c8b5f
2 changed files with 148 additions and 8 deletions
+104 -2
View File
@@ -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']);
});
});