Files
homarr/scripts/benchmarks/stress-restore.mts
T
Thomas Camlong 79ea987761 perf: bound V8's young generation and unbundle server-only deps
Two further wins, both measured on a restored 28-widget board behind a real
demo session (scripts/benchmarks/stress-restore.mts).

--max-semi-space-size=4 in run.sh. --max-old-space-size only bounds the old
generation; V8 also sizes the young generation generously (16 MB per
semi-space) and that slack is pure resident memory. 4 MB cut idle anonymous
memory 120.0 -> 97.2 MiB and peak 710.6 -> 560.2 MiB. It costs CPU, as any
GC-space reduction does — 15.8 -> 17.1 CPU-seconds over the run, ~8% — with
no board-load latency regression (median 594 -> 570 ms). Overridable via
HOMARR_MAX_SEMI_SPACE_SIZE.

serverExternalPackages for the remaining server-only dependencies. Bundling
a package inlines its transitive graph into a chunk that is compiled at boot
even when the code path never runs; leaving it external makes it a lazy
require. Adding winston, drizzle-orm, ical.js, jszip, ldapts, node-unifi,
@kubernetes/client-node and linkedom took idle to 90.3 MiB and board-loaded
209.6 -> 164.6 MiB, with slightly lower CPU (16.3s) and latency (549 ms).
The trade-off is image size: 388 -> 401 MB, because those packages now ship
in node_modules instead of being tree-shaken into chunks.

Cumulative vs the merge-base: idle 141.7 -> 90.3 MiB (-36%), board loaded
257.8 -> 164.6 MiB (-36%), under load 392.7 -> 302.5 MiB (-23%).

The harness now proves the session rather than inferring it: it asserts an
anonymous request to the private board is denied, requires the Auth.js
session cookie after sign-in, and reports whether it had to modify the
restored data at all (now "none"). It also records cgroup CPU seconds and
board-load latency, so a memory win bought with CPU cannot hide.

pnpm test 144/144 files, 1795 tests; typecheck and lint clean.
2026-08-09 01:43:06 +02:00

427 lines
18 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";
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";
const containerName = `homarr-stress-${Date.now()}`;
const checkpoints: StressCheckpoint[] = [];
let startedAt = Date.now();
const log = (message: string) => console.log(`[stress:${label}] ${message}`);
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);
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`);
}
}
};
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}`);
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")}`);
}
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 },
// 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();