next-leak 0.8.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 +11 -5
- package/dist/{chunk-RS3AO7HB.js → chunk-T7LM4Z33.js} +8 -0
- package/dist/{chunk-LFGWGM5Z.js → chunk-UVA77PMC.js} +109 -15
- package/dist/cli.js +24 -2
- 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/README.md
CHANGED
|
@@ -142,14 +142,20 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
|
|
|
142
142
|
```json
|
|
143
143
|
{
|
|
144
144
|
"params": { "lang": "en" },
|
|
145
|
-
"routes": { "/products/[id]": { "id": "42" } },
|
|
145
|
+
"routes": { "/products/[id]": { "id": "42-{n}" } },
|
|
146
146
|
"headers": { "accept-encoding": "gzip, br", "cookie": "session=..." }
|
|
147
147
|
}
|
|
148
148
|
```
|
|
149
149
|
|
|
150
|
-
`--write-config` generates that file for you
|
|
151
|
-
build already prerendered
|
|
152
|
-
|
|
150
|
+
`--write-config` generates that file for you. It takes the *shape* of each value
|
|
151
|
+
from paths your build already prerendered, and makes the value itself move:
|
|
152
|
+
`post-0` becomes `post-{n}`. A value the build prerendered is the one value
|
|
153
|
+
guaranteed not to measure anything — every request hits the same warm cache
|
|
154
|
+
entry, so the route reads as flat whatever it retains, and that false negative
|
|
155
|
+
lands on exactly the leaks being reported now (`use cache`, `cacheComponents`
|
|
156
|
+
and ISR all key on the params). If your app answers 404 for params it never
|
|
157
|
+
prerendered, the run says so through its non-2xx count; drop the marker then.
|
|
158
|
+
When a run skips a route it prints the same fragment.
|
|
153
159
|
|
|
154
160
|
```json
|
|
155
161
|
```
|
|
@@ -215,7 +221,7 @@ separates them, because each one has a different fix:
|
|
|
215
221
|
allocator, external-buffer or fragmentation problem, not a JS-heap leak.
|
|
216
222
|
- **`leak`** — the report names the culprit when attribution resolves: your file (`culprit: src/app/x/page.tsx (your code)`), a dependency (package name), or framework internals. An `ISSUE-<route>.md` draft is generated **when the evidence plainly supports the verdict** — a `leak` carrying low-confidence warnings (growth barely over the threshold, one cycle dominating the mean, too few cycles for its size) gets the verdict but no draft, because a draft is written to be pasted into someone else's tracker. If the leak is app-owned, the draft tells you **not** to file it upstream.
|
|
217
223
|
- **`inconclusive`** — the evidence does not decide. The run does not stop there: any inconclusive route is **measured again automatically**, with twice the cycles, and the second pass is what you see (`resolved at 8 cycles` next to the verdict). On the reproduction for [#95094](https://github.com/vercel/next.js/issues/95094), `--quick` alone reports `inconclusive` on three deltas and then comes back with the leak. `--no-resolve` turns the second pass off; when even that is undecided, the re-run command is still printed.
|
|
218
|
-
- **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design.
|
|
224
|
+
- **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design. A process that died of **heap exhaustion** is not one of these: it reports `leak`, because a route that could not survive its own load did not fail to be measured — it was measured right up to the point where it stopped fitting. The verdict comes from that outcome, not from the shape of the truncated curve, which is the same rule `next-leak build` applies to a static-generation worker that dies. The run prints the cycles it survived and the growth up to the death, and exits 0 with a finding rather than 1 with an error.
|
|
219
225
|
|
|
220
226
|
## Peak pressure: `stable` is not the same as safe
|
|
221
227
|
|
|
@@ -80,6 +80,7 @@ function renderHtmlReport(run) {
|
|
|
80
80
|
const measured = run.routes.filter((route) => route.status === "measured");
|
|
81
81
|
const skipped = run.routes.filter((route) => route.status === "skipped");
|
|
82
82
|
const failed = run.routes.filter((route) => route.status === "failed");
|
|
83
|
+
const exhausted = run.routes.filter((route) => route.status === "died-of-heap");
|
|
83
84
|
const environment = run.environment;
|
|
84
85
|
return `<!doctype html>
|
|
85
86
|
<html lang="en"><head><meta charset="utf-8">
|
|
@@ -102,6 +103,13 @@ code{background:#f4f4f4;padding:0 4px;border-radius:3px}
|
|
|
102
103
|
)} \xB7 next-leak ${escapeHtml(environment.nextLeakVersion)}</p>
|
|
103
104
|
<p class="meta">${run.parameters.cycles} cycles \xD7 ${run.parameters.loadRequests} requests \xB7 heap cap ${run.parameters.maxOldSpaceMb} MB \xB7 growth gate ${(run.parameters.minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle</p>
|
|
104
105
|
${measured.map((route) => measuredSection(route, run.parameters)).join("\n")}
|
|
106
|
+
${exhausted.map(
|
|
107
|
+
(route) => route.status !== "died-of-heap" ? "" : `<h2><span class="badge" style="background:#c0392b">leak</span> <code>${escapeHtml(
|
|
108
|
+
route.route
|
|
109
|
+
)}</code></h2>
|
|
110
|
+
<p class="curve">Ran out of heap after ${route.cyclesCompleted} of ${route.cyclesRequested} cycles \xD7 ${route.requestsPerCycle} requests. Post-GC heap up to the death: ${route.memorySamples.map((sample) => `${(sample.heapUsed / MB).toFixed(1)} MB`).join(" \u2192 ")}</p>
|
|
111
|
+
<p class="warn">${escapeHtml(route.reason)}</p>`
|
|
112
|
+
).join("\n")}
|
|
105
113
|
${skipped.length === 0 ? "" : `<h2>Skipped</h2><ul>${skipped.map((route) => `<li><code>${escapeHtml(route.route)}</code> \u2014 ${escapeHtml(route.status === "skipped" ? route.reason : "")}</li>`).join("")}</ul>`}
|
|
106
114
|
${failed.length === 0 ? "" : `<h2>Failed</h2><ul>${failed.map((route) => `<li><code>${escapeHtml(route.route)}</code> \u2014 ${escapeHtml(route.status === "failed" ? route.reason : "")}</li>`).join("")}</ul>`}
|
|
107
115
|
<p class="meta">Raw snapshots and run.json live next to this file \u2014 verify in Chrome DevTools \u2192 Memory \u2192 Load.</p>
|
|
@@ -92692,8 +92692,11 @@ function explainStartupFailure(stderr) {
|
|
|
92692
92692
|
return `stderr:
|
|
92693
92693
|
${stderr}`;
|
|
92694
92694
|
}
|
|
92695
|
+
function stderrShowsHeapExhaustion(stderr) {
|
|
92696
|
+
return /heap out of memory|Reached heap limit|Ineffective mark-compacts/i.test(stderr);
|
|
92697
|
+
}
|
|
92695
92698
|
function explainRuntimeFailure(stderr, maxOldSpaceMb) {
|
|
92696
|
-
if (
|
|
92699
|
+
if (stderrShowsHeapExhaustion(stderr)) {
|
|
92697
92700
|
return `the measured process ran out of heap and was killed by V8 mid-run (limit in force: --max-old-space-size=${maxOldSpaceMb} MB). That is the measurement: this route does not fit in ${maxOldSpaceMb} MB under this load. Raise it with --max-old-space <mb> to match your deployment, or lower --requests/--connections to measure a lighter regime.`;
|
|
92698
92701
|
}
|
|
92699
92702
|
return `the measured process exited mid-run. ${explainStartupFailure(stderr)}`;
|
|
@@ -92808,7 +92811,19 @@ ${stderrTailBuffer}`;
|
|
|
92808
92811
|
pid: child.pid ?? -1,
|
|
92809
92812
|
appPort: options.appPort,
|
|
92810
92813
|
controlPort,
|
|
92811
|
-
explainExit: () =>
|
|
92814
|
+
explainExit: () => {
|
|
92815
|
+
if (!exited) {
|
|
92816
|
+
return null;
|
|
92817
|
+
}
|
|
92818
|
+
const stderr = stderrWindow();
|
|
92819
|
+
return {
|
|
92820
|
+
reason: explainRuntimeFailure(
|
|
92821
|
+
stderr,
|
|
92822
|
+
options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB
|
|
92823
|
+
),
|
|
92824
|
+
heapExhausted: stderrShowsHeapExhaustion(stderr)
|
|
92825
|
+
};
|
|
92826
|
+
},
|
|
92812
92827
|
close: async () => {
|
|
92813
92828
|
if (exited) {
|
|
92814
92829
|
return;
|
|
@@ -93255,6 +93270,13 @@ function resolveRoutePath(routeTemplate, config) {
|
|
|
93255
93270
|
|
|
93256
93271
|
// src/route-guidance.ts
|
|
93257
93272
|
var PLACEHOLDER = "REPLACE-ME";
|
|
93273
|
+
function varyingValueFrom(prerendered) {
|
|
93274
|
+
const numbered = /^(.*?)(\d+)$/.exec(prerendered);
|
|
93275
|
+
if (numbered !== null && numbered[1] !== "") {
|
|
93276
|
+
return `${numbered[1]}{n}`;
|
|
93277
|
+
}
|
|
93278
|
+
return `${prerendered}-{n}`;
|
|
93279
|
+
}
|
|
93258
93280
|
function sampleValuesFromManifest(manifest, routeTemplate) {
|
|
93259
93281
|
const routes = manifest?.routes;
|
|
93260
93282
|
if (routes === void 0) {
|
|
@@ -93291,7 +93313,8 @@ function renderConfigSkeleton(routeTemplates, manifest = void 0) {
|
|
|
93291
93313
|
const sampled = sampleValuesFromManifest(manifest, template);
|
|
93292
93314
|
const values = {};
|
|
93293
93315
|
for (const segment of segments) {
|
|
93294
|
-
|
|
93316
|
+
const prerendered = sampled?.[segment.name];
|
|
93317
|
+
values[segment.name] = prerendered === void 0 ? PLACEHOLDER : varyingValueFrom(prerendered);
|
|
93295
93318
|
}
|
|
93296
93319
|
routes[template] = values;
|
|
93297
93320
|
}
|
|
@@ -93488,6 +93511,13 @@ function routeLines(route, parameters) {
|
|
|
93488
93511
|
if (route.status === "not-exercised") {
|
|
93489
93512
|
return [` \u2013 ${route.route} not exercised: ${route.reason}`];
|
|
93490
93513
|
}
|
|
93514
|
+
if (route.status === "died-of-heap") {
|
|
93515
|
+
const curve2 = route.memorySamples.map((sample) => formatMb(sample.heapUsed)).join(" \u2192 ");
|
|
93516
|
+
return [
|
|
93517
|
+
` ${VERDICT_ICON.leak} ${route.route} leak (ran out of heap after ${route.cyclesCompleted} of ${route.cyclesRequested} cycles)` + (curve2 === "" ? "" : ` heap ${curve2}`),
|
|
93518
|
+
` ${route.reason}`
|
|
93519
|
+
];
|
|
93520
|
+
}
|
|
93491
93521
|
const verdict = effectiveVerdict(route);
|
|
93492
93522
|
const curve = route.samples.map(formatMb).join(" \u2192 ");
|
|
93493
93523
|
const resolved = route.resolvedWithCycles === void 0 ? "" : ` (resolved at ${route.resolvedWithCycles} cycles)`;
|
|
@@ -93510,7 +93540,7 @@ function skippedGuidanceLines(report) {
|
|
|
93510
93540
|
if (skeleton === null) {
|
|
93511
93541
|
return [];
|
|
93512
93542
|
}
|
|
93513
|
-
const editing = hasPlaceholders(skeleton) ? ` Replace each ${"REPLACE-ME"} with a value that exists in your app.` : ` The
|
|
93543
|
+
const editing = hasPlaceholders(skeleton) ? ` Replace each ${"REPLACE-ME"} with a value that exists in your app.` : ` The shapes come from paths your build already prerendered; \`{n}\` makes every request use a different one, because reusing a prerendered value serves the warm cache and reads as flat whatever the route retains. Use \`{n%200}\` instead to revisit a fixed set of 200 keys, and drop the marker only if the app 404s on params it never prerendered.`;
|
|
93514
93544
|
return [
|
|
93515
93545
|
"",
|
|
93516
93546
|
`${needConfig.length} route(s) need sample params. Write this to next-leak.config.json in the app directory:${editing}`,
|
|
@@ -93519,7 +93549,9 @@ function skippedGuidanceLines(report) {
|
|
|
93519
93549
|
}
|
|
93520
93550
|
function coverageLine(report) {
|
|
93521
93551
|
const total = report.routes.length;
|
|
93522
|
-
const measured = report.routes.filter(
|
|
93552
|
+
const measured = report.routes.filter(
|
|
93553
|
+
(route) => route.status === "measured" || route.status === "died-of-heap"
|
|
93554
|
+
).length;
|
|
93523
93555
|
if (measured === total) {
|
|
93524
93556
|
return `measured all ${total} discovered route(s)`;
|
|
93525
93557
|
}
|
|
@@ -93605,6 +93637,29 @@ async function describeRedirect(url) {
|
|
|
93605
93637
|
return null;
|
|
93606
93638
|
}
|
|
93607
93639
|
}
|
|
93640
|
+
async function describeUnprerenderedParams(url) {
|
|
93641
|
+
const bounded = boundedMarkerOf(url);
|
|
93642
|
+
const marker = bounded === null ? UNIQUE_MARKER : bounded.marker;
|
|
93643
|
+
if (!url.includes(marker)) {
|
|
93644
|
+
return null;
|
|
93645
|
+
}
|
|
93646
|
+
const probe = async (value) => {
|
|
93647
|
+
try {
|
|
93648
|
+
const response = await fetch(url.split(marker).join(value), { redirect: "manual" });
|
|
93649
|
+
return response.status;
|
|
93650
|
+
} catch {
|
|
93651
|
+
return null;
|
|
93652
|
+
}
|
|
93653
|
+
};
|
|
93654
|
+
const [novel, familiar] = await Promise.all([probe("999999"), probe("0")]);
|
|
93655
|
+
if (novel === null || familiar === null) {
|
|
93656
|
+
return null;
|
|
93657
|
+
}
|
|
93658
|
+
if (novel === 404 && familiar >= 200 && familiar < 300) {
|
|
93659
|
+
return `the sample value varies per request (\`${marker}\`) and this app answers 404 for params it never prerendered \u2014 the route itself is fine, the value is the problem. Bound it to the params your build did prerender (\`{n%N}\` with N of them), or drop the marker and accept that every request serves the same cached entry`;
|
|
93660
|
+
}
|
|
93661
|
+
return null;
|
|
93662
|
+
}
|
|
93608
93663
|
function boundedRequestSequence(fullUrl, bounded) {
|
|
93609
93664
|
const parsed = new URL(fullUrl);
|
|
93610
93665
|
const template = fullUrl.slice(parsed.origin.length);
|
|
@@ -93656,9 +93711,9 @@ async function runLoadPhase(options) {
|
|
|
93656
93711
|
const ratio = options.amount === 0 ? 0 : failures / options.amount;
|
|
93657
93712
|
if (ratio > (options.maxErrorRatio ?? 0.01)) {
|
|
93658
93713
|
const unanswered = failures - result.non2xx - result.errors - result.timeouts;
|
|
93659
|
-
const
|
|
93714
|
+
const explained = result.non2xx > 0 ? await describeUnprerenderedParams(options.url) ?? await describeRedirect(options.url) : null;
|
|
93660
93715
|
throw new LoadError(
|
|
93661
|
-
`${failures} of ${options.amount} requests failed against ${options.url} (${result.non2xx} non-2xx, ${result.errors} errors, ${result.timeouts} timeouts` + (unanswered > 0 ? `, ${unanswered} with no recorded response` : "") + ")" + (
|
|
93716
|
+
`${failures} of ${options.amount} requests failed against ${options.url} (${result.non2xx} non-2xx, ${result.errors} errors, ${result.timeouts} timeouts` + (unanswered > 0 ? `, ${unanswered} with no recorded response` : "") + ")" + (explained === null ? diagnoseFailure(result, options.connections) : ` \u2014 ${explained}`),
|
|
93662
93717
|
result
|
|
93663
93718
|
);
|
|
93664
93719
|
}
|
|
@@ -93870,6 +93925,14 @@ Host: ${target.host}\r
|
|
|
93870
93925
|
|
|
93871
93926
|
// src/ritual.ts
|
|
93872
93927
|
var MIN_POLLS_TO_JUDGE = 2;
|
|
93928
|
+
var HeapExhaustedError = class extends Error {
|
|
93929
|
+
evidence;
|
|
93930
|
+
constructor(message, evidence) {
|
|
93931
|
+
super(message);
|
|
93932
|
+
this.name = "HeapExhaustedError";
|
|
93933
|
+
this.evidence = evidence;
|
|
93934
|
+
}
|
|
93935
|
+
};
|
|
93873
93936
|
var SETTLE_POLL_MS = 2e3;
|
|
93874
93937
|
var SETTLE_TOLERANCE = 0.01;
|
|
93875
93938
|
var PEAK_POLL_MS = 250;
|
|
@@ -94022,6 +94085,13 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94022
94085
|
timings.push({ phase, seconds: Math.round((Date.now() - started) / 100) / 10 });
|
|
94023
94086
|
}
|
|
94024
94087
|
};
|
|
94088
|
+
const memorySamples = [];
|
|
94089
|
+
const unreclaimedSamples = [];
|
|
94090
|
+
let unreclaimedLost = false;
|
|
94091
|
+
const peaks = [];
|
|
94092
|
+
let afterSnapshot = "";
|
|
94093
|
+
let baselineSnapshot = "";
|
|
94094
|
+
let cyclesCompleted = 0;
|
|
94025
94095
|
try {
|
|
94026
94096
|
const routeUrl = `http://127.0.0.1:${app.appPort}${options.route}`;
|
|
94027
94097
|
await timed(
|
|
@@ -94037,11 +94107,8 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94037
94107
|
"baseline snapshot",
|
|
94038
94108
|
() => requestSnapshot(app.controlPort, "baseline")
|
|
94039
94109
|
);
|
|
94040
|
-
|
|
94041
|
-
|
|
94042
|
-
let unreclaimedLost = false;
|
|
94043
|
-
const peaks = [];
|
|
94044
|
-
let afterSnapshot = "";
|
|
94110
|
+
memorySamples.push(baseline.sample);
|
|
94111
|
+
baselineSnapshot = baseline.file;
|
|
94045
94112
|
for (let cycle = 1; cycle <= cycles; cycle += 1) {
|
|
94046
94113
|
await timed(`cycle ${cycle} load`, async () => {
|
|
94047
94114
|
const poller = pollPeak(app.controlPort, `cycle ${cycle}`, deps);
|
|
@@ -94075,6 +94142,7 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94075
94142
|
} else {
|
|
94076
94143
|
memorySamples.push(await requestGc(app.controlPort));
|
|
94077
94144
|
}
|
|
94145
|
+
cyclesCompleted = cycle;
|
|
94078
94146
|
}
|
|
94079
94147
|
const samples = memorySamples.map((sample) => sample.heapUsed);
|
|
94080
94148
|
const externalSamples = memorySamples.map((sample) => sample.external);
|
|
@@ -94095,7 +94163,7 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94095
94163
|
unreclaimedSamples.map((sample) => sample.external),
|
|
94096
94164
|
{ minGrowthPerCycle }
|
|
94097
94165
|
),
|
|
94098
|
-
baselineSnapshot
|
|
94166
|
+
baselineSnapshot,
|
|
94099
94167
|
afterSnapshot,
|
|
94100
94168
|
trend: classifyMemoryTrend(samples, externalSamples, { minGrowthPerCycle }),
|
|
94101
94169
|
requestsPerCycle: loadRequests,
|
|
@@ -94104,7 +94172,18 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94104
94172
|
} catch (cause) {
|
|
94105
94173
|
const death = app.explainExit();
|
|
94106
94174
|
if (death !== null) {
|
|
94107
|
-
|
|
94175
|
+
if (death.heapExhausted) {
|
|
94176
|
+
throw new HeapExhaustedError(death.reason, {
|
|
94177
|
+
memorySamples,
|
|
94178
|
+
peaks,
|
|
94179
|
+
unreclaimedSamples: unreclaimedLost ? [] : unreclaimedSamples,
|
|
94180
|
+
cyclesCompleted,
|
|
94181
|
+
cyclesRequested: cycles,
|
|
94182
|
+
requestsPerCycle: loadRequests,
|
|
94183
|
+
baselineSnapshot
|
|
94184
|
+
});
|
|
94185
|
+
}
|
|
94186
|
+
throw new Error(death.reason);
|
|
94108
94187
|
}
|
|
94109
94188
|
throw cause;
|
|
94110
94189
|
} finally {
|
|
@@ -94454,7 +94533,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
94454
94533
|
};
|
|
94455
94534
|
}
|
|
94456
94535
|
async function writeEvidenceBundle(report, workDir) {
|
|
94457
|
-
const { renderHtmlReport } = await import("./html-report-
|
|
94536
|
+
const { renderHtmlReport } = await import("./html-report-5XMR3EXE.js");
|
|
94458
94537
|
const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
|
|
94459
94538
|
for (const route of report.routes) {
|
|
94460
94539
|
if (route.status === "measured" && warrantsIssueDraft(route)) {
|
|
@@ -94503,6 +94582,21 @@ async function routeReportFor(context, route, index, total) {
|
|
|
94503
94582
|
return first;
|
|
94504
94583
|
}
|
|
94505
94584
|
} catch (cause) {
|
|
94585
|
+
if (cause instanceof HeapExhaustedError) {
|
|
94586
|
+
progress(`leak ${label}: ${cause.message}`);
|
|
94587
|
+
const { evidence } = cause;
|
|
94588
|
+
return {
|
|
94589
|
+
route: route.path,
|
|
94590
|
+
status: "died-of-heap",
|
|
94591
|
+
requestPath,
|
|
94592
|
+
reason: cause.message,
|
|
94593
|
+
memorySamples: evidence.memorySamples,
|
|
94594
|
+
peaks: evidence.peaks,
|
|
94595
|
+
cyclesCompleted: evidence.cyclesCompleted,
|
|
94596
|
+
cyclesRequested: evidence.cyclesRequested,
|
|
94597
|
+
requestsPerCycle: evidence.requestsPerCycle
|
|
94598
|
+
};
|
|
94599
|
+
}
|
|
94506
94600
|
const failure = cause instanceof Error ? cause.message : String(cause);
|
|
94507
94601
|
progress(`failed ${label}: ${failure}`);
|
|
94508
94602
|
return { route: route.path, status: "failed", reason: failure };
|
package/dist/cli.js
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
runMeasurement,
|
|
21
21
|
unregisterChild,
|
|
22
22
|
validateTarget
|
|
23
|
-
} from "./chunk-
|
|
23
|
+
} from "./chunk-UVA77PMC.js";
|
|
24
24
|
import {
|
|
25
25
|
classifyTrend
|
|
26
26
|
} from "./chunk-EJ26OBZI.js";
|
|
@@ -263,6 +263,21 @@ function formatBuildReport(result, attribution = null) {
|
|
|
263
263
|
);
|
|
264
264
|
return lines.join("\n");
|
|
265
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
|
+
}
|
|
266
281
|
if (result.status === "nothing-to-measure") {
|
|
267
282
|
lines.push(
|
|
268
283
|
` \u2013 no static-generation worker ran, so there was nothing to measure`,
|
|
@@ -296,6 +311,7 @@ function formatBuildReport(result, attribution = null) {
|
|
|
296
311
|
}
|
|
297
312
|
lines.push(
|
|
298
313
|
...attributionLines(attribution),
|
|
314
|
+
...parentPeakLines(result),
|
|
299
315
|
"",
|
|
300
316
|
` peak worker rss ${mb(result.peakWorkerRssBytes)} \xB7 sampled from the process tree, so a`,
|
|
301
317
|
` spike shorter than the polling interval is not observed`
|
|
@@ -848,6 +864,10 @@ ${skeleton}`
|
|
|
848
864
|
console.log(`wrote ${file}`);
|
|
849
865
|
if (hasPlaceholders(skeleton)) {
|
|
850
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
|
+
);
|
|
851
871
|
}
|
|
852
872
|
}
|
|
853
873
|
async function main() {
|
|
@@ -899,7 +919,9 @@ async function main() {
|
|
|
899
919
|
process.exitCode = 130;
|
|
900
920
|
return;
|
|
901
921
|
}
|
|
902
|
-
if (!report.routes.some(
|
|
922
|
+
if (!report.routes.some(
|
|
923
|
+
(route) => route.status === "measured" || route.status === "died-of-heap"
|
|
924
|
+
)) {
|
|
903
925
|
console.error("error: no route was measured \u2014 nothing above is a verdict about your app");
|
|
904
926
|
process.exitCode = 1;
|
|
905
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. */
|
package/dist/route-guidance.d.ts
CHANGED
|
@@ -1,4 +1,23 @@
|
|
|
1
1
|
import type { PrerenderManifest } from "./manifests.js";
|
|
2
|
+
/**
|
|
3
|
+
* Turns a prerendered value into one that varies per request.
|
|
4
|
+
*
|
|
5
|
+
* A value the build already prerendered is the one value guaranteed *not* to
|
|
6
|
+
* measure anything: every request hits the same warm cache entry and the route
|
|
7
|
+
* reads as flat no matter what it retains. That is a silent false negative, and
|
|
8
|
+
* it lands on exactly the leaks being reported now — `use cache`,
|
|
9
|
+
* `cacheComponents` and ISR all key on the params
|
|
10
|
+
* (vercel/next.js#97938, #97776, #97424, #92287).
|
|
11
|
+
*
|
|
12
|
+
* The prerendered value is still the best hint available: it shows the shape
|
|
13
|
+
* the app accepts. So the shape is kept and only the tail is made to move —
|
|
14
|
+
* `post-0` becomes `post-{n}`, `seed` becomes `seed-{n}`.
|
|
15
|
+
*
|
|
16
|
+
* If the app answers 404 for params it never prerendered, the run says so
|
|
17
|
+
* loudly through its non-2xx count. A noisy 404 is a far better outcome than a
|
|
18
|
+
* quiet `stable`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function varyingValueFrom(prerendered: string): string;
|
|
2
21
|
/**
|
|
3
22
|
* Sample values lifted from paths the build actually prerendered.
|
|
4
23
|
*
|
package/dist/runner.d.ts
CHANGED
|
@@ -18,6 +18,28 @@ export type RouteReport = {
|
|
|
18
18
|
status: "failed";
|
|
19
19
|
reason: string;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The measured process ran out of heap partway through. This is a verdict,
|
|
23
|
+
* not a failure: the route did not fit in the limit the run gave it, and the
|
|
24
|
+
* outcome decides regardless of the shape of the truncated curve — the same
|
|
25
|
+
* rule the build path applies to a static-generation worker that dies.
|
|
26
|
+
*
|
|
27
|
+
* Kept apart from `measured` because there is no after-snapshot and no
|
|
28
|
+
* complete trend, and dressing it as a full measurement would claim data
|
|
29
|
+
* that does not exist.
|
|
30
|
+
*/
|
|
31
|
+
| {
|
|
32
|
+
route: string;
|
|
33
|
+
status: "died-of-heap";
|
|
34
|
+
requestPath: string;
|
|
35
|
+
reason: string;
|
|
36
|
+
/** Post-GC readings up to the death; may be as short as the baseline. */
|
|
37
|
+
memorySamples: HeapSample[];
|
|
38
|
+
peaks: PeakSample[];
|
|
39
|
+
cyclesCompleted: number;
|
|
40
|
+
cyclesRequested: number;
|
|
41
|
+
requestsPerCycle: number;
|
|
42
|
+
}
|
|
21
43
|
/**
|
|
22
44
|
* The route was reachable, but the load could not have exercised the code
|
|
23
45
|
* path it represents — so no verdict is emitted. Measuring an ISR route
|