next-leak 0.4.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
@@ -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
@@ -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-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));
@@ -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-BDIPW6FU.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}`)
@@ -93851,18 +93869,21 @@ function skipReason(route, requestPath) {
93851
93869
  }
93852
93870
  return null;
93853
93871
  }
93854
- async function measureRoute(context, route, requestPath, index) {
93872
+ async function measureRoute(context, route, requestPath, index, pass = void 0) {
93855
93873
  const { deps, options, target, workDir, routeConfig, registry, nextVersion, progress } = context;
93856
93874
  const result = await deps.ritual({
93857
93875
  serverPath: target.standaloneServer,
93858
93876
  route: requestPath,
93859
- 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
+ ),
93860
93881
  bootstrapPath: options.bootstrapPath,
93861
93882
  appPort: await deps.freePort(),
93862
93883
  ...options.warmupRequests !== void 0 && { warmupRequests: options.warmupRequests },
93863
93884
  ...options.loadRequests !== void 0 && { loadRequests: options.loadRequests },
93864
93885
  ...options.connections !== void 0 && { connections: options.connections },
93865
- ...options.cycles !== void 0 && { cycles: options.cycles },
93886
+ ...pass !== void 0 ? { cycles: pass.cycles } : options.cycles !== void 0 && { cycles: options.cycles },
93866
93887
  ...options.idleMs !== void 0 && { idleMs: options.idleMs },
93867
93888
  ...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb },
93868
93889
  ...routeConfig.headers !== void 0 && { headers: routeConfig.headers },
@@ -93896,6 +93917,7 @@ async function measureRoute(context, route, requestPath, index) {
93896
93917
  route: route.path,
93897
93918
  status: "measured",
93898
93919
  requestPath,
93920
+ ...pass !== void 0 && { resolvedWithCycles: pass.cycles },
93899
93921
  samples: result.samples,
93900
93922
  memorySamples: result.memorySamples,
93901
93923
  peaks: result.peaks,
@@ -93914,7 +93936,7 @@ async function measureRoute(context, route, requestPath, index) {
93914
93936
  };
93915
93937
  }
93916
93938
  async function writeEvidenceBundle(report, workDir) {
93917
- const { renderHtmlReport } = await import("./html-report-VW7VQOCH.js");
93939
+ const { renderHtmlReport } = await import("./html-report-76DP5ADD.js");
93918
93940
  const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
93919
93941
  for (const route of report.routes) {
93920
93942
  if (route.status === "measured" && warrantsIssueDraft(route)) {
@@ -93925,6 +93947,7 @@ async function writeEvidenceBundle(report, workDir) {
93925
93947
  }
93926
93948
  await writeFile(report.bundle.htmlReport, renderHtmlReport(report));
93927
93949
  }
93950
+ var resolveCycles = (cycles) => Math.max(cycles * 2, 6);
93928
93951
  async function routeReportFor(context, route, index, total) {
93929
93952
  const { routeConfig, progress } = context;
93930
93953
  const label = `${route.path} (${index + 1}/${total})`;
@@ -93937,7 +93960,16 @@ async function routeReportFor(context, route, index, total) {
93937
93960
  try {
93938
93961
  const asPath = requestPath === route.path ? "" : ` as ${requestPath}`;
93939
93962
  progress(`measuring ${label}${asPath}`);
93940
- 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
+ });
93941
93973
  } catch (cause) {
93942
93974
  const failure = cause instanceof Error ? cause.message : String(cause);
93943
93975
  progress(`failed ${label}: ${failure}`);
@@ -93972,7 +94004,7 @@ async function planRun(options, target, deps, progress) {
93972
94004
  ).length;
93973
94005
  const estimate = estimateRun(measurable, parameters);
93974
94006
  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
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
93976
94008
  // ways out. Suppressed once load parameters were tuned by hand (or by
93977
94009
  // --quick, which arrives here as explicit loadRequests/idleMs).
93978
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-USNLL625.js";
13
- import "./chunk-BDIPW6FU.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-NYWCWKS6.js";
5
- import "./chunk-BDIPW6FU.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-USNLL625.js";
42
+ } from "./chunk-OJIXF7RP.js";
43
43
  import {
44
44
  renderHtmlReport
45
- } from "./chunk-NYWCWKS6.js";
45
+ } from "./chunk-BTWQYJOW.js";
46
46
  import {
47
47
  classifyTrend
48
- } from "./chunk-BDIPW6FU.js";
48
+ } from "./chunk-FFCY6POI.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;
@@ -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.4.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",