next-leak 0.1.2 → 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/dist/bootstrap.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
2
- import "./chunk-PF7KYD5N.js";
2
+ import "./chunk-6XYFBOL2.js";
3
3
 
4
4
  // src/bootstrap.ts
5
5
  import { mkdir, writeFile } from "fs/promises";
@@ -11,11 +11,20 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
11
11
  if (typeof require !== "undefined") return require.apply(this, arguments);
12
12
  throw Error('Dynamic require of "' + x + '" is not supported');
13
13
  });
14
- var __esm = (fn, res) => function __init() {
15
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
14
+ var __esm = (fn, res, err) => function __init() {
15
+ if (err) throw err[0];
16
+ try {
17
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
18
+ } catch (e) {
19
+ throw err = [e], e;
20
+ }
16
21
  };
17
22
  var __commonJS = (cb, mod) => function __require2() {
18
- return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
23
+ try {
24
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
25
+ } catch (e) {
26
+ throw mod = 0, e;
27
+ }
19
28
  };
20
29
  var __export = (target, all) => {
21
30
  for (var name in all)
@@ -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,
@@ -11,7 +14,7 @@ import {
11
14
  __require,
12
15
  __toCommonJS,
13
16
  __toESM
14
- } from "./chunk-PF7KYD5N.js";
17
+ } from "./chunk-6XYFBOL2.js";
15
18
 
16
19
  // node_modules/.pnpm/fs-extra@4.0.3/node_modules/fs-extra/lib/util/assign.js
17
20
  var require_assign = __commonJS({
@@ -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,11 +92432,14 @@ function formatReport(report) {
92475
92432
  for (const route of report.routes) {
92476
92433
  lines.push(...routeLines(route));
92477
92434
  }
92478
- lines.push("", `snapshots and run.json: ${report.workDir}`);
92479
- lines.push(`report: ${report.bundle.htmlReport}`);
92480
- for (const issue of report.bundle.issues) {
92481
- lines.push(`issue draft (${issue.route}): ${issue.file}`);
92482
- }
92435
+ const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
92436
+ lines.push(
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`,
92439
+ `snapshots and run.json: ${report.workDir}`,
92440
+ `report: ${report.bundle.htmlReport}`,
92441
+ ...report.bundle.issues.map((issue) => `issue draft (${issue.route}): ${issue.file}`)
92442
+ );
92483
92443
  const inconclusive = report.routes.filter(
92484
92444
  (route) => route.status === "measured" && effectiveVerdict(route) === "inconclusive"
92485
92445
  );
@@ -92582,7 +92542,7 @@ function classifySource(rawSource) {
92582
92542
  }
92583
92543
  const nodeModulesSplit = source.split("/node_modules/");
92584
92544
  if (nodeModulesSplit.length > 1) {
92585
- const tail = nodeModulesSplit[nodeModulesSplit.length - 1] ?? "";
92545
+ const tail = nodeModulesSplit.at(-1) ?? "";
92586
92546
  const segments = tail.split("/");
92587
92547
  const packageName = (segments[0]?.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0]) ?? null;
92588
92548
  if (packageName === "next" || packageName === "react" || packageName === "react-dom") {
@@ -92890,6 +92850,16 @@ var routesManifestSchema = z3.looseObject({
92890
92850
  });
92891
92851
  var INTERNAL_PATHS = /* @__PURE__ */ new Set(["/_global-error", "/_not-found"]);
92892
92852
  var INTERCEPTING_SEGMENT = /^(\(\.{1,3}\))+/;
92853
+ var STATIC_ASSET_PATHS = /* @__PURE__ */ new Set(["/favicon.ico"]);
92854
+ function unaddressableReason(routePath) {
92855
+ if (routePath.split("/").some((segment) => INTERCEPTING_SEGMENT.test(segment))) {
92856
+ return "intercepting route \u2014 only reachable via client navigation";
92857
+ }
92858
+ if (STATIC_ASSET_PATHS.has(routePath)) {
92859
+ return "static asset served by a generated handler \u2014 no user code to leak";
92860
+ }
92861
+ return void 0;
92862
+ }
92893
92863
  function toRequestPath(manifestKey) {
92894
92864
  let kind;
92895
92865
  let withoutSuffix;
@@ -92927,14 +92897,12 @@ function discoverRoutes(appPaths) {
92927
92897
  if (parsed === null || INTERNAL_PATHS.has(parsed.path) || routes.has(parsed.path)) {
92928
92898
  continue;
92929
92899
  }
92930
- const intercepting = parsed.path.split("/").some((segment) => INTERCEPTING_SEGMENT.test(segment));
92900
+ const reason = unaddressableReason(parsed.path);
92931
92901
  routes.set(parsed.path, {
92932
92902
  path: parsed.path,
92933
92903
  kind: parsed.kind,
92934
92904
  dynamic: parsed.path.includes("["),
92935
- ...intercepting && {
92936
- unaddressableReason: "intercepting route \u2014 only reachable via client navigation"
92937
- }
92905
+ ...reason !== void 0 && { unaddressableReason: reason }
92938
92906
  });
92939
92907
  }
92940
92908
  return [...routes.values()].sort((a, b) => a.path.localeCompare(b.path));
@@ -93283,55 +93251,55 @@ Host: ${target.host}\r
93283
93251
  errors: 0
93284
93252
  };
93285
93253
  let remaining = options.amount;
93254
+ const sendOne = () => new Promise((resolve) => {
93255
+ const socket = net.connect({ host: target.hostname, port });
93256
+ let settled = false;
93257
+ let responseStarted = false;
93258
+ let timer;
93259
+ const finish = () => {
93260
+ if (settled) {
93261
+ return;
93262
+ }
93263
+ settled = true;
93264
+ clearTimeout(timer);
93265
+ socket.destroy();
93266
+ resolve();
93267
+ };
93268
+ const giveUp = () => {
93269
+ if (!settled) {
93270
+ result.abandoned += 1;
93271
+ if (responseStarted) {
93272
+ result.abandonedMidStream += 1;
93273
+ }
93274
+ }
93275
+ finish();
93276
+ };
93277
+ socket.once("connect", () => {
93278
+ result.sent += 1;
93279
+ socket.write(request2);
93280
+ timer = setTimeout(giveUp, options.abandonAfterMs);
93281
+ timer.unref();
93282
+ });
93283
+ socket.on("data", () => {
93284
+ responseStarted = true;
93285
+ });
93286
+ socket.once("end", () => {
93287
+ if (!settled) {
93288
+ result.completed += 1;
93289
+ }
93290
+ finish();
93291
+ });
93292
+ socket.once("error", () => {
93293
+ if (!settled) {
93294
+ result.errors += 1;
93295
+ }
93296
+ finish();
93297
+ });
93298
+ });
93286
93299
  const worker = async () => {
93287
93300
  while (remaining > 0) {
93288
93301
  remaining -= 1;
93289
- await new Promise((resolve) => {
93290
- const socket = net.connect({ host: target.hostname, port });
93291
- let settled = false;
93292
- let responseStarted = false;
93293
- let timer;
93294
- const finish = () => {
93295
- if (settled) {
93296
- return;
93297
- }
93298
- settled = true;
93299
- clearTimeout(timer);
93300
- socket.destroy();
93301
- resolve();
93302
- };
93303
- socket.once("connect", () => {
93304
- result.sent += 1;
93305
- socket.write(request2);
93306
- timer = setTimeout(() => {
93307
- if (!settled) {
93308
- result.abandoned += 1;
93309
- if (responseStarted) {
93310
- result.abandonedMidStream += 1;
93311
- }
93312
- }
93313
- finish();
93314
- }, options.abandonAfterMs);
93315
- timer.unref();
93316
- });
93317
- socket.on("data", () => {
93318
- responseStarted = true;
93319
- });
93320
- socket.once("end", () => {
93321
- if (!settled) {
93322
- result.completed += 1;
93323
- }
93324
- clearTimeout(timer);
93325
- finish();
93326
- });
93327
- socket.once("error", () => {
93328
- if (!settled) {
93329
- result.errors += 1;
93330
- }
93331
- clearTimeout(timer);
93332
- finish();
93333
- });
93334
- });
93302
+ await sendOne();
93335
93303
  }
93336
93304
  };
93337
93305
  await Promise.all(
@@ -93373,7 +93341,7 @@ var RITUAL_DEFAULTS = {
93373
93341
  warmupRequests: 200,
93374
93342
  loadRequests: 5e3,
93375
93343
  connections: 100,
93376
- cycles: 3,
93344
+ cycles: 4,
93377
93345
  idleMs: 3e4
93378
93346
  };
93379
93347
  async function runRitual(options, deps = defaultDeps) {
@@ -93386,24 +93354,20 @@ async function runRitual(options, deps = defaultDeps) {
93386
93354
  throw new Error("the trend verdict needs at least 3 cycles");
93387
93355
  }
93388
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
+ };
93389
93363
  let app;
93390
93364
  try {
93391
- app = await deps.launch({
93392
- serverPath: options.serverPath,
93393
- workDir: options.workDir,
93394
- appPort: options.appPort,
93395
- bootstrapPath: options.bootstrapPath
93396
- });
93365
+ app = await deps.launch({ ...launchOptions, appPort: options.appPort });
93397
93366
  } catch (cause) {
93398
93367
  if (!String(cause).includes("EADDRINUSE")) {
93399
93368
  throw cause;
93400
93369
  }
93401
- app = await deps.launch({
93402
- serverPath: options.serverPath,
93403
- workDir: options.workDir,
93404
- appPort: options.appPort + 1,
93405
- bootstrapPath: options.bootstrapPath
93406
- });
93370
+ app = await deps.launch({ ...launchOptions, appPort: options.appPort + 1 });
93407
93371
  }
93408
93372
  const timings = [];
93409
93373
  const loadOutcomes = [];
@@ -93488,6 +93452,7 @@ async function runRitual(options, deps = defaultDeps) {
93488
93452
  }
93489
93453
  const samples = memorySamples.map((sample) => sample.heapUsed);
93490
93454
  const externalSamples = memorySamples.map((sample) => sample.external);
93455
+ const minGrowthPerCycle = minGrowthFor(loadRequests);
93491
93456
  return {
93492
93457
  route: options.route,
93493
93458
  timings,
@@ -93497,8 +93462,9 @@ async function runRitual(options, deps = defaultDeps) {
93497
93462
  memorySamples,
93498
93463
  baselineSnapshot: baseline.file,
93499
93464
  afterSnapshot,
93500
- trend: classifyMemoryTrend(samples, externalSamples),
93501
- requestsPerCycle: loadRequests
93465
+ trend: classifyMemoryTrend(samples, externalSamples, { minGrowthPerCycle }),
93466
+ requestsPerCycle: loadRequests,
93467
+ minGrowthPerCycle
93502
93468
  };
93503
93469
  } finally {
93504
93470
  await app.close();
@@ -93682,9 +93648,20 @@ import { createServer } from "net";
93682
93648
  import path6 from "path";
93683
93649
  var ESTIMATED_RPS = 250;
93684
93650
  var PER_ROUTE_OVERHEAD_SECONDS = 10;
93685
- function estimateRunSeconds(routeCount, parameters) {
93686
- const perRoute = parameters.warmupRequests / ESTIMATED_RPS + parameters.cycles * (parameters.loadRequests / ESTIMATED_RPS + parameters.idleMs / 1e3) + PER_ROUTE_OVERHEAD_SECONDS;
93687
- return Math.round(routeCount * perRoute);
93651
+ var MIN_SETTLE_SECONDS = 4;
93652
+ function estimateRun(routeCount, parameters) {
93653
+ const settleSeconds = Math.min(MIN_SETTLE_SECONDS, parameters.idleMs / 1e3);
93654
+ const slowPerRoute = parameters.warmupRequests / ESTIMATED_RPS + parameters.cycles * (parameters.loadRequests / ESTIMATED_RPS + parameters.idleMs / 1e3) + PER_ROUTE_OVERHEAD_SECONDS;
93655
+ const fastPerRoute = PER_ROUTE_OVERHEAD_SECONDS + parameters.cycles * settleSeconds;
93656
+ return {
93657
+ fastSeconds: Math.round(routeCount * fastPerRoute),
93658
+ slowSeconds: Math.round(routeCount * slowPerRoute)
93659
+ };
93660
+ }
93661
+ function formatEstimate(estimate) {
93662
+ const fast = formatDuration(estimate.fastSeconds);
93663
+ const slow = formatDuration(estimate.slowSeconds);
93664
+ return fast === slow ? `\u2248 ${slow}` : `\u2248 ${fast}\u2013${slow}`;
93688
93665
  }
93689
93666
  function formatDuration(seconds) {
93690
93667
  if (seconds < 90) {
@@ -93732,7 +93709,7 @@ function rssTrend(memorySamples) {
93732
93709
  return deltas.reduce((sum, delta) => sum + delta, 0) / deltas.length;
93733
93710
  }
93734
93711
  function routeSlug(route) {
93735
- const sanitized = route.replaceAll(/[^a-zA-Z0-9-]+/g, "_").replace(/^_+/, "").replace(/_+$/, "");
93712
+ const sanitized = route.replaceAll(/[^a-zA-Z0-9-]+/g, "_").replace(/^_/, "").replace(/_$/, "");
93736
93713
  if (route === "/") {
93737
93714
  return "root";
93738
93715
  }
@@ -93742,9 +93719,16 @@ function routeSlug(route) {
93742
93719
  const digest = createHash3("sha1").update(route).digest("hex").slice(0, 6);
93743
93720
  return sanitized === "" ? `route-${digest}` : `${sanitized}-${digest}`;
93744
93721
  }
93722
+ function trimTrailingSlashes(value) {
93723
+ let end = value.length;
93724
+ while (end > 0 && value[end - 1] === "/") {
93725
+ end -= 1;
93726
+ }
93727
+ return value.slice(0, end);
93728
+ }
93745
93729
  function filterRoutes(routes, selectors, progress) {
93746
93730
  const matches = (routePath, selector) => {
93747
- const normalized = selector.replace(/[/]+$/, "");
93731
+ const normalized = trimTrailingSlashes(selector);
93748
93732
  if (normalized === "") {
93749
93733
  return routePath === "/";
93750
93734
  }
@@ -93779,6 +93763,7 @@ async function measureRoute(context, route, requestPath, index) {
93779
93763
  ...options.connections !== void 0 && { connections: options.connections },
93780
93764
  ...options.cycles !== void 0 && { cycles: options.cycles },
93781
93765
  ...options.idleMs !== void 0 && { idleMs: options.idleMs },
93766
+ ...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb },
93782
93767
  ...routeConfig.headers !== void 0 && { headers: routeConfig.headers },
93783
93768
  ...routeConfig.abandonAfterMs !== void 0 && {
93784
93769
  abandonAfterMs: routeConfig.abandonAfterMs
@@ -93788,6 +93773,11 @@ async function measureRoute(context, route, requestPath, index) {
93788
93773
  trend: result.trend,
93789
93774
  loadOutcomes: result.loadOutcomes,
93790
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,
93791
93781
  ...routeConfig.abandonAfterMs !== void 0 && {
93792
93782
  abandonAfterMs: routeConfig.abandonAfterMs
93793
93783
  }
@@ -93822,8 +93812,8 @@ async function measureRoute(context, route, requestPath, index) {
93822
93812
  };
93823
93813
  }
93824
93814
  async function writeEvidenceBundle(report, workDir) {
93825
- const { renderHtmlReport } = await import("./html-report-I4PNSLKY.js");
93826
- const { renderIssueMarkdown } = await import("./issue-report-VQEUXP2E.js");
93815
+ const { renderHtmlReport } = await import("./html-report-2SOTNXDG.js");
93816
+ const { renderIssueMarkdown } = await import("./issue-report-X5JYAVN5.js");
93827
93817
  for (const route of report.routes) {
93828
93818
  if (route.status === "measured" && warrantsIssueDraft(route)) {
93829
93819
  const file = path6.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
@@ -93865,22 +93855,25 @@ async function planRun(options, target, deps, progress) {
93865
93855
  progress(
93866
93856
  `module registry: ${registry.size} modules` + (nextVersion === null ? "" : ` \xB7 next ${nextVersion}`)
93867
93857
  );
93858
+ const loadRequests = options.loadRequests ?? RITUAL_DEFAULTS.loadRequests;
93868
93859
  const parameters = {
93869
93860
  warmupRequests: options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests,
93870
- loadRequests: options.loadRequests ?? RITUAL_DEFAULTS.loadRequests,
93861
+ loadRequests,
93871
93862
  connections: options.connections ?? RITUAL_DEFAULTS.connections,
93872
93863
  cycles: options.cycles ?? RITUAL_DEFAULTS.cycles,
93873
- 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)
93874
93867
  };
93875
- const estimatedSeconds = estimateRunSeconds(
93876
- routes.filter((route) => resolveRoutePath(route.path, routeConfig) !== null).length,
93877
- parameters
93878
- );
93868
+ const measurable = routes.filter(
93869
+ (route) => skipReason(route, resolveRoutePath(route.path, routeConfig)) === null
93870
+ ).length;
93871
+ const estimate = estimateRun(measurable, parameters);
93879
93872
  progress(
93880
- `${routes.length} routes discovered \xB7 estimated \u2248 ${formatDuration(estimatedSeconds)}` + // Long default runs are where first-time users give up; point at the two
93873
+ `${routes.length} routes discovered` + (measurable === routes.length ? "" : ` \xB7 ${measurable} measurable`) + ` \xB7 estimated ${formatEstimate(estimate)}` + // Long default runs are where first-time users give up; point at the two
93881
93874
  // ways out. Suppressed once load parameters were tuned by hand (or by
93882
93875
  // --quick, which arrives here as explicit loadRequests/idleMs).
93883
- (estimatedSeconds > 15 * 60 && options.loadRequests === void 0 && options.idleMs === void 0 ? " \u2014 use --quick for the fast validated preset, or narrow with --routes" : "")
93876
+ (estimate.slowSeconds > 15 * 60 && options.loadRequests === void 0 && options.idleMs === void 0 ? " \u2014 use --quick for the fast validated preset, or narrow with --routes" : "")
93884
93877
  );
93885
93878
  return { routes, routeConfig, registry, nextVersion, parameters };
93886
93879
  }
@@ -93945,7 +93938,6 @@ export {
93945
93938
  LaunchError,
93946
93939
  killActiveChildren,
93947
93940
  launchInstrumented,
93948
- classifyTrend,
93949
93941
  formatReport,
93950
93942
  RouteConfigError,
93951
93943
  ROUTE_CONFIG_FILE,