diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 12a4e86..8a56a9e 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -10,6 +10,8 @@ export interface Config { /** Trust X-Forwarded-For (only when running behind our reverse proxy). */ trustProxy: boolean; superadminDiscordIds: string[]; + /** Load-test only: lift per-IP HTTP limits. Ignored for https origins. */ + loadTestNoHttpLimits: boolean; } /** The site owner's Discord ID (docs/14-admin.md §2). */ @@ -33,6 +35,7 @@ export function loadConfig(env: Record = process.env sessionSecret: sessionSecret || 'dev-only-insecure-session-secret-change-me', secureCookies: publicOrigin.startsWith('https://'), trustProxy: env.TRUST_PROXY === '1', + loadTestNoHttpLimits: env.LOADTEST_NO_HTTP_LIMITS === '1' && !publicOrigin.startsWith('https://'), superadminDiscordIds: (env.SUPERADMIN_DISCORD_IDS ?? DEFAULT_SUPERADMIN).split(',').map((s) => s.trim()).filter(Boolean), }; } diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index 580cec9..aa9cac4 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -33,9 +33,10 @@ const OAUTH_COOKIE = 'oauth'; export function createHttpApp(d: HttpDeps) { const app = new Hono(); - const createLimiter = new WindowLimiter(10, 60_000); - const lookupLimiter = new WindowLimiter(30, 60_000); - const guestLimiter = new WindowLimiter(20, 60_000); + const lift = d.config.loadTestNoHttpLimits ? 1_000_000 : 1; + const createLimiter = new WindowLimiter(10 * lift, 60_000); + const lookupLimiter = new WindowLimiter(30 * lift, 60_000); + const guestLimiter = new WindowLimiter(20 * lift, 60_000); const redirectUri = `${d.config.publicOrigin}/api/auth/discord/callback`; const cookieOpts = (maxAgeMs: number) => ({ diff --git a/bun.lock b/bun.lock index 09a905a..53a2d37 100644 --- a/bun.lock +++ b/bun.lock @@ -54,6 +54,7 @@ "version": "0.0.0", "dependencies": { "@bg/engine": "workspace:*", + "chess.js": "1.4.0", "zod": "4.6.5", }, }, @@ -124,6 +125,8 @@ "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="], + "chess.js": ["chess.js@1.4.0", "", {}, "sha512-BBJgrrtKQOzFLonR0l+k64A98NLemPwNsCskwb+29bRwobUa4iTm51E1kwGPbWXAcfdDa18nad6vpPPKPWarqw=="], + "cookie-es": ["cookie-es@3.1.1", "", {}, "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], diff --git a/packages/games/package.json b/packages/games/package.json index 54fd90e..350023d 100644 --- a/packages/games/package.json +++ b/packages/games/package.json @@ -3,6 +3,13 @@ "version": "0.0.0", "private": true, "type": "module", - "exports": { ".": "./src/index.ts", "./*": "./src/*/index.ts" }, - "dependencies": { "@bg/engine": "workspace:*", "zod": "4.6.5" } + "exports": { + ".": "./src/index.ts", + "./*": "./src/*/index.ts" + }, + "dependencies": { + "@bg/engine": "workspace:*", + "chess.js": "1.4.0", + "zod": "4.6.5" + } } diff --git a/scripts/loadtest.ts b/scripts/loadtest.ts new file mode 100644 index 0000000..c85d59c --- /dev/null +++ b/scripts/loadtest.ts @@ -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= + * 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 = () => (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);