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/dist/cli-args.d.ts
CHANGED
|
@@ -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-
|
|
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
|
|
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
|
-
|
|
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
|
|
308
|
+
import path4 from "path";
|
|
114
309
|
|
|
115
310
|
// src/build-target.ts
|
|
116
|
-
import { access, readFile, readdir } from "fs/promises";
|
|
117
|
-
import
|
|
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(
|
|
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 =
|
|
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 =
|
|
343
|
+
const resolved = path3.resolve(appDir);
|
|
149
344
|
let entries;
|
|
150
345
|
try {
|
|
151
|
-
entries = await
|
|
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 =
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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