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 { 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]
@@ -42,6 +43,10 @@ Options:
--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
@@ -74,6 +79,7 @@ const parseArgs = (argv) => {
thenTab: null,
panels: [],
expandProjects: false,
expandSessions: false,
duration: 30,
settle: 15,
output: null,
@@ -99,6 +105,7 @@ const parseArgs = (argv) => {
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])
@@ -243,7 +250,7 @@ const buildSummary = ({ options, before, after, samples, cpu, probe, elapsedSeco
frames: Number(after.Frames ?? 0),
heapStartMb: round(heapSamples.at(0) ?? 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"),
},
cpuProfile: cpu,
@@ -357,10 +364,7 @@ const main = async () => {
await loaded
if (options.expandProjects) {
// The sidebar persists the ids of collapsed projects, so an empty list
// 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", "[]")`)
await expandProjects(client)
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.`)
await wait(options.settle * 1000)
if (options.thenTab) {
// Route changes normally come from clicks; driving history directly
// reaches the same router path without generating input work inside the
// 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.`)
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)
}
+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 { summarizeCpuProfile } from "./perf/cpu-profile.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 cliPath = join(repoRoot, "packages/web/bin/cli.js")
@@ -47,6 +48,9 @@ Options:
--port <port> OpenChamber CLI port (default: from --url)
--dir <path> Session directory (default: repository root)
--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
another one. Measures what an idle session costs
while a different session is active in the
@@ -79,6 +83,8 @@ const parseArgs = (argv) => {
dir: repoRoot,
session: null,
viewSession: null,
expandProjects: false,
expandSessions: false,
prompt: DEFAULT_PROMPT,
model: null,
agent: null,
@@ -108,6 +114,8 @@ const parseArgs = (argv) => {
else if (value === "--dir") options.dir = argv[++index]
else if (value === "--session") options.session = 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 === "--model") options.model = argv[++index]
else if (value === "--agent") options.agent = argv[++index]
@@ -364,7 +372,11 @@ const main = async () => {
})
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)
await client.send("Page.navigate", { url: target.toString() })
@@ -375,6 +387,10 @@ const main = async () => {
localStorage.setItem("openchamber_sync_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)
await client.send("Page.reload", { ignoreCache: false })
await loaded
@@ -382,6 +398,12 @@ const main = async () => {
console.log(`Opened the session; settling for ${options.settle}s.`)
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, `window.__openchamberSyncPerformance?.reset()`)
await evaluateValue(client, `window.__openchamberStreamPerformance?.setEnabled(true)`)
@@ -553,7 +575,7 @@ const main = async () => {
listenerGrowth: delta("JSEventListeners"),
heapStartMb: round(heapSamples.at(0) ?? 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"),
},
frameLiveness,