next-leak 0.11.3 → 0.12.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 +42 -3
- package/dist/bootstrap.js +27 -1
- package/dist/{chunk-4FYSLLSX.js → chunk-4NKGNMEA.js} +19 -4
- package/dist/{chunk-72SHITHC.js → chunk-5VXBMWDN.js} +85 -19
- package/dist/{chunk-YLHE4N5G.js → chunk-7WUE3WOI.js} +9 -5
- package/dist/{chunk-XHPUAMJG.js → chunk-E464SO7G.js} +39 -2
- package/dist/{chunk-AUUMRTZZ.js → chunk-YPVBX3QA.js} +4 -3
- package/dist/cli.js +3 -3
- package/dist/confidence.d.ts +21 -0
- package/dist/control-server.d.ts +21 -0
- package/dist/{html-report-O7ILFOF5.js → html-report-6KGPHCMA.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 })
|
|
@@ -106,13 +106,17 @@ 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
|
|
|
@@ -125,7 +129,8 @@ var VERDICT_WEAKENING = /* @__PURE__ */ new Set([
|
|
|
125
129
|
"near-threshold",
|
|
126
130
|
"spiky-growth",
|
|
127
131
|
"thin-evidence",
|
|
128
|
-
"repetitions-disagree"
|
|
132
|
+
"repetitions-disagree",
|
|
133
|
+
"cache-residency"
|
|
129
134
|
]);
|
|
130
135
|
function warrantsIssueDraft(report) {
|
|
131
136
|
return effectiveVerdict(report) === "leak" && !report.confidence.warnings.some((warning) => VERDICT_WEAKENING.has(warning.code));
|
|
@@ -275,6 +280,15 @@ function thinEvidenceWarnings(trend, minGrowth) {
|
|
|
275
280
|
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
281
|
}];
|
|
277
282
|
}
|
|
283
|
+
function cacheResidencyWarnings(trend, revalidatesFromCache) {
|
|
284
|
+
if (trend.cacheDriven !== true || trend.verdict !== "leak" || revalidatesFromCache !== true) {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
return [{
|
|
288
|
+
code: "cache-residency",
|
|
289
|
+
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`
|
|
290
|
+
}];
|
|
291
|
+
}
|
|
278
292
|
function isVerdictInvalid(input) {
|
|
279
293
|
if (input.trend.verdict !== "leak") {
|
|
280
294
|
return false;
|
|
@@ -295,6 +309,7 @@ function assessConfidence(input) {
|
|
|
295
309
|
...growthShapeWarnings(input.trend),
|
|
296
310
|
...noiseFloorWarnings(input.trend, minGrowth),
|
|
297
311
|
...thinEvidenceWarnings(input.trend, minGrowth),
|
|
312
|
+
...cacheResidencyWarnings(input.trend, input.revalidatesFromCache),
|
|
298
313
|
...heapCeilingWarnings(input),
|
|
299
314
|
...warmUpBaselineWarnings(input)
|
|
300
315
|
];
|
|
@@ -7,11 +7,13 @@ import {
|
|
|
7
7
|
minGrowthFor,
|
|
8
8
|
resolveCycles,
|
|
9
9
|
warrantsIssueDraft
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-4NKGNMEA.js";
|
|
11
11
|
import {
|
|
12
12
|
assessPeakPressure,
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
assessPressureVerdict,
|
|
14
|
+
describePeakPressure,
|
|
15
|
+
retainedAfterLoad
|
|
16
|
+
} from "./chunk-E464SO7G.js";
|
|
15
17
|
import {
|
|
16
18
|
__commonJS,
|
|
17
19
|
__esm,
|
|
@@ -94047,6 +94049,14 @@ var encodeSegment = (value) => restoreMarkers(encodeURIComponent(value).split(EN
|
|
|
94047
94049
|
var ENCODED_BOUNDED = /%7Bn%25(\d+)%7D/gi;
|
|
94048
94050
|
var restoreMarkers = (value) => value.replace(ENCODED_BOUNDED, (_match, bound) => `{n%${bound}}`);
|
|
94049
94051
|
var encodeCatchAll = (value) => value.split("/").map(encodeSegment).join("/");
|
|
94052
|
+
function mixesMarkers(requestPath) {
|
|
94053
|
+
return requestPath.includes(UNIQUE_MARKER) && boundedMarkerOf(requestPath) !== null;
|
|
94054
|
+
}
|
|
94055
|
+
function probeRequestPath(requestPath) {
|
|
94056
|
+
const bounded = boundedMarkerOf(requestPath);
|
|
94057
|
+
const withBound = bounded === null ? requestPath : requestPath.split(bounded.marker).join("0");
|
|
94058
|
+
return withBound.split(UNIQUE_MARKER).join("0");
|
|
94059
|
+
}
|
|
94050
94060
|
function resolveRoutePath(routeTemplate, config) {
|
|
94051
94061
|
const resolved = [];
|
|
94052
94062
|
for (const segment of routeTemplate.split("/")) {
|
|
@@ -94189,7 +94199,12 @@ var sampleSchema = z5.object({
|
|
|
94189
94199
|
heapUsed: z5.number(),
|
|
94190
94200
|
rss: z5.number(),
|
|
94191
94201
|
external: z5.number(),
|
|
94192
|
-
arrayBuffers: z5.number()
|
|
94202
|
+
arrayBuffers: z5.number(),
|
|
94203
|
+
pid: z5.number(),
|
|
94204
|
+
ppid: z5.number(),
|
|
94205
|
+
argv: z5.array(z5.string()),
|
|
94206
|
+
cwd: z5.string(),
|
|
94207
|
+
servedRequests: z5.number().optional()
|
|
94193
94208
|
});
|
|
94194
94209
|
var snapshotResponseSchema = z5.object({ file: z5.string(), sample: sampleSchema });
|
|
94195
94210
|
var ControlError = class extends Error {
|
|
@@ -94560,8 +94575,10 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94560
94575
|
bootstrapPath: options.bootstrapPath,
|
|
94561
94576
|
// Wait on the route this ritual is about to measure. `/` is a different
|
|
94562
94577
|
// page with different failure modes, and readiness judged on it withdrew
|
|
94563
|
-
// routes that were serving fine (#74).
|
|
94564
|
-
|
|
94578
|
+
// routes that were serving fine (#74). Markers resolved, so the probe asks
|
|
94579
|
+
// for a key the load will also ask for rather than planting a literal
|
|
94580
|
+
// `{n}` in the route's cache.
|
|
94581
|
+
readyPath: probeRequestPath(options.route),
|
|
94565
94582
|
...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb },
|
|
94566
94583
|
...options.readyTimeoutMs !== void 0 && { readyTimeoutMs: options.readyTimeoutMs }
|
|
94567
94584
|
};
|
|
@@ -94713,7 +94730,16 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94713
94730
|
baselineSnapshot,
|
|
94714
94731
|
afterSnapshot,
|
|
94715
94732
|
...snapshotFailure !== void 0 && { snapshotFailure },
|
|
94716
|
-
|
|
94733
|
+
// A forced GC runs before every sample above, and production runs none.
|
|
94734
|
+
// The peaks are the only readings in this result taken under load, so
|
|
94735
|
+
// this is the one place that can tell a route which retains nothing from
|
|
94736
|
+
// a route which retains nothing and dies anyway.
|
|
94737
|
+
trend: assessPressureVerdict({
|
|
94738
|
+
trend: classifyMemoryTrend(samples, externalSamples, trendOptions),
|
|
94739
|
+
peaks,
|
|
94740
|
+
retainedHeapBytes: retainedAfterLoad(memorySamples) ?? 0,
|
|
94741
|
+
maxOldSpaceMb: options.maxOldSpaceMb ?? RITUAL_DEFAULTS.maxOldSpaceMb
|
|
94742
|
+
}),
|
|
94717
94743
|
requestsPerCycle: loadRequests,
|
|
94718
94744
|
minGrowthPerCycle
|
|
94719
94745
|
};
|
|
@@ -94917,7 +94943,8 @@ var VERDICT_ICON = {
|
|
|
94917
94943
|
leak: "\u2716",
|
|
94918
94944
|
stable: "\u2714",
|
|
94919
94945
|
inconclusive: "?",
|
|
94920
|
-
saturating: "~"
|
|
94946
|
+
saturating: "~",
|
|
94947
|
+
pressure: "\u25B2"
|
|
94921
94948
|
};
|
|
94922
94949
|
var RSS_MIN_GROWTH_PER_CYCLE = 16 * MB2;
|
|
94923
94950
|
var RSS_MIN_TOTAL_GROWTH = 64 * MB2;
|
|
@@ -94943,8 +94970,14 @@ function ownerLabel(attribution) {
|
|
|
94943
94970
|
}
|
|
94944
94971
|
}
|
|
94945
94972
|
function revalidationLines(route) {
|
|
94946
|
-
|
|
94947
|
-
|
|
94973
|
+
if (route.revalidatedEverySeconds === void 0) {
|
|
94974
|
+
return [];
|
|
94975
|
+
}
|
|
94976
|
+
const every = `revalidates every ${route.revalidatedEverySeconds}s`;
|
|
94977
|
+
return route.revalidationDriven === true ? [
|
|
94978
|
+
` 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`
|
|
94979
|
+
] : [
|
|
94980
|
+
` 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
94981
|
];
|
|
94949
94982
|
}
|
|
94950
94983
|
function repetitionLines(route) {
|
|
@@ -94971,9 +95004,22 @@ function cacheLines(route) {
|
|
|
94971
95004
|
lines.push(
|
|
94972
95005
|
` 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
95006
|
);
|
|
95007
|
+
if (route.revalidatedEverySeconds !== void 0) {
|
|
95008
|
+
lines.push(
|
|
95009
|
+
` 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`
|
|
95010
|
+
);
|
|
95011
|
+
}
|
|
94974
95012
|
}
|
|
94975
95013
|
return lines;
|
|
94976
95014
|
}
|
|
95015
|
+
function pressureLines(verdict) {
|
|
95016
|
+
if (verdict !== "pressure") {
|
|
95017
|
+
return [];
|
|
95018
|
+
}
|
|
95019
|
+
return [
|
|
95020
|
+
` 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`
|
|
95021
|
+
];
|
|
95022
|
+
}
|
|
94977
95023
|
function abandonLines(route) {
|
|
94978
95024
|
if (route.abandon === void 0) {
|
|
94979
95025
|
return [];
|
|
@@ -95002,7 +95048,7 @@ function memorySourceLines(route, verdict) {
|
|
|
95002
95048
|
` verdict comes from EXTERNAL memory (buffers, streams, fetch bodies), not the JS heap: external ${externalCurve}`
|
|
95003
95049
|
);
|
|
95004
95050
|
}
|
|
95005
|
-
if (verdict === "stable" && hasSustainedRssGrowth(route.memorySamples)) {
|
|
95051
|
+
if ((verdict === "stable" || verdict === "pressure") && hasSustainedRssGrowth(route.memorySamples)) {
|
|
95006
95052
|
const rssCurve = route.memorySamples.map((sample) => formatMb(sample.rss)).join(" \u2192 ");
|
|
95007
95053
|
lines.push(
|
|
95008
95054
|
` 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 +95083,7 @@ function findingLines(route) {
|
|
|
95037
95083
|
return lines;
|
|
95038
95084
|
}
|
|
95039
95085
|
function peakPressureLines(route, parameters) {
|
|
95040
|
-
const retained = route.memorySamples
|
|
95086
|
+
const retained = retainedAfterLoad(route.memorySamples);
|
|
95041
95087
|
if (retained === void 0) {
|
|
95042
95088
|
return [];
|
|
95043
95089
|
}
|
|
@@ -95084,6 +95130,7 @@ function routeLines(route, parameters) {
|
|
|
95084
95130
|
...repetitionLines(route),
|
|
95085
95131
|
...revalidationLines(route),
|
|
95086
95132
|
...cacheLines(route),
|
|
95133
|
+
...pressureLines(verdict),
|
|
95087
95134
|
...abandonLines(route),
|
|
95088
95135
|
...confidenceLines(route),
|
|
95089
95136
|
...memorySourceLines(route, verdict),
|
|
@@ -95304,6 +95351,9 @@ import { createServer } from "net";
|
|
|
95304
95351
|
import path9 from "path";
|
|
95305
95352
|
|
|
95306
95353
|
// src/isr.ts
|
|
95354
|
+
function keysAreNewEveryRequest(requestPath) {
|
|
95355
|
+
return requestPath.includes(UNIQUE_MARKER) && boundedMarkerOf(requestPath) === null;
|
|
95356
|
+
}
|
|
95307
95357
|
var REVALIDATE_HEADER = "x-prerender-revalidate";
|
|
95308
95358
|
function revalidateSecondsFor(manifest, route) {
|
|
95309
95359
|
const routes = manifest?.routes;
|
|
@@ -95324,7 +95374,7 @@ function revalidateSecondsFor(manifest, route) {
|
|
|
95324
95374
|
function revalidates(manifest, route) {
|
|
95325
95375
|
return revalidateSecondsFor(manifest, route) !== null;
|
|
95326
95376
|
}
|
|
95327
|
-
function planRevalidation(manifest, route, userHeaders) {
|
|
95377
|
+
function planRevalidation(manifest, route, userHeaders, requestPath) {
|
|
95328
95378
|
const userSupplied = Object.keys(userHeaders ?? {}).some(
|
|
95329
95379
|
(name) => name.toLowerCase() === REVALIDATE_HEADER
|
|
95330
95380
|
);
|
|
@@ -95334,6 +95384,9 @@ function planRevalidation(manifest, route, userHeaders) {
|
|
|
95334
95384
|
if (!revalidates(manifest, route)) {
|
|
95335
95385
|
return { kind: "not-isr" };
|
|
95336
95386
|
}
|
|
95387
|
+
if (requestPath !== void 0 && keysAreNewEveryRequest(requestPath)) {
|
|
95388
|
+
return { kind: "no-cache-to-drive" };
|
|
95389
|
+
}
|
|
95337
95390
|
const previewModeId = manifest?.preview?.previewModeId;
|
|
95338
95391
|
if (previewModeId === void 0 || previewModeId === "") {
|
|
95339
95392
|
return {
|
|
@@ -95447,15 +95500,18 @@ function skipReason(route, requestPath) {
|
|
|
95447
95500
|
if (requestPath === null) {
|
|
95448
95501
|
return "needs sample params for dynamic segments (next-leak.config.json)";
|
|
95449
95502
|
}
|
|
95503
|
+
if (mixesMarkers(requestPath)) {
|
|
95504
|
+
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.`;
|
|
95505
|
+
}
|
|
95450
95506
|
return null;
|
|
95451
95507
|
}
|
|
95452
95508
|
async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
95453
95509
|
const { deps, options, target, workDir, routeConfig, registry, nextVersion, progress } = context;
|
|
95454
|
-
const plan = planRevalidation(target.prerender, route.path, routeConfig.headers);
|
|
95510
|
+
const plan = planRevalidation(target.prerender, route.path, routeConfig.headers, requestPath);
|
|
95455
95511
|
const revalidateSeconds = revalidateSecondsFor(target.prerender, route.path);
|
|
95456
95512
|
const bounded = boundedMarkerOf(requestPath);
|
|
95457
95513
|
const driven = plan.kind === "drive" ? plan.headers : {};
|
|
95458
|
-
const cacheDriven = plan.kind === "drive" && bounded === null;
|
|
95514
|
+
const cacheDriven = plan.kind === "no-cache-to-drive" || plan.kind === "drive" && bounded === null;
|
|
95459
95515
|
const merged = { ...driven, ...routeConfig.headers ?? {} };
|
|
95460
95516
|
const headers = Object.keys(merged).length === 0 ? void 0 : merged;
|
|
95461
95517
|
const result = await deps.ritual({
|
|
@@ -95493,6 +95549,9 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95493
95549
|
memorySamples: result.memorySamples,
|
|
95494
95550
|
maxOldSpaceMb: options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB,
|
|
95495
95551
|
warmupRequests: options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests,
|
|
95552
|
+
// Decides whether the cache-residency remedy exists on this route: bounding
|
|
95553
|
+
// the keys of an ISR route hands the requests back to the cache.
|
|
95554
|
+
revalidatesFromCache: revalidateSeconds !== null,
|
|
95496
95555
|
...routeConfig.abandonAfterMs !== void 0 && {
|
|
95497
95556
|
abandonAfterMs: routeConfig.abandonAfterMs
|
|
95498
95557
|
},
|
|
@@ -95512,7 +95571,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95512
95571
|
detail: result.snapshotFailure
|
|
95513
95572
|
};
|
|
95514
95573
|
progress(`no snapshot to attribute for ${route.path}: ${result.snapshotFailure}`);
|
|
95515
|
-
} else if (verdict !== "stable" || options.diffAll === true) {
|
|
95574
|
+
} else if (verdict !== "stable" && verdict !== "pressure" || options.diffAll === true) {
|
|
95516
95575
|
progress(`diffing snapshots for ${route.path}`);
|
|
95517
95576
|
try {
|
|
95518
95577
|
diff = await deps.diff(result.baselineSnapshot, result.afterSnapshot);
|
|
@@ -95535,6 +95594,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95535
95594
|
memorySamples: result.memorySamples,
|
|
95536
95595
|
peaks: result.peaks,
|
|
95537
95596
|
...revalidateSeconds !== null && { revalidatedEverySeconds: revalidateSeconds },
|
|
95597
|
+
...plan.kind === "drive" && { revalidationDriven: true },
|
|
95538
95598
|
...bounded !== null && { keyCardinality: bounded.bound },
|
|
95539
95599
|
unreclaimedSamples: result.unreclaimedSamples,
|
|
95540
95600
|
unreclaimedTrend: result.unreclaimedTrend,
|
|
@@ -95561,8 +95621,8 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95561
95621
|
};
|
|
95562
95622
|
}
|
|
95563
95623
|
async function writeEvidenceBundle(report, workDir) {
|
|
95564
|
-
const { renderHtmlReport } = await import("./html-report-
|
|
95565
|
-
const { renderIssueMarkdown } = await import("./issue-report-
|
|
95624
|
+
const { renderHtmlReport } = await import("./html-report-6KGPHCMA.js");
|
|
95625
|
+
const { renderIssueMarkdown } = await import("./issue-report-OALZZLVR.js");
|
|
95566
95626
|
for (const route of report.routes) {
|
|
95567
95627
|
if (route.status === "measured" && warrantsIssueDraft(route)) {
|
|
95568
95628
|
const file = path9.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
|
|
@@ -95594,7 +95654,12 @@ async function routeReportFor(context, route, index, total) {
|
|
|
95594
95654
|
progress(`skipping ${label}: ${reason ?? "needs sample params"}`);
|
|
95595
95655
|
return { route: route.path, status: "skipped", reason: reason ?? "needs sample params" };
|
|
95596
95656
|
}
|
|
95597
|
-
const plan = planRevalidation(
|
|
95657
|
+
const plan = planRevalidation(
|
|
95658
|
+
context.target.prerender,
|
|
95659
|
+
route.path,
|
|
95660
|
+
routeConfig.headers,
|
|
95661
|
+
requestPath
|
|
95662
|
+
);
|
|
95598
95663
|
if (plan.kind === "cannot-drive") {
|
|
95599
95664
|
progress(`not measuring ${label}: ${plan.reason}`);
|
|
95600
95665
|
return { route: route.path, status: "not-exercised", reason: plan.reason };
|
|
@@ -95609,6 +95674,7 @@ function reasonToResolve(verdict) {
|
|
|
95609
95674
|
return "growth was still decelerating when the window ran out";
|
|
95610
95675
|
case "leak":
|
|
95611
95676
|
case "stable":
|
|
95677
|
+
case "pressure":
|
|
95612
95678
|
return null;
|
|
95613
95679
|
}
|
|
95614
95680
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
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-4NKGNMEA.js";
|
|
5
5
|
import {
|
|
6
6
|
assessPeakPressure,
|
|
7
|
-
describePeakPressure
|
|
8
|
-
|
|
7
|
+
describePeakPressure,
|
|
8
|
+
retainedAfterLoad
|
|
9
|
+
} from "./chunk-E464SO7G.js";
|
|
9
10
|
|
|
10
11
|
// src/html-report.ts
|
|
11
12
|
var MB = 1024 * 1024;
|
|
@@ -13,7 +14,10 @@ var VERDICT_COLOR = {
|
|
|
13
14
|
leak: "#c0392b",
|
|
14
15
|
stable: "#27ae60",
|
|
15
16
|
inconclusive: "#e67e22",
|
|
16
|
-
saturating: "#2980b9"
|
|
17
|
+
saturating: "#2980b9",
|
|
18
|
+
// Its own colour, not the leak red: nothing was retained, and nothing is
|
|
19
|
+
// green about a process that reached a ceiling it cannot come back from.
|
|
20
|
+
pressure: "#8e44ad"
|
|
17
21
|
};
|
|
18
22
|
function escapeHtml(value) {
|
|
19
23
|
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
@@ -46,7 +50,7 @@ function ownerCell(attribution) {
|
|
|
46
50
|
return `${attribution.owner}${source}${packageName}`;
|
|
47
51
|
}
|
|
48
52
|
function peakBlock(route, parameters) {
|
|
49
|
-
const retained = route.memorySamples
|
|
53
|
+
const retained = retainedAfterLoad(route.memorySamples);
|
|
50
54
|
if (retained === void 0 || route.peaks === void 0) {
|
|
51
55
|
return "";
|
|
52
56
|
}
|
|
@@ -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,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-5VXBMWDN.js";
|
|
26
26
|
import {
|
|
27
27
|
classifyTrend
|
|
28
|
-
} from "./chunk-
|
|
29
|
-
import "./chunk-
|
|
28
|
+
} from "./chunk-4NKGNMEA.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
|
*
|
|
@@ -61,6 +77,11 @@ export type ConfidenceInput = {
|
|
|
61
77
|
maxOldSpaceMb?: number;
|
|
62
78
|
/** Warm-up requests the run sent before the baseline, for the warm-up check. */
|
|
63
79
|
warmupRequests?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Whether this route is served from the ISR cache. Decides whether the
|
|
82
|
+
* cache-residency remedy exists — see `cacheResidencyWarnings`.
|
|
83
|
+
*/
|
|
84
|
+
revalidatesFromCache?: boolean;
|
|
64
85
|
};
|
|
65
86
|
/**
|
|
66
87
|
* 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-7WUE3WOI.js";
|
|
5
|
+
import "./chunk-4NKGNMEA.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-5VXBMWDN.js";
|
|
45
45
|
import {
|
|
46
46
|
renderHtmlReport
|
|
47
|
-
} from "./chunk-
|
|
47
|
+
} from "./chunk-7WUE3WOI.js";
|
|
48
48
|
import {
|
|
49
49
|
classifyTrend
|
|
50
|
-
} from "./chunk-
|
|
50
|
+
} from "./chunk-4NKGNMEA.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. */
|