next-leak 0.3.0 → 0.4.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 +24 -8
- package/dist/abandon-load.d.ts +5 -3
- package/dist/{chunk-E5ZKAANQ.js → chunk-BDIPW6FU.js} +1 -1
- package/dist/{chunk-BUNQT6VK.js → chunk-NYWCWKS6.js} +1 -1
- package/dist/{chunk-2BQ5KXIJ.js → chunk-USNLL625.js} +22 -3
- package/dist/cli.js +2 -2
- package/dist/{html-report-KQGUDYK5.js → html-report-VW7VQOCH.js} +2 -2
- package/dist/index.js +3 -3
- package/dist/ritual.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ holds it — without being told what to look for.
|
|
|
35
35
|
| [#95094](https://github.com/vercel/next.js/issues/95094) | Middleware `setTimeout` ids retained by the sandbox | **Reproduced** · mechanism named · 112 MB retained |
|
|
36
36
|
| [#94890](https://github.com/vercel/next.js/issues/94890) | Router LRU cache doesn't count its keys | **Reproduced** · 26.7 → 71.9 MB |
|
|
37
37
|
| [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware | **Reproduced** · 32.8 → 369.9 MB |
|
|
38
|
-
| [#94919](https://github.com/vercel/next.js/issues/94919) | RSC tree retained on client aborts |
|
|
38
|
+
| [#94919](https://github.com/vercel/next.js/issues/94919) | RSC tree retained on client aborts | **Reproduced** · 39 → 139 MB · [with a caveat](#scope-and-limits-read-before-filing-issues) |
|
|
39
39
|
|
|
40
40
|
The full causal chain, measured on that same issue: leak found (28.7 -> 138.9 MB
|
|
41
41
|
across 8 cycles), the workaround from the thread applied (`clearTimeout(id)`
|
|
@@ -86,8 +86,11 @@ The verdict comes from the **shape of the post-GC curve**: retained heap that
|
|
|
86
86
|
keeps growing every cycle is a leak; growth that flattens is warm-up. Where the
|
|
87
87
|
heap sits is noise — 40 MB and 400 MB say nothing on their own — so only the
|
|
88
88
|
shape is judged. The one absolute number involved is the gate a cycle's growth
|
|
89
|
-
must clear to count, and
|
|
90
|
-
|
|
89
|
+
must clear to count, and above 5000 requests per cycle it scales with the
|
|
90
|
+
traffic that cycle served — so in that range changing `--requests` changes how
|
|
91
|
+
long the run takes and not what it decides. Below 5000 the gate stops shrinking
|
|
92
|
+
and sits on the instrument's noise floor instead, so less traffic really does
|
|
93
|
+
buy a less sensitive run: that is the trade `--quick` makes at 2000 requests.
|
|
91
94
|
Every report prints the gate it used.
|
|
92
95
|
|
|
93
96
|
## Options
|
|
@@ -123,10 +126,14 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
|
|
|
123
126
|
without it.
|
|
124
127
|
- **`query`** appends a query string per route template
|
|
125
128
|
(`{ "/api/payload/[slug]": "weightKb=2048" }`).
|
|
126
|
-
- **`abandonAfterMs`** makes clients hang up
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
129
|
+
- **`abandonAfterMs`** makes clients hang up mid-response, the way closed tabs,
|
|
130
|
+
load-balancer timeouts and bots do. Some leaks only exist on that path
|
|
131
|
+
(`ServerResponse` retained after an early disconnect; the RSC tee branch in
|
|
132
|
+
[#94919](https://github.com/vercel/next.js/issues/94919)). The clock starts
|
|
133
|
+
at the **first byte of the response**, not at the request — under load a
|
|
134
|
+
request-relative window cuts before the stream begins and tests a different
|
|
135
|
+
path. Small values are the point: `4` means "read the first chunk, then
|
|
136
|
+
vanish". Requests abandoned on purpose are not counted as failures.
|
|
130
137
|
|
|
131
138
|
`run.json` records what every load phase actually did — requests sent,
|
|
132
139
|
2xx, abandoned — so a run can be audited instead of trusted.
|
|
@@ -155,7 +162,9 @@ separates them, because each one has a different fix:
|
|
|
155
162
|
is deliberately biased toward missing a leak rather than inventing one (a
|
|
156
163
|
single flat or falling cycle is enough to call a route stable), so a leak
|
|
157
164
|
that oscillates while it climbs can land here. To press harder, raise
|
|
158
|
-
`--cycles`
|
|
165
|
+
`--cycles` — every extra cycle is another delta the verdict gets to see.
|
|
166
|
+
Raising `--requests` only helps from below 5000: above that the gate scales
|
|
167
|
+
with the traffic, so the longer run decides the same thing. If the heap is
|
|
159
168
|
flat but RSS keeps climbing, the report says so explicitly: that is an
|
|
160
169
|
allocator, external-buffer or fragmentation problem, not a JS-heap leak.
|
|
161
170
|
- **`leak`** — the report names the culprit when attribution resolves: your file (`culprit: src/app/x/page.tsx (your code)`), a dependency (package name), or framework internals. An `ISSUE-<route>.md` draft is generated; if the leak is app-owned, the draft tells you **not** to file it upstream.
|
|
@@ -260,6 +269,13 @@ through the build's source maps.
|
|
|
260
269
|
`--max-old-space`, or every route dies as an OOM that is not the app's
|
|
261
270
|
fault. When a run's heap gets close to the cap, the report says so.
|
|
262
271
|
- Borderline routes can flip between `stable`/`leak` across runs — more cycles resolves this.
|
|
272
|
+
- The [#94919](https://github.com/vercel/next.js/issues/94919) reproduction ships
|
|
273
|
+
a **custom Express server and deliberately no standalone output**, which this
|
|
274
|
+
tool cannot measure as published. The figure above comes from the same app
|
|
275
|
+
built with `output: "standalone"` — the leak is there too, but that is Next's
|
|
276
|
+
server under test, not the reporter's middleware chain. Instrumenting their
|
|
277
|
+
own server by hand (same `--import` bootstrap, no CLI) showed the same shape:
|
|
278
|
+
post-GC heap 43 → 56 MB and arrayBuffers 0.2 → 10.7 MB over four cycles.
|
|
263
279
|
- The **peak-pressure** thresholds are calibrated against one reproduction measured in three regimes plus the bundled fixture, not against the ~40-route validation set the verdicts were tuned on. A peak note never changes a verdict, so the cost of a false one is noise, not a false accusation — but treat the exact thresholds as young.
|
|
264
280
|
- The measured app runs with its real environment: routes that call external services will call them under load. Scope with `--routes` and moderate `--requests` accordingly.
|
|
265
281
|
|
package/dist/abandon-load.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type AbandonPhaseOptions = {
|
|
|
2
2
|
url: string;
|
|
3
3
|
amount: number;
|
|
4
4
|
connections: number;
|
|
5
|
-
/** Destroy the socket this many ms after
|
|
5
|
+
/** Destroy the socket this many ms after the first byte of the response. */
|
|
6
6
|
abandonAfterMs: number;
|
|
7
7
|
headers?: Record<string, string>;
|
|
8
8
|
};
|
|
@@ -15,6 +15,8 @@ export type AbandonPhaseResult = {
|
|
|
15
15
|
* different path (the server may never have begun rendering).
|
|
16
16
|
*/
|
|
17
17
|
abandonedMidStream: number;
|
|
18
|
+
/** Abandonments where the first-byte budget expired in silence. */
|
|
19
|
+
abandonedBeforeResponse: number;
|
|
18
20
|
completed: number;
|
|
19
21
|
errors: number;
|
|
20
22
|
};
|
|
@@ -27,7 +29,7 @@ export type AbandonPhaseResult = {
|
|
|
27
29
|
* `ServerResponse` retention to an early disconnect, which only happens when
|
|
28
30
|
* a client goes away mid-flight (closed tabs, load-balancer timeouts, bots).
|
|
29
31
|
*
|
|
30
|
-
* Raw sockets keep this honest: write the request, wait
|
|
31
|
-
* destroy the socket
|
|
32
|
+
* Raw sockets keep this honest: write the request, wait for the response to
|
|
33
|
+
* start, then wait `abandonAfterMs` and destroy the socket mid-stream.
|
|
32
34
|
*/
|
|
33
35
|
export declare function runAbandonPhase(options: AbandonPhaseOptions): Promise<AbandonPhaseResult>;
|
|
@@ -115,7 +115,7 @@ function abandonmentWarnings(outcome) {
|
|
|
115
115
|
if (abandoned > 0 && midStream < abandoned * MID_STREAM_FLOOR) {
|
|
116
116
|
return [{
|
|
117
117
|
code: "abandon-before-response",
|
|
118
|
-
detail: `${outcome.phase} cut ${abandoned} requests
|
|
118
|
+
detail: `${outcome.phase} cut ${abandoned} requests that never produced a byte (${midStream} mid-stream) \u2014 the route did not start responding, so mid-stream teardown was not exercised; the route is saturated or hung at this load, not mistuned`
|
|
119
119
|
}];
|
|
120
120
|
}
|
|
121
121
|
return [];
|
|
@@ -1,7 +1,7 @@
|
|
|
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-BDIPW6FU.js";
|
|
5
5
|
import {
|
|
6
6
|
assessPeakPressure,
|
|
7
7
|
describePeakPressure
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
effectiveVerdict,
|
|
7
7
|
minGrowthFor,
|
|
8
8
|
warrantsIssueDraft
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-BDIPW6FU.js";
|
|
10
10
|
import {
|
|
11
11
|
assessPeakPressure,
|
|
12
12
|
describePeakPressure
|
|
@@ -92501,6 +92501,11 @@ var routeConfigSchema = z2.object({
|
|
|
92501
92501
|
* that path — vercel/next.js#89091 traces `ServerResponse` retention to
|
|
92502
92502
|
* an early disconnect — and a load generator that always waits politely
|
|
92503
92503
|
* never reaches it.
|
|
92504
|
+
*
|
|
92505
|
+
* Counted **from the first byte of the response**, not from the request:
|
|
92506
|
+
* under load a server's first byte arrives long after any sane fixed
|
|
92507
|
+
* window, so a request-relative clock cuts before the stream starts and
|
|
92508
|
+
* tests the wrong path entirely.
|
|
92504
92509
|
*/
|
|
92505
92510
|
abandonAfterMs: z2.number().int().positive().optional()
|
|
92506
92511
|
}).strict();
|
|
@@ -92764,6 +92769,9 @@ function diffAgainstBaseline(baseline, after, options = {}) {
|
|
|
92764
92769
|
node.type,
|
|
92765
92770
|
(afterTypeSelfSizes.get(node.type) ?? 0) + node.self_size
|
|
92766
92771
|
);
|
|
92772
|
+
if (node.type === "synthetic") {
|
|
92773
|
+
return;
|
|
92774
|
+
}
|
|
92767
92775
|
if (!baseline.nodeIds.has(node.id)) {
|
|
92768
92776
|
if (node.retainedSize >= resolved.newThresholdBytes) {
|
|
92769
92777
|
const chain = walkChain(node, resolved.chainDepth);
|
|
@@ -93260,6 +93268,7 @@ async function requestSnapshot(port, name) {
|
|
|
93260
93268
|
|
|
93261
93269
|
// src/abandon-load.ts
|
|
93262
93270
|
import net from "net";
|
|
93271
|
+
var FIRST_BYTE_BUDGET_MS = 5e3;
|
|
93263
93272
|
async function runAbandonPhase(options) {
|
|
93264
93273
|
const target = new URL(options.url);
|
|
93265
93274
|
const port = Number(target.port || 80);
|
|
@@ -93274,6 +93283,7 @@ Host: ${target.host}\r
|
|
|
93274
93283
|
sent: 0,
|
|
93275
93284
|
abandoned: 0,
|
|
93276
93285
|
abandonedMidStream: 0,
|
|
93286
|
+
abandonedBeforeResponse: 0,
|
|
93277
93287
|
completed: 0,
|
|
93278
93288
|
errors: 0
|
|
93279
93289
|
};
|
|
@@ -93297,6 +93307,8 @@ Host: ${target.host}\r
|
|
|
93297
93307
|
result.abandoned += 1;
|
|
93298
93308
|
if (responseStarted) {
|
|
93299
93309
|
result.abandonedMidStream += 1;
|
|
93310
|
+
} else {
|
|
93311
|
+
result.abandonedBeforeResponse += 1;
|
|
93300
93312
|
}
|
|
93301
93313
|
}
|
|
93302
93314
|
finish();
|
|
@@ -93304,11 +93316,17 @@ Host: ${target.host}\r
|
|
|
93304
93316
|
socket.once("connect", () => {
|
|
93305
93317
|
result.sent += 1;
|
|
93306
93318
|
socket.write(request2);
|
|
93307
|
-
timer = setTimeout(giveUp,
|
|
93319
|
+
timer = setTimeout(giveUp, FIRST_BYTE_BUDGET_MS);
|
|
93308
93320
|
timer.unref();
|
|
93309
93321
|
});
|
|
93310
93322
|
socket.on("data", () => {
|
|
93323
|
+
if (responseStarted) {
|
|
93324
|
+
return;
|
|
93325
|
+
}
|
|
93311
93326
|
responseStarted = true;
|
|
93327
|
+
clearTimeout(timer);
|
|
93328
|
+
timer = setTimeout(giveUp, options.abandonAfterMs);
|
|
93329
|
+
timer.unref();
|
|
93312
93330
|
});
|
|
93313
93331
|
socket.once("end", () => {
|
|
93314
93332
|
if (!settled) {
|
|
@@ -93470,6 +93488,7 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93470
93488
|
sent: outcome.sent,
|
|
93471
93489
|
abandoned: outcome.abandoned,
|
|
93472
93490
|
abandonedMidStream: outcome.abandonedMidStream,
|
|
93491
|
+
abandonedBeforeResponse: outcome.abandonedBeforeResponse,
|
|
93473
93492
|
ok2xx: outcome.completed,
|
|
93474
93493
|
errors: outcome.errors
|
|
93475
93494
|
});
|
|
@@ -93895,7 +93914,7 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93895
93914
|
};
|
|
93896
93915
|
}
|
|
93897
93916
|
async function writeEvidenceBundle(report, workDir) {
|
|
93898
|
-
const { renderHtmlReport } = await import("./html-report-
|
|
93917
|
+
const { renderHtmlReport } = await import("./html-report-VW7VQOCH.js");
|
|
93899
93918
|
const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
|
|
93900
93919
|
for (const route of report.routes) {
|
|
93901
93920
|
if (route.status === "measured" && warrantsIssueDraft(route)) {
|
package/dist/cli.js
CHANGED
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
killActiveChildren,
|
|
10
10
|
parseCliArgs,
|
|
11
11
|
runMeasurement
|
|
12
|
-
} from "./chunk-
|
|
13
|
-
import "./chunk-
|
|
12
|
+
} from "./chunk-USNLL625.js";
|
|
13
|
+
import "./chunk-BDIPW6FU.js";
|
|
14
14
|
import "./chunk-XHPUAMJG.js";
|
|
15
15
|
import "./chunk-6XYFBOL2.js";
|
|
16
16
|
|
|
@@ -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
|
renderHtmlReport
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-NYWCWKS6.js";
|
|
5
|
+
import "./chunk-BDIPW6FU.js";
|
|
6
6
|
import "./chunk-XHPUAMJG.js";
|
|
7
7
|
import "./chunk-6XYFBOL2.js";
|
|
8
8
|
export {
|
package/dist/index.js
CHANGED
|
@@ -39,13 +39,13 @@ import {
|
|
|
39
39
|
sourceIndexAt,
|
|
40
40
|
summarizeBaseline,
|
|
41
41
|
validateTarget
|
|
42
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-USNLL625.js";
|
|
43
43
|
import {
|
|
44
44
|
renderHtmlReport
|
|
45
|
-
} from "./chunk-
|
|
45
|
+
} from "./chunk-NYWCWKS6.js";
|
|
46
46
|
import {
|
|
47
47
|
classifyTrend
|
|
48
|
-
} from "./chunk-
|
|
48
|
+
} from "./chunk-BDIPW6FU.js";
|
|
49
49
|
import {
|
|
50
50
|
renderIssueMarkdown
|
|
51
51
|
} from "./chunk-WXAFXVWS.js";
|
package/dist/ritual.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type LoadOutcome = {
|
|
|
39
39
|
abandoned?: number;
|
|
40
40
|
/** Abandonments where the response had already started — the mid-stream path. */
|
|
41
41
|
abandonedMidStream?: number;
|
|
42
|
+
/** Abandonments where the first-byte budget expired in silence. */
|
|
43
|
+
abandonedBeforeResponse?: number;
|
|
42
44
|
};
|
|
43
45
|
/**
|
|
44
46
|
* Whether the heap actually held still before each sample was taken.
|