Files
homarr/scripts/benchmarks/stress-restore.mts
T
Thomas Camlong 3cc6ff33df perf(docker): stop loading ssh2, which allocates a 16 MiB WASM heap nobody uses
Profiling at the memory high-water mark - not at idle, which is what every earlier
round measured - found the largest single retainer in every snapshot was one V8
Context holding a 16.00 MiB ArrayBuffer. Expanding it through the dominator tree
named it: ssh2's crypto module, whose POLY1305_WASM_MODULE instantiates a
WebAssembly memory for ChaCha20-Poly1305 as soon as it is required.

Homarr has no SSH feature and does not depend on ssh2. The chain is
dockerode -> docker-modem -> ssh2, and docker-modem/lib/modem.js requires './ssh'
at module scope while using it in exactly one place, already guarded by
`protocol === 'ssh'`. A pnpm patch moves the require inside that branch. SSH-based
Docker hosts still work - require() is cached, so they pay it once - and every
socket-based install, which is everyone else, never loads it.

Measured over four alternated runs on a restored real backup, external memory:

                     before        after
  board loaded    25.4, 25.1    8.3, 11.2
  nav-01          28.5, 25.4   10.1,  8.6
  multi-tab       31.1, 35.8   13.0, 10.0
  post-soak       21.4, 21.5    5.5,  7.2

Consistently -15 to -18 MiB, ranges non-overlapping, and 30 fewer files loaded
(1470 -> 1440).

Peak RSS does NOT improve, and that is worth stating plainly: V8 sizes its heap
against available headroom, so freeing native memory lets it grow further before
collecting. What changes is the composition - a fixed 16 MiB of unusable WASM
buffer becomes headroom the app can actually use, which matters under a container
memory limit but does not move the peak number.

Also adds the instruments this needed. A heap snapshot can never explain a peak,
because V8 runs a full GC before serialising one and reports only survivors:

  - memory-probe.cjs gains in-process allocation sampling, on-demand snapshots via
    v8.writeHeapSnapshot, an smaps endpoint, and require-graph recording.
  - analyze-allocations.mjs diffs two stages to isolate what one stage allocated.
  - analyze-smaps-stages.mjs splits each stage's RSS into JS heap, JIT, native and
    file-backed pages.
  - analyze-heap-dominators.mjs can now expand the largest retainer, which is what
    identified ssh2. A plain graph walk escapes through __proto__ and is useless.

One trap recorded in the findings: taking the peak snapshot over CDP delivers it as
JS string chunks and added ~134 MiB of ArrayBuffers to the process being measured,
producing a fake 693 MiB reading. v8.writeHeapSnapshot streams from C++ instead, and
the probe now reports its own observerCostMiB so the cost of looking is visible.
2026-08-09 22:17:25 +02:00

666 lines
30 KiB
TypeScript

/**
* Dashboard stress benchmark.
*
* Measures container memory for a *populated* Homarr rather than an empty one:
* boots a fresh container, restores a real backup through the onboarding
* restore UI (the same flow a user takes, including the "I understand"
* confirmation), signs in, opens the busiest board, then hammers navigation and
* soaks idle while sampling cgroup memory.
*
* The backup is supplied by path and never copied into the repo — it contains
* real integration secrets.
*
* Usage:
* STRESS_IMAGE=homarr-bench:baseline \
* STRESS_BACKUP_ZIP=~/Downloads/homarr-backup.zip \
* node --experimental-strip-types scripts/benchmarks/stress-restore.mts
*/
import { execFile } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { chromium } from "@playwright/test";
import type { Browser, Page } from "@playwright/test";
import { parseStressSnapshot, stressMemoryScript, summarizeStress, toMiB } from "./stress-restore-lib.mts";
import type { StressCheckpoint } from "./stress-restore-lib.mts";
const execFileAsync = promisify(execFile);
const requireEnv = (name: string) => {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
};
const image = requireEnv("STRESS_IMAGE");
const backupZip = path.resolve(requireEnv("STRESS_BACKUP_ZIP").replace(/^~/, os.homedir()));
const label = process.env.STRESS_LABEL ?? image.replace(/[^a-z0-9]+/gi, "-");
const memoryLimit = process.env.STRESS_MEMORY_LIMIT ?? "2g";
const heapMb = process.env.STRESS_HEAP_MB ?? "";
const nodeOptions = process.env.STRESS_NODE_OPTIONS ?? "";
const navIterations = Number(process.env.STRESS_NAV_ITERATIONS ?? 6);
const soakMs = Number(process.env.STRESS_SOAK_MS ?? 120_000);
const soakIntervalMs = Number(process.env.STRESS_SOAK_INTERVAL_MS ?? 20_000);
const keepContainer = process.env.STRESS_KEEP_CONTAINER === "true";
/** Dump a browser heap snapshot here (analyse with analyze-react-fibers.mjs). */
const clientSnapshotPath = process.env.STRESS_CLIENT_SNAPSHOT;
/** Dump a *server* heap snapshot taken at peak, with all tabs still open. */
const peakSnapshotPath = process.env.STRESS_PEAK_SNAPSHOT
? path.resolve(process.env.STRESS_PEAK_SNAPSHOT)
: undefined;
/**
* Extra `docker run` wiring, so a diagnostics-only preload can be injected into a
* shipped image without rebuilding it. Used to mount scripts/benchmarks at /probe and
* point NODE_OPTIONS at memory-probe.cjs, which is what makes per-V8-space and
* per-module attribution possible at every capture stage.
* STRESS_MOUNT="hostPath:containerPath:ro" (comma-separated for several)
* STRESS_ENV="KEY=value,KEY2=value2"
*/
const extraMounts = (process.env.STRESS_MOUNT ?? "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
const extraEnv = (process.env.STRESS_ENV ?? "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
const outputDirectory = path.resolve(process.env.STRESS_OUTPUT ?? `.bench/stress/${label}`);
const password = process.env.STRESS_PASSWORD ?? "demodemo";
const username = process.env.STRESS_USERNAME ?? "demo";
/** Board to stress. The backup's busiest board (28 items / 16 widget kinds). */
const boardName = process.env.STRESS_BOARD ?? "default";
/**
* Real usage is several tabs on several boards, not one tab on one board, and the
* browser heap turned out to matter more than the container's. Comma-separated.
*/
const boardNames = (process.env.STRESS_BOARDS ?? boardName)
.split(",")
.map((name) => name.trim())
.filter(Boolean);
const tabsPerBoard = Number(process.env.STRESS_TABS_PER_BOARD ?? 1);
const containerName = `homarr-stress-${Date.now()}`;
const checkpoints: StressCheckpoint[] = [];
let startedAt = Date.now();
const log = (message: string) => console.log(`[stress:${label}] ${message}`);
/** Port the injected memory probe serves JSON on, if one was wired up via STRESS_ENV. */
const probePort = /HOMARR_PROBE_PORT=(\d+)/.exec(extraEnv.join(","))?.[1];
/**
* Pulls the probe's V8-space and loaded-module attribution alongside the coarse memory
* numbers. Saved per stage so the *delta* between stages is attributable — "the 90 MiB
* that appeared at sign-in was old-space objects, not code" is a different problem from
* "the code got bigger", and only per-stage capture can tell them apart.
*/
const fetchProbeAsync = async (probePath: string) => {
const script = `fetch("http://127.0.0.1:${probePort}${probePath}").then((r) => r.text()).then((t) => console.log(t)).catch((e) => console.log(JSON.stringify({ error: String(e && e.message) })));`;
// NODE_OPTIONS is cleared so this helper does not also run the server's --require preload.
const { stdout } = await execFileAsync("docker", ["exec", "-e", "NODE_OPTIONS=", containerName, "node", "-e", script], {
maxBuffer: 32 * 1024 * 1024,
}).catch((error: Error) => ({ stdout: JSON.stringify({ error: error.message }) }));
return stdout;
};
const captureProbeAsync = async (name: string) => {
if (!probePort) return;
await writeFile(path.join(outputDirectory, `${name}.probe.json`), await fetchProbeAsync("/"));
// Buffer allocation sites, when tracking is enabled. Cumulative, so the useful reading is
// the difference between two stages: that isolates what one workload allocated.
const buffers = await fetchProbeAsync("/buffers");
if (!buffers.includes('"disabled"')) {
await writeFile(path.join(outputDirectory, `${name}.buffers.json`), buffers);
}
const fetches = await fetchProbeAsync("/fetches");
if (!fetches.includes('"disabled"')) {
await writeFile(path.join(outputDirectory, `${name}.fetches.json`), fetches);
}
// Allocation totals are cumulative since boot, so a single capture cannot separate "the
// app loaded its code" from "four tabs churned through memory". Capturing at every stage
// makes those subtractable.
const allocations = await fetchProbeAsync("/allocations");
if (!allocations.includes('"disabled"')) {
await writeFile(path.join(outputDirectory, `${name}.allocations.json`), allocations);
}
// The address-space map is the only place JS heap, JIT code, native malloc and file-backed
// pages are distinguishable, and `rss - heapTotal - external` grew by ~85 MiB between boot
// and peak with no JS-side explanation. Captured every stage because which stage is the
// true high-water mark is not known in advance — it is not always the multi-tab one.
const maps = await fetchProbeAsync("/maps");
if (!maps.includes('"error"')) {
await writeFile(path.join(outputDirectory, `${name}.smaps.json`), maps);
}
};
/**
* Grabs the two artefacts that only make sense at the high-water mark, with load still on:
* a heap snapshot (what peak memory *retains*) and the cumulative allocation profile (what
* peak memory *churned through*, which no snapshot can show because V8 GCs before it
* serialises one). Set STRESS_PEAK_SNAPSHOT to a host path to keep the snapshot.
*/
const capturePeakAsync = async () => {
if (!probePort) return;
// Mappings first, and before any snapshot: this is the only reading that separates JS heap
// from native, JIT and file-backed pages, and it must reflect the loaded process rather
// than the process plus whatever the act of snapshotting allocated.
const maps = await fetchProbeAsync("/maps");
if (!maps.includes('"error"')) {
await writeFile(path.join(outputDirectory, "peak.smaps.json"), maps);
log("peak: address-space map captured");
}
const allocations = await fetchProbeAsync("/allocations");
if (!allocations.includes('"disabled"')) {
await writeFile(path.join(outputDirectory, "peak.allocations.json"), allocations);
log("peak: allocation profile captured");
}
if (!peakSnapshotPath) return;
const inContainer = "/tmp/peak.heapsnapshot";
const result = await fetchProbeAsync(`/snapshot?path=${encodeURIComponent(inContainer)}`);
if (result.includes('"error"')) {
log(`peak: snapshot failed — ${result.slice(0, 160)}`);
return;
}
await execFileAsync("docker", ["cp", `${containerName}:${inContainer}`, peakSnapshotPath]);
await execFileAsync("docker", ["exec", containerName, "rm", "-f", inContainer]).catch(() => undefined);
log(`peak: heap snapshot saved to ${peakSnapshotPath}`);
};
const captureAsync = async (name: string) => {
const { stdout } = await execFileAsync("docker", ["exec", containerName, "sh", "-c", stressMemoryScript], {
maxBuffer: 8 * 1024 * 1024,
});
await writeFile(path.join(outputDirectory, `${name}.txt`), stdout);
await captureProbeAsync(name);
const checkpoint = parseStressSnapshot(name, Date.now() - startedAt, stdout);
checkpoints.push(checkpoint);
log(`${name}: container=${toMiB(checkpoint.container.currentBytes)} MiB`);
return checkpoint;
};
const waitForReadyAsync = async (baseUrl: string, timeoutMs = 180_000) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(`${baseUrl}/api/health/ready`);
if (response.ok) return;
} catch {
// Socket not listening yet.
}
await new Promise((resolve) => setTimeout(resolve, 1_000));
}
throw new Error(`Container never became ready at ${baseUrl}`);
};
/**
* Ensures the benchmark user can open the target board, and reports what it had
* to change. A backup where that user is already an admin with the board as
* their home board reports "none" — which keeps the run honest about how much
* the harness touched the restored state.
*/
const applyAccessFixtureAsync = async () => {
const script = `
const Database = require("/app/node_modules/better-sqlite3");
const db = new Database("/appdata/db/db.sqlite");
const user = db.prepare("SELECT id, home_board_id FROM user WHERE name = ?").get(process.argv[1]);
if (!user) throw new Error("user not found: " + process.argv[1]);
const board = db.prepare("SELECT id FROM board WHERE name = ?").get(process.argv[2]);
if (!board) throw new Error("board not found: " + process.argv[2]);
const adminGroup = db.prepare("SELECT group_id AS id FROM groupPermission WHERE permission = 'admin' LIMIT 1").get();
const changes = [];
if (adminGroup) {
const already = db
.prepare("SELECT 1 AS ok FROM groupMember WHERE group_id = ? AND user_id = ?")
.get(adminGroup.id, user.id);
if (!already) {
db.prepare("INSERT INTO groupMember (group_id, user_id) VALUES (?, ?)").run(adminGroup.id, user.id);
changes.push("granted-admin");
}
}
if (user.home_board_id !== board.id) {
db.prepare("UPDATE user SET home_board_id = ?, mobile_home_board_id = ? WHERE id = ?").run(board.id, board.id, user.id);
changes.push("set-home-board");
}
db.close();
console.log("FIXTURE:" + (changes.length ? changes.join(",") : "none"));
`;
const { stdout } = await execFileAsync("docker", ["exec", containerName, "node", "-e", script, username, boardName]);
const applied = /FIXTURE:(.*)/.exec(stdout)?.[1]?.trim();
if (!applied) throw new Error(`Access fixture failed: ${stdout}`);
log(`access fixture changes: ${applied}`);
return applied;
};
const restoreBackupAsync = async (page: Page, baseUrl: string) => {
// networkidle (not domcontentloaded): the toggle below is a client component, and
// clicking it before hydration silently does nothing.
await page.goto(`${baseUrl}/init`, { waitUntil: "networkidle" });
// The onboarding start step hides the restore dropzone behind a toggle. Retry the
// click until the dropzone's file input exists, so a slow hydration cannot make the
// whole run fail.
const expandRestore = page.getByRole("button", { name: /restore.*sqlite|sqlite.*restore|restore.*backup/i });
const fileInput = page.locator('input[type="file"]');
for (let attempt = 1; attempt <= 3; attempt++) {
if ((await fileInput.count()) > 0) break;
await expandRestore.first().click({ timeout: 30_000 });
await fileInput
.first()
.waitFor({ state: "attached", timeout: 10_000 })
.catch(() => log(`restore toggle click ${attempt} did not reveal the dropzone, retrying`));
}
await fileInput.first().waitFor({ state: "attached", timeout: 30_000 });
await fileInput.first().setInputFiles(backupZip);
log("backup uploaded, waiting for analysis");
const continueButton = page.getByRole("button", { name: /continue to restore|continue/i });
await continueButton.first().waitFor({ state: "visible", timeout: 180_000 });
await continueButton.first().click();
const confirmInput = page.locator('input[placeholder="I understand"]');
await confirmInput.waitFor({ state: "visible", timeout: 30_000 });
await confirmInput.fill("I understand");
log('typed "I understand"');
const submit = page.getByRole("button", { name: /restore|confirm/i }).last();
await submit.click();
log("restore submitted; server will exit and be restarted by run.sh");
};
/**
* Control: the restored boards are private, so an anonymous visitor must not see
* one. Without this, "28 widgets rendered" could mean the board was public and
* the sign-in step never actually authenticated anything.
*/
const verifyAnonymousAccessDeniedAsync = async (browser: Browser, baseUrl: string) => {
const context = await browser.newContext();
try {
const page = await context.newPage();
await page.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" });
const boardVisible = await page
.locator("[data-homarr-dev-benchmark-board]")
.waitFor({ state: "visible", timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (boardVisible) {
throw new Error(
`Anonymous request to /boards/${boardName} rendered the board — it is public, so the ` +
`authenticated measurement would prove nothing about sign-in.`,
);
}
log(`control: anonymous access to /boards/${boardName} denied (landed on ${new URL(page.url()).pathname})`);
return new URL(page.url()).pathname;
} finally {
await context.close();
}
};
const loginAsync = async (page: Page, baseUrl: string) => {
// networkidle: submitting before the form hydrates is a no-op click, which then
// looks exactly like an authentication failure.
await page.goto(`${baseUrl}/auth/login`, { waitUntil: "networkidle" });
for (let attempt = 1; attempt <= 3; attempt++) {
// Selectors mirror e2e/lazy-widgets.spec.ts: getByLabel(/password/i) is ambiguous
// because Mantine's PasswordInput also renders a visibility-toggle button.
await page.getByLabel("Username").fill(username);
await page.locator("#password").fill(password);
await page.locator("css=button[type='submit']").first().click();
try {
// Poll location rather than page.waitForURL: sign-in redirects client-side, so
// waitForURL's implicit "load" state is never reached for that navigation.
await page.waitForFunction(() => !window.location.pathname.includes("/auth/login"), undefined, {
timeout: 20_000,
});
// Leaving /auth/login is necessary but not sufficient — require the session
// cookie Auth.js sets, so a redirect for any other reason cannot pass as auth.
const sessionCookie = (await page.context().cookies()).find((cookie) => cookie.name.endsWith("session-token"));
if (!sessionCookie) {
throw new Error(`Navigated away from /auth/login but no session-token cookie was set for ${username}`);
}
log(`signed in as ${username} (session cookie "${sessionCookie.name}" present)`);
return sessionCookie.name;
} catch {
if (attempt === 3) {
const error = await page
.locator('[role="alert"], .mantine-Alert-message')
.first()
.innerText()
.catch(() => "(no alert on page)");
throw new Error(`Login did not navigate away from /auth/login after 3 attempts. Page alert: ${error}`);
}
log(`login attempt ${attempt} did not navigate (likely a pre-hydration click), retrying`);
}
}
};
/**
* Browser-side heap, via CDP. The container figure says nothing about this, and on a
* real session the tab heap turned out to be the larger number of the two.
*/
const sampleClientHeapAsync = async (page: Page) => {
const cdp = await page.context().newCDPSession(page);
try {
await cdp.send("HeapProfiler.enable");
// Force a GC so the reading is retained memory rather than uncollected garbage.
await cdp.send("HeapProfiler.collectGarbage");
// Performance.getMetrics returns an empty list unless the domain is enabled
// first — it does not throw, so this has to be unconditional.
await cdp.send("Performance.enable");
const { metrics } = await cdp.send("Performance.getMetrics");
const byName = new Map(metrics.map((metric) => [metric.name, metric.value]));
const counted = (name: string) => byName.get(name) ?? 0;
return {
jsHeapUsedBytes: counted("JSHeapUsedSize"),
jsHeapTotalBytes: counted("JSHeapTotalSize"),
documents: counted("Documents"),
nodes: counted("Nodes"),
jsEventListeners: counted("JSEventListeners"),
};
} finally {
await cdp.detach().catch(() => undefined);
}
};
/**
* Clicks into widgets the way a person does — opening whatever detail/live views the
* board exposes. Purely reading a board never exercised these paths.
*/
const interactWithWidgetsAsync = async (page: Page, maxClicks = 6) => {
const clickable = page.locator(
'[data-homarr-dev-benchmark-board] [data-type="item"] button:visible, ' +
'[data-homarr-dev-benchmark-board] [data-type="item"] [role="tab"]:visible',
);
const available = await clickable.count().catch(() => 0);
let clicked = 0;
// Walk spread-out indices: adjacent controls usually belong to the same widget,
// so stepping through hits more distinct widgets for the same number of clicks.
const step = Math.max(1, Math.floor(available / maxClicks));
for (let attempt = 0; attempt < maxClicks; attempt++) {
const index = attempt * step;
if (index >= available) break;
try {
// force: widgets overlay hover controls that Playwright considers obscured;
// the click still exercises the handler, which is what costs memory.
await clickable.nth(index).click({ timeout: 2_000, noWaitAfter: true, force: true });
clicked++;
await page.waitForTimeout(350);
// Only dismiss if something modal actually opened, so we do not close the
// live view we just paid to render.
if (await page.locator('[role="dialog"]:visible').count()) {
await page.keyboard.press("Escape").catch(() => undefined);
}
} catch {
// Widget-specific controls come and go; a miss is not a failure.
}
}
return { available, clicked };
};
/** Streams a browser heap snapshot to disk over CDP, for offline fiber analysis. */
const captureClientSnapshotAsync = async (page: Page, destination: string) => {
const cdp = await page.context().newCDPSession(page);
const chunks: string[] = [];
cdp.on("HeapProfiler.addHeapSnapshotChunk", ({ chunk }) => chunks.push(chunk));
try {
await cdp.send("HeapProfiler.enable");
await cdp.send("HeapProfiler.collectGarbage");
await cdp.send("HeapProfiler.takeHeapSnapshot", { reportProgress: false });
await writeFile(destination, chunks.join(""));
log(`client heap snapshot -> ${destination}`);
} finally {
await cdp.detach().catch(() => undefined);
}
};
const waitForBoardAsync = async (page: Page) => {
const boardSelector = "[data-homarr-dev-benchmark-board]";
await page.locator(boardSelector).waitFor({ state: "visible", timeout: 120_000 });
// Every grid item must have mounted its content (ready or explicitly errored),
// otherwise memory is sampled mid-render and understates the real cost.
await page
.waitForFunction(
(selector) => {
const board = document.querySelector(selector);
const items = [...(board?.querySelectorAll('[data-type="item"]') ?? [])];
return (
items.length > 0 &&
items.every(
(item) =>
item.querySelector("[data-homarr-widget-ready]") !== null ||
item.querySelector("[data-homarr-widget-error]") !== null,
)
);
},
boardSelector,
{ timeout: 120_000 },
)
.catch(() => log("warning: not all widgets reported ready/error before timeout"));
const outcome = await page.evaluate((selector) => {
const board = document.querySelector(selector);
const items = [...(board?.querySelectorAll('[data-type="item"]') ?? [])];
return {
items: items.length,
ready: items.filter((item) => item.querySelector("[data-homarr-widget-ready]") !== null).length,
errored: items.filter((item) => item.querySelector("[data-homarr-widget-error]") !== null).length,
};
}, boardSelector);
log(`board rendered: ${outcome.items} items (${outcome.ready} ready, ${outcome.errored} errored)`);
return outcome;
};
const main = async () => {
await rm(outputDirectory, { recursive: true, force: true });
await mkdir(outputDirectory, { recursive: true });
await readFile(backupZip); // Fail fast if the backup path is wrong.
log(`image=${image} memoryLimit=${memoryLimit} heapMb=${heapMb || "(image default)"}`);
const runArgs = [
"run",
"-d",
"--name",
containerName,
"--cpus",
process.env.STRESS_CPUS ?? "4",
"--memory",
memoryLimit,
"--memory-swap",
memoryLimit,
"-p",
"0:7575",
// Restore re-encrypts the backup's secrets to this key, so any 64-hex value works.
"-e",
`SECRET_ENCRYPTION_KEY=${"0".repeat(64)}`,
// Silences Homarr's own outbound work (update check, icon repos, analytics) so
// the measurement is not perturbed by internet reachability. Integration widget
// requests are NOT gated by this flag, so integration clients still load.
"-e",
"NO_EXTERNAL_CONNECTION=true",
"-e",
"LOG_LEVEL=warn",
];
if (heapMb) runArgs.push("-e", `HOMARR_MAX_OLD_SPACE_SIZE=${heapMb}`);
// Escape hatch for A/B-ing V8 flags against an image whose run.sh does not read
// HOMARR_MAX_OLD_SPACE_SIZE (e.g. measuring the heap cap against an older image).
if (nodeOptions) runArgs.push("-e", `NODE_OPTIONS=${nodeOptions}`);
for (const mount of extraMounts) runArgs.push("-v", mount);
for (const entry of extraEnv) runArgs.push("-e", entry);
runArgs.push(image);
await execFileAsync("docker", runArgs);
startedAt = Date.now();
const browser = await chromium.launch();
try {
const { stdout: portOut } = await execFileAsync("docker", ["port", containerName, "7575"]);
const mappedPort = portOut.trim().split("\n")[0]?.split(":").pop();
if (!mappedPort) throw new Error(`Could not resolve mapped port from: ${portOut}`);
const baseUrl = `http://127.0.0.1:${mappedPort}`;
log(`container up at ${baseUrl}`);
await waitForReadyAsync(baseUrl);
// Let boot-time work (cron registration, first GC) settle before the idle baseline.
await new Promise((resolve) => setTimeout(resolve, 15_000));
await captureAsync("01-boot-idle");
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const page = await context.newPage();
await restoreBackupAsync(page, baseUrl);
// The import route exits the process; run.sh restarts it.
await new Promise((resolve) => setTimeout(resolve, 5_000));
await waitForReadyAsync(baseUrl);
await captureAsync("02-post-restore");
const fixtureChanges = await applyAccessFixtureAsync();
await context.close();
const anonymousLanding = await verifyAnonymousAccessDeniedAsync(browser, baseUrl);
const freshContext = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const freshPage = await freshContext.newPage();
const sessionCookieName = await loginAsync(freshPage, baseUrl);
await captureAsync("03-post-login");
await freshPage.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" });
const boardOutcome = await waitForBoardAsync(freshPage);
await captureAsync("04-board-loaded");
log(`stress: ${navIterations} reload iterations`);
const boardLoadMs: number[] = [];
for (let iteration = 0; iteration < navIterations; iteration++) {
const startedNav = Date.now();
await freshPage.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" });
await waitForBoardAsync(freshPage);
// Wall time from navigation start to every widget having mounted — the
// latency side of any memory/CPU trade-off.
boardLoadMs.push(Date.now() - startedNav);
await captureAsync(`nav-${String(iteration + 1).padStart(2, "0")}`);
}
// Multi-tab phase: hold several boards open at once, like a real session, and
// measure the browser heap as well as the container.
const openTabs: Page[] = [];
const tabPlan = boardNames.flatMap((name) => Array.from({ length: tabsPerBoard }, () => name));
let widgetInteractions = { available: 0, clicked: 0 };
if (tabPlan.length > 1 || tabsPerBoard > 1) {
log(`multi-tab: opening ${tabPlan.length} tabs across boards [${boardNames.join(", ")}]`);
for (const [index, name] of tabPlan.entries()) {
const tab = index === 0 ? freshPage : await freshContext.newPage();
if (index > 0) openTabs.push(tab);
await tab.goto(`${baseUrl}/boards/${name}`, { waitUntil: "domcontentloaded" });
const outcome = await waitForBoardAsync(tab);
log(` tab ${index + 1} (${name}): ${outcome.items} items, ${outcome.ready} ready`);
const interacted = await interactWithWidgetsAsync(tab);
widgetInteractions = {
available: widgetInteractions.available + interacted.available,
clicked: widgetInteractions.clicked + interacted.clicked,
};
}
log(`multi-tab: clicked ${widgetInteractions.clicked}/${widgetInteractions.available} widget controls`);
await captureAsync("07-multi-tab");
// Taken here, with every tab still open, because this is the high-water mark. Every
// earlier snapshot was of a drained process and could not show what peak memory holds.
await capturePeakAsync();
}
if (clientSnapshotPath) await captureClientSnapshotAsync(freshPage, clientSnapshotPath);
const clientHeap = await sampleClientHeapAsync(freshPage);
log(
`client heap: used=${toMiB(clientHeap.jsHeapUsedBytes)} MiB total=${toMiB(clientHeap.jsHeapTotalBytes)} MiB ` +
`nodes=${clientHeap.nodes} listeners=${clientHeap.jsEventListeners} docs=${clientHeap.documents}`,
);
const sortedLoads = [...boardLoadMs].sort((left, right) => left - right);
const lowerMedian = sortedLoads[Math.floor((sortedLoads.length - 1) / 2)];
const upperMedian = sortedLoads[Math.ceil((sortedLoads.length - 1) / 2)];
const medianBoardLoadMs =
lowerMedian === undefined || upperMedian === undefined ? null : (lowerMedian + upperMedian) / 2;
log(`board load ms: [${boardLoadMs.join(", ")}] median=${medianBoardLoadMs}`);
await captureAsync("05-after-stress");
log(`soak: ${soakMs}ms idle with board open`);
const soakDeadline = Date.now() + soakMs;
let soakIndex = 0;
while (Date.now() < soakDeadline) {
await new Promise((resolve) => setTimeout(resolve, soakIntervalMs));
await captureAsync(`soak-${String(++soakIndex).padStart(2, "0")}`);
}
await captureAsync("06-final");
const summary = {
label,
image,
imageRevision: (
await execFileAsync("docker", [
"image",
"inspect",
image,
"--format",
'{{index .Config.Labels "org.opencontainers.image.revision"}}',
])
).stdout.trim(),
imageSizeBytes: Number(
(await execFileAsync("docker", ["image", "inspect", image, "--format", "{{.Size}}"])).stdout.trim(),
),
config: {
memoryLimit,
heapMb: heapMb || null,
nodeOptions: nodeOptions || null,
navIterations,
soakMs,
soakIntervalMs,
boardName,
},
// Recorded so an A/B pair can be rejected if the two runs rendered
// different amounts of the board (e.g. more widgets errored on one side).
board: boardOutcome,
latency: { boardLoadMs, medianBoardLoadMs },
// The browser side. Container memory alone hid this entirely.
client: {
jsHeapUsedMiB: toMiB(clientHeap.jsHeapUsedBytes),
jsHeapTotalMiB: toMiB(clientHeap.jsHeapTotalBytes),
domNodes: clientHeap.nodes,
jsEventListeners: clientHeap.jsEventListeners,
documents: clientHeap.documents,
tabsOpen: tabPlan.length,
boards: boardNames,
widgetInteractions,
},
// Evidence that the board was measured behind a real session rather than
// because it happened to be publicly readable.
auth: {
username,
sessionCookieName,
anonymousBoardAccess: `denied (redirected to ${anonymousLanding})`,
fixtureChanges,
},
...summarizeStress(checkpoints),
};
await writeFile(path.join(outputDirectory, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`);
await writeFile(path.join(outputDirectory, "checkpoints.json"), `${JSON.stringify(checkpoints, null, 2)}\n`);
console.log(JSON.stringify(summary, null, 2));
await freshContext.close();
} finally {
await browser.close().catch(() => undefined);
await execFileAsync("docker", ["logs", containerName])
.then(({ stdout, stderr }) => writeFile(path.join(outputDirectory, "container.log"), `${stdout}\n${stderr}`))
.catch(() => undefined);
if (keepContainer) {
log(`keeping container ${containerName}`);
} else {
// -v also drops the anonymous /appdata volume so repeated runs start clean.
await execFileAsync("docker", ["rm", "-fv", containerName]).catch(() => undefined);
}
}
};
await main();