next-leak 0.3.0 → 0.4.1
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 +26 -9
- package/dist/abandon-load.d.ts +5 -3
- package/dist/{chunk-BUNQT6VK.js → chunk-BTWQYJOW.js} +1 -1
- package/dist/{chunk-E5ZKAANQ.js → chunk-FFCY6POI.js} +28 -3
- package/dist/{chunk-2BQ5KXIJ.js → chunk-OJIXF7RP.js} +61 -10
- package/dist/cli-args.d.ts +1 -0
- package/dist/cli.js +3 -2
- package/dist/confidence.d.ts +2 -1
- package/dist/{html-report-KQGUDYK5.js → html-report-76DP5ADD.js} +2 -2
- package/dist/index.js +3 -3
- package/dist/ritual.d.ts +2 -0
- package/dist/runner.d.ts +15 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ holds it — without being told what to look for.
|
|
|
35
35
|
| [#95094](https://github.com/vercel/next.js/issues/95094) | Middleware `setTimeout` ids retained by the sandbox | **Reproduced** · mechanism named · 112 MB retained |
|
|
36
36
|
| [#94890](https://github.com/vercel/next.js/issues/94890) | Router LRU cache doesn't count its keys | **Reproduced** · 26.7 → 71.9 MB |
|
|
37
37
|
| [#84884](https://github.com/vercel/next.js/issues/84884) | axios + `AbortSignal` in middleware | **Reproduced** · 32.8 → 369.9 MB |
|
|
38
|
-
| [#94919](https://github.com/vercel/next.js/issues/94919) | RSC tree retained on client aborts |
|
|
38
|
+
| [#94919](https://github.com/vercel/next.js/issues/94919) | RSC tree retained on client aborts | **Reproduced** · 39 → 139 MB · [with a caveat](#scope-and-limits-read-before-filing-issues) |
|
|
39
39
|
|
|
40
40
|
The full causal chain, measured on that same issue: leak found (28.7 -> 138.9 MB
|
|
41
41
|
across 8 cycles), the workaround from the thread applied (`clearTimeout(id)`
|
|
@@ -86,8 +86,11 @@ The verdict comes from the **shape of the post-GC curve**: retained heap that
|
|
|
86
86
|
keeps growing every cycle is a leak; growth that flattens is warm-up. Where the
|
|
87
87
|
heap sits is noise — 40 MB and 400 MB say nothing on their own — so only the
|
|
88
88
|
shape is judged. The one absolute number involved is the gate a cycle's growth
|
|
89
|
-
must clear to count, and
|
|
90
|
-
|
|
89
|
+
must clear to count, and above 5000 requests per cycle it scales with the
|
|
90
|
+
traffic that cycle served — so in that range changing `--requests` changes how
|
|
91
|
+
long the run takes and not what it decides. Below 5000 the gate stops shrinking
|
|
92
|
+
and sits on the instrument's noise floor instead, so less traffic really does
|
|
93
|
+
buy a less sensitive run: that is the trade `--quick` makes at 2000 requests.
|
|
91
94
|
Every report prints the gate it used.
|
|
92
95
|
|
|
93
96
|
## Options
|
|
@@ -101,6 +104,7 @@ Every report prints the gate it used.
|
|
|
101
104
|
| `--idle <seconds>` | 30 | **Maximum** wait before each sample; the run continues as soon as the heap settles |
|
|
102
105
|
| `--max-old-space <mb>` | 512 | Heap cap of each measured process. Raise it for apps whose legitimate working set is larger, or they die under measurement |
|
|
103
106
|
| `--quick` | off | Fast preset (2000 requests × 4 cycles, 8s idle) — the exact profile the real-app validation ran with. Same cycle count as the default; what it trades away is traffic per cycle, so it sits on the noise floor and is less sensitive to slow leaks. Explicit flags override it |
|
|
107
|
+
| `--no-resolve` | off | Skip the second pass on inconclusive routes |
|
|
104
108
|
| `--diff-all` | off | Diff snapshots for stable routes too |
|
|
105
109
|
| `--output <dir>` | `<app>/.next-leak` | Where runs are written |
|
|
106
110
|
|
|
@@ -123,10 +127,14 @@ Dynamic routes need sample params in `next-leak.config.json` in your app dir:
|
|
|
123
127
|
without it.
|
|
124
128
|
- **`query`** appends a query string per route template
|
|
125
129
|
(`{ "/api/payload/[slug]": "weightKb=2048" }`).
|
|
126
|
-
- **`abandonAfterMs`** makes clients hang up
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
- **`abandonAfterMs`** makes clients hang up mid-response, the way closed tabs,
|
|
131
|
+
load-balancer timeouts and bots do. Some leaks only exist on that path
|
|
132
|
+
(`ServerResponse` retained after an early disconnect; the RSC tee branch in
|
|
133
|
+
[#94919](https://github.com/vercel/next.js/issues/94919)). The clock starts
|
|
134
|
+
at the **first byte of the response**, not at the request — under load a
|
|
135
|
+
request-relative window cuts before the stream begins and tests a different
|
|
136
|
+
path. Small values are the point: `4` means "read the first chunk, then
|
|
137
|
+
vanish". Requests abandoned on purpose are not counted as failures.
|
|
130
138
|
|
|
131
139
|
`run.json` records what every load phase actually did — requests sent,
|
|
132
140
|
2xx, abandoned — so a run can be audited instead of trusted.
|
|
@@ -155,11 +163,13 @@ separates them, because each one has a different fix:
|
|
|
155
163
|
is deliberately biased toward missing a leak rather than inventing one (a
|
|
156
164
|
single flat or falling cycle is enough to call a route stable), so a leak
|
|
157
165
|
that oscillates while it climbs can land here. To press harder, raise
|
|
158
|
-
`--cycles`
|
|
166
|
+
`--cycles` — every extra cycle is another delta the verdict gets to see.
|
|
167
|
+
Raising `--requests` only helps from below 5000: above that the gate scales
|
|
168
|
+
with the traffic, so the longer run decides the same thing. If the heap is
|
|
159
169
|
flat but RSS keeps climbing, the report says so explicitly: that is an
|
|
160
170
|
allocator, external-buffer or fragmentation problem, not a JS-heap leak.
|
|
161
171
|
- **`leak`** — the report names the culprit when attribution resolves: your file (`culprit: src/app/x/page.tsx (your code)`), a dependency (package name), or framework internals. An `ISSUE-<route>.md` draft is generated; if the leak is app-owned, the draft tells you **not** to file it upstream.
|
|
162
|
-
- **`inconclusive`** —
|
|
172
|
+
- **`inconclusive`** — the evidence does not decide. The run does not stop there: any inconclusive route is **measured again automatically**, with twice the cycles, and the second pass is what you see (`resolved at 8 cycles` next to the verdict). On the reproduction for [#95094](https://github.com/vercel/next.js/issues/95094), `--quick` alone reports `inconclusive` on three deltas and then comes back with the leak. `--no-resolve` turns the second pass off; when even that is undecided, the re-run command is still printed.
|
|
163
173
|
- **`failed`** — the route errored under load (auth redirects, POST-only endpoints). >1% non-2xx aborts measurement instead of measuring garbage. That's by design.
|
|
164
174
|
|
|
165
175
|
## Peak pressure: `stable` is not the same as safe
|
|
@@ -260,6 +270,13 @@ through the build's source maps.
|
|
|
260
270
|
`--max-old-space`, or every route dies as an OOM that is not the app's
|
|
261
271
|
fault. When a run's heap gets close to the cap, the report says so.
|
|
262
272
|
- Borderline routes can flip between `stable`/`leak` across runs — more cycles resolves this.
|
|
273
|
+
- The [#94919](https://github.com/vercel/next.js/issues/94919) reproduction ships
|
|
274
|
+
a **custom Express server and deliberately no standalone output**, which this
|
|
275
|
+
tool cannot measure as published. The figure above comes from the same app
|
|
276
|
+
built with `output: "standalone"` — the leak is there too, but that is Next's
|
|
277
|
+
server under test, not the reporter's middleware chain. Instrumenting their
|
|
278
|
+
own server by hand (same `--import` bootstrap, no CLI) showed the same shape:
|
|
279
|
+
post-GC heap 43 → 56 MB and arrayBuffers 0.2 → 10.7 MB over four cycles.
|
|
263
280
|
- The **peak-pressure** thresholds are calibrated against one reproduction measured in three regimes plus the bundled fixture, not against the ~40-route validation set the verdicts were tuned on. A peak note never changes a verdict, so the cost of a false one is noise, not a false accusation — but treat the exact thresholds as young.
|
|
264
281
|
- The measured app runs with its real environment: routes that call external services will call them under load. Scope with `--routes` and moderate `--requests` accordingly.
|
|
265
282
|
|
package/dist/abandon-load.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type AbandonPhaseOptions = {
|
|
|
2
2
|
url: string;
|
|
3
3
|
amount: number;
|
|
4
4
|
connections: number;
|
|
5
|
-
/** Destroy the socket this many ms after
|
|
5
|
+
/** Destroy the socket this many ms after the first byte of the response. */
|
|
6
6
|
abandonAfterMs: number;
|
|
7
7
|
headers?: Record<string, string>;
|
|
8
8
|
};
|
|
@@ -15,6 +15,8 @@ export type AbandonPhaseResult = {
|
|
|
15
15
|
* different path (the server may never have begun rendering).
|
|
16
16
|
*/
|
|
17
17
|
abandonedMidStream: number;
|
|
18
|
+
/** Abandonments where the first-byte budget expired in silence. */
|
|
19
|
+
abandonedBeforeResponse: number;
|
|
18
20
|
completed: number;
|
|
19
21
|
errors: number;
|
|
20
22
|
};
|
|
@@ -27,7 +29,7 @@ export type AbandonPhaseResult = {
|
|
|
27
29
|
* `ServerResponse` retention to an early disconnect, which only happens when
|
|
28
30
|
* a client goes away mid-flight (closed tabs, load-balancer timeouts, bots).
|
|
29
31
|
*
|
|
30
|
-
* Raw sockets keep this honest: write the request, wait
|
|
31
|
-
* destroy the socket
|
|
32
|
+
* Raw sockets keep this honest: write the request, wait for the response to
|
|
33
|
+
* start, then wait `abandonAfterMs` and destroy the socket mid-stream.
|
|
32
34
|
*/
|
|
33
35
|
export declare function runAbandonPhase(options: AbandonPhaseOptions): Promise<AbandonPhaseResult>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
|
|
2
2
|
import {
|
|
3
3
|
effectiveVerdict
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-FFCY6POI.js";
|
|
5
5
|
import {
|
|
6
6
|
assessPeakPressure,
|
|
7
7
|
describePeakPressure
|
|
@@ -73,7 +73,8 @@ function effectiveVerdict(report) {
|
|
|
73
73
|
}
|
|
74
74
|
var VERDICT_WEAKENING = /* @__PURE__ */ new Set([
|
|
75
75
|
"near-threshold",
|
|
76
|
-
"spiky-growth"
|
|
76
|
+
"spiky-growth",
|
|
77
|
+
"thin-evidence"
|
|
77
78
|
]);
|
|
78
79
|
function warrantsIssueDraft(report) {
|
|
79
80
|
return effectiveVerdict(report) === "leak" && !report.confidence.warnings.some((warning) => VERDICT_WEAKENING.has(warning.code));
|
|
@@ -115,7 +116,7 @@ function abandonmentWarnings(outcome) {
|
|
|
115
116
|
if (abandoned > 0 && midStream < abandoned * MID_STREAM_FLOOR) {
|
|
116
117
|
return [{
|
|
117
118
|
code: "abandon-before-response",
|
|
118
|
-
detail: `${outcome.phase} cut ${abandoned} requests
|
|
119
|
+
detail: `${outcome.phase} cut ${abandoned} requests that never produced a byte (${midStream} mid-stream) \u2014 the route did not start responding, so mid-stream teardown was not exercised; the route is saturated or hung at this load, not mistuned`
|
|
119
120
|
}];
|
|
120
121
|
}
|
|
121
122
|
return [];
|
|
@@ -182,13 +183,36 @@ function heapCeilingWarnings(input) {
|
|
|
182
183
|
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
|
}];
|
|
184
185
|
}
|
|
186
|
+
var THIN_EVIDENCE_MIN_DELTAS = 5;
|
|
187
|
+
var THIN_EVIDENCE_MIN_DELTA_RATIO = 4;
|
|
188
|
+
function isThinEvidence(trend, minGrowth) {
|
|
189
|
+
if (trend.deltas.length >= THIN_EVIDENCE_MIN_DELTAS || trend.deltas.length === 0) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
const smallest = Math.min(...trend.deltas);
|
|
193
|
+
return smallest < minGrowth * THIN_EVIDENCE_MIN_DELTA_RATIO;
|
|
194
|
+
}
|
|
195
|
+
function thinEvidenceWarnings(trend, minGrowth) {
|
|
196
|
+
if (trend.verdict !== "leak" || !isThinEvidence(trend, minGrowth)) {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const smallest = Math.min(...trend.deltas);
|
|
200
|
+
return [{
|
|
201
|
+
code: "thin-evidence",
|
|
202
|
+
detail: `judged on ${trend.deltas.length} cycles, and its weakest grew ${mb(smallest)} against a ${mb(minGrowth)} gate \u2014 on that few cycles ordinary oscillation reaches that, and a repeat run often disagrees; measure more cycles to resolve it`
|
|
203
|
+
}];
|
|
204
|
+
}
|
|
185
205
|
function isVerdictInvalid(input) {
|
|
186
206
|
if (input.trend.verdict !== "leak") {
|
|
187
207
|
return false;
|
|
188
208
|
}
|
|
189
209
|
const neverSettled = input.settleOutcomes.length > 0 && input.settleOutcomes.every((outcome) => outcome.status === "moving");
|
|
190
210
|
const abandonedNothing = input.abandonAfterMs !== void 0 && input.loadOutcomes.length > 0 && input.loadOutcomes.every((outcome) => (outcome.abandoned ?? 0) === 0);
|
|
191
|
-
|
|
211
|
+
const thinEvidence = isThinEvidence(
|
|
212
|
+
input.trend,
|
|
213
|
+
input.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR
|
|
214
|
+
);
|
|
215
|
+
return neverSettled || abandonedNothing || thinEvidence;
|
|
192
216
|
}
|
|
193
217
|
function assessConfidence(input) {
|
|
194
218
|
const minGrowth = input.minGrowthPerCycle ?? MIN_GROWTH_NOISE_FLOOR;
|
|
@@ -197,6 +221,7 @@ function assessConfidence(input) {
|
|
|
197
221
|
...loadWarnings(input.loadOutcomes, input.abandonAfterMs),
|
|
198
222
|
...growthShapeWarnings(input.trend),
|
|
199
223
|
...noiseFloorWarnings(input.trend, minGrowth),
|
|
224
|
+
...thinEvidenceWarnings(input.trend, minGrowth),
|
|
200
225
|
...heapCeilingWarnings(input)
|
|
201
226
|
];
|
|
202
227
|
return {
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
effectiveVerdict,
|
|
7
7
|
minGrowthFor,
|
|
8
8
|
warrantsIssueDraft
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-FFCY6POI.js";
|
|
10
10
|
import {
|
|
11
11
|
assessPeakPressure,
|
|
12
12
|
describePeakPressure
|
|
@@ -91992,6 +91992,11 @@ var FLAGS = [
|
|
|
91992
91992
|
help: "Fast preset: 2000 requests x 4 cycles, 8s idle \u2014 same cycle count as the default, less traffic per cycle"
|
|
91993
91993
|
},
|
|
91994
91994
|
{ flag: "--diff-all", value: "none", help: "Diff snapshots for stable routes too (slow)" },
|
|
91995
|
+
{
|
|
91996
|
+
flag: "--no-resolve",
|
|
91997
|
+
value: "none",
|
|
91998
|
+
help: "Do not re-measure inconclusive routes with more cycles (default: re-measure once)"
|
|
91999
|
+
},
|
|
91995
92000
|
{ flag: "--output", value: "string", argName: "<dir>", help: "Where to write runs (default <app-dir>/.next-leak)" },
|
|
91996
92001
|
{ flag: "--help", alias: "-h", value: "none", help: "Show this help" },
|
|
91997
92002
|
{ flag: "--version", alias: "-v", value: "none", help: "Print the version" }
|
|
@@ -92087,6 +92092,9 @@ function applyFlag(spec, value, options) {
|
|
|
92087
92092
|
case "--diff-all":
|
|
92088
92093
|
options.diffAll = true;
|
|
92089
92094
|
return FLAG_OK;
|
|
92095
|
+
case "--no-resolve":
|
|
92096
|
+
options.noResolve = true;
|
|
92097
|
+
return FLAG_OK;
|
|
92090
92098
|
case "--output":
|
|
92091
92099
|
options.output = value;
|
|
92092
92100
|
return FLAG_OK;
|
|
@@ -92137,6 +92145,7 @@ function parseCliArgs(argv) {
|
|
|
92137
92145
|
idleSeconds: null,
|
|
92138
92146
|
maxOldSpaceMb: null,
|
|
92139
92147
|
quick: false,
|
|
92148
|
+
noResolve: false,
|
|
92140
92149
|
diffAll: false,
|
|
92141
92150
|
output: null
|
|
92142
92151
|
};
|
|
@@ -92441,10 +92450,11 @@ function routeLines(route, parameters) {
|
|
|
92441
92450
|
}
|
|
92442
92451
|
const verdict = effectiveVerdict(route);
|
|
92443
92452
|
const curve = route.samples.map(formatMb).join(" \u2192 ");
|
|
92453
|
+
const resolved = route.resolvedWithCycles === void 0 ? "" : ` (resolved at ${route.resolvedWithCycles} cycles)`;
|
|
92444
92454
|
return [
|
|
92445
92455
|
` ${VERDICT_ICON[verdict]} ${route.route} ${verdict} (${formatGrowth(
|
|
92446
92456
|
route.growthPer1000Requests
|
|
92447
|
-
)}) heap ${curve}`,
|
|
92457
|
+
)}) heap ${curve}${resolved}`,
|
|
92448
92458
|
...confidenceLines(route),
|
|
92449
92459
|
...memorySourceLines(route, verdict),
|
|
92450
92460
|
...peakPressureLines(route, parameters),
|
|
@@ -92457,9 +92467,17 @@ function formatReport(report) {
|
|
|
92457
92467
|
lines.push(...routeLines(route, report.parameters));
|
|
92458
92468
|
}
|
|
92459
92469
|
const { minGrowthPerCycle, loadRequests, cycles, maxOldSpaceMb } = report.parameters;
|
|
92470
|
+
const resolvedCycles = [
|
|
92471
|
+
...new Set(
|
|
92472
|
+
report.routes.flatMap(
|
|
92473
|
+
(route) => route.status === "measured" && route.resolvedWithCycles !== void 0 ? [route.resolvedWithCycles] : []
|
|
92474
|
+
)
|
|
92475
|
+
)
|
|
92476
|
+
].sort((a, b) => a - b);
|
|
92477
|
+
const cyclesLabel = resolvedCycles.length === 0 ? `${cycles} cycles` : `${cycles} cycles (${resolvedCycles.join(", ")} where resolved)`;
|
|
92460
92478
|
lines.push(
|
|
92461
92479
|
"",
|
|
92462
|
-
`judged over ${
|
|
92480
|
+
`judged over ${cyclesLabel} \xD7 ${loadRequests} requests, growth gate ${(minGrowthPerCycle / 1024).toFixed(0)} KiB/cycle (${formatGrowth(minGrowthPerCycle / loadRequests * 1e3)}), heap cap ${maxOldSpaceMb} MB`,
|
|
92463
92481
|
`snapshots and run.json: ${report.workDir}`,
|
|
92464
92482
|
`report: ${report.bundle.htmlReport}`,
|
|
92465
92483
|
...report.bundle.issues.map((issue) => `issue draft (${issue.route}): ${issue.file}`)
|
|
@@ -92501,6 +92519,11 @@ var routeConfigSchema = z2.object({
|
|
|
92501
92519
|
* that path — vercel/next.js#89091 traces `ServerResponse` retention to
|
|
92502
92520
|
* an early disconnect — and a load generator that always waits politely
|
|
92503
92521
|
* never reaches it.
|
|
92522
|
+
*
|
|
92523
|
+
* Counted **from the first byte of the response**, not from the request:
|
|
92524
|
+
* under load a server's first byte arrives long after any sane fixed
|
|
92525
|
+
* window, so a request-relative clock cuts before the stream starts and
|
|
92526
|
+
* tests the wrong path entirely.
|
|
92504
92527
|
*/
|
|
92505
92528
|
abandonAfterMs: z2.number().int().positive().optional()
|
|
92506
92529
|
}).strict();
|
|
@@ -92764,6 +92787,9 @@ function diffAgainstBaseline(baseline, after, options = {}) {
|
|
|
92764
92787
|
node.type,
|
|
92765
92788
|
(afterTypeSelfSizes.get(node.type) ?? 0) + node.self_size
|
|
92766
92789
|
);
|
|
92790
|
+
if (node.type === "synthetic") {
|
|
92791
|
+
return;
|
|
92792
|
+
}
|
|
92767
92793
|
if (!baseline.nodeIds.has(node.id)) {
|
|
92768
92794
|
if (node.retainedSize >= resolved.newThresholdBytes) {
|
|
92769
92795
|
const chain = walkChain(node, resolved.chainDepth);
|
|
@@ -93260,6 +93286,7 @@ async function requestSnapshot(port, name) {
|
|
|
93260
93286
|
|
|
93261
93287
|
// src/abandon-load.ts
|
|
93262
93288
|
import net from "net";
|
|
93289
|
+
var FIRST_BYTE_BUDGET_MS = 5e3;
|
|
93263
93290
|
async function runAbandonPhase(options) {
|
|
93264
93291
|
const target = new URL(options.url);
|
|
93265
93292
|
const port = Number(target.port || 80);
|
|
@@ -93274,6 +93301,7 @@ Host: ${target.host}\r
|
|
|
93274
93301
|
sent: 0,
|
|
93275
93302
|
abandoned: 0,
|
|
93276
93303
|
abandonedMidStream: 0,
|
|
93304
|
+
abandonedBeforeResponse: 0,
|
|
93277
93305
|
completed: 0,
|
|
93278
93306
|
errors: 0
|
|
93279
93307
|
};
|
|
@@ -93297,6 +93325,8 @@ Host: ${target.host}\r
|
|
|
93297
93325
|
result.abandoned += 1;
|
|
93298
93326
|
if (responseStarted) {
|
|
93299
93327
|
result.abandonedMidStream += 1;
|
|
93328
|
+
} else {
|
|
93329
|
+
result.abandonedBeforeResponse += 1;
|
|
93300
93330
|
}
|
|
93301
93331
|
}
|
|
93302
93332
|
finish();
|
|
@@ -93304,11 +93334,17 @@ Host: ${target.host}\r
|
|
|
93304
93334
|
socket.once("connect", () => {
|
|
93305
93335
|
result.sent += 1;
|
|
93306
93336
|
socket.write(request2);
|
|
93307
|
-
timer = setTimeout(giveUp,
|
|
93337
|
+
timer = setTimeout(giveUp, FIRST_BYTE_BUDGET_MS);
|
|
93308
93338
|
timer.unref();
|
|
93309
93339
|
});
|
|
93310
93340
|
socket.on("data", () => {
|
|
93341
|
+
if (responseStarted) {
|
|
93342
|
+
return;
|
|
93343
|
+
}
|
|
93311
93344
|
responseStarted = true;
|
|
93345
|
+
clearTimeout(timer);
|
|
93346
|
+
timer = setTimeout(giveUp, options.abandonAfterMs);
|
|
93347
|
+
timer.unref();
|
|
93312
93348
|
});
|
|
93313
93349
|
socket.once("end", () => {
|
|
93314
93350
|
if (!settled) {
|
|
@@ -93470,6 +93506,7 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
93470
93506
|
sent: outcome.sent,
|
|
93471
93507
|
abandoned: outcome.abandoned,
|
|
93472
93508
|
abandonedMidStream: outcome.abandonedMidStream,
|
|
93509
|
+
abandonedBeforeResponse: outcome.abandonedBeforeResponse,
|
|
93473
93510
|
ok2xx: outcome.completed,
|
|
93474
93511
|
errors: outcome.errors
|
|
93475
93512
|
});
|
|
@@ -93832,18 +93869,21 @@ function skipReason(route, requestPath) {
|
|
|
93832
93869
|
}
|
|
93833
93870
|
return null;
|
|
93834
93871
|
}
|
|
93835
|
-
async function measureRoute(context, route, requestPath, index) {
|
|
93872
|
+
async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
93836
93873
|
const { deps, options, target, workDir, routeConfig, registry, nextVersion, progress } = context;
|
|
93837
93874
|
const result = await deps.ritual({
|
|
93838
93875
|
serverPath: target.standaloneServer,
|
|
93839
93876
|
route: requestPath,
|
|
93840
|
-
workDir: path6.join(
|
|
93877
|
+
workDir: path6.join(
|
|
93878
|
+
workDir,
|
|
93879
|
+
`${String(index + 1).padStart(2, "0")}-${routeSlug(route.path)}${pass?.dirSuffix ?? ""}`
|
|
93880
|
+
),
|
|
93841
93881
|
bootstrapPath: options.bootstrapPath,
|
|
93842
93882
|
appPort: await deps.freePort(),
|
|
93843
93883
|
...options.warmupRequests !== void 0 && { warmupRequests: options.warmupRequests },
|
|
93844
93884
|
...options.loadRequests !== void 0 && { loadRequests: options.loadRequests },
|
|
93845
93885
|
...options.connections !== void 0 && { connections: options.connections },
|
|
93846
|
-
...options.cycles !== void 0 && { cycles: options.cycles },
|
|
93886
|
+
...pass !== void 0 ? { cycles: pass.cycles } : options.cycles !== void 0 && { cycles: options.cycles },
|
|
93847
93887
|
...options.idleMs !== void 0 && { idleMs: options.idleMs },
|
|
93848
93888
|
...options.maxOldSpaceMb !== void 0 && { maxOldSpaceMb: options.maxOldSpaceMb },
|
|
93849
93889
|
...routeConfig.headers !== void 0 && { headers: routeConfig.headers },
|
|
@@ -93877,6 +93917,7 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93877
93917
|
route: route.path,
|
|
93878
93918
|
status: "measured",
|
|
93879
93919
|
requestPath,
|
|
93920
|
+
...pass !== void 0 && { resolvedWithCycles: pass.cycles },
|
|
93880
93921
|
samples: result.samples,
|
|
93881
93922
|
memorySamples: result.memorySamples,
|
|
93882
93923
|
peaks: result.peaks,
|
|
@@ -93895,7 +93936,7 @@ async function measureRoute(context, route, requestPath, index) {
|
|
|
93895
93936
|
};
|
|
93896
93937
|
}
|
|
93897
93938
|
async function writeEvidenceBundle(report, workDir) {
|
|
93898
|
-
const { renderHtmlReport } = await import("./html-report-
|
|
93939
|
+
const { renderHtmlReport } = await import("./html-report-76DP5ADD.js");
|
|
93899
93940
|
const { renderIssueMarkdown } = await import("./issue-report-HKA26WNV.js");
|
|
93900
93941
|
for (const route of report.routes) {
|
|
93901
93942
|
if (route.status === "measured" && warrantsIssueDraft(route)) {
|
|
@@ -93906,6 +93947,7 @@ async function writeEvidenceBundle(report, workDir) {
|
|
|
93906
93947
|
}
|
|
93907
93948
|
await writeFile(report.bundle.htmlReport, renderHtmlReport(report));
|
|
93908
93949
|
}
|
|
93950
|
+
var resolveCycles = (cycles) => Math.max(cycles * 2, 6);
|
|
93909
93951
|
async function routeReportFor(context, route, index, total) {
|
|
93910
93952
|
const { routeConfig, progress } = context;
|
|
93911
93953
|
const label = `${route.path} (${index + 1}/${total})`;
|
|
@@ -93918,7 +93960,16 @@ async function routeReportFor(context, route, index, total) {
|
|
|
93918
93960
|
try {
|
|
93919
93961
|
const asPath = requestPath === route.path ? "" : ` as ${requestPath}`;
|
|
93920
93962
|
progress(`measuring ${label}${asPath}`);
|
|
93921
|
-
|
|
93963
|
+
const first = await measureRoute(context, route, requestPath, index);
|
|
93964
|
+
if (first.status !== "measured" || context.options.resolveInconclusive === false || effectiveVerdict(first) !== "inconclusive") {
|
|
93965
|
+
return first;
|
|
93966
|
+
}
|
|
93967
|
+
const cycles = resolveCycles(context.options.cycles ?? RITUAL_DEFAULTS.cycles);
|
|
93968
|
+
progress(`re-measuring ${route.path} with ${cycles} cycles: the first pass could not call it`);
|
|
93969
|
+
return await measureRoute(context, route, requestPath, index, {
|
|
93970
|
+
cycles,
|
|
93971
|
+
dirSuffix: "-resolve"
|
|
93972
|
+
});
|
|
93922
93973
|
} catch (cause) {
|
|
93923
93974
|
const failure = cause instanceof Error ? cause.message : String(cause);
|
|
93924
93975
|
progress(`failed ${label}: ${failure}`);
|
|
@@ -93953,7 +94004,7 @@ async function planRun(options, target, deps, progress) {
|
|
|
93953
94004
|
).length;
|
|
93954
94005
|
const estimate = estimateRun(measurable, parameters);
|
|
93955
94006
|
progress(
|
|
93956
|
-
`${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
|
|
94007
|
+
`${routes.length} routes discovered` + (measurable === routes.length ? "" : ` \xB7 ${measurable} measurable`) + ` \xB7 estimated ${formatEstimate(estimate)}` + (options.resolveInconclusive === false ? "" : " (inconclusive routes are measured again)") + // Long default runs are where first-time users give up; point at the two
|
|
93957
94008
|
// ways out. Suppressed once load parameters were tuned by hand (or by
|
|
93958
94009
|
// --quick, which arrives here as explicit loadRequests/idleMs).
|
|
93959
94010
|
(estimate.slowSeconds > 15 * 60 && options.loadRequests === void 0 && options.idleMs === void 0 ? " \u2014 use --quick for the fast validated preset, or narrow with --routes" : "")
|
package/dist/cli-args.d.ts
CHANGED
package/dist/cli.js
CHANGED
|
@@ -9,8 +9,8 @@ import {
|
|
|
9
9
|
killActiveChildren,
|
|
10
10
|
parseCliArgs,
|
|
11
11
|
runMeasurement
|
|
12
|
-
} from "./chunk-
|
|
13
|
-
import "./chunk-
|
|
12
|
+
} from "./chunk-OJIXF7RP.js";
|
|
13
|
+
import "./chunk-FFCY6POI.js";
|
|
14
14
|
import "./chunk-XHPUAMJG.js";
|
|
15
15
|
import "./chunk-6XYFBOL2.js";
|
|
16
16
|
|
|
@@ -100,6 +100,7 @@ async function main() {
|
|
|
100
100
|
...options.idleSeconds !== null && { idleMs: options.idleSeconds * 1e3 },
|
|
101
101
|
...options.maxOldSpaceMb !== null && { maxOldSpaceMb: options.maxOldSpaceMb },
|
|
102
102
|
...options.diffAll && { diffAll: true },
|
|
103
|
+
...options.noResolve && { resolveInconclusive: false },
|
|
103
104
|
...options.output !== null && { outputDir: options.output },
|
|
104
105
|
onProgress: (message) => console.error(`\xB7 ${message}`)
|
|
105
106
|
});
|
package/dist/confidence.d.ts
CHANGED
|
@@ -12,10 +12,11 @@ import { type TrendResult, type TrendVerdict } from "./trend.js";
|
|
|
12
12
|
* mid-stream teardown path was never reached
|
|
13
13
|
* `spiky-growth` — one cycle dominates, so the mean describes little
|
|
14
14
|
* `near-threshold` — growth barely clears the noise floor
|
|
15
|
+
* `thin-evidence` — a leak called on too few cycles for its size
|
|
15
16
|
* `near-heap-ceiling` — the heap approached the cap the process ran under,
|
|
16
17
|
* so the curve was measured against a ceiling instead of running free
|
|
17
18
|
*/
|
|
18
|
-
export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold" | "near-heap-ceiling";
|
|
19
|
+
export type WarningCode = "unsettled" | "settle-unverified" | "load-incomplete" | "abandon-ineffective" | "abandon-before-response" | "spiky-growth" | "near-threshold" | "thin-evidence" | "near-heap-ceiling";
|
|
19
20
|
export type MeasurementWarning = {
|
|
20
21
|
code: WarningCode;
|
|
21
22
|
detail: string;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
|
|
2
2
|
import {
|
|
3
3
|
renderHtmlReport
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-BTWQYJOW.js";
|
|
5
|
+
import "./chunk-FFCY6POI.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-
|
|
42
|
+
} from "./chunk-OJIXF7RP.js";
|
|
43
43
|
import {
|
|
44
44
|
renderHtmlReport
|
|
45
|
-
} from "./chunk-
|
|
45
|
+
} from "./chunk-BTWQYJOW.js";
|
|
46
46
|
import {
|
|
47
47
|
classifyTrend
|
|
48
|
-
} from "./chunk-
|
|
48
|
+
} from "./chunk-FFCY6POI.js";
|
|
49
49
|
import {
|
|
50
50
|
renderIssueMarkdown
|
|
51
51
|
} from "./chunk-WXAFXVWS.js";
|
package/dist/ritual.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type LoadOutcome = {
|
|
|
39
39
|
abandoned?: number;
|
|
40
40
|
/** Abandonments where the response had already started — the mid-stream path. */
|
|
41
41
|
abandonedMidStream?: number;
|
|
42
|
+
/** Abandonments where the first-byte budget expired in silence. */
|
|
43
|
+
abandonedBeforeResponse?: number;
|
|
42
44
|
};
|
|
43
45
|
/**
|
|
44
46
|
* Whether the heap actually held still before each sample was taken.
|
package/dist/runner.d.ts
CHANGED
|
@@ -56,6 +56,11 @@ export type RouteReport = {
|
|
|
56
56
|
/** Null when there is no diff or no module registry. */
|
|
57
57
|
attribution: AttributedDiff | null;
|
|
58
58
|
signatures: MatchedSignature[];
|
|
59
|
+
/**
|
|
60
|
+
* Cycles used by the second pass, when the first came back
|
|
61
|
+
* `inconclusive` and the run went back for more evidence.
|
|
62
|
+
*/
|
|
63
|
+
resolvedWithCycles?: number;
|
|
59
64
|
};
|
|
60
65
|
export type MeasuredRoute = Extract<RouteReport, {
|
|
61
66
|
status: "measured";
|
|
@@ -111,6 +116,11 @@ export type RunOptions = {
|
|
|
111
116
|
diffAll?: boolean;
|
|
112
117
|
/** Only measure routes matching these templates or prefixes. */
|
|
113
118
|
routeFilter?: string[];
|
|
119
|
+
/**
|
|
120
|
+
* Measure a route again, with more cycles, when the first pass could not
|
|
121
|
+
* call it. Default true: the run should answer the question it was asked.
|
|
122
|
+
*/
|
|
123
|
+
resolveInconclusive?: boolean;
|
|
114
124
|
/** Abort between phases; remaining routes are reported as interrupted. */
|
|
115
125
|
signal?: AbortSignal;
|
|
116
126
|
onProgress?: (message: string) => void;
|
|
@@ -143,6 +153,11 @@ export type RunnerDeps = {
|
|
|
143
153
|
};
|
|
144
154
|
export declare function freePort(): Promise<number>;
|
|
145
155
|
export declare function routeSlug(route: string): string;
|
|
156
|
+
/**
|
|
157
|
+
* Cycles a second pass uses — the same figure the report recommends for a
|
|
158
|
+
* manual re-run, so the tool follows its own advice.
|
|
159
|
+
*/
|
|
160
|
+
export declare const resolveCycles: (cycles: number) => number;
|
|
146
161
|
/**
|
|
147
162
|
* Full measurement run: validate the target, discover routes, run the ritual
|
|
148
163
|
* per route in a fresh process, diff snapshots for non-stable verdicts, and
|