next-leak 0.6.0 → 0.8.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 +24 -4
- package/dist/abandon-load.d.ts +12 -2
- 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-22PBLCGO.js → chunk-EJ26OBZI.js} +7 -4
- package/dist/{chunk-AMSDGT3Y.js → chunk-LFGWGM5Z.js} +890 -825
- package/dist/{chunk-6CJU5TVV.js → chunk-RS3AO7HB.js} +1 -1
- package/dist/cli-args.d.ts +3 -0
- package/dist/cli.js +313 -20
- package/dist/confidence.d.ts +3 -0
- package/dist/{html-report-2UAOOZQO.js → html-report-TN7PYVU4.js} +2 -2
- package/dist/index.js +3 -3
- package/dist/ritual.d.ts +3 -1
- package/dist/route-config.d.ts +4 -0
- package/dist/runner.d.ts +11 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -167,10 +167,19 @@ first try. When a run skips a route it prints the same fragment.
|
|
|
167
167
|
load-balancer timeouts and bots do. Some leaks only exist on that path
|
|
168
168
|
(`ServerResponse` retained after an early disconnect; the RSC tee branch in
|
|
169
169
|
[#94919](https://github.com/vercel/next.js/issues/94919)). The clock starts
|
|
170
|
-
at the **first byte of the response**,
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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.
|
|
174
183
|
|
|
175
184
|
`run.json` records what every load phase actually did — requests sent,
|
|
176
185
|
2xx, abandoned — so a run can be audited instead of trusted.
|
|
@@ -359,6 +368,17 @@ through the build's source maps.
|
|
|
359
368
|
to projects using `experimental.workerThreads: true`: a worker thread has no
|
|
360
369
|
resident memory of its own to sample, and the run says so instead of
|
|
361
370
|
reporting a number that would be measuring the parent.
|
|
371
|
+
- **`next-leak build <dir> --attribute`** additionally names *what* the worker
|
|
372
|
+
retained, by signalling it for a pair of heap snapshots. **Opt-in, and slow
|
|
373
|
+
by nature:** the signal makes the worker write its whole heap to disk, which
|
|
374
|
+
on the #97464 reproduction has stalled a build for minutes at a time and
|
|
375
|
+
once for an hour and a half. Use it on a reproduction you are investigating,
|
|
376
|
+
not on a build you need to finish. It also covers only a low slice of the
|
|
377
|
+
curve, and says which: a snapshot weighs roughly 0.4x to 0.8x the worker's
|
|
378
|
+
resident size, so past about a gigabyte it exceeds the 512 MB a V8 string can
|
|
379
|
+
hold and cannot be read back — both captures happen below that, and the
|
|
380
|
+
report states what share of the observed growth they span (19% on that
|
|
381
|
+
reproduction, whose worker peaks near 4 GB).
|
|
362
382
|
- **Architectures:** verified on **arm64 and x64** (linux/amd64 in Docker) — same app, same parameters, same verdicts.
|
|
363
383
|
- **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.
|
|
364
384
|
- 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.
|
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,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;
|
|
@@ -113,7 +113,7 @@ function settleWarnings(outcomes) {
|
|
|
113
113
|
}
|
|
114
114
|
return warnings;
|
|
115
115
|
}
|
|
116
|
-
function abandonmentWarnings(outcome) {
|
|
116
|
+
function abandonmentWarnings(outcome, abandonFrom) {
|
|
117
117
|
const abandoned = outcome.abandoned ?? 0;
|
|
118
118
|
if (outcome.sent > 0 && abandoned < outcome.sent * ABANDON_EFFECTIVE_FLOOR) {
|
|
119
119
|
return [{
|
|
@@ -121,6 +121,9 @@ function abandonmentWarnings(outcome) {
|
|
|
121
121
|
detail: `${outcome.phase} disconnected early on only ${abandoned} of ${outcome.sent} requests (${pct(abandoned, outcome.sent)}) \u2014 the early-disconnect path was largely not exercised`
|
|
122
122
|
}];
|
|
123
123
|
}
|
|
124
|
+
if (abandonFrom === "request") {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
124
127
|
const midStream = outcome.abandonedMidStream ?? 0;
|
|
125
128
|
if (abandoned > 0 && midStream < abandoned * MID_STREAM_FLOOR) {
|
|
126
129
|
return [{
|
|
@@ -130,11 +133,11 @@ function abandonmentWarnings(outcome) {
|
|
|
130
133
|
}
|
|
131
134
|
return [];
|
|
132
135
|
}
|
|
133
|
-
function loadWarnings(outcomes, abandonAfterMs) {
|
|
136
|
+
function loadWarnings(outcomes, abandonAfterMs, abandonFrom) {
|
|
134
137
|
const warnings = [];
|
|
135
138
|
for (const outcome of outcomes) {
|
|
136
139
|
if (abandonAfterMs !== void 0) {
|
|
137
|
-
warnings.push(...abandonmentWarnings(outcome));
|
|
140
|
+
warnings.push(...abandonmentWarnings(outcome, abandonFrom));
|
|
138
141
|
continue;
|
|
139
142
|
}
|
|
140
143
|
const landed = outcome.ok2xx ?? 0;
|
|
@@ -246,7 +249,7 @@ function assessConfidence(input) {
|
|
|
246
249
|
const minGrowth = input.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR;
|
|
247
250
|
const warnings = [
|
|
248
251
|
...settleWarnings(input.settleOutcomes),
|
|
249
|
-
...loadWarnings(input.loadOutcomes, input.abandonAfterMs),
|
|
252
|
+
...loadWarnings(input.loadOutcomes, input.abandonAfterMs, input.abandonFrom),
|
|
250
253
|
...growthShapeWarnings(input.trend),
|
|
251
254
|
...noiseFloorWarnings(input.trend, minGrowth),
|
|
252
255
|
...thinEvidenceWarnings(input.trend, minGrowth),
|