207 lines
6.5 KiB
JavaScript
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();
|
|
}
|