next-leak 0.7.0 → 0.9.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 +22 -5
- 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-RS3AO7HB.js → chunk-T7LM4Z33.js} +8 -0
- package/dist/{chunk-BXDUPN6Z.js → chunk-UVA77PMC.js} +1544 -1434
- package/dist/cli-args.d.ts +3 -0
- package/dist/cli.js +335 -20
- package/dist/{html-report-TN7PYVU4.js → html-report-5XMR3EXE.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/launcher.d.ts +20 -1
- package/dist/load.d.ts +19 -0
- package/dist/ritual.d.ts +24 -0
- package/dist/route-guidance.d.ts +19 -0
- package/dist/runner.d.ts +22 -0
- 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-UVA77PMC.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}`, "");
|
|
@@ -69,6 +263,21 @@ function formatBuildReport(result) {
|
|
|
69
263
|
);
|
|
70
264
|
return lines.join("\n");
|
|
71
265
|
}
|
|
266
|
+
function parentPeakLines(result2) {
|
|
267
|
+
const peak = result2.parentSamples.reduce(
|
|
268
|
+
(highest, sample) => Math.max(highest, sample.rssBytes),
|
|
269
|
+
0
|
|
270
|
+
);
|
|
271
|
+
if (peak === 0) {
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
const last = result2.parentSamples[result2.parentSamples.length - 1];
|
|
275
|
+
const ended = last === void 0 ? 0 : last.rssBytes;
|
|
276
|
+
return [
|
|
277
|
+
` the build's own process peaked at ${mb(peak)} (ended at ${mb(ended)}) \u2014 reported, not judged:`,
|
|
278
|
+
` it sheds while workers climb, so it is never added to the figure above`
|
|
279
|
+
];
|
|
280
|
+
}
|
|
72
281
|
if (result.status === "nothing-to-measure") {
|
|
73
282
|
lines.push(
|
|
74
283
|
` \u2013 no static-generation worker ran, so there was nothing to measure`,
|
|
@@ -101,6 +310,8 @@ function formatBuildReport(result) {
|
|
|
101
310
|
lines.push(` ${result.workers.length} workers ran; the verdict is the worst of them`);
|
|
102
311
|
}
|
|
103
312
|
lines.push(
|
|
313
|
+
...attributionLines(attribution),
|
|
314
|
+
...parentPeakLines(result),
|
|
104
315
|
"",
|
|
105
316
|
` peak worker rss ${mb(result.peakWorkerRssBytes)} \xB7 sampled from the process tree, so a`,
|
|
106
317
|
` spike shorter than the polling interval is not observed`
|
|
@@ -110,11 +321,11 @@ function formatBuildReport(result) {
|
|
|
110
321
|
|
|
111
322
|
// src/build-run.ts
|
|
112
323
|
import { spawn } from "child_process";
|
|
113
|
-
import
|
|
324
|
+
import path4 from "path";
|
|
114
325
|
|
|
115
326
|
// src/build-target.ts
|
|
116
|
-
import { access, readFile, readdir } from "fs/promises";
|
|
117
|
-
import
|
|
327
|
+
import { access, readFile, readdir as readdir2 } from "fs/promises";
|
|
328
|
+
import path3 from "path";
|
|
118
329
|
var CONFIG_NAMES = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"];
|
|
119
330
|
async function exists(file) {
|
|
120
331
|
try {
|
|
@@ -126,14 +337,14 @@ async function exists(file) {
|
|
|
126
337
|
}
|
|
127
338
|
async function readPackageJson(appDir) {
|
|
128
339
|
try {
|
|
129
|
-
return JSON.parse(await readFile(
|
|
340
|
+
return JSON.parse(await readFile(path3.join(appDir, "package.json"), "utf8"));
|
|
130
341
|
} catch {
|
|
131
342
|
return null;
|
|
132
343
|
}
|
|
133
344
|
}
|
|
134
345
|
async function usesWorkerThreads(appDir) {
|
|
135
346
|
for (const name of CONFIG_NAMES) {
|
|
136
|
-
const file =
|
|
347
|
+
const file = path3.join(appDir, name);
|
|
137
348
|
if (!await exists(file)) {
|
|
138
349
|
continue;
|
|
139
350
|
}
|
|
@@ -145,10 +356,10 @@ async function usesWorkerThreads(appDir) {
|
|
|
145
356
|
return false;
|
|
146
357
|
}
|
|
147
358
|
async function validateBuildTarget(appDir) {
|
|
148
|
-
const resolved =
|
|
359
|
+
const resolved = path3.resolve(appDir);
|
|
149
360
|
let entries;
|
|
150
361
|
try {
|
|
151
|
-
entries = await
|
|
362
|
+
entries = await readdir2(resolved);
|
|
152
363
|
} catch {
|
|
153
364
|
throw new TargetError("NO_BUILD", `Cannot read ${resolved}.`);
|
|
154
365
|
}
|
|
@@ -319,15 +530,22 @@ async function sampleProcessTable() {
|
|
|
319
530
|
// src/build-run.ts
|
|
320
531
|
var POLL_MS = 500;
|
|
321
532
|
function spawnNextBuild(appDir, env) {
|
|
322
|
-
const nextBin =
|
|
533
|
+
const nextBin = path4.join(appDir, "node_modules", "next", "dist", "bin", "next");
|
|
323
534
|
return spawn(process.execPath, [nextBin, "build"], {
|
|
324
535
|
cwd: appDir,
|
|
325
536
|
env,
|
|
326
537
|
stdio: ["ignore", "pipe", "pipe"]
|
|
327
538
|
});
|
|
328
539
|
}
|
|
329
|
-
var
|
|
540
|
+
var signalWorker = (pid) => {
|
|
541
|
+
try {
|
|
542
|
+
process.kill(pid, "SIGUSR2");
|
|
543
|
+
} catch {
|
|
544
|
+
}
|
|
545
|
+
};
|
|
546
|
+
var defaultDeps2 = {
|
|
330
547
|
spawnBuild: spawnNextBuild,
|
|
548
|
+
signalWorker,
|
|
331
549
|
sampleTable: sampleProcessTable,
|
|
332
550
|
now: () => Date.now(),
|
|
333
551
|
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
@@ -346,6 +564,22 @@ function recordSample(rows, rootPid, atMs, workers, parentSamples) {
|
|
|
346
564
|
workers.set(row.pid, series);
|
|
347
565
|
}
|
|
348
566
|
}
|
|
567
|
+
async function collectCapture(options) {
|
|
568
|
+
const { appDir, workDir, pid, stage, baselineRssBytes, afterRssBytes } = options;
|
|
569
|
+
if (workDir === void 0 || pid === null) {
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
if (stage !== "pair-taken" || baselineRssBytes === null || afterRssBytes === null) {
|
|
573
|
+
await discardSnapshots(appDir, pid);
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
const files = await collectSnapshotPair(appDir, workDir, pid);
|
|
577
|
+
if (files === null) {
|
|
578
|
+
await discardSnapshots(appDir, pid);
|
|
579
|
+
return null;
|
|
580
|
+
}
|
|
581
|
+
return { pid, files, baselineRssBytes, afterRssBytes, peakRssBytes: options.peakRssBytes };
|
|
582
|
+
}
|
|
349
583
|
function worstWorker(workers) {
|
|
350
584
|
let worst = null;
|
|
351
585
|
let worstGrowth = -Infinity;
|
|
@@ -359,7 +593,7 @@ function worstWorker(workers) {
|
|
|
359
593
|
return worst;
|
|
360
594
|
}
|
|
361
595
|
var peakOf = (samples) => samples.reduce((highest, sample) => Math.max(highest, sample.rssBytes), 0);
|
|
362
|
-
async function runBuildMeasurement(options, deps =
|
|
596
|
+
async function runBuildMeasurement(options, deps = defaultDeps2) {
|
|
363
597
|
const target = await validateBuildTarget(options.appDir);
|
|
364
598
|
const progress = options.onProgress ?? (() => {
|
|
365
599
|
});
|
|
@@ -368,8 +602,13 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
|
|
|
368
602
|
if (strippedCapWarning !== null) {
|
|
369
603
|
progress(strippedCapWarning);
|
|
370
604
|
}
|
|
605
|
+
const capturing = options.workDir !== void 0;
|
|
606
|
+
const buildEnv = capturing ? {
|
|
607
|
+
...env,
|
|
608
|
+
NODE_OPTIONS: `${env["NODE_OPTIONS"] ?? ""} --heapsnapshot-signal=SIGUSR2`.trim()
|
|
609
|
+
} : env;
|
|
371
610
|
progress(`building ${target.appDir}`);
|
|
372
|
-
const child = deps.spawnBuild(target.appDir,
|
|
611
|
+
const child = deps.spawnBuild(target.appDir, buildEnv);
|
|
373
612
|
registerChild(child);
|
|
374
613
|
let output = "";
|
|
375
614
|
const capture = (chunk) => {
|
|
@@ -392,6 +631,36 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
|
|
|
392
631
|
};
|
|
393
632
|
options.signal?.addEventListener("abort", abort, { once: true });
|
|
394
633
|
let samplingFailure = null;
|
|
634
|
+
let capturePid = null;
|
|
635
|
+
let captureStage = "waiting";
|
|
636
|
+
let captureBaselineRss = null;
|
|
637
|
+
let captureAfterRss = null;
|
|
638
|
+
const stepCapture = (rows, rootPid) => {
|
|
639
|
+
const descendants = descendantsOf(rows, rootPid);
|
|
640
|
+
const followed = capturePid === null ? descendants.find(isStaticGenWorker) : descendants.find((row) => row.pid === capturePid);
|
|
641
|
+
if (followed === void 0) {
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
capturePid ??= followed.pid;
|
|
645
|
+
switch (decideCapture(followed.rssBytes, captureStage, captureBaselineRss)) {
|
|
646
|
+
case "take-baseline":
|
|
647
|
+
deps.signalWorker(followed.pid);
|
|
648
|
+
captureStage = "baseline-taken";
|
|
649
|
+
captureBaselineRss = followed.rssBytes;
|
|
650
|
+
registerPendingCapture(target.appDir, followed.pid);
|
|
651
|
+
return;
|
|
652
|
+
case "take-after":
|
|
653
|
+
deps.signalWorker(followed.pid);
|
|
654
|
+
captureStage = "pair-taken";
|
|
655
|
+
captureAfterRss = followed.rssBytes;
|
|
656
|
+
return;
|
|
657
|
+
case "give-up":
|
|
658
|
+
captureStage = "missed";
|
|
659
|
+
return;
|
|
660
|
+
case "wait":
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
};
|
|
395
664
|
const polling = (async () => {
|
|
396
665
|
while (!finished) {
|
|
397
666
|
await deps.sleep(POLL_MS);
|
|
@@ -404,6 +673,9 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
|
|
|
404
673
|
return;
|
|
405
674
|
}
|
|
406
675
|
recordSample(sample.rows, child.pid, deps.now() - startedAt, workerSamples, parentSamples);
|
|
676
|
+
if (capturing) {
|
|
677
|
+
stepCapture(sample.rows, child.pid);
|
|
678
|
+
}
|
|
407
679
|
}
|
|
408
680
|
})();
|
|
409
681
|
const exitCode = await exit;
|
|
@@ -412,6 +684,17 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
|
|
|
412
684
|
unregisterChild(child);
|
|
413
685
|
const workers = [...workerSamples].map(([pid, samples]) => ({ pid, samples }));
|
|
414
686
|
const heapExhausted = diedOfHeapExhaustion(output);
|
|
687
|
+
const capturedSamples = capturePid === null ? void 0 : workerSamples.get(capturePid);
|
|
688
|
+
const workerCapture = await collectCapture({
|
|
689
|
+
appDir: target.appDir,
|
|
690
|
+
workDir: options.workDir,
|
|
691
|
+
pid: capturePid,
|
|
692
|
+
stage: captureStage,
|
|
693
|
+
baselineRssBytes: captureBaselineRss,
|
|
694
|
+
afterRssBytes: captureAfterRss,
|
|
695
|
+
peakRssBytes: capturedSamples === void 0 ? 0 : peakOf(capturedSamples)
|
|
696
|
+
});
|
|
697
|
+
clearPendingCapture();
|
|
415
698
|
const pagesGenerated = pagesGeneratedFrom(output);
|
|
416
699
|
if (workers.length === 0) {
|
|
417
700
|
return {
|
|
@@ -430,6 +713,7 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
|
|
|
430
713
|
pagesGenerated,
|
|
431
714
|
retentionPerPageBytes: null,
|
|
432
715
|
heapExhausted,
|
|
716
|
+
capture: workerCapture,
|
|
433
717
|
strippedCapWarning,
|
|
434
718
|
exitCode,
|
|
435
719
|
output
|
|
@@ -458,6 +742,9 @@ async function runBuildMeasurement(options, deps = defaultDeps) {
|
|
|
458
742
|
pagesGenerated,
|
|
459
743
|
retentionPerPageBytes: measured && growthBytes > 0 && pagesGenerated !== null && pagesGenerated > 0 ? growthBytes / pagesGenerated : null,
|
|
460
744
|
heapExhausted,
|
|
745
|
+
// A pair belonging to a worker other than the one judged supports no claim
|
|
746
|
+
// about the verdict above, so it is dropped rather than reported next to it.
|
|
747
|
+
capture: workerCapture !== null && workerCapture.pid === worst?.pid ? workerCapture : null,
|
|
461
748
|
strippedCapWarning,
|
|
462
749
|
exitCode,
|
|
463
750
|
output
|
|
@@ -491,6 +778,7 @@ function installInterruptHandlers() {
|
|
|
491
778
|
process.on(signalName, () => {
|
|
492
779
|
interrupts += 1;
|
|
493
780
|
if (interrupts > 1) {
|
|
781
|
+
discardPendingSnapshotsSync();
|
|
494
782
|
process.exit(130);
|
|
495
783
|
}
|
|
496
784
|
console.error("\n\xB7 interrupted \u2014 stopping the measured process and writing a partial run.json");
|
|
@@ -523,14 +811,31 @@ function handleNonRunCommand(parsed) {
|
|
|
523
811
|
}
|
|
524
812
|
return false;
|
|
525
813
|
}
|
|
526
|
-
async function runBuildCommand(appDir) {
|
|
814
|
+
async function runBuildCommand(appDir, outputDir, attribute) {
|
|
527
815
|
const aborter = installInterruptHandlers();
|
|
816
|
+
const workDir = path5.join(
|
|
817
|
+
outputDir ?? path5.join(appDir, ".next-leak"),
|
|
818
|
+
(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")
|
|
819
|
+
);
|
|
820
|
+
await mkdir(workDir, { recursive: true });
|
|
528
821
|
const result = await runBuildMeasurement({
|
|
529
822
|
appDir,
|
|
823
|
+
// Capture is opt-in because signalling a worker for a heap snapshot can
|
|
824
|
+
// stall the build it is measuring. Observed on the #97464 reproduction:
|
|
825
|
+
// the second signal landed and the worker sat frozen for fourteen minutes
|
|
826
|
+
// with an empty snapshot file on disk, and on an earlier run for ninety.
|
|
827
|
+
// A curve and a verdict are what this command promises; naming the objects
|
|
828
|
+
// is worth asking for, not worth risking every run for.
|
|
829
|
+
...attribute && { workDir },
|
|
530
830
|
signal: aborter.signal,
|
|
531
831
|
onProgress: (message) => console.error(`\xB7 ${message}`)
|
|
532
832
|
});
|
|
533
|
-
|
|
833
|
+
const attribution = await attributeBuildCapture(
|
|
834
|
+
result,
|
|
835
|
+
appDir,
|
|
836
|
+
(message) => console.error(`\xB7 ${message}`)
|
|
837
|
+
);
|
|
838
|
+
console.log(formatBuildReport(result, attribution));
|
|
534
839
|
if (aborter.signal.aborted) {
|
|
535
840
|
process.exitCode = 130;
|
|
536
841
|
}
|
|
@@ -543,7 +848,7 @@ async function writeConfigSkeleton(appDir) {
|
|
|
543
848
|
console.log("no route needs sample params \u2014 nothing to write");
|
|
544
849
|
return;
|
|
545
850
|
}
|
|
546
|
-
const file =
|
|
851
|
+
const file = path5.join(target.appDir, "next-leak.config.json");
|
|
547
852
|
try {
|
|
548
853
|
await writeFile(file, `${skeleton}
|
|
549
854
|
`, { flag: "wx" });
|
|
@@ -559,6 +864,10 @@ ${skeleton}`
|
|
|
559
864
|
console.log(`wrote ${file}`);
|
|
560
865
|
if (hasPlaceholders(skeleton)) {
|
|
561
866
|
console.log("replace each REPLACE-ME with a value that exists in your app");
|
|
867
|
+
} else {
|
|
868
|
+
console.log(
|
|
869
|
+
"`{n}` gives every request a different value: a prerendered one serves the warm cache and reads as flat whatever the route retains. Use `{n%200}` to revisit a fixed set of keys, and drop the marker only if the app 404s on params it never prerendered."
|
|
870
|
+
);
|
|
562
871
|
}
|
|
563
872
|
}
|
|
564
873
|
async function main() {
|
|
@@ -567,7 +876,11 @@ async function main() {
|
|
|
567
876
|
return;
|
|
568
877
|
}
|
|
569
878
|
if (parsed.kind === "build") {
|
|
570
|
-
await runBuildCommand(
|
|
879
|
+
await runBuildCommand(
|
|
880
|
+
parsed.options.appDir,
|
|
881
|
+
parsed.options.output,
|
|
882
|
+
parsed.options.attributeBuild
|
|
883
|
+
);
|
|
571
884
|
return;
|
|
572
885
|
}
|
|
573
886
|
if (parsed.kind !== "run") {
|
|
@@ -606,7 +919,9 @@ async function main() {
|
|
|
606
919
|
process.exitCode = 130;
|
|
607
920
|
return;
|
|
608
921
|
}
|
|
609
|
-
if (!report.routes.some(
|
|
922
|
+
if (!report.routes.some(
|
|
923
|
+
(route) => route.status === "measured" || route.status === "died-of-heap"
|
|
924
|
+
)) {
|
|
610
925
|
console.error("error: no route was measured \u2014 nothing above is a verdict about your app");
|
|
611
926
|
process.exitCode = 1;
|
|
612
927
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
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);
|
|
2
2
|
import {
|
|
3
3
|
renderHtmlReport
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-T7LM4Z33.js";
|
|
5
5
|
import "./chunk-EJ26OBZI.js";
|
|
6
6
|
import "./chunk-XHPUAMJG.js";
|
|
7
7
|
import "./chunk-6XYFBOL2.js";
|
package/dist/index.js
CHANGED
|
@@ -39,10 +39,10 @@ import {
|
|
|
39
39
|
sourceIndexAt,
|
|
40
40
|
summarizeBaseline,
|
|
41
41
|
validateTarget
|
|
42
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-UVA77PMC.js";
|
|
43
43
|
import {
|
|
44
44
|
renderHtmlReport
|
|
45
|
-
} from "./chunk-
|
|
45
|
+
} from "./chunk-T7LM4Z33.js";
|
|
46
46
|
import {
|
|
47
47
|
classifyTrend
|
|
48
48
|
} from "./chunk-EJ26OBZI.js";
|
package/dist/launcher.d.ts
CHANGED
|
@@ -31,11 +31,23 @@ export type LaunchedApp = {
|
|
|
31
31
|
* a child that died mid-run surfaces as "fetch failed", which reads like a
|
|
32
32
|
* bug in the tool and hides the finding — most often that the app blew
|
|
33
33
|
* through the heap limit the run configured.
|
|
34
|
+
*
|
|
35
|
+
* `heapExhausted` separates that one death from every other, because it is
|
|
36
|
+
* the only one the run is allowed to call a verdict rather than a failure.
|
|
34
37
|
*/
|
|
35
|
-
explainExit: () =>
|
|
38
|
+
explainExit: () => RuntimeDeath | null;
|
|
36
39
|
/** SIGTERM, then SIGKILL after a grace period. Resolves when the child exited. */
|
|
37
40
|
close: () => Promise<void>;
|
|
38
41
|
};
|
|
42
|
+
/**
|
|
43
|
+
* How the measured process died. `heapExhausted` is the one death that is a
|
|
44
|
+
* measurement: the app did not fit in the limit the run gave it, which is the
|
|
45
|
+
* finding the tool exists to produce. Every other death is a failure.
|
|
46
|
+
*/
|
|
47
|
+
export type RuntimeDeath = {
|
|
48
|
+
reason: string;
|
|
49
|
+
heapExhausted: boolean;
|
|
50
|
+
};
|
|
39
51
|
export declare class LaunchError extends Error {
|
|
40
52
|
constructor(message: string);
|
|
41
53
|
}
|
|
@@ -61,6 +73,13 @@ export declare function explainStartupFailure(stderr: string): string;
|
|
|
61
73
|
* is a finding rather than an accident: the app did not fit in the limit the
|
|
62
74
|
* run gave it.
|
|
63
75
|
*/
|
|
76
|
+
/**
|
|
77
|
+
* Whether a stderr window carries V8's own fatal heap message. The build path
|
|
78
|
+
* asks the same question of build output in `build-verdict.ts`; the two are
|
|
79
|
+
* deliberately not shared yet, because `launcher` importing the build verdict
|
|
80
|
+
* would couple the runtime path to it for one regex.
|
|
81
|
+
*/
|
|
82
|
+
export declare function stderrShowsHeapExhaustion(stderr: string): boolean;
|
|
64
83
|
export declare function explainRuntimeFailure(stderr: string, maxOldSpaceMb: number): string;
|
|
65
84
|
/**
|
|
66
85
|
* Spawns the measured server in a fresh child process with GC exposed and the
|
package/dist/load.d.ts
CHANGED
|
@@ -24,5 +24,24 @@ export declare class LoadError extends Error {
|
|
|
24
24
|
readonly result: LoadPhaseResult;
|
|
25
25
|
constructor(message: string, result: LoadPhaseResult);
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether the app answers for params it never prerendered.
|
|
29
|
+
*
|
|
30
|
+
* A varying sample value is what makes a keyed leak visible, and on an app with
|
|
31
|
+
* a closed param set (`generateStaticParams` plus `dynamicParams = false`) it is
|
|
32
|
+
* also what makes every request a 404. The two look identical in the counters —
|
|
33
|
+
* a wall of non-2xx — and the honest answer is not a guess: request one value
|
|
34
|
+
* the marker would produce and one the app is known to serve, and compare.
|
|
35
|
+
*
|
|
36
|
+
* Measured on an app with `post-0..post-2` prerendered and `dynamicParams` off:
|
|
37
|
+
* `/post-0` answers 200, `/post-3` answers 404. Without this probe the run told
|
|
38
|
+
* the user the fault was in their route, and sent them to inspect a route that
|
|
39
|
+
* was working correctly.
|
|
40
|
+
*
|
|
41
|
+
* Note this branch cannot occur under `cacheComponents`: Next rejects
|
|
42
|
+
* `dynamicParams` at build time when it is enabled, so those apps always render
|
|
43
|
+
* on demand.
|
|
44
|
+
*/
|
|
45
|
+
export declare function describeUnprerenderedParams(url: string): Promise<string | null>;
|
|
27
46
|
/** Runs one bounded load phase and fails when the error budget is exceeded. */
|
|
28
47
|
export declare function runLoadPhase(options: LoadPhaseOptions): Promise<LoadPhaseResult>;
|
package/dist/ritual.d.ts
CHANGED
|
@@ -77,6 +77,30 @@ export type PeakSample = {
|
|
|
77
77
|
/** Readings taken; 0 means the poller never got one. */
|
|
78
78
|
polls: number;
|
|
79
79
|
};
|
|
80
|
+
/** What survived a process that ran out of heap partway through a run. */
|
|
81
|
+
export type HeapExhaustedEvidence = {
|
|
82
|
+
/** Post-GC readings taken before the death: baseline first, then per cycle. */
|
|
83
|
+
memorySamples: HeapSample[];
|
|
84
|
+
peaks: PeakSample[];
|
|
85
|
+
unreclaimedSamples: HeapSample[];
|
|
86
|
+
/** Cycles that finished. Zero means it died inside the first one. */
|
|
87
|
+
cyclesCompleted: number;
|
|
88
|
+
cyclesRequested: number;
|
|
89
|
+
requestsPerCycle: number;
|
|
90
|
+
baselineSnapshot: string;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* The measured process ran out of heap mid-run.
|
|
94
|
+
*
|
|
95
|
+
* Thrown rather than returned because there is no `RitualResult` to build: no
|
|
96
|
+
* after-snapshot was taken and the trend has nothing complete to classify. It
|
|
97
|
+
* is still a finding, not a failure — see `explainExit` in `launcher.ts` — and
|
|
98
|
+
* the evidence it carries is what the report shows instead of a curve.
|
|
99
|
+
*/
|
|
100
|
+
export declare class HeapExhaustedError extends Error {
|
|
101
|
+
readonly evidence: HeapExhaustedEvidence;
|
|
102
|
+
constructor(message: string, evidence: HeapExhaustedEvidence);
|
|
103
|
+
}
|
|
80
104
|
export type RitualResult = {
|
|
81
105
|
route: string;
|
|
82
106
|
/** Wall-clock per phase, so slow runs can be explained instead of guessed. */
|