next-leak 0.11.3 → 0.12.1
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 +42 -3
- package/dist/bootstrap.js +27 -1
- package/dist/{chunk-XHPUAMJG.js → chunk-E464SO7G.js} +39 -2
- package/dist/{chunk-YLHE4N5G.js → chunk-G2777DKL.js} +12 -7
- package/dist/{chunk-72SHITHC.js → chunk-L5KWU2RU.js} +88 -21
- package/dist/{chunk-4FYSLLSX.js → chunk-UU4RXOZO.js} +23 -4
- package/dist/{chunk-AUUMRTZZ.js → chunk-YPVBX3QA.js} +4 -3
- package/dist/cli.js +3 -3
- package/dist/confidence.d.ts +53 -3
- package/dist/control-server.d.ts +21 -0
- package/dist/{html-report-O7ILFOF5.js → html-report-DLKFB3IU.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/isr.d.ts +17 -1
- package/dist/{issue-report-FPVOZ6HU.js → issue-report-OALZZLVR.js} +2 -2
- package/dist/peak-pressure.d.ts +70 -5
- package/dist/request-probe.d.ts +18 -0
- package/dist/route-config.d.ts +23 -0
- package/dist/runner.d.ts +10 -4
- package/dist/trend.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -241,6 +241,7 @@ separates them, because each one has a different fix:
|
|
|
241
241
|
| A route that is expensive, not leaky | `failed` under load it cannot sustain, flat once concurrency fits | Real leaks survive forced GC at any concurrency; saturation disappears when load drops |
|
|
242
242
|
| Growth that pauses and resumes (stepwise) | `leak` | A healthy route gives back 20-30% of its growth; a stepwise leak gives back nothing |
|
|
243
243
|
| A cache filling up under the load that measures it | `saturating` | A bounded store grows by less each cycle as new keys get rarer; a leak does not decelerate |
|
|
244
|
+
| Memory a forced GC reclaims that production never reclaims in time | `pressure` | Every verdict sample is post-GC; the peaks are sampled under load, and a ceiling every settled cycle comes back to is a regime, not an episode |
|
|
244
245
|
| Native/buffer memory with a flat JS heap | `leak (external)` or an explicit RSS note | Heap, `external` and RSS are sampled and judged separately |
|
|
245
246
|
| A leak in your code vs a dependency vs Next itself | `culprit: src/app/x/page.tsx (your code)` — or the package, or framework internals | Retainer chains mapped through the build's source maps |
|
|
246
247
|
| A run whose own evidence is weak | `low confidence` warnings, or the verdict is withdrawn | Every run audits itself: did the load land, did the heap settle, does one cycle carry the average, did the heap run into its own ceiling |
|
|
@@ -277,6 +278,27 @@ separates them, because each one has a different fix:
|
|
|
277
278
|
draft is generated. When the load was driving a cache with keys it had never
|
|
278
279
|
served, the report says so on any growing route and points at `{n%N}` to
|
|
279
280
|
bound the key set — measure again that way before believing the number.
|
|
281
|
+
- **`pressure`** — nothing is retained, and the process is still heading for a
|
|
282
|
+
ceiling. Every sample a verdict is computed from is taken after a forced GC,
|
|
283
|
+
and production runs none of those, so the retention verdicts above are
|
|
284
|
+
structurally blind to memory a full collection *does* reclaim but that the
|
|
285
|
+
runtime does not reclaim fast enough on its own. This is that case: the
|
|
286
|
+
post-GC curve is flat or falling, the peak sampled under load is far above
|
|
287
|
+
what the route retains, **and** every settled cycle reaches that height. On
|
|
288
|
+
the reproduction for [#92287](https://github.com/vercel/next.js/issues/92287)
|
|
289
|
+
the app allocated about 1 MB of `arrayBuffers` per request, passed 3 GB and
|
|
290
|
+
was OOM-killed — and the old verdict was `stable`, which is precisely the trap
|
|
291
|
+
this tool exists to warn other people about. A single high peak is *not* this:
|
|
292
|
+
one cycle that reached a ceiling for a reason that did not repeat stays
|
|
293
|
+
`stable` with a peak note, because an episode is not a regime. What separates
|
|
294
|
+
this from an app entitled to a large working set is not the shape of the
|
|
295
|
+
curve, it is that a working set survives the forced collection and lands in
|
|
296
|
+
what the route retains, which acquits it on the ratio alone. These
|
|
297
|
+
routes are not measured again (the run already saw the ceiling it is
|
|
298
|
+
reporting) and get no issue draft: the finding is real, but it is not
|
|
299
|
+
retention, so there is nothing for a snapshot diff to name. The fix is
|
|
300
|
+
usually allocation rate, response size or concurrency, not a missing
|
|
301
|
+
`delete`.
|
|
280
302
|
- **`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.
|
|
281
303
|
- **`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.
|
|
282
304
|
|
|
@@ -303,9 +325,26 @@ That is a real measurement of the reproduction in
|
|
|
303
325
|
app on 16.3.1 under a shorter profile still reaches 544 MB against 33.8 MB
|
|
304
326
|
retained, so the shape has not gone anywhere. The note fires when the peak heap comes within
|
|
305
327
|
75% of `--max-old-space`, or when peak RSS is at least 8× the retained heap
|
|
306
|
-
and above 512 MB.
|
|
307
|
-
|
|
308
|
-
|
|
328
|
+
and above 512 MB. A peak is the highest value *sampled* (every 250 ms), so it
|
|
329
|
+
is a lower bound.
|
|
330
|
+
|
|
331
|
+
A single high peak stays a note and leaves the verdict alone: one cycle that
|
|
332
|
+
reached a ceiling may have reached it for a reason that will not happen again.
|
|
333
|
+
When **every settled cycle** comes back to that height the verdict becomes
|
|
334
|
+
[`pressure`](#reading-the-verdicts) instead, because at that point the run is no
|
|
335
|
+
longer describing an episode, it is describing what the route does under load —
|
|
336
|
+
which is the number a container is sized against. The peak is not required to
|
|
337
|
+
climb, and asking it to would make the verdict unreachable: each cycle is
|
|
338
|
+
preceded by a forced collection and runs the same traffic, so the peak converges
|
|
339
|
+
on traffic × cost-per-request rather than ramping. Measured on the #92287
|
|
340
|
+
reproduction on 2026-09-24, the rss peaks across four cycles were 1229, 1344,
|
|
341
|
+
1369 and 1379 MB — an asymptote.
|
|
342
|
+
|
|
343
|
+
What the route retains, for this ratio, is the **floor** of its post-GC cycle
|
|
344
|
+
samples rather than the last of them. Those samples follow a forced collection
|
|
345
|
+
but can still carry memory the collector had not reached yet, and on that same
|
|
346
|
+
reproduction they swung between 45 MB and 217 MB inside a single run, which by
|
|
347
|
+
itself decided whether the note appeared at all.
|
|
309
348
|
|
|
310
349
|
If the measured process dies at the limit instead of merely approaching it,
|
|
311
350
|
the route fails saying exactly that, with the limit in force and how to raise
|
package/dist/bootstrap.js
CHANGED
|
@@ -3,6 +3,8 @@ import "./chunk-6XYFBOL2.js";
|
|
|
3
3
|
|
|
4
4
|
// src/bootstrap.ts
|
|
5
5
|
import { mkdir, writeFile } from "fs/promises";
|
|
6
|
+
import http2 from "http";
|
|
7
|
+
import https from "https";
|
|
6
8
|
import path2 from "path";
|
|
7
9
|
|
|
8
10
|
// src/control-server.ts
|
|
@@ -21,6 +23,7 @@ async function forceGc(passes = 3) {
|
|
|
21
23
|
}
|
|
22
24
|
return true;
|
|
23
25
|
}
|
|
26
|
+
var requestProbe = globalThis;
|
|
24
27
|
function sampleMemory(gcExposed) {
|
|
25
28
|
const usage = process.memoryUsage();
|
|
26
29
|
return {
|
|
@@ -28,7 +31,12 @@ function sampleMemory(gcExposed) {
|
|
|
28
31
|
heapUsed: usage.heapUsed,
|
|
29
32
|
rss: usage.rss,
|
|
30
33
|
external: usage.external,
|
|
31
|
-
arrayBuffers: usage.arrayBuffers
|
|
34
|
+
arrayBuffers: usage.arrayBuffers,
|
|
35
|
+
pid: process.pid,
|
|
36
|
+
ppid: process.ppid,
|
|
37
|
+
argv: process.argv,
|
|
38
|
+
cwd: process.cwd(),
|
|
39
|
+
...requestProbe.__nextLeakServedRequests === void 0 ? {} : { servedRequests: requestProbe.__nextLeakServedRequests }
|
|
32
40
|
};
|
|
33
41
|
}
|
|
34
42
|
async function startControlServer(options) {
|
|
@@ -88,12 +96,30 @@ async function startControlServer(options) {
|
|
|
88
96
|
};
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
// src/request-probe.ts
|
|
100
|
+
function countServedRequests(module) {
|
|
101
|
+
const original = module.createServer;
|
|
102
|
+
module.createServer = function patched(...args) {
|
|
103
|
+
const server = original.apply(this, args);
|
|
104
|
+
server.on("request", () => {
|
|
105
|
+
requestProbe.__nextLeakServedRequests = (requestProbe.__nextLeakServedRequests ?? 0) + 1;
|
|
106
|
+
});
|
|
107
|
+
return server;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
// src/bootstrap.ts
|
|
92
112
|
var workDir = process.env["NEXT_LEAK_DIR"];
|
|
113
|
+
function installRequestProbe() {
|
|
114
|
+
requestProbe.__nextLeakServedRequests = 0;
|
|
115
|
+
countServedRequests(http2);
|
|
116
|
+
countServedRequests(https);
|
|
117
|
+
}
|
|
93
118
|
if (workDir !== void 0 && workDir !== "") {
|
|
94
119
|
try {
|
|
95
120
|
await mkdir(workDir, { recursive: true });
|
|
96
121
|
const server = await startControlServer({ snapshotDir: workDir });
|
|
122
|
+
installRequestProbe();
|
|
97
123
|
await writeFile(
|
|
98
124
|
path2.join(workDir, `control-${process.pid}.json`),
|
|
99
125
|
JSON.stringify({ port: server.port, pid: process.pid })
|
|
@@ -6,6 +6,12 @@ var HEAP_LIMIT_SHARE = 0.75;
|
|
|
6
6
|
var RSS_OVER_RETAINED = 8;
|
|
7
7
|
var RSS_FLOOR_BYTES = 512 * MB;
|
|
8
8
|
var maxOf = (peaks, read) => peaks.reduce((highest, peak) => Math.max(highest, read(peak)), 0);
|
|
9
|
+
function retainedAfterLoad(memorySamples) {
|
|
10
|
+
const cycles = memorySamples.slice(1);
|
|
11
|
+
return cycles.length === 0 ? void 0 : cycles.reduce((lowest, sample) => Math.min(lowest, sample.heapUsed), Infinity);
|
|
12
|
+
}
|
|
13
|
+
var reachesHeapCeiling = (heapUsed, heapLimitBytes) => heapUsed >= heapLimitBytes * HEAP_LIMIT_SHARE;
|
|
14
|
+
var reachesRssCeiling = (rss, retainedHeapBytes) => rss >= RSS_FLOOR_BYTES && rss >= retainedHeapBytes * RSS_OVER_RETAINED;
|
|
9
15
|
function assessPeakPressure(input) {
|
|
10
16
|
const sampled = input.peaks.filter((peak) => peak.polls > 0);
|
|
11
17
|
if (sampled.length === 0) {
|
|
@@ -14,7 +20,7 @@ function assessPeakPressure(input) {
|
|
|
14
20
|
const heapLimitBytes = input.maxOldSpaceMb * MB;
|
|
15
21
|
const peakHeap = maxOf(sampled, (peak) => peak.heapUsed);
|
|
16
22
|
const peakRss = maxOf(sampled, (peak) => peak.rss);
|
|
17
|
-
if (peakHeap
|
|
23
|
+
if (reachesHeapCeiling(peakHeap, heapLimitBytes)) {
|
|
18
24
|
return {
|
|
19
25
|
class: "heap",
|
|
20
26
|
peakBytes: peakHeap,
|
|
@@ -22,7 +28,7 @@ function assessPeakPressure(input) {
|
|
|
22
28
|
heapLimitBytes
|
|
23
29
|
};
|
|
24
30
|
}
|
|
25
|
-
if (peakRss
|
|
31
|
+
if (reachesRssCeiling(peakRss, input.retainedHeapBytes)) {
|
|
26
32
|
return {
|
|
27
33
|
class: "rss",
|
|
28
34
|
peakBytes: peakRss,
|
|
@@ -32,6 +38,35 @@ function assessPeakPressure(input) {
|
|
|
32
38
|
}
|
|
33
39
|
return null;
|
|
34
40
|
}
|
|
41
|
+
var PRESSURE_MIN_SETTLED_CYCLES = 2;
|
|
42
|
+
var reachesCeiling = (peak, pressureClass, input) => pressureClass === "heap" ? reachesHeapCeiling(peak.heapUsed, input.maxOldSpaceMb * MB) : reachesRssCeiling(peak.rss, input.retainedHeapBytes);
|
|
43
|
+
function isSustained(input, pressureClass) {
|
|
44
|
+
if (!input.peaks.every((peak) => peak.polls > 0)) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
const settled = input.peaks.slice(1);
|
|
48
|
+
if (settled.length < PRESSURE_MIN_SETTLED_CYCLES) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
return settled.every((peak) => reachesCeiling(peak, pressureClass, input));
|
|
52
|
+
}
|
|
53
|
+
function assessPressureVerdict(input) {
|
|
54
|
+
if (input.trend.verdict !== "stable" && input.trend.verdict !== "saturating") {
|
|
55
|
+
return input.trend;
|
|
56
|
+
}
|
|
57
|
+
const pressure = assessPeakPressure({
|
|
58
|
+
peaks: input.peaks,
|
|
59
|
+
retainedHeapBytes: input.retainedHeapBytes,
|
|
60
|
+
maxOldSpaceMb: input.maxOldSpaceMb
|
|
61
|
+
});
|
|
62
|
+
if (pressure === null) {
|
|
63
|
+
return input.trend;
|
|
64
|
+
}
|
|
65
|
+
if (!isSustained(input, pressure.class)) {
|
|
66
|
+
return input.trend;
|
|
67
|
+
}
|
|
68
|
+
return { ...input.trend, verdict: "pressure" };
|
|
69
|
+
}
|
|
35
70
|
var mb = (bytes) => `${(bytes / MB).toFixed(1)} MB`;
|
|
36
71
|
function describePeakPressure(pressure) {
|
|
37
72
|
if (pressure.class === "heap") {
|
|
@@ -41,6 +76,8 @@ function describePeakPressure(pressure) {
|
|
|
41
76
|
}
|
|
42
77
|
|
|
43
78
|
export {
|
|
79
|
+
retainedAfterLoad,
|
|
44
80
|
assessPeakPressure,
|
|
81
|
+
assessPressureVerdict,
|
|
45
82
|
describePeakPressure
|
|
46
83
|
};
|
|
@@ -1,11 +1,13 @@
|
|
|
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
|
-
effectiveVerdict
|
|
4
|
-
|
|
3
|
+
effectiveVerdict,
|
|
4
|
+
withdrawnByDisagreement
|
|
5
|
+
} from "./chunk-UU4RXOZO.js";
|
|
5
6
|
import {
|
|
6
7
|
assessPeakPressure,
|
|
7
|
-
describePeakPressure
|
|
8
|
-
|
|
8
|
+
describePeakPressure,
|
|
9
|
+
retainedAfterLoad
|
|
10
|
+
} from "./chunk-E464SO7G.js";
|
|
9
11
|
|
|
10
12
|
// src/html-report.ts
|
|
11
13
|
var MB = 1024 * 1024;
|
|
@@ -13,7 +15,10 @@ var VERDICT_COLOR = {
|
|
|
13
15
|
leak: "#c0392b",
|
|
14
16
|
stable: "#27ae60",
|
|
15
17
|
inconclusive: "#e67e22",
|
|
16
|
-
saturating: "#2980b9"
|
|
18
|
+
saturating: "#2980b9",
|
|
19
|
+
// Its own colour, not the leak red: nothing was retained, and nothing is
|
|
20
|
+
// green about a process that reached a ceiling it cannot come back from.
|
|
21
|
+
pressure: "#8e44ad"
|
|
17
22
|
};
|
|
18
23
|
function escapeHtml(value) {
|
|
19
24
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
@@ -46,7 +51,7 @@ function ownerCell(attribution) {
|
|
|
46
51
|
return `${attribution.owner}${source}${packageName}`;
|
|
47
52
|
}
|
|
48
53
|
function peakBlock(route, parameters) {
|
|
49
|
-
const retained = route.memorySamples
|
|
54
|
+
const retained = retainedAfterLoad(route.memorySamples);
|
|
50
55
|
if (retained === void 0 || route.peaks === void 0) {
|
|
51
56
|
return "";
|
|
52
57
|
}
|
|
@@ -70,7 +75,7 @@ function measuredSection(route, parameters) {
|
|
|
70
75
|
}
|
|
71
76
|
const verdict = effectiveVerdict(route);
|
|
72
77
|
const color = VERDICT_COLOR[verdict];
|
|
73
|
-
const withdrawn = route.confidence.supersededVerdict === void 0 ? "" : `<p class="warn">Measured <strong>${route.trend.verdict}</strong>, withdrawn: the run did not observe what that verdict needs.</p>`;
|
|
78
|
+
const withdrawn = route.confidence.supersededVerdict === void 0 || withdrawnByDisagreement(route.confidence) ? "" : `<p class="warn">Measured <strong>${route.trend.verdict}</strong>, withdrawn: the run did not observe what that verdict needs.</p>`;
|
|
74
79
|
const warnings = route.confidence.warnings.length === 0 ? "" : `<ul class="warn">${route.confidence.warnings.map((warning) => `<li>${escapeHtml(warning.detail)}</li>`).join("")}</ul>`;
|
|
75
80
|
const curve = route.samples.map((sample) => (sample / MB).toFixed(1)).join(" \u2192 ");
|
|
76
81
|
const findings = [...route.diff?.grownNodes ?? [], ...route.diff?.newNodes ?? []];
|
|
@@ -6,12 +6,15 @@ import {
|
|
|
6
6
|
effectiveVerdict,
|
|
7
7
|
minGrowthFor,
|
|
8
8
|
resolveCycles,
|
|
9
|
-
warrantsIssueDraft
|
|
10
|
-
|
|
9
|
+
warrantsIssueDraft,
|
|
10
|
+
withdrawnByDisagreement
|
|
11
|
+
} from "./chunk-UU4RXOZO.js";
|
|
11
12
|
import {
|
|
12
13
|
assessPeakPressure,
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
assessPressureVerdict,
|
|
15
|
+
describePeakPressure,
|
|
16
|
+
retainedAfterLoad
|
|
17
|
+
} from "./chunk-E464SO7G.js";
|
|
15
18
|
import {
|
|
16
19
|
__commonJS,
|
|
17
20
|
__esm,
|
|
@@ -94047,6 +94050,14 @@ var encodeSegment = (value) => restoreMarkers(encodeURIComponent(value).split(EN
|
|
|
94047
94050
|
var ENCODED_BOUNDED = /%7Bn%25(\d+)%7D/gi;
|
|
94048
94051
|
var restoreMarkers = (value) => value.replace(ENCODED_BOUNDED, (_match, bound) => `{n%${bound}}`);
|
|
94049
94052
|
var encodeCatchAll = (value) => value.split("/").map(encodeSegment).join("/");
|
|
94053
|
+
function mixesMarkers(requestPath) {
|
|
94054
|
+
return requestPath.includes(UNIQUE_MARKER) && boundedMarkerOf(requestPath) !== null;
|
|
94055
|
+
}
|
|
94056
|
+
function probeRequestPath(requestPath) {
|
|
94057
|
+
const bounded = boundedMarkerOf(requestPath);
|
|
94058
|
+
const withBound = bounded === null ? requestPath : requestPath.split(bounded.marker).join("0");
|
|
94059
|
+
return withBound.split(UNIQUE_MARKER).join("0");
|
|
94060
|
+
}
|
|
94050
94061
|
function resolveRoutePath(routeTemplate, config) {
|
|
94051
94062
|
const resolved = [];
|
|
94052
94063
|
for (const segment of routeTemplate.split("/")) {
|
|
@@ -94189,7 +94200,12 @@ var sampleSchema = z5.object({
|
|
|
94189
94200
|
heapUsed: z5.number(),
|
|
94190
94201
|
rss: z5.number(),
|
|
94191
94202
|
external: z5.number(),
|
|
94192
|
-
arrayBuffers: z5.number()
|
|
94203
|
+
arrayBuffers: z5.number(),
|
|
94204
|
+
pid: z5.number(),
|
|
94205
|
+
ppid: z5.number(),
|
|
94206
|
+
argv: z5.array(z5.string()),
|
|
94207
|
+
cwd: z5.string(),
|
|
94208
|
+
servedRequests: z5.number().optional()
|
|
94193
94209
|
});
|
|
94194
94210
|
var snapshotResponseSchema = z5.object({ file: z5.string(), sample: sampleSchema });
|
|
94195
94211
|
var ControlError = class extends Error {
|
|
@@ -94560,8 +94576,10 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94560
94576
|
bootstrapPath: options.bootstrapPath,
|
|
94561
94577
|
// Wait on the route this ritual is about to measure. `/` is a different
|
|
94562
94578
|
// page with different failure modes, and readiness judged on it withdrew
|
|
94563
|
-
// routes that were serving fine (#74).
|
|
94564
|
-
|
|
94579
|
+
// routes that were serving fine (#74). Markers resolved, so the probe asks
|
|
94580
|
+
// for a key the load will also ask for rather than planting a literal
|
|
94581
|
+
// `{n}` in the route's cache.
|
|
94582
|
+
readyPath: probeRequestPath(options.route),
|
|
94565
94583
|
...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb },
|
|
94566
94584
|
...options.readyTimeoutMs !== void 0 && { readyTimeoutMs: options.readyTimeoutMs }
|
|
94567
94585
|
};
|
|
@@ -94713,7 +94731,16 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94713
94731
|
baselineSnapshot,
|
|
94714
94732
|
afterSnapshot,
|
|
94715
94733
|
...snapshotFailure !== void 0 && { snapshotFailure },
|
|
94716
|
-
|
|
94734
|
+
// A forced GC runs before every sample above, and production runs none.
|
|
94735
|
+
// The peaks are the only readings in this result taken under load, so
|
|
94736
|
+
// this is the one place that can tell a route which retains nothing from
|
|
94737
|
+
// a route which retains nothing and dies anyway.
|
|
94738
|
+
trend: assessPressureVerdict({
|
|
94739
|
+
trend: classifyMemoryTrend(samples, externalSamples, trendOptions),
|
|
94740
|
+
peaks,
|
|
94741
|
+
retainedHeapBytes: retainedAfterLoad(memorySamples) ?? 0,
|
|
94742
|
+
maxOldSpaceMb: options.maxOldSpaceMb ?? RITUAL_DEFAULTS.maxOldSpaceMb
|
|
94743
|
+
}),
|
|
94717
94744
|
requestsPerCycle: loadRequests,
|
|
94718
94745
|
minGrowthPerCycle
|
|
94719
94746
|
};
|
|
@@ -94917,7 +94944,8 @@ var VERDICT_ICON = {
|
|
|
94917
94944
|
leak: "\u2716",
|
|
94918
94945
|
stable: "\u2714",
|
|
94919
94946
|
inconclusive: "?",
|
|
94920
|
-
saturating: "~"
|
|
94947
|
+
saturating: "~",
|
|
94948
|
+
pressure: "\u25B2"
|
|
94921
94949
|
};
|
|
94922
94950
|
var RSS_MIN_GROWTH_PER_CYCLE = 16 * MB2;
|
|
94923
94951
|
var RSS_MIN_TOTAL_GROWTH = 64 * MB2;
|
|
@@ -94943,8 +94971,14 @@ function ownerLabel(attribution) {
|
|
|
94943
94971
|
}
|
|
94944
94972
|
}
|
|
94945
94973
|
function revalidationLines(route) {
|
|
94946
|
-
|
|
94947
|
-
|
|
94974
|
+
if (route.revalidatedEverySeconds === void 0) {
|
|
94975
|
+
return [];
|
|
94976
|
+
}
|
|
94977
|
+
const every = `revalidates every ${route.revalidatedEverySeconds}s`;
|
|
94978
|
+
return route.revalidationDriven === true ? [
|
|
94979
|
+
` driven through ISR revalidation (${every}; without it the load would serve the cache) \u2014 this is Next's revalidation path, so a leak that only happens on the path your users take is not in this number`
|
|
94980
|
+
] : [
|
|
94981
|
+
` not driven (${every}), because every request asks for a key this route has never cached \u2014 the load reached the renderer through the path your users take`
|
|
94948
94982
|
];
|
|
94949
94983
|
}
|
|
94950
94984
|
function repetitionLines(route) {
|
|
@@ -94971,9 +95005,22 @@ function cacheLines(route) {
|
|
|
94971
95005
|
lines.push(
|
|
94972
95006
|
` the load served keys this route had never cached, so some of this growth is cache residency; bound it with {n%N} in next-leak.config.json`
|
|
94973
95007
|
);
|
|
95008
|
+
if (route.revalidatedEverySeconds !== void 0) {
|
|
95009
|
+
lines.push(
|
|
95010
|
+
` on this route {n%N} does not isolate that: a bounded key set is served from the ISR cache, so the run would drive revalidation and measure a different path. Compare the two runs rather than trusting either number alone`
|
|
95011
|
+
);
|
|
95012
|
+
}
|
|
94974
95013
|
}
|
|
94975
95014
|
return lines;
|
|
94976
95015
|
}
|
|
95016
|
+
function pressureLines(verdict) {
|
|
95017
|
+
if (verdict !== "pressure") {
|
|
95018
|
+
return [];
|
|
95019
|
+
}
|
|
95020
|
+
return [
|
|
95021
|
+
` nothing was retained between cycles, so the rate above is flat: this verdict is about what the process reached while serving, which every sample here hides behind a forced GC that production never runs`
|
|
95022
|
+
];
|
|
95023
|
+
}
|
|
94977
95024
|
function abandonLines(route) {
|
|
94978
95025
|
if (route.abandon === void 0) {
|
|
94979
95026
|
return [];
|
|
@@ -94984,7 +95031,7 @@ function abandonLines(route) {
|
|
|
94984
95031
|
}
|
|
94985
95032
|
function confidenceLines(route) {
|
|
94986
95033
|
const lines = [];
|
|
94987
|
-
if (route.confidence.supersededVerdict !== void 0) {
|
|
95034
|
+
if (route.confidence.supersededVerdict !== void 0 && !withdrawnByDisagreement(route.confidence)) {
|
|
94988
95035
|
lines.push(
|
|
94989
95036
|
` measured ${route.trend.verdict}, withdrawn: the run did not observe what that verdict needs`
|
|
94990
95037
|
);
|
|
@@ -95002,7 +95049,7 @@ function memorySourceLines(route, verdict) {
|
|
|
95002
95049
|
` verdict comes from EXTERNAL memory (buffers, streams, fetch bodies), not the JS heap: external ${externalCurve}`
|
|
95003
95050
|
);
|
|
95004
95051
|
}
|
|
95005
|
-
if (verdict === "stable" && hasSustainedRssGrowth(route.memorySamples)) {
|
|
95052
|
+
if ((verdict === "stable" || verdict === "pressure") && hasSustainedRssGrowth(route.memorySamples)) {
|
|
95006
95053
|
const rssCurve = route.memorySamples.map((sample) => formatMb(sample.rss)).join(" \u2192 ");
|
|
95007
95054
|
lines.push(
|
|
95008
95055
|
` note: heap is flat but RSS grows ${formatGrowth(route.rssPer1000Requests)} \u2014 not a JS-heap leak (allocator, external buffers or fragmentation): RSS ${rssCurve}`
|
|
@@ -95037,7 +95084,7 @@ function findingLines(route) {
|
|
|
95037
95084
|
return lines;
|
|
95038
95085
|
}
|
|
95039
95086
|
function peakPressureLines(route, parameters) {
|
|
95040
|
-
const retained = route.memorySamples
|
|
95087
|
+
const retained = retainedAfterLoad(route.memorySamples);
|
|
95041
95088
|
if (retained === void 0) {
|
|
95042
95089
|
return [];
|
|
95043
95090
|
}
|
|
@@ -95084,6 +95131,7 @@ function routeLines(route, parameters) {
|
|
|
95084
95131
|
...repetitionLines(route),
|
|
95085
95132
|
...revalidationLines(route),
|
|
95086
95133
|
...cacheLines(route),
|
|
95134
|
+
...pressureLines(verdict),
|
|
95087
95135
|
...abandonLines(route),
|
|
95088
95136
|
...confidenceLines(route),
|
|
95089
95137
|
...memorySourceLines(route, verdict),
|
|
@@ -95304,6 +95352,9 @@ import { createServer } from "net";
|
|
|
95304
95352
|
import path9 from "path";
|
|
95305
95353
|
|
|
95306
95354
|
// src/isr.ts
|
|
95355
|
+
function keysAreNewEveryRequest(requestPath) {
|
|
95356
|
+
return requestPath.includes(UNIQUE_MARKER) && boundedMarkerOf(requestPath) === null;
|
|
95357
|
+
}
|
|
95307
95358
|
var REVALIDATE_HEADER = "x-prerender-revalidate";
|
|
95308
95359
|
function revalidateSecondsFor(manifest, route) {
|
|
95309
95360
|
const routes = manifest?.routes;
|
|
@@ -95324,7 +95375,7 @@ function revalidateSecondsFor(manifest, route) {
|
|
|
95324
95375
|
function revalidates(manifest, route) {
|
|
95325
95376
|
return revalidateSecondsFor(manifest, route) !== null;
|
|
95326
95377
|
}
|
|
95327
|
-
function planRevalidation(manifest, route, userHeaders) {
|
|
95378
|
+
function planRevalidation(manifest, route, userHeaders, requestPath) {
|
|
95328
95379
|
const userSupplied = Object.keys(userHeaders ?? {}).some(
|
|
95329
95380
|
(name) => name.toLowerCase() === REVALIDATE_HEADER
|
|
95330
95381
|
);
|
|
@@ -95334,6 +95385,9 @@ function planRevalidation(manifest, route, userHeaders) {
|
|
|
95334
95385
|
if (!revalidates(manifest, route)) {
|
|
95335
95386
|
return { kind: "not-isr" };
|
|
95336
95387
|
}
|
|
95388
|
+
if (requestPath !== void 0 && keysAreNewEveryRequest(requestPath)) {
|
|
95389
|
+
return { kind: "no-cache-to-drive" };
|
|
95390
|
+
}
|
|
95337
95391
|
const previewModeId = manifest?.preview?.previewModeId;
|
|
95338
95392
|
if (previewModeId === void 0 || previewModeId === "") {
|
|
95339
95393
|
return {
|
|
@@ -95447,15 +95501,18 @@ function skipReason(route, requestPath) {
|
|
|
95447
95501
|
if (requestPath === null) {
|
|
95448
95502
|
return "needs sample params for dynamic segments (next-leak.config.json)";
|
|
95449
95503
|
}
|
|
95504
|
+
if (mixesMarkers(requestPath)) {
|
|
95505
|
+
return `mixes "{n}" and "{n%N}" across its params in next-leak.config.json \u2014 the load resolves one and sends the other as a literal, so every request would ask for a path with "{n}" in it. Pick one cardinality for the whole route.`;
|
|
95506
|
+
}
|
|
95450
95507
|
return null;
|
|
95451
95508
|
}
|
|
95452
95509
|
async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
95453
95510
|
const { deps, options, target, workDir, routeConfig, registry, nextVersion, progress } = context;
|
|
95454
|
-
const plan = planRevalidation(target.prerender, route.path, routeConfig.headers);
|
|
95511
|
+
const plan = planRevalidation(target.prerender, route.path, routeConfig.headers, requestPath);
|
|
95455
95512
|
const revalidateSeconds = revalidateSecondsFor(target.prerender, route.path);
|
|
95456
95513
|
const bounded = boundedMarkerOf(requestPath);
|
|
95457
95514
|
const driven = plan.kind === "drive" ? plan.headers : {};
|
|
95458
|
-
const cacheDriven = plan.kind === "drive" && bounded === null;
|
|
95515
|
+
const cacheDriven = plan.kind === "no-cache-to-drive" || plan.kind === "drive" && bounded === null;
|
|
95459
95516
|
const merged = { ...driven, ...routeConfig.headers ?? {} };
|
|
95460
95517
|
const headers = Object.keys(merged).length === 0 ? void 0 : merged;
|
|
95461
95518
|
const result = await deps.ritual({
|
|
@@ -95493,6 +95550,9 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95493
95550
|
memorySamples: result.memorySamples,
|
|
95494
95551
|
maxOldSpaceMb: options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB,
|
|
95495
95552
|
warmupRequests: options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests,
|
|
95553
|
+
// Decides whether the cache-residency remedy exists on this route: bounding
|
|
95554
|
+
// the keys of an ISR route hands the requests back to the cache.
|
|
95555
|
+
revalidatesFromCache: revalidateSeconds !== null,
|
|
95496
95556
|
...routeConfig.abandonAfterMs !== void 0 && {
|
|
95497
95557
|
abandonAfterMs: routeConfig.abandonAfterMs
|
|
95498
95558
|
},
|
|
@@ -95512,7 +95572,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95512
95572
|
detail: result.snapshotFailure
|
|
95513
95573
|
};
|
|
95514
95574
|
progress(`no snapshot to attribute for ${route.path}: ${result.snapshotFailure}`);
|
|
95515
|
-
} else if (verdict !== "stable" || options.diffAll === true) {
|
|
95575
|
+
} else if (verdict !== "stable" && verdict !== "pressure" || options.diffAll === true) {
|
|
95516
95576
|
progress(`diffing snapshots for ${route.path}`);
|
|
95517
95577
|
try {
|
|
95518
95578
|
diff = await deps.diff(result.baselineSnapshot, result.afterSnapshot);
|
|
@@ -95535,6 +95595,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95535
95595
|
memorySamples: result.memorySamples,
|
|
95536
95596
|
peaks: result.peaks,
|
|
95537
95597
|
...revalidateSeconds !== null && { revalidatedEverySeconds: revalidateSeconds },
|
|
95598
|
+
...plan.kind === "drive" && { revalidationDriven: true },
|
|
95538
95599
|
...bounded !== null && { keyCardinality: bounded.bound },
|
|
95539
95600
|
unreclaimedSamples: result.unreclaimedSamples,
|
|
95540
95601
|
unreclaimedTrend: result.unreclaimedTrend,
|
|
@@ -95561,8 +95622,8 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95561
95622
|
};
|
|
95562
95623
|
}
|
|
95563
95624
|
async function writeEvidenceBundle(report, workDir) {
|
|
95564
|
-
const { renderHtmlReport } = await import("./html-report-
|
|
95565
|
-
const { renderIssueMarkdown } = await import("./issue-report-
|
|
95625
|
+
const { renderHtmlReport } = await import("./html-report-DLKFB3IU.js");
|
|
95626
|
+
const { renderIssueMarkdown } = await import("./issue-report-OALZZLVR.js");
|
|
95566
95627
|
for (const route of report.routes) {
|
|
95567
95628
|
if (route.status === "measured" && warrantsIssueDraft(route)) {
|
|
95568
95629
|
const file = path9.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
|
|
@@ -95594,7 +95655,12 @@ async function routeReportFor(context, route, index, total) {
|
|
|
95594
95655
|
progress(`skipping ${label}: ${reason ?? "needs sample params"}`);
|
|
95595
95656
|
return { route: route.path, status: "skipped", reason: reason ?? "needs sample params" };
|
|
95596
95657
|
}
|
|
95597
|
-
const plan = planRevalidation(
|
|
95658
|
+
const plan = planRevalidation(
|
|
95659
|
+
context.target.prerender,
|
|
95660
|
+
route.path,
|
|
95661
|
+
routeConfig.headers,
|
|
95662
|
+
requestPath
|
|
95663
|
+
);
|
|
95598
95664
|
if (plan.kind === "cannot-drive") {
|
|
95599
95665
|
progress(`not measuring ${label}: ${plan.reason}`);
|
|
95600
95666
|
return { route: route.path, status: "not-exercised", reason: plan.reason };
|
|
@@ -95609,6 +95675,7 @@ function reasonToResolve(verdict) {
|
|
|
95609
95675
|
return "growth was still decelerating when the window ran out";
|
|
95610
95676
|
case "leak":
|
|
95611
95677
|
case "stable":
|
|
95678
|
+
case "pressure":
|
|
95612
95679
|
return null;
|
|
95613
95680
|
}
|
|
95614
95681
|
}
|
|
@@ -106,17 +106,24 @@ function classifyMemoryTrend(heapSamples, externalSamples, options = {}) {
|
|
|
106
106
|
const external = classifyTrend(externalSamples, options);
|
|
107
107
|
const severity = {
|
|
108
108
|
leak: 0,
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
pressure: 1,
|
|
110
|
+
inconclusive: 2,
|
|
111
|
+
saturating: 3,
|
|
112
|
+
stable: 4
|
|
112
113
|
};
|
|
113
114
|
if (severity[external.verdict] < severity[heap.verdict]) {
|
|
114
115
|
return { ...external, source: "external" };
|
|
115
116
|
}
|
|
117
|
+
if (severity[external.verdict] === severity[heap.verdict] && external.growthPerCycle > heap.growthPerCycle) {
|
|
118
|
+
return { ...external, source: "external" };
|
|
119
|
+
}
|
|
116
120
|
return heap;
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
// src/confidence.ts
|
|
124
|
+
function withdrawnByDisagreement(confidence) {
|
|
125
|
+
return confidence.warnings.some((warning) => warning.code === "repetitions-disagree");
|
|
126
|
+
}
|
|
120
127
|
var resolveCycles = (cycles) => Math.max(cycles * 2, 6);
|
|
121
128
|
function effectiveVerdict(report) {
|
|
122
129
|
return report.confidence.supersededVerdict ?? report.trend.verdict;
|
|
@@ -125,7 +132,8 @@ var VERDICT_WEAKENING = /* @__PURE__ */ new Set([
|
|
|
125
132
|
"near-threshold",
|
|
126
133
|
"spiky-growth",
|
|
127
134
|
"thin-evidence",
|
|
128
|
-
"repetitions-disagree"
|
|
135
|
+
"repetitions-disagree",
|
|
136
|
+
"cache-residency"
|
|
129
137
|
]);
|
|
130
138
|
function warrantsIssueDraft(report) {
|
|
131
139
|
return effectiveVerdict(report) === "leak" && !report.confidence.warnings.some((warning) => VERDICT_WEAKENING.has(warning.code));
|
|
@@ -275,6 +283,15 @@ function thinEvidenceWarnings(trend, minGrowth) {
|
|
|
275
283
|
detail: `judged on ${trend.deltas.length} cycles, and its weakest grew ${mb(smallest)} against a ${mb(minGrowth)} gate \u2014 on that few cycles ordinary oscillation reaches that, and a repeat run often disagrees; measure more cycles to resolve it`
|
|
276
284
|
}];
|
|
277
285
|
}
|
|
286
|
+
function cacheResidencyWarnings(trend, revalidatesFromCache) {
|
|
287
|
+
if (trend.cacheDriven !== true || trend.verdict !== "leak" || revalidatesFromCache !== true) {
|
|
288
|
+
return [];
|
|
289
|
+
}
|
|
290
|
+
return [{
|
|
291
|
+
code: "cache-residency",
|
|
292
|
+
detail: `every request asked this ISR route for a key it had never cached, so storing them is part of the ${mb(trend.growthPerCycle)}/cycle \u2014 and here the usual remedy does not apply: bounding the keys ({n%N}) hands the requests back to the ISR cache, so the run would drive revalidation and measure Next's other path rather than a cleaner version of this one. Run it both ways and compare before attributing this to anyone`
|
|
293
|
+
}];
|
|
294
|
+
}
|
|
278
295
|
function isVerdictInvalid(input) {
|
|
279
296
|
if (input.trend.verdict !== "leak") {
|
|
280
297
|
return false;
|
|
@@ -295,6 +312,7 @@ function assessConfidence(input) {
|
|
|
295
312
|
...growthShapeWarnings(input.trend),
|
|
296
313
|
...noiseFloorWarnings(input.trend, minGrowth),
|
|
297
314
|
...thinEvidenceWarnings(input.trend, minGrowth),
|
|
315
|
+
...cacheResidencyWarnings(input.trend, input.revalidatesFromCache),
|
|
298
316
|
...heapCeilingWarnings(input),
|
|
299
317
|
...warmUpBaselineWarnings(input)
|
|
300
318
|
];
|
|
@@ -309,6 +327,7 @@ export {
|
|
|
309
327
|
minGrowthFor,
|
|
310
328
|
classifyTrend,
|
|
311
329
|
classifyMemoryTrend,
|
|
330
|
+
withdrawnByDisagreement,
|
|
312
331
|
resolveCycles,
|
|
313
332
|
effectiveVerdict,
|
|
314
333
|
warrantsIssueDraft,
|
|
@@ -1,8 +1,9 @@
|
|
|
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
|
assessPeakPressure,
|
|
4
|
-
describePeakPressure
|
|
5
|
-
|
|
4
|
+
describePeakPressure,
|
|
5
|
+
retainedAfterLoad
|
|
6
|
+
} from "./chunk-E464SO7G.js";
|
|
6
7
|
|
|
7
8
|
// src/issue-report.ts
|
|
8
9
|
import path from "path";
|
|
@@ -50,7 +51,7 @@ next-leak audits its own run and reports these limits. They do not overturn the
|
|
|
50
51
|
|
|
51
52
|
` + route.confidence.warnings.map((warning) => `- ${warning.detail}`).join("\n") + `
|
|
52
53
|
`;
|
|
53
|
-
const retainedHeap = route.memorySamples
|
|
54
|
+
const retainedHeap = retainedAfterLoad(route.memorySamples);
|
|
54
55
|
const pressure = retainedHeap === void 0 || route.peaks === void 0 ? null : assessPeakPressure({
|
|
55
56
|
peaks: route.peaks,
|
|
56
57
|
retainedHeapBytes: retainedHeap,
|
package/dist/cli.js
CHANGED
|
@@ -22,11 +22,11 @@ import {
|
|
|
22
22
|
runSelfCheck,
|
|
23
23
|
unregisterChild,
|
|
24
24
|
validateTarget
|
|
25
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-L5KWU2RU.js";
|
|
26
26
|
import {
|
|
27
27
|
classifyTrend
|
|
28
|
-
} from "./chunk-
|
|
29
|
-
import "./chunk-
|
|
28
|
+
} from "./chunk-UU4RXOZO.js";
|
|
29
|
+
import "./chunk-E464SO7G.js";
|
|
30
30
|
import "./chunk-6XYFBOL2.js";
|
|
31
31
|
|
|
32
32
|
// src/cli.ts
|
package/dist/confidence.d.ts
CHANGED
|
@@ -21,6 +21,22 @@ import { type TrendResult, type TrendVerdict } from "./trend.js";
|
|
|
21
21
|
* resting size
|
|
22
22
|
*/
|
|
23
23
|
export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold" | "thin-evidence" | "near-heap-ceiling" | "warm-up-baseline"
|
|
24
|
+
/**
|
|
25
|
+
* The load filled a cache the route never had to hold, and the run has no
|
|
26
|
+
* second experiment that would settle how much of the growth that was.
|
|
27
|
+
*
|
|
28
|
+
* A cache filling up and memory going missing both retain and both climb;
|
|
29
|
+
* only a bounded-key re-measurement tells them apart. On an ISR route that
|
|
30
|
+
* re-measurement is not available: a bounded key set is served from the
|
|
31
|
+
* cache, so the run drives revalidation to reach the renderer and lands on a
|
|
32
|
+
* different path of Next's. Measured on the vercel/next.js#99077
|
|
33
|
+
* reproduction, `/plain` — the same app with the leak taken out — reported
|
|
34
|
+
* `leak (+180.39 MB/1000 req)` with a fresh key per request and `stable
|
|
35
|
+
* (+1.81)` bounded, and the two builds that retain 7x apart came out at
|
|
36
|
+
* +1809.97 and +1815.05 once driven. The number stands as measured; what
|
|
37
|
+
* cannot stand is a paste-ready draft built on it.
|
|
38
|
+
*/
|
|
39
|
+
| "cache-residency"
|
|
24
40
|
/**
|
|
25
41
|
* Repeated measurements of the same route did not agree.
|
|
26
42
|
*
|
|
@@ -39,12 +55,41 @@ export type ConfidenceReport = {
|
|
|
39
55
|
warnings: MeasurementWarning[];
|
|
40
56
|
/**
|
|
41
57
|
* Verdict the evidence actually supports, when the measurement is not merely
|
|
42
|
-
* noisy but invalid.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
58
|
+
* noisy but invalid.
|
|
59
|
+
*
|
|
60
|
+
* Two different mechanisms set this, and they mean different things.
|
|
61
|
+
* `assessConfidence` sets it when a run did not observe what its own verdict
|
|
62
|
+
* requires, and only ever downgrades `leak`: accusing an app of leaking on
|
|
63
|
+
* evidence that does not hold is the expensive error — it sends someone
|
|
64
|
+
* chasing a ghost and ends as an issue against this tool. `aggregateRepetitions`
|
|
65
|
+
* sets it for *any* verdict when repetitions of the same route disagreed,
|
|
66
|
+
* which is not a flaw in this run at all. `withdrawnByDisagreement` below
|
|
67
|
+
* tells the two apart; anything that explains a withdrawal to a reader has to.
|
|
45
68
|
*/
|
|
46
69
|
supersededVerdict?: TrendVerdict;
|
|
47
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* Whether a withdrawal came from repetitions disagreeing rather than from this
|
|
73
|
+
* run failing its own audit.
|
|
74
|
+
*
|
|
75
|
+
* The distinction matters to anyone phrasing it. An audited withdrawal means
|
|
76
|
+
* the run never observed what the verdict needs — it never settled, the
|
|
77
|
+
* disconnects cut nothing, the deltas were too thin. A disagreement means the
|
|
78
|
+
* opposite: this run observed exactly what it needed, and another run of the
|
|
79
|
+
* same route observed something else. Telling a reader the run "did not observe
|
|
80
|
+
* what that verdict needs" in the second case is simply untrue, and the
|
|
81
|
+
* disagreement already carries its own warning naming the verdicts it produced.
|
|
82
|
+
*
|
|
83
|
+
* Both can be true of one report, and then this answers `true` and the audit's
|
|
84
|
+
* sentence is dropped along with the disagreement's. `aggregateRepetitions`
|
|
85
|
+
* keeps the winning pass's own warnings and overwrites only `supersededVerdict`,
|
|
86
|
+
* which both mechanisms set to `inconclusive`, so no reading is lost: the
|
|
87
|
+
* audit's warning still prints its own detail, which names the cause the generic
|
|
88
|
+
* sentence never did. Separating the two would mean carrying the winner's
|
|
89
|
+
* withdrawal reason through the aggregation, and it would buy the reader a
|
|
90
|
+
* sentence they already have in a more specific form.
|
|
91
|
+
*/
|
|
92
|
+
export declare function withdrawnByDisagreement(confidence: ConfidenceReport): boolean;
|
|
48
93
|
export type ConfidenceInput = {
|
|
49
94
|
trend: TrendResult;
|
|
50
95
|
loadOutcomes: readonly LoadOutcome[];
|
|
@@ -61,6 +106,11 @@ export type ConfidenceInput = {
|
|
|
61
106
|
maxOldSpaceMb?: number;
|
|
62
107
|
/** Warm-up requests the run sent before the baseline, for the warm-up check. */
|
|
63
108
|
warmupRequests?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Whether this route is served from the ISR cache. Decides whether the
|
|
111
|
+
* cache-residency remedy exists — see `cacheResidencyWarnings`.
|
|
112
|
+
*/
|
|
113
|
+
revalidatesFromCache?: boolean;
|
|
64
114
|
};
|
|
65
115
|
/**
|
|
66
116
|
* Cycles a re-measurement uses when a verdict came back `inconclusive` — the
|
package/dist/control-server.d.ts
CHANGED
|
@@ -11,6 +11,27 @@ export type HeapSample = {
|
|
|
11
11
|
rss: number;
|
|
12
12
|
external: number;
|
|
13
13
|
arrayBuffers: number;
|
|
14
|
+
/**
|
|
15
|
+
* Identity of the process that produced this sample. Optional in the type so
|
|
16
|
+
* fixtures stay readable; the wire schema in `control-client.ts` requires it,
|
|
17
|
+
* so a real sample always carries it.
|
|
18
|
+
*/
|
|
19
|
+
pid?: number;
|
|
20
|
+
ppid?: number;
|
|
21
|
+
argv?: readonly string[];
|
|
22
|
+
cwd?: string;
|
|
23
|
+
/**
|
|
24
|
+
* HTTP requests this process has served since boot, counted by the probe in
|
|
25
|
+
* `bootstrap.ts`. Recorded as evidence of how much traffic reached the
|
|
26
|
+
* process behind a reading, not checked anywhere: a clustered server counts
|
|
27
|
+
* in whichever process took the connection. `undefined` when the probe was
|
|
28
|
+
* not installed.
|
|
29
|
+
*/
|
|
30
|
+
servedRequests?: number | undefined;
|
|
31
|
+
};
|
|
32
|
+
/** Where `bootstrap.ts` publishes the served-request count. */
|
|
33
|
+
export declare const requestProbe: typeof globalThis & {
|
|
34
|
+
__nextLeakServedRequests?: number;
|
|
14
35
|
};
|
|
15
36
|
export type ControlServerOptions = {
|
|
16
37
|
/** Directory where heap snapshots are written. */
|
|
@@ -1,9 +1,9 @@
|
|
|
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-
|
|
5
|
-
import "./chunk-
|
|
6
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-G2777DKL.js";
|
|
5
|
+
import "./chunk-UU4RXOZO.js";
|
|
6
|
+
import "./chunk-E464SO7G.js";
|
|
7
7
|
import "./chunk-6XYFBOL2.js";
|
|
8
8
|
export {
|
|
9
9
|
renderHtmlReport
|
package/dist/index.js
CHANGED
|
@@ -41,17 +41,17 @@ import {
|
|
|
41
41
|
sourceIndexAt,
|
|
42
42
|
summarizeBaseline,
|
|
43
43
|
validateTarget
|
|
44
|
-
} from "./chunk-
|
|
44
|
+
} from "./chunk-L5KWU2RU.js";
|
|
45
45
|
import {
|
|
46
46
|
renderHtmlReport
|
|
47
|
-
} from "./chunk-
|
|
47
|
+
} from "./chunk-G2777DKL.js";
|
|
48
48
|
import {
|
|
49
49
|
classifyTrend
|
|
50
|
-
} from "./chunk-
|
|
50
|
+
} from "./chunk-UU4RXOZO.js";
|
|
51
51
|
import {
|
|
52
52
|
renderIssueMarkdown
|
|
53
|
-
} from "./chunk-
|
|
54
|
-
import "./chunk-
|
|
53
|
+
} from "./chunk-YPVBX3QA.js";
|
|
54
|
+
import "./chunk-E464SO7G.js";
|
|
55
55
|
import "./chunk-6XYFBOL2.js";
|
|
56
56
|
export {
|
|
57
57
|
LaunchError,
|
package/dist/isr.d.ts
CHANGED
|
@@ -25,6 +25,10 @@ export type RevalidationPlan = {
|
|
|
25
25
|
kind: "drive";
|
|
26
26
|
headers: Record<string, string>;
|
|
27
27
|
}
|
|
28
|
+
/** ISR, but every request asks for a key the cache has never held. */
|
|
29
|
+
| {
|
|
30
|
+
kind: "no-cache-to-drive";
|
|
31
|
+
}
|
|
28
32
|
/** ISR, but the manifest cannot supply what an authentic request needs. */
|
|
29
33
|
| {
|
|
30
34
|
kind: "cannot-drive";
|
|
@@ -35,5 +39,17 @@ export type RevalidationPlan = {
|
|
|
35
39
|
*
|
|
36
40
|
* A header the user set themselves wins untouched: someone driving a bespoke
|
|
37
41
|
* revalidation path knows more about it than the manifest does.
|
|
42
|
+
*
|
|
43
|
+
* `requestPath` decides whether driving is needed at all. The header does not
|
|
44
|
+
* merely bypass the cache: it makes Next serve the request through its
|
|
45
|
+
* revalidation path instead of its normal one, and a route whose render count
|
|
46
|
+
* depends on the normal path is then measured somewhere its users never go.
|
|
47
|
+
* Measured on the vercel/next.js#99077 reproduction: with the header, the
|
|
48
|
+
* `partialPrefetching: true` and `false` builds both created 1.95 timers per
|
|
49
|
+
* request and were reported at the same +1970 MB/1000 req; without it they
|
|
50
|
+
* created 3.80 and 0.99 and separated 5x, matching the issue. So the header is
|
|
51
|
+
* sent only where it buys something — when the load revisits keys the cache
|
|
52
|
+
* can already hold. With `{n}` every request carries a key the route has never
|
|
53
|
+
* served, so there is no cache to bypass and driving only distorts.
|
|
38
54
|
*/
|
|
39
|
-
export declare function planRevalidation(manifest: PrerenderManifest | undefined, route: string, userHeaders: Record<string, string> | undefined): RevalidationPlan;
|
|
55
|
+
export declare function planRevalidation(manifest: PrerenderManifest | undefined, route: string, userHeaders: Record<string, string> | undefined, requestPath?: string): RevalidationPlan;
|
|
@@ -1,8 +1,8 @@
|
|
|
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
|
renderIssueMarkdown
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-YPVBX3QA.js";
|
|
5
|
+
import "./chunk-E464SO7G.js";
|
|
6
6
|
import "./chunk-6XYFBOL2.js";
|
|
7
7
|
export {
|
|
8
8
|
renderIssueMarkdown
|
package/dist/peak-pressure.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TrendResult } from "./trend.js";
|
|
2
|
+
import type { HeapSample } from "./control-server.js";
|
|
1
3
|
import type { PeakSample } from "./ritual.js";
|
|
2
4
|
/**
|
|
3
5
|
* Which ceiling the process came closest to.
|
|
@@ -23,17 +25,80 @@ export type PeakPressureInput = {
|
|
|
23
25
|
retainedHeapBytes: number;
|
|
24
26
|
maxOldSpaceMb: number;
|
|
25
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* What a route retains once it has served traffic: the floor of the post-GC
|
|
30
|
+
* cycle samples, not the last of them.
|
|
31
|
+
*
|
|
32
|
+
* Every sample here follows a forced collection, but "after a forced
|
|
33
|
+
* collection" is not "after everything collectable was collected" — a sample
|
|
34
|
+
* can carry memory the collector had not reached yet. Measured on the
|
|
35
|
+
* vercel/next.js#92287 reproduction, 2026-09-24, one run's cycle samples were
|
|
36
|
+
* 217.1 / 125.0 / 45.2 / 194.9 MB, and the next run's were 69.3 / 121.2 / 48.4
|
|
37
|
+
* / 41.0. Taking the last put the figure everything here divides by at 194.9 MB
|
|
38
|
+
* on one run and 41.0 MB on the other, which by itself decided whether the same
|
|
39
|
+
* app under the same load was reported at all.
|
|
40
|
+
*
|
|
41
|
+
* The floor is the honest reading: what a process comes back down to is what it
|
|
42
|
+
* holds, and everything above it is memory not yet reclaimed. The baseline is
|
|
43
|
+
* excluded because it precedes any traffic — a route retains nothing before it
|
|
44
|
+
* has served anything, and dividing by that would make every route look
|
|
45
|
+
* disproportionate.
|
|
46
|
+
*/
|
|
47
|
+
export declare function retainedAfterLoad(memorySamples: readonly HeapSample[]): number | undefined;
|
|
26
48
|
/**
|
|
27
49
|
* Whether a route's peak is far enough from the memory its verdict was
|
|
28
50
|
* computed on to be worth saying out loud.
|
|
29
51
|
*
|
|
30
|
-
*
|
|
31
|
-
* statements about retention after GC, calibrated against
|
|
32
|
-
* false positives, and a peak is a different axis. A
|
|
33
|
-
*
|
|
34
|
-
*
|
|
52
|
+
* On its own this stays outside the verdict, and for the original reason:
|
|
53
|
+
* `leak`/`stable` are statements about retention after GC, calibrated against
|
|
54
|
+
* real leaks with no false positives, and a peak is a different axis. A single
|
|
55
|
+
* high peak is a size, not a direction — an app that reserves 600 MB on its
|
|
56
|
+
* first cycle and holds that level is doing nothing wrong.
|
|
57
|
+
*
|
|
58
|
+
* What the note alone could not carry is the rest of that sentence: a process
|
|
59
|
+
* that climbs to 3.5 GB and hands it all back is honestly `stable`, and still
|
|
60
|
+
* OOM-killed in a 1 GB container. `assessPressureVerdict` below is where a run
|
|
61
|
+
* that reaches the ceiling on every cycle stops being a footnote under a `✔`.
|
|
35
62
|
*/
|
|
36
63
|
export declare function assessPeakPressure(input: PeakPressureInput): PeakPressure | null;
|
|
64
|
+
export type PressureVerdictInput = {
|
|
65
|
+
/** The post-GC verdict, exactly as the classifier produced it. */
|
|
66
|
+
trend: TrendResult;
|
|
67
|
+
peaks: readonly PeakSample[];
|
|
68
|
+
retainedHeapBytes: number;
|
|
69
|
+
maxOldSpaceMb: number;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Whether a run that retains nothing is nonetheless heading for the ceiling.
|
|
73
|
+
*
|
|
74
|
+
* Every number a verdict is computed from is taken after a forced collection,
|
|
75
|
+
* and production never runs those. That makes the verdict structurally blind to
|
|
76
|
+
* a whole class of death: memory a full GC does reclaim, allocated faster than
|
|
77
|
+
* the runtime reclaims it on its own. Measured on the vercel/next.js#92287
|
|
78
|
+
* reproduction, 2026-09-23 — the app grew ~1 MB of `arrayBuffers` per request
|
|
79
|
+
* to over 3 GB and died, and this tool called it `stable` at -9.00 MB/1000
|
|
80
|
+
* requests, because a forced GC handed all of it back before every sample. That
|
|
81
|
+
* is the trap next-leak exists to warn other people about.
|
|
82
|
+
*
|
|
83
|
+
* Three conditions, and no threshold of its own: the ceiling rules are
|
|
84
|
+
* `assessPeakPressure`'s, asked of each cycle instead of the highest reading.
|
|
85
|
+
*
|
|
86
|
+
* - **The post-GC verdict is `stable` or `saturating`.** `leak` is already the
|
|
87
|
+
* worse news and `inconclusive` is an admission that the series did not
|
|
88
|
+
* decide — promoting *that* to an accusation would be inventing a finding out
|
|
89
|
+
* of a measurement that failed.
|
|
90
|
+
* - **The peak is far enough from what the route retains to be remarked on** —
|
|
91
|
+
* `assessPeakPressure`, thresholds unchanged.
|
|
92
|
+
* - **Every settled cycle reached that ceiling**, not just the highest one.
|
|
93
|
+
* This is the condition that makes the verdict safe. A single high peak is an
|
|
94
|
+
* episode and gets only the note; a process that returns to the ceiling every
|
|
95
|
+
* time it serves traffic is describing what it does under load, which is the
|
|
96
|
+
* thing a container is sized against.
|
|
97
|
+
*
|
|
98
|
+
* Returns the trend unchanged when it does not qualify, so `trend.verdict`
|
|
99
|
+
* stays the raw record everywhere else.
|
|
100
|
+
*/
|
|
101
|
+
export declare function assessPressureVerdict(input: PressureVerdictInput): TrendResult;
|
|
37
102
|
/**
|
|
38
103
|
* One line, phrased so it never contradicts the verdict next to it. A peak is
|
|
39
104
|
* the highest value *sampled*: a spike shorter than the poll interval is not
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Server } from "node:net";
|
|
2
|
+
/**
|
|
3
|
+
* Wraps a module's `createServer` so every server it hands out counts the
|
|
4
|
+
* requests it serves.
|
|
5
|
+
*
|
|
6
|
+
* Its own module rather than a helper inside `bootstrap.ts`, because the
|
|
7
|
+
* bootstrap is an entry point that runs on import and lands inside the
|
|
8
|
+
* *measured* process — where no coverage instrumentation reaches it, and where
|
|
9
|
+
* a mistake is only visible as a run whose numbers came from a process that
|
|
10
|
+
* served nothing.
|
|
11
|
+
*
|
|
12
|
+
* `http.createServer` and `https.createServer` have different signatures, so
|
|
13
|
+
* there is no shared type to borrow. Only the return value is touched, and the
|
|
14
|
+
* arguments are forwarded untyped and unchanged.
|
|
15
|
+
*/
|
|
16
|
+
export declare function countServedRequests<T extends {
|
|
17
|
+
createServer: (...args: never[]) => Server;
|
|
18
|
+
}>(module: T): void;
|
package/dist/route-config.d.ts
CHANGED
|
@@ -42,6 +42,29 @@ export declare function boundedMarkerOf(value: string): {
|
|
|
42
42
|
marker: string;
|
|
43
43
|
bound: number;
|
|
44
44
|
} | null;
|
|
45
|
+
/**
|
|
46
|
+
* Whether a request path mixes both load markers across its segments.
|
|
47
|
+
*
|
|
48
|
+
* A single sample value carrying both is rejected when the config loads, but
|
|
49
|
+
* two params of the same route can each carry a different one, and the mix has
|
|
50
|
+
* no coherent meaning: the load phase resolves the bounded marker per request
|
|
51
|
+
* and leaves `{n}` in the path as a literal, so every request asks for a URL
|
|
52
|
+
* with `%7Bn%7D` in it. The route is refused rather than measured that way.
|
|
53
|
+
*/
|
|
54
|
+
export declare function mixesMarkers(requestPath: string): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* A concrete path to ask for on behalf of a request path that still carries
|
|
57
|
+
* load markers.
|
|
58
|
+
*
|
|
59
|
+
* The readiness probe has to ask for what the load will ask for, but expanding
|
|
60
|
+
* the markers belongs to the load phase. Sent as they are, the probe requests a
|
|
61
|
+
* literal `/posts/post-%7Bn%7D` — a key no request of the run will ever revisit,
|
|
62
|
+
* planted in the route's cache before the baseline snapshot is taken.
|
|
63
|
+
*
|
|
64
|
+
* `0` is the value the build is most likely to have prerendered already, and it
|
|
65
|
+
* is one the bounded sequence visits anyway.
|
|
66
|
+
*/
|
|
67
|
+
export declare function probeRequestPath(requestPath: string): string;
|
|
45
68
|
/**
|
|
46
69
|
* Substitutes sample values into a dynamic route template and returns a
|
|
47
70
|
* URL-safe request path (or null when a param has no configured value;
|
package/dist/runner.d.ts
CHANGED
|
@@ -108,12 +108,18 @@ export type RouteReport = {
|
|
|
108
108
|
*/
|
|
109
109
|
keyCardinality?: number;
|
|
110
110
|
/**
|
|
111
|
-
* Seconds of
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* against a re-render are different experiments.
|
|
111
|
+
* Seconds of this route's ISR revalidation period. Absent on routes not
|
|
112
|
+
* served from the ISR cache. Recorded because a curve measured against a
|
|
113
|
+
* cache and one measured against a re-render are different experiments.
|
|
115
114
|
*/
|
|
116
115
|
revalidatedEverySeconds?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Whether the load carried the build's own revalidation header. Set apart
|
|
118
|
+
* from the period because the two answer different questions: the period
|
|
119
|
+
* says the ISR cache is in play, this says which of Next's two paths
|
|
120
|
+
* served the requests that produced the curve.
|
|
121
|
+
*/
|
|
122
|
+
revalidationDriven?: true;
|
|
117
123
|
/** RSS growth per 1000 requests, computed like the heap figure. */
|
|
118
124
|
rssPer1000Requests: number;
|
|
119
125
|
/** Wall-clock per phase — explains where a long run spent its time. */
|
package/dist/trend.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type TrendVerdict = "leak" | "stable" | "inconclusive" | "saturating";
|
|
1
|
+
export type TrendVerdict = "leak" | "stable" | "inconclusive" | "saturating" | "pressure";
|
|
2
2
|
export type TrendResult = {
|
|
3
3
|
verdict: TrendVerdict;
|
|
4
4
|
/** Mean retained-heap growth per cycle (bytes) over the analyzed window. */
|