next-leak 0.3.0 → 0.4.1

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
@@ -101,6 +104,7 @@ Every report prints the gate it used.
101
104
  | `--idle <seconds>` | 30 | **Maximum** wait before each sample; the run continues as soon as the heap settles |
102
105
  | `--max-old-space <mb>` | 512 | Heap cap of each measured process. Raise it for apps whose legitimate working set is larger, or they die under measurement |
103
106
  | `--quick` | off | Fast preset (2000 requests × 4 cycles, 8s idle) — the exact profile the real-app validation ran with. Same cycle count as the default; what it trades away is traffic per cycle, so it sits on the noise floor and is less sensitive to slow leaks. Explicit flags override it |
107
+ | `--no-resolve` | off | Skip the second pass on inconclusive routes |
104
108
  | `--diff-all` | off | Diff snapshots for stable routes too |
105
109
  | `--output <dir>` | `<app>/.next-leak` | Where runs are written |
106
110
 
@@ -123,10 +127,14 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
123
127
  without it.
124
128
  - **`query`** appends a query string per route template
125
129
  (`{ "/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.
130
+ - **`abandonAfterMs`** makes clients hang up mid-response, the way closed tabs,
131
+ load-balancer timeouts and bots do. Some leaks only exist on that path
132
+ (`ServerResponse` retained after an early disconnect; the RSC tee branch in
133
+ [#94919](https://github.com/vercel/next.js/issues/94919)). The clock starts
134
+ at the **first byte of the response**, not at the request — under load a
135
+ request-relative window cuts before the stream begins and tests a different
136
+ path. Small values are the point: `4` means "read the first chunk, then
137
+ vanish". Requests abandoned on purpose are not counted as failures.
130
138
 
131
139
  `run.json` records what every load phase actually did — requests sent,
132
140
  2xx, abandoned — so a run can be audited instead of trusted.
@@ -155,11 +163,13 @@ separates them, because each one has a different fix:
155
163
  is deliberately biased toward missing a leak rather than inventing one (a
156
164
  single flat or falling cycle is enough to call a route stable), so a leak
157
165
  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
166
+ `--cycles` every extra cycle is another delta the verdict gets to see.
167
+ Raising `--requests` only helps from below 5000: above that the gate scales
168
+ with the traffic, so the longer run decides the same thing. If the heap is
159
169
  flat but RSS keeps climbing, the report says so explicitly: that is an
160
170
  allocator, external-buffer or fragmentation problem, not a JS-heap leak.
161
171
  - **`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
- - **`inconclusive`** — sustained sub-threshold growth: measure longer. The CLI prints the exact re-run command (`--routes <those> --cycles 6`).
172
+ - **`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.
163
173
  - **`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
174
 
165
175
  ## Peak pressure: `stable` is not the same as safe
@@ -260,6 +270,13 @@ through the build's source maps.
260
270
  `--max-old-space`, or every route dies as an OOM that is not the app's
261
271
  fault. When a run's heap gets close to the cap, the report says so.
262
272
  - Borderline routes can flip between `stable`/`leak` across runs — more cycles resolves this.
273
+ - The [#94919](https://github.com/vercel/next.js/issues/94919) reproduction ships
274
+ a **custom Express server and deliberately no standalone output**, which this
275
+ tool cannot measure as published. The figure above comes from the same app
276
+ built with `output: "standalone"` — the leak is there too, but that is Next's
277
+ server under test, not the reporter's middleware chain. Instrumenting their
278
+ own server by hand (same `--import` bootstrap, no CLI) showed the same shape:
279
+ post-GC heap 43 → 56 MB and arrayBuffers 0.2 → 10.7 MB over four cycles.
263
280
  - 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
281
  - 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
282
 
@@ -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>;
@@ -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-FFCY6POI.js";
5
5
  import {
6
6
  assessPeakPressure,
7
7
  describePeakPressure
@@ -73,7 +73,8 @@ function effectiveVerdict(report) {
73
73
  }
74
74
  var VERDICT_WEAKENING = /* @__PURE__ */ new Set([
75
75
  "near-threshold",
76
- "spiky-growth"
76
+ "spiky-growth",
77
+ "thin-evidence"
77
78
  ]);
78
79
  function warrantsIssueDraft(report) {
79
80
  return effectiveVerdict(report) === "leak" && !report.confidence.warnings.some((warning) => VERDICT_WEAKENING.has(warning.code));
@@ -115,7 +116,7 @@ function abandonmentWarnings(outcome) {
115
116
  if (abandoned > 0 && midStream < abandoned * MID_STREAM_FLOOR) {
116
117
  return [{
117
118
  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`
119
+ 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
120
  }];
120
121
  }
121
122
  return [];
@@ -182,13 +183,36 @@ function heapCeilingWarnings(input) {
182
183
  detail: `the heap peaked at ${mb(peak)} against a ${capMb} MB cap (${pct(peak, capBytes)}) \u2014 the curve may have been clipped by the ceiling rather than by the app; re-run with a larger --max-old-space`
183
184
  }];
184
185
  }
186
+ var THIN_EVIDENCE_MIN_DELTAS = 5;
187
+ var THIN_EVIDENCE_MIN_DELTA_RATIO = 4;
188
+ function isThinEvidence(trend, minGrowth) {
189
+ if (trend.deltas.length >= THIN_EVIDENCE_MIN_DELTAS || trend.deltas.length === 0) {
190
+ return false;
191
+ }
192
+ const smallest = Math.min(...trend.deltas);
193
+ return smallest < minGrowth * THIN_EVIDENCE_MIN_DELTA_RATIO;
194
+ }
195
+ function thinEvidenceWarnings(trend, minGrowth) {
196
+ if (trend.verdict !== "leak" || !isThinEvidence(trend, minGrowth)) {
197
+ return [];
198
+ }
199
+ const smallest = Math.min(...trend.deltas);
200
+ return [{
201
+ code: "thin-evidence",
202
+ detail: `judged on ${trend.deltas.length} cycles, and its weakest grew ${mb(smallest)} against a ${mb(minGrowth)} gate \u2014 on that few cycles ordinary oscillation reaches that, and a repeat run often disagrees; measure more cycles to resolve it`
203
+ }];
204
+ }
185
205
  function isVerdictInvalid(input) {
186
206
  if (input.trend.verdict !== "leak") {
187
207
  return false;
188
208
  }
189
209
  const neverSettled = input.settleOutcomes.length > 0 && input.settleOutcomes.every((outcome) => outcome.status === "moving");
190
210
  const abandonedNothing = input.abandonAfterMs !== void 0 && input.loadOutcomes.length > 0 && input.loadOutcomes.every((outcome) => (outcome.abandoned ?? 0) === 0);
191
- return neverSettled || abandonedNothing;
211
+ const thinEvidence = isThinEvidence(
212
+ input.trend,
213
+ input.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR
214
+ );
215
+ return neverSettled || abandonedNothing || thinEvidence;
192
216
  }
193
217
  function assessConfidence(input) {
194
218
  const minGrowth = input.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR;
@@ -197,6 +221,7 @@ function assessConfidence(input) {
197
221
  ...loadWarnings(input.loadOutcomes, input.abandonAfterMs),
198
222
  ...growthShapeWarnings(input.trend),
199
223
  ...noiseFloorWarnings(input.trend, minGrowth),
224
+ ...thinEvidenceWarnings(input.trend, minGrowth),
200
225
  ...heapCeilingWarnings(input)
201
226
  ];
202
227
  return {
@@ -6,7 +6,7 @@ import {
6
6
  effectiveVerdict,
7
7
  minGrowthFor,
8
8
  warrantsIssueDraft
9
- } from "./chunk-E5ZKAANQ.js";
9
+ } from "./chunk-FFCY6POI.js";
10
10
  import {
11
11
  assessPeakPressure,
12
12
  describePeakPressure
@@ -91992,6 +91992,11 @@ var FLAGS = [
91992
91992
  help: "Fast preset: 2000 requests x 4 cycles, 8s idle \u2014 same cycle count as the default, less traffic per cycle"
91993
91993
  },
91994
91994
  { flag: "--diff-all", value: "none", help: "Diff snapshots for stable routes too (slow)" },
91995
+ {
91996
+ flag: "--no-resolve",
91997
+ value: "none",
91998
+ help: "Do not re-measure inconclusive routes with more cycles (default: re-measure once)"
91999
+ },
91995
92000
  { flag: "--output", value: "string", argName: "<dir>", help: "Where to write runs (default <app-dir>/.next-leak)" },
91996
92001
  { flag: "--help", alias: "-h", value: "none", help: "Show this help" },
91997
92002
  { flag: "--version", alias: "-v", value: "none", help: "Print the version" }
@@ -92087,6 +92092,9 @@ function applyFlag(spec, value, options) {
92087
92092
  case "--diff-all":
92088
92093
  options.diffAll = true;
92089
92094
  return FLAG_OK;
92095
+ case "--no-resolve":
92096
+ options.noResolve = true;
92097
+ return FLAG_OK;
92090
92098
  case "--output":
92091
92099
  options.output = value;
92092
92100
  return FLAG_OK;
@@ -92137,6 +92145,7 @@ function parseCliArgs(argv) {
92137
92145
  idleSeconds: null,
92138
92146
  maxOldSpaceMb: null,
92139
92147
  quick: false,
92148
+ noResolve: false,
92140
92149
  diffAll: false,
92141
92150
  output: null
92142
92151
  };
@@ -92441,10 +92450,11 @@ function routeLines(route, parameters) {
92441
92450
  }
92442
92451
  const verdict = effectiveVerdict(route);
92443
92452
  const curve = route.samples.map(formatMb).join(" \u2192 ");
92453
+ const resolved = route.resolvedWithCycles === void 0 ? "" : ` (resolved at ${route.resolvedWithCycles} cycles)`;
92444
92454
  return [
92445
92455
  ` ${VERDICT_ICON[verdict]} ${route.route} ${verdict} (${formatGrowth(
92446
92456
  route.growthPer1000Requests
92447
- )}) heap ${curve}`,
92457
+ )}) heap ${curve}${resolved}`,
92448
92458
  ...confidenceLines(route),
92449
92459
  ...memorySourceLines(route, verdict),
92450
92460
  ...peakPressureLines(route, parameters),
@@ -92457,9 +92467,17 @@ function formatReport(report) {
92457
92467
  lines.push(...routeLines(route, report.parameters));
92458
92468
  }
92459
92469
  const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
92470
+ const resolvedCycles = [
92471
+ ...new Set(
92472
+ report.routes.flatMap(
92473
+ (route) => route.status === "measured" && route.resolvedWithCycles !== void 0 ? [route.resolvedWithCycles] : []
92474
+ )
92475
+ )
92476
+ ].sort((a, b) => a - b);
92477
+ const cyclesLabel = resolvedCycles.length === 0 ? `${cycles} cycles` : `${cycles} cycles (${resolvedCycles.join(", ")} where resolved)`;
92460
92478
  lines.push(
92461
92479
  "",
92462
- `judged over ${cycles} cycles \xD7 ${loadRequests} requests, growth gate ${(minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle (${formatGrowth(minGrowthPerCycle / loadRequests * 1e3)}), heap cap ${maxOldSpaceMb} MB`,
92480
+ `judged over ${cyclesLabel} \xD7 ${loadRequests} requests, growth gate ${(minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle (${formatGrowth(minGrowthPerCycle / loadRequests * 1e3)}), heap cap ${maxOldSpaceMb} MB`,
92463
92481
  `snapshots and run.json: ${report.workDir}`,
92464
92482
  `report: ${report.bundle.htmlReport}`,
92465
92483
  ...report.bundle.issues.map((issue) => `issue draft (${issue.route}): ${issue.file}`)
@@ -92501,6 +92519,11 @@ var routeConfigSchema = z2.object({
92501
92519
  * that path — vercel/next.js#89091 traces `ServerResponse` retention to
92502
92520
  * an early disconnect — and a load generator that always waits politely
92503
92521
  * never reaches it.
92522
+ *
92523
+ * Counted **from the first byte of the response**, not from the request:
92524
+ * under load a server's first byte arrives long after any sane fixed
92525
+ * window, so a request-relative clock cuts before the stream starts and
92526
+ * tests the wrong path entirely.
92504
92527
  */
92505
92528
  abandonAfterMs: z2.number().int().positive().optional()
92506
92529
  }).strict();
@@ -92764,6 +92787,9 @@ function diffAgainstBaseline(baseline, after, options = {}) {
92764
92787
  node.type,
92765
92788
  (afterTypeSelfSizes.get(node.type) ?? 0) + node.self_size
92766
92789
  );
92790
+ if (node.type === "synthetic") {
92791
+ return;
92792
+ }
92767
92793
  if (!baseline.nodeIds.has(node.id)) {
92768
92794
  if (node.retainedSize >= resolved.newThresholdBytes) {
92769
92795
  const chain = walkChain(node, resolved.chainDepth);
@@ -93260,6 +93286,7 @@ async function requestSnapshot(port, name) {
93260
93286
 
93261
93287
  // src/abandon-load.ts
93262
93288
  import net from "net";
93289
+ var FIRST_BYTE_BUDGET_MS = 5e3;
93263
93290
  async function runAbandonPhase(options) {
93264
93291
  const target = new URL(options.url);
93265
93292
  const port = Number(target.port || 80);
@@ -93274,6 +93301,7 @@ Host: ${target.host}\r
93274
93301
  sent: 0,
93275
93302
  abandoned: 0,
93276
93303
  abandonedMidStream: 0,
93304
+ abandonedBeforeResponse: 0,
93277
93305
  completed: 0,
93278
93306
  errors: 0
93279
93307
  };
@@ -93297,6 +93325,8 @@ Host: ${target.host}\r
93297
93325
  result.abandoned += 1;
93298
93326
  if (responseStarted) {
93299
93327
  result.abandonedMidStream += 1;
93328
+ } else {
93329
+ result.abandonedBeforeResponse += 1;
93300
93330
  }
93301
93331
  }
93302
93332
  finish();
@@ -93304,11 +93334,17 @@ Host: ${target.host}\r
93304
93334
  socket.once("connect", () => {
93305
93335
  result.sent += 1;
93306
93336
  socket.write(request2);
93307
- timer = setTimeout(giveUp, options.abandonAfterMs);
93337
+ timer = setTimeout(giveUp, FIRST_BYTE_BUDGET_MS);
93308
93338
  timer.unref();
93309
93339
  });
93310
93340
  socket.on("data", () => {
93341
+ if (responseStarted) {
93342
+ return;
93343
+ }
93311
93344
  responseStarted = true;
93345
+ clearTimeout(timer);
93346
+ timer = setTimeout(giveUp, options.abandonAfterMs);
93347
+ timer.unref();
93312
93348
  });
93313
93349
  socket.once("end", () => {
93314
93350
  if (!settled) {
@@ -93470,6 +93506,7 @@ async function runRitual(options, deps = defaultDeps) {
93470
93506
  sent: outcome.sent,
93471
93507
  abandoned: outcome.abandoned,
93472
93508
  abandonedMidStream: outcome.abandonedMidStream,
93509
+ abandonedBeforeResponse: outcome.abandonedBeforeResponse,
93473
93510
  ok2xx: outcome.completed,
93474
93511
  errors: outcome.errors
93475
93512
  });
@@ -93832,18 +93869,21 @@ function skipReason(route, requestPath) {
93832
93869
  }
93833
93870
  return null;
93834
93871
  }
93835
- async function measureRoute(context, route, requestPath, index) {
93872
+ async function measureRoute(context, route, requestPath, index, pass = void 0) {
93836
93873
  const { deps, options, target, workDir, routeConfig, registry, nextVersion, progress } = context;
93837
93874
  const result = await deps.ritual({
93838
93875
  serverPath: target.standaloneServer,
93839
93876
  route: requestPath,
93840
- workDir: path6.join(workDir, `${String(index + 1).padStart(2, "0")}-${routeSlug(route.path)}`),
93877
+ workDir: path6.join(
93878
+ workDir,
93879
+ `${String(index + 1).padStart(2, "0")}-${routeSlug(route.path)}${pass?.dirSuffix ?? ""}`
93880
+ ),
93841
93881
  bootstrapPath: options.bootstrapPath,
93842
93882
  appPort: await deps.freePort(),
93843
93883
  ...options.warmupRequests !== void 0 && { warmupRequests: options.warmupRequests },
93844
93884
  ...options.loadRequests !== void 0 && { loadRequests: options.loadRequests },
93845
93885
  ...options.connections !== void 0 && { connections: options.connections },
93846
- ...options.cycles !== void 0 && { cycles: options.cycles },
93886
+ ...pass !== void 0 ? { cycles: pass.cycles } : options.cycles !== void 0 && { cycles: options.cycles },
93847
93887
  ...options.idleMs !== void 0 && { idleMs: options.idleMs },
93848
93888
  ...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb },
93849
93889
  ...routeConfig.headers !== void 0 && { headers: routeConfig.headers },
@@ -93877,6 +93917,7 @@ async function measureRoute(context, route, requestPath, index) {
93877
93917
  route: route.path,
93878
93918
  status: "measured",
93879
93919
  requestPath,
93920
+ ...pass !== void 0 && { resolvedWithCycles: pass.cycles },
93880
93921
  samples: result.samples,
93881
93922
  memorySamples: result.memorySamples,
93882
93923
  peaks: result.peaks,
@@ -93895,7 +93936,7 @@ async function measureRoute(context, route, requestPath, index) {
93895
93936
  };
93896
93937
  }
93897
93938
  async function writeEvidenceBundle(report, workDir) {
93898
- const { renderHtmlReport } = await import("./html-report-KQGUDYK5.js");
93939
+ const { renderHtmlReport } = await import("./html-report-76DP5ADD.js");
93899
93940
  const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
93900
93941
  for (const route of report.routes) {
93901
93942
  if (route.status === "measured" && warrantsIssueDraft(route)) {
@@ -93906,6 +93947,7 @@ async function writeEvidenceBundle(report, workDir) {
93906
93947
  }
93907
93948
  await writeFile(report.bundle.htmlReport, renderHtmlReport(report));
93908
93949
  }
93950
+ var resolveCycles = (cycles) => Math.max(cycles * 2, 6);
93909
93951
  async function routeReportFor(context, route, index, total) {
93910
93952
  const { routeConfig, progress } = context;
93911
93953
  const label = `${route.path} (${index + 1}/${total})`;
@@ -93918,7 +93960,16 @@ async function routeReportFor(context, route, index, total) {
93918
93960
  try {
93919
93961
  const asPath = requestPath === route.path ? "" : ` as ${requestPath}`;
93920
93962
  progress(`measuring ${label}${asPath}`);
93921
- return await measureRoute(context, route, requestPath, index);
93963
+ const first = await measureRoute(context, route, requestPath, index);
93964
+ if (first.status !== "measured" || context.options.resolveInconclusive === false || effectiveVerdict(first) !== "inconclusive") {
93965
+ return first;
93966
+ }
93967
+ const cycles = resolveCycles(context.options.cycles ?? RITUAL_DEFAULTS.cycles);
93968
+ progress(`re-measuring ${route.path} with ${cycles} cycles: the first pass could not call it`);
93969
+ return await measureRoute(context, route, requestPath, index, {
93970
+ cycles,
93971
+ dirSuffix: "-resolve"
93972
+ });
93922
93973
  } catch (cause) {
93923
93974
  const failure = cause instanceof Error ? cause.message : String(cause);
93924
93975
  progress(`failed ${label}: ${failure}`);
@@ -93953,7 +94004,7 @@ async function planRun(options, target, deps, progress) {
93953
94004
  ).length;
93954
94005
  const estimate = estimateRun(measurable, parameters);
93955
94006
  progress(
93956
- `${routes.length} routes discovered` + (measurable === routes.length ? "" : ` \xB7 ${measurable} measurable`) + ` \xB7 estimated ${formatEstimate(estimate)}` + // Long default runs are where first-time users give up; point at the two
94007
+ `${routes.length} routes discovered` + (measurable === routes.length ? "" : ` \xB7 ${measurable} measurable`) + ` \xB7 estimated ${formatEstimate(estimate)}` + (options.resolveInconclusive === false ? "" : " (inconclusive routes are measured again)") + // Long default runs are where first-time users give up; point at the two
93957
94008
  // ways out. Suppressed once load parameters were tuned by hand (or by
93958
94009
  // --quick, which arrives here as explicit loadRequests/idleMs).
93959
94010
  (estimate.slowSeconds > 15 * 60 && options.loadRequests === void 0 && options.idleMs === void 0 ? " \u2014 use --quick for the fast validated preset, or narrow with --routes" : "")
@@ -7,6 +7,7 @@ export type CliRunOptions = {
7
7
  idleSeconds: number | null;
8
8
  maxOldSpaceMb: number | null;
9
9
  quick: boolean;
10
+ noResolve: boolean;
10
11
  diffAll: boolean;
11
12
  output: string | null;
12
13
  };
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-OJIXF7RP.js";
13
+ import "./chunk-FFCY6POI.js";
14
14
  import "./chunk-XHPUAMJG.js";
15
15
  import "./chunk-6XYFBOL2.js";
16
16
 
@@ -100,6 +100,7 @@ async function main() {
100
100
  ...options.idleSeconds !== null && { idleMs: options.idleSeconds * 1e3 },
101
101
  ...options.maxOldSpaceMb !== null && { maxOldSpaceMb: options.maxOldSpaceMb },
102
102
  ...options.diffAll && { diffAll: true },
103
+ ...options.noResolve && { resolveInconclusive: false },
103
104
  ...options.output !== null && { outputDir: options.output },
104
105
  onProgress: (message) => console.error(`\xB7 ${message}`)
105
106
  });
@@ -12,10 +12,11 @@ import { type TrendResult, type TrendVerdict } from "./trend.js";
12
12
  * mid-stream teardown path was never reached
13
13
  * `spiky-growth` — one cycle dominates, so the mean describes little
14
14
  * `near-threshold` — growth barely clears the noise floor
15
+ * `thin-evidence` — a leak called on too few cycles for its size
15
16
  * `near-heap-ceiling` — the heap approached the cap the process ran under,
16
17
  * so the curve was measured against a ceiling instead of running free
17
18
  */
18
- export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold" | "near-heap-ceiling";
19
+ export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold" | "thin-evidence" | "near-heap-ceiling";
19
20
  export type MeasurementWarning = {
20
21
  code: WarningCode;
21
22
  detail: string;
@@ -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-BTWQYJOW.js";
5
+ import "./chunk-FFCY6POI.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-OJIXF7RP.js";
43
43
  import {
44
44
  renderHtmlReport
45
- } from "./chunk-BUNQT6VK.js";
45
+ } from "./chunk-BTWQYJOW.js";
46
46
  import {
47
47
  classifyTrend
48
- } from "./chunk-E5ZKAANQ.js";
48
+ } from "./chunk-FFCY6POI.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/dist/runner.d.ts CHANGED
@@ -56,6 +56,11 @@ export type RouteReport = {
56
56
  /** Null when there is no diff or no module registry. */
57
57
  attribution: AttributedDiff | null;
58
58
  signatures: MatchedSignature[];
59
+ /**
60
+ * Cycles used by the second pass, when the first came back
61
+ * `inconclusive` and the run went back for more evidence.
62
+ */
63
+ resolvedWithCycles?: number;
59
64
  };
60
65
  export type MeasuredRoute = Extract<RouteReport, {
61
66
  status: "measured";
@@ -111,6 +116,11 @@ export type RunOptions = {
111
116
  diffAll?: boolean;
112
117
  /** Only measure routes matching these templates or prefixes. */
113
118
  routeFilter?: string[];
119
+ /**
120
+ * Measure a route again, with more cycles, when the first pass could not
121
+ * call it. Default true: the run should answer the question it was asked.
122
+ */
123
+ resolveInconclusive?: boolean;
114
124
  /** Abort between phases; remaining routes are reported as interrupted. */
115
125
  signal?: AbortSignal;
116
126
  onProgress?: (message: string) => void;
@@ -143,6 +153,11 @@ export type RunnerDeps = {
143
153
  };
144
154
  export declare function freePort(): Promise<number>;
145
155
  export declare function routeSlug(route: string): string;
156
+ /**
157
+ * Cycles a second pass uses — the same figure the report recommends for a
158
+ * manual re-run, so the tool follows its own advice.
159
+ */
160
+ export declare const resolveCycles: (cycles: number) => number;
146
161
  /**
147
162
  * Full measurement run: validate the target, discover routes, run the ritual
148
163
  * per route in a fresh process, diff snapshots for non-stable verdicts, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",