next-leak 0.5.1 → 0.7.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 +129 -22
- package/dist/abandon-load.d.ts +12 -2
- package/dist/build-report.d.ts +11 -0
- package/dist/build-run.d.ts +58 -0
- package/dist/build-target.d.ts +13 -0
- package/dist/build-verdict.d.ts +80 -0
- package/dist/{chunk-KJFYTJCF.js → chunk-BXDUPN6Z.js} +867 -420
- package/dist/{chunk-2FZXZLZW.js → chunk-EJ26OBZI.js} +36 -5
- package/dist/{chunk-NNDGXIGQ.js → chunk-RS3AO7HB.js} +1 -1
- package/dist/cli-args.d.ts +9 -0
- package/dist/cli.js +508 -6
- package/dist/confidence.d.ts +9 -1
- package/dist/{html-report-X2LUOZRH.js → html-report-TN7PYVU4.js} +2 -2
- package/dist/index.js +3 -3
- package/dist/isr.d.ts +39 -0
- package/dist/launcher.d.ts +8 -0
- package/dist/manifests.d.ts +20 -0
- package/dist/process-tree.d.ts +44 -0
- package/dist/report.d.ts +0 -1
- package/dist/ritual.d.ts +25 -1
- package/dist/route-config.d.ts +23 -5
- package/dist/route-guidance.d.ts +21 -0
- package/dist/runner.d.ts +49 -0
- package/dist/target.d.ts +6 -1
- package/dist/unreclaimed-retention.d.ts +64 -0
- package/package.json +6 -2
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)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
**Verified against real
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
| [#
|
|
37
|
-
| [#
|
|
38
|
-
| [#
|
|
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.07 → 2.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.
|
|
@@ -131,10 +167,19 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
|
|
|
131
167
|
load-balancer timeouts and bots do. Some leaks only exist on that path
|
|
132
168
|
(`ServerResponse` retained after an early disconnect; the RSC tee branch in
|
|
133
169
|
[#94919](https://github.com/vercel/next.js/issues/94919)). The clock starts
|
|
134
|
-
at the **first byte of the response**,
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
170
|
+
at the **first byte of the response**, so the cut lands mid-stream however
|
|
171
|
+
long the route takes to answer. Small values are the point: `4` means "read
|
|
172
|
+
the first chunk, then vanish". Requests abandoned on purpose are not counted
|
|
173
|
+
as failures.
|
|
174
|
+
- **`abandonFrom`** moves that clock to the request itself
|
|
175
|
+
(`"abandonFrom": "request"`). Use it for the opposite experiment: a client
|
|
176
|
+
that is already gone before the server produces anything. On a route slower
|
|
177
|
+
than the deadline the default never cuts early — against the reproduction
|
|
178
|
+
for [#84648](https://github.com/vercel/next.js/issues/84648), whose upstream
|
|
179
|
+
answers in 400 ms while its load generator cuts at 60 ms, first-byte reached
|
|
180
|
+
7% of requests and `request` reached all of them. It stays opt-in because a
|
|
181
|
+
deadline armed on connect loses the race to the first byte on fast routes,
|
|
182
|
+
which measures the wrong path silently.
|
|
138
183
|
|
|
139
184
|
`run.json` records what every load phase actually did — requests sent,
|
|
140
185
|
2xx, abandoned — so a run can be audited instead of trusted.
|
|
@@ -168,7 +213,7 @@ separates them, because each one has a different fix:
|
|
|
168
213
|
with the traffic, so the longer run decides the same thing. If the heap is
|
|
169
214
|
flat but RSS keeps climbing, the report says so explicitly: that is an
|
|
170
215
|
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
|
|
216
|
+
- **`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
217
|
- **`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
218
|
- **`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
219
|
|
|
@@ -188,8 +233,10 @@ the verdict:
|
|
|
188
233
|
```
|
|
189
234
|
|
|
190
235
|
That is a real measurement of the reproduction in
|
|
191
|
-
[vercel/next.js#92287](https://github.com/vercel/next.js/issues/92287)
|
|
192
|
-
retention, and 3 GB reached
|
|
236
|
+
[vercel/next.js#92287](https://github.com/vercel/next.js/issues/92287) on
|
|
237
|
+
16.2.2: no retention, and 3 GB reached under its own sustained load. The same
|
|
238
|
+
app on 16.3.1 under a shorter profile still reaches 544 MB against 33.8 MB
|
|
239
|
+
retained, so the shape has not gone anywhere. The note fires when the peak heap comes within
|
|
193
240
|
75% of `--max-old-space`, or when peak RSS is at least 8× the retained heap
|
|
194
241
|
and above 512 MB. It never changes the verdict — retention and peak are
|
|
195
242
|
different questions, and only one of them is a leak. A peak is the highest
|
|
@@ -199,6 +246,49 @@ If the measured process dies at the limit instead of merely approaching it,
|
|
|
199
246
|
the route fails saying exactly that, with the limit in force and how to raise
|
|
200
247
|
it.
|
|
201
248
|
|
|
249
|
+
### Memory a GC reclaims that production never reclaims
|
|
250
|
+
|
|
251
|
+
There is a third question again, and it is the shape of most of the leaks
|
|
252
|
+
reported in August: memory the process *holds between collections*, which a
|
|
253
|
+
forced GC takes back and a long-running server may never run one to take back.
|
|
254
|
+
Every verdict above is post-GC, so that class is invisible to it by
|
|
255
|
+
construction.
|
|
256
|
+
|
|
257
|
+
Each cycle is therefore read twice — once before any forced collection, once
|
|
258
|
+
after — and the gap between them is reported when it is large:
|
|
259
|
+
|
|
260
|
+
```
|
|
261
|
+
✖ /posts/[slug] leak (+0.16 MB/1000 req) heap 27.1 → 26.3 → … → 27.9 MB
|
|
262
|
+
driven through ISR revalidation (revalidates every 3600s; without it the load
|
|
263
|
+
would serve the cache)
|
|
264
|
+
▲ unreclaimed: held 4.72 MB of arrayBuffers between collections that a GC took
|
|
265
|
+
back (4.7x what it retains) — a forced GC reclaims this, a long-running
|
|
266
|
+
process may not run one often enough to
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
A real measurement of the reproduction in
|
|
270
|
+
[#96533](https://github.com/vercel/next.js/issues/96533), whose reporter
|
|
271
|
+
accumulated 1.16 GB of `arrayBuffers` over four days against a flat JS heap.
|
|
272
|
+
The note fires on the **gap**, not on a trend: that pre-collection series
|
|
273
|
+
oscillates rather than climbs, and a rule keyed on it climbing reported nothing
|
|
274
|
+
at all. It needs both a floor (2 MB) and a ratio (0.35× what the route retains),
|
|
275
|
+
because the absolute size alone does not separate this from an ordinary leak.
|
|
276
|
+
|
|
277
|
+
Its limit, stated on the line itself: the reading is taken seconds after load,
|
|
278
|
+
not the hours a production process runs between full collections, so it
|
|
279
|
+
includes memory that had not been collected yet. It never changes the verdict.
|
|
280
|
+
|
|
281
|
+
### ISR routes are driven, not served from cache
|
|
282
|
+
|
|
283
|
+
A route with a revalidation period serves its cache unless the request carries
|
|
284
|
+
the build's own `x-prerender-revalidate` header. Measured on that same app: the
|
|
285
|
+
identical run reports `leak` with the header and `stable` without it. next-leak
|
|
286
|
+
reads `previewModeId` from `.next/prerender-manifest.json` and drives those
|
|
287
|
+
routes itself; a header you set in `next-leak.config.json` wins untouched. When
|
|
288
|
+
the manifest cannot supply one, the route is reported `not exercised` with no
|
|
289
|
+
verdict, because a flat curve measured against a static cache says nothing about
|
|
290
|
+
the app.
|
|
291
|
+
|
|
202
292
|
## The tool grades its own measurement
|
|
203
293
|
|
|
204
294
|
A leak detector is an instrument, and a miscalibrated instrument doesn't fail
|
|
@@ -260,10 +350,27 @@ through the build's source maps.
|
|
|
260
350
|
|
|
261
351
|
## Scope and limits (read before filing issues)
|
|
262
352
|
|
|
263
|
-
- **Supported:** App Router · `output: "standalone"` · Node ≥ 22 · Linux/macOS. Pages Router, non-standalone, and Windows are rejected with a clear message.
|
|
353
|
+
- **Supported (default command):** App Router · `output: "standalone"` · Node ≥ 22 · Linux/macOS. Pages Router, non-standalone, and Windows are rejected with a clear message.
|
|
354
|
+
- **Sample values can vary per request.** `"slug": "post-{n}"` gives every
|
|
355
|
+
request its own URL — the shape of bot traffic and of a cache that never
|
|
356
|
+
repeats a key. `"slug": "post-{n%200}"` cycles through exactly 200 distinct
|
|
357
|
+
values, revisiting them across cycles, which is the shape the reported leaks
|
|
358
|
+
actually have ([#96533](https://github.com/vercel/next.js/issues/96533)
|
|
359
|
+
revalidates a fixed set of posts;
|
|
360
|
+
[#92287](https://github.com/vercel/next.js/issues/92287) turns on how many
|
|
361
|
+
keys a cache holds at once). A value carrying both markers is rejected before
|
|
362
|
+
the run starts.
|
|
363
|
+
- **`next-leak build <dir>`** measures the build instead of the server, and needs
|
|
364
|
+
neither a previous build nor standalone output — it runs `next build` and
|
|
365
|
+
samples the resident memory of each static-generation worker, which is where
|
|
366
|
+
large sites run out of heap while prerendering
|
|
367
|
+
([#97464](https://github.com/vercel/next.js/issues/97464)). It does not apply
|
|
368
|
+
to projects using `experimental.workerThreads: true`: a worker thread has no
|
|
369
|
+
resident memory of its own to sample, and the run says so instead of
|
|
370
|
+
reporting a number that would be measuring the parent.
|
|
264
371
|
- **Architectures:** verified on **arm64 and x64** (linux/amd64 in Docker) — same app, same parameters, same verdicts.
|
|
265
372
|
- **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
|
|
373
|
+
- 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
374
|
- **Each measured process runs under a 512 MB heap cap** by default, so a leak
|
|
268
375
|
reaches a ceiling in minutes instead of the hours a production container
|
|
269
376
|
takes. An app whose legitimate working set is larger needs
|
package/dist/abandon-load.d.ts
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the abandon deadline starts. `first-byte` puts the cut mid-stream
|
|
3
|
+
* whatever the route's latency; `request` puts it before the response exists,
|
|
4
|
+
* which is a different leak path and not reachable from the other origin.
|
|
5
|
+
*/
|
|
6
|
+
export type AbandonOrigin = "first-byte" | "request";
|
|
1
7
|
export type AbandonPhaseOptions = {
|
|
2
8
|
url: string;
|
|
3
9
|
amount: number;
|
|
4
10
|
connections: number;
|
|
5
|
-
/** Destroy the socket this many ms after the
|
|
11
|
+
/** Destroy the socket this many ms after the origin below. */
|
|
6
12
|
abandonAfterMs: number;
|
|
13
|
+
/** Defaults to `first-byte`. */
|
|
14
|
+
abandonFrom?: AbandonOrigin;
|
|
7
15
|
headers?: Record<string, string>;
|
|
8
16
|
};
|
|
9
17
|
export type AbandonPhaseResult = {
|
|
@@ -30,6 +38,8 @@ export type AbandonPhaseResult = {
|
|
|
30
38
|
* a client goes away mid-flight (closed tabs, load-balancer timeouts, bots).
|
|
31
39
|
*
|
|
32
40
|
* Raw sockets keep this honest: write the request, wait for the response to
|
|
33
|
-
* start, then wait `abandonAfterMs` and destroy the socket mid-stream.
|
|
41
|
+
* start, then wait `abandonAfterMs` and destroy the socket mid-stream. With
|
|
42
|
+
* `abandonFrom: "request"` the wait starts at the write instead, which is the
|
|
43
|
+
* only way to cut a route that has not begun answering yet.
|
|
34
44
|
*/
|
|
35
45
|
export declare function runAbandonPhase(options: AbandonPhaseOptions): Promise<AbandonPhaseResult>;
|
|
@@ -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;
|