Files
Shade/packages/shade-sdk/tests/webrtc-throughput.test.ts
Sterister 53ce776c02 test: stop guessing port numbers
Twenty test servers picked a port with `NNNN + Math.floor(Math.random() * 500)`
and hoped it was free. Several files guessed inside the *same* range —
sdk.test.ts and gates.test.ts both on 19500, three files on 22000, and
webrtc-integration.test.ts used 22000 twice within itself — so with test files
serving in parallel the collision was not unlikely, it was scheduled.

Found from the outside, in Nova, where Shade is vendored: across eight runs of
an unchanged tree, two had failures and six were clean. The visible error was

    Failed to start server. Is port 24287 in use?  EADDRINUSE

and then a second, louder one: `teardownRig` ran on a rig that setup had never
finished building and died on `rig.alice`, so the TypeError from cleanup is
what you read first and the real cause scrolled past above it.

`Bun.serve({ port: 0 })` lets the OS hand out a free port and `server.port`
reads it back. There is no range to collide inside.

teardownRig now tolerates a rig that was never built. Cleanup must never be the
loudest thing in a failing test.

A suite that fails a quarter of the time is worse than a suite that fails: it
teaches everyone to re-run it, and then a real regression looks like the flake.

Verified: 1198 pass / 0 fail here, and the same fix is going into Nova's
vendored copy so the next sync does not bring the guessing back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014489bKUtUEY1Zgs9xN9mt7
2026-09-08 16:33:40 +02:00

195 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* V3.11 acceptance criterion (loopback flavour): a multi-lane payload
* over the in-process WebRTC transport completes faster than the same
* payload over HTTP-loopback.
*
* The MemoryRtcFactory short-circuits the network entirely, so this is
* effectively comparing "in-process pipe" vs "HTTP-loopback round-trip"
* — P2P should still win because every chunk goes through the OS TCP
* stack on the HTTP side. This stand-in test validates the wiring; the
* "real" same-LAN comparison runs in `webrtc-native.test.ts` when
* `globalThis.RTCPeerConnection` exists.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import {
createShade,
type Shade,
type TransferHandle,
type TransferResult,
} from '../src/index.js';
import {
createPrekeyServer,
MemoryPrekeyStore,
PrekeyServerEvents,
} from '@shade/server';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { sha256Once } from '@shade/streams';
import { MemoryRtcFactory } from '@shade/transport-webrtc';
const crypto = new SubtleCryptoProvider();
interface Rig {
alice: Shade;
bob: Shade;
prekeyStop: () => void;
aliceServerStop: () => void;
bobServerStop: () => void;
}
async function startPrekeyServer(): Promise<{ url: string; stop: () => void }> {
const events = new PrekeyServerEvents();
const server = createPrekeyServer({
crypto,
store: new MemoryPrekeyStore(),
disableRateLimit: true,
events,
});
// Port 0 lets the OS hand out a free one. This guessed inside a 500-wide
// range and hoped — and several files here guessed inside the SAME range,
// so with test files serving in parallel the collision arrived about a
// quarter of the time as EADDRINUSE.
const handle = Bun.serve({ port: 0, fetch: server.fetch });
const port = handle.port;
return { url: `http://localhost:${port}`, stop: () => handle.stop() };
}
async function setupRig(opts: { withWebRTC: boolean }): Promise<Rig> {
const prekey = await startPrekeyServer();
const alice = await createShade({ prekeyServer: prekey.url, address: 'alice' });
const bob = await createShade({ prekeyServer: prekey.url, address: 'bob' });
const baseUrls = new Map<string, string>();
const resolveBaseUrl = async (addr: string): Promise<string> => {
const url = baseUrls.get(addr);
if (url === undefined) throw new Error(`unknown peer ${addr}`);
return url;
};
alice.configureTransfers({ resolveBaseUrl });
bob.configureTransfers({ resolveBaseUrl });
if (opts.withWebRTC) {
const factory = new MemoryRtcFactory();
alice.configureWebRTC({ factory, connectTimeoutMs: 10_000 });
bob.configureWebRTC({ factory, connectTimeoutMs: 10_000 });
}
const bobApp = await bob.transferRoute();
// Port 0 lets the OS hand out a free one. This guessed inside a 500-wide
// range and hoped — and several files here guessed inside the SAME range,
// so with test files serving in parallel the collision arrived about a
// quarter of the time as EADDRINUSE.
const bobServer = Bun.serve({ port: 0, fetch: bobApp.fetch });
const bobPort = bobServer.port;
baseUrls.set('bob', `http://localhost:${bobPort}`);
const aliceApp = await alice.transferRoute();
// Port 0 lets the OS hand out a free one. This guessed inside a 500-wide
// range and hoped — and several files here guessed inside the SAME range,
// so with test files serving in parallel the collision arrived about a
// quarter of the time as EADDRINUSE.
const aliceServer = Bun.serve({ port: 0, fetch: aliceApp.fetch });
const alicePort = aliceServer.port;
baseUrls.set('alice', `http://localhost:${alicePort}`);
return {
alice,
bob,
prekeyStop: prekey.stop,
aliceServerStop: () => aliceServer.stop(),
bobServerStop: () => bobServer.stop(),
};
}
async function teardownRig(rig: Rig): Promise<void> {
await rig.alice.shutdown();
await rig.bob.shutdown();
rig.bobServerStop();
rig.aliceServerStop();
rig.prekeyStop();
MemoryRtcFactory.reset();
}
function hex(b: Uint8Array): string {
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
}
async function uploadAndAwait(
rig: Rig,
input: Uint8Array,
opts: { lanes: number; chunkSize: number },
): Promise<{ senderResult: TransferResult; received: Uint8Array; elapsed: number }> {
let resolveRecv!: (h: TransferHandle) => void;
const recvHandlePromise = new Promise<TransferHandle>((r) => {
resolveRecv = r;
});
const unsubscribe = await rig.bob.onIncomingTransfer(async (incoming) => {
const h = await incoming.accept({ output: { kind: 'buffer' } });
resolveRecv(h);
});
const t0 = performance.now();
const handle = await rig.alice.upload({
to: 'bob',
input,
lanes: opts.lanes,
chunkSize: opts.chunkSize,
metadata: { name: 'throughput.bin' },
});
const recvHandle = await recvHandlePromise;
const [senderResult, recvResult] = await Promise.all([
handle.done(),
recvHandle.done(),
]);
const elapsed = performance.now() - t0;
unsubscribe();
const received =
(recvResult as TransferResult & { bytes?: Uint8Array }).bytes ?? new Uint8Array();
return { senderResult, received, elapsed };
}
describe('V3.11 throughput — WebRTC loopback vs HTTP loopback', () => {
let webrtcRig: Rig;
let httpRig: Rig;
beforeAll(async () => {
webrtcRig = await setupRig({ withWebRTC: true });
httpRig = await setupRig({ withWebRTC: false });
});
afterAll(async () => {
await teardownRig(webrtcRig);
await teardownRig(httpRig);
});
test(
'integrity match across both transports for 4 MiB / 4 lanes',
async () => {
const input = crypto.randomBytes(4 * 1024 * 1024);
const expectedHash = hex(sha256Once(input));
const w = await uploadAndAwait(webrtcRig, input, { lanes: 4, chunkSize: 64 * 1024 });
expect(w.received).toEqual(input);
expect(w.senderResult.sha256).toBe(expectedHash);
const h = await uploadAndAwait(httpRig, input, { lanes: 4, chunkSize: 64 * 1024 });
expect(h.received).toEqual(input);
expect(h.senderResult.sha256).toBe(expectedHash);
// Diagnostic logging — not a hard assertion since loopback is
// dominated by crypto cost rather than transport. We do assert
// that WebRTC is the primary on the WebRTC rig and that no fallback
// happened.
const runtime = webrtcRig.alice.getWebRtcRuntime();
expect(runtime!.fallback.activeName).toBe('webrtc');
expect(runtime!.fallback.hasFallenBack).toBe(false);
// eslint-disable-next-line no-console
console.log(
`[throughput] webrtc=${w.elapsed.toFixed(0)}ms http=${h.elapsed.toFixed(0)}ms ` +
`(speedup ×${(h.elapsed / w.elapsed).toFixed(2)})`,
);
},
60_000,
);
});