diff --git a/packages/server/src/utils/federationPeering.test.ts b/packages/server/src/utils/federationPeering.test.ts index e25d4495..96f31624 100644 --- a/packages/server/src/utils/federationPeering.test.ts +++ b/packages/server/src/utils/federationPeering.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import type { EnsurePeeredResult } from './federationPeering.js'; +import { racePeering } from './federationPeering.js'; describe('EnsurePeeredResult type', () => { it('active result has peerId', () => { @@ -43,3 +44,49 @@ describe('EnsurePeeredResult type', () => { } }); }); + +describe('racePeering', () => { + it('returns the ensurePeered result when it resolves before the timeout', async () => { + const stub = vi.fn(async (): Promise => ({ + status: 'active', + peerId: 'peer-1', + })); + const result = await racePeering('https://example.com', 1_000, stub); + expect(result).toEqual({ status: 'active', peerId: 'peer-1' }); + expect(stub).toHaveBeenCalledWith('https://example.com'); + }); + + it('returns timeout when ensurePeered takes longer than the deadline', async () => { + const stub = vi.fn((): Promise => new Promise(() => { + // Never resolves — simulates a slow handshake. + })); + const result = await racePeering('https://example.com', 50, stub); + expect(result).toEqual({ status: 'timeout' }); + }); + + it('returns rejected result verbatim when ensurePeered resolves with rejection', async () => { + const stub = vi.fn(async (): Promise => ({ + status: 'rejected', + error: 'peer denied', + })); + const result = await racePeering('https://example.com', 1_000, stub); + expect(result).toEqual({ status: 'rejected', error: 'peer denied' }); + }); + + it('attaches a warn-logged catch to the background promise so race-losing rejections do not emit unhandledRejection', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const stub = vi.fn(() => new Promise((_, reject) => { + setTimeout(() => reject(new Error('late failure')), 30); + })); + const result = await racePeering('https://example.com', 10, stub); + expect(result).toEqual({ status: 'timeout' }); + // Give the background promise time to reject. + await new Promise(r => setTimeout(r, 50)); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('background handshake'), + 'https://example.com', + expect.any(Error), + ); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/server/src/utils/federationPeering.ts b/packages/server/src/utils/federationPeering.ts index 2bf49fcd..29fe7abc 100644 --- a/packages/server/src/utils/federationPeering.ts +++ b/packages/server/src/utils/federationPeering.ts @@ -210,3 +210,38 @@ async function performHandshake( export function _clearInFlightPeering(): void { inFlightPeering.clear(); } + +/** + * Race ensurePeered() against a deadline. On timeout, the background + * handshake is NOT aborted — it continues so the next attempt finds + * the peer active. A warn-logged catch is attached so a late-rejecting + * background promise does not emit an unhandledRejection. + * + * The ensurePeered implementation is injectable for testing; the default + * is the real function. + */ +export async function racePeering( + origin: string, + timeoutMs: number, + ensurePeeredFn: (origin: string) => Promise = ensurePeered, +): Promise { + const handshake = ensurePeeredFn(origin); + + // Attach a warn-logged catch so a background arm that rejects AFTER the + // race loses does not trigger unhandledRejection. This runs regardless + // of which arm wins. + handshake.catch(err => { + console.warn('[federation] background handshake after call-relay race:', origin, err); + }); + + let timeoutHandle: ReturnType | undefined; + const timeoutPromise = new Promise<{ status: 'timeout' }>(resolve => { + timeoutHandle = setTimeout(() => resolve({ status: 'timeout' }), timeoutMs); + }); + + try { + return await Promise.race([handshake, timeoutPromise]); + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + } +}