Files
shacraft-core/scripts/benchmark_server.mjs

207 lines
6.5 KiB
JavaScript

// Fixed local release-server budgets. This is a reproducible Shacraft-only workload,
// not a comparison with Minecraft and not a production player-capacity claim.
import assert from "node:assert/strict";
import path from "node:path";
import {readFile} from "node:fs/promises";
import { harness, Client, delay, options, root } from "./server-harness.mjs";
const opts = options();
opts.binary ??= path.join(root, "target/release/shacraft-server");
const budgets = { ten_client_rss_bytes: 256 * 1024 * 1024, p95_tick_ms: 50 };
const duration = Number(opts["sample-ms"] || 15000);
assert.ok(
duration >= 3000 && duration <= 120000,
"--sample-ms must be 3000..120000",
);
const h = await harness(opts),
clients = [],
samples = [],
phases = [];
let reportWritten = false;
const percentile = (values, p) =>
[...values].sort((a, b) => a - b)[
Math.min(values.length - 1, Math.floor((values.length - 1) * p))
];
function summary(name, list) {
const rss = list.map((s) => s.rss_bytes).filter(Number.isFinite),
ticks = list.map((s) => s.tick_ms).filter(Number.isFinite);
assert.ok(
rss.length && ticks.length,
"Measured RSS and tick durations are required",
);
const last = list.at(-1);
return {
phase: name,
samples: list.length,
rss_bytes: {
p50: percentile(rss, 0.5),
p95: percentile(rss, 0.95),
max: Math.max(...rss),
},
tick_ms: {
p50: percentile(ticks, 0.5),
p95: percentile(ticks, 0.95),
max: Math.max(...ticks),
},
process_high_water_bytes: last.process_high_water_bytes,
players: last.players,
active_worlds: last.active_worlds,
world_count: last.world_count,
storage: last.storage,
slow_clients_disconnected: last.slow_clients_disconnected,
queued_client_bytes: last.queued_client_bytes,
};
}
async function sample(name, milliseconds = duration) {
const list = [],
end = Date.now() + milliseconds;
while (Date.now() < end) {
const m = await h.get("/api/metrics");
const processStatus = await readFile(`/proc/${h.pid}/status`, "utf8");
const highWater = Number(processStatus.match(/^VmHWM:\s+(\d+)/m)?.[1]) * 1024;
assert.ok(Number.isFinite(highWater), "Linux process high-water RSS required");
m.process_high_water_bytes = highWater;
const s = { phase: name, time: Date.now(), ...m };
samples.push(s);
list.push(s);
await delay(100);
}
const result = summary(name, list);
phases.push(result);
return result;
}
let mover;
async function moving(count, distinct) {
const manifest = await h.get("/api/manifest");
for (let i = 0; i < count; i++) {
const c = new Client(h.url);
clients.push(c);
await c.join(
manifest,
`test_bench_${String(distinct ? i : 0).padStart(3, "0")}`,
`Bench${i}`,
);
}
const started = Date.now();
mover = setInterval(() => {
const t = (Date.now() - started) / 1000;
for (let i = 0; i < clients.length; i++)
clients[i].input({
yaw: (t * 0.75 + (i * Math.PI) / 5) % (Math.PI * 2),
pitch: -0.1,
forward: 1,
strafe: 0,
jump: Math.floor(t) % 5 === 0,
});
}, 50);
await delay(2000);
}
async function disconnect() {
clearInterval(mover);
await Promise.all(clients.splice(0).map((c) => c.close()));
for (let n = 0; n < 50; n++) {
const m = await h.get("/api/metrics");
if (m.players === 0) return;
await delay(100);
}
throw Error("Players did not disconnect within 5 seconds");
}
try {
let created = 0;
for (const count of [1, 10, 100]) {
while (created < count) {
await h.control("world.create", {
world: `test_bench_${String(created).padStart(3, "0")}`,
template: "lobby",
});
created++;
}
await delay(1000);
const phase = await sample(`idle_${count}_template_forks`, 3000);
assert.equal(phase.players, 0);
assert.equal(phase.active_worlds, 0);
}
await moving(10, false);
await sample("10_moving_clients_shared_world");
await disconnect();
await moving(10, true);
await sample("10_moving_clients_separate_worlds");
await disconnect();
await delay(1500);
const idle = await sample("after_disconnect_idle", 3000);
assert.equal(idle.players, 0);
assert.equal(idle.active_worlds, 0);
const active = phases.filter((p) => p.phase.includes("moving_clients"));
const budgetResults = {
rss_passed: active.every(
(p) => p.rss_bytes.max <= budgets.ten_client_rss_bytes && p.process_high_water_bytes <= budgets.ten_client_rss_bytes,
),
tick_passed: active.every((p) => p.tick_ms.p95 <= budgets.p95_tick_ms),
};
const cacheSamples = samples.map((s) => {
const storage = s.storage;
return {
entries: storage.cache_entries ?? storage.cache?.entries,
capacity:
storage.cache_capacity_sections ??
storage.cache_capacity ??
storage.cache_sections ??
storage.cache?.capacity,
};
});
const recognizedCache = cacheSamples.filter(
(s) => Number.isFinite(s.entries) && Number.isFinite(s.capacity),
);
const cacheBounded =
recognizedCache.length === cacheSamples.length &&
recognizedCache.every((s) => s.entries <= s.capacity);
const passed =
budgetResults.rss_passed && budgetResults.tick_passed && cacheBounded;
await h.report({
passed,
profile: h.binary.includes("/release/")
? "release"
: "custom binary; verify optimization separately",
binary: h.binary,
budgets,
budget_results: budgetResults,
cache_bounded: cacheBounded,
workload: {
fork_counts: [1, 10, 100],
players: 10,
input_hz: 20,
sampling_interval_ms: 100,
active_sample_ms: duration,
template: "lobby",
scope:
"local seeded map; storage and authoritative movement; not full vanilla mechanics",
},
limitations: [
"No comparison with Minecraft or a modded server",
"Tick metrics measure server step work, not end-to-end latency",
"Process RSS excludes browser clients and operating-system disk cache",
"Cache entries remain bounded after disconnect; zero active worlds does not imply zero retained cache",
],
phases,
samples,
});
reportWritten = true;
assert.ok(
passed,
"Predetermined benchmark budget or bounded-cache check failed; inspect report",
);
} catch (error) {
if (!reportWritten)
await h.report({
passed: false,
budgets,
error: error.stack,
server_logs: h.logs(),
phases,
samples,
});
throw error;
} finally {
await disconnect().catch(() => {});
await h.stop();
}