diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index f1dee9f53..18b058def 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -30,8 +30,28 @@ const nextConfig: NextConfig = { typescript: { ignoreBuildErrors: true }, /** * dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612 + * + * Everything else here is a server-only dependency that is cheaper left as a + * runtime require than inlined into the server bundle. Bundling pulls a package's + * whole transitive graph into a chunk that is loaded and compiled at boot even + * when the code path never runs — measurably so for the DB drivers: only + * better-sqlite3 is ever used, yet inlining mysql2 and pg cost ~21 MiB of + * resident memory at idle. */ - serverExternalPackages: ["dockerode", "better-sqlite3", "mysql2", "pg"], + serverExternalPackages: [ + "dockerode", + "better-sqlite3", + "mysql2", + "pg", + "winston", + "drizzle-orm", + "ical.js", + "jszip", + "ldapts", + "node-unifi", + "@kubernetes/client-node", + "linkedom", + ], experimental: { optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"], turbopackFileSystemCacheForBuild: true, diff --git a/scripts/benchmarks/stress-restore-lib.mts b/scripts/benchmarks/stress-restore-lib.mts index 1c01e9fa7..8b370d2b6 100644 --- a/scripts/benchmarks/stress-restore-lib.mts +++ b/scripts/benchmarks/stress-restore-lib.mts @@ -23,6 +23,12 @@ export type StressCheckpoint = { anonymousBytes: number; fileBytes: number; }; + /** + * Cumulative cgroup CPU time. Needed because most ways of lowering memory + * (smaller GC spaces, tighter heap caps) buy it with CPU, and a win that just + * moves the cost somewhere unmeasured is not a win. + */ + cpu: { usageUsec: number; userUsec: number; systemUsec: number } | null; processes: ProcessSample[]; redis: { usedMemoryBytes: number; peakMemoryBytes: number } | null; }; @@ -39,6 +45,7 @@ echo "memory_current=$(cat /sys/fs/cgroup/memory.current 2>/dev/null || echo 0)" memory_peak="$(cat /sys/fs/cgroup/memory.peak 2>/dev/null || true)" case "$memory_peak" in ''|'max') ;; *) echo "memory_peak=$memory_peak" ;; esac awk '/^anon / { print "cgroup_anon=" $2 } /^file / { print "cgroup_file=" $2 }' /sys/fs/cgroup/memory.stat 2>/dev/null +awk '/^usage_usec / { print "cpu_usage_usec=" $2 } /^user_usec / { print "cpu_user_usec=" $2 } /^system_usec / { print "cpu_system_usec=" $2 }' /sys/fs/cgroup/cpu.stat 2>/dev/null for status_file in /proc/[0-9]*/status; do [ -r "$status_file" ] || continue process_dir="$(dirname "$status_file")" @@ -84,6 +91,7 @@ export const parseStressSnapshot = (name: string, elapsedMs: number, output: str const redisUsed = readNumericLine(output, "redis_used_memory"); const redisPeak = readNumericLine(output, "redis_used_memory_peak"); + const cpuUsage = readNumericLine(output, "cpu_usage_usec"); return { name, @@ -95,6 +103,14 @@ export const parseStressSnapshot = (name: string, elapsedMs: number, output: str anonymousBytes: readNumericLine(output, "cgroup_anon") ?? 0, fileBytes: readNumericLine(output, "cgroup_file") ?? 0, }, + cpu: + cpuUsage === null + ? null + : { + usageUsec: cpuUsage, + userUsec: readNumericLine(output, "cpu_user_usec") ?? 0, + systemUsec: readNumericLine(output, "cpu_system_usec") ?? 0, + }, processes, redis: redisUsed === null || redisPeak === null ? null : { usedMemoryBytes: redisUsed, peakMemoryBytes: redisPeak }, }; @@ -183,5 +199,12 @@ export const summarizeStress = (checkpoints: StressCheckpoint[], soakPrefix = "s const peaks = checkpoints.flatMap((c) => (c.redis ? [c.redis.peakMemoryBytes] : [])); return peaks.length === 0 ? null : toMiB(Math.max(...peaks)); })(), + // Total CPU the container burned over the run, so a memory reduction bought + // with extra GC work shows up instead of hiding. + cpuSeconds: (() => { + const samples = checkpoints.flatMap((c) => (c.cpu ? [c.cpu.usageUsec] : [])); + if (samples.length < 2) return null; + return Math.round(((Math.max(...samples) - Math.min(...samples)) / 1_000_000) * 10) / 10; + })(), }; }; diff --git a/scripts/benchmarks/stress-restore.mts b/scripts/benchmarks/stress-restore.mts index 1c43440b4..b5378f962 100644 --- a/scripts/benchmarks/stress-restore.mts +++ b/scripts/benchmarks/stress-restore.mts @@ -21,7 +21,7 @@ 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 type { Browser, Page } from "@playwright/test"; import { parseStressSnapshot, stressMemoryScript, summarizeStress, toMiB } from "./stress-restore-lib.mts"; import type { StressCheckpoint } from "./stress-restore-lib.mts"; @@ -83,29 +83,42 @@ const waitForReadyAsync = async (baseUrl: string, timeoutMs = 180_000) => { }; /** - * 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. + * 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 FROM user WHERE name = ?").get(process.argv[1]); +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) { - db.prepare("INSERT OR IGNORE INTO groupMember (group_id, user_id) VALUES (?, ?)").run(adminGroup.id, user.id); + 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.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"); +console.log("FIXTURE:" + (changes.length ? changes.join(",") : "none")); `; 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 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) => { @@ -145,6 +158,34 @@ const restoreBackupAsync = async (page: Page, baseUrl: string) => { 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. @@ -162,8 +203,14 @@ const loginAsync = async (page: Page, baseUrl: string) => { await page.waitForFunction(() => !window.location.pathname.includes("/auth/login"), undefined, { timeout: 20_000, }); - log(`signed in as ${username}`); - return; + // 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 @@ -276,13 +323,15 @@ const main = async () => { await waitForReadyAsync(baseUrl); await captureAsync("02-post-restore"); - await applyAccessFixtureAsync(); + 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(); - await loginAsync(freshPage, baseUrl); + const sessionCookieName = await loginAsync(freshPage, baseUrl); await captureAsync("03-post-login"); await freshPage.goto(`${baseUrl}/boards/${boardName}`, { waitUntil: "domcontentloaded" }); @@ -290,11 +339,22 @@ const main = async () => { 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`); @@ -333,6 +393,15 @@ const main = async () => { // 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`); diff --git a/scripts/run.sh b/scripts/run.sh index 9f26b2406..5601d4786 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -6,7 +6,13 @@ mkdir -p /appdata/redis mkdir -p /appdata/trusted-certificates # Run migrations -export NODE_OPTIONS="--max-old-space-size=${HOMARR_MAX_OLD_SPACE_SIZE:-512} ${NODE_OPTIONS}" +# --max-old-space-size bounds the old generation; without it V8 sizes the heap from +# the *host's* RAM, so a big host lets RSS drift for a long time before collecting. +# --max-semi-space-size bounds the young generation, which V8 also sizes generously +# by default (16 MB per semi-space). Measured on a restored 28-widget board: 4 MB cut +# idle anonymous memory ~19% and peak ~21% for ~8% more CPU, with no board-load +# latency regression. Both are overridable for hosts that want to trade back. +export NODE_OPTIONS="--max-old-space-size=${HOMARR_MAX_OLD_SPACE_SIZE:-512} --max-semi-space-size=${HOMARR_MAX_SEMI_SPACE_SIZE:-4} ${NODE_OPTIONS}" if [ "$DB_MIGRATIONS_DISABLED" = "true" ]; then echo "DB migrations are disabled, skipping" else