#!/usr/bin/env bun /** * Shade 4.0 GA — combined soak harness. * * Runs the ratchet stack under sustained ping-pong load for a configurable * duration. Designed to be wrapped in `systemd-run` / `nohup` / a Gitea * scheduled job and left running for ≥ 2 weeks before tagging GA-stable. * * Usage: * bun run scripts/soak.ts # default 1h * bun run scripts/soak.ts --hours 168 # 1 week * bun run scripts/soak.ts --hours 336 # 2 weeks (V4.0 §Soak) * bun run scripts/soak.ts --hours 0.05 # 3 minute smoke (CI gate) */ import { ShadeSessionManager } from '../packages/shade-core/src/index.js'; import { SubtleCryptoProvider, MemoryStorage } from '../packages/shade-crypto-web/src/index.js'; interface Args { hours: number; pairs: number; reportSeconds: number; } function parseArgs(argv: string[]): Args { const out: Args = { hours: 1, pairs: 4, reportSeconds: 60 }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--hours') out.hours = parseFloat(argv[++i]!); else if (a === '--pairs') out.pairs = parseInt(argv[++i]!, 10); else if (a === '--report-seconds') out.reportSeconds = parseInt(argv[++i]!, 10); else if (a === '--help') { console.log('Usage: bun run scripts/soak.ts [--hours N] [--pairs N] [--report-seconds N]'); process.exit(0); } } return out; } interface Counters { ratchetSent: number; ratchetRecv: number; ratchetErrs: number; errors: { workload: string; message: string; ts: number }[]; } function newCounters(): Counters { return { ratchetSent: 0, ratchetRecv: 0, ratchetErrs: 0, errors: [], }; } interface Pair { id: number; aliceMgr: ShadeSessionManager; bobMgr: ShadeSessionManager; aliceAddr: string; bobAddr: string; } async function setupPair(id: number): Promise { const crypto = new SubtleCryptoProvider(); const aliceMgr = new ShadeSessionManager(crypto, new MemoryStorage()); const bobMgr = new ShadeSessionManager(crypto, new MemoryStorage()); await aliceMgr.initialize(); await bobMgr.initialize(); const otpks = await bobMgr.generateOneTimePreKeys(5); const bundle = await bobMgr.createPreKeyBundle(); bundle.oneTimePreKey = { keyId: otpks[0]!.keyId, publicKey: otpks[0]!.keyPair.publicKey }; const aliceAddr = `alice-${id}`; const bobAddr = `bob-${id}`; await aliceMgr.initSessionFromBundle(bobAddr, bundle); // Establish the receiver session by sending a first message and decrypting. const init = await aliceMgr.encrypt(bobAddr, 'init'); await bobMgr.decrypt(aliceAddr, init); return { id, aliceMgr, bobMgr, aliceAddr, bobAddr }; } async function ratchetPingPong(pair: Pair, counters: Counters, abort: AbortSignal) { const { aliceMgr, bobMgr, aliceAddr, bobAddr } = pair; let i = 0; while (!abort.aborted) { try { const env = await aliceMgr.encrypt(bobAddr, `msg-${i++}`); counters.ratchetSent++; await bobMgr.decrypt(aliceAddr, env); counters.ratchetRecv++; const reply = await bobMgr.encrypt(aliceAddr, `re-${i}`); counters.ratchetSent++; await aliceMgr.decrypt(bobAddr, reply); counters.ratchetRecv++; if ((i & 0xff) === 0) await new Promise((r) => setTimeout(r, 0)); } catch (err) { counters.ratchetErrs++; counters.errors.push({ workload: `pair-${pair.id}`, message: (err as Error).message, ts: Date.now(), }); if (counters.ratchetErrs > 8) break; } } } async function main() { const args = parseArgs(process.argv.slice(2)); const totalMs = Math.max(60_000, Math.round(args.hours * 3_600_000)); const ac = new AbortController(); const counters = newCounters(); const start = Date.now(); console.log(`[soak] start — ${args.pairs} pairs, ${args.hours}h, report every ${args.reportSeconds}s`); const pairs = await Promise.all(Array.from({ length: args.pairs }, (_, i) => setupPair(i))); setTimeout(() => ac.abort(), totalMs).unref(); process.on('SIGINT', () => ac.abort()); process.on('SIGTERM', () => ac.abort()); const reporter = setInterval(() => { const elapsedS = ((Date.now() - start) / 1000).toFixed(0); const rps = (counters.ratchetSent / Math.max(1, (Date.now() - start) / 1000)).toFixed(1); console.log( `[soak] t=${elapsedS}s sent=${counters.ratchetSent} recv=${counters.ratchetRecv} errs=${counters.ratchetErrs} (${rps} sent/s)`, ); if (counters.errors.length > 32) counters.errors.splice(0, counters.errors.length - 32); }, args.reportSeconds * 1000); reporter.unref(); await Promise.all(pairs.map((p) => ratchetPingPong(p, counters, ac.signal))); clearInterval(reporter); const elapsedS = ((Date.now() - start) / 1000).toFixed(0); console.log(`\n[soak] done in ${elapsedS}s`); console.log(` ratchet sent ${counters.ratchetSent}`); console.log(` ratchet recv ${counters.ratchetRecv}`); console.log(` ratchet errs ${counters.ratchetErrs}`); if (counters.ratchetErrs > 0) { console.error('\nFAIL: ratchet errors recorded:'); for (const e of counters.errors.slice(-10)) { console.error(` [${new Date(e.ts).toISOString()}] ${e.workload}: ${e.message}`); } process.exit(1); } process.exit(0); } void main();