next-leak 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -167,10 +167,19 @@ first try. When a run skips a route it prints the same fragment.
167
167
  load-balancer timeouts and bots do. Some leaks only exist on that path
168
168
  (`ServerResponse` retained after an early disconnect; the RSC tee branch in
169
169
  [#94919](https://github.com/vercel/next.js/issues/94919)). The clock starts
170
- at the **first byte of the response**, not at the request under load a
171
- request-relative window cuts before the stream begins and tests a different
172
- path. Small values are the point: `4` means "read the first chunk, then
173
- vanish". Requests abandoned on purpose are not counted as failures.
170
+ at the **first byte of the response**, so the cut lands mid-stream however
171
+ long the route takes to answer. Small values are the point: `4` means "read
172
+ the first chunk, then vanish". Requests abandoned on purpose are not counted
173
+ as failures.
174
+ - **`abandonFrom`** moves that clock to the request itself
175
+ (`"abandonFrom": "request"`). Use it for the opposite experiment: a client
176
+ that is already gone before the server produces anything. On a route slower
177
+ than the deadline the default never cuts early — against the reproduction
178
+ for [#84648](https://github.com/vercel/next.js/issues/84648), whose upstream
179
+ answers in 400 ms while its load generator cuts at 60 ms, first-byte reached
180
+ 7% of requests and `request` reached all of them. It stays opt-in because a
181
+ deadline armed on connect loses the race to the first byte on fast routes,
182
+ which measures the wrong path silently.
174
183
 
175
184
  `run.json` records what every load phase actually did — requests sent,
176
185
  2xx, abandoned — so a run can be audited instead of trusted.
@@ -1,9 +1,17 @@
1
+ /**
2
+ * Where the abandon deadline starts. `first-byte` puts the cut mid-stream
3
+ * whatever the route's latency; `request` puts it before the response exists,
4
+ * which is a different leak path and not reachable from the other origin.
5
+ */
6
+ export type AbandonOrigin = "first-byte" | "request";
1
7
  export type AbandonPhaseOptions = {
2
8
  url: string;
3
9
  amount: number;
4
10
  connections: number;
5
- /** Destroy the socket this many ms after the first byte of the response. */
11
+ /** Destroy the socket this many ms after the origin below. */
6
12
  abandonAfterMs: number;
13
+ /** Defaults to `first-byte`. */
14
+ abandonFrom?: AbandonOrigin;
7
15
  headers?: Record<string, string>;
8
16
  };
9
17
  export type AbandonPhaseResult = {
@@ -30,6 +38,8 @@ export type AbandonPhaseResult = {
30
38
  * a client goes away mid-flight (closed tabs, load-balancer timeouts, bots).
31
39
  *
32
40
  * Raw sockets keep this honest: write the request, wait for the response to
33
- * start, then wait `abandonAfterMs` and destroy the socket mid-stream.
41
+ * start, then wait `abandonAfterMs` and destroy the socket mid-stream. With
42
+ * `abandonFrom: "request"` the wait starts at the write instead, which is the
43
+ * only way to cut a route that has not begun answering yet.
34
44
  */
35
45
  export declare function runAbandonPhase(options: AbandonPhaseOptions): Promise<AbandonPhaseResult>;
@@ -7,7 +7,7 @@ import {
7
7
  minGrowthFor,
8
8
  resolveCycles,
9
9
  warrantsIssueDraft
10
- } from "./chunk-22PBLCGO.js";
10
+ } from "./chunk-EJ26OBZI.js";
11
11
  import {
12
12
  assessPeakPressure,
13
13
  describePeakPressure
@@ -92624,8 +92624,28 @@ var routeConfigSchema = z3.object({
92624
92624
  * window, so a request-relative clock cuts before the stream starts and
92625
92625
  * tests the wrong path entirely.
92626
92626
  */
92627
- abandonAfterMs: z3.number().int().positive().optional()
92628
- }).strict();
92627
+ abandonAfterMs: z3.number().int().positive().optional(),
92628
+ /**
92629
+ * Where the abandon deadline starts. `first-byte` is the default and the
92630
+ * semantics everything else assumes.
92631
+ *
92632
+ * `request` starts it when the request is written, which is the only way
92633
+ * to reach leaks that need the client already gone before the server
92634
+ * produces anything: against a route whose time-to-first-byte exceeds the
92635
+ * deadline, a first-byte clock never cuts early. Measured on the
92636
+ * vercel/next.js#84648 repro, whose upstream answers in 400 ms while its
92637
+ * own load generator cuts at 60 ms — the first-byte clock reached 7% of
92638
+ * requests there, none of them on the path the issue describes.
92639
+ *
92640
+ * Opt-in per route, never a default: armed on connect, the deadline loses
92641
+ * the race to the first byte on fast routes, which is how the earlier
92642
+ * version of this measured 2 mid-stream cuts out of ~1500 on #94919.
92643
+ */
92644
+ abandonFrom: z3.enum(["first-byte", "request"]).optional()
92645
+ }).strict().refine((config) => config.abandonFrom === void 0 || config.abandonAfterMs !== void 0, {
92646
+ message: '"abandonFrom" configures nothing without "abandonAfterMs"',
92647
+ path: ["abandonFrom"]
92648
+ });
92629
92649
  var RouteConfigError = class extends Error {
92630
92650
  constructor(message) {
92631
92651
  super(message);
@@ -92846,6 +92866,14 @@ function revalidationLines(route) {
92846
92866
  ` driven through ISR revalidation (revalidates every ${route.revalidatedEverySeconds}s; without it the load would serve the cache)`
92847
92867
  ];
92848
92868
  }
92869
+ function abandonLines(route) {
92870
+ if (route.abandon === void 0) {
92871
+ return [];
92872
+ }
92873
+ return [
92874
+ route.abandon.from === "request" ? ` cut ${route.abandon.afterMs}ms after the request was sent, so the client is gone before the response starts` : ` cut ${route.abandon.afterMs}ms after the first byte, so the cut lands mid-stream`
92875
+ ];
92876
+ }
92849
92877
  function confidenceLines(route) {
92850
92878
  const lines = [];
92851
92879
  if (route.confidence.supersededVerdict !== void 0) {
@@ -92939,6 +92967,7 @@ function routeLines(route, parameters) {
92939
92967
  route.growthPer1000Requests
92940
92968
  )}) heap ${curve}${resolved}`,
92941
92969
  ...revalidationLines(route),
92970
+ ...abandonLines(route),
92942
92971
  ...confidenceLines(route),
92943
92972
  ...memorySourceLines(route, verdict),
92944
92973
  ...peakPressureLines(route, parameters),
@@ -93735,6 +93764,7 @@ var FIRST_BYTE_BUDGET_MS = 5e3;
93735
93764
  async function runAbandonPhase(options) {
93736
93765
  const target = new URL(options.url);
93737
93766
  const port = Number(target.port || 80);
93767
+ const fromRequest = options.abandonFrom === "request";
93738
93768
  const headerLines = Object.entries(options.headers ?? {}).map(([name, value]) => `${name}: ${value}\r
93739
93769
  `).join("");
93740
93770
  const request2 = `GET ${target.pathname}${target.search} HTTP/1.1\r
@@ -93779,7 +93809,10 @@ Host: ${target.host}\r
93779
93809
  socket.once("connect", () => {
93780
93810
  result.sent += 1;
93781
93811
  socket.write(request2);
93782
- timer = setTimeout(giveUp, FIRST_BYTE_BUDGET_MS);
93812
+ timer = setTimeout(
93813
+ giveUp,
93814
+ fromRequest ? options.abandonAfterMs : FIRST_BYTE_BUDGET_MS
93815
+ );
93783
93816
  timer.unref();
93784
93817
  });
93785
93818
  socket.on("data", () => {
@@ -93787,6 +93820,9 @@ Host: ${target.host}\r
93787
93820
  return;
93788
93821
  }
93789
93822
  responseStarted = true;
93823
+ if (fromRequest) {
93824
+ return;
93825
+ }
93790
93826
  clearTimeout(timer);
93791
93827
  timer = setTimeout(giveUp, options.abandonAfterMs);
93792
93828
  timer.unref();
@@ -93949,6 +93985,7 @@ async function runRitual(options, deps = defaultDeps) {
93949
93985
  amount,
93950
93986
  connections,
93951
93987
  abandonAfterMs,
93988
+ ...options.abandonFrom !== void 0 && { abandonFrom: options.abandonFrom },
93952
93989
  ...options.headers !== void 0 && { headers: options.headers }
93953
93990
  });
93954
93991
  loadOutcomes.push({
@@ -94330,6 +94367,9 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94330
94367
  ...headers !== void 0 && { headers },
94331
94368
  ...routeConfig.abandonAfterMs !== void 0 && {
94332
94369
  abandonAfterMs: routeConfig.abandonAfterMs
94370
+ },
94371
+ ...routeConfig.abandonFrom !== void 0 && {
94372
+ abandonFrom: routeConfig.abandonFrom
94333
94373
  }
94334
94374
  });
94335
94375
  const confidence = assessConfidence({
@@ -94344,6 +94384,9 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94344
94384
  warmupRequests: options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests,
94345
94385
  ...routeConfig.abandonAfterMs !== void 0 && {
94346
94386
  abandonAfterMs: routeConfig.abandonAfterMs
94387
+ },
94388
+ ...routeConfig.abandonFrom !== void 0 && {
94389
+ abandonFrom: routeConfig.abandonFrom
94347
94390
  }
94348
94391
  });
94349
94392
  const verdict = confidence.supersededVerdict ?? result.trend.verdict;
@@ -94374,6 +94417,12 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94374
94417
  unreclaimedSamples: result.unreclaimedSamples,
94375
94418
  unreclaimedTrend: result.unreclaimedTrend,
94376
94419
  requestsPerCycle: result.requestsPerCycle,
94420
+ ...routeConfig.abandonAfterMs !== void 0 && {
94421
+ abandon: {
94422
+ afterMs: routeConfig.abandonAfterMs,
94423
+ from: routeConfig.abandonFrom ?? "first-byte"
94424
+ }
94425
+ },
94377
94426
  timings: result.timings,
94378
94427
  loadOutcomes: result.loadOutcomes,
94379
94428
  settleOutcomes: result.settleOutcomes,
@@ -94389,7 +94438,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
94389
94438
  };
94390
94439
  }
94391
94440
  async function writeEvidenceBundle(report, workDir) {
94392
- const { renderHtmlReport } = await import("./html-report-2UAOOZQO.js");
94441
+ const { renderHtmlReport } = await import("./html-report-TN7PYVU4.js");
94393
94442
  const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
94394
94443
  for (const route of report.routes) {
94395
94444
  if (route.status === "measured" && warrantsIssueDraft(route)) {
@@ -113,7 +113,7 @@ function settleWarnings(outcomes) {
113
113
  }
114
114
  return warnings;
115
115
  }
116
- function abandonmentWarnings(outcome) {
116
+ function abandonmentWarnings(outcome, abandonFrom) {
117
117
  const abandoned = outcome.abandoned ?? 0;
118
118
  if (outcome.sent > 0 && abandoned < outcome.sent * ABANDON_EFFECTIVE_FLOOR) {
119
119
  return [{
@@ -121,6 +121,9 @@ function abandonmentWarnings(outcome) {
121
121
  detail: `${outcome.phase} disconnected early on only ${abandoned} of ${outcome.sent} requests (${pct(abandoned, outcome.sent)}) \u2014 the early-disconnect path was largely not exercised`
122
122
  }];
123
123
  }
124
+ if (abandonFrom === "request") {
125
+ return [];
126
+ }
124
127
  const midStream = outcome.abandonedMidStream ?? 0;
125
128
  if (abandoned > 0 && midStream < abandoned * MID_STREAM_FLOOR) {
126
129
  return [{
@@ -130,11 +133,11 @@ function abandonmentWarnings(outcome) {
130
133
  }
131
134
  return [];
132
135
  }
133
- function loadWarnings(outcomes, abandonAfterMs) {
136
+ function loadWarnings(outcomes, abandonAfterMs, abandonFrom) {
134
137
  const warnings = [];
135
138
  for (const outcome of outcomes) {
136
139
  if (abandonAfterMs !== void 0) {
137
- warnings.push(...abandonmentWarnings(outcome));
140
+ warnings.push(...abandonmentWarnings(outcome, abandonFrom));
138
141
  continue;
139
142
  }
140
143
  const landed = outcome.ok2xx ?? 0;
@@ -246,7 +249,7 @@ function assessConfidence(input) {
246
249
  const minGrowth = input.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR;
247
250
  const warnings = [
248
251
  ...settleWarnings(input.settleOutcomes),
249
- ...loadWarnings(input.loadOutcomes, input.abandonAfterMs),
252
+ ...loadWarnings(input.loadOutcomes, input.abandonAfterMs, input.abandonFrom),
250
253
  ...growthShapeWarnings(input.trend),
251
254
  ...noiseFloorWarnings(input.trend, minGrowth),
252
255
  ...thinEvidenceWarnings(input.trend, minGrowth),
@@ -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-22PBLCGO.js";
4
+ } from "./chunk-EJ26OBZI.js";
5
5
  import {
6
6
  assessPeakPressure,
7
7
  describePeakPressure
package/dist/cli.js CHANGED
@@ -16,10 +16,10 @@ import {
16
16
  runMeasurement,
17
17
  unregisterChild,
18
18
  validateTarget
19
- } from "./chunk-AMSDGT3Y.js";
19
+ } from "./chunk-BXDUPN6Z.js";
20
20
  import {
21
21
  classifyTrend
22
- } from "./chunk-22PBLCGO.js";
22
+ } from "./chunk-EJ26OBZI.js";
23
23
  import "./chunk-XHPUAMJG.js";
24
24
  import "./chunk-6XYFBOL2.js";
25
25
 
@@ -1,3 +1,4 @@
1
+ import type { AbandonOrigin } from "./abandon-load.js";
1
2
  import type { HeapSample } from "./control-server.js";
2
3
  import type { LoadOutcome, SettleOutcome } from "./ritual.js";
3
4
  import { type TrendResult, type TrendVerdict } from "./trend.js";
@@ -41,6 +42,8 @@ export type ConfidenceInput = {
41
42
  settleOutcomes: readonly SettleOutcome[];
42
43
  /** Set when the run asked for early disconnects. */
43
44
  abandonAfterMs?: number;
45
+ /** Which deadline origin those disconnects used. Defaults to `first-byte`. */
46
+ abandonFrom?: AbandonOrigin;
44
47
  /** Threshold the verdict used, for the noise-floor check. */
45
48
  minGrowthPerCycle?: number;
46
49
  /** Post-GC samples, for the heap-ceiling check. */
@@ -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-6CJU5TVV.js";
5
- import "./chunk-22PBLCGO.js";
4
+ } from "./chunk-RS3AO7HB.js";
5
+ import "./chunk-EJ26OBZI.js";
6
6
  import "./chunk-XHPUAMJG.js";
7
7
  import "./chunk-6XYFBOL2.js";
8
8
  export {
package/dist/index.js CHANGED
@@ -39,13 +39,13 @@ import {
39
39
  sourceIndexAt,
40
40
  summarizeBaseline,
41
41
  validateTarget
42
- } from "./chunk-AMSDGT3Y.js";
42
+ } from "./chunk-BXDUPN6Z.js";
43
43
  import {
44
44
  renderHtmlReport
45
- } from "./chunk-6CJU5TVV.js";
45
+ } from "./chunk-RS3AO7HB.js";
46
46
  import {
47
47
  classifyTrend
48
- } from "./chunk-22PBLCGO.js";
48
+ } from "./chunk-EJ26OBZI.js";
49
49
  import {
50
50
  renderIssueMarkdown
51
51
  } from "./chunk-WXAFXVWS.js";
package/dist/ritual.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { HeapSample } from "./control-server.js";
2
2
  import { launchInstrumented } from "./launcher.js";
3
- import { runAbandonPhase } from "./abandon-load.js";
3
+ import { runAbandonPhase, type AbandonOrigin } from "./abandon-load.js";
4
4
  import { runLoadPhase } from "./load.js";
5
5
  import { type TrendResult } from "./trend.js";
6
6
  export type RitualOptions = {
@@ -24,6 +24,8 @@ export type RitualOptions = {
24
24
  headers?: Record<string, string>;
25
25
  /** Emulate clients that disconnect before the response arrives. */
26
26
  abandonAfterMs?: number;
27
+ /** Where that deadline starts. Defaults to `first-byte`. */
28
+ abandonFrom?: AbandonOrigin;
27
29
  };
28
30
  export type PhaseTiming = {
29
31
  phase: string;
@@ -5,6 +5,10 @@ declare const routeConfigSchema: z.ZodObject<{
5
5
  headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
6
6
  query: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
7
7
  abandonAfterMs: z.ZodOptional<z.ZodNumber>;
8
+ abandonFrom: z.ZodOptional<z.ZodEnum<{
9
+ "first-byte": "first-byte";
10
+ request: "request";
11
+ }>>;
8
12
  }, z.core.$strict>;
9
13
  export type RouteConfig = z.infer<typeof routeConfigSchema>;
10
14
  export declare class RouteConfigError extends Error {
package/dist/runner.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AbandonOrigin } from "./abandon-load.js";
1
2
  import { type AttributedDiff } from "./attribution.js";
2
3
  import { type ConfidenceReport } from "./confidence.js";
3
4
  import type { HeapSample } from "./control-server.js";
@@ -56,6 +57,16 @@ export type RouteReport = {
56
57
  unreclaimedTrend: TrendResult;
57
58
  /** Requests each cycle served — what the growth rates normalize by. */
58
59
  requestsPerCycle: number;
60
+ /**
61
+ * The early-disconnect regime, when the route asked for one. Recorded
62
+ * because a curve measured with cuts landing mid-stream and one measured
63
+ * with cuts landing before the response are different experiments, and
64
+ * the counters alone do not say which was intended.
65
+ */
66
+ abandon?: {
67
+ afterMs: number;
68
+ from: AbandonOrigin;
69
+ };
59
70
  /**
60
71
  * Distinct keys the load cycled through, when the route asked for a
61
72
  * bounded set. A verdict about a cache depends on how many keys it saw,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "next-leak",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Find out whether your Next.js app actually leaks memory — how much, on which route, and whose fault it is.",
5
5
  "keywords": [
6
6
  "nextjs",
@@ -49,7 +49,11 @@
49
49
  "overrides": {
50
50
  "uuid": ">=11.1.1",
51
51
  "esbuild": ">=0.28.1",
52
- "qs": ">=6.15.2"
52
+ "qs": ">=6.15.2",
53
+ "js-yaml": ">=4.3.1 <5",
54
+ "fast-uri": ">=3.1.5 <4",
55
+ "ip-address": ">=10.3.1 <11",
56
+ "postcss": ">=8.5.23 <9"
53
57
  }
54
58
  },
55
59
  "devDependencies": {