next-leak 0.12.2 → 0.13.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 +19 -4
- package/dist/bootstrap.js +12 -93
- package/dist/build-report.d.ts +3 -7
- package/dist/{chunk-ULTMNWRX.js → chunk-FJVY5MKV.js} +10 -6
- package/dist/{chunk-HDCOHSBY.js → chunk-GBRBWUZZ.js} +82 -37
- package/dist/{chunk-QUFL2FXU.js → chunk-GU7IJUNY.js} +14 -0
- package/dist/chunk-IKWF7SSB.js +114 -0
- package/dist/{chunk-VKPGB5MG.js → chunk-QNNVRQ4L.js} +2 -2
- package/dist/{chunk-E464SO7G.js → chunk-VTKOO3YJ.js} +14 -25
- package/dist/cli.js +18 -19
- package/dist/control-client.d.ts +10 -5
- package/dist/control-server.d.ts +10 -0
- package/dist/{html-report-MSEC3ZI4.js → html-report-VNTPHLO3.js} +3 -3
- package/dist/index.js +6 -5
- package/dist/{issue-report-JXVLP7CV.js → issue-report-4RW5YOT3.js} +2 -2
- package/dist/launcher.d.ts +2 -0
- package/dist/manifests.d.ts +3 -0
- package/dist/peak-pressure.d.ts +5 -5
- package/dist/ritual.d.ts +2 -1
- package/dist/route-guidance.d.ts +2 -0
- package/dist/runner.d.ts +6 -0
- package/dist/terminal-text.d.ts +14 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -193,7 +193,10 @@ entry, so the route reads as flat whatever it retains, and that false negative
|
|
|
193
193
|
lands on exactly the leaks being reported now (`use cache`, `cacheComponents`
|
|
194
194
|
and ISR all key on the params). If your app answers 404 for params it never
|
|
195
195
|
prerendered, the run says so through its non-2xx count; drop the marker then.
|
|
196
|
-
When
|
|
196
|
+
When the build prerendered nothing for a route, the value is `REPLACE-ME-{n}`:
|
|
197
|
+
put the shape of a real value in place of `REPLACE-ME` and keep the `-{n}`, or
|
|
198
|
+
the run measures a single cache entry. When a run skips a route it prints the
|
|
199
|
+
same fragment.
|
|
197
200
|
|
|
198
201
|
```json
|
|
199
202
|
```
|
|
@@ -240,7 +243,7 @@ separates them, because each one has a different fix:
|
|
|
240
243
|
| One-time warm-up growth (JIT, lazy caches) | `stable` | The first cycle is excluded from the verdict; warm-up flattens, leaks keep climbing |
|
|
241
244
|
| A route that is expensive, not leaky | `failed` under load it cannot sustain, flat once concurrency fits | Real leaks survive forced GC at any concurrency; saturation disappears when load drops |
|
|
242
245
|
| Growth that pauses and resumes (stepwise) | `leak` | A healthy route gives back 20-30% of its growth; a stepwise leak gives back nothing |
|
|
243
|
-
| A cache filling up under the load that measures it | `saturating` | A bounded store grows by less each cycle as new keys get rarer; a leak does not decelerate |
|
|
246
|
+
| A cache filling up under the load that measures it | `saturating` | A bounded store grows by less each cycle as new keys get rarer; a leak does not decelerate. A bend that lands on a steady rate above the gate is a leak under the fill, and stays `leak` |
|
|
244
247
|
| Memory a forced GC reclaims that production never reclaims in time | `pressure` | Every verdict sample is post-GC; the peaks are sampled under load, and a ceiling every settled cycle comes back to is a regime, not an episode |
|
|
245
248
|
| Native/buffer memory with a flat JS heap | `leak (external)` or an explicit RSS note | Heap, `external` and RSS are sampled and judged separately |
|
|
246
249
|
| A leak in your code vs a dependency vs Next itself | `culprit: src/app/x/page.tsx (your code)` — or the package, or framework internals | Retainer chains mapped through the build's source maps |
|
|
@@ -271,7 +274,15 @@ separates them, because each one has a different fix:
|
|
|
271
274
|
alternative was calling it a leak: a `use cache` route measured on Next
|
|
272
275
|
16.3.3 came out at +603 MB per 1000 requests that was entirely the cache
|
|
273
276
|
storing what it had been asked to store — the same route dropped to +88 MB
|
|
274
|
-
once the payload was removed.
|
|
277
|
+
once the payload was removed. The bend has to head for zero, though: a store
|
|
278
|
+
filling on top of a leak bends too, and then holds the leak's rate for as
|
|
279
|
+
long as the load runs. So when the last third of the window (at least three
|
|
280
|
+
cycles) sits at one steady rate at or above the gate, within 1.5x of itself,
|
|
281
|
+
the route stays `leak`. Measured on the reproduction for
|
|
282
|
+
[#99335](https://github.com/vercel/next.js/issues/99335), an ISR route
|
|
283
|
+
keeping every new path's cache control in an unbounded map: it closed on four
|
|
284
|
+
cycles of ~2.2 MB, twice the gate, and used to be called `saturating`, with
|
|
285
|
+
no issue draft. Because the shape requires every cycle to clear
|
|
275
286
|
the growth gate, a decelerating curve always ends the window still growing,
|
|
276
287
|
so where it settles is outside what was measured: these routes are
|
|
277
288
|
**measured again** with twice the cycles, like `inconclusive` ones. No issue
|
|
@@ -388,7 +399,11 @@ A route with a revalidation period serves its cache unless the request carries
|
|
|
388
399
|
the build's own `x-prerender-revalidate` header. Measured on that same app: the
|
|
389
400
|
identical run reports `leak` with the header and `stable` without it. next-leak
|
|
390
401
|
reads `previewModeId` from `.next/prerender-manifest.json` and drives those
|
|
391
|
-
routes itself; a header you set in `next-leak.config.json` wins untouched.
|
|
402
|
+
routes itself; a header you set in `next-leak.config.json` wins untouched. A
|
|
403
|
+
dynamic template with nothing prerendered (`generateStaticParams()` returning
|
|
404
|
+
`[]`) counts too: the build records no period for it, but its blocking fallback
|
|
405
|
+
caches every new param on first request, so `{n}` fills that cache while it is
|
|
406
|
+
measured and the report says so. When
|
|
392
407
|
the manifest cannot supply one, the route is reported `not exercised` with no
|
|
393
408
|
verdict, because a flat curve measured against a static cache says nothing about
|
|
394
409
|
the app.
|
package/dist/bootstrap.js
CHANGED
|
@@ -1,100 +1,15 @@
|
|
|
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 {
|
|
3
|
+
requestProbe,
|
|
4
|
+
startControlServer
|
|
5
|
+
} from "./chunk-IKWF7SSB.js";
|
|
2
6
|
import "./chunk-6XYFBOL2.js";
|
|
3
7
|
|
|
4
8
|
// src/bootstrap.ts
|
|
5
9
|
import { mkdir, writeFile } from "fs/promises";
|
|
6
|
-
import http2 from "http";
|
|
7
|
-
import https from "https";
|
|
8
|
-
import path2 from "path";
|
|
9
|
-
|
|
10
|
-
// src/control-server.ts
|
|
11
10
|
import http from "http";
|
|
11
|
+
import https from "https";
|
|
12
12
|
import path from "path";
|
|
13
|
-
import { writeHeapSnapshot } from "v8";
|
|
14
|
-
var g = globalThis;
|
|
15
|
-
var tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
16
|
-
async function forceGc(passes = 3) {
|
|
17
|
-
if (typeof g.gc !== "function") {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
for (let i = 0; i < passes; i += 1) {
|
|
21
|
-
g.gc();
|
|
22
|
-
await tick();
|
|
23
|
-
}
|
|
24
|
-
return true;
|
|
25
|
-
}
|
|
26
|
-
var requestProbe = globalThis;
|
|
27
|
-
function sampleMemory(gcExposed) {
|
|
28
|
-
const usage = process.memoryUsage();
|
|
29
|
-
return {
|
|
30
|
-
gcExposed,
|
|
31
|
-
heapUsed: usage.heapUsed,
|
|
32
|
-
rss: usage.rss,
|
|
33
|
-
external: usage.external,
|
|
34
|
-
arrayBuffers: usage.arrayBuffers,
|
|
35
|
-
pid: process.pid,
|
|
36
|
-
ppid: process.ppid,
|
|
37
|
-
argv: process.argv,
|
|
38
|
-
cwd: process.cwd(),
|
|
39
|
-
...requestProbe.__nextLeakServedRequests === void 0 ? {} : { servedRequests: requestProbe.__nextLeakServedRequests }
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
async function startControlServer(options) {
|
|
43
|
-
const write = options.writeSnapshot ?? writeHeapSnapshot;
|
|
44
|
-
const server = http.createServer((request, response) => {
|
|
45
|
-
void handle(request, response);
|
|
46
|
-
});
|
|
47
|
-
async function handle(request, response) {
|
|
48
|
-
const url = new URL(request.url ?? "/", "http://control.local");
|
|
49
|
-
const respond = (status, body) => {
|
|
50
|
-
response.writeHead(status, { "content-type": "application/json" });
|
|
51
|
-
response.end(JSON.stringify(body));
|
|
52
|
-
};
|
|
53
|
-
try {
|
|
54
|
-
if (url.pathname === "/gc") {
|
|
55
|
-
const gcExposed = await forceGc();
|
|
56
|
-
respond(200, sampleMemory(gcExposed));
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
if (url.pathname === "/mem") {
|
|
60
|
-
respond(200, sampleMemory(typeof g.gc === "function"));
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
if (url.pathname === "/snapshot") {
|
|
64
|
-
const name = url.searchParams.get("name");
|
|
65
|
-
if (name === null || name === "") {
|
|
66
|
-
respond(400, { error: "missing ?name=<label>" });
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
const gcExposed = await forceGc();
|
|
70
|
-
const sample = sampleMemory(gcExposed);
|
|
71
|
-
const file = write(
|
|
72
|
-
path.join(options.snapshotDir, `${path.basename(name)}.heapsnapshot`)
|
|
73
|
-
);
|
|
74
|
-
respond(200, { file, sample });
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
respond(404, { error: `unknown path ${url.pathname}` });
|
|
78
|
-
} catch (cause) {
|
|
79
|
-
respond(500, { error: cause instanceof Error ? cause.message : String(cause) });
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
await new Promise((resolve, reject) => {
|
|
83
|
-
server.once("error", reject);
|
|
84
|
-
server.listen(0, "127.0.0.1", resolve);
|
|
85
|
-
});
|
|
86
|
-
server.unref();
|
|
87
|
-
const address = server.address();
|
|
88
|
-
if (address === null || typeof address === "string") {
|
|
89
|
-
throw new Error("control server has no bound port");
|
|
90
|
-
}
|
|
91
|
-
return {
|
|
92
|
-
port: address.port,
|
|
93
|
-
close: () => new Promise((resolve, reject) => {
|
|
94
|
-
server.close((error) => error ? reject(error) : resolve());
|
|
95
|
-
})
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
13
|
|
|
99
14
|
// src/request-probe.ts
|
|
100
15
|
function countServedRequests(module) {
|
|
@@ -110,18 +25,22 @@ function countServedRequests(module) {
|
|
|
110
25
|
|
|
111
26
|
// src/bootstrap.ts
|
|
112
27
|
var workDir = process.env["NEXT_LEAK_DIR"];
|
|
28
|
+
var token = process.env["NEXT_LEAK_TOKEN"];
|
|
113
29
|
function installRequestProbe() {
|
|
114
30
|
requestProbe.__nextLeakServedRequests = 0;
|
|
115
|
-
countServedRequests(
|
|
31
|
+
countServedRequests(http);
|
|
116
32
|
countServedRequests(https);
|
|
117
33
|
}
|
|
118
34
|
if (workDir !== void 0 && workDir !== "") {
|
|
119
35
|
try {
|
|
120
36
|
await mkdir(workDir, { recursive: true });
|
|
121
|
-
|
|
37
|
+
if (token === void 0 || token === "") {
|
|
38
|
+
throw new Error("NEXT_LEAK_TOKEN is not set, and the channel does not start without it");
|
|
39
|
+
}
|
|
40
|
+
const server = await startControlServer({ snapshotDir: workDir, token });
|
|
122
41
|
installRequestProbe();
|
|
123
42
|
await writeFile(
|
|
124
|
-
|
|
43
|
+
path.join(workDir, `control-${process.pid}.json`),
|
|
125
44
|
JSON.stringify({ port: server.port, pid: process.pid })
|
|
126
45
|
);
|
|
127
46
|
} catch (cause) {
|
package/dist/build-report.d.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
import { type BuildAttribution, type BuildAttributionGap } from "./build-attribution.js";
|
|
2
2
|
import type { BuildRunResult } from "./build-run.js";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* are resident memory with no forced collection behind them, and calling them
|
|
8
|
-
* retention would borrow a precision the measurement does not have. RSS is
|
|
9
|
-
* still the honest axis here — it is what a CI runner's limit is enforced
|
|
10
|
-
* against and what the OOM killer reads.
|
|
4
|
+
* The report as it is written to the terminal. The build's output and the
|
|
5
|
+
* names in the attribution come from the measured app, so the text goes
|
|
6
|
+
* through `terminalSafe` whichever branch produced it.
|
|
11
7
|
*/
|
|
12
8
|
export declare function formatBuildReport(result: BuildRunResult, attribution?: BuildAttribution | BuildAttributionGap | null): string;
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
assessPeakPressure,
|
|
4
4
|
describePeakPressure,
|
|
5
5
|
retainedAfterLoad
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-VTKOO3YJ.js";
|
|
7
7
|
|
|
8
8
|
// src/issue-report.ts
|
|
9
9
|
import path from "path";
|
|
@@ -24,22 +24,26 @@ function ownerLabel(attribution) {
|
|
|
24
24
|
return "unattributed";
|
|
25
25
|
}
|
|
26
26
|
const source = attribution.source ? ` \u2014 ${codeSpan(attribution.source)}` : "";
|
|
27
|
-
const packageName = attribution.packageName ? ` \u2014 ${attribution.packageName}` : "";
|
|
27
|
+
const packageName = attribution.packageName ? ` \u2014 ${codeSpan(attribution.packageName)}` : "";
|
|
28
28
|
return `${attribution.owner}${source}${packageName}`;
|
|
29
29
|
}
|
|
30
|
+
function publicDetail(detail, run) {
|
|
31
|
+
return detail.replaceAll(run.workDir, publicWorkDir(run)).replaceAll(run.appDir, "<app-dir>").replaceAll(/[\r\n]+/g, " ");
|
|
32
|
+
}
|
|
30
33
|
function blamedParty(owner, culprit) {
|
|
31
34
|
if (owner === "app") {
|
|
32
35
|
return `**your own code** (${codeSpan(culprit?.source ?? "app code")})`;
|
|
33
36
|
}
|
|
34
|
-
|
|
37
|
+
const packageName = culprit?.packageName;
|
|
38
|
+
return `the dependency **${packageName ? codeSpan(packageName) : "a dependency"}**`;
|
|
35
39
|
}
|
|
36
|
-
function missingEvidence(route) {
|
|
40
|
+
function missingEvidence(route, run) {
|
|
37
41
|
if (route.attributionGap === void 0) {
|
|
38
42
|
return "- (no findings above thresholds)";
|
|
39
43
|
}
|
|
40
44
|
const cause = route.attributionGap.reason === "snapshot-unavailable" ? "the final heap snapshot was never taken" : "the heap snapshots could not be read";
|
|
41
45
|
return `- **Attribution unavailable** \u2014 ${cause}, so this run cannot name what retains the memory. This is not a finding that nothing grew: the diff was never computed. The growth figure above is unaffected.
|
|
42
|
-
- ${route.attributionGap.detail}`;
|
|
46
|
+
- ${publicDetail(route.attributionGap.detail, run)}`;
|
|
43
47
|
}
|
|
44
48
|
function evidenceRows(route) {
|
|
45
49
|
const findings = [...route.diff?.grownNodes ?? [], ...route.diff?.newNodes ?? []];
|
|
@@ -128,7 +132,7 @@ The first cycle is excluded from the verdict (engine warm-up).
|
|
|
128
132
|
|
|
129
133
|
### Heap evidence
|
|
130
134
|
|
|
131
|
-
${evidenceRows(route) || missingEvidence(route)}
|
|
135
|
+
${evidenceRows(route) || missingEvidence(route, run)}
|
|
132
136
|
${signatures === "" ? "" : `
|
|
133
137
|
**Matched known causes:**
|
|
134
138
|
${signatures}
|
|
@@ -1,4 +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
|
+
import {
|
|
3
|
+
CONTROL_TOKEN_HEADER
|
|
4
|
+
} from "./chunk-IKWF7SSB.js";
|
|
2
5
|
import {
|
|
3
6
|
assessConfidence,
|
|
4
7
|
classifyMemoryTrend,
|
|
@@ -8,13 +11,13 @@ import {
|
|
|
8
11
|
resolveCycles,
|
|
9
12
|
warrantsIssueDraft,
|
|
10
13
|
withdrawnByDisagreement
|
|
11
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-GU7IJUNY.js";
|
|
12
15
|
import {
|
|
13
16
|
assessPeakPressure,
|
|
14
17
|
assessPressureVerdict,
|
|
15
18
|
describePeakPressure,
|
|
16
19
|
retainedAfterLoad
|
|
17
|
-
} from "./chunk-
|
|
20
|
+
} from "./chunk-VTKOO3YJ.js";
|
|
18
21
|
import {
|
|
19
22
|
__commonJS,
|
|
20
23
|
__esm,
|
|
@@ -93199,6 +93202,16 @@ async function extractModuleRegistry(nextServerDir) {
|
|
|
93199
93202
|
return registry;
|
|
93200
93203
|
}
|
|
93201
93204
|
|
|
93205
|
+
// src/terminal-text.ts
|
|
93206
|
+
import { stripVTControlCharacters } from "util";
|
|
93207
|
+
var CONTROL_CHARACTER = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
|
93208
|
+
function terminalSafe(text) {
|
|
93209
|
+
return stripVTControlCharacters(text.replaceAll("\r\n", "\n")).replaceAll(
|
|
93210
|
+
CONTROL_CHARACTER,
|
|
93211
|
+
(character) => `\\x${character.charCodeAt(0).toString(16).padStart(2, "0")}`
|
|
93212
|
+
);
|
|
93213
|
+
}
|
|
93214
|
+
|
|
93202
93215
|
// src/manifests.ts
|
|
93203
93216
|
import { z as z2 } from "zod";
|
|
93204
93217
|
var appPathsManifestSchema = z2.record(z2.string(), z2.string());
|
|
@@ -93222,6 +93235,10 @@ var prerenderManifestSchema = z2.looseObject({
|
|
|
93222
93235
|
srcRoute: z2.string().nullable().optional()
|
|
93223
93236
|
})
|
|
93224
93237
|
).optional(),
|
|
93238
|
+
// Read only for `fallback`, and left untyped: its shape has changed across
|
|
93239
|
+
// Next versions (`null`, `false`, an HTML path, a PPR shell), and a template
|
|
93240
|
+
// the tool cannot classify must not fail the whole manifest.
|
|
93241
|
+
dynamicRoutes: z2.record(z2.string(), z2.looseObject({ fallback: z2.unknown().optional() })).optional(),
|
|
93225
93242
|
preview: z2.looseObject({ previewModeId: z2.string() }).optional()
|
|
93226
93243
|
});
|
|
93227
93244
|
var INTERNAL_PATHS = /* @__PURE__ */ new Set(["/_global-error", "/_not-found"]);
|
|
@@ -93392,6 +93409,7 @@ This only changes how the build is packaged \u2014 not how your app behaves.`
|
|
|
93392
93409
|
|
|
93393
93410
|
// src/launcher.ts
|
|
93394
93411
|
import { spawn } from "child_process";
|
|
93412
|
+
import { randomBytes } from "crypto";
|
|
93395
93413
|
import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
|
|
93396
93414
|
import path3 from "path";
|
|
93397
93415
|
import { pathToFileURL } from "url";
|
|
@@ -93489,6 +93507,7 @@ async function pollUntil(deadline, describe, probe, failed) {
|
|
|
93489
93507
|
async function launchInstrumented(options) {
|
|
93490
93508
|
const hostname = options.hostname ?? "127.0.0.1";
|
|
93491
93509
|
const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
|
93510
|
+
const controlToken = randomBytes(32).toString("hex");
|
|
93492
93511
|
const child = spawn(
|
|
93493
93512
|
process.execPath,
|
|
93494
93513
|
[
|
|
@@ -93506,7 +93525,8 @@ async function launchInstrumented(options) {
|
|
|
93506
93525
|
NODE_ENV: "production",
|
|
93507
93526
|
PORT: String(options.appPort),
|
|
93508
93527
|
HOSTNAME: hostname,
|
|
93509
|
-
NEXT_LEAK_DIR: options.workDir
|
|
93528
|
+
NEXT_LEAK_DIR: options.workDir,
|
|
93529
|
+
NEXT_LEAK_TOKEN: controlToken
|
|
93510
93530
|
},
|
|
93511
93531
|
stdio: ["ignore", "ignore", "pipe"]
|
|
93512
93532
|
}
|
|
@@ -93550,7 +93570,9 @@ ${stderrTailBuffer}`;
|
|
|
93550
93570
|
const parsed = controlFileSchema.parse(
|
|
93551
93571
|
JSON.parse(await readFile3(path3.join(options.workDir, entry), "utf8"))
|
|
93552
93572
|
);
|
|
93553
|
-
const response = await fetch(`http://127.0.0.1:${parsed.port}/gc
|
|
93573
|
+
const response = await fetch(`http://127.0.0.1:${parsed.port}/gc`, {
|
|
93574
|
+
headers: { [CONTROL_TOKEN_HEADER]: controlToken }
|
|
93575
|
+
});
|
|
93554
93576
|
if (response.ok) {
|
|
93555
93577
|
return parsed.port;
|
|
93556
93578
|
}
|
|
@@ -93584,6 +93606,7 @@ ${stderrTailBuffer}`;
|
|
|
93584
93606
|
pid: child.pid ?? -1,
|
|
93585
93607
|
appPort: options.appPort,
|
|
93586
93608
|
controlPort,
|
|
93609
|
+
controlToken,
|
|
93587
93610
|
explainExit: () => {
|
|
93588
93611
|
if (!exited) {
|
|
93589
93612
|
return null;
|
|
@@ -93922,6 +93945,12 @@ function parseCliArgs(argv) {
|
|
|
93922
93945
|
}
|
|
93923
93946
|
};
|
|
93924
93947
|
}
|
|
93948
|
+
if (options.attributeBuild) {
|
|
93949
|
+
return {
|
|
93950
|
+
kind: "error",
|
|
93951
|
+
message: `option "--attribute" applies only to "next-leak build": a run already names what it retains \u2014 see --help`
|
|
93952
|
+
};
|
|
93953
|
+
}
|
|
93925
93954
|
return { kind: "run", options };
|
|
93926
93955
|
}
|
|
93927
93956
|
|
|
@@ -94251,7 +94280,7 @@ function describeProgress(bytes) {
|
|
|
94251
94280
|
function describeDuration(ms) {
|
|
94252
94281
|
return ms >= 36e5 ? `${Math.round(ms / 36e5)}h` : `${Math.round(ms / 1e3)}s`;
|
|
94253
94282
|
}
|
|
94254
|
-
function get(port, pathname, deadlineMs, watch) {
|
|
94283
|
+
function get({ port, token }, pathname, deadlineMs, watch) {
|
|
94255
94284
|
return new Promise((resolve, reject) => {
|
|
94256
94285
|
let settled = false;
|
|
94257
94286
|
const settle = (outcome) => {
|
|
@@ -94266,7 +94295,8 @@ function get(port, pathname, deadlineMs, watch) {
|
|
|
94266
94295
|
reject(outcome.error);
|
|
94267
94296
|
}
|
|
94268
94297
|
};
|
|
94269
|
-
const
|
|
94298
|
+
const headers = { [CONTROL_TOKEN_HEADER]: token };
|
|
94299
|
+
const request2 = http.get({ host: "127.0.0.1", port, path: pathname, headers }, (response) => {
|
|
94270
94300
|
const chunks = [];
|
|
94271
94301
|
response.on("data", (chunk) => chunks.push(chunk));
|
|
94272
94302
|
response.once(
|
|
@@ -94336,8 +94366,8 @@ function get(port, pathname, deadlineMs, watch) {
|
|
|
94336
94366
|
);
|
|
94337
94367
|
});
|
|
94338
94368
|
}
|
|
94339
|
-
async function request(
|
|
94340
|
-
const response = await get(
|
|
94369
|
+
async function request(control, pathname, deadlineMs = deadlineFor(pathname), watch) {
|
|
94370
|
+
const response = await get(control, pathname, deadlineMs, watch);
|
|
94341
94371
|
if (response.status !== 200) {
|
|
94342
94372
|
throw new ControlError(`control channel ${pathname} responded ${response.status}`);
|
|
94343
94373
|
}
|
|
@@ -94347,8 +94377,8 @@ async function request(port, pathname, deadlineMs = deadlineFor(pathname), watch
|
|
|
94347
94377
|
throw new ControlError(`control channel ${pathname} answered with malformed JSON`);
|
|
94348
94378
|
}
|
|
94349
94379
|
}
|
|
94350
|
-
async function requestGc(
|
|
94351
|
-
const sample = sampleSchema.parse(await request(
|
|
94380
|
+
async function requestGc(control) {
|
|
94381
|
+
const sample = sampleSchema.parse(await request(control, "/gc"));
|
|
94352
94382
|
if (!sample.gcExposed) {
|
|
94353
94383
|
throw new ControlError(
|
|
94354
94384
|
"the measured process is running without --expose-gc; samples would be meaningless"
|
|
@@ -94356,14 +94386,14 @@ async function requestGc(port) {
|
|
|
94356
94386
|
}
|
|
94357
94387
|
return sample;
|
|
94358
94388
|
}
|
|
94359
|
-
async function requestMemory(
|
|
94360
|
-
return sampleSchema.parse(await request(
|
|
94389
|
+
async function requestMemory(control) {
|
|
94390
|
+
return sampleSchema.parse(await request(control, "/mem"));
|
|
94361
94391
|
}
|
|
94362
|
-
async function requestSnapshot(
|
|
94392
|
+
async function requestSnapshot(control, name, workDir) {
|
|
94363
94393
|
const pathname = `/snapshot?name=${encodeURIComponent(name)}`;
|
|
94364
94394
|
const parsed = snapshotResponseSchema.parse(
|
|
94365
94395
|
await request(
|
|
94366
|
-
|
|
94396
|
+
control,
|
|
94367
94397
|
pathname,
|
|
94368
94398
|
deadlineFor(pathname),
|
|
94369
94399
|
workDir === void 0 ? void 0 : productionWatch(snapshotPathFor(workDir, name))
|
|
@@ -94488,7 +94518,7 @@ var UNRECLAIMED_SETTLE_MS = SETTLE_POLL_MS;
|
|
|
94488
94518
|
function unreclaimedSettleFor(idleMs) {
|
|
94489
94519
|
return Math.min(UNRECLAIMED_SETTLE_MS, Math.floor(idleMs / 4));
|
|
94490
94520
|
}
|
|
94491
|
-
function pollPeak(
|
|
94521
|
+
function pollPeak(control, phase, deps) {
|
|
94492
94522
|
const peak = {
|
|
94493
94523
|
phase,
|
|
94494
94524
|
heapUsed: 0,
|
|
@@ -94506,7 +94536,7 @@ function pollPeak(controlPort, phase, deps) {
|
|
|
94506
94536
|
}
|
|
94507
94537
|
let sample;
|
|
94508
94538
|
try {
|
|
94509
|
-
sample = await deps.readMemory(
|
|
94539
|
+
sample = await deps.readMemory(control);
|
|
94510
94540
|
} catch {
|
|
94511
94541
|
return;
|
|
94512
94542
|
}
|
|
@@ -94525,7 +94555,7 @@ function pollPeak(controlPort, phase, deps) {
|
|
|
94525
94555
|
}
|
|
94526
94556
|
};
|
|
94527
94557
|
}
|
|
94528
|
-
async function waitUntilSettled(
|
|
94558
|
+
async function waitUntilSettled(control, maxIdleMs, deps) {
|
|
94529
94559
|
const deadline = Date.now() + maxIdleMs;
|
|
94530
94560
|
let previous = null;
|
|
94531
94561
|
let polls = 0;
|
|
@@ -94533,7 +94563,7 @@ async function waitUntilSettled(controlPort, maxIdleMs, deps) {
|
|
|
94533
94563
|
await deps.sleep(Math.min(SETTLE_POLL_MS, Math.max(deadline - Date.now(), 0)));
|
|
94534
94564
|
let current;
|
|
94535
94565
|
try {
|
|
94536
|
-
current = (await requestGc(
|
|
94566
|
+
current = (await requestGc(control)).heapUsed;
|
|
94537
94567
|
} catch {
|
|
94538
94568
|
return { status: "unknown", polls };
|
|
94539
94569
|
}
|
|
@@ -94592,6 +94622,7 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94592
94622
|
}
|
|
94593
94623
|
app = await deps.launch({ ...launchOptions, appPort: options.appPort + 1 });
|
|
94594
94624
|
}
|
|
94625
|
+
const control = { port: app.controlPort, token: app.controlToken };
|
|
94595
94626
|
const timings = [];
|
|
94596
94627
|
const loadOutcomes = [];
|
|
94597
94628
|
const settleOutcomes = [];
|
|
@@ -94661,13 +94692,13 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94661
94692
|
);
|
|
94662
94693
|
const baseline = await timed(
|
|
94663
94694
|
"baseline snapshot",
|
|
94664
|
-
() => requestSnapshot(
|
|
94695
|
+
() => requestSnapshot(control, "baseline", options.workDir)
|
|
94665
94696
|
);
|
|
94666
94697
|
memorySamples.push(baseline.sample);
|
|
94667
94698
|
baselineSnapshot = baseline.file;
|
|
94668
94699
|
for (let cycle = 1; cycle <= cycles; cycle += 1) {
|
|
94669
94700
|
await timed(`cycle ${cycle} load`, async () => {
|
|
94670
|
-
const poller = pollPeak(
|
|
94701
|
+
const poller = pollPeak(control, `cycle ${cycle}`, deps);
|
|
94671
94702
|
try {
|
|
94672
94703
|
await loadCycle(`cycle ${cycle}`, loadRequests);
|
|
94673
94704
|
} finally {
|
|
@@ -94678,30 +94709,30 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94678
94709
|
await timed(`cycle ${cycle} unreclaimed read`, async () => {
|
|
94679
94710
|
await deps.sleep(unreclaimedSettleMs);
|
|
94680
94711
|
try {
|
|
94681
|
-
unreclaimedSamples.push(await deps.readMemory(
|
|
94712
|
+
unreclaimedSamples.push(await deps.readMemory(control));
|
|
94682
94713
|
} catch {
|
|
94683
94714
|
unreclaimedLost = true;
|
|
94684
94715
|
}
|
|
94685
94716
|
});
|
|
94686
94717
|
const settle = await timed(
|
|
94687
94718
|
`cycle ${cycle} settle`,
|
|
94688
|
-
() => waitUntilSettled(
|
|
94719
|
+
() => waitUntilSettled(control, idleMs - unreclaimedSettleMs, deps)
|
|
94689
94720
|
);
|
|
94690
94721
|
settleOutcomes.push({ phase: `cycle ${cycle}`, ...settle });
|
|
94691
94722
|
if (cycle === cycles) {
|
|
94692
94723
|
try {
|
|
94693
94724
|
const after = await timed(
|
|
94694
94725
|
"after snapshot",
|
|
94695
|
-
() => requestSnapshot(
|
|
94726
|
+
() => requestSnapshot(control, "after", options.workDir)
|
|
94696
94727
|
);
|
|
94697
94728
|
afterSnapshot = after.file;
|
|
94698
94729
|
memorySamples.push(after.sample);
|
|
94699
94730
|
} catch (cause) {
|
|
94700
94731
|
snapshotFailure = cause instanceof Error ? cause.message : String(cause);
|
|
94701
|
-
memorySamples.push(await requestGc(
|
|
94732
|
+
memorySamples.push(await requestGc(control));
|
|
94702
94733
|
}
|
|
94703
94734
|
} else {
|
|
94704
|
-
memorySamples.push(await requestGc(
|
|
94735
|
+
memorySamples.push(await requestGc(control));
|
|
94705
94736
|
}
|
|
94706
94737
|
cyclesCompleted = cycle;
|
|
94707
94738
|
}
|
|
@@ -94865,7 +94896,7 @@ function renderConfigSkeleton(routeTemplates, manifest = void 0) {
|
|
|
94865
94896
|
const values = {};
|
|
94866
94897
|
for (const segment of segments) {
|
|
94867
94898
|
const prerendered = sampled?.[segment.name];
|
|
94868
|
-
values[segment.name] = prerendered
|
|
94899
|
+
values[segment.name] = varyingValueFrom(prerendered ?? PLACEHOLDER);
|
|
94869
94900
|
}
|
|
94870
94901
|
routes[template] = values;
|
|
94871
94902
|
}
|
|
@@ -94874,6 +94905,12 @@ function renderConfigSkeleton(routeTemplates, manifest = void 0) {
|
|
|
94874
94905
|
function hasPlaceholders(skeleton) {
|
|
94875
94906
|
return skeleton.includes(PLACEHOLDER);
|
|
94876
94907
|
}
|
|
94908
|
+
function writtenConfigNote(skeleton) {
|
|
94909
|
+
if (hasPlaceholders(skeleton)) {
|
|
94910
|
+
return `replace each ${PLACEHOLDER} with the shape of a value that exists in your app, and keep the \`-{n}\` after it: a fixed value measures one cache entry and reads as flat whatever the route retains`;
|
|
94911
|
+
}
|
|
94912
|
+
return "`{n}` gives every request a different value: a prerendered one serves the warm cache and reads as flat whatever the route retains. Use `{n%200}` to revisit a fixed set of keys, and drop the marker only if the app 404s on params it never prerendered.";
|
|
94913
|
+
}
|
|
94877
94914
|
|
|
94878
94915
|
// src/unreclaimed-retention.ts
|
|
94879
94916
|
var MB = 1024 * 1024;
|
|
@@ -94971,10 +95008,10 @@ function ownerLabel(attribution) {
|
|
|
94971
95008
|
}
|
|
94972
95009
|
}
|
|
94973
95010
|
function revalidationLines(route) {
|
|
94974
|
-
if (route.
|
|
95011
|
+
if (route.servedFromIsrCache !== true) {
|
|
94975
95012
|
return [];
|
|
94976
95013
|
}
|
|
94977
|
-
const every = `revalidates every ${route.revalidatedEverySeconds}s`;
|
|
95014
|
+
const every = route.revalidatedEverySeconds === void 0 ? "caches each new key on its first request" : `revalidates every ${route.revalidatedEverySeconds}s`;
|
|
94978
95015
|
return route.revalidationDriven === true ? [
|
|
94979
95016
|
` driven through ISR revalidation (${every}; without it the load would serve the cache) \u2014 this is Next's revalidation path, so a leak that only happens on the path your users take is not in this number`
|
|
94980
95017
|
] : [
|
|
@@ -95005,7 +95042,7 @@ function cacheLines(route) {
|
|
|
95005
95042
|
lines.push(
|
|
95006
95043
|
` the load served keys this route had never cached, so some of this growth is cache residency; bound it with {n%N} in next-leak.config.json`
|
|
95007
95044
|
);
|
|
95008
|
-
if (route.
|
|
95045
|
+
if (route.servedFromIsrCache === true) {
|
|
95009
95046
|
lines.push(
|
|
95010
95047
|
` on this route {n%N} does not isolate that: a bounded key set is served from the ISR cache, so the run would drive revalidation and measure a different path. Compare the two runs rather than trusting either number alone`
|
|
95011
95048
|
);
|
|
@@ -95146,7 +95183,7 @@ function skippedGuidanceLines(report) {
|
|
|
95146
95183
|
if (skeleton === null) {
|
|
95147
95184
|
return [];
|
|
95148
95185
|
}
|
|
95149
|
-
const editing = hasPlaceholders(skeleton) ? ` Replace each ${"REPLACE-ME"} with a value that exists in your app.` : ` The shapes come from paths your build already prerendered; \`{n}\` makes every request use a different one, because reusing a prerendered value serves the warm cache and reads as flat whatever the route retains. Use \`{n%200}\` instead to revisit a fixed set of 200 keys, and drop the marker only if the app 404s on params it never prerendered.`;
|
|
95186
|
+
const editing = hasPlaceholders(skeleton) ? ` Replace each ${"REPLACE-ME"} with the shape of a value that exists in your app, and keep the \`-{n}\` after it: a fixed value measures one cache entry and reads as flat whatever the route retains.` : ` The shapes come from paths your build already prerendered; \`{n}\` makes every request use a different one, because reusing a prerendered value serves the warm cache and reads as flat whatever the route retains. Use \`{n%200}\` instead to revisit a fixed set of 200 keys, and drop the marker only if the app 404s on params it never prerendered.`;
|
|
95150
95187
|
return [
|
|
95151
95188
|
"",
|
|
95152
95189
|
`${needConfig.length} route(s) need sample params. Write this to next-leak.config.json in the app directory:${editing}`,
|
|
@@ -95233,7 +95270,7 @@ function formatReport(report) {
|
|
|
95233
95270
|
` next-leak ${report.appDir} --routes ${routeList} --cycles ${moreCycles}`
|
|
95234
95271
|
);
|
|
95235
95272
|
}
|
|
95236
|
-
return lines.join("\n");
|
|
95273
|
+
return terminalSafe(lines.join("\n"));
|
|
95237
95274
|
}
|
|
95238
95275
|
|
|
95239
95276
|
// src/environment.ts
|
|
@@ -95374,8 +95411,11 @@ function revalidateSecondsFor(manifest, route) {
|
|
|
95374
95411
|
}
|
|
95375
95412
|
return null;
|
|
95376
95413
|
}
|
|
95414
|
+
function cachesOnDemand(manifest, route) {
|
|
95415
|
+
return manifest?.dynamicRoutes?.[route]?.fallback === null;
|
|
95416
|
+
}
|
|
95377
95417
|
function revalidates(manifest, route) {
|
|
95378
|
-
return revalidateSecondsFor(manifest, route) !== null;
|
|
95418
|
+
return revalidateSecondsFor(manifest, route) !== null || cachesOnDemand(manifest, route);
|
|
95379
95419
|
}
|
|
95380
95420
|
function planRevalidation(manifest, route, userHeaders, requestPath) {
|
|
95381
95421
|
const userSupplied = Object.keys(userHeaders ?? {}).some(
|
|
@@ -95392,9 +95432,11 @@ function planRevalidation(manifest, route, userHeaders, requestPath) {
|
|
|
95392
95432
|
}
|
|
95393
95433
|
const previewModeId = manifest?.preview?.previewModeId;
|
|
95394
95434
|
if (previewModeId === void 0 || previewModeId === "") {
|
|
95435
|
+
const seconds = revalidateSecondsFor(manifest, route);
|
|
95436
|
+
const cached = seconds === null ? "is served from the ISR cache" : `revalidates every ${seconds}s`;
|
|
95395
95437
|
return {
|
|
95396
95438
|
kind: "cannot-drive",
|
|
95397
|
-
reason:
|
|
95439
|
+
reason: `${cached}, but the build's prerender-manifest.json carries no previewModeId \u2014 load would serve the cache and measure nothing. Set "${REVALIDATE_HEADER}" in next-leak.config.json to drive it.`
|
|
95398
95440
|
};
|
|
95399
95441
|
}
|
|
95400
95442
|
return { kind: "drive", headers: { [REVALIDATE_HEADER]: previewModeId } };
|
|
@@ -95512,6 +95554,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95512
95554
|
const { deps, options, target, workDir, routeConfig, registry, nextVersion, progress } = context;
|
|
95513
95555
|
const plan = planRevalidation(target.prerender, route.path, routeConfig.headers, requestPath);
|
|
95514
95556
|
const revalidateSeconds = revalidateSecondsFor(target.prerender, route.path);
|
|
95557
|
+
const servedFromIsrCache = revalidates(target.prerender, route.path);
|
|
95515
95558
|
const bounded = boundedMarkerOf(requestPath);
|
|
95516
95559
|
const driven = plan.kind === "drive" ? plan.headers : {};
|
|
95517
95560
|
const cacheDriven = plan.kind === "no-cache-to-drive" || plan.kind === "drive" && bounded === null;
|
|
@@ -95554,7 +95597,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95554
95597
|
warmupRequests: options.warmupRequests ?? RITUAL_DEFAULTS.warmupRequests,
|
|
95555
95598
|
// Decides whether the cache-residency remedy exists on this route: bounding
|
|
95556
95599
|
// the keys of an ISR route hands the requests back to the cache.
|
|
95557
|
-
revalidatesFromCache:
|
|
95600
|
+
revalidatesFromCache: servedFromIsrCache,
|
|
95558
95601
|
...routeConfig.abandonAfterMs !== void 0 && {
|
|
95559
95602
|
abandonAfterMs: routeConfig.abandonAfterMs
|
|
95560
95603
|
},
|
|
@@ -95597,6 +95640,7 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95597
95640
|
memorySamples: result.memorySamples,
|
|
95598
95641
|
peaks: result.peaks,
|
|
95599
95642
|
...revalidateSeconds !== null && { revalidatedEverySeconds: revalidateSeconds },
|
|
95643
|
+
...servedFromIsrCache && { servedFromIsrCache: true },
|
|
95600
95644
|
...plan.kind === "drive" && { revalidationDriven: true },
|
|
95601
95645
|
...bounded !== null && { keyCardinality: bounded.bound },
|
|
95602
95646
|
unreclaimedSamples: result.unreclaimedSamples,
|
|
@@ -95624,8 +95668,8 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95624
95668
|
};
|
|
95625
95669
|
}
|
|
95626
95670
|
async function writeEvidenceBundle(report, workDir) {
|
|
95627
|
-
const { renderHtmlReport } = await import("./html-report-
|
|
95628
|
-
const { renderIssueMarkdown } = await import("./issue-report-
|
|
95671
|
+
const { renderHtmlReport } = await import("./html-report-VNTPHLO3.js");
|
|
95672
|
+
const { renderIssueMarkdown } = await import("./issue-report-4RW5YOT3.js");
|
|
95629
95673
|
for (const route of report.routes) {
|
|
95630
95674
|
if (route.status === "measured" && warrantsIssueDraft(route)) {
|
|
95631
95675
|
const file = path9.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
|
|
@@ -95897,6 +95941,7 @@ export {
|
|
|
95897
95941
|
sourceIndexAt,
|
|
95898
95942
|
extractChunkModules,
|
|
95899
95943
|
extractModuleRegistry,
|
|
95944
|
+
terminalSafe,
|
|
95900
95945
|
appPathsManifestSchema,
|
|
95901
95946
|
routesManifestSchema,
|
|
95902
95947
|
discoverPagesRoutes,
|
|
@@ -95920,7 +95965,7 @@ export {
|
|
|
95920
95965
|
runRitual,
|
|
95921
95966
|
runSelfCheck,
|
|
95922
95967
|
renderConfigSkeleton,
|
|
95923
|
-
|
|
95968
|
+
writtenConfigNote,
|
|
95924
95969
|
formatReport,
|
|
95925
95970
|
captureEnvironment,
|
|
95926
95971
|
loadSignatures,
|
|
@@ -11,6 +11,8 @@ var STEPWISE_MAX_DRAWDOWN_RATIO = 0.1;
|
|
|
11
11
|
var ACQUITTAL_MAX_GROWTH_MULTIPLE = 8;
|
|
12
12
|
var SATURATION_MIN_CYCLES = 3;
|
|
13
13
|
var SATURATION_MAX_FINAL_RATIO = 0.5;
|
|
14
|
+
var SETTLED_TAIL_MIN_CYCLES = 3;
|
|
15
|
+
var SETTLED_TAIL_MAX_SPREAD = 1.5;
|
|
14
16
|
function maxDrawdown(samples) {
|
|
15
17
|
let peak = samples[1] ?? 0;
|
|
16
18
|
let worst = 0;
|
|
@@ -46,8 +48,20 @@ function isSaturating(deltas, minGrowth) {
|
|
|
46
48
|
if (last > first * SATURATION_MAX_FINAL_RATIO) {
|
|
47
49
|
return false;
|
|
48
50
|
}
|
|
51
|
+
if (hasSettledAboveGate(deltas, minGrowth)) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
49
54
|
return isHeadingDown(deltas);
|
|
50
55
|
}
|
|
56
|
+
function hasSettledAboveGate(deltas, minGrowth) {
|
|
57
|
+
const size = Math.max(SETTLED_TAIL_MIN_CYCLES, Math.ceil(deltas.length / 3));
|
|
58
|
+
const tail = deltas.slice(-size);
|
|
59
|
+
const smallest = Math.min(...tail);
|
|
60
|
+
if (smallest < minGrowth) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return Math.max(...tail) <= smallest * SETTLED_TAIL_MAX_SPREAD;
|
|
64
|
+
}
|
|
51
65
|
function hasArrivedWithoutResuming(deltas, minGrowth) {
|
|
52
66
|
const stop = deltas.findIndex((delta) => delta <= 0);
|
|
53
67
|
if (stop === -1) {
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
|
|
3
|
+
// src/control-server.ts
|
|
4
|
+
import { timingSafeEqual } from "crypto";
|
|
5
|
+
import http from "http";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import { writeHeapSnapshot } from "v8";
|
|
8
|
+
var g = globalThis;
|
|
9
|
+
var tick = () => new Promise((resolve) => setImmediate(resolve));
|
|
10
|
+
async function forceGc(passes = 3) {
|
|
11
|
+
if (typeof g.gc !== "function") {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
for (let i = 0; i < passes; i += 1) {
|
|
15
|
+
g.gc();
|
|
16
|
+
await tick();
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
var requestProbe = globalThis;
|
|
21
|
+
function sampleMemory(gcExposed) {
|
|
22
|
+
const usage = process.memoryUsage();
|
|
23
|
+
return {
|
|
24
|
+
gcExposed,
|
|
25
|
+
heapUsed: usage.heapUsed,
|
|
26
|
+
rss: usage.rss,
|
|
27
|
+
external: usage.external,
|
|
28
|
+
arrayBuffers: usage.arrayBuffers,
|
|
29
|
+
pid: process.pid,
|
|
30
|
+
ppid: process.ppid,
|
|
31
|
+
argv: process.argv,
|
|
32
|
+
cwd: process.cwd(),
|
|
33
|
+
...requestProbe.__nextLeakServedRequests === void 0 ? {} : { servedRequests: requestProbe.__nextLeakServedRequests }
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
var CONTROL_TOKEN_HEADER = "x-next-leak-token";
|
|
37
|
+
async function startControlServer(options) {
|
|
38
|
+
if (options.token === "") {
|
|
39
|
+
throw new Error("the control server needs a token and was given an empty one");
|
|
40
|
+
}
|
|
41
|
+
const write = options.writeSnapshot ?? writeHeapSnapshot;
|
|
42
|
+
const expected = Buffer.from(options.token);
|
|
43
|
+
const carriesToken = (request) => {
|
|
44
|
+
const header = request.headers[CONTROL_TOKEN_HEADER];
|
|
45
|
+
if (typeof header !== "string") {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const given = Buffer.from(header);
|
|
49
|
+
return given.length === expected.length && timingSafeEqual(given, expected);
|
|
50
|
+
};
|
|
51
|
+
const server = http.createServer((request, response) => {
|
|
52
|
+
void handle(request, response);
|
|
53
|
+
});
|
|
54
|
+
async function handle(request, response) {
|
|
55
|
+
const url = new URL(request.url ?? "/", "http://control.local");
|
|
56
|
+
const respond = (status, body) => {
|
|
57
|
+
response.writeHead(status, { "content-type": "application/json" });
|
|
58
|
+
response.end(JSON.stringify(body));
|
|
59
|
+
};
|
|
60
|
+
if (!carriesToken(request)) {
|
|
61
|
+
respond(403, { error: `missing or wrong ${CONTROL_TOKEN_HEADER} header` });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
if (url.pathname === "/gc") {
|
|
66
|
+
const gcExposed = await forceGc();
|
|
67
|
+
respond(200, sampleMemory(gcExposed));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (url.pathname === "/mem") {
|
|
71
|
+
respond(200, sampleMemory(typeof g.gc === "function"));
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (url.pathname === "/snapshot") {
|
|
75
|
+
const name = url.searchParams.get("name");
|
|
76
|
+
if (name === null || name === "") {
|
|
77
|
+
respond(400, { error: "missing ?name=<label>" });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const gcExposed = await forceGc();
|
|
81
|
+
const sample = sampleMemory(gcExposed);
|
|
82
|
+
const file = write(
|
|
83
|
+
path.join(options.snapshotDir, `${path.basename(name)}.heapsnapshot`)
|
|
84
|
+
);
|
|
85
|
+
respond(200, { file, sample });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
respond(404, { error: `unknown path ${url.pathname}` });
|
|
89
|
+
} catch (cause) {
|
|
90
|
+
respond(500, { error: cause instanceof Error ? cause.message : String(cause) });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
await new Promise((resolve, reject) => {
|
|
94
|
+
server.once("error", reject);
|
|
95
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
96
|
+
});
|
|
97
|
+
server.unref();
|
|
98
|
+
const address = server.address();
|
|
99
|
+
if (address === null || typeof address === "string") {
|
|
100
|
+
throw new Error("control server has no bound port");
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
port: address.port,
|
|
104
|
+
close: () => new Promise((resolve, reject) => {
|
|
105
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
106
|
+
})
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export {
|
|
111
|
+
requestProbe,
|
|
112
|
+
CONTROL_TOKEN_HEADER,
|
|
113
|
+
startControlServer
|
|
114
|
+
};
|
|
@@ -2,12 +2,12 @@ import { createRequire as __nextLeakCreateRequire } from 'node:module';import {
|
|
|
2
2
|
import {
|
|
3
3
|
effectiveVerdict,
|
|
4
4
|
withdrawnByDisagreement
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-GU7IJUNY.js";
|
|
6
6
|
import {
|
|
7
7
|
assessPeakPressure,
|
|
8
8
|
describePeakPressure,
|
|
9
9
|
retainedAfterLoad
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-VTKOO3YJ.js";
|
|
11
11
|
|
|
12
12
|
// src/html-report.ts
|
|
13
13
|
var MB = 1024 * 1024;
|
|
@@ -20,23 +20,16 @@ function assessPeakPressure(input) {
|
|
|
20
20
|
const heapLimitBytes = input.maxOldSpaceMb * MB;
|
|
21
21
|
const peakHeap = maxOf(sampled, (peak) => peak.heapUsed);
|
|
22
22
|
const peakRss = maxOf(sampled, (peak) => peak.rss);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
peakBytes: peakHeap,
|
|
27
|
-
retainedBytes: input.retainedHeapBytes,
|
|
28
|
-
heapLimitBytes
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
if (reachesRssCeiling(peakRss, input.retainedHeapBytes)) {
|
|
32
|
-
return {
|
|
33
|
-
class: "rss",
|
|
34
|
-
peakBytes: peakRss,
|
|
35
|
-
retainedBytes: input.retainedHeapBytes,
|
|
36
|
-
heapLimitBytes
|
|
37
|
-
};
|
|
23
|
+
const pressureClass = sustainedClass(input) ?? (reachesHeapCeiling(peakHeap, heapLimitBytes) ? "heap" : reachesRssCeiling(peakRss, input.retainedHeapBytes) ? "rss" : null);
|
|
24
|
+
if (pressureClass === null) {
|
|
25
|
+
return null;
|
|
38
26
|
}
|
|
39
|
-
return
|
|
27
|
+
return {
|
|
28
|
+
class: pressureClass,
|
|
29
|
+
peakBytes: pressureClass === "heap" ? peakHeap : peakRss,
|
|
30
|
+
retainedBytes: input.retainedHeapBytes,
|
|
31
|
+
heapLimitBytes
|
|
32
|
+
};
|
|
40
33
|
}
|
|
41
34
|
var PRESSURE_MIN_SETTLED_CYCLES = 2;
|
|
42
35
|
var reachesCeiling = (peak, pressureClass, input) => pressureClass === "heap" ? reachesHeapCeiling(peak.heapUsed, input.maxOldSpaceMb * MB) : reachesRssCeiling(peak.rss, input.retainedHeapBytes);
|
|
@@ -50,19 +43,15 @@ function isSustained(input, pressureClass) {
|
|
|
50
43
|
}
|
|
51
44
|
return settled.every((peak) => reachesCeiling(peak, pressureClass, input));
|
|
52
45
|
}
|
|
46
|
+
var PRESSURE_CLASSES = ["heap", "rss"];
|
|
47
|
+
function sustainedClass(input) {
|
|
48
|
+
return PRESSURE_CLASSES.find((pressureClass) => isSustained(input, pressureClass)) ?? null;
|
|
49
|
+
}
|
|
53
50
|
function assessPressureVerdict(input) {
|
|
54
51
|
if (input.trend.verdict !== "stable" && input.trend.verdict !== "saturating") {
|
|
55
52
|
return input.trend;
|
|
56
53
|
}
|
|
57
|
-
|
|
58
|
-
peaks: input.peaks,
|
|
59
|
-
retainedHeapBytes: input.retainedHeapBytes,
|
|
60
|
-
maxOldSpaceMb: input.maxOldSpaceMb
|
|
61
|
-
});
|
|
62
|
-
if (pressure === null) {
|
|
63
|
-
return input.trend;
|
|
64
|
-
}
|
|
65
|
-
if (!isSustained(input, pressure.class)) {
|
|
54
|
+
if (sustainedClass(input) === null) {
|
|
66
55
|
return input.trend;
|
|
67
56
|
}
|
|
68
57
|
return { ...input.trend, verdict: "pressure" };
|
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
extractModuleRegistry,
|
|
13
13
|
formatReport,
|
|
14
14
|
freePort,
|
|
15
|
-
hasPlaceholders,
|
|
16
15
|
helpText,
|
|
17
16
|
killActiveChildren,
|
|
18
17
|
parseCliArgs,
|
|
@@ -20,13 +19,16 @@ import {
|
|
|
20
19
|
renderConfigSkeleton,
|
|
21
20
|
runMeasurement,
|
|
22
21
|
runSelfCheck,
|
|
22
|
+
terminalSafe,
|
|
23
23
|
unregisterChild,
|
|
24
|
-
validateTarget
|
|
25
|
-
|
|
24
|
+
validateTarget,
|
|
25
|
+
writtenConfigNote
|
|
26
|
+
} from "./chunk-GBRBWUZZ.js";
|
|
27
|
+
import "./chunk-IKWF7SSB.js";
|
|
26
28
|
import {
|
|
27
29
|
classifyTrend
|
|
28
|
-
} from "./chunk-
|
|
29
|
-
import "./chunk-
|
|
30
|
+
} from "./chunk-GU7IJUNY.js";
|
|
31
|
+
import "./chunk-VTKOO3YJ.js";
|
|
30
32
|
import "./chunk-6XYFBOL2.js";
|
|
31
33
|
|
|
32
34
|
// src/cli.ts
|
|
@@ -254,7 +256,7 @@ function attributionLines(attribution) {
|
|
|
254
256
|
}
|
|
255
257
|
return lines;
|
|
256
258
|
}
|
|
257
|
-
function
|
|
259
|
+
function buildReportText(result, attribution = null) {
|
|
258
260
|
const lines = [`next-leak build \xB7 ${result.appDir}`, ""];
|
|
259
261
|
if (result.strippedCapWarning !== null) {
|
|
260
262
|
lines.push(` \u25B2 ${result.strippedCapWarning}`, "");
|
|
@@ -332,6 +334,9 @@ function formatBuildReport(result, attribution = null) {
|
|
|
332
334
|
);
|
|
333
335
|
return lines.join("\n");
|
|
334
336
|
}
|
|
337
|
+
function formatBuildReport(result, attribution = null) {
|
|
338
|
+
return terminalSafe(buildReportText(result, attribution));
|
|
339
|
+
}
|
|
335
340
|
|
|
336
341
|
// src/build-run.ts
|
|
337
342
|
import { spawn } from "child_process";
|
|
@@ -868,12 +873,12 @@ async function runBuildCommand(appDir, outputDir, attribute) {
|
|
|
868
873
|
// is worth asking for, not worth risking every run for.
|
|
869
874
|
...attribute && { workDir },
|
|
870
875
|
signal: aborter.signal,
|
|
871
|
-
onProgress: (message) => console.error(`\xB7 ${message}`)
|
|
876
|
+
onProgress: (message) => console.error(`\xB7 ${terminalSafe(message)}`)
|
|
872
877
|
});
|
|
873
878
|
const attribution = await attributeBuildCapture(
|
|
874
879
|
result,
|
|
875
880
|
appDir,
|
|
876
|
-
(message) => console.error(`\xB7 ${message}`)
|
|
881
|
+
(message) => console.error(`\xB7 ${terminalSafe(message)}`)
|
|
877
882
|
);
|
|
878
883
|
console.log(formatBuildReport(result, attribution));
|
|
879
884
|
if (aborter.signal.aborted) {
|
|
@@ -902,13 +907,7 @@ ${skeleton}`
|
|
|
902
907
|
return;
|
|
903
908
|
}
|
|
904
909
|
console.log(`wrote ${file}`);
|
|
905
|
-
|
|
906
|
-
console.log("replace each REPLACE-ME with a value that exists in your app");
|
|
907
|
-
} else {
|
|
908
|
-
console.log(
|
|
909
|
-
"`{n}` gives every request a different value: a prerendered one serves the warm cache and reads as flat whatever the route retains. Use `{n%200}` to revisit a fixed set of keys, and drop the marker only if the app 404s on params it never prerendered."
|
|
910
|
-
);
|
|
911
|
-
}
|
|
910
|
+
console.log(writtenConfigNote(skeleton));
|
|
912
911
|
}
|
|
913
912
|
async function main() {
|
|
914
913
|
const parsed = parseCliArgs(process.argv.slice(2));
|
|
@@ -949,9 +948,9 @@ async function main() {
|
|
|
949
948
|
...options.idleSeconds !== null && { idleMs: options.idleSeconds * 1e3 },
|
|
950
949
|
...options.maxOldSpaceMb !== null && { maxOldSpaceMb: options.maxOldSpaceMb }
|
|
951
950
|
});
|
|
952
|
-
console.error(`\xB7 ${check.summary}`);
|
|
951
|
+
console.error(`\xB7 ${terminalSafe(check.summary)}`);
|
|
953
952
|
if (!check.passed) {
|
|
954
|
-
console.error(`error: ${check.summary}`);
|
|
953
|
+
console.error(`error: ${terminalSafe(check.summary)}`);
|
|
955
954
|
process.exitCode = 1;
|
|
956
955
|
return;
|
|
957
956
|
}
|
|
@@ -977,7 +976,7 @@ async function main() {
|
|
|
977
976
|
...options.diffAll && { diffAll: true },
|
|
978
977
|
...options.noResolve && { resolveInconclusive: false },
|
|
979
978
|
...options.output !== null && { outputDir: options.output },
|
|
980
|
-
onProgress: (message) => console.error(`\xB7 ${message}`)
|
|
979
|
+
onProgress: (message) => console.error(`\xB7 ${terminalSafe(message)}`)
|
|
981
980
|
});
|
|
982
981
|
console.log(formatReport(report));
|
|
983
982
|
if (aborter.signal.aborted) {
|
|
@@ -995,7 +994,7 @@ try {
|
|
|
995
994
|
await main();
|
|
996
995
|
} catch (cause) {
|
|
997
996
|
if (cause instanceof TargetError || cause instanceof RouteConfigError) {
|
|
998
|
-
console.error(`error: ${cause.message}`);
|
|
997
|
+
console.error(`error: ${terminalSafe(cause.message)}`);
|
|
999
998
|
} else {
|
|
1000
999
|
console.error(cause);
|
|
1001
1000
|
}
|
package/dist/control-client.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type HeapSample } from "./control-server.js";
|
|
2
|
+
/** Where a measured process listens for control requests, and the secret it expects. */
|
|
3
|
+
export type ControlChannel = {
|
|
4
|
+
port: number;
|
|
5
|
+
token: string;
|
|
6
|
+
};
|
|
2
7
|
declare function deadlineFor(pathname: string): number;
|
|
3
8
|
/** Where the control server will put `<name>.heapsnapshot`, so the client can watch it grow. */
|
|
4
9
|
export declare function snapshotPathFor(workDir: string, name: string): string;
|
|
@@ -13,14 +18,14 @@ export type SnapshotWatch = {
|
|
|
13
18
|
/** How often to look. */
|
|
14
19
|
pollMs: number;
|
|
15
20
|
};
|
|
16
|
-
declare function request(
|
|
21
|
+
declare function request(control: ControlChannel, pathname: string, deadlineMs?: number, watch?: SnapshotWatch): Promise<unknown>;
|
|
17
22
|
/** Forces GC in the measured process and returns a settled memory sample. */
|
|
18
|
-
export declare function requestGc(
|
|
23
|
+
export declare function requestGc(control: ControlChannel): Promise<HeapSample>;
|
|
19
24
|
/**
|
|
20
25
|
* Reads memory without collecting. Used to poll a process under load, where a
|
|
21
26
|
* forced GC would change the number being read.
|
|
22
27
|
*/
|
|
23
|
-
export declare function requestMemory(
|
|
28
|
+
export declare function requestMemory(control: ControlChannel): Promise<HeapSample>;
|
|
24
29
|
/**
|
|
25
30
|
* Forces GC, writes a named heap snapshot, and returns its path and sample.
|
|
26
31
|
*
|
|
@@ -29,7 +34,7 @@ export declare function requestMemory(port: number): Promise<HeapSample>;
|
|
|
29
34
|
* the call falls back to the plain wall-clock bound, which cannot tell a big
|
|
30
35
|
* snapshot from a wedged process.
|
|
31
36
|
*/
|
|
32
|
-
export declare function requestSnapshot(
|
|
37
|
+
export declare function requestSnapshot(control: ControlChannel, name: string, workDir?: string): Promise<{
|
|
33
38
|
file: string;
|
|
34
39
|
sample: HeapSample;
|
|
35
40
|
}>;
|
package/dist/control-server.d.ts
CHANGED
|
@@ -33,9 +33,13 @@ export type HeapSample = {
|
|
|
33
33
|
export declare const requestProbe: typeof globalThis & {
|
|
34
34
|
__nextLeakServedRequests?: number;
|
|
35
35
|
};
|
|
36
|
+
/** Header that carries the shared secret on every control request. */
|
|
37
|
+
export declare const CONTROL_TOKEN_HEADER = "x-next-leak-token";
|
|
36
38
|
export type ControlServerOptions = {
|
|
37
39
|
/** Directory where heap snapshots are written. */
|
|
38
40
|
snapshotDir: string;
|
|
41
|
+
/** Secret the launcher generated for this run; a request without it is refused. */
|
|
42
|
+
token: string;
|
|
39
43
|
/** Injectable for tests; defaults to `v8.writeHeapSnapshot`. */
|
|
40
44
|
writeSnapshot?: (file: string) => string;
|
|
41
45
|
};
|
|
@@ -50,5 +54,11 @@ export type ControlServer = {
|
|
|
50
54
|
* - `GET /mem` — respond with a memory sample WITHOUT collecting.
|
|
51
55
|
* - `GET /snapshot?name=<label>` — force GC, write `<label>.heapsnapshot`
|
|
52
56
|
* into `snapshotDir`, respond `{ file, sample }` only once fully written.
|
|
57
|
+
*
|
|
58
|
+
* Every request must carry the run's token. The socket is bound to loopback,
|
|
59
|
+
* which keeps other machines out and nobody else: any local process can reach
|
|
60
|
+
* it, and so can a web page open in a browser on the same machine. Without
|
|
61
|
+
* the token either could read the process's argv and working directory, or
|
|
62
|
+
* ask for heap snapshots until the disk is full.
|
|
53
63
|
*/
|
|
54
64
|
export declare function startControlServer(options: ControlServerOptions): Promise<ControlServer>;
|
|
@@ -1,9 +1,9 @@
|
|
|
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-
|
|
6
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-QNNVRQ4L.js";
|
|
5
|
+
import "./chunk-GU7IJUNY.js";
|
|
6
|
+
import "./chunk-VTKOO3YJ.js";
|
|
7
7
|
import "./chunk-6XYFBOL2.js";
|
|
8
8
|
export {
|
|
9
9
|
renderHtmlReport
|
package/dist/index.js
CHANGED
|
@@ -41,17 +41,18 @@ import {
|
|
|
41
41
|
sourceIndexAt,
|
|
42
42
|
summarizeBaseline,
|
|
43
43
|
validateTarget
|
|
44
|
-
} from "./chunk-
|
|
44
|
+
} from "./chunk-GBRBWUZZ.js";
|
|
45
|
+
import "./chunk-IKWF7SSB.js";
|
|
45
46
|
import {
|
|
46
47
|
renderHtmlReport
|
|
47
|
-
} from "./chunk-
|
|
48
|
+
} from "./chunk-QNNVRQ4L.js";
|
|
48
49
|
import {
|
|
49
50
|
classifyTrend
|
|
50
|
-
} from "./chunk-
|
|
51
|
+
} from "./chunk-GU7IJUNY.js";
|
|
51
52
|
import {
|
|
52
53
|
renderIssueMarkdown
|
|
53
|
-
} from "./chunk-
|
|
54
|
-
import "./chunk-
|
|
54
|
+
} from "./chunk-FJVY5MKV.js";
|
|
55
|
+
import "./chunk-VTKOO3YJ.js";
|
|
55
56
|
import "./chunk-6XYFBOL2.js";
|
|
56
57
|
export {
|
|
57
58
|
LaunchError,
|
|
@@ -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
|
renderIssueMarkdown
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-FJVY5MKV.js";
|
|
5
|
+
import "./chunk-VTKOO3YJ.js";
|
|
6
6
|
import "./chunk-6XYFBOL2.js";
|
|
7
7
|
export {
|
|
8
8
|
renderIssueMarkdown
|
package/dist/launcher.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ export type LaunchedApp = {
|
|
|
49
49
|
pid: number;
|
|
50
50
|
appPort: number;
|
|
51
51
|
controlPort: number;
|
|
52
|
+
/** Secret the control channel expects on every request; generated per launch. */
|
|
53
|
+
controlToken: string;
|
|
52
54
|
/**
|
|
53
55
|
* Why the measured process is gone, or null while it is alive. Without it
|
|
54
56
|
* a child that died mid-run surfaces as "fetch failed", which reads like a
|
package/dist/manifests.d.ts
CHANGED
|
@@ -34,6 +34,9 @@ export declare const prerenderManifestSchema: z.ZodObject<{
|
|
|
34
34
|
initialRevalidateSeconds: z.ZodOptional<z.ZodUnion<readonly [z.ZodNumber, z.ZodLiteral<false>]>>;
|
|
35
35
|
srcRoute: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
36
36
|
}, z.core.$loose>>>;
|
|
37
|
+
dynamicRoutes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
38
|
+
fallback: z.ZodOptional<z.ZodUnknown>;
|
|
39
|
+
}, z.core.$loose>>>;
|
|
37
40
|
preview: z.ZodOptional<z.ZodObject<{
|
|
38
41
|
previewModeId: z.ZodString;
|
|
39
42
|
}, z.core.$loose>>;
|
package/dist/peak-pressure.d.ts
CHANGED
|
@@ -80,17 +80,17 @@ export type PressureVerdictInput = {
|
|
|
80
80
|
* requests, because a forced GC handed all of it back before every sample. That
|
|
81
81
|
* is the trap next-leak exists to warn other people about.
|
|
82
82
|
*
|
|
83
|
-
*
|
|
83
|
+
* Two conditions, and no threshold of its own: the ceiling rules are
|
|
84
84
|
* `assessPeakPressure`'s, asked of each cycle instead of the highest reading.
|
|
85
85
|
*
|
|
86
86
|
* - **The post-GC verdict is `stable` or `saturating`.** `leak` is already the
|
|
87
87
|
* worse news and `inconclusive` is an admission that the series did not
|
|
88
88
|
* decide — promoting *that* to an accusation would be inventing a finding out
|
|
89
89
|
* of a measurement that failed.
|
|
90
|
-
* - **
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
90
|
+
* - **Every settled cycle reached the same ceiling**, not just the highest one.
|
|
91
|
+
* This is the condition that makes the verdict safe, and it contains the one
|
|
92
|
+
* that used to stand beside it: if every settled cycle reached a ceiling, so
|
|
93
|
+
* did the highest reading of the run. A single high peak is an
|
|
94
94
|
* episode and gets only the note; a process that returns to the ceiling every
|
|
95
95
|
* time it serves traffic is describing what it does under load, which is the
|
|
96
96
|
* thing a container is sized against.
|
package/dist/ritual.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ControlChannel } from "./control-client.js";
|
|
1
2
|
import type { HeapSample } from "./control-server.js";
|
|
2
3
|
import { launchInstrumented } from "./launcher.js";
|
|
3
4
|
import { runAbandonPhase, type AbandonOrigin } from "./abandon-load.js";
|
|
@@ -164,7 +165,7 @@ export type RitualDeps = {
|
|
|
164
165
|
abandon: typeof runAbandonPhase;
|
|
165
166
|
sleep: (ms: number) => Promise<void>;
|
|
166
167
|
/** GC-free read, polled while the app is under load. */
|
|
167
|
-
readMemory: (
|
|
168
|
+
readMemory: (control: ControlChannel) => Promise<HeapSample>;
|
|
168
169
|
};
|
|
169
170
|
/**
|
|
170
171
|
* The pre-collection wait, taken *out of* the idle rather than added to it.
|
package/dist/route-guidance.d.ts
CHANGED
|
@@ -38,3 +38,5 @@ export declare function sampleValuesFromManifest(manifest: PrerenderManifest | u
|
|
|
38
38
|
export declare function renderConfigSkeleton(routeTemplates: readonly string[], manifest?: PrerenderManifest | undefined): string | null;
|
|
39
39
|
/** Whether a rendered skeleton still needs the user to fill anything in. */
|
|
40
40
|
export declare function hasPlaceholders(skeleton: string): boolean;
|
|
41
|
+
/** What `--write-config` prints after writing a skeleton: how to edit it. */
|
|
42
|
+
export declare function writtenConfigNote(skeleton: string): string;
|
package/dist/runner.d.ts
CHANGED
|
@@ -113,6 +113,12 @@ export type RouteReport = {
|
|
|
113
113
|
* cache and one measured against a re-render are different experiments.
|
|
114
114
|
*/
|
|
115
115
|
revalidatedEverySeconds?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Set on every route served from the ISR cache, including a dynamic
|
|
118
|
+
* template that caches each new key with no prerendered entry to carry
|
|
119
|
+
* its period — the period is then absent, but the cache is in play.
|
|
120
|
+
*/
|
|
121
|
+
servedFromIsrCache?: true;
|
|
116
122
|
/**
|
|
117
123
|
* Whether the load carried the build's own revalidation header. Set apart
|
|
118
124
|
* from the period because the two answer different questions: the period
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text as it can be written to a terminal.
|
|
3
|
+
*
|
|
4
|
+
* Heap node names, source paths, route names and the build's own output all
|
|
5
|
+
* come from the measured app, and a terminal obeys what it is sent: an escape
|
|
6
|
+
* sequence in a string name can clear the screen, move the cursor over a
|
|
7
|
+
* verdict already printed, or retitle the window. next-leak writes no
|
|
8
|
+
* sequences of its own, so none that reaches the output is ours to keep.
|
|
9
|
+
*
|
|
10
|
+
* A complete sequence is an instruction, not text, and is dropped. A control
|
|
11
|
+
* character left on its own is shown as `\xNN`: it was in the value, and a
|
|
12
|
+
* reader comparing the report against `run.json` should still find it.
|
|
13
|
+
*/
|
|
14
|
+
export declare function terminalSafe(text: string): string;
|