Files
homarr/scripts/benchmarks/stress-restore.mts
T
Thomas Camlong fb80b3882d test(benchmarks): compare on anon memory, retry pre-hydration login clicks
Two fixes found by running the harness against a real pair of images:

Login submitted before the form hydrated, so the click was a no-op that
looked identical to bad credentials. Load the page at networkidle and
retry the submit, surfacing any page alert if all attempts fail — the
same race already handled for the onboarding restore toggle.

memory.current counts reclaimable page cache, which moved the "same"
baseline between 192 and 261 MiB across runs. Anonymous memory is now
the headline metric (idle was 144 MiB anon vs 77 MiB of page cache), so
an A/B is not decided by unrelated disk I/O.
2026-08-09 00:44:50 +02:00

358 lines
15 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 { 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}`);
};
/**
* The restored backup's boards are private and owned by another account, and
* the demo user has no home board. Grant access explicitly so the measurement
* targets a fully-populated board instead of an empty 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 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();
if (adminGroup) {
db.prepare("INSERT OR IGNORE INTO groupMember (group_id, user_id) VALUES (?, ?)").run(adminGroup.id, user.id);
}
db.prepare("UPDATE user SET home_board_id = ?, mobile_home_board_id = ? WHERE id = ?").run(board.id, board.id, user.id);
db.close();
console.log("fixture-applied");
`;
const { stdout } = await execFileAsync("docker", ["exec", containerName, "node", "-e", script, username, boardName]);
if (!stdout.includes("fixture-applied")) throw new Error(`Access fixture failed: ${stdout}`);
log("access fixture applied (admin group + home board)");
};
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");
};
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,
});
log(`signed in as ${username}`);
return;
} 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");
await applyAccessFixtureAsync();
await context.close();
const freshContext = await browser.newContext({ viewport: { width: 1920, height: 1080 } });
const freshPage = await freshContext.newPage();
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`);
for (let iteration = 0; iteration < navIterations; iteration++) {
await freshPage.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" });
await waitForBoardAsync(freshPage);
await captureAsync(`nav-${String(iteration + 1).padStart(2, "0")}`);
}
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,
...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();