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",