Files
joke-app/scripts/loadtest.ts
EJClaw f72be633f8 부하·장애 주입 테스트 스크립트, 테스트 전용 HTTP 제한 해제 옵션, chess.js 의존성
- scripts/loadtest.ts: 방 N개 × (선수 2 + 관전자) 연결, 방마다 2초에 한 수, 상대 수신까지 지연 p50/p95/p99,
  서버 CPU·메모리, --chaos 시 서버 SIGKILL → 재시작 → 재연결·수 유실 검사
- LOADTEST_NO_HTTP_LIMITS=1(https 주소에서는 무시)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 05:07:43 +09:00

201 lines
8.3 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.
/**
* 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=<url>
* 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<void> {
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<number, number>(); // 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 = () => (pid ? Number(/VmRSS:\s+(\d+)/.exec(require('node:fs').readFileSync(`/proc/${pid}/status`, 'utf8'))![1]) / 1024 : NaN);
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);