openclaw/scripts/bench-control-ui-cold-load.md

56 KiB
Raw Permalink Blame History

Team-scale Control UI cold-load benchmark

Low-memory mobile profile

Both bench-control-ui-cold-load.ts and bench-control-ui-interactions.ts accept --low-memory. This starts Chromium in low-end-device mode with a 128 MiB V8 old-generation limit and records the profile in the report. It does not impose an Android OS/process RSS limit or emulate a physical Android WebView. Pair it with --viewport mobile --cpu-throttle 6; keep the memory profile identical in the unthrottled control. For slow-4G boot delivery, use --network-latency-ms 150 --download-kbps 1600 in the cold-load runner. Static HTTP delivery and synthetic Gateway RPC delays remain separate measurements.

Use untraced runs for ranking and interleaved base/control/head comparisons, then a separate --trace interaction run to attribute an expensive cell. On a busy host, repeat with --thread-cpu-time to distinguish renderer CPU from descheduling. Multi-action scenarios report aggregate task time, not per-event input latency.

Cold route CSS coverage and rendering cost

bench-control-ui-css.ts serves already-built base and head bundles through the production Gateway HTTP handler with the deterministic team-scale RPC fixture. It serially interleaves base, identical-base control, and head (reversing the triplet on alternating pairs), using a fresh browser context and cold HTTP cache for every direct navigation. The default matrix is Chat, New Session, Sessions, and Settings Appearance × desktop/mobile × light/dark × 1×/4× CPU. Settings uses /settings/appearance: desktop /settings redirects to Chat. Readiness requires the matching successful route, no pending route, the requested theme, and the enabled composer plus transcript text, a session table row, or a theme tile, as appropriate, across two animation frames. Chat also waits for the visible final inline metric's nested sandbox content and fonts. Every route then settles the parent document's fonts and two animation frames before stopping metrics, so blank embedded fixtures and late font layout cannot look like performance wins. Reports retain shell-ready and complete-screen-ready wall times separately. The renderer's threadTicks clock reports script/task/style/layout CPU and style/layout counts; browser navigation time to readiness remains wall time. Reports include raw samples, median/p95 and range, host load, CSS resource counts/sizes, build identity, and first-pair screenshots. The readiness probe's frame/geometry checks and the short CDP collection tail are included consistently in every CPU sample.

NODE_DISABLE_COMPILE_CACHE=1 node --import ./scripts/tsx.mjs scripts/bench-control-ui-css.ts \
  --bundle-dir /absolute/base/control-ui --compare-bundle-dir /absolute/head/control-ui \
  --pairs 10 --output /absolute/proof/css-local

Repeat with --network-latency-ms 100 for HTTP at 100 ms latency/10 Mbps. Filter with --routes chat,new,sessions,settings, --viewports desktop,mobile, --themes light,dark, and --cpu-rates 1,4. Run a separate --coverage --pairs 1 pass to retain raw stylesheet text and Chromium used ranges through asserted readiness; its instrumented timings are not performance evidence. Used ranges record rules observed during boot, including earlier transient states, rather than proving every selector in a rule matched the final screen. Zero coverage is not deletion authority: inactive routes, states, pseudo-classes, media queries, and shadow roots require source/consumer audits and their own behavior coverage. Screenshots disable animations after measurement; inspect them before sharing. Service workers are blocked, immutable server asset caches stay warm, and RPC/bootstrap/plugin data are synthetic. HTTP throttling does not add RPC latency, so these results describe CSS delivery and renderer cost, not live Gateway/provider performance. Use the A/A control and 20 pairs with CPU metrics when shared-host wall timings are noisy.

Cold routes and dependency leaks

bench-control-ui-route-leaks.ts visits selected routes in fresh browser contexts, with the HTTP cache disabled and service workers blocked. It serves immutable production bundles through the Gateway HTTP handler and uses synthetic Gateway responses. Coverage runs record requested chunks, source-map modules, import initiators, and V8 executed versus unexecuted bytes, including preloaded modules that were never evaluated. Requested source bytes and completed/evaluated bytes are separate: a preload still transferring at readiness is not a completed load.

NODE_DISABLE_COMPILE_CACHE=1 node --import ./scripts/tsx.mjs scripts/bench-control-ui-route-leaks.ts \
  --bundle-dir /absolute/base/control-ui --compare-bundle-dir /absolute/head/control-ui \
  --mode coverage --profiles local --output /absolute/proof/routes

Use --routes sessions,cron,usage to narrow the inventory. Timing runs are separate from coverage instrumentation: --mode timing --pairs 20 --profiles local,remote interleaves base, a second base control, and head. The remote profile adds 100 ms HTTP latency at 10 Mbps; synthetic WebSocket responses have no RTT. Reports retain readiness, thread-clock script/task CPU, host load, and each failed or unsupported scenario. Usable route readiness requires a loaded route and a visible usable control; sharing additionally opens its menu. Compare medians and p95 against the A/A control before attributing timing changes on a loaded host.

Use --routes agents,cron --interactions agents-files,cron-history,cron-transcript for the corresponding follow-through probes. Their automation-to-ready wall and CPU intervals are separate from cold readiness; they are not INP measurements. Coverage mode also records which requested modules actually evaluated during each action. Transcript probing opts into a synthetic completed run and messages. Every matching Agents/Cron variant receives at least 2,000 ms of post-ready think time, including builds without passive preloads. When the built navigation descriptor provides agents-next or cron-next, the probe also waits for those byte preloads. It records the actual warmup wall time and CPU for every variant; preload completion may extend the window. Coverage mode records V8 evaluation and rejects a panel that evaluates before its action. Cold readiness and coverage remain bounded before this optional warmup.

Use --routes advanced --interactions settings-security-immediate or settings-security-idle for one independent native Settings → Security visit. Run each action in its own command and fresh contexts; do not combine them. The idle case has a fixed 2,000 ms post-ready minimum and does not wait longer for preloads. Both record actual modulepreload links, resource completion, config/schema receipt counts, and V8 evaluation before/after the native click. Security readiness requires its checked Browser toggle and four enabled tool profiles with no override selected. The action precedes artifact analysis and screenshots; readinessToActionMs records observation/automation overhead. Cold Advanced coverage remains frozen at its own readiness. Use separate coverage and timing runs, both localhost and 100 ms HTTP, with the same A/A control and CPU rules above. HTTP shaping does not add synthetic RPC latency. A route's declared next interaction is its only automatic idle choice; after it completes, New Session still warms on intent or on routes without a declaration.

Media load, remount, and disposal

bench-control-ui-media.ts compares already-built base and head bundles with 96 synthetic transcript messages (16 repeated 1600×900 PNGs, eight repeated link hosts) and the existing 120-person avatar fixture. It never builds or contacts a real Gateway. Each pair gets fresh browser contexts, alternates AB/BA order, and runs both localhost and 100 ms RTT / 10 Mbps profiles by default:

NODE_DISABLE_COMPILE_CACHE=1 node --import ./scripts/tsx.mjs scripts/bench-control-ui-media.ts \
  --bundle-dir /absolute/base/control-ui \
  --compare-bundle-dir /absolute/head/control-ui \
  --engine chromium --pairs 10 --output /absolute/proof/media

Repeat with --engine webkit for the same media workload in WebKit. Chromium is the default; WebKit records wall timings, request counts, blob lifetimes, and feature-detected layout shifts without CDP CPU, heap, or trace metrics. Unsupported native Layout Instability is recorded as nativeLayoutShiftSupported: false and null values for layoutShiftSum, mediaAttributedShiftSum, and maxSessionWindowShift; it is never presented as zero shifts.

The observer resets immediately before held media is released. Each stable, scrolled, and disposed snapshot retains all non-recent-input events since that reset. layoutShiftSum adds their values; mediaAttributedShiftSum adds whole events with at least one media-containing source, which does not establish that media caused the entire event. maxSessionWindowShift records the largest window sum, starting a new window at a gap of at least 1,000 ms or a duration of at least 5,000 ms. This reset-bounded score is not navigation-lifetime Web Vitals CLS. layoutShiftPhaseSums reports the load sum and additional scroll/disposal sums from those existing snapshot boundaries. Legacy artifacts retain their old cls/mediaCls cumulative sums; derive a window score from their raw shifts rather than relabeling those values.

Use --dimensions canonical to include the persisted __openclaw.media image width/height and inline slot mapping. The default --dimensions missing preserves the original metadata-free cohort. Compare the same cohort on base and head; known-dimension reservation cannot prove zero shift for absent dimensions.

For attribution, add --geometry --pairs 1 in a separate diagnostic run. It captures all DOM element rectangles before and after the held-media release, including row keys, classes, scroll geometry, and image decode dimensions. During that fixed-viewport load interval only, an rAF sampler records changed visible transcript row, image, frame, footer, and footer-descendant rectangles with timestamps and source classes. Insertions and removals have a null previous or current rectangle. Sampling stops before scrolling; these rectangle changes are not a synthetic CLS score. The geometry reads add layout work, so do not mix these diagnostic timings with uninstrumented timing runs.

The initial viewport is settled while media is held. Releasing those requests records the load-phase shifts before scrolling. Each measured phase allows intersection admission frames, then waits for all admitted media HTTP requests and visible loading images/favicons across two settled frame checks; unadmitted off-screen placeholders do not block. The runner then scrolls the transcript and online roster, remounts the first/last rows, and removes the native openclaw-app host to exercise production disconnection while preserving the document for lifetime measurement. Settings parks chat and cannot prove disposal. A document nonce assertion rejects any reload as invalid disposal evidence. Per-sample JSON records browser request events by full URL (including revision, ticket, and size queries), method, kind and opaque auth-context identity; credentials are never recorded. Cumulative before/stable/scroll/disposal snapshots and separate boot/steady duplicate counts distinguish request ownership from bootstrap work. cacheProbe.serverRequests separately records actual HTTP admissions. cacheProbe.fetchAttempts separates cacheOnly (only-if-cached) thumbnail probes from networkEligible fetches and records their cache mode, request mode, credentials mode, opaque authorization identity, and outcome. Correlate these with server admissions by exact URL and method; fetch authorization identities are independent of browser request contexts, which also include cookies. cacheProbe.networkDownloads is null: HTTP admissions do not establish complete download bytes. Browser request events include cache hits and failed requests; cache-only probes must not reach the server. A repeated browser event cannot by itself prove a repeated network transfer. The native HTTP fixture serves bounded WebP thumbnails and the original full-image PNG; earlier routed-media workloads are not directly comparable. Chromium duration metrics use its threadTicks clock to measure thread CPU time, excluding time descheduled by other work on the host. Wall-clock load/scroll timings remain separate.

It also records native layout-shift source rectangles, timestamps, and media attribution when the engine supports Layout Instability, object URL creation/revocation and remaining DOM consumers, CDP script/task/layout/style duration, and JS heap before scrolling and after forced GC immediately before and after host disconnection, including unreferenced favicon blob URLs. The first pair includes pending and loaded screenshots for each bundle/profile. summary.json retains every sample, load average, bundle identity, and median/p95 load/scroll timings.

Acceptance is zero CLS in the fixed media viewport and no live object URLs owned only by disposed markdown consumers. Review repeated admissions alongside cache-only probes before attributing application reuse. Review retained URLs against their actual owner: a documented shared page-lifetime cache is distinct from a disposed consumer leak. Timing needs ten pairs with median regression ≤5% and p95 not worse. On a loaded host, repeat with the same base directory supplied as both bundles for an A/A control, then use 20 interleaved pairs and CDP CPU plus structural counters if wall timing is noisy.

Use a separate Chromium --trace --pairs 1 run for decode/paint attribution; tracing adds overhead and must not be mixed with the untraced timing samples. The trace and named-event duration sums are retained per sample. CDP JS heap excludes native decoded-image/GPU memory. The mock RPC transport has no RTT; Chromium static HTTP uses CDP throttling, while WebKit static HTTP is unthrottled. Both engines use the same explicit routed-media delay because Playwright fulfillment bypasses network emulation. Load measurement starts after static assets are ready; this is not a cross-engine cold-navigation comparison. Routing disables the HTTP cache, so duplicate counts measure application request reuse rather than disk-cache behavior. These are renderer/media results, not real-Gateway throughput or process-memory results.

Run from a prepared checkout with its package dependencies and Playwright Chromium:

node --import tsx scripts/bench-control-ui-cold-load.ts

For cold readiness comparisons, use --gateway-http with already-built bundles. It serves documents and assets through the production Gateway HTTP handler, including route preload hints and negotiated compression sidecars:

NODE_DISABLE_COMPILE_CACHE=1 node --import ./scripts/tsx.mjs scripts/bench-control-ui-cold-load.ts \
  --gateway-http --bundle-dir /absolute/base/control-ui \
  --compare-bundle-dir /absolute/head/control-ui --runs 20 \
  --first-load --cold-http-cache --untraced --thread-cpu-time --critical-path \
  --face chat --critical-path-route sessions --critical-path-route settings \
  --critical-path-route cron --output /absolute/proof/readiness

Twenty runs alternate ten base/head pairs. Supply a second copy of the base bundle for an A/A control. --boot-route new measures the New Session composer. Cold New Session omits owner registration and sidebar counters because it has no Chat owner to finish registration. The effective ownerProbes and sidebarCounters flags are recorded in summary.json; other routes and warm reloads retain their existing instrumentation. Browser storage and HTTP caches are cleared between samples; Gateway asset caches stay warm for each immutable bundle. Each bundle's index is read before sampling to initialize the shared file reader outside the first timed reload. The WebSocket/bootstrap fixtures remain synthetic, so this measures the production HTTP and renderer paths, not Gateway RPC or provider latency. The default Vite preview server does not activate Gateway route preload hints or serve the shipped compression sidecars; retain that mode for renderer diagnostics rather than network-readiness claims.

Add --critical-path-intent-ms 150 to hover and focus the destination for a 150 ms dwell before activation. Settings uses its real identity-menu item in this mode; without the option it retains the keyboard-shortcut entry point. elapsedMs remains activation-to-ready. intentElapsedMs includes hover/focus, the dwell, and activation-to-ready, while intentDwellMs records the actual pre-activation interval. The critical-path:<route>:intent mark and interval RPC/resource records retain work started during intent, including requests that finish before activation; carryInRpcCount counts requests begun before activation. CPU collection also starts before intent. Each destination still starts from a separately settled chat boot, so this measures first route visits, not direct cold route loads or retained return visits.

Each destination's criticalPath[].presentation records layout shifts from intent (or activation without intent) through a separate snapshot taken after an additional 500 ms settle. Readiness, RPC/resource capture, and CPU measurement finish before that settle and the snapshot processing. Source elements and previous/current rectangles are retained. cls applies the same five-second session window and one-second gap rule as boot, excluding hadRecentInput entries. allShiftSum includes those entries: route clicks can suppress standard CLS while still causing a visible shift. Inspect both values and the sources.

For Settings workload measurements, add --settings-core-schema. Before the browser launches, the runner dynamically imports src/config/schema.ts, calls buildConfigSchemaCore() once, and supplies its full response as the synthetic config.schema RPC payload. This includes the core JSON Schema, UI hints, version, generation timestamp, and bundled channel schema metadata. It excludes extra runtime-installed plugin/provider and channel metadata normally merged by the Gateway's runtime schema owner. Schema construction is outside sampling; browser JSON parsing, schema processing, rendering, and the existing response-byte network shaping remain inside the measured flow. summary.json records the flag. Without it, the existing default fixture is unchanged. This measures a real core schema payload over the synthetic transport, not a live Gateway or its plugin inventory.

For twenty interleaved base/base/head triplets, supply the base bundle twice and use 60 runs. The first candidate is the A/A control; candidate-2 is the head. Run the two profiles serially, with the same built bundles. Set the synthetic Gateway replay delay as well as the HTTP latency: the remote example uses 100 ms for both; local uses zero. The default 4 ms frame gap remains in both profiles:

base_bundle=/absolute/base/control-ui
head_bundle=/absolute/head/control-ui
profiles=(local remote)
for profile in "${profiles[@]}"; do
  network_flags=(--network-latency-ms 0 --download-kbps 0 --latency-ms 0)
  if [[ "$profile" == remote ]]; then
    network_flags=(--network-latency-ms 100 --download-kbps 10000 --latency-ms 100)
  fi
  NODE_DISABLE_COMPILE_CACHE=1 node --import ./scripts/tsx.mjs scripts/bench-control-ui-cold-load.ts \
    --gateway-http --bundle-dir "$base_bundle" \
    --compare-bundle-dir "$base_bundle" --compare-bundle-dir "$head_bundle" --runs 60 \
    --first-load --cold-http-cache --untraced --thread-cpu-time --critical-path \
    --face chat --settings-core-schema \
    --critical-path-route settings --critical-path-route cron \
    "${network_flags[@]}" --output "/absolute/proof/readiness-$profile"
done

Use the resulting two summary.json paths to summarize readiness, CPU, work counts, and layout shifts. This keeps each A/A and head comparison paired to the preceding base sample; percentile values use nearest rank, and median values average the middle pair. CLS maximums retain isolated shifts:

node --input-type=module - /absolute/local/summary.json /absolute/remote/summary.json <<'NODE'
import { readFileSync } from 'node:fs';
const stats = (values) => {
  const v = values.filter(Number.isFinite).toSorted((a, b) => a - b);
  if (!v.length) return null;
  return { n: v.length, median: (v[Math.floor((v.length - 1) / 2)] + v[Math.floor(v.length / 2)]) / 2,
    p95: v[Math.ceil(v.length * .95) - 1], max: v.at(-1) };
};
const metrics = {
  readyMs: (s) => s.elapsedMs,
  taskMs: (s) => s.cpuMilliseconds.TaskDuration,
  scriptMs: (s) => s.cpuMilliseconds.ScriptDuration,
  rpcCount: (s) => s.rpcs.length,
  rpcResponseBytes: (s) => s.rpcs.reduce((sum, r) => sum + r.responseBytes, 0),
  resourceTransferBytes: (s) => s.resources.reduce((sum, r) => sum + r.transferSize, 0),
  cls: (s) => s.presentation.cls,
  allShiftSum: (s) => s.presentation.allShiftSum ?? s.presentation.layoutShifts.reduce((sum, r) => sum + r.value, 0),
};
for (const file of process.argv.slice(2)) {
  const summary = JSON.parse(readFileSync(file, 'utf8'));
  if (summary.results.length !== 60) throw new Error(`Expected 20 complete triplets: ${file}`);
  const report = { file, hostLoadAverage: summary.results.map((r) => r.hostLoadAverage), routes: {} };
  for (const route of ['cold-chat', 'settings', 'cron']) {
    const samples = summary.results.map((r) => {
      const sample = r.criticalPath.find((s) => s.name === route);
      if (!sample) throw new Error(`Missing ${route}: run ${r.run}`);
      return { ...sample, presentation: sample.presentation ?? r.presentation };
    });
    report.routes[route] = {};
    for (const [name, read] of Object.entries(metrics)) {
      const groups = [0, 1, 2].map((slot) => samples.filter((_, i) => i % 3 === slot).map(read));
      const delta = (values) => stats(values.map((value, i) => groups[0][i] > 0 ? 100 * (value / groups[0][i] - 1) : NaN));
      report.routes[route][name] = { base: stats(groups[0]), control: stats(groups[1]), head: stats(groups[2]),
        controlPairedPercent: delta(groups[1]), headPairedPercent: delta(groups[2]) };
    }
  }
  console.log(JSON.stringify(report, null, 2));
}
NODE

Each route visit saves run-N.ROUTE.requests.json with CDP request IDs, wall-clock timestamps, and observed initiators for that document, including its settled boot. After all measured runs finish, run-N.ROUTE.waterfall.html combines these records with the existing critical-path capture in a standalone timeline and table. Open it locally; no server or external assets are needed. Starts and ends are relative to activation, with carry-in work, a ready marker, and post-ready work labeled separately. Requests without completed resource timings remain visible with an unknown end and size.

HTTP sizes distinguish encoded body, decoded body, and transfer bytes; request sizes are not captured. RPC sizes are UTF-8 JSON frame bytes, excluding WebSocket framing, headers, and compression. RPC timings include the synthetic replay scheduler and network shaping, not real Gateway service time. Observed initiator and caller-stack links do not establish causal completion dependencies. Matching Resource Timing entries to CDP records uses URL and nearest start time. The HTML includes no RPC parameters, message contents, or HTTP headers; the existing critical-path JSON keeps its original shape.

For code-splitting evidence, use a separate --coverage --untraced run without --cpu-profile or --cpu-profile-compare. Coverage and CPU sampling use different CDP sessions, while Chromium coverage state is isolate-global. --thread-cpu-time is allowed because it reads Performance metrics without starting the sampler. Coverage captures observed executed and unobserved raw source bytes; these are not measurements of data reads, compile cost, or removable code. javaScriptResources separately counts completed JavaScript fetches from Resource Timing: decoded body bytes, encoded body bytes, and transfer bytes including headers. Its outsideCoverage subset includes fetch-only preloads, so V8 coverage alone must not be reported as all downloaded code. Module preloads can parse/compile without evaluation, and dormant function bodies can be compiled lazily; unused source bytes are not a CPU estimate.

Use a separate traced run (omit --untraced) for trace.compilation: coarse V8 module parse/compile spans are unioned per renderer thread; this is not an exhaustive compiler profile. Main-thread and all-renderer thread CPU are reported separately from wall duration. CPU is unavailable when the trace lacks complete thread-clock intervals. Never mix traced readiness timings into the untraced comparison.

This opt-in benchmark builds the production Control UI into a fresh artifact directory, warms a signed-in synthetic dashboard visit, and measures three reloads. It never connects to a real Gateway or account. The reusable inventory is ui/src/test-helpers/control-ui-team-scale-fixture.ts; its browser contract is ui/src/e2e/team-scale-cold-load.e2e.test.ts.

The browser contract belongs to the existing release-only UI tier. Automatic CI runs it only when that test file changes; full manual/release validation retains it. The fixture remains in the normal UI TypeScript graph. To run the contract explicitly:

pnpm test ui/src/e2e/team-scale-cold-load.e2e.test.ts --maxWorkers=1

The selected agent has 300 root sessions plus 54 children, 24 cron sessions and 12 system sessions, with a 200-row shared first page, 30 pinned dashboards, 34 owners, 120 people, six agent identities (one selected chip), seven UI plugins, 240 transcript messages, 120 cron jobs across enabled, disabled, running and error states, and 12 inline widgets. Six catalog-host events and twelve full presence snapshots arrive individually. Catalog notifications exercise the data owner; the grouped agent roster does not display catalog rows. Widgets use the production sandbox host on a separate loopback listener. Plugins are small synthetic modules exercising registration/loading, not replicas of any private plugin implementation.

--latency-profile live-fast replays a fast-handshake delivery envelope: challenge no earlier than 439 ms from navigation, connect reply no earlier than 41 ms after send, ordinary messages 20 ms, and no inter-frame spacing. Critical boot methods have individual request-to-delivery floors:

Method Floor (ms)
chat.startup 220
sessions.resolve 216
sessions.describe 149
sessions.list 195
sessions.subscribe 117
sessions.messages.subscribe 183
board.get 189
users.list 144
agents.list 149
agent.identity.get 150
plugins.controlUi.list 125
cron.list 165
workboard.boards.list 141

These rounded medians come from the first reply per method in six signed-in reload observations (three chat, three dashboard). Challenge delivery was 344–566 ms from navigation; connect RTT was 25–162 ms. The observations include browser queueing and server work: replaying them as floors is a conservative scheduling scenario, not an isolated network model. Ordinary-message delay stays at 20 ms to avoid applying boot congestion to every later event. Repeated calls still use the method floor; they do not reproduce the faster later live replies. Socket-open timestamps were absent, so the challenge uses a navigation floor, not an inferred socket-open delay. Document/proxy latency is not simulated.

--latency-profile legacy-handshake reproduces the earlier campaign scenario: 340 ms challenge floor, 220 ms connect, 195 ms resolve/describe, 20 ms ordinary messages and zero frame spacing. The default profile retains the original 35 ms / 4 ms / no challenge floor settings. Explicit --latency-ms, --frame-gap-ms, --connect-not-before-ms, --method-latency-ms and --sidebar-query-latency-ms overrides take precedence over any profile. summary.json records the selected profile and all effective floors. Warm-reload milestones include socketOpen, challenge, connectSent, hello (reply dispatch) and dashboard-stage helloHandledMs; keep the handled milestone as a separate gate when moving work near the handshake. Profile selection alone does not qualify a product scheduling change against live behavior.

--latency-ms sets the inbound message delay (default 35 ms). --frame-gap-ms sets the minimum separation between Gateway message deliveries (default 4 ms). Concurrent requests remain concurrent; replies share one serialized wire. --sidebar-query-latency-ms ownerFirst=400 (also involvingMe, ownerCounts, and children) overrides method latency for that sidebar query shape. Use --omit-hello-self-presence --method-latency-ms users.self=900 to exercise identity resolution after hello. --sidebar-avatars supplies synthetic avatar resources; --sidebar-expanded saves a Show more selection before replay. Warm-reload output includes per-frame sidebar row keys/order hashes, section headers, avatar sources, and retained cached row elements. These are DOM observations, not pixel paint times. --sidebar-view selects sessions (default, with Involving me selected) or online. Run both because current UI presents these inventories in separate sidebar views. Default reloads use one agent chip; explicit churn scenarios use the all-agent roster. The Online run also requires the saved sidebar view to survive the reload. If it returns to Sessions, the run fails instead of reporting Sessions measurements as Online measurements. --runs accepts 1–20 reloads. --output selects the artifact parent. --bundle-dir serves an already built production bundle instead of rebuilding it; keep its build identity equal to the checkout head. This preserves a baseline bundle while production sources are edited.

For a boot-weight comparison, use --bundle-dir <base> --compare-bundle-dir <head> --runs 20 --untraced --cold-http-cache. The runs alternate builds and restore each build's own warm state. Every run records presentation.firstNonemptyTranscriptMs and presentation.firstEnabledComposerMs from visible DOM observed on animation frames; these are readiness observations, not compositor paint timestamps. firstEnabledComposerMs retains its original definition: the first matching hydrated-chat or New Session textarea is visible and enabled. It does not include the static boot composer or reject read-only or inert editors.

presentation.firstUsableComposerMs adds an editable-input observation, with firstUsableComposerKind identifying static, hydrated, or new. It accepts a visible, enabled, non-read-only textarea with no inert ancestor from the static boot composer, the active hydrated chat pane, or New Session. It uses the same animation-frame sampler and visibility check, with no extra geometry or hit test. This is editor eligibility, not actual key-to-paint latency, compositor paint, click-occlusion proof, or send admission. Sampling ends at the existing shell and route completion boundary; the value remains null if no eligible editor was observed during that window. Older captures have no such fields: preserve them as absent, without relabeling or retrofitting the legacy metric.

Warm reloads also record warmReload.milestones.firstLiveTranscript on the first sample with both visible transcript rows and the pane's transcriptReady state. It reuses the existing visibility and catch-up observations. firstTranscript still marks the first visible rows, including saved rows, and catchupApplied still marks owner readiness even while the virtualizer keeps rows hidden. The new marker distinguishes live transcript visibility from both; it is a DOM readiness observation, not an Element Timing paint. It remains absent if not observed before collection stops. Cold firstNonemptyTranscriptMs is unchanged.

Every run also records browser paint entries (presentation.firstPaintMs and firstContentfulPaintMs), the first visible #openclaw-boot-shell frame and its geometry, and the first live shell's geometry. Layout-shift entries retain their source rectangles and hadRecentInput flag. presentation.cls uses the largest five-second session window with less than one second between non-input shifts; nonRecentInputShiftSum retains the total for the measured boot. A zero CLS alone does not prove matching geometry when DOM nodes are replaced: inspect the static and live frame/sidebar/composer rectangles and the filmstrip too.

For a first-paint diagnostic, use the same built bundles with --filmstrip-only instead of --untraced. This saves run-N.filmstrip/*.jpg without the heavy warm-reload probes. visual.visuallyCompleteMs estimates the earliest compositor frame in the final stable suffix; at 344×236, fewer than 0.4% of pixels may differ from the final frame by a channel value of at least 32. This excludes small/JPEG changes and includes skeleton animation, so inspect frames rather than treating it as a browser-standard paint metric. Traced filmstrip and untraced readiness samples are separate populations. Compare cold (--first-load) and warm (omit --first-load) runs independently, on localhost and with --network-latency-ms 100 --download-kbps 10000 for a simulated remote link. Add --hold-boot-assets --first-load --runs 1 to a filmstrip diagnostic to hold all external scripts and stylesheets, capture boot-assets-held.png plus completed resource counts/geometry, then release them and verify ordinary chat readiness. This deliberately blocked-assets sample is structural HTML-only paint proof, not a latency sample. Keep profiling and the optional warm-reload probes out of this timing run. Console progress contains readiness and host load; full RPC and resource records are serialized to summary.json after all runs, outside the preview-server workload.

Add --thread-cpu-time to measure consumed renderer CPU with CDP's threadTicks time domain, including in --untraced runs. Each result's threadCpu records scriptMs, taskMs, layoutMs, styleMs, and whole-thread threadMs from just before reload through settlement (and any requested --capture-ms extension). These are CPU deltas rather than elapsed wall time; later live, streaming, and session-switch phases retain the same selected time domain. Default runs keep CDP's timeTicks domain.

Use a separate --coverage --runs 1 --first-load --cold-http-cache --untraced capture to inspect JavaScript exercised by cold boot. Do not combine it with CPU sampling; the default traced run also starts the CPU sampler. Run a separate --cpu-profile capture for source-mapped CPU attribution. Each run-N.coverage.json records V8 function/block ranges and loaded, executed, and unused UTF-8 bytes per asset through settlement; nested block counts override enclosing function counts. Keep coverage captures separate from timing comparisons because instrumentation changes execution cost. Source maps and the exact saved bundle allow attribution of the unused ranges; preload transfer budgets remain the performance checker's authority, including assets that have not executed.

On a noisy host, run an A/A control before interpreting candidate differences. First copy the baseline bundle to a separate directory (cp -R <base> <control>): the runner temporarily swaps comparison bundles, so both slots cannot use the same path. Run --bundle-dir <base> --compare-bundle-dir <control> --runs 20 --untraced --thread-cpu-time. Then replace the comparison bundle with the candidate, keeping the same fixture and options. Compare CPU deltas alongside readiness and structural counts; thread CPU does not measure compositor presentation or server work.

pnpm ui:check-performance --module-attribution --dist <build> ranks the top 30 emitted JavaScript modules in each route's preload graph. Add --json for all modules and mapping coverage. It gzips source-mapped generated ranges independently, so those scores identify expensive modules but are not additive transfer costs; the existing shipped gzip sidecars remain the route budget authority.

Every reload replays the warm visit's persisted sidebar and trimmed v8 transcript (after unloading the app), local storage and session storage. --gateway-http serves synthetic HTTP fixtures natively so production JS/CSS can use the browser's cache during warm reloads. Without that option, Playwright's synthetic HTTP routes disable caching: "warm" then describes persisted application state only. Warm-up captures each build's native local/session storage writes after the application exits, including page-exit-only cache hints. Measured repeats replay those frozen inputs without carrying writes from a prior measured run. Empty reset documents use temporary interception; build switches unload to about:blank. No reset route remains active during a measured navigation, since Playwright routing disables the browser HTTP cache. http.waterfall[].transferBytes records the actual transfer.

--warm-reload adds self-contained cache/paint diagnostics. An optional --probe-dir /path/to/probes supplies mutattr.init.js, loaf-mutation.init.js, and ws-rpc-census.init.js for additional attribution; externalRig records which extra instruments are available. The output records cache reads, cached transcript visibility, hello, live catch-up, enabled input, layout shifts, mutation batches, and retained row elements, duplicate visible row keys, and the same visible row's viewport offset at the cached-to-live handoff. Visibility and enabled-input milestones are DOM observations; FCP is the browser's paint metric. Diagnostic runs also save filmstrip frames. Live catch-up recognizes both chat.startup and initial history in a sessions.messages.subscribe reply, so cross-revision runs do not label live rows as cached rows after the subscription starts carrying history. The benchmark enables subscriptionInitialHistory; requested initial history uses the shared startup-response fixture owner. Older clients still make a separate startup request. Scenarios testing that fallback set subscriptionInitialHistory: false. Use --untraced for timing comparisons without boot tracing or CPU sampling, then collect separate traced runs for the storyboard and attribution.

--large-snapshot expands the transcript to 1,100 synthetic messages (about 3 MB), including tool results and replay metadata. Combine it with --warm-reload to exercise a large live history with the current bounded IndexedDB restore. snapshotSave reports the persisted row count. --transcript-messages 400 selects a specific positive message count instead.

--session-switch clicks the first recent session after the other measured phases. It opens another 400-message transcript when paired with --transcript-messages 400, using the same synthetic history and native sidebar navigation. The phase ends once the target pane and inline preview are visible and pending wire work has been quiet for 750 ms. It records task/script/layout/style CPU and interval RPCs in sessionSwitch, plus a separate source-mapped session-switch profile with --cpu-profile. This measures a first switch to that session in the current document, not a warm return.

The dashboard fixture includes reaction pickers, widget actions, context usage, and viewers. Use --warm-reload to measure its cached restore. Transaction labels in diagnostic traces distinguish transcript, sidebar, and composer cache completions; a completion callback can include later render microtasks and is not a measurement of native IndexedDB cost alone.

--live-events adds 125 synthetic live frames over 20 seconds after each reload settles. The stream mixes full presence snapshots, session patches for displayed, held-but-hidden, and outside-window rows, open-session messages and assistant streaming, changing catalog hosts, and unrelated cron/run-state activity. The live phase runs after reload tracing and CPU sampling stop; --cpu-profile starts a separate live CPU capture. The selected session starts with an active canonical run; live messages and deltas advance its history receipt before publishing the event, so later history reads retain the same messages and stream. Capture ends after all frames arrive and the planned 20-second stream plus 750 ms have elapsed; busy sessions need not become idle. plannedWindowMs and the observed elapsedMs distinguish the planned window from delayed delivery on a loaded host. Its run-N.live.json records observed Solid owner calls and bounded lifecycle scheduling stacks per event label, plus DOM snapshots of the affected rows, chat viewers, and streaming text; run-N.live.png shows the synthetic final state. Row attachment and viewport visibility distinguish an absent row from a rendered row below the fold.

live.milliseconds records Chrome's task, scripting, layout, and style CPU deltas across the replay. For unprofiled comparisons, divide ScriptDuration by sentEvents to report scripting milliseconds per event; keep sampled CPU captures separate.

For the Solid sidebar, solidSidebar.lifecycle separately counts owner invalidate calls and prepareRender/commitRender projection lifecycle passes. Bounded owner and session-data origin stacks identify their callers. These are not full Solid render counts: fine-grained bindings update independently. The init probe captures the existing host-to-owner WeakMap registration, restores that tap immediately, and restores the owner wrappers at the end of the live phase. A missing sidebar or pane owner fails the capture instead of reporting zero work.

solidChat counts the pane runtime's actual render calls and existing lifecycle hook notifications. Each render retains any explicit lifecycle invalidation IDs and scheduling stacks received since the preceding render. Unattributed renders and remaining pending producers are explicit; domain mutations and fine-grained bindings can update without a whole-pane render or lifecycle invalidation. Owner and element notifications remain separate, so their combined count is not a DOM commit count. The probe preserves and restores the hook's complete descriptor, including the test adapter's accessor, and the pane render method. Page snapshot computations and fine-grained Solid bindings are explicitly listed as unmeasured. Use source-mapped CPU profiles for those paths; a missing counter is not evidence of zero work. The retired Lit counters are no longer emitted.

Live updates are attributed to the most recently delivered event label. Paced refreshes and clock-driven presence updates may finish during a later event's interval; inspect their scheduling stacks before treating a per-label count as causal. Compare total steady-state render counts and renders per second as well. Wall-clock durations and frame coalescing vary on a loaded host. The benchmark's static RPC responses are not authority for live title/run-state correctness; use focused browser contracts with updated authoritative mock responses for that proof.

--selector-stats adds ranked Chromium selector snapshots to boot captures in run-N.selectors.json, with stylesheet identities, elapsed microseconds, match attempts, matches, and fast rejects. It requires tracing. The warm interaction runner (scripts/bench-control-ui-interactions.ts) accepts the same flag, plus --theme light|dark and --thread-cpu-time for thread CPU duration metrics on a loaded host. Selector instrumentation adds substantial overhead; use separate untraced interleaved runs for timing comparisons. Empty Chromium selector snapshots represent no matching work and are omitted from the ranking.

For per-row resource counts, the warm runner accepts --resource-counts and --steps sidebar-resources-scroll-resize,sidebar-resources-idle-visible,sidebar-resources-idle-hidden. These scenarios use 1,000 long session labels, 400-message transcripts, and native clocks. The result records created/active observers, observed targets, callbacks, intervals, media queries, and global listeners before and after each action. Run timing comparisons separately without instrumentation, with --thread-cpu-time --pairs 20 --compare-bundle-dir <other-build> and an A/A control on a loaded host. Hidden idle runs headless: the runner launches full Chrome for Testing with --headless=new, minimizes its own window, and verifies native document visibility; no window appears on screen. Hidden samples use a fresh runner-owned profile and Playwright's noDefaults connection so its normal focus emulation cannot override native visibility. The profile and process are removed after each sample. A browser that remains visible produces an explicit skip, never a simulated hidden result.

Each invocation keeps its exact Git head and dirty-tree inventory, raw Chrome trace, CPU profile, settled screenshot, and JSON summary in a fresh timestamped directory. It measures renderer-main-thread task interval union, long tasks, layouts with JavaScript stacks, observed Solid lifecycle calls, source-mapped CPU self-time hotspots, Solid flush self/inclusive sampled time, and RPC request/response bytes. The top 20 CPU entries retain browser/GC overhead alongside source-attributed call sites. A reload settles after all scheduled fixture events and outstanding RPCs complete, then 750 ms with no wire or captured Solid lifecycle activity. That quiet interval is measurement padding, not part of a correctness test.

Filmstrip frames come from Chrome tracing. Significant changes compare consecutive frames at 344×236: at least 0.4% of pixels differ by at least 32 in an RGB channel. Significant frames separated by more than 100 ms start a new transition group. These automated groups are not equivalent to the campaign's manually selected visual states. Profiling/instrumentation adds overhead; compare runs on the same machine, Chromium version, fixture, latency, and profiling settings.

This measures the production renderer against the existing mock Gateway. It cannot measure server CPU, HTTP authentication/proxy redirects, real plugin bundle cost, or a service-worker cache (service workers are blocked). The original real-Gateway latency rig remains useful for those boundaries. Synthetic fixture fidelity and measured results must be reported separately from private deployment evidence.

Source-mapped CPU attribution

Use --cpu-profile --live-events --untraced to collect separate reload and live profiles without the additional timeline trace. CPU mode leaves the owner-method and animation-frame wrappers disabled (ownerProbes: false, solidChat/solidSidebar absent as null); source stacks supply the attribution. This also lets live replay run after renderer owners have been replaced by native Solid components. --cpu-profile also captures --streaming-reply when selected. Each phase prints self time, inclusive function time (application sources first), and inclusive source-file time. Recursion and multiple frames from one file count once per sample. All rows are saved in run-N.cpu-profile.json; cpu-profile.json holds per-row medians, treating missing functions as zero. Raw phase profiles retain unmapped browser/native frames. The mapper reads adjacent .js.map files, including hidden release maps, and reports missing mappings; keep maps alongside any --bundle-dir assets.

--cpu-profile-compare /path/to/baseline/cpu-profile.json enables profiling and prints candidate-minus-baseline self/inclusive function deltas, also saved in cpu-profile-compare.json. A per-run report is accepted too. Function identity is source path, name, line and column; moving a function appears as a removal/addition. Mapped filesystem paths are repository-relative, independent of the build output directory. Use the same fixture, phases, sampling and machine for both captures. Profiling inflates timing: use these tables to prove the hot path runs, then measure improvements in separate unprofiled, interleaved runs.

For streaming comparisons, keep both production builds and run complete alternating cycles with --bundle-dir <baseline> --compare-bundle-dir <candidate> --runs 20 --streaming-reply --transcript-messages 400 --face chat --untraced. The benchmark restores each build's warm state before each run. Streaming comparisons do not require the separate --warm-reload diagnostics or external probe scripts. The native typing probe appends to the existing draft and verifies that every dispatched character survives streaming; streaming.typing retains the count and final synthetic draft alongside the timing evidence.

Use --transcript-messages 2000 for the long-session stream. Add --streaming-content tool-code to grow an open TypeScript fence alongside an expanded tool card, or --streaming-panes 2 to stream two independent sessions in the native split layout. Each pane must retain its typed draft, show the complete reply, and follow its own growing tail. streaming.panes and streaming.typingByPane retain these results; the original first-pane fields remain available. The default Markdown fixture is still 645 deltas.

Add --streaming-allocations to sample JavaScript allocations during the stream with CDP HeapProfiler at a 4 KiB interval, retaining samples collected by both major and minor GC. streaming.allocations reports estimated bytes and bytes per delivered delta; run-N.streaming.heapprofile retains the allocation call tree. The interval covers the whole renderer, including synthetic delivery and native composer input, and excludes reload and report serialization. These are sampled allocation estimates, not retained heap sizes or exact array counts. Collect timings in separate runs without allocation sampling.

For concurrent paired comparisons, use separate processes and bundle directories with the same --fixture-now-ms and a future --live-start-at-ms, --runs 1, and distinct --port / --sandbox-port values. The start barrier supports either --streaming-reply or --live-events and fails if setup misses the requested time.

Use --streaming-rate 150 --streaming-burst-size 5 to enqueue five snapshots every 33.3 ms (fast-provider bursts). The default rate remains 30 snapshots/s; --streaming-burst-size 5 alone enqueues five snapshots every 166.7 ms while retaining the average 30 deltas/s. The mock transport still applies --frame-gap-ms; set it to zero to remove intentional spacing, and inspect delivery timestamps before treating a group as one-frame delivery. --filmstrip-only --streaming-reply retains the stream's screenshot frames in run-N.streaming.filmstrip/. Traced/filmstrip runs also check plain-paragraph suffix visibility on the next rendering frame (streaming.textVisibility); keep these instrumented captures separate from timing comparisons. Streaming reports count synchronous stream assignments, frame publications, pane render calls/time, forced rail observations, and row ResizeObserver deliveries. These are separate boundaries: publication is not a rendered commit, and pane render time excludes subsequent Solid and Markdown work. The source-mapped CPU profile covers that later work; the task/microtask and next-rAF timings do not measure presentation to the screen. After timing, each run also checks that the real transcript grew and remains within two pixels of its native scroll bottom, without manually requesting a render.

For a mixed fast turn in both engines, use the cross-engine companion:

node --import ./scripts/tsx.mjs scripts/bench-control-ui-engines.ts \
  --bundle-dir dist/control-ui --steps streaming-mixed-fast \
  --viewport desktop --engines chromium,webkit --pairs 1 \
  --output .artifacts/streaming-profile

This workload delivers the 645-delta Markdown reply at 150 deltas/s in bursts of five, with reasoning, 24 tool calls and results, and an image arriving midway. It requires --viewport desktop: mobile's modal navigation drawer prevents simultaneous composer and sidebar input. It checks native composer typing, the sidebar's Show more action, complete text, tool results, reasoning, tail following, and image decoding. The outer action includes the final image inspection; streaming probe metrics stop before that inspection. Delivery-to-next-task includes scheduling delay. Delivery-to-rAF and DOM-mutation-to-rAF are frame-boundary proxies, not paint timestamps.

Use a separate --trace run for script/layout/style attribution. Long Task API metrics are present only when supported; WebKit's missing counters are not zero. WebKit Inspector durations are elapsed time, not CPU consumed by the action. The probe emits streaming-reply:start:<startedAt> once per stream run to align Inspector records with the snapshot's page-clock startedAt and relative delta timestamps. startMarkCompletedAt bounds the mark call's completion; use the interval between it and startedAt as clock uncertainty when Inspector records the mark's emission time. Delivery-window script/style/layout durations may overlap and include other tasks between deltas; they are elapsed work within the window, not CPU time or causal attribution to a single delta. Pool deliveries with identical timestamps and end the last window at startedAt + elapsedMs. --cpu-throttle 4 --engines chromium measures Chromium's emulated slowdown; WebKit has no equivalent supported CPU throttle, so requesting it fails explicitly. Use --compare-bundle-dir and --control-bundle-dir for interleaved candidate and A/A comparisons, following the same-machine timing rules above.

Initial baseline

Production UI at 488c0a3850fc, Blacksmith Testbox, three Sessions reloads with 35 ms reply latency and 4 ms frame spacing:

Metric Median Range
Navigation to settle, including 750 ms quiet window 4,444 ms 4,398–5,128 ms
Main-thread busy time 3,263 ms 3,109–3,647 ms
Long tasks 15 11–16
Forced layouts 38 37–40
Forced-layout time 434 ms 399–509 ms
Lit updates 1,100 1,091–1,104
Solid flush inclusive sampled time 690 ms 598–788 ms
Boot RPCs 85 85–86
RPC request + response bytes 942,400 942,400–1,024,426
Filmstrip transition groups 5 5

This reproduces the high update volume that the earlier small synthetic fixture missed. The largest source-attributed native getter is scrollHeight from ui/src/components/scroll-state-observer.ts:19 (107–199 ms sampled self time). The Online attempt returned to Sessions after reload and is excluded from these numbers; the fixture E2E opens Online explicitly to verify all 120 people.

Initial completed and active transcript

Use scripts/bench-control-ui-rich-boot.ts to isolate first-screen transcript content from the larger inventory workload. It serves production bundles through the Gateway HTTP owner with synthetic RPC, starts with one completed tool run and one active answer, and records the first visible composer, completed answer, and active answer. It interleaves base, an identical base control, and head with cold HTTP caches, renderer thread CPU, host load, screenshots, and resource timing.

node --import ./scripts/tsx.mjs scripts/bench-control-ui-rich-boot.ts \
  --bundle-dir /path/to/base --compare-bundle-dir /path/to/head \
  --pairs 20 --network-latency-ms 100 --output .artifacts/rich-boot-rtt

Omit network latency for localhost; a positive latency also applies 10 Mbps throughput. These results cover browser rendering, not provider latency.

The rich-boot report also records Resource Timing phases, negotiated protocol, compression/cache response headers, and the Gateway's actual request inventory. Use those phases to distinguish connection queueing from response transfer. Add --service-worker to install the production worker, leave the warm document, stop the worker, and clear HTTP, asset, and browser-state caches before each measured navigation. Worker fetches receive the same network profile as page fetches. The report includes preload state and worker requests; each sample must issue exactly one document request. This mode measures a stopped worker with cold assets, rather than an already-open tab or an offline-cache hit.