fix(server): racePeering normalizes handshake rejections and only warns on timeout win
Addresses code review on b22a7bd: (1) a rejected handshake now returns
{ status: 'failed', error } instead of throwing, keeping the structured
contract; (2) the "background handshake" warn only fires when the
timeout arm wins — not when the handshake is itself the race winner by
rejection. Timing tests migrated to vi.useFakeTimers for determinism.
Regression test added for the handshake-wins-by-rejection case.
This commit is contained in:
@@ -57,11 +57,15 @@ describe('racePeering', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns timeout when ensurePeered takes longer than the deadline', async () => {
|
it('returns timeout when ensurePeered takes longer than the deadline', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
const stub = vi.fn((): Promise<EnsurePeeredResult> => new Promise(() => {
|
const stub = vi.fn((): Promise<EnsurePeeredResult> => new Promise(() => {
|
||||||
// Never resolves — simulates a slow handshake.
|
// Never resolves — simulates a slow handshake.
|
||||||
}));
|
}));
|
||||||
const result = await racePeering('https://example.com', 50, stub);
|
const racePromise = racePeering('https://example.com', 50, stub);
|
||||||
|
await vi.advanceTimersByTimeAsync(50);
|
||||||
|
const result = await racePromise;
|
||||||
expect(result).toEqual({ status: 'timeout' });
|
expect(result).toEqual({ status: 'timeout' });
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns rejected result verbatim when ensurePeered resolves with rejection', async () => {
|
it('returns rejected result verbatim when ensurePeered resolves with rejection', async () => {
|
||||||
@@ -73,20 +77,38 @@ describe('racePeering', () => {
|
|||||||
expect(result).toEqual({ status: 'rejected', error: 'peer denied' });
|
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 () => {
|
it('attaches a warn-logged catch to the background handshake when the timeout wins', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
const stub = vi.fn(() => new Promise<EnsurePeeredResult>((_, reject) => {
|
const stub = vi.fn(() => new Promise<EnsurePeeredResult>((_, reject) => {
|
||||||
setTimeout(() => reject(new Error('late failure')), 30);
|
setTimeout(() => reject(new Error('late failure')), 30);
|
||||||
}));
|
}));
|
||||||
const result = await racePeering('https://example.com', 10, stub);
|
const racePromise = racePeering('https://example.com', 10, stub);
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
const result = await racePromise;
|
||||||
expect(result).toEqual({ status: 'timeout' });
|
expect(result).toEqual({ status: 'timeout' });
|
||||||
// Give the background promise time to reject.
|
await vi.advanceTimersByTimeAsync(30);
|
||||||
await new Promise(r => setTimeout(r, 50));
|
// Let microtasks flush so the .catch handler runs.
|
||||||
|
await Promise.resolve();
|
||||||
expect(warnSpy).toHaveBeenCalledWith(
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('background handshake'),
|
expect.stringContaining('background handshake'),
|
||||||
'https://example.com',
|
'https://example.com',
|
||||||
expect.any(Error),
|
expect.any(Error),
|
||||||
);
|
);
|
||||||
|
vi.useRealTimers();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes a thrown handshake error into { status: failed } without emitting the background warn', async () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
const stub = vi.fn(async (): Promise<EnsurePeeredResult> => {
|
||||||
|
throw new Error('immediate handshake failure');
|
||||||
|
});
|
||||||
|
const result = await racePeering('https://example.com', 1_000, stub);
|
||||||
|
expect(result).toEqual({ status: 'failed', error: 'immediate handshake failure' });
|
||||||
|
// The handshake rejection was the race winner — no background warn should fire.
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
warnSpy.mockRestore();
|
warnSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -227,21 +227,29 @@ export async function racePeering(
|
|||||||
): Promise<EnsurePeeredResult | { status: 'timeout' }> {
|
): Promise<EnsurePeeredResult | { status: 'timeout' }> {
|
||||||
const handshake = ensurePeeredFn(origin);
|
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;
|
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||||
const timeoutPromise = new Promise<{ status: 'timeout' }>(resolve => {
|
const timeoutPromise = new Promise<{ status: 'timeout' }>(resolve => {
|
||||||
timeoutHandle = setTimeout(() => resolve({ status: 'timeout' }), timeoutMs);
|
timeoutHandle = setTimeout(() => resolve({ status: 'timeout' }), timeoutMs);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let raceResult: EnsurePeeredResult | { status: 'timeout' };
|
||||||
try {
|
try {
|
||||||
return await Promise.race([handshake, timeoutPromise]);
|
raceResult = await Promise.race([handshake, timeoutPromise]);
|
||||||
} finally {
|
} catch (err) {
|
||||||
|
// ensurePeeredFn rejected as the race winner. Normalize to failed.
|
||||||
if (timeoutHandle) clearTimeout(timeoutHandle);
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown handshake error';
|
||||||
|
return { status: 'failed', error: message };
|
||||||
}
|
}
|
||||||
|
if (timeoutHandle) clearTimeout(timeoutHandle);
|
||||||
|
|
||||||
|
// Only when the timeout arm won is the background handshake still running.
|
||||||
|
// Guard its eventual rejection so we don't emit unhandledRejection.
|
||||||
|
if (raceResult.status === 'timeout') {
|
||||||
|
handshake.catch(err => {
|
||||||
|
console.warn('[federation] background handshake after call-relay race:', origin, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return raceResult;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user