next-leak 0.4.0 → 0.4.2

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
@@ -104,6 +104,7 @@ Every report prints the gate it used.
104
104
  | `--idle <seconds>` | 30 | **Maximum** wait before each sample; the run continues as soon as the heap settles |
105
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 |
106
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 |
107
108
  | `--diff-all` | off | Diff snapshots for stable routes too |
108
109
  | `--output <dir>` | `<app>/.next-leak` | Where runs are written |
109
110
 
@@ -168,7 +169,7 @@ separates them, because each one has a different fix:
168
169
  flat but RSS keeps climbing, the report says so explicitly: that is an
169
170
  allocator, external-buffer or fragmentation problem, not a JS-heap leak.
170
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.
171
- - **`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.
172
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.
173
174
 
174
175
  ## Peak pressure: `stable` is not the same as safe
@@ -68,12 +68,14 @@ function classifyMemoryTrend(heapSamples, externalSamples, options = {}) {
68
68
  }
69
69
 
70
70
  // src/confidence.ts
71
+ var resolveCycles = (cycles) => Math.max(cycles * 2, 6);
71
72
  function effectiveVerdict(report) {
72
73
  return report.confidence.supersededVerdict ?? report.trend.verdict;
73
74
  }
74
75
  var VERDICT_WEAKENING = /* @__PURE__ */ new Set([
75
76
  "near-threshold",
76
- "spiky-growth"
77
+ "spiky-growth",
78
+ "thin-evidence"
77
79
  ]);
78
80
  function warrantsIssueDraft(report) {
79
81
  return effectiveVerdict(report) === "leak" && !report.confidence.warnings.some((warning) => VERDICT_WEAKENING.has(warning.code));
@@ -182,13 +184,36 @@ function heapCeilingWarnings(input) {
182
184
  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
185
  }];
184
186
  }
187
+ var THIN_EVIDENCE_MIN_DELTAS = 5;
188
+ var THIN_EVIDENCE_MIN_DELTA_RATIO = 4;
189
+ function isThinEvidence(trend, minGrowth) {
190
+ if (trend.deltas.length >= THIN_EVIDENCE_MIN_DELTAS || trend.deltas.length === 0) {
191
+ return false;
192
+ }
193
+ const smallest = Math.min(...trend.deltas);
194
+ return smallest < minGrowth * THIN_EVIDENCE_MIN_DELTA_RATIO;
195
+ }
196
+ function thinEvidenceWarnings(trend, minGrowth) {
197
+ if (trend.verdict !== "leak" || !isThinEvidence(trend, minGrowth)) {
198
+ return [];
199
+ }
200
+ const smallest = Math.min(...trend.deltas);
201
+ return [{
202
+ code: "thin-evidence",
203
+ 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`
204
+ }];
205
+ }
185
206
  function isVerdictInvalid(input) {
186
207
  if (input.trend.verdict !== "leak") {
187
208
  return false;
188
209
  }
189
210
  const neverSettled = input.settleOutcomes.length > 0 && input.settleOutcomes.every((outcome) => outcome.status === "moving");
190
211
  const abandonedNothing = input.abandonAfterMs !== void 0 && input.loadOutcomes.length > 0 && input.loadOutcomes.every((outcome) => (outcome.abandoned ?? 0) === 0);
191
- return neverSettled || abandonedNothing;
212
+ const thinEvidence = isThinEvidence(
213
+ input.trend,
214
+ input.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR
215
+ );
216
+ return neverSettled || abandonedNothing || thinEvidence;
192
217
  }
193
218
  function assessConfidence(input) {
194
219
  const minGrowth = input.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR;
@@ -197,6 +222,7 @@ function assessConfidence(input) {
197
222
  ...loadWarnings(input.loadOutcomes, input.abandonAfterMs),
198
223
  ...growthShapeWarnings(input.trend),
199
224
  ...noiseFloorWarnings(input.trend, minGrowth),
225
+ ...thinEvidenceWarnings(input.trend, minGrowth),
200
226
  ...heapCeilingWarnings(input)
201
227
  ];
202
228
  return {
@@ -210,6 +236,7 @@ export {
210
236
  minGrowthFor,
211
237
  classifyTrend,
212
238
  classifyMemoryTrend,
239
+ resolveCycles,
213
240
  effectiveVerdict,
214
241
  warrantsIssueDraft,
215
242
  assessConfidence
@@ -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-BDIPW6FU.js";
4
+ } from "./chunk-2FZXZLZW.js";
5
5
  import {
6
6
  assessPeakPressure,
7
7
  describePeakPressure
@@ -5,8 +5,9 @@ import {
5
5
  classifyTrend,
6
6
  effectiveVerdict,
7
7
  minGrowthFor,
8
+ resolveCycles,
8
9
  warrantsIssueDraft
9
- } from "./chunk-BDIPW6FU.js";
10
+ } from "./chunk-2FZXZLZW.js";
10
11
  import {
11
12
  assessPeakPressure,
12
13
  describePeakPressure
@@ -91992,6 +91993,11 @@ var FLAGS = [
91992
91993
  help: "Fast preset: 2000 requests x 4 cycles, 8s idle \u2014 same cycle count as the default, less traffic per cycle"
91993
91994
  },
91994
91995
  { flag: "--diff-all", value: "none", help: "Diff snapshots for stable routes too (slow)" },
91996
+ {
91997
+ flag: "--no-resolve",
91998
+ value: "none",
91999
+ help: "Do not re-measure inconclusive routes with more cycles (default: re-measure once)"
92000
+ },
91995
92001
  { flag: "--output", value: "string", argName: "<dir>", help: "Where to write runs (default <app-dir>/.next-leak)" },
91996
92002
  { flag: "--help", alias: "-h", value: "none", help: "Show this help" },
91997
92003
  { flag: "--version", alias: "-v", value: "none", help: "Print the version" }
@@ -92087,6 +92093,9 @@ function applyFlag(spec, value, options) {
92087
92093
  case "--diff-all":
92088
92094
  options.diffAll = true;
92089
92095
  return FLAG_OK;
92096
+ case "--no-resolve":
92097
+ options.noResolve = true;
92098
+ return FLAG_OK;
92090
92099
  case "--output":
92091
92100
  options.output = value;
92092
92101
  return FLAG_OK;
@@ -92137,6 +92146,7 @@ function parseCliArgs(argv) {
92137
92146
  idleSeconds: null,
92138
92147
  maxOldSpaceMb: null,
92139
92148
  quick: false,
92149
+ noResolve: false,
92140
92150
  diffAll: false,
92141
92151
  output: null
92142
92152
  };
@@ -92441,10 +92451,11 @@ function routeLines(route, parameters) {
92441
92451
  }
92442
92452
  const verdict = effectiveVerdict(route);
92443
92453
  const curve = route.samples.map(formatMb).join(" \u2192 ");
92454
+ const resolved = route.resolvedWithCycles === void 0 ? "" : ` (resolved at ${route.resolvedWithCycles} cycles)`;
92444
92455
  return [
92445
92456
  ` ${VERDICT_ICON[verdict]} ${route.route} ${verdict} (${formatGrowth(
92446
92457
  route.growthPer1000Requests
92447
- )}) heap ${curve}`,
92458
+ )}) heap ${curve}${resolved}`,
92448
92459
  ...confidenceLines(route),
92449
92460
  ...memorySourceLines(route, verdict),
92450
92461
  ...peakPressureLines(route, parameters),
@@ -92457,9 +92468,17 @@ function formatReport(report) {
92457
92468
  lines.push(...routeLines(route, report.parameters));
92458
92469
  }
92459
92470
  const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
92471
+ const resolvedCycles = [
92472
+ ...new Set(
92473
+ report.routes.flatMap(
92474
+ (route) => route.status === "measured" && route.resolvedWithCycles !== void 0 ? [route.resolvedWithCycles] : []
92475
+ )
92476
+ )
92477
+ ].sort((a, b) => a - b);
92478
+ const cyclesLabel = resolvedCycles.length === 0 ? `${cycles} cycles` : `${cycles} cycles (${resolvedCycles.join(", ")} where resolved)`;
92460
92479
  lines.push(
92461
92480
  "",
92462
- `judged over ${cycles} cycles \xD7 ${loadRequests} requests, growth gate ${(minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle (${formatGrowth(minGrowthPerCycle / loadRequests * 1e3)}), heap cap ${maxOldSpaceMb} MB`,
92481
+ `judged over ${cyclesLabel} \xD7 ${loadRequests} requests, growth gate ${(minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle (${formatGrowth(minGrowthPerCycle / loadRequests * 1e3)}), heap cap ${maxOldSpaceMb} MB`,
92463
92482
  `snapshots and run.json: ${report.workDir}`,
92464
92483
  `report: ${report.bundle.htmlReport}`,
92465
92484
  ...report.bundle.issues.map((issue) => `issue draft (${issue.route}): ${issue.file}`)
@@ -92469,7 +92488,7 @@ function formatReport(report) {
92469
92488
  );
92470
92489
  if (inconclusive.length > 0) {
92471
92490
  const routeList = inconclusive.map((route) => route.route).join(",");
92472
- const moreCycles = Math.max(report.parameters.cycles * 2, 6);
92491
+ const moreCycles = resolveCycles(report.parameters.cycles);
92473
92492
  lines.push(
92474
92493
  "",
92475
92494
  "hint: inconclusive means sustained sub-threshold growth \u2014 measure longer to resolve it:",
@@ -93236,7 +93255,14 @@ var ControlError = class extends Error {
93236
93255
  }
93237
93256
  };
93238
93257
  async function request(port, pathname) {
93239
- const response = await fetch(`http://127.0.0.1:${port}${pathname}`);
93258
+ let response;
93259
+ try {
93260
+ response = await fetch(`http://127.0.0.1:${port}${pathname}`);
93261
+ } catch (cause) {
93262
+ throw new ControlError(
93263
+ `control channel ${pathname} on port ${port} did not answer (${cause instanceof Error ? cause.message : String(cause)}) \u2014 the measured process is gone or its event loop is blocked`
93264
+ );
93265
+ }
93240
93266
  if (!response.ok) {
93241
93267
  throw new ControlError(`control channel ${pathname} responded ${response.status}`);
93242
93268
  }
@@ -93851,18 +93877,21 @@ function skipReason(route, requestPath) {
93851
93877
  }
93852
93878
  return null;
93853
93879
  }
93854
- async function measureRoute(context, route, requestPath, index) {
93880
+ async function measureRoute(context, route, requestPath, index, pass = void 0) {
93855
93881
  const { deps, options, target, workDir, routeConfig, registry, nextVersion, progress } = context;
93856
93882
  const result = await deps.ritual({
93857
93883
  serverPath: target.standaloneServer,
93858
93884
  route: requestPath,
93859
- workDir: path6.join(workDir, `${String(index + 1).padStart(2, "0")}-${routeSlug(route.path)}`),
93885
+ workDir: path6.join(
93886
+ workDir,
93887
+ `${String(index + 1).padStart(2, "0")}-${routeSlug(route.path)}${pass?.dirSuffix ?? ""}`
93888
+ ),
93860
93889
  bootstrapPath: options.bootstrapPath,
93861
93890
  appPort: await deps.freePort(),
93862
93891
  ...options.warmupRequests !== void 0 && { warmupRequests: options.warmupRequests },
93863
93892
  ...options.loadRequests !== void 0 && { loadRequests: options.loadRequests },
93864
93893
  ...options.connections !== void 0 && { connections: options.connections },
93865
- ...options.cycles !== void 0 && { cycles: options.cycles },
93894
+ ...pass !== void 0 ? { cycles: pass.cycles } : options.cycles !== void 0 && { cycles: options.cycles },
93866
93895
  ...options.idleMs !== void 0 && { idleMs: options.idleMs },
93867
93896
  ...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb },
93868
93897
  ...routeConfig.headers !== void 0 && { headers: routeConfig.headers },
@@ -93896,6 +93925,7 @@ async function measureRoute(context, route, requestPath, index) {
93896
93925
  route: route.path,
93897
93926
  status: "measured",
93898
93927
  requestPath,
93928
+ ...pass !== void 0 && { resolvedWithCycles: pass.cycles },
93899
93929
  samples: result.samples,
93900
93930
  memorySamples: result.memorySamples,
93901
93931
  peaks: result.peaks,
@@ -93914,7 +93944,7 @@ async function measureRoute(context, route, requestPath, index) {
93914
93944
  };
93915
93945
  }
93916
93946
  async function writeEvidenceBundle(report, workDir) {
93917
- const { renderHtmlReport } = await import("./html-report-VW7VQOCH.js");
93947
+ const { renderHtmlReport } = await import("./html-report-X2LUOZRH.js");
93918
93948
  const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
93919
93949
  for (const route of report.routes) {
93920
93950
  if (route.status === "measured" && warrantsIssueDraft(route)) {
@@ -93937,7 +93967,26 @@ async function routeReportFor(context, route, index, total) {
93937
93967
  try {
93938
93968
  const asPath = requestPath === route.path ? "" : ` as ${requestPath}`;
93939
93969
  progress(`measuring ${label}${asPath}`);
93940
- return await measureRoute(context, route, requestPath, index);
93970
+ const first = await measureRoute(context, route, requestPath, index);
93971
+ if (first.status !== "measured" || context.options.resolveInconclusive === false || effectiveVerdict(first) !== "inconclusive") {
93972
+ return first;
93973
+ }
93974
+ if (context.options.signal?.aborted === true) {
93975
+ return first;
93976
+ }
93977
+ const cycles = resolveCycles(context.options.cycles ?? RITUAL_DEFAULTS.cycles);
93978
+ progress(`re-measuring ${route.path} with ${cycles} cycles: the first pass could not call it`);
93979
+ try {
93980
+ return await measureRoute(context, route, requestPath, index, {
93981
+ cycles,
93982
+ dirSuffix: "-resolve"
93983
+ });
93984
+ } catch (cause) {
93985
+ progress(
93986
+ `re-measurement of ${route.path} failed (${cause instanceof Error ? cause.message : String(cause)}) \u2014 keeping the first pass`
93987
+ );
93988
+ return first;
93989
+ }
93941
93990
  } catch (cause) {
93942
93991
  const failure = cause instanceof Error ? cause.message : String(cause);
93943
93992
  progress(`failed ${label}: ${failure}`);
@@ -93972,7 +94021,7 @@ async function planRun(options, target, deps, progress) {
93972
94021
  ).length;
93973
94022
  const estimate = estimateRun(measurable, parameters);
93974
94023
  progress(
93975
- `${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
94024
+ `${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
93976
94025
  // ways out. Suppressed once load parameters were tuned by hand (or by
93977
94026
  // --quick, which arrives here as explicit loadRequests/idleMs).
93978
94027
  (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-USNLL625.js";
13
- import "./chunk-BDIPW6FU.js";
12
+ } from "./chunk-YDMJVVWG.js";
13
+ import "./chunk-2FZXZLZW.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;
@@ -44,6 +45,12 @@ export type ConfidenceInput = {
44
45
  /** Old-space cap the measured process ran under (MB). */
45
46
  maxOldSpaceMb?: number;
46
47
  };
48
+ /**
49
+ * Cycles a re-measurement uses when a verdict came back `inconclusive` — the
50
+ * same figure the report's manual re-run hint prints. One definition, imported
51
+ * by both, so the tool can never recommend one number and use another.
52
+ */
53
+ export declare const resolveCycles: (cycles: number) => number;
47
54
  /**
48
55
  * The verdict a route's evidence actually supports.
49
56
  *
@@ -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-NYWCWKS6.js";
5
- import "./chunk-BDIPW6FU.js";
4
+ } from "./chunk-NNDGXIGQ.js";
5
+ import "./chunk-2FZXZLZW.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-USNLL625.js";
42
+ } from "./chunk-YDMJVVWG.js";
43
43
  import {
44
44
  renderHtmlReport
45
- } from "./chunk-NYWCWKS6.js";
45
+ } from "./chunk-NNDGXIGQ.js";
46
46
  import {
47
47
  classifyTrend
48
- } from "./chunk-BDIPW6FU.js";
48
+ } from "./chunk-2FZXZLZW.js";
49
49
  import {
50
50
  renderIssueMarkdown
51
51
  } from "./chunk-WXAFXVWS.js";
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
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",