Files
ssessesse-ki-battle/tests/webrtc-load.ts
T

80 lines
7.0 KiB
TypeScript

// HTTP/DB and transient signaling only: this does NOT simulate real RTC pairs.
import { fork } from 'node:child_process';
import { randomBytes, randomUUID } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { MongoClient } from 'mongodb';
import { io } from 'socket.io-client';
const pairCount = 1500, seconds = Number(process.env.LOAD_SECONDS ?? 900);
const databaseName = `dragon_ball_test_load_${randomUUID().replaceAll('-', '')}`;
const child = fork(new URL('./webrtc-load-server.ts', import.meta.url), { env: { ...process.env, LOAD_DATABASE: databaseName }, stdio: ['ignore', 'inherit', 'inherit', 'ipc'], windowsHide: true });
let url = '', phase = 'ramp', signalingConnections = 0, failures = 0;
const metrics: any[] = [], requests: Record<string, { ms: number[]; status: Record<string, number> }> = {};
child.on('message', (message: any) => { if (message.type === 'metrics') metrics.push({ ...message, phase, at: Date.now() }); });
await new Promise<void>((resolve, reject) => { child.on('message', (m: any) => { if (m.type === 'ready') { url = m.url; resolve(); } }); child.once('error', reject); child.once('exit', code => reject(new Error(`SERVER_EXIT_${code}`))); });
const sleep = (ms: number) => new Promise(r => setTimeout(r, Math.max(0, ms)));
async function post(path: string, data: unknown, session?: any) {
const start = performance.now(); let status = 0;
try {
const response = await fetch(url + path, { method: 'POST', headers: { 'content-type': 'application/json', ...(session ? { authorization: `Bearer ${session.token}`, 'x-game-session': session.gameSessionId } : {}) }, body: JSON.stringify(data), signal: AbortSignal.timeout(10000) });
status = response.status; const body: any = await response.json();
if (!response.ok || !body.ok) throw new Error(`HTTP_${status}_${body.code}`);
return body.data;
} finally {
const key = phase + ':' + path.replace(/\/rooms\/[^/]+\//, '/rooms/:id/');
const entry = requests[key] ??= { ms: [], status: {} }; entry.ms.push(performance.now() - start); entry.status[status] = (entry.status[status] ?? 0) + 1;
}
}
async function signal(session: any, room: any) {
const socket = io(url + '/signal', { transports: ['websocket'], forceNew: true, reconnection: false, auth: { gameSessionId: session.gameSessionId, token: session.token, roomId: room.id } });
try { await new Promise<void>((resolve, reject) => { socket.once('connect', () => resolve()); socket.once('connect_error', reject); }); signalingConnections++; }
finally { socket.disconnect(); }
}
const pairs: { sessions: any[]; room: any; active: any[]; seq: number }[] = [];
async function prepare(index: number) {
const sessions = await Promise.all([0, 1].map(() => post('/api/game-sessions', { createRequestId: randomBytes(32).toString('base64url') })));
let room = await post('/api/rooms', { requestId: randomUUID() }, sessions[0]);
room = await post('/api/rooms/join', { code: room.code }, sessions[1]);
await Promise.all(sessions.map(s => signal(s, room)));
const active = await Promise.all(sessions.map(s => post('/api/game-session/matches', { requestId: randomUUID(), matchId: room.currentMatchId, roomId: room.id, mode: 'friend', expectedPreviousSeq: 0 }, s)));
pairs[index] = { sessions, room, active, seq: 0 };
}
const inflight = new Set<Promise<void>>();
function launch(work: Promise<void>) { const tracked = work.catch(error => { failures++; console.error(String(error)); }).finally(() => inflight.delete(tracked)); inflight.add(tracked); }
const started = Date.now();
try {
for (let i = 0; i < pairCount; i++) { await sleep(started + i * 40 - Date.now()); launch(prepare(i)); }
await Promise.all(inflight); if (pairs.filter(Boolean).length !== pairCount) throw new Error('RAMP_FAILED');
const rampMs = Date.now() - started; console.log(JSON.stringify({ phase: 'ramp-complete', sessions: pairCount * 2, signalingConnections, rampMs }));
phase = 'steady'; const steadyStart = Date.now(); let duplicateResults = 0;
async function cycle(index: number) {
const p = pairs[index % pairCount];
const results = p.active.map((active, i) => ({ ...active, outcome: i === 0 ? 'win' : 'loss', reason: 'hit' }));
await Promise.all(p.sessions.flatMap((s, i) => [post('/api/game-session/results', results[i], s), post('/api/analytics/activity', { visible: true }, s)]));
if (index % 10 === 0) { await Promise.all(p.sessions.map((s, i) => post('/api/game-session/results', results[i], s))); duplicateResults += 2; }
const round = p.room.round;
const rooms = await Promise.all(p.sessions.map(s => post('/api/rooms/' + p.room.id + '/rematch', { round, requestId: randomUUID() }, s)));
p.room = rooms.sort((a, b) => b.round - a.round)[0]; p.seq++;
p.active = await Promise.all(p.sessions.map(s => post('/api/game-session/matches', { requestId: randomUUID(), matchId: p.room.currentMatchId, roomId: p.room.id, mode: 'friend', expectedPreviousSeq: p.seq }, s)));
}
for (let i = 0; i < seconds * 25; i++) {
await sleep(steadyStart + i * 40 - Date.now()); launch(cycle(i));
if (i && i % 1500 === 0) console.log(JSON.stringify({ phase, elapsedSeconds: Math.round((Date.now() - steadyStart) / 1000), completedPairCycles: i, inflight: inflight.size, failures }));
}
await Promise.all(inflight);
const elapsedMs = Date.now() - steadyStart;
const inspect = await new MongoClient(process.env.TEST_MONGODB_URI!).connect();
try {
const db = inspect.db(databaseName);
const totals = await db.collection('game_sessions').aggregate([{ $group: { _id: null, sessions: { $sum: 1 }, wins: { $sum: '$stats.friend.wins' }, losses: { $sum: '$stats.friend.losses' }, best: { $max: '$stats.friend.best' } } }]).toArray();
const percent = (values: number[], n: number) => values.sort((a,b) => a-b)[Math.min(values.length-1, Math.floor(values.length*n))] ?? null;
const summary = Object.fromEntries(Object.entries(requests).map(([key,v]) => [key, { count: v.ms.length, status: v.status, p50: percent(v.ms,.5), p95: percent(v.ms,.95), p99: percent(v.ms,.99) }]));
const steady = metrics.filter(m => m.phase === 'steady');
const output = { scope: 'HTTP + Standalone MongoDB + transient signaling; no real RTC load or TURN', sessions: 3000, pairs: pairCount, requestedSeconds: seconds, elapsedMs, rampMs, signalingConnections, duplicateResults, failures, totals, legacyMatches: await db.collection('matches').countDocuments(),
resources: { peakRssBytes: Math.max(...steady.map(m => m.rss)), meanCpuPercentOneCore: steady.reduce((n,m) => n+m.cpuPercent,0)/steady.length, peakWebSockets: Math.max(...metrics.map(m=>m.peakSockets)), steadyPeakWebSockets: Math.max(...steady.map(m=>m.sockets)), writeMethodCalls: metrics.at(-1)?.writes }, summary, metrics };
await mkdir('docs/verification', { recursive: true }); await writeFile('docs/verification/webrtc-load.json', JSON.stringify(output, null, 2));
console.log(JSON.stringify({ ...output, metrics: undefined }));
// This invocation exclusively owns this randomly named test database.
await db.dropDatabase();
} finally { await inspect.close(); }
} finally { child.send('stop'); }