From 632fc09e183ab1be7b12986666c76667e6c37fc8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 3 Aug 2026 18:01:52 +0300 Subject: [PATCH] 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. --- scripts/perf/scenario.mjs | 44 +++++++++++++++++++++++++++++++++++++ scripts/profile-idle.mjs | 29 ++++++++++-------------- scripts/profile-session.mjs | 26 ++++++++++++++++++++-- 3 files changed, 80 insertions(+), 19 deletions(-) create mode 100644 scripts/perf/scenario.mjs diff --git a/scripts/perf/scenario.mjs b/scripts/perf/scenario.mjs new file mode 100644 index 00000000..105e11d5 --- /dev/null +++ b/scripts/perf/scenario.mjs @@ -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 +} diff --git a/scripts/profile-idle.mjs b/scripts/profile-idle.mjs index 98dc67a3..bb06d637 100644 --- a/scripts/profile-idle.mjs +++ b/scripts/profile-idle.mjs @@ -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 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) } diff --git a/scripts/profile-session.mjs b/scripts/profile-session.mjs index 2b8f82e6..3fafdf5c 100644 --- a/scripts/profile-session.mjs +++ b/scripts/profile-session.mjs @@ -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 OpenChamber CLI port (default: from --url) --dir Session directory (default: repository root) --session 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 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,