// node scripts/benchmark-runtime.mjs import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import http from 'node:http'; import { execFileSync, execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { setTimeout as delay } from 'node:timers/promises'; import { createHash } from 'node:crypto'; import { monitorEventLoopDelay } from 'node:perf_hooks'; import { chromium } from 'playwright'; const work = path.resolve(process.argv[2] || ''); const manifest = JSON.parse(fs.readFileSync(path.join(work, 'manifest.json'), 'utf8')); const exec = promisify(execFile); const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8', windowsHide: true }).trim(); const families = ['aquadactyl', 'pterodactyl']; const summary = (values) => { assert.ok(values.length && values.every(Number.isFinite)); const sorted = [...values].sort((a, b) => a - b); const n = sorted.length; return { median: n % 2 ? sorted[(n - 1) / 2] : (sorted[n / 2 - 1] + sorted[n / 2]) / 2, p95: sorted[Math.ceil(n * 0.95) - 1], min: sorted[0], max: sorted.at(-1) }; }; const credentials = Object.fromEntries(families.map(family => [family, JSON.parse(docker('exec', manifest.panels[family].container, 'cat', '/bench/storage/benchmark-access.json'))])); const report = { schemaVersion: 1, measuredOn: new Date().toISOString().slice(0, 10), startedAt: new Date().toISOString(), environment: { hostCpu: os.cpus()[0].model, hostLogicalCpus: os.cpus().length, hostMemoryGiB: Math.round(os.totalmem() / 2 ** 30), hostOs: `${os.platform()} ${os.release()} ${os.arch()}`, node: process.version, docker: docker('version', '--format', '{{.Server.Version}}'), dockerKernel: docker('info', '--format', '{{.KernelVersion}}'), runtimeImageId: manifest.runtimeImageId, }, methodology: { fixture: 'One non-admin user owning 50 suspended server records, one node, one nest, one egg, one allocation per server. No Wings calls or running game containers in the measured stacks.', sharedConfiguration: 'PHP 8.5, four static FPM workers, OPcache 128 MiB, PHP memory_limit 256 MiB; nginx one worker with gzip level 6; config/route/view caches; one queue worker. Each panel 2 CPU quota cores/1 GiB; each MariaDB 1 core/512 MiB; each Redis 0.5 core/128 MiB. REDIS_CLIENT=phpredis on both. Production mode, debug and telemetry off, no extra addons.', browser: 'Headless Chromium, 1440x900, deviceScaleFactor 1, no CPU or network throttling, localhost Docker port forwarding. One warm-up pair discarded; ten measured pairs per panel in alternating order. Cold uses a new context restored from an authenticated session; warm reloads the same page/context. Server/OS caches remain warm. Wait for all 50 server names, then 2 seconds to observe paints. Third-party requests are blocked identically to isolate the local panel; blocked URLs are recorded. FCP/LCP are navigation-to-paint; dashboardReady is measured when all server names exist in DOM, a custom application milestone. Heap is CDP JSHeapUsedSize after an explicit garbage collection; not total browser RAM.', api: 'GET /api/client/account and GET /api/client?per_page=50, valid user API key, HTTP/1.1 persistent connections, uncompressed response bodies, concurrency 1/8/16; 10-second closed-loop windows, three repetitions with alternating panel order. Two-second warm-up before each run. No new requests after the window, in-flight requests drained and included in elapsed time. Validate every HTTP status and JSON result. Rate limits raised to 1,000,000/min on both disposable panels. Successes per actual elapsed second, not offered requests per second or a maximum capacity claim.', memory: 'cgroup v2 memory.current minus inactive_file, clamped to zero (Docker-style working set). Separately retain raw usage bytes. Ten 1-second-spaced samples per window, three windows each after warm-up, idle and during concurrency-16 server-list load. Each sample records panel, database and Redis, and their sum. Peak is the maximum observed sample, not the kernel high-water mark. No application restarts or forced PHP GC between windows. Browser heap and server working set are different metrics.', limitations: 'Single shared Windows/WSL2 workstation; existing unrelated game containers remain running. No CPU pinning, controlled network emulation, TLS, WAN latency, mobile device, endurance test or real-player load. This is a controlled local configuration comparison of pinned distributions, not their default deployment presets or proof of general production capacity.', }, panels: {}, apiRuns: [], browserRuns: [], memoryRuns: [], }; for (const family of families) { const panel = manifest.panels[family]; report.panels[family] = { ...panel, container: undefined, port: undefined, imageId: docker('inspect', panel.container, '--format', '{{.Image}}'), php: docker('exec', panel.container, 'php', '-r', 'echo PHP_VERSION;'), nginx: docker('exec', panel.container, 'sh', '-c', 'nginx -v 2>&1'), }; } const save = () => { fs.mkdirSync('public/benchmarks', { recursive: true }); fs.writeFileSync('public/benchmarks/runtime.json', JSON.stringify(report, null, 2) + '\n'); }; const endpoints = [ { id: 'account', path: '/api/client/account', valid: body => body.object === 'user' && body.attributes.username === 'benchmark' }, { id: 'servers', path: '/api/client?per_page=50', valid: body => body.object === 'list' && body.data.length === 50 && body.meta.pagination.total === 50 }, ]; async function load(family, endpoint, concurrency, durationMs) { const agent = new http.Agent({ keepAlive: true, maxSockets: concurrency, maxFreeSockets: concurrency }); const latencies = [], statuses = {}, errors = {}; let successes = 0, responseBytes = 0; const loop = monitorEventLoopDelay({ resolution: 10 }); loop.enable(); const started = performance.now(); const stopAt = started + durationMs; const request = () => new Promise(resolve => { const start = performance.now(); const req = http.get(`http://127.0.0.1:${manifest.panels[family].port}${endpoint.path}`, { agent, headers: { Authorization: `Bearer ${credentials[family].token}`, Accept: 'application/json', 'Accept-Encoding': 'identity' }, timeout: 10000, }, res => { const chunks = []; res.on('data', chunk => chunks.push(chunk)); res.on('end', () => { const elapsed = performance.now() - start; statuses[res.statusCode] = (statuses[res.statusCode] || 0) + 1; try { const bytes = Buffer.concat(chunks); assert.equal(res.statusCode, 200); assert.ok(endpoint.valid(JSON.parse(bytes.toString('utf8')))); successes++; responseBytes += bytes.length; latencies.push(elapsed); } catch { errors.invalidResponse = (errors.invalidResponse || 0) + 1; } resolve(); }); res.on('error', error => { errors[error.code || 'responseError'] = (errors[error.code || 'responseError'] || 0) + 1; resolve(); }); }); req.on('timeout', () => req.destroy(new Error('Request timeout'))); req.on('error', error => { errors[error.code || error.message] = (errors[error.code || error.message] || 0) + 1; resolve(); }); }); await Promise.all(Array.from({ length: concurrency }, async () => { while (performance.now() < stopAt) await request(); })); const elapsedMs = performance.now() - started; loop.disable(); agent.destroy(); assert.ok(successes > 0, `${family} ${endpoint.id}: no successful responses; ${JSON.stringify({ statuses, errors })}`); assert.equal(Object.values(errors).reduce((a, b) => a + b, 0), 0, `${family}: request errors`); return { elapsedMs, successes, statuses, errors, responseBytes, requestsPerSecond: successes / (elapsedMs / 1000), latencyMs: summary(latencies), latencySamplesMs: latencies, clientEventLoopDelayMs: { mean: loop.mean / 1e6, max: loop.max / 1e6 } }; } // Browser phase precedes load tests so heap/paint sampling does not contend with them. const browser = await chromium.launch({ headless: true }); report.environment.browser = browser.version(); report.environment.playwright = JSON.parse(fs.readFileSync('node_modules/playwright/package.json')).version; const states = {}; const names = Array.from({ length: 50 }, (_, i) => `Benchmark Server ${String(i + 1).padStart(2, '0')}`); try { for (const family of families) { const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, locale: 'en-GB' }); const page = await context.newPage(); await page.goto(`http://127.0.0.1:${manifest.panels[family].port}/auth/login`); await page.locator('input[name="username"]').fill(credentials[family].username); await page.locator('input[name="password"]').fill(credentials[family].password); await page.locator('button[type="submit"]').click(); await page.waitForURL(url => url.pathname === '/', { timeout: 30000 }); await page.waitForFunction(names => names.every(name => document.body.textContent.includes(name)), names, { timeout: 30000 }); states[family] = await context.storageState(); await context.close(); } for (let round = -1; round < 10; round++) { for (const family of round % 2 === 0 ? families : [...families].reverse()) { const context = await browser.newContext({ storageState: states[family], viewport: { width: 1440, height: 900 }, locale: 'en-GB', deviceScaleFactor: 1 }); const base = `http://127.0.0.1:${manifest.panels[family].port}`; const blocked = new Set(), failures = [], pageErrors = []; // Context routing disables HTTP cache; use CDP interception only for external origins. const page = await context.newPage(); const cdp = await context.newCDPSession(page); await cdp.send('Performance.enable'); await cdp.send('Network.enable'); await cdp.send('Network.setBlockedURLs', { urls: ['https://*', 'http://fonts.*', 'http://www.*'] }); page.on('requestfailed', request => { if (new URL(request.url()).origin !== base) blocked.add(request.url().split('?')[0]); else failures.push({ path: new URL(request.url()).pathname, error: request.failure()?.errorText }); }); page.on('pageerror', error => pageErrors.push(error.message)); await page.addInitScript(() => { window.__benchmarkLcp = null; new PerformanceObserver(list => { for (const entry of list.getEntries()) window.__benchmarkLcp = entry.startTime; }).observe({ type: 'largest-contentful-paint', buffered: true }); }); for (const cache of ['cold', 'warm']) { if (cache === 'cold') await page.goto(base, { waitUntil: 'domcontentloaded' }); else await page.reload({ waitUntil: 'domcontentloaded' }); const ready = await page.waitForFunction(names => { if (names.every(name => document.body.textContent.includes(name))) return performance.now(); return false; }, names, { timeout: 30000, polling: 'raf' }); const dashboardReadyMs = await ready.jsonValue(); await page.waitForLoadState('load'); await delay(2000); const timing = await page.evaluate(() => { const nav = performance.getEntriesByType('navigation')[0]; return { ttfbMs: nav.responseStart - nav.startTime, domContentLoadedMs: nav.domContentLoadedEventEnd, loadMs: nav.loadEventEnd, fcpMs: performance.getEntriesByName('first-contentful-paint')[0]?.startTime, lcpMs: window.__benchmarkLcp, resourceTransferBytes: performance.getEntriesByType('resource').reduce((total, resource) => total + resource.transferSize, 0) }; }); await cdp.send('HeapProfiler.collectGarbage'); const { metrics } = await cdp.send('Performance.getMetrics'); const heapUsedBytes = metrics.find(metric => metric.name === 'JSHeapUsedSize').value; assert.ok(timing.fcpMs > 0 && timing.lcpMs > 0 && timing.loadMs > 0); assert.deepEqual(failures, [], `${family}: local resource requests failed`); assert.deepEqual(pageErrors, [], `${family}: browser errors`); if (round >= 0) report.browserRuns.push({ family, round: round + 1, cache, ...timing, dashboardReadyMs, heapUsedBytes, blockedExternal: [...blocked] }); if (round === 0 && cache === 'cold') { fs.mkdirSync('artifacts/runtime-benchmark', { recursive: true }); await page.screenshot({ path: `artifacts/runtime-benchmark/${family}.png`, fullPage: true }); } } await context.close(); console.log(`${family} browser ${round < 0 ? 'warm-up' : round + 1}/10 complete`); save(); } } } finally { await browser.close(); } for (let round = 0; round < 3; round++) { for (const endpoint of endpoints) { for (const concurrency of [1, 8, 16]) { for (const family of round % 2 === 0 ? families : [...families].reverse()) { await load(family, endpoint, concurrency, 2000); const result = await load(family, endpoint, concurrency, 10000); report.apiRuns.push({ family, round: round + 1, endpoint: endpoint.id, path: endpoint.path, concurrency, ...result }); console.log(`${family} API ${endpoint.id} c=${concurrency} run=${round + 1}: ${result.requestsPerSecond.toFixed(1)} successful req/s, p95 ${result.latencyMs.p95.toFixed(1)} ms`); save(); } } } } async function memorySample(family) { const containers = [manifest.panels[family].container, `panel-runtime-benchmark-${family}-database-1`, `panel-runtime-benchmark-${family}-cache-1`]; const names = ['panel', 'database', 'redis']; const values = await Promise.all(containers.map(async (container, i) => { const { stdout } = await exec('docker', ['exec', container, 'sh', '-c', 'cat /sys/fs/cgroup/memory.current; cat /sys/fs/cgroup/memory.stat'], { windowsHide: true }); const usageBytes = Number(stdout.split('\n')[0]); const inactiveFileBytes = Number(stdout.match(/^inactive_file (\d+)$/m)[1]); return [names[i], { usageBytes, inactiveFileBytes, workingSetBytes: Math.max(0, usageBytes - inactiveFileBytes) }]; })); const parts = Object.fromEntries(values); return { sampledAt: new Date().toISOString(), ...parts, stackWorkingSetBytes: values.reduce((sum, [, value]) => sum + value.workingSetBytes, 0) }; } for (let round = 0; round < 3; round++) { for (const family of round % 2 === 0 ? families : [...families].reverse()) { for (const state of ['idle', 'loaded']) { await load(family, endpoints[1], 16, 2000); if (state === 'idle') await delay(3000); const samplesPromise = (async () => { const samples = []; for (let i = 0; i < 10; i++) { samples.push(await memorySample(family)); await delay(1000); } return samples; })(); const [samples, loadResult] = await Promise.all([samplesPromise, state === 'loaded' ? load(family, endpoints[1], 16, 15000) : null]); report.memoryRuns.push({ family, round: round + 1, state, samples, loadSuccesses: loadResult?.successes ?? 0 }); console.log(`${family} memory ${state} run=${round + 1}: panel ${ (summary(samples.map(sample => sample.panel.workingSetBytes)).median / 2 ** 20).toFixed(1)} MiB`); save(); } } } for (const family of families) { report.panels[family].browser = Object.fromEntries(['cold', 'warm'].map(cache => { const runs = report.browserRuns.filter(run => run.family === family && run.cache === cache); return [cache, Object.fromEntries(['ttfbMs', 'domContentLoadedMs', 'loadMs', 'fcpMs', 'lcpMs', 'dashboardReadyMs', 'heapUsedBytes'].map(metric => [metric, summary(runs.map(run => run[metric]))]))]; })); report.panels[family].api = Object.fromEntries(endpoints.map(endpoint => [endpoint.id, Object.fromEntries([1, 8, 16].map(concurrency => { const runs = report.apiRuns.filter(run => run.family === family && run.endpoint === endpoint.id && run.concurrency === concurrency); return [concurrency, { requestsPerSecond: summary(runs.map(run => run.requestsPerSecond)), medianLatencyMs: summary(runs.map(run => run.latencyMs.median)), p95LatencyMs: summary(runs.map(run => run.latencyMs.p95)), successes: runs.reduce((sum, run) => sum + run.successes, 0), errors: runs.reduce((sum, run) => sum + Object.values(run.errors).reduce((a, b) => a + b, 0), 0) }]; }))])); report.panels[family].memory = Object.fromEntries(['idle', 'loaded'].map(state => { const samples = report.memoryRuns.filter(run => run.family === family && run.state === state).flatMap(run => run.samples); return [state, { panelWorkingSetBytes: summary(samples.map(sample => sample.panel.workingSetBytes)), stackWorkingSetBytes: summary(samples.map(sample => sample.stackWorkingSetBytes)), sampleCount: samples.length }]; })); } report.completedAt = new Date().toISOString(); report.harnessSha256 = createHash('sha256').update(fs.readFileSync(new URL(import.meta.url))).digest('hex'); save(); console.log('Completed: public/benchmarks/runtime.json');