next-leak 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,19 +23,33 @@ $ npx next-leak . --quick
23
23
  ```
24
24
 
25
25
  That first line is a real run against the reproduction for
26
- [vercel/next.js#95094](https://github.com/vercel/next.js/issues/95094), an open
27
- Next.js issue: the sandbox's `TimeoutsManager` never releases timeout ids from
28
- middleware. next-leak found the growth, the retaining object and the chain that
29
- holds it — without being told what to look for.
30
-
31
- **Verified against real, open Next.js issues**, not synthetic fixtures:
32
-
33
- | Issue | What it is | Result |
34
- |---|---|---|
35
- | [#95094](https://github.com/vercel/next.js/issues/95094) | Middleware `setTimeout` ids retained by the sandbox | **Reproduced** · mechanism named · 112 MB retained |
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
- | [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware | **Reproduced** · 32.8369.9 MB |
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) |
26
+ [vercel/next.js#95094](https://github.com/vercel/next.js/issues/95094): the
27
+ sandbox's `TimeoutsManager` never released timeout ids from middleware.
28
+ next-leak found the growth, the retaining object and the chain that holds it —
29
+ without being told what to look for. Next.js 16.3.0 has since fixed it.
30
+
31
+ **Verified against real Next.js issues**, not synthetic fixtures. Issue states
32
+ checked 2026-08-18:
33
+
34
+ | Issue | What it is | Measured | State today |
35
+ |---|---|---|---|
36
+ | [#96533](https://github.com/vercel/next.js/issues/96533) | ISR revalidation holds RSC buffers between collections | 4–5 MB of `arrayBuffers` held vs 0.32 MB retained | **open** |
37
+ | [#97464](https://github.com/vercel/next.js/issues/97464) | Static-gen worker retains per prerendered page | worker rss 1.072.96 GB, then OOM | **open** |
38
+ | [#92287](https://github.com/vercel/next.js/issues/92287) | Cache Components: unbounded `arrayBuffers` under load | 37.5 MB of arrayBuffers held between collections, 37x what it retains (16.3.1) | **open** |
39
+ | [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware | 32.8 → 369.9 MB | **open** |
40
+ | [#89091](https://github.com/vercel/next.js/issues/89091) | zlib retention on mid-stream aborts | +42.5 MB/1000 aborted req on 16.1.5; **+0.03 on 16.3.1** | open, no longer reproduces |
41
+ | [#95094](https://github.com/vercel/next.js/issues/95094) | Middleware `setTimeout` ids retained by the sandbox | 112 MB retained; flat after the fix | fixed in 16.3.0 |
42
+ | [#94890](https://github.com/vercel/next.js/issues/94890) | Router LRU cache doesn't count its keys | 26.7 → 71.9 MB | fixed in 16.3.0 |
43
+ | [#94919](https://github.com/vercel/next.js/issues/94919) | Retention on client aborts | 39 → 139 MB · [with a caveat](#scope-and-limits-read-before-filing-issues) | fixed in 16.3.0 |
44
+
45
+ The three fixed ones are kept deliberately: a tool that only lists open bugs
46
+ looks impressive until the bugs close, and what those rows show is that the
47
+ measurements matched what the fixes turned out to be. #94919 is the sharpest —
48
+ the PR that closed it discarded the RSC-WeakMap hypothesis in the title and
49
+ attributed the leak to native zlib retention instead, which is the same
50
+ mechanism the #89091 measurement had already isolated — and re-measuring #89091
51
+ on 16.3.1 (2026-08-18) shows it gone, from +42.5 MB per 1000 aborted requests
52
+ down to +0.03, which is the same fix arriving from the other direction.
39
53
 
40
54
  The full causal chain, measured on that same issue: leak found (28.7 -> 138.9 MB
41
55
  across 8 cycles), the workaround from the thread applied (`clearTimeout(id)`
@@ -102,11 +116,26 @@ Every report prints the gate it used.
102
116
  | `--requests <n>` | 5000 | Requests per cycle. Raises sensitivity as well as duration: the growth gate scales with it, down to a noise floor around 5000 |
103
117
  | `--connections <n>` | 100 | Concurrent connections |
104
118
  | `--idle <seconds>` | 30 | **Maximum** wait before each sample; the run continues as soon as the heap settles |
119
+ | `--warmup <n>` | 200 | Requests before the baseline snapshot. Lower it on apps that cache per request: warm-up fills those caches and the baseline then measures the warm-up, not the app. The run says so when it happens |
105
120
  | `--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
121
  | `--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
122
  | `--no-resolve` | off | Skip the second pass on inconclusive routes |
108
123
  | `--diff-all` | off | Diff snapshots for stable routes too |
109
124
  | `--output <dir>` | `<app>/.next-leak` | Where runs are written |
125
+ | `--write-config` | off | Write `next-leak.config.json` for the routes that need sample params, then exit. Never overwrites an existing file |
126
+
127
+ There is a second command for the other half of the problem:
128
+
129
+ ```bash
130
+ # Measure the build itself, not a built server
131
+ npx next-leak build .
132
+ ```
133
+
134
+ A large site can run out of heap while prerendering, before any server exists to
135
+ measure ([#97464](https://github.com/vercel/next.js/issues/97464)). That command
136
+ runs your build unmodified and samples the resident memory of each
137
+ static-generation worker. It needs neither a previous build nor standalone
138
+ output, and takes `--output` only.
110
139
 
111
140
  Dynamic routes need sample params in `next-leak.config.json` in your app dir:
112
141
 
@@ -118,6 +147,13 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
118
147
  }
119
148
  ```
120
149
 
150
+ `--write-config` generates that file for you, filling in values from paths your
151
+ build already prerendered where it knows them, so it usually resolves on the
152
+ first try. When a run skips a route it prints the same fragment.
153
+
154
+ ```json
155
+ ```
156
+
121
157
  - **`headers`** are sent with every request. Real traffic is not header-less:
122
158
  compression, sessions and auth change which code paths run, and some leaks
123
159
  only live on those paths.
@@ -168,7 +204,7 @@ separates them, because each one has a different fix:
168
204
  with the traffic, so the longer run decides the same thing. If the heap is
169
205
  flat but RSS keeps climbing, the report says so explicitly: that is an
170
206
  allocator, external-buffer or fragmentation problem, not a JS-heap leak.
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.
207
+ - **`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 **when the evidence plainly supports the verdict** — a `leak` carrying low-confidence warnings (growth barely over the threshold, one cycle dominating the mean, too few cycles for its size) gets the verdict but no draft, because a draft is written to be pasted into someone else's tracker. If the leak is app-owned, the draft tells you **not** to file it upstream.
172
208
  - **`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.
173
209
  - **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design.
174
210
 
@@ -188,8 +224,10 @@ the verdict:
188
224
  ```
189
225
 
190
226
  That is a real measurement of the reproduction in
191
- [vercel/next.js#92287](https://github.com/vercel/next.js/issues/92287): no
192
- retention, and 3 GB reached. The note fires when the peak heap comes within
227
+ [vercel/next.js#92287](https://github.com/vercel/next.js/issues/92287) on
228
+ 16.2.2: no retention, and 3 GB reached under its own sustained load. The same
229
+ app on 16.3.1 under a shorter profile still reaches 544 MB against 33.8 MB
230
+ retained, so the shape has not gone anywhere. The note fires when the peak heap comes within
193
231
  75% of `--max-old-space`, or when peak RSS is at least 8× the retained heap
194
232
  and above 512 MB. It never changes the verdict — retention and peak are
195
233
  different questions, and only one of them is a leak. A peak is the highest
@@ -199,6 +237,49 @@ If the measured process dies at the limit instead of merely approaching it,
199
237
  the route fails saying exactly that, with the limit in force and how to raise
200
238
  it.
201
239
 
240
+ ### Memory a GC reclaims that production never reclaims
241
+
242
+ There is a third question again, and it is the shape of most of the leaks
243
+ reported in August: memory the process *holds between collections*, which a
244
+ forced GC takes back and a long-running server may never run one to take back.
245
+ Every verdict above is post-GC, so that class is invisible to it by
246
+ construction.
247
+
248
+ Each cycle is therefore read twice — once before any forced collection, once
249
+ after — and the gap between them is reported when it is large:
250
+
251
+ ```
252
+ ✖ /posts/[slug] leak (+0.16 MB/1000 req) heap 27.1 → 26.3 → … → 27.9 MB
253
+ driven through ISR revalidation (revalidates every 3600s; without it the load
254
+ would serve the cache)
255
+ ▲ unreclaimed: held 4.72 MB of arrayBuffers between collections that a GC took
256
+ back (4.7x what it retains) — a forced GC reclaims this, a long-running
257
+ process may not run one often enough to
258
+ ```
259
+
260
+ A real measurement of the reproduction in
261
+ [#96533](https://github.com/vercel/next.js/issues/96533), whose reporter
262
+ accumulated 1.16 GB of `arrayBuffers` over four days against a flat JS heap.
263
+ The note fires on the **gap**, not on a trend: that pre-collection series
264
+ oscillates rather than climbs, and a rule keyed on it climbing reported nothing
265
+ at all. It needs both a floor (2 MB) and a ratio (0.35× what the route retains),
266
+ because the absolute size alone does not separate this from an ordinary leak.
267
+
268
+ Its limit, stated on the line itself: the reading is taken seconds after load,
269
+ not the hours a production process runs between full collections, so it
270
+ includes memory that had not been collected yet. It never changes the verdict.
271
+
272
+ ### ISR routes are driven, not served from cache
273
+
274
+ A route with a revalidation period serves its cache unless the request carries
275
+ the build's own `x-prerender-revalidate` header. Measured on that same app: the
276
+ identical run reports `leak` with the header and `stable` without it. next-leak
277
+ reads `previewModeId` from `.next/prerender-manifest.json` and drives those
278
+ routes itself; a header you set in `next-leak.config.json` wins untouched. When
279
+ the manifest cannot supply one, the route is reported `not exercised` with no
280
+ verdict, because a flat curve measured against a static cache says nothing about
281
+ the app.
282
+
202
283
  ## The tool grades its own measurement
203
284
 
204
285
  A leak detector is an instrument, and a miscalibrated instrument doesn't fail
@@ -260,10 +341,27 @@ through the build's source maps.
260
341
 
261
342
  ## Scope and limits (read before filing issues)
262
343
 
263
- - **Supported:** App Router · `output: "standalone"` · Node ≥ 22 · Linux/macOS. Pages Router, non-standalone, and Windows are rejected with a clear message.
344
+ - **Supported (default command):** App Router · `output: "standalone"` · Node ≥ 22 · Linux/macOS. Pages Router, non-standalone, and Windows are rejected with a clear message.
345
+ - **Sample values can vary per request.** `"slug": "post-{n}"` gives every
346
+ request its own URL — the shape of bot traffic and of a cache that never
347
+ repeats a key. `"slug": "post-{n%200}"` cycles through exactly 200 distinct
348
+ values, revisiting them across cycles, which is the shape the reported leaks
349
+ actually have ([#96533](https://github.com/vercel/next.js/issues/96533)
350
+ revalidates a fixed set of posts;
351
+ [#92287](https://github.com/vercel/next.js/issues/92287) turns on how many
352
+ keys a cache holds at once). A value carrying both markers is rejected before
353
+ the run starts.
354
+ - **`next-leak build <dir>`** measures the build instead of the server, and needs
355
+ neither a previous build nor standalone output — it runs `next build` and
356
+ samples the resident memory of each static-generation worker, which is where
357
+ large sites run out of heap while prerendering
358
+ ([#97464](https://github.com/vercel/next.js/issues/97464)). It does not apply
359
+ to projects using `experimental.workerThreads: true`: a worker thread has no
360
+ resident memory of its own to sample, and the run says so instead of
361
+ reporting a number that would be measuring the parent.
264
362
  - **Architectures:** verified on **arm64 and x64** (linux/amd64 in Docker) — same app, same parameters, same verdicts.
265
363
  - **Attribution** (naming the file) needs a Turbopack build with server sourcemaps — the Next 15+ default. On webpack builds the registry is empty by design and findings degrade to `unattributed` with raw retainer chains; measurement itself does not depend on it. Note that `output: "standalone"` + `--webpack` produced a bundle that could not start at all on `16.3.0-canary.90` (missing `@swc/helpers`), independently of this tool.
266
- - Empirically validated on Next **15.5.4, 16.0.x, 16.1.5, 16.2.x and 16.3-canary** (incl. Sentry, OpenTelemetry, PPR and i18n apps), against real reproductions from open issues. The contracts it relies on are stable since Next 13–14, but older versions are untested.
364
+ - Empirically validated on Next **15.5.4, 16.0.x, 16.1.5, 16.2.x, 16.3.0/16.3.1 and 16.3-canary** (incl. Sentry, OpenTelemetry, PPR and i18n apps), against the public reproductions attached to real issues (open and since-fixed). Most recent measurements, 2026-08-17/18: the runtime path on 16.2.12 and 16.3.1-canary.18, the build path on 16.2.12 and 16.3.1. The contracts it relies on are stable since Next 13–14, but older versions are untested.
267
365
  - **Each measured process runs under a 512 MB heap cap** by default, so a leak
268
366
  reaches a ceiling in minutes instead of the hours a production container
269
367
  takes. An app whose legitimate working set is larger needs
@@ -0,0 +1,11 @@
1
+ import type { BuildRunResult } from "./build-run.js";
2
+ /**
3
+ * Renders the build report.
4
+ *
5
+ * Says "worker rss" everywhere rather than "heap" or "retained": these samples
6
+ * are resident memory with no forced collection behind them, and calling them
7
+ * retention would borrow a precision the measurement does not have. RSS is
8
+ * still the honest axis here — it is what a CI runner's limit is enforced
9
+ * against and what the OOM killer reads.
10
+ */
11
+ export declare function formatBuildReport(result: BuildRunResult): string;
@@ -0,0 +1,58 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ import { type BuildSample } from "./build-verdict.js";
3
+ import { type ProcessTableSample } from "./process-tree.js";
4
+ import type { TrendResult } from "./trend.js";
5
+ export type WorkerSeries = {
6
+ pid: number;
7
+ samples: BuildSample[];
8
+ };
9
+ export type BuildRunResult = {
10
+ appDir: string;
11
+ /**
12
+ * `measured` carries a verdict. `build-failed` means the build broke for a
13
+ * reason that is not memory, and makes no memory claim. `nothing-to-measure`
14
+ * means no static-generation worker ever ran. `cannot-sample` means the
15
+ * process table could not be read, which is not the same as finding nothing
16
+ * in it.
17
+ */
18
+ status: "measured" | "build-failed" | "nothing-to-measure" | "cannot-sample";
19
+ /** Why sampling stopped, when it did. */
20
+ samplingFailure: string | null;
21
+ verdict: TrendResult["verdict"] | null;
22
+ trend: TrendResult | null;
23
+ /** Segmented levels the verdict was read from, in bytes. */
24
+ levels: number[];
25
+ workers: WorkerSeries[];
26
+ /** The build's own process, reported but never judged: it sheds while workers climb. */
27
+ parentSamples: BuildSample[];
28
+ peakWorkerRssBytes: number;
29
+ netGrowthBytes: number;
30
+ pagesGenerated: number | null;
31
+ retentionPerPageBytes: number | null;
32
+ /** True when a worker hit the V8 heap limit — the finding, not a failure. */
33
+ heapExhausted: boolean;
34
+ strippedCapWarning: string | null;
35
+ exitCode: number | null;
36
+ output: string;
37
+ };
38
+ export type BuildRunOptions = {
39
+ appDir: string;
40
+ signal?: AbortSignal;
41
+ onProgress?: (message: string) => void;
42
+ /** Overrides `process.env` for the build. */
43
+ env?: NodeJS.ProcessEnv;
44
+ };
45
+ export type BuildRunDeps = {
46
+ spawnBuild: (appDir: string, env: NodeJS.ProcessEnv) => ChildProcess;
47
+ sampleTable: () => Promise<ProcessTableSample>;
48
+ now: () => number;
49
+ sleep: (ms: number) => Promise<void>;
50
+ };
51
+ /**
52
+ * Measures the memory of a `next build`'s static-generation workers.
53
+ *
54
+ * The build runs unmodified — nothing is injected into it. Workers are child
55
+ * processes with their own resident memory, so the whole measurement is made
56
+ * from outside by watching the process tree.
57
+ */
58
+ export declare function runBuildMeasurement(options: BuildRunOptions, deps?: BuildRunDeps): Promise<BuildRunResult>;
@@ -0,0 +1,13 @@
1
+ export type ValidatedBuildTarget = {
2
+ appDir: string;
3
+ /** The package script to run, when one of the usual names exists. */
4
+ buildScript: string | null;
5
+ };
6
+ /**
7
+ * Validates a target for a *build* measurement.
8
+ *
9
+ * Deliberately weaker than `validateTarget`: this command measures the build
10
+ * itself, so demanding `.next` or a standalone bundle would reject exactly the
11
+ * projects it exists to help — a build that OOMs never produces either.
12
+ */
13
+ export declare function validateBuildTarget(appDir: string): Promise<ValidatedBuildTarget>;
@@ -0,0 +1,80 @@
1
+ import { type TrendResult } from "./trend.js";
2
+ export type BuildSample = {
3
+ /** Milliseconds since the build started. */
4
+ atMs: number;
5
+ rssBytes: number;
6
+ };
7
+ /**
8
+ * Segments the sampling window into equal slices and takes each slice's last
9
+ * reading.
10
+ *
11
+ * A build has no cycles. Its only natural unit of work is a page, and pages are
12
+ * not observable from outside the worker — so the window is divided by time
13
+ * instead, which yields a series with the shape semantics the trend classifier
14
+ * already reads: one level per unit of work. Taking the last sample of each
15
+ * slice rather than the mean keeps a climb a climb; averaging would flatten the
16
+ * end of every segment into its start.
17
+ */
18
+ export declare function segmentSamples(samples: readonly BuildSample[], segments: number): number[];
19
+ /**
20
+ * Segments a build needs before its curve can be judged.
21
+ *
22
+ * The classifier drops the first delta as warm-up and wants at least three
23
+ * more, and a worker's first segment is genuinely warm-up: module loading and
24
+ * JIT, the same reason the runtime ritual discards its baseline delta.
25
+ */
26
+ export declare const BUILD_SEGMENTS = 6;
27
+ export type BuildTrend = {
28
+ trend: TrendResult;
29
+ /** The segmented levels the verdict was read from. */
30
+ levels: number[];
31
+ };
32
+ /**
33
+ * Per-segment growth a build worker must clear to count as retaining.
34
+ *
35
+ * Not the runtime gate. That one is the noise floor of a *post-GC heap* sample,
36
+ * 256 KiB, and it does not describe RSS: a build worker doing legitimate work
37
+ * moves tens of megabytes between readings through allocator behaviour alone,
38
+ * with nothing retained.
39
+ *
40
+ * Anchored on two builds of the same project, same heap cap, 2026-08-17:
41
+ * 16.2.12 stayed flat (428 → 530 MB, largest segment delta 17 MB) while 16.3.1
42
+ * climbed 1070 → 2960 MB and then OOMed, ~315 MB per segment. 32 MB is roughly
43
+ * twice the largest healthy delta and an order of magnitude below the leaking
44
+ * one. The consequence to be honest about: a build retaining less than this per
45
+ * segment reads as stable — and one retaining less than this does not OOM.
46
+ */
47
+ export declare const BUILD_GROWTH_GATE_BYTES: number;
48
+ /** Classifies a worker's resident-memory curve. */
49
+ export declare function classifyBuildSamples(samples: readonly BuildSample[]): BuildTrend;
50
+ /** Growth across the analyzed window, in bytes. */
51
+ export declare function netGrowthOf(levels: readonly number[]): number;
52
+ /**
53
+ * Retention per page generated.
54
+ *
55
+ * Null when the page count is unknown: a growth figure with an invented
56
+ * denominator is worse than no figure, and this is the number people quote.
57
+ */
58
+ export declare function retentionPerPage(levels: readonly number[], pagesGenerated: number | null): number | null;
59
+ /**
60
+ * The heap cap Next removes from the worker's environment.
61
+ *
62
+ * `lib/worker.js` deletes `max-old-space-size` and `max_old_space_size` from
63
+ * `NODE_OPTIONS` when it spawns an isolated-memory worker, so the flag every
64
+ * OOM guide recommends never reaches the process that runs out of memory.
65
+ * Verified by measurement on the #97464 reproduction: a build capped at 50 MB
66
+ * with `--max-old-space-size` completes, while `--max-heap-size=50` kills it in
67
+ * 374 ms.
68
+ */
69
+ export declare function strippedHeapCap(nodeOptions: string | undefined): string | null;
70
+ /** Whether build output carries a V8 heap-limit fatal error. */
71
+ export declare function diedOfHeapExhaustion(output: string): boolean;
72
+ /**
73
+ * How many pages the build generated, read from its own progress line.
74
+ *
75
+ * Next prints `Generating static pages (1234/2504)`; the largest first number
76
+ * seen is how far it got, which is the useful figure whether it finished or
77
+ * died. Null when the build never printed one — an invented denominator would
78
+ * turn an unknown into a wrong number.
79
+ */
80
+ export declare function pagesGeneratedFrom(output: string): number | null;
@@ -8,6 +8,7 @@ function minGrowthFor(requestsPerCycle) {
8
8
  }
9
9
  var STEPWISE_MIN_GROWING_CYCLES = 2;
10
10
  var STEPWISE_MAX_DRAWDOWN_RATIO = 0.1;
11
+ var ACQUITTAL_MAX_GROWTH_MULTIPLE = 8;
11
12
  function maxDrawdown(samples) {
12
13
  let peak = samples[1] ?? 0;
13
14
  let worst = 0;
@@ -28,6 +29,10 @@ function isStepwiseGrowth(samples, deltas, growingCycles, mean, minGrowth) {
28
29
  }
29
30
  return maxDrawdown(samples) <= netGrowth * STEPWISE_MAX_DRAWDOWN_RATIO;
30
31
  }
32
+ function isTooLargeToAcquit(deltas, mean, minGrowth) {
33
+ const netGrowth = deltas.reduce((sum, delta) => sum + delta, 0);
34
+ return mean >= minGrowth * ACQUITTAL_MAX_GROWTH_MULTIPLE && netGrowth > 0;
35
+ }
31
36
  function classifyTrend(samples, options = {}) {
32
37
  const minGrowth = options.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR;
33
38
  if (samples.length < 4) {
@@ -53,6 +58,9 @@ function classifyTrend(samples, options = {}) {
53
58
  return { verdict: "leak", growthPerCycle: mean, deltas, source: "heap" };
54
59
  }
55
60
  if (anyFlatOrDown || mean < minGrowth) {
61
+ if (isTooLargeToAcquit(deltas, mean, minGrowth)) {
62
+ return { verdict: "inconclusive", growthPerCycle: mean, deltas, source: "heap" };
63
+ }
56
64
  return { verdict: "stable", growthPerCycle: mean, deltas, source: "heap" };
57
65
  }
58
66
  return { verdict: "inconclusive", growthPerCycle: mean, deltas, source: "heap" };
@@ -184,6 +192,25 @@ function heapCeilingWarnings(input) {
184
192
  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`
185
193
  }];
186
194
  }
195
+ var WARM_UP_BASELINE_SHARE = 0.5;
196
+ var WARM_UP_BASELINE_FLOOR_BYTES = 16 * 1024 * 1024;
197
+ function warmUpBaselineWarnings(input) {
198
+ const samples = input.memorySamples;
199
+ const baseline = samples?.[0]?.heapUsed;
200
+ const firstCycle = samples?.[1]?.heapUsed;
201
+ if (baseline === void 0 || firstCycle === void 0) {
202
+ return [];
203
+ }
204
+ const drained = baseline - firstCycle;
205
+ if (drained < WARM_UP_BASELINE_FLOOR_BYTES || drained < baseline * WARM_UP_BASELINE_SHARE) {
206
+ return [];
207
+ }
208
+ const warmup = input.warmupRequests === void 0 ? "" : ` (${input.warmupRequests} warm-up requests)`;
209
+ return [{
210
+ code: "warm-up-baseline",
211
+ detail: `the baseline was ${mb(baseline)} and the first cycle ${mb(firstCycle)} \u2014 ${pct(drained, baseline)} of it drained away, so it carries warm-up's own retention rather than the app's resting size${warmup}; every delta is measured from that inflated start. Lower --warmup if the app caches per request`
212
+ }];
213
+ }
187
214
  var THIN_EVIDENCE_MIN_DELTAS = 5;
188
215
  var THIN_EVIDENCE_MIN_DELTA_RATIO = 4;
189
216
  function isThinEvidence(trend, minGrowth) {
@@ -223,7 +250,8 @@ function assessConfidence(input) {
223
250
  ...growthShapeWarnings(input.trend),
224
251
  ...noiseFloorWarnings(input.trend, minGrowth),
225
252
  ...thinEvidenceWarnings(input.trend, minGrowth),
226
- ...heapCeilingWarnings(input)
253
+ ...heapCeilingWarnings(input),
254
+ ...warmUpBaselineWarnings(input)
227
255
  ];
228
256
  return {
229
257
  level: warnings.length === 0 ? "high" : "low",
@@ -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-2FZXZLZW.js";
4
+ } from "./chunk-22PBLCGO.js";
5
5
  import {
6
6
  assessPeakPressure,
7
7
  describePeakPressure