/** * Load / chaos test (docs/10-testing.md §2, §4). * bun scripts/loadtest.ts --rooms 500 --spectators 2 --seconds 60 [--chaos] [--url http://localhost:4600] * The server must run separately (so client load does not skew server numbers) with * LOADTEST_NO_HTTP_LIMITS=1 PUBLIC_ORIGIN= * Each room: 2 omok players alternating one move each ~2s per room, plus N spectators. * Latency = mover sends act → opponent receives the state containing that move. * --chaos: SIGKILL the server at 40% of the run (pid from --pid), the harness restarts it via --restart-cmd, * clients reconnect; verifies no committed move was lost. */ const args = Object.fromEntries( process.argv.slice(2).reduce<[string, string][]>((acc, a, i, arr) => { if (a.startsWith('--')) acc.push([a.slice(2), arr[i + 1] && !arr[i + 1]!.startsWith('--') ? arr[i + 1]! : 'true']); return acc; }, []), ); const URL_ = args.url ?? 'http://localhost:4600'; const ROOMS = Number(args.rooms ?? 500); const SPECT = Number(args.spectators ?? 2); const SECONDS = Number(args.seconds ?? 60); const MOVE_EVERY_MS = 2000; const CHAOS = args.chaos === 'true'; type Msg = { t: string; [k: string]: any }; async function guest(i: number): Promise<{ cookie: string; id: string }> { for (;;) { try { const r = await fetch(`${URL_}/api/auth/guest`, { method: 'POST', headers: { origin: URL_, 'content-type': 'application/json' }, body: JSON.stringify({ nickname: `부하${i}` }) }); const body = (await r.json()) as any; return { cookie: r.headers.get('set-cookie')!.split(';')[0]!, id: body.me.id }; } catch { await Bun.sleep(200); } } } class C { ws!: WebSocket; open = false; handlers: ((m: Msg) => void)[] = []; room: string | null = null; constructor(public cookie: string, public id: string) {} connect(): Promise { return new Promise((resolve) => { const ws = new WebSocket(URL_.replace('http', 'ws') + '/ws', { headers: { cookie: this.cookie, origin: URL_ } } as any); this.ws = ws; ws.onopen = () => { this.open = true; resolve(); }; ws.onmessage = (e) => { const m = JSON.parse(String(e.data)) as Msg; if (m.t === 'welcome' && this.room) this.send({ t: 'join', code: this.room, as: 'player' }); for (const h of this.handlers) h(m); }; ws.onclose = () => { this.open = false; stats.disconnects++; setTimeout(() => this.connect().catch(() => {}), 500 + Math.random() * 1500); }; ws.onerror = () => {}; }); } send(m: unknown) { if (this.open) this.ws.send(JSON.stringify(m)); } } const stats = { lat: [] as number[], acts: 0, rejects: 0, disconnects: 0, lostMoves: 0, games: 0 }; const pct = (a: number[], p: number) => (a.length ? [...a].sort((x, y) => x - y)[Math.min(a.length - 1, Math.floor((a.length * p) / 100))]! : NaN); async function setupRoom(i: number) { const [ga, gb] = await Promise.all([guest(i * 10), guest(i * 10 + 1)]); const res = await fetch(`${URL_}/api/rooms`, { method: 'POST', headers: { origin: URL_, cookie: ga.cookie, 'content-type': 'application/json' }, body: JSON.stringify({ gameId: 'omok' }) }); const { code } = (await res.json()) as any; const a = new C(ga.cookie, ga.id); const b = new C(gb.cookie, gb.id); a.room = b.room = code; await Promise.all([a.connect(), b.connect()]); const specs: C[] = []; for (let s = 0; s < SPECT; s++) { const g = await guest(i * 10 + 2 + s); const c = new C(g.cookie, g.id); c.room = code; await c.connect(); specs.push(c); } let lastMoveCount = 0; let maxSeenMoveCount = 0; const sentAt = new Map(); // moveCount after my move → send time let view: any = null; const onState = (who: C) => (m: Msg) => { if (m.t === 'reject') stats.rejects++; if (m.t !== 'state') return; view = m.view; const mc = view.moveCount as number; if (mc < maxSeenMoveCount && view.phase === 'playing' && who === a) { // A committed move disappeared (only possible if persistence failed). if (!(mc === 0 && m.events?.some?.((e: any) => e.type === 'gameStarted'))) stats.lostMoves += maxSeenMoveCount - mc; } maxSeenMoveCount = view.phase === 'playing' ? Math.max(maxSeenMoveCount, mc) : 0; const t0 = sentAt.get(mc); const mover = view.moves[mc - 1]?.color; const receiverIsOpponent = mover && view.myColor && view.myColor !== mover; if (t0 && receiverIsOpponent) { stats.lat.push(performance.now() - t0); sentAt.delete(mc); } lastMoveCount = mc; }; a.handlers.push(onState(a)); b.handlers.push(onState(b)); a.send({ t: 'join', code, as: 'player' }); await Bun.sleep(50); b.send({ t: 'join', code, as: 'player' }); await Bun.sleep(50); for (const s of specs) s.send({ t: 'join', code, as: 'spectator' }); a.send({ t: 'config', options: { ruleSet: 'free', colorAssignment: 'hostBlack', timeControl: { kind: 'none' } } }); await Bun.sleep(100); a.send({ t: 'start' }); let cs = 0; const tick = () => { if (!view) return; if (view.phase === 'finished') { stats.games++; view = null; a.send({ t: 'rematch' }); b.send({ t: 'rematch' }); return; } const mover = view.players.black === a.id ? (view.turn === 'black' ? a : b) : view.turn === 'black' ? b : a; const empties: number[] = []; view.board.forEach((c: number, k: number) => c === 0 && empties.push(k)); const k = empties[Math.floor(Math.random() * empties.length)]!; sentAt.set(lastMoveCount + 1, performance.now()); mover.send({ t: 'act', cs: ++cs + Date.now(), a: { type: 'place', x: k % 15, y: Math.floor(k / 15) } }); stats.acts++; }; // Stagger rooms evenly across the interval. setTimeout(() => setInterval(tick, MOVE_EVERY_MS), Math.random() * MOVE_EVERY_MS); return [a, b, ...specs]; } console.log(`setting up ${ROOMS} rooms × ${2 + SPECT} connections against ${URL_}…`); const t0 = performance.now(); const all: C[] = []; for (let i = 0; i < ROOMS; i += 25) { const batch = await Promise.all(Array.from({ length: Math.min(25, ROOMS - i) }, (_, k) => setupRoom(i + k))); for (const b of batch) all.push(...b); } console.log(`ready: ${all.length} connections in ${((performance.now() - t0) / 1000).toFixed(1)}s`); stats.lat = []; stats.acts = 0; stats.disconnects = 0; const pid = Number(args.pid ?? 0); const cpu = () => { if (!pid) return null; const st = require('node:fs').readFileSync(`/proc/${pid}/stat`, 'utf8').split(') ')[1].split(' '); return (Number(st[11]) + Number(st[12])) / 100; // utime+stime seconds (USER_HZ=100) }; const rss = () => { try { return pid ? Number(/VmRSS:\s+(\d+)/.exec(require('node:fs').readFileSync(`/proc/${pid}/status`, 'utf8'))![1]) / 1024 : NaN; } catch { return NaN; // server was restarted (chaos mode) } }; const cpu0 = cpu(); const start = performance.now(); let chaosDone = false; let lastReport = start; while (performance.now() - start < SECONDS * 1000) { await Bun.sleep(1000); if (CHAOS && !chaosDone && performance.now() - start > SECONDS * 400) { chaosDone = true; console.log('CHAOS: sending SIGKILL to server', args.pid); process.kill(pid, 'SIGKILL'); await Bun.$`sh -c ${args['restart-cmd']}`.quiet().nothrow(); console.log('CHAOS: restart command issued'); } if (performance.now() - lastReport > 10_000) { lastReport = performance.now(); const connected = all.filter((c) => c.open).length; console.log(`t=${Math.round((lastReport - start) / 1000)}s acts=${stats.acts} lat p50=${pct(stats.lat, 50).toFixed(1)} p95=${pct(stats.lat, 95).toFixed(1)} p99=${pct(stats.lat, 99).toFixed(1)}ms connected=${connected}/${all.length}`); } } const elapsed = (performance.now() - start) / 1000; const cpuUsed = cpu0 !== null && !CHAOS ? (cpu()! - cpu0) / elapsed : NaN; const result = { rooms: ROOMS, connections: all.length, connectedAtEnd: all.filter((c) => c.open).length, seconds: Math.round(elapsed), actionsPerSec: +(stats.acts / elapsed).toFixed(1), latencyMs: { p50: +pct(stats.lat, 50).toFixed(1), p95: +pct(stats.lat, 95).toFixed(1), p99: +pct(stats.lat, 99).toFixed(1), samples: stats.lat.length }, rejects: stats.rejects, disconnects: stats.disconnects, lostMoves: stats.lostMoves, gamesFinished: stats.games, serverCpuCores: Number.isNaN(cpuUsed) ? null : +cpuUsed.toFixed(2), serverRssMb: Number.isNaN(rss()) ? null : Math.round(rss()), }; console.log('RESULT ' + JSON.stringify(result)); process.exit(0);