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.
This commit is contained in:
Bohdan Triapitsyn
2026-08-03 18:01:52 +03:00
parent b2cac4d242
commit 632fc09e18
3 changed files with 80 additions and 19 deletions
+44
View File
@@ -0,0 +1,44 @@
/**
* Scenario setup shared by the idle and streaming profilers.
*
* Idle and streaming cost both depend on how much of the sidebar is mounted,
* so both commands need the same way to reach a heavily populated sidebar.
* Setup always runs before the measured window.
*/
import { evaluateValue, wait } from "./cdp.mjs"
/**
* Expands every project. The sidebar persists the ids of collapsed projects,
* so an empty list expands everything. Requires a reload to take effect.
*/
export const expandProjects = async (client) => {
await evaluateValue(client, `localStorage.setItem("oc.sessions.projectCollapse", "[]")`)
}
/**
* Clicks every "Show more sessions" control until none remain.
*
* Session list pagination is component state, so unlike project collapse it
* cannot be seeded through storage. The controls only exist once the sidebar
* has populated, so call this after the page has settled, never straight after
* the load event.
*
* Matching is a case-insensitive substring test, which assumes the English UI
* locale; a non-English locale expands nothing and reports zero.
*/
export const expandSessionLists = async (client, { passes = 40, settleMs = 400 } = {}) => {
let totalClicked = 0
for (let pass = 0; pass < passes; pass += 1) {
const clicked = await evaluateValue(client, `(() => {
const controls = [...document.querySelectorAll("button")]
.filter((button) => (button.textContent ?? "").toLowerCase().includes("show more"))
for (const control of controls) control.click()
return controls.length
})()`)
if (!clicked) break
totalClicked += clicked
await wait(settleMs)
}
return totalClicked
}
+12 -17
View File
@@ -29,6 +29,7 @@ import process from "node:process"
import { CdpClient, createPageTarget, evaluateValue, launchChrome, reservePort, resolveChrome, wait } from "./perf/cdp.mjs" import { CdpClient, createPageTarget, evaluateValue, launchChrome, reservePort, resolveChrome, wait } from "./perf/cdp.mjs"
import { buildIdleProbeSource, IDLE_PROBE_GLOBAL } from "./perf/idle-probe.mjs" import { buildIdleProbeSource, IDLE_PROBE_GLOBAL } from "./perf/idle-probe.mjs"
import { summarizeCpuProfile } from "./perf/cpu-profile.mjs" import { summarizeCpuProfile } from "./perf/cpu-profile.mjs"
import { expandProjects, expandSessionLists } from "./perf/scenario.mjs"
import { growthPerSecond, metricMap, round } from "./perf/metrics.mjs" import { growthPerSecond, metricMap, round } from "./perf/metrics.mjs"
const HELP = `Usage: bun run profile:idle -- [options] const HELP = `Usage: bun run profile:idle -- [options]
@@ -42,6 +43,10 @@ Options:
--then-tab <name> After settling, navigate to this tab without a --then-tab <name> After settling, navigate to this tab without a
reload, then record. Use it to measure what a reload, then record. Use it to measure what a
surface keeps doing after the user leaves it. 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 --expand-projects Expand every project in the sidebar before
recording, which mounts a row per worktree and recording, which mounts a row per worktree and
session directory session directory
@@ -74,6 +79,7 @@ const parseArgs = (argv) => {
thenTab: null, thenTab: null,
panels: [], panels: [],
expandProjects: false, expandProjects: false,
expandSessions: false,
duration: 30, duration: 30,
settle: 15, settle: 15,
output: null, output: null,
@@ -99,6 +105,7 @@ const parseArgs = (argv) => {
else if (value === "--then-tab") options.thenTab = argv[++index] else if (value === "--then-tab") options.thenTab = argv[++index]
else if (value === "--panel") options.panels.push(argv[++index]) else if (value === "--panel") options.panels.push(argv[++index])
else if (value === "--expand-projects") options.expandProjects = true 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 === "--label") options.label = argv[++index]
else if (value === "--duration") options.duration = Number(argv[++index]) else if (value === "--duration") options.duration = Number(argv[++index])
else if (value === "--settle") options.settle = Number(argv[++index]) else if (value === "--settle") options.settle = Number(argv[++index])
@@ -243,7 +250,7 @@ const buildSummary = ({ options, before, after, samples, cpu, probe, elapsedSeco
frames: Number(after.Frames ?? 0), frames: Number(after.Frames ?? 0),
heapStartMb: round(heapSamples.at(0) ?? 0), heapStartMb: round(heapSamples.at(0) ?? 0),
heapEndMb: round(heapSamples.at(-1) ?? 0), heapEndMb: round(heapSamples.at(-1) ?? 0),
heapMaxMb: round(Math.max(0, ...heapSamples)), heapMaxMb: round(heapSamples.reduce((max, value) => Math.max(max, value), 0)),
heapGrowthMbPerSecond: growthPerSecond(samples, "jsHeapUsedMb"), heapGrowthMbPerSecond: growthPerSecond(samples, "jsHeapUsedMb"),
}, },
cpuProfile: cpu, cpuProfile: cpu,
@@ -357,10 +364,7 @@ const main = async () => {
await loaded await loaded
if (options.expandProjects) { if (options.expandProjects) {
// The sidebar persists the ids of collapsed projects, so an empty list await expandProjects(client)
// expands everything. This is the state in issue #1472: one mounted
// directory-bound row per worktree and session.
await evaluateValue(client, `localStorage.setItem("oc.sessions.projectCollapse", "[]")`)
console.log("Expanded every project in the sidebar.") console.log("Expanded every project in the sidebar.")
} }
@@ -374,18 +378,9 @@ const main = async () => {
console.log(`Loaded ${options.url}; settling for ${options.settle}s before recording.`) console.log(`Loaded ${options.url}; settling for ${options.settle}s before recording.`)
await wait(options.settle * 1000) await wait(options.settle * 1000)
if (options.thenTab) { if (options.expandSessions) {
// Route changes normally come from clicks; driving history directly const expanded = await expandSessionLists(client)
// reaches the same router path without generating input work inside the console.log(`Expanded ${expanded} collapsed session lists; settling ${options.settle}s again.`)
// recorded window, and leaves already-mounted surfaces mounted.
await evaluateValue(client, `(() => {
const url = new URL(window.location.href)
url.searchParams.set("tab", ${JSON.stringify(options.thenTab)})
window.history.pushState({}, "", url.toString())
window.dispatchEvent(new PopStateEvent("popstate", { state: {} }))
return true
})()`)
console.log(`Navigated to tab "${options.thenTab}" without reloading; settling ${options.settle}s again.`)
await wait(options.settle * 1000) await wait(options.settle * 1000)
} }
+24 -2
View File
@@ -30,6 +30,7 @@ import { CdpClient, createPageTarget, evaluateValue, launchChrome, reservePort,
import { buildIdleProbeSource, IDLE_PROBE_GLOBAL } from "./perf/idle-probe.mjs" import { buildIdleProbeSource, IDLE_PROBE_GLOBAL } from "./perf/idle-probe.mjs"
import { summarizeCpuProfile } from "./perf/cpu-profile.mjs" import { summarizeCpuProfile } from "./perf/cpu-profile.mjs"
import { growthPerSecond, metricMap, round, summarizeLongTasks, summarizeTraceEvents } from "./perf/metrics.mjs" import { growthPerSecond, metricMap, round, summarizeLongTasks, summarizeTraceEvents } from "./perf/metrics.mjs"
import { expandProjects, expandSessionLists } from "./perf/scenario.mjs"
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..") const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..")
const cliPath = join(repoRoot, "packages/web/bin/cli.js") const cliPath = join(repoRoot, "packages/web/bin/cli.js")
@@ -47,6 +48,9 @@ Options:
--port <port> OpenChamber CLI port (default: from --url) --port <port> OpenChamber CLI port (default: from --url)
--dir <path> Session directory (default: repository root) --dir <path> Session directory (default: repository root)
--session <id> Reuse this session instead of creating one --session <id> Reuse this session instead of creating one
--expand-projects Expand every project in the sidebar before recording
--expand-sessions Click every "Show more sessions" control before
recording, so all session rows are mounted
--view-session <id> Display this session while the prompt streams into --view-session <id> Display this session while the prompt streams into
another one. Measures what an idle session costs another one. Measures what an idle session costs
while a different session is active in the while a different session is active in the
@@ -79,6 +83,8 @@ const parseArgs = (argv) => {
dir: repoRoot, dir: repoRoot,
session: null, session: null,
viewSession: null, viewSession: null,
expandProjects: false,
expandSessions: false,
prompt: DEFAULT_PROMPT, prompt: DEFAULT_PROMPT,
model: null, model: null,
agent: null, agent: null,
@@ -108,6 +114,8 @@ const parseArgs = (argv) => {
else if (value === "--dir") options.dir = argv[++index] else if (value === "--dir") options.dir = argv[++index]
else if (value === "--session") options.session = argv[++index] else if (value === "--session") options.session = argv[++index]
else if (value === "--view-session") options.viewSession = argv[++index] else if (value === "--view-session") options.viewSession = argv[++index]
else if (value === "--expand-projects") options.expandProjects = true
else if (value === "--expand-sessions") options.expandSessions = true
else if (value === "--prompt") options.prompt = argv[++index] else if (value === "--prompt") options.prompt = argv[++index]
else if (value === "--model") options.model = argv[++index] else if (value === "--model") options.model = argv[++index]
else if (value === "--agent") options.agent = argv[++index] else if (value === "--agent") options.agent = argv[++index]
@@ -364,7 +372,11 @@ const main = async () => {
}) })
const traceEvents = [] const traceEvents = []
const unsubscribeTrace = client.on("Tracing.dataCollected", ({ value }) => traceEvents.push(...(value ?? []))) // A spread push overflows the call stack once a chunk carries hundreds of
// thousands of events, which a heavily populated sidebar easily produces.
const unsubscribeTrace = client.on("Tracing.dataCollected", ({ value }) => {
for (const event of value ?? []) traceEvents.push(event)
})
let loaded = client.once("Page.loadEventFired", 60_000) let loaded = client.once("Page.loadEventFired", 60_000)
await client.send("Page.navigate", { url: target.toString() }) await client.send("Page.navigate", { url: target.toString() })
@@ -375,6 +387,10 @@ const main = async () => {
localStorage.setItem("openchamber_sync_perf", "1") localStorage.setItem("openchamber_sync_perf", "1")
localStorage.setItem("openchamber_stream_perf", "1") localStorage.setItem("openchamber_stream_perf", "1")
`) `)
if (options.expandProjects) {
await expandProjects(client)
console.log("Expanded every project in the sidebar.")
}
loaded = client.once("Page.loadEventFired", 60_000) loaded = client.once("Page.loadEventFired", 60_000)
await client.send("Page.reload", { ignoreCache: false }) await client.send("Page.reload", { ignoreCache: false })
await loaded await loaded
@@ -382,6 +398,12 @@ const main = async () => {
console.log(`Opened the session; settling for ${options.settle}s.`) console.log(`Opened the session; settling for ${options.settle}s.`)
await wait(options.settle * 1000) 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 evaluateValue(client, `globalThis[${JSON.stringify(IDLE_PROBE_GLOBAL)}]?.start()`)
await evaluateValue(client, `window.__openchamberSyncPerformance?.reset()`) await evaluateValue(client, `window.__openchamberSyncPerformance?.reset()`)
await evaluateValue(client, `window.__openchamberStreamPerformance?.setEnabled(true)`) await evaluateValue(client, `window.__openchamberStreamPerformance?.setEnabled(true)`)
@@ -553,7 +575,7 @@ const main = async () => {
listenerGrowth: delta("JSEventListeners"), listenerGrowth: delta("JSEventListeners"),
heapStartMb: round(heapSamples.at(0) ?? 0), heapStartMb: round(heapSamples.at(0) ?? 0),
heapEndMb: round(heapSamples.at(-1) ?? 0), heapEndMb: round(heapSamples.at(-1) ?? 0),
heapMaxMb: round(Math.max(0, ...heapSamples)), heapMaxMb: round(heapSamples.reduce((max, value) => Math.max(max, value), 0)),
heapGrowthMbPerSecond: growthPerSecond(samples, "jsHeapUsedMb"), heapGrowthMbPerSecond: growthPerSecond(samples, "jsHeapUsedMb"),
}, },
frameLiveness, frameLiveness,