Files
openchamber/scripts/profile-idle.mjs
T
Bohdan Triapitsyn 632fc09e18 perf(tooling): reach a fully populated sidebar in both profilers
Idle and streaming cost both depend on how much of the sidebar is mounted, so
the scenario setup is now shared. --expand-projects seeds the persisted collapse
state; --expand-sessions clicks every "Show more sessions" control, which
cannot be seeded because pagination is component state. Both run before the
measured window, so it stays input-free. Session expansion must run after the
sidebar has populated, not straight after the load event, or the controls do
not exist yet.

Also replaces a spread push over collected trace events, which overflowed the
call stack once a populated sidebar produced chunks of hundreds of thousands of
events, and the equivalent spread in the heap-maximum calculation.
2026-08-03 18:01:52 +03:00

467 lines
21 KiB
JavaScript

#!/usr/bin/env node
/**
* Fully automated idle CPU/memory capture for OpenChamber.
*
* Unlike `profile:browser`, this command needs no human in the loop: it loads
* the app, lets it settle, then records a window during which no input is
* delivered. Everything it reports is therefore work the app performs while the
* user is doing nothing, which is the regression class users notice as fan
* noise, battery drain, and a permanently busy tab.
*
* Reported dimensions (per second of the idle window):
* - main-thread busy time, script time, style recalculation, layout;
* - style recalculation and layout counts;
* - DOM node, document, frame, and JS event listener growth;
* - JS heap trajectory (start/end/max plus linear growth rate);
* - CPU sampling profile with self time per function;
* - scheduled-work attribution per timer/animation-frame/observer call site.
*
* Runs are directly comparable: `--baseline <run-directory>` prints a per-metric
* delta table and exits non-zero when a budget in `--budget-*` is exceeded, so
* the same command works as an investigation tool and as a regression gate.
*/
import { mkdir, readFile, writeFile } from "node:fs/promises"
import { homedir } from "node:os"
import { join, resolve } from "node:path"
import process from "node:process"
import { CdpClient, createPageTarget, evaluateValue, launchChrome, reservePort, resolveChrome, wait } from "./perf/cdp.mjs"
import { buildIdleProbeSource, IDLE_PROBE_GLOBAL } from "./perf/idle-probe.mjs"
import { summarizeCpuProfile } from "./perf/cpu-profile.mjs"
import { expandProjects, expandSessionLists } from "./perf/scenario.mjs"
import { growthPerSecond, metricMap, round } from "./perf/metrics.mjs"
const HELP = `Usage: bun run profile:idle -- [options]
Records what OpenChamber does while nobody is interacting with it.
Options:
--url <url> OpenChamber URL (default: http://localhost:3000)
--session <id> Open this session before recording (deep link)
--tab <name> Open this main tab before recording
--then-tab <name> After settling, navigate to this tab without a
reload, then record. Use it to measure what a
surface keeps doing after the user leaves it.
--expand-sessions Click every "Show more sessions" control until the
sidebar has no collapsed session lists left, so all
session rows are mounted. Clicks happen before the
recording window, which stays input-free.
--expand-projects Expand every project in the sidebar before
recording, which mounts a row per worktree and
session directory
--panel <mode[=target]> Open the context panel on this surface before
recording (chat, preview, terminal, git, pr, notes,
file, diff, plan, context, browser, walkthrough).
Repeatable; the first entry becomes the active tab.
--duration <seconds> Idle recording window (default: 30)
--settle <seconds> Wait after load before recording (default: 15)
--output <directory> Artifact directory (default: artifacts/idle-profile-<time>)
--label <text> Human label stored in the summary
--chrome <path> Chrome/Chromium executable
--profile-dir <path> Reusable isolated Chrome profile
--headed Show the browser (default: headless)
--sampling-interval <us> CPU sampler interval in microseconds (default: 200)
--baseline <directory> Compare against a previous run directory
--budget-cpu <percent> Fail when idle main-thread busy time exceeds this
--budget-listeners <n> Fail when net listener growth exceeds this
--budget-heap <mb> Fail when heap growth exceeds this
--json Print the summary as JSON instead of a table
--help Show this help
Exit code is non-zero when any provided budget is exceeded.`
const parseArgs = (argv) => {
const options = {
url: "http://localhost:3000",
session: null,
tab: null,
thenTab: null,
panels: [],
expandProjects: false,
expandSessions: false,
duration: 30,
settle: 15,
output: null,
label: null,
chrome: null,
profileDir: join(homedir(), ".openchamber", "browser-profile-google-chrome"),
headless: true,
samplingInterval: 200,
baseline: null,
budgetCpu: null,
budgetListeners: null,
budgetHeap: null,
json: false,
}
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index]
if (value === "--help") return { ...options, help: true }
else if (value === "--headed") options.headless = false
else if (value === "--json") options.json = true
else if (value === "--url") options.url = argv[++index]
else if (value === "--session") options.session = argv[++index]
else if (value === "--tab") options.tab = argv[++index]
else if (value === "--then-tab") options.thenTab = argv[++index]
else if (value === "--panel") options.panels.push(argv[++index])
else if (value === "--expand-projects") options.expandProjects = true
else if (value === "--expand-sessions") options.expandSessions = true
else if (value === "--label") options.label = argv[++index]
else if (value === "--duration") options.duration = Number(argv[++index])
else if (value === "--settle") options.settle = Number(argv[++index])
else if (value === "--output") options.output = argv[++index]
else if (value === "--chrome") options.chrome = argv[++index]
else if (value === "--profile-dir") options.profileDir = argv[++index]
else if (value === "--sampling-interval") options.samplingInterval = Number(argv[++index])
else if (value === "--baseline") options.baseline = argv[++index]
else if (value === "--budget-cpu") options.budgetCpu = Number(argv[++index])
else if (value === "--budget-listeners") options.budgetListeners = Number(argv[++index])
else if (value === "--budget-heap") options.budgetHeap = Number(argv[++index])
else throw new Error(`Unknown option: ${value}`)
}
if (!Number.isFinite(options.duration) || options.duration <= 0) throw new Error("--duration must be a positive number")
if (!Number.isFinite(options.settle) || options.settle < 0) throw new Error("--settle must be zero or greater")
// Deep-link parameters are folded into the URL so the recorded window starts
// from the requested screen without synthesising input events.
const target = new URL(options.url)
if (options.session) target.searchParams.set("session", options.session)
if (options.tab) target.searchParams.set("tab", options.tab)
options.url = target.toString()
return options
}
/**
* Mirrors `useUIStore`'s context-panel tab identity rules so a seeded tab is
* indistinguishable from one the user opened. Only `file` and `preview` key
* their identity by target path; every other surface allows one tab per mode.
*/
const buildPanelTab = (descriptor, touchedAt) => {
const { mode, targetPath } = descriptor
const dedupeKey = (mode === "file" || mode === "preview") ? (targetPath || mode) : mode
return {
id: dedupeKey === mode ? mode : `${mode}:${dedupeKey}`,
mode,
targetPath: targetPath || null,
dedupeKey,
label: null,
sessionTitleFallback: null,
readOnly: false,
stagedDiff: false,
diffScope: "working",
touchedAt,
}
}
const parsePanelDescriptor = (value) => {
const separator = value.indexOf("=")
if (separator === -1) return { mode: value.trim(), targetPath: null }
return { mode: value.slice(0, separator).trim(), targetPath: value.slice(separator + 1).trim() || null }
}
/**
* Opens the context panel by seeding the persisted store the app reads on
* boot, then reloading. Driving persisted state rather than synthesising
* clicks keeps the scenario deterministic and keeps the recorded window free
* of input-driven work that a real idle session would not perform.
*/
const seedContextPanel = async (client, panels, sessionId) => {
const descriptors = panels.map(parsePanelDescriptor)
const tabs = descriptors.map((descriptor, index) => buildPanelTab(
descriptor.mode === "chat" && !descriptor.targetPath ? { ...descriptor, targetPath: sessionId } : descriptor,
Date.now() + index,
))
const stored = await evaluateValue(client, `JSON.stringify({
lastDirectory: localStorage.getItem("lastDirectory"),
uiStore: localStorage.getItem("ui-store"),
})`)
const { lastDirectory, uiStore } = JSON.parse(stored ?? "{}")
if (!lastDirectory) throw new Error("Could not open the context panel: no lastDirectory in browser storage")
if (!uiStore) throw new Error("Could not open the context panel: no ui-store in browser storage")
// `lastDirectory` is persisted as a JSON string by some writers and as a raw
// path by others; accept both rather than guessing.
let directory = lastDirectory
try {
const decoded = JSON.parse(lastDirectory)
if (typeof decoded === "string") directory = decoded
} catch {
// Already a raw path.
}
const normalized = directory.replace(/\\/g, "/").replace(/\/+$/g, "") || "/"
const parsed = JSON.parse(uiStore)
parsed.state = parsed.state ?? {}
parsed.state.contextPanelByDirectory = parsed.state.contextPanelByDirectory ?? {}
parsed.state.contextPanelByDirectory[normalized] = {
isOpen: true,
expanded: false,
tabs,
activeTabId: tabs[0]?.id ?? null,
widthByMode: {},
touchedAt: Date.now(),
}
await evaluateValue(client, `localStorage.setItem("ui-store", ${JSON.stringify(JSON.stringify(parsed))})`)
console.log(`Context panel seeded for ${normalized}: ${tabs.map((tab) => tab.id).join(", ")}`)
}
const REPORTED_METRICS = [
{ key: "mainThreadBusyPercent", label: "Main-thread busy", unit: "%", lowerIsBetter: true },
{ key: "scriptPercent", label: "Script", unit: "%", lowerIsBetter: true },
{ key: "recalcStylePercent", label: "Style recalc", unit: "%", lowerIsBetter: true },
{ key: "layoutPercent", label: "Layout", unit: "%", lowerIsBetter: true },
{ key: "recalcStylePerSecond", label: "Style recalcs/sec", unit: "", lowerIsBetter: true },
{ key: "layoutsPerSecond", label: "Layouts/sec", unit: "", lowerIsBetter: true },
{ key: "tasksPerSecond", label: "Tasks/sec", unit: "", lowerIsBetter: true },
{ key: "listenerGrowth", label: "Listener growth", unit: "", lowerIsBetter: true },
{ key: "nodeGrowth", label: "DOM node growth", unit: "", lowerIsBetter: true },
{ key: "heapGrowthMbPerSecond", label: "Heap growth", unit: "MB/s", lowerIsBetter: true },
{ key: "heapMaxMb", label: "Heap max", unit: "MB", lowerIsBetter: true },
]
const buildSummary = ({ options, before, after, samples, cpu, probe, elapsedSeconds }) => {
const delta = (name) => Number(after[name] ?? 0) - Number(before[name] ?? 0)
const percent = (name) => round((delta(name) / elapsedSeconds) * 100)
const heapSamples = samples.map((sample) => sample.jsHeapUsedMb)
return {
recordedAt: new Date().toISOString(),
label: options.label,
url: options.url,
durationSeconds: round(elapsedSeconds),
settleSeconds: options.settle,
metrics: {
mainThreadBusyPercent: percent("TaskDuration"),
scriptPercent: percent("ScriptDuration"),
recalcStylePercent: percent("RecalcStyleDuration"),
layoutPercent: percent("LayoutDuration"),
tasksPerSecond: round(delta("TaskCount") / elapsedSeconds),
recalcStylePerSecond: round(delta("RecalcStyleCount") / elapsedSeconds),
layoutsPerSecond: round(delta("LayoutCount") / elapsedSeconds),
listenerStart: Number(before.JSEventListeners ?? 0),
listenerEnd: Number(after.JSEventListeners ?? 0),
listenerGrowth: delta("JSEventListeners"),
listenerGrowthPerSecond: growthPerSecond(samples, "jsEventListeners"),
nodeStart: Number(before.Nodes ?? 0),
nodeEnd: Number(after.Nodes ?? 0),
nodeGrowth: delta("Nodes"),
documents: Number(after.Documents ?? 0),
frames: Number(after.Frames ?? 0),
heapStartMb: round(heapSamples.at(0) ?? 0),
heapEndMb: round(heapSamples.at(-1) ?? 0),
heapMaxMb: round(heapSamples.reduce((max, value) => Math.max(max, value), 0)),
heapGrowthMbPerSecond: growthPerSecond(samples, "jsHeapUsedMb"),
},
cpuProfile: cpu,
scheduledWork: probe,
samples,
}
}
const formatRow = (label, value, unit) => `${label.padEnd(22)} ${String(value).padStart(12)} ${unit}`
const printReport = (summary, baseline) => {
const { metrics } = summary
console.log(`\nIdle profile — ${summary.durationSeconds}s window at ${summary.url}`)
if (summary.label) console.log(`Label: ${summary.label}`)
console.log("")
for (const metric of REPORTED_METRICS) {
const current = metrics[metric.key]
if (!baseline) {
console.log(formatRow(metric.label, current, metric.unit))
continue
}
const previous = baseline.metrics?.[metric.key]
const change = Number.isFinite(previous) ? round(current - previous) : null
const marker = change === null || change === 0
? ""
: (change < 0) === metric.lowerIsBetter ? " improved" : " WORSE"
const changeText = change === null ? "n/a" : `${change > 0 ? "+" : ""}${change}`
console.log(`${formatRow(metric.label, current, metric.unit).padEnd(42)} was ${String(previous ?? "n/a").padStart(10)} ${changeText.padStart(9)}${marker}`)
}
console.log("\nTop self time while idle:")
for (const entry of summary.cpuProfile?.topSelfTime?.slice(0, 12) ?? []) {
console.log(` ${String(entry.selfMs).padStart(9)} ms ${String(entry.percentOfBusy).padStart(5)}% ${entry.function}`)
}
console.log("\nTop scheduled-work call sites while idle:")
for (const entry of summary.scheduledWork?.sites?.slice(0, 12) ?? []) {
console.log(` ${String(entry.totalMs).padStart(9)} ms ${String(entry.calls).padStart(6)}x ${entry.site}`)
}
const counters = summary.scheduledWork?.counters
if (counters) {
console.log(
`\nScheduled during window: timeouts ${counters.setTimeoutScheduled}, intervals ${counters.setIntervalScheduled},`
+ ` frames ${counters.rafScheduled}, listeners +${counters.listenersAdded}/-${counters.listenersRemoved},`
+ ` mutations ${counters.mutationRecords}, resizes ${counters.resizeEntries}, fetches ${counters.fetches}`,
)
}
}
const evaluateBudgets = (summary, options) => {
const failures = []
const check = (budget, key, label, unit) => {
if (!Number.isFinite(budget)) return
const value = summary.metrics[key]
if (value > budget) failures.push(`${label} ${value}${unit} exceeds budget ${budget}${unit}`)
}
check(options.budgetCpu, "mainThreadBusyPercent", "Idle main-thread busy", "%")
check(options.budgetListeners, "listenerGrowth", "Listener growth", "")
check(options.budgetHeap, "heapGrowthMbPerSecond", "Heap growth", "MB/s")
return failures
}
const main = async () => {
const options = parseArgs(process.argv.slice(2))
if (options.help) {
console.log(HELP)
return
}
const chrome = resolveChrome(options.chrome)
const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-")
const output = resolve(options.output ?? join("artifacts", `idle-profile-${timestamp}`))
const profileDir = resolve(options.profileDir)
await mkdir(output, { recursive: true })
await mkdir(profileDir, { recursive: true })
const baseline = options.baseline
? JSON.parse(await readFile(join(resolve(options.baseline), "idle-summary.json"), "utf8"))
: null
const port = await reservePort()
const chromeProcess = launchChrome({ chrome, profileDir, port, headless: options.headless })
let client
try {
const target = await createPageTarget(port)
client = new CdpClient(target.webSocketDebuggerUrl)
await client.connect()
await Promise.all([
client.send("Page.enable"),
client.send("Runtime.enable"),
client.send("Performance.enable"),
client.send("Profiler.enable"),
client.send("Network.enable", { maxTotalBufferSize: 0, maxResourceBufferSize: 0 }),
])
// Measure the current local build, never a service-worker-cached bundle
// from an earlier optimization run.
await client.send("Network.setBypassServiceWorker", { bypass: true })
await client.send("Page.addScriptToEvaluateOnNewDocument", { source: buildIdleProbeSource() })
// A fixed viewport keeps runs comparable and guarantees a compositor in
// headless mode, so frame-driven work is measured rather than skipped.
await client.send("Emulation.setDeviceMetricsOverride", {
width: 1600,
height: 1000,
deviceScaleFactor: 1,
mobile: false,
})
const loaded = client.once("Page.loadEventFired", 60_000)
await client.send("Page.navigate", { url: options.url })
await loaded
if (options.expandProjects) {
await expandProjects(client)
console.log("Expanded every project in the sidebar.")
}
if (options.panels.length > 0 || options.expandProjects) {
if (options.panels.length > 0) await seedContextPanel(client, options.panels, options.session)
const reloaded = client.once("Page.loadEventFired", 60_000)
await client.send("Page.reload", { ignoreCache: false })
await reloaded
}
console.log(`Loaded ${options.url}; settling for ${options.settle}s before recording.`)
await wait(options.settle * 1000)
if (options.expandSessions) {
const expanded = await expandSessionLists(client)
console.log(`Expanded ${expanded} collapsed session lists; settling ${options.settle}s again.`)
await wait(options.settle * 1000)
}
await evaluateValue(client, `globalThis[${JSON.stringify(IDLE_PROBE_GLOBAL)}]?.start()`)
await client.send("Profiler.setSamplingInterval", { interval: options.samplingInterval })
await client.send("Profiler.start")
const before = metricMap((await client.send("Performance.getMetrics")).metrics)
const startedAt = Date.now()
console.log(`Recording ${options.duration}s of idle time. No input is delivered to the page.`)
const samples = []
while (Date.now() - startedAt < options.duration * 1000) {
await wait(1_000)
const current = metricMap((await client.send("Performance.getMetrics")).metrics)
samples.push({
elapsedSeconds: round((Date.now() - startedAt) / 1000),
jsHeapUsedMb: round(Number(current.JSHeapUsedSize ?? 0) / (1024 * 1024)),
jsEventListeners: Number(current.JSEventListeners ?? 0),
nodes: Number(current.Nodes ?? 0),
taskDuration: round(Number(current.TaskDuration ?? 0), 3),
})
}
// A renderer that is throttled or occluded reports near-zero rendering work
// no matter what the page does. Measuring frame liveness turns that failure
// mode into an explicit warning instead of a falsely clean report.
const frameLiveness = await evaluateValue(client, `new Promise((resolve) => {
let frames = 0
const startedAt = performance.now()
const tick = () => {
frames += 1
if (performance.now() - startedAt < 1000) requestAnimationFrame(tick)
else resolve({ framesPerSecond: frames, visibilityState: document.visibilityState })
}
requestAnimationFrame(tick)
setTimeout(() => resolve({ framesPerSecond: frames, visibilityState: document.visibilityState }), 2000)
})`)
const elapsedSeconds = (Date.now() - startedAt) / 1000
const after = metricMap((await client.send("Performance.getMetrics")).metrics)
const { profile } = await client.send("Profiler.stop")
await evaluateValue(client, `globalThis[${JSON.stringify(IDLE_PROBE_GLOBAL)}]?.stop()`)
const probe = await evaluateValue(client, `globalThis[${JSON.stringify(IDLE_PROBE_GLOBAL)}]?.snapshot() ?? null`)
const summary = buildSummary({
options,
before,
after,
samples,
cpu: summarizeCpuProfile(profile),
probe,
elapsedSeconds,
})
summary.frameLiveness = frameLiveness
if (Number(frameLiveness?.framesPerSecond ?? 0) < 10) {
console.warn(
`\nWARNING: the renderer produced ${frameLiveness?.framesPerSecond ?? 0} frames per second`
+ ` (visibility: ${frameLiveness?.visibilityState ?? "unknown"}). Rendering metrics from this run understate real work.`,
)
}
await writeFile(join(output, "idle-summary.json"), JSON.stringify(summary, null, 2))
await writeFile(join(output, "cpu-profile.cpuprofile"), JSON.stringify(profile))
if (options.json) console.log(JSON.stringify(summary, null, 2))
else printReport(summary, baseline)
console.log(`\nSaved to ${output}`)
const failures = evaluateBudgets(summary, options)
if (failures.length > 0) {
console.error(`\nBudget failures:\n${failures.map((failure) => ` - ${failure}`).join("\n")}`)
process.exitCode = 1
}
} finally {
client?.close()
if (!chromeProcess.killed) chromeProcess.kill("SIGTERM")
}
}
main().catch((error) => {
console.error(`Idle profiling failed: ${error.message}`)
process.exitCode = 1
})