next-leak 0.2.0 → 0.3.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
@@ -162,6 +162,33 @@ separates them, because each one has a different fix:
162
162
  - **`inconclusive`** — sustained sub-threshold growth: measure longer. The CLI prints the exact re-run command (`--routes <those> --cycles 6`).
163
163
  - **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design.
164
164
 
165
+ ## Peak pressure: `stable` is not the same as safe
166
+
167
+ Every verdict above is about what a route **retains** after idle and a forced
168
+ GC. That is the right question for a leak and the wrong one for an
169
+ `OOMKilled`: a process can climb to gigabytes under load, hand it all back
170
+ when the load stops, and still be killed at its peak. So each load cycle is
171
+ also sampled *without* collecting, and the highest value is reported next to
172
+ the verdict:
173
+
174
+ ```
175
+ ✔ /[slug] stable (-164.08 MB/1000 req) heap 261.9 MB → 265.7 MB → 35.4 MB → 36.0 MB
176
+ ▲ peak pressure: peaked at 3145.7 MB rss under load while retaining 36.0 MB —
177
+ a container sized on what it retains dies on what it reaches
178
+ ```
179
+
180
+ That is a real measurement of the reproduction in
181
+ [vercel/next.js#92287](https://github.com/vercel/next.js/issues/92287): no
182
+ retention, and 3 GB reached. The note fires when the peak heap comes within
183
+ 75% of `--max-old-space`, or when peak RSS is at least 8× the retained heap
184
+ and above 512 MB. It never changes the verdict — retention and peak are
185
+ different questions, and only one of them is a leak. A peak is the highest
186
+ value *sampled* (every 250 ms), so it is a lower bound.
187
+
188
+ If the measured process dies at the limit instead of merely approaching it,
189
+ the route fails saying exactly that, with the limit in force and how to raise
190
+ it.
191
+
165
192
  ## The tool grades its own measurement
166
193
 
167
194
  A leak detector is an instrument, and a miscalibrated instrument doesn't fail
@@ -233,6 +260,7 @@ through the build's source maps.
233
260
  `--max-old-space`, or every route dies as an OOM that is not the app's
234
261
  fault. When a run's heap gets close to the cap, the report says so.
235
262
  - Borderline routes can flip between `stable`/`leak` across runs — more cycles resolves this.
263
+ - The **peak-pressure** thresholds are calibrated against one reproduction measured in three regimes plus the bundled fixture, not against the ~40-route validation set the verdicts were tuned on. A peak note never changes a verdict, so the cost of a false one is noise, not a false accusation — but treat the exact thresholds as young.
236
264
  - The measured app runs with its real environment: routes that call external services will call them under load. Scope with `--routes` and moderate `--requests` accordingly.
237
265
 
238
266
  ## Development
package/dist/bootstrap.js CHANGED
@@ -48,6 +48,10 @@ async function startControlServer(options) {
48
48
  respond(200, sampleMemory(gcExposed));
49
49
  return;
50
50
  }
51
+ if (url.pathname === "/mem") {
52
+ respond(200, sampleMemory(typeof g.gc === "function"));
53
+ return;
54
+ }
51
55
  if (url.pathname === "/snapshot") {
52
56
  const name = url.searchParams.get("name");
53
57
  if (name === null || name === "") {
@@ -7,6 +7,10 @@ import {
7
7
  minGrowthFor,
8
8
  warrantsIssueDraft
9
9
  } from "./chunk-E5ZKAANQ.js";
10
+ import {
11
+ assessPeakPressure,
12
+ describePeakPressure
13
+ } from "./chunk-XHPUAMJG.js";
10
14
  import {
11
15
  __commonJS,
12
16
  __esm,
@@ -92205,6 +92209,12 @@ function explainStartupFailure(stderr) {
92205
92209
  return `stderr:
92206
92210
  ${stderr}`;
92207
92211
  }
92212
+ function explainRuntimeFailure(stderr, maxOldSpaceMb) {
92213
+ if (/heap out of memory|Reached heap limit|Ineffective mark-compacts/i.test(stderr)) {
92214
+ 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.`;
92215
+ }
92216
+ return `the measured process exited mid-run. ${explainStartupFailure(stderr)}`;
92217
+ }
92208
92218
  var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
92209
92219
  async function pollUntil(deadline, what, probe, failed) {
92210
92220
  for (; ; ) {
@@ -92307,6 +92317,7 @@ async function launchInstrumented(options) {
92307
92317
  pid: child.pid ?? -1,
92308
92318
  appPort: options.appPort,
92309
92319
  controlPort,
92320
+ explainExit: () => exited ? explainRuntimeFailure(stderrTail, options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB) : null,
92310
92321
  close: async () => {
92311
92322
  if (exited) {
92312
92323
  return;
@@ -92409,7 +92420,19 @@ function findingLines(route) {
92409
92420
  }
92410
92421
  return lines;
92411
92422
  }
92412
- function routeLines(route) {
92423
+ function peakPressureLines(route, parameters) {
92424
+ const retained = route.memorySamples.at(-1)?.heapUsed;
92425
+ if (retained === void 0) {
92426
+ return [];
92427
+ }
92428
+ const pressure = assessPeakPressure({
92429
+ peaks: route.peaks,
92430
+ retainedHeapBytes: retained,
92431
+ maxOldSpaceMb: parameters.maxOldSpaceMb
92432
+ });
92433
+ return pressure === null ? [] : [` \u25B2 peak pressure: ${describePeakPressure(pressure)}`];
92434
+ }
92435
+ function routeLines(route, parameters) {
92413
92436
  if (route.status === "skipped") {
92414
92437
  return [` \u2013 ${route.route} skipped: ${route.reason}`];
92415
92438
  }
@@ -92424,13 +92447,14 @@ function routeLines(route) {
92424
92447
  )}) heap ${curve}`,
92425
92448
  ...confidenceLines(route),
92426
92449
  ...memorySourceLines(route, verdict),
92450
+ ...peakPressureLines(route, parameters),
92427
92451
  ...findingLines(route)
92428
92452
  ];
92429
92453
  }
92430
92454
  function formatReport(report) {
92431
92455
  const lines = [`next-leak \u2014 ${report.appDir}`, ""];
92432
92456
  for (const route of report.routes) {
92433
- lines.push(...routeLines(route));
92457
+ lines.push(...routeLines(route, report.parameters));
92434
92458
  }
92435
92459
  const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
92436
92460
  lines.push(
@@ -93219,6 +93243,9 @@ async function requestGc(port) {
93219
93243
  }
93220
93244
  return sample;
93221
93245
  }
93246
+ async function requestMemory(port) {
93247
+ return sampleSchema.parse(await request(port, "/mem"));
93248
+ }
93222
93249
  async function requestSnapshot(port, name) {
93223
93250
  const parsed = snapshotResponseSchema.parse(
93224
93251
  await request(port, `/snapshot?name=${encodeURIComponent(name)}`)
@@ -93312,6 +93339,44 @@ Host: ${target.host}\r
93312
93339
  var MIN_POLLS_TO_JUDGE = 2;
93313
93340
  var SETTLE_POLL_MS = 2e3;
93314
93341
  var SETTLE_TOLERANCE = 0.01;
93342
+ var PEAK_POLL_MS = 250;
93343
+ function pollPeak(controlPort, phase, deps) {
93344
+ const peak = {
93345
+ phase,
93346
+ heapUsed: 0,
93347
+ external: 0,
93348
+ arrayBuffers: 0,
93349
+ rss: 0,
93350
+ polls: 0
93351
+ };
93352
+ let running = true;
93353
+ const loop = (async () => {
93354
+ while (running) {
93355
+ await deps.sleep(PEAK_POLL_MS);
93356
+ if (!running) {
93357
+ return;
93358
+ }
93359
+ let sample;
93360
+ try {
93361
+ sample = await deps.readMemory(controlPort);
93362
+ } catch {
93363
+ return;
93364
+ }
93365
+ peak.polls += 1;
93366
+ peak.heapUsed = Math.max(peak.heapUsed, sample.heapUsed);
93367
+ peak.external = Math.max(peak.external, sample.external);
93368
+ peak.arrayBuffers = Math.max(peak.arrayBuffers, sample.arrayBuffers);
93369
+ peak.rss = Math.max(peak.rss, sample.rss);
93370
+ }
93371
+ })();
93372
+ return {
93373
+ stop: async () => {
93374
+ running = false;
93375
+ await loop;
93376
+ return peak;
93377
+ }
93378
+ };
93379
+ }
93315
93380
  async function waitUntilSettled(controlPort, maxIdleMs, deps) {
93316
93381
  const deadline = Date.now() + maxIdleMs;
93317
93382
  let previous = null;
@@ -93335,14 +93400,16 @@ async function waitUntilSettled(controlPort, maxIdleMs, deps) {
93335
93400
  var defaultDeps = {
93336
93401
  launch: launchInstrumented,
93337
93402
  load: runLoadPhase,
93338
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
93403
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
93404
+ readMemory: requestMemory
93339
93405
  };
93340
93406
  var RITUAL_DEFAULTS = {
93341
93407
  warmupRequests: 200,
93342
93408
  loadRequests: 5e3,
93343
93409
  connections: 100,
93344
93410
  cycles: 4,
93345
- idleMs: 3e4
93411
+ idleMs: 3e4,
93412
+ maxOldSpaceMb: 512
93346
93413
  };
93347
93414
  async function runRitual(options, deps = defaultDeps) {
93348
93415
  const warmupRequests = options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests;
@@ -93431,9 +93498,17 @@ async function runRitual(options, deps = defaultDeps) {
93431
93498
  () => requestSnapshot(app.controlPort, "baseline")
93432
93499
  );
93433
93500
  const memorySamples = [baseline.sample];
93501
+ const peaks = [];
93434
93502
  let afterSnapshot = "";
93435
93503
  for (let cycle = 1; cycle <= cycles; cycle += 1) {
93436
- await timed(`cycle ${cycle} load`, () => loadCycle(`cycle ${cycle}`, loadRequests));
93504
+ await timed(`cycle ${cycle} load`, async () => {
93505
+ const poller = pollPeak(app.controlPort, `cycle ${cycle}`, deps);
93506
+ try {
93507
+ await loadCycle(`cycle ${cycle}`, loadRequests);
93508
+ } finally {
93509
+ peaks.push(await poller.stop());
93510
+ }
93511
+ });
93437
93512
  const settle = await timed(
93438
93513
  `cycle ${cycle} settle`,
93439
93514
  () => waitUntilSettled(app.controlPort, idleMs, deps)
@@ -93460,12 +93535,19 @@ async function runRitual(options, deps = defaultDeps) {
93460
93535
  settleOutcomes,
93461
93536
  samples,
93462
93537
  memorySamples,
93538
+ peaks,
93463
93539
  baselineSnapshot: baseline.file,
93464
93540
  afterSnapshot,
93465
93541
  trend: classifyMemoryTrend(samples, externalSamples, { minGrowthPerCycle }),
93466
93542
  requestsPerCycle: loadRequests,
93467
93543
  minGrowthPerCycle
93468
93544
  };
93545
+ } catch (cause) {
93546
+ const death = app.explainExit();
93547
+ if (death !== null) {
93548
+ throw new Error(death);
93549
+ }
93550
+ throw cause;
93469
93551
  } finally {
93470
93552
  await app.close();
93471
93553
  }
@@ -93797,6 +93879,7 @@ async function measureRoute(context, route, requestPath, index) {
93797
93879
  requestPath,
93798
93880
  samples: result.samples,
93799
93881
  memorySamples: result.memorySamples,
93882
+ peaks: result.peaks,
93800
93883
  timings: result.timings,
93801
93884
  loadOutcomes: result.loadOutcomes,
93802
93885
  settleOutcomes: result.settleOutcomes,
@@ -93812,8 +93895,8 @@ async function measureRoute(context, route, requestPath, index) {
93812
93895
  };
93813
93896
  }
93814
93897
  async function writeEvidenceBundle(report, workDir) {
93815
- const { renderHtmlReport } = await import("./html-report-2SOTNXDG.js");
93816
- const { renderIssueMarkdown } = await import("./issue-report-X5JYAVN5.js");
93898
+ const { renderHtmlReport } = await import("./html-report-KQGUDYK5.js");
93899
+ const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
93817
93900
  for (const route of report.routes) {
93818
93901
  if (route.status === "measured" && warrantsIssueDraft(route)) {
93819
93902
  const file = path6.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
@@ -2,6 +2,10 @@ import { createRequire as __nextLeakCreateRequire } from 'node:module';import {
2
2
  import {
3
3
  effectiveVerdict
4
4
  } from "./chunk-E5ZKAANQ.js";
5
+ import {
6
+ assessPeakPressure,
7
+ describePeakPressure
8
+ } from "./chunk-XHPUAMJG.js";
5
9
 
6
10
  // src/html-report.ts
7
11
  var MB = 1024 * 1024;
@@ -36,7 +40,26 @@ function ownerCell(attribution) {
36
40
  const packageName = attribution.packageName ? ` (${escapeHtml(attribution.packageName)})` : "";
37
41
  return `${attribution.owner}${source}${packageName}`;
38
42
  }
39
- function measuredSection(route) {
43
+ function peakBlock(route, parameters) {
44
+ const retained = route.memorySamples.at(-1)?.heapUsed;
45
+ if (retained === void 0 || route.peaks === void 0) {
46
+ return "";
47
+ }
48
+ const pressure = assessPeakPressure({
49
+ peaks: route.peaks,
50
+ retainedHeapBytes: retained,
51
+ maxOldSpaceMb: parameters.maxOldSpaceMb
52
+ });
53
+ const peakRows = route.peaks.filter((peak) => peak.polls > 0).map(
54
+ (peak) => `<tr><td>${escapeHtml(peak.phase)}</td><td>${(peak.heapUsed / MB).toFixed(1)} MB</td><td>${(peak.external / MB).toFixed(1)} MB</td><td>${(peak.arrayBuffers / MB).toFixed(1)} MB</td><td>${(peak.rss / MB).toFixed(1)} MB</td></tr>`
55
+ ).join("");
56
+ if (peakRows === "") {
57
+ return "";
58
+ }
59
+ const note = pressure === null ? "" : `<p class="warn">Peak pressure: ${escapeHtml(describePeakPressure(pressure))}</p>`;
60
+ return note + `<table><tr><th>peak during</th><th>heap</th><th>external</th><th>arrayBuffers</th><th>rss</th></tr>${peakRows}</table>`;
61
+ }
62
+ function measuredSection(route, parameters) {
40
63
  if (route.status !== "measured") {
41
64
  return "";
42
65
  }
@@ -51,7 +74,7 @@ function measuredSection(route) {
51
74
  const owner = ownerCell(attribution);
52
75
  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>`;
53
76
  }).join("");
54
- 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 + (findingRows === "" ? "" : `<table><tr><th>kind</th><th>type</th><th>node</th><th>retained</th><th>owner</th></tr>${findingRows}</table>`) + `</section>`;
77
+ 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>`;
55
78
  }
56
79
  function renderHtmlReport(run) {
57
80
  const measured = run.routes.filter((route) => route.status === "measured");
@@ -78,7 +101,7 @@ code{background:#f4f4f4;padding:0 4px;border-radius:3px}
78
101
  environment.nextVersion ?? "unknown"
79
102
  )} \xB7 next-leak ${escapeHtml(environment.nextLeakVersion)}</p>
80
103
  <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>
81
- ${measured.map(measuredSection).join("\n")}
104
+ ${measured.map((route) => measuredSection(route, run.parameters)).join("\n")}
82
105
  ${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>`}
83
106
  ${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>`}
84
107
  <p class="meta">Raw snapshots and run.json live next to this file \u2014 verify in Chrome DevTools \u2192 Memory \u2192 Load.</p>
@@ -1,4 +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
+ import {
3
+ assessPeakPressure,
4
+ describePeakPressure
5
+ } from "./chunk-XHPUAMJG.js";
2
6
 
3
7
  // src/issue-report.ts
4
8
  import path from "path";
@@ -41,6 +45,21 @@ function renderIssueMarkdown(route, run) {
41
45
  next-leak audits its own run and reports these limits. They do not overturn the verdict above, but they bound how much weight it carries:
42
46
 
43
47
  ` + route.confidence.warnings.map((warning) => `- ${warning.detail}`).join("\n") + `
48
+ `;
49
+ const retainedHeap = route.memorySamples.at(-1)?.heapUsed;
50
+ const pressure = retainedHeap === void 0 || route.peaks === void 0 ? null : assessPeakPressure({
51
+ peaks: route.peaks,
52
+ retainedHeapBytes: retainedHeap,
53
+ maxOldSpaceMb: parameters.maxOldSpaceMb
54
+ });
55
+ const peakSection = pressure === null ? "" : `
56
+ ### Peak memory under load
57
+
58
+ Sampled during the load phases, without forcing collection: ${describePeakPressure(pressure)}. Per cycle:
59
+
60
+ ` + route.peaks.filter((peak) => peak.polls > 0).map(
61
+ (peak) => `- ${peak.phase}: heap ${(peak.heapUsed / MB).toFixed(1)} MB, external ${(peak.external / MB).toFixed(1)} MB, arrayBuffers ${(peak.arrayBuffers / MB).toFixed(1)} MB, rss ${(peak.rss / MB).toFixed(1)} MB`
62
+ ).join("\n") + `
44
63
  `;
45
64
  const curve = route.samples.map((sample) => (sample / MB).toFixed(1)).join(" \u2192 ");
46
65
  const deltas = route.trend.deltas.map((delta) => `+${(delta / MB).toFixed(2)}`).join(", ");
@@ -89,7 +108,7 @@ ${evidenceRows(route) || "- (no findings above thresholds)"}
89
108
  ${signatures === "" ? "" : `
90
109
  **Matched known causes:**
91
110
  ${signatures}
92
- `}${caveats}
111
+ `}${peakSection}${caveats}
93
112
  ### Verify it yourself
94
113
 
95
114
  Raw snapshots (Chrome DevTools \u2192 Memory \u2192 Load, compare baseline vs after):
@@ -0,0 +1,46 @@
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/peak-pressure.ts
4
+ var MB = 1024 * 1024;
5
+ var HEAP_LIMIT_SHARE = 0.75;
6
+ var RSS_OVER_RETAINED = 8;
7
+ var RSS_FLOOR_BYTES = 512 * MB;
8
+ var maxOf = (peaks, read) => peaks.reduce((highest, peak) => Math.max(highest, read(peak)), 0);
9
+ function assessPeakPressure(input) {
10
+ const sampled = input.peaks.filter((peak) => peak.polls > 0);
11
+ if (sampled.length === 0) {
12
+ return null;
13
+ }
14
+ const heapLimitBytes = input.maxOldSpaceMb * MB;
15
+ const peakHeap = maxOf(sampled, (peak) => peak.heapUsed);
16
+ const peakRss = maxOf(sampled, (peak) => peak.rss);
17
+ if (peakHeap >= heapLimitBytes * HEAP_LIMIT_SHARE) {
18
+ return {
19
+ class: "heap",
20
+ peakBytes: peakHeap,
21
+ retainedBytes: input.retainedHeapBytes,
22
+ heapLimitBytes
23
+ };
24
+ }
25
+ if (peakRss >= RSS_FLOOR_BYTES && peakRss >= input.retainedHeapBytes * RSS_OVER_RETAINED) {
26
+ return {
27
+ class: "rss",
28
+ peakBytes: peakRss,
29
+ retainedBytes: input.retainedHeapBytes,
30
+ heapLimitBytes
31
+ };
32
+ }
33
+ return null;
34
+ }
35
+ var mb = (bytes) => `${(bytes / MB).toFixed(1)} MB`;
36
+ function describePeakPressure(pressure) {
37
+ if (pressure.class === "heap") {
38
+ return `peaked at ${mb(pressure.peakBytes)} heap under load against a ${mb(pressure.heapLimitBytes)} limit (retains ${mb(pressure.retainedBytes)}) \u2014 the run came close to the heap ceiling even though nothing was retained; peaks are the highest value sampled, not a guaranteed maximum`;
39
+ }
40
+ return `peaked at ${mb(pressure.peakBytes)} rss under load while retaining ${mb(pressure.retainedBytes)} \u2014 a container sized on what it retains dies on what it reaches; peaks are the highest value sampled, not a guaranteed maximum`;
41
+ }
42
+
43
+ export {
44
+ assessPeakPressure,
45
+ describePeakPressure
46
+ };
package/dist/cli.js CHANGED
@@ -9,8 +9,9 @@ import {
9
9
  killActiveChildren,
10
10
  parseCliArgs,
11
11
  runMeasurement
12
- } from "./chunk-HAKKAIHN.js";
12
+ } from "./chunk-2BQ5KXIJ.js";
13
13
  import "./chunk-E5ZKAANQ.js";
14
+ import "./chunk-XHPUAMJG.js";
14
15
  import "./chunk-6XYFBOL2.js";
15
16
 
16
17
  // src/cli.ts
@@ -1,6 +1,11 @@
1
1
  import type { HeapSample } from "./control-server.js";
2
2
  /** Forces GC in the measured process and returns a settled memory sample. */
3
3
  export declare function requestGc(port: number): Promise<HeapSample>;
4
+ /**
5
+ * Reads memory without collecting. Used to poll a process under load, where a
6
+ * forced GC would change the number being read.
7
+ */
8
+ export declare function requestMemory(port: number): Promise<HeapSample>;
4
9
  /** Forces GC, writes a named heap snapshot, and returns its path and sample. */
5
10
  export declare function requestSnapshot(port: number, name: string): Promise<{
6
11
  file: string;
@@ -26,6 +26,7 @@ export type ControlServer = {
26
26
  * Internal control channel booted inside the measured app's process.
27
27
  *
28
28
  * - `GET /gc` — force GC, respond with a memory sample.
29
+ * - `GET /mem` — respond with a memory sample WITHOUT collecting.
29
30
  * - `GET /snapshot?name=<label>` — force GC, write `<label>.heapsnapshot`
30
31
  * into `snapshotDir`, respond `{ file, sample }` only once fully written.
31
32
  */
@@ -1,8 +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-SKAPGI62.js";
4
+ } from "./chunk-BUNQT6VK.js";
5
5
  import "./chunk-E5ZKAANQ.js";
6
+ import "./chunk-XHPUAMJG.js";
6
7
  import "./chunk-6XYFBOL2.js";
7
8
  export {
8
9
  renderHtmlReport
package/dist/index.js CHANGED
@@ -39,16 +39,17 @@ import {
39
39
  sourceIndexAt,
40
40
  summarizeBaseline,
41
41
  validateTarget
42
- } from "./chunk-HAKKAIHN.js";
42
+ } from "./chunk-2BQ5KXIJ.js";
43
43
  import {
44
44
  renderHtmlReport
45
- } from "./chunk-SKAPGI62.js";
45
+ } from "./chunk-BUNQT6VK.js";
46
46
  import {
47
47
  classifyTrend
48
48
  } from "./chunk-E5ZKAANQ.js";
49
49
  import {
50
50
  renderIssueMarkdown
51
- } from "./chunk-MYYPTZJW.js";
51
+ } from "./chunk-WXAFXVWS.js";
52
+ import "./chunk-XHPUAMJG.js";
52
53
  import "./chunk-6XYFBOL2.js";
53
54
  export {
54
55
  LaunchError,
@@ -1,7 +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-MYYPTZJW.js";
4
+ } from "./chunk-WXAFXVWS.js";
5
+ import "./chunk-XHPUAMJG.js";
5
6
  import "./chunk-6XYFBOL2.js";
6
7
  export {
7
8
  renderIssueMarkdown
@@ -25,6 +25,13 @@ export type LaunchedApp = {
25
25
  pid: number;
26
26
  appPort: number;
27
27
  controlPort: number;
28
+ /**
29
+ * Why the measured process is gone, or null while it is alive. Without it
30
+ * a child that died mid-run surfaces as "fetch failed", which reads like a
31
+ * bug in the tool and hides the finding — most often that the app blew
32
+ * through the heap limit the run configured.
33
+ */
34
+ explainExit: () => string | null;
28
35
  /** SIGTERM, then SIGKILL after a grace period. Resolves when the child exited. */
29
36
  close: () => Promise<void>;
30
37
  };
@@ -40,6 +47,13 @@ export declare function killActiveChildren(): void;
40
47
  * the messenger, and should say so instead of printing 20 lines of trace.
41
48
  */
42
49
  export declare function explainStartupFailure(stderr: string): string;
50
+ /**
51
+ * Same idea as `explainStartupFailure`, for a process that died *during* a
52
+ * run. Heap exhaustion is the one death this tool can name outright, and it
53
+ * is a finding rather than an accident: the app did not fit in the limit the
54
+ * run gave it.
55
+ */
56
+ export declare function explainRuntimeFailure(stderr: string, maxOldSpaceMb: number): string;
43
57
  /**
44
58
  * Spawns the measured server in a fresh child process with GC exposed and the
45
59
  * control-channel bootstrap preloaded, and waits until both the app port and
@@ -0,0 +1,42 @@
1
+ import type { PeakSample } from "./ritual.js";
2
+ /**
3
+ * Which ceiling the process came closest to.
4
+ *
5
+ * `heap` is the only class bounded by `--max-old-space`; `rss` is what a
6
+ * container kills. `external`/`arrayBuffers` live in rss, which is why they
7
+ * are reported through it instead of against a limit that does not apply to
8
+ * them (vercel/next.js#92287: a healthy heap next to 4.3 GB of arrayBuffers).
9
+ */
10
+ export type PeakPressureClass = "heap" | "rss";
11
+ export type PeakPressure = {
12
+ class: PeakPressureClass;
13
+ /** Highest value observed for that class, across cycles (bytes). */
14
+ peakBytes: number;
15
+ /** Retained heap the verdict was computed on (bytes). */
16
+ retainedBytes: number;
17
+ /** Heap limit in force, in bytes. */
18
+ heapLimitBytes: number;
19
+ };
20
+ export type PeakPressureInput = {
21
+ peaks: readonly PeakSample[];
22
+ /** Post-GC heapUsed of the final sample: what the route actually retains. */
23
+ retainedHeapBytes: number;
24
+ maxOldSpaceMb: number;
25
+ };
26
+ /**
27
+ * Whether a route's peak is far enough from the memory its verdict was
28
+ * computed on to be worth saying out loud.
29
+ *
30
+ * Deliberately outside the verdict: `leak`/`stable`/`inconclusive` are
31
+ * statements about retention after GC, calibrated against real leaks with no
32
+ * false positives, and a peak is a different axis. A process that climbs to
33
+ * 3.5 GB and hands it all back is honestly `stable` — and still OOM-killed in
34
+ * a 1 GB container.
35
+ */
36
+ export declare function assessPeakPressure(input: PeakPressureInput): PeakPressure | null;
37
+ /**
38
+ * One line, phrased so it never contradicts the verdict next to it. A peak is
39
+ * the highest value *sampled*: a spike shorter than the poll interval is not
40
+ * observed, so this is a lower bound.
41
+ */
42
+ export declare function describePeakPressure(pressure: PeakPressure): string;
package/dist/ritual.d.ts CHANGED
@@ -54,6 +54,24 @@ export type SettleOutcome = {
54
54
  /** GC polls taken before converging or giving up. */
55
55
  polls: number;
56
56
  };
57
+ /**
58
+ * Highest memory observed *during* a load cycle, per class.
59
+ *
60
+ * Every other number in a run is taken after idle and a forced GC, which is
61
+ * what a verdict about retention needs. It is also blind to the process that
62
+ * climbs to 3.5 GB under load and hands it all back: `stable`, and dead in a
63
+ * 1 GB container. A peak is a lower bound — a spike shorter than the poll
64
+ * interval is never seen.
65
+ */
66
+ export type PeakSample = {
67
+ phase: string;
68
+ heapUsed: number;
69
+ external: number;
70
+ arrayBuffers: number;
71
+ rss: number;
72
+ /** Readings taken; 0 means the poller never got one. */
73
+ polls: number;
74
+ };
57
75
  export type RitualResult = {
58
76
  route: string;
59
77
  /** Wall-clock per phase, so slow runs can be explained instead of guessed. */
@@ -66,6 +84,8 @@ export type RitualResult = {
66
84
  samples: number[];
67
85
  /** Full memory samples in the same order. */
68
86
  memorySamples: HeapSample[];
87
+ /** Highest memory seen during each load cycle, sampled without collecting. */
88
+ peaks: PeakSample[];
69
89
  baselineSnapshot: string;
70
90
  afterSnapshot: string;
71
91
  trend: TrendResult;
@@ -82,6 +102,8 @@ export type RitualDeps = {
82
102
  launch: typeof launchInstrumented;
83
103
  load: typeof runLoadPhase;
84
104
  sleep: (ms: number) => Promise<void>;
105
+ /** GC-free read, polled while the app is under load. */
106
+ readMemory: (port: number) => Promise<HeapSample>;
85
107
  };
86
108
  /**
87
109
  * Single source of truth for ritual defaults — reports must echo them.
@@ -101,6 +123,7 @@ export declare const RITUAL_DEFAULTS: {
101
123
  readonly connections: 100;
102
124
  readonly cycles: 4;
103
125
  readonly idleMs: 30000;
126
+ readonly maxOldSpaceMb: 512;
104
127
  };
105
128
  /**
106
129
  * Runs the validated phase-0 ritual against one route in a fresh process:
package/dist/runner.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { HeapSample } from "./control-server.js";
4
4
  import { type MeasurementEnvironment } from "./environment.js";
5
5
  import { diffSnapshotFiles, type HeapDiff } from "./heap-diff.js";
6
6
  import { extractModuleRegistry } from "./module-registry.js";
7
- import { runRitual, type LoadOutcome, type PhaseTiming, type SettleOutcome } from "./ritual.js";
7
+ import { runRitual, type LoadOutcome, type PeakSample, type PhaseTiming, type SettleOutcome } from "./ritual.js";
8
8
  import { readNextVersion, type MatchedSignature } from "./signatures.js";
9
9
  import { type TrendResult } from "./trend.js";
10
10
  export type RouteReport = {
@@ -28,6 +28,11 @@ export type RouteReport = {
28
28
  * different fix than a heap leak.
29
29
  */
30
30
  memorySamples: HeapSample[];
31
+ /**
32
+ * Highest memory reached *during* each load cycle. Every other number
33
+ * here is post-GC; this is the one a container limit is judged against.
34
+ */
35
+ peaks: PeakSample[];
31
36
  /** RSS growth per 1000 requests, computed like the heap figure. */
32
37
  rssPer1000Requests: number;
33
38
  /** Wall-clock per phase — explains where a long run spent its time. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",