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.
@@ -10,12 +10,15 @@ export type CliRunOptions = {
10
10
  quick: boolean;
11
11
  noResolve: boolean;
12
12
  diffAll: boolean;
13
+ attributeBuild: boolean;
13
14
  writeConfig: boolean;
14
15
  output: string | null;
15
16
  };
16
17
  export type CliBuildOptions = {
17
18
  appDir: string;
18
19
  output: string | null;
20
+ /** Opt-in: signal the worker for snapshots and name what it retains. */
21
+ attributeBuild: boolean;
19
22
  };
20
23
  export type ParsedCli = {
21
24
  kind: "run";
package/dist/cli.js CHANGED
@@ -2,10 +2,14 @@
2
2
  import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
3
3
  import {
4
4
  RouteConfigError,
5
+ SnapshotError,
5
6
  TargetError,
7
+ attributeDiff,
6
8
  checkRuntime,
9
+ diffSnapshotFiles,
7
10
  discoverPagesRoutes,
8
11
  discoverRoutes,
12
+ extractModuleRegistry,
9
13
  formatReport,
10
14
  hasPlaceholders,
11
15
  helpText,
@@ -16,7 +20,7 @@ import {
16
20
  runMeasurement,
17
21
  unregisterChild,
18
22
  validateTarget
19
- } from "./chunk-BXDUPN6Z.js";
23
+ } from "./chunk-LFGWGM5Z.js";
20
24
  import {
21
25
  classifyTrend
22
26
  } from "./chunk-EJ26OBZI.js";
@@ -25,11 +29,166 @@ import "./chunk-6XYFBOL2.js";
25
29
 
26
30
  // src/cli.ts
27
31
  import { spawn as spawn2 } from "child_process";
28
- import { writeFile } from "fs/promises";
32
+ import { mkdir, writeFile } from "fs/promises";
29
33
  import { createRequire } from "module";
30
- import path3 from "path";
34
+ import path5 from "path";
31
35
  import { fileURLToPath } from "url";
32
36
 
37
+ // src/build-attribution.ts
38
+ import path2 from "path";
39
+
40
+ // src/build-snapshot.ts
41
+ import { readdirSync, unlinkSync } from "fs";
42
+ import { readdir, rename, unlink } from "fs/promises";
43
+ import path from "path";
44
+ var PARSEABLE_WORKER_RSS_BYTES = 1024 * 1024 * 1024;
45
+ var START_UP_FLOOR_BYTES = 128 * 1024 * 1024;
46
+ var MIN_PAIR_GROWTH_BYTES = 128 * 1024 * 1024;
47
+ var AFTER_TARGET_RSS_BYTES = Math.floor(PARSEABLE_WORKER_RSS_BYTES * 0.9);
48
+ function decideCapture(rssBytes, stage, baselineRssBytes = null) {
49
+ if (stage === "pair-taken" || stage === "missed") {
50
+ return "wait";
51
+ }
52
+ if (stage === "waiting") {
53
+ if (rssBytes > PARSEABLE_WORKER_RSS_BYTES) {
54
+ return "give-up";
55
+ }
56
+ return rssBytes >= START_UP_FLOOR_BYTES ? "take-baseline" : "wait";
57
+ }
58
+ const grown = rssBytes - (baselineRssBytes ?? 0);
59
+ if (rssBytes > PARSEABLE_WORKER_RSS_BYTES) {
60
+ return "give-up";
61
+ }
62
+ if (rssBytes < AFTER_TARGET_RSS_BYTES) {
63
+ return "wait";
64
+ }
65
+ return grown >= MIN_PAIR_GROWTH_BYTES ? "take-after" : "wait";
66
+ }
67
+ var SNAPSHOT_NAME = /^Heap\.\d{8}\.\d{6}\.(\d+)\.\d+\.(\d+)\.heapsnapshot$/;
68
+ var byCodeUnit = (left, right) => {
69
+ if (left === right) {
70
+ return 0;
71
+ }
72
+ return left < right ? -1 : 1;
73
+ };
74
+ function snapshotsWrittenBy(pid, filenames) {
75
+ return filenames.filter((name) => {
76
+ const match = SNAPSHOT_NAME.exec(name);
77
+ return match !== null && Number(match[1]) === pid;
78
+ }).sort(byCodeUnit);
79
+ }
80
+ async function collectSnapshotPair(appDir, outDir, pid) {
81
+ let entries;
82
+ try {
83
+ entries = await readdir(appDir);
84
+ } catch {
85
+ return null;
86
+ }
87
+ const written = snapshotsWrittenBy(pid, entries);
88
+ const [baseline, after] = written;
89
+ if (baseline === void 0 || after === void 0) {
90
+ return null;
91
+ }
92
+ const baselineFile = path.join(outDir, `worker-${pid}-baseline.heapsnapshot`);
93
+ const afterFile = path.join(outDir, `worker-${pid}-after.heapsnapshot`);
94
+ try {
95
+ await rename(path.join(appDir, baseline), baselineFile);
96
+ await rename(path.join(appDir, after), afterFile);
97
+ } catch {
98
+ await unlink(baselineFile).catch(() => {
99
+ });
100
+ await unlink(afterFile).catch(() => {
101
+ });
102
+ return null;
103
+ }
104
+ return { baselineFile, afterFile };
105
+ }
106
+ async function discardSnapshots(appDir, pid) {
107
+ let entries;
108
+ try {
109
+ entries = await readdir(appDir);
110
+ } catch {
111
+ return;
112
+ }
113
+ for (const name of snapshotsWrittenBy(pid, entries)) {
114
+ try {
115
+ await unlink(path.join(appDir, name));
116
+ } catch {
117
+ }
118
+ }
119
+ }
120
+ function bracketedShare(baselineRssBytes, afterRssBytes, peakRssBytes) {
121
+ const total = peakRssBytes - baselineRssBytes;
122
+ if (total <= 0) {
123
+ return 1;
124
+ }
125
+ return Math.min(1, Math.max(0, (afterRssBytes - baselineRssBytes) / total));
126
+ }
127
+ var pendingCapture = null;
128
+ function registerPendingCapture(appDir, pid) {
129
+ pendingCapture = { appDir, pid };
130
+ }
131
+ function clearPendingCapture() {
132
+ pendingCapture = null;
133
+ }
134
+ function discardPendingSnapshotsSync() {
135
+ if (pendingCapture === null) {
136
+ return;
137
+ }
138
+ const { appDir, pid } = pendingCapture;
139
+ pendingCapture = null;
140
+ try {
141
+ for (const name of snapshotsWrittenBy(pid, readdirSync(appDir))) {
142
+ try {
143
+ unlinkSync(path.join(appDir, name));
144
+ } catch {
145
+ }
146
+ }
147
+ } catch {
148
+ }
149
+ }
150
+
151
+ // src/build-attribution.ts
152
+ var defaultDeps = {
153
+ diff: diffSnapshotFiles,
154
+ registry: extractModuleRegistry
155
+ };
156
+ async function attributeBuildCapture(result, appDir, onProgress = () => {
157
+ }, deps = defaultDeps) {
158
+ const capture = result.capture;
159
+ if (capture === null) {
160
+ return null;
161
+ }
162
+ onProgress(`diffing what worker ${capture.pid} retained`);
163
+ let diff;
164
+ try {
165
+ diff = await deps.diff(capture.files.baselineFile, capture.files.afterFile);
166
+ } catch (cause) {
167
+ onProgress(
168
+ cause instanceof SnapshotError ? `no attribution: ${cause.message}` : `no attribution: the snapshot pair could not be diffed`
169
+ );
170
+ return null;
171
+ }
172
+ const registry = await deps.registry(path2.join(appDir, ".next", "server"));
173
+ return {
174
+ diff,
175
+ attributed: attributeDiff(diff, registry),
176
+ registrySize: registry.size,
177
+ // Against the captured worker's own peak. `result.peakWorkerRssBytes` is
178
+ // the highest of every worker, which on a multi-worker build can belong to
179
+ // a process these findings say nothing about.
180
+ bracketed: bracketedShare(
181
+ capture.baselineRssBytes,
182
+ capture.afterRssBytes,
183
+ capture.peakRssBytes
184
+ ),
185
+ baselineRssBytes: capture.baselineRssBytes,
186
+ afterRssBytes: capture.afterRssBytes,
187
+ baselineFile: capture.files.baselineFile,
188
+ afterFile: capture.files.afterFile
189
+ };
190
+ }
191
+
33
192
  // src/build-report.ts
34
193
  var MB = 1024 * 1024;
35
194
  var mb = (bytes) => `${(bytes / MB).toFixed(1)} MB`;
@@ -46,7 +205,42 @@ function growthLine(result) {
46
205
  const perPage = result.retentionPerPageBytes === null ? "" : ` (${mbPrecise(result.retentionPerPageBytes)}/page over ${result.pagesGenerated} pages)`;
47
206
  return ` grew ${mb(result.netGrowthBytes)} across the generation phase${perPage}`;
48
207
  }
49
- function formatBuildReport(result) {
208
+ var MAX_REPORTED_FINDINGS = 3;
209
+ function attributionLines(attribution) {
210
+ if (attribution === null) {
211
+ return [];
212
+ }
213
+ const { diff, attributed, bracketed } = attribution;
214
+ const findings = [...diff.grownNodes, ...diff.newNodes];
215
+ if (findings.length === 0) {
216
+ return ["", ` nothing grew between the two snapshots`];
217
+ }
218
+ const lines = [
219
+ "",
220
+ ` what it retained, between ${mb(attribution.baselineRssBytes)} and ${mb(attribution.afterRssBytes)} of worker rss \u2014`,
221
+ ` ${(bracketed * 100).toFixed(0)}% of the growth this run observed. The rest is not`,
222
+ ` attributed: a snapshot taken higher up cannot be parsed back.`
223
+ ];
224
+ if (attribution.registrySize === 0) {
225
+ lines.push(
226
+ ` no module registry resolved, so no owner is named below \u2014 only`,
227
+ ` where the bytes hang`
228
+ );
229
+ }
230
+ lines.push(
231
+ ` snapshots: ${attribution.baselineFile} / ${attribution.afterFile}`
232
+ );
233
+ for (const [index, finding] of findings.slice(0, MAX_REPORTED_FINDINGS).entries()) {
234
+ const owner = attributed.findings[index];
235
+ const name = owner?.source ?? owner?.packageName ?? owner?.owner ?? "unattributed";
236
+ lines.push(
237
+ ` \u21B3 ${finding.name === "" ? "(anonymous)" : finding.name} ${mb(finding.retainedBytes)} \xB7 ${name}`,
238
+ ` ${finding.retainerChain}`
239
+ );
240
+ }
241
+ return lines;
242
+ }
243
+ function formatBuildReport(result, attribution = null) {
50
244
  const lines = [`next-leak build \xB7 ${result.appDir}`, ""];
51
245
  if (result.strippedCapWarning !== null) {
52
246
  lines.push(` \u25B2 ${result.strippedCapWarning}`, "");
@@ -101,6 +295,7 @@ function formatBuildReport(result) {
101
295
  lines.push(` ${result.workers.length} workers ran; the verdict is the worst of them`);
102
296
  }
103
297
  lines.push(
298
+ ...attributionLines(attribution),
104
299
  "",
105
300
  ` peak worker rss ${mb(result.peakWorkerRssBytes)} \xB7 sampled from the process tree, so a`,
106
301
  ` spike shorter than the polling interval is not observed`
@@ -110,11 +305,11 @@ function formatBuildReport(result) {
110
305
 
111
306
  // src/build-run.ts
112
307
  import { spawn } from "child_process";
113
- import path2 from "path";
308
+ import path4 from "path";
114
309
 
115
310
  // src/build-target.ts
116
- import { access, readFile, readdir } from "fs/promises";
117
- import path from "path";
311
+ import { access, readFile, readdir as readdir2 } from "fs/promises";
312
+ import path3 from "path";
118
313
  var CONFIG_NAMES = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"];
119
314
  async function exists(file) {
120
315
  try {
@@ -126,14 +321,14 @@ async function exists(file) {
126
321
  }
127
322
  async function readPackageJson(appDir) {
128
323
  try {
129
- return JSON.parse(await readFile(path.join(appDir, "package.json"), "utf8"));
324
+ return JSON.parse(await readFile(path3.join(appDir, "package.json"), "utf8"));
130
325
  } catch {
131
326
  return null;
132
327
  }
133
328
  }
134
329
  async function usesWorkerThreads(appDir) {
135
330
  for (const name of CONFIG_NAMES) {
136
- const file = path.join(appDir, name);
331
+ const file = path3.join(appDir, name);
137
332
  if (!await exists(file)) {
138
333
  continue;
139
334
  }
@@ -145,10 +340,10 @@ async function usesWorkerThreads(appDir) {
145
340
  return false;
146
341
  }
147
342
  async function validateBuildTarget(appDir) {
148
- const resolved = path.resolve(appDir);
343
+ const resolved = path3.resolve(appDir);
149
344
  let entries;
150
345
  try {
151
- entries = await readdir(resolved);
346
+ entries = await readdir2(resolved);
152
347
  } catch {
153
348
  throw new TargetError("NO_BUILD", `Cannot read ${resolved}.`);
154
349
  }
@@ -319,15 +514,22 @@ async function sampleProcessTable() {
319
514
  // src/build-run.ts
320
515
  var POLL_MS = 500;
321
516
  function spawnNextBuild(appDir, env) {
322
- const nextBin = path2.join(appDir, "node_modules", "next", "dist", "bin", "next");
517
+ const nextBin = path4.join(appDir, "node_modules", "next", "dist", "bin", "next");
323
518
  return spawn(process.execPath, [nextBin, "build"], {
324
519
  cwd: appDir,
325
520
  env,
326
521
  stdio: ["ignore", "pipe", "pipe"]
327
522
  });
328
523
  }
329
- var defaultDeps = {
524
+ var signalWorker = (pid) => {
525
+ try {
526
+ process.kill(pid, "SIGUSR2");
527
+ } catch {
528
+ }
529
+ };
530
+ var defaultDeps2 = {
330
531
  spawnBuild: spawnNextBuild,
532
+ signalWorker,
331
533
  sampleTable: sampleProcessTable,
332
534
  now: () => Date.now(),
333
535
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
@@ -346,6 +548,22 @@ function recordSample(rows, rootPid, atMs, workers, parentSamples) {
346
548
  workers.set(row.pid, series);
347
549
  }
348
550
  }
551
+ async function collectCapture(options) {
552
+ const { appDir, workDir, pid, stage, baselineRssBytes, afterRssBytes } = options;
553
+ if (workDir === void 0 || pid === null) {
554
+ return null;
555
+ }
556
+ if (stage !== "pair-taken" || baselineRssBytes === null || afterRssBytes === null) {
557
+ await discardSnapshots(appDir, pid);
558
+ return null;
559
+ }
560
+ const files = await collectSnapshotPair(appDir, workDir, pid);
561
+ if (files === null) {
562
+ await discardSnapshots(appDir, pid);
563
+ return null;
564
+ }
565
+ return { pid, files, baselineRssBytes, afterRssBytes, peakRssBytes: options.peakRssBytes };
566
+ }
349
567
  function worstWorker(workers) {
350
568
  let worst = null;
351
569
  let worstGrowth = -Infinity;
@@ -359,7 +577,7 @@ function worstWorker(workers) {
359
577
  return worst;
360
578
  }
361
579
  var peakOf = (samples) => samples.reduce((highest, sample) => Math.max(highest, sample.rssBytes), 0);
362
- async function runBuildMeasurement(options, deps = defaultDeps) {
580
+ async function runBuildMeasurement(options, deps = defaultDeps2) {
363
581
  const target = await validateBuildTarget(options.appDir);
364
582
  const progress = options.onProgress ?? (() => {
365
583
  });
@@ -368,8 +586,13 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
368
586
  if (strippedCapWarning !== null) {
369
587
  progress(strippedCapWarning);
370
588
  }
589
+ const capturing = options.workDir !== void 0;
590
+ const buildEnv = capturing ? {
591
+ ...env,
592
+ NODE_OPTIONS: `${env["NODE_OPTIONS"] ?? ""} --heapsnapshot-signal=SIGUSR2`.trim()
593
+ } : env;
371
594
  progress(`building ${target.appDir}`);
372
- const child = deps.spawnBuild(target.appDir, env);
595
+ const child = deps.spawnBuild(target.appDir, buildEnv);
373
596
  registerChild(child);
374
597
  let output = "";
375
598
  const capture = (chunk) => {
@@ -392,6 +615,36 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
392
615
  };
393
616
  options.signal?.addEventListener("abort", abort, { once: true });
394
617
  let samplingFailure = null;
618
+ let capturePid = null;
619
+ let captureStage = "waiting";
620
+ let captureBaselineRss = null;
621
+ let captureAfterRss = null;
622
+ const stepCapture = (rows, rootPid) => {
623
+ const descendants = descendantsOf(rows, rootPid);
624
+ const followed = capturePid === null ? descendants.find(isStaticGenWorker) : descendants.find((row) => row.pid === capturePid);
625
+ if (followed === void 0) {
626
+ return;
627
+ }
628
+ capturePid ??= followed.pid;
629
+ switch (decideCapture(followed.rssBytes, captureStage, captureBaselineRss)) {
630
+ case "take-baseline":
631
+ deps.signalWorker(followed.pid);
632
+ captureStage = "baseline-taken";
633
+ captureBaselineRss = followed.rssBytes;
634
+ registerPendingCapture(target.appDir, followed.pid);
635
+ return;
636
+ case "take-after":
637
+ deps.signalWorker(followed.pid);
638
+ captureStage = "pair-taken";
639
+ captureAfterRss = followed.rssBytes;
640
+ return;
641
+ case "give-up":
642
+ captureStage = "missed";
643
+ return;
644
+ case "wait":
645
+ return;
646
+ }
647
+ };
395
648
  const polling = (async () => {
396
649
  while (!finished) {
397
650
  await deps.sleep(POLL_MS);
@@ -404,6 +657,9 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
404
657
  return;
405
658
  }
406
659
  recordSample(sample.rows, child.pid, deps.now() - startedAt, workerSamples, parentSamples);
660
+ if (capturing) {
661
+ stepCapture(sample.rows, child.pid);
662
+ }
407
663
  }
408
664
  })();
409
665
  const exitCode = await exit;
@@ -412,6 +668,17 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
412
668
  unregisterChild(child);
413
669
  const workers = [...workerSamples].map(([pid, samples]) => ({ pid, samples }));
414
670
  const heapExhausted = diedOfHeapExhaustion(output);
671
+ const capturedSamples = capturePid === null ? void 0 : workerSamples.get(capturePid);
672
+ const workerCapture = await collectCapture({
673
+ appDir: target.appDir,
674
+ workDir: options.workDir,
675
+ pid: capturePid,
676
+ stage: captureStage,
677
+ baselineRssBytes: captureBaselineRss,
678
+ afterRssBytes: captureAfterRss,
679
+ peakRssBytes: capturedSamples === void 0 ? 0 : peakOf(capturedSamples)
680
+ });
681
+ clearPendingCapture();
415
682
  const pagesGenerated = pagesGeneratedFrom(output);
416
683
  if (workers.length === 0) {
417
684
  return {
@@ -430,6 +697,7 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
430
697
  pagesGenerated,
431
698
  retentionPerPageBytes: null,
432
699
  heapExhausted,
700
+ capture: workerCapture,
433
701
  strippedCapWarning,
434
702
  exitCode,
435
703
  output
@@ -458,6 +726,9 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
458
726
  pagesGenerated,
459
727
  retentionPerPageBytes: measured && growthBytes > 0 && pagesGenerated !== null && pagesGenerated > 0 ? growthBytes / pagesGenerated : null,
460
728
  heapExhausted,
729
+ // A pair belonging to a worker other than the one judged supports no claim
730
+ // about the verdict above, so it is dropped rather than reported next to it.
731
+ capture: workerCapture !== null && workerCapture.pid === worst?.pid ? workerCapture : null,
461
732
  strippedCapWarning,
462
733
  exitCode,
463
734
  output
@@ -491,6 +762,7 @@ function installInterruptHandlers() {
491
762
  process.on(signalName, () => {
492
763
  interrupts += 1;
493
764
  if (interrupts > 1) {
765
+ discardPendingSnapshotsSync();
494
766
  process.exit(130);
495
767
  }
496
768
  console.error("\n\xB7 interrupted \u2014 stopping the measured process and writing a partial run.json");
@@ -523,14 +795,31 @@ function handleNonRunCommand(parsed) {
523
795
  }
524
796
  return false;
525
797
  }
526
- async function runBuildCommand(appDir) {
798
+ async function runBuildCommand(appDir, outputDir, attribute) {
527
799
  const aborter = installInterruptHandlers();
800
+ const workDir = path5.join(
801
+ outputDir ?? path5.join(appDir, ".next-leak"),
802
+ (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")
803
+ );
804
+ await mkdir(workDir, { recursive: true });
528
805
  const result = await runBuildMeasurement({
529
806
  appDir,
807
+ // Capture is opt-in because signalling a worker for a heap snapshot can
808
+ // stall the build it is measuring. Observed on the #97464 reproduction:
809
+ // the second signal landed and the worker sat frozen for fourteen minutes
810
+ // with an empty snapshot file on disk, and on an earlier run for ninety.
811
+ // A curve and a verdict are what this command promises; naming the objects
812
+ // is worth asking for, not worth risking every run for.
813
+ ...attribute && { workDir },
530
814
  signal: aborter.signal,
531
815
  onProgress: (message) => console.error(`\xB7 ${message}`)
532
816
  });
533
- console.log(formatBuildReport(result));
817
+ const attribution = await attributeBuildCapture(
818
+ result,
819
+ appDir,
820
+ (message) => console.error(`\xB7 ${message}`)
821
+ );
822
+ console.log(formatBuildReport(result, attribution));
534
823
  if (aborter.signal.aborted) {
535
824
  process.exitCode = 130;
536
825
  }
@@ -543,7 +832,7 @@ async function writeConfigSkeleton(appDir) {
543
832
  console.log("no route needs sample params \u2014 nothing to write");
544
833
  return;
545
834
  }
546
- const file = path3.join(target.appDir, "next-leak.config.json");
835
+ const file = path5.join(target.appDir, "next-leak.config.json");
547
836
  try {
548
837
  await writeFile(file, `${skeleton}
549
838
  `, { flag: "wx" });
@@ -567,7 +856,11 @@ async function main() {
567
856
  return;
568
857
  }
569
858
  if (parsed.kind === "build") {
570
- await runBuildCommand(parsed.options.appDir);
859
+ await runBuildCommand(
860
+ parsed.options.appDir,
861
+ parsed.options.output,
862
+ parsed.options.attributeBuild
863
+ );
571
864
  return;
572
865
  }
573
866
  if (parsed.kind !== "run") {
package/dist/index.js CHANGED
@@ -39,7 +39,7 @@ import {
39
39
  sourceIndexAt,
40
40
  summarizeBaseline,
41
41
  validateTarget
42
- } from "./chunk-BXDUPN6Z.js";
42
+ } from "./chunk-LFGWGM5Z.js";
43
43
  import {
44
44
  renderHtmlReport
45
45
  } from "./chunk-RS3AO7HB.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Find out whether your Next.js app actually leaks memory — how much, on which route, and whose fault it is.",
5
5
  "keywords": [
6
6
  "nextjs",