next-leak 0.7.0 → 0.9.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 +22 -5
- package/dist/build-attribution.d.ts +33 -0
- package/dist/build-report.d.ts +2 -1
- package/dist/build-run.d.ts +23 -0
- package/dist/build-snapshot.d.ts +55 -0
- package/dist/{chunk-RS3AO7HB.js → chunk-T7LM4Z33.js} +8 -0
- package/dist/{chunk-BXDUPN6Z.js → chunk-UVA77PMC.js} +1544 -1434
- package/dist/cli-args.d.ts +3 -0
- package/dist/cli.js +335 -20
- package/dist/{html-report-TN7PYVU4.js → html-report-5XMR3EXE.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/launcher.d.ts +20 -1
- package/dist/load.d.ts +19 -0
- package/dist/ritual.d.ts +24 -0
- package/dist/route-guidance.d.ts +19 -0
- package/dist/runner.d.ts +22 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -142,14 +142,20 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
|
|
|
142
142
|
```json
|
|
143
143
|
{
|
|
144
144
|
"params": { "lang": "en" },
|
|
145
|
-
"routes": { "/products/[id]": { "id": "42" } },
|
|
145
|
+
"routes": { "/products/[id]": { "id": "42-{n}" } },
|
|
146
146
|
"headers": { "accept-encoding": "gzip, br", "cookie": "session=..." }
|
|
147
147
|
}
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
`--write-config` generates that file for you
|
|
151
|
-
build already prerendered
|
|
152
|
-
|
|
150
|
+
`--write-config` generates that file for you. It takes the *shape* of each value
|
|
151
|
+
from paths your build already prerendered, and makes the value itself move:
|
|
152
|
+
`post-0` becomes `post-{n}`. A value the build prerendered is the one value
|
|
153
|
+
guaranteed not to measure anything — every request hits the same warm cache
|
|
154
|
+
entry, so the route reads as flat whatever it retains, and that false negative
|
|
155
|
+
lands on exactly the leaks being reported now (`use cache`, `cacheComponents`
|
|
156
|
+
and ISR all key on the params). If your app answers 404 for params it never
|
|
157
|
+
prerendered, the run says so through its non-2xx count; drop the marker then.
|
|
158
|
+
When a run skips a route it prints the same fragment.
|
|
153
159
|
|
|
154
160
|
```json
|
|
155
161
|
```
|
|
@@ -215,7 +221,7 @@ separates them, because each one has a different fix:
|
|
|
215
221
|
allocator, external-buffer or fragmentation problem, not a JS-heap leak.
|
|
216
222
|
- **`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.
|
|
217
223
|
- **`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.
|
|
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.
|
|
224
|
+
- **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design. A process that died of **heap exhaustion** is not one of these: it reports `leak`, because a route that could not survive its own load did not fail to be measured — it was measured right up to the point where it stopped fitting. The verdict comes from that outcome, not from the shape of the truncated curve, which is the same rule `next-leak build` applies to a static-generation worker that dies. The run prints the cycles it survived and the growth up to the death, and exits 0 with a finding rather than 1 with an error.
|
|
219
225
|
|
|
220
226
|
## Peak pressure: `stable` is not the same as safe
|
|
221
227
|
|
|
@@ -368,6 +374,17 @@ through the build's source maps.
|
|
|
368
374
|
to projects using `experimental.workerThreads: true`: a worker thread has no
|
|
369
375
|
resident memory of its own to sample, and the run says so instead of
|
|
370
376
|
reporting a number that would be measuring the parent.
|
|
377
|
+
- **`next-leak build <dir> --attribute`** additionally names *what* the worker
|
|
378
|
+
retained, by signalling it for a pair of heap snapshots. **Opt-in, and slow
|
|
379
|
+
by nature:** the signal makes the worker write its whole heap to disk, which
|
|
380
|
+
on the #97464 reproduction has stalled a build for minutes at a time and
|
|
381
|
+
once for an hour and a half. Use it on a reproduction you are investigating,
|
|
382
|
+
not on a build you need to finish. It also covers only a low slice of the
|
|
383
|
+
curve, and says which: a snapshot weighs roughly 0.4x to 0.8x the worker's
|
|
384
|
+
resident size, so past about a gigabyte it exceeds the 512 MB a V8 string can
|
|
385
|
+
hold and cannot be read back — both captures happen below that, and the
|
|
386
|
+
report states what share of the observed growth they span (19% on that
|
|
387
|
+
reproduction, whose worker peaks near 4 GB).
|
|
371
388
|
- **Architectures:** verified on **arm64 and x64** (linux/amd64 in Docker) — same app, same parameters, same verdicts.
|
|
372
389
|
- **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.
|
|
373
390
|
- 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.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type AttributedDiff } from "./attribution.js";
|
|
2
|
+
import type { BuildRunResult } from "./build-run.js";
|
|
3
|
+
import { diffSnapshotFiles, type HeapDiff } from "./heap-diff.js";
|
|
4
|
+
import { extractModuleRegistry } from "./module-registry.js";
|
|
5
|
+
export type BuildAttribution = {
|
|
6
|
+
diff: HeapDiff;
|
|
7
|
+
attributed: AttributedDiff;
|
|
8
|
+
/** Modules the registry resolved. Zero means every finding stays unattributed. */
|
|
9
|
+
registrySize: number;
|
|
10
|
+
/** Share of the worker's observed growth the snapshot pair spans, 0 to 1. */
|
|
11
|
+
bracketed: number;
|
|
12
|
+
baselineRssBytes: number;
|
|
13
|
+
afterRssBytes: number;
|
|
14
|
+
/** Where the pair was stored, so the finding can be checked by hand. */
|
|
15
|
+
baselineFile: string;
|
|
16
|
+
afterFile: string;
|
|
17
|
+
};
|
|
18
|
+
export type BuildAttributionDeps = {
|
|
19
|
+
diff: typeof diffSnapshotFiles;
|
|
20
|
+
registry: typeof extractModuleRegistry;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Names what a build's worker retained, from the pair the run captured.
|
|
24
|
+
*
|
|
25
|
+
* Returns null rather than throwing on every failure path. A build measurement
|
|
26
|
+
* stands on its curve and its verdict; this is an addition to the report, and
|
|
27
|
+
* an addition that can fail must not be able to take the report with it.
|
|
28
|
+
*
|
|
29
|
+
* The registry is read from `.next/server` *after* the build, never during it:
|
|
30
|
+
* a half-written chunk can resolve a module id to the wrong source, and naming
|
|
31
|
+
* the wrong owner confidently is worse than naming none.
|
|
32
|
+
*/
|
|
33
|
+
export declare function attributeBuildCapture(result: BuildRunResult, appDir: string, onProgress?: (message: string) => void, deps?: BuildAttributionDeps): Promise<BuildAttribution | null>;
|
package/dist/build-report.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { BuildAttribution } from "./build-attribution.js";
|
|
1
2
|
import type { BuildRunResult } from "./build-run.js";
|
|
2
3
|
/**
|
|
3
4
|
* Renders the build report.
|
|
@@ -8,4 +9,4 @@ import type { BuildRunResult } from "./build-run.js";
|
|
|
8
9
|
* still the honest axis here — it is what a CI runner's limit is enforced
|
|
9
10
|
* against and what the OOM killer reads.
|
|
10
11
|
*/
|
|
11
|
-
export declare function formatBuildReport(result: BuildRunResult): string;
|
|
12
|
+
export declare function formatBuildReport(result: BuildRunResult, attribution?: BuildAttribution | null): string;
|
package/dist/build-run.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ChildProcess } from "node:child_process";
|
|
2
|
+
import { type CollectedPair } from "./build-snapshot.js";
|
|
2
3
|
import { type BuildSample } from "./build-verdict.js";
|
|
3
4
|
import { type ProcessTableSample } from "./process-tree.js";
|
|
4
5
|
import type { TrendResult } from "./trend.js";
|
|
@@ -6,6 +7,19 @@ export type WorkerSeries = {
|
|
|
6
7
|
pid: number;
|
|
7
8
|
samples: BuildSample[];
|
|
8
9
|
};
|
|
10
|
+
export type BuildCapture = {
|
|
11
|
+
pid: number;
|
|
12
|
+
files: CollectedPair;
|
|
13
|
+
baselineRssBytes: number;
|
|
14
|
+
afterRssBytes: number;
|
|
15
|
+
/**
|
|
16
|
+
* Peak resident memory of *this* worker, not of the build. The share of
|
|
17
|
+
* growth the pair covers is quoted against the curve the findings came
|
|
18
|
+
* from, and on a multi-worker build the highest peak can belong to a worker
|
|
19
|
+
* nothing was captured from.
|
|
20
|
+
*/
|
|
21
|
+
peakRssBytes: number;
|
|
22
|
+
};
|
|
9
23
|
export type BuildRunResult = {
|
|
10
24
|
appDir: string;
|
|
11
25
|
/**
|
|
@@ -31,12 +45,20 @@ export type BuildRunResult = {
|
|
|
31
45
|
retentionPerPageBytes: number | null;
|
|
32
46
|
/** True when a worker hit the V8 heap limit — the finding, not a failure. */
|
|
33
47
|
heapExhausted: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* The snapshot pair captured from a worker, when one was. Attribution is an
|
|
50
|
+
* addition to this report, never a precondition for it: every field above is
|
|
51
|
+
* produced whether or not capture worked.
|
|
52
|
+
*/
|
|
53
|
+
capture: BuildCapture | null;
|
|
34
54
|
strippedCapWarning: string | null;
|
|
35
55
|
exitCode: number | null;
|
|
36
56
|
output: string;
|
|
37
57
|
};
|
|
38
58
|
export type BuildRunOptions = {
|
|
39
59
|
appDir: string;
|
|
60
|
+
/** Where a captured snapshot pair is moved to. Capture is skipped without it. */
|
|
61
|
+
workDir?: string;
|
|
40
62
|
signal?: AbortSignal;
|
|
41
63
|
onProgress?: (message: string) => void;
|
|
42
64
|
/** Overrides `process.env` for the build. */
|
|
@@ -44,6 +66,7 @@ export type BuildRunOptions = {
|
|
|
44
66
|
};
|
|
45
67
|
export type BuildRunDeps = {
|
|
46
68
|
spawnBuild: (appDir: string, env: NodeJS.ProcessEnv) => ChildProcess;
|
|
69
|
+
signalWorker: (pid: number) => void;
|
|
47
70
|
sampleTable: () => Promise<ProcessTableSample>;
|
|
48
71
|
now: () => number;
|
|
49
72
|
sleep: (ms: number) => Promise<void>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker resident memory above which its heap snapshot stops being parseable.
|
|
3
|
+
*
|
|
4
|
+
* Measured on 2026-08-19 against the vercel/next.js#97464 reproduction, three
|
|
5
|
+
* points: a worker at 802 MB wrote 310 MB, at 1201 MB wrote 563 MB, and at
|
|
6
|
+
* 3010 MB wrote 2388 MB. The ratio is not flat — 0.39x, 0.47x, 0.79x — so the
|
|
7
|
+
* 512 MB a V8 string can hold is crossed somewhere just past 1 GB of RSS.
|
|
8
|
+
* A snapshot taken above this is written, costs the disk, and then cannot be
|
|
9
|
+
* read (see `assertReadableSnapshot`), which is the worst of both.
|
|
10
|
+
*/
|
|
11
|
+
export declare const PARSEABLE_WORKER_RSS_BYTES: number;
|
|
12
|
+
export type CaptureStage = "waiting" | "baseline-taken" | "pair-taken" | "missed";
|
|
13
|
+
export type CaptureDecision = "wait" | "take-baseline" | "take-after" | "give-up";
|
|
14
|
+
/**
|
|
15
|
+
* Decides what to do with a worker at this resident size.
|
|
16
|
+
*
|
|
17
|
+
* Both snapshots have to happen low on the curve, which is not where the
|
|
18
|
+
* interesting memory is. That is forced by the parse limit above and is the
|
|
19
|
+
* central compromise of build-time attribution: the pair samples the start of
|
|
20
|
+
* the growth rather than bracketing it, so the report has to say how much of
|
|
21
|
+
* the curve it actually covers.
|
|
22
|
+
*/
|
|
23
|
+
export declare function decideCapture(rssBytes: number, stage: CaptureStage, baselineRssBytes?: number | null): CaptureDecision;
|
|
24
|
+
export declare function snapshotsWrittenBy(pid: number, filenames: readonly string[]): string[];
|
|
25
|
+
export type CollectedPair = {
|
|
26
|
+
baselineFile: string;
|
|
27
|
+
afterFile: string;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Moves a worker's two snapshots out of the project and into the run's own
|
|
31
|
+
* directory. They are the user's source tree's problem otherwise: V8 chose
|
|
32
|
+
* where to put them, and leaving half a gigabyte of them behind in a repo
|
|
33
|
+
* would be a poor trade for a measurement.
|
|
34
|
+
*/
|
|
35
|
+
export declare function collectSnapshotPair(appDir: string, outDir: string, pid: number): Promise<CollectedPair | null>;
|
|
36
|
+
/**
|
|
37
|
+
* Deletes snapshots a run wrote and cannot use — a give-up after the baseline,
|
|
38
|
+
* or a worker whose pair never completed. Without this a failed capture leaves
|
|
39
|
+
* hundreds of megabytes in the user's project with no report referring to them.
|
|
40
|
+
*/
|
|
41
|
+
export declare function discardSnapshots(appDir: string, pid: number): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* What share of the worker's observed growth the pair actually spans.
|
|
44
|
+
*
|
|
45
|
+
* Reported rather than hidden because the parse limit forces the pair low: on
|
|
46
|
+
* the #97464 reproduction the worker peaks near 3.3 GB and the pair cannot
|
|
47
|
+
* reach past 1 GB, so the attributed bytes explain a minority of the curve. A
|
|
48
|
+
* reader comparing the two numbers without this would conclude the rest went
|
|
49
|
+
* unexplained.
|
|
50
|
+
*/
|
|
51
|
+
export declare function bracketedShare(baselineRssBytes: number, afterRssBytes: number, peakRssBytes: number): number;
|
|
52
|
+
export declare function registerPendingCapture(appDir: string, pid: number): void;
|
|
53
|
+
export declare function clearPendingCapture(): void;
|
|
54
|
+
/** Best effort, synchronous, and never throws: it runs on the way out. */
|
|
55
|
+
export declare function discardPendingSnapshotsSync(): void;
|
|
@@ -80,6 +80,7 @@ function renderHtmlReport(run) {
|
|
|
80
80
|
const measured = run.routes.filter((route) => route.status === "measured");
|
|
81
81
|
const skipped = run.routes.filter((route) => route.status === "skipped");
|
|
82
82
|
const failed = run.routes.filter((route) => route.status === "failed");
|
|
83
|
+
const exhausted = run.routes.filter((route) => route.status === "died-of-heap");
|
|
83
84
|
const environment = run.environment;
|
|
84
85
|
return `<!doctype html>
|
|
85
86
|
<html lang="en"><head><meta charset="utf-8">
|
|
@@ -102,6 +103,13 @@ code{background:#f4f4f4;padding:0 4px;border-radius:3px}
|
|
|
102
103
|
)} \xB7 next-leak ${escapeHtml(environment.nextLeakVersion)}</p>
|
|
103
104
|
<p class="meta">${run.parameters.cycles} cycles \xD7 ${run.parameters.loadRequests} requests \xB7 heap cap ${run.parameters.maxOldSpaceMb} MB \xB7 growth gate ${(run.parameters.minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle</p>
|
|
104
105
|
${measured.map((route) => measuredSection(route, run.parameters)).join("\n")}
|
|
106
|
+
${exhausted.map(
|
|
107
|
+
(route) => route.status !== "died-of-heap" ? "" : `<h2><span class="badge" style="background:#c0392b">leak</span> <code>${escapeHtml(
|
|
108
|
+
route.route
|
|
109
|
+
)}</code></h2>
|
|
110
|
+
<p class="curve">Ran out of heap after ${route.cyclesCompleted} of ${route.cyclesRequested} cycles \xD7 ${route.requestsPerCycle} requests. Post-GC heap up to the death: ${route.memorySamples.map((sample) => `${(sample.heapUsed / MB).toFixed(1)} MB`).join(" \u2192 ")}</p>
|
|
111
|
+
<p class="warn">${escapeHtml(route.reason)}</p>`
|
|
112
|
+
).join("\n")}
|
|
105
113
|
${skipped.length === 0 ? "" : `<h2>Skipped</h2><ul>${skipped.map((route) => `<li><code>${escapeHtml(route.route)}</code> \u2014 ${escapeHtml(route.status === "skipped" ? route.reason : "")}</li>`).join("")}</ul>`}
|
|
106
114
|
${failed.length === 0 ? "" : `<h2>Failed</h2><ul>${failed.map((route) => `<li><code>${escapeHtml(route.route)}</code> \u2014 ${escapeHtml(route.status === "failed" ? route.reason : "")}</li>`).join("")}</ul>`}
|
|
107
115
|
<p class="meta">Raw snapshots and run.json live next to this file \u2014 verify in Chrome DevTools \u2192 Memory \u2192 Load.</p>
|