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.
@@ -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 ?? DEFAULT_MIN_GROWTH;
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-2BQZCZ4Z.js";
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 3, minimum 3)" },
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 profile used for real-app validation"
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 ?? 512}`,
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: 3,
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-26DU7JFW.js");
93853
- const { renderIssueMarkdown } = await import("./issue-report-FUTEOHN5.js");
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: options.loadRequests ?? RITUAL_DEFAULTS.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=512
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-2BQZCZ4Z.js";
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>`}
@@ -5,6 +5,7 @@ export type CliRunOptions = {
5
5
  requests: number | null;
6
6
  connections: number | null;
7
7
  idleSeconds: number | null;
8
+ maxOldSpaceMb: number | null;
8
9
  quick: boolean;
9
10
  diffAll: boolean;
10
11
  output: string | null;
package/dist/cli.js CHANGED
@@ -9,8 +9,8 @@ import {
9
9
  killActiveChildren,
10
10
  parseCliArgs,
11
11
  runMeasurement
12
- } from "./chunk-ZFYIR423.js";
13
- import "./chunk-2BQZCZ4Z.js";
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}`)
@@ -1,5 +1,6 @@
1
+ import type { HeapSample } from "./control-server.js";
1
2
  import type { LoadOutcome, SettleOutcome } from "./ritual.js";
2
- import type { TrendResult, TrendVerdict } from "./trend.js";
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-C3KH5Z2W.js";
5
- import "./chunk-2BQZCZ4Z.js";
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-ZFYIR423.js";
42
+ } from "./chunk-HAKKAIHN.js";
44
43
  import {
45
44
  renderHtmlReport
46
- } from "./chunk-C3KH5Z2W.js";
47
- import "./chunk-2BQZCZ4Z.js";
45
+ } from "./chunk-SKAPGI62.js";
46
+ import {
47
+ classifyTrend
48
+ } from "./chunk-E5ZKAANQ.js";
48
49
  import {
49
50
  renderIssueMarkdown
50
- } from "./chunk-5ZYZW2BL.js";
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-5ZYZW2BL.js";
4
+ } from "./chunk-MYYPTZJW.js";
5
5
  import "./chunk-6XYFBOL2.js";
6
6
  export {
7
7
  renderIssueMarkdown
@@ -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
- /** Single source of truth for ritual defaults — reports must echo them. */
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: 3;
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 { TrendResult } from "./trend.js";
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. */