Files
Shade/packages/shade-transfer/tests/resume.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

157 lines
5.5 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import { SubtleCryptoProvider } from '@shade/crypto-web';
import { sha256Once } from '@shade/streams';
import {
TransferEngine,
MemoryControlChannel,
MemoryResumeStore,
ShadeTransferHttpTransport,
createTransferRoutes,
type TransferHandle,
type TransferResult,
} from '../src/index.js';
const crypto = new SubtleCryptoProvider();
function hex(b: Uint8Array): string {
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
}
describe('Resume protocol — kill-restart-verify', () => {
test('sender crash mid-transfer → resumeUpload completes the same stream', async () => {
const senderResumeStore = new MemoryResumeStore();
// Stable deviceKey shared across engine instances — simulates a stable
// identity-derived key. In real use this is `deriveDeviceKey(identity)`.
const deviceKey = crypto.randomBytes(32);
// Receiver is a single, long-lived engine. Its in-memory IncomingState
// already tracks accepted chunks; we don't need to persist receiver state
// for the in-process scenario.
const { a: ctrlA, b: ctrlB } = MemoryControlChannel.linked('alice', 'bob');
const receiverEngine = new TransferEngine({
crypto,
controlChannel: ctrlB,
transport: {
probe: async () => undefined,
sendChunk: async () => {
throw new Error('receiver-side sendChunk should not be called');
},
fetchResumeState: async () => null,
},
myAddress: 'bob',
});
const receiverApp = await createTransferRoutes(receiverEngine);
// 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 server = Bun.serve({ port: 0, fetch: receiverApp.fetch });
const port = server.port;
const baseUrl = `http://localhost:${port}`;
// Receiver accepts incoming.
let resolveRecv!: (h: TransferHandle) => void;
const recvHandlePromise = new Promise<TransferHandle>((r) => {
resolveRecv = r;
});
receiverEngine.onIncomingTransfer(async (incoming) => {
const h = await incoming.accept({ output: { kind: 'buffer' } });
resolveRecv(h);
});
// Sender #1 — this one will "crash" partway through.
const senderEngine1 = new TransferEngine({
crypto,
controlChannel: ctrlA,
transport: new ShadeTransferHttpTransport({
resolveBaseUrl: async () => baseUrl,
authenticator: {
signChunk: async () => ({ 'X-Shade-Sender-Address': 'alice' }),
signControl: async () => ({ 'X-Shade-Sender-Address': 'alice' }),
},
}),
myAddress: 'alice',
resumeStore: senderResumeStore,
deviceKey,
});
const input = crypto.randomBytes(256 * 1024); // 256 KiB
// Start an upload that will be intentionally interrupted. We pause the
// sender by aborting the upload after ~25% bytes. The receiver still
// holds its IncomingState — chunks already accepted stay tracked.
const abort = new AbortController();
let bytesAtAbort = 0;
const handle1 = await senderEngine1.upload({
to: 'bob',
input,
lanes: 4,
chunkSize: 8 * 1024,
partition: 'range',
signal: abort.signal,
onProgress: (p) => {
if (p.bytesSent > input.length / 4 && bytesAtAbort === 0) {
bytesAtAbort = p.bytesSent;
abort.abort();
}
},
});
const streamId = handle1.streamId;
// Wait for the upload to fail (abort).
let firstErrCaught = false;
try {
await handle1.done();
} catch {
firstErrCaught = true;
}
expect(firstErrCaught).toBe(true);
expect(bytesAtAbort).toBeGreaterThan(0);
// Tear down the first sender engine — simulates a crashed/restarted client.
senderEngine1.destroy();
// Sender #2 — fresh engine, same resume store.
const senderEngine2 = new TransferEngine({
crypto,
controlChannel: ctrlA, // re-use the link; in real life this would
transport: new ShadeTransferHttpTransport({
resolveBaseUrl: async () => baseUrl,
authenticator: {
signChunk: async () => ({ 'X-Shade-Sender-Address': 'alice' }),
signControl: async () => ({ 'X-Shade-Sender-Address': 'alice' }),
},
}),
myAddress: 'alice',
resumeStore: senderResumeStore,
deviceKey,
});
// Verify state is still in the resume store.
const persisted = await senderResumeStore.get(streamId);
expect(persisted).not.toBeNull();
expect(persisted!.status).toBe('active');
// Resume the upload with the same input bytes.
const handle2 = await senderEngine2.resumeUpload(streamId, input);
const senderResult = await handle2.done();
expect(senderResult.streamId).toBe(streamId);
// Receiver finishes too.
const recvHandle = await recvHandlePromise;
const recvResult = await recvHandle.done();
const received =
(recvResult as TransferResult & { bytes?: Uint8Array }).bytes ?? new Uint8Array();
expect(received).toEqual(input);
expect(senderResult.sha256).toBe(hex(sha256Once(input)));
// Persisted state should now be finished.
const after = await senderResumeStore.get(streamId);
expect(after?.status).toBe('finished');
senderEngine2.destroy();
receiverEngine.destroy();
server.stop();
}, 30_000);
});