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 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. It never changes the verdict — retention and peak are
307
- different questions, and only one of them is a leak. A peak is the highest
308
- value *sampled* (every 250 ms), so it is a lower bound.
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
- inconclusive: 1,
110
- saturating: 2,
111
- stable: 3
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-4FYSLLSX.js";
10
+ } from "./chunk-4NKGNMEA.js";
11
11
  import {
12
12
  assessPeakPressure,
13
- describePeakPressure
14
- } from "./chunk-XHPUAMJG.js";
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
- readyPath: options.route,
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
- trend: classifyMemoryTrend(samples, externalSamples, trendOptions),
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
- return route.revalidatedEverySeconds === void 0 ? [] : [
94947
- ` driven through ISR revalidation (revalidates every ${route.revalidatedEverySeconds}s; without it the load would serve the cache)`
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.at(-1)?.heapUsed;
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-O7ILFOF5.js");
95565
- const { renderIssueMarkdown } = await import("./issue-report-FPVOZ6HU.js");
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(context.target.prerender, route.path, routeConfig.headers);
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-4FYSLLSX.js";
4
+ } from "./chunk-4NKGNMEA.js";
5
5
  import {
6
6
  assessPeakPressure,
7
- describePeakPressure
8
- } from "./chunk-XHPUAMJG.js";
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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
@@ -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.at(-1)?.heapUsed;
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 >= heapLimitBytes * HEAP_LIMIT_SHARE) {
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 >= RSS_FLOOR_BYTES && peakRss >= input.retainedHeapBytes * RSS_OVER_RETAINED) {
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
- } from "./chunk-XHPUAMJG.js";
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.at(-1)?.heapUsed;
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-72SHITHC.js";
25
+ } from "./chunk-5VXBMWDN.js";
26
26
  import {
27
27
  classifyTrend
28
- } from "./chunk-4FYSLLSX.js";
29
- import "./chunk-XHPUAMJG.js";
28
+ } from "./chunk-4NKGNMEA.js";
29
+ import "./chunk-E464SO7G.js";
30
30
  import "./chunk-6XYFBOL2.js";
31
31
 
32
32
  // src/cli.ts
@@ -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
@@ -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-YLHE4N5G.js";
5
- import "./chunk-4FYSLLSX.js";
6
- import "./chunk-XHPUAMJG.js";
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-72SHITHC.js";
44
+ } from "./chunk-5VXBMWDN.js";
45
45
  import {
46
46
  renderHtmlReport
47
- } from "./chunk-YLHE4N5G.js";
47
+ } from "./chunk-7WUE3WOI.js";
48
48
  import {
49
49
  classifyTrend
50
- } from "./chunk-4FYSLLSX.js";
50
+ } from "./chunk-4NKGNMEA.js";
51
51
  import {
52
52
  renderIssueMarkdown
53
- } from "./chunk-AUUMRTZZ.js";
54
- import "./chunk-XHPUAMJG.js";
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-AUUMRTZZ.js";
5
- import "./chunk-XHPUAMJG.js";
4
+ } from "./chunk-YPVBX3QA.js";
5
+ import "./chunk-E464SO7G.js";
6
6
  import "./chunk-6XYFBOL2.js";
7
7
  export {
8
8
  renderIssueMarkdown
@@ -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
- * Deliberately outside the verdict: `leak`/`stable`/`inconclusive` are
31
- * statements about retention after GC, calibrated against real leaks with no
32
- * false positives, and a peak is a different axis. A process that climbs to
33
- * 3.5 GB and hands it all back is honestly `stable` — and still OOM-killed in
34
- * a 1 GB container.
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;
@@ -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 the ISR revalidation period this route was driven through,
112
- * when it has one. Absent on routes not served from the ISR cache.
113
- * Recorded because a curve measured against a cache and one measured
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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.11.3",
3
+ "version": "0.12.0",
4
4
  "description": "Find out whether your Next.js app actually leaks memory — how much, on which route, and whose fault it is.",
5
5
  "keywords": [
6
6
  "nextjs",