From 8f18b17207a916bb90b4df547a67cf57cffb6a33 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 9 Aug 2026 08:21:57 +0200 Subject: [PATCH] test(perf): profiling build, container introspection, client-side measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The container-only harness was measuring the wrong thing. Real usage — several tabs, several boards, clicking into widgets — reaches ~880 MB server and a 650 MB *browser* heap, and the browser was never measured at all. Profiling build: `docker build --build-arg HOMARR_PROFILING=true . -t homarr:performance`. React's production bundle strips the hooks DevTools needs, hence "Profiling not supported". Note reactProductionProfiling in next.config is read only by the webpack path; under Turbopack it comes from `next build --profile`, and turbo strips any env var not declared in the task's `env`, so all three had to line up before the flag actually took effect — verified by markers present only in React's profiling bundle, and static chunks going 20 -> 84 MB with source maps. inspect-container.mts attaches to an already-running container (SIGUSR1 opens Node's inspector — no rebuild, no restart), reports cgroup/per-process/V8 memory and can pull a heap snapshot out over CDP. The inspected context has no require and no dynamic import, so it reads globals and uses CDP domains instead. analyze-heapsnapshot / analyze-react-fibers / analyze-heap-retainers summarise a snapshot too large for DevTools to open. Harness now opens STRESS_BOARDS across STRESS_TABS_PER_BOARD tabs, clicks into widgets, and records browser heap, DOM nodes and listener count via CDP. Performance.getMetrics silently returns nothing unless the domain is enabled, which is why the first run reported a 0 MiB client heap. --- Dockerfile | 4 + apps/nextjs/next.config.ts | 20 ++ apps/nextjs/package.json | 2 +- scripts/benchmarks/analyze-heap-retainers.mjs | 122 +++++++++++ scripts/benchmarks/analyze-heapsnapshot.mjs | 64 ++++++ scripts/benchmarks/analyze-react-fibers.mjs | 73 +++++++ scripts/benchmarks/inspect-container.mts | 202 ++++++++++++++++++ scripts/benchmarks/stress-restore.mts | 112 ++++++++++ turbo.json | 5 +- 9 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 scripts/benchmarks/analyze-heap-retainers.mjs create mode 100644 scripts/benchmarks/analyze-heapsnapshot.mjs create mode 100644 scripts/benchmarks/analyze-react-fibers.mjs create mode 100644 scripts/benchmarks/inspect-container.mts diff --git a/Dockerfile b/Dockerfile index afc7b5ded..7181fafd5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,10 @@ ARG SKIP_ENV_VALIDATION='true' ARG CI='true' ARG DISABLE_REDIS_LOGS='true' ARG TARGETPLATFORM +# Opt-in React profiling build + browser source maps, for React DevTools' Profiler +# and for readable names in heap snapshots. See next.config.ts. +ARG HOMARR_PROFILING='false' +ENV HOMARR_PROFILING=${HOMARR_PROFILING} RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \ --mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \ diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 08f79872d..ba94c9c90 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -15,7 +15,27 @@ const withNextIntl = createNextIntlPlugin({ requestConfig: "../../packages/translation/src/request.ts", }); +/** + * Profiling build. React's production bundle strips the hooks React DevTools needs, + * which is why the Profiler tab reports "Profiling not supported"; the profiling + * variant keeps them. Source maps are emitted alongside so heap snapshots and flame + * charts show real component names instead of minified ones like `y`. + * + * Build with: + * docker build --build-arg HOMARR_PROFILING=true . -t homarr:performance + * + * `reactProductionProfiling` in this config is only read by the webpack path — under + * Turbopack it comes from `next build --profile`, which apps/nextjs/package.json + * passes when HOMARR_PROFILING=true. Both are set so either bundler works. + * + * Off by default: the profiling variant is slower and source maps are large, so this + * must never be what ships to users. + */ +const isProfilingBuild = process.env.HOMARR_PROFILING === "true"; + const nextConfig: NextConfig = { + reactProductionProfiling: isProfilingBuild, + productionBrowserSourceMaps: isProfilingBuild, // Next previews otherwise create agent instruction files in the application // directory during development. agentRules: false, diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index a68484719..6b893960c 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build", + "build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build $([ \"$HOMARR_PROFILING\" = \"true\" ] && echo --profile)", "clean": "git clean -xdf .next .turbo node_modules", "dev": "pnpm with-env next dev", "format": "oxfmt .", diff --git a/scripts/benchmarks/analyze-heap-retainers.mjs b/scripts/benchmarks/analyze-heap-retainers.mjs new file mode 100644 index 000000000..a6c4f50e4 --- /dev/null +++ b/scripts/benchmarks/analyze-heap-retainers.mjs @@ -0,0 +1,122 @@ +// Builds a reverse-edge index over a .heapsnapshot and walks upward from the +// heaviest object groups to find who retains them. Answers "what is holding +// these 800k objects", which shallow-size aggregation cannot. +import { readFileSync } from "node:fs"; + +const file = process.argv[2]; +const snap = JSON.parse(readFileSync(file, "utf8")); +const meta = snap.snapshot.meta; + +const nf = meta.node_fields; +const nStride = nf.length; +const nType = nf.indexOf("type"); +const nName = nf.indexOf("name"); +const nEdgeCount = nf.indexOf("edge_count"); +const nodeTypes = meta.node_types[0]; + +const ef = meta.edge_fields; +const eStride = ef.length; +const eType = ef.indexOf("type"); +const eName = ef.indexOf("name_or_index"); +const eTo = ef.indexOf("to_node"); +const edgeTypes = meta.edge_types[0]; + +const nodes = snap.nodes; +const edges = snap.edges; +const strings = snap.strings; +const N = snap.snapshot.node_count; +const E = snap.snapshot.edge_count; + +const nodeName = (o) => strings[nodes[o * nStride + nName]] ?? ""; +const nodeType = (o) => nodeTypes[nodes[o * nStride + nType]] ?? "?"; + +// firstEdge[o] = index of node o's first edge +const firstEdge = new Uint32Array(N + 1); +for (let o = 0, acc = 0; o < N; o++) { + firstEdge[o] = acc; + acc += nodes[o * nStride + nEdgeCount]; +} +firstEdge[N] = E; + +// Reverse index via counting sort. +const inDeg = new Uint32Array(N); +for (let e = 0; e < E; e++) inDeg[edges[e * eStride + eTo] / nStride]++; +const revStart = new Uint32Array(N + 1); +for (let o = 0, acc = 0; o < N; o++) { + revStart[o] = acc; + acc += inDeg[o]; +} +revStart[N] = E; +const cursor = Uint32Array.from(revStart.subarray(0, N)); +const revTo = new Uint32Array(E); +for (let src = 0; src < N; src++) { + for (let e = firstEdge[src]; e < firstEdge[src + 1]; e++) { + revTo[cursor[edges[e * eStride + eTo] / nStride]++] = src; + } +} + +const edgeLabelInto = (src, dstOrd) => { + for (let e = firstEdge[src]; e < firstEdge[src + 1]; e++) { + if (edges[e * eStride + eTo] / nStride === dstOrd) { + const t = edgeTypes[edges[e * eStride + eType]]; + const raw = edges[e * eStride + eName]; + return t === "element" || t === "hidden" ? `[${raw}]` : (strings[raw] ?? String(raw)); + } + } + return "?"; +}; + +const interesting = (o) => { + const n = nodeName(o); + const t = nodeType(o); + if (!n) return false; + if (t === "synthetic") return true; + return !/^(system|Object|Array|\(.*\))/.test(n); +}; + +// Walk up to `maxDepth` retainers, preferring paths that reach something named. +const retainerPath = (start, maxDepth = 14) => { + const seen = new Set([start]); + let frontier = [{ ord: start, path: [] }]; + for (let d = 0; d < maxDepth; d++) { + const next = []; + for (const { ord, path } of frontier) { + for (let r = revStart[ord]; r < revStart[ord + 1]; r++) { + const src = revTo[r]; + if (seen.has(src)) continue; + seen.add(src); + const step = `${nodeType(src)}:${nodeName(src) || "-"} --${edgeLabelInto(src, ord)}-->`; + const newPath = [step, ...path]; + if (interesting(src)) return newPath; + next.push({ ord: src, path: newPath }); + if (next.length > 4000) break; + } + } + if (!next.length) break; + frontier = next.slice(0, 4000); + } + return null; +}; + +const target = process.argv[3] ?? "rb"; +const wantType = process.argv[4]; +const sampleSize = Number(process.argv[5] ?? 12); + +const matches = []; +for (let o = 0; o < N && matches.length < 400000; o++) { + if (nodeName(o) === target && (!wantType || nodeType(o) === wantType)) matches.push(o); +} +console.log(`target "${target}"${wantType ? ` (type ${wantType})` : ""}: ${matches.length} instances`); + +const counts = new Map(); +const step = Math.max(1, Math.floor(matches.length / sampleSize)); +for (let i = 0; i < matches.length; i += step) { + const p = retainerPath(matches[i]); + if (!p) continue; + const key = p.slice(-4).join(" "); + counts.set(key, (counts.get(key) ?? 0) + 1); +} +console.log("\n=== most common retainer tails (nearest 4 hops) ==="); +for (const [k, v] of [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)) { + console.log(` ${v}x ${k}`); +} diff --git a/scripts/benchmarks/analyze-heapsnapshot.mjs b/scripts/benchmarks/analyze-heapsnapshot.mjs new file mode 100644 index 000000000..7e1f125ea --- /dev/null +++ b/scripts/benchmarks/analyze-heapsnapshot.mjs @@ -0,0 +1,64 @@ +// Aggregates a Chrome .heapsnapshot by shallow (self) size per constructor/type, +// which is what "Summary" view in DevTools shows. Retained size needs graph +// traversal; shallow is usually enough to identify a dominant allocator. +import { readFileSync } from "node:fs"; + +const file = process.argv[2]; +const raw = readFileSync(file, "utf8"); +const snap = JSON.parse(raw); + +const meta = snap.snapshot.meta; +const nodeFields = meta.node_fields; +const nodeTypes = meta.node_types[0]; +const stride = nodeFields.length; +const iType = nodeFields.indexOf("type"); +const iName = nodeFields.indexOf("name"); +const iSelf = nodeFields.indexOf("self_size"); + +const nodes = snap.nodes; +const strings = snap.strings; +const count = snap.snapshot.node_count ?? nodes.length / stride; + +const byKey = new Map(); +let total = 0; +for (let i = 0; i < count; i++) { + const base = i * stride; + const type = nodeTypes[nodes[base + iType]] ?? "?"; + const name = strings[nodes[base + iName]] ?? ""; + const self = nodes[base + iSelf]; + total += self; + // Group strings/numbers together — individually they are noise, collectively signal. + const key = type === "string" || type === "number" || type === "hidden" ? `(${type})` : `${type}:${name}`; + const acc = byKey.get(key) ?? { bytes: 0, n: 0 }; + acc.bytes += self; + acc.n++; + byKey.set(key, acc); +} + +const MiB = (b) => (b / 1048576).toFixed(1); +console.log(`file: ${file}`); +console.log(`nodes: ${count.toLocaleString()} edges: ${(snap.snapshot.edge_count ?? 0).toLocaleString()}`); +console.log(`total shallow size: ${MiB(total)} MiB\n`); + +const top = [...byKey.entries()].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 35); +console.log("=== top shallow-size groups ==="); +console.log("bytes(MiB)".padStart(11), "count".padStart(10), " key"); +for (const [key, v] of top) { + console.log(MiB(v.bytes).padStart(11), String(v.n).padStart(10), ` ${key.slice(0, 90)}`); +} + +// Constructor-name rollup ignoring the type prefix, to surface app-level objects. +const byCtor = new Map(); +for (const [key, v] of byKey) { + if (key.startsWith("(")) continue; + const name = key.slice(key.indexOf(":") + 1); + if (!name) continue; + const acc = byCtor.get(name) ?? { bytes: 0, n: 0 }; + acc.bytes += v.bytes; + acc.n += v.n; + byCtor.set(name, acc); +} +console.log("\n=== top named constructors ==="); +for (const [name, v] of [...byCtor.entries()].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 30)) { + console.log(MiB(v.bytes).padStart(11), String(v.n).padStart(10), ` ${name.slice(0, 90)}`); +} diff --git a/scripts/benchmarks/analyze-react-fibers.mjs b/scripts/benchmarks/analyze-react-fibers.mjs new file mode 100644 index 000000000..3d643cb5f --- /dev/null +++ b/scripts/benchmarks/analyze-react-fibers.mjs @@ -0,0 +1,73 @@ +// Aggregates React Fiber nodes by their `type`/`elementType` target, giving a +// component/element histogram of the live tree — i.e. what is actually mounted. +import { readFileSync } from "node:fs"; + +const snap = JSON.parse(readFileSync(process.argv[2], "utf8")); +const meta = snap.snapshot.meta; + +const nf = meta.node_fields; +const nStride = nf.length; +const nType = nf.indexOf("type"); +const nName = nf.indexOf("name"); +const nEdgeCount = nf.indexOf("edge_count"); +const nodeTypes = meta.node_types[0]; + +const ef = meta.edge_fields; +const eStride = ef.length; +const eType = ef.indexOf("type"); +const eName = ef.indexOf("name_or_index"); +const eTo = ef.indexOf("to_node"); +const edgeTypes = meta.edge_types[0]; + +const nodes = snap.nodes; +const edges = snap.edges; +const strings = snap.strings; +const N = snap.snapshot.node_count; +const E = snap.snapshot.edge_count; + +const nodeName = (o) => strings[nodes[o * nStride + nName]] ?? ""; +const nodeTypeName = (o) => nodeTypes[nodes[o * nStride + nType]] ?? "?"; + +const firstEdge = new Uint32Array(N + 1); +for (let o = 0, acc = 0; o < N; o++) { + firstEdge[o] = acc; + acc += nodes[o * nStride + nEdgeCount]; +} +firstEdge[N] = E; + +const fiberClass = process.argv[3] ?? "rb"; +const hist = new Map(); +let fibers = 0; +let withoutType = 0; + +for (let o = 0; o < N; o++) { + if (nodeName(o) !== fiberClass || nodeTypeName(o) !== "object") continue; + fibers++; + let label = null; + for (let e = firstEdge[o]; e < firstEdge[o + 1]; e++) { + const et = edgeTypes[edges[e * eStride + eType]]; + if (et === "element" || et === "hidden") continue; + const prop = strings[edges[e * eStride + eName]]; + if (prop !== "type" && prop !== "elementType") continue; + const dst = edges[e * eStride + eTo] / nStride; + const dt = nodeTypeName(dst); + const dn = nodeName(dst); + // Host elements point at a string ("div"); components at a closure/object. + if (dt === "string" || dt === "concatenated string") label = `<${dn}>`; + else if (dn) label = `${dt}:${dn}`; + else label = `${dt}:(anonymous)`; + if (prop === "type") break; + } + if (!label) { + withoutType++; + continue; + } + hist.set(label, (hist.get(label) ?? 0) + 1); +} + +console.log(`live fibers: ${fibers.toLocaleString()} (no type edge: ${withoutType.toLocaleString()})`); +console.log(`distinct types: ${hist.size}\n`); +console.log("count".padStart(9), " fiber type"); +for (const [k, v] of [...hist.entries()].sort((a, b) => b[1] - a[1]).slice(0, 40)) { + console.log(String(v).padStart(9), ` ${k.slice(0, 90)}`); +} diff --git a/scripts/benchmarks/inspect-container.mts b/scripts/benchmarks/inspect-container.mts new file mode 100644 index 000000000..4b9f0eca2 --- /dev/null +++ b/scripts/benchmarks/inspect-container.mts @@ -0,0 +1,202 @@ +/** + * Live introspection of a running Homarr container: what the server process is + * actually holding, and optionally a heap snapshot pulled out for DevTools. + * + * Needs no rebuild and no restart. Node opens its inspector on SIGUSR1, so this + * attaches to whatever is already running — including a container you started by + * hand with `docker run homarr:performance`. + * + * node --experimental-strip-types scripts/benchmarks/inspect-container.mts + * node --experimental-strip-types scripts/benchmarks/inspect-container.mts --snapshot server.heapsnapshot + * + * The snapshot opens in DevTools > Memory > Load. Analyse it with + * scripts/benchmarks/analyze-heapsnapshot.mjs for a constructor histogram. + */ +import { execFile, spawn } from "node:child_process"; +import { createWriteStream } from "node:fs"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const container = process.argv[2]; +if (!container) { + throw new Error("usage: inspect-container.mts [--snapshot ]"); +} +const snapshotIndex = process.argv.indexOf("--snapshot"); +const snapshotPath = snapshotIndex === -1 ? undefined : process.argv[snapshotIndex + 1]; + +const execIn = async (script: string) => { + const { stdout } = await execFileAsync("docker", ["exec", container, "sh", "-c", script], { + maxBuffer: 32 * 1024 * 1024, + }); + return stdout; +}; + +/** + * Runs a Node script inside the container by piping it to stdin. Passing it via + * `sh -c 'node -e "..."'` mangles escapes as soon as the script contains quotes or + * newlines, which every non-trivial one does. + */ +const execNodeIn = (script: string) => + new Promise((resolve, reject) => { + const proc = spawn("docker", ["exec", "-i", container, "node"], { stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (chunk) => (stdout += chunk)); + proc.stderr.on("data", (chunk) => (stderr += chunk)); + proc.on("error", reject); + proc.on("close", (code) => + code === 0 ? resolve(stdout) : reject(new Error(stderr.trim() || `node exited ${code}`)), + ); + proc.stdin.end(script); + }); + +/** + * The Next server retitles itself, and comm is truncated to 15 chars — it reads + * "next-server (v1", not "next-server" — so these have to be prefix matches. + */ +const findServerPidAsync = async () => { + const out = await execIn( + `for p in /proc/[0-9]*; do n=$(cat "$p/comm" 2>/dev/null);` + + ` case "$n" in node*|next-server*|bun*) echo "$(basename "$p") $n";; esac; done`, + ); + const line = out.trim().split("\n").filter(Boolean)[0]; + if (!line) throw new Error("no node/next-server/bun process found in the container"); + const [pid, comm] = line.split(/\s+/); + return { pid: Number(pid), comm: comm ?? "?" }; +}; + +/** + * Evaluates an expression inside the live server over CDP. Node 22+ ships a global + * WebSocket, so the helper needs no dependency present in the image. + */ +const evaluateInServerAsync = async (webSocketUrl: string, expression: string) => { + const client = ` +const ws = new WebSocket(${JSON.stringify(webSocketUrl)}); +let nextId = 0; +const pending = new Map(); +const send = (method, params = {}) => + new Promise((resolve) => { + const id = ++nextId; + pending.set(id, resolve); + ws.send(JSON.stringify({ id, method, params })); + }); +ws.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.id && pending.has(message.id)) { + pending.get(message.id)(message.result); + pending.delete(message.id); + } +}); +ws.addEventListener("error", () => { console.error("inspector websocket error"); process.exit(1); }); +ws.addEventListener("open", async () => { + await send("Runtime.enable"); + const response = await send("Runtime.evaluate", { + expression: ${JSON.stringify(expression)}, + returnByValue: true, + awaitPromise: true, + }); + const result = response?.result; + if (result?.subtype === "error" || response?.exceptionDetails) { + console.error(JSON.stringify(response, null, 2)); + process.exit(1); + } + console.log(JSON.stringify(result?.value ?? null, null, 2)); + ws.close(); + process.exit(0); +}); +`; + return execNodeIn(client); +}; + +const { pid, comm } = await findServerPidAsync(); +console.log(`server process: pid=${pid} (${comm})\n`); + +console.log("=== cgroup and process memory ==="); +console.log( + await execIn( + `awk '/^anon |^file |^slab /{printf " cgroup %-6s %8.1f MiB\\n", $1, $2/1048576}' /sys/fs/cgroup/memory.stat;` + + `awk '/^VmRSS|^RssAnon|^RssFile/{printf " %-12s %8.1f MiB\\n", $1, $2/1024}' /proc/${pid}/status`, + ), +); + +console.log("=== per-process RSS ==="); +console.log( + await execIn( + `for p in /proc/[0-9]*/status; do` + + ` awk '/^Name:/{n=$2} /^VmRSS:/{printf " %-16s %8.1f MiB\\n", n, $2/1024}' "$p" 2>/dev/null; done | sort -k2 -rn`, + ), +); + +await execIn(`kill -USR1 ${pid}`).catch(() => undefined); +await new Promise((resolve) => setTimeout(resolve, 1_500)); + +const listScript = ` +const http = require("node:http"); +http.get({ host: "127.0.0.1", port: 9229, path: "/json/list" }, (response) => { + let body = ""; + response.on("data", (chunk) => (body += chunk)); + response.on("end", () => console.log(body)); +}).on("error", (error) => { console.log("ERR " + error.message); }); +`; +const listRaw = await execNodeIn(listScript).catch((error: Error) => `ERR ${error.message}`); +const webSocketUrl = /"webSocketDebuggerUrl":\s*"([^"]+)"/.exec(listRaw)?.[1]; +if (!webSocketUrl) { + console.log(`could not reach the inspector on 9229. Raw response:\n${listRaw.slice(0, 400)}`); + process.exit(1); +} + +// The inspected context has no `require` and no dynamic import — only globals — so +// read what `process` exposes directly and get the rest from CDP's own domains. +console.log("=== live heap of the running server ==="); +console.log( + await evaluateInServerAsync( + webSocketUrl, + `(() => { + const usage = process.memoryUsage(); + const MiB = (bytes) => Math.round((bytes / 1048576) * 10) / 10; + return { + rssMiB: MiB(usage.rss), + heapUsedMiB: MiB(usage.heapUsed), + heapTotalMiB: MiB(usage.heapTotal), + externalMiB: MiB(usage.external), + arrayBuffersMiB: MiB(usage.arrayBuffers), + uptimeSeconds: Math.round(process.uptime()), + }; + })()`, + ), +); + +if (snapshotPath) { + const inContainerPath = "/tmp/server.heapsnapshot"; + console.log(`\n=== taking heap snapshot over CDP ===`); + const snapshotClient = ` +const fs = require("node:fs"); +const out = fs.createWriteStream(${JSON.stringify(inContainerPath)}); +const ws = new WebSocket(${JSON.stringify(webSocketUrl)}); +let nextId = 0; +ws.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.method === "HeapProfiler.addHeapSnapshotChunk") { + out.write(message.params.chunk); + } else if (message.id === 2) { + out.end(() => { console.log("written"); process.exit(0); }); + } +}); +ws.addEventListener("error", () => { console.error("inspector websocket error"); process.exit(1); }); +ws.addEventListener("open", () => { + ws.send(JSON.stringify({ id: ++nextId, method: "HeapProfiler.enable" })); + ws.send(JSON.stringify({ id: ++nextId, method: "HeapProfiler.takeHeapSnapshot", params: { reportProgress: false } })); +}); +`; + console.log((await execNodeIn(snapshotClient)).trim()); + await new Promise((resolve, reject) => { + const out = createWriteStream(snapshotPath); + const proc = spawn("docker", ["exec", container, "cat", inContainerPath]); + proc.stdout.pipe(out); + proc.on("error", reject); + proc.on("close", (code) => (code === 0 ? resolve() : reject(new Error(`docker exec cat exited ${code}`)))); + }); + console.log(`saved ${snapshotPath}`); + console.log(`analyse: node scripts/benchmarks/analyze-heapsnapshot.mjs ${snapshotPath}`); +} diff --git a/scripts/benchmarks/stress-restore.mts b/scripts/benchmarks/stress-restore.mts index b5378f962..a020afba0 100644 --- a/scripts/benchmarks/stress-restore.mts +++ b/scripts/benchmarks/stress-restore.mts @@ -50,6 +50,15 @@ 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[] = []; @@ -225,6 +234,69 @@ const loginAsync = async (page: Page, baseUrl: string) => { } }; +/** + * 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 }; +}; + const waitForBoardAsync = async (page: Page) => { const boardSelector = "[data-homarr-dev-benchmark-board]"; await page.locator(boardSelector).waitFor({ state: "visible", timeout: 120_000 }); @@ -349,6 +421,35 @@ const main = async () => { 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"); + } + + 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)]; @@ -394,6 +495,17 @@ const main = async () => { // 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: { diff --git a/turbo.json b/turbo.json index a34257de2..709be874f 100644 --- a/turbo.json +++ b/turbo.json @@ -13,7 +13,10 @@ }, "build": { "dependsOn": ["^build"], - "env": ["TURBO_PLATFORM"], + // HOMARR_PROFILING must be declared or turbo strips it from the task env, and + // the profiling build silently produces a normal one. It is also part of the + // cache key, so toggling it correctly invalidates the cached build. + "env": ["TURBO_PLATFORM", "HOMARR_PROFILING"], "inputs": [ "$TURBO_DEFAULT$", "!**/*.test.ts",