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 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**, not at the request under load a
171
- request-relative window cuts before the stream begins and tests a different
172
- path. Small values are the point: `4` means "read the first chunk, then
173
- vanish". Requests abandoned on purpose are not counted as failures.
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.
@@ -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 first byte of the response. */
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>;
@@ -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;
@@ -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),