next-leak 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bootstrap.js +12 -93
- package/dist/build-attribution.d.ts +26 -4
- package/dist/build-report.d.ts +5 -9
- package/dist/build-run.d.ts +9 -0
- package/dist/{chunk-YPVBX3QA.js → chunk-FJVY5MKV.js} +34 -11
- package/dist/chunk-IKWF7SSB.js +114 -0
- package/dist/{chunk-UU4RXOZO.js → chunk-QUFL2FXU.js} +12 -1
- package/dist/{chunk-L5KWU2RU.js → chunk-UKWFONEI.js} +65 -35
- package/dist/{chunk-G2777DKL.js → chunk-UOAW5UA5.js} +10 -3
- package/dist/{chunk-E464SO7G.js → chunk-VTKOO3YJ.js} +14 -25
- package/dist/cli.js +58 -15
- package/dist/control-client.d.ts +10 -5
- package/dist/control-server.d.ts +10 -0
- package/dist/{html-report-DLKFB3IU.js → html-report-ZXDEJ6X5.js} +3 -3
- package/dist/index.js +6 -5
- package/dist/{issue-report-OALZZLVR.js → issue-report-4RW5YOT3.js} +2 -2
- package/dist/launcher.d.ts +2 -0
- package/dist/peak-pressure.d.ts +5 -5
- package/dist/ritual.d.ts +2 -1
- package/dist/terminal-text.d.ts +14 -0
- package/package.json +1 -1
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) {
|
|
@@ -15,6 +15,26 @@ export type BuildAttribution = {
|
|
|
15
15
|
baselineFile: string;
|
|
16
16
|
afterFile: string;
|
|
17
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* Why a run that asked for attribution came back without one.
|
|
20
|
+
*
|
|
21
|
+
* An absent section and a failed diff read the same in a report, and they are
|
|
22
|
+
* opposite findings: one says nobody asked what the worker retained, the other
|
|
23
|
+
* says the question was asked and the evidence could not be read. The build
|
|
24
|
+
* path fails this way hardest on the builds worth measuring — a snapshot past
|
|
25
|
+
* roughly a gigabyte of worker rss cannot be parsed back, so the bigger the
|
|
26
|
+
* build grew, the likelier the report says nothing about what grew.
|
|
27
|
+
*/
|
|
28
|
+
export type BuildAttributionGap = {
|
|
29
|
+
/**
|
|
30
|
+
* `capture-missed`: the run was told to capture and never got a usable pair.
|
|
31
|
+
* `snapshot-unreadable`: the pair exists and the diff refused it.
|
|
32
|
+
*/
|
|
33
|
+
reason: "capture-missed" | "snapshot-unreadable";
|
|
34
|
+
detail: string;
|
|
35
|
+
};
|
|
36
|
+
/** Narrows the outcome of `attributeBuildCapture`. */
|
|
37
|
+
export declare function isAttributionGap(outcome: BuildAttribution | BuildAttributionGap | null): outcome is BuildAttributionGap;
|
|
18
38
|
export type BuildAttributionDeps = {
|
|
19
39
|
diff: typeof diffSnapshotFiles;
|
|
20
40
|
registry: typeof extractModuleRegistry;
|
|
@@ -22,12 +42,14 @@ export type BuildAttributionDeps = {
|
|
|
22
42
|
/**
|
|
23
43
|
* Names what a build's worker retained, from the pair the run captured.
|
|
24
44
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
45
|
+
* Never throws on a failure path. A build measurement stands on its curve and
|
|
46
|
+
* its verdict; this is an addition to the report, and an addition that can
|
|
47
|
+
* fail must not be able to take the report with it. It returns a gap instead,
|
|
48
|
+
* so the report can tell a question nobody asked from one it could not answer.
|
|
49
|
+
* Null is reserved for the run that never asked.
|
|
28
50
|
*
|
|
29
51
|
* The registry is read from `.next/server` *after* the build, never during it:
|
|
30
52
|
* a half-written chunk can resolve a module id to the wrong source, and naming
|
|
31
53
|
* the wrong owner confidently is worse than naming none.
|
|
32
54
|
*/
|
|
33
|
-
export declare function attributeBuildCapture(result: BuildRunResult, appDir: string, onProgress?: (message: string) => void, deps?: BuildAttributionDeps): Promise<BuildAttribution | null>;
|
|
55
|
+
export declare function attributeBuildCapture(result: BuildRunResult, appDir: string, onProgress?: (message: string) => void, deps?: BuildAttributionDeps): Promise<BuildAttribution | BuildAttributionGap | null>;
|
package/dist/build-report.d.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import type
|
|
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
|
-
export declare function formatBuildReport(result: BuildRunResult, attribution?: BuildAttribution | null): string;
|
|
8
|
+
export declare function formatBuildReport(result: BuildRunResult, attribution?: BuildAttribution | BuildAttributionGap | null): string;
|
package/dist/build-run.d.ts
CHANGED
|
@@ -51,6 +51,15 @@ export type BuildRunResult = {
|
|
|
51
51
|
* produced whether or not capture worked.
|
|
52
52
|
*/
|
|
53
53
|
capture: BuildCapture | null;
|
|
54
|
+
/**
|
|
55
|
+
* Whether this run was asked to capture at all. Without it a null `capture`
|
|
56
|
+
* is ambiguous — nobody asked, or it was asked and missed — and the report
|
|
57
|
+
* has to choose between staying silent on a lost measurement and nagging
|
|
58
|
+
* every run that never wanted one.
|
|
59
|
+
*/
|
|
60
|
+
captureRequested: boolean;
|
|
61
|
+
/** How far capture got before giving up, when it was requested and missed. */
|
|
62
|
+
captureFailure: string | null;
|
|
54
63
|
strippedCapWarning: string | null;
|
|
55
64
|
exitCode: number | null;
|
|
56
65
|
output: string;
|
|
@@ -3,32 +3,55 @@ 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";
|
|
10
10
|
var MB = 1024 * 1024;
|
|
11
|
+
function codeSpan(text) {
|
|
12
|
+
const flat = text.replaceAll(/[\r\n]+/g, " ");
|
|
13
|
+
const longestRun = Math.max(0, ...[...flat.matchAll(/`+/g)].map((run) => run[0].length));
|
|
14
|
+
const fence = "`".repeat(longestRun + 1);
|
|
15
|
+
const padding = flat.startsWith("`") || flat.endsWith("`") ? " " : "";
|
|
16
|
+
return `${fence}${padding}${flat}${padding}${fence}`;
|
|
17
|
+
}
|
|
18
|
+
function publicWorkDir(run) {
|
|
19
|
+
const relative = path.relative(run.appDir, run.workDir);
|
|
20
|
+
return relative.startsWith("..") || path.isAbsolute(relative) ? path.basename(run.workDir) : relative;
|
|
21
|
+
}
|
|
11
22
|
function ownerLabel(attribution) {
|
|
12
23
|
if (attribution === void 0 || attribution.owner === "unattributed") {
|
|
13
24
|
return "unattributed";
|
|
14
25
|
}
|
|
15
|
-
const source = attribution.source ? ` \u2014
|
|
16
|
-
const packageName = attribution.packageName ? ` \u2014 ${attribution.packageName}` : "";
|
|
26
|
+
const source = attribution.source ? ` \u2014 ${codeSpan(attribution.source)}` : "";
|
|
27
|
+
const packageName = attribution.packageName ? ` \u2014 ${codeSpan(attribution.packageName)}` : "";
|
|
17
28
|
return `${attribution.owner}${source}${packageName}`;
|
|
18
29
|
}
|
|
30
|
+
function publicDetail(detail, run) {
|
|
31
|
+
return detail.replaceAll(run.workDir, publicWorkDir(run)).replaceAll(run.appDir, "<app-dir>").replaceAll(/[\r\n]+/g, " ");
|
|
32
|
+
}
|
|
19
33
|
function blamedParty(owner, culprit) {
|
|
20
34
|
if (owner === "app") {
|
|
21
|
-
return `**your own code** (
|
|
35
|
+
return `**your own code** (${codeSpan(culprit?.source ?? "app code")})`;
|
|
36
|
+
}
|
|
37
|
+
const packageName = culprit?.packageName;
|
|
38
|
+
return `the dependency **${packageName ? codeSpan(packageName) : "a dependency"}**`;
|
|
39
|
+
}
|
|
40
|
+
function missingEvidence(route, run) {
|
|
41
|
+
if (route.attributionGap === void 0) {
|
|
42
|
+
return "- (no findings above thresholds)";
|
|
22
43
|
}
|
|
23
|
-
|
|
44
|
+
const cause = route.attributionGap.reason === "snapshot-unavailable" ? "the final heap snapshot was never taken" : "the heap snapshots could not be read";
|
|
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.
|
|
46
|
+
- ${publicDetail(route.attributionGap.detail, run)}`;
|
|
24
47
|
}
|
|
25
48
|
function evidenceRows(route) {
|
|
26
49
|
const findings = [...route.diff?.grownNodes ?? [], ...route.diff?.newNodes ?? []];
|
|
27
50
|
return findings.slice(0, 6).map((finding, index) => {
|
|
28
51
|
const attribution = route.attribution?.findings[index];
|
|
29
52
|
const owner = ownerLabel(attribution);
|
|
30
|
-
return `- **${finding.kind}**
|
|
31
|
-
- retainers:
|
|
53
|
+
return `- **${finding.kind}** ${codeSpan(`[${finding.nodeType}] ${finding.name}`)} ${(finding.retainedBytes / MB).toFixed(2)} MB retained (${owner})
|
|
54
|
+
- retainers: ${codeSpan(finding.retainerChain || "(none)")}`;
|
|
32
55
|
}).join("\n");
|
|
33
56
|
}
|
|
34
57
|
function renderIssueMarkdown(route, run) {
|
|
@@ -71,7 +94,7 @@ Sampled during the load phases, without forcing collection: ${describePeakPressu
|
|
|
71
94
|
const signatures = route.signatures.map(
|
|
72
95
|
(signature) => `- ${signature.historical ? "(historical) " : ""}${signature.title} \u2014 ${signature.issue}`
|
|
73
96
|
).join("\n");
|
|
74
|
-
return `${preamble}${cacheDisclosure}# Memory leak on route
|
|
97
|
+
return `${preamble}${cacheDisclosure}# Memory leak on route ${codeSpan(route.route)}
|
|
75
98
|
|
|
76
99
|
Report generated by \`next-leak\` \u2014 every number below is reproducible from
|
|
77
100
|
the raw heap snapshots referenced at the bottom.
|
|
@@ -93,7 +116,7 @@ Deployment: output "standalone", node --expose-gc --max-old-space-size=${paramet
|
|
|
93
116
|
\`\`\`
|
|
94
117
|
npx next-leak <app-dir>
|
|
95
118
|
\`\`\`
|
|
96
|
-
2. next-leak boots \`.next/standalone/server.js\` in a fresh process per route and runs, against
|
|
119
|
+
2. next-leak boots \`.next/standalone/server.js\` in a fresh process per route and runs, against ${codeSpan(route.requestPath)}:
|
|
97
120
|
warm-up ${parameters.warmupRequests} requests \u2192 forced GC \u2192 baseline heap snapshot \u2192
|
|
98
121
|
${parameters.cycles} \xD7 [${parameters.loadRequests} requests at ${parameters.connections} connections \u2192 ${(parameters.idleMs / 1e3).toFixed(0)}s idle \u2192 forced GC \u2192 post-GC sample] \u2192 final snapshot.
|
|
99
122
|
3. A cycle counts as growing when it retains at least ${(parameters.minGrowthPerCycle / 1024).toFixed(0)} KiB
|
|
@@ -109,7 +132,7 @@ The first cycle is excluded from the verdict (engine warm-up).
|
|
|
109
132
|
|
|
110
133
|
### Heap evidence
|
|
111
134
|
|
|
112
|
-
${evidenceRows(route) ||
|
|
135
|
+
${evidenceRows(route) || missingEvidence(route, run)}
|
|
113
136
|
${signatures === "" ? "" : `
|
|
114
137
|
**Matched known causes:**
|
|
115
138
|
${signatures}
|
|
@@ -118,7 +141,7 @@ ${signatures}
|
|
|
118
141
|
|
|
119
142
|
Raw snapshots (Chrome DevTools \u2192 Memory \u2192 Load, compare baseline vs after):
|
|
120
143
|
|
|
121
|
-
-
|
|
144
|
+
- ${codeSpan(path.basename(route.baselineSnapshot))} / ${codeSpan(path.basename(route.afterSnapshot))} in ${codeSpan(publicWorkDir(run))}
|
|
122
145
|
- machine-readable data: \`run.json\`, curves: \`report.html\`
|
|
123
146
|
`;
|
|
124
147
|
}
|
|
@@ -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
|
+
};
|
|
@@ -40,7 +40,7 @@ function isSaturating(deltas, minGrowth) {
|
|
|
40
40
|
if (first === void 0 || last === void 0 || first < minGrowth) {
|
|
41
41
|
return false;
|
|
42
42
|
}
|
|
43
|
-
if (!deltas
|
|
43
|
+
if (!hasArrivedWithoutResuming(deltas, minGrowth)) {
|
|
44
44
|
return false;
|
|
45
45
|
}
|
|
46
46
|
if (last > first * SATURATION_MAX_FINAL_RATIO) {
|
|
@@ -48,6 +48,17 @@ function isSaturating(deltas, minGrowth) {
|
|
|
48
48
|
}
|
|
49
49
|
return isHeadingDown(deltas);
|
|
50
50
|
}
|
|
51
|
+
function hasArrivedWithoutResuming(deltas, minGrowth) {
|
|
52
|
+
const stop = deltas.findIndex((delta) => delta <= 0);
|
|
53
|
+
if (stop === -1) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
const approach = deltas[stop - 1];
|
|
57
|
+
if (approach === void 0 || approach >= minGrowth) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return deltas.slice(stop).every((delta) => delta < minGrowth);
|
|
61
|
+
}
|
|
51
62
|
function isHeadingDown(deltas) {
|
|
52
63
|
const midpoint = Math.floor(deltas.length / 2);
|
|
53
64
|
const head = deltas.slice(0, midpoint);
|
|
@@ -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-QUFL2FXU.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,
|
|
@@ -93182,15 +93185,15 @@ async function jsFilesUnder(dir) {
|
|
|
93182
93185
|
async function extractModuleRegistry(nextServerDir) {
|
|
93183
93186
|
const registry = /* @__PURE__ */ new Map();
|
|
93184
93187
|
for (const file of await jsFilesUnder(nextServerDir)) {
|
|
93185
|
-
let
|
|
93186
|
-
let map;
|
|
93188
|
+
let modules;
|
|
93187
93189
|
try {
|
|
93188
|
-
code = await readFile(file, "utf8");
|
|
93189
|
-
map = JSON.parse(await readFile(`${file}.map`, "utf8"));
|
|
93190
|
+
const code = await readFile(file, "utf8");
|
|
93191
|
+
const map = JSON.parse(await readFile(`${file}.map`, "utf8"));
|
|
93192
|
+
modules = extractChunkModules(code, map);
|
|
93190
93193
|
} catch {
|
|
93191
93194
|
continue;
|
|
93192
93195
|
}
|
|
93193
|
-
for (const [id, source] of
|
|
93196
|
+
for (const [id, source] of modules) {
|
|
93194
93197
|
if (!registry.has(id)) {
|
|
93195
93198
|
registry.set(id, source);
|
|
93196
93199
|
}
|
|
@@ -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());
|
|
@@ -93392,6 +93405,7 @@ This only changes how the build is packaged \u2014 not how your app behaves.`
|
|
|
93392
93405
|
|
|
93393
93406
|
// src/launcher.ts
|
|
93394
93407
|
import { spawn } from "child_process";
|
|
93408
|
+
import { randomBytes } from "crypto";
|
|
93395
93409
|
import { readdir as readdir2, readFile as readFile3 } from "fs/promises";
|
|
93396
93410
|
import path3 from "path";
|
|
93397
93411
|
import { pathToFileURL } from "url";
|
|
@@ -93489,6 +93503,7 @@ async function pollUntil(deadline, describe, probe, failed) {
|
|
|
93489
93503
|
async function launchInstrumented(options) {
|
|
93490
93504
|
const hostname = options.hostname ?? "127.0.0.1";
|
|
93491
93505
|
const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
|
93506
|
+
const controlToken = randomBytes(32).toString("hex");
|
|
93492
93507
|
const child = spawn(
|
|
93493
93508
|
process.execPath,
|
|
93494
93509
|
[
|
|
@@ -93506,7 +93521,8 @@ async function launchInstrumented(options) {
|
|
|
93506
93521
|
NODE_ENV: "production",
|
|
93507
93522
|
PORT: String(options.appPort),
|
|
93508
93523
|
HOSTNAME: hostname,
|
|
93509
|
-
NEXT_LEAK_DIR: options.workDir
|
|
93524
|
+
NEXT_LEAK_DIR: options.workDir,
|
|
93525
|
+
NEXT_LEAK_TOKEN: controlToken
|
|
93510
93526
|
},
|
|
93511
93527
|
stdio: ["ignore", "ignore", "pipe"]
|
|
93512
93528
|
}
|
|
@@ -93550,7 +93566,9 @@ ${stderrTailBuffer}`;
|
|
|
93550
93566
|
const parsed = controlFileSchema.parse(
|
|
93551
93567
|
JSON.parse(await readFile3(path3.join(options.workDir, entry), "utf8"))
|
|
93552
93568
|
);
|
|
93553
|
-
const response = await fetch(`http://127.0.0.1:${parsed.port}/gc
|
|
93569
|
+
const response = await fetch(`http://127.0.0.1:${parsed.port}/gc`, {
|
|
93570
|
+
headers: { [CONTROL_TOKEN_HEADER]: controlToken }
|
|
93571
|
+
});
|
|
93554
93572
|
if (response.ok) {
|
|
93555
93573
|
return parsed.port;
|
|
93556
93574
|
}
|
|
@@ -93584,6 +93602,7 @@ ${stderrTailBuffer}`;
|
|
|
93584
93602
|
pid: child.pid ?? -1,
|
|
93585
93603
|
appPort: options.appPort,
|
|
93586
93604
|
controlPort,
|
|
93605
|
+
controlToken,
|
|
93587
93606
|
explainExit: () => {
|
|
93588
93607
|
if (!exited) {
|
|
93589
93608
|
return null;
|
|
@@ -93922,6 +93941,12 @@ function parseCliArgs(argv) {
|
|
|
93922
93941
|
}
|
|
93923
93942
|
};
|
|
93924
93943
|
}
|
|
93944
|
+
if (options.attributeBuild) {
|
|
93945
|
+
return {
|
|
93946
|
+
kind: "error",
|
|
93947
|
+
message: `option "--attribute" applies only to "next-leak build": a run already names what it retains \u2014 see --help`
|
|
93948
|
+
};
|
|
93949
|
+
}
|
|
93925
93950
|
return { kind: "run", options };
|
|
93926
93951
|
}
|
|
93927
93952
|
|
|
@@ -94251,7 +94276,7 @@ function describeProgress(bytes) {
|
|
|
94251
94276
|
function describeDuration(ms) {
|
|
94252
94277
|
return ms >= 36e5 ? `${Math.round(ms / 36e5)}h` : `${Math.round(ms / 1e3)}s`;
|
|
94253
94278
|
}
|
|
94254
|
-
function get(port, pathname, deadlineMs, watch) {
|
|
94279
|
+
function get({ port, token }, pathname, deadlineMs, watch) {
|
|
94255
94280
|
return new Promise((resolve, reject) => {
|
|
94256
94281
|
let settled = false;
|
|
94257
94282
|
const settle = (outcome) => {
|
|
@@ -94266,7 +94291,8 @@ function get(port, pathname, deadlineMs, watch) {
|
|
|
94266
94291
|
reject(outcome.error);
|
|
94267
94292
|
}
|
|
94268
94293
|
};
|
|
94269
|
-
const
|
|
94294
|
+
const headers = { [CONTROL_TOKEN_HEADER]: token };
|
|
94295
|
+
const request2 = http.get({ host: "127.0.0.1", port, path: pathname, headers }, (response) => {
|
|
94270
94296
|
const chunks = [];
|
|
94271
94297
|
response.on("data", (chunk) => chunks.push(chunk));
|
|
94272
94298
|
response.once(
|
|
@@ -94336,8 +94362,8 @@ function get(port, pathname, deadlineMs, watch) {
|
|
|
94336
94362
|
);
|
|
94337
94363
|
});
|
|
94338
94364
|
}
|
|
94339
|
-
async function request(
|
|
94340
|
-
const response = await get(
|
|
94365
|
+
async function request(control, pathname, deadlineMs = deadlineFor(pathname), watch) {
|
|
94366
|
+
const response = await get(control, pathname, deadlineMs, watch);
|
|
94341
94367
|
if (response.status !== 200) {
|
|
94342
94368
|
throw new ControlError(`control channel ${pathname} responded ${response.status}`);
|
|
94343
94369
|
}
|
|
@@ -94347,8 +94373,8 @@ async function request(port, pathname, deadlineMs = deadlineFor(pathname), watch
|
|
|
94347
94373
|
throw new ControlError(`control channel ${pathname} answered with malformed JSON`);
|
|
94348
94374
|
}
|
|
94349
94375
|
}
|
|
94350
|
-
async function requestGc(
|
|
94351
|
-
const sample = sampleSchema.parse(await request(
|
|
94376
|
+
async function requestGc(control) {
|
|
94377
|
+
const sample = sampleSchema.parse(await request(control, "/gc"));
|
|
94352
94378
|
if (!sample.gcExposed) {
|
|
94353
94379
|
throw new ControlError(
|
|
94354
94380
|
"the measured process is running without --expose-gc; samples would be meaningless"
|
|
@@ -94356,14 +94382,14 @@ async function requestGc(port) {
|
|
|
94356
94382
|
}
|
|
94357
94383
|
return sample;
|
|
94358
94384
|
}
|
|
94359
|
-
async function requestMemory(
|
|
94360
|
-
return sampleSchema.parse(await request(
|
|
94385
|
+
async function requestMemory(control) {
|
|
94386
|
+
return sampleSchema.parse(await request(control, "/mem"));
|
|
94361
94387
|
}
|
|
94362
|
-
async function requestSnapshot(
|
|
94388
|
+
async function requestSnapshot(control, name, workDir) {
|
|
94363
94389
|
const pathname = `/snapshot?name=${encodeURIComponent(name)}`;
|
|
94364
94390
|
const parsed = snapshotResponseSchema.parse(
|
|
94365
94391
|
await request(
|
|
94366
|
-
|
|
94392
|
+
control,
|
|
94367
94393
|
pathname,
|
|
94368
94394
|
deadlineFor(pathname),
|
|
94369
94395
|
workDir === void 0 ? void 0 : productionWatch(snapshotPathFor(workDir, name))
|
|
@@ -94488,7 +94514,7 @@ var UNRECLAIMED_SETTLE_MS = SETTLE_POLL_MS;
|
|
|
94488
94514
|
function unreclaimedSettleFor(idleMs) {
|
|
94489
94515
|
return Math.min(UNRECLAIMED_SETTLE_MS, Math.floor(idleMs / 4));
|
|
94490
94516
|
}
|
|
94491
|
-
function pollPeak(
|
|
94517
|
+
function pollPeak(control, phase, deps) {
|
|
94492
94518
|
const peak = {
|
|
94493
94519
|
phase,
|
|
94494
94520
|
heapUsed: 0,
|
|
@@ -94506,7 +94532,7 @@ function pollPeak(controlPort, phase, deps) {
|
|
|
94506
94532
|
}
|
|
94507
94533
|
let sample;
|
|
94508
94534
|
try {
|
|
94509
|
-
sample = await deps.readMemory(
|
|
94535
|
+
sample = await deps.readMemory(control);
|
|
94510
94536
|
} catch {
|
|
94511
94537
|
return;
|
|
94512
94538
|
}
|
|
@@ -94525,7 +94551,7 @@ function pollPeak(controlPort, phase, deps) {
|
|
|
94525
94551
|
}
|
|
94526
94552
|
};
|
|
94527
94553
|
}
|
|
94528
|
-
async function waitUntilSettled(
|
|
94554
|
+
async function waitUntilSettled(control, maxIdleMs, deps) {
|
|
94529
94555
|
const deadline = Date.now() + maxIdleMs;
|
|
94530
94556
|
let previous = null;
|
|
94531
94557
|
let polls = 0;
|
|
@@ -94533,7 +94559,7 @@ async function waitUntilSettled(controlPort, maxIdleMs, deps) {
|
|
|
94533
94559
|
await deps.sleep(Math.min(SETTLE_POLL_MS, Math.max(deadline - Date.now(), 0)));
|
|
94534
94560
|
let current;
|
|
94535
94561
|
try {
|
|
94536
|
-
current = (await requestGc(
|
|
94562
|
+
current = (await requestGc(control)).heapUsed;
|
|
94537
94563
|
} catch {
|
|
94538
94564
|
return { status: "unknown", polls };
|
|
94539
94565
|
}
|
|
@@ -94592,6 +94618,7 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94592
94618
|
}
|
|
94593
94619
|
app = await deps.launch({ ...launchOptions, appPort: options.appPort + 1 });
|
|
94594
94620
|
}
|
|
94621
|
+
const control = { port: app.controlPort, token: app.controlToken };
|
|
94595
94622
|
const timings = [];
|
|
94596
94623
|
const loadOutcomes = [];
|
|
94597
94624
|
const settleOutcomes = [];
|
|
@@ -94661,13 +94688,13 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94661
94688
|
);
|
|
94662
94689
|
const baseline = await timed(
|
|
94663
94690
|
"baseline snapshot",
|
|
94664
|
-
() => requestSnapshot(
|
|
94691
|
+
() => requestSnapshot(control, "baseline", options.workDir)
|
|
94665
94692
|
);
|
|
94666
94693
|
memorySamples.push(baseline.sample);
|
|
94667
94694
|
baselineSnapshot = baseline.file;
|
|
94668
94695
|
for (let cycle = 1; cycle <= cycles; cycle += 1) {
|
|
94669
94696
|
await timed(`cycle ${cycle} load`, async () => {
|
|
94670
|
-
const poller = pollPeak(
|
|
94697
|
+
const poller = pollPeak(control, `cycle ${cycle}`, deps);
|
|
94671
94698
|
try {
|
|
94672
94699
|
await loadCycle(`cycle ${cycle}`, loadRequests);
|
|
94673
94700
|
} finally {
|
|
@@ -94678,30 +94705,30 @@ async function runRitual(options, deps = defaultDeps) {
|
|
|
94678
94705
|
await timed(`cycle ${cycle} unreclaimed read`, async () => {
|
|
94679
94706
|
await deps.sleep(unreclaimedSettleMs);
|
|
94680
94707
|
try {
|
|
94681
|
-
unreclaimedSamples.push(await deps.readMemory(
|
|
94708
|
+
unreclaimedSamples.push(await deps.readMemory(control));
|
|
94682
94709
|
} catch {
|
|
94683
94710
|
unreclaimedLost = true;
|
|
94684
94711
|
}
|
|
94685
94712
|
});
|
|
94686
94713
|
const settle = await timed(
|
|
94687
94714
|
`cycle ${cycle} settle`,
|
|
94688
|
-
() => waitUntilSettled(
|
|
94715
|
+
() => waitUntilSettled(control, idleMs - unreclaimedSettleMs, deps)
|
|
94689
94716
|
);
|
|
94690
94717
|
settleOutcomes.push({ phase: `cycle ${cycle}`, ...settle });
|
|
94691
94718
|
if (cycle === cycles) {
|
|
94692
94719
|
try {
|
|
94693
94720
|
const after = await timed(
|
|
94694
94721
|
"after snapshot",
|
|
94695
|
-
() => requestSnapshot(
|
|
94722
|
+
() => requestSnapshot(control, "after", options.workDir)
|
|
94696
94723
|
);
|
|
94697
94724
|
afterSnapshot = after.file;
|
|
94698
94725
|
memorySamples.push(after.sample);
|
|
94699
94726
|
} catch (cause) {
|
|
94700
94727
|
snapshotFailure = cause instanceof Error ? cause.message : String(cause);
|
|
94701
|
-
memorySamples.push(await requestGc(
|
|
94728
|
+
memorySamples.push(await requestGc(control));
|
|
94702
94729
|
}
|
|
94703
94730
|
} else {
|
|
94704
|
-
memorySamples.push(await requestGc(
|
|
94731
|
+
memorySamples.push(await requestGc(control));
|
|
94705
94732
|
}
|
|
94706
94733
|
cyclesCompleted = cycle;
|
|
94707
94734
|
}
|
|
@@ -94812,7 +94839,7 @@ async function runSelfCheck(options, deps = defaultDeps2) {
|
|
|
94812
94839
|
passed,
|
|
94813
94840
|
verdict: result.trend.verdict,
|
|
94814
94841
|
growthPer1000Requests,
|
|
94815
|
-
summary: passed ? `harness
|
|
94842
|
+
summary: passed ? `harness detected the planted leak at ${rate} \u2014 it responds to retention in this environment; the routes' own figures are not checked by this` : `harness NOT verified: a leak of ${PLANTED_LEAK_BYTES_PER_REQUEST} bytes per request came back ${result.trend.verdict} at ${rate}. Verdicts measured in this environment cannot be trusted \u2014 a flat curve here means the measurement is not working, not that nothing leaks`
|
|
94816
94843
|
};
|
|
94817
94844
|
} finally {
|
|
94818
94845
|
await rm(dir, { recursive: true, force: true });
|
|
@@ -95187,7 +95214,9 @@ function harnessLine(report) {
|
|
|
95187
95214
|
const measured = report.routes.filter((route) => route.status === "measured");
|
|
95188
95215
|
if (report.harness.verified) {
|
|
95189
95216
|
const rate = formatGrowth(report.harness.growthPer1000Requests);
|
|
95190
|
-
return [
|
|
95217
|
+
return [
|
|
95218
|
+
`harness detected a planted leak this session at ${rate} \u2014 it can see retention here; that is not a check on the figures above`
|
|
95219
|
+
];
|
|
95191
95220
|
}
|
|
95192
95221
|
const allStable = measured.length > 0 && measured.every((route) => effectiveVerdict(route) === "stable");
|
|
95193
95222
|
if (!allStable) {
|
|
@@ -95231,7 +95260,7 @@ function formatReport(report) {
|
|
|
95231
95260
|
` next-leak ${report.appDir} --routes ${routeList} --cycles ${moreCycles}`
|
|
95232
95261
|
);
|
|
95233
95262
|
}
|
|
95234
|
-
return lines.join("\n");
|
|
95263
|
+
return terminalSafe(lines.join("\n"));
|
|
95235
95264
|
}
|
|
95236
95265
|
|
|
95237
95266
|
// src/environment.ts
|
|
@@ -95622,8 +95651,8 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95622
95651
|
};
|
|
95623
95652
|
}
|
|
95624
95653
|
async function writeEvidenceBundle(report, workDir) {
|
|
95625
|
-
const { renderHtmlReport } = await import("./html-report-
|
|
95626
|
-
const { renderIssueMarkdown } = await import("./issue-report-
|
|
95654
|
+
const { renderHtmlReport } = await import("./html-report-ZXDEJ6X5.js");
|
|
95655
|
+
const { renderIssueMarkdown } = await import("./issue-report-4RW5YOT3.js");
|
|
95627
95656
|
for (const route of report.routes) {
|
|
95628
95657
|
if (route.status === "measured" && warrantsIssueDraft(route)) {
|
|
95629
95658
|
const file = path9.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
|
|
@@ -95895,6 +95924,7 @@ export {
|
|
|
95895
95924
|
sourceIndexAt,
|
|
95896
95925
|
extractChunkModules,
|
|
95897
95926
|
extractModuleRegistry,
|
|
95927
|
+
terminalSafe,
|
|
95898
95928
|
appPathsManifestSchema,
|
|
95899
95929
|
routesManifestSchema,
|
|
95900
95930
|
discoverPagesRoutes,
|
|
@@ -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-QUFL2FXU.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;
|
|
@@ -69,6 +69,13 @@ function peakBlock(route, parameters) {
|
|
|
69
69
|
const note = pressure === null ? "" : `<p class="warn">Peak pressure: ${escapeHtml(describePeakPressure(pressure))}</p>`;
|
|
70
70
|
return note + `<table><tr><th>peak during</th><th>heap</th><th>external</th><th>arrayBuffers</th><th>rss</th></tr>${peakRows}</table>`;
|
|
71
71
|
}
|
|
72
|
+
function attributionGapBlock(route) {
|
|
73
|
+
if (route.status !== "measured" || route.attributionGap === void 0) {
|
|
74
|
+
return "";
|
|
75
|
+
}
|
|
76
|
+
const cause = route.attributionGap.reason === "snapshot-unavailable" ? "the final heap snapshot was never taken" : "the heap snapshots could not be read";
|
|
77
|
+
return `<p class="warn">No attribution: ${cause}, so this run cannot name what retains the memory. The curve above is unaffected. ${escapeHtml(route.attributionGap.detail)}</p>`;
|
|
78
|
+
}
|
|
72
79
|
function measuredSection(route, parameters) {
|
|
73
80
|
if (route.status !== "measured") {
|
|
74
81
|
return "";
|
|
@@ -84,7 +91,7 @@ function measuredSection(route, parameters) {
|
|
|
84
91
|
const owner = ownerCell(attribution);
|
|
85
92
|
return `<tr><td>${finding.kind}</td><td>${escapeHtml(finding.nodeType)}</td><td>${escapeHtml(finding.name)}</td><td>${(finding.retainedBytes / MB).toFixed(2)} MB</td><td>${owner}</td></tr>`;
|
|
86
93
|
}).join("");
|
|
87
|
-
return `<section><h2><span class="badge" style="background:${color}">${verdict}</span> <code>${escapeHtml(route.route)}</code></h2>` + heapCurveSvg(route.samples, color) + `<p class="curve">heap ${curve} MB \xB7 ${(route.growthPer1000Requests / MB).toFixed(2)} MB/1000 req</p>` + withdrawn + warnings + peakBlock(route, parameters) + (findingRows === "" ?
|
|
94
|
+
return `<section><h2><span class="badge" style="background:${color}">${verdict}</span> <code>${escapeHtml(route.route)}</code></h2>` + heapCurveSvg(route.samples, color) + `<p class="curve">heap ${curve} MB \xB7 ${(route.growthPer1000Requests / MB).toFixed(2)} MB/1000 req</p>` + withdrawn + warnings + peakBlock(route, parameters) + (findingRows === "" ? attributionGapBlock(route) : `<table><tr><th>kind</th><th>type</th><th>node</th><th>retained</th><th>owner</th></tr>${findingRows}</table>`) + `</section>`;
|
|
88
95
|
}
|
|
89
96
|
function renderHtmlReport(run) {
|
|
90
97
|
const measured = run.routes.filter((route) => route.status === "measured");
|
|
@@ -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
|
@@ -20,13 +20,15 @@ import {
|
|
|
20
20
|
renderConfigSkeleton,
|
|
21
21
|
runMeasurement,
|
|
22
22
|
runSelfCheck,
|
|
23
|
+
terminalSafe,
|
|
23
24
|
unregisterChild,
|
|
24
25
|
validateTarget
|
|
25
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-UKWFONEI.js";
|
|
27
|
+
import "./chunk-IKWF7SSB.js";
|
|
26
28
|
import {
|
|
27
29
|
classifyTrend
|
|
28
|
-
} from "./chunk-
|
|
29
|
-
import "./chunk-
|
|
30
|
+
} from "./chunk-QUFL2FXU.js";
|
|
31
|
+
import "./chunk-VTKOO3YJ.js";
|
|
30
32
|
import "./chunk-6XYFBOL2.js";
|
|
31
33
|
|
|
32
34
|
// src/cli.ts
|
|
@@ -151,6 +153,9 @@ function discardPendingSnapshotsSync() {
|
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
// src/build-attribution.ts
|
|
156
|
+
function isAttributionGap(outcome) {
|
|
157
|
+
return outcome !== null && "reason" in outcome;
|
|
158
|
+
}
|
|
154
159
|
var defaultDeps = {
|
|
155
160
|
diff: diffSnapshotFiles,
|
|
156
161
|
registry: extractModuleRegistry
|
|
@@ -159,17 +164,19 @@ async function attributeBuildCapture(result, appDir, onProgress = () => {
|
|
|
159
164
|
}, deps = defaultDeps) {
|
|
160
165
|
const capture = result.capture;
|
|
161
166
|
if (capture === null) {
|
|
162
|
-
return
|
|
167
|
+
return result.captureRequested ? {
|
|
168
|
+
reason: "capture-missed",
|
|
169
|
+
detail: result.captureFailure ?? "the run never captured a usable snapshot pair from the worker"
|
|
170
|
+
} : null;
|
|
163
171
|
}
|
|
164
172
|
onProgress(`diffing what worker ${capture.pid} retained`);
|
|
165
173
|
let diff;
|
|
166
174
|
try {
|
|
167
175
|
diff = await deps.diff(capture.files.baselineFile, capture.files.afterFile);
|
|
168
176
|
} catch (cause) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return null;
|
|
177
|
+
const detail = cause instanceof SnapshotError ? cause.message : "the snapshot pair could not be diffed";
|
|
178
|
+
onProgress(`no attribution: ${detail}`);
|
|
179
|
+
return { reason: "snapshot-unreadable", detail };
|
|
173
180
|
}
|
|
174
181
|
const registry = await deps.registry(path2.join(appDir, ".next", "server"));
|
|
175
182
|
return {
|
|
@@ -212,6 +219,13 @@ function attributionLines(attribution) {
|
|
|
212
219
|
if (attribution === null) {
|
|
213
220
|
return [];
|
|
214
221
|
}
|
|
222
|
+
if (isAttributionGap(attribution)) {
|
|
223
|
+
return [
|
|
224
|
+
"",
|
|
225
|
+
` no attribution: ${attribution.detail}.`,
|
|
226
|
+
` This does not say nothing grew \u2014 the comparison was never made.`
|
|
227
|
+
];
|
|
228
|
+
}
|
|
215
229
|
const { diff, attributed, bracketed } = attribution;
|
|
216
230
|
const findings = [...diff.grownNodes, ...diff.newNodes];
|
|
217
231
|
if (findings.length === 0) {
|
|
@@ -242,7 +256,7 @@ function attributionLines(attribution) {
|
|
|
242
256
|
}
|
|
243
257
|
return lines;
|
|
244
258
|
}
|
|
245
|
-
function
|
|
259
|
+
function buildReportText(result, attribution = null) {
|
|
246
260
|
const lines = [`next-leak build \xB7 ${result.appDir}`, ""];
|
|
247
261
|
if (result.strippedCapWarning !== null) {
|
|
248
262
|
lines.push(` \u25B2 ${result.strippedCapWarning}`, "");
|
|
@@ -320,6 +334,9 @@ function formatBuildReport(result, attribution = null) {
|
|
|
320
334
|
);
|
|
321
335
|
return lines.join("\n");
|
|
322
336
|
}
|
|
337
|
+
function formatBuildReport(result, attribution = null) {
|
|
338
|
+
return terminalSafe(buildReportText(result, attribution));
|
|
339
|
+
}
|
|
323
340
|
|
|
324
341
|
// src/build-run.ts
|
|
325
342
|
import { spawn } from "child_process";
|
|
@@ -566,6 +583,24 @@ function recordSample(rows, rootPid, atMs, workers, parentSamples) {
|
|
|
566
583
|
workers.set(row.pid, series);
|
|
567
584
|
}
|
|
568
585
|
}
|
|
586
|
+
function describeCaptureFailure(stage, pid, mismatchedWorker) {
|
|
587
|
+
if (mismatchedWorker) {
|
|
588
|
+
return "the snapshot pair belongs to a worker other than the one judged";
|
|
589
|
+
}
|
|
590
|
+
if (pid === null) {
|
|
591
|
+
return "no static-generation worker was seen to capture from";
|
|
592
|
+
}
|
|
593
|
+
switch (stage) {
|
|
594
|
+
case "waiting":
|
|
595
|
+
return `worker ${pid} never grew enough to snapshot`;
|
|
596
|
+
case "baseline-taken":
|
|
597
|
+
return `worker ${pid} was snapshotted once and never reached a second point`;
|
|
598
|
+
case "missed":
|
|
599
|
+
return `capture gave up on worker ${pid} before it had a pair`;
|
|
600
|
+
case "pair-taken":
|
|
601
|
+
return `the pair captured from worker ${pid} could not be collected from disk`;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
569
604
|
async function collectCapture(options) {
|
|
570
605
|
const { appDir, workDir, pid, stage, baselineRssBytes, afterRssBytes } = options;
|
|
571
606
|
if (workDir === void 0 || pid === null) {
|
|
@@ -716,6 +751,8 @@ async function runBuildMeasurement(options, deps = defaultDeps2) {
|
|
|
716
751
|
retentionPerPageBytes: null,
|
|
717
752
|
heapExhausted,
|
|
718
753
|
capture: workerCapture,
|
|
754
|
+
captureRequested: capturing,
|
|
755
|
+
captureFailure: workerCapture === null ? describeCaptureFailure(captureStage, capturePid, false) : null,
|
|
719
756
|
strippedCapWarning,
|
|
720
757
|
exitCode,
|
|
721
758
|
output
|
|
@@ -747,6 +784,12 @@ async function runBuildMeasurement(options, deps = defaultDeps2) {
|
|
|
747
784
|
// A pair belonging to a worker other than the one judged supports no claim
|
|
748
785
|
// about the verdict above, so it is dropped rather than reported next to it.
|
|
749
786
|
capture: workerCapture !== null && workerCapture.pid === worst?.pid ? workerCapture : null,
|
|
787
|
+
captureRequested: capturing,
|
|
788
|
+
captureFailure: workerCapture !== null && workerCapture.pid === worst?.pid ? null : describeCaptureFailure(
|
|
789
|
+
captureStage,
|
|
790
|
+
capturePid,
|
|
791
|
+
workerCapture !== null && workerCapture.pid !== worst?.pid
|
|
792
|
+
),
|
|
750
793
|
strippedCapWarning,
|
|
751
794
|
exitCode,
|
|
752
795
|
output
|
|
@@ -830,12 +873,12 @@ async function runBuildCommand(appDir, outputDir, attribute) {
|
|
|
830
873
|
// is worth asking for, not worth risking every run for.
|
|
831
874
|
...attribute && { workDir },
|
|
832
875
|
signal: aborter.signal,
|
|
833
|
-
onProgress: (message) => console.error(`\xB7 ${message}`)
|
|
876
|
+
onProgress: (message) => console.error(`\xB7 ${terminalSafe(message)}`)
|
|
834
877
|
});
|
|
835
878
|
const attribution = await attributeBuildCapture(
|
|
836
879
|
result,
|
|
837
880
|
appDir,
|
|
838
|
-
(message) => console.error(`\xB7 ${message}`)
|
|
881
|
+
(message) => console.error(`\xB7 ${terminalSafe(message)}`)
|
|
839
882
|
);
|
|
840
883
|
console.log(formatBuildReport(result, attribution));
|
|
841
884
|
if (aborter.signal.aborted) {
|
|
@@ -911,9 +954,9 @@ async function main() {
|
|
|
911
954
|
...options.idleSeconds !== null && { idleMs: options.idleSeconds * 1e3 },
|
|
912
955
|
...options.maxOldSpaceMb !== null && { maxOldSpaceMb: options.maxOldSpaceMb }
|
|
913
956
|
});
|
|
914
|
-
console.error(`\xB7 ${check.summary}`);
|
|
957
|
+
console.error(`\xB7 ${terminalSafe(check.summary)}`);
|
|
915
958
|
if (!check.passed) {
|
|
916
|
-
console.error(`error: ${check.summary}`);
|
|
959
|
+
console.error(`error: ${terminalSafe(check.summary)}`);
|
|
917
960
|
process.exitCode = 1;
|
|
918
961
|
return;
|
|
919
962
|
}
|
|
@@ -939,7 +982,7 @@ async function main() {
|
|
|
939
982
|
...options.diffAll && { diffAll: true },
|
|
940
983
|
...options.noResolve && { resolveInconclusive: false },
|
|
941
984
|
...options.output !== null && { outputDir: options.output },
|
|
942
|
-
onProgress: (message) => console.error(`\xB7 ${message}`)
|
|
985
|
+
onProgress: (message) => console.error(`\xB7 ${terminalSafe(message)}`)
|
|
943
986
|
});
|
|
944
987
|
console.log(formatReport(report));
|
|
945
988
|
if (aborter.signal.aborted) {
|
|
@@ -957,7 +1000,7 @@ try {
|
|
|
957
1000
|
await main();
|
|
958
1001
|
} catch (cause) {
|
|
959
1002
|
if (cause instanceof TargetError || cause instanceof RouteConfigError) {
|
|
960
|
-
console.error(`error: ${cause.message}`);
|
|
1003
|
+
console.error(`error: ${terminalSafe(cause.message)}`);
|
|
961
1004
|
} else {
|
|
962
1005
|
console.error(cause);
|
|
963
1006
|
}
|
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-UOAW5UA5.js";
|
|
5
|
+
import "./chunk-QUFL2FXU.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-UKWFONEI.js";
|
|
45
|
+
import "./chunk-IKWF7SSB.js";
|
|
45
46
|
import {
|
|
46
47
|
renderHtmlReport
|
|
47
|
-
} from "./chunk-
|
|
48
|
+
} from "./chunk-UOAW5UA5.js";
|
|
48
49
|
import {
|
|
49
50
|
classifyTrend
|
|
50
|
-
} from "./chunk-
|
|
51
|
+
} from "./chunk-QUFL2FXU.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/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.
|
|
@@ -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;
|