next-leak 0.11.2 → 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 +69 -9
- package/THIRD-PARTY-NOTICES.md +7 -7
- package/dist/bootstrap.js +27 -1
- package/dist/{chunk-4FYSLLSX.js → chunk-4NKGNMEA.js} +19 -4
- package/dist/{chunk-QNYUT2XF.js → chunk-5VXBMWDN.js} +1828 -1140
- 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 +5 -3
|
@@ -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. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "next-leak",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -51,9 +51,11 @@
|
|
|
51
51
|
"esbuild": ">=0.28.1",
|
|
52
52
|
"qs": ">=6.15.2",
|
|
53
53
|
"js-yaml": ">=4.3.1 <5",
|
|
54
|
-
"fast-uri": ">=3.1.
|
|
54
|
+
"fast-uri": ">=3.1.6 <4",
|
|
55
55
|
"ip-address": ">=10.3.1 <11",
|
|
56
|
-
"postcss": ">=8.5.23 <9"
|
|
56
|
+
"postcss": ">=8.5.23 <9",
|
|
57
|
+
"browserslist": ">=4.28.7",
|
|
58
|
+
"@puppeteer/browsers": "^3.2.2"
|
|
57
59
|
}
|
|
58
60
|
},
|
|
59
61
|
"devDependencies": {
|