perf(docker): stop loading ssh2, which allocates a 16 MiB WASM heap nobody uses

Profiling at the memory high-water mark - not at idle, which is what every earlier
round measured - found the largest single retainer in every snapshot was one V8
Context holding a 16.00 MiB ArrayBuffer. Expanding it through the dominator tree
named it: ssh2's crypto module, whose POLY1305_WASM_MODULE instantiates a
WebAssembly memory for ChaCha20-Poly1305 as soon as it is required.

Homarr has no SSH feature and does not depend on ssh2. The chain is
dockerode -> docker-modem -> ssh2, and docker-modem/lib/modem.js requires './ssh'
at module scope while using it in exactly one place, already guarded by
`protocol === 'ssh'`. A pnpm patch moves the require inside that branch. SSH-based
Docker hosts still work - require() is cached, so they pay it once - and every
socket-based install, which is everyone else, never loads it.

Measured over four alternated runs on a restored real backup, external memory:

                     before        after
  board loaded    25.4, 25.1    8.3, 11.2
  nav-01          28.5, 25.4   10.1,  8.6
  multi-tab       31.1, 35.8   13.0, 10.0
  post-soak       21.4, 21.5    5.5,  7.2

Consistently -15 to -18 MiB, ranges non-overlapping, and 30 fewer files loaded
(1470 -> 1440).

Peak RSS does NOT improve, and that is worth stating plainly: V8 sizes its heap
against available headroom, so freeing native memory lets it grow further before
collecting. What changes is the composition - a fixed 16 MiB of unusable WASM
buffer becomes headroom the app can actually use, which matters under a container
memory limit but does not move the peak number.

Also adds the instruments this needed. A heap snapshot can never explain a peak,
because V8 runs a full GC before serialising one and reports only survivors:

  - memory-probe.cjs gains in-process allocation sampling, on-demand snapshots via
    v8.writeHeapSnapshot, an smaps endpoint, and require-graph recording.
  - analyze-allocations.mjs diffs two stages to isolate what one stage allocated.
  - analyze-smaps-stages.mjs splits each stage's RSS into JS heap, JIT, native and
    file-backed pages.
  - analyze-heap-dominators.mjs can now expand the largest retainer, which is what
    identified ssh2. A plain graph walk escapes through __proto__ and is useless.

One trap recorded in the findings: taking the peak snapshot over CDP delivers it as
JS string chunks and added ~134 MiB of ArrayBuffers to the process being measured,
producing a fake 693 MiB reading. v8.writeHeapSnapshot streams from C++ instead, and
the probe now reports its own observerCostMiB so the cost of looking is visible.
This commit is contained in:
Thomas Camlong committed 2026-08-09 22:17:25 +02:00
1 parent 3bc25509cf
commit 3cc6ff33df
9 files changed
+598 -32

No files matched your search

@@ -0,0 +1,99 @@
// 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 <dir> [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 <dir> [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";
};
const shorten = (key) =>
key.replace("|", " <- ").replace("/app/apps/nextjs/.next/server/", "").replace("/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)}`);
}
@@ -198,6 +198,17 @@ const label = (o) => {
return `${type}:${name}`;
};
/** The property name a parent reaches a child through — that is what names the contents. */
const edgeLabelBetween = (from, to) => {
for (let e = firstEdge[from]; e < firstEdge[from + 1]; e++) {
if (edges[e * eStride + eTo] / nStride !== to) continue;
const type = edgeTypes[edges[e * eStride + eType]];
const raw = edges[e * eStride + eName];
return type === "element" || type === "hidden" ? `[${raw}]` : (strings[raw] ?? String(raw));
}
return "(indirect)";
};
const candidates = [];
for (let i = 0; i < reachableCount; i++) {
if (i === rootPost) continue;
@@ -228,6 +239,26 @@ for (const i of candidates) {
if (shown.length >= topN) break;
}
// Expanding the single largest retainer is usually the whole point: "a Context retains 16 MiB"
// is not actionable until its contents are named. Only the dominator tree can do this — a
// plain graph walk escapes through __proto__ into the entire heap and reports nonsense.
if (shown.length) {
const top = shown[0];
const childrenOfTop = [];
for (let i = 0; i < reachableCount; i++) {
if (i !== top && dom[i] === top) childrenOfTop.push(i);
}
childrenOfTop.sort((a, b) => retained[b] - retained[a]);
const topNode = postOrderNodes[top];
console.log(`\n=== inside the largest retainer (${label(topNode)}, ${MiB(retained[top])} MiB) ===`);
console.log(` it dominates ${childrenOfTop.length} direct children; heaviest:`);
for (const child of childrenOfTop.slice(0, 20)) {
const node = postOrderNodes[child];
const edgeName = edgeLabelBetween(topNode, node);
console.log(` ${MiB(retained[child]).padStart(8)} MiB ${edgeName} -> ${label(node).slice(0, 78)}`);
}
}
// Grouping by constructor over retained size shows which *kind* of thing dominates, which
// is what a shallow histogram cannot express.
const byName = new Map();
+121
View File
@@ -0,0 +1,121 @@
// Breaks each stage's RSS into JS heap, JIT code, native malloc and file-backed pages.
//
// node scripts/benchmarks/analyze-smaps-stages.mjs .bench/stress/<label>
//
// `process.memoryUsage()` accounts for the JS heap and external buffers and stops there. On a
// loaded container that leaves a large unexplained remainder — measured at ~152 MiB of a
// 352 MiB peak — which is invisible to every JS-level tool. Only /proc/<pid>/smaps
// distinguishes what that remainder is, and only a per-stage capture shows which part of it
// grows under load.
//
// Anonymous mappings are classified by signature: V8 allocates its heap in 256 KiB pages
// (kRegularPageSize) and nothing else in the process allocates at that granularity in bulk,
// while pthread reserves ~8 MiB per thread stack and touches almost none of it.
import { readFileSync, readdirSync } from "node:fs";
import path from "node:path";
const directory = process.argv[2];
if (!directory) throw new Error("usage: analyze-smaps-stages.mjs <stress output dir>");
const V8_PAGE_BYTES = 262144;
const THREAD_STACK_BYTES = 8196 * 1024;
const parse = (text) => {
const mappings = [];
let current;
for (const line of text.split("\n")) {
const header = /^([0-9a-f]+)-([0-9a-f]+)\s+(\S{4})\s+\S+\s+\S+\s+\S+\s*(.*)$/.exec(line);
if (header) {
if (current) mappings.push(current);
const start = Number.parseInt(header[1], 16);
const end = Number.parseInt(header[2], 16);
current = { size: end - start, rss: 0, perms: header[3], file: (header[4] ?? "").trim() };
continue;
}
if (!current) continue;
const rss = /^Rss:\s+(\d+) kB/.exec(line);
if (rss) current.rss = Number(rss[1]) * 1024;
}
if (current) mappings.push(current);
return mappings;
};
const bucketOf = (mapping) => {
const executable = mapping.perms.includes("x");
if (mapping.file === "[stack]" || mapping.size === THREAD_STACK_BYTES) return "thread stacks";
if (mapping.file === "[heap]") return "malloc brk heap";
if (mapping.file.startsWith("[")) return "kernel";
if (mapping.file) {
if (/\.node$/.test(mapping.file)) return "native addons (file-backed)";
if (/\/node$/.test(mapping.file)) return "node binary (file-backed)";
if (/\.so/.test(mapping.file)) return "shared libraries (file-backed)";
return "other file-backed";
}
if (executable) return "JIT code";
if (mapping.size === V8_PAGE_BYTES) return "V8 heap pages";
return "native malloc / buffers";
};
const ORDER = ["01-boot", "02-post-restore", "03-post-login", "04-board", "nav-", "07-multi-tab", "05-after", "soak-", "06-final"];
const rank = (name) => {
const index = ORDER.findIndex((prefix) => name.startsWith(prefix));
return index === -1 ? ORDER.length : index;
};
const stages = readdirSync(directory)
.filter((name) => name.endsWith(".smaps.json"))
.map((name) => name.replace(".smaps.json", ""))
.sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
if (!stages.length) throw new Error(`no *.smaps.json in ${directory}`);
const MiB = (bytes) => (bytes / 1048576).toFixed(1);
const BUCKETS = [
"V8 heap pages",
"native malloc / buffers",
"JIT code",
"node binary (file-backed)",
"shared libraries (file-backed)",
"native addons (file-backed)",
"thread stacks",
"malloc brk heap",
];
const rows = [];
for (const stage of stages) {
const payload = JSON.parse(readFileSync(path.join(directory, `${stage}.smaps.json`), "utf8"));
if (!payload.smaps) continue;
const totals = new Map();
let rss = 0;
for (const mapping of parse(payload.smaps)) {
rss += mapping.rss;
const bucket = bucketOf(mapping);
totals.set(bucket, (totals.get(bucket) ?? 0) + mapping.rss);
}
rows.push({ stage, rss, totals });
}
const header = ["stage".padEnd(16), "RSS".padStart(8), ...BUCKETS.map((name) => name.slice(0, 11).padStart(12))].join(" ");
console.log(`\n=== RSS composition per stage, MiB ===`);
console.log(header);
console.log("-".repeat(header.length));
for (const row of rows) {
const cells = BUCKETS.map((bucket) => MiB(row.totals.get(bucket) ?? 0).padStart(12));
console.log([row.stage.padEnd(16), MiB(row.rss).padStart(8), ...cells].join(" "));
}
const first = rows[0];
const peak = rows.reduce((best, row) => (row.rss > best.rss ? row : best), rows[0]);
console.log(`\n=== boot ("${first.stage}") vs peak ("${peak.stage}") ===`);
console.log(`${"bucket".padEnd(32)} ${"boot".padStart(9)} ${"peak".padStart(9)} ${"growth".padStart(9)}`);
for (const bucket of BUCKETS) {
const from = first.totals.get(bucket) ?? 0;
const to = peak.totals.get(bucket) ?? 0;
if (from === 0 && to === 0) continue;
const delta = to - from;
console.log(
`${bucket.padEnd(32)} ${MiB(from).padStart(9)} ${MiB(to).padStart(9)} ${((delta >= 0 ? "+" : "") + MiB(delta)).padStart(9)}`,
);
}
console.log(
`${"TOTAL RSS".padEnd(32)} ${MiB(first.rss).padStart(9)} ${MiB(peak.rss).padStart(9)} ${("+" + MiB(peak.rss - first.rss)).padStart(9)}`,
);
+149 -30
View File
@@ -105,6 +105,112 @@ globalThis.__homarrProbe = {
moduleLoadList: () => process.moduleLoadList.slice(),
};
/**
* Opt-in allocation sampling, and on-demand heap snapshots, over an in-process inspector
* session.
*
* A heap snapshot cannot explain a peak. V8 runs a full GC before serialising one, so by
* construction it shows what survived, never the transient churn that *is* the peak — the
* 94 MiB of ArrayBuffers that drove this container to 500 MB were invisible in every
* snapshot taken. The sampling heap profiler is the tool that sees them: it records an
* allocation stack every N bytes allocated, whether or not the object survives.
*
* An in-process `inspector.Session` is used rather than SIGUSR1 plus a debug port, so this
* needs no signal, opens no socket, and works while the process is under load.
*
* Enable with HOMARR_PROBE_SAMPLE_ALLOCATIONS=<sampling interval in bytes>.
*/
const inspector = require("inspector");
let session = null;
const ensureSession = () => {
if (session) return session;
session = new inspector.Session();
session.connect();
return session;
};
const postAsync = (method, params) =>
new Promise((resolve, reject) =>
ensureSession().post(method, params, (error, result) => (error ? reject(error) : resolve(result))),
);
const samplingInterval = Number(process.env.HOMARR_PROBE_SAMPLE_ALLOCATIONS ?? 0);
if (samplingInterval > 0) {
postAsync("HeapProfiler.enable")
.then(() => postAsync("HeapProfiler.startSampling", { samplingInterval }))
.then(() => console.log(`[memory-probe] allocation sampling every ${samplingInterval} bytes`))
.catch((error) => console.error(`[memory-probe] could not start sampling: ${error.message}`));
/**
* Flattens the profile's call tree to self-bytes per allocation site.
*
* Sites are keyed by frame and aggregated over the *whole* tree, not just the largest
* nodes, so the reported totals actually sum to the profile total — a truncated list
* silently drops most of the bytes and makes every percentage wrong.
*
* `getSamplingProfile` reports cumulative bytes since sampling began, which mixes boot with
* load. Capturing at two stages and subtracting is what isolates a single stage, so the key
* is kept stable and machine-comparable for exactly that purpose.
*/
globalThis.__homarrProbe.allocations = async () => {
const { profile } = await postAsync("HeapProfiler.getSamplingProfile");
const sites = new Map();
let total = 0;
const walk = (node, ancestry) => {
const frame = node.callFrame;
const key = `${frame.functionName || "(anonymous)"}|${frame.url}:${frame.lineNumber + 1}`;
const stack = [key, ...ancestry];
const selfSize = node.selfSize ?? 0;
if (selfSize > 0) {
total += selfSize;
const acc = sites.get(key) ?? { bytes: 0, samples: 0, stack: stack.slice(0, 6) };
acc.bytes += selfSize;
acc.samples++;
sites.set(key, acc);
}
for (const child of node.children ?? []) walk(child, stack);
};
walk(profile.head, []);
return {
total,
sites: [...sites.entries()]
.map(([key, acc]) => ({ key, ...acc }))
.sort((a, b) => b.bytes - a.bytes)
.slice(0, 400),
};
};
}
/**
* Writes a heap snapshot to a path inside the container. Exposed so the harness can grab one
* at a chosen moment — in particular while tabs are still open, which is a different heap
* from the post-soak one every earlier snapshot captured.
*/
globalThis.__homarrProbe.snapshot = (destination) => {
// v8.writeHeapSnapshot streams to the file from C++. The CDP route
// (HeapProfiler.takeHeapSnapshot) delivers the snapshot as JS string chunks instead, and
// for a ~135 MB snapshot that briefly added ~134 MiB of ArrayBuffers to the very process
// being measured — an observer effect large enough to be mistaken for an application
// problem, which is exactly what happened on the first attempt.
const before = process.memoryUsage();
v8.writeHeapSnapshot(destination);
const after = process.memoryUsage();
return {
written: destination,
bytes: fs.statSync(destination).size,
// Reported so the cost of observing is visible rather than assumed to be zero.
observerCostMiB: Math.round(((after.rss - before.rss) / 1048576) * 10) / 10,
};
};
/** Address-space breakdown at the moment of capture; only /proc shows native vs JS vs code. */
globalThis.__homarrProbe.maps = () => {
try {
return { smaps: fs.readFileSync(`/proc/${process.pid}/smaps`, "utf8") };
} catch (error) {
return { error: String(error && error.message) };
}
};
/**
* Opt-in recording of the CommonJS require graph.
*
@@ -284,36 +390,49 @@ if (probePort > 0) {
const probe = globalThis.__homarrProbe;
require("http")
.createServer((request, response) => {
let body;
try {
// The raw file list is served separately: it is long, and every stage of a stress
// run fetches the summary, so keeping it out of the default payload keeps those
// captures small while still allowing a boot-vs-loaded diff on demand.
body =
request.url === "/files"
? JSON.stringify(probe.moduleFiles())
: request.url === "/buffers"
? JSON.stringify(probe.buffers ? probe.buffers() : { disabled: true })
: request.url === "/fetches"
? JSON.stringify(probe.fetches ? probe.fetches() : { disabled: true })
: request.url === "/requires"
? JSON.stringify(probe.requireGraph ? probe.requireGraph() : { disabled: true })
: JSON.stringify({
usage: probe.usage(),
spaces: probe.spaces(),
heap: probe.heap(),
modules: probe.modules(),
largestModules: probe.largestModules(25),
uptime: process.uptime(),
execArgv: process.execArgv,
});
} catch (error) {
response.writeHead(500, { "content-type": "application/json" });
response.end(JSON.stringify({ error: String(error && error.message) }));
return;
}
response.writeHead(200, { "content-type": "application/json" });
response.end(body);
// Several endpoints are async (the inspector ones), so every route resolves through a
// promise rather than some returning a value and some a pending Promise object.
const [route, query] = (request.url ?? "/").split("?");
const resolve = async () => {
switch (route) {
// The raw file list is served separately: it is long, and every stage of a stress
// run fetches the summary, so keeping it out of the default payload keeps those
// captures small while still allowing a boot-vs-loaded diff on demand.
case "/files":
return probe.moduleFiles();
case "/buffers":
return probe.buffers ? probe.buffers() : { disabled: true };
case "/fetches":
return probe.fetches ? probe.fetches() : { disabled: true };
case "/requires":
return probe.requireGraph ? probe.requireGraph() : { disabled: true };
case "/allocations":
return probe.allocations ? await probe.allocations() : { disabled: true };
case "/maps":
return probe.maps();
case "/snapshot":
return await probe.snapshot(new URLSearchParams(query).get("path") ?? "/tmp/peak.heapsnapshot");
default:
return {
usage: probe.usage(),
spaces: probe.spaces(),
heap: probe.heap(),
modules: probe.modules(),
largestModules: probe.largestModules(25),
uptime: process.uptime(),
execArgv: process.execArgv,
};
}
};
resolve()
.then((value) => {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(value));
})
.catch((error) => {
response.writeHead(500, { "content-type": "application/json" });
response.end(JSON.stringify({ error: String(error && error.message) }));
});
})
// NODE_OPTIONS applies to every node process in the container, including the DB
// migration step and any `docker exec node` the harness runs, so all of them load this
+55
View File
@@ -46,6 +46,10 @@ const soakIntervalMs = Number(process.env.STRESS_SOAK_INTERVAL_MS ?? 20_000);
const keepContainer = process.env.STRESS_KEEP_CONTAINER === "true";
/** Dump a browser heap snapshot here (analyse with analyze-react-fibers.mjs). */
const clientSnapshotPath = process.env.STRESS_CLIENT_SNAPSHOT;
/** Dump a *server* heap snapshot taken at peak, with all tabs still open. */
const peakSnapshotPath = process.env.STRESS_PEAK_SNAPSHOT
? path.resolve(process.env.STRESS_PEAK_SNAPSHOT)
: undefined;
/**
* Extra `docker run` wiring, so a diagnostics-only preload can be injected into a
* shipped image without rebuilding it. Used to mount scripts/benchmarks at /probe and
@@ -115,6 +119,54 @@ const captureProbeAsync = async (name: string) => {
if (!fetches.includes('"disabled"')) {
await writeFile(path.join(outputDirectory, `${name}.fetches.json`), fetches);
}
// Allocation totals are cumulative since boot, so a single capture cannot separate "the
// app loaded its code" from "four tabs churned through memory". Capturing at every stage
// makes those subtractable.
const allocations = await fetchProbeAsync("/allocations");
if (!allocations.includes('"disabled"')) {
await writeFile(path.join(outputDirectory, `${name}.allocations.json`), allocations);
}
// The address-space map is the only place JS heap, JIT code, native malloc and file-backed
// pages are distinguishable, and `rss - heapTotal - external` grew by ~85 MiB between boot
// and peak with no JS-side explanation. Captured every stage because which stage is the
// true high-water mark is not known in advance — it is not always the multi-tab one.
const maps = await fetchProbeAsync("/maps");
if (!maps.includes('"error"')) {
await writeFile(path.join(outputDirectory, `${name}.smaps.json`), maps);
}
};
/**
* Grabs the two artefacts that only make sense at the high-water mark, with load still on:
* a heap snapshot (what peak memory *retains*) and the cumulative allocation profile (what
* peak memory *churned through*, which no snapshot can show because V8 GCs before it
* serialises one). Set STRESS_PEAK_SNAPSHOT to a host path to keep the snapshot.
*/
const capturePeakAsync = async () => {
if (!probePort) return;
// Mappings first, and before any snapshot: this is the only reading that separates JS heap
// from native, JIT and file-backed pages, and it must reflect the loaded process rather
// than the process plus whatever the act of snapshotting allocated.
const maps = await fetchProbeAsync("/maps");
if (!maps.includes('"error"')) {
await writeFile(path.join(outputDirectory, "peak.smaps.json"), maps);
log("peak: address-space map captured");
}
const allocations = await fetchProbeAsync("/allocations");
if (!allocations.includes('"disabled"')) {
await writeFile(path.join(outputDirectory, "peak.allocations.json"), allocations);
log("peak: allocation profile captured");
}
if (!peakSnapshotPath) return;
const inContainer = "/tmp/peak.heapsnapshot";
const result = await fetchProbeAsync(`/snapshot?path=${encodeURIComponent(inContainer)}`);
if (result.includes('"error"')) {
log(`peak: snapshot failed — ${result.slice(0, 160)}`);
return;
}
await execFileAsync("docker", ["cp", `${containerName}:${inContainer}`, peakSnapshotPath]);
await execFileAsync("docker", ["exec", containerName, "rm", "-f", inContainer]).catch(() => undefined);
log(`peak: heap snapshot saved to ${peakSnapshotPath}`);
};
const captureAsync = async (name: string) => {
@@ -512,6 +564,9 @@ const main = async () => {
}
log(`multi-tab: clicked ${widgetInteractions.clicked}/${widgetInteractions.available} widget controls`);
await captureAsync("07-multi-tab");
// Taken here, with every tab still open, because this is the high-water mark. Every
// earlier snapshot was of a drained process and could not show what peak memory holds.
await capturePeakAsync();
}
if (clientSnapshotPath) await captureClientSnapshotAsync(freshPage, clientSnapshotPath);