next-leak 0.2.0 → 0.4.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
@@ -35,7 +35,7 @@ holds it — without being told what to look for.
35
35
  | [#95094](https://github.com/vercel/next.js/issues/95094) | Middleware `setTimeout` ids retained by the sandbox | **Reproduced** · mechanism named · 112 MB retained |
36
36
  | [#94890](https://github.com/vercel/next.js/issues/94890) | Router LRU cache doesn't count its keys | **Reproduced** · 26.7 → 71.9 MB |
37
37
  | [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware | **Reproduced** · 32.8 → 369.9 MB |
38
- | [#94919](https://github.com/vercel/next.js/issues/94919) | RSC tree retained on client aborts | Not reproduced on standalone [and it says why](#scope-and-limits-read-before-filing-issues) |
38
+ | [#94919](https://github.com/vercel/next.js/issues/94919) | RSC tree retained on client aborts | **Reproduced** · 39 139 MB · [with a caveat](#scope-and-limits-read-before-filing-issues) |
39
39
 
40
40
  The full causal chain, measured on that same issue: leak found (28.7 -> 138.9 MB
41
41
  across 8 cycles), the workaround from the thread applied (`clearTimeout(id)`
@@ -86,8 +86,11 @@ The verdict comes from the **shape of the post-GC curve**: retained heap that
86
86
  keeps growing every cycle is a leak; growth that flattens is warm-up. Where the
87
87
  heap sits is noise — 40 MB and 400 MB say nothing on their own — so only the
88
88
  shape is judged. The one absolute number involved is the gate a cycle's growth
89
- must clear to count, and it scales with the traffic that cycle served, so
90
- changing `--requests` changes how long the run takes and not what it decides.
89
+ must clear to count, and above 5000 requests per cycle it scales with the
90
+ traffic that cycle served so in that range changing `--requests` changes how
91
+ long the run takes and not what it decides. Below 5000 the gate stops shrinking
92
+ and sits on the instrument's noise floor instead, so less traffic really does
93
+ buy a less sensitive run: that is the trade `--quick` makes at 2000 requests.
91
94
  Every report prints the gate it used.
92
95
 
93
96
  ## Options
@@ -123,10 +126,14 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
123
126
  without it.
124
127
  - **`query`** appends a query string per route template
125
128
  (`{ "/api/payload/[slug]": "weightKb=2048" }`).
126
- - **`abandonAfterMs`** makes clients hang up before the response arrives, the
127
- way closed tabs, load-balancer timeouts and bots do. Some leaks only exist
128
- on that path (`ServerResponse` retained after an early disconnect). Requests
129
- abandoned on purpose are not counted as failures.
129
+ - **`abandonAfterMs`** makes clients hang up mid-response, the way closed tabs,
130
+ load-balancer timeouts and bots do. Some leaks only exist on that path
131
+ (`ServerResponse` retained after an early disconnect; the RSC tee branch in
132
+ [#94919](https://github.com/vercel/next.js/issues/94919)). The clock starts
133
+ at the **first byte of the response**, not at the request — under load a
134
+ request-relative window cuts before the stream begins and tests a different
135
+ path. Small values are the point: `4` means "read the first chunk, then
136
+ vanish". Requests abandoned on purpose are not counted as failures.
130
137
 
131
138
  `run.json` records what every load phase actually did — requests sent,
132
139
  2xx, abandoned — so a run can be audited instead of trusted.
@@ -155,13 +162,42 @@ separates them, because each one has a different fix:
155
162
  is deliberately biased toward missing a leak rather than inventing one (a
156
163
  single flat or falling cycle is enough to call a route stable), so a leak
157
164
  that oscillates while it climbs can land here. To press harder, raise
158
- `--cycles` and `--requests`: both make the run more sensitive. If the heap is
165
+ `--cycles` every extra cycle is another delta the verdict gets to see.
166
+ Raising `--requests` only helps from below 5000: above that the gate scales
167
+ with the traffic, so the longer run decides the same thing. If the heap is
159
168
  flat but RSS keeps climbing, the report says so explicitly: that is an
160
169
  allocator, external-buffer or fragmentation problem, not a JS-heap leak.
161
170
  - **`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; if the leak is app-owned, the draft tells you **not** to file it upstream.
162
171
  - **`inconclusive`** — sustained sub-threshold growth: measure longer. The CLI prints the exact re-run command (`--routes <those> --cycles 6`).
163
172
  - **`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
173
 
174
+ ## Peak pressure: `stable` is not the same as safe
175
+
176
+ Every verdict above is about what a route **retains** after idle and a forced
177
+ GC. That is the right question for a leak and the wrong one for an
178
+ `OOMKilled`: a process can climb to gigabytes under load, hand it all back
179
+ when the load stops, and still be killed at its peak. So each load cycle is
180
+ also sampled *without* collecting, and the highest value is reported next to
181
+ the verdict:
182
+
183
+ ```
184
+ ✔ /[slug] stable (-164.08 MB/1000 req) heap 261.9 MB → 265.7 MB → 35.4 MB → 36.0 MB
185
+ ▲ peak pressure: peaked at 3145.7 MB rss under load while retaining 36.0 MB —
186
+ a container sized on what it retains dies on what it reaches
187
+ ```
188
+
189
+ That is a real measurement of the reproduction in
190
+ [vercel/next.js#92287](https://github.com/vercel/next.js/issues/92287): no
191
+ retention, and 3 GB reached. The note fires when the peak heap comes within
192
+ 75% of `--max-old-space`, or when peak RSS is at least 8× the retained heap
193
+ and above 512 MB. It never changes the verdict — retention and peak are
194
+ different questions, and only one of them is a leak. A peak is the highest
195
+ value *sampled* (every 250 ms), so it is a lower bound.
196
+
197
+ If the measured process dies at the limit instead of merely approaching it,
198
+ the route fails saying exactly that, with the limit in force and how to raise
199
+ it.
200
+
165
201
  ## The tool grades its own measurement
166
202
 
167
203
  A leak detector is an instrument, and a miscalibrated instrument doesn't fail
@@ -233,6 +269,14 @@ through the build's source maps.
233
269
  `--max-old-space`, or every route dies as an OOM that is not the app's
234
270
  fault. When a run's heap gets close to the cap, the report says so.
235
271
  - Borderline routes can flip between `stable`/`leak` across runs — more cycles resolves this.
272
+ - The [#94919](https://github.com/vercel/next.js/issues/94919) reproduction ships
273
+ a **custom Express server and deliberately no standalone output**, which this
274
+ tool cannot measure as published. The figure above comes from the same app
275
+ built with `output: "standalone"` — the leak is there too, but that is Next's
276
+ server under test, not the reporter's middleware chain. Instrumenting their
277
+ own server by hand (same `--import` bootstrap, no CLI) showed the same shape:
278
+ post-GC heap 43 → 56 MB and arrayBuffers 0.2 → 10.7 MB over four cycles.
279
+ - 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
280
  - 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
281
 
238
282
  ## Development
@@ -2,7 +2,7 @@ export type AbandonPhaseOptions = {
2
2
  url: string;
3
3
  amount: number;
4
4
  connections: number;
5
- /** Destroy the socket this many ms after sending the request. */
5
+ /** Destroy the socket this many ms after the first byte of the response. */
6
6
  abandonAfterMs: number;
7
7
  headers?: Record<string, string>;
8
8
  };
@@ -15,6 +15,8 @@ export type AbandonPhaseResult = {
15
15
  * different path (the server may never have begun rendering).
16
16
  */
17
17
  abandonedMidStream: number;
18
+ /** Abandonments where the first-byte budget expired in silence. */
19
+ abandonedBeforeResponse: number;
18
20
  completed: number;
19
21
  errors: number;
20
22
  };
@@ -27,7 +29,7 @@ export type AbandonPhaseResult = {
27
29
  * `ServerResponse` retention to an early disconnect, which only happens when
28
30
  * a client goes away mid-flight (closed tabs, load-balancer timeouts, bots).
29
31
  *
30
- * Raw sockets keep this honest: write the request, wait `abandonAfterMs`,
31
- * destroy the socket. No response is read.
32
+ * Raw sockets keep this honest: write the request, wait for the response to
33
+ * start, then wait `abandonAfterMs` and destroy the socket mid-stream.
32
34
  */
33
35
  export declare function runAbandonPhase(options: AbandonPhaseOptions): Promise<AbandonPhaseResult>;
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 === "") {
@@ -115,7 +115,7 @@ function abandonmentWarnings(outcome) {
115
115
  if (abandoned > 0 && midStream < abandoned * MID_STREAM_FLOOR) {
116
116
  return [{
117
117
  code: "abandon-before-response",
118
- detail: `${outcome.phase} cut ${abandoned} requests before the server sent anything (${midStream} mid-stream) \u2014 this tested pre-response disconnects, not mid-stream teardown; raise abandonAfterMs above the route's time-to-first-byte`
118
+ detail: `${outcome.phase} cut ${abandoned} requests that never produced a byte (${midStream} mid-stream) \u2014 the route did not start responding, so mid-stream teardown was not exercised; the route is saturated or hung at this load, not mistuned`
119
119
  }];
120
120
  }
121
121
  return [];
@@ -1,7 +1,11 @@
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
  effectiveVerdict
4
- } from "./chunk-E5ZKAANQ.js";
4
+ } from "./chunk-BDIPW6FU.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>
@@ -6,7 +6,11 @@ import {
6
6
  effectiveVerdict,
7
7
  minGrowthFor,
8
8
  warrantsIssueDraft
9
- } from "./chunk-E5ZKAANQ.js";
9
+ } from "./chunk-BDIPW6FU.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(
@@ -92477,6 +92501,11 @@ var routeConfigSchema = z2.object({
92477
92501
  * that path — vercel/next.js#89091 traces `ServerResponse` retention to
92478
92502
  * an early disconnect — and a load generator that always waits politely
92479
92503
  * never reaches it.
92504
+ *
92505
+ * Counted **from the first byte of the response**, not from the request:
92506
+ * under load a server's first byte arrives long after any sane fixed
92507
+ * window, so a request-relative clock cuts before the stream starts and
92508
+ * tests the wrong path entirely.
92480
92509
  */
92481
92510
  abandonAfterMs: z2.number().int().positive().optional()
92482
92511
  }).strict();
@@ -92740,6 +92769,9 @@ function diffAgainstBaseline(baseline, after, options = {}) {
92740
92769
  node.type,
92741
92770
  (afterTypeSelfSizes.get(node.type) ?? 0) + node.self_size
92742
92771
  );
92772
+ if (node.type === "synthetic") {
92773
+ return;
92774
+ }
92743
92775
  if (!baseline.nodeIds.has(node.id)) {
92744
92776
  if (node.retainedSize >= resolved.newThresholdBytes) {
92745
92777
  const chain = walkChain(node, resolved.chainDepth);
@@ -93219,6 +93251,9 @@ async function requestGc(port) {
93219
93251
  }
93220
93252
  return sample;
93221
93253
  }
93254
+ async function requestMemory(port) {
93255
+ return sampleSchema.parse(await request(port, "/mem"));
93256
+ }
93222
93257
  async function requestSnapshot(port, name) {
93223
93258
  const parsed = snapshotResponseSchema.parse(
93224
93259
  await request(port, `/snapshot?name=${encodeURIComponent(name)}`)
@@ -93233,6 +93268,7 @@ async function requestSnapshot(port, name) {
93233
93268
 
93234
93269
  // src/abandon-load.ts
93235
93270
  import net from "net";
93271
+ var FIRST_BYTE_BUDGET_MS = 5e3;
93236
93272
  async function runAbandonPhase(options) {
93237
93273
  const target = new URL(options.url);
93238
93274
  const port = Number(target.port || 80);
@@ -93247,6 +93283,7 @@ Host: ${target.host}\r
93247
93283
  sent: 0,
93248
93284
  abandoned: 0,
93249
93285
  abandonedMidStream: 0,
93286
+ abandonedBeforeResponse: 0,
93250
93287
  completed: 0,
93251
93288
  errors: 0
93252
93289
  };
@@ -93270,6 +93307,8 @@ Host: ${target.host}\r
93270
93307
  result.abandoned += 1;
93271
93308
  if (responseStarted) {
93272
93309
  result.abandonedMidStream += 1;
93310
+ } else {
93311
+ result.abandonedBeforeResponse += 1;
93273
93312
  }
93274
93313
  }
93275
93314
  finish();
@@ -93277,11 +93316,17 @@ Host: ${target.host}\r
93277
93316
  socket.once("connect", () => {
93278
93317
  result.sent += 1;
93279
93318
  socket.write(request2);
93280
- timer = setTimeout(giveUp, options.abandonAfterMs);
93319
+ timer = setTimeout(giveUp, FIRST_BYTE_BUDGET_MS);
93281
93320
  timer.unref();
93282
93321
  });
93283
93322
  socket.on("data", () => {
93323
+ if (responseStarted) {
93324
+ return;
93325
+ }
93284
93326
  responseStarted = true;
93327
+ clearTimeout(timer);
93328
+ timer = setTimeout(giveUp, options.abandonAfterMs);
93329
+ timer.unref();
93285
93330
  });
93286
93331
  socket.once("end", () => {
93287
93332
  if (!settled) {
@@ -93312,6 +93357,44 @@ Host: ${target.host}\r
93312
93357
  var MIN_POLLS_TO_JUDGE = 2;
93313
93358
  var SETTLE_POLL_MS = 2e3;
93314
93359
  var SETTLE_TOLERANCE = 0.01;
93360
+ var PEAK_POLL_MS = 250;
93361
+ function pollPeak(controlPort, phase, deps) {
93362
+ const peak = {
93363
+ phase,
93364
+ heapUsed: 0,
93365
+ external: 0,
93366
+ arrayBuffers: 0,
93367
+ rss: 0,
93368
+ polls: 0
93369
+ };
93370
+ let running = true;
93371
+ const loop = (async () => {
93372
+ while (running) {
93373
+ await deps.sleep(PEAK_POLL_MS);
93374
+ if (!running) {
93375
+ return;
93376
+ }
93377
+ let sample;
93378
+ try {
93379
+ sample = await deps.readMemory(controlPort);
93380
+ } catch {
93381
+ return;
93382
+ }
93383
+ peak.polls += 1;
93384
+ peak.heapUsed = Math.max(peak.heapUsed, sample.heapUsed);
93385
+ peak.external = Math.max(peak.external, sample.external);
93386
+ peak.arrayBuffers = Math.max(peak.arrayBuffers, sample.arrayBuffers);
93387
+ peak.rss = Math.max(peak.rss, sample.rss);
93388
+ }
93389
+ })();
93390
+ return {
93391
+ stop: async () => {
93392
+ running = false;
93393
+ await loop;
93394
+ return peak;
93395
+ }
93396
+ };
93397
+ }
93315
93398
  async function waitUntilSettled(controlPort, maxIdleMs, deps) {
93316
93399
  const deadline = Date.now() + maxIdleMs;
93317
93400
  let previous = null;
@@ -93335,14 +93418,16 @@ async function waitUntilSettled(controlPort, maxIdleMs, deps) {
93335
93418
  var defaultDeps = {
93336
93419
  launch: launchInstrumented,
93337
93420
  load: runLoadPhase,
93338
- sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
93421
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
93422
+ readMemory: requestMemory
93339
93423
  };
93340
93424
  var RITUAL_DEFAULTS = {
93341
93425
  warmupRequests: 200,
93342
93426
  loadRequests: 5e3,
93343
93427
  connections: 100,
93344
93428
  cycles: 4,
93345
- idleMs: 3e4
93429
+ idleMs: 3e4,
93430
+ maxOldSpaceMb: 512
93346
93431
  };
93347
93432
  async function runRitual(options, deps = defaultDeps) {
93348
93433
  const warmupRequests = options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests;
@@ -93403,6 +93488,7 @@ async function runRitual(options, deps = defaultDeps) {
93403
93488
  sent: outcome.sent,
93404
93489
  abandoned: outcome.abandoned,
93405
93490
  abandonedMidStream: outcome.abandonedMidStream,
93491
+ abandonedBeforeResponse: outcome.abandonedBeforeResponse,
93406
93492
  ok2xx: outcome.completed,
93407
93493
  errors: outcome.errors
93408
93494
  });
@@ -93431,9 +93517,17 @@ async function runRitual(options, deps = defaultDeps) {
93431
93517
  () => requestSnapshot(app.controlPort, "baseline")
93432
93518
  );
93433
93519
  const memorySamples = [baseline.sample];
93520
+ const peaks = [];
93434
93521
  let afterSnapshot = "";
93435
93522
  for (let cycle = 1; cycle <= cycles; cycle += 1) {
93436
- await timed(`cycle ${cycle} load`, () => loadCycle(`cycle ${cycle}`, loadRequests));
93523
+ await timed(`cycle ${cycle} load`, async () => {
93524
+ const poller = pollPeak(app.controlPort, `cycle ${cycle}`, deps);
93525
+ try {
93526
+ await loadCycle(`cycle ${cycle}`, loadRequests);
93527
+ } finally {
93528
+ peaks.push(await poller.stop());
93529
+ }
93530
+ });
93437
93531
  const settle = await timed(
93438
93532
  `cycle ${cycle} settle`,
93439
93533
  () => waitUntilSettled(app.controlPort, idleMs, deps)
@@ -93460,12 +93554,19 @@ async function runRitual(options, deps = defaultDeps) {
93460
93554
  settleOutcomes,
93461
93555
  samples,
93462
93556
  memorySamples,
93557
+ peaks,
93463
93558
  baselineSnapshot: baseline.file,
93464
93559
  afterSnapshot,
93465
93560
  trend: classifyMemoryTrend(samples, externalSamples, { minGrowthPerCycle }),
93466
93561
  requestsPerCycle: loadRequests,
93467
93562
  minGrowthPerCycle
93468
93563
  };
93564
+ } catch (cause) {
93565
+ const death = app.explainExit();
93566
+ if (death !== null) {
93567
+ throw new Error(death);
93568
+ }
93569
+ throw cause;
93469
93570
  } finally {
93470
93571
  await app.close();
93471
93572
  }
@@ -93797,6 +93898,7 @@ async function measureRoute(context, route, requestPath, index) {
93797
93898
  requestPath,
93798
93899
  samples: result.samples,
93799
93900
  memorySamples: result.memorySamples,
93901
+ peaks: result.peaks,
93800
93902
  timings: result.timings,
93801
93903
  loadOutcomes: result.loadOutcomes,
93802
93904
  settleOutcomes: result.settleOutcomes,
@@ -93812,8 +93914,8 @@ async function measureRoute(context, route, requestPath, index) {
93812
93914
  };
93813
93915
  }
93814
93916
  async function writeEvidenceBundle(report, workDir) {
93815
- const { renderHtmlReport } = await import("./html-report-2SOTNXDG.js");
93816
- const { renderIssueMarkdown } = await import("./issue-report-X5JYAVN5.js");
93917
+ const { renderHtmlReport } = await import("./html-report-VW7VQOCH.js");
93918
+ const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
93817
93919
  for (const route of report.routes) {
93818
93920
  if (route.status === "measured" && warrantsIssueDraft(route)) {
93819
93921
  const file = path6.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
@@ -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";
13
- import "./chunk-E5ZKAANQ.js";
12
+ } from "./chunk-USNLL625.js";
13
+ import "./chunk-BDIPW6FU.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";
5
- import "./chunk-E5ZKAANQ.js";
4
+ } from "./chunk-NYWCWKS6.js";
5
+ import "./chunk-BDIPW6FU.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-USNLL625.js";
43
43
  import {
44
44
  renderHtmlReport
45
- } from "./chunk-SKAPGI62.js";
45
+ } from "./chunk-NYWCWKS6.js";
46
46
  import {
47
47
  classifyTrend
48
- } from "./chunk-E5ZKAANQ.js";
48
+ } from "./chunk-BDIPW6FU.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
@@ -39,6 +39,8 @@ export type LoadOutcome = {
39
39
  abandoned?: number;
40
40
  /** Abandonments where the response had already started — the mid-stream path. */
41
41
  abandonedMidStream?: number;
42
+ /** Abandonments where the first-byte budget expired in silence. */
43
+ abandonedBeforeResponse?: number;
42
44
  };
43
45
  /**
44
46
  * Whether the heap actually held still before each sample was taken.
@@ -54,6 +56,24 @@ export type SettleOutcome = {
54
56
  /** GC polls taken before converging or giving up. */
55
57
  polls: number;
56
58
  };
59
+ /**
60
+ * Highest memory observed *during* a load cycle, per class.
61
+ *
62
+ * Every other number in a run is taken after idle and a forced GC, which is
63
+ * what a verdict about retention needs. It is also blind to the process that
64
+ * climbs to 3.5 GB under load and hands it all back: `stable`, and dead in a
65
+ * 1 GB container. A peak is a lower bound — a spike shorter than the poll
66
+ * interval is never seen.
67
+ */
68
+ export type PeakSample = {
69
+ phase: string;
70
+ heapUsed: number;
71
+ external: number;
72
+ arrayBuffers: number;
73
+ rss: number;
74
+ /** Readings taken; 0 means the poller never got one. */
75
+ polls: number;
76
+ };
57
77
  export type RitualResult = {
58
78
  route: string;
59
79
  /** Wall-clock per phase, so slow runs can be explained instead of guessed. */
@@ -66,6 +86,8 @@ export type RitualResult = {
66
86
  samples: number[];
67
87
  /** Full memory samples in the same order. */
68
88
  memorySamples: HeapSample[];
89
+ /** Highest memory seen during each load cycle, sampled without collecting. */
90
+ peaks: PeakSample[];
69
91
  baselineSnapshot: string;
70
92
  afterSnapshot: string;
71
93
  trend: TrendResult;
@@ -82,6 +104,8 @@ export type RitualDeps = {
82
104
  launch: typeof launchInstrumented;
83
105
  load: typeof runLoadPhase;
84
106
  sleep: (ms: number) => Promise<void>;
107
+ /** GC-free read, polled while the app is under load. */
108
+ readMemory: (port: number) => Promise<HeapSample>;
85
109
  };
86
110
  /**
87
111
  * Single source of truth for ritual defaults — reports must echo them.
@@ -101,6 +125,7 @@ export declare const RITUAL_DEFAULTS: {
101
125
  readonly connections: 100;
102
126
  readonly cycles: 4;
103
127
  readonly idleMs: 30000;
128
+ readonly maxOldSpaceMb: 512;
104
129
  };
105
130
  /**
106
131
  * 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.4.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",