next-leak 0.2.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 +52 -8
- package/dist/abandon-load.d.ts +5 -3
- package/dist/bootstrap.js +4 -0
- package/dist/{chunk-E5ZKAANQ.js → chunk-BDIPW6FU.js} +1 -1
- package/dist/{chunk-SKAPGI62.js → chunk-NYWCWKS6.js} +27 -4
- package/dist/{chunk-HAKKAIHN.js → chunk-USNLL625.js} +111 -9
- package/dist/{chunk-MYYPTZJW.js → chunk-WXAFXVWS.js} +20 -1
- package/dist/chunk-XHPUAMJG.js +46 -0
- package/dist/cli.js +3 -2
- package/dist/control-client.d.ts +5 -0
- package/dist/control-server.d.ts +1 -0
- package/dist/{html-report-2SOTNXDG.js → html-report-VW7VQOCH.js} +3 -2
- package/dist/index.js +5 -4
- package/dist/{issue-report-X5JYAVN5.js → issue-report-HKA26WNV.js} +2 -1
- package/dist/launcher.d.ts +14 -0
- package/dist/peak-pressure.d.ts +42 -0
- package/dist/ritual.d.ts +25 -0
- package/dist/runner.d.ts +6 -1
- 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,13 +162,42 @@ 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.
|
|
162
171
|
- **`inconclusive`** — sustained sub-threshold growth: measure longer. The CLI prints the exact re-run command (`--routes <those> --cycles 6`).
|
|
163
172
|
- **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design.
|
|
164
173
|
|
|
174
|
+
## Peak pressure: `stable` is not the same as safe
|
|
175
|
+
|
|
176
|
+
Every verdict above is about what a route **retains** after idle and a forced
|
|
177
|
+
GC. That is the right question for a leak and the wrong one for an
|
|
178
|
+
`OOMKilled`: a process can climb to gigabytes under load, hand it all back
|
|
179
|
+
when the load stops, and still be killed at its peak. So each load cycle is
|
|
180
|
+
also sampled *without* collecting, and the highest value is reported next to
|
|
181
|
+
the verdict:
|
|
182
|
+
|
|
183
|
+
```
|
|
184
|
+
✔ /[slug] stable (-164.08 MB/1000 req) heap 261.9 MB → 265.7 MB → 35.4 MB → 36.0 MB
|
|
185
|
+
▲ peak pressure: peaked at 3145.7 MB rss under load while retaining 36.0 MB —
|
|
186
|
+
a container sized on what it retains dies on what it reaches
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
That is a real measurement of the reproduction in
|
|
190
|
+
[vercel/next.js#92287](https://github.com/vercel/next.js/issues/92287): no
|
|
191
|
+
retention, and 3 GB reached. The note fires when the peak heap comes within
|
|
192
|
+
75% of `--max-old-space`, or when peak RSS is at least 8× the retained heap
|
|
193
|
+
and above 512 MB. It never changes the verdict — retention and peak are
|
|
194
|
+
different questions, and only one of them is a leak. A peak is the highest
|
|
195
|
+
value *sampled* (every 250 ms), so it is a lower bound.
|
|
196
|
+
|
|
197
|
+
If the measured process dies at the limit instead of merely approaching it,
|
|
198
|
+
the route fails saying exactly that, with the limit in force and how to raise
|
|
199
|
+
it.
|
|
200
|
+
|
|
165
201
|
## The tool grades its own measurement
|
|
166
202
|
|
|
167
203
|
A leak detector is an instrument, and a miscalibrated instrument doesn't fail
|
|
@@ -233,6 +269,14 @@ through the build's source maps.
|
|
|
233
269
|
`--max-old-space`, or every route dies as an OOM that is not the app's
|
|
234
270
|
fault. When a run's heap gets close to the cap, the report says so.
|
|
235
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.
|
|
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.
|
|
236
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.
|
|
237
281
|
|
|
238
282
|
## Development
|
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>;
|
package/dist/bootstrap.js
CHANGED
|
@@ -48,6 +48,10 @@ async function startControlServer(options) {
|
|
|
48
48
|
respond(200, sampleMemory(gcExposed));
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
|
+
if (url.pathname === "/mem") {
|
|
52
|
+
respond(200, sampleMemory(typeof g.gc === "function"));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
51
55
|
if (url.pathname === "/snapshot") {
|
|
52
56
|
const name = url.searchParams.get("name");
|
|
53
57
|
if (name === null || name === "") {
|
|
@@ -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,11 @@
|
|
|
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
|
+
import {
|
|
6
|
+
assessPeakPressure,
|
|
7
|
+
describePeakPressure
|
|
8
|
+
} from "./chunk-XHPUAMJG.js";
|
|
5
9
|
|
|
6
10
|
// src/html-report.ts
|
|
7
11
|
var MB = 1024 * 1024;
|
|
@@ -36,7 +40,26 @@ function ownerCell(attribution) {
|
|
|
36
40
|
const packageName = attribution.packageName ? ` (${escapeHtml(attribution.packageName)})` : "";
|
|
37
41
|
return `${attribution.owner}${source}${packageName}`;
|
|
38
42
|
}
|
|
39
|
-
function
|
|
43
|
+
function peakBlock(route, parameters) {
|
|
44
|
+
const retained = route.memorySamples.at(-1)?.heapUsed;
|
|
45
|
+
if (retained === void 0 || route.peaks === void 0) {
|
|
46
|
+
return "";
|
|
47
|
+
}
|
|
48
|
+
const pressure = assessPeakPressure({
|
|
49
|
+
peaks: route.peaks,
|
|
50
|
+
retainedHeapBytes: retained,
|
|
51
|
+
maxOldSpaceMb: parameters.maxOldSpaceMb
|
|
52
|
+
});
|
|
53
|
+
const peakRows = route.peaks.filter((peak) => peak.polls > 0).map(
|
|
54
|
+
(peak) => `<tr><td>${escapeHtml(peak.phase)}</td><td>${(peak.heapUsed / MB).toFixed(1)} MB</td><td>${(peak.external / MB).toFixed(1)} MB</td><td>${(peak.arrayBuffers / MB).toFixed(1)} MB</td><td>${(peak.rss / MB).toFixed(1)} MB</td></tr>`
|
|
55
|
+
).join("");
|
|
56
|
+
if (peakRows === "") {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
const note = pressure === null ? "" : `<p class="warn">Peak pressure: ${escapeHtml(describePeakPressure(pressure))}</p>`;
|
|
60
|
+
return note + `<table><tr><th>peak during</th><th>heap</th><th>external</th><th>arrayBuffers</th><th>rss</th></tr>${peakRows}</table>`;
|
|
61
|
+
}
|
|
62
|
+
function measuredSection(route, parameters) {
|
|
40
63
|
if (route.status !== "measured") {
|
|
41
64
|
return "";
|
|
42
65
|
}
|
|
@@ -51,7 +74,7 @@ function measuredSection(route) {
|
|
|
51
74
|
const owner = ownerCell(attribution);
|
|
52
75
|
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>`;
|
|
53
76
|
}).join("");
|
|
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>`;
|
|
77
|
+
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 + peakBlock(route, parameters) + (findingRows === "" ? "" : `<table><tr><th>kind</th><th>type</th><th>node</th><th>retained</th><th>owner</th></tr>${findingRows}</table>`) + `</section>`;
|
|
55
78
|
}
|
|
56
79
|
function renderHtmlReport(run) {
|
|
57
80
|
const measured = run.routes.filter((route) => route.status === "measured");
|
|
@@ -78,7 +101,7 @@ code{background:#f4f4f4;padding:0 4px;border-radius:3px}
|
|
|
78
101
|
environment.nextVersion ?? "unknown"
|
|
79
102
|
)} \xB7 next-leak ${escapeHtml(environment.nextLeakVersion)}</p>
|
|
80
103
|
<p class="meta">${run.parameters.cycles} cycles \xD7 ${run.parameters.loadRequests} requests \xB7 heap cap ${run.parameters.maxOldSpaceMb} MB \xB7 growth gate ${(run.parameters.minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle</p>
|
|
81
|
-
${measured.map(measuredSection).join("\n")}
|
|
104
|
+
${measured.map((route) => measuredSection(route, run.parameters)).join("\n")}
|
|
82
105
|
${skipped.length === 0 ? "" : `<h2>Skipped</h2><ul>${skipped.map((route) => `<li><code>${escapeHtml(route.route)}</code> \u2014 ${escapeHtml(route.status === "skipped" ? route.reason : "")}</li>`).join("")}</ul>`}
|
|
83
106
|
${failed.length === 0 ? "" : `<h2>Failed</h2><ul>${failed.map((route) => `<li><code>${escapeHtml(route.route)}</code> \u2014 ${escapeHtml(route.status === "failed" ? route.reason : "")}</li>`).join("")}</ul>`}
|
|
84
107
|
<p class="meta">Raw snapshots and run.json live next to this file \u2014 verify in Chrome DevTools \u2192 Memory \u2192 Load.</p>
|
|
@@ -6,7 +6,11 @@ import {
|
|
|
6
6
|
effectiveVerdict,
|
|
7
7
|
minGrowthFor,
|
|
8
8
|
warrantsIssueDraft
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-BDIPW6FU.js";
|
|
10
|
+
import {
|
|
11
|
+
assessPeakPressure,
|
|
12
|
+
describePeakPressure
|
|
13
|
+
} from "./chunk-XHPUAMJG.js";
|
|
10
14
|
import {
|
|
11
15
|
__commonJS,
|
|
12
16
|
__esm,
|
|
@@ -92205,6 +92209,12 @@ function explainStartupFailure(stderr) {
|
|
|
92205
92209
|
return `stderr:
|
|
92206
92210
|
${stderr}`;
|
|
92207
92211
|
}
|
|
92212
|
+
function explainRuntimeFailure(stderr, maxOldSpaceMb) {
|
|
92213
|
+
if (/heap out of memory|Reached heap limit|Ineffective mark-compacts/i.test(stderr)) {
|
|
92214
|
+
return `the measured process ran out of heap and was killed by V8 mid-run (limit in force: --max-old-space-size=${maxOldSpaceMb} MB). That is the measurement: this route does not fit in ${maxOldSpaceMb} MB under this load. Raise it with --max-old-space <mb> to match your deployment, or lower --requests/--connections to measure a lighter regime.`;
|
|
92215
|
+
}
|
|
92216
|
+
return `the measured process exited mid-run. ${explainStartupFailure(stderr)}`;
|
|
92217
|
+
}
|
|
92208
92218
|
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
92209
92219
|
async function pollUntil(deadline, what, probe, failed) {
|
|
92210
92220
|
for (; ; ) {
|
|
@@ -92307,6 +92317,7 @@ async function launchInstrumented(options) {
|
|
|
92307
92317
|
pid: child.pid ?? -1,
|
|
92308
92318
|
appPort: options.appPort,
|
|
92309
92319
|
controlPort,
|
|
92320
|
+
explainExit: () => exited ? explainRuntimeFailure(stderrTail, options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB) : null,
|
|
92310
92321
|
close: async () => {
|
|
92311
92322
|
if (exited) {
|
|
92312
92323
|
return;
|
|
@@ -92409,7 +92420,19 @@ function findingLines(route) {
|
|
|
92409
92420
|
}
|
|
92410
92421
|
return lines;
|
|
92411
92422
|
}
|
|
92412
|
-
function
|
|
92423
|
+
function peakPressureLines(route, parameters) {
|
|
92424
|
+
const retained = route.memorySamples.at(-1)?.heapUsed;
|
|
92425
|
+
if (retained === void 0) {
|
|
92426
|
+
return [];
|
|
92427
|
+
}
|
|
92428
|
+
const pressure = assessPeakPressure({
|
|
92429
|
+
peaks: route.peaks,
|
|
92430
|
+
retainedHeapBytes: retained,
|
|
92431
|
+
maxOldSpaceMb: parameters.maxOldSpaceMb
|
|
92432
|
+
});
|
|
92433
|
+
return pressure === null ? [] : [` \u25B2 peak pressure: ${describePeakPressure(pressure)}`];
|
|
92434
|
+
}
|
|
92435
|
+
function routeLines(route, parameters) {
|
|
92413
92436
|
if (route.status === "skipped") {
|
|
92414
92437
|
return [` \u2013 ${route.route} skipped: ${route.reason}`];
|
|
92415
92438
|
}
|
|
@@ -92424,13 +92447,14 @@ function routeLines(route) {
|
|
|
92424
92447
|
)}) heap ${curve}`,
|
|
92425
92448
|
...confidenceLines(route),
|
|
92426
92449
|
...memorySourceLines(route, verdict),
|
|
92450
|
+
...peakPressureLines(route, parameters),
|
|
92427
92451
|
...findingLines(route)
|
|
92428
92452
|
];
|
|
92429
92453
|
}
|
|
92430
92454
|
function formatReport(report) {
|
|
92431
92455
|
const lines = [`next-leak \u2014 ${report.appDir}`, ""];
|
|
92432
92456
|
for (const route of report.routes) {
|
|
92433
|
-
lines.push(...routeLines(route));
|
|
92457
|
+
lines.push(...routeLines(route, report.parameters));
|
|
92434
92458
|
}
|
|
92435
92459
|
const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
|
|
92436
92460
|
lines.push(
|
|
@@ -92477,6 +92501,11 @@ var routeConfigSchema = z2.object({
|
|
|
92477
92501
|
* that path — vercel/next.js#89091 traces `ServerResponse` retention to
|
|
92478
92502
|
* an early disconnect — and a load generator that always waits politely
|
|
92479
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.
|
|
92480
92509
|
*/
|
|
92481
92510
|
abandonAfterMs: z2.number().int().positive().optional()
|
|
92482
92511
|
}).strict();
|
|
@@ -92740,6 +92769,9 @@ function diffAgainstBaseline(baseline, after, options = {}) {
|
|
|
92740
92769
|
node.type,
|
|
92741
92770
|
(afterTypeSelfSizes.get(node.type) ?? 0) + node.self_size
|
|
92742
92771
|
);
|
|
92772
|
+
if (node.type === "synthetic") {
|
|
92773
|
+
return;
|
|
92774
|
+
}
|
|
92743
92775
|
if (!baseline.nodeIds.has(node.id)) {
|
|
92744
92776
|
if (node.retainedSize >= resolved.newThresholdBytes) {
|
|
92745
92777
|
const chain = walkChain(node, resolved.chainDepth);
|
|
@@ -93219,6 +93251,9 @@ async function requestGc(port) {
|
|
|
93219
93251
|
}
|
|
93220
93252
|
return sample;
|
|
93221
93253
|
}
|
|
93254
|
+
async function requestMemory(port) {
|
|
93255
|
+
return sampleSchema.parse(await request(port, "/mem"));
|
|
93256
|
+
}
|
|
93222
93257
|
async function requestSnapshot(port, name) {
|
|
93223
93258
|
const parsed = snapshotResponseSchema.parse(
|
|
93224
93259
|
await request(port, `/snapshot?name=${encodeURIComponent(name)}`)
|
|
@@ -93233,6 +93268,7 @@ async function requestSnapshot(port, name) {
|
|
|
93233
93268
|
|
|
93234
93269
|
// src/abandon-load.ts
|
|
93235
93270
|
import net from "net";
|
|
93271
|
+
var FIRST_BYTE_BUDGET_MS = 5e3;
|
|
93236
93272
|
async function runAbandonPhase(options) {
|
|
93237
93273
|
const target = new URL(options.url);
|
|
93238
93274
|
const port = Number(target.port || 80);
|
|
@@ -93247,6 +93283,7 @@ Host: ${target.host}\r
|
|
|
93247
93283
|
sent: 0,
|
|
93248
93284
|
abandoned: 0,
|
|
93249
93285
|
abandonedMidStream: 0,
|
|
93286
|
+
abandonedBeforeResponse: 0,
|
|
93250
93287
|
completed: 0,
|
|
93251
93288
|
errors: 0
|
|
93252
93289
|
};
|
|
@@ -93270,6 +93307,8 @@ Host: ${target.host}\r
|
|
|
93270
93307
|
result.abandoned += 1;
|
|
93271
93308
|
if (responseStarted) {
|
|
93272
93309
|
result.abandonedMidStream += 1;
|
|
93310
|
+
} else {
|
|
93311
|
+
result.abandonedBeforeResponse += 1;
|
|
93273
93312
|
}
|
|
93274
93313
|
}
|
|
93275
93314
|
finish();
|
|
@@ -93277,11 +93316,17 @@ Host: ${target.host}\r
|
|
|
93277
93316
|
socket.once("connect", () => {
|
|
93278
93317
|
result.sent += 1;
|
|
93279
93318
|
socket.write(request2);
|
|
93280
|
-
timer = setTimeout(giveUp,
|
|
93319
|
+
timer = setTimeout(giveUp, FIRST_BYTE_BUDGET_MS);
|
|
93281
93320
|
timer.unref();
|
|
93282
93321
|
});
|
|
93283
93322
|
socket.on("data", () => {
|
|
93323
|
+
if (responseStarted) {
|
|
93324
|
+
return;
|
|
93325
|
+
}
|
|
93284
93326
|
responseStarted = true;
|
|
93327
|
+
clearTimeout(timer);
|
|
93328
|
+
timer = setTimeout(giveUp, options.abandonAfterMs);
|
|
93329
|
+
timer.unref();
|
|
93285
93330
|
});
|
|
93286
93331
|
socket.once("end", () => {
|
|
93287
93332
|
if (!settled) {
|
|
@@ -93312,6 +93357,44 @@ Host: ${target.host}\r
|
|
|
93312
93357
|
var MIN_POLLS_TO_JUDGE = 2;
|
|
93313
93358
|
var SETTLE_POLL_MS = 2e3;
|
|
93314
93359
|
var SETTLE_TOLERANCE = 0.01;
|
|
93360
|
+
var PEAK_POLL_MS = 250;
|
|
93361
|
+
function pollPeak(controlPort, phase, deps) {
|
|
93362
|
+
const peak = {
|
|
93363
|
+
phase,
|
|
93364
|
+
heapUsed: 0,
|
|
93365
|
+
external: 0,
|
|
93366
|
+
arrayBuffers: 0,
|
|
93367
|
+
rss: 0,
|
|
93368
|
+
polls: 0
|
|
93369
|
+
};
|
|
93370
|
+
let running = true;
|
|
93371
|
+
const loop = (async () => {
|
|
93372
|
+
while (running) {
|
|
93373
|
+
await deps.sleep(PEAK_POLL_MS);
|
|
93374
|
+
if (!running) {
|
|
93375
|
+
return;
|
|
93376
|
+
}
|
|
93377
|
+
let sample;
|
|
93378
|
+
try {
|
|
93379
|
+
sample = await deps.readMemory(controlPort);
|
|
93380
|
+
} catch {
|
|
93381
|
+
return;
|
|
93382
|
+
}
|
|
93383
|
+
peak.polls += 1;
|
|
93384
|
+
peak.heapUsed = Math.max(peak.heapUsed, sample.heapUsed);
|
|
93385
|
+
peak.external = Math.max(peak.external, sample.external);
|
|
93386
|
+
peak.arrayBuffers = Math.max(peak.arrayBuffers, sample.arrayBuffers);
|
|
93387
|
+
peak.rss = Math.max(peak.rss, sample.rss);
|
|
93388
|
+
}
|
|
93389
|
+
})();
|
|
93390
|
+
return {
|
|
93391
|
+
stop: async () => {
|
|
93392
|
+
running = false;
|
|
93393
|
+
await loop;
|
|
93394
|
+
return peak;
|
|
93395
|
+
}
|
|
93396
|
+
};
|
|
93397
|
+
}
|
|
93315
93398
|
async function waitUntilSettled(controlPort, maxIdleMs, deps) {
|
|
93316
93399
|
const deadline = Date.now() + maxIdleMs;
|
|
93317
93400
|
let previous = null;
|
|
@@ -93335,14 +93418,16 @@ async function waitUntilSettled(controlPort, maxIdleMs, deps) {
|
|
|
93335
93418
|
var defaultDeps = {
|
|
93336
93419
|
launch: launchInstrumented,
|
|
93337
93420
|
load: runLoadPhase,
|
|
93338
|
-
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
93421
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
93422
|
+
readMemory: requestMemory
|
|
93339
93423
|
};
|
|
93340
93424
|
var RITUAL_DEFAULTS = {
|
|
93341
93425
|
warmupRequests: 200,
|
|
93342
93426
|
loadRequests: 5e3,
|
|
93343
93427
|
connections: 100,
|
|
93344
93428
|
cycles: 4,
|
|
93345
|
-
idleMs: 3e4
|
|
93429
|
+
idleMs: 3e4,
|
|
93430
|
+
maxOldSpaceMb: 512
|
|
93346
93431
|
};
|
|
93347
93432
|
async function runRitual(options, deps = defaultDeps) {
|
|
93348
93433
|
const warmupRequests = options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests;
|
|
@@ -93403,6 +93488,7 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93403
93488
|
sent: outcome.sent,
|
|
93404
93489
|
abandoned: outcome.abandoned,
|
|
93405
93490
|
abandonedMidStream: outcome.abandonedMidStream,
|
|
93491
|
+
abandonedBeforeResponse: outcome.abandonedBeforeResponse,
|
|
93406
93492
|
ok2xx: outcome.completed,
|
|
93407
93493
|
errors: outcome.errors
|
|
93408
93494
|
});
|
|
@@ -93431,9 +93517,17 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93431
93517
|
() => requestSnapshot(app.controlPort, "baseline")
|
|
93432
93518
|
);
|
|
93433
93519
|
const memorySamples = [baseline.sample];
|
|
93520
|
+
const peaks = [];
|
|
93434
93521
|
let afterSnapshot = "";
|
|
93435
93522
|
for (let cycle = 1; cycle <= cycles; cycle += 1) {
|
|
93436
|
-
await timed(`cycle ${cycle} load`, () =>
|
|
93523
|
+
await timed(`cycle ${cycle} load`, async () => {
|
|
93524
|
+
const poller = pollPeak(app.controlPort, `cycle ${cycle}`, deps);
|
|
93525
|
+
try {
|
|
93526
|
+
await loadCycle(`cycle ${cycle}`, loadRequests);
|
|
93527
|
+
} finally {
|
|
93528
|
+
peaks.push(await poller.stop());
|
|
93529
|
+
}
|
|
93530
|
+
});
|
|
93437
93531
|
const settle = await timed(
|
|
93438
93532
|
`cycle ${cycle} settle`,
|
|
93439
93533
|
() => waitUntilSettled(app.controlPort, idleMs, deps)
|
|
@@ -93460,12 +93554,19 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93460
93554
|
settleOutcomes,
|
|
93461
93555
|
samples,
|
|
93462
93556
|
memorySamples,
|
|
93557
|
+
peaks,
|
|
93463
93558
|
baselineSnapshot: baseline.file,
|
|
93464
93559
|
afterSnapshot,
|
|
93465
93560
|
trend: classifyMemoryTrend(samples, externalSamples, { minGrowthPerCycle }),
|
|
93466
93561
|
requestsPerCycle: loadRequests,
|
|
93467
93562
|
minGrowthPerCycle
|
|
93468
93563
|
};
|
|
93564
|
+
} catch (cause) {
|
|
93565
|
+
const death = app.explainExit();
|
|
93566
|
+
if (death !== null) {
|
|
93567
|
+
throw new Error(death);
|
|
93568
|
+
}
|
|
93569
|
+
throw cause;
|
|
93469
93570
|
} finally {
|
|
93470
93571
|
await app.close();
|
|
93471
93572
|
}
|
|
@@ -93797,6 +93898,7 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93797
93898
|
requestPath,
|
|
93798
93899
|
samples: result.samples,
|
|
93799
93900
|
memorySamples: result.memorySamples,
|
|
93901
|
+
peaks: result.peaks,
|
|
93800
93902
|
timings: result.timings,
|
|
93801
93903
|
loadOutcomes: result.loadOutcomes,
|
|
93802
93904
|
settleOutcomes: result.settleOutcomes,
|
|
@@ -93812,8 +93914,8 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93812
93914
|
};
|
|
93813
93915
|
}
|
|
93814
93916
|
async function writeEvidenceBundle(report, workDir) {
|
|
93815
|
-
const { renderHtmlReport } = await import("./html-report-
|
|
93816
|
-
const { renderIssueMarkdown } = await import("./issue-report-
|
|
93917
|
+
const { renderHtmlReport } = await import("./html-report-VW7VQOCH.js");
|
|
93918
|
+
const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
|
|
93817
93919
|
for (const route of report.routes) {
|
|
93818
93920
|
if (route.status === "measured" && warrantsIssueDraft(route)) {
|
|
93819
93921
|
const file = path6.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
|
|
@@ -1,4 +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
|
+
import {
|
|
3
|
+
assessPeakPressure,
|
|
4
|
+
describePeakPressure
|
|
5
|
+
} from "./chunk-XHPUAMJG.js";
|
|
2
6
|
|
|
3
7
|
// src/issue-report.ts
|
|
4
8
|
import path from "path";
|
|
@@ -41,6 +45,21 @@ function renderIssueMarkdown(route, run) {
|
|
|
41
45
|
next-leak audits its own run and reports these limits. They do not overturn the verdict above, but they bound how much weight it carries:
|
|
42
46
|
|
|
43
47
|
` + route.confidence.warnings.map((warning) => `- ${warning.detail}`).join("\n") + `
|
|
48
|
+
`;
|
|
49
|
+
const retainedHeap = route.memorySamples.at(-1)?.heapUsed;
|
|
50
|
+
const pressure = retainedHeap === void 0 || route.peaks === void 0 ? null : assessPeakPressure({
|
|
51
|
+
peaks: route.peaks,
|
|
52
|
+
retainedHeapBytes: retainedHeap,
|
|
53
|
+
maxOldSpaceMb: parameters.maxOldSpaceMb
|
|
54
|
+
});
|
|
55
|
+
const peakSection = pressure === null ? "" : `
|
|
56
|
+
### Peak memory under load
|
|
57
|
+
|
|
58
|
+
Sampled during the load phases, without forcing collection: ${describePeakPressure(pressure)}. Per cycle:
|
|
59
|
+
|
|
60
|
+
` + route.peaks.filter((peak) => peak.polls > 0).map(
|
|
61
|
+
(peak) => `- ${peak.phase}: heap ${(peak.heapUsed / MB).toFixed(1)} MB, external ${(peak.external / MB).toFixed(1)} MB, arrayBuffers ${(peak.arrayBuffers / MB).toFixed(1)} MB, rss ${(peak.rss / MB).toFixed(1)} MB`
|
|
62
|
+
).join("\n") + `
|
|
44
63
|
`;
|
|
45
64
|
const curve = route.samples.map((sample) => (sample / MB).toFixed(1)).join(" \u2192 ");
|
|
46
65
|
const deltas = route.trend.deltas.map((delta) => `+${(delta / MB).toFixed(2)}`).join(", ");
|
|
@@ -89,7 +108,7 @@ ${evidenceRows(route) || "- (no findings above thresholds)"}
|
|
|
89
108
|
${signatures === "" ? "" : `
|
|
90
109
|
**Matched known causes:**
|
|
91
110
|
${signatures}
|
|
92
|
-
`}${caveats}
|
|
111
|
+
`}${peakSection}${caveats}
|
|
93
112
|
### Verify it yourself
|
|
94
113
|
|
|
95
114
|
Raw snapshots (Chrome DevTools \u2192 Memory \u2192 Load, compare baseline vs after):
|
|
@@ -0,0 +1,46 @@
|
|
|
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/peak-pressure.ts
|
|
4
|
+
var MB = 1024 * 1024;
|
|
5
|
+
var HEAP_LIMIT_SHARE = 0.75;
|
|
6
|
+
var RSS_OVER_RETAINED = 8;
|
|
7
|
+
var RSS_FLOOR_BYTES = 512 * MB;
|
|
8
|
+
var maxOf = (peaks, read) => peaks.reduce((highest, peak) => Math.max(highest, read(peak)), 0);
|
|
9
|
+
function assessPeakPressure(input) {
|
|
10
|
+
const sampled = input.peaks.filter((peak) => peak.polls > 0);
|
|
11
|
+
if (sampled.length === 0) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const heapLimitBytes = input.maxOldSpaceMb * MB;
|
|
15
|
+
const peakHeap = maxOf(sampled, (peak) => peak.heapUsed);
|
|
16
|
+
const peakRss = maxOf(sampled, (peak) => peak.rss);
|
|
17
|
+
if (peakHeap >= heapLimitBytes * HEAP_LIMIT_SHARE) {
|
|
18
|
+
return {
|
|
19
|
+
class: "heap",
|
|
20
|
+
peakBytes: peakHeap,
|
|
21
|
+
retainedBytes: input.retainedHeapBytes,
|
|
22
|
+
heapLimitBytes
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (peakRss >= RSS_FLOOR_BYTES && peakRss >= input.retainedHeapBytes * RSS_OVER_RETAINED) {
|
|
26
|
+
return {
|
|
27
|
+
class: "rss",
|
|
28
|
+
peakBytes: peakRss,
|
|
29
|
+
retainedBytes: input.retainedHeapBytes,
|
|
30
|
+
heapLimitBytes
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
var mb = (bytes) => `${(bytes / MB).toFixed(1)} MB`;
|
|
36
|
+
function describePeakPressure(pressure) {
|
|
37
|
+
if (pressure.class === "heap") {
|
|
38
|
+
return `peaked at ${mb(pressure.peakBytes)} heap under load against a ${mb(pressure.heapLimitBytes)} limit (retains ${mb(pressure.retainedBytes)}) \u2014 the run came close to the heap ceiling even though nothing was retained; peaks are the highest value sampled, not a guaranteed maximum`;
|
|
39
|
+
}
|
|
40
|
+
return `peaked at ${mb(pressure.peakBytes)} rss under load while retaining ${mb(pressure.retainedBytes)} \u2014 a container sized on what it retains dies on what it reaches; peaks are the highest value sampled, not a guaranteed maximum`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
assessPeakPressure,
|
|
45
|
+
describePeakPressure
|
|
46
|
+
};
|
package/dist/cli.js
CHANGED
|
@@ -9,8 +9,9 @@ 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
|
+
import "./chunk-XHPUAMJG.js";
|
|
14
15
|
import "./chunk-6XYFBOL2.js";
|
|
15
16
|
|
|
16
17
|
// src/cli.ts
|
package/dist/control-client.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { HeapSample } from "./control-server.js";
|
|
2
2
|
/** Forces GC in the measured process and returns a settled memory sample. */
|
|
3
3
|
export declare function requestGc(port: number): Promise<HeapSample>;
|
|
4
|
+
/**
|
|
5
|
+
* Reads memory without collecting. Used to poll a process under load, where a
|
|
6
|
+
* forced GC would change the number being read.
|
|
7
|
+
*/
|
|
8
|
+
export declare function requestMemory(port: number): Promise<HeapSample>;
|
|
4
9
|
/** Forces GC, writes a named heap snapshot, and returns its path and sample. */
|
|
5
10
|
export declare function requestSnapshot(port: number, name: string): Promise<{
|
|
6
11
|
file: string;
|
package/dist/control-server.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type ControlServer = {
|
|
|
26
26
|
* Internal control channel booted inside the measured app's process.
|
|
27
27
|
*
|
|
28
28
|
* - `GET /gc` — force GC, respond with a memory sample.
|
|
29
|
+
* - `GET /mem` — respond with a memory sample WITHOUT collecting.
|
|
29
30
|
* - `GET /snapshot?name=<label>` — force GC, write `<label>.heapsnapshot`
|
|
30
31
|
* into `snapshotDir`, respond `{ file, sample }` only once fully written.
|
|
31
32
|
*/
|
|
@@ -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
|
renderHtmlReport
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-NYWCWKS6.js";
|
|
5
|
+
import "./chunk-BDIPW6FU.js";
|
|
6
|
+
import "./chunk-XHPUAMJG.js";
|
|
6
7
|
import "./chunk-6XYFBOL2.js";
|
|
7
8
|
export {
|
|
8
9
|
renderHtmlReport
|
package/dist/index.js
CHANGED
|
@@ -39,16 +39,17 @@ 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
|
-
} from "./chunk-
|
|
51
|
+
} from "./chunk-WXAFXVWS.js";
|
|
52
|
+
import "./chunk-XHPUAMJG.js";
|
|
52
53
|
import "./chunk-6XYFBOL2.js";
|
|
53
54
|
export {
|
|
54
55
|
LaunchError,
|
|
@@ -1,7 +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-
|
|
4
|
+
} from "./chunk-WXAFXVWS.js";
|
|
5
|
+
import "./chunk-XHPUAMJG.js";
|
|
5
6
|
import "./chunk-6XYFBOL2.js";
|
|
6
7
|
export {
|
|
7
8
|
renderIssueMarkdown
|
package/dist/launcher.d.ts
CHANGED
|
@@ -25,6 +25,13 @@ export type LaunchedApp = {
|
|
|
25
25
|
pid: number;
|
|
26
26
|
appPort: number;
|
|
27
27
|
controlPort: number;
|
|
28
|
+
/**
|
|
29
|
+
* Why the measured process is gone, or null while it is alive. Without it
|
|
30
|
+
* a child that died mid-run surfaces as "fetch failed", which reads like a
|
|
31
|
+
* bug in the tool and hides the finding — most often that the app blew
|
|
32
|
+
* through the heap limit the run configured.
|
|
33
|
+
*/
|
|
34
|
+
explainExit: () => string | null;
|
|
28
35
|
/** SIGTERM, then SIGKILL after a grace period. Resolves when the child exited. */
|
|
29
36
|
close: () => Promise<void>;
|
|
30
37
|
};
|
|
@@ -40,6 +47,13 @@ export declare function killActiveChildren(): void;
|
|
|
40
47
|
* the messenger, and should say so instead of printing 20 lines of trace.
|
|
41
48
|
*/
|
|
42
49
|
export declare function explainStartupFailure(stderr: string): string;
|
|
50
|
+
/**
|
|
51
|
+
* Same idea as `explainStartupFailure`, for a process that died *during* a
|
|
52
|
+
* run. Heap exhaustion is the one death this tool can name outright, and it
|
|
53
|
+
* is a finding rather than an accident: the app did not fit in the limit the
|
|
54
|
+
* run gave it.
|
|
55
|
+
*/
|
|
56
|
+
export declare function explainRuntimeFailure(stderr: string, maxOldSpaceMb: number): string;
|
|
43
57
|
/**
|
|
44
58
|
* Spawns the measured server in a fresh child process with GC exposed and the
|
|
45
59
|
* control-channel bootstrap preloaded, and waits until both the app port and
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PeakSample } from "./ritual.js";
|
|
2
|
+
/**
|
|
3
|
+
* Which ceiling the process came closest to.
|
|
4
|
+
*
|
|
5
|
+
* `heap` is the only class bounded by `--max-old-space`; `rss` is what a
|
|
6
|
+
* container kills. `external`/`arrayBuffers` live in rss, which is why they
|
|
7
|
+
* are reported through it instead of against a limit that does not apply to
|
|
8
|
+
* them (vercel/next.js#92287: a healthy heap next to 4.3 GB of arrayBuffers).
|
|
9
|
+
*/
|
|
10
|
+
export type PeakPressureClass = "heap" | "rss";
|
|
11
|
+
export type PeakPressure = {
|
|
12
|
+
class: PeakPressureClass;
|
|
13
|
+
/** Highest value observed for that class, across cycles (bytes). */
|
|
14
|
+
peakBytes: number;
|
|
15
|
+
/** Retained heap the verdict was computed on (bytes). */
|
|
16
|
+
retainedBytes: number;
|
|
17
|
+
/** Heap limit in force, in bytes. */
|
|
18
|
+
heapLimitBytes: number;
|
|
19
|
+
};
|
|
20
|
+
export type PeakPressureInput = {
|
|
21
|
+
peaks: readonly PeakSample[];
|
|
22
|
+
/** Post-GC heapUsed of the final sample: what the route actually retains. */
|
|
23
|
+
retainedHeapBytes: number;
|
|
24
|
+
maxOldSpaceMb: number;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Whether a route's peak is far enough from the memory its verdict was
|
|
28
|
+
* computed on to be worth saying out loud.
|
|
29
|
+
*
|
|
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.
|
|
35
|
+
*/
|
|
36
|
+
export declare function assessPeakPressure(input: PeakPressureInput): PeakPressure | null;
|
|
37
|
+
/**
|
|
38
|
+
* One line, phrased so it never contradicts the verdict next to it. A peak is
|
|
39
|
+
* the highest value *sampled*: a spike shorter than the poll interval is not
|
|
40
|
+
* observed, so this is a lower bound.
|
|
41
|
+
*/
|
|
42
|
+
export declare function describePeakPressure(pressure: PeakPressure): string;
|
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.
|
|
@@ -54,6 +56,24 @@ export type SettleOutcome = {
|
|
|
54
56
|
/** GC polls taken before converging or giving up. */
|
|
55
57
|
polls: number;
|
|
56
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Highest memory observed *during* a load cycle, per class.
|
|
61
|
+
*
|
|
62
|
+
* Every other number in a run is taken after idle and a forced GC, which is
|
|
63
|
+
* what a verdict about retention needs. It is also blind to the process that
|
|
64
|
+
* climbs to 3.5 GB under load and hands it all back: `stable`, and dead in a
|
|
65
|
+
* 1 GB container. A peak is a lower bound — a spike shorter than the poll
|
|
66
|
+
* interval is never seen.
|
|
67
|
+
*/
|
|
68
|
+
export type PeakSample = {
|
|
69
|
+
phase: string;
|
|
70
|
+
heapUsed: number;
|
|
71
|
+
external: number;
|
|
72
|
+
arrayBuffers: number;
|
|
73
|
+
rss: number;
|
|
74
|
+
/** Readings taken; 0 means the poller never got one. */
|
|
75
|
+
polls: number;
|
|
76
|
+
};
|
|
57
77
|
export type RitualResult = {
|
|
58
78
|
route: string;
|
|
59
79
|
/** Wall-clock per phase, so slow runs can be explained instead of guessed. */
|
|
@@ -66,6 +86,8 @@ export type RitualResult = {
|
|
|
66
86
|
samples: number[];
|
|
67
87
|
/** Full memory samples in the same order. */
|
|
68
88
|
memorySamples: HeapSample[];
|
|
89
|
+
/** Highest memory seen during each load cycle, sampled without collecting. */
|
|
90
|
+
peaks: PeakSample[];
|
|
69
91
|
baselineSnapshot: string;
|
|
70
92
|
afterSnapshot: string;
|
|
71
93
|
trend: TrendResult;
|
|
@@ -82,6 +104,8 @@ export type RitualDeps = {
|
|
|
82
104
|
launch: typeof launchInstrumented;
|
|
83
105
|
load: typeof runLoadPhase;
|
|
84
106
|
sleep: (ms: number) => Promise<void>;
|
|
107
|
+
/** GC-free read, polled while the app is under load. */
|
|
108
|
+
readMemory: (port: number) => Promise<HeapSample>;
|
|
85
109
|
};
|
|
86
110
|
/**
|
|
87
111
|
* Single source of truth for ritual defaults — reports must echo them.
|
|
@@ -101,6 +125,7 @@ export declare const RITUAL_DEFAULTS: {
|
|
|
101
125
|
readonly connections: 100;
|
|
102
126
|
readonly cycles: 4;
|
|
103
127
|
readonly idleMs: 30000;
|
|
128
|
+
readonly maxOldSpaceMb: 512;
|
|
104
129
|
};
|
|
105
130
|
/**
|
|
106
131
|
* Runs the validated phase-0 ritual against one route in a fresh process:
|
package/dist/runner.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { HeapSample } from "./control-server.js";
|
|
|
4
4
|
import { type MeasurementEnvironment } from "./environment.js";
|
|
5
5
|
import { diffSnapshotFiles, type HeapDiff } from "./heap-diff.js";
|
|
6
6
|
import { extractModuleRegistry } from "./module-registry.js";
|
|
7
|
-
import { runRitual, type LoadOutcome, type PhaseTiming, type SettleOutcome } from "./ritual.js";
|
|
7
|
+
import { runRitual, type LoadOutcome, type PeakSample, type PhaseTiming, type SettleOutcome } from "./ritual.js";
|
|
8
8
|
import { readNextVersion, type MatchedSignature } from "./signatures.js";
|
|
9
9
|
import { type TrendResult } from "./trend.js";
|
|
10
10
|
export type RouteReport = {
|
|
@@ -28,6 +28,11 @@ export type RouteReport = {
|
|
|
28
28
|
* different fix than a heap leak.
|
|
29
29
|
*/
|
|
30
30
|
memorySamples: HeapSample[];
|
|
31
|
+
/**
|
|
32
|
+
* Highest memory reached *during* each load cycle. Every other number
|
|
33
|
+
* here is post-GC; this is the one a container limit is judged against.
|
|
34
|
+
*/
|
|
35
|
+
peaks: PeakSample[];
|
|
31
36
|
/** RSS growth per 1000 requests, computed like the heap figure. */
|
|
32
37
|
rssPer1000Requests: number;
|
|
33
38
|
/** Wall-clock per phase — explains where a long run spent its time. */
|