부하·장애 주입 테스트 스크립트, 테스트 전용 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>
This commit is contained in:
EJClaw
2026-10-04 05:07:43 +09:00
parent f81f20a4bf
commit f72be633f8
5 changed files with 219 additions and 5 deletions

200
scripts/loadtest.ts Normal file
View File

@@ -0,0 +1,200 @@
/**
* 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);