feat(server): add racePeering helper with tests
Exports `racePeering(origin, timeoutMs, ensurePeeredFn?)` that races `ensurePeered` against a deadline. On timeout, the background handshake continues (warming the peer for the next attempt) and a warn-logged .catch() prevents unhandledRejection. Injectable `ensurePeeredFn` param enables full DI in tests without mocking module internals.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import type { EnsurePeeredResult } from './federationPeering.js';
|
import type { EnsurePeeredResult } from './federationPeering.js';
|
||||||
|
import { racePeering } from './federationPeering.js';
|
||||||
|
|
||||||
describe('EnsurePeeredResult type', () => {
|
describe('EnsurePeeredResult type', () => {
|
||||||
it('active result has peerId', () => {
|
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<EnsurePeeredResult> => ({
|
||||||
|
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<EnsurePeeredResult> => 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<EnsurePeeredResult> => ({
|
||||||
|
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<EnsurePeeredResult>((_, 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -210,3 +210,38 @@ async function performHandshake(
|
|||||||
export function _clearInFlightPeering(): void {
|
export function _clearInFlightPeering(): void {
|
||||||
inFlightPeering.clear();
|
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<EnsurePeeredResult> = ensurePeered,
|
||||||
|
): Promise<EnsurePeeredResult | { status: 'timeout' }> {
|
||||||
|
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<typeof setTimeout> | 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user