perf(tooling): add automated idle profiling harness

Adds `bun run profile:idle`: a fully unattended capture of what OpenChamber
does while nobody interacts with it. It reports main-thread busy time, style
recalculation and layout rates, DOM node and listener growth, heap trajectory,
a CPU sampling profile, and per-call-site attribution of timer, animation
frame, and observer work.

Chrome throttles timers and stops producing frames for occluded or backgrounded
windows, which silently reports an idle-looking renderer regardless of what the
page schedules. Launch flags now disable that throttling, and every run measures
frame liveness so a throttled capture is reported as a warning rather than as a
clean result.

CDP launch and client code is shared with the existing browser profiler.
This commit is contained in:
Bohdan Triapitsyn
2026-08-03 14:20:27 +03:00
parent e0bd787468
commit 255c738b5e
6 changed files with 851 additions and 1 deletions
+2
View File
@@ -69,3 +69,5 @@ workspaces/
.worktrees/
test-results/
artifacts/browser-profile-*/
artifacts/idle-profile-*/
artifacts/idle-*/
+2 -1
View File
@@ -81,7 +81,8 @@
"release:prepare": "bun run build && bun run type-check && bun run lint",
"release:test": "./scripts/test-release-build.sh",
"release:test:intel": "./scripts/test-release-build.sh x86_64",
"release:test:arm": "./scripts/test-release-build.sh aarch64"
"release:test:arm": "./scripts/test-release-build.sh aarch64",
"profile:idle": "node scripts/profile-idle.mjs"
},
"dependencies": {
"@base-ui/react": "^1.4.0",
+191
View File
@@ -0,0 +1,191 @@
/**
* Shared Chrome DevTools Protocol helpers for OpenChamber performance tooling.
*
* `scripts/profile-browser.mjs` and `scripts/profile-idle.mjs` both drive Chrome
* over CDP. Launching, target discovery, and the minimal protocol client live
* here so both entry points stay thin and behave identically.
*/
import { spawn } from "node:child_process"
import { createServer } from "node:net"
import { existsSync } from "node:fs"
import { platform } from "node:os"
import { join, resolve } from "node:path"
import process from "node:process"
export const wait = (milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds))
const chromeCandidates = () => {
if (platform() === "darwin") {
return [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
}
if (platform() === "win32") {
return [
join(process.env.PROGRAMFILES ?? "", "Google/Chrome/Application/chrome.exe"),
join(process.env["PROGRAMFILES(X86)"] ?? "", "Google/Chrome/Application/chrome.exe"),
join(process.env.LOCALAPPDATA ?? "", "Google/Chrome/Application/chrome.exe"),
]
}
return ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"]
}
export const resolveChrome = (explicit) => {
if (explicit) {
const candidate = resolve(explicit)
if (!existsSync(candidate)) throw new Error(`Chrome executable not found: ${candidate}`)
return candidate
}
const candidate = chromeCandidates().find((path) => path && existsSync(path))
if (!candidate) throw new Error("Chrome/Chromium was not found. Pass its path with --chrome.")
return candidate
}
export const reservePort = () => new Promise((resolvePort, reject) => {
const server = createServer()
server.unref()
server.on("error", reject)
server.listen(0, "127.0.0.1", () => {
const address = server.address()
if (!address || typeof address === "string") {
server.close()
reject(new Error("Could not reserve a Chrome debugging port"))
return
}
const port = address.port
server.close(() => resolvePort(port))
})
})
const waitForJson = async (url, timeoutMs = 15_000) => {
const deadline = Date.now() + timeoutMs
let lastError
while (Date.now() < deadline) {
try {
const response = await fetch(url)
if (response.ok) return await response.json()
} catch (error) {
lastError = error
}
await wait(100)
}
throw new Error(`Chrome debugging endpoint did not start: ${lastError?.message ?? url}`)
}
export const createPageTarget = async (port) => {
const baseUrl = `http://127.0.0.1:${port}`
await waitForJson(`${baseUrl}/json/version`)
try {
const response = await fetch(`${baseUrl}/json/new?${encodeURIComponent("about:blank")}`, { method: "PUT" })
if (response.ok) {
const target = await response.json()
if (target?.type === "page" && target.webSocketDebuggerUrl) return target
}
} catch {
// Some Chromium variants do not expose /json/new; use their startup page.
}
const targets = await waitForJson(`${baseUrl}/json`)
const target = targets.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl)
if (!target) throw new Error("Chrome did not expose or create a page target")
return target
}
/**
* Chrome throttles timers and stops producing frames for windows it considers
* backgrounded or occluded. A profiling run must never silently measure a
* throttled renderer, so occlusion and background throttling are disabled for
* every launch: without these the idle report shows zero layouts per second
* regardless of how much work the page actually schedules.
*/
const ANTI_THROTTLING_ARGS = [
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--disable-features=CalculateNativeWinOcclusion,IntensiveWakeUpThrottling",
]
export const launchChrome = ({ chrome, profileDir, port, headless, extraArgs = [] }) => {
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
...ANTI_THROTTLING_ARGS,
...extraArgs,
"about:blank",
]
if (headless) args.unshift("--headless=new", "--disable-gpu")
return spawn(chrome, args, { stdio: "ignore" })
}
export class CdpClient {
constructor(url) {
this.socket = new WebSocket(url)
this.nextId = 1
this.pending = new Map()
this.listeners = new Map()
}
async connect() {
await new Promise((resolveConnect, reject) => {
this.socket.addEventListener("open", resolveConnect, { once: true })
this.socket.addEventListener("error", reject, { once: true })
})
this.socket.addEventListener("message", (event) => {
const message = JSON.parse(String(event.data))
if (message.id) {
const pending = this.pending.get(message.id)
if (!pending) return
this.pending.delete(message.id)
if (message.error) pending.reject(new Error(message.error.message))
else pending.resolve(message.result ?? {})
return
}
for (const listener of this.listeners.get(message.method) ?? []) listener(message.params ?? {})
})
}
send(method, params = {}) {
const id = this.nextId++
return new Promise((resolveSend, reject) => {
this.pending.set(id, { resolve: resolveSend, reject: reject })
this.socket.send(JSON.stringify({ id, method, params }))
})
}
on(method, listener) {
const listeners = this.listeners.get(method) ?? new Set()
listeners.add(listener)
this.listeners.set(method, listeners)
return () => listeners.delete(listener)
}
once(method, timeoutMs = 15_000) {
return new Promise((resolveEvent, reject) => {
const timeout = setTimeout(() => {
unsubscribe()
reject(new Error(`Timed out waiting for ${method}`))
}, timeoutMs)
const unsubscribe = this.on(method, (params) => {
clearTimeout(timeout)
unsubscribe()
resolveEvent(params)
})
})
}
close() {
this.socket.close()
}
}
export const evaluateValue = async (client, expression) => {
const result = await client.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true })
return result.result?.value ?? null
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Aggregation helpers for `Profiler.stop()` CPU profiles.
*
* The sampling profiler answers "which functions burned the main thread while
* nobody touched the app", which is the question the idle report exists to
* answer. Self time is derived from the sample stream rather than from
* `hitCount`, because sample deltas carry the actual elapsed time.
*/
const frameLabel = (callFrame) => {
const name = callFrame.functionName || "(anonymous)"
const url = callFrame.url || "(native)"
const shortUrl = url.replace(/^https?:\/\/[^/]+/, "")
return `${name} @ ${shortUrl}:${callFrame.lineNumber + 1}`
}
/**
* @param {{nodes: Array, samples: Array<number>, timeDeltas: Array<number>}} profile
* @param {number} topCount
*/
export const summarizeCpuProfile = (profile, topCount = 25) => {
const nodes = new Map()
for (const node of profile?.nodes ?? []) nodes.set(node.id, node)
const samples = profile?.samples ?? []
const timeDeltas = profile?.timeDeltas ?? []
const selfMicros = new Map()
let totalMicros = 0
let idleMicros = 0
let gcMicros = 0
let programMicros = 0
for (let index = 0; index < samples.length; index += 1) {
// `timeDeltas[i]` is the interval preceding sample `i`.
const delta = Math.max(0, Number(timeDeltas[index] ?? 0))
totalMicros += delta
const node = nodes.get(samples[index])
if (!node) continue
const name = node.callFrame?.functionName
if (name === "(idle)") {
idleMicros += delta
continue
}
if (name === "(garbage collector)") gcMicros += delta
if (name === "(program)") programMicros += delta
const label = frameLabel(node.callFrame ?? {})
selfMicros.set(label, (selfMicros.get(label) ?? 0) + delta)
}
const busyMicros = totalMicros - idleMicros
const toMs = (micros) => Number((micros / 1000).toFixed(2))
return {
sampleCount: samples.length,
totalMs: toMs(totalMicros),
idleMs: toMs(idleMicros),
busyMs: toMs(busyMicros),
busyPercent: totalMicros > 0 ? Number(((busyMicros / totalMicros) * 100).toFixed(2)) : 0,
garbageCollectorMs: toMs(gcMicros),
programMs: toMs(programMicros),
topSelfTime: [...selfMicros.entries()]
.sort((left, right) => right[1] - left[1])
.slice(0, topCount)
.map(([label, micros]) => ({
function: label,
selfMs: toMs(micros),
percentOfBusy: busyMicros > 0 ? Number(((micros / busyMicros) * 100).toFixed(2)) : 0,
})),
}
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Page-side idle instrumentation.
*
* `buildIdleProbeSource()` returns a self-contained script installed with
* `Page.addScriptToEvaluateOnNewDocument`, so it wraps the scheduling APIs
* before any application module runs. The probe attributes wall time to the
* call site that scheduled the work, which is what identifies a background
* loop that keeps running while the user is idle.
*
* Design constraints:
* - The probe must not change observable behaviour: every wrapper forwards
* arguments and return values unchanged, and preserves handle identity so
* `clearInterval`/`cancelAnimationFrame` keep working.
* - Stack capture happens at schedule time, not at fire time, and stops after
* a bounded number of captures so instrumentation cannot become the
* bottleneck it is measuring.
*/
export const IDLE_PROBE_GLOBAL = "__openchamberIdleProbe"
const probeFactory = function installOpenchamberIdleProbe(globalName, stackCaptureBudget) {
if (globalThis[globalName]) return
const now = () => performance.now()
const sites = new Map()
let stackCaptures = 0
let recording = false
const siteFromStack = (kind) => {
if (stackCaptures >= stackCaptureBudget) return `${kind} <stack budget exhausted>`
stackCaptures += 1
const stack = new Error().stack ?? ""
const lines = stack.split("\n")
for (const line of lines) {
// Skip the Error line and every frame belonging to the probe itself.
if (!line.includes("http")) continue
if (line.includes("installOpenchamberIdleProbe")) continue
const match = line.match(/\(?((?:https?:)\/\/[^\s)]+)\)?$/)
const location = match ? match[1] : line.trim()
const name = line.trim().replace(/^at\s+/, "").split(" (")[0]
return `${kind} ${name} @ ${location}`
}
return `${kind} <unknown>`
}
const record = (site, elapsedMs) => {
if (!recording) return
let entry = sites.get(site)
if (!entry) {
entry = { site, calls: 0, totalMs: 0, maxMs: 0 }
sites.set(site, entry)
}
entry.calls += 1
entry.totalMs += elapsedMs
if (elapsedMs > entry.maxMs) entry.maxMs = elapsedMs
}
const counters = {
setTimeoutScheduled: 0,
setIntervalScheduled: 0,
rafScheduled: 0,
idleCallbackScheduled: 0,
listenersAdded: 0,
listenersRemoved: 0,
mutationRecords: 0,
resizeEntries: 0,
intersectionEntries: 0,
fetches: 0,
postMessages: 0,
}
const listenerTypes = new Map()
const bump = (map, key, amount) => map.set(key, (map.get(key) ?? 0) + amount)
const wrapCallback = (callback, site) => {
if (typeof callback !== "function") return callback
return function instrumentedIdleProbeCallback(...args) {
const started = now()
try {
return callback.apply(this, args)
} finally {
record(site, now() - started)
}
}
}
const nativeSetTimeout = globalThis.setTimeout
const nativeSetInterval = globalThis.setInterval
const nativeRaf = globalThis.requestAnimationFrame
const nativeIdle = globalThis.requestIdleCallback
globalThis.setTimeout = function setTimeout(handler, timeout, ...rest) {
counters.setTimeoutScheduled += 1
if (typeof handler !== "function") return nativeSetTimeout.call(this, handler, timeout, ...rest)
const site = siteFromStack(`setTimeout(${Number(timeout) || 0})`)
return nativeSetTimeout.call(this, wrapCallback(handler, site), timeout, ...rest)
}
globalThis.setInterval = function setInterval(handler, timeout, ...rest) {
counters.setIntervalScheduled += 1
if (typeof handler !== "function") return nativeSetInterval.call(this, handler, timeout, ...rest)
const site = siteFromStack(`setInterval(${Number(timeout) || 0})`)
return nativeSetInterval.call(this, wrapCallback(handler, site), timeout, ...rest)
}
if (typeof nativeRaf === "function") {
globalThis.requestAnimationFrame = function requestAnimationFrame(callback) {
counters.rafScheduled += 1
if (typeof callback !== "function") return nativeRaf.call(this, callback)
const site = siteFromStack("requestAnimationFrame")
return nativeRaf.call(this, wrapCallback(callback, site))
}
}
if (typeof nativeIdle === "function") {
globalThis.requestIdleCallback = function requestIdleCallback(callback, options) {
counters.idleCallbackScheduled += 1
if (typeof callback !== "function") return nativeIdle.call(this, callback, options)
const site = siteFromStack("requestIdleCallback")
return nativeIdle.call(this, wrapCallback(callback, site), options)
}
}
// Listener accounting explains the growing "JS event listeners" curve.
const nativeAdd = EventTarget.prototype.addEventListener
const nativeRemove = EventTarget.prototype.removeEventListener
EventTarget.prototype.addEventListener = function addEventListener(type, listener, options) {
counters.listenersAdded += 1
bump(listenerTypes, String(type), 1)
return nativeAdd.call(this, type, listener, options)
}
EventTarget.prototype.removeEventListener = function removeEventListener(type, listener, options) {
counters.listenersRemoved += 1
bump(listenerTypes, String(type), -1)
return nativeRemove.call(this, type, listener, options)
}
const wrapObserver = (Original, kind, countEntries) => {
if (typeof Original !== "function") return Original
const Wrapped = function ObserverWrapper(callback, ...rest) {
const site = siteFromStack(kind)
const instrumented = typeof callback === "function"
? function instrumentedObserverCallback(entries, observer) {
countEntries(entries)
const started = now()
try {
return callback.call(this, entries, observer)
} finally {
record(site, now() - started)
}
}
: callback
return new Original(instrumented, ...rest)
}
Wrapped.prototype = Original.prototype
return Wrapped
}
globalThis.MutationObserver = wrapObserver(globalThis.MutationObserver, "MutationObserver", (entries) => {
counters.mutationRecords += entries?.length ?? 0
})
globalThis.ResizeObserver = wrapObserver(globalThis.ResizeObserver, "ResizeObserver", (entries) => {
counters.resizeEntries += entries?.length ?? 0
})
globalThis.IntersectionObserver = wrapObserver(globalThis.IntersectionObserver, "IntersectionObserver", (entries) => {
counters.intersectionEntries += entries?.length ?? 0
})
const nativeFetch = globalThis.fetch
if (typeof nativeFetch === "function") {
globalThis.fetch = function fetch(...args) {
counters.fetches += 1
return nativeFetch.apply(this, args)
}
}
const nativePostMessage = globalThis.postMessage
if (typeof nativePostMessage === "function") {
globalThis.postMessage = function postMessage(...args) {
counters.postMessages += 1
return nativePostMessage.apply(this, args)
}
}
globalThis[globalName] = {
start() {
recording = true
sites.clear()
for (const key of Object.keys(counters)) counters[key] = 0
},
stop() {
recording = false
},
snapshot() {
return {
stackCaptures,
stackBudgetExhausted: stackCaptures >= stackCaptureBudget,
counters: { ...counters },
listenerTypes: [...listenerTypes.entries()]
.map(([type, net]) => ({ type, net }))
.filter((entry) => entry.net !== 0)
.sort((left, right) => right.net - left.net)
.slice(0, 25),
sites: [...sites.values()]
.sort((left, right) => right.totalMs - left.totalMs)
.slice(0, 40)
.map((entry) => ({
site: entry.site,
calls: entry.calls,
totalMs: Number(entry.totalMs.toFixed(2)),
maxMs: Number(entry.maxMs.toFixed(2)),
})),
}
},
}
}
export const buildIdleProbeSource = (stackCaptureBudget = 200_000) =>
`(${probeFactory.toString()})(${JSON.stringify(IDLE_PROBE_GLOBAL)}, ${stackCaptureBudget});`
+367
View File
@@ -0,0 +1,367 @@
#!/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"
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
--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,
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 === "--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
}
const metricMap = (metrics = []) => Object.fromEntries(metrics.map(({ name, value }) => [name, value]))
/** Least-squares slope of a sampled series, in units per second. */
const growthPerSecond = (samples, key) => {
if (samples.length < 2) return 0
const meanTime = samples.reduce((total, sample) => total + sample.elapsedSeconds, 0) / samples.length
const meanValue = samples.reduce((total, sample) => total + (sample[key] ?? 0), 0) / samples.length
let covariance = 0
let variance = 0
for (const sample of samples) {
const timeDelta = sample.elapsedSeconds - meanTime
covariance += timeDelta * ((sample[key] ?? 0) - meanValue)
variance += timeDelta * timeDelta
}
return variance === 0 ? 0 : Number((covariance / variance).toFixed(3))
}
const round = (value, digits = 2) => Number(Number(value ?? 0).toFixed(digits))
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(Math.max(0, ...heapSamples)),
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
console.log(`Loaded ${options.url}; settling for ${options.settle}s before recording.`)
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
})