next-leak 0.1.0 → 0.1.2
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 +22 -0
- package/dist/attribution.d.ts +0 -2
- package/dist/bootstrap.js +1 -1
- package/dist/chunk-2BQZCZ4Z.js +129 -0
- package/dist/{chunk-5K4WEMIS.js → chunk-5ZYZW2BL.js} +16 -2
- package/dist/{chunk-4BMPC45G.js → chunk-C3KH5Z2W.js} +11 -3
- package/dist/{chunk-OSZ6ND6E.js → chunk-PF7KYD5N.js} +12 -1
- package/dist/{chunk-UZTZWHKH.js → chunk-WHC6S57X.js} +27020 -804
- package/dist/cli.js +35 -23
- package/dist/control-client.d.ts +0 -3
- package/dist/control-server.d.ts +0 -1
- package/dist/{html-report-3JIRVRBF.js → html-report-I4PNSLKY.js} +3 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +5 -5
- package/dist/{issue-report-AB62FRNM.js → issue-report-VQEUXP2E.js} +2 -2
- package/dist/issue-report.d.ts +1 -4
- package/dist/module-registry.d.ts +0 -14
- package/dist/route-config.d.ts +2 -1
- package/dist/run-report.fixture.d.ts +0 -1
- package/dist/runner.d.ts +0 -12
- package/dist/vlq.d.ts +0 -1
- package/package.json +3 -6
- package/dist/chunk-K5PZFVJH.js +0 -103
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
> Find out whether your Next.js app actually leaks memory — how much, on which route, and whose fault it is.
|
|
9
9
|
|
|
10
|
+
<img src="https://raw.githubusercontent.com/xabierlameiro/next-leak/main/docs/demo.svg" alt="next-leak finding a real Next.js memory leak (3-minute run, sped up)" width="720">
|
|
11
|
+
|
|
10
12
|
```
|
|
11
13
|
$ npx next-leak . --quick
|
|
12
14
|
|
|
@@ -35,6 +37,12 @@ holds it — without being told what to look for.
|
|
|
35
37
|
| [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware | **Reproduced** · 32.8 → 369.9 MB |
|
|
36
38
|
| [#94919](https://github.com/vercel/next.js/issues/94919) | RSC tree retained on client aborts | Not reproduced on standalone — [and it says why](#scope-and-limits-read-before-filing-issues) |
|
|
37
39
|
|
|
40
|
+
The full causal chain, measured on that same issue: leak found (28.7 -> 138.9 MB
|
|
41
|
+
across 8 cycles), the workaround from the thread applied (`clearTimeout(id)`
|
|
42
|
+
inside the callback), same app re-measured with identical parameters:
|
|
43
|
+
**27.8 -> 25.6 MB, flat**. That is what a diagnostic tool should prove - not
|
|
44
|
+
that installing it saves memory, but that what it points at is the real cause.
|
|
45
|
+
|
|
38
46
|
Across ~25 healthy routes on production applications (PPR, MDX, Auth.js,
|
|
39
47
|
Sentry, i18n), it reported **zero false positives**.
|
|
40
48
|
|
|
@@ -107,6 +115,20 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
|
|
|
107
115
|
|
|
108
116
|
Before measuring, the CLI prints a duration estimate — a 60-route app under defaults is **hours**; narrow with `--routes` for iteration.
|
|
109
117
|
|
|
118
|
+
## What it tells apart
|
|
119
|
+
|
|
120
|
+
"Memory leak" is one name for six different situations. The verdict machinery
|
|
121
|
+
separates them, because each one has a different fix:
|
|
122
|
+
|
|
123
|
+
| Looks like a leak | What next-leak reports | How it knows |
|
|
124
|
+
|---|---|---|
|
|
125
|
+
| One-time warm-up growth (JIT, lazy caches) | `stable` | The first cycle is excluded from the verdict; warm-up flattens, leaks keep climbing |
|
|
126
|
+
| 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 |
|
|
127
|
+
| Growth that pauses and resumes (stepwise) | `leak` | A healthy route gives back 20-30% of its growth; a stepwise leak gives back nothing |
|
|
128
|
+
| Native/buffer memory with a flat JS heap | `leak (external)` or an explicit RSS note | Heap, `external` and RSS are sampled and judged separately |
|
|
129
|
+
| 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 |
|
|
130
|
+
| 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 |
|
|
131
|
+
|
|
110
132
|
## Reading the verdicts
|
|
111
133
|
|
|
112
134
|
- **`stable`** — done, stop hunting. The report proves it. If the heap is flat
|
package/dist/attribution.d.ts
CHANGED
|
@@ -18,8 +18,6 @@ export type RouteAttribution = FindingAttribution & {
|
|
|
18
18
|
* (Next 16.2 sectioned maps). Exported for direct unit testing.
|
|
19
19
|
*/
|
|
20
20
|
export declare function classifySource(rawSource: string): FindingAttribution;
|
|
21
|
-
/** Best-effort owner for chains the module registry cannot resolve. */
|
|
22
|
-
export declare function classifyByChain(retainerChain: string): FindingAttribution | null;
|
|
23
21
|
/**
|
|
24
22
|
* Resolves a finding's harvested module ids against the registry. When a
|
|
25
23
|
* chain crosses several modules (e.g. Next's page-template wrapper retaining
|
package/dist/bootstrap.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
|
-
import "./chunk-
|
|
2
|
+
import "./chunk-PF7KYD5N.js";
|
|
3
3
|
|
|
4
4
|
// src/bootstrap.ts
|
|
5
5
|
import { mkdir, writeFile } from "fs/promises";
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
|
|
3
|
+
// src/confidence.ts
|
|
4
|
+
function effectiveVerdict(report) {
|
|
5
|
+
return report.confidence.supersededVerdict ?? report.trend.verdict;
|
|
6
|
+
}
|
|
7
|
+
var VERDICT_WEAKENING = /* @__PURE__ */ new Set([
|
|
8
|
+
"near-threshold",
|
|
9
|
+
"spiky-growth"
|
|
10
|
+
]);
|
|
11
|
+
function warrantsIssueDraft(report) {
|
|
12
|
+
return effectiveVerdict(report) === "leak" && !report.confidence.warnings.some((warning) => VERDICT_WEAKENING.has(warning.code));
|
|
13
|
+
}
|
|
14
|
+
var DEFAULT_MIN_GROWTH = 256 * 1024;
|
|
15
|
+
var LOAD_COMPLETION_FLOOR = 0.99;
|
|
16
|
+
var ABANDON_EFFECTIVE_FLOOR = 0.9;
|
|
17
|
+
var MID_STREAM_FLOOR = 0.1;
|
|
18
|
+
var SPIKE_RATIO = 4;
|
|
19
|
+
var NOISE_FLOOR_MULTIPLE = 2;
|
|
20
|
+
var mb = (bytes) => `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
|
21
|
+
var pct = (part, whole) => `${(part / whole * 100).toFixed(1)}%`;
|
|
22
|
+
function settleWarnings(outcomes) {
|
|
23
|
+
const warnings = [];
|
|
24
|
+
const moving = outcomes.filter((outcome) => outcome.status === "moving");
|
|
25
|
+
if (moving.length > 0) {
|
|
26
|
+
warnings.push({
|
|
27
|
+
code: "unsettled",
|
|
28
|
+
detail: `the heap never held steady before sampling on ${moving.map((outcome) => outcome.phase).join(", ")} \u2014 raise --idle-ms so post-load transients finish draining`
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const unverified = outcomes.filter((outcome) => outcome.status === "unknown");
|
|
32
|
+
if (unverified.length > 0) {
|
|
33
|
+
warnings.push({
|
|
34
|
+
code: "settle-unverified",
|
|
35
|
+
detail: `the idle budget was too short to check whether the heap had settled on ${unverified.map((outcome) => outcome.phase).join(", ")} \u2014 the samples may include post-load transients`
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return warnings;
|
|
39
|
+
}
|
|
40
|
+
function abandonmentWarnings(outcome) {
|
|
41
|
+
const abandoned = outcome.abandoned ?? 0;
|
|
42
|
+
if (outcome.sent > 0 && abandoned < outcome.sent * ABANDON_EFFECTIVE_FLOOR) {
|
|
43
|
+
return [{
|
|
44
|
+
code: "abandon-ineffective",
|
|
45
|
+
detail: `${outcome.phase} disconnected early on only ${abandoned} of ${outcome.sent} requests (${pct(abandoned, outcome.sent)}) \u2014 the early-disconnect path was largely not exercised`
|
|
46
|
+
}];
|
|
47
|
+
}
|
|
48
|
+
const midStream = outcome.abandonedMidStream ?? 0;
|
|
49
|
+
if (abandoned > 0 && midStream < abandoned * MID_STREAM_FLOOR) {
|
|
50
|
+
return [{
|
|
51
|
+
code: "abandon-before-response",
|
|
52
|
+
detail: `${outcome.phase} cut ${abandoned} requests before the server sent anything (${midStream} mid-stream) \u2014 this tested pre-response disconnects, not mid-stream teardown; raise abandonAfterMs above the route's time-to-first-byte`
|
|
53
|
+
}];
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
function loadWarnings(outcomes, abandonAfterMs) {
|
|
58
|
+
const warnings = [];
|
|
59
|
+
for (const outcome of outcomes) {
|
|
60
|
+
if (abandonAfterMs !== void 0) {
|
|
61
|
+
warnings.push(...abandonmentWarnings(outcome));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const landed = outcome.ok2xx ?? 0;
|
|
65
|
+
if (outcome.sent > 0 && landed < outcome.sent * LOAD_COMPLETION_FLOOR) {
|
|
66
|
+
warnings.push({
|
|
67
|
+
code: "load-incomplete",
|
|
68
|
+
detail: `${outcome.phase} landed ${landed} of ${outcome.sent} requests (${pct(landed, outcome.sent)}) \u2014 the route saw less traffic than reported`
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return warnings;
|
|
73
|
+
}
|
|
74
|
+
function growthShapeWarnings(trend) {
|
|
75
|
+
const judged = trend.verdict === "leak" || trend.verdict === "inconclusive";
|
|
76
|
+
if (!judged || trend.deltas.length < 2) {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
const positive = trend.deltas.filter((delta) => delta > 0);
|
|
80
|
+
if (positive.length !== trend.deltas.length) {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
const smallest = Math.min(...positive);
|
|
84
|
+
const largest = Math.max(...positive);
|
|
85
|
+
if (largest <= smallest * SPIKE_RATIO) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
return [{
|
|
89
|
+
code: "spiky-growth",
|
|
90
|
+
detail: `one cycle grew ${mb(largest)} and another ${mb(smallest)} \u2014 the mean of ${mb(trend.growthPerCycle)}/cycle summarizes an uneven series; measure more cycles before quoting it`
|
|
91
|
+
}];
|
|
92
|
+
}
|
|
93
|
+
function noiseFloorWarnings(trend, minGrowth) {
|
|
94
|
+
if (trend.verdict !== "leak" || trend.growthPerCycle >= minGrowth * NOISE_FLOOR_MULTIPLE) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
return [{
|
|
98
|
+
code: "near-threshold",
|
|
99
|
+
detail: `growth of ${mb(trend.growthPerCycle)}/cycle barely clears the ${mb(minGrowth)} threshold \u2014 raise --load-requests so the signal outgrows the noise`
|
|
100
|
+
}];
|
|
101
|
+
}
|
|
102
|
+
function isVerdictInvalid(input) {
|
|
103
|
+
if (input.trend.verdict !== "leak") {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const neverSettled = input.settleOutcomes.length > 0 && input.settleOutcomes.every((outcome) => outcome.status === "moving");
|
|
107
|
+
const abandonedNothing = input.abandonAfterMs !== void 0 && input.loadOutcomes.length > 0 && input.loadOutcomes.every((outcome) => (outcome.abandoned ?? 0) === 0);
|
|
108
|
+
return neverSettled || abandonedNothing;
|
|
109
|
+
}
|
|
110
|
+
function assessConfidence(input) {
|
|
111
|
+
const minGrowth = input.minGrowthPerCycle ?? DEFAULT_MIN_GROWTH;
|
|
112
|
+
const warnings = [
|
|
113
|
+
...settleWarnings(input.settleOutcomes),
|
|
114
|
+
...loadWarnings(input.loadOutcomes, input.abandonAfterMs),
|
|
115
|
+
...growthShapeWarnings(input.trend),
|
|
116
|
+
...noiseFloorWarnings(input.trend, minGrowth)
|
|
117
|
+
];
|
|
118
|
+
return {
|
|
119
|
+
level: warnings.length === 0 ? "high" : "low",
|
|
120
|
+
warnings,
|
|
121
|
+
...isVerdictInvalid(input) && { supersededVerdict: "inconclusive" }
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export {
|
|
126
|
+
effectiveVerdict,
|
|
127
|
+
warrantsIssueDraft,
|
|
128
|
+
assessConfidence
|
|
129
|
+
};
|
|
@@ -3,11 +3,25 @@ import { createRequire as __nextLeakCreateRequire } from 'node:module';import {
|
|
|
3
3
|
// src/issue-report.ts
|
|
4
4
|
import path from "path";
|
|
5
5
|
var MB = 1024 * 1024;
|
|
6
|
+
function ownerLabel(attribution) {
|
|
7
|
+
if (attribution === void 0 || attribution.owner === "unattributed") {
|
|
8
|
+
return "unattributed";
|
|
9
|
+
}
|
|
10
|
+
const source = attribution.source ? ` \u2014 \`${attribution.source}\`` : "";
|
|
11
|
+
const packageName = attribution.packageName ? ` \u2014 ${attribution.packageName}` : "";
|
|
12
|
+
return `${attribution.owner}${source}${packageName}`;
|
|
13
|
+
}
|
|
14
|
+
function blamedParty(owner, culprit) {
|
|
15
|
+
if (owner === "app") {
|
|
16
|
+
return `**your own code** (\`${culprit?.source ?? "app code"}\`)`;
|
|
17
|
+
}
|
|
18
|
+
return `the dependency **${culprit?.packageName ?? "a dependency"}**`;
|
|
19
|
+
}
|
|
6
20
|
function evidenceRows(route) {
|
|
7
21
|
const findings = [...route.diff?.grownNodes ?? [], ...route.diff?.newNodes ?? []];
|
|
8
22
|
return findings.slice(0, 6).map((finding, index) => {
|
|
9
23
|
const attribution = route.attribution?.findings[index];
|
|
10
|
-
const owner = attribution
|
|
24
|
+
const owner = ownerLabel(attribution);
|
|
11
25
|
return `- **${finding.kind}** \`[${finding.nodeType}] ${finding.name}\` ${(finding.retainedBytes / MB).toFixed(2)} MB retained (${owner})
|
|
12
26
|
- retainers: \`${finding.retainerChain || "(none)"}\``;
|
|
13
27
|
}).join("\n");
|
|
@@ -18,7 +32,7 @@ function renderIssueMarkdown(route, run) {
|
|
|
18
32
|
const owner = route.attribution?.route.owner ?? "unattributed";
|
|
19
33
|
const culprit = route.attribution?.route;
|
|
20
34
|
const preamble = owner === "app" || owner === "dependency" ? `> [!WARNING]
|
|
21
|
-
> next-leak attributes this leak to ${owner
|
|
35
|
+
> next-leak attributes this leak to ${blamedParty(owner, culprit)}. Fix or report it there \u2014 do **not** file this against Next.js.
|
|
22
36
|
|
|
23
37
|
` : "";
|
|
24
38
|
const caveats = route.confidence.warnings.length === 0 ? "" : `
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
|
|
2
2
|
import {
|
|
3
3
|
effectiveVerdict
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-2BQZCZ4Z.js";
|
|
5
5
|
|
|
6
6
|
// src/html-report.ts
|
|
7
7
|
var MB = 1024 * 1024;
|
|
8
8
|
var VERDICT_COLOR = { leak: "#c0392b", stable: "#27ae60", inconclusive: "#e67e22" };
|
|
9
9
|
function escapeHtml(value) {
|
|
10
|
-
return value.
|
|
10
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
11
11
|
}
|
|
12
12
|
function heapCurveSvg(samples, color) {
|
|
13
13
|
const width = 320;
|
|
@@ -28,6 +28,14 @@ function heapCurveSvg(samples, color) {
|
|
|
28
28
|
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="2.5" fill="${color}"/>`;
|
|
29
29
|
}).join("") + `</svg>`;
|
|
30
30
|
}
|
|
31
|
+
function ownerCell(attribution) {
|
|
32
|
+
if (attribution === void 0 || attribution.owner === "unattributed") {
|
|
33
|
+
return "\u2014";
|
|
34
|
+
}
|
|
35
|
+
const source = attribution.source ? `: ${escapeHtml(attribution.source)}` : "";
|
|
36
|
+
const packageName = attribution.packageName ? ` (${escapeHtml(attribution.packageName)})` : "";
|
|
37
|
+
return `${attribution.owner}${source}${packageName}`;
|
|
38
|
+
}
|
|
31
39
|
function measuredSection(route) {
|
|
32
40
|
if (route.status !== "measured") {
|
|
33
41
|
return "";
|
|
@@ -40,7 +48,7 @@ function measuredSection(route) {
|
|
|
40
48
|
const findings = [...route.diff?.grownNodes ?? [], ...route.diff?.newNodes ?? []];
|
|
41
49
|
const findingRows = findings.slice(0, 6).map((finding, index) => {
|
|
42
50
|
const attribution = route.attribution?.findings[index];
|
|
43
|
-
const owner =
|
|
51
|
+
const owner = ownerCell(attribution);
|
|
44
52
|
return `<tr><td>${finding.kind}</td><td>${escapeHtml(finding.nodeType)}</td><td>${escapeHtml(finding.name)}</td><td>${(finding.retainedBytes / MB).toFixed(2)} MB</td><td>${owner}</td></tr>`;
|
|
45
53
|
}).join("");
|
|
46
54
|
return `<section><h2><span class="badge" style="background:${color}">${verdict}</span> <code>${escapeHtml(route.route)}</code></h2>` + heapCurveSvg(route.samples, color) + `<p class="curve">heap ${curve} MB \xB7 ${(route.growthPer1000Requests / MB).toFixed(2)} MB/1000 req</p>` + withdrawn + warnings + (findingRows === "" ? "" : `<table><tr><th>kind</th><th>type</th><th>node</th><th>retained</th><th>owner</th></tr>${findingRows}</table>`) + `</section>`;
|
|
@@ -11,9 +11,16 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
11
11
|
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
12
12
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
13
13
|
});
|
|
14
|
+
var __esm = (fn, res) => function __init() {
|
|
15
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
16
|
+
};
|
|
14
17
|
var __commonJS = (cb, mod) => function __require2() {
|
|
15
18
|
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
16
19
|
};
|
|
20
|
+
var __export = (target, all) => {
|
|
21
|
+
for (var name in all)
|
|
22
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
23
|
+
};
|
|
17
24
|
var __copyProps = (to, from, except, desc) => {
|
|
18
25
|
if (from && typeof from === "object" || typeof from === "function") {
|
|
19
26
|
for (let key of __getOwnPropNames(from))
|
|
@@ -30,9 +37,13 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
30
37
|
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
31
38
|
mod
|
|
32
39
|
));
|
|
40
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
33
41
|
|
|
34
42
|
export {
|
|
35
43
|
__require,
|
|
44
|
+
__esm,
|
|
36
45
|
__commonJS,
|
|
37
|
-
|
|
46
|
+
__export,
|
|
47
|
+
__toESM,
|
|
48
|
+
__toCommonJS
|
|
38
49
|
};
|