Files
Shade/packages/shade-sdk/tests/webrtc-throughput.test.ts
Sterister e6fdf31b49
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled
release(v4.0.0): Shade GA — V3.x consolidation + audit prep
V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:35:35 +02:00

183 lines
6.0 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,
});
const port = 24500 + Math.floor(Math.random() * 500);
const handle = Bun.serve({ port, fetch: server.fetch });
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();
const bobPort = 25000 + Math.floor(Math.random() * 500);
const bobServer = Bun.serve({ port: bobPort, fetch: bobApp.fetch });
baseUrls.set('bob', `http://localhost:${bobPort}`);
const aliceApp = await alice.transferRoute();
const alicePort = 25500 + Math.floor(Math.random() * 500);
const aliceServer = Bun.serve({ port: alicePort, fetch: aliceApp.fetch });
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,
);
});