next-leak 0.1.3 → 0.3.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.
@@ -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(", ");
@@ -60,7 +79,7 @@ OS: ${environment.platform} ${environment.arch}${environment.cpuModel ? ` (${env
60
79
  Memory: ${(environment.totalMemoryBytes / (1024 * MB)).toFixed(0)} GB
61
80
  Next.js: ${environment.nextVersion ?? "unknown"}
62
81
  next-leak: ${environment.nextLeakVersion}
63
- Deployment: output "standalone", node --expose-gc --max-old-space-size=512
82
+ Deployment: output "standalone", node --expose-gc --max-old-space-size=${parameters.maxOldSpaceMb}
64
83
  \`\`\`
65
84
 
66
85
  ### To Reproduce
@@ -72,6 +91,8 @@ Deployment: output "standalone", node --expose-gc --max-old-space-size=512
72
91
  2. next-leak boots \`.next/standalone/server.js\` in a fresh process per route and runs, against \`${route.requestPath}\`:
73
92
  warm-up ${parameters.warmupRequests} requests \u2192 forced GC \u2192 baseline heap snapshot \u2192
74
93
  ${parameters.cycles} \xD7 [${parameters.loadRequests} requests at ${parameters.connections} connections \u2192 ${(parameters.idleMs / 1e3).toFixed(0)}s idle \u2192 forced GC \u2192 post-GC sample] \u2192 final snapshot.
94
+ 3. A cycle counts as growing when it retains at least ${(parameters.minGrowthPerCycle / 1024).toFixed(0)} KiB
95
+ (the gate for this run's ${parameters.loadRequests} requests per cycle); the warm-up cycle is excluded.
75
96
 
76
97
  ### Current vs. Expected behavior
77
98
 
@@ -87,7 +108,7 @@ ${evidenceRows(route) || "- (no findings above thresholds)"}
87
108
  ${signatures === "" ? "" : `
88
109
  **Matched known causes:**
89
110
  ${signatures}
90
- `}${caveats}
111
+ `}${peakSection}${caveats}
91
112
  ### Verify it yourself
92
113
 
93
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
+ };
@@ -5,6 +5,7 @@ export type CliRunOptions = {
5
5
  requests: number | null;
6
6
  connections: number | null;
7
7
  idleSeconds: number | null;
8
+ maxOldSpaceMb: number | null;
8
9
  quick: boolean;
9
10
  diffAll: boolean;
10
11
  output: string | null;
package/dist/cli.js CHANGED
@@ -9,8 +9,9 @@ import {
9
9
  killActiveChildren,
10
10
  parseCliArgs,
11
11
  runMeasurement
12
- } from "./chunk-ZFYIR423.js";
13
- import "./chunk-2BQZCZ4Z.js";
12
+ } from "./chunk-2BQ5KXIJ.js";
13
+ import "./chunk-E5ZKAANQ.js";
14
+ import "./chunk-XHPUAMJG.js";
14
15
  import "./chunk-6XYFBOL2.js";
15
16
 
16
17
  // src/cli.ts
@@ -97,6 +98,7 @@ async function main() {
97
98
  ...options.requests !== null && { loadRequests: options.requests },
98
99
  ...options.connections !== null && { connections: options.connections },
99
100
  ...options.idleSeconds !== null && { idleMs: options.idleSeconds * 1e3 },
101
+ ...options.maxOldSpaceMb !== null && { maxOldSpaceMb: options.maxOldSpaceMb },
100
102
  ...options.diffAll && { diffAll: true },
101
103
  ...options.output !== null && { outputDir: options.output },
102
104
  onProgress: (message) => console.error(`\xB7 ${message}`)
@@ -1,5 +1,6 @@
1
+ import type { HeapSample } from "./control-server.js";
1
2
  import type { LoadOutcome, SettleOutcome } from "./ritual.js";
2
- import type { TrendResult, TrendVerdict } from "./trend.js";
3
+ import { type TrendResult, type TrendVerdict } from "./trend.js";
3
4
  /**
4
5
  * Why a measurement may not support its own verdict.
5
6
  *
@@ -11,8 +12,10 @@ import type { TrendResult, TrendVerdict } from "./trend.js";
11
12
  * mid-stream teardown path was never reached
12
13
  * `spiky-growth` — one cycle dominates, so the mean describes little
13
14
  * `near-threshold` — growth barely clears the noise floor
15
+ * `near-heap-ceiling` — the heap approached the cap the process ran under,
16
+ * so the curve was measured against a ceiling instead of running free
14
17
  */
15
- export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold";
18
+ export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold" | "near-heap-ceiling";
16
19
  export type MeasurementWarning = {
17
20
  code: WarningCode;
18
21
  detail: string;
@@ -36,6 +39,10 @@ export type ConfidenceInput = {
36
39
  abandonAfterMs?: number;
37
40
  /** Threshold the verdict used, for the noise-floor check. */
38
41
  minGrowthPerCycle?: number;
42
+ /** Post-GC samples, for the heap-ceiling check. */
43
+ memorySamples?: readonly HeapSample[];
44
+ /** Old-space cap the measured process ran under (MB). */
45
+ maxOldSpaceMb?: number;
39
46
  };
40
47
  /**
41
48
  * The verdict a route's evidence actually supports.
@@ -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-C3KH5Z2W.js";
5
- import "./chunk-2BQZCZ4Z.js";
4
+ } from "./chunk-BUNQT6VK.js";
5
+ import "./chunk-E5ZKAANQ.js";
6
+ import "./chunk-XHPUAMJG.js";
6
7
  import "./chunk-6XYFBOL2.js";
7
8
  export {
8
9
  renderHtmlReport
package/dist/index.js CHANGED
@@ -13,7 +13,6 @@ import {
13
13
  captureEnvironment,
14
14
  checkRuntime,
15
15
  classifySource,
16
- classifyTrend,
17
16
  decodeMappings,
18
17
  decodeVlqLine,
19
18
  diffAgainstBaseline,
@@ -40,14 +39,17 @@ import {
40
39
  sourceIndexAt,
41
40
  summarizeBaseline,
42
41
  validateTarget
43
- } from "./chunk-ZFYIR423.js";
42
+ } from "./chunk-2BQ5KXIJ.js";
44
43
  import {
45
44
  renderHtmlReport
46
- } from "./chunk-C3KH5Z2W.js";
47
- import "./chunk-2BQZCZ4Z.js";
45
+ } from "./chunk-BUNQT6VK.js";
46
+ import {
47
+ classifyTrend
48
+ } from "./chunk-E5ZKAANQ.js";
48
49
  import {
49
50
  renderIssueMarkdown
50
- } from "./chunk-5ZYZW2BL.js";
51
+ } from "./chunk-WXAFXVWS.js";
52
+ import "./chunk-XHPUAMJG.js";
51
53
  import "./chunk-6XYFBOL2.js";
52
54
  export {
53
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-5ZYZW2BL.js";
4
+ } from "./chunk-WXAFXVWS.js";
5
+ import "./chunk-XHPUAMJG.js";
5
6
  import "./chunk-6XYFBOL2.js";
6
7
  export {
7
8
  renderIssueMarkdown
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Old-space cap for measured processes, when the caller does not pick one.
3
+ *
4
+ * Small on purpose: a leak that would take a production container an hour to
5
+ * kill reaches a 512 MB ceiling in minutes. It is a default, not a constant —
6
+ * an app whose legitimate working set is larger cannot be measured under it,
7
+ * so `--max-old-space` exists and the chosen value is recorded in `run.json`.
8
+ */
9
+ export declare const DEFAULT_MAX_OLD_SPACE_MB = 512;
1
10
  export type LaunchOptions = {
2
11
  /** Absolute path to the standalone `server.js` (or any PORT/HOSTNAME-honoring server). */
3
12
  serverPath: string;
@@ -16,6 +25,13 @@ export type LaunchedApp = {
16
25
  pid: number;
17
26
  appPort: number;
18
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;
19
35
  /** SIGTERM, then SIGKILL after a grace period. Resolves when the child exited. */
20
36
  close: () => Promise<void>;
21
37
  };
@@ -31,6 +47,13 @@ export declare function killActiveChildren(): void;
31
47
  * the messenger, and should say so instead of printing 20 lines of trace.
32
48
  */
33
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;
34
57
  /**
35
58
  * Spawns the measured server in a fresh child process with GC exposed and the
36
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
@@ -17,6 +17,8 @@ export type RitualOptions = {
17
17
  connections?: number;
18
18
  cycles?: number;
19
19
  idleMs?: number;
20
+ /** Old-space cap for the measured process (MB). Default: 512. */
21
+ maxOldSpaceMb?: number;
20
22
  /** Headers sent with every request during warm-up and load. */
21
23
  headers?: Record<string, string>;
22
24
  /** Emulate clients that disconnect before the response arrives. */
@@ -52,6 +54,24 @@ export type SettleOutcome = {
52
54
  /** GC polls taken before converging or giving up. */
53
55
  polls: number;
54
56
  };
57
+ /**
58
+ * Highest memory observed *during* a load cycle, per class.
59
+ *
60
+ * Every other number in a run is taken after idle and a forced GC, which is
61
+ * what a verdict about retention needs. It is also blind to the process that
62
+ * climbs to 3.5 GB under load and hands it all back: `stable`, and dead in a
63
+ * 1 GB container. A peak is a lower bound — a spike shorter than the poll
64
+ * interval is never seen.
65
+ */
66
+ export type PeakSample = {
67
+ phase: string;
68
+ heapUsed: number;
69
+ external: number;
70
+ arrayBuffers: number;
71
+ rss: number;
72
+ /** Readings taken; 0 means the poller never got one. */
73
+ polls: number;
74
+ };
55
75
  export type RitualResult = {
56
76
  route: string;
57
77
  /** Wall-clock per phase, so slow runs can be explained instead of guessed. */
@@ -64,24 +84,46 @@ export type RitualResult = {
64
84
  samples: number[];
65
85
  /** Full memory samples in the same order. */
66
86
  memorySamples: HeapSample[];
87
+ /** Highest memory seen during each load cycle, sampled without collecting. */
88
+ peaks: PeakSample[];
67
89
  baselineSnapshot: string;
68
90
  afterSnapshot: string;
69
91
  trend: TrendResult;
70
92
  requestsPerCycle: number;
93
+ /**
94
+ * The gate (bytes per cycle) this verdict was judged against. Travels with
95
+ * the result so the confidence audit grades against the same number the
96
+ * verdict used, and so the report can print what it measured against.
97
+ */
98
+ minGrowthPerCycle: number;
71
99
  };
72
100
  /** Injectable seams for unit tests; production uses the real implementations. */
73
101
  export type RitualDeps = {
74
102
  launch: typeof launchInstrumented;
75
103
  load: typeof runLoadPhase;
76
104
  sleep: (ms: number) => Promise<void>;
105
+ /** GC-free read, polled while the app is under load. */
106
+ readMemory: (port: number) => Promise<HeapSample>;
77
107
  };
78
- /** Single source of truth for ritual defaults — reports must echo them. */
108
+ /**
109
+ * Single source of truth for ritual defaults — reports must echo them.
110
+ *
111
+ * `cycles` is 4 rather than the minimum 3 because of how much the verdict
112
+ * actually sees: the sample array is `[baseline, ...cycles]` and
113
+ * `classifyTrend` drops the warm-up delta, leaving `cycles - 1` numbers. At 3
114
+ * cycles that is **two** deltas, and `anyFlatOrDown` needs only one of the two
115
+ * to be non-positive to call a route stable — one noisy cycle flips the
116
+ * verdict. Four cycles give three deltas, which is what the real-app
117
+ * validation ran with. The 3-cycle minimum stays available for users who want
118
+ * a faster, weaker read.
119
+ */
79
120
  export declare const RITUAL_DEFAULTS: {
80
121
  readonly warmupRequests: 200;
81
122
  readonly loadRequests: 5000;
82
123
  readonly connections: 100;
83
- readonly cycles: 3;
124
+ readonly cycles: 4;
84
125
  readonly idleMs: 30000;
126
+ readonly maxOldSpaceMb: 512;
85
127
  };
86
128
  /**
87
129
  * Runs the validated phase-0 ritual against one route in a fresh process:
package/dist/runner.d.ts CHANGED
@@ -4,9 +4,9 @@ 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
- import type { TrendResult } from "./trend.js";
9
+ import { type TrendResult } from "./trend.js";
10
10
  export type RouteReport = {
11
11
  route: string;
12
12
  status: "skipped";
@@ -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. */
@@ -61,6 +66,18 @@ export type RunParameters = {
61
66
  connections: number;
62
67
  cycles: number;
63
68
  idleMs: number;
69
+ /**
70
+ * Old-space cap of each measured process (MB). Part of the measurement
71
+ * regime: a run near its ceiling is not the same experiment as one with
72
+ * headroom, so it belongs on the record.
73
+ */
74
+ maxOldSpaceMb: number;
75
+ /**
76
+ * Per-cycle growth gate the verdicts were judged against (bytes). Derived
77
+ * from `loadRequests`; recorded because a verdict whose threshold is not
78
+ * printed cannot be audited or reproduced.
79
+ */
80
+ minGrowthPerCycle: number;
64
81
  };
65
82
  export type RunReport = {
66
83
  appDir: string;
@@ -88,6 +105,8 @@ export type RunOptions = {
88
105
  connections?: number;
89
106
  cycles?: number;
90
107
  idleMs?: number;
108
+ /** Old-space cap for each measured process (MB). Default 512. */
109
+ maxOldSpaceMb?: number;
91
110
  /** Also diff routes with a stable verdict. Default false: diffs are slow. */
92
111
  diffAll?: boolean;
93
112
  /** Only measure routes matching these templates or prefixes. */
package/dist/trend.d.ts CHANGED
@@ -16,9 +16,44 @@ export type TrendResult = {
16
16
  source?: "heap" | "external";
17
17
  };
18
18
  export type TrendOptions = {
19
- /** Minimum per-cycle growth (bytes) considered leak-like. Default: 256 KiB. */
19
+ /**
20
+ * Minimum per-cycle growth (bytes) considered leak-like. Defaults to the
21
+ * noise floor alone — callers that know how much traffic ran should pass
22
+ * `minGrowthFor(requestsPerCycle)` instead.
23
+ */
20
24
  minGrowthPerCycle?: number;
21
25
  };
26
+ /**
27
+ * Smallest per-cycle growth distinguishable from measurement noise.
28
+ *
29
+ * This is a property of the *instrument*, not of the leak: post-GC samples
30
+ * jitter by roughly this much regardless of how much traffic ran, so no amount
31
+ * of load makes growth below it meaningful.
32
+ */
33
+ export declare const MIN_GROWTH_NOISE_FLOOR: number;
34
+ /**
35
+ * Growth rate that counts as leak-like, per 1000 requests.
36
+ *
37
+ * This is a property of the *leak*: a route that retains memory per request
38
+ * grows in proportion to the traffic it served, so the gate has to scale with
39
+ * it. 51.2 KiB is the rate that leaves the default profile (5000 requests per
40
+ * cycle) on exactly the 256 KiB gate this tool was validated against.
41
+ */
42
+ export declare const MIN_GROWTH_PER_1000_REQUESTS: number;
43
+ /**
44
+ * The gate a per-cycle delta must clear to count as growth.
45
+ *
46
+ * `max` rather than a sum, because the two terms bound different things: the
47
+ * floor is what the instrument can resolve, the rate is what the leak should
48
+ * produce. Below ~5000 requests per cycle the floor dominates and the run is
49
+ * noise-limited — which is a real limit of measuring less traffic, not a
50
+ * threshold that can be lowered.
51
+ *
52
+ * Without this, the verdict silently depended on `--requests`: the same route
53
+ * leaking 100 KiB per 1000 requests printed the same headline in every mode
54
+ * but came out `stable` at 2000 requests per cycle and `leak` at 5000.
55
+ */
56
+ export declare function minGrowthFor(requestsPerCycle: number): number;
22
57
  /**
23
58
  * Classifies a series of post-GC retained-heap samples — baseline first, then
24
59
  * one sample per load cycle — as leaking or stable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.1.3",
3
+ "version": "0.3.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",
@@ -27,14 +27,15 @@
27
27
  "main": "./dist/index.js",
28
28
  "types": "./dist/index.d.ts",
29
29
  "files": [
30
- "dist"
30
+ "dist",
31
+ "THIRD-PARTY-NOTICES.md"
31
32
  ],
32
33
  "engines": {
33
34
  "node": ">=22"
34
35
  },
35
36
  "packageManager": "pnpm@10.20.0",
36
37
  "scripts": {
37
- "build": "tsup && tsc -p tsconfig.build.json",
38
+ "build": "tsup && tsc -p tsconfig.build.json && node scripts/generate-notices.mjs && node scripts/check-bundle.mjs",
38
39
  "dev": "tsup --watch",
39
40
  "test": "vitest run",
40
41
  "test:watch": "vitest",