next-leak 0.7.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 +11 -0
- 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-BXDUPN6Z.js → chunk-LFGWGM5Z.js} +1442 -1426
- package/dist/cli-args.d.ts +3 -0
- package/dist/cli.js +312 -19
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -368,6 +368,17 @@ through the build's source maps.
|
|
|
368
368
|
to projects using `experimental.workerThreads: true`: a worker thread has no
|
|
369
369
|
resident memory of its own to sample, and the run says so instead of
|
|
370
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).
|
|
371
382
|
- **Architectures:** verified on **arm64 and x64** (linux/amd64 in Docker) — same app, same parameters, same verdicts.
|
|
372
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.
|
|
373
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.
|
|
@@ -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;
|