// Reports what allocated memory during a stress run, by site and by owner — and, given two
// stages, what a single stage allocated.
//
// node scripts/benchmarks/analyze-allocations.mjs
[fromStage] [toStage]
// node scripts/benchmarks/analyze-allocations.mjs .bench/stress/peak nav-03 07-multi-tab
//
// This is the counterpart to a heap snapshot and answers the opposite question. A snapshot is
// taken after a forced GC, so it shows only what *survived*; the churn that produces a memory
// peak is by definition gone by then. The sampling heap profiler records an allocation stack
// every N bytes regardless of survival, so a peak driven by transient garbage — which is what
// this container's peak turned out to be — shows up here and nowhere else.
//
// Sampling totals are cumulative since boot, so a single stage is only meaningful as the
// difference between two captures. Without stage arguments this reports the cumulative
// profile, which is dominated by boot-time module loading.
import { readFileSync, existsSync } from "node:fs";
import path from "node:path";
const [, , directory, fromStage, toStage] = process.argv;
if (!directory) throw new Error("usage: analyze-allocations.mjs [fromStage] [toStage]");
const load = (stage) => {
const file = path.join(directory, `${stage}.allocations.json`);
if (!existsSync(file)) throw new Error(`missing ${file}`);
const parsed = JSON.parse(readFileSync(file, "utf8"));
if (parsed.disabled) throw new Error("sampling was not enabled (HOMARR_PROBE_SAMPLE_ALLOCATIONS)");
return parsed;
};
const MiB = (bytes) => (bytes / 1048576).toFixed(2);
/** Groups a frame to something a developer could act on: a package, a chunk, or node itself. */
const ownerOf = (key) => {
const url = key.slice(key.indexOf("|") + 1);
if (!url || url.startsWith("undefined")) return "unattributed (no script)";
if (/^node:/.test(url)) {
if (/cjs\/loader|modules\//.test(url)) return "node: CommonJS module loader (reading source)";
return "node internals";
}
const dependency = /\/app\/node_modules\/((?:@[^/]+\/)?[^/]+)\//.exec(url);
if (dependency) return `node_modules: ${dependency[1]}`;
if (url.includes("/.next/server/chunks/")) {
const chunk = /chunks\/(?:ssr\/)?([^/:]+)/.exec(url);
return `chunk ${chunk ? chunk[1] : "?"}`;
}
if (url.includes("/.next/server/app/")) return "route bundle";
if (url.includes("/probe/")) return "the probe itself (diagnostics)";
return "other";
};
/**
* Renders a `functionName|url:line` key for display. Split on the first separator explicitly
* rather than with a single-occurrence `replace`, which reads as a botched sanitiser (CodeQL
* flags it as one) and would mangle any URL that itself contained a pipe.
*/
const shorten = (key) => {
const separator = key.indexOf("|");
const functionName = separator === -1 ? key : key.slice(0, separator);
const url = separator === -1 ? "" : key.slice(separator + 1);
return `${functionName} <- ${url}`
.replaceAll("/app/apps/nextjs/.next/server/", "")
.replaceAll("/app/node_modules/", "");
};
let label;
let total;
let sites;
if (fromStage && toStage) {
const before = load(fromStage);
const after = load(toStage);
const previous = new Map(before.sites.map((site) => [site.key, site.bytes]));
label = `allocated between "${fromStage}" and "${toStage}"`;
total = after.total - before.total;
sites = after.sites
.map((site) => ({ ...site, bytes: site.bytes - (previous.get(site.key) ?? 0) }))
.filter((site) => site.bytes > 0)
.sort((a, b) => b.bytes - a.bytes);
} else {
const profile = load(fromStage ?? "06-final");
label = `cumulative since boot (${fromStage ?? "06-final"})`;
total = profile.total;
sites = profile.sites;
}
const covered = sites.reduce((sum, site) => sum + site.bytes, 0);
console.log(`${label}: ${MiB(total)} MiB total, ${MiB(covered)} MiB attributed across ${sites.length} sites\n`);
const byOwner = new Map();
for (const site of sites) {
const owner = ownerOf(site.key);
const acc = byOwner.get(owner) ?? { bytes: 0, sites: 0 };
acc.bytes += site.bytes;
acc.sites++;
byOwner.set(owner, acc);
}
console.log("=== allocation volume by owner ===");
console.log(`${"MiB".padStart(9)} ${"share".padStart(7)} ${"sites".padStart(6)} owner`);
for (const [owner, acc] of [...byOwner.entries()].sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 18)) {
const share = covered === 0 ? "-" : `${((acc.bytes / covered) * 100).toFixed(1)}%`;
console.log(`${MiB(acc.bytes).padStart(9)} ${share.padStart(7)} ${String(acc.sites).padStart(6)} ${owner}`);
}
console.log(`\n=== top individual allocation sites ===`);
for (const site of sites.slice(0, 16)) {
const share = covered === 0 ? "-" : `${((site.bytes / covered) * 100).toFixed(1)}%`;
console.log(`\n ${MiB(site.bytes)} MiB (${share}) ${shorten(site.key).slice(0, 110)}`);
for (const frame of (site.stack ?? []).slice(1, 4)) console.log(` from ${shorten(frame).slice(0, 106)}`);
}