next-leak 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/bootstrap.js CHANGED
@@ -1,100 +1,15 @@
1
1
  import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
2
+ import {
3
+ requestProbe,
4
+ startControlServer
5
+ } from "./chunk-IKWF7SSB.js";
2
6
  import "./chunk-6XYFBOL2.js";
3
7
 
4
8
  // src/bootstrap.ts
5
9
  import { mkdir, writeFile } from "fs/promises";
6
- import http2 from "http";
7
- import https from "https";
8
- import path2 from "path";
9
-
10
- // src/control-server.ts
11
10
  import http from "http";
11
+ import https from "https";
12
12
  import path from "path";
13
- import { writeHeapSnapshot } from "v8";
14
- var g = globalThis;
15
- var tick = () => new Promise((resolve) => setImmediate(resolve));
16
- async function forceGc(passes = 3) {
17
- if (typeof g.gc !== "function") {
18
- return false;
19
- }
20
- for (let i = 0; i < passes; i += 1) {
21
- g.gc();
22
- await tick();
23
- }
24
- return true;
25
- }
26
- var requestProbe = globalThis;
27
- function sampleMemory(gcExposed) {
28
- const usage = process.memoryUsage();
29
- return {
30
- gcExposed,
31
- heapUsed: usage.heapUsed,
32
- rss: usage.rss,
33
- external: usage.external,
34
- arrayBuffers: usage.arrayBuffers,
35
- pid: process.pid,
36
- ppid: process.ppid,
37
- argv: process.argv,
38
- cwd: process.cwd(),
39
- ...requestProbe.__nextLeakServedRequests === void 0 ? {} : { servedRequests: requestProbe.__nextLeakServedRequests }
40
- };
41
- }
42
- async function startControlServer(options) {
43
- const write = options.writeSnapshot ?? writeHeapSnapshot;
44
- const server = http.createServer((request, response) => {
45
- void handle(request, response);
46
- });
47
- async function handle(request, response) {
48
- const url = new URL(request.url ?? "/", "http://control.local");
49
- const respond = (status, body) => {
50
- response.writeHead(status, { "content-type": "application/json" });
51
- response.end(JSON.stringify(body));
52
- };
53
- try {
54
- if (url.pathname === "/gc") {
55
- const gcExposed = await forceGc();
56
- respond(200, sampleMemory(gcExposed));
57
- return;
58
- }
59
- if (url.pathname === "/mem") {
60
- respond(200, sampleMemory(typeof g.gc === "function"));
61
- return;
62
- }
63
- if (url.pathname === "/snapshot") {
64
- const name = url.searchParams.get("name");
65
- if (name === null || name === "") {
66
- respond(400, { error: "missing ?name=<label>" });
67
- return;
68
- }
69
- const gcExposed = await forceGc();
70
- const sample = sampleMemory(gcExposed);
71
- const file = write(
72
- path.join(options.snapshotDir, `${path.basename(name)}.heapsnapshot`)
73
- );
74
- respond(200, { file, sample });
75
- return;
76
- }
77
- respond(404, { error: `unknown path ${url.pathname}` });
78
- } catch (cause) {
79
- respond(500, { error: cause instanceof Error ? cause.message : String(cause) });
80
- }
81
- }
82
- await new Promise((resolve, reject) => {
83
- server.once("error", reject);
84
- server.listen(0, "127.0.0.1", resolve);
85
- });
86
- server.unref();
87
- const address = server.address();
88
- if (address === null || typeof address === "string") {
89
- throw new Error("control server has no bound port");
90
- }
91
- return {
92
- port: address.port,
93
- close: () => new Promise((resolve, reject) => {
94
- server.close((error) => error ? reject(error) : resolve());
95
- })
96
- };
97
- }
98
13
 
99
14
  // src/request-probe.ts
100
15
  function countServedRequests(module) {
@@ -110,18 +25,22 @@ function countServedRequests(module) {
110
25
 
111
26
  // src/bootstrap.ts
112
27
  var workDir = process.env["NEXT_LEAK_DIR"];
28
+ var token = process.env["NEXT_LEAK_TOKEN"];
113
29
  function installRequestProbe() {
114
30
  requestProbe.__nextLeakServedRequests = 0;
115
- countServedRequests(http2);
31
+ countServedRequests(http);
116
32
  countServedRequests(https);
117
33
  }
118
34
  if (workDir !== void 0 && workDir !== "") {
119
35
  try {
120
36
  await mkdir(workDir, { recursive: true });
121
- const server = await startControlServer({ snapshotDir: workDir });
37
+ if (token === void 0 || token === "") {
38
+ throw new Error("NEXT_LEAK_TOKEN is not set, and the channel does not start without it");
39
+ }
40
+ const server = await startControlServer({ snapshotDir: workDir, token });
122
41
  installRequestProbe();
123
42
  await writeFile(
124
- path2.join(workDir, `control-${process.pid}.json`),
43
+ path.join(workDir, `control-${process.pid}.json`),
125
44
  JSON.stringify({ port: server.port, pid: process.pid })
126
45
  );
127
46
  } catch (cause) {
@@ -15,6 +15,26 @@ export type BuildAttribution = {
15
15
  baselineFile: string;
16
16
  afterFile: string;
17
17
  };
18
+ /**
19
+ * Why a run that asked for attribution came back without one.
20
+ *
21
+ * An absent section and a failed diff read the same in a report, and they are
22
+ * opposite findings: one says nobody asked what the worker retained, the other
23
+ * says the question was asked and the evidence could not be read. The build
24
+ * path fails this way hardest on the builds worth measuring — a snapshot past
25
+ * roughly a gigabyte of worker rss cannot be parsed back, so the bigger the
26
+ * build grew, the likelier the report says nothing about what grew.
27
+ */
28
+ export type BuildAttributionGap = {
29
+ /**
30
+ * `capture-missed`: the run was told to capture and never got a usable pair.
31
+ * `snapshot-unreadable`: the pair exists and the diff refused it.
32
+ */
33
+ reason: "capture-missed" | "snapshot-unreadable";
34
+ detail: string;
35
+ };
36
+ /** Narrows the outcome of `attributeBuildCapture`. */
37
+ export declare function isAttributionGap(outcome: BuildAttribution | BuildAttributionGap | null): outcome is BuildAttributionGap;
18
38
  export type BuildAttributionDeps = {
19
39
  diff: typeof diffSnapshotFiles;
20
40
  registry: typeof extractModuleRegistry;
@@ -22,12 +42,14 @@ export type BuildAttributionDeps = {
22
42
  /**
23
43
  * Names what a build's worker retained, from the pair the run captured.
24
44
  *
25
- * Returns null rather than throwing on every failure path. A build measurement
26
- * stands on its curve and its verdict; this is an addition to the report, and
27
- * an addition that can fail must not be able to take the report with it.
45
+ * Never throws on a failure path. A build measurement stands on its curve and
46
+ * its verdict; this is an addition to the report, and an addition that can
47
+ * fail must not be able to take the report with it. It returns a gap instead,
48
+ * so the report can tell a question nobody asked from one it could not answer.
49
+ * Null is reserved for the run that never asked.
28
50
  *
29
51
  * The registry is read from `.next/server` *after* the build, never during it:
30
52
  * a half-written chunk can resolve a module id to the wrong source, and naming
31
53
  * the wrong owner confidently is worse than naming none.
32
54
  */
33
- export declare function attributeBuildCapture(result: BuildRunResult, appDir: string, onProgress?: (message: string) => void, deps?: BuildAttributionDeps): Promise<BuildAttribution | null>;
55
+ export declare function attributeBuildCapture(result: BuildRunResult, appDir: string, onProgress?: (message: string) => void, deps?: BuildAttributionDeps): Promise<BuildAttribution | BuildAttributionGap | null>;
@@ -1,12 +1,8 @@
1
- import type { BuildAttribution } from "./build-attribution.js";
1
+ import { type BuildAttribution, type BuildAttributionGap } from "./build-attribution.js";
2
2
  import type { BuildRunResult } from "./build-run.js";
3
3
  /**
4
- * Renders the build report.
5
- *
6
- * Says "worker rss" everywhere rather than "heap" or "retained": these samples
7
- * are resident memory with no forced collection behind them, and calling them
8
- * retention would borrow a precision the measurement does not have. RSS is
9
- * still the honest axis here — it is what a CI runner's limit is enforced
10
- * against and what the OOM killer reads.
4
+ * The report as it is written to the terminal. The build's output and the
5
+ * names in the attribution come from the measured app, so the text goes
6
+ * through `terminalSafe` whichever branch produced it.
11
7
  */
12
- export declare function formatBuildReport(result: BuildRunResult, attribution?: BuildAttribution | null): string;
8
+ export declare function formatBuildReport(result: BuildRunResult, attribution?: BuildAttribution | BuildAttributionGap | null): string;
@@ -51,6 +51,15 @@ export type BuildRunResult = {
51
51
  * produced whether or not capture worked.
52
52
  */
53
53
  capture: BuildCapture | null;
54
+ /**
55
+ * Whether this run was asked to capture at all. Without it a null `capture`
56
+ * is ambiguous — nobody asked, or it was asked and missed — and the report
57
+ * has to choose between staying silent on a lost measurement and nagging
58
+ * every run that never wanted one.
59
+ */
60
+ captureRequested: boolean;
61
+ /** How far capture got before giving up, when it was requested and missed. */
62
+ captureFailure: string | null;
54
63
  strippedCapWarning: string | null;
55
64
  exitCode: number | null;
56
65
  output: string;
@@ -3,32 +3,55 @@ import {
3
3
  assessPeakPressure,
4
4
  describePeakPressure,
5
5
  retainedAfterLoad
6
- } from "./chunk-E464SO7G.js";
6
+ } from "./chunk-VTKOO3YJ.js";
7
7
 
8
8
  // src/issue-report.ts
9
9
  import path from "path";
10
10
  var MB = 1024 * 1024;
11
+ function codeSpan(text) {
12
+ const flat = text.replaceAll(/[\r\n]+/g, " ");
13
+ const longestRun = Math.max(0, ...[...flat.matchAll(/`+/g)].map((run) => run[0].length));
14
+ const fence = "`".repeat(longestRun + 1);
15
+ const padding = flat.startsWith("`") || flat.endsWith("`") ? " " : "";
16
+ return `${fence}${padding}${flat}${padding}${fence}`;
17
+ }
18
+ function publicWorkDir(run) {
19
+ const relative = path.relative(run.appDir, run.workDir);
20
+ return relative.startsWith("..") || path.isAbsolute(relative) ? path.basename(run.workDir) : relative;
21
+ }
11
22
  function ownerLabel(attribution) {
12
23
  if (attribution === void 0 || attribution.owner === "unattributed") {
13
24
  return "unattributed";
14
25
  }
15
- const source = attribution.source ? ` \u2014 \`${attribution.source}\`` : "";
16
- const packageName = attribution.packageName ? ` \u2014 ${attribution.packageName}` : "";
26
+ const source = attribution.source ? ` \u2014 ${codeSpan(attribution.source)}` : "";
27
+ const packageName = attribution.packageName ? ` \u2014 ${codeSpan(attribution.packageName)}` : "";
17
28
  return `${attribution.owner}${source}${packageName}`;
18
29
  }
30
+ function publicDetail(detail, run) {
31
+ return detail.replaceAll(run.workDir, publicWorkDir(run)).replaceAll(run.appDir, "<app-dir>").replaceAll(/[\r\n]+/g, " ");
32
+ }
19
33
  function blamedParty(owner, culprit) {
20
34
  if (owner === "app") {
21
- return `**your own code** (\`${culprit?.source ?? "app code"}\`)`;
35
+ return `**your own code** (${codeSpan(culprit?.source ?? "app code")})`;
36
+ }
37
+ const packageName = culprit?.packageName;
38
+ return `the dependency **${packageName ? codeSpan(packageName) : "a dependency"}**`;
39
+ }
40
+ function missingEvidence(route, run) {
41
+ if (route.attributionGap === void 0) {
42
+ return "- (no findings above thresholds)";
22
43
  }
23
- return `the dependency **${culprit?.packageName ?? "a dependency"}**`;
44
+ const cause = route.attributionGap.reason === "snapshot-unavailable" ? "the final heap snapshot was never taken" : "the heap snapshots could not be read";
45
+ return `- **Attribution unavailable** \u2014 ${cause}, so this run cannot name what retains the memory. This is not a finding that nothing grew: the diff was never computed. The growth figure above is unaffected.
46
+ - ${publicDetail(route.attributionGap.detail, run)}`;
24
47
  }
25
48
  function evidenceRows(route) {
26
49
  const findings = [...route.diff?.grownNodes ?? [], ...route.diff?.newNodes ?? []];
27
50
  return findings.slice(0, 6).map((finding, index) => {
28
51
  const attribution = route.attribution?.findings[index];
29
52
  const owner = ownerLabel(attribution);
30
- return `- **${finding.kind}** \`[${finding.nodeType}] ${finding.name}\` ${(finding.retainedBytes / MB).toFixed(2)} MB retained (${owner})
31
- - retainers: \`${finding.retainerChain || "(none)"}\``;
53
+ return `- **${finding.kind}** ${codeSpan(`[${finding.nodeType}] ${finding.name}`)} ${(finding.retainedBytes / MB).toFixed(2)} MB retained (${owner})
54
+ - retainers: ${codeSpan(finding.retainerChain || "(none)")}`;
32
55
  }).join("\n");
33
56
  }
34
57
  function renderIssueMarkdown(route, run) {
@@ -71,7 +94,7 @@ Sampled during the load phases, without forcing collection: ${describePeakPressu
71
94
  const signatures = route.signatures.map(
72
95
  (signature) => `- ${signature.historical ? "(historical) " : ""}${signature.title} \u2014 ${signature.issue}`
73
96
  ).join("\n");
74
- return `${preamble}${cacheDisclosure}# Memory leak on route \`${route.route}\`
97
+ return `${preamble}${cacheDisclosure}# Memory leak on route ${codeSpan(route.route)}
75
98
 
76
99
  Report generated by \`next-leak\` \u2014 every number below is reproducible from
77
100
  the raw heap snapshots referenced at the bottom.
@@ -93,7 +116,7 @@ Deployment: output "standalone", node --expose-gc --max-old-space-size=${paramet
93
116
  \`\`\`
94
117
  npx next-leak <app-dir>
95
118
  \`\`\`
96
- 2. next-leak boots \`.next/standalone/server.js\` in a fresh process per route and runs, against \`${route.requestPath}\`:
119
+ 2. next-leak boots \`.next/standalone/server.js\` in a fresh process per route and runs, against ${codeSpan(route.requestPath)}:
97
120
  warm-up ${parameters.warmupRequests} requests \u2192 forced GC \u2192 baseline heap snapshot \u2192
98
121
  ${parameters.cycles} \xD7 [${parameters.loadRequests} requests at ${parameters.connections} connections \u2192 ${(parameters.idleMs / 1e3).toFixed(0)}s idle \u2192 forced GC \u2192 post-GC sample] \u2192 final snapshot.
99
122
  3. A cycle counts as growing when it retains at least ${(parameters.minGrowthPerCycle / 1024).toFixed(0)} KiB
@@ -109,7 +132,7 @@ The first cycle is excluded from the verdict (engine warm-up).
109
132
 
110
133
  ### Heap evidence
111
134
 
112
- ${evidenceRows(route) || "- (no findings above thresholds)"}
135
+ ${evidenceRows(route) || missingEvidence(route, run)}
113
136
  ${signatures === "" ? "" : `
114
137
  **Matched known causes:**
115
138
  ${signatures}
@@ -118,7 +141,7 @@ ${signatures}
118
141
 
119
142
  Raw snapshots (Chrome DevTools \u2192 Memory \u2192 Load, compare baseline vs after):
120
143
 
121
- - \`${path.basename(route.baselineSnapshot)}\` / \`${path.basename(route.afterSnapshot)}\` in \`${run.workDir}\`
144
+ - ${codeSpan(path.basename(route.baselineSnapshot))} / ${codeSpan(path.basename(route.afterSnapshot))} in ${codeSpan(publicWorkDir(run))}
122
145
  - machine-readable data: \`run.json\`, curves: \`report.html\`
123
146
  `;
124
147
  }
@@ -0,0 +1,114 @@
1
+ import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
2
+
3
+ // src/control-server.ts
4
+ import { timingSafeEqual } from "crypto";
5
+ import http from "http";
6
+ import path from "path";
7
+ import { writeHeapSnapshot } from "v8";
8
+ var g = globalThis;
9
+ var tick = () => new Promise((resolve) => setImmediate(resolve));
10
+ async function forceGc(passes = 3) {
11
+ if (typeof g.gc !== "function") {
12
+ return false;
13
+ }
14
+ for (let i = 0; i < passes; i += 1) {
15
+ g.gc();
16
+ await tick();
17
+ }
18
+ return true;
19
+ }
20
+ var requestProbe = globalThis;
21
+ function sampleMemory(gcExposed) {
22
+ const usage = process.memoryUsage();
23
+ return {
24
+ gcExposed,
25
+ heapUsed: usage.heapUsed,
26
+ rss: usage.rss,
27
+ external: usage.external,
28
+ arrayBuffers: usage.arrayBuffers,
29
+ pid: process.pid,
30
+ ppid: process.ppid,
31
+ argv: process.argv,
32
+ cwd: process.cwd(),
33
+ ...requestProbe.__nextLeakServedRequests === void 0 ? {} : { servedRequests: requestProbe.__nextLeakServedRequests }
34
+ };
35
+ }
36
+ var CONTROL_TOKEN_HEADER = "x-next-leak-token";
37
+ async function startControlServer(options) {
38
+ if (options.token === "") {
39
+ throw new Error("the control server needs a token and was given an empty one");
40
+ }
41
+ const write = options.writeSnapshot ?? writeHeapSnapshot;
42
+ const expected = Buffer.from(options.token);
43
+ const carriesToken = (request) => {
44
+ const header = request.headers[CONTROL_TOKEN_HEADER];
45
+ if (typeof header !== "string") {
46
+ return false;
47
+ }
48
+ const given = Buffer.from(header);
49
+ return given.length === expected.length && timingSafeEqual(given, expected);
50
+ };
51
+ const server = http.createServer((request, response) => {
52
+ void handle(request, response);
53
+ });
54
+ async function handle(request, response) {
55
+ const url = new URL(request.url ?? "/", "http://control.local");
56
+ const respond = (status, body) => {
57
+ response.writeHead(status, { "content-type": "application/json" });
58
+ response.end(JSON.stringify(body));
59
+ };
60
+ if (!carriesToken(request)) {
61
+ respond(403, { error: `missing or wrong ${CONTROL_TOKEN_HEADER} header` });
62
+ return;
63
+ }
64
+ try {
65
+ if (url.pathname === "/gc") {
66
+ const gcExposed = await forceGc();
67
+ respond(200, sampleMemory(gcExposed));
68
+ return;
69
+ }
70
+ if (url.pathname === "/mem") {
71
+ respond(200, sampleMemory(typeof g.gc === "function"));
72
+ return;
73
+ }
74
+ if (url.pathname === "/snapshot") {
75
+ const name = url.searchParams.get("name");
76
+ if (name === null || name === "") {
77
+ respond(400, { error: "missing ?name=<label>" });
78
+ return;
79
+ }
80
+ const gcExposed = await forceGc();
81
+ const sample = sampleMemory(gcExposed);
82
+ const file = write(
83
+ path.join(options.snapshotDir, `${path.basename(name)}.heapsnapshot`)
84
+ );
85
+ respond(200, { file, sample });
86
+ return;
87
+ }
88
+ respond(404, { error: `unknown path ${url.pathname}` });
89
+ } catch (cause) {
90
+ respond(500, { error: cause instanceof Error ? cause.message : String(cause) });
91
+ }
92
+ }
93
+ await new Promise((resolve, reject) => {
94
+ server.once("error", reject);
95
+ server.listen(0, "127.0.0.1", resolve);
96
+ });
97
+ server.unref();
98
+ const address = server.address();
99
+ if (address === null || typeof address === "string") {
100
+ throw new Error("control server has no bound port");
101
+ }
102
+ return {
103
+ port: address.port,
104
+ close: () => new Promise((resolve, reject) => {
105
+ server.close((error) => error ? reject(error) : resolve());
106
+ })
107
+ };
108
+ }
109
+
110
+ export {
111
+ requestProbe,
112
+ CONTROL_TOKEN_HEADER,
113
+ startControlServer
114
+ };
@@ -40,7 +40,7 @@ function isSaturating(deltas, minGrowth) {
40
40
  if (first === void 0 || last === void 0 || first < minGrowth) {
41
41
  return false;
42
42
  }
43
- if (!deltas.every((delta) => delta > 0)) {
43
+ if (!hasArrivedWithoutResuming(deltas, minGrowth)) {
44
44
  return false;
45
45
  }
46
46
  if (last > first * SATURATION_MAX_FINAL_RATIO) {
@@ -48,6 +48,17 @@ function isSaturating(deltas, minGrowth) {
48
48
  }
49
49
  return isHeadingDown(deltas);
50
50
  }
51
+ function hasArrivedWithoutResuming(deltas, minGrowth) {
52
+ const stop = deltas.findIndex((delta) => delta <= 0);
53
+ if (stop === -1) {
54
+ return true;
55
+ }
56
+ const approach = deltas[stop - 1];
57
+ if (approach === void 0 || approach >= minGrowth) {
58
+ return false;
59
+ }
60
+ return deltas.slice(stop).every((delta) => delta < minGrowth);
61
+ }
51
62
  function isHeadingDown(deltas) {
52
63
  const midpoint = Math.floor(deltas.length / 2);
53
64
  const head = deltas.slice(0, midpoint);
@@ -1,4 +1,7 @@
1
1
  import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
2
+ import {
3
+ CONTROL_TOKEN_HEADER
4
+ } from "./chunk-IKWF7SSB.js";
2
5
  import {
3
6
  assessConfidence,
4
7
  classifyMemoryTrend,
@@ -8,13 +11,13 @@ import {
8
11
  resolveCycles,
9
12
  warrantsIssueDraft,
10
13
  withdrawnByDisagreement
11
- } from "./chunk-UU4RXOZO.js";
14
+ } from "./chunk-QUFL2FXU.js";
12
15
  import {
13
16
  assessPeakPressure,
14
17
  assessPressureVerdict,
15
18
  describePeakPressure,
16
19
  retainedAfterLoad
17
- } from "./chunk-E464SO7G.js";
20
+ } from "./chunk-VTKOO3YJ.js";
18
21
  import {
19
22
  __commonJS,
20
23
  __esm,
@@ -93182,15 +93185,15 @@ async function jsFilesUnder(dir) {
93182
93185
  async function extractModuleRegistry(nextServerDir) {
93183
93186
  const registry = /* @__PURE__ */ new Map();
93184
93187
  for (const file of await jsFilesUnder(nextServerDir)) {
93185
- let code;
93186
- let map;
93188
+ let modules;
93187
93189
  try {
93188
- code = await readFile(file, "utf8");
93189
- map = JSON.parse(await readFile(`${file}.map`, "utf8"));
93190
+ const code = await readFile(file, "utf8");
93191
+ const map = JSON.parse(await readFile(`${file}.map`, "utf8"));
93192
+ modules = extractChunkModules(code, map);
93190
93193
  } catch {
93191
93194
  continue;
93192
93195
  }
93193
- for (const [id, source] of extractChunkModules(code, map)) {
93196
+ for (const [id, source] of modules) {
93194
93197
  if (!registry.has(id)) {
93195
93198
  registry.set(id, source);
93196
93199
  }
@@ -93199,6 +93202,16 @@ async function extractModuleRegistry(nextServerDir) {
93199
93202
  return registry;
93200
93203
  }
93201
93204
 
93205
+ // src/terminal-text.ts
93206
+ import { stripVTControlCharacters } from "util";
93207
+ var CONTROL_CHARACTER = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
93208
+ function terminalSafe(text) {
93209
+ return stripVTControlCharacters(text.replaceAll("\r\n", "\n")).replaceAll(
93210
+ CONTROL_CHARACTER,
93211
+ (character) => `\\x${character.charCodeAt(0).toString(16).padStart(2, "0")}`
93212
+ );
93213
+ }
93214
+
93202
93215
  // src/manifests.ts
93203
93216
  import { z as z2 } from "zod";
93204
93217
  var appPathsManifestSchema = z2.record(z2.string(), z2.string());
@@ -93392,6 +93405,7 @@ This only changes how the build is packaged \u2014 not how your app behaves.`
93392
93405
 
93393
93406
  // src/launcher.ts
93394
93407
  import { spawn } from "child_process";
93408
+ import { randomBytes } from "crypto";
93395
93409
  import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
93396
93410
  import path3 from "path";
93397
93411
  import { pathToFileURL } from "url";
@@ -93489,6 +93503,7 @@ async function pollUntil(deadline, describe, probe, failed) {
93489
93503
  async function launchInstrumented(options) {
93490
93504
  const hostname = options.hostname ?? "127.0.0.1";
93491
93505
  const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
93506
+ const controlToken = randomBytes(32).toString("hex");
93492
93507
  const child = spawn(
93493
93508
  process.execPath,
93494
93509
  [
@@ -93506,7 +93521,8 @@ async function launchInstrumented(options) {
93506
93521
  NODE_ENV: "production",
93507
93522
  PORT: String(options.appPort),
93508
93523
  HOSTNAME: hostname,
93509
- NEXT_LEAK_DIR: options.workDir
93524
+ NEXT_LEAK_DIR: options.workDir,
93525
+ NEXT_LEAK_TOKEN: controlToken
93510
93526
  },
93511
93527
  stdio: ["ignore", "ignore", "pipe"]
93512
93528
  }
@@ -93550,7 +93566,9 @@ ${stderrTailBuffer}`;
93550
93566
  const parsed = controlFileSchema.parse(
93551
93567
  JSON.parse(await readFile3(path3.join(options.workDir, entry), "utf8"))
93552
93568
  );
93553
- const response = await fetch(`http://127.0.0.1:${parsed.port}/gc`);
93569
+ const response = await fetch(`http://127.0.0.1:${parsed.port}/gc`, {
93570
+ headers: { [CONTROL_TOKEN_HEADER]: controlToken }
93571
+ });
93554
93572
  if (response.ok) {
93555
93573
  return parsed.port;
93556
93574
  }
@@ -93584,6 +93602,7 @@ ${stderrTailBuffer}`;
93584
93602
  pid: child.pid ?? -1,
93585
93603
  appPort: options.appPort,
93586
93604
  controlPort,
93605
+ controlToken,
93587
93606
  explainExit: () => {
93588
93607
  if (!exited) {
93589
93608
  return null;
@@ -93922,6 +93941,12 @@ function parseCliArgs(argv) {
93922
93941
  }
93923
93942
  };
93924
93943
  }
93944
+ if (options.attributeBuild) {
93945
+ return {
93946
+ kind: "error",
93947
+ message: `option "--attribute" applies only to "next-leak build": a run already names what it retains \u2014 see --help`
93948
+ };
93949
+ }
93925
93950
  return { kind: "run", options };
93926
93951
  }
93927
93952
 
@@ -94251,7 +94276,7 @@ function describeProgress(bytes) {
94251
94276
  function describeDuration(ms) {
94252
94277
  return ms >= 36e5 ? `${Math.round(ms / 36e5)}h` : `${Math.round(ms / 1e3)}s`;
94253
94278
  }
94254
- function get(port, pathname, deadlineMs, watch) {
94279
+ function get({ port, token }, pathname, deadlineMs, watch) {
94255
94280
  return new Promise((resolve, reject) => {
94256
94281
  let settled = false;
94257
94282
  const settle = (outcome) => {
@@ -94266,7 +94291,8 @@ function get(port, pathname, deadlineMs, watch) {
94266
94291
  reject(outcome.error);
94267
94292
  }
94268
94293
  };
94269
- const request2 = http.get({ host: "127.0.0.1", port, path: pathname }, (response) => {
94294
+ const headers = { [CONTROL_TOKEN_HEADER]: token };
94295
+ const request2 = http.get({ host: "127.0.0.1", port, path: pathname, headers }, (response) => {
94270
94296
  const chunks = [];
94271
94297
  response.on("data", (chunk) => chunks.push(chunk));
94272
94298
  response.once(
@@ -94336,8 +94362,8 @@ function get(port, pathname, deadlineMs, watch) {
94336
94362
  );
94337
94363
  });
94338
94364
  }
94339
- async function request(port, pathname, deadlineMs = deadlineFor(pathname), watch) {
94340
- const response = await get(port, pathname, deadlineMs, watch);
94365
+ async function request(control, pathname, deadlineMs = deadlineFor(pathname), watch) {
94366
+ const response = await get(control, pathname, deadlineMs, watch);
94341
94367
  if (response.status !== 200) {
94342
94368
  throw new ControlError(`control channel ${pathname} responded ${response.status}`);
94343
94369
  }
@@ -94347,8 +94373,8 @@ async function request(port, pathname, deadlineMs = deadlineFor(pathname), watch
94347
94373
  throw new ControlError(`control channel ${pathname} answered with malformed JSON`);
94348
94374
  }
94349
94375
  }
94350
- async function requestGc(port) {
94351
- const sample = sampleSchema.parse(await request(port, "/gc"));
94376
+ async function requestGc(control) {
94377
+ const sample = sampleSchema.parse(await request(control, "/gc"));
94352
94378
  if (!sample.gcExposed) {
94353
94379
  throw new ControlError(
94354
94380
  "the measured process is running without --expose-gc; samples would be meaningless"
@@ -94356,14 +94382,14 @@ async function requestGc(port) {
94356
94382
  }
94357
94383
  return sample;
94358
94384
  }
94359
- async function requestMemory(port) {
94360
- return sampleSchema.parse(await request(port, "/mem"));
94385
+ async function requestMemory(control) {
94386
+ return sampleSchema.parse(await request(control, "/mem"));
94361
94387
  }
94362
- async function requestSnapshot(port, name, workDir) {
94388
+ async function requestSnapshot(control, name, workDir) {
94363
94389
  const pathname = `/snapshot?name=${encodeURIComponent(name)}`;
94364
94390
  const parsed = snapshotResponseSchema.parse(
94365
94391
  await request(
94366
- port,
94392
+ control,
94367
94393
  pathname,
94368
94394
  deadlineFor(pathname),
94369
94395
  workDir === void 0 ? void 0 : productionWatch(snapshotPathFor(workDir, name))
@@ -94488,7 +94514,7 @@ var UNRECLAIMED_SETTLE_MS = SETTLE_POLL_MS;
94488
94514
  function unreclaimedSettleFor(idleMs) {
94489
94515
  return Math.min(UNRECLAIMED_SETTLE_MS, Math.floor(idleMs / 4));
94490
94516
  }
94491
- function pollPeak(controlPort, phase, deps) {
94517
+ function pollPeak(control, phase, deps) {
94492
94518
  const peak = {
94493
94519
  phase,
94494
94520
  heapUsed: 0,
@@ -94506,7 +94532,7 @@ function pollPeak(controlPort, phase, deps) {
94506
94532
  }
94507
94533
  let sample;
94508
94534
  try {
94509
- sample = await deps.readMemory(controlPort);
94535
+ sample = await deps.readMemory(control);
94510
94536
  } catch {
94511
94537
  return;
94512
94538
  }
@@ -94525,7 +94551,7 @@ function pollPeak(controlPort, phase, deps) {
94525
94551
  }
94526
94552
  };
94527
94553
  }
94528
- async function waitUntilSettled(controlPort, maxIdleMs, deps) {
94554
+ async function waitUntilSettled(control, maxIdleMs, deps) {
94529
94555
  const deadline = Date.now() + maxIdleMs;
94530
94556
  let previous = null;
94531
94557
  let polls = 0;
@@ -94533,7 +94559,7 @@ async function waitUntilSettled(controlPort, maxIdleMs, deps) {
94533
94559
  await deps.sleep(Math.min(SETTLE_POLL_MS, Math.max(deadline - Date.now(), 0)));
94534
94560
  let current;
94535
94561
  try {
94536
- current = (await requestGc(controlPort)).heapUsed;
94562
+ current = (await requestGc(control)).heapUsed;
94537
94563
  } catch {
94538
94564
  return { status: "unknown", polls };
94539
94565
  }
@@ -94592,6 +94618,7 @@ async function runRitual(options, deps = defaultDeps) {
94592
94618
  }
94593
94619
  app = await deps.launch({ ...launchOptions, appPort: options.appPort + 1 });
94594
94620
  }
94621
+ const control = { port: app.controlPort, token: app.controlToken };
94595
94622
  const timings = [];
94596
94623
  const loadOutcomes = [];
94597
94624
  const settleOutcomes = [];
@@ -94661,13 +94688,13 @@ async function runRitual(options, deps = defaultDeps) {
94661
94688
  );
94662
94689
  const baseline = await timed(
94663
94690
  "baseline snapshot",
94664
- () => requestSnapshot(app.controlPort, "baseline", options.workDir)
94691
+ () => requestSnapshot(control, "baseline", options.workDir)
94665
94692
  );
94666
94693
  memorySamples.push(baseline.sample);
94667
94694
  baselineSnapshot = baseline.file;
94668
94695
  for (let cycle = 1; cycle <= cycles; cycle += 1) {
94669
94696
  await timed(`cycle ${cycle} load`, async () => {
94670
- const poller = pollPeak(app.controlPort, `cycle ${cycle}`, deps);
94697
+ const poller = pollPeak(control, `cycle ${cycle}`, deps);
94671
94698
  try {
94672
94699
  await loadCycle(`cycle ${cycle}`, loadRequests);
94673
94700
  } finally {
@@ -94678,30 +94705,30 @@ async function runRitual(options, deps = defaultDeps) {
94678
94705
  await timed(`cycle ${cycle} unreclaimed read`, async () => {
94679
94706
  await deps.sleep(unreclaimedSettleMs);
94680
94707
  try {
94681
- unreclaimedSamples.push(await deps.readMemory(app.controlPort));
94708
+ unreclaimedSamples.push(await deps.readMemory(control));
94682
94709
  } catch {
94683
94710
  unreclaimedLost = true;
94684
94711
  }
94685
94712
  });
94686
94713
  const settle = await timed(
94687
94714
  `cycle ${cycle} settle`,
94688
- () => waitUntilSettled(app.controlPort, idleMs - unreclaimedSettleMs, deps)
94715
+ () => waitUntilSettled(control, idleMs - unreclaimedSettleMs, deps)
94689
94716
  );
94690
94717
  settleOutcomes.push({ phase: `cycle ${cycle}`, ...settle });
94691
94718
  if (cycle === cycles) {
94692
94719
  try {
94693
94720
  const after = await timed(
94694
94721
  "after snapshot",
94695
- () => requestSnapshot(app.controlPort, "after", options.workDir)
94722
+ () => requestSnapshot(control, "after", options.workDir)
94696
94723
  );
94697
94724
  afterSnapshot = after.file;
94698
94725
  memorySamples.push(after.sample);
94699
94726
  } catch (cause) {
94700
94727
  snapshotFailure = cause instanceof Error ? cause.message : String(cause);
94701
- memorySamples.push(await requestGc(app.controlPort));
94728
+ memorySamples.push(await requestGc(control));
94702
94729
  }
94703
94730
  } else {
94704
- memorySamples.push(await requestGc(app.controlPort));
94731
+ memorySamples.push(await requestGc(control));
94705
94732
  }
94706
94733
  cyclesCompleted = cycle;
94707
94734
  }
@@ -94812,7 +94839,7 @@ async function runSelfCheck(options, deps = defaultDeps2) {
94812
94839
  passed,
94813
94840
  verdict: result.trend.verdict,
94814
94841
  growthPer1000Requests,
94815
- summary: passed ? `harness verified: the planted leak was detected at ${rate}` : `harness NOT verified: a leak of ${PLANTED_LEAK_BYTES_PER_REQUEST} bytes per request came back ${result.trend.verdict} at ${rate}. Verdicts measured in this environment cannot be trusted \u2014 a flat curve here means the measurement is not working, not that nothing leaks`
94842
+ summary: passed ? `harness detected the planted leak at ${rate} \u2014 it responds to retention in this environment; the routes' own figures are not checked by this` : `harness NOT verified: a leak of ${PLANTED_LEAK_BYTES_PER_REQUEST} bytes per request came back ${result.trend.verdict} at ${rate}. Verdicts measured in this environment cannot be trusted \u2014 a flat curve here means the measurement is not working, not that nothing leaks`
94816
94843
  };
94817
94844
  } finally {
94818
94845
  await rm(dir, { recursive: true, force: true });
@@ -95187,7 +95214,9 @@ function harnessLine(report) {
95187
95214
  const measured = report.routes.filter((route) => route.status === "measured");
95188
95215
  if (report.harness.verified) {
95189
95216
  const rate = formatGrowth(report.harness.growthPer1000Requests);
95190
- return [`harness verified this session: a planted leak measured ${rate}`];
95217
+ return [
95218
+ `harness detected a planted leak this session at ${rate} \u2014 it can see retention here; that is not a check on the figures above`
95219
+ ];
95191
95220
  }
95192
95221
  const allStable = measured.length > 0 && measured.every((route) => effectiveVerdict(route) === "stable");
95193
95222
  if (!allStable) {
@@ -95231,7 +95260,7 @@ function formatReport(report) {
95231
95260
  ` next-leak ${report.appDir} --routes ${routeList} --cycles ${moreCycles}`
95232
95261
  );
95233
95262
  }
95234
- return lines.join("\n");
95263
+ return terminalSafe(lines.join("\n"));
95235
95264
  }
95236
95265
 
95237
95266
  // src/environment.ts
@@ -95622,8 +95651,8 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
95622
95651
  };
95623
95652
  }
95624
95653
  async function writeEvidenceBundle(report, workDir) {
95625
- const { renderHtmlReport } = await import("./html-report-DLKFB3IU.js");
95626
- const { renderIssueMarkdown } = await import("./issue-report-OALZZLVR.js");
95654
+ const { renderHtmlReport } = await import("./html-report-ZXDEJ6X5.js");
95655
+ const { renderIssueMarkdown } = await import("./issue-report-4RW5YOT3.js");
95627
95656
  for (const route of report.routes) {
95628
95657
  if (route.status === "measured" && warrantsIssueDraft(route)) {
95629
95658
  const file = path9.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
@@ -95895,6 +95924,7 @@ export {
95895
95924
  sourceIndexAt,
95896
95925
  extractChunkModules,
95897
95926
  extractModuleRegistry,
95927
+ terminalSafe,
95898
95928
  appPathsManifestSchema,
95899
95929
  routesManifestSchema,
95900
95930
  discoverPagesRoutes,
@@ -2,12 +2,12 @@ import { createRequire as __nextLeakCreateRequire } from 'node:module';import {
2
2
  import {
3
3
  effectiveVerdict,
4
4
  withdrawnByDisagreement
5
- } from "./chunk-UU4RXOZO.js";
5
+ } from "./chunk-QUFL2FXU.js";
6
6
  import {
7
7
  assessPeakPressure,
8
8
  describePeakPressure,
9
9
  retainedAfterLoad
10
- } from "./chunk-E464SO7G.js";
10
+ } from "./chunk-VTKOO3YJ.js";
11
11
 
12
12
  // src/html-report.ts
13
13
  var MB = 1024 * 1024;
@@ -69,6 +69,13 @@ function peakBlock(route, parameters) {
69
69
  const note = pressure === null ? "" : `<p class="warn">Peak pressure: ${escapeHtml(describePeakPressure(pressure))}</p>`;
70
70
  return note + `<table><tr><th>peak during</th><th>heap</th><th>external</th><th>arrayBuffers</th><th>rss</th></tr>${peakRows}</table>`;
71
71
  }
72
+ function attributionGapBlock(route) {
73
+ if (route.status !== "measured" || route.attributionGap === void 0) {
74
+ return "";
75
+ }
76
+ const cause = route.attributionGap.reason === "snapshot-unavailable" ? "the final heap snapshot was never taken" : "the heap snapshots could not be read";
77
+ return `<p class="warn">No attribution: ${cause}, so this run cannot name what retains the memory. The curve above is unaffected. ${escapeHtml(route.attributionGap.detail)}</p>`;
78
+ }
72
79
  function measuredSection(route, parameters) {
73
80
  if (route.status !== "measured") {
74
81
  return "";
@@ -84,7 +91,7 @@ function measuredSection(route, parameters) {
84
91
  const owner = ownerCell(attribution);
85
92
  return `<tr><td>${finding.kind}</td><td>${escapeHtml(finding.nodeType)}</td><td>${escapeHtml(finding.name)}</td><td>${(finding.retainedBytes / MB).toFixed(2)} MB</td><td>${owner}</td></tr>`;
86
93
  }).join("");
87
- return `<section><h2><span class="badge" style="background:${color}">${verdict}</span> <code>${escapeHtml(route.route)}</code></h2>` + heapCurveSvg(route.samples, color) + `<p class="curve">heap ${curve} MB \xB7 ${(route.growthPer1000Requests / MB).toFixed(2)} MB/1000 req</p>` + withdrawn + warnings + peakBlock(route, parameters) + (findingRows === "" ? "" : `<table><tr><th>kind</th><th>type</th><th>node</th><th>retained</th><th>owner</th></tr>${findingRows}</table>`) + `</section>`;
94
+ return `<section><h2><span class="badge" style="background:${color}">${verdict}</span> <code>${escapeHtml(route.route)}</code></h2>` + heapCurveSvg(route.samples, color) + `<p class="curve">heap ${curve} MB \xB7 ${(route.growthPer1000Requests / MB).toFixed(2)} MB/1000 req</p>` + withdrawn + warnings + peakBlock(route, parameters) + (findingRows === "" ? attributionGapBlock(route) : `<table><tr><th>kind</th><th>type</th><th>node</th><th>retained</th><th>owner</th></tr>${findingRows}</table>`) + `</section>`;
88
95
  }
89
96
  function renderHtmlReport(run) {
90
97
  const measured = run.routes.filter((route) => route.status === "measured");
@@ -20,23 +20,16 @@ function assessPeakPressure(input) {
20
20
  const heapLimitBytes = input.maxOldSpaceMb * MB;
21
21
  const peakHeap = maxOf(sampled, (peak) => peak.heapUsed);
22
22
  const peakRss = maxOf(sampled, (peak) => peak.rss);
23
- if (reachesHeapCeiling(peakHeap, heapLimitBytes)) {
24
- return {
25
- class: "heap",
26
- peakBytes: peakHeap,
27
- retainedBytes: input.retainedHeapBytes,
28
- heapLimitBytes
29
- };
30
- }
31
- if (reachesRssCeiling(peakRss, input.retainedHeapBytes)) {
32
- return {
33
- class: "rss",
34
- peakBytes: peakRss,
35
- retainedBytes: input.retainedHeapBytes,
36
- heapLimitBytes
37
- };
23
+ const pressureClass = sustainedClass(input) ?? (reachesHeapCeiling(peakHeap, heapLimitBytes) ? "heap" : reachesRssCeiling(peakRss, input.retainedHeapBytes) ? "rss" : null);
24
+ if (pressureClass === null) {
25
+ return null;
38
26
  }
39
- return null;
27
+ return {
28
+ class: pressureClass,
29
+ peakBytes: pressureClass === "heap" ? peakHeap : peakRss,
30
+ retainedBytes: input.retainedHeapBytes,
31
+ heapLimitBytes
32
+ };
40
33
  }
41
34
  var PRESSURE_MIN_SETTLED_CYCLES = 2;
42
35
  var reachesCeiling = (peak, pressureClass, input) => pressureClass === "heap" ? reachesHeapCeiling(peak.heapUsed, input.maxOldSpaceMb * MB) : reachesRssCeiling(peak.rss, input.retainedHeapBytes);
@@ -50,19 +43,15 @@ function isSustained(input, pressureClass) {
50
43
  }
51
44
  return settled.every((peak) => reachesCeiling(peak, pressureClass, input));
52
45
  }
46
+ var PRESSURE_CLASSES = ["heap", "rss"];
47
+ function sustainedClass(input) {
48
+ return PRESSURE_CLASSES.find((pressureClass) => isSustained(input, pressureClass)) ?? null;
49
+ }
53
50
  function assessPressureVerdict(input) {
54
51
  if (input.trend.verdict !== "stable" && input.trend.verdict !== "saturating") {
55
52
  return input.trend;
56
53
  }
57
- const pressure = assessPeakPressure({
58
- peaks: input.peaks,
59
- retainedHeapBytes: input.retainedHeapBytes,
60
- maxOldSpaceMb: input.maxOldSpaceMb
61
- });
62
- if (pressure === null) {
63
- return input.trend;
64
- }
65
- if (!isSustained(input, pressure.class)) {
54
+ if (sustainedClass(input) === null) {
66
55
  return input.trend;
67
56
  }
68
57
  return { ...input.trend, verdict: "pressure" };
package/dist/cli.js CHANGED
@@ -20,13 +20,15 @@ import {
20
20
  renderConfigSkeleton,
21
21
  runMeasurement,
22
22
  runSelfCheck,
23
+ terminalSafe,
23
24
  unregisterChild,
24
25
  validateTarget
25
- } from "./chunk-L5KWU2RU.js";
26
+ } from "./chunk-UKWFONEI.js";
27
+ import "./chunk-IKWF7SSB.js";
26
28
  import {
27
29
  classifyTrend
28
- } from "./chunk-UU4RXOZO.js";
29
- import "./chunk-E464SO7G.js";
30
+ } from "./chunk-QUFL2FXU.js";
31
+ import "./chunk-VTKOO3YJ.js";
30
32
  import "./chunk-6XYFBOL2.js";
31
33
 
32
34
  // src/cli.ts
@@ -151,6 +153,9 @@ function discardPendingSnapshotsSync() {
151
153
  }
152
154
 
153
155
  // src/build-attribution.ts
156
+ function isAttributionGap(outcome) {
157
+ return outcome !== null && "reason" in outcome;
158
+ }
154
159
  var defaultDeps = {
155
160
  diff: diffSnapshotFiles,
156
161
  registry: extractModuleRegistry
@@ -159,17 +164,19 @@ async function attributeBuildCapture(result, appDir, onProgress = () => {
159
164
  }, deps = defaultDeps) {
160
165
  const capture = result.capture;
161
166
  if (capture === null) {
162
- return null;
167
+ return result.captureRequested ? {
168
+ reason: "capture-missed",
169
+ detail: result.captureFailure ?? "the run never captured a usable snapshot pair from the worker"
170
+ } : null;
163
171
  }
164
172
  onProgress(`diffing what worker ${capture.pid} retained`);
165
173
  let diff;
166
174
  try {
167
175
  diff = await deps.diff(capture.files.baselineFile, capture.files.afterFile);
168
176
  } catch (cause) {
169
- onProgress(
170
- cause instanceof SnapshotError ? `no attribution: ${cause.message}` : `no attribution: the snapshot pair could not be diffed`
171
- );
172
- return null;
177
+ const detail = cause instanceof SnapshotError ? cause.message : "the snapshot pair could not be diffed";
178
+ onProgress(`no attribution: ${detail}`);
179
+ return { reason: "snapshot-unreadable", detail };
173
180
  }
174
181
  const registry = await deps.registry(path2.join(appDir, ".next", "server"));
175
182
  return {
@@ -212,6 +219,13 @@ function attributionLines(attribution) {
212
219
  if (attribution === null) {
213
220
  return [];
214
221
  }
222
+ if (isAttributionGap(attribution)) {
223
+ return [
224
+ "",
225
+ ` no attribution: ${attribution.detail}.`,
226
+ ` This does not say nothing grew \u2014 the comparison was never made.`
227
+ ];
228
+ }
215
229
  const { diff, attributed, bracketed } = attribution;
216
230
  const findings = [...diff.grownNodes, ...diff.newNodes];
217
231
  if (findings.length === 0) {
@@ -242,7 +256,7 @@ function attributionLines(attribution) {
242
256
  }
243
257
  return lines;
244
258
  }
245
- function formatBuildReport(result, attribution = null) {
259
+ function buildReportText(result, attribution = null) {
246
260
  const lines = [`next-leak build \xB7 ${result.appDir}`, ""];
247
261
  if (result.strippedCapWarning !== null) {
248
262
  lines.push(` \u25B2 ${result.strippedCapWarning}`, "");
@@ -320,6 +334,9 @@ function formatBuildReport(result, attribution = null) {
320
334
  );
321
335
  return lines.join("\n");
322
336
  }
337
+ function formatBuildReport(result, attribution = null) {
338
+ return terminalSafe(buildReportText(result, attribution));
339
+ }
323
340
 
324
341
  // src/build-run.ts
325
342
  import { spawn } from "child_process";
@@ -566,6 +583,24 @@ function recordSample(rows, rootPid, atMs, workers, parentSamples) {
566
583
  workers.set(row.pid, series);
567
584
  }
568
585
  }
586
+ function describeCaptureFailure(stage, pid, mismatchedWorker) {
587
+ if (mismatchedWorker) {
588
+ return "the snapshot pair belongs to a worker other than the one judged";
589
+ }
590
+ if (pid === null) {
591
+ return "no static-generation worker was seen to capture from";
592
+ }
593
+ switch (stage) {
594
+ case "waiting":
595
+ return `worker ${pid} never grew enough to snapshot`;
596
+ case "baseline-taken":
597
+ return `worker ${pid} was snapshotted once and never reached a second point`;
598
+ case "missed":
599
+ return `capture gave up on worker ${pid} before it had a pair`;
600
+ case "pair-taken":
601
+ return `the pair captured from worker ${pid} could not be collected from disk`;
602
+ }
603
+ }
569
604
  async function collectCapture(options) {
570
605
  const { appDir, workDir, pid, stage, baselineRssBytes, afterRssBytes } = options;
571
606
  if (workDir === void 0 || pid === null) {
@@ -716,6 +751,8 @@ async function runBuildMeasurement(options, deps = defaultDeps2) {
716
751
  retentionPerPageBytes: null,
717
752
  heapExhausted,
718
753
  capture: workerCapture,
754
+ captureRequested: capturing,
755
+ captureFailure: workerCapture === null ? describeCaptureFailure(captureStage, capturePid, false) : null,
719
756
  strippedCapWarning,
720
757
  exitCode,
721
758
  output
@@ -747,6 +784,12 @@ async function runBuildMeasurement(options, deps = defaultDeps2) {
747
784
  // A pair belonging to a worker other than the one judged supports no claim
748
785
  // about the verdict above, so it is dropped rather than reported next to it.
749
786
  capture: workerCapture !== null && workerCapture.pid === worst?.pid ? workerCapture : null,
787
+ captureRequested: capturing,
788
+ captureFailure: workerCapture !== null && workerCapture.pid === worst?.pid ? null : describeCaptureFailure(
789
+ captureStage,
790
+ capturePid,
791
+ workerCapture !== null && workerCapture.pid !== worst?.pid
792
+ ),
750
793
  strippedCapWarning,
751
794
  exitCode,
752
795
  output
@@ -830,12 +873,12 @@ async function runBuildCommand(appDir, outputDir, attribute) {
830
873
  // is worth asking for, not worth risking every run for.
831
874
  ...attribute && { workDir },
832
875
  signal: aborter.signal,
833
- onProgress: (message) => console.error(`\xB7 ${message}`)
876
+ onProgress: (message) => console.error(`\xB7 ${terminalSafe(message)}`)
834
877
  });
835
878
  const attribution = await attributeBuildCapture(
836
879
  result,
837
880
  appDir,
838
- (message) => console.error(`\xB7 ${message}`)
881
+ (message) => console.error(`\xB7 ${terminalSafe(message)}`)
839
882
  );
840
883
  console.log(formatBuildReport(result, attribution));
841
884
  if (aborter.signal.aborted) {
@@ -911,9 +954,9 @@ async function main() {
911
954
  ...options.idleSeconds !== null && { idleMs: options.idleSeconds * 1e3 },
912
955
  ...options.maxOldSpaceMb !== null && { maxOldSpaceMb: options.maxOldSpaceMb }
913
956
  });
914
- console.error(`\xB7 ${check.summary}`);
957
+ console.error(`\xB7 ${terminalSafe(check.summary)}`);
915
958
  if (!check.passed) {
916
- console.error(`error: ${check.summary}`);
959
+ console.error(`error: ${terminalSafe(check.summary)}`);
917
960
  process.exitCode = 1;
918
961
  return;
919
962
  }
@@ -939,7 +982,7 @@ async function main() {
939
982
  ...options.diffAll && { diffAll: true },
940
983
  ...options.noResolve && { resolveInconclusive: false },
941
984
  ...options.output !== null && { outputDir: options.output },
942
- onProgress: (message) => console.error(`\xB7 ${message}`)
985
+ onProgress: (message) => console.error(`\xB7 ${terminalSafe(message)}`)
943
986
  });
944
987
  console.log(formatReport(report));
945
988
  if (aborter.signal.aborted) {
@@ -957,7 +1000,7 @@ try {
957
1000
  await main();
958
1001
  } catch (cause) {
959
1002
  if (cause instanceof TargetError || cause instanceof RouteConfigError) {
960
- console.error(`error: ${cause.message}`);
1003
+ console.error(`error: ${terminalSafe(cause.message)}`);
961
1004
  } else {
962
1005
  console.error(cause);
963
1006
  }
@@ -1,4 +1,9 @@
1
- import type { HeapSample } from "./control-server.js";
1
+ import { type HeapSample } from "./control-server.js";
2
+ /** Where a measured process listens for control requests, and the secret it expects. */
3
+ export type ControlChannel = {
4
+ port: number;
5
+ token: string;
6
+ };
2
7
  declare function deadlineFor(pathname: string): number;
3
8
  /** Where the control server will put `<name>.heapsnapshot`, so the client can watch it grow. */
4
9
  export declare function snapshotPathFor(workDir: string, name: string): string;
@@ -13,14 +18,14 @@ export type SnapshotWatch = {
13
18
  /** How often to look. */
14
19
  pollMs: number;
15
20
  };
16
- declare function request(port: number, pathname: string, deadlineMs?: number, watch?: SnapshotWatch): Promise<unknown>;
21
+ declare function request(control: ControlChannel, pathname: string, deadlineMs?: number, watch?: SnapshotWatch): Promise<unknown>;
17
22
  /** Forces GC in the measured process and returns a settled memory sample. */
18
- export declare function requestGc(port: number): Promise<HeapSample>;
23
+ export declare function requestGc(control: ControlChannel): Promise<HeapSample>;
19
24
  /**
20
25
  * Reads memory without collecting. Used to poll a process under load, where a
21
26
  * forced GC would change the number being read.
22
27
  */
23
- export declare function requestMemory(port: number): Promise<HeapSample>;
28
+ export declare function requestMemory(control: ControlChannel): Promise<HeapSample>;
24
29
  /**
25
30
  * Forces GC, writes a named heap snapshot, and returns its path and sample.
26
31
  *
@@ -29,7 +34,7 @@ export declare function requestMemory(port: number): Promise<HeapSample>;
29
34
  * the call falls back to the plain wall-clock bound, which cannot tell a big
30
35
  * snapshot from a wedged process.
31
36
  */
32
- export declare function requestSnapshot(port: number, name: string, workDir?: string): Promise<{
37
+ export declare function requestSnapshot(control: ControlChannel, name: string, workDir?: string): Promise<{
33
38
  file: string;
34
39
  sample: HeapSample;
35
40
  }>;
@@ -33,9 +33,13 @@ export type HeapSample = {
33
33
  export declare const requestProbe: typeof globalThis & {
34
34
  __nextLeakServedRequests?: number;
35
35
  };
36
+ /** Header that carries the shared secret on every control request. */
37
+ export declare const CONTROL_TOKEN_HEADER = "x-next-leak-token";
36
38
  export type ControlServerOptions = {
37
39
  /** Directory where heap snapshots are written. */
38
40
  snapshotDir: string;
41
+ /** Secret the launcher generated for this run; a request without it is refused. */
42
+ token: string;
39
43
  /** Injectable for tests; defaults to `v8.writeHeapSnapshot`. */
40
44
  writeSnapshot?: (file: string) => string;
41
45
  };
@@ -50,5 +54,11 @@ export type ControlServer = {
50
54
  * - `GET /mem` — respond with a memory sample WITHOUT collecting.
51
55
  * - `GET /snapshot?name=<label>` — force GC, write `<label>.heapsnapshot`
52
56
  * into `snapshotDir`, respond `{ file, sample }` only once fully written.
57
+ *
58
+ * Every request must carry the run's token. The socket is bound to loopback,
59
+ * which keeps other machines out and nobody else: any local process can reach
60
+ * it, and so can a web page open in a browser on the same machine. Without
61
+ * the token either could read the process's argv and working directory, or
62
+ * ask for heap snapshots until the disk is full.
53
63
  */
54
64
  export declare function startControlServer(options: ControlServerOptions): Promise<ControlServer>;
@@ -1,9 +1,9 @@
1
1
  import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
2
2
  import {
3
3
  renderHtmlReport
4
- } from "./chunk-G2777DKL.js";
5
- import "./chunk-UU4RXOZO.js";
6
- import "./chunk-E464SO7G.js";
4
+ } from "./chunk-UOAW5UA5.js";
5
+ import "./chunk-QUFL2FXU.js";
6
+ import "./chunk-VTKOO3YJ.js";
7
7
  import "./chunk-6XYFBOL2.js";
8
8
  export {
9
9
  renderHtmlReport
package/dist/index.js CHANGED
@@ -41,17 +41,18 @@ import {
41
41
  sourceIndexAt,
42
42
  summarizeBaseline,
43
43
  validateTarget
44
- } from "./chunk-L5KWU2RU.js";
44
+ } from "./chunk-UKWFONEI.js";
45
+ import "./chunk-IKWF7SSB.js";
45
46
  import {
46
47
  renderHtmlReport
47
- } from "./chunk-G2777DKL.js";
48
+ } from "./chunk-UOAW5UA5.js";
48
49
  import {
49
50
  classifyTrend
50
- } from "./chunk-UU4RXOZO.js";
51
+ } from "./chunk-QUFL2FXU.js";
51
52
  import {
52
53
  renderIssueMarkdown
53
- } from "./chunk-YPVBX3QA.js";
54
- import "./chunk-E464SO7G.js";
54
+ } from "./chunk-FJVY5MKV.js";
55
+ import "./chunk-VTKOO3YJ.js";
55
56
  import "./chunk-6XYFBOL2.js";
56
57
  export {
57
58
  LaunchError,
@@ -1,8 +1,8 @@
1
1
  import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
2
2
  import {
3
3
  renderIssueMarkdown
4
- } from "./chunk-YPVBX3QA.js";
5
- import "./chunk-E464SO7G.js";
4
+ } from "./chunk-FJVY5MKV.js";
5
+ import "./chunk-VTKOO3YJ.js";
6
6
  import "./chunk-6XYFBOL2.js";
7
7
  export {
8
8
  renderIssueMarkdown
@@ -49,6 +49,8 @@ export type LaunchedApp = {
49
49
  pid: number;
50
50
  appPort: number;
51
51
  controlPort: number;
52
+ /** Secret the control channel expects on every request; generated per launch. */
53
+ controlToken: string;
52
54
  /**
53
55
  * Why the measured process is gone, or null while it is alive. Without it
54
56
  * a child that died mid-run surfaces as "fetch failed", which reads like a
@@ -80,17 +80,17 @@ export type PressureVerdictInput = {
80
80
  * requests, because a forced GC handed all of it back before every sample. That
81
81
  * is the trap next-leak exists to warn other people about.
82
82
  *
83
- * Three conditions, and no threshold of its own: the ceiling rules are
83
+ * Two conditions, and no threshold of its own: the ceiling rules are
84
84
  * `assessPeakPressure`'s, asked of each cycle instead of the highest reading.
85
85
  *
86
86
  * - **The post-GC verdict is `stable` or `saturating`.** `leak` is already the
87
87
  * worse news and `inconclusive` is an admission that the series did not
88
88
  * decide — promoting *that* to an accusation would be inventing a finding out
89
89
  * of a measurement that failed.
90
- * - **The peak is far enough from what the route retains to be remarked on** —
91
- * `assessPeakPressure`, thresholds unchanged.
92
- * - **Every settled cycle reached that ceiling**, not just the highest one.
93
- * This is the condition that makes the verdict safe. A single high peak is an
90
+ * - **Every settled cycle reached the same ceiling**, not just the highest one.
91
+ * This is the condition that makes the verdict safe, and it contains the one
92
+ * that used to stand beside it: if every settled cycle reached a ceiling, so
93
+ * did the highest reading of the run. A single high peak is an
94
94
  * episode and gets only the note; a process that returns to the ceiling every
95
95
  * time it serves traffic is describing what it does under load, which is the
96
96
  * thing a container is sized against.
package/dist/ritual.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type ControlChannel } from "./control-client.js";
1
2
  import type { HeapSample } from "./control-server.js";
2
3
  import { launchInstrumented } from "./launcher.js";
3
4
  import { runAbandonPhase, type AbandonOrigin } from "./abandon-load.js";
@@ -164,7 +165,7 @@ export type RitualDeps = {
164
165
  abandon: typeof runAbandonPhase;
165
166
  sleep: (ms: number) => Promise<void>;
166
167
  /** GC-free read, polled while the app is under load. */
167
- readMemory: (port: number) => Promise<HeapSample>;
168
+ readMemory: (control: ControlChannel) => Promise<HeapSample>;
168
169
  };
169
170
  /**
170
171
  * The pre-collection wait, taken *out of* the idle rather than added to it.
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Text as it can be written to a terminal.
3
+ *
4
+ * Heap node names, source paths, route names and the build's own output all
5
+ * come from the measured app, and a terminal obeys what it is sent: an escape
6
+ * sequence in a string name can clear the screen, move the cursor over a
7
+ * verdict already printed, or retitle the window. next-leak writes no
8
+ * sequences of its own, so none that reaches the output is ours to keep.
9
+ *
10
+ * A complete sequence is an instruction, not text, and is dropped. A control
11
+ * character left on its own is shown as `\xNN`: it was in the value, and a
12
+ * reader comparing the report against `run.json` should still find it.
13
+ */
14
+ export declare function terminalSafe(text: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Find out whether your Next.js app actually leaks memory — how much, on which route, and whose fault it is.",
5
5
  "keywords": [
6
6
  "nextjs",