next-leak 0.2.0 → 0.3.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 +28 -0
- package/dist/bootstrap.js +4 -0
- package/dist/{chunk-HAKKAIHN.js → chunk-2BQ5KXIJ.js} +90 -7
- package/dist/{chunk-SKAPGI62.js → chunk-BUNQT6VK.js} +26 -3
- package/dist/{chunk-MYYPTZJW.js → chunk-WXAFXVWS.js} +20 -1
- package/dist/chunk-XHPUAMJG.js +46 -0
- package/dist/cli.js +2 -1
- package/dist/control-client.d.ts +5 -0
- package/dist/control-server.d.ts +1 -0
- package/dist/{html-report-2SOTNXDG.js → html-report-KQGUDYK5.js} +2 -1
- package/dist/index.js +4 -3
- 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 +23 -0
- package/dist/runner.d.ts +6 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -162,6 +162,33 @@ separates them, because each one has a different fix:
|
|
|
162
162
|
- **`inconclusive`** — sustained sub-threshold growth: measure longer. The CLI prints the exact re-run command (`--routes <those> --cycles 6`).
|
|
163
163
|
- **`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
164
|
|
|
165
|
+
## Peak pressure: `stable` is not the same as safe
|
|
166
|
+
|
|
167
|
+
Every verdict above is about what a route **retains** after idle and a forced
|
|
168
|
+
GC. That is the right question for a leak and the wrong one for an
|
|
169
|
+
`OOMKilled`: a process can climb to gigabytes under load, hand it all back
|
|
170
|
+
when the load stops, and still be killed at its peak. So each load cycle is
|
|
171
|
+
also sampled *without* collecting, and the highest value is reported next to
|
|
172
|
+
the verdict:
|
|
173
|
+
|
|
174
|
+
```
|
|
175
|
+
✔ /[slug] stable (-164.08 MB/1000 req) heap 261.9 MB → 265.7 MB → 35.4 MB → 36.0 MB
|
|
176
|
+
▲ peak pressure: peaked at 3145.7 MB rss under load while retaining 36.0 MB —
|
|
177
|
+
a container sized on what it retains dies on what it reaches
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
That is a real measurement of the reproduction in
|
|
181
|
+
[vercel/next.js#92287](https://github.com/vercel/next.js/issues/92287): no
|
|
182
|
+
retention, and 3 GB reached. The note fires when the peak heap comes within
|
|
183
|
+
75% of `--max-old-space`, or when peak RSS is at least 8× the retained heap
|
|
184
|
+
and above 512 MB. It never changes the verdict — retention and peak are
|
|
185
|
+
different questions, and only one of them is a leak. A peak is the highest
|
|
186
|
+
value *sampled* (every 250 ms), so it is a lower bound.
|
|
187
|
+
|
|
188
|
+
If the measured process dies at the limit instead of merely approaching it,
|
|
189
|
+
the route fails saying exactly that, with the limit in force and how to raise
|
|
190
|
+
it.
|
|
191
|
+
|
|
165
192
|
## The tool grades its own measurement
|
|
166
193
|
|
|
167
194
|
A leak detector is an instrument, and a miscalibrated instrument doesn't fail
|
|
@@ -233,6 +260,7 @@ through the build's source maps.
|
|
|
233
260
|
`--max-old-space`, or every route dies as an OOM that is not the app's
|
|
234
261
|
fault. When a run's heap gets close to the cap, the report says so.
|
|
235
262
|
- Borderline routes can flip between `stable`/`leak` across runs — more cycles resolves this.
|
|
263
|
+
- 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
264
|
- 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
265
|
|
|
238
266
|
## Development
|
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 === "") {
|
|
@@ -7,6 +7,10 @@ import {
|
|
|
7
7
|
minGrowthFor,
|
|
8
8
|
warrantsIssueDraft
|
|
9
9
|
} from "./chunk-E5ZKAANQ.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(
|
|
@@ -93219,6 +93243,9 @@ async function requestGc(port) {
|
|
|
93219
93243
|
}
|
|
93220
93244
|
return sample;
|
|
93221
93245
|
}
|
|
93246
|
+
async function requestMemory(port) {
|
|
93247
|
+
return sampleSchema.parse(await request(port, "/mem"));
|
|
93248
|
+
}
|
|
93222
93249
|
async function requestSnapshot(port, name) {
|
|
93223
93250
|
const parsed = snapshotResponseSchema.parse(
|
|
93224
93251
|
await request(port, `/snapshot?name=${encodeURIComponent(name)}`)
|
|
@@ -93312,6 +93339,44 @@ Host: ${target.host}\r
|
|
|
93312
93339
|
var MIN_POLLS_TO_JUDGE = 2;
|
|
93313
93340
|
var SETTLE_POLL_MS = 2e3;
|
|
93314
93341
|
var SETTLE_TOLERANCE = 0.01;
|
|
93342
|
+
var PEAK_POLL_MS = 250;
|
|
93343
|
+
function pollPeak(controlPort, phase, deps) {
|
|
93344
|
+
const peak = {
|
|
93345
|
+
phase,
|
|
93346
|
+
heapUsed: 0,
|
|
93347
|
+
external: 0,
|
|
93348
|
+
arrayBuffers: 0,
|
|
93349
|
+
rss: 0,
|
|
93350
|
+
polls: 0
|
|
93351
|
+
};
|
|
93352
|
+
let running = true;
|
|
93353
|
+
const loop = (async () => {
|
|
93354
|
+
while (running) {
|
|
93355
|
+
await deps.sleep(PEAK_POLL_MS);
|
|
93356
|
+
if (!running) {
|
|
93357
|
+
return;
|
|
93358
|
+
}
|
|
93359
|
+
let sample;
|
|
93360
|
+
try {
|
|
93361
|
+
sample = await deps.readMemory(controlPort);
|
|
93362
|
+
} catch {
|
|
93363
|
+
return;
|
|
93364
|
+
}
|
|
93365
|
+
peak.polls += 1;
|
|
93366
|
+
peak.heapUsed = Math.max(peak.heapUsed, sample.heapUsed);
|
|
93367
|
+
peak.external = Math.max(peak.external, sample.external);
|
|
93368
|
+
peak.arrayBuffers = Math.max(peak.arrayBuffers, sample.arrayBuffers);
|
|
93369
|
+
peak.rss = Math.max(peak.rss, sample.rss);
|
|
93370
|
+
}
|
|
93371
|
+
})();
|
|
93372
|
+
return {
|
|
93373
|
+
stop: async () => {
|
|
93374
|
+
running = false;
|
|
93375
|
+
await loop;
|
|
93376
|
+
return peak;
|
|
93377
|
+
}
|
|
93378
|
+
};
|
|
93379
|
+
}
|
|
93315
93380
|
async function waitUntilSettled(controlPort, maxIdleMs, deps) {
|
|
93316
93381
|
const deadline = Date.now() + maxIdleMs;
|
|
93317
93382
|
let previous = null;
|
|
@@ -93335,14 +93400,16 @@ async function waitUntilSettled(controlPort, maxIdleMs, deps) {
|
|
|
93335
93400
|
var defaultDeps = {
|
|
93336
93401
|
launch: launchInstrumented,
|
|
93337
93402
|
load: runLoadPhase,
|
|
93338
|
-
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
93403
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
93404
|
+
readMemory: requestMemory
|
|
93339
93405
|
};
|
|
93340
93406
|
var RITUAL_DEFAULTS = {
|
|
93341
93407
|
warmupRequests: 200,
|
|
93342
93408
|
loadRequests: 5e3,
|
|
93343
93409
|
connections: 100,
|
|
93344
93410
|
cycles: 4,
|
|
93345
|
-
idleMs: 3e4
|
|
93411
|
+
idleMs: 3e4,
|
|
93412
|
+
maxOldSpaceMb: 512
|
|
93346
93413
|
};
|
|
93347
93414
|
async function runRitual(options, deps = defaultDeps) {
|
|
93348
93415
|
const warmupRequests = options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests;
|
|
@@ -93431,9 +93498,17 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93431
93498
|
() => requestSnapshot(app.controlPort, "baseline")
|
|
93432
93499
|
);
|
|
93433
93500
|
const memorySamples = [baseline.sample];
|
|
93501
|
+
const peaks = [];
|
|
93434
93502
|
let afterSnapshot = "";
|
|
93435
93503
|
for (let cycle = 1; cycle <= cycles; cycle += 1) {
|
|
93436
|
-
await timed(`cycle ${cycle} load`, () =>
|
|
93504
|
+
await timed(`cycle ${cycle} load`, async () => {
|
|
93505
|
+
const poller = pollPeak(app.controlPort, `cycle ${cycle}`, deps);
|
|
93506
|
+
try {
|
|
93507
|
+
await loadCycle(`cycle ${cycle}`, loadRequests);
|
|
93508
|
+
} finally {
|
|
93509
|
+
peaks.push(await poller.stop());
|
|
93510
|
+
}
|
|
93511
|
+
});
|
|
93437
93512
|
const settle = await timed(
|
|
93438
93513
|
`cycle ${cycle} settle`,
|
|
93439
93514
|
() => waitUntilSettled(app.controlPort, idleMs, deps)
|
|
@@ -93460,12 +93535,19 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93460
93535
|
settleOutcomes,
|
|
93461
93536
|
samples,
|
|
93462
93537
|
memorySamples,
|
|
93538
|
+
peaks,
|
|
93463
93539
|
baselineSnapshot: baseline.file,
|
|
93464
93540
|
afterSnapshot,
|
|
93465
93541
|
trend: classifyMemoryTrend(samples, externalSamples, { minGrowthPerCycle }),
|
|
93466
93542
|
requestsPerCycle: loadRequests,
|
|
93467
93543
|
minGrowthPerCycle
|
|
93468
93544
|
};
|
|
93545
|
+
} catch (cause) {
|
|
93546
|
+
const death = app.explainExit();
|
|
93547
|
+
if (death !== null) {
|
|
93548
|
+
throw new Error(death);
|
|
93549
|
+
}
|
|
93550
|
+
throw cause;
|
|
93469
93551
|
} finally {
|
|
93470
93552
|
await app.close();
|
|
93471
93553
|
}
|
|
@@ -93797,6 +93879,7 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93797
93879
|
requestPath,
|
|
93798
93880
|
samples: result.samples,
|
|
93799
93881
|
memorySamples: result.memorySamples,
|
|
93882
|
+
peaks: result.peaks,
|
|
93800
93883
|
timings: result.timings,
|
|
93801
93884
|
loadOutcomes: result.loadOutcomes,
|
|
93802
93885
|
settleOutcomes: result.settleOutcomes,
|
|
@@ -93812,8 +93895,8 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93812
93895
|
};
|
|
93813
93896
|
}
|
|
93814
93897
|
async function writeEvidenceBundle(report, workDir) {
|
|
93815
|
-
const { renderHtmlReport } = await import("./html-report-
|
|
93816
|
-
const { renderIssueMarkdown } = await import("./issue-report-
|
|
93898
|
+
const { renderHtmlReport } = await import("./html-report-KQGUDYK5.js");
|
|
93899
|
+
const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
|
|
93817
93900
|
for (const route of report.routes) {
|
|
93818
93901
|
if (route.status === "measured" && warrantsIssueDraft(route)) {
|
|
93819
93902
|
const file = path6.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
|
|
@@ -2,6 +2,10 @@ import { createRequire as __nextLeakCreateRequire } from 'node:module';import {
|
|
|
2
2
|
import {
|
|
3
3
|
effectiveVerdict
|
|
4
4
|
} from "./chunk-E5ZKAANQ.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>
|
|
@@ -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
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-
|
|
4
|
+
} from "./chunk-BUNQT6VK.js";
|
|
5
5
|
import "./chunk-E5ZKAANQ.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-2BQ5KXIJ.js";
|
|
43
43
|
import {
|
|
44
44
|
renderHtmlReport
|
|
45
|
-
} from "./chunk-
|
|
45
|
+
} from "./chunk-BUNQT6VK.js";
|
|
46
46
|
import {
|
|
47
47
|
classifyTrend
|
|
48
48
|
} from "./chunk-E5ZKAANQ.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
|
@@ -54,6 +54,24 @@ export type SettleOutcome = {
|
|
|
54
54
|
/** GC polls taken before converging or giving up. */
|
|
55
55
|
polls: number;
|
|
56
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* Highest memory observed *during* a load cycle, per class.
|
|
59
|
+
*
|
|
60
|
+
* Every other number in a run is taken after idle and a forced GC, which is
|
|
61
|
+
* what a verdict about retention needs. It is also blind to the process that
|
|
62
|
+
* climbs to 3.5 GB under load and hands it all back: `stable`, and dead in a
|
|
63
|
+
* 1 GB container. A peak is a lower bound — a spike shorter than the poll
|
|
64
|
+
* interval is never seen.
|
|
65
|
+
*/
|
|
66
|
+
export type PeakSample = {
|
|
67
|
+
phase: string;
|
|
68
|
+
heapUsed: number;
|
|
69
|
+
external: number;
|
|
70
|
+
arrayBuffers: number;
|
|
71
|
+
rss: number;
|
|
72
|
+
/** Readings taken; 0 means the poller never got one. */
|
|
73
|
+
polls: number;
|
|
74
|
+
};
|
|
57
75
|
export type RitualResult = {
|
|
58
76
|
route: string;
|
|
59
77
|
/** Wall-clock per phase, so slow runs can be explained instead of guessed. */
|
|
@@ -66,6 +84,8 @@ export type RitualResult = {
|
|
|
66
84
|
samples: number[];
|
|
67
85
|
/** Full memory samples in the same order. */
|
|
68
86
|
memorySamples: HeapSample[];
|
|
87
|
+
/** Highest memory seen during each load cycle, sampled without collecting. */
|
|
88
|
+
peaks: PeakSample[];
|
|
69
89
|
baselineSnapshot: string;
|
|
70
90
|
afterSnapshot: string;
|
|
71
91
|
trend: TrendResult;
|
|
@@ -82,6 +102,8 @@ export type RitualDeps = {
|
|
|
82
102
|
launch: typeof launchInstrumented;
|
|
83
103
|
load: typeof runLoadPhase;
|
|
84
104
|
sleep: (ms: number) => Promise<void>;
|
|
105
|
+
/** GC-free read, polled while the app is under load. */
|
|
106
|
+
readMemory: (port: number) => Promise<HeapSample>;
|
|
85
107
|
};
|
|
86
108
|
/**
|
|
87
109
|
* Single source of truth for ritual defaults — reports must echo them.
|
|
@@ -101,6 +123,7 @@ export declare const RITUAL_DEFAULTS: {
|
|
|
101
123
|
readonly connections: 100;
|
|
102
124
|
readonly cycles: 4;
|
|
103
125
|
readonly idleMs: 30000;
|
|
126
|
+
readonly maxOldSpaceMb: 512;
|
|
104
127
|
};
|
|
105
128
|
/**
|
|
106
129
|
* 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. */
|