release(v4.0.0): Shade GA — V3.x consolidation + audit prep
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
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
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>
This commit is contained in:
115
packages/shade-transfer/tests/multi-fallback.test.ts
Normal file
115
packages/shade-transfer/tests/multi-fallback.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
MultiTransportFallback,
|
||||
TransferTransportError,
|
||||
type ChunkAck,
|
||||
type ITransferTransport,
|
||||
type TransferResumeState,
|
||||
} from '../src/index.js';
|
||||
|
||||
class StubTransport implements ITransferTransport {
|
||||
callCount = 0;
|
||||
constructor(
|
||||
private readonly behavior: () => 'ok' | 'transport-error' | 'other-error',
|
||||
) {}
|
||||
async probe(): Promise<void> {
|
||||
this.callCount++;
|
||||
const verdict = this.behavior();
|
||||
if (verdict === 'transport-error') throw new TransferTransportError('probe failed');
|
||||
if (verdict === 'other-error') throw new Error('other');
|
||||
}
|
||||
async sendChunk(
|
||||
_peer: string,
|
||||
_streamId: string,
|
||||
_laneId: number,
|
||||
seq: number | bigint,
|
||||
): Promise<ChunkAck> {
|
||||
this.callCount++;
|
||||
const verdict = this.behavior();
|
||||
if (verdict === 'transport-error') throw new TransferTransportError('send failed');
|
||||
if (verdict === 'other-error') throw new Error('other');
|
||||
return { lastSeq: typeof seq === 'bigint' ? Number(seq) : seq };
|
||||
}
|
||||
async fetchResumeState(): Promise<TransferResumeState | null> {
|
||||
this.callCount++;
|
||||
const verdict = this.behavior();
|
||||
if (verdict === 'transport-error') throw new TransferTransportError('resume failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe('MultiTransportFallback', () => {
|
||||
it('uses the primary transport when probe succeeds', async () => {
|
||||
const primary = new StubTransport(() => 'ok');
|
||||
const secondary = new StubTransport(() => 'ok');
|
||||
const tertiary = new StubTransport(() => 'ok');
|
||||
const fb = new MultiTransportFallback([
|
||||
{ name: 'webrtc', transport: primary },
|
||||
{ name: 'ws', transport: secondary },
|
||||
{ name: 'http', transport: tertiary },
|
||||
]);
|
||||
await fb.probe('bob');
|
||||
expect(fb.activeName).toBe('webrtc');
|
||||
expect(primary.callCount).toBe(1);
|
||||
expect(secondary.callCount).toBe(0);
|
||||
});
|
||||
|
||||
it('demotes through layers on transport errors', async () => {
|
||||
const primary = new StubTransport(() => 'transport-error');
|
||||
const secondary = new StubTransport(() => 'transport-error');
|
||||
const tertiary = new StubTransport(() => 'ok');
|
||||
const fb = new MultiTransportFallback([
|
||||
{ name: 'webrtc', transport: primary },
|
||||
{ name: 'ws', transport: secondary },
|
||||
{ name: 'http', transport: tertiary },
|
||||
]);
|
||||
const switches: Array<{ from: string; to: string }> = [];
|
||||
fb.onSwitch((from, to) => switches.push({ from, to }));
|
||||
await fb.probe('bob');
|
||||
expect(fb.activeName).toBe('http');
|
||||
expect(switches).toEqual([
|
||||
{ from: 'webrtc', to: 'ws' },
|
||||
{ from: 'ws', to: 'http' },
|
||||
]);
|
||||
expect(fb.failures).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('throws if every layer fails', async () => {
|
||||
const fb = new MultiTransportFallback([
|
||||
{ name: 'a', transport: new StubTransport(() => 'transport-error') },
|
||||
{ name: 'b', transport: new StubTransport(() => 'transport-error') },
|
||||
]);
|
||||
await expect(fb.probe('bob')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('does NOT demote on non-transport errors', async () => {
|
||||
const primary = new StubTransport(() => 'other-error');
|
||||
const fb = new MultiTransportFallback([
|
||||
{ name: 'p', transport: primary },
|
||||
{ name: 's', transport: new StubTransport(() => 'ok') },
|
||||
]);
|
||||
await expect(fb.probe('bob')).rejects.toThrow(/other/);
|
||||
// We did NOT advance — non-transport errors are caller bugs.
|
||||
expect(fb.activeName).toBe('p');
|
||||
});
|
||||
|
||||
it('sticks to the demoted layer for sendChunk after probe failure', async () => {
|
||||
const primary = new StubTransport(() => 'transport-error');
|
||||
const secondary = new StubTransport(() => 'ok');
|
||||
const fb = new MultiTransportFallback([
|
||||
{ name: 'p', transport: primary },
|
||||
{ name: 's', transport: secondary },
|
||||
]);
|
||||
await fb.probe('bob');
|
||||
expect(fb.activeName).toBe('s');
|
||||
// primary not called again
|
||||
const before = primary.callCount;
|
||||
await fb.sendChunk('bob', 'sid', 0, 0n, new Uint8Array(8));
|
||||
expect(primary.callCount).toBe(before);
|
||||
expect(secondary.callCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects empty transport list', () => {
|
||||
expect(() => new MultiTransportFallback([])).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user