부하·장애 주입 테스트 스크립트, 테스트 전용 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:
200
scripts/loadtest.ts
Normal file
200
scripts/loadtest.ts
Normal 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);
|
||||
Reference in New Issue
Block a user