next-leak 0.1.3 → 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 +64 -11
- package/THIRD-PARTY-NOTICES.md +2678 -0
- package/dist/bootstrap.js +4 -0
- package/dist/{chunk-ZFYIR423.js → chunk-2BQ5KXIJ.js} +141 -93
- package/dist/{chunk-C3KH5Z2W.js → chunk-BUNQT6VK.js} +28 -4
- package/dist/{chunk-2BQZCZ4Z.js → chunk-E5ZKAANQ.js} +90 -3
- package/dist/{chunk-5ZYZW2BL.js → chunk-WXAFXVWS.js} +23 -2
- package/dist/chunk-XHPUAMJG.js +46 -0
- package/dist/cli-args.d.ts +1 -0
- package/dist/cli.js +4 -2
- package/dist/confidence.d.ts +9 -2
- package/dist/control-client.d.ts +5 -0
- package/dist/control-server.d.ts +1 -0
- package/dist/{html-report-26DU7JFW.js → html-report-KQGUDYK5.js} +3 -2
- package/dist/index.js +7 -5
- package/dist/{issue-report-FUTEOHN5.js → issue-report-HKA26WNV.js} +2 -1
- package/dist/launcher.d.ts +23 -0
- package/dist/peak-pressure.d.ts +42 -0
- package/dist/ritual.d.ts +44 -2
- package/dist/runner.d.ts +21 -2
- package/dist/trend.d.ts +36 -1
- package/package.json +4 -3
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 === "") {
|
|
@@ -1,9 +1,16 @@
|
|
|
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
|
assessConfidence,
|
|
4
|
+
classifyMemoryTrend,
|
|
5
|
+
classifyTrend,
|
|
4
6
|
effectiveVerdict,
|
|
7
|
+
minGrowthFor,
|
|
5
8
|
warrantsIssueDraft
|
|
6
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-E5ZKAANQ.js";
|
|
10
|
+
import {
|
|
11
|
+
assessPeakPressure,
|
|
12
|
+
describePeakPressure
|
|
13
|
+
} from "./chunk-XHPUAMJG.js";
|
|
7
14
|
import {
|
|
8
15
|
__commonJS,
|
|
9
16
|
__esm,
|
|
@@ -91969,14 +91976,20 @@ var FLAGS = [
|
|
|
91969
91976
|
argName: "<list>",
|
|
91970
91977
|
help: "Only measure these routes \u2014 comma-separated templates or prefixes (e.g. /api,/dashboard)"
|
|
91971
91978
|
},
|
|
91972
|
-
{ flag: "--cycles", value: "int", argName: "<n>", help: "Load cycles per route (default
|
|
91979
|
+
{ flag: "--cycles", value: "int", argName: "<n>", help: "Load cycles per route (default 4, minimum 3)" },
|
|
91973
91980
|
{ flag: "--requests", value: "int", argName: "<n>", help: "Requests per cycle (default 5000)" },
|
|
91974
91981
|
{ flag: "--connections", value: "int", argName: "<n>", help: "Concurrent connections (default 100)" },
|
|
91975
91982
|
{ flag: "--idle", value: "int", argName: "<seconds>", help: "Idle seconds before each sample (default 30)" },
|
|
91983
|
+
{
|
|
91984
|
+
flag: "--max-old-space",
|
|
91985
|
+
value: "int",
|
|
91986
|
+
argName: "<mb>",
|
|
91987
|
+
help: "Heap cap of each measured process (default 512) \u2014 raise it for apps whose working set is larger"
|
|
91988
|
+
},
|
|
91976
91989
|
{
|
|
91977
91990
|
flag: "--quick",
|
|
91978
91991
|
value: "none",
|
|
91979
|
-
help: "Fast preset: 2000 requests x 4 cycles, 8s idle \u2014 the
|
|
91992
|
+
help: "Fast preset: 2000 requests x 4 cycles, 8s idle \u2014 same cycle count as the default, less traffic per cycle"
|
|
91980
91993
|
},
|
|
91981
91994
|
{ flag: "--diff-all", value: "none", help: "Diff snapshots for stable routes too (slow)" },
|
|
91982
91995
|
{ flag: "--output", value: "string", argName: "<dir>", help: "Where to write runs (default <app-dir>/.next-leak)" },
|
|
@@ -92015,8 +92028,10 @@ var LIMITS = {
|
|
|
92015
92028
|
"--cycles": 100,
|
|
92016
92029
|
"--requests": 1e6,
|
|
92017
92030
|
"--connections": 1e4,
|
|
92018
|
-
"--idle": 3600
|
|
92031
|
+
"--idle": 3600,
|
|
92032
|
+
"--max-old-space": 65536
|
|
92019
92033
|
};
|
|
92034
|
+
var MIN_MAX_OLD_SPACE_MB = 128;
|
|
92020
92035
|
function findSpec(argument) {
|
|
92021
92036
|
return FLAGS.find((spec) => spec.flag === argument || spec.alias === argument);
|
|
92022
92037
|
}
|
|
@@ -92044,10 +92059,16 @@ function applyNumericFlag(flag, value, options) {
|
|
|
92044
92059
|
if (flag === "--cycles" && parsed < 3) {
|
|
92045
92060
|
return flagError("the trend verdict needs at least 3 cycles (--cycles 3 or more)");
|
|
92046
92061
|
}
|
|
92062
|
+
if (flag === "--max-old-space" && parsed < MIN_MAX_OLD_SPACE_MB) {
|
|
92063
|
+
return flagError(
|
|
92064
|
+
`option "--max-old-space" needs at least ${MIN_MAX_OLD_SPACE_MB} MB (got ${parsed}) \u2014 below that the measured app cannot start`
|
|
92065
|
+
);
|
|
92066
|
+
}
|
|
92047
92067
|
if (flag === "--cycles") options.cycles = parsed;
|
|
92048
92068
|
if (flag === "--requests") options.requests = parsed;
|
|
92049
92069
|
if (flag === "--connections") options.connections = parsed;
|
|
92050
92070
|
if (flag === "--idle") options.idleSeconds = parsed;
|
|
92071
|
+
if (flag === "--max-old-space") options.maxOldSpaceMb = parsed;
|
|
92051
92072
|
return FLAG_OK;
|
|
92052
92073
|
}
|
|
92053
92074
|
function applyFlag(spec, value, options) {
|
|
@@ -92058,6 +92079,7 @@ function applyFlag(spec, value, options) {
|
|
|
92058
92079
|
case "--requests":
|
|
92059
92080
|
case "--connections":
|
|
92060
92081
|
case "--idle":
|
|
92082
|
+
case "--max-old-space":
|
|
92061
92083
|
return applyNumericFlag(spec.flag, value, options);
|
|
92062
92084
|
case "--quick":
|
|
92063
92085
|
options.quick = true;
|
|
@@ -92113,6 +92135,7 @@ function parseCliArgs(argv) {
|
|
|
92113
92135
|
requests: null,
|
|
92114
92136
|
connections: null,
|
|
92115
92137
|
idleSeconds: null,
|
|
92138
|
+
maxOldSpaceMb: null,
|
|
92116
92139
|
quick: false,
|
|
92117
92140
|
diffAll: false,
|
|
92118
92141
|
output: null
|
|
@@ -92162,6 +92185,7 @@ import path from "path";
|
|
|
92162
92185
|
import { pathToFileURL } from "url";
|
|
92163
92186
|
import { z } from "zod";
|
|
92164
92187
|
var controlFileSchema = z.object({ port: z.number(), pid: z.number() });
|
|
92188
|
+
var DEFAULT_MAX_OLD_SPACE_MB = 512;
|
|
92165
92189
|
var LaunchError = class extends Error {
|
|
92166
92190
|
constructor(message) {
|
|
92167
92191
|
super(message);
|
|
@@ -92185,6 +92209,12 @@ function explainStartupFailure(stderr) {
|
|
|
92185
92209
|
return `stderr:
|
|
92186
92210
|
${stderr}`;
|
|
92187
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
|
+
}
|
|
92188
92218
|
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
92189
92219
|
async function pollUntil(deadline, what, probe, failed) {
|
|
92190
92220
|
for (; ; ) {
|
|
@@ -92209,7 +92239,7 @@ async function launchInstrumented(options) {
|
|
|
92209
92239
|
process.execPath,
|
|
92210
92240
|
[
|
|
92211
92241
|
"--expose-gc",
|
|
92212
|
-
`--max-old-space-size=${options.maxOldSpaceMb ??
|
|
92242
|
+
`--max-old-space-size=${options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB}`,
|
|
92213
92243
|
"--import",
|
|
92214
92244
|
pathToFileURL(options.bootstrapPath).href,
|
|
92215
92245
|
options.serverPath
|
|
@@ -92287,6 +92317,7 @@ async function launchInstrumented(options) {
|
|
|
92287
92317
|
pid: child.pid ?? -1,
|
|
92288
92318
|
appPort: options.appPort,
|
|
92289
92319
|
controlPort,
|
|
92320
|
+
explainExit: () => exited ? explainRuntimeFailure(stderrTail, options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB) : null,
|
|
92290
92321
|
close: async () => {
|
|
92291
92322
|
if (exited) {
|
|
92292
92323
|
return;
|
|
@@ -92304,69 +92335,6 @@ async function launchInstrumented(options) {
|
|
|
92304
92335
|
}
|
|
92305
92336
|
}
|
|
92306
92337
|
|
|
92307
|
-
// src/trend.ts
|
|
92308
|
-
var DEFAULT_MIN_GROWTH = 256 * 1024;
|
|
92309
|
-
var STEPWISE_MIN_GROWING_CYCLES = 2;
|
|
92310
|
-
var STEPWISE_MAX_DRAWDOWN_RATIO = 0.1;
|
|
92311
|
-
function maxDrawdown(samples) {
|
|
92312
|
-
let peak = samples[1] ?? 0;
|
|
92313
|
-
let worst = 0;
|
|
92314
|
-
for (let i = 1; i < samples.length; i += 1) {
|
|
92315
|
-
const value = samples[i] ?? 0;
|
|
92316
|
-
peak = Math.max(peak, value);
|
|
92317
|
-
worst = Math.max(worst, peak - value);
|
|
92318
|
-
}
|
|
92319
|
-
return worst;
|
|
92320
|
-
}
|
|
92321
|
-
function isStepwiseGrowth(samples, deltas, growingCycles, mean, minGrowth) {
|
|
92322
|
-
if (growingCycles < STEPWISE_MIN_GROWING_CYCLES || mean < minGrowth) {
|
|
92323
|
-
return false;
|
|
92324
|
-
}
|
|
92325
|
-
const netGrowth = deltas.reduce((sum, delta) => sum + delta, 0);
|
|
92326
|
-
if (netGrowth <= 0) {
|
|
92327
|
-
return false;
|
|
92328
|
-
}
|
|
92329
|
-
return maxDrawdown(samples) <= netGrowth * STEPWISE_MAX_DRAWDOWN_RATIO;
|
|
92330
|
-
}
|
|
92331
|
-
function classifyTrend(samples, options = {}) {
|
|
92332
|
-
const minGrowth = options.minGrowthPerCycle ?? DEFAULT_MIN_GROWTH;
|
|
92333
|
-
if (samples.length < 4) {
|
|
92334
|
-
return { verdict: "inconclusive", growthPerCycle: 0, deltas: [] };
|
|
92335
|
-
}
|
|
92336
|
-
const deltas = [];
|
|
92337
|
-
for (let i = 2; i < samples.length; i += 1) {
|
|
92338
|
-
const current = samples[i];
|
|
92339
|
-
const previous = samples[i - 1];
|
|
92340
|
-
if (current === void 0 || previous === void 0) {
|
|
92341
|
-
return { verdict: "inconclusive", growthPerCycle: 0, deltas: [] };
|
|
92342
|
-
}
|
|
92343
|
-
deltas.push(current - previous);
|
|
92344
|
-
}
|
|
92345
|
-
const mean = deltas.reduce((sum, d) => sum + d, 0) / deltas.length;
|
|
92346
|
-
const allGrow = deltas.every((d) => d >= minGrowth);
|
|
92347
|
-
const anyFlatOrDown = deltas.some((d) => d <= 0);
|
|
92348
|
-
const growingCycles = deltas.filter((d) => d >= minGrowth).length;
|
|
92349
|
-
if (allGrow) {
|
|
92350
|
-
return { verdict: "leak", growthPerCycle: mean, deltas, source: "heap" };
|
|
92351
|
-
}
|
|
92352
|
-
if (isStepwiseGrowth(samples, deltas, growingCycles, mean, minGrowth)) {
|
|
92353
|
-
return { verdict: "leak", growthPerCycle: mean, deltas, source: "heap" };
|
|
92354
|
-
}
|
|
92355
|
-
if (anyFlatOrDown || mean < minGrowth) {
|
|
92356
|
-
return { verdict: "stable", growthPerCycle: mean, deltas, source: "heap" };
|
|
92357
|
-
}
|
|
92358
|
-
return { verdict: "inconclusive", growthPerCycle: mean, deltas, source: "heap" };
|
|
92359
|
-
}
|
|
92360
|
-
function classifyMemoryTrend(heapSamples, externalSamples, options = {}) {
|
|
92361
|
-
const heap = classifyTrend(heapSamples, options);
|
|
92362
|
-
const external = classifyTrend(externalSamples, options);
|
|
92363
|
-
const severity = { leak: 0, inconclusive: 1, stable: 2 };
|
|
92364
|
-
if (severity[external.verdict] < severity[heap.verdict]) {
|
|
92365
|
-
return { ...external, source: "external" };
|
|
92366
|
-
}
|
|
92367
|
-
return heap;
|
|
92368
|
-
}
|
|
92369
|
-
|
|
92370
92338
|
// src/report.ts
|
|
92371
92339
|
var MB = 1024 * 1024;
|
|
92372
92340
|
var formatMb = (bytes) => `${(bytes / MB).toFixed(1)} MB`;
|
|
@@ -92452,7 +92420,19 @@ function findingLines(route) {
|
|
|
92452
92420
|
}
|
|
92453
92421
|
return lines;
|
|
92454
92422
|
}
|
|
92455
|
-
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) {
|
|
92456
92436
|
if (route.status === "skipped") {
|
|
92457
92437
|
return [` \u2013 ${route.route} skipped: ${route.reason}`];
|
|
92458
92438
|
}
|
|
@@ -92467,16 +92447,19 @@ function routeLines(route) {
|
|
|
92467
92447
|
)}) heap ${curve}`,
|
|
92468
92448
|
...confidenceLines(route),
|
|
92469
92449
|
...memorySourceLines(route, verdict),
|
|
92450
|
+
...peakPressureLines(route, parameters),
|
|
92470
92451
|
...findingLines(route)
|
|
92471
92452
|
];
|
|
92472
92453
|
}
|
|
92473
92454
|
function formatReport(report) {
|
|
92474
92455
|
const lines = [`next-leak \u2014 ${report.appDir}`, ""];
|
|
92475
92456
|
for (const route of report.routes) {
|
|
92476
|
-
lines.push(...routeLines(route));
|
|
92457
|
+
lines.push(...routeLines(route, report.parameters));
|
|
92477
92458
|
}
|
|
92459
|
+
const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
|
|
92478
92460
|
lines.push(
|
|
92479
92461
|
"",
|
|
92462
|
+
`judged over ${cycles} cycles \xD7 ${loadRequests} requests, growth gate ${(minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle (${formatGrowth(minGrowthPerCycle / loadRequests * 1e3)}), heap cap ${maxOldSpaceMb} MB`,
|
|
92480
92463
|
`snapshots and run.json: ${report.workDir}`,
|
|
92481
92464
|
`report: ${report.bundle.htmlReport}`,
|
|
92482
92465
|
...report.bundle.issues.map((issue) => `issue draft (${issue.route}): ${issue.file}`)
|
|
@@ -93260,6 +93243,9 @@ async function requestGc(port) {
|
|
|
93260
93243
|
}
|
|
93261
93244
|
return sample;
|
|
93262
93245
|
}
|
|
93246
|
+
async function requestMemory(port) {
|
|
93247
|
+
return sampleSchema.parse(await request(port, "/mem"));
|
|
93248
|
+
}
|
|
93263
93249
|
async function requestSnapshot(port, name) {
|
|
93264
93250
|
const parsed = snapshotResponseSchema.parse(
|
|
93265
93251
|
await request(port, `/snapshot?name=${encodeURIComponent(name)}`)
|
|
@@ -93353,6 +93339,44 @@ Host: ${target.host}\r
|
|
|
93353
93339
|
var MIN_POLLS_TO_JUDGE = 2;
|
|
93354
93340
|
var SETTLE_POLL_MS = 2e3;
|
|
93355
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
|
+
}
|
|
93356
93380
|
async function waitUntilSettled(controlPort, maxIdleMs, deps) {
|
|
93357
93381
|
const deadline = Date.now() + maxIdleMs;
|
|
93358
93382
|
let previous = null;
|
|
@@ -93376,14 +93400,16 @@ async function waitUntilSettled(controlPort, maxIdleMs, deps) {
|
|
|
93376
93400
|
var defaultDeps = {
|
|
93377
93401
|
launch: launchInstrumented,
|
|
93378
93402
|
load: runLoadPhase,
|
|
93379
|
-
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
93403
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
93404
|
+
readMemory: requestMemory
|
|
93380
93405
|
};
|
|
93381
93406
|
var RITUAL_DEFAULTS = {
|
|
93382
93407
|
warmupRequests: 200,
|
|
93383
93408
|
loadRequests: 5e3,
|
|
93384
93409
|
connections: 100,
|
|
93385
|
-
cycles:
|
|
93386
|
-
idleMs: 3e4
|
|
93410
|
+
cycles: 4,
|
|
93411
|
+
idleMs: 3e4,
|
|
93412
|
+
maxOldSpaceMb: 512
|
|
93387
93413
|
};
|
|
93388
93414
|
async function runRitual(options, deps = defaultDeps) {
|
|
93389
93415
|
const warmupRequests = options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests;
|
|
@@ -93395,24 +93421,20 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93395
93421
|
throw new Error("the trend verdict needs at least 3 cycles");
|
|
93396
93422
|
}
|
|
93397
93423
|
await mkdir(options.workDir, { recursive: true });
|
|
93424
|
+
const launchOptions = {
|
|
93425
|
+
serverPath: options.serverPath,
|
|
93426
|
+
workDir: options.workDir,
|
|
93427
|
+
bootstrapPath: options.bootstrapPath,
|
|
93428
|
+
...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb }
|
|
93429
|
+
};
|
|
93398
93430
|
let app;
|
|
93399
93431
|
try {
|
|
93400
|
-
app = await deps.launch({
|
|
93401
|
-
serverPath: options.serverPath,
|
|
93402
|
-
workDir: options.workDir,
|
|
93403
|
-
appPort: options.appPort,
|
|
93404
|
-
bootstrapPath: options.bootstrapPath
|
|
93405
|
-
});
|
|
93432
|
+
app = await deps.launch({ ...launchOptions, appPort: options.appPort });
|
|
93406
93433
|
} catch (cause) {
|
|
93407
93434
|
if (!String(cause).includes("EADDRINUSE")) {
|
|
93408
93435
|
throw cause;
|
|
93409
93436
|
}
|
|
93410
|
-
app = await deps.launch({
|
|
93411
|
-
serverPath: options.serverPath,
|
|
93412
|
-
workDir: options.workDir,
|
|
93413
|
-
appPort: options.appPort + 1,
|
|
93414
|
-
bootstrapPath: options.bootstrapPath
|
|
93415
|
-
});
|
|
93437
|
+
app = await deps.launch({ ...launchOptions, appPort: options.appPort + 1 });
|
|
93416
93438
|
}
|
|
93417
93439
|
const timings = [];
|
|
93418
93440
|
const loadOutcomes = [];
|
|
@@ -93476,9 +93498,17 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93476
93498
|
() => requestSnapshot(app.controlPort, "baseline")
|
|
93477
93499
|
);
|
|
93478
93500
|
const memorySamples = [baseline.sample];
|
|
93501
|
+
const peaks = [];
|
|
93479
93502
|
let afterSnapshot = "";
|
|
93480
93503
|
for (let cycle = 1; cycle <= cycles; cycle += 1) {
|
|
93481
|
-
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
|
+
});
|
|
93482
93512
|
const settle = await timed(
|
|
93483
93513
|
`cycle ${cycle} settle`,
|
|
93484
93514
|
() => waitUntilSettled(app.controlPort, idleMs, deps)
|
|
@@ -93497,6 +93527,7 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93497
93527
|
}
|
|
93498
93528
|
const samples = memorySamples.map((sample) => sample.heapUsed);
|
|
93499
93529
|
const externalSamples = memorySamples.map((sample) => sample.external);
|
|
93530
|
+
const minGrowthPerCycle = minGrowthFor(loadRequests);
|
|
93500
93531
|
return {
|
|
93501
93532
|
route: options.route,
|
|
93502
93533
|
timings,
|
|
@@ -93504,11 +93535,19 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93504
93535
|
settleOutcomes,
|
|
93505
93536
|
samples,
|
|
93506
93537
|
memorySamples,
|
|
93538
|
+
peaks,
|
|
93507
93539
|
baselineSnapshot: baseline.file,
|
|
93508
93540
|
afterSnapshot,
|
|
93509
|
-
trend: classifyMemoryTrend(samples, externalSamples),
|
|
93510
|
-
requestsPerCycle: loadRequests
|
|
93541
|
+
trend: classifyMemoryTrend(samples, externalSamples, { minGrowthPerCycle }),
|
|
93542
|
+
requestsPerCycle: loadRequests,
|
|
93543
|
+
minGrowthPerCycle
|
|
93511
93544
|
};
|
|
93545
|
+
} catch (cause) {
|
|
93546
|
+
const death = app.explainExit();
|
|
93547
|
+
if (death !== null) {
|
|
93548
|
+
throw new Error(death);
|
|
93549
|
+
}
|
|
93550
|
+
throw cause;
|
|
93512
93551
|
} finally {
|
|
93513
93552
|
await app.close();
|
|
93514
93553
|
}
|
|
@@ -93806,6 +93845,7 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93806
93845
|
...options.connections !== void 0 && { connections: options.connections },
|
|
93807
93846
|
...options.cycles !== void 0 && { cycles: options.cycles },
|
|
93808
93847
|
...options.idleMs !== void 0 && { idleMs: options.idleMs },
|
|
93848
|
+
...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb },
|
|
93809
93849
|
...routeConfig.headers !== void 0 && { headers: routeConfig.headers },
|
|
93810
93850
|
...routeConfig.abandonAfterMs !== void 0 && {
|
|
93811
93851
|
abandonAfterMs: routeConfig.abandonAfterMs
|
|
@@ -93815,6 +93855,11 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93815
93855
|
trend: result.trend,
|
|
93816
93856
|
loadOutcomes: result.loadOutcomes,
|
|
93817
93857
|
settleOutcomes: result.settleOutcomes,
|
|
93858
|
+
// The audit has to grade against the gate the verdict actually used, or
|
|
93859
|
+
// the noise-floor warning describes a threshold nobody applied.
|
|
93860
|
+
minGrowthPerCycle: result.minGrowthPerCycle,
|
|
93861
|
+
memorySamples: result.memorySamples,
|
|
93862
|
+
maxOldSpaceMb: options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB,
|
|
93818
93863
|
...routeConfig.abandonAfterMs !== void 0 && {
|
|
93819
93864
|
abandonAfterMs: routeConfig.abandonAfterMs
|
|
93820
93865
|
}
|
|
@@ -93834,6 +93879,7 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93834
93879
|
requestPath,
|
|
93835
93880
|
samples: result.samples,
|
|
93836
93881
|
memorySamples: result.memorySamples,
|
|
93882
|
+
peaks: result.peaks,
|
|
93837
93883
|
timings: result.timings,
|
|
93838
93884
|
loadOutcomes: result.loadOutcomes,
|
|
93839
93885
|
settleOutcomes: result.settleOutcomes,
|
|
@@ -93849,8 +93895,8 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93849
93895
|
};
|
|
93850
93896
|
}
|
|
93851
93897
|
async function writeEvidenceBundle(report, workDir) {
|
|
93852
|
-
const { renderHtmlReport } = await import("./html-report-
|
|
93853
|
-
const { renderIssueMarkdown } = await import("./issue-report-
|
|
93898
|
+
const { renderHtmlReport } = await import("./html-report-KQGUDYK5.js");
|
|
93899
|
+
const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
|
|
93854
93900
|
for (const route of report.routes) {
|
|
93855
93901
|
if (route.status === "measured" && warrantsIssueDraft(route)) {
|
|
93856
93902
|
const file = path6.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
|
|
@@ -93892,12 +93938,15 @@ async function planRun(options, target, deps, progress) {
|
|
|
93892
93938
|
progress(
|
|
93893
93939
|
`module registry: ${registry.size} modules` + (nextVersion === null ? "" : ` \xB7 next ${nextVersion}`)
|
|
93894
93940
|
);
|
|
93941
|
+
const loadRequests = options.loadRequests ?? RITUAL_DEFAULTS.loadRequests;
|
|
93895
93942
|
const parameters = {
|
|
93896
93943
|
warmupRequests: options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests,
|
|
93897
|
-
loadRequests
|
|
93944
|
+
loadRequests,
|
|
93898
93945
|
connections: options.connections ?? RITUAL_DEFAULTS.connections,
|
|
93899
93946
|
cycles: options.cycles ?? RITUAL_DEFAULTS.cycles,
|
|
93900
|
-
idleMs: options.idleMs ?? RITUAL_DEFAULTS.idleMs
|
|
93947
|
+
idleMs: options.idleMs ?? RITUAL_DEFAULTS.idleMs,
|
|
93948
|
+
maxOldSpaceMb: options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB,
|
|
93949
|
+
minGrowthPerCycle: minGrowthFor(loadRequests)
|
|
93901
93950
|
};
|
|
93902
93951
|
const measurable = routes.filter(
|
|
93903
93952
|
(route) => skipReason(route, resolveRoutePath(route.path, routeConfig)) === null
|
|
@@ -93972,7 +94021,6 @@ export {
|
|
|
93972
94021
|
LaunchError,
|
|
93973
94022
|
killActiveChildren,
|
|
93974
94023
|
launchInstrumented,
|
|
93975
|
-
classifyTrend,
|
|
93976
94024
|
formatReport,
|
|
93977
94025
|
RouteConfigError,
|
|
93978
94026
|
ROUTE_CONFIG_FILE,
|
|
@@ -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-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");
|
|
@@ -77,7 +100,8 @@ code{background:#f4f4f4;padding:0 4px;border-radius:3px}
|
|
|
77
100
|
)} \xB7 ${escapeHtml(environment.platform)}/${escapeHtml(environment.arch)} \xB7 next ${escapeHtml(
|
|
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>
|
|
104
|
+
${measured.map((route) => measuredSection(route, run.parameters)).join("\n")}
|
|
81
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>`}
|
|
82
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>`}
|
|
83
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,5 +1,72 @@
|
|
|
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
|
|
|
3
|
+
// src/trend.ts
|
|
4
|
+
var MIN_GROWTH_NOISE_FLOOR = 256 * 1024;
|
|
5
|
+
var MIN_GROWTH_PER_1000_REQUESTS = 51.2 * 1024;
|
|
6
|
+
function minGrowthFor(requestsPerCycle) {
|
|
7
|
+
return Math.max(MIN_GROWTH_NOISE_FLOOR, MIN_GROWTH_PER_1000_REQUESTS * requestsPerCycle / 1e3);
|
|
8
|
+
}
|
|
9
|
+
var STEPWISE_MIN_GROWING_CYCLES = 2;
|
|
10
|
+
var STEPWISE_MAX_DRAWDOWN_RATIO = 0.1;
|
|
11
|
+
function maxDrawdown(samples) {
|
|
12
|
+
let peak = samples[1] ?? 0;
|
|
13
|
+
let worst = 0;
|
|
14
|
+
for (let i = 1; i < samples.length; i += 1) {
|
|
15
|
+
const value = samples[i] ?? 0;
|
|
16
|
+
peak = Math.max(peak, value);
|
|
17
|
+
worst = Math.max(worst, peak - value);
|
|
18
|
+
}
|
|
19
|
+
return worst;
|
|
20
|
+
}
|
|
21
|
+
function isStepwiseGrowth(samples, deltas, growingCycles, mean, minGrowth) {
|
|
22
|
+
if (growingCycles < STEPWISE_MIN_GROWING_CYCLES || mean < minGrowth) {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const netGrowth = deltas.reduce((sum, delta) => sum + delta, 0);
|
|
26
|
+
if (netGrowth <= 0) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return maxDrawdown(samples) <= netGrowth * STEPWISE_MAX_DRAWDOWN_RATIO;
|
|
30
|
+
}
|
|
31
|
+
function classifyTrend(samples, options = {}) {
|
|
32
|
+
const minGrowth = options.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR;
|
|
33
|
+
if (samples.length < 4) {
|
|
34
|
+
return { verdict: "inconclusive", growthPerCycle: 0, deltas: [] };
|
|
35
|
+
}
|
|
36
|
+
const deltas = [];
|
|
37
|
+
for (let i = 2; i < samples.length; i += 1) {
|
|
38
|
+
const current = samples[i];
|
|
39
|
+
const previous = samples[i - 1];
|
|
40
|
+
if (current === void 0 || previous === void 0) {
|
|
41
|
+
return { verdict: "inconclusive", growthPerCycle: 0, deltas: [] };
|
|
42
|
+
}
|
|
43
|
+
deltas.push(current - previous);
|
|
44
|
+
}
|
|
45
|
+
const mean = deltas.reduce((sum, d) => sum + d, 0) / deltas.length;
|
|
46
|
+
const allGrow = deltas.every((d) => d >= minGrowth);
|
|
47
|
+
const anyFlatOrDown = deltas.some((d) => d <= 0);
|
|
48
|
+
const growingCycles = deltas.filter((d) => d >= minGrowth).length;
|
|
49
|
+
if (allGrow) {
|
|
50
|
+
return { verdict: "leak", growthPerCycle: mean, deltas, source: "heap" };
|
|
51
|
+
}
|
|
52
|
+
if (isStepwiseGrowth(samples, deltas, growingCycles, mean, minGrowth)) {
|
|
53
|
+
return { verdict: "leak", growthPerCycle: mean, deltas, source: "heap" };
|
|
54
|
+
}
|
|
55
|
+
if (anyFlatOrDown || mean < minGrowth) {
|
|
56
|
+
return { verdict: "stable", growthPerCycle: mean, deltas, source: "heap" };
|
|
57
|
+
}
|
|
58
|
+
return { verdict: "inconclusive", growthPerCycle: mean, deltas, source: "heap" };
|
|
59
|
+
}
|
|
60
|
+
function classifyMemoryTrend(heapSamples, externalSamples, options = {}) {
|
|
61
|
+
const heap = classifyTrend(heapSamples, options);
|
|
62
|
+
const external = classifyTrend(externalSamples, options);
|
|
63
|
+
const severity = { leak: 0, inconclusive: 1, stable: 2 };
|
|
64
|
+
if (severity[external.verdict] < severity[heap.verdict]) {
|
|
65
|
+
return { ...external, source: "external" };
|
|
66
|
+
}
|
|
67
|
+
return heap;
|
|
68
|
+
}
|
|
69
|
+
|
|
3
70
|
// src/confidence.ts
|
|
4
71
|
function effectiveVerdict(report) {
|
|
5
72
|
return report.confidence.supersededVerdict ?? report.trend.verdict;
|
|
@@ -11,7 +78,6 @@ var VERDICT_WEAKENING = /* @__PURE__ */ new Set([
|
|
|
11
78
|
function warrantsIssueDraft(report) {
|
|
12
79
|
return effectiveVerdict(report) === "leak" && !report.confidence.warnings.some((warning) => VERDICT_WEAKENING.has(warning.code));
|
|
13
80
|
}
|
|
14
|
-
var DEFAULT_MIN_GROWTH = 256 * 1024;
|
|
15
81
|
var LOAD_COMPLETION_FLOOR = 0.99;
|
|
16
82
|
var ABANDON_EFFECTIVE_FLOOR = 0.9;
|
|
17
83
|
var MID_STREAM_FLOOR = 0.1;
|
|
@@ -99,6 +165,23 @@ function noiseFloorWarnings(trend, minGrowth) {
|
|
|
99
165
|
detail: `growth of ${mb(trend.growthPerCycle)}/cycle barely clears the ${mb(minGrowth)} threshold \u2014 raise --load-requests so the signal outgrows the noise`
|
|
100
166
|
}];
|
|
101
167
|
}
|
|
168
|
+
var HEAP_CEILING_RATIO = 0.7;
|
|
169
|
+
function heapCeilingWarnings(input) {
|
|
170
|
+
const samples = input.memorySamples;
|
|
171
|
+
const capMb = input.maxOldSpaceMb;
|
|
172
|
+
if (samples === void 0 || capMb === void 0 || samples.length === 0) {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
const peak = Math.max(...samples.map((sample) => sample.heapUsed));
|
|
176
|
+
const capBytes = capMb * 1024 * 1024;
|
|
177
|
+
if (peak < capBytes * HEAP_CEILING_RATIO) {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
return [{
|
|
181
|
+
code: "near-heap-ceiling",
|
|
182
|
+
detail: `the heap peaked at ${mb(peak)} against a ${capMb} MB cap (${pct(peak, capBytes)}) \u2014 the curve may have been clipped by the ceiling rather than by the app; re-run with a larger --max-old-space`
|
|
183
|
+
}];
|
|
184
|
+
}
|
|
102
185
|
function isVerdictInvalid(input) {
|
|
103
186
|
if (input.trend.verdict !== "leak") {
|
|
104
187
|
return false;
|
|
@@ -108,12 +191,13 @@ function isVerdictInvalid(input) {
|
|
|
108
191
|
return neverSettled || abandonedNothing;
|
|
109
192
|
}
|
|
110
193
|
function assessConfidence(input) {
|
|
111
|
-
const minGrowth = input.minGrowthPerCycle ??
|
|
194
|
+
const minGrowth = input.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR;
|
|
112
195
|
const warnings = [
|
|
113
196
|
...settleWarnings(input.settleOutcomes),
|
|
114
197
|
...loadWarnings(input.loadOutcomes, input.abandonAfterMs),
|
|
115
198
|
...growthShapeWarnings(input.trend),
|
|
116
|
-
...noiseFloorWarnings(input.trend, minGrowth)
|
|
199
|
+
...noiseFloorWarnings(input.trend, minGrowth),
|
|
200
|
+
...heapCeilingWarnings(input)
|
|
117
201
|
];
|
|
118
202
|
return {
|
|
119
203
|
level: warnings.length === 0 ? "high" : "low",
|
|
@@ -123,6 +207,9 @@ function assessConfidence(input) {
|
|
|
123
207
|
}
|
|
124
208
|
|
|
125
209
|
export {
|
|
210
|
+
minGrowthFor,
|
|
211
|
+
classifyTrend,
|
|
212
|
+
classifyMemoryTrend,
|
|
126
213
|
effectiveVerdict,
|
|
127
214
|
warrantsIssueDraft,
|
|
128
215
|
assessConfidence
|