benchforge 0.1.11 → 0.2.4
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/LICENSE +20 -0
- package/README.md +99 -294
- package/bin/benchforge +1 -2
- package/dist/AnalyzeArchive-8NCJhmhS.mjs +145 -0
- package/dist/AnalyzeArchive-8NCJhmhS.mjs.map +1 -0
- package/dist/BenchMatrix-BZVrBB_h.mjs +1050 -0
- package/dist/BenchMatrix-BZVrBB_h.mjs.map +1 -0
- package/dist/{BenchRunner-BzyUfiyB.d.mts → BenchRunner-DglX1NOn.d.mts} +119 -66
- package/dist/CoverageSampler-D5T9DRqe.mjs +27 -0
- package/dist/CoverageSampler-D5T9DRqe.mjs.map +1 -0
- package/dist/Formatters-BWj3d4sv.mjs +95 -0
- package/dist/Formatters-BWj3d4sv.mjs.map +1 -0
- package/dist/{HeapSampler-B8dtKHn1.mjs → HeapSampler-Dq-hpXem.mjs} +4 -4
- package/dist/HeapSampler-Dq-hpXem.mjs.map +1 -0
- package/dist/RunBenchCLI-C17DrJz8.mjs +3075 -0
- package/dist/RunBenchCLI-C17DrJz8.mjs.map +1 -0
- package/dist/StatisticalUtils-BD92crgM.mjs +255 -0
- package/dist/StatisticalUtils-BD92crgM.mjs.map +1 -0
- package/dist/TimeSampler-Ds8n7l2B.mjs +29 -0
- package/dist/TimeSampler-Ds8n7l2B.mjs.map +1 -0
- package/dist/ViewerServer-BJhdnxlN.mjs +639 -0
- package/dist/ViewerServer-BJhdnxlN.mjs.map +1 -0
- package/dist/ViewerServer-CuMNdNBz.mjs +2 -0
- package/dist/bin/benchforge.mjs +4 -5
- package/dist/bin/benchforge.mjs.map +1 -1
- package/dist/index.d.mts +711 -558
- package/dist/index.mjs +98 -3
- package/dist/index.mjs.map +1 -0
- package/dist/runners/WorkerScript.d.mts +12 -4
- package/dist/runners/WorkerScript.mjs +77 -105
- package/dist/runners/WorkerScript.mjs.map +1 -1
- package/dist/viewer/assets/CIPlot-BkOvMoMa.js +1 -0
- package/dist/viewer/assets/HistogramKde-CmSyUFY0.js +1 -0
- package/dist/viewer/assets/LegendUtils-BJpbn_jr.js +55 -0
- package/dist/viewer/assets/SampleTimeSeries-C4VBhXr3.js +1 -0
- package/dist/viewer/assets/index-Br9bp_cX.js +153 -0
- package/dist/viewer/assets/index-NzXXe_CC.css +1 -0
- package/dist/viewer/index.html +19 -0
- package/dist/viewer/speedscope/LICENSE +21 -0
- package/dist/viewer/speedscope/SourceCodePro-Regular.ttf-ILST5JV6.woff2 +0 -0
- package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js +2 -0
- package/dist/viewer/speedscope/favicon-16x16-V2DMIAZS.js.map +7 -0
- package/dist/viewer/speedscope/favicon-16x16-VSI62OPJ.png +0 -0
- package/dist/viewer/speedscope/favicon-32x32-3EB2YCUY.png +0 -0
- package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js +2 -0
- package/dist/viewer/speedscope/favicon-32x32-THY3JDJL.js.map +7 -0
- package/dist/viewer/speedscope/favicon-FOKUP5Y5.ico +0 -0
- package/dist/viewer/speedscope/favicon-M34RF7BI.js +2 -0
- package/dist/viewer/speedscope/favicon-M34RF7BI.js.map +7 -0
- package/dist/viewer/speedscope/file-format-schema.json +274 -0
- package/dist/viewer/speedscope/index.html +19 -0
- package/dist/viewer/speedscope/jfrview_bg-BLJXNNQB.wasm +0 -0
- package/dist/viewer/speedscope/perf-vertx-stacks-01-collapsed-all-ZNUIGAJL.txt +199 -0
- package/dist/viewer/speedscope/release.txt +3 -0
- package/dist/viewer/speedscope/source-code-pro.LICENSE.md +93 -0
- package/dist/viewer/speedscope/speedscope-GHPHNKXC.css +2 -0
- package/dist/viewer/speedscope/speedscope-GHPHNKXC.css.map +7 -0
- package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js +212 -0
- package/dist/viewer/speedscope/speedscope-QZFMJ7VP.js.map +7 -0
- package/package.json +52 -27
- package/src/bin/benchforge.ts +2 -2
- package/src/cli/AnalyzeArchive.ts +232 -0
- package/src/cli/BrowserBench.ts +322 -0
- package/src/cli/CliArgs.ts +164 -51
- package/src/cli/CliExport.ts +179 -0
- package/src/cli/CliOptions.ts +147 -0
- package/src/cli/CliReport.ts +197 -0
- package/src/cli/FilterBenchmarks.ts +18 -30
- package/src/cli/RunBenchCLI.ts +132 -866
- package/src/cli/SuiteRunner.ts +160 -0
- package/src/cli/ViewerServer.ts +282 -0
- package/src/export/AllocExport.ts +121 -0
- package/src/export/ArchiveExport.ts +146 -0
- package/src/export/ArchiveFormat.ts +50 -0
- package/src/export/CoverageExport.ts +148 -0
- package/src/export/EditorUri.ts +10 -0
- package/src/export/PerfettoExport.ts +64 -99
- package/src/export/SpeedscopeTypes.ts +98 -0
- package/src/export/TimeExport.ts +115 -0
- package/src/index.ts +86 -67
- package/src/matrix/BenchMatrix.ts +230 -0
- package/src/matrix/CaseLoader.ts +8 -6
- package/src/matrix/MatrixDirRunner.ts +153 -0
- package/src/matrix/MatrixFilter.ts +49 -47
- package/src/matrix/MatrixInlineRunner.ts +50 -0
- package/src/matrix/MatrixReport.ts +90 -250
- package/src/matrix/VariantLoader.ts +5 -5
- package/src/profiling/browser/BenchLoop.ts +51 -0
- package/src/profiling/browser/BrowserCDP.ts +133 -0
- package/src/profiling/browser/BrowserGcStats.ts +33 -0
- package/src/profiling/browser/BrowserProfiler.ts +160 -0
- package/src/profiling/browser/CdpClient.ts +82 -0
- package/src/profiling/browser/CdpPage.ts +138 -0
- package/src/profiling/browser/ChromeLauncher.ts +158 -0
- package/src/profiling/browser/ChromeTraceEvent.ts +28 -0
- package/src/profiling/browser/PageLoadMode.ts +61 -0
- package/src/profiling/node/CoverageSampler.ts +27 -0
- package/src/profiling/node/CoverageTypes.ts +23 -0
- package/src/profiling/node/HeapSampleReport.ts +261 -0
- package/src/{heap-sample → profiling/node}/HeapSampler.ts +1 -2
- package/src/{heap-sample → profiling/node}/ResolvedProfile.ts +18 -9
- package/src/profiling/node/TimeSampler.ts +57 -0
- package/src/report/BenchmarkReport.ts +146 -0
- package/src/report/Colors.ts +9 -0
- package/src/report/Formatters.ts +110 -0
- package/src/report/GcSections.ts +151 -0
- package/src/{GitUtils.ts → report/GitUtils.ts} +18 -19
- package/src/report/HtmlReport.ts +223 -0
- package/src/report/ParseStats.ts +73 -0
- package/src/report/StandardSections.ts +147 -0
- package/src/report/ViewerSections.ts +286 -0
- package/src/report/text/TableReport.ts +253 -0
- package/src/report/text/TextReport.ts +123 -0
- package/src/runners/AdaptiveWrapper.ts +116 -236
- package/src/runners/BenchRunner.ts +20 -15
- package/src/{Benchmark.ts → runners/BenchmarkSpec.ts} +5 -6
- package/src/runners/CreateRunner.ts +5 -7
- package/src/runners/GcStats.ts +47 -50
- package/src/{MeasuredResults.ts → runners/MeasuredResults.ts} +43 -37
- package/src/runners/MergeBatches.ts +123 -0
- package/src/{NodeGC.ts → runners/NodeGC.ts} +2 -3
- package/src/runners/RunnerOrchestrator.ts +127 -243
- package/src/runners/RunnerUtils.ts +75 -1
- package/src/runners/SampleStats.ts +100 -0
- package/src/runners/TimingRunner.ts +244 -0
- package/src/runners/TimingUtils.ts +3 -2
- package/src/runners/WorkerScript.ts +135 -151
- package/src/stats/BootstrapDifference.ts +282 -0
- package/src/{PermutationTest.ts → stats/PermutationTest.ts} +8 -17
- package/src/stats/StatisticalUtils.ts +445 -0
- package/src/{tests → test}/AdaptiveConvergence.test.ts +10 -10
- package/src/test/AdaptiveRunner.test.ts +39 -41
- package/src/{tests → test}/AdaptiveSampling.test.ts +9 -9
- package/src/test/AdaptiveStatistics.integration.ts +2 -2
- package/src/{tests → test}/BenchMatrix.test.ts +19 -16
- package/src/test/BenchmarkReport.test.ts +63 -13
- package/src/test/BrowserBench.e2e.test.ts +186 -17
- package/src/test/BrowserBench.test.ts +10 -5
- package/src/test/BuildTimeSection.test.ts +130 -0
- package/src/test/CapSamples.test.ts +82 -0
- package/src/test/CoverageExport.test.ts +115 -0
- package/src/test/CoverageSampler.test.ts +33 -0
- package/src/test/HeapAttribution.test.ts +14 -14
- package/src/{tests → test}/MatrixFilter.test.ts +1 -1
- package/src/{tests → test}/MatrixReport.test.ts +1 -1
- package/src/test/PermutationTest.test.ts +1 -1
- package/src/{tests → test}/RealDataValidation.test.ts +6 -6
- package/src/test/RunBenchCLI.test.ts +39 -38
- package/src/test/RunnerOrchestrator.test.ts +12 -12
- package/src/test/StatisticalUtils.test.ts +48 -12
- package/src/{table-util/test → test}/TableReport.test.ts +2 -2
- package/src/test/TestUtils.ts +12 -7
- package/src/test/TimeExport.test.ts +139 -0
- package/src/test/TimeSampler.test.ts +37 -0
- package/src/test/ViewerLive.e2e.test.ts +159 -0
- package/src/test/ViewerStatic.static.e2e.test.ts +137 -0
- package/src/{tests → test}/fixtures/baseline/impl.ts +1 -1
- package/src/{tests → test}/fixtures/bevy30-samples.ts +3 -1
- package/src/test/fixtures/cases/asyncCases.ts +9 -0
- package/src/{tests → test}/fixtures/cases/cases.ts +5 -2
- package/src/test/fixtures/cases/variants/product.ts +2 -0
- package/src/test/fixtures/cases/variants/sum.ts +2 -0
- package/src/test/fixtures/discover/fast.ts +1 -0
- package/src/{tests → test}/fixtures/discover/slow.ts +1 -1
- package/src/test/fixtures/invalid/bad.ts +1 -0
- package/src/test/fixtures/loader/fast.ts +1 -0
- package/src/{tests → test}/fixtures/loader/slow.ts +1 -1
- package/src/test/fixtures/loader/stateful.ts +2 -0
- package/src/test/fixtures/stateful/stateful.ts +2 -0
- package/src/test/fixtures/variants/extra.ts +1 -0
- package/src/test/fixtures/variants/impl.ts +1 -0
- package/src/test/fixtures/worker/fast.ts +1 -0
- package/src/{tests → test}/fixtures/worker/slow.ts +1 -1
- package/src/viewer/DateFormat.ts +30 -0
- package/src/viewer/Helpers.ts +23 -0
- package/src/viewer/LineData.ts +120 -0
- package/src/viewer/Providers.ts +191 -0
- package/src/viewer/ReportData.ts +123 -0
- package/src/viewer/State.ts +49 -0
- package/src/viewer/Theme.ts +15 -0
- package/src/viewer/components/App.tsx +73 -0
- package/src/viewer/components/DropZone.tsx +71 -0
- package/src/viewer/components/LazyPlot.ts +33 -0
- package/src/viewer/components/SamplesPanel.tsx +214 -0
- package/src/viewer/components/Shell.tsx +26 -0
- package/src/viewer/components/SourcePanel.tsx +216 -0
- package/src/viewer/components/SummaryPanel.tsx +332 -0
- package/src/viewer/components/TabBar.tsx +131 -0
- package/src/viewer/components/TabContent.tsx +46 -0
- package/src/viewer/components/ThemeToggle.tsx +50 -0
- package/src/viewer/index.html +20 -0
- package/src/viewer/main.tsx +4 -0
- package/src/viewer/plots/CIPlot.ts +313 -0
- package/src/{html/browser → viewer/plots}/HistogramKde.ts +33 -38
- package/src/viewer/plots/LegendUtils.ts +134 -0
- package/src/viewer/plots/PlotTypes.ts +85 -0
- package/src/viewer/plots/RenderPlots.ts +230 -0
- package/src/viewer/plots/SampleTimeSeries.ts +306 -0
- package/src/viewer/plots/SvgHelpers.ts +136 -0
- package/src/viewer/plots/TimeSeriesMarks.ts +319 -0
- package/src/viewer/report.css +427 -0
- package/src/viewer/shell.css +357 -0
- package/src/viewer/tsconfig.json +11 -0
- package/dist/BrowserHeapSampler-B6asLKWQ.mjs +0 -202
- package/dist/BrowserHeapSampler-B6asLKWQ.mjs.map +0 -1
- package/dist/GcStats-wX7Xyblu.mjs +0 -77
- package/dist/GcStats-wX7Xyblu.mjs.map +0 -1
- package/dist/HeapSampler-B8dtKHn1.mjs.map +0 -1
- package/dist/TimingUtils-DwOwkc8G.mjs +0 -597
- package/dist/TimingUtils-DwOwkc8G.mjs.map +0 -1
- package/dist/browser/index.js +0 -914
- package/dist/src-B-DDaCa9.mjs +0 -3108
- package/dist/src-B-DDaCa9.mjs.map +0 -1
- package/src/BenchMatrix.ts +0 -380
- package/src/BenchmarkReport.ts +0 -161
- package/src/HtmlDataPrep.ts +0 -148
- package/src/StandardSections.ts +0 -261
- package/src/StatisticalUtils.ts +0 -175
- package/src/TypeUtil.ts +0 -8
- package/src/browser/BrowserGcStats.ts +0 -44
- package/src/browser/BrowserHeapSampler.ts +0 -271
- package/src/export/JsonExport.ts +0 -103
- package/src/export/JsonFormat.ts +0 -91
- package/src/export/SpeedscopeExport.ts +0 -202
- package/src/heap-sample/HeapSampleReport.ts +0 -269
- package/src/html/HtmlReport.ts +0 -131
- package/src/html/HtmlTemplate.ts +0 -284
- package/src/html/Types.ts +0 -88
- package/src/html/browser/CIPlot.ts +0 -287
- package/src/html/browser/LegendUtils.ts +0 -163
- package/src/html/browser/RenderPlots.ts +0 -263
- package/src/html/browser/SampleTimeSeries.ts +0 -389
- package/src/html/browser/Types.ts +0 -96
- package/src/html/browser/index.ts +0 -1
- package/src/html/index.ts +0 -17
- package/src/runners/BasicRunner.ts +0 -364
- package/src/table-util/ConvergenceFormatters.ts +0 -19
- package/src/table-util/Formatters.ts +0 -157
- package/src/table-util/README.md +0 -70
- package/src/table-util/TableReport.ts +0 -293
- package/src/tests/fixtures/cases/asyncCases.ts +0 -7
- package/src/tests/fixtures/cases/variants/product.ts +0 -2
- package/src/tests/fixtures/cases/variants/sum.ts +0 -2
- package/src/tests/fixtures/discover/fast.ts +0 -1
- package/src/tests/fixtures/invalid/bad.ts +0 -1
- package/src/tests/fixtures/loader/fast.ts +0 -1
- package/src/tests/fixtures/loader/stateful.ts +0 -2
- package/src/tests/fixtures/stateful/stateful.ts +0 -2
- package/src/tests/fixtures/variants/extra.ts +0 -1
- package/src/tests/fixtures/variants/impl.ts +0 -1
- package/src/tests/fixtures/worker/fast.ts +0 -1
- /package/src/{table-util/test → test}/TableValueExtractor.test.ts +0 -0
- /package/src/{table-util/test → test}/TableValueExtractor.ts +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { type ChildProcess, execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
/** Handle for a running Chrome instance. */
|
|
8
|
+
export interface ChromeInstance {
|
|
9
|
+
port: number;
|
|
10
|
+
process: ChildProcess;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Flags to suppress background services irrelevant to benchmarking. */
|
|
15
|
+
const quietFlags = [
|
|
16
|
+
"--disable-background-networking",
|
|
17
|
+
"--disable-client-side-phishing-detection",
|
|
18
|
+
"--disable-component-update",
|
|
19
|
+
"--disable-field-trial-config",
|
|
20
|
+
"--disable-sync",
|
|
21
|
+
"--disable-breakpad",
|
|
22
|
+
"--noerrdialogs",
|
|
23
|
+
"--disable-features=OptimizationHints,Translate,MediaRouter,DialMediaRouteProvider",
|
|
24
|
+
"--disable-extensions",
|
|
25
|
+
"--disable-component-extensions-with-background-pages",
|
|
26
|
+
"--disable-default-apps",
|
|
27
|
+
"--metrics-recording-only",
|
|
28
|
+
"--no-service-autorun",
|
|
29
|
+
"--password-store=basic",
|
|
30
|
+
"--use-mock-keychain",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Stderr patterns to suppress (irrelevant to benchmarking). */
|
|
34
|
+
const chromeNoise =
|
|
35
|
+
/SharedImageManager|skia_output_device_buffer_queue|task_policy_set/;
|
|
36
|
+
|
|
37
|
+
/** Launch Chrome with remote debugging and return instance handle. */
|
|
38
|
+
export async function launchChrome(opts: {
|
|
39
|
+
headless?: boolean;
|
|
40
|
+
chromePath?: string;
|
|
41
|
+
chromeProfile?: string;
|
|
42
|
+
args?: string[];
|
|
43
|
+
}): Promise<ChromeInstance> {
|
|
44
|
+
const { headless = false, chromeProfile, chromePath, args = [] } = opts;
|
|
45
|
+
const chrome = chromePath || process.env.CHROME_PATH || findChrome();
|
|
46
|
+
|
|
47
|
+
const tmpDir = chromeProfile
|
|
48
|
+
? undefined
|
|
49
|
+
: await mkdtemp(join(tmpdir(), "benchforge-"));
|
|
50
|
+
const userDataDir = chromeProfile ?? tmpDir!;
|
|
51
|
+
|
|
52
|
+
const flags = [
|
|
53
|
+
"--remote-debugging-port=0",
|
|
54
|
+
`--user-data-dir=${userDataDir}`,
|
|
55
|
+
"--no-first-run",
|
|
56
|
+
"--no-default-browser-check",
|
|
57
|
+
...quietFlags,
|
|
58
|
+
...(headless ? ["--headless=new"] : []),
|
|
59
|
+
...args,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const proc = spawn(chrome, flags, { stdio: ["pipe", "pipe", "pipe"] });
|
|
63
|
+
const wsUrlPromise = parseWsUrl(proc);
|
|
64
|
+
pipeChromeOutput(proc);
|
|
65
|
+
const wsUrl = await wsUrlPromise;
|
|
66
|
+
const port = Number(new URL(wsUrl).port);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
port,
|
|
70
|
+
process: proc,
|
|
71
|
+
async close() {
|
|
72
|
+
proc.kill();
|
|
73
|
+
await new Promise<void>(r => proc.on("exit", () => r()));
|
|
74
|
+
if (tmpDir)
|
|
75
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Create a new browser tab and return its CDP WebSocket URL and target ID. */
|
|
81
|
+
export async function createTab(
|
|
82
|
+
port: number,
|
|
83
|
+
): Promise<{ wsUrl: string; targetId: string }> {
|
|
84
|
+
const url = `http://127.0.0.1:${port}/json/new`;
|
|
85
|
+
const resp = await fetch(url, { method: "PUT" });
|
|
86
|
+
const text = await resp.text();
|
|
87
|
+
try {
|
|
88
|
+
const json = JSON.parse(text);
|
|
89
|
+
return { wsUrl: json.webSocketDebuggerUrl, targetId: json.id };
|
|
90
|
+
} catch {
|
|
91
|
+
const msg = `Chrome /json/new returned non-JSON: ${text.slice(0, 200)}`;
|
|
92
|
+
throw new Error(msg);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Close a browser tab by target ID. */
|
|
97
|
+
export async function closeTab(port: number, targetId: string): Promise<void> {
|
|
98
|
+
const url = `http://127.0.0.1:${port}/json/close/${targetId}`;
|
|
99
|
+
await fetch(url).catch(() => {});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Find Chrome/Chromium on the system. */
|
|
103
|
+
function findChrome(): string {
|
|
104
|
+
if (process.platform === "darwin") {
|
|
105
|
+
const path = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
106
|
+
if (existsSync(path)) return path;
|
|
107
|
+
}
|
|
108
|
+
if (process.platform === "win32") {
|
|
109
|
+
for (const env of ["ProgramFiles", "ProgramFiles(x86)"] as const) {
|
|
110
|
+
const base = process.env[env];
|
|
111
|
+
if (!base) continue;
|
|
112
|
+
const p = join(base, "Google", "Chrome", "Application", "chrome.exe");
|
|
113
|
+
if (existsSync(p)) return p;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const name of ["google-chrome", "chromium-browser", "chromium"]) {
|
|
117
|
+
try {
|
|
118
|
+
return execFileSync("which", [name], { encoding: "utf8" }).trim();
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
throw new Error(
|
|
122
|
+
"Chrome not found. Install Chrome or set CHROME_PATH, or use --chrome <path>.",
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Parse the DevTools WebSocket URL from Chrome's stderr. */
|
|
127
|
+
function parseWsUrl(proc: ChildProcess): Promise<string> {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
const wsPattern = /DevTools listening on (ws:\/\/\S+)/;
|
|
130
|
+
const onData = (chunk: Buffer) => {
|
|
131
|
+
const match = chunk.toString().match(wsPattern);
|
|
132
|
+
if (match) {
|
|
133
|
+
proc.stderr?.off("data", onData);
|
|
134
|
+
resolve(match[1]);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
proc.stderr?.on("data", onData);
|
|
138
|
+
proc.on("error", reject);
|
|
139
|
+
proc.on("exit", code =>
|
|
140
|
+
reject(new Error(`Chrome exited (code ${code}) before DevTools ready`)),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Forward Chrome stdout/stderr to terminal, filtering known noise. */
|
|
146
|
+
function pipeChromeOutput(proc: ChildProcess): void {
|
|
147
|
+
const forward = (stream: NodeJS.ReadableStream | null) =>
|
|
148
|
+
stream?.on("data", (chunk: Buffer) => {
|
|
149
|
+
const lines = chunk
|
|
150
|
+
.toString()
|
|
151
|
+
.split("\n")
|
|
152
|
+
.map(l => l.trim())
|
|
153
|
+
.filter(l => l && !chromeNoise.test(l));
|
|
154
|
+
for (const line of lines) process.stderr.write(`[chrome] ${line}\n`);
|
|
155
|
+
});
|
|
156
|
+
forward(proc.stdout);
|
|
157
|
+
forward(proc.stderr);
|
|
158
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Chrome Trace Event format (used by Perfetto and CDP tracing). */
|
|
2
|
+
export interface TraceEvent {
|
|
3
|
+
/** Event type: M=metadata, C=counter, i=instant, B/E=begin/end, X=complete */
|
|
4
|
+
ph: string;
|
|
5
|
+
|
|
6
|
+
/** Timestamp in microseconds */
|
|
7
|
+
ts: number;
|
|
8
|
+
|
|
9
|
+
/** Process ID */
|
|
10
|
+
pid?: number;
|
|
11
|
+
|
|
12
|
+
/** Thread ID */
|
|
13
|
+
tid?: number;
|
|
14
|
+
|
|
15
|
+
/** Event category */
|
|
16
|
+
cat?: string;
|
|
17
|
+
|
|
18
|
+
name: string;
|
|
19
|
+
|
|
20
|
+
/** Arbitrary event arguments */
|
|
21
|
+
args?: Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
/** Scope for instant events: "t"=thread, "p"=process, "g"=global */
|
|
24
|
+
s?: string;
|
|
25
|
+
|
|
26
|
+
/** Duration for complete events (microseconds) */
|
|
27
|
+
dur?: number;
|
|
28
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
instrumentOpts,
|
|
3
|
+
startInstruments,
|
|
4
|
+
stopInstruments,
|
|
5
|
+
} from "./BrowserCDP.ts";
|
|
6
|
+
import type {
|
|
7
|
+
BrowserProfileResult,
|
|
8
|
+
NavTiming,
|
|
9
|
+
ProfileCtx,
|
|
10
|
+
} from "./BrowserProfiler.ts";
|
|
11
|
+
import type { CdpPage } from "./CdpPage.ts";
|
|
12
|
+
|
|
13
|
+
/** Run passive page-load profiling: instrument ==> navigate ==> wait ==> collect. */
|
|
14
|
+
export async function runPageLoad(
|
|
15
|
+
ctx: ProfileCtx,
|
|
16
|
+
): Promise<BrowserProfileResult> {
|
|
17
|
+
const { page, cdp, params, samplingInterval } = ctx;
|
|
18
|
+
const opts = instrumentOpts(params, samplingInterval);
|
|
19
|
+
await startInstruments(cdp, opts);
|
|
20
|
+
|
|
21
|
+
// Observe LCP via PerformanceObserver (avoids deprecated getEntriesByType warning)
|
|
22
|
+
await page.addInitScript(() => {
|
|
23
|
+
const g = globalThis as any;
|
|
24
|
+
g.__lcpTime = undefined;
|
|
25
|
+
new PerformanceObserver(list => {
|
|
26
|
+
const entries = list.getEntries();
|
|
27
|
+
if (entries.length) g.__lcpTime = entries.at(-1)!.startTime;
|
|
28
|
+
}).observe({ type: "largest-contentful-paint" as any, buffered: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const { url, waitFor } = params;
|
|
32
|
+
|
|
33
|
+
const isBuiltinWait = waitFor === "load" || waitFor === "domcontentloaded";
|
|
34
|
+
const waitUntil = isBuiltinWait ? waitFor : "load";
|
|
35
|
+
await page.navigate(url, { waitUntil });
|
|
36
|
+
|
|
37
|
+
if (waitFor && !isBuiltinWait) {
|
|
38
|
+
if (/^[#.[]/.test(waitFor)) {
|
|
39
|
+
await page.waitForSelector(waitFor);
|
|
40
|
+
} else {
|
|
41
|
+
await page.waitForFunction(waitFor);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const navTiming = await readNavTiming(page);
|
|
46
|
+
const collected = await stopInstruments(cdp, opts);
|
|
47
|
+
return { ...collected, navTiming, wallTimeMs: navTiming.loadEvent };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read navigation timing from the page via Performance API. */
|
|
51
|
+
export async function readNavTiming(page: CdpPage): Promise<NavTiming> {
|
|
52
|
+
return page.evaluate(() => {
|
|
53
|
+
const perf = performance as any;
|
|
54
|
+
const nav = perf.getEntriesByType("navigation")[0] ?? {};
|
|
55
|
+
return {
|
|
56
|
+
domContentLoaded: (nav.domContentLoadedEventEnd as number) ?? 0,
|
|
57
|
+
loadEvent: (nav.loadEventEnd as number) ?? 0,
|
|
58
|
+
lcp: (globalThis as any).__lcpTime as number | undefined,
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Session } from "node:inspector/promises";
|
|
2
|
+
import type { CoverageData, ScriptCoverage } from "./CoverageTypes.ts";
|
|
3
|
+
|
|
4
|
+
/** Run a function while collecting precise V8 coverage, return execution counts.
|
|
5
|
+
* The session passed to `fn` can be shared with TimeSampler to avoid resetting counters. */
|
|
6
|
+
export async function withCoverageProfiling<T>(
|
|
7
|
+
fn: (session: Session) => Promise<T> | T,
|
|
8
|
+
): Promise<{ result: T; coverage: CoverageData }> {
|
|
9
|
+
const session = new Session();
|
|
10
|
+
session.connect();
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
await session.post("Profiler.enable");
|
|
14
|
+
await session.post("Profiler.startPreciseCoverage", {
|
|
15
|
+
callCount: true,
|
|
16
|
+
detailed: true,
|
|
17
|
+
});
|
|
18
|
+
const result = await fn(session);
|
|
19
|
+
const raw = await session.post("Profiler.takePreciseCoverage");
|
|
20
|
+
const scripts = raw.result as unknown as ScriptCoverage[];
|
|
21
|
+
return { result, coverage: { scripts } };
|
|
22
|
+
} finally {
|
|
23
|
+
await session.post("Profiler.stopPreciseCoverage");
|
|
24
|
+
await session.post("Profiler.disable");
|
|
25
|
+
session.disconnect();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Per-function execution counts from V8 Profiler.takePreciseCoverage (Node and CDP). */
|
|
2
|
+
export interface CoverageData {
|
|
3
|
+
scripts: ScriptCoverage[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Coverage data for a single script (file) */
|
|
7
|
+
export interface ScriptCoverage {
|
|
8
|
+
url: string;
|
|
9
|
+
functions: FunctionCoverage[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Coverage data for a single function within a script */
|
|
13
|
+
export interface FunctionCoverage {
|
|
14
|
+
functionName: string;
|
|
15
|
+
ranges: CoverageRange[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A byte-offset range within a function with its execution count */
|
|
19
|
+
export interface CoverageRange {
|
|
20
|
+
startOffset: number;
|
|
21
|
+
endOffset: number;
|
|
22
|
+
count: number;
|
|
23
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import colors from "../../report/Colors.ts";
|
|
2
|
+
import { formatBytes } from "../../report/Formatters.ts";
|
|
3
|
+
import type { HeapProfile, HeapSample } from "./HeapSampler.ts";
|
|
4
|
+
import {
|
|
5
|
+
type ResolvedFrame,
|
|
6
|
+
type ResolvedProfile,
|
|
7
|
+
resolveProfile,
|
|
8
|
+
} from "./ResolvedProfile.ts";
|
|
9
|
+
|
|
10
|
+
/** An allocation site with byte totals, call stack, and optional raw samples */
|
|
11
|
+
export interface HeapSite {
|
|
12
|
+
name: string;
|
|
13
|
+
url: string;
|
|
14
|
+
/** 1-indexed */
|
|
15
|
+
line: number;
|
|
16
|
+
col?: number;
|
|
17
|
+
bytes: number;
|
|
18
|
+
/** Call stack from root to this frame */
|
|
19
|
+
stack?: ResolvedFrame[];
|
|
20
|
+
/** Individual allocation samples at this site */
|
|
21
|
+
samples?: HeapSample[];
|
|
22
|
+
/** Distinct caller paths with byte weights (populated by {@link aggregateSites}) */
|
|
23
|
+
callers?: { stack: ResolvedFrame[]; bytes: number }[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Predicate that returns true for user code (vs. runtime internals) */
|
|
27
|
+
export type UserCodeFilter = (site: ResolvedFrame) => boolean;
|
|
28
|
+
|
|
29
|
+
/** Options for {@link formatHeapReport} */
|
|
30
|
+
export interface HeapReportOptions {
|
|
31
|
+
/** Max sites to display */
|
|
32
|
+
topN: number;
|
|
33
|
+
/** Caller stack frames to show per site (default 3) */
|
|
34
|
+
stackDepth?: number;
|
|
35
|
+
/** Multi-line format with file paths (default false) */
|
|
36
|
+
verbose?: boolean;
|
|
37
|
+
/** Dump every raw sample */
|
|
38
|
+
raw?: boolean;
|
|
39
|
+
/** Filter to user code only, hiding runtime internals */
|
|
40
|
+
userOnly?: boolean;
|
|
41
|
+
/** Predicate for user vs internal code (default {@link isNodeUserCode}) */
|
|
42
|
+
isUserCode?: UserCodeFilter;
|
|
43
|
+
/** Total bytes across all nodes (before filtering) */
|
|
44
|
+
totalAll?: number;
|
|
45
|
+
/** Total bytes for user code only */
|
|
46
|
+
totalUserCode?: number;
|
|
47
|
+
/** Number of samples taken */
|
|
48
|
+
sampleCount?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Sum selfSize across all nodes in profile (before any filtering) */
|
|
52
|
+
export function totalProfileBytes(profile: HeapProfile): number {
|
|
53
|
+
return resolveProfile(profile).totalBytes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Flatten resolved profile into sorted list of allocation sites with call stacks.
|
|
57
|
+
* When raw samples are available, attaches them to corresponding sites. */
|
|
58
|
+
export function flattenProfile(resolved: ResolvedProfile): HeapSite[] {
|
|
59
|
+
const sites: HeapSite[] = [];
|
|
60
|
+
const nodeIdToSites = new Map<number, HeapSite[]>();
|
|
61
|
+
|
|
62
|
+
for (const node of resolved.allocationNodes) {
|
|
63
|
+
const site: HeapSite = {
|
|
64
|
+
...node.frame,
|
|
65
|
+
bytes: node.selfSize,
|
|
66
|
+
stack: node.stack,
|
|
67
|
+
};
|
|
68
|
+
sites.push(site);
|
|
69
|
+
const bucket = nodeIdToSites.get(node.nodeId) ?? [];
|
|
70
|
+
if (!bucket.length) nodeIdToSites.set(node.nodeId, bucket);
|
|
71
|
+
bucket.push(site);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const sample of resolved.sortedSamples ?? []) {
|
|
75
|
+
const matchingSites = nodeIdToSites.get(sample.nodeId);
|
|
76
|
+
if (!matchingSites) continue;
|
|
77
|
+
for (const site of matchingSites) {
|
|
78
|
+
if (!site.samples) site.samples = [];
|
|
79
|
+
site.samples.push(sample);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return sites.sort((a, b) => b.bytes - a.bytes);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Return true if the call frame is user code (excludes node: and internal/ URLs) */
|
|
87
|
+
export function isNodeUserCode(site: ResolvedFrame): boolean {
|
|
88
|
+
const { url } = site;
|
|
89
|
+
return (
|
|
90
|
+
!!url &&
|
|
91
|
+
!url.startsWith("node:") &&
|
|
92
|
+
!url.includes("(native)") &&
|
|
93
|
+
!url.includes("internal/")
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Return true if the call frame is user code (excludes chrome-extension:// and devtools:// URLs) */
|
|
98
|
+
export function isBrowserUserCode(site: ResolvedFrame): boolean {
|
|
99
|
+
const { url } = site;
|
|
100
|
+
return (
|
|
101
|
+
!!url &&
|
|
102
|
+
!url.startsWith("chrome-extension://") &&
|
|
103
|
+
!url.startsWith("devtools://") &&
|
|
104
|
+
!url.includes("(native)")
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Return only sites matching a user-code predicate (default: {@link isNodeUserCode}) */
|
|
109
|
+
export function filterSites(
|
|
110
|
+
sites: HeapSite[],
|
|
111
|
+
isUser: UserCodeFilter = isNodeUserCode,
|
|
112
|
+
): HeapSite[] {
|
|
113
|
+
return sites.filter(isUser);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Aggregate sites by location (combine same file:line:col).
|
|
117
|
+
* Tracks distinct caller stacks with byte weights when merging. */
|
|
118
|
+
export function aggregateSites(sites: HeapSite[]): HeapSite[] {
|
|
119
|
+
const byLocation = new Map<string, HeapSite>();
|
|
120
|
+
|
|
121
|
+
for (const site of sites) {
|
|
122
|
+
// When column is unknown, include name to avoid merging distinct sites
|
|
123
|
+
const colKey = site.col != null ? `${site.col}` : `?:${site.name}`;
|
|
124
|
+
const key = `${site.url}:${site.line}:${colKey}`;
|
|
125
|
+
const existing = byLocation.get(key);
|
|
126
|
+
if (existing) {
|
|
127
|
+
existing.bytes += site.bytes;
|
|
128
|
+
addCaller(existing, site);
|
|
129
|
+
} else {
|
|
130
|
+
const callers = site.stack
|
|
131
|
+
? [{ stack: site.stack, bytes: site.bytes }]
|
|
132
|
+
: undefined;
|
|
133
|
+
byLocation.set(key, { ...site, callers });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const site of byLocation.values()) {
|
|
138
|
+
if (!site.callers || site.callers.length <= 1) continue;
|
|
139
|
+
site.callers.sort((a, b) => b.bytes - a.bytes);
|
|
140
|
+
site.stack = site.callers[0].stack;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return [...byLocation.values()].sort((a, b) => b.bytes - a.bytes);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Format heap report for console output */
|
|
147
|
+
export function formatHeapReport(
|
|
148
|
+
sites: HeapSite[],
|
|
149
|
+
options: HeapReportOptions,
|
|
150
|
+
): string {
|
|
151
|
+
const { topN, stackDepth = 3, verbose = false } = options;
|
|
152
|
+
const { totalAll, totalUserCode, sampleCount, isUserCode } = options;
|
|
153
|
+
const isUser = isUserCode ?? isNodeUserCode;
|
|
154
|
+
const formatSite = verbose ? formatVerboseSite : formatCompactSite;
|
|
155
|
+
const lines: string[] = [];
|
|
156
|
+
lines.push(`Heap allocation sites (top ${topN}, garbage included):`);
|
|
157
|
+
|
|
158
|
+
for (const site of sites.slice(0, topN)) {
|
|
159
|
+
formatSite(lines, site, stackDepth, isUser);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
lines.push("");
|
|
163
|
+
if (totalAll !== undefined)
|
|
164
|
+
lines.push(`Total (all): ${fmtBytes(totalAll)}`);
|
|
165
|
+
if (totalUserCode !== undefined)
|
|
166
|
+
lines.push(`Total (user-code): ${fmtBytes(totalUserCode)}`);
|
|
167
|
+
if (sampleCount !== undefined)
|
|
168
|
+
lines.push(`Samples: ${sampleCount.toLocaleString()}`);
|
|
169
|
+
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Sum bytes across all sites */
|
|
174
|
+
export function totalBytes(sites: HeapSite[]): number {
|
|
175
|
+
return sites.reduce((sum, s) => sum + s.bytes, 0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Format every raw sample as one line, ordered by ordinal (time).
|
|
179
|
+
* Output is tab-separated for easy piping/grep/diff. */
|
|
180
|
+
export function formatRawSamples(resolved: ResolvedProfile): string {
|
|
181
|
+
const { sortedSamples, nodeMap } = resolved;
|
|
182
|
+
if (!sortedSamples || sortedSamples.length === 0)
|
|
183
|
+
return "No raw samples available.";
|
|
184
|
+
|
|
185
|
+
const header = "ordinal\tsize\tfunction\tlocation";
|
|
186
|
+
const rows = sortedSamples.map(s => {
|
|
187
|
+
const frame = nodeMap.get(s.nodeId)?.frame;
|
|
188
|
+
const fn = frame?.name || "(unknown)";
|
|
189
|
+
const url = frame?.url || "";
|
|
190
|
+
const loc = url ? fmtLoc(url, frame!.line, frame!.col) : "(unknown)";
|
|
191
|
+
return `${s.ordinal}\t${s.size}\t${fn}\t${loc}`;
|
|
192
|
+
});
|
|
193
|
+
return [header, ...rows].join("\n");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Add a caller stack to an aggregated site, merging if the same path exists */
|
|
197
|
+
function addCaller(existing: HeapSite, site: HeapSite): void {
|
|
198
|
+
if (!site.stack) return;
|
|
199
|
+
existing.callers ??= [];
|
|
200
|
+
const key = callerKey(site.stack);
|
|
201
|
+
const match = existing.callers.find(c => callerKey(c.stack) === key);
|
|
202
|
+
if (match) match.bytes += site.bytes;
|
|
203
|
+
else existing.callers.push({ stack: site.stack, bytes: site.bytes });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Verbose multi-line format with file:// paths and line numbers */
|
|
207
|
+
function formatVerboseSite(
|
|
208
|
+
lines: string[],
|
|
209
|
+
site: HeapSite,
|
|
210
|
+
stackDepth: number,
|
|
211
|
+
isUser: UserCodeFilter,
|
|
212
|
+
): void {
|
|
213
|
+
const bytes = fmtBytes(site.bytes).padStart(10);
|
|
214
|
+
const loc = site.url ? fmtLoc(site.url, site.line, site.col) : "(unknown)";
|
|
215
|
+
const style = isUser(site) ? (s: string) => s : colors.dim;
|
|
216
|
+
lines.push(style(`${bytes} ${site.name} ${loc}`));
|
|
217
|
+
|
|
218
|
+
const userCallers = callerFrames(site, stackDepth).filter(
|
|
219
|
+
f => f.url && isUser(f),
|
|
220
|
+
);
|
|
221
|
+
for (const frame of userCallers) {
|
|
222
|
+
const loc = fmtLoc(frame.url, frame.line, frame.col);
|
|
223
|
+
lines.push(style(` <- ${frame.name} ${loc}`));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Compact single-line format: `49 MB fn1 <- fn2 <- fn3` */
|
|
228
|
+
function formatCompactSite(
|
|
229
|
+
lines: string[],
|
|
230
|
+
site: HeapSite,
|
|
231
|
+
stackDepth: number,
|
|
232
|
+
isUser: UserCodeFilter,
|
|
233
|
+
): void {
|
|
234
|
+
const bytes = fmtBytes(site.bytes).padStart(10);
|
|
235
|
+
const callers = callerFrames(site, stackDepth)
|
|
236
|
+
.filter(f => f.url && isUser(f))
|
|
237
|
+
.map(f => f.name);
|
|
238
|
+
const line = `${bytes} ${[site.name, ...callers].join(" <- ")}`;
|
|
239
|
+
lines.push(isUser(site) ? line : colors.dim(line));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Format bytes with a space separator, falling back to raw bytes */
|
|
243
|
+
function fmtBytes(bytes: number): string {
|
|
244
|
+
return formatBytes(bytes, { space: true }) ?? `${bytes} B`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Format location, omitting column when unknown */
|
|
248
|
+
function fmtLoc(url: string, line: number, col?: number): string {
|
|
249
|
+
return col != null ? `${url}:${line}:${col}` : `${url}:${line}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Serialize a call stack for dedup comparison */
|
|
253
|
+
function callerKey(stack: ResolvedFrame[]): string {
|
|
254
|
+
return stack.map(f => `${f.url}:${f.line}:${f.col}`).join("|");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Get caller frames (parent stack excluding self, reversed, truncated) */
|
|
258
|
+
function callerFrames(site: HeapSite, depth: number): ResolvedFrame[] {
|
|
259
|
+
if (!site.stack || site.stack.length <= 1) return [];
|
|
260
|
+
return site.stack.slice(0, -1).reverse().slice(0, depth);
|
|
261
|
+
}
|
|
@@ -103,7 +103,6 @@ async function startSampling(
|
|
|
103
103
|
includeObjectsCollectedByMinorGC: opts.includeMinorGC,
|
|
104
104
|
includeObjectsCollectedByMajorGC: opts.includeMajorGC,
|
|
105
105
|
};
|
|
106
|
-
|
|
107
106
|
try {
|
|
108
107
|
await session.post("HeapProfiler.startSampling", params);
|
|
109
108
|
} catch {
|
|
@@ -114,8 +113,8 @@ async function startSampling(
|
|
|
114
113
|
}
|
|
115
114
|
}
|
|
116
115
|
|
|
116
|
+
/** Stop heap sampling and return the profile, casting past incomplete @types/node typings */
|
|
117
117
|
async function stopSampling(session: Session): Promise<HeapProfile> {
|
|
118
118
|
const { profile } = await session.post("HeapProfiler.stopSampling");
|
|
119
|
-
// V8 returns id/samples fields not in @types/node's incomplete SamplingHeapProfile
|
|
120
119
|
return profile as unknown as HeapProfile;
|
|
121
120
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
CallFrame,
|
|
3
|
+
HeapProfile,
|
|
4
|
+
HeapSample,
|
|
5
|
+
ProfileNode,
|
|
6
|
+
} from "./HeapSampler.ts";
|
|
2
7
|
|
|
3
8
|
/** A call frame with display-ready 1-indexed source positions */
|
|
4
9
|
export interface ResolvedFrame {
|
|
@@ -48,6 +53,16 @@ export interface ResolvedProfile {
|
|
|
48
53
|
totalBytes: number;
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
/** Convert a V8 0-indexed CallFrame to a display-ready 1-indexed ResolvedFrame */
|
|
57
|
+
export function resolveCallFrame(cf: CallFrame): ResolvedFrame {
|
|
58
|
+
return {
|
|
59
|
+
name: cf.functionName || "(anonymous)",
|
|
60
|
+
url: cf.url || "",
|
|
61
|
+
line: cf.lineNumber + 1,
|
|
62
|
+
col: cf.columnNumber != null ? cf.columnNumber + 1 : undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
51
66
|
/** Walk a HeapProfile tree once, producing a fully resolved intermediate form */
|
|
52
67
|
export function resolveProfile(profile: HeapProfile): ResolvedProfile {
|
|
53
68
|
const nodes: ResolvedNode[] = [];
|
|
@@ -55,13 +70,7 @@ export function resolveProfile(profile: HeapProfile): ResolvedProfile {
|
|
|
55
70
|
let totalBytes = 0;
|
|
56
71
|
|
|
57
72
|
function walk(node: ProfileNode, parentStack: ResolvedFrame[]): void {
|
|
58
|
-
const
|
|
59
|
-
const frame: ResolvedFrame = {
|
|
60
|
-
name: functionName || "(anonymous)",
|
|
61
|
-
url: url || "",
|
|
62
|
-
line: lineNumber + 1,
|
|
63
|
-
col: columnNumber != null ? columnNumber + 1 : undefined,
|
|
64
|
-
};
|
|
73
|
+
const frame = resolveCallFrame(node.callFrame);
|
|
65
74
|
const stack = [...parentStack, frame];
|
|
66
75
|
const resolved: ResolvedNode = {
|
|
67
76
|
frame,
|
|
@@ -72,7 +81,7 @@ export function resolveProfile(profile: HeapProfile): ResolvedProfile {
|
|
|
72
81
|
nodes.push(resolved);
|
|
73
82
|
nodeMap.set(node.id, resolved);
|
|
74
83
|
totalBytes += node.selfSize;
|
|
75
|
-
for (const child of node.children
|
|
84
|
+
for (const child of node.children ?? []) walk(child, stack);
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
walk(profile.head, []);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Session } from "node:inspector/promises";
|
|
2
|
+
import type { CallFrame } from "./HeapSampler.ts";
|
|
3
|
+
|
|
4
|
+
/** V8 CPU profile node (flat array element, not tree) */
|
|
5
|
+
export interface TimeProfileNode {
|
|
6
|
+
id: number;
|
|
7
|
+
callFrame: CallFrame;
|
|
8
|
+
hitCount?: number;
|
|
9
|
+
/** Child node IDs */
|
|
10
|
+
children?: number[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** V8 CPU profile returned by Profiler.stop */
|
|
14
|
+
export interface TimeProfile {
|
|
15
|
+
nodes: TimeProfileNode[];
|
|
16
|
+
/** Microseconds */
|
|
17
|
+
startTime: number;
|
|
18
|
+
/** Microseconds */
|
|
19
|
+
endTime: number;
|
|
20
|
+
/** Node IDs sampled at each tick */
|
|
21
|
+
samples?: number[];
|
|
22
|
+
/** Microseconds between samples */
|
|
23
|
+
timeDeltas?: number[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TimeProfileOptions {
|
|
27
|
+
/** Sampling interval in microseconds (default 1000 = 1ms) */
|
|
28
|
+
interval?: number;
|
|
29
|
+
/** External session to use (shares Profiler domain, caller manages enable/disable) */
|
|
30
|
+
session?: Session;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Run a function while sampling CPU time, return profile */
|
|
34
|
+
export async function withTimeProfiling<T>(
|
|
35
|
+
options: TimeProfileOptions,
|
|
36
|
+
fn: () => Promise<T> | T,
|
|
37
|
+
): Promise<{ result: T; profile: TimeProfile }> {
|
|
38
|
+
const { interval } = options;
|
|
39
|
+
const ownSession = !options.session;
|
|
40
|
+
const session = options.session ?? new Session();
|
|
41
|
+
if (ownSession) session.connect();
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
if (ownSession) await session.post("Profiler.enable");
|
|
45
|
+
if (interval)
|
|
46
|
+
await session.post("Profiler.setSamplingInterval", { interval });
|
|
47
|
+
await session.post("Profiler.start");
|
|
48
|
+
const result = await fn();
|
|
49
|
+
const { profile } = await session.post("Profiler.stop");
|
|
50
|
+
return { result, profile: profile as unknown as TimeProfile };
|
|
51
|
+
} finally {
|
|
52
|
+
if (ownSession) {
|
|
53
|
+
await session.post("Profiler.disable");
|
|
54
|
+
session.disconnect();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|