next-leak 0.1.3 → 0.2.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 +36 -11
- package/THIRD-PARTY-NOTICES.md +2678 -0
- package/dist/{chunk-2BQZCZ4Z.js → chunk-E5ZKAANQ.js} +90 -3
- package/dist/{chunk-ZFYIR423.js → chunk-HAKKAIHN.js} +53 -88
- package/dist/{chunk-5ZYZW2BL.js → chunk-MYYPTZJW.js} +3 -1
- package/dist/{chunk-C3KH5Z2W.js → chunk-SKAPGI62.js} +2 -1
- package/dist/cli-args.d.ts +1 -0
- package/dist/cli.js +3 -2
- package/dist/confidence.d.ts +9 -2
- package/dist/{html-report-26DU7JFW.js → html-report-2SOTNXDG.js} +2 -2
- package/dist/index.js +6 -5
- package/dist/{issue-report-FUTEOHN5.js → issue-report-X5JYAVN5.js} +1 -1
- package/dist/launcher.d.ts +9 -0
- package/dist/ritual.d.ts +21 -2
- package/dist/runner.d.ts +15 -1
- package/dist/trend.d.ts +36 -1
- package/package.json +4 -3
|
@@ -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
|
|
@@ -1,9 +1,12 @@
|
|
|
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";
|
|
7
10
|
import {
|
|
8
11
|
__commonJS,
|
|
9
12
|
__esm,
|
|
@@ -91969,14 +91972,20 @@ var FLAGS = [
|
|
|
91969
91972
|
argName: "<list>",
|
|
91970
91973
|
help: "Only measure these routes \u2014 comma-separated templates or prefixes (e.g. /api,/dashboard)"
|
|
91971
91974
|
},
|
|
91972
|
-
{ flag: "--cycles", value: "int", argName: "<n>", help: "Load cycles per route (default
|
|
91975
|
+
{ flag: "--cycles", value: "int", argName: "<n>", help: "Load cycles per route (default 4, minimum 3)" },
|
|
91973
91976
|
{ flag: "--requests", value: "int", argName: "<n>", help: "Requests per cycle (default 5000)" },
|
|
91974
91977
|
{ flag: "--connections", value: "int", argName: "<n>", help: "Concurrent connections (default 100)" },
|
|
91975
91978
|
{ flag: "--idle", value: "int", argName: "<seconds>", help: "Idle seconds before each sample (default 30)" },
|
|
91979
|
+
{
|
|
91980
|
+
flag: "--max-old-space",
|
|
91981
|
+
value: "int",
|
|
91982
|
+
argName: "<mb>",
|
|
91983
|
+
help: "Heap cap of each measured process (default 512) \u2014 raise it for apps whose working set is larger"
|
|
91984
|
+
},
|
|
91976
91985
|
{
|
|
91977
91986
|
flag: "--quick",
|
|
91978
91987
|
value: "none",
|
|
91979
|
-
help: "Fast preset: 2000 requests x 4 cycles, 8s idle \u2014 the
|
|
91988
|
+
help: "Fast preset: 2000 requests x 4 cycles, 8s idle \u2014 same cycle count as the default, less traffic per cycle"
|
|
91980
91989
|
},
|
|
91981
91990
|
{ flag: "--diff-all", value: "none", help: "Diff snapshots for stable routes too (slow)" },
|
|
91982
91991
|
{ flag: "--output", value: "string", argName: "<dir>", help: "Where to write runs (default <app-dir>/.next-leak)" },
|
|
@@ -92015,8 +92024,10 @@ var LIMITS = {
|
|
|
92015
92024
|
"--cycles": 100,
|
|
92016
92025
|
"--requests": 1e6,
|
|
92017
92026
|
"--connections": 1e4,
|
|
92018
|
-
"--idle": 3600
|
|
92027
|
+
"--idle": 3600,
|
|
92028
|
+
"--max-old-space": 65536
|
|
92019
92029
|
};
|
|
92030
|
+
var MIN_MAX_OLD_SPACE_MB = 128;
|
|
92020
92031
|
function findSpec(argument) {
|
|
92021
92032
|
return FLAGS.find((spec) => spec.flag === argument || spec.alias === argument);
|
|
92022
92033
|
}
|
|
@@ -92044,10 +92055,16 @@ function applyNumericFlag(flag, value, options) {
|
|
|
92044
92055
|
if (flag === "--cycles" && parsed < 3) {
|
|
92045
92056
|
return flagError("the trend verdict needs at least 3 cycles (--cycles 3 or more)");
|
|
92046
92057
|
}
|
|
92058
|
+
if (flag === "--max-old-space" && parsed < MIN_MAX_OLD_SPACE_MB) {
|
|
92059
|
+
return flagError(
|
|
92060
|
+
`option "--max-old-space" needs at least ${MIN_MAX_OLD_SPACE_MB} MB (got ${parsed}) \u2014 below that the measured app cannot start`
|
|
92061
|
+
);
|
|
92062
|
+
}
|
|
92047
92063
|
if (flag === "--cycles") options.cycles = parsed;
|
|
92048
92064
|
if (flag === "--requests") options.requests = parsed;
|
|
92049
92065
|
if (flag === "--connections") options.connections = parsed;
|
|
92050
92066
|
if (flag === "--idle") options.idleSeconds = parsed;
|
|
92067
|
+
if (flag === "--max-old-space") options.maxOldSpaceMb = parsed;
|
|
92051
92068
|
return FLAG_OK;
|
|
92052
92069
|
}
|
|
92053
92070
|
function applyFlag(spec, value, options) {
|
|
@@ -92058,6 +92075,7 @@ function applyFlag(spec, value, options) {
|
|
|
92058
92075
|
case "--requests":
|
|
92059
92076
|
case "--connections":
|
|
92060
92077
|
case "--idle":
|
|
92078
|
+
case "--max-old-space":
|
|
92061
92079
|
return applyNumericFlag(spec.flag, value, options);
|
|
92062
92080
|
case "--quick":
|
|
92063
92081
|
options.quick = true;
|
|
@@ -92113,6 +92131,7 @@ function parseCliArgs(argv) {
|
|
|
92113
92131
|
requests: null,
|
|
92114
92132
|
connections: null,
|
|
92115
92133
|
idleSeconds: null,
|
|
92134
|
+
maxOldSpaceMb: null,
|
|
92116
92135
|
quick: false,
|
|
92117
92136
|
diffAll: false,
|
|
92118
92137
|
output: null
|
|
@@ -92162,6 +92181,7 @@ import path from "path";
|
|
|
92162
92181
|
import { pathToFileURL } from "url";
|
|
92163
92182
|
import { z } from "zod";
|
|
92164
92183
|
var controlFileSchema = z.object({ port: z.number(), pid: z.number() });
|
|
92184
|
+
var DEFAULT_MAX_OLD_SPACE_MB = 512;
|
|
92165
92185
|
var LaunchError = class extends Error {
|
|
92166
92186
|
constructor(message) {
|
|
92167
92187
|
super(message);
|
|
@@ -92209,7 +92229,7 @@ async function launchInstrumented(options) {
|
|
|
92209
92229
|
process.execPath,
|
|
92210
92230
|
[
|
|
92211
92231
|
"--expose-gc",
|
|
92212
|
-
`--max-old-space-size=${options.maxOldSpaceMb ??
|
|
92232
|
+
`--max-old-space-size=${options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB}`,
|
|
92213
92233
|
"--import",
|
|
92214
92234
|
pathToFileURL(options.bootstrapPath).href,
|
|
92215
92235
|
options.serverPath
|
|
@@ -92304,69 +92324,6 @@ async function launchInstrumented(options) {
|
|
|
92304
92324
|
}
|
|
92305
92325
|
}
|
|
92306
92326
|
|
|
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
92327
|
// src/report.ts
|
|
92371
92328
|
var MB = 1024 * 1024;
|
|
92372
92329
|
var formatMb = (bytes) => `${(bytes / MB).toFixed(1)} MB`;
|
|
@@ -92475,8 +92432,10 @@ function formatReport(report) {
|
|
|
92475
92432
|
for (const route of report.routes) {
|
|
92476
92433
|
lines.push(...routeLines(route));
|
|
92477
92434
|
}
|
|
92435
|
+
const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
|
|
92478
92436
|
lines.push(
|
|
92479
92437
|
"",
|
|
92438
|
+
`judged over ${cycles} cycles \xD7 ${loadRequests} requests, growth gate ${(minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle (${formatGrowth(minGrowthPerCycle / loadRequests * 1e3)}), heap cap ${maxOldSpaceMb} MB`,
|
|
92480
92439
|
`snapshots and run.json: ${report.workDir}`,
|
|
92481
92440
|
`report: ${report.bundle.htmlReport}`,
|
|
92482
92441
|
...report.bundle.issues.map((issue) => `issue draft (${issue.route}): ${issue.file}`)
|
|
@@ -93382,7 +93341,7 @@ var RITUAL_DEFAULTS = {
|
|
|
93382
93341
|
warmupRequests: 200,
|
|
93383
93342
|
loadRequests: 5e3,
|
|
93384
93343
|
connections: 100,
|
|
93385
|
-
cycles:
|
|
93344
|
+
cycles: 4,
|
|
93386
93345
|
idleMs: 3e4
|
|
93387
93346
|
};
|
|
93388
93347
|
async function runRitual(options, deps = defaultDeps) {
|
|
@@ -93395,24 +93354,20 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93395
93354
|
throw new Error("the trend verdict needs at least 3 cycles");
|
|
93396
93355
|
}
|
|
93397
93356
|
await mkdir(options.workDir, { recursive: true });
|
|
93357
|
+
const launchOptions = {
|
|
93358
|
+
serverPath: options.serverPath,
|
|
93359
|
+
workDir: options.workDir,
|
|
93360
|
+
bootstrapPath: options.bootstrapPath,
|
|
93361
|
+
...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb }
|
|
93362
|
+
};
|
|
93398
93363
|
let app;
|
|
93399
93364
|
try {
|
|
93400
|
-
app = await deps.launch({
|
|
93401
|
-
serverPath: options.serverPath,
|
|
93402
|
-
workDir: options.workDir,
|
|
93403
|
-
appPort: options.appPort,
|
|
93404
|
-
bootstrapPath: options.bootstrapPath
|
|
93405
|
-
});
|
|
93365
|
+
app = await deps.launch({ ...launchOptions, appPort: options.appPort });
|
|
93406
93366
|
} catch (cause) {
|
|
93407
93367
|
if (!String(cause).includes("EADDRINUSE")) {
|
|
93408
93368
|
throw cause;
|
|
93409
93369
|
}
|
|
93410
|
-
app = await deps.launch({
|
|
93411
|
-
serverPath: options.serverPath,
|
|
93412
|
-
workDir: options.workDir,
|
|
93413
|
-
appPort: options.appPort + 1,
|
|
93414
|
-
bootstrapPath: options.bootstrapPath
|
|
93415
|
-
});
|
|
93370
|
+
app = await deps.launch({ ...launchOptions, appPort: options.appPort + 1 });
|
|
93416
93371
|
}
|
|
93417
93372
|
const timings = [];
|
|
93418
93373
|
const loadOutcomes = [];
|
|
@@ -93497,6 +93452,7 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93497
93452
|
}
|
|
93498
93453
|
const samples = memorySamples.map((sample) => sample.heapUsed);
|
|
93499
93454
|
const externalSamples = memorySamples.map((sample) => sample.external);
|
|
93455
|
+
const minGrowthPerCycle = minGrowthFor(loadRequests);
|
|
93500
93456
|
return {
|
|
93501
93457
|
route: options.route,
|
|
93502
93458
|
timings,
|
|
@@ -93506,8 +93462,9 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93506
93462
|
memorySamples,
|
|
93507
93463
|
baselineSnapshot: baseline.file,
|
|
93508
93464
|
afterSnapshot,
|
|
93509
|
-
trend: classifyMemoryTrend(samples, externalSamples),
|
|
93510
|
-
requestsPerCycle: loadRequests
|
|
93465
|
+
trend: classifyMemoryTrend(samples, externalSamples, { minGrowthPerCycle }),
|
|
93466
|
+
requestsPerCycle: loadRequests,
|
|
93467
|
+
minGrowthPerCycle
|
|
93511
93468
|
};
|
|
93512
93469
|
} finally {
|
|
93513
93470
|
await app.close();
|
|
@@ -93806,6 +93763,7 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93806
93763
|
...options.connections !== void 0 && { connections: options.connections },
|
|
93807
93764
|
...options.cycles !== void 0 && { cycles: options.cycles },
|
|
93808
93765
|
...options.idleMs !== void 0 && { idleMs: options.idleMs },
|
|
93766
|
+
...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb },
|
|
93809
93767
|
...routeConfig.headers !== void 0 && { headers: routeConfig.headers },
|
|
93810
93768
|
...routeConfig.abandonAfterMs !== void 0 && {
|
|
93811
93769
|
abandonAfterMs: routeConfig.abandonAfterMs
|
|
@@ -93815,6 +93773,11 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93815
93773
|
trend: result.trend,
|
|
93816
93774
|
loadOutcomes: result.loadOutcomes,
|
|
93817
93775
|
settleOutcomes: result.settleOutcomes,
|
|
93776
|
+
// The audit has to grade against the gate the verdict actually used, or
|
|
93777
|
+
// the noise-floor warning describes a threshold nobody applied.
|
|
93778
|
+
minGrowthPerCycle: result.minGrowthPerCycle,
|
|
93779
|
+
memorySamples: result.memorySamples,
|
|
93780
|
+
maxOldSpaceMb: options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB,
|
|
93818
93781
|
...routeConfig.abandonAfterMs !== void 0 && {
|
|
93819
93782
|
abandonAfterMs: routeConfig.abandonAfterMs
|
|
93820
93783
|
}
|
|
@@ -93849,8 +93812,8 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93849
93812
|
};
|
|
93850
93813
|
}
|
|
93851
93814
|
async function writeEvidenceBundle(report, workDir) {
|
|
93852
|
-
const { renderHtmlReport } = await import("./html-report-
|
|
93853
|
-
const { renderIssueMarkdown } = await import("./issue-report-
|
|
93815
|
+
const { renderHtmlReport } = await import("./html-report-2SOTNXDG.js");
|
|
93816
|
+
const { renderIssueMarkdown } = await import("./issue-report-X5JYAVN5.js");
|
|
93854
93817
|
for (const route of report.routes) {
|
|
93855
93818
|
if (route.status === "measured" && warrantsIssueDraft(route)) {
|
|
93856
93819
|
const file = path6.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
|
|
@@ -93892,12 +93855,15 @@ async function planRun(options, target, deps, progress) {
|
|
|
93892
93855
|
progress(
|
|
93893
93856
|
`module registry: ${registry.size} modules` + (nextVersion === null ? "" : ` \xB7 next ${nextVersion}`)
|
|
93894
93857
|
);
|
|
93858
|
+
const loadRequests = options.loadRequests ?? RITUAL_DEFAULTS.loadRequests;
|
|
93895
93859
|
const parameters = {
|
|
93896
93860
|
warmupRequests: options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests,
|
|
93897
|
-
loadRequests
|
|
93861
|
+
loadRequests,
|
|
93898
93862
|
connections: options.connections ?? RITUAL_DEFAULTS.connections,
|
|
93899
93863
|
cycles: options.cycles ?? RITUAL_DEFAULTS.cycles,
|
|
93900
|
-
idleMs: options.idleMs ?? RITUAL_DEFAULTS.idleMs
|
|
93864
|
+
idleMs: options.idleMs ?? RITUAL_DEFAULTS.idleMs,
|
|
93865
|
+
maxOldSpaceMb: options.maxOldSpaceMb ?? DEFAULT_MAX_OLD_SPACE_MB,
|
|
93866
|
+
minGrowthPerCycle: minGrowthFor(loadRequests)
|
|
93901
93867
|
};
|
|
93902
93868
|
const measurable = routes.filter(
|
|
93903
93869
|
(route) => skipReason(route, resolveRoutePath(route.path, routeConfig)) === null
|
|
@@ -93972,7 +93938,6 @@ export {
|
|
|
93972
93938
|
LaunchError,
|
|
93973
93939
|
killActiveChildren,
|
|
93974
93940
|
launchInstrumented,
|
|
93975
|
-
classifyTrend,
|
|
93976
93941
|
formatReport,
|
|
93977
93942
|
RouteConfigError,
|
|
93978
93943
|
ROUTE_CONFIG_FILE,
|
|
@@ -60,7 +60,7 @@ OS: ${environment.platform} ${environment.arch}${environment.cpuModel ? ` (${env
|
|
|
60
60
|
Memory: ${(environment.totalMemoryBytes / (1024 * MB)).toFixed(0)} GB
|
|
61
61
|
Next.js: ${environment.nextVersion ?? "unknown"}
|
|
62
62
|
next-leak: ${environment.nextLeakVersion}
|
|
63
|
-
Deployment: output "standalone", node --expose-gc --max-old-space-size
|
|
63
|
+
Deployment: output "standalone", node --expose-gc --max-old-space-size=${parameters.maxOldSpaceMb}
|
|
64
64
|
\`\`\`
|
|
65
65
|
|
|
66
66
|
### To Reproduce
|
|
@@ -72,6 +72,8 @@ Deployment: output "standalone", node --expose-gc --max-old-space-size=512
|
|
|
72
72
|
2. next-leak boots \`.next/standalone/server.js\` in a fresh process per route and runs, against \`${route.requestPath}\`:
|
|
73
73
|
warm-up ${parameters.warmupRequests} requests \u2192 forced GC \u2192 baseline heap snapshot \u2192
|
|
74
74
|
${parameters.cycles} \xD7 [${parameters.loadRequests} requests at ${parameters.connections} connections \u2192 ${(parameters.idleMs / 1e3).toFixed(0)}s idle \u2192 forced GC \u2192 post-GC sample] \u2192 final snapshot.
|
|
75
|
+
3. A cycle counts as growing when it retains at least ${(parameters.minGrowthPerCycle / 1024).toFixed(0)} KiB
|
|
76
|
+
(the gate for this run's ${parameters.loadRequests} requests per cycle); the warm-up cycle is excluded.
|
|
75
77
|
|
|
76
78
|
### Current vs. Expected behavior
|
|
77
79
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
|
|
2
2
|
import {
|
|
3
3
|
effectiveVerdict
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-E5ZKAANQ.js";
|
|
5
5
|
|
|
6
6
|
// src/html-report.ts
|
|
7
7
|
var MB = 1024 * 1024;
|
|
@@ -77,6 +77,7 @@ code{background:#f4f4f4;padding:0 4px;border-radius:3px}
|
|
|
77
77
|
)} \xB7 ${escapeHtml(environment.platform)}/${escapeHtml(environment.arch)} \xB7 next ${escapeHtml(
|
|
78
78
|
environment.nextVersion ?? "unknown"
|
|
79
79
|
)} \xB7 next-leak ${escapeHtml(environment.nextLeakVersion)}</p>
|
|
80
|
+
<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>
|
|
80
81
|
${measured.map(measuredSection).join("\n")}
|
|
81
82
|
${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
83
|
${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>`}
|
package/dist/cli-args.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
killActiveChildren,
|
|
10
10
|
parseCliArgs,
|
|
11
11
|
runMeasurement
|
|
12
|
-
} from "./chunk-
|
|
13
|
-
import "./chunk-
|
|
12
|
+
} from "./chunk-HAKKAIHN.js";
|
|
13
|
+
import "./chunk-E5ZKAANQ.js";
|
|
14
14
|
import "./chunk-6XYFBOL2.js";
|
|
15
15
|
|
|
16
16
|
// src/cli.ts
|
|
@@ -97,6 +97,7 @@ async function main() {
|
|
|
97
97
|
...options.requests !== null && { loadRequests: options.requests },
|
|
98
98
|
...options.connections !== null && { connections: options.connections },
|
|
99
99
|
...options.idleSeconds !== null && { idleMs: options.idleSeconds * 1e3 },
|
|
100
|
+
...options.maxOldSpaceMb !== null && { maxOldSpaceMb: options.maxOldSpaceMb },
|
|
100
101
|
...options.diffAll && { diffAll: true },
|
|
101
102
|
...options.output !== null && { outputDir: options.output },
|
|
102
103
|
onProgress: (message) => console.error(`\xB7 ${message}`)
|
package/dist/confidence.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import type { HeapSample } from "./control-server.js";
|
|
1
2
|
import type { LoadOutcome, SettleOutcome } from "./ritual.js";
|
|
2
|
-
import type
|
|
3
|
+
import { type TrendResult, type TrendVerdict } from "./trend.js";
|
|
3
4
|
/**
|
|
4
5
|
* Why a measurement may not support its own verdict.
|
|
5
6
|
*
|
|
@@ -11,8 +12,10 @@ import type { TrendResult, TrendVerdict } from "./trend.js";
|
|
|
11
12
|
* mid-stream teardown path was never reached
|
|
12
13
|
* `spiky-growth` — one cycle dominates, so the mean describes little
|
|
13
14
|
* `near-threshold` — growth barely clears the noise floor
|
|
15
|
+
* `near-heap-ceiling` — the heap approached the cap the process ran under,
|
|
16
|
+
* so the curve was measured against a ceiling instead of running free
|
|
14
17
|
*/
|
|
15
|
-
export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold";
|
|
18
|
+
export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold" | "near-heap-ceiling";
|
|
16
19
|
export type MeasurementWarning = {
|
|
17
20
|
code: WarningCode;
|
|
18
21
|
detail: string;
|
|
@@ -36,6 +39,10 @@ export type ConfidenceInput = {
|
|
|
36
39
|
abandonAfterMs?: number;
|
|
37
40
|
/** Threshold the verdict used, for the noise-floor check. */
|
|
38
41
|
minGrowthPerCycle?: number;
|
|
42
|
+
/** Post-GC samples, for the heap-ceiling check. */
|
|
43
|
+
memorySamples?: readonly HeapSample[];
|
|
44
|
+
/** Old-space cap the measured process ran under (MB). */
|
|
45
|
+
maxOldSpaceMb?: number;
|
|
39
46
|
};
|
|
40
47
|
/**
|
|
41
48
|
* The verdict a route's evidence actually supports.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
|
|
2
2
|
import {
|
|
3
3
|
renderHtmlReport
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-SKAPGI62.js";
|
|
5
|
+
import "./chunk-E5ZKAANQ.js";
|
|
6
6
|
import "./chunk-6XYFBOL2.js";
|
|
7
7
|
export {
|
|
8
8
|
renderHtmlReport
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
captureEnvironment,
|
|
14
14
|
checkRuntime,
|
|
15
15
|
classifySource,
|
|
16
|
-
classifyTrend,
|
|
17
16
|
decodeMappings,
|
|
18
17
|
decodeVlqLine,
|
|
19
18
|
diffAgainstBaseline,
|
|
@@ -40,14 +39,16 @@ import {
|
|
|
40
39
|
sourceIndexAt,
|
|
41
40
|
summarizeBaseline,
|
|
42
41
|
validateTarget
|
|
43
|
-
} from "./chunk-
|
|
42
|
+
} from "./chunk-HAKKAIHN.js";
|
|
44
43
|
import {
|
|
45
44
|
renderHtmlReport
|
|
46
|
-
} from "./chunk-
|
|
47
|
-
import
|
|
45
|
+
} from "./chunk-SKAPGI62.js";
|
|
46
|
+
import {
|
|
47
|
+
classifyTrend
|
|
48
|
+
} from "./chunk-E5ZKAANQ.js";
|
|
48
49
|
import {
|
|
49
50
|
renderIssueMarkdown
|
|
50
|
-
} from "./chunk-
|
|
51
|
+
} from "./chunk-MYYPTZJW.js";
|
|
51
52
|
import "./chunk-6XYFBOL2.js";
|
|
52
53
|
export {
|
|
53
54
|
LaunchError,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
|
|
2
2
|
import {
|
|
3
3
|
renderIssueMarkdown
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-MYYPTZJW.js";
|
|
5
5
|
import "./chunk-6XYFBOL2.js";
|
|
6
6
|
export {
|
|
7
7
|
renderIssueMarkdown
|
package/dist/launcher.d.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Old-space cap for measured processes, when the caller does not pick one.
|
|
3
|
+
*
|
|
4
|
+
* Small on purpose: a leak that would take a production container an hour to
|
|
5
|
+
* kill reaches a 512 MB ceiling in minutes. It is a default, not a constant —
|
|
6
|
+
* an app whose legitimate working set is larger cannot be measured under it,
|
|
7
|
+
* so `--max-old-space` exists and the chosen value is recorded in `run.json`.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_MAX_OLD_SPACE_MB = 512;
|
|
1
10
|
export type LaunchOptions = {
|
|
2
11
|
/** Absolute path to the standalone `server.js` (or any PORT/HOSTNAME-honoring server). */
|
|
3
12
|
serverPath: string;
|
package/dist/ritual.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type RitualOptions = {
|
|
|
17
17
|
connections?: number;
|
|
18
18
|
cycles?: number;
|
|
19
19
|
idleMs?: number;
|
|
20
|
+
/** Old-space cap for the measured process (MB). Default: 512. */
|
|
21
|
+
maxOldSpaceMb?: number;
|
|
20
22
|
/** Headers sent with every request during warm-up and load. */
|
|
21
23
|
headers?: Record<string, string>;
|
|
22
24
|
/** Emulate clients that disconnect before the response arrives. */
|
|
@@ -68,6 +70,12 @@ export type RitualResult = {
|
|
|
68
70
|
afterSnapshot: string;
|
|
69
71
|
trend: TrendResult;
|
|
70
72
|
requestsPerCycle: number;
|
|
73
|
+
/**
|
|
74
|
+
* The gate (bytes per cycle) this verdict was judged against. Travels with
|
|
75
|
+
* the result so the confidence audit grades against the same number the
|
|
76
|
+
* verdict used, and so the report can print what it measured against.
|
|
77
|
+
*/
|
|
78
|
+
minGrowthPerCycle: number;
|
|
71
79
|
};
|
|
72
80
|
/** Injectable seams for unit tests; production uses the real implementations. */
|
|
73
81
|
export type RitualDeps = {
|
|
@@ -75,12 +83,23 @@ export type RitualDeps = {
|
|
|
75
83
|
load: typeof runLoadPhase;
|
|
76
84
|
sleep: (ms: number) => Promise<void>;
|
|
77
85
|
};
|
|
78
|
-
/**
|
|
86
|
+
/**
|
|
87
|
+
* Single source of truth for ritual defaults — reports must echo them.
|
|
88
|
+
*
|
|
89
|
+
* `cycles` is 4 rather than the minimum 3 because of how much the verdict
|
|
90
|
+
* actually sees: the sample array is `[baseline, ...cycles]` and
|
|
91
|
+
* `classifyTrend` drops the warm-up delta, leaving `cycles - 1` numbers. At 3
|
|
92
|
+
* cycles that is **two** deltas, and `anyFlatOrDown` needs only one of the two
|
|
93
|
+
* to be non-positive to call a route stable — one noisy cycle flips the
|
|
94
|
+
* verdict. Four cycles give three deltas, which is what the real-app
|
|
95
|
+
* validation ran with. The 3-cycle minimum stays available for users who want
|
|
96
|
+
* a faster, weaker read.
|
|
97
|
+
*/
|
|
79
98
|
export declare const RITUAL_DEFAULTS: {
|
|
80
99
|
readonly warmupRequests: 200;
|
|
81
100
|
readonly loadRequests: 5000;
|
|
82
101
|
readonly connections: 100;
|
|
83
|
-
readonly cycles:
|
|
102
|
+
readonly cycles: 4;
|
|
84
103
|
readonly idleMs: 30000;
|
|
85
104
|
};
|
|
86
105
|
/**
|
package/dist/runner.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { diffSnapshotFiles, type HeapDiff } from "./heap-diff.js";
|
|
|
6
6
|
import { extractModuleRegistry } from "./module-registry.js";
|
|
7
7
|
import { runRitual, type LoadOutcome, type PhaseTiming, type SettleOutcome } from "./ritual.js";
|
|
8
8
|
import { readNextVersion, type MatchedSignature } from "./signatures.js";
|
|
9
|
-
import type
|
|
9
|
+
import { type TrendResult } from "./trend.js";
|
|
10
10
|
export type RouteReport = {
|
|
11
11
|
route: string;
|
|
12
12
|
status: "skipped";
|
|
@@ -61,6 +61,18 @@ export type RunParameters = {
|
|
|
61
61
|
connections: number;
|
|
62
62
|
cycles: number;
|
|
63
63
|
idleMs: number;
|
|
64
|
+
/**
|
|
65
|
+
* Old-space cap of each measured process (MB). Part of the measurement
|
|
66
|
+
* regime: a run near its ceiling is not the same experiment as one with
|
|
67
|
+
* headroom, so it belongs on the record.
|
|
68
|
+
*/
|
|
69
|
+
maxOldSpaceMb: number;
|
|
70
|
+
/**
|
|
71
|
+
* Per-cycle growth gate the verdicts were judged against (bytes). Derived
|
|
72
|
+
* from `loadRequests`; recorded because a verdict whose threshold is not
|
|
73
|
+
* printed cannot be audited or reproduced.
|
|
74
|
+
*/
|
|
75
|
+
minGrowthPerCycle: number;
|
|
64
76
|
};
|
|
65
77
|
export type RunReport = {
|
|
66
78
|
appDir: string;
|
|
@@ -88,6 +100,8 @@ export type RunOptions = {
|
|
|
88
100
|
connections?: number;
|
|
89
101
|
cycles?: number;
|
|
90
102
|
idleMs?: number;
|
|
103
|
+
/** Old-space cap for each measured process (MB). Default 512. */
|
|
104
|
+
maxOldSpaceMb?: number;
|
|
91
105
|
/** Also diff routes with a stable verdict. Default false: diffs are slow. */
|
|
92
106
|
diffAll?: boolean;
|
|
93
107
|
/** Only measure routes matching these templates or prefixes. */
|