next-leak 0.3.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,7 +162,9 @@ 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.
@@ -260,6 +269,13 @@ through the build's source maps.
260
269
  `--max-old-space`, or every route dies as an OOM that is not the app's
261
270
  fault. When a run's heap gets close to the cap, the report says so.
262
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.
263
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.
264
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.
265
281
 
@@ -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>;
@@ -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,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
  effectiveVerdict
4
- } from "./chunk-E5ZKAANQ.js";
4
+ } from "./chunk-BDIPW6FU.js";
5
5
  import {
6
6
  assessPeakPressure,
7
7
  describePeakPressure
@@ -6,7 +6,7 @@ import {
6
6
  effectiveVerdict,
7
7
  minGrowthFor,
8
8
  warrantsIssueDraft
9
- } from "./chunk-E5ZKAANQ.js";
9
+ } from "./chunk-BDIPW6FU.js";
10
10
  import {
11
11
  assessPeakPressure,
12
12
  describePeakPressure
@@ -92501,6 +92501,11 @@ var routeConfigSchema = z2.object({
92501
92501
  * that path — vercel/next.js#89091 traces `ServerResponse` retention to
92502
92502
  * an early disconnect — and a load generator that always waits politely
92503
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.
92504
92509
  */
92505
92510
  abandonAfterMs: z2.number().int().positive().optional()
92506
92511
  }).strict();
@@ -92764,6 +92769,9 @@ function diffAgainstBaseline(baseline, after, options = {}) {
92764
92769
  node.type,
92765
92770
  (afterTypeSelfSizes.get(node.type) ?? 0) + node.self_size
92766
92771
  );
92772
+ if (node.type === "synthetic") {
92773
+ return;
92774
+ }
92767
92775
  if (!baseline.nodeIds.has(node.id)) {
92768
92776
  if (node.retainedSize >= resolved.newThresholdBytes) {
92769
92777
  const chain = walkChain(node, resolved.chainDepth);
@@ -93260,6 +93268,7 @@ async function requestSnapshot(port, name) {
93260
93268
 
93261
93269
  // src/abandon-load.ts
93262
93270
  import net from "net";
93271
+ var FIRST_BYTE_BUDGET_MS = 5e3;
93263
93272
  async function runAbandonPhase(options) {
93264
93273
  const target = new URL(options.url);
93265
93274
  const port = Number(target.port || 80);
@@ -93274,6 +93283,7 @@ Host: ${target.host}\r
93274
93283
  sent: 0,
93275
93284
  abandoned: 0,
93276
93285
  abandonedMidStream: 0,
93286
+ abandonedBeforeResponse: 0,
93277
93287
  completed: 0,
93278
93288
  errors: 0
93279
93289
  };
@@ -93297,6 +93307,8 @@ Host: ${target.host}\r
93297
93307
  result.abandoned += 1;
93298
93308
  if (responseStarted) {
93299
93309
  result.abandonedMidStream += 1;
93310
+ } else {
93311
+ result.abandonedBeforeResponse += 1;
93300
93312
  }
93301
93313
  }
93302
93314
  finish();
@@ -93304,11 +93316,17 @@ Host: ${target.host}\r
93304
93316
  socket.once("connect", () => {
93305
93317
  result.sent += 1;
93306
93318
  socket.write(request2);
93307
- timer = setTimeout(giveUp, options.abandonAfterMs);
93319
+ timer = setTimeout(giveUp, FIRST_BYTE_BUDGET_MS);
93308
93320
  timer.unref();
93309
93321
  });
93310
93322
  socket.on("data", () => {
93323
+ if (responseStarted) {
93324
+ return;
93325
+ }
93311
93326
  responseStarted = true;
93327
+ clearTimeout(timer);
93328
+ timer = setTimeout(giveUp, options.abandonAfterMs);
93329
+ timer.unref();
93312
93330
  });
93313
93331
  socket.once("end", () => {
93314
93332
  if (!settled) {
@@ -93470,6 +93488,7 @@ async function runRitual(options, deps = defaultDeps) {
93470
93488
  sent: outcome.sent,
93471
93489
  abandoned: outcome.abandoned,
93472
93490
  abandonedMidStream: outcome.abandonedMidStream,
93491
+ abandonedBeforeResponse: outcome.abandonedBeforeResponse,
93473
93492
  ok2xx: outcome.completed,
93474
93493
  errors: outcome.errors
93475
93494
  });
@@ -93895,7 +93914,7 @@ async function measureRoute(context, route, requestPath, index) {
93895
93914
  };
93896
93915
  }
93897
93916
  async function writeEvidenceBundle(report, workDir) {
93898
- const { renderHtmlReport } = await import("./html-report-KQGUDYK5.js");
93917
+ const { renderHtmlReport } = await import("./html-report-VW7VQOCH.js");
93899
93918
  const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
93900
93919
  for (const route of report.routes) {
93901
93920
  if (route.status === "measured" && warrantsIssueDraft(route)) {
package/dist/cli.js CHANGED
@@ -9,8 +9,8 @@ import {
9
9
  killActiveChildren,
10
10
  parseCliArgs,
11
11
  runMeasurement
12
- } from "./chunk-2BQ5KXIJ.js";
13
- import "./chunk-E5ZKAANQ.js";
12
+ } from "./chunk-USNLL625.js";
13
+ import "./chunk-BDIPW6FU.js";
14
14
  import "./chunk-XHPUAMJG.js";
15
15
  import "./chunk-6XYFBOL2.js";
16
16
 
@@ -1,8 +1,8 @@
1
1
  import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
2
2
  import {
3
3
  renderHtmlReport
4
- } from "./chunk-BUNQT6VK.js";
5
- import "./chunk-E5ZKAANQ.js";
4
+ } from "./chunk-NYWCWKS6.js";
5
+ import "./chunk-BDIPW6FU.js";
6
6
  import "./chunk-XHPUAMJG.js";
7
7
  import "./chunk-6XYFBOL2.js";
8
8
  export {
package/dist/index.js CHANGED
@@ -39,13 +39,13 @@ import {
39
39
  sourceIndexAt,
40
40
  summarizeBaseline,
41
41
  validateTarget
42
- } from "./chunk-2BQ5KXIJ.js";
42
+ } from "./chunk-USNLL625.js";
43
43
  import {
44
44
  renderHtmlReport
45
- } from "./chunk-BUNQT6VK.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
51
  } from "./chunk-WXAFXVWS.js";
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.3.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",