next-leak 0.12.0 → 0.12.2
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/build-attribution.d.ts +26 -4
- package/dist/build-report.d.ts +2 -2
- package/dist/build-run.d.ts +9 -0
- package/dist/{chunk-5VXBMWDN.js → chunk-HDCOHSBY.js} +15 -12
- package/dist/{chunk-4NKGNMEA.js → chunk-QUFL2FXU.js} +16 -1
- package/dist/{chunk-YPVBX3QA.js → chunk-ULTMNWRX.js} +27 -8
- package/dist/{chunk-7WUE3WOI.js → chunk-VKPGB5MG.js} +12 -4
- package/dist/cli.js +45 -7
- package/dist/confidence.d.ts +32 -3
- package/dist/{html-report-6KGPHCMA.js → html-report-MSEC3ZI4.js} +2 -2
- package/dist/index.js +4 -4
- package/dist/{issue-report-OALZZLVR.js → issue-report-JXVLP7CV.js} +1 -1
- package/package.json +1 -1
|
@@ -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,4 +1,4 @@
|
|
|
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
4
|
* Renders the build report.
|
|
@@ -9,4 +9,4 @@ import type { BuildRunResult } from "./build-run.js";
|
|
|
9
9
|
* still the honest axis here — it is what a CI runner's limit is enforced
|
|
10
10
|
* against and what the OOM killer reads.
|
|
11
11
|
*/
|
|
12
|
-
export declare function formatBuildReport(result: BuildRunResult, attribution?: BuildAttribution | null): string;
|
|
12
|
+
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;
|
|
@@ -6,8 +6,9 @@ import {
|
|
|
6
6
|
effectiveVerdict,
|
|
7
7
|
minGrowthFor,
|
|
8
8
|
resolveCycles,
|
|
9
|
-
warrantsIssueDraft
|
|
10
|
-
|
|
9
|
+
warrantsIssueDraft,
|
|
10
|
+
withdrawnByDisagreement
|
|
11
|
+
} from "./chunk-QUFL2FXU.js";
|
|
11
12
|
import {
|
|
12
13
|
assessPeakPressure,
|
|
13
14
|
assessPressureVerdict,
|
|
@@ -93181,15 +93182,15 @@ async function jsFilesUnder(dir) {
|
|
|
93181
93182
|
async function extractModuleRegistry(nextServerDir) {
|
|
93182
93183
|
const registry = /* @__PURE__ */ new Map();
|
|
93183
93184
|
for (const file of await jsFilesUnder(nextServerDir)) {
|
|
93184
|
-
let
|
|
93185
|
-
let map;
|
|
93185
|
+
let modules;
|
|
93186
93186
|
try {
|
|
93187
|
-
code = await readFile(file, "utf8");
|
|
93188
|
-
map = JSON.parse(await readFile(`${file}.map`, "utf8"));
|
|
93187
|
+
const code = await readFile(file, "utf8");
|
|
93188
|
+
const map = JSON.parse(await readFile(`${file}.map`, "utf8"));
|
|
93189
|
+
modules = extractChunkModules(code, map);
|
|
93189
93190
|
} catch {
|
|
93190
93191
|
continue;
|
|
93191
93192
|
}
|
|
93192
|
-
for (const [id, source] of
|
|
93193
|
+
for (const [id, source] of modules) {
|
|
93193
93194
|
if (!registry.has(id)) {
|
|
93194
93195
|
registry.set(id, source);
|
|
93195
93196
|
}
|
|
@@ -94811,7 +94812,7 @@ async function runSelfCheck(options, deps = defaultDeps2) {
|
|
|
94811
94812
|
passed,
|
|
94812
94813
|
verdict: result.trend.verdict,
|
|
94813
94814
|
growthPer1000Requests,
|
|
94814
|
-
summary: passed ? `harness
|
|
94815
|
+
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`
|
|
94815
94816
|
};
|
|
94816
94817
|
} finally {
|
|
94817
94818
|
await rm(dir, { recursive: true, force: true });
|
|
@@ -95030,7 +95031,7 @@ function abandonLines(route) {
|
|
|
95030
95031
|
}
|
|
95031
95032
|
function confidenceLines(route) {
|
|
95032
95033
|
const lines = [];
|
|
95033
|
-
if (route.confidence.supersededVerdict !== void 0) {
|
|
95034
|
+
if (route.confidence.supersededVerdict !== void 0 && !withdrawnByDisagreement(route.confidence)) {
|
|
95034
95035
|
lines.push(
|
|
95035
95036
|
` measured ${route.trend.verdict}, withdrawn: the run did not observe what that verdict needs`
|
|
95036
95037
|
);
|
|
@@ -95186,7 +95187,9 @@ function harnessLine(report) {
|
|
|
95186
95187
|
const measured = report.routes.filter((route) => route.status === "measured");
|
|
95187
95188
|
if (report.harness.verified) {
|
|
95188
95189
|
const rate = formatGrowth(report.harness.growthPer1000Requests);
|
|
95189
|
-
return [
|
|
95190
|
+
return [
|
|
95191
|
+
`harness detected a planted leak this session at ${rate} \u2014 it can see retention here; that is not a check on the figures above`
|
|
95192
|
+
];
|
|
95190
95193
|
}
|
|
95191
95194
|
const allStable = measured.length > 0 && measured.every((route) => effectiveVerdict(route) === "stable");
|
|
95192
95195
|
if (!allStable) {
|
|
@@ -95621,8 +95624,8 @@ async function measureRoute(context, route, requestPath, index, pass = void 0) {
|
|
|
95621
95624
|
};
|
|
95622
95625
|
}
|
|
95623
95626
|
async function writeEvidenceBundle(report, workDir) {
|
|
95624
|
-
const { renderHtmlReport } = await import("./html-report-
|
|
95625
|
-
const { renderIssueMarkdown } = await import("./issue-report-
|
|
95627
|
+
const { renderHtmlReport } = await import("./html-report-MSEC3ZI4.js");
|
|
95628
|
+
const { renderIssueMarkdown } = await import("./issue-report-JXVLP7CV.js");
|
|
95626
95629
|
for (const route of report.routes) {
|
|
95627
95630
|
if (route.status === "measured" && warrantsIssueDraft(route)) {
|
|
95628
95631
|
const file = path9.join(workDir, `ISSUE-${routeSlug(route.route)}.md`);
|
|
@@ -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);
|
|
@@ -121,6 +132,9 @@ function classifyMemoryTrend(heapSamples, externalSamples, options = {}) {
|
|
|
121
132
|
}
|
|
122
133
|
|
|
123
134
|
// src/confidence.ts
|
|
135
|
+
function withdrawnByDisagreement(confidence) {
|
|
136
|
+
return confidence.warnings.some((warning) => warning.code === "repetitions-disagree");
|
|
137
|
+
}
|
|
124
138
|
var resolveCycles = (cycles) => Math.max(cycles * 2, 6);
|
|
125
139
|
function effectiveVerdict(report) {
|
|
126
140
|
return report.confidence.supersededVerdict ?? report.trend.verdict;
|
|
@@ -324,6 +338,7 @@ export {
|
|
|
324
338
|
minGrowthFor,
|
|
325
339
|
classifyTrend,
|
|
326
340
|
classifyMemoryTrend,
|
|
341
|
+
withdrawnByDisagreement,
|
|
327
342
|
resolveCycles,
|
|
328
343
|
effectiveVerdict,
|
|
329
344
|
warrantsIssueDraft,
|
|
@@ -8,27 +8,46 @@ import {
|
|
|
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
|
|
26
|
+
const source = attribution.source ? ` \u2014 ${codeSpan(attribution.source)}` : "";
|
|
16
27
|
const packageName = attribution.packageName ? ` \u2014 ${attribution.packageName}` : "";
|
|
17
28
|
return `${attribution.owner}${source}${packageName}`;
|
|
18
29
|
}
|
|
19
30
|
function blamedParty(owner, culprit) {
|
|
20
31
|
if (owner === "app") {
|
|
21
|
-
return `**your own code** (
|
|
32
|
+
return `**your own code** (${codeSpan(culprit?.source ?? "app code")})`;
|
|
22
33
|
}
|
|
23
34
|
return `the dependency **${culprit?.packageName ?? "a dependency"}**`;
|
|
24
35
|
}
|
|
36
|
+
function missingEvidence(route) {
|
|
37
|
+
if (route.attributionGap === void 0) {
|
|
38
|
+
return "- (no findings above thresholds)";
|
|
39
|
+
}
|
|
40
|
+
const cause = route.attributionGap.reason === "snapshot-unavailable" ? "the final heap snapshot was never taken" : "the heap snapshots could not be read";
|
|
41
|
+
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}`;
|
|
43
|
+
}
|
|
25
44
|
function evidenceRows(route) {
|
|
26
45
|
const findings = [...route.diff?.grownNodes ?? [], ...route.diff?.newNodes ?? []];
|
|
27
46
|
return findings.slice(0, 6).map((finding, index) => {
|
|
28
47
|
const attribution = route.attribution?.findings[index];
|
|
29
48
|
const owner = ownerLabel(attribution);
|
|
30
|
-
return `- **${finding.kind}**
|
|
31
|
-
- retainers:
|
|
49
|
+
return `- **${finding.kind}** ${codeSpan(`[${finding.nodeType}] ${finding.name}`)} ${(finding.retainedBytes / MB).toFixed(2)} MB retained (${owner})
|
|
50
|
+
- retainers: ${codeSpan(finding.retainerChain || "(none)")}`;
|
|
32
51
|
}).join("\n");
|
|
33
52
|
}
|
|
34
53
|
function renderIssueMarkdown(route, run) {
|
|
@@ -71,7 +90,7 @@ Sampled during the load phases, without forcing collection: ${describePeakPressu
|
|
|
71
90
|
const signatures = route.signatures.map(
|
|
72
91
|
(signature) => `- ${signature.historical ? "(historical) " : ""}${signature.title} \u2014 ${signature.issue}`
|
|
73
92
|
).join("\n");
|
|
74
|
-
return `${preamble}${cacheDisclosure}# Memory leak on route
|
|
93
|
+
return `${preamble}${cacheDisclosure}# Memory leak on route ${codeSpan(route.route)}
|
|
75
94
|
|
|
76
95
|
Report generated by \`next-leak\` \u2014 every number below is reproducible from
|
|
77
96
|
the raw heap snapshots referenced at the bottom.
|
|
@@ -93,7 +112,7 @@ Deployment: output "standalone", node --expose-gc --max-old-space-size=${paramet
|
|
|
93
112
|
\`\`\`
|
|
94
113
|
npx next-leak <app-dir>
|
|
95
114
|
\`\`\`
|
|
96
|
-
2. next-leak boots \`.next/standalone/server.js\` in a fresh process per route and runs, against
|
|
115
|
+
2. next-leak boots \`.next/standalone/server.js\` in a fresh process per route and runs, against ${codeSpan(route.requestPath)}:
|
|
97
116
|
warm-up ${parameters.warmupRequests} requests \u2192 forced GC \u2192 baseline heap snapshot \u2192
|
|
98
117
|
${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
118
|
3. A cycle counts as growing when it retains at least ${(parameters.minGrowthPerCycle / 1024).toFixed(0)} KiB
|
|
@@ -109,7 +128,7 @@ The first cycle is excluded from the verdict (engine warm-up).
|
|
|
109
128
|
|
|
110
129
|
### Heap evidence
|
|
111
130
|
|
|
112
|
-
${evidenceRows(route) ||
|
|
131
|
+
${evidenceRows(route) || missingEvidence(route)}
|
|
113
132
|
${signatures === "" ? "" : `
|
|
114
133
|
**Matched known causes:**
|
|
115
134
|
${signatures}
|
|
@@ -118,7 +137,7 @@ ${signatures}
|
|
|
118
137
|
|
|
119
138
|
Raw snapshots (Chrome DevTools \u2192 Memory \u2192 Load, compare baseline vs after):
|
|
120
139
|
|
|
121
|
-
-
|
|
140
|
+
- ${codeSpan(path.basename(route.baselineSnapshot))} / ${codeSpan(path.basename(route.afterSnapshot))} in ${codeSpan(publicWorkDir(run))}
|
|
122
141
|
- machine-readable data: \`run.json\`, curves: \`report.html\`
|
|
123
142
|
`;
|
|
124
143
|
}
|
|
@@ -1,7 +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
|
-
effectiveVerdict
|
|
4
|
-
|
|
3
|
+
effectiveVerdict,
|
|
4
|
+
withdrawnByDisagreement
|
|
5
|
+
} from "./chunk-QUFL2FXU.js";
|
|
5
6
|
import {
|
|
6
7
|
assessPeakPressure,
|
|
7
8
|
describePeakPressure,
|
|
@@ -68,13 +69,20 @@ function peakBlock(route, parameters) {
|
|
|
68
69
|
const note = pressure === null ? "" : `<p class="warn">Peak pressure: ${escapeHtml(describePeakPressure(pressure))}</p>`;
|
|
69
70
|
return note + `<table><tr><th>peak during</th><th>heap</th><th>external</th><th>arrayBuffers</th><th>rss</th></tr>${peakRows}</table>`;
|
|
70
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
|
+
}
|
|
71
79
|
function measuredSection(route, parameters) {
|
|
72
80
|
if (route.status !== "measured") {
|
|
73
81
|
return "";
|
|
74
82
|
}
|
|
75
83
|
const verdict = effectiveVerdict(route);
|
|
76
84
|
const color = VERDICT_COLOR[verdict];
|
|
77
|
-
const withdrawn = route.confidence.supersededVerdict === void 0 ? "" : `<p class="warn">Measured <strong>${route.trend.verdict}</strong>, withdrawn: the run did not observe what that verdict needs.</p>`;
|
|
85
|
+
const withdrawn = route.confidence.supersededVerdict === void 0 || withdrawnByDisagreement(route.confidence) ? "" : `<p class="warn">Measured <strong>${route.trend.verdict}</strong>, withdrawn: the run did not observe what that verdict needs.</p>`;
|
|
78
86
|
const warnings = route.confidence.warnings.length === 0 ? "" : `<ul class="warn">${route.confidence.warnings.map((warning) => `<li>${escapeHtml(warning.detail)}</li>`).join("")}</ul>`;
|
|
79
87
|
const curve = route.samples.map((sample) => (sample / MB).toFixed(1)).join(" \u2192 ");
|
|
80
88
|
const findings = [...route.diff?.grownNodes ?? [], ...route.diff?.newNodes ?? []];
|
|
@@ -83,7 +91,7 @@ function measuredSection(route, parameters) {
|
|
|
83
91
|
const owner = ownerCell(attribution);
|
|
84
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>`;
|
|
85
93
|
}).join("");
|
|
86
|
-
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>`;
|
|
87
95
|
}
|
|
88
96
|
function renderHtmlReport(run) {
|
|
89
97
|
const measured = run.routes.filter((route) => route.status === "measured");
|
package/dist/cli.js
CHANGED
|
@@ -22,10 +22,10 @@ import {
|
|
|
22
22
|
runSelfCheck,
|
|
23
23
|
unregisterChild,
|
|
24
24
|
validateTarget
|
|
25
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-HDCOHSBY.js";
|
|
26
26
|
import {
|
|
27
27
|
classifyTrend
|
|
28
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-QUFL2FXU.js";
|
|
29
29
|
import "./chunk-E464SO7G.js";
|
|
30
30
|
import "./chunk-6XYFBOL2.js";
|
|
31
31
|
|
|
@@ -151,6 +151,9 @@ function discardPendingSnapshotsSync() {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
// src/build-attribution.ts
|
|
154
|
+
function isAttributionGap(outcome) {
|
|
155
|
+
return outcome !== null && "reason" in outcome;
|
|
156
|
+
}
|
|
154
157
|
var defaultDeps = {
|
|
155
158
|
diff: diffSnapshotFiles,
|
|
156
159
|
registry: extractModuleRegistry
|
|
@@ -159,17 +162,19 @@ async function attributeBuildCapture(result, appDir, onProgress = () => {
|
|
|
159
162
|
}, deps = defaultDeps) {
|
|
160
163
|
const capture = result.capture;
|
|
161
164
|
if (capture === null) {
|
|
162
|
-
return
|
|
165
|
+
return result.captureRequested ? {
|
|
166
|
+
reason: "capture-missed",
|
|
167
|
+
detail: result.captureFailure ?? "the run never captured a usable snapshot pair from the worker"
|
|
168
|
+
} : null;
|
|
163
169
|
}
|
|
164
170
|
onProgress(`diffing what worker ${capture.pid} retained`);
|
|
165
171
|
let diff;
|
|
166
172
|
try {
|
|
167
173
|
diff = await deps.diff(capture.files.baselineFile, capture.files.afterFile);
|
|
168
174
|
} catch (cause) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return null;
|
|
175
|
+
const detail = cause instanceof SnapshotError ? cause.message : "the snapshot pair could not be diffed";
|
|
176
|
+
onProgress(`no attribution: ${detail}`);
|
|
177
|
+
return { reason: "snapshot-unreadable", detail };
|
|
173
178
|
}
|
|
174
179
|
const registry = await deps.registry(path2.join(appDir, ".next", "server"));
|
|
175
180
|
return {
|
|
@@ -212,6 +217,13 @@ function attributionLines(attribution) {
|
|
|
212
217
|
if (attribution === null) {
|
|
213
218
|
return [];
|
|
214
219
|
}
|
|
220
|
+
if (isAttributionGap(attribution)) {
|
|
221
|
+
return [
|
|
222
|
+
"",
|
|
223
|
+
` no attribution: ${attribution.detail}.`,
|
|
224
|
+
` This does not say nothing grew \u2014 the comparison was never made.`
|
|
225
|
+
];
|
|
226
|
+
}
|
|
215
227
|
const { diff, attributed, bracketed } = attribution;
|
|
216
228
|
const findings = [...diff.grownNodes, ...diff.newNodes];
|
|
217
229
|
if (findings.length === 0) {
|
|
@@ -566,6 +578,24 @@ function recordSample(rows, rootPid, atMs, workers, parentSamples) {
|
|
|
566
578
|
workers.set(row.pid, series);
|
|
567
579
|
}
|
|
568
580
|
}
|
|
581
|
+
function describeCaptureFailure(stage, pid, mismatchedWorker) {
|
|
582
|
+
if (mismatchedWorker) {
|
|
583
|
+
return "the snapshot pair belongs to a worker other than the one judged";
|
|
584
|
+
}
|
|
585
|
+
if (pid === null) {
|
|
586
|
+
return "no static-generation worker was seen to capture from";
|
|
587
|
+
}
|
|
588
|
+
switch (stage) {
|
|
589
|
+
case "waiting":
|
|
590
|
+
return `worker ${pid} never grew enough to snapshot`;
|
|
591
|
+
case "baseline-taken":
|
|
592
|
+
return `worker ${pid} was snapshotted once and never reached a second point`;
|
|
593
|
+
case "missed":
|
|
594
|
+
return `capture gave up on worker ${pid} before it had a pair`;
|
|
595
|
+
case "pair-taken":
|
|
596
|
+
return `the pair captured from worker ${pid} could not be collected from disk`;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
569
599
|
async function collectCapture(options) {
|
|
570
600
|
const { appDir, workDir, pid, stage, baselineRssBytes, afterRssBytes } = options;
|
|
571
601
|
if (workDir === void 0 || pid === null) {
|
|
@@ -716,6 +746,8 @@ async function runBuildMeasurement(options, deps = defaultDeps2) {
|
|
|
716
746
|
retentionPerPageBytes: null,
|
|
717
747
|
heapExhausted,
|
|
718
748
|
capture: workerCapture,
|
|
749
|
+
captureRequested: capturing,
|
|
750
|
+
captureFailure: workerCapture === null ? describeCaptureFailure(captureStage, capturePid, false) : null,
|
|
719
751
|
strippedCapWarning,
|
|
720
752
|
exitCode,
|
|
721
753
|
output
|
|
@@ -747,6 +779,12 @@ async function runBuildMeasurement(options, deps = defaultDeps2) {
|
|
|
747
779
|
// A pair belonging to a worker other than the one judged supports no claim
|
|
748
780
|
// about the verdict above, so it is dropped rather than reported next to it.
|
|
749
781
|
capture: workerCapture !== null && workerCapture.pid === worst?.pid ? workerCapture : null,
|
|
782
|
+
captureRequested: capturing,
|
|
783
|
+
captureFailure: workerCapture !== null && workerCapture.pid === worst?.pid ? null : describeCaptureFailure(
|
|
784
|
+
captureStage,
|
|
785
|
+
capturePid,
|
|
786
|
+
workerCapture !== null && workerCapture.pid !== worst?.pid
|
|
787
|
+
),
|
|
750
788
|
strippedCapWarning,
|
|
751
789
|
exitCode,
|
|
752
790
|
output
|
package/dist/confidence.d.ts
CHANGED
|
@@ -55,12 +55,41 @@ export type ConfidenceReport = {
|
|
|
55
55
|
warnings: MeasurementWarning[];
|
|
56
56
|
/**
|
|
57
57
|
* Verdict the evidence actually supports, when the measurement is not merely
|
|
58
|
-
* noisy but invalid.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
58
|
+
* noisy but invalid.
|
|
59
|
+
*
|
|
60
|
+
* Two different mechanisms set this, and they mean different things.
|
|
61
|
+
* `assessConfidence` sets it when a run did not observe what its own verdict
|
|
62
|
+
* requires, and only ever downgrades `leak`: accusing an app of leaking on
|
|
63
|
+
* evidence that does not hold is the expensive error — it sends someone
|
|
64
|
+
* chasing a ghost and ends as an issue against this tool. `aggregateRepetitions`
|
|
65
|
+
* sets it for *any* verdict when repetitions of the same route disagreed,
|
|
66
|
+
* which is not a flaw in this run at all. `withdrawnByDisagreement` below
|
|
67
|
+
* tells the two apart; anything that explains a withdrawal to a reader has to.
|
|
61
68
|
*/
|
|
62
69
|
supersededVerdict?: TrendVerdict;
|
|
63
70
|
};
|
|
71
|
+
/**
|
|
72
|
+
* Whether a withdrawal came from repetitions disagreeing rather than from this
|
|
73
|
+
* run failing its own audit.
|
|
74
|
+
*
|
|
75
|
+
* The distinction matters to anyone phrasing it. An audited withdrawal means
|
|
76
|
+
* the run never observed what the verdict needs — it never settled, the
|
|
77
|
+
* disconnects cut nothing, the deltas were too thin. A disagreement means the
|
|
78
|
+
* opposite: this run observed exactly what it needed, and another run of the
|
|
79
|
+
* same route observed something else. Telling a reader the run "did not observe
|
|
80
|
+
* what that verdict needs" in the second case is simply untrue, and the
|
|
81
|
+
* disagreement already carries its own warning naming the verdicts it produced.
|
|
82
|
+
*
|
|
83
|
+
* Both can be true of one report, and then this answers `true` and the audit's
|
|
84
|
+
* sentence is dropped along with the disagreement's. `aggregateRepetitions`
|
|
85
|
+
* keeps the winning pass's own warnings and overwrites only `supersededVerdict`,
|
|
86
|
+
* which both mechanisms set to `inconclusive`, so no reading is lost: the
|
|
87
|
+
* audit's warning still prints its own detail, which names the cause the generic
|
|
88
|
+
* sentence never did. Separating the two would mean carrying the winner's
|
|
89
|
+
* withdrawal reason through the aggregation, and it would buy the reader a
|
|
90
|
+
* sentence they already have in a more specific form.
|
|
91
|
+
*/
|
|
92
|
+
export declare function withdrawnByDisagreement(confidence: ConfidenceReport): boolean;
|
|
64
93
|
export type ConfidenceInput = {
|
|
65
94
|
trend: TrendResult;
|
|
66
95
|
loadOutcomes: readonly LoadOutcome[];
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
|
|
2
2
|
import {
|
|
3
3
|
renderHtmlReport
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-VKPGB5MG.js";
|
|
5
|
+
import "./chunk-QUFL2FXU.js";
|
|
6
6
|
import "./chunk-E464SO7G.js";
|
|
7
7
|
import "./chunk-6XYFBOL2.js";
|
|
8
8
|
export {
|
package/dist/index.js
CHANGED
|
@@ -41,16 +41,16 @@ import {
|
|
|
41
41
|
sourceIndexAt,
|
|
42
42
|
summarizeBaseline,
|
|
43
43
|
validateTarget
|
|
44
|
-
} from "./chunk-
|
|
44
|
+
} from "./chunk-HDCOHSBY.js";
|
|
45
45
|
import {
|
|
46
46
|
renderHtmlReport
|
|
47
|
-
} from "./chunk-
|
|
47
|
+
} from "./chunk-VKPGB5MG.js";
|
|
48
48
|
import {
|
|
49
49
|
classifyTrend
|
|
50
|
-
} from "./chunk-
|
|
50
|
+
} from "./chunk-QUFL2FXU.js";
|
|
51
51
|
import {
|
|
52
52
|
renderIssueMarkdown
|
|
53
|
-
} from "./chunk-
|
|
53
|
+
} from "./chunk-ULTMNWRX.js";
|
|
54
54
|
import "./chunk-E464SO7G.js";
|
|
55
55
|
import "./chunk-6XYFBOL2.js";
|
|
56
56
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createRequire as __nextLeakCreateRequire } from 'node:module';import { fileURLToPath as __nextLeakFileURLToPath } from 'node:url';import { dirname as __nextLeakDirname } from 'node:path';const require = __nextLeakCreateRequire(import.meta.url);const __filename = __nextLeakFileURLToPath(import.meta.url);const __dirname = __nextLeakDirname(__filename);
|
|
2
2
|
import {
|
|
3
3
|
renderIssueMarkdown
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-ULTMNWRX.js";
|
|
5
5
|
import "./chunk-E464SO7G.js";
|
|
6
6
|
import "./chunk-6XYFBOL2.js";
|
|
7
7
|
export {
|