next-leak 0.8.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.
package/README.md CHANGED
@@ -142,14 +142,20 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
142
142
  ```json
143
143
  {
144
144
  "params": { "lang": "en" },
145
- "routes": { "/products/[id]": { "id": "42" } },
145
+ "routes": { "/products/[id]": { "id": "42-{n}" } },
146
146
  "headers": { "accept-encoding": "gzip, br", "cookie": "session=..." }
147
147
  }
148
148
  ```
149
149
 
150
- `--write-config` generates that file for you, filling in values from paths your
151
- build already prerendered where it knows them, so it usually resolves on the
152
- first try. When a run skips a route it prints the same fragment.
150
+ `--write-config` generates that file for you. It takes the *shape* of each value
151
+ from paths your build already prerendered, and makes the value itself move:
152
+ `post-0` becomes `post-{n}`. A value the build prerendered is the one value
153
+ guaranteed not to measure anything — every request hits the same warm cache
154
+ entry, so the route reads as flat whatever it retains, and that false negative
155
+ lands on exactly the leaks being reported now (`use cache`, `cacheComponents`
156
+ and ISR all key on the params). If your app answers 404 for params it never
157
+ prerendered, the run says so through its non-2xx count; drop the marker then.
158
+ When a run skips a route it prints the same fragment.
153
159
 
154
160
  ```json
155
161
  ```
@@ -215,7 +221,7 @@ separates them, because each one has a different fix:
215
221
  allocator, external-buffer or fragmentation problem, not a JS-heap leak.
216
222
  - **`leak`** — the report names the culprit when attribution resolves: your file (`culprit: src/app/x/page.tsx (your code)`), a dependency (package name), or framework internals. An `ISSUE-<route>.md` draft is generated **when the evidence plainly supports the verdict** — a `leak` carrying low-confidence warnings (growth barely over the threshold, one cycle dominating the mean, too few cycles for its size) gets the verdict but no draft, because a draft is written to be pasted into someone else's tracker. If the leak is app-owned, the draft tells you **not** to file it upstream.
217
223
  - **`inconclusive`** — the evidence does not decide. The run does not stop there: any inconclusive route is **measured again automatically**, with twice the cycles, and the second pass is what you see (`resolved at 8 cycles` next to the verdict). On the reproduction for [#95094](https://github.com/vercel/next.js/issues/95094), `--quick` alone reports `inconclusive` on three deltas and then comes back with the leak. `--no-resolve` turns the second pass off; when even that is undecided, the re-run command is still printed.
218
- - **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design.
224
+ - **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design. A process that died of **heap exhaustion** is not one of these: it reports `leak`, because a route that could not survive its own load did not fail to be measured — it was measured right up to the point where it stopped fitting. The verdict comes from that outcome, not from the shape of the truncated curve, which is the same rule `next-leak build` applies to a static-generation worker that dies. The run prints the cycles it survived and the growth up to the death, and exits 0 with a finding rather than 1 with an error.
219
225
 
220
226
  ## Peak pressure: `stable` is not the same as safe
221
227
 
@@ -80,6 +80,7 @@ function renderHtmlReport(run) {
80
80
  const measured = run.routes.filter((route) => route.status === "measured");
81
81
  const skipped = run.routes.filter((route) => route.status === "skipped");
82
82
  const failed = run.routes.filter((route) => route.status === "failed");
83
+ const exhausted = run.routes.filter((route) => route.status === "died-of-heap");
83
84
  const environment = run.environment;
84
85
  return `<!doctype html>
85
86
  <html lang="en"><head><meta charset="utf-8">
@@ -102,6 +103,13 @@ code{background:#f4f4f4;padding:0 4px;border-radius:3px}
102
103
  )} \xB7 next-leak ${escapeHtml(environment.nextLeakVersion)}</p>
103
104
  <p class="meta">${run.parameters.cycles} cycles \xD7 ${run.parameters.loadRequests} requests \xB7 heap cap ${run.parameters.maxOldSpaceMb} MB \xB7 growth gate ${(run.parameters.minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle</p>
104
105
  ${measured.map((route) => measuredSection(route, run.parameters)).join("\n")}
106
+ ${exhausted.map(
107
+ (route) => route.status !== "died-of-heap" ? "" : `<h2><span class="badge" style="background:#c0392b">leak</span> <code>${escapeHtml(
108
+ route.route
109
+ )}</code></h2>
110
+ <p class="curve">Ran out of heap after ${route.cyclesCompleted} of ${route.cyclesRequested} cycles \xD7 ${route.requestsPerCycle} requests. Post-GC heap up to the death: ${route.memorySamples.map((sample) => `${(sample.heapUsed / MB).toFixed(1)} MB`).join(" \u2192 ")}</p>
111
+ <p class="warn">${escapeHtml(route.reason)}</p>`
112
+ ).join("\n")}
105
113
  ${skipped.length === 0 ? "" : `<h2>Skipped</h2><ul>${skipped.map((route) => `<li><code>${escapeHtml(route.route)}</code> \u2014 ${escapeHtml(route.status === "skipped" ? route.reason : "")}</li>`).join("")}</ul>`}
106
114
  ${failed.length === 0 ? "" : `<h2>Failed</h2><ul>${failed.map((route) => `<li><code>${escapeHtml(route.route)}</code> \u2014 ${escapeHtml(route.status === "failed" ? route.reason : "")}</li>`).join("")}</ul>`}
107
115
  <p class="meta">Raw snapshots and run.json live next to this file \u2014 verify in Chrome DevTools \u2192 Memory \u2192 Load.</p>
@@ -92692,8 +92692,11 @@ function explainStartupFailure(stderr) {
92692
92692
  return `stderr:
92693
92693
  ${stderr}`;
92694
92694
  }
92695
+ function stderrShowsHeapExhaustion(stderr) {
92696
+ return /heap out of memory|Reached heap limit|Ineffective mark-compacts/i.test(stderr);
92697
+ }
92695
92698
  function explainRuntimeFailure(stderr, maxOldSpaceMb) {
92696
- if (/heap out of memory|Reached heap limit|Ineffective mark-compacts/i.test(stderr)) {
92699
+ if (stderrShowsHeapExhaustion(stderr)) {
92697
92700
  return `the measured process ran out of heap and was killed by V8 mid-run (limit in force: --max-old-space-size=${maxOldSpaceMb} MB). That is the measurement: this route does not fit in ${maxOldSpaceMb} MB under this load. Raise it with --max-old-space <mb> to match your deployment, or lower --requests/--connections to measure a lighter regime.`;
92698
92701
  }
92699
92702
  return `the measured process exited mid-run. ${explainStartupFailure(stderr)}`;
@@ -92808,7 +92811,19 @@ ${stderrTailBuffer}`;
92808
92811
  pid: child.pid ?? -1,
92809
92812
  appPort: options.appPort,
92810
92813
  controlPort,
92811
- explainExit: () => exited ? explainRuntimeFailure(stderrWindow(), options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB) : null,
92814
+ explainExit: () => {
92815
+ if (!exited) {
92816
+ return null;
92817
+ }
92818
+ const stderr = stderrWindow();
92819
+ return {
92820
+ reason: explainRuntimeFailure(
92821
+ stderr,
92822
+ options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB
92823
+ ),
92824
+ heapExhausted: stderrShowsHeapExhaustion(stderr)
92825
+ };
92826
+ },
92812
92827
  close: async () => {
92813
92828
  if (exited) {
92814
92829
  return;
@@ -93255,6 +93270,13 @@ function resolveRoutePath(routeTemplate, config) {
93255
93270
 
93256
93271
  // src/route-guidance.ts
93257
93272
  var PLACEHOLDER = "REPLACE-ME";
93273
+ function varyingValueFrom(prerendered) {
93274
+ const numbered = /^(.*?)(\d+)$/.exec(prerendered);
93275
+ if (numbered !== null && numbered[1] !== "") {
93276
+ return `${numbered[1]}{n}`;
93277
+ }
93278
+ return `${prerendered}-{n}`;
93279
+ }
93258
93280
  function sampleValuesFromManifest(manifest, routeTemplate) {
93259
93281
  const routes = manifest?.routes;
93260
93282
  if (routes === void 0) {
@@ -93291,7 +93313,8 @@ function renderConfigSkeleton(routeTemplates, manifest = void 0) {
93291
93313
  const sampled = sampleValuesFromManifest(manifest, template);
93292
93314
  const values = {};
93293
93315
  for (const segment of segments) {
93294
- values[segment.name] = sampled?.[segment.name] ?? PLACEHOLDER;
93316
+ const prerendered = sampled?.[segment.name];
93317
+ values[segment.name] = prerendered === void 0 ? PLACEHOLDER : varyingValueFrom(prerendered);
93295
93318
  }
93296
93319
  routes[template] = values;
93297
93320
  }
@@ -93488,6 +93511,13 @@ function routeLines(route, parameters) {
93488
93511
  if (route.status === "not-exercised") {
93489
93512
  return [` \u2013 ${route.route} not exercised: ${route.reason}`];
93490
93513
  }
93514
+ if (route.status === "died-of-heap") {
93515
+ const curve2 = route.memorySamples.map((sample) => formatMb(sample.heapUsed)).join(" \u2192 ");
93516
+ return [
93517
+ ` ${VERDICT_ICON.leak} ${route.route} leak (ran out of heap after ${route.cyclesCompleted} of ${route.cyclesRequested} cycles)` + (curve2 === "" ? "" : ` heap ${curve2}`),
93518
+ ` ${route.reason}`
93519
+ ];
93520
+ }
93491
93521
  const verdict = effectiveVerdict(route);
93492
93522
  const curve = route.samples.map(formatMb).join(" \u2192 ");
93493
93523
  const resolved = route.resolvedWithCycles === void 0 ? "" : ` (resolved at ${route.resolvedWithCycles} cycles)`;
@@ -93510,7 +93540,7 @@ function skippedGuidanceLines(report) {
93510
93540
  if (skeleton === null) {
93511
93541
  return [];
93512
93542
  }
93513
- const editing = hasPlaceholders(skeleton) ? ` Replace each ${"REPLACE-ME"} with a value that exists in your app.` : ` The values come from paths your build already prerendered.`;
93543
+ const editing = hasPlaceholders(skeleton) ? ` Replace each ${"REPLACE-ME"} with a value that exists in your app.` : ` The shapes come from paths your build already prerendered; \`{n}\` makes every request use a different one, because reusing a prerendered value serves the warm cache and reads as flat whatever the route retains. Use \`{n%200}\` instead to revisit a fixed set of 200 keys, and drop the marker only if the app 404s on params it never prerendered.`;
93514
93544
  return [
93515
93545
  "",
93516
93546
  `${needConfig.length} route(s) need sample params. Write this to next-leak.config.json in the app directory:${editing}`,
@@ -93519,7 +93549,9 @@ function skippedGuidanceLines(report) {
93519
93549
  }
93520
93550
  function coverageLine(report) {
93521
93551
  const total = report.routes.length;
93522
- const measured = report.routes.filter((route) => route.status === "measured").length;
93552
+ const measured = report.routes.filter(
93553
+ (route) => route.status === "measured" || route.status === "died-of-heap"
93554
+ ).length;
93523
93555
  if (measured === total) {
93524
93556
  return `measured all ${total} discovered route(s)`;
93525
93557
  }
@@ -93605,6 +93637,29 @@ async function describeRedirect(url) {
93605
93637
  return null;
93606
93638
  }
93607
93639
  }
93640
+ async function describeUnprerenderedParams(url) {
93641
+ const bounded = boundedMarkerOf(url);
93642
+ const marker = bounded === null ? UNIQUE_MARKER : bounded.marker;
93643
+ if (!url.includes(marker)) {
93644
+ return null;
93645
+ }
93646
+ const probe = async (value) => {
93647
+ try {
93648
+ const response = await fetch(url.split(marker).join(value), { redirect: "manual" });
93649
+ return response.status;
93650
+ } catch {
93651
+ return null;
93652
+ }
93653
+ };
93654
+ const [novel, familiar] = await Promise.all([probe("999999"), probe("0")]);
93655
+ if (novel === null || familiar === null) {
93656
+ return null;
93657
+ }
93658
+ if (novel === 404 && familiar >= 200 && familiar < 300) {
93659
+ return `the sample value varies per request (\`${marker}\`) and this app answers 404 for params it never prerendered \u2014 the route itself is fine, the value is the problem. Bound it to the params your build did prerender (\`{n%N}\` with N of them), or drop the marker and accept that every request serves the same cached entry`;
93660
+ }
93661
+ return null;
93662
+ }
93608
93663
  function boundedRequestSequence(fullUrl, bounded) {
93609
93664
  const parsed = new URL(fullUrl);
93610
93665
  const template = fullUrl.slice(parsed.origin.length);
@@ -93656,9 +93711,9 @@ async function runLoadPhase(options) {
93656
93711
  const ratio = options.amount === 0 ? 0 : failures / options.amount;
93657
93712
  if (ratio > (options.maxErrorRatio ?? 0.01)) {
93658
93713
  const unanswered = failures - result.non2xx - result.errors - result.timeouts;
93659
- const redirect = result.non2xx > 0 ? await describeRedirect(options.url) : null;
93714
+ const explained = result.non2xx > 0 ? await describeUnprerenderedParams(options.url) ?? await describeRedirect(options.url) : null;
93660
93715
  throw new LoadError(
93661
- `${failures} of ${options.amount} requests failed against ${options.url} (${result.non2xx} non-2xx, ${result.errors} errors, ${result.timeouts} timeouts` + (unanswered > 0 ? `, ${unanswered} with no recorded response` : "") + ")" + (redirect === null ? diagnoseFailure(result, options.connections) : ` \u2014 ${redirect}`),
93716
+ `${failures} of ${options.amount} requests failed against ${options.url} (${result.non2xx} non-2xx, ${result.errors} errors, ${result.timeouts} timeouts` + (unanswered > 0 ? `, ${unanswered} with no recorded response` : "") + ")" + (explained === null ? diagnoseFailure(result, options.connections) : ` \u2014 ${explained}`),
93662
93717
  result
93663
93718
  );
93664
93719
  }
@@ -93870,6 +93925,14 @@ Host: ${target.host}\r
93870
93925
 
93871
93926
  // src/ritual.ts
93872
93927
  var MIN_POLLS_TO_JUDGE = 2;
93928
+ var HeapExhaustedError = class extends Error {
93929
+ evidence;
93930
+ constructor(message, evidence) {
93931
+ super(message);
93932
+ this.name = "HeapExhaustedError";
93933
+ this.evidence = evidence;
93934
+ }
93935
+ };
93873
93936
  var SETTLE_POLL_MS = 2e3;
93874
93937
  var SETTLE_TOLERANCE = 0.01;
93875
93938
  var PEAK_POLL_MS = 250;
@@ -94022,6 +94085,13 @@ async function runRitual(options, deps = defaultDeps) {
94022
94085
  timings.push({ phase, seconds: Math.round((Date.now() - started) / 100) / 10 });
94023
94086
  }
94024
94087
  };
94088
+ const memorySamples = [];
94089
+ const unreclaimedSamples = [];
94090
+ let unreclaimedLost = false;
94091
+ const peaks = [];
94092
+ let afterSnapshot = "";
94093
+ let baselineSnapshot = "";
94094
+ let cyclesCompleted = 0;
94025
94095
  try {
94026
94096
  const routeUrl = `http://127.0.0.1:${app.appPort}${options.route}`;
94027
94097
  await timed(
@@ -94037,11 +94107,8 @@ async function runRitual(options, deps = defaultDeps) {
94037
94107
  "baseline snapshot",
94038
94108
  () => requestSnapshot(app.controlPort, "baseline")
94039
94109
  );
94040
- const memorySamples = [baseline.sample];
94041
- const unreclaimedSamples = [];
94042
- let unreclaimedLost = false;
94043
- const peaks = [];
94044
- let afterSnapshot = "";
94110
+ memorySamples.push(baseline.sample);
94111
+ baselineSnapshot = baseline.file;
94045
94112
  for (let cycle = 1; cycle <= cycles; cycle += 1) {
94046
94113
  await timed(`cycle ${cycle} load`, async () => {
94047
94114
  const poller = pollPeak(app.controlPort, `cycle ${cycle}`, deps);
@@ -94075,6 +94142,7 @@ async function runRitual(options, deps = defaultDeps) {
94075
94142
  } else {
94076
94143
  memorySamples.push(await requestGc(app.controlPort));
94077
94144
  }
94145
+ cyclesCompleted = cycle;
94078
94146
  }
94079
94147
  const samples = memorySamples.map((sample) => sample.heapUsed);
94080
94148
  const externalSamples = memorySamples.map((sample) => sample.external);
@@ -94095,7 +94163,7 @@ async function runRitual(options, deps = defaultDeps) {
94095
94163
  unreclaimedSamples.map((sample) => sample.external),
94096
94164
  { minGrowthPerCycle }
94097
94165
  ),
94098
- baselineSnapshot: baseline.file,
94166
+ baselineSnapshot,
94099
94167
  afterSnapshot,
94100
94168
  trend: classifyMemoryTrend(samples, externalSamples, { minGrowthPerCycle }),
94101
94169
  requestsPerCycle: loadRequests,
@@ -94104,7 +94172,18 @@ async function runRitual(options, deps = defaultDeps) {
94104
94172
  } catch (cause) {
94105
94173
  const death = app.explainExit();
94106
94174
  if (death !== null) {
94107
- throw new Error(death);
94175
+ if (death.heapExhausted) {
94176
+ throw new HeapExhaustedError(death.reason, {
94177
+ memorySamples,
94178
+ peaks,
94179
+ unreclaimedSamples: unreclaimedLost ? [] : unreclaimedSamples,
94180
+ cyclesCompleted,
94181
+ cyclesRequested: cycles,
94182
+ requestsPerCycle: loadRequests,
94183
+ baselineSnapshot
94184
+ });
94185
+ }
94186
+ throw new Error(death.reason);
94108
94187
  }
94109
94188
  throw cause;
94110
94189
  } finally {
@@ -94454,7 +94533,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94454
94533
  };
94455
94534
  }
94456
94535
  async function writeEvidenceBundle(report, workDir) {
94457
- const { renderHtmlReport } = await import("./html-report-TN7PYVU4.js");
94536
+ const { renderHtmlReport } = await import("./html-report-5XMR3EXE.js");
94458
94537
  const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
94459
94538
  for (const route of report.routes) {
94460
94539
  if (route.status === "measured" && warrantsIssueDraft(route)) {
@@ -94503,6 +94582,21 @@ async function routeReportFor(context, route, index, total) {
94503
94582
  return first;
94504
94583
  }
94505
94584
  } catch (cause) {
94585
+ if (cause instanceof HeapExhaustedError) {
94586
+ progress(`leak ${label}: ${cause.message}`);
94587
+ const { evidence } = cause;
94588
+ return {
94589
+ route: route.path,
94590
+ status: "died-of-heap",
94591
+ requestPath,
94592
+ reason: cause.message,
94593
+ memorySamples: evidence.memorySamples,
94594
+ peaks: evidence.peaks,
94595
+ cyclesCompleted: evidence.cyclesCompleted,
94596
+ cyclesRequested: evidence.cyclesRequested,
94597
+ requestsPerCycle: evidence.requestsPerCycle
94598
+ };
94599
+ }
94506
94600
  const failure = cause instanceof Error ? cause.message : String(cause);
94507
94601
  progress(`failed ${label}: ${failure}`);
94508
94602
  return { route: route.path, status: "failed", reason: failure };
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  runMeasurement,
21
21
  unregisterChild,
22
22
  validateTarget
23
- } from "./chunk-LFGWGM5Z.js";
23
+ } from "./chunk-UVA77PMC.js";
24
24
  import {
25
25
  classifyTrend
26
26
  } from "./chunk-EJ26OBZI.js";
@@ -263,6 +263,21 @@ function formatBuildReport(result, attribution = null) {
263
263
  );
264
264
  return lines.join("\n");
265
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
+ }
266
281
  if (result.status === "nothing-to-measure") {
267
282
  lines.push(
268
283
  ` \u2013 no static-generation worker ran, so there was nothing to measure`,
@@ -296,6 +311,7 @@ function formatBuildReport(result, attribution = null) {
296
311
  }
297
312
  lines.push(
298
313
  ...attributionLines(attribution),
314
+ ...parentPeakLines(result),
299
315
  "",
300
316
  ` peak worker rss ${mb(result.peakWorkerRssBytes)} \xB7 sampled from the process tree, so a`,
301
317
  ` spike shorter than the polling interval is not observed`
@@ -848,6 +864,10 @@ ${skeleton}`
848
864
  console.log(`wrote ${file}`);
849
865
  if (hasPlaceholders(skeleton)) {
850
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
+ );
851
871
  }
852
872
  }
853
873
  async function main() {
@@ -899,7 +919,9 @@ async function main() {
899
919
  process.exitCode = 130;
900
920
  return;
901
921
  }
902
- if (!report.routes.some((route) => route.status === "measured")) {
922
+ if (!report.routes.some(
923
+ (route) => route.status === "measured" || route.status === "died-of-heap"
924
+ )) {
903
925
  console.error("error: no route was measured \u2014 nothing above is a verdict about your app");
904
926
  process.exitCode = 1;
905
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-LFGWGM5Z.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. */
@@ -1,4 +1,23 @@
1
1
  import type { PrerenderManifest } from "./manifests.js";
2
+ /**
3
+ * Turns a prerendered value into one that varies per request.
4
+ *
5
+ * A value the build already prerendered is the one value guaranteed *not* to
6
+ * measure anything: every request hits the same warm cache entry and the route
7
+ * reads as flat no matter what it retains. That is a silent false negative, and
8
+ * it lands on exactly the leaks being reported now — `use cache`,
9
+ * `cacheComponents` and ISR all key on the params
10
+ * (vercel/next.js#97938, #97776, #97424, #92287).
11
+ *
12
+ * The prerendered value is still the best hint available: it shows the shape
13
+ * the app accepts. So the shape is kept and only the tail is made to move —
14
+ * `post-0` becomes `post-{n}`, `seed` becomes `seed-{n}`.
15
+ *
16
+ * If the app answers 404 for params it never prerendered, the run says so
17
+ * loudly through its non-2xx count. A noisy 404 is a far better outcome than a
18
+ * quiet `stable`.
19
+ */
20
+ export declare function varyingValueFrom(prerendered: string): string;
2
21
  /**
3
22
  * Sample values lifted from paths the build actually prerendered.
4
23
  *
package/dist/runner.d.ts CHANGED
@@ -18,6 +18,28 @@ export type RouteReport = {
18
18
  status: "failed";
19
19
  reason: string;
20
20
  }
21
+ /**
22
+ * The measured process ran out of heap partway through. This is a verdict,
23
+ * not a failure: the route did not fit in the limit the run gave it, and the
24
+ * outcome decides regardless of the shape of the truncated curve — the same
25
+ * rule the build path applies to a static-generation worker that dies.
26
+ *
27
+ * Kept apart from `measured` because there is no after-snapshot and no
28
+ * complete trend, and dressing it as a full measurement would claim data
29
+ * that does not exist.
30
+ */
31
+ | {
32
+ route: string;
33
+ status: "died-of-heap";
34
+ requestPath: string;
35
+ reason: string;
36
+ /** Post-GC readings up to the death; may be as short as the baseline. */
37
+ memorySamples: HeapSample[];
38
+ peaks: PeakSample[];
39
+ cyclesCompleted: number;
40
+ cyclesRequested: number;
41
+ requestsPerCycle: number;
42
+ }
21
43
  /**
22
44
  * The route was reachable, but the load could not have exercised the code
23
45
  * path it represents — so no verdict is emitted. Measuring an ISR route
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",