next-leak 0.5.1 → 0.7.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 +129 -22
- package/dist/abandon-load.d.ts +12 -2
- package/dist/build-report.d.ts +11 -0
- package/dist/build-run.d.ts +58 -0
- package/dist/build-target.d.ts +13 -0
- package/dist/build-verdict.d.ts +80 -0
- package/dist/{chunk-KJFYTJCF.js → chunk-BXDUPN6Z.js} +867 -420
- package/dist/{chunk-2FZXZLZW.js → chunk-EJ26OBZI.js} +36 -5
- package/dist/{chunk-NNDGXIGQ.js → chunk-RS3AO7HB.js} +1 -1
- package/dist/cli-args.d.ts +9 -0
- package/dist/cli.js +508 -6
- package/dist/confidence.d.ts +9 -1
- package/dist/{html-report-X2LUOZRH.js → html-report-TN7PYVU4.js} +2 -2
- package/dist/index.js +3 -3
- package/dist/isr.d.ts +39 -0
- package/dist/launcher.d.ts +8 -0
- package/dist/manifests.d.ts +20 -0
- package/dist/process-tree.d.ts +44 -0
- package/dist/report.d.ts +0 -1
- package/dist/ritual.d.ts +25 -1
- package/dist/route-config.d.ts +23 -5
- package/dist/route-guidance.d.ts +21 -0
- package/dist/runner.d.ts +49 -0
- package/dist/target.d.ts +6 -1
- package/dist/unreclaimed-retention.d.ts +64 -0
- package/package.json +6 -2
|
@@ -8,6 +8,7 @@ function minGrowthFor(requestsPerCycle) {
|
|
|
8
8
|
}
|
|
9
9
|
var STEPWISE_MIN_GROWING_CYCLES = 2;
|
|
10
10
|
var STEPWISE_MAX_DRAWDOWN_RATIO = 0.1;
|
|
11
|
+
var ACQUITTAL_MAX_GROWTH_MULTIPLE = 8;
|
|
11
12
|
function maxDrawdown(samples) {
|
|
12
13
|
let peak = samples[1] ?? 0;
|
|
13
14
|
let worst = 0;
|
|
@@ -28,6 +29,10 @@ function isStepwiseGrowth(samples, deltas, growingCycles, mean, minGrowth) {
|
|
|
28
29
|
}
|
|
29
30
|
return maxDrawdown(samples) <= netGrowth * STEPWISE_MAX_DRAWDOWN_RATIO;
|
|
30
31
|
}
|
|
32
|
+
function isTooLargeToAcquit(deltas, mean, minGrowth) {
|
|
33
|
+
const netGrowth = deltas.reduce((sum, delta) => sum + delta, 0);
|
|
34
|
+
return mean >= minGrowth * ACQUITTAL_MAX_GROWTH_MULTIPLE && netGrowth > 0;
|
|
35
|
+
}
|
|
31
36
|
function classifyTrend(samples, options = {}) {
|
|
32
37
|
const minGrowth = options.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR;
|
|
33
38
|
if (samples.length < 4) {
|
|
@@ -53,6 +58,9 @@ function classifyTrend(samples, options = {}) {
|
|
|
53
58
|
return { verdict: "leak", growthPerCycle: mean, deltas, source: "heap" };
|
|
54
59
|
}
|
|
55
60
|
if (anyFlatOrDown || mean < minGrowth) {
|
|
61
|
+
if (isTooLargeToAcquit(deltas, mean, minGrowth)) {
|
|
62
|
+
return { verdict: "inconclusive", growthPerCycle: mean, deltas, source: "heap" };
|
|
63
|
+
}
|
|
56
64
|
return { verdict: "stable", growthPerCycle: mean, deltas, source: "heap" };
|
|
57
65
|
}
|
|
58
66
|
return { verdict: "inconclusive", growthPerCycle: mean, deltas, source: "heap" };
|
|
@@ -105,7 +113,7 @@ function settleWarnings(outcomes) {
|
|
|
105
113
|
}
|
|
106
114
|
return warnings;
|
|
107
115
|
}
|
|
108
|
-
function abandonmentWarnings(outcome) {
|
|
116
|
+
function abandonmentWarnings(outcome, abandonFrom) {
|
|
109
117
|
const abandoned = outcome.abandoned ?? 0;
|
|
110
118
|
if (outcome.sent > 0 && abandoned < outcome.sent * ABANDON_EFFECTIVE_FLOOR) {
|
|
111
119
|
return [{
|
|
@@ -113,6 +121,9 @@ function abandonmentWarnings(outcome) {
|
|
|
113
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`
|
|
114
122
|
}];
|
|
115
123
|
}
|
|
124
|
+
if (abandonFrom === "request") {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
116
127
|
const midStream = outcome.abandonedMidStream ?? 0;
|
|
117
128
|
if (abandoned > 0 && midStream < abandoned * MID_STREAM_FLOOR) {
|
|
118
129
|
return [{
|
|
@@ -122,11 +133,11 @@ function abandonmentWarnings(outcome) {
|
|
|
122
133
|
}
|
|
123
134
|
return [];
|
|
124
135
|
}
|
|
125
|
-
function loadWarnings(outcomes, abandonAfterMs) {
|
|
136
|
+
function loadWarnings(outcomes, abandonAfterMs, abandonFrom) {
|
|
126
137
|
const warnings = [];
|
|
127
138
|
for (const outcome of outcomes) {
|
|
128
139
|
if (abandonAfterMs !== void 0) {
|
|
129
|
-
warnings.push(...abandonmentWarnings(outcome));
|
|
140
|
+
warnings.push(...abandonmentWarnings(outcome, abandonFrom));
|
|
130
141
|
continue;
|
|
131
142
|
}
|
|
132
143
|
const landed = outcome.ok2xx ?? 0;
|
|
@@ -184,6 +195,25 @@ function heapCeilingWarnings(input) {
|
|
|
184
195
|
detail: `the heap peaked at ${mb(peak)} against a ${capMb} MB cap (${pct(peak, capBytes)}) \u2014 the curve may have been clipped by the ceiling rather than by the app; re-run with a larger --max-old-space`
|
|
185
196
|
}];
|
|
186
197
|
}
|
|
198
|
+
var WARM_UP_BASELINE_SHARE = 0.5;
|
|
199
|
+
var WARM_UP_BASELINE_FLOOR_BYTES = 16 * 1024 * 1024;
|
|
200
|
+
function warmUpBaselineWarnings(input) {
|
|
201
|
+
const samples = input.memorySamples;
|
|
202
|
+
const baseline = samples?.[0]?.heapUsed;
|
|
203
|
+
const firstCycle = samples?.[1]?.heapUsed;
|
|
204
|
+
if (baseline === void 0 || firstCycle === void 0) {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
const drained = baseline - firstCycle;
|
|
208
|
+
if (drained < WARM_UP_BASELINE_FLOOR_BYTES || drained < baseline * WARM_UP_BASELINE_SHARE) {
|
|
209
|
+
return [];
|
|
210
|
+
}
|
|
211
|
+
const warmup = input.warmupRequests === void 0 ? "" : ` (${input.warmupRequests} warm-up requests)`;
|
|
212
|
+
return [{
|
|
213
|
+
code: "warm-up-baseline",
|
|
214
|
+
detail: `the baseline was ${mb(baseline)} and the first cycle ${mb(firstCycle)} \u2014 ${pct(drained, baseline)} of it drained away, so it carries warm-up's own retention rather than the app's resting size${warmup}; every delta is measured from that inflated start. Lower --warmup if the app caches per request`
|
|
215
|
+
}];
|
|
216
|
+
}
|
|
187
217
|
var THIN_EVIDENCE_MIN_DELTAS = 5;
|
|
188
218
|
var THIN_EVIDENCE_MIN_DELTA_RATIO = 4;
|
|
189
219
|
function isThinEvidence(trend, minGrowth) {
|
|
@@ -219,11 +249,12 @@ function assessConfidence(input) {
|
|
|
219
249
|
const minGrowth = input.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR;
|
|
220
250
|
const warnings = [
|
|
221
251
|
...settleWarnings(input.settleOutcomes),
|
|
222
|
-
...loadWarnings(input.loadOutcomes, input.abandonAfterMs),
|
|
252
|
+
...loadWarnings(input.loadOutcomes, input.abandonAfterMs, input.abandonFrom),
|
|
223
253
|
...growthShapeWarnings(input.trend),
|
|
224
254
|
...noiseFloorWarnings(input.trend, minGrowth),
|
|
225
255
|
...thinEvidenceWarnings(input.trend, minGrowth),
|
|
226
|
-
...heapCeilingWarnings(input)
|
|
256
|
+
...heapCeilingWarnings(input),
|
|
257
|
+
...warmUpBaselineWarnings(input)
|
|
227
258
|
];
|
|
228
259
|
return {
|
|
229
260
|
level: warnings.length === 0 ? "high" : "low",
|
|
@@ -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
|
effectiveVerdict
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-EJ26OBZI.js";
|
|
5
5
|
import {
|
|
6
6
|
assessPeakPressure,
|
|
7
7
|
describePeakPressure
|
package/dist/cli-args.d.ts
CHANGED
|
@@ -5,15 +5,24 @@ export type CliRunOptions = {
|
|
|
5
5
|
requests: number | null;
|
|
6
6
|
connections: number | null;
|
|
7
7
|
idleSeconds: number | null;
|
|
8
|
+
warmupRequests: number | null;
|
|
8
9
|
maxOldSpaceMb: number | null;
|
|
9
10
|
quick: boolean;
|
|
10
11
|
noResolve: boolean;
|
|
11
12
|
diffAll: boolean;
|
|
13
|
+
writeConfig: boolean;
|
|
14
|
+
output: string | null;
|
|
15
|
+
};
|
|
16
|
+
export type CliBuildOptions = {
|
|
17
|
+
appDir: string;
|
|
12
18
|
output: string | null;
|
|
13
19
|
};
|
|
14
20
|
export type ParsedCli = {
|
|
15
21
|
kind: "run";
|
|
16
22
|
options: CliRunOptions;
|
|
23
|
+
} | {
|
|
24
|
+
kind: "build";
|
|
25
|
+
options: CliBuildOptions;
|
|
17
26
|
} | {
|
|
18
27
|
kind: "help";
|
|
19
28
|
} | {
|
package/dist/cli.js
CHANGED
|
@@ -4,20 +4,467 @@ import {
|
|
|
4
4
|
RouteConfigError,
|
|
5
5
|
TargetError,
|
|
6
6
|
checkRuntime,
|
|
7
|
+
discoverPagesRoutes,
|
|
8
|
+
discoverRoutes,
|
|
7
9
|
formatReport,
|
|
10
|
+
hasPlaceholders,
|
|
8
11
|
helpText,
|
|
9
12
|
killActiveChildren,
|
|
10
13
|
parseCliArgs,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
registerChild,
|
|
15
|
+
renderConfigSkeleton,
|
|
16
|
+
runMeasurement,
|
|
17
|
+
unregisterChild,
|
|
18
|
+
validateTarget
|
|
19
|
+
} from "./chunk-BXDUPN6Z.js";
|
|
20
|
+
import {
|
|
21
|
+
classifyTrend
|
|
22
|
+
} from "./chunk-EJ26OBZI.js";
|
|
14
23
|
import "./chunk-XHPUAMJG.js";
|
|
15
24
|
import "./chunk-6XYFBOL2.js";
|
|
16
25
|
|
|
17
26
|
// src/cli.ts
|
|
18
|
-
import { spawn } from "child_process";
|
|
27
|
+
import { spawn as spawn2 } from "child_process";
|
|
28
|
+
import { writeFile } from "fs/promises";
|
|
19
29
|
import { createRequire } from "module";
|
|
30
|
+
import path3 from "path";
|
|
20
31
|
import { fileURLToPath } from "url";
|
|
32
|
+
|
|
33
|
+
// src/build-report.ts
|
|
34
|
+
var MB = 1024 * 1024;
|
|
35
|
+
var mb = (bytes) => `${(bytes / MB).toFixed(1)} MB`;
|
|
36
|
+
var mbPrecise = (bytes) => `${(bytes / MB).toFixed(2)} MB`;
|
|
37
|
+
var VERDICT_ICON = {
|
|
38
|
+
leak: "\u2716",
|
|
39
|
+
stable: "\u2714",
|
|
40
|
+
inconclusive: "?"
|
|
41
|
+
};
|
|
42
|
+
function curveLine(levels) {
|
|
43
|
+
return levels.map((level) => mb(level)).join(" \u2192 ");
|
|
44
|
+
}
|
|
45
|
+
function growthLine(result) {
|
|
46
|
+
const perPage = result.retentionPerPageBytes === null ? "" : ` (${mbPrecise(result.retentionPerPageBytes)}/page over ${result.pagesGenerated} pages)`;
|
|
47
|
+
return ` grew ${mb(result.netGrowthBytes)} across the generation phase${perPage}`;
|
|
48
|
+
}
|
|
49
|
+
function formatBuildReport(result) {
|
|
50
|
+
const lines = [`next-leak build \xB7 ${result.appDir}`, ""];
|
|
51
|
+
if (result.strippedCapWarning !== null) {
|
|
52
|
+
lines.push(` \u25B2 ${result.strippedCapWarning}`, "");
|
|
53
|
+
}
|
|
54
|
+
if (result.status === "build-failed") {
|
|
55
|
+
lines.push(
|
|
56
|
+
` \u2716 the build failed for a reason that is not memory (exit ${result.exitCode ?? "?"})`,
|
|
57
|
+
` no memory claim is made; the build's own output follows`,
|
|
58
|
+
"",
|
|
59
|
+
...result.output.trimEnd().split("\n").slice(-20).map((line) => ` ${line}`)
|
|
60
|
+
);
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
if (result.status === "cannot-sample") {
|
|
64
|
+
lines.push(
|
|
65
|
+
` \u2716 could not read the process table, so the build was not measured`,
|
|
66
|
+
` ${result.samplingFailure ?? "unknown reason"}`,
|
|
67
|
+
` this is not a clean run: an unreadable table and a build with no`,
|
|
68
|
+
` workers look identical from here, and only one of them is good news`
|
|
69
|
+
);
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
|
72
|
+
if (result.status === "nothing-to-measure") {
|
|
73
|
+
lines.push(
|
|
74
|
+
` \u2013 no static-generation worker ran, so there was nothing to measure`,
|
|
75
|
+
` a build with no prerendered pages does not exercise this path`
|
|
76
|
+
);
|
|
77
|
+
return lines.join("\n");
|
|
78
|
+
}
|
|
79
|
+
const verdict = result.verdict ?? "inconclusive";
|
|
80
|
+
const icon = VERDICT_ICON[verdict] ?? "?";
|
|
81
|
+
lines.push(` ${icon} static-generation worker ${verdict}`);
|
|
82
|
+
if (result.levels.length > 0) {
|
|
83
|
+
lines.push(` worker rss ${curveLine(result.levels)}`);
|
|
84
|
+
}
|
|
85
|
+
if (result.netGrowthBytes > 0) {
|
|
86
|
+
lines.push(growthLine(result));
|
|
87
|
+
}
|
|
88
|
+
if (verdict === "inconclusive" && result.levels.length === 0) {
|
|
89
|
+
lines.push(` the build was too short to judge \u2014 not a sign of health`);
|
|
90
|
+
}
|
|
91
|
+
if (result.heapExhausted) {
|
|
92
|
+
lines.push(
|
|
93
|
+
` the worker ran out of heap and the build died with it after ${result.pagesGenerated ?? "?"} pages, reaching ${mb(result.peakWorkerRssBytes)}`,
|
|
94
|
+
` no per-page figure for a crashed build: the curve is truncated where`,
|
|
95
|
+
` the worker died, so any denominator would be guesswork`,
|
|
96
|
+
` that outcome is the verdict here, whatever shape the curve above has:`,
|
|
97
|
+
` raising the cap moves the wall; it does not remove it`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (result.workers.length > 1) {
|
|
101
|
+
lines.push(` ${result.workers.length} workers ran; the verdict is the worst of them`);
|
|
102
|
+
}
|
|
103
|
+
lines.push(
|
|
104
|
+
"",
|
|
105
|
+
` peak worker rss ${mb(result.peakWorkerRssBytes)} \xB7 sampled from the process tree, so a`,
|
|
106
|
+
` spike shorter than the polling interval is not observed`
|
|
107
|
+
);
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/build-run.ts
|
|
112
|
+
import { spawn } from "child_process";
|
|
113
|
+
import path2 from "path";
|
|
114
|
+
|
|
115
|
+
// src/build-target.ts
|
|
116
|
+
import { access, readFile, readdir } from "fs/promises";
|
|
117
|
+
import path from "path";
|
|
118
|
+
var CONFIG_NAMES = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"];
|
|
119
|
+
async function exists(file) {
|
|
120
|
+
try {
|
|
121
|
+
await access(file);
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function readPackageJson(appDir) {
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(await readFile(path.join(appDir, "package.json"), "utf8"));
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function usesWorkerThreads(appDir) {
|
|
135
|
+
for (const name of CONFIG_NAMES) {
|
|
136
|
+
const file = path.join(appDir, name);
|
|
137
|
+
if (!await exists(file)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const source = await readFile(file, "utf8");
|
|
141
|
+
if (/workerThreads\s*:\s*true/.test(source)) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
async function validateBuildTarget(appDir) {
|
|
148
|
+
const resolved = path.resolve(appDir);
|
|
149
|
+
let entries;
|
|
150
|
+
try {
|
|
151
|
+
entries = await readdir(resolved);
|
|
152
|
+
} catch {
|
|
153
|
+
throw new TargetError("NO_BUILD", `Cannot read ${resolved}.`);
|
|
154
|
+
}
|
|
155
|
+
const packageJson = await readPackageJson(resolved);
|
|
156
|
+
const dependencies = {
|
|
157
|
+
...packageJson?.dependencies ?? {},
|
|
158
|
+
...packageJson?.devDependencies ?? {}
|
|
159
|
+
};
|
|
160
|
+
const hasNext = "next" in dependencies || entries.some((entry) => CONFIG_NAMES.includes(entry));
|
|
161
|
+
if (!hasNext) {
|
|
162
|
+
throw new TargetError(
|
|
163
|
+
"NO_BUILD",
|
|
164
|
+
`${resolved} is not a Next.js project: no "next" dependency in package.json and no next.config.
|
|
165
|
+
next-leak build measures a Next.js build. Point it at the directory holding your package.json.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (await usesWorkerThreads(resolved)) {
|
|
169
|
+
throw new TargetError(
|
|
170
|
+
"NO_BUILD",
|
|
171
|
+
`This project sets experimental.workerThreads: true.
|
|
172
|
+
next-leak build measures the resident memory of each static-generation worker, and a
|
|
173
|
+
worker thread has none of its own \u2014 it shares the parent's. Remove the flag (Next's
|
|
174
|
+
default) to make the build measurable.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const scripts = packageJson?.scripts ?? {};
|
|
178
|
+
const buildScript = "build" in scripts ? "build" : null;
|
|
179
|
+
return { appDir: resolved, buildScript };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/build-verdict.ts
|
|
183
|
+
function segmentSamples(samples, segments) {
|
|
184
|
+
if (segments <= 0 || samples.length < segments) {
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
const first = samples[0];
|
|
188
|
+
const last = samples[samples.length - 1];
|
|
189
|
+
if (first === void 0 || last === void 0) {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
const span = last.atMs - first.atMs;
|
|
193
|
+
if (span <= 0) {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
196
|
+
const levels = [];
|
|
197
|
+
for (let segment = 1; segment <= segments; segment += 1) {
|
|
198
|
+
const until = first.atMs + span * segment / segments;
|
|
199
|
+
let level;
|
|
200
|
+
for (const sample of samples) {
|
|
201
|
+
if (sample.atMs <= until) {
|
|
202
|
+
level = sample.rssBytes;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (level !== void 0) {
|
|
206
|
+
levels.push(level);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return levels;
|
|
210
|
+
}
|
|
211
|
+
var BUILD_SEGMENTS = 6;
|
|
212
|
+
var BUILD_GROWTH_GATE_BYTES = 32 * 1024 * 1024;
|
|
213
|
+
function classifyBuildSamples(samples) {
|
|
214
|
+
const levels = segmentSamples(samples, BUILD_SEGMENTS);
|
|
215
|
+
return {
|
|
216
|
+
trend: classifyTrend(levels, { minGrowthPerCycle: BUILD_GROWTH_GATE_BYTES }),
|
|
217
|
+
levels
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function netGrowthOf(levels) {
|
|
221
|
+
const first = levels[1] ?? levels[0];
|
|
222
|
+
const last = levels[levels.length - 1];
|
|
223
|
+
if (first === void 0 || last === void 0) {
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
return last - first;
|
|
227
|
+
}
|
|
228
|
+
function strippedHeapCap(nodeOptions) {
|
|
229
|
+
if (nodeOptions === void 0) {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
const match = /--max[-_]old[-_]space[-_]size[= ]\s*(\d+)/.exec(nodeOptions);
|
|
233
|
+
if (match === null) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
const value = match[1] ?? "";
|
|
237
|
+
return `NODE_OPTIONS sets --max-old-space-size=${value}, which Next strips from the static-generation worker's environment \u2014 the worker never sees it. Use --max-heap-size=${value} instead, which survives.`;
|
|
238
|
+
}
|
|
239
|
+
var HEAP_DEATH_MARKERS = [
|
|
240
|
+
"JavaScript heap out of memory",
|
|
241
|
+
"Reached heap limit",
|
|
242
|
+
"Ineffective mark-compacts near heap limit"
|
|
243
|
+
];
|
|
244
|
+
function diedOfHeapExhaustion(output) {
|
|
245
|
+
return HEAP_DEATH_MARKERS.some((marker) => output.includes(marker));
|
|
246
|
+
}
|
|
247
|
+
function pagesGeneratedFrom(output) {
|
|
248
|
+
let furthest = null;
|
|
249
|
+
for (const line of output.split("\n")) {
|
|
250
|
+
if (!line.includes("static pages")) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
for (const match of line.matchAll(/\((\d+)\/(\d+)\)/g)) {
|
|
254
|
+
const done = Number(match[1]);
|
|
255
|
+
if (Number.isFinite(done) && (furthest === null || done > furthest)) {
|
|
256
|
+
furthest = done;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return furthest;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/process-tree.ts
|
|
264
|
+
import { execFile } from "child_process";
|
|
265
|
+
import { promisify } from "util";
|
|
266
|
+
var run = promisify(execFile);
|
|
267
|
+
var STATIC_WORKER_MARKER = "jest-worker/processChild";
|
|
268
|
+
var KB = 1024;
|
|
269
|
+
function parseProcessTable(output) {
|
|
270
|
+
const rows = [];
|
|
271
|
+
for (const line of output.split("\n")) {
|
|
272
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/.exec(line);
|
|
273
|
+
if (match === null) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const [, pid, ppid, rssKb, command] = match;
|
|
277
|
+
if (pid === void 0 || ppid === void 0 || rssKb === void 0 || command === void 0) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
rows.push({
|
|
281
|
+
pid: Number(pid),
|
|
282
|
+
ppid: Number(ppid),
|
|
283
|
+
rssBytes: Number(rssKb) * KB,
|
|
284
|
+
command
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
return rows;
|
|
288
|
+
}
|
|
289
|
+
function descendantsOf(rows, rootPid) {
|
|
290
|
+
const found = /* @__PURE__ */ new Map();
|
|
291
|
+
let frontier = /* @__PURE__ */ new Set([rootPid]);
|
|
292
|
+
while (frontier.size > 0) {
|
|
293
|
+
const next = /* @__PURE__ */ new Set();
|
|
294
|
+
for (const row of rows) {
|
|
295
|
+
if (!frontier.has(row.ppid) || found.has(row.pid) || row.pid === rootPid) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
found.set(row.pid, row);
|
|
299
|
+
next.add(row.pid);
|
|
300
|
+
}
|
|
301
|
+
frontier = next;
|
|
302
|
+
}
|
|
303
|
+
return [...found.values()];
|
|
304
|
+
}
|
|
305
|
+
function isStaticGenWorker(row) {
|
|
306
|
+
return row.command.includes(STATIC_WORKER_MARKER);
|
|
307
|
+
}
|
|
308
|
+
async function sampleProcessTable() {
|
|
309
|
+
try {
|
|
310
|
+
const { stdout } = await run("ps", ["-axo", "pid,ppid,rss,command"], {
|
|
311
|
+
maxBuffer: 8 * 1024 * 1024
|
|
312
|
+
});
|
|
313
|
+
return { ok: true, rows: parseProcessTable(stdout) };
|
|
314
|
+
} catch (cause) {
|
|
315
|
+
return { ok: false, reason: cause instanceof Error ? cause.message : String(cause) };
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// src/build-run.ts
|
|
320
|
+
var POLL_MS = 500;
|
|
321
|
+
function spawnNextBuild(appDir, env) {
|
|
322
|
+
const nextBin = path2.join(appDir, "node_modules", "next", "dist", "bin", "next");
|
|
323
|
+
return spawn(process.execPath, [nextBin, "build"], {
|
|
324
|
+
cwd: appDir,
|
|
325
|
+
env,
|
|
326
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
var defaultDeps = {
|
|
330
|
+
spawnBuild: spawnNextBuild,
|
|
331
|
+
sampleTable: sampleProcessTable,
|
|
332
|
+
now: () => Date.now(),
|
|
333
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
334
|
+
};
|
|
335
|
+
function recordSample(rows, rootPid, atMs, workers, parentSamples) {
|
|
336
|
+
const root = rows.find((row) => row.pid === rootPid);
|
|
337
|
+
if (root !== void 0) {
|
|
338
|
+
parentSamples.push({ atMs, rssBytes: root.rssBytes });
|
|
339
|
+
}
|
|
340
|
+
for (const row of descendantsOf(rows, rootPid)) {
|
|
341
|
+
if (!isStaticGenWorker(row)) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
const series = workers.get(row.pid) ?? [];
|
|
345
|
+
series.push({ atMs, rssBytes: row.rssBytes });
|
|
346
|
+
workers.set(row.pid, series);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function worstWorker(workers) {
|
|
350
|
+
let worst = null;
|
|
351
|
+
let worstGrowth = -Infinity;
|
|
352
|
+
for (const worker of workers) {
|
|
353
|
+
const growth = netGrowthOf(classifyBuildSamples(worker.samples).levels);
|
|
354
|
+
if (growth > worstGrowth) {
|
|
355
|
+
worstGrowth = growth;
|
|
356
|
+
worst = worker;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return worst;
|
|
360
|
+
}
|
|
361
|
+
var peakOf = (samples) => samples.reduce((highest, sample) => Math.max(highest, sample.rssBytes), 0);
|
|
362
|
+
async function runBuildMeasurement(options, deps = defaultDeps) {
|
|
363
|
+
const target = await validateBuildTarget(options.appDir);
|
|
364
|
+
const progress = options.onProgress ?? (() => {
|
|
365
|
+
});
|
|
366
|
+
const env = options.env ?? process.env;
|
|
367
|
+
const strippedCapWarning = strippedHeapCap(env["NODE_OPTIONS"]);
|
|
368
|
+
if (strippedCapWarning !== null) {
|
|
369
|
+
progress(strippedCapWarning);
|
|
370
|
+
}
|
|
371
|
+
progress(`building ${target.appDir}`);
|
|
372
|
+
const child = deps.spawnBuild(target.appDir, env);
|
|
373
|
+
registerChild(child);
|
|
374
|
+
let output = "";
|
|
375
|
+
const capture = (chunk) => {
|
|
376
|
+
output += String(chunk);
|
|
377
|
+
};
|
|
378
|
+
child.stdout?.on("data", capture);
|
|
379
|
+
child.stderr?.on("data", capture);
|
|
380
|
+
const startedAt = deps.now();
|
|
381
|
+
const workerSamples = /* @__PURE__ */ new Map();
|
|
382
|
+
const parentSamples = [];
|
|
383
|
+
let finished = false;
|
|
384
|
+
const exit = new Promise((resolve) => {
|
|
385
|
+
child.once("exit", (code) => {
|
|
386
|
+
finished = true;
|
|
387
|
+
resolve(code);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
const abort = () => {
|
|
391
|
+
child.kill("SIGTERM");
|
|
392
|
+
};
|
|
393
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
394
|
+
let samplingFailure = null;
|
|
395
|
+
const polling = (async () => {
|
|
396
|
+
while (!finished) {
|
|
397
|
+
await deps.sleep(POLL_MS);
|
|
398
|
+
if (finished || child.pid === void 0) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const sample = await deps.sampleTable();
|
|
402
|
+
if (!sample.ok) {
|
|
403
|
+
samplingFailure = sample.reason;
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
recordSample(sample.rows, child.pid, deps.now() - startedAt, workerSamples, parentSamples);
|
|
407
|
+
}
|
|
408
|
+
})();
|
|
409
|
+
const exitCode = await exit;
|
|
410
|
+
await polling;
|
|
411
|
+
options.signal?.removeEventListener("abort", abort);
|
|
412
|
+
unregisterChild(child);
|
|
413
|
+
const workers = [...workerSamples].map(([pid, samples]) => ({ pid, samples }));
|
|
414
|
+
const heapExhausted = diedOfHeapExhaustion(output);
|
|
415
|
+
const pagesGenerated = pagesGeneratedFrom(output);
|
|
416
|
+
if (workers.length === 0) {
|
|
417
|
+
return {
|
|
418
|
+
appDir: target.appDir,
|
|
419
|
+
// "Could not look" is not "nothing was there". A build that broke before
|
|
420
|
+
// generating anything is a failed build, not a measurement of nothing.
|
|
421
|
+
status: samplingFailure !== null ? "cannot-sample" : exitCode === 0 ? "nothing-to-measure" : "build-failed",
|
|
422
|
+
samplingFailure,
|
|
423
|
+
verdict: null,
|
|
424
|
+
trend: null,
|
|
425
|
+
levels: [],
|
|
426
|
+
workers,
|
|
427
|
+
parentSamples,
|
|
428
|
+
peakWorkerRssBytes: 0,
|
|
429
|
+
netGrowthBytes: 0,
|
|
430
|
+
pagesGenerated,
|
|
431
|
+
retentionPerPageBytes: null,
|
|
432
|
+
heapExhausted,
|
|
433
|
+
strippedCapWarning,
|
|
434
|
+
exitCode,
|
|
435
|
+
output
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
const worst = worstWorker(workers);
|
|
439
|
+
const { trend, levels } = classifyBuildSamples(worst?.samples ?? []);
|
|
440
|
+
const peakWorkerRssBytes = workers.reduce(
|
|
441
|
+
(highest, worker) => Math.max(highest, peakOf(worker.samples)),
|
|
442
|
+
0
|
|
443
|
+
);
|
|
444
|
+
const measured = exitCode === 0 || heapExhausted;
|
|
445
|
+
const verdict = heapExhausted ? "leak" : trend.verdict;
|
|
446
|
+
const growthBytes = heapExhausted ? 0 : netGrowthOf(levels);
|
|
447
|
+
return {
|
|
448
|
+
appDir: target.appDir,
|
|
449
|
+
status: measured ? "measured" : "build-failed",
|
|
450
|
+
samplingFailure,
|
|
451
|
+
verdict: measured ? verdict : null,
|
|
452
|
+
trend: measured ? trend : null,
|
|
453
|
+
levels: measured ? levels : [],
|
|
454
|
+
workers,
|
|
455
|
+
parentSamples,
|
|
456
|
+
peakWorkerRssBytes,
|
|
457
|
+
netGrowthBytes: measured ? growthBytes : 0,
|
|
458
|
+
pagesGenerated,
|
|
459
|
+
retentionPerPageBytes: measured && growthBytes > 0 && pagesGenerated !== null && pagesGenerated > 0 ? growthBytes / pagesGenerated : null,
|
|
460
|
+
heapExhausted,
|
|
461
|
+
strippedCapWarning,
|
|
462
|
+
exitCode,
|
|
463
|
+
output
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// src/cli.ts
|
|
21
468
|
var require2 = createRequire(import.meta.url);
|
|
22
469
|
var { version } = require2("../package.json");
|
|
23
470
|
var HEADROOM_SENTINEL = "NEXT_LEAK_WITH_HEADROOM";
|
|
@@ -26,7 +473,7 @@ function hasHeapHeadroom() {
|
|
|
26
473
|
return process.execArgv.some((argument) => argument.includes("--max-old-space-size")) || (process.env.NODE_OPTIONS ?? "").includes("--max-old-space-size") || process.env[HEADROOM_SENTINEL] === "1";
|
|
27
474
|
}
|
|
28
475
|
function reexecWithHeadroom() {
|
|
29
|
-
const child =
|
|
476
|
+
const child = spawn2(process.execPath, [`--max-old-space-size=${HEADROOM_MB}`, ...process.argv.slice(1)], {
|
|
30
477
|
stdio: "inherit",
|
|
31
478
|
env: { ...process.env, [HEADROOM_SENTINEL]: "1" }
|
|
32
479
|
});
|
|
@@ -76,9 +523,58 @@ function handleNonRunCommand(parsed) {
|
|
|
76
523
|
}
|
|
77
524
|
return false;
|
|
78
525
|
}
|
|
526
|
+
async function runBuildCommand(appDir) {
|
|
527
|
+
const aborter = installInterruptHandlers();
|
|
528
|
+
const result = await runBuildMeasurement({
|
|
529
|
+
appDir,
|
|
530
|
+
signal: aborter.signal,
|
|
531
|
+
onProgress: (message) => console.error(`\xB7 ${message}`)
|
|
532
|
+
});
|
|
533
|
+
console.log(formatBuildReport(result));
|
|
534
|
+
if (aborter.signal.aborted) {
|
|
535
|
+
process.exitCode = 130;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
async function writeConfigSkeleton(appDir) {
|
|
539
|
+
const target = await validateTarget(appDir);
|
|
540
|
+
const routes = [...discoverRoutes(target.appPaths), ...discoverPagesRoutes(target.pages)].filter((route) => route.dynamic && route.unaddressableReason === void 0).map((route) => route.path);
|
|
541
|
+
const skeleton = renderConfigSkeleton(routes, target.prerender);
|
|
542
|
+
if (skeleton === null) {
|
|
543
|
+
console.log("no route needs sample params \u2014 nothing to write");
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const file = path3.join(target.appDir, "next-leak.config.json");
|
|
547
|
+
try {
|
|
548
|
+
await writeFile(file, `${skeleton}
|
|
549
|
+
`, { flag: "wx" });
|
|
550
|
+
} catch {
|
|
551
|
+
console.error(
|
|
552
|
+
`error: ${file} already exists \u2014 not overwriting. The fragment that would measure the skipped routes:
|
|
553
|
+
|
|
554
|
+
${skeleton}`
|
|
555
|
+
);
|
|
556
|
+
process.exitCode = 1;
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
console.log(`wrote ${file}`);
|
|
560
|
+
if (hasPlaceholders(skeleton)) {
|
|
561
|
+
console.log("replace each REPLACE-ME with a value that exists in your app");
|
|
562
|
+
}
|
|
563
|
+
}
|
|
79
564
|
async function main() {
|
|
80
565
|
const parsed = parseCliArgs(process.argv.slice(2));
|
|
81
|
-
if (handleNonRunCommand(parsed)
|
|
566
|
+
if (handleNonRunCommand(parsed)) {
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (parsed.kind === "build") {
|
|
570
|
+
await runBuildCommand(parsed.options.appDir);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (parsed.kind !== "run") {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (parsed.options.writeConfig) {
|
|
577
|
+
await writeConfigSkeleton(parsed.options.appDir);
|
|
82
578
|
return;
|
|
83
579
|
}
|
|
84
580
|
if (!hasHeapHeadroom()) {
|
|
@@ -98,6 +594,7 @@ async function main() {
|
|
|
98
594
|
...options.requests !== null && { loadRequests: options.requests },
|
|
99
595
|
...options.connections !== null && { connections: options.connections },
|
|
100
596
|
...options.idleSeconds !== null && { idleMs: options.idleSeconds * 1e3 },
|
|
597
|
+
...options.warmupRequests !== null && { warmupRequests: options.warmupRequests },
|
|
101
598
|
...options.maxOldSpaceMb !== null && { maxOldSpaceMb: options.maxOldSpaceMb },
|
|
102
599
|
...options.diffAll && { diffAll: true },
|
|
103
600
|
...options.noResolve && { resolveInconclusive: false },
|
|
@@ -107,6 +604,11 @@ async function main() {
|
|
|
107
604
|
console.log(formatReport(report));
|
|
108
605
|
if (aborter.signal.aborted) {
|
|
109
606
|
process.exitCode = 130;
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
if (!report.routes.some((route) => route.status === "measured")) {
|
|
610
|
+
console.error("error: no route was measured \u2014 nothing above is a verdict about your app");
|
|
611
|
+
process.exitCode = 1;
|
|
110
612
|
}
|
|
111
613
|
}
|
|
112
614
|
try {
|