Files
joke-app/scripts/loadtest.ts
EJClaw aa7f6bffef M2 안정성: 부하·장애 주입 테스트 통과 + 재시작 후 시간 초과 몰림 수정
- 2,000 연결 / 500 방 / 초당 248 행동: p95 20ms, p99 37ms, CPU 0.2코어, 메모리 141MB, 오류 0
- 부하 중 SIGKILL→재시작: 2,000 연결 자동 복구, 수 유실 0
- 복구된 방 마감을 30초 + 0~30초 무작위로 분산(몰림으로 p99 336ms → 40ms)
- 보고서: docs/reports/load-20261004.md

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

207 lines
8.4 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 = () => {
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);