next-leak 0.7.0 → 0.9.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.
@@ -10,12 +10,15 @@ export type CliRunOptions = {
10
10
  quick: boolean;
11
11
  noResolve: boolean;
12
12
  diffAll: boolean;
13
+ attributeBuild: boolean;
13
14
  writeConfig: boolean;
14
15
  output: string | null;
15
16
  };
16
17
  export type CliBuildOptions = {
17
18
  appDir: string;
18
19
  output: string | null;
20
+ /** Opt-in: signal the worker for snapshots and name what it retains. */
21
+ attributeBuild: boolean;
19
22
  };
20
23
  export type ParsedCli = {
21
24
  kind: "run";
package/dist/cli.js CHANGED
@@ -2,10 +2,14 @@
2
2
  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);
3
3
  import {
4
4
  RouteConfigError,
5
+ SnapshotError,
5
6
  TargetError,
7
+ attributeDiff,
6
8
  checkRuntime,
9
+ diffSnapshotFiles,
7
10
  discoverPagesRoutes,
8
11
  discoverRoutes,
12
+ extractModuleRegistry,
9
13
  formatReport,
10
14
  hasPlaceholders,
11
15
  helpText,
@@ -16,7 +20,7 @@ import {
16
20
  runMeasurement,
17
21
  unregisterChild,
18
22
  validateTarget
19
- } from "./chunk-BXDUPN6Z.js";
23
+ } from "./chunk-UVA77PMC.js";
20
24
  import {
21
25
  classifyTrend
22
26
  } from "./chunk-EJ26OBZI.js";
@@ -25,11 +29,166 @@ import "./chunk-6XYFBOL2.js";
25
29
 
26
30
  // src/cli.ts
27
31
  import { spawn as spawn2 } from "child_process";
28
- import { writeFile } from "fs/promises";
32
+ import { mkdir, writeFile } from "fs/promises";
29
33
  import { createRequire } from "module";
30
- import path3 from "path";
34
+ import path5 from "path";
31
35
  import { fileURLToPath } from "url";
32
36
 
37
+ // src/build-attribution.ts
38
+ import path2 from "path";
39
+
40
+ // src/build-snapshot.ts
41
+ import { readdirSync, unlinkSync } from "fs";
42
+ import { readdir, rename, unlink } from "fs/promises";
43
+ import path from "path";
44
+ var PARSEABLE_WORKER_RSS_BYTES = 1024 * 1024 * 1024;
45
+ var START_UP_FLOOR_BYTES = 128 * 1024 * 1024;
46
+ var MIN_PAIR_GROWTH_BYTES = 128 * 1024 * 1024;
47
+ var AFTER_TARGET_RSS_BYTES = Math.floor(PARSEABLE_WORKER_RSS_BYTES * 0.9);
48
+ function decideCapture(rssBytes, stage, baselineRssBytes = null) {
49
+ if (stage === "pair-taken" || stage === "missed") {
50
+ return "wait";
51
+ }
52
+ if (stage === "waiting") {
53
+ if (rssBytes > PARSEABLE_WORKER_RSS_BYTES) {
54
+ return "give-up";
55
+ }
56
+ return rssBytes >= START_UP_FLOOR_BYTES ? "take-baseline" : "wait";
57
+ }
58
+ const grown = rssBytes - (baselineRssBytes ?? 0);
59
+ if (rssBytes > PARSEABLE_WORKER_RSS_BYTES) {
60
+ return "give-up";
61
+ }
62
+ if (rssBytes < AFTER_TARGET_RSS_BYTES) {
63
+ return "wait";
64
+ }
65
+ return grown >= MIN_PAIR_GROWTH_BYTES ? "take-after" : "wait";
66
+ }
67
+ var SNAPSHOT_NAME = /^Heap\.\d{8}\.\d{6}\.(\d+)\.\d+\.(\d+)\.heapsnapshot$/;
68
+ var byCodeUnit = (left, right) => {
69
+ if (left === right) {
70
+ return 0;
71
+ }
72
+ return left < right ? -1 : 1;
73
+ };
74
+ function snapshotsWrittenBy(pid, filenames) {
75
+ return filenames.filter((name) => {
76
+ const match = SNAPSHOT_NAME.exec(name);
77
+ return match !== null && Number(match[1]) === pid;
78
+ }).sort(byCodeUnit);
79
+ }
80
+ async function collectSnapshotPair(appDir, outDir, pid) {
81
+ let entries;
82
+ try {
83
+ entries = await readdir(appDir);
84
+ } catch {
85
+ return null;
86
+ }
87
+ const written = snapshotsWrittenBy(pid, entries);
88
+ const [baseline, after] = written;
89
+ if (baseline === void 0 || after === void 0) {
90
+ return null;
91
+ }
92
+ const baselineFile = path.join(outDir, `worker-${pid}-baseline.heapsnapshot`);
93
+ const afterFile = path.join(outDir, `worker-${pid}-after.heapsnapshot`);
94
+ try {
95
+ await rename(path.join(appDir, baseline), baselineFile);
96
+ await rename(path.join(appDir, after), afterFile);
97
+ } catch {
98
+ await unlink(baselineFile).catch(() => {
99
+ });
100
+ await unlink(afterFile).catch(() => {
101
+ });
102
+ return null;
103
+ }
104
+ return { baselineFile, afterFile };
105
+ }
106
+ async function discardSnapshots(appDir, pid) {
107
+ let entries;
108
+ try {
109
+ entries = await readdir(appDir);
110
+ } catch {
111
+ return;
112
+ }
113
+ for (const name of snapshotsWrittenBy(pid, entries)) {
114
+ try {
115
+ await unlink(path.join(appDir, name));
116
+ } catch {
117
+ }
118
+ }
119
+ }
120
+ function bracketedShare(baselineRssBytes, afterRssBytes, peakRssBytes) {
121
+ const total = peakRssBytes - baselineRssBytes;
122
+ if (total <= 0) {
123
+ return 1;
124
+ }
125
+ return Math.min(1, Math.max(0, (afterRssBytes - baselineRssBytes) / total));
126
+ }
127
+ var pendingCapture = null;
128
+ function registerPendingCapture(appDir, pid) {
129
+ pendingCapture = { appDir, pid };
130
+ }
131
+ function clearPendingCapture() {
132
+ pendingCapture = null;
133
+ }
134
+ function discardPendingSnapshotsSync() {
135
+ if (pendingCapture === null) {
136
+ return;
137
+ }
138
+ const { appDir, pid } = pendingCapture;
139
+ pendingCapture = null;
140
+ try {
141
+ for (const name of snapshotsWrittenBy(pid, readdirSync(appDir))) {
142
+ try {
143
+ unlinkSync(path.join(appDir, name));
144
+ } catch {
145
+ }
146
+ }
147
+ } catch {
148
+ }
149
+ }
150
+
151
+ // src/build-attribution.ts
152
+ var defaultDeps = {
153
+ diff: diffSnapshotFiles,
154
+ registry: extractModuleRegistry
155
+ };
156
+ async function attributeBuildCapture(result, appDir, onProgress = () => {
157
+ }, deps = defaultDeps) {
158
+ const capture = result.capture;
159
+ if (capture === null) {
160
+ return null;
161
+ }
162
+ onProgress(`diffing what worker ${capture.pid} retained`);
163
+ let diff;
164
+ try {
165
+ diff = await deps.diff(capture.files.baselineFile, capture.files.afterFile);
166
+ } catch (cause) {
167
+ onProgress(
168
+ cause instanceof SnapshotError ? `no attribution: ${cause.message}` : `no attribution: the snapshot pair could not be diffed`
169
+ );
170
+ return null;
171
+ }
172
+ const registry = await deps.registry(path2.join(appDir, ".next", "server"));
173
+ return {
174
+ diff,
175
+ attributed: attributeDiff(diff, registry),
176
+ registrySize: registry.size,
177
+ // Against the captured worker's own peak. `result.peakWorkerRssBytes` is
178
+ // the highest of every worker, which on a multi-worker build can belong to
179
+ // a process these findings say nothing about.
180
+ bracketed: bracketedShare(
181
+ capture.baselineRssBytes,
182
+ capture.afterRssBytes,
183
+ capture.peakRssBytes
184
+ ),
185
+ baselineRssBytes: capture.baselineRssBytes,
186
+ afterRssBytes: capture.afterRssBytes,
187
+ baselineFile: capture.files.baselineFile,
188
+ afterFile: capture.files.afterFile
189
+ };
190
+ }
191
+
33
192
  // src/build-report.ts
34
193
  var MB = 1024 * 1024;
35
194
  var mb = (bytes) => `${(bytes / MB).toFixed(1)} MB`;
@@ -46,7 +205,42 @@ function growthLine(result) {
46
205
  const perPage = result.retentionPerPageBytes === null ? "" : ` (${mbPrecise(result.retentionPerPageBytes)}/page over ${result.pagesGenerated} pages)`;
47
206
  return ` grew ${mb(result.netGrowthBytes)} across the generation phase${perPage}`;
48
207
  }
49
- function formatBuildReport(result) {
208
+ var MAX_REPORTED_FINDINGS = 3;
209
+ function attributionLines(attribution) {
210
+ if (attribution === null) {
211
+ return [];
212
+ }
213
+ const { diff, attributed, bracketed } = attribution;
214
+ const findings = [...diff.grownNodes, ...diff.newNodes];
215
+ if (findings.length === 0) {
216
+ return ["", ` nothing grew between the two snapshots`];
217
+ }
218
+ const lines = [
219
+ "",
220
+ ` what it retained, between ${mb(attribution.baselineRssBytes)} and ${mb(attribution.afterRssBytes)} of worker rss \u2014`,
221
+ ` ${(bracketed * 100).toFixed(0)}% of the growth this run observed. The rest is not`,
222
+ ` attributed: a snapshot taken higher up cannot be parsed back.`
223
+ ];
224
+ if (attribution.registrySize === 0) {
225
+ lines.push(
226
+ ` no module registry resolved, so no owner is named below \u2014 only`,
227
+ ` where the bytes hang`
228
+ );
229
+ }
230
+ lines.push(
231
+ ` snapshots: ${attribution.baselineFile} / ${attribution.afterFile}`
232
+ );
233
+ for (const [index, finding] of findings.slice(0, MAX_REPORTED_FINDINGS).entries()) {
234
+ const owner = attributed.findings[index];
235
+ const name = owner?.source ?? owner?.packageName ?? owner?.owner ?? "unattributed";
236
+ lines.push(
237
+ ` \u21B3 ${finding.name === "" ? "(anonymous)" : finding.name} ${mb(finding.retainedBytes)} \xB7 ${name}`,
238
+ ` ${finding.retainerChain}`
239
+ );
240
+ }
241
+ return lines;
242
+ }
243
+ function formatBuildReport(result, attribution = null) {
50
244
  const lines = [`next-leak build \xB7 ${result.appDir}`, ""];
51
245
  if (result.strippedCapWarning !== null) {
52
246
  lines.push(` \u25B2 ${result.strippedCapWarning}`, "");
@@ -69,6 +263,21 @@ function formatBuildReport(result) {
69
263
  );
70
264
  return lines.join("\n");
71
265
  }
266
+ function parentPeakLines(result2) {
267
+ const peak = result2.parentSamples.reduce(
268
+ (highest, sample) => Math.max(highest, sample.rssBytes),
269
+ 0
270
+ );
271
+ if (peak === 0) {
272
+ return [];
273
+ }
274
+ const last = result2.parentSamples[result2.parentSamples.length - 1];
275
+ const ended = last === void 0 ? 0 : last.rssBytes;
276
+ return [
277
+ ` the build's own process peaked at ${mb(peak)} (ended at ${mb(ended)}) \u2014 reported, not judged:`,
278
+ ` it sheds while workers climb, so it is never added to the figure above`
279
+ ];
280
+ }
72
281
  if (result.status === "nothing-to-measure") {
73
282
  lines.push(
74
283
  ` \u2013 no static-generation worker ran, so there was nothing to measure`,
@@ -101,6 +310,8 @@ function formatBuildReport(result) {
101
310
  lines.push(` ${result.workers.length} workers ran; the verdict is the worst of them`);
102
311
  }
103
312
  lines.push(
313
+ ...attributionLines(attribution),
314
+ ...parentPeakLines(result),
104
315
  "",
105
316
  ` peak worker rss ${mb(result.peakWorkerRssBytes)} \xB7 sampled from the process tree, so a`,
106
317
  ` spike shorter than the polling interval is not observed`
@@ -110,11 +321,11 @@ function formatBuildReport(result) {
110
321
 
111
322
  // src/build-run.ts
112
323
  import { spawn } from "child_process";
113
- import path2 from "path";
324
+ import path4 from "path";
114
325
 
115
326
  // src/build-target.ts
116
- import { access, readFile, readdir } from "fs/promises";
117
- import path from "path";
327
+ import { access, readFile, readdir as readdir2 } from "fs/promises";
328
+ import path3 from "path";
118
329
  var CONFIG_NAMES = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"];
119
330
  async function exists(file) {
120
331
  try {
@@ -126,14 +337,14 @@ async function exists(file) {
126
337
  }
127
338
  async function readPackageJson(appDir) {
128
339
  try {
129
- return JSON.parse(await readFile(path.join(appDir, "package.json"), "utf8"));
340
+ return JSON.parse(await readFile(path3.join(appDir, "package.json"), "utf8"));
130
341
  } catch {
131
342
  return null;
132
343
  }
133
344
  }
134
345
  async function usesWorkerThreads(appDir) {
135
346
  for (const name of CONFIG_NAMES) {
136
- const file = path.join(appDir, name);
347
+ const file = path3.join(appDir, name);
137
348
  if (!await exists(file)) {
138
349
  continue;
139
350
  }
@@ -145,10 +356,10 @@ async function usesWorkerThreads(appDir) {
145
356
  return false;
146
357
  }
147
358
  async function validateBuildTarget(appDir) {
148
- const resolved = path.resolve(appDir);
359
+ const resolved = path3.resolve(appDir);
149
360
  let entries;
150
361
  try {
151
- entries = await readdir(resolved);
362
+ entries = await readdir2(resolved);
152
363
  } catch {
153
364
  throw new TargetError("NO_BUILD", `Cannot read ${resolved}.`);
154
365
  }
@@ -319,15 +530,22 @@ async function sampleProcessTable() {
319
530
  // src/build-run.ts
320
531
  var POLL_MS = 500;
321
532
  function spawnNextBuild(appDir, env) {
322
- const nextBin = path2.join(appDir, "node_modules", "next", "dist", "bin", "next");
533
+ const nextBin = path4.join(appDir, "node_modules", "next", "dist", "bin", "next");
323
534
  return spawn(process.execPath, [nextBin, "build"], {
324
535
  cwd: appDir,
325
536
  env,
326
537
  stdio: ["ignore", "pipe", "pipe"]
327
538
  });
328
539
  }
329
- var defaultDeps = {
540
+ var signalWorker = (pid) => {
541
+ try {
542
+ process.kill(pid, "SIGUSR2");
543
+ } catch {
544
+ }
545
+ };
546
+ var defaultDeps2 = {
330
547
  spawnBuild: spawnNextBuild,
548
+ signalWorker,
331
549
  sampleTable: sampleProcessTable,
332
550
  now: () => Date.now(),
333
551
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -346,6 +564,22 @@ function recordSample(rows, rootPid, atMs, workers, parentSamples) {
346
564
  workers.set(row.pid, series);
347
565
  }
348
566
  }
567
+ async function collectCapture(options) {
568
+ const { appDir, workDir, pid, stage, baselineRssBytes, afterRssBytes } = options;
569
+ if (workDir === void 0 || pid === null) {
570
+ return null;
571
+ }
572
+ if (stage !== "pair-taken" || baselineRssBytes === null || afterRssBytes === null) {
573
+ await discardSnapshots(appDir, pid);
574
+ return null;
575
+ }
576
+ const files = await collectSnapshotPair(appDir, workDir, pid);
577
+ if (files === null) {
578
+ await discardSnapshots(appDir, pid);
579
+ return null;
580
+ }
581
+ return { pid, files, baselineRssBytes, afterRssBytes, peakRssBytes: options.peakRssBytes };
582
+ }
349
583
  function worstWorker(workers) {
350
584
  let worst = null;
351
585
  let worstGrowth = -Infinity;
@@ -359,7 +593,7 @@ function worstWorker(workers) {
359
593
  return worst;
360
594
  }
361
595
  var peakOf = (samples) => samples.reduce((highest, sample) => Math.max(highest, sample.rssBytes), 0);
362
- async function runBuildMeasurement(options, deps = defaultDeps) {
596
+ async function runBuildMeasurement(options, deps = defaultDeps2) {
363
597
  const target = await validateBuildTarget(options.appDir);
364
598
  const progress = options.onProgress ?? (() => {
365
599
  });
@@ -368,8 +602,13 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
368
602
  if (strippedCapWarning !== null) {
369
603
  progress(strippedCapWarning);
370
604
  }
605
+ const capturing = options.workDir !== void 0;
606
+ const buildEnv = capturing ? {
607
+ ...env,
608
+ NODE_OPTIONS: `${env["NODE_OPTIONS"] ?? ""} --heapsnapshot-signal=SIGUSR2`.trim()
609
+ } : env;
371
610
  progress(`building ${target.appDir}`);
372
- const child = deps.spawnBuild(target.appDir, env);
611
+ const child = deps.spawnBuild(target.appDir, buildEnv);
373
612
  registerChild(child);
374
613
  let output = "";
375
614
  const capture = (chunk) => {
@@ -392,6 +631,36 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
392
631
  };
393
632
  options.signal?.addEventListener("abort", abort, { once: true });
394
633
  let samplingFailure = null;
634
+ let capturePid = null;
635
+ let captureStage = "waiting";
636
+ let captureBaselineRss = null;
637
+ let captureAfterRss = null;
638
+ const stepCapture = (rows, rootPid) => {
639
+ const descendants = descendantsOf(rows, rootPid);
640
+ const followed = capturePid === null ? descendants.find(isStaticGenWorker) : descendants.find((row) => row.pid === capturePid);
641
+ if (followed === void 0) {
642
+ return;
643
+ }
644
+ capturePid ??= followed.pid;
645
+ switch (decideCapture(followed.rssBytes, captureStage, captureBaselineRss)) {
646
+ case "take-baseline":
647
+ deps.signalWorker(followed.pid);
648
+ captureStage = "baseline-taken";
649
+ captureBaselineRss = followed.rssBytes;
650
+ registerPendingCapture(target.appDir, followed.pid);
651
+ return;
652
+ case "take-after":
653
+ deps.signalWorker(followed.pid);
654
+ captureStage = "pair-taken";
655
+ captureAfterRss = followed.rssBytes;
656
+ return;
657
+ case "give-up":
658
+ captureStage = "missed";
659
+ return;
660
+ case "wait":
661
+ return;
662
+ }
663
+ };
395
664
  const polling = (async () => {
396
665
  while (!finished) {
397
666
  await deps.sleep(POLL_MS);
@@ -404,6 +673,9 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
404
673
  return;
405
674
  }
406
675
  recordSample(sample.rows, child.pid, deps.now() - startedAt, workerSamples, parentSamples);
676
+ if (capturing) {
677
+ stepCapture(sample.rows, child.pid);
678
+ }
407
679
  }
408
680
  })();
409
681
  const exitCode = await exit;
@@ -412,6 +684,17 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
412
684
  unregisterChild(child);
413
685
  const workers = [...workerSamples].map(([pid, samples]) => ({ pid, samples }));
414
686
  const heapExhausted = diedOfHeapExhaustion(output);
687
+ const capturedSamples = capturePid === null ? void 0 : workerSamples.get(capturePid);
688
+ const workerCapture = await collectCapture({
689
+ appDir: target.appDir,
690
+ workDir: options.workDir,
691
+ pid: capturePid,
692
+ stage: captureStage,
693
+ baselineRssBytes: captureBaselineRss,
694
+ afterRssBytes: captureAfterRss,
695
+ peakRssBytes: capturedSamples === void 0 ? 0 : peakOf(capturedSamples)
696
+ });
697
+ clearPendingCapture();
415
698
  const pagesGenerated = pagesGeneratedFrom(output);
416
699
  if (workers.length === 0) {
417
700
  return {
@@ -430,6 +713,7 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
430
713
  pagesGenerated,
431
714
  retentionPerPageBytes: null,
432
715
  heapExhausted,
716
+ capture: workerCapture,
433
717
  strippedCapWarning,
434
718
  exitCode,
435
719
  output
@@ -458,6 +742,9 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
458
742
  pagesGenerated,
459
743
  retentionPerPageBytes: measured && growthBytes > 0 && pagesGenerated !== null && pagesGenerated > 0 ? growthBytes / pagesGenerated : null,
460
744
  heapExhausted,
745
+ // A pair belonging to a worker other than the one judged supports no claim
746
+ // about the verdict above, so it is dropped rather than reported next to it.
747
+ capture: workerCapture !== null && workerCapture.pid === worst?.pid ? workerCapture : null,
461
748
  strippedCapWarning,
462
749
  exitCode,
463
750
  output
@@ -491,6 +778,7 @@ function installInterruptHandlers() {
491
778
  process.on(signalName, () => {
492
779
  interrupts += 1;
493
780
  if (interrupts > 1) {
781
+ discardPendingSnapshotsSync();
494
782
  process.exit(130);
495
783
  }
496
784
  console.error("\n\xB7 interrupted \u2014 stopping the measured process and writing a partial run.json");
@@ -523,14 +811,31 @@ function handleNonRunCommand(parsed) {
523
811
  }
524
812
  return false;
525
813
  }
526
- async function runBuildCommand(appDir) {
814
+ async function runBuildCommand(appDir, outputDir, attribute) {
527
815
  const aborter = installInterruptHandlers();
816
+ const workDir = path5.join(
817
+ outputDir ?? path5.join(appDir, ".next-leak"),
818
+ (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")
819
+ );
820
+ await mkdir(workDir, { recursive: true });
528
821
  const result = await runBuildMeasurement({
529
822
  appDir,
823
+ // Capture is opt-in because signalling a worker for a heap snapshot can
824
+ // stall the build it is measuring. Observed on the #97464 reproduction:
825
+ // the second signal landed and the worker sat frozen for fourteen minutes
826
+ // with an empty snapshot file on disk, and on an earlier run for ninety.
827
+ // A curve and a verdict are what this command promises; naming the objects
828
+ // is worth asking for, not worth risking every run for.
829
+ ...attribute && { workDir },
530
830
  signal: aborter.signal,
531
831
  onProgress: (message) => console.error(`\xB7 ${message}`)
532
832
  });
533
- console.log(formatBuildReport(result));
833
+ const attribution = await attributeBuildCapture(
834
+ result,
835
+ appDir,
836
+ (message) => console.error(`\xB7 ${message}`)
837
+ );
838
+ console.log(formatBuildReport(result, attribution));
534
839
  if (aborter.signal.aborted) {
535
840
  process.exitCode = 130;
536
841
  }
@@ -543,7 +848,7 @@ async function writeConfigSkeleton(appDir) {
543
848
  console.log("no route needs sample params \u2014 nothing to write");
544
849
  return;
545
850
  }
546
- const file = path3.join(target.appDir, "next-leak.config.json");
851
+ const file = path5.join(target.appDir, "next-leak.config.json");
547
852
  try {
548
853
  await writeFile(file, `${skeleton}
549
854
  `, { flag: "wx" });
@@ -559,6 +864,10 @@ ${skeleton}`
559
864
  console.log(`wrote ${file}`);
560
865
  if (hasPlaceholders(skeleton)) {
561
866
  console.log("replace each REPLACE-ME with a value that exists in your app");
867
+ } else {
868
+ console.log(
869
+ "`{n}` gives every request a different value: a prerendered one serves the warm cache and reads as flat whatever the route retains. Use `{n%200}` to revisit a fixed set of keys, and drop the marker only if the app 404s on params it never prerendered."
870
+ );
562
871
  }
563
872
  }
564
873
  async function main() {
@@ -567,7 +876,11 @@ async function main() {
567
876
  return;
568
877
  }
569
878
  if (parsed.kind === "build") {
570
- await runBuildCommand(parsed.options.appDir);
879
+ await runBuildCommand(
880
+ parsed.options.appDir,
881
+ parsed.options.output,
882
+ parsed.options.attributeBuild
883
+ );
571
884
  return;
572
885
  }
573
886
  if (parsed.kind !== "run") {
@@ -606,7 +919,9 @@ async function main() {
606
919
  process.exitCode = 130;
607
920
  return;
608
921
  }
609
- if (!report.routes.some((route) => route.status === "measured")) {
922
+ if (!report.routes.some(
923
+ (route) => route.status === "measured" || route.status === "died-of-heap"
924
+ )) {
610
925
  console.error("error: no route was measured \u2014 nothing above is a verdict about your app");
611
926
  process.exitCode = 1;
612
927
  }
@@ -1,7 +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
2
  import {
3
3
  renderHtmlReport
4
- } from "./chunk-RS3AO7HB.js";
4
+ } from "./chunk-T7LM4Z33.js";
5
5
  import "./chunk-EJ26OBZI.js";
6
6
  import "./chunk-XHPUAMJG.js";
7
7
  import "./chunk-6XYFBOL2.js";
package/dist/index.js CHANGED
@@ -39,10 +39,10 @@ import {
39
39
  sourceIndexAt,
40
40
  summarizeBaseline,
41
41
  validateTarget
42
- } from "./chunk-BXDUPN6Z.js";
42
+ } from "./chunk-UVA77PMC.js";
43
43
  import {
44
44
  renderHtmlReport
45
- } from "./chunk-RS3AO7HB.js";
45
+ } from "./chunk-T7LM4Z33.js";
46
46
  import {
47
47
  classifyTrend
48
48
  } from "./chunk-EJ26OBZI.js";
@@ -31,11 +31,23 @@ export type LaunchedApp = {
31
31
  * a child that died mid-run surfaces as "fetch failed", which reads like a
32
32
  * bug in the tool and hides the finding — most often that the app blew
33
33
  * through the heap limit the run configured.
34
+ *
35
+ * `heapExhausted` separates that one death from every other, because it is
36
+ * the only one the run is allowed to call a verdict rather than a failure.
34
37
  */
35
- explainExit: () => string | null;
38
+ explainExit: () => RuntimeDeath | null;
36
39
  /** SIGTERM, then SIGKILL after a grace period. Resolves when the child exited. */
37
40
  close: () => Promise<void>;
38
41
  };
42
+ /**
43
+ * How the measured process died. `heapExhausted` is the one death that is a
44
+ * measurement: the app did not fit in the limit the run gave it, which is the
45
+ * finding the tool exists to produce. Every other death is a failure.
46
+ */
47
+ export type RuntimeDeath = {
48
+ reason: string;
49
+ heapExhausted: boolean;
50
+ };
39
51
  export declare class LaunchError extends Error {
40
52
  constructor(message: string);
41
53
  }
@@ -61,6 +73,13 @@ export declare function explainStartupFailure(stderr: string): string;
61
73
  * is a finding rather than an accident: the app did not fit in the limit the
62
74
  * run gave it.
63
75
  */
76
+ /**
77
+ * Whether a stderr window carries V8's own fatal heap message. The build path
78
+ * asks the same question of build output in `build-verdict.ts`; the two are
79
+ * deliberately not shared yet, because `launcher` importing the build verdict
80
+ * would couple the runtime path to it for one regex.
81
+ */
82
+ export declare function stderrShowsHeapExhaustion(stderr: string): boolean;
64
83
  export declare function explainRuntimeFailure(stderr: string, maxOldSpaceMb: number): string;
65
84
  /**
66
85
  * Spawns the measured server in a fresh child process with GC exposed and the
package/dist/load.d.ts CHANGED
@@ -24,5 +24,24 @@ export declare class LoadError extends Error {
24
24
  readonly result: LoadPhaseResult;
25
25
  constructor(message: string, result: LoadPhaseResult);
26
26
  }
27
+ /**
28
+ * Whether the app answers for params it never prerendered.
29
+ *
30
+ * A varying sample value is what makes a keyed leak visible, and on an app with
31
+ * a closed param set (`generateStaticParams` plus `dynamicParams = false`) it is
32
+ * also what makes every request a 404. The two look identical in the counters —
33
+ * a wall of non-2xx — and the honest answer is not a guess: request one value
34
+ * the marker would produce and one the app is known to serve, and compare.
35
+ *
36
+ * Measured on an app with `post-0..post-2` prerendered and `dynamicParams` off:
37
+ * `/post-0` answers 200, `/post-3` answers 404. Without this probe the run told
38
+ * the user the fault was in their route, and sent them to inspect a route that
39
+ * was working correctly.
40
+ *
41
+ * Note this branch cannot occur under `cacheComponents`: Next rejects
42
+ * `dynamicParams` at build time when it is enabled, so those apps always render
43
+ * on demand.
44
+ */
45
+ export declare function describeUnprerenderedParams(url: string): Promise<string | null>;
27
46
  /** Runs one bounded load phase and fails when the error budget is exceeded. */
28
47
  export declare function runLoadPhase(options: LoadPhaseOptions): Promise<LoadPhaseResult>;
package/dist/ritual.d.ts CHANGED
@@ -77,6 +77,30 @@ export type PeakSample = {
77
77
  /** Readings taken; 0 means the poller never got one. */
78
78
  polls: number;
79
79
  };
80
+ /** What survived a process that ran out of heap partway through a run. */
81
+ export type HeapExhaustedEvidence = {
82
+ /** Post-GC readings taken before the death: baseline first, then per cycle. */
83
+ memorySamples: HeapSample[];
84
+ peaks: PeakSample[];
85
+ unreclaimedSamples: HeapSample[];
86
+ /** Cycles that finished. Zero means it died inside the first one. */
87
+ cyclesCompleted: number;
88
+ cyclesRequested: number;
89
+ requestsPerCycle: number;
90
+ baselineSnapshot: string;
91
+ };
92
+ /**
93
+ * The measured process ran out of heap mid-run.
94
+ *
95
+ * Thrown rather than returned because there is no `RitualResult` to build: no
96
+ * after-snapshot was taken and the trend has nothing complete to classify. It
97
+ * is still a finding, not a failure — see `explainExit` in `launcher.ts` — and
98
+ * the evidence it carries is what the report shows instead of a curve.
99
+ */
100
+ export declare class HeapExhaustedError extends Error {
101
+ readonly evidence: HeapExhaustedEvidence;
102
+ constructor(message: string, evidence: HeapExhaustedEvidence);
103
+ }
80
104
  export type RitualResult = {
81
105
  route: string;
82
106
  /** Wall-clock per phase, so slow runs can be explained instead of guessed. */