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/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-2BQZCZ4Z.js";
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 3, minimum 3)" },
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 profile used for real-app validation"
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 ?? 512}`,
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 routeLines(route) {
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: 3,
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`, () => loadCycle(`cycle ${cycle}`, loadRequests));
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-26DU7JFW.js");
93853
- const { renderIssueMarkdown } = await import("./issue-report-FUTEOHN5.js");
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: options.loadRequests ?? RITUAL_DEFAULTS.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-2BQZCZ4Z.js";
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 measuredSection(route) {
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
- ${measured.map(measuredSection).join("\n")}
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 ?? 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