# strobe — spec `strobe` measures and explains performance: a web page, an Electron app (installed or packaged, launched in isolation from an app profile), a Bun or Node process, or any process by PID. It records everything on one clock, refuses measurements it cannot defend, and gives people and agents a verdict plus handles to drill into. This file is the source of truth. If an implementation idea conflicts with it, stop and ask. When a design change is agreed, update this file in the same change. --- ## 1. Rules (non-negotiable) 1. **Valid or nothing.** A number strobe cannot defend is not printed. Validators refuse a run (exit 4, §10.1); analysers warn next to the number (§10.2). Every number carries its unit, `n`, interval or `exact`, method, probe set, build and device. 2. **Measure the app, not strobe.** Low overhead first: no helper process where an OS API or a file read works; no polling where an event exists; budgets in §19 hold. strobe's own CPU, GPU and memory are a track in every recording. 3. **Timing and attribution never share a block.** Profilers, heap snapshots, allocation sampling, coverage, counters and instrumented builds change timing. They run in attribution blocks only (§8.1). A recording that mixes them is marked `mixed`, and its timing metrics are labelled `perturbed`. 4. **One primary metric per experiment**, declared before the run. Secondary metrics get a verdict only through the gate in §9.3; otherwise they are descriptive. 5. **No automated tests. Ever.** No unit, integration, e2e or snapshot tests, no test files, no test dependencies, no test steps in CI. Verification is the calibration page and A/A runs on real devices (§10.3, §10.4). CI builds and lints, and runs strobe's own action once on its known-answer page as a project would (§26.6): a smoke run, never a test suite. 6. **Early iteration speed beats everything else.** Smallest change that works. No speculative abstraction. 7. **One concept, one name.** Each MCP tool is exactly a CLI command: same name, same arguments, same JSON as `--json`. 8. **Raw, general tools.** Five MCP tools (§17). New questions become views or arguments, not new tools. 9. **Local only.** Recordings never leave the machine unless the user copies them. Traces and heap snapshots can hold prompts, file contents and credentials: they are written owner-only, reports and MCP results carry aggregates, and string contents appear only with `--reveal`. What ran beside the target is the bench machine's owner's business: no stored warning, note or refusal, `progress.log`, `query summary`, or `report` format (`pr`, `summary`, `md`, `html`, `card`, `badge`) names or gives the PID, window title or command line of a process outside the target's own; live interactive terminal progress may. `result.json`, recording `meta.json` and `query --json` are owner-only local files and keep local paths, `screen.compositor.name`, and the structured `plan.reserve.{moved,failed}`, `blocks[].background` and `insights..busiest` lists for local diagnosis and cross-block deduplication (`--format md` and `--format html` omit foreign process names too, while only `--format pr|summary|card|badge` and the GitHub Action's artifact scrubber also redact local paths). A measured phone's transport address, serial and device ID are also private. Stored diagnostics identify the selected phone by role, model, transport or its position in the attached list, not a hardware ID. The exact selector stays in local inventory, routing, calibration and owner-only metadata, not reports. 10. **Never break user space.** strobe writes only its own files and marked managed entries, all removed by `strobe leave`. It never changes a target's settings, profile or data. Launched targets get isolated homes; attached targets are left as they were on detach. 11. **One-time setup does every privileged step.** `strobe up` asks for the one UAC or sudo it needs. After that nothing prompts; `status` only reports problems. 12. **Rust stable, pragmatic dependencies.** Well-known crates are fine. Each dependency has a one-line reason in `Cargo.toml`. The budgets in §19 decide, not taste. No crate that needs a C compiler: every device builds with the plain Rust toolchain (adais has no C toolchain). One-time downloads shell out to `curl` (built into Windows 10+, macOS and Linux) instead of linking TLS. 13. **No backwards compatibility while pre-1.0.** Breaking changes are fine. No migration code. 14. **Normal speed only.** strobe never slows the target: no CPU throttling, no network throttling, no frame-rate caps in timing blocks. CPU throttling slows only the renderer's main thread, so the bottleneck moves where no real device has it. Small effects are found with more pairs, stress comes from bigger workloads (`params`), and slow hardware is measured on slow hardware (another device). The `Throttled` validator (§10.1) catches a slowed target from any cause. 15. **Generic: strobe knows no product it measures.** Apps under test are consumers. What one of them needs (its install path, env vars, seed files, the user state to guard, URL schemes it registers, how to build and serve it, its mocks and benches) lives in that app's own repo: `strobe.json` (`apps`, `build`, `serve`) and its bench files. Strobe has mechanisms, never a product's names. The only product-specific code is on the using side: registering strobe's MCP and skill with the agent harness that drives it (§5.3), as mesh does. `scripts/generic.ts` (the Check workflow) fails on a product's name in any file outside its explicit allowlist. ## 2. Invariants - Windows 10+, macOS 13+, or Linux (glibc or musl), x64 or arm64 (§20 lists the packages). - `gh` installed and authenticated for `report --update-pr` (§11.2). Install and update need no `gh`. - `opencode` installed for MCP and skill registration. Not needed to measure. - Chromium targets: an installed Chrome, Edge or Chromium, or Playwright's Chromium. strobe finds one; it never downloads a browser. - Firefox targets (`--engine firefox`, optional): a stock Firefox 148 or later from Mozilla (release, Developer Edition or Nightly; a distribution's package; Snap). An older one is refused before it launches (§13.1). Never Playwright's patched Firefox (§13.1). - WebKit targets (`--engine webkit`, optional): Safari or Safari Technology Preview with remote automation on (macOS), WebKitGTK's or WPE's WebDriver and MiniBrowser from the distribution (Linux). None on Windows; never Playwright's WebKit build (§13.1). - Deep mode (§13.4): one UAC (Windows) or one sudo (Linux) during `up`. Tools may be installed but not on PATH (desktop-app shells). strobe looks on PATH first, as the user's shell would, then in known install locations (`host::find_exe`). Only absolute PATH folders count (an empty entry or `.` would run a program in the current folder, an untrusted checkout's), and on Unix only a file with an execute bit. ## 3. Glossary | Term | Meaning | |---|---| | target | What is measured (§5.1) | | probe | One source of data: trace categories, CPU sampler, OS sampler, heap census, … (§8.1) | | probe set | Named group of probes: `timing`, `attribution`, `memory`, `deep` | | recording | Everything one capture produced, addressed by a handle `rec_…` | | experiment | One `bench` invocation, addressed by `exp_…` | | variant | One side of an experiment: a git ref, a directory, an installed app | | bench file | A `.bench.ts` file: targets, params, setup and cases (§9.6) | | case | One entry in a bench file's `cases`, with its `run` function | | series | One case × target × params combination: the unit that gets a verdict | | run | One call of a case's `run` (BenchmarkDotNet's iteration) | | phase | A named span inside a run, such as `down` | | block | One fresh launch of one variant: prepare, setup, warmup runs, measured runs, close | | pair | Two adjacent blocks of different variants; order alternates A B, B A | | timing / attribution block | A block with the `timing` probe set, or with profilers (§8.1) | | primary | The metric the experiment is about, declared before the run | | verdict | `faster`/`slower` (or `less`/`more`, `fewer`/`more`), `same`, `unclear`, `underpowered`, `refused`; with a margin, `no worse`/`worse` (§9.3) | | validator | A check that refuses a run (§10.1) | | analyser | A check that warns in the result (§10.2) | | census | A consistent per-process memory account at one moment (§8.6) | | bridge | `@hona/strobe-electron` inside an app's main process (§13.2; not built) | | accessibility tree | what an app exposes to the OS's assistive technology (UI Automation on Windows, the AX API on macOS, AT-SPI on Linux): strobe finds and waits on a native app's elements through it (§13.7) | | calibration | The known-cost page every probe must recover (§10.3) | | A/A | An experiment with the same build on both sides (§10.4) | | implementation comparison | An experiment whose two sides are different targets of one bench file: two implementations of one app (§9.10) | | journey | A case run on both implementations | | parity gap | A journey one implementation cannot run: listed, never run or counted (§9.10) | | fixture | A process both sides talk to (a server with seeded data), started per block outside both process sets (§9.10) | ## 4. Repo and files ### 4.1 Tool repo — `anomalyco/strobe` ``` Cargo.toml build.rs src/ one package: lib + bins strobe, strobew (Windows windowless); §4.3 viewer/ self-contained HTML report viewer (`viewer/report.html`), embedded calibration/ the known-cost page (static), embedded assets/fonts/ Inter Regular and SemiBold (Latin subset, SIL OFL 1.1, OFL.txt) for result cards, embedded sdk/bench/ TypeScript: @hona/strobe (the CLI launcher and the bench SDK, §20) npm/ the platform packages (@hona/strobe--, §20) and the @hona/strobe-bench alias skill/SKILL.md the built-in skill, embedded (include_str!) schema/ strobe.v1.json (project config), result.v1.json (experiment results); both shipped in @hona/strobe (`schema/`) packaging/ macOS entitlements scripts/ generic.ts (rule 15), doc-check.ts, release.ts, macos-sign.sh, ci-action.ts (the action's steps, §26); dev/ holds the dev benches and fixtures docs/ the README's images action.yml the GitHub Action (§26) .github/workflows/release.yml build + release on a v.. tag, then its GitHub Release (§20) and the action's v. tag (§26.5); no tests .github/workflows/check.yml clippy and build on every OS, scripts/generic.ts (rule 15) and doc-check.ts, and the action's dogfood run (§26.6) SPEC.md AGENTS.md README.md LICENSE (MIT) ``` ### 4.2 Local state (per device, never in a repo) State dir: `~/.local/share/strobe/` (respect `XDG_DATA_HOME`) on Linux and macOS; `%LOCALAPPDATA%\strobe\` on Windows. Created `0700` / user-only ACL; `run/` too. Secrets (tokens) are written `0600` from their creation (`host::paths::write_owner_only`). Every file strobe keeps is written whole or not at all: into a temporary file beside it, flushed to disk, then renamed over it (`host::paths::write_atomic`; on Windows a rename that a scanner holds up is tried again for about 2.5 s). A file written in pieces (a trace streamed from the target, a heap snapshot) goes the same way (`host::paths::Atomic`, through `store::create`, `0600` like a secret: it holds the target's data verbatim): an error midway leaves no file. A recording that fails or is refused before its `meta.json` leaves no folder (`store::Draft`). Random ids and tokens come from the OS's random source (`host::paths::csprng_hex`). ``` state.json { "mcp_port", "helper", "engine", "last_calibration" } mcp-token MCP bearer token, user-only experiments// result.json per experiment; index.jsonl lists them (§14.2) recordings// §14.1 history/ tracked series per device, schedules (§25.6) builds// variant build cache (§9.5) seeds// shared copies of large seed files (§13.2) gpu-cache// GPU shader caches of warm launches (§13.2) sdk// the bench SDK the runner imports, one folder per strobe build (`STROBE_BUILD_ID`); each use touches its `.used`; other builds' folders are caches (§5.14) symbols/ source maps by debug ID, PDB cache engine// trace_processor (§15), sha256-checked run/ log/ temp files; daemon.log; helper.log, left-alone.log (rotated at 1 MB) .used a cache entry's stamp: when strobe last used it, locked while a process does (§5.14) disk-cache.json the last walk of each recording, experiment and large build (§5.14) prune.json what the last automatic prune removed, and when (§5.14) cycle-clock.json the cycle clock's check, once per boot (§13.6) native-speed.json the native speed check's references per CPU mask (§10.1 `Throttled`) phones.json adb/ Chrome channels given to strobe per phone; adb journals (§13.1) ``` Installed binary: `~/.local/bin/strobe` (Linux/macOS); `%LOCALAPPDATA%\Programs\strobe\{strobe,strobew}.exe` (Windows, on user PATH). The deep-mode helper lives where only an elevated step can write (§13.4). **macOS signing.** macOS keeps a privacy grant (Accessibility, called Device Control and Data Access on macOS 27, and Screen Recording) for a binary's designated requirement. An ad-hoc signature's requirement is its hash, which every release changes, so each update silently dropped the grants. The Release workflow signs each macOS binary with a Developer ID Application certificate under the hardened runtime (`packaging/macos/strobe.entitlements`: no exceptions), identifier `dev.hona.strobe`, and notarizes it (`scripts/macos-sign.sh`), so the requirement is the identifier and team and the grants survive updates. It needs five repository secrets: `MACOS_CERTIFICATE_P12` (the certificate with its private key as a `.p12`, base64), `MACOS_CERTIFICATE_PASSWORD`, `MACOS_NOTARY_KEY_P8` (an App Store Connect API key's `.p8` contents), `MACOS_NOTARY_KEY_ID` and `MACOS_NOTARY_ISSUER_ID`. Without them the binaries ship ad-hoc signed and a tag build warns. A bare executable cannot carry a stapled ticket: Gatekeeper looks it up online at its first run. **Windows metadata.** Defender's machine-learning classifier quarantined unsigned builds as `Trojan:Win32/Bearfoos.A!ml`: strobe does what droppers do (copies itself into Program Files with a scheduled task, elevates once, suspends threads and reads other processes' memory, sets their affinity, replaces its own executable). Each executable carries a version resource (CompanyName, FileDescription, FileVersion, InternalName, LegalCopyright, OriginalFilename, ProductName, ProductVersion from Cargo.toml) and an application manifest (`asInvoker`, Windows 10 and 11, no DPI awareness: strobe sets that per thread). `build.rs` writes both as a COFF `.rsrc` object and links it: no resource compiler. The binaries are not Authenticode-signed yet. **Inherited and own grants.** macOS attributes a process's permissions to its responsible process. On macOS 27 a child of a launchd daemon (a remote shell's, such as mesh) is AX-trusted through the daemon, but Screen Recording is checked against the child's own binary: ScreenCaptureKit answered -3801 ("user declined TCCs") with the daemon granted. `strobe status` says whose grant each permission is (inherited from the process it names, or strobe's own) and, for that split, names strobe's own executable (symlinks resolved) as the one to add in Screen & System Audio Recording. ### 4.3 Architecture (`src/`) ``` lib.rs module declarations only main.rs bin `strobe`: parse args → app → exit code bin/strobew.rs Windows windowless build (daemon autostart) cli.rs the flag table (`FLAGS`), argument parsing, durations, help (`HELP`) model.rs plain data: errors, `ValidatorId` and `Refusal`, verdict kinds, the metric catalog config.rs strobe.json, read strictly (jsonc.rs: comments and trailing commas) app/ commands: up/ (autostart), status, record, bench/ (blocks, launches, batches, cli, script, tty and native targets, fixtures, journeys, the slot, the decision loop in sequential.rs), plan, query/ (recording views, a..b diffs, actions, waits, http, wir), report, pr/ (the PR block), card/ (cards, badges, forest plots), open, look, top, track/, history/, bisect, family (implementation comparisons), run, update, mcp, statcheck/ (hidden `strobe stats`), outside (record pid:), video, helper, prune (`strobe prune` and status's disk numbers, §5.14) target/ finding and launching targets → chromium.rs and cdp.rs (CDP), firefox.rs and bidi.rs (WebDriver BiDi), webkit.rs and webdriver.rs (WebDriver Classic), web.rs (a page in any engine: the WebBrowser and WebPage enums every page caller drives), wir.rs (an open Safari tab or WKWebView on a USB-cabled iPhone over Web Inspector, §13.1), isolate.rs (isolated launches), electron.rs, command.rs, android_chrome.rs (Chrome on a phone over adb, §13.1), webview.rs (apps on the OS's webview, §13.8), native.rs (native apps, §13.7), wayland.rs (strobe's headless compositor, §13.7), motion.rs, a11y/ (the OS accessibility client: uia.rs, ax.rs, atspi.rs; input: input.rs (posted messages), cg_input.rs (Quartz), xtest.rs, wl_input.rs, linux_input.rs; keys.rs shared), tty.rs (terminal programs on strobe's own pseudo-terminal and grid, §13.9; tty/image.rs draws the grid), events.rs (an app's STROBE_EVENTS stream) driver.rs the bench file's `page` over CDP, BiDi or WebDriver, and the page probe (`HELPER`); driver/ native.rs (a native app's accessibility tree and input), os.rs (OS input to pages), touch.rs (an emulated phone's touches), pixels.rs (image anchors), window.rs, tty.rs probe/ collecting → trace.rs, cpu.rs, alloc.rs, heap.rs, symbols.rs and symbols/ (source maps), census.rs, memory.rs, gecko.rs (Firefox's profiler and about:memory), webkit_inspector.rs (WebKit Inspector Timeline, ScriptProfiler, Heap v3 → V8 heapsnapshot, Memory and Network over wir), shifts.rs, os.rs (the OS sampler), gpu.rs, gpu_linux.rs, gpu_macos.rs, wakes.rs (wakes and counters, §7.5), stacks.rs (native stacks: `Deep`, one sampler per OS: kernel_profile.rs in the helper, suspend.rs on Windows, perf.rs on Linux, sample.rs on macOS), etw.rs (ETW sessions), helper.rs (the deep-mode helper, §13.4), present/ (what reached the screen, §7.2: windows.rs ETW present events, sck.rs ScreenCaptureKit, xpresent.rs X11 Present, wayland.rs the compositor's own, frames.rs frame metrics), video/ (`--video`, §8.8: wgc.rs Windows, avf.rs macOS, x11.rs Linux, screencast.rs a Chromium page's renderer, ffmpeg.rs the external encoder), http_probe.rs (the HTTP probe, §8.9). The helper runs present and stack sessions, and both use its access, so they live in one backend. heapsnap.rs V8 heap snapshots: streaming parse, dominators, classes, retainer paths, diff, leaks metrics.rs a block's trace → metric values perfetto.rs `strobe open`'s merged trace (§11.4) engine.rs trace_processor: the only place that runs it (§15) store.rs experiments/ and recordings/: the only place that writes them (§14); store/ the action log and the rerun ledger history/ tracked series (the only writer of history/) and change-point detection (§25) runner.rs the bench runner link: spawns `bun`, speaks the runner protocol (§9.6) stats.rs paired tests, intervals, modes, power, multiplicity (§9.3) validity.rs validators, analysers, calibration (§10) symbol/ native symbols: DWARF and PDB (§12) fmt.rs font.rs image.rs png.rs numbers, the embedded font, drawn images (cards, badges, terminal snapshots) host/ paths, processes (spawn.rs, jobs.rs, cgroup.rs), the cycle clock, power plans, CPU reservation, captures, git, builds, X11, Wayland, D-Bus, iOS devices (ios/), Android devices (adb/), disk.rs (sizes on the disk and removal that follows no link, §5.14), cache.rs (cache entries' leases and stamps, §5.14) ``` - `cli`, `mcp` → `app` → everything else. Nothing outside `app/`, `cli` and `main` names `app` or `cli` (shared helpers such as `fmt.rs` live below `app`, which re-exports them). Backends (`target`, `probe`, `engine`, `store`, `symbol`) use each other only through their public entry points, never their internals: a probe drives the page through `target`'s CDP, writes through `store`, and reads the trace it just took through `engine::query` while the page is still open (a census's allocators, a layout shift's moved elements: the rows name nodes only the live page can resolve). Only `engine` runs trace_processor; only `store` writes `experiments/` and `recordings/` (`store::write_atomic`, `write_json`, `create` for streamed files, `Draft`). They return `model` types. - One backend each until a second exists. No traits or plugin layers ahead of need. - Idiomatic Rust: `pub(crate)` by default, `cargo fmt`, `cargo clippy` clean. ## 5. CLI ``` strobe [flags] Setup init · up · update · leave · prune (§5.14) Use status · record · bench · plan · run (§5.13) · config · query · report · open (§11.4) · look (§5.12) · top · track · history (§25) Internal daemon · mcp · helper · stats (hidden, §5.11) Global --json -q/--quiet -v/--verbose -h/--help -V/--version --progress stderr|stdout|off Times --unit us|ms|s (commands that print times; "unit" in strobe.json; default us) ``` `strobe --help` prints that command's lines of the help and every flag it reads, from the flag table; `strobe --help` (or a command the help does not name) prints all of it, ending with where the result schema is (`schema/result.v1.json`, also in the `@hona/strobe` package's `schema/`). `-q` turns progress off, and stdout then holds only the handles a command made (`rec_…`, `exp_…`, one a line), or `report --format md|pr|summary`'s markdown, which is that command's output. A command that made one and did not succeed writes why on stderr (what halted it, else its `FAIL` lines) and keeps its exit code. `--json` prints the result's JSON on stdout instead (the shape of `result.json`, §14.2, for `bench`). A flag that takes a list (`--filter`, `--target`, `--params`, `--metrics`, `--noworse`, `--skip`) may be given more than once: the lists add up (`--filter a --filter b` is `--filter a,b`). Any other flag given twice with another value is a usage error (exit 2); the last one never wins silently. Sizes print in binary units: KB, MB and GB are 2^10, 2^20 and 2^30 bytes (§7.1). Times print in microseconds, digits grouped (`1,234 µs`); `--unit ms|s` or `"unit"` in strobe.json picks another unit, for the text only (§7.1 "Times"). `strobe config []` checks a project's strobe.json without a bench, strictly read-only (it runs before the power plan and CPU-set restores every other command does, and makes no state folder): the strict read every command does (§9.5), then every app profile resolved for this OS as a launch resolves it (its executable, its guards and `moved` folders, whose variables must be set: a profile that cannot be used here is a usage error naming it), its kind, the file its executable resolves to or `not there yet` (a relative path from the project root, the folder of the nearest strobe.json, a bare name else from `PATH`: §13.2) and `window: false`, and the build and serve commands for this OS with the folders they run in: those are from the checkout's root (§9.5), whichever folder of it holds strobe.json; a `build.output` given per OS without this OS is a problem. It launches nothing. `strobe init [] [--target web|lib|server|native|electron|tty|cli] [--force]` starts a project's first bench from what its own files say, and names no product (rule 15): it reads generic markers only, package.json's `scripts` (`build`, `preview`, `start`, `dev`), `exports`, `module`, `types` (`typings`), `main`, `bin` and an `electron` dependency, a Cargo.toml's `[[bin]]` names (else `[package]` `name` with `src/main.rs`), and an `index.html`. A script runs a file when its first word after a JavaScript runtime (`bun`, `node`, `tsx`, `ts-node`, `deno`) that is no flag, `run` or `watch` is a code file (`.ts`, `.js`, `.mjs`, `.cjs`, `.mts`, `.cts`, `.tsx`, `.jsx`; never a `.d.ts`): `tsc --watch`, `next start` and `bun run build` run none. A package says what a library says with `exports`, `module`, `types` or `typings`, or a `main` that no `start` script (without one, no `dev` script) runs and no `server.{ts,js,mjs,cjs}` in the folder or its `src` sits beside. Without `--target` it guesses (`Project::guess`): an Electron dependency is `electron`; a page (an index.html in the folder or its `public`, `src`, `app` or `static`, or a `preview` script) `web`; a `bin` `cli`; a server (a `server.*` file, or a `start` script, else a `dev` script, that runs a file in a package with nothing a library says) `server`; what a library says with an entry `lib`; a build script alone `web`; a package with no `dev` or `start` script whose entry is there `lib`; a Cargo binary `cli` (a binary that opens a window is `--target native`, one in a terminal `tty`); a folder with none of them is a usage error that names the flag. A library's entry is what `exports` names for a runtime (a path without `*`, an array's first, a subpath map's `.` else its first other subpath but `./package.json`, a condition map's `bun`, `import`, `module`, `node`, `default` or `require`, never `types`), else `module`, else `main`, each a code file that is there or that the build script makes; else `src/index`, `src/lib` or `src/mod` (`.ts`, `.mts`, `.js`, `.mjs`) or `index` or `lib` (`.ts`, `.js`, `.mjs`) that is there. A path the package names that leaves its folder (absolute, or with `..`) is never used. It writes `strobe.json` with its `$schema` (the path to the `@hona/strobe` package's `schema/strobe.v1.json` in the folder's or an ancestor's `node_modules`, else `./node_modules/…`) and `perf/smoke.bench.ts`, a bench that passes `--check` as written, with the helper it needs: - **web:** `build` from the build script (run with the package manager the lockfile names, Bun without one) and `serve` `bun perf/serve.ts {port}`: a static server of the first of `dist`, `build`, `out` and `public` that holds an index.html (else the folder; a path without a file gets index.html), with a comment naming the project's own `preview` or `start` server as the other choice (never a dev server). Cases: a launch to `body *` (`launch.ready`) and an idle second in the open page (`main.work`), with a commented reload (`main.work` of `page.goto` and `waitFor`). The reload is not live: strobe's driver and the runner spend about 3 ms of CPU on each load (adais, pinned: 2.8–4.6 ms), so on a small page (a 30 ms load) it warned `OverheadBudget` on its first check, at 12–17% of a core; more reloads a run did not lower the share (5 and 10 a run: 12.7% and 14.1%), and the idle second reads 3% (strobe 2.7%, runner 0.4%). On vps (unpinned, budget 5%) the reload read 12.2–16.7% and the idle second 1.1%; the web, lib, server, cli and tty starters' first checks there warn nothing of strobe's own. - **lib:** `perf/lib.ts` imports the entry (`import * as lib from "../"`) and exports how many names it has. Target `cold` is `cli("bun /perf/lib.ts")`, a fresh process per run; target `lib` is `script("./lib.ts")`; both take the build script as their `build`. Case `import` (on `cold`): `run.duration` with `cpu.work` and `memory.peak` of a cold import. A commented `work` case (on `lib`: `cpu.work` with `run.duration` and `js.heap.retained`, `ctx.call("work")`) calls a commented `work()` in perf/lib.ts; edited to loop an export over realistic input for 20-50 ms (so the relay stays out of the run), its attribution profile names the library's functions. init never calls an export itself: their arguments and side effects are the project's. Both pass `--check` as written and with `work` uncommented (the placeholder `work` is tiny, so overhead warnings fire until you edit it), and `tsc --noEmit` with `noUnusedLocals` for a source entry. - **server:** a `script("./server.ts")` harness whose `start()` sets `PORT` to a free port, imports the entry (the file the `start` script, else the `dev` script, runs, a `server.*` file, `main`, `module`, else `src/index` and the like; a stand-in that answers when there is none) and waits up to 5 s until it answers: its error names `strobe init --target lib --force`, for a library guessed a server; setup probes it (`ctx.httpProbe`, any status below 500) and the case makes 1000 requests from four clients 10 ms apart from the runner: `cpu.main` with `http.latency.p95` and `http.timeouts`. - **native:** `build` `cargo build --release` into `{cache}/cargo-target` with the line-table, no-strip release settings (§9.5) and an `apps` profile of kind `native` whose `exe` is the folder's own `target/release` build; the case leaves the app open for a second (`cpu.work`, `cpu.wakes`). - **electron:** `command("bunx electron . --remote-debugging-port={port}")` with the install (and build) as its `build`; an idle second (`main.work`, `cpu.work`). - **tty:** the package's `bin` (with the runtime its first line names, Bun otherwise), else the Cargo binary (built into the folder's `target`), else the `start` script, on strobe's terminal; an idle second (`cpu.work`, `cpu.wakes`, `tty.updates`). - **cli:** the same program with `--help`, run to its exit (`run.duration`, `cpu.work`, `memory.peak`). - tty and cli of a package with a build script take it as the target's `build` (a program that is its build's output, TypeScript to `dist/`), run once per variant before its first block. A `--target cli` or `tty` with no program to run says, for a JS/TS library, `strobe init --target lib`. A folder below its checkout's root gets every path from the root (`build.cwd`, `serve.cwd`, a command's path), as a folder variant builds there (§9.5). A file that is there is kept (`kept`), never replaced without `--force`. Nothing is written through a symbolic link: `perf` as a link is a usage error, a file that is a link (a broken one too) is kept, and with `--force` refused before anything is written. A folder inside strobe's own state folder (whose `strobe.json` is the user's config for every repository) or install folder is refused. It then prints the commands to run from the folder: `strobe config`, `strobe bench perf/smoke.bench.ts --head . --check` (with `--headless` for a web bench where no page can show: Linux without a display, a locked session) and a timed comparison against the branch the checkout's `origin/HEAD` names, else `main` or `master`, else `--aa` outside git; `bunx strobe` where the folder resolves the package. Like `config` it runs before the restores other commands do and writes nothing of strobe's own; it builds and launches nothing. JSON: `dir`, `root`, `target`, `what`, `wrote`, `kept`, `schema`, `headless`, `next`; a kept strobe.json that differs from what init would write gets a note naming the keys the bench needs from it. Checked (2026-11-10), each `config` then `--check` passing as written: adais (Windows 11) web (`--headless`), server, cli and tty, each as its own folder and as a package below a monorepo's git root; vps (2 vCPU) web (`--headless`), server, cli, tty and native (an X11 window on Xvfb that sets `_NET_WM_PID`, as toolkits do). vps from an empty folder holding a three-file web app, on a cold Bun cache: `bun add -d @hona/strobe` 1.5 s, `init` and `config` under 0.1 s each, the `--check` 7.5 s (its build, the server and both cases; Chromium was installed): about 10 s of commands from install to a passing check. Checked (2026-11-10, W5-4) on adais (headless) and vps, each `--check` passing as written: a standalone TypeScript library (`exports` `.` → `src/index.ts`) guessed `lib`, and with `work` calling its `render` 500 times `--check`, `--aa --pairs 6` (no `ObserverCost`) and `record --bench .#work`, whose hotspots name `inline`, `render` and `escape`; a monorepo package (`main` `./dist/index.cjs`, `module` `./dist/index.js`, `types`, `exports` `{ ".": { types, import, require } }`, a `bun build` build, `dev` `--watch`, `start` `tsc --noEmit`, `dist` ignored) guessed `lib` on `dist/index.js` with build `cd packages/markdown && bun run build`, its `--check` and `--base main --head . --pairs 2` with `perf/` uncommitted (the harness overlaid on base) passing; the same library as `--target server` failing in 5.1 s (vps; 8.8 s on adais) with the `--target lib --force` hint, which then passes; a server (`start` `bun src/index.ts`, `main`) and a Bun server with only `dev` (`bun run --hot src/index.ts`) still `server` and passing; a framework app (`dev`, `build`, `start` `next start`) still `web`; a copy of a real monorepo markdown package (`exports` `./src/index.ts` only), before guessed `server` and failing its check, now `lib` and passing. On Linux the cold import's `memory.peak` reads `-` in timed blocks (`NotOnThisOs`, as for every `cli()`). Not run: an Electron project (a headed app; as root on Linux it also needs `--no-sandbox`). Every flag strobe reads is in one table (`cli::FLAGS`, with whether it takes a value and which commands read it); any other flag is a usage error (exit 2) that names the closest one, never a setting silently ignored, and so is an unknown short flag (`-x`; a negative number stays an argument). A flag strobe has that the command does not read is a usage error too (exit 2, `status has no flag --runs (record, bench, plan, track and run take it)`), checked before the command runs, from the CLI and MCP alike (`Args::check_command`): `look --engine firefox` once ran Chromium for 43 s, and `status --runs 5` read as nothing. A command that passes flags on to another lists them as its own: `plan --pilot` runs a bench with plan's flags (so plan takes `--target`, `--params`, `--primary`, `--pin`, `--power`, `--reserve-cpus`, `--trace`, `--style`), `run --base/--head` and `record --bench` run a bench with theirs. One flag a command reads only to refuse it in its own typed way is exempt: `look --engine` (anything but `chromium` is `EngineUnsupported`, exit 4). Internal flags no command takes from the command line (`--gpu-store`, which `record --bench` sets) are refused for every command. A schedule an older strobe saved with a flag `track` never read runs without it, as before. Everything after `--` is no flag of strobe's (`run -- `, `look exe: -- `). A switch takes `--name`, `--name=true` or `--name=false` (off), nothing else. MCP tool schemas take their types from the same table (§17). Durations take `500ms`, `10s`, `2m`, `2h`, `1d` (and a bare `0`); a negative or non-finite one is refused, and so is a number without its unit (exit 2, `not a duration: 2000; give a unit: 2000ms or 2000s`): `--for 2000` meant as milliseconds once recorded for 33 minutes. Progress lines of long commands go to stderr (stdout stays the result); `--progress stdout` (or `STROBE_PROGRESS=stdout`) puts them on stdout, `off` drops them; with `--json` stdout never gets them. Windows PowerShell 5.1 turns every stderr line of a native command it redirects (`2>&1`, `*>`) into an error record and sets `$?` to false, and `powershell -Command` then exits 1 although strobe exited 0: read strobe's own code from `$LASTEXITCODE`, or use `--progress stdout`. Checked on adais: a successful `record --bench` exits 0 (`$LASTEXITCODE`), and its shell reported 1 only with `2>&1`. **Clean-up.** strobe ends what it launched: each block's app, browser, script host or command with everything it started (a command that leaves processes running 100 ms after it exits fails its run, and strobe ends them), every launch a stopped command still owns (Ctrl-C, SIGTERM, `--stop`: each one's tree, then its group or job), what still runs stamped with a launch's isolated home, and a home strobe made. A strobe that died leaves its launches behind; the next `bench`, `record` or `look` ends those stamped with a dead strobe's home (`reap_dead_owners`). strobe signals a process only while it holds proof the process is one it launched: its pid with the start time read when strobe identified it, and strobe's stamp (`STROBE_HOME`, `STROBE_OWNER`) wherever its environment is readable (`src/host/kill.rs` lists every path and its proof; on Windows the check goes through a held handle, on Linux through a pidfd where the kernel has them); a process group only when strobe created it. What it cannot prove it leaves alone, and says so (0.33.0 and later): - every command (a stop's exit 130 too) ends with `strobe left alone what it could not prove its own:` and a line per such process (`: `), on stderr under `-q`; the PID is live only; - `--json` and MCP results carry `left_alone` (`[{ pid, what, why }]`, `schema/result.v1.json`; absent when empty, never null), errors too; the PID of an unverified process is null; - every strobe process (CLI, daemon, `strobe mcp`) writes each note the moment it is made to `log/left-alone.log` in the state folder (seconds since 1970 and fixed words; rotated to `left-alone.log.old` at 1 MB); a note made again in one invocation is written once; - `strobe status` says when the log holds notes from the last day (`i left N process(es) strobe could not prove its own were left alone in the last day: `; `left_alone_last_day` in its JSON). What is left behind is a leftover a person may end; strobe never ends a process it cannot prove its own (a reused pid, another strobe's, a person's copy of the app). ### 5.1 Targets | Syntax | Meaning | |---|---| | `https://…`, `http://…` | a page in a strobe-launched Chromium (or `--engine firefox`: Firefox; `--engine webkit`: Safari, WebKitGTK or WPE): isolated profile, real GPU, fixed window (§13.1) | | `cdp:`, `cdp:ws://…` | a running CDP endpoint (Playwright's, a dev Electron's) | | `app:` | an app by its profile in strobe.json `apps`: an Electron app (§13.2) launched in an isolated home (attaching through a bridge, §13.3, is not built); a profile with `kind: "native"` is a native app (§13.7), one with `kind: "webview"` an app on the OS's webview (§13.8) | | `exe:` | launch a packaged Electron app in an isolated home; `--app ` borrows a profile for another build of that app; `--native` launches any executable as a native app (§13.7) | | `pid:`, `proc:` | outside-in only (`record`): OS, GPU, OS present events, deep (§13.6). `proc:` takes the one process of that name whose parent is not of that name | | `page.html`, `file:page.html` | a local page (`.html`, `.htm`, `.xhtml`, `.svg` that exists) in a strobe-launched Chromium at its `file://` address (the path's `%`, spaces, `?` and `#` escaped), as `https://…`; any other `file:` is an import | | `file:` | import an existing capture: a Chrome trace, `.cpuprofile`, `.heapsnapshot`, or a folder of them with a `manifest.json` (§8.7) | | `wir:[]` | an open Safari tab or WKWebView on a USB-cabled iPhone over Web Inspector, read-only (`record`, `look`; §13.1) | | `tty:` | a terminal program on strobe's own terminal (`look` only; benches use `tty()` targets, §13.9) | Not built: `node:` and `bun:` (an inspector endpoint, §13.5: a Node inspector on a `cdp:` port is refused with a pointer to `script()` and `pid:`), `@` (a mesh device, §9.8) and `ring:` (§5.5). ### 5.2 Exit codes | Code | Meaning | |---|---| | 0 | normal completion; not a claim that head won or that every series was compared | | 1 | general error; a `--check` case that failed | | 2 | usage error | | 3 | needs a human (UAC, consent in the app, Safari's remote automation, a `prune` with no terminal to confirm on and no `--force`); the exact step is printed | | 4 | refused; also a required parity gap, or a counter check that ran but held nothing to base | | 5 | inconclusive (`unclear` or `underpowered`), only with `--require-verdict` | | 124 | timeout (also a bench whose `--unlock-wait` ran out: what finished is written, `--resume` goes on) | | 130 | `bench` stopped (Ctrl-C, SIGINT, SIGTERM or `--stop`): what finished is written, `--resume` goes on | | 255 | target or device unreachable | **How a bench ends.** For a timed comparison (including `strobe run --base … --head …`), read `series[].primary.verdict_kind` for the outcome; slower is not an error. A check asks whether the workload runs, not whether it is faster. For `--check --noworse`, **check the process exit code**: `result.json`'s `passed` is true only when a case ran and the check completed successfully. A whole-machine refusal before any series can exit 4 without a result. A check of refused cases writes their rows even when none ran. A metric affected by a series-level `Background`, `Drift` or `StateDrift` warning carries `warned: true`. An informational note does not set it. Under `--require-verdict`, a warned `same` or `no_worse` primary needs confirmation (5); the normal exit is still 0. A directional verdict keeps its direction. The background check needs at least eight clean pairs to compare their verdict with the full set, independent of `--pairs`. | Run | Ending | What it prints | Exit | |---|---|---|---| | `--check` (one build) | every case passes | `pass `; `N of N cases passed (…; nothing compared)` | 0 | | | a case fails: a run threw or timed out, its primary or `until` gave nothing, a refused primary in its block | `FAIL `, `fix: …`; `M of N cases passed` | 1 | | | a primary refused for the target, engine or display, before or during its run | `refused : `; the rest of the file runs | 0 beside passing cases; 4 if every non-gap case was refused, or any with `--require-verdict` | | `--check --runs N` or `--check --repeat R` | N runs in one launch, or R fresh launches; still no timing verdict | median and `[lowest … highest run]`, or median and each launch's value | as `--check`; use `--repeat` to repeat a launch case. With `--why always`, `--runs` applies to attribution instead | | `--check --base ` | both sides run every case | head's line, `base · rec ` under it; `…; base and head each checked; nothing compared: the same command without --check times head against base` | 0 | | | base cannot build | build error; the check stops before the case runs on either side | 1 | | | base built but failed the case | `FAIL`, `base failed: `, `K failed on base`; if head passed, `so head was checked against nothing` and head's values; if head failed too, head's own error | 1 | | `--check --base --noworse ` | at least one counter compared, no row or case failed | `noworse count.items 3 → 3 · no worse (≤0.5) (exact)`; `…; only the --noworse counters compared, exactly` | 0 | | | a row `worse`, `unstable`, `missing` or `not_compared` | `! noworse `, `FAIL --noworse: …` | 1 | | | cases ran, none failed, no counter compared | `nothing was compared (exit 4)`; `! Uncounted` for a counter not counted anywhere, or `every --noworse counter was refused on both sides (the refused lines say why)` without that warning | 4 | | `--check --batch N` | eligible run cases share a launch on one build; a failed case ends its launch | case lines, then `batch cases in launches (--batch N)` for batched cases | as `--check`; three consecutive batch launches failing before a case runs stop the check (1); with `--base` or `--repeat`: 2 | | `--check --base --head ` | check two bare target names on the current build; no timed comparison or `--noworse` | one line per case and target; parity matrix for shared journeys; `gap` for an unsupported journey; `refused this journey` if the two runs disagree | 0 with gaps alone; 4 with `--require-parity`; 1 if a case fails or the journeys disagree; adding `--noworse` exits 2 | | `--base ` (timed), `--aa`, `strobe run --base … --head …` | every series decides | the verdict table, `★` primary first | 0, also for `slower`, `more`, `worse` | | | a series has no verdict | `unclear`, `underpowered` | 0; 5 with `--require-verdict` | | | a series refused before or during timing | `not run: : ` or `refused ()` | 0 beside a series whose primary was not refused, even if undecided; 4 when every non-gap primary is refused, or any with `--require-verdict` | | implementation comparison (`--base --head `) | a journey one side cannot run | `gap` in the matrix | 0; 4 with `--require-parity` or `--require-verdict` | | `--blocks N` | one build, nothing compared | `★ median [ … ]`, or the single block's value | 0; 4 when every series is refused | | `--probes memory` | growth per run on each variant, not an A/B | `leaks`, `grows, then levels off`, `no growth` or `unclear` | 0 even for a leak (`worse`); 5 for an undecided primary with `--require-verdict`; 4 for every primary refused (or any with `--require-verdict`) | | `--pixels-only --base ` | compare the snapshots taken | `same`, `differ`, `only base` or `only head` | 0 if all snapshot rows are `same` (including no rows); 1 if a snapshot differs or exists on only one side | | `--bisect ..` | search one series for the commit that moved it | `found: `, `no change` or `unclear` | 0 for a completed search; 5 for `outcome: unclear` with `--require-verdict` | | timed comparison, `--max-time` reached in the series loop | keep finished pairs and verdicts | `max time` for a cut series; `MaxTime` lists series that had no time for a pair | normal comparison rules: 0, or 5 if undecided with `--require-verdict`; not a timeout exit | | deadline before the first series in the display-fit slot wait; non-comparison mode out of time; `--unlock-wait` exhausted | wait ended, or work left unfinished | timeout reason; `stopped` and work kept if the bench got that far | 124 | | Ctrl-C, SIGTERM or `bench --stop ` | the running bench stops | what finished; `stopped` | 130 for the stopped run, not for the command requesting the stop | | `bench --resume ` | resume the saved command with its original flags and completed work | `finished before the resume: kept` for reused series; then that mode's result | same exit rules as the saved mode; no saved state or still running: 2 | On a check, case failures (1) take precedence over missing counter comparisons or parity gaps (4). If every entry is a parity gap, `--check --noworse` compared nothing: exit 4. Without a counter gate a parity inventory can complete with gaps. Either `--require-parity` or `--require-verdict` makes any gap exit 4. When `Uncounted` says a check compared nothing (exit 4), check each name against the runs' `ctx.count("…")` calls first: the `Uncounted` note offers a name the bench file counts one typo away. When a run case counts the counter only on some paths, declare it in that case's metrics (`metrics: ["count.fallbacks"]`): a declared counter reads 0 when it never fires, so 0 on both sides compares as no worse. §5.6 "A check against base" has every rule. ### 5.3 `strobe up` Idempotent; re-running is the repair command. ``` strobe up [--skip self,deep,daemon,mcp,skill,calibrate] strobe up --check strobe up --mobile[=] --channel canary|dev|beta ``` `--skip` names steps below (`helper` is `deep`'s old name); any other name is a usage error (exit 2). `self` (the binary into the install folder and that folder onto the user's PATH) and `skill` are for a copy that must not install itself, such as a CI job's (§26.3): `up --skip self,skill,daemon,mcp,deep,calibrate` only fetches the engine into the state folder and changes nothing else in the user's profile (no binary, PATH entry, autostart, daemon, MCP registration or skill). `self` needs `daemon` too: the daemon runs only from the install folder. `up --check` prints the preflight lines (cpu, power, browser, Firefox, WebKit, phones, Bun) and stops: it installs, starts and registers nothing. `up --mobile --channel` does only this: it gives strobe that Chrome channel on the phone (installed; kept in `/phones.json` by the phone's hardware serial), the one step before `--mobile` runs on a phone may force-stop it and clear the origins they load (13.1 "Chrome on a phone"). Never Chrome itself (`--channel` takes no `stable`); one not installed is exit 3. Steps (each checks first, prints ✓ / ! / ✗): 1. **Preflight** — OS, CPU and topology (CCDs, V-Cache by L3 size, P/E cores), power plan, GPUs (adapters, NVML), displays and refresh rates, `opencode`, browsers, installed apps. 2. **Binary and engine** — copy this build into the install folder and put that folder on the user's PATH (`--skip self`: neither; the line names where this copy runs from; a copy under another name than `strobe` is a usage error, since it once set the installed copy aside and installed nothing while saying `replaced`); download the pinned trace_processor (§15) into the state folder, verify sha256. 3. **Helper** — deep mode (§13.4): one UAC or sudo. Skippable (`--skip deep`). On Windows the one elevated step adds the user to Performance Log Users (present events in strobe's own process from the next logon) and installs the helper (present events until then, and native stacks). 4. **Daemon** — autostart registered, this build's daemon running, started through that service manager where there is one (§16). The line says how it starts at login, or why it does not. 5. **MCP** — register `strobe` in opencode's global config and make it live in every loaded location, exactly as mesh does (`opencode mcp add`, then `opencode api experimental.mcp.add`), with the token read from `{file:/mcp-token}`. 6. **Skill** — write `~/.config/opencode/skills/strobe/SKILL.md` (strobe-owned folder, §18; `--skip skill` leaves it). 7. **Calibrate** — the quick calibration (§10.3) in a strobe-launched Chromium. ``` $ strobe up strobe up · adais ✓ cpu AMD Ryzen 9 9950X3D 16-Core Processor · 32 logical · CCD0 96 MB L3 (16 CPUs, V-Cache), CCD1 32 MB L3 (16 CPUs) i power Balanced (benches and timed recordings switch to High performance for the run, then back) ✓ gpu RTX 5070 Ti · NVML ok · D3DKMT ok ✓ displays 2 · 144 Hz, 60 Hz ✓ targets Chrome · 2 app profiles installed ✓ engine trace_processor · sha256 … ok · ! helper deep mode needs admin: approve the UAC prompt ✓ daemon running · starts at login ✓ mcp registered "strobe" · live in 6 opencode locations ✓ skill ~/.config/opencode/skills/strobe/SKILL.md ✓ calibrate speed check 4,100 µs · display 144 Hz · 4/4 cases within tolerance ``` The cpu line's shape (`Topology::cpu_shape`, the same in `status`): several L3 groups are named `CCD MB L3 ( CPUs[, V-Cache])`; one L3 group is no CCD (an Intel CPU, a single-CCD Ryzen): `24 MB L3`; a hybrid CPU adds its kinds of core, `6 fast and 8 slower cores` (`1 slower core`). JSON `topology.groups` is unchanged. A Firefox older than 148 is an `i` line, `(needs Firefox 148+ for --engine firefox)`, as in `status`. ### 5.4 `strobe status` ``` strobe status [--calibrate] [--fast] [--disk] [--json] ``` It checks this device and takes no target (one is a usage error pointing at `look`). Reports: environment (§9.4 provenance fields), a validity preflight (idle load per core for 3 s, power plan, battery, throttling, displays and the adapter driving each), daemon, helper, engine, attachable targets (running apps, CDP endpoints found through `DevToolsActivePort` files, installed apps), the disk strobe's files take (`disk` line, §5.14), last calibration, the cycle clock (`cycles` line, §13.6) and macOS timer coalescing (`timers` line). The cpu line and a Firefox older than 148 read as in `up` (§5.3). The benches running here are listed (§10.1 `ConcurrentBench`); a machine-wide bench slot held by a bench of another user or state folder, which this folder's list cannot show, is a `slot` line (`slot_elsewhere` in `--json`). On a shared CI runner (§10.1 `SharedRunner`) a `! runner shared: ; …` line names what still works there and ends `; a dedicated runner in a virtual machine says RUNNER_ENVIRONMENT=self-hosted or CI_SHARED_ENVIRONMENT=false`, the problems list says so, and `env.shared_runner` holds the reason. The `bun` line (in `up` too) names the Bun that runs bench files and its version (`bun_version`): the first `bun` on PATH, else `~/.bun/bin`'s, else Homebrew's on macOS (`runner::find_bun`; every tool strobe looks for is taken from PATH first, then from its known install places, §2). A Bun before 1.4.0 by SemVer's order (a 1.4.0 pre-release is before it; a version without three numbers is not called old) reads `!` with `(ctx.playwright needs Bun 1.4.0 or later: update this Bun)` (§9.6 `prepare`). The `engine` line tells a trace engine not yet fetched (`! engine trace_processor v59.1 (not downloaded)`) from a file at its path that is not the pinned build (`(damaged: not the pinned build; the next trace query or strobe up fetches it again)`, a problem, `engine.damaged: true`). A `left` line says when strobe left processes alone in the last day (§5 "Clean-up"). `--calibrate` runs the full calibration (§10.3). `--fast` skips the 3 s idle-load sample and the slow device reads (an adb server that refuses the loopback connection is given up after 200 ms, as usbmuxd is): `status --fast` 2.0 s → 0.56 s on adais. Disk (`disk` line, `disk` in JSON; `disk_bytes` is its total, null when the walk was cut short and the total only a floor): what everything strobe keeps takes on the disk, the state folder and strobe's own files outside it, and what a plain `strobe prune` would free now (§5.14), measured after the idle-load sample, which would otherwise count the walk as load: `! disk 119 GB (119 GB in , 75.7 MB outside it) · 96.2 GB reclaimable: strobe prune frees it · 2440 GB free on its disk` (adais). The mark is `!`, with `: strobe prune frees it`, from 5 GB reclaimable or a tenth of the free space (vps: 1.68 GB of 6.31 GB free). Entries unchanged since their last walk count it (§5.14 "Sizes"); the rest are walked within 0.6 s (`--fast` 0.3 s; status keeps to its 4 s, §19); what the time does not reach is left out and said (`at least …`, `N entries not measured …`, JSON `complete: false`, `unmeasured`), and the next status goes on from there. `--disk` walks every entry again, kept walks aside, and adds a table: each category's size, files, entries and reclaimable bytes, with what `strobe prune ` frees for recordings and experiments. Before this, the line summed `recordings/` and `experiments/` alone: 22.8 GB of a 116 GB state folder on adais. iOS devices (`ios` row, `ios.devices` and `ios.pages` in JSON): every phone the OS's usbmuxd sees, read beside the idle-load sample within 2.5 s for all of them (`--fast`: 250 ms, without the temperature or page listing): model (`ProductType`), iOS version, link (USB, Wi-Fi or both), battery level and charging (lockdownd's battery domain), battery temperature (diagnostics_relay's `AppleSmartBatteryPack` `BatteryData` on iOS 27+, falling back to `IOPMPowerSource` on earlier iOS, kept only between 0 and 100 °C), locked (on the lock screen with a passcode), and on an unlocked USB phone its inspectable Web Inspector pages (`wir:/safari/`, with scrubbed URL, title and whether another debugger is attached). A phone only on Wi-Fi is listed, not read. Never the phone's name: a personal phone's name is its owner's. The JSON carries the full UDID, to select the phone as a target; the text prints its last 8 characters (a current UDID's first 8 are the chip id, the same for every phone of a model). No usbmuxd (Windows without Apple Mobile Device Service, Linux without the usbmuxd package) is information, not a problem; Linux's usbmuxd runs only while a phone is plugged in, so an installed one without a socket reads as no phone. strobe reads with the trust the user already gave this computer (the pair record usbmuxd keeps) and is read-only toward the phone: it never pairs, sets a value or installs anything; an untrusted phone says to unlock it and tap Trust. Android devices (`android` row, `android.devices` in JSON; `strobe up` lists them too): every device of an adb server that runs (status starts none: "no adb server runs" is information), each read in one shell round trip (`adb::Facts`) within 3 s for all (`--fast`: 500 ms): manufacturer and model, Android version, link (USB, Wi-Fi, emulator), battery level and whether it is on power, battery temperature, thermal status, a screen that is off or locked, and what a run may do with it: an emulator `check and look only (Emulator)` (attribution recordings and bench leak runs also run there: nothing they take is timed), a phone on Wi-Fi `untimed only (WirelessTransport)`. The serial selects it (`--mobile=`, 13.1 "Chrome on a phone"). An unauthorized phone says to accept "Allow USB debugging?". Information, never a problem. ### 5.5 `strobe record` ``` strobe record [--probes ] [--for | --bench #] [--max-time ] [--video] [--headless] [--engine chromium|firefox|webkit] [--served ] [--emulate mobile|touch] [--mobile[=]] [--style] [--os-frames] [--power high|keep] [--pin …] [--id rec_…] [--json] strobe record app: | exe: [--native] [--app ] [--home ] [--windowless] [--gpu-cache warm|cold] [--until-line ] [--stop-line ] [--wayland] strobe record pid: | proc: [--probes timing|deep|waits|stacks-unprivileged] ``` Not built: `--ring` (the flight recorder, below) and `--name`. `--pin` is the CPU choice of §9.4. `strobe run` (§5.13) runs an ad-hoc command under a bench block's conditions. - `--mobile[=]` records a page (https://…, a local page) in Chrome on an Android phone or emulator over adb, at the phone's own screen with its touch (13.1 "Chrome on a phone"; on a phone, only in a Chrome channel given to strobe with `strobe up --mobile= --channel canary`). A timed recording refuses an emulator (`Emulator`), adb over Wi-Fi, Battery Saver and a low battery, waits up to 3 minutes for a hot phone, and refuses `Thermal` when it got hot during the recording; `--probes attribution` runs on an emulator. The recording keeps `phone`; `cpu.app.total` is refused `NotForTarget` (the page's processes are the phone's). Not with `--headless`, `--emulate`, `--os-frames`, an app, or `--probes memory`. - `--emulate mobile|touch` launches a page as a phone or a desktop with a touch screen (13.1 "Emulated phones and touch screens"); without it a page is a desktop. Not with `--bench` (the bench target says what its page emulates), an attached browser or an app. - `--video` films the recording (§8.8): the window's screen (headed) or a Chromium page's renderer (headless), its `timing` `perturbed`; with `--bench`, the check run's own recording holds it. - One recording; returns its handle, validity and a summary. - Default probe set: `timing`. `--probes attribution,memory` and friends per §8.1. - `--for` records for a duration (default 10 s; a native app with `--stop-line` and no `--for`: at most 10 minutes). - `--bench #` records a bench case (a case glob matches) on `--head` (default `.`), so a flow behind clicks or keys records as easily as a URL. It runs as `bench --check --why always`: setup, warmup until steady, then the measured runs under the attribution probes; it prints the recording handle per matching series. No `` is needed: the bench file names it (`--target` picks one of its targets). `--gpu-cache` is refused there: the bench target's `gpuCache` decides, as in a bench. `--headless` runs a page case in a headless browser, as `bench --check --headless` does (§5.6): no display needed, frame and latency metrics refused. `record app: --headless` (or `exe:`) is a usage error: an app draws its own window, and the flag once only skipped the lock check on it. `--engine` passes on as in a bench. A case its engine has no profiler for in strobe (a Firefox launch case, any WebKit case) runs its check and then refuses `EngineUnsupported` (exit 4) with why: there is no recording. `--max-time` caps it as a check's (§9.1 "Stop rule"): a wait that reaches it ends the record with exit 124 and no recording. Without `--bench`, `--max-time` caps a timed recording's wait for the bench slot (exit 124, `ConcurrentBench`, §10.1), not the recording (`--for` ends it); on an import (`file:`), which waits for nothing, it is a usage error. A script recording's `next` line names `hotspots --by package · threads --timeline · summary (per run)` (no page views). Its check block's slow speed check is a warning, never a wait (§10.1 `Throttled`): it prints the run's `Background` and `Throttled` warnings under the recordings (JSON `warnings`), since they were taken on a loaded machine, and the bench's `Attribution` (with `Missed:`), `ProbeOverhead`, `ObserverCost` and `TimerCoalescing` warnings; `--json` marks each recording `kind: "attribution"`, with its `check_recording`. - `--engine firefox [--headless]` (or `webkit`: Safari headed only on macOS; WebKitGTK on Linux, headed, or `--headless` under `xvfb-run`; WPE headless sees a touch device and refuses `DeviceMismatch`) records a page in that engine (§13.1) from outside: its processes' CPU in cycles (`cpu.app.total`, `cpu.work`), GPU use and VRAM from the OS sampler, private memory, DOM nodes, and headed the OS's presents of its processes (`frames.*`, §7.2), kept as `presents.json.gz` and `frames.json` so `query frames` reads them as a native recording's. Where cycles are not time on the device (Windows on Arm, §13.7) both CPU metrics are refused, as for a native app; a headless browser that drew in software reports no `gpu.vram`. A Playwright build named by `STROBE_FIREFOX` warns `PatchedBrowser`, and the recording says so (`patched_browser`). In Firefox `--probes attribution` runs Firefox's own profiler and about:memory (§8.3, §8.6) and `--probes memory` refuses (`EngineUnsupported`: no heap snapshots); in WebKit both refuse. An attached endpoint or an app takes no `--engine` (usage error). - `file:` imports (§8.7). - A native app (`app:` with a native profile, or `exe: --native`) records one run from outside after a 3 s settle: CPU, thread wakes, IO, GPU, memory, the UI thread (`ui.cpu`, `ui.wakes`) and OS present events (§13.7). `--probes attribution` adds native stacks (§13.4) and keeps the present events beside them (`os_frames`; frames, `presents.json.gz`, `query inputs`; on Windows they share the deep helper as §13.4 "Stacks and presents in one block" says). A native attribution or memory block is kept for its recording when its primary is refused, as a page's is: a native `interaction.p50` case once failed `NoPresentSource` ("no frames in the runs") in its attribution block, which took no presents, and left no recording (blade, `record --bench scripts/dev/canvas.bench.ts#latency --head . --target app`: now `query inputs` 5 measured inputs, 6.7–8.8 ms, and 5 frames on screen). `--until-line ` starts the recording when the app prints a matching line (stdout or stderr) instead of after the fixed settle, within 120 s; `--stop-line ` ends it at one (`--for` caps it, else 10 minutes). A program that prints its states (`device released`) records each state without sleeps in the program or a script polling its output. `--windowless` records a native app that shows no window (a tray app, a service), as `native(…, { window: false })`: no window is waited for or placed and nothing on screen is measured. On a locked Windows session a windowed native record keeps CPU, memory and IO and refuses frames and `gpu.process.3d` (`SessionLocked`), with a `session locked` line; other OSes refuse a windowed native record on a locked session. - `record pid:` with `--probes deep`, `waits` or `stacks-unprivileged` keeps `capture_cost` in its meta.json (printed as `cost` and by `query summary` and the report): the tree's context switches a second and CPU over the capture and over 200–500 ms right after it. A rate during the capture lower by more than a quarter warns `CaptureCost`: the sampler slows a switch-heavy process, and its stacks describe that slower process (§10.2). A deep recording's summary gives both resolved shares (`resolved: X% of leaf frames, Y% of all frames`), as `query hotspots` does (§11.1). - Not built: `--ring ` (a flight recorder in the daemon, a ring buffer of the last N seconds of timing probes, dumped by `strobe record ring:`); no command takes `--ring` (an unknown flag). ### 5.6 `strobe bench` ``` strobe bench --head [--base ] [--filter ] [--target ] [--params ] [--primary ] [--equivalence ] [--pairs auto|] [--runs ] [--probes ] [--repeat ] [--pin auto|none|ccd0|ccd1|] [--aa] [--check] [--blocks ] [--bisect ..] [--max-time ] [--why auto|always|never] [--trace lean|full] [--power high|keep] [--reserve-cpus auto|on|off] [--noworse |=,…] [--plan [--effect ] [--pilot]] [--unlock-wait ] [--require-verdict] [--require-parity] [--engine chromium|firefox|webkit] [--headless] [--wayland] [--mobile[=]] [--pixels | --pixels-only] [--style] [--keep] [--id ] [--seed ] [--video] [--check --batch ] [--json] strobe plan … the same as bench … --plan: estimate, run nothing (§9.1) strobe bench --stop strobe bench --resume ``` **Seed.** `--seed ` (a 32-bit unsigned integer) is the experiment's seed, from which every case's `ctx.seed` and `ctx.random()` come (§9.6); without it strobe draws one from the OS's random source. Either way it is printed on the first line (`strobe bench x.bench.ts · exp_… · seed 42`), kept in `result.json` `plan.seed` (with `plan.used_random`: whether a hook read `ctx.seed` or called `ctx.random()`), in each block recording's `meta.json` (`seed`) and `actions.json.gz` (§14.1), and in the saved command line, so `--resume` replays the same workload (`state.json` also keeps whether the runs before the stop used it, which the resumed runner does not know). The PR block's Rerun drawer and a failed `--check`'s footer (`seed 42 …: add --seed 42 to replay the same workload`) print it only when a hook used it. `record --bench … --seed ` and `strobe run --base … --head …` pass it on; `plan` checks it; `record` of a target and `strobe run -- ` refuse it (usage error): nothing seeds them. A bisect (§9.7) runs every step with one seed: `--seed`, else step 0's when it already ran (a tracked change's confirmation), else one drawn once; its `result.json` keeps it (`seed`). `track` (§25.2) runs its points, confirmations and bisects with one seed per repository and bench file. `--video` (§8.8): with `--check` the check block films its run (`series[].video.head`); on a comparison, after every timed pair of a series, one untimed block per variant films a warmed run (`series[].video.base`, `.head`, and `mixed` when their kinds differ). Never inside a timed block. **Stopping and resuming.** Ctrl-C (SIGINT), SIGTERM or `strobe bench --stop ` (from any shell, or for a bench the daemon runs) stops a bench once its current block ends; waits (a locked session, the bench slot, busy CPUs) end at once. On Unix a second stop at least 300 ms later ends the launches, sweeps helpers stamped by this strobe (even if their launch is no longer listed), restores its settings, reports leftovers, and exits 130. An earlier repeat is the same stop. A third stop can force exit. `--stop` and `--resume` take an id or a unique prefix. `--stop` finds the bench's registration (§10.1) and says what it was doing; one that was waiting stops within a second, which `--stop` waits to report as `stopped`. An experiment no bench runs is a usage error that says whether it finished or ended (then `--resume` goes on); `--resume` refuses one still running. A stop of a bisect stops the step that runs, and a stop ends `track` and `bisect` (exit 130). Each invocation keeps its own progress log and stop files (the daemon runs several at once); the daemon installs no signal handler, so SIGTERM ends it. `--unlock-wait ` caps each wait for an unlock of a series that needs a presenting window, and on macOS each wait for nobody to use the Mac for a minute (a block whose app lost the front while a person used the Mac). Each wait prints a line a minute (how long it has waited; on macOS the last input's age). When the cap runs out, only that series is not run: it is listed in `stopped.not_run` with its `halt.validator` (`SessionLocked`, or `WindowHidden` for an app that must be the active one), and while that state lasts every later series that needs a window is not run either, at once, without a wait of its own. Every series that needs no window (§10.5) still runs. The bench then exits 124 (typed by that validator) with a hint to resume once unlocked, and `--resume` runs what was not run. A wait that ends a bench is a stop, never a failure: a `--check` case (batched or not) is not run and `--resume` runs it, a series cut mid-pairs reads "stopped after N pairs" (resumable), as do its counter or timing pass and the report's stopped line, `--pixels-only` exits 124 too, and the block that met the wait had its recordings discarded before it waited. `--max-time ` is the whole run's deadline in every mode, `--check` included: no series starts after it. The cap includes waits for the bench slot, quiet CPUs, a speed check or launch retry, an unlock, or an idle Mac. A timed comparison keeps its pairs and verdicts under the ordinary exit rules in §5.2; a display-fit slot wait before the first series can still time out (124). Other modes stop with 124 (§9.1 "Stop rule", §10.1 `ConcurrentBench`). Both a stop and an unlock wait that ran out keep the experiment's warm GPU cache stores for `--resume`, and `track` and `bisect` take a bench that ended either way as that error, never as a partial answer (a bisect step without its pairs would otherwise move `good`). `--stop` and `--resume` together are a usage error. On Windows and in a terminal the runner and the browser get the same Ctrl-C and end with it, so the block in flight is dropped. What finished stays: the half pair goes, the finished pairs of the running series get their comparison (no verdict unless a planned look had decided it), finished series keep theirs, and the rest are listed as not run. The result (`result.json`, `report`, `--format pr`, which marks it incomplete) is written and strobe exits 130. A failure after some pairs (a refused block, a launch that kept failing) ends the same way, with that error's exit code. After every pair a bench saves its command line and each series' finished blocks and pairs, or its result once done, in the experiment's `state.json`; `--resume ` runs the same command line again (the bench file and directory variants as full paths, so from any folder) from there: finished series are kept, the running one goes on from its next pair, and variants build again (unchanged ones from the cache). **Launches that fail and pass.** A block whose launch failed for a reason that passes (the browser did not open its debugging port, a DevTools call such as `Target.attachToTarget` got no reply, the connection dropped) runs again, after 30 s and a wait for quiet CPUs, holding nothing meanwhile, up to 3 times per block; a run that failed, or a validator, still ends it. Two such failures under load (load average 35–70 on a shared Mac) once lost a 40-minute experiment with its finished pairs. A navigation the browser refuses for its address (a blocked port, a bad scheme or URL, a host that does not resolve: `net::ERR_UNSAFE_PORT` and its kin) fails the case at once: running the launch again cannot pass. An evaluation in the page that never ended (`Runtime.evaluate`, `Runtime.callFunctionOn`, `Runtime.awaitPromise`, BiDi's `script.evaluate` and `script.callFunction` with no reply) once the block's own page work began (its `prepare` ran) is the page's, refused `RunIncomplete` ("the page stopped answering"), never retried: the browser answered, the page did not, and a new launch meets the same page (macOS: a frame wait on a popup's hidden opener took 6 minutes a case in 4 tries). One while the browser attaches (its first blank page, Firefox's chrome reads, fitting the viewport) is a launch that may pass, retried as before. A launch macOS held before it ran (Gatekeeper waiting for a person after a download or an update, §13.1) is not retried either: it exits 3 with the step, for browsers, `app:`/`exe:` and native targets, launch cases too (`isolate::held_at_launch`). A page's address in such errors, and on a failed step's `page:` line, is cut to 80 characters with its length (a 4 KB `data:` page). Every series in the file (case × target × params, narrowed by `--filter`, `--target` and `--params`) gets its own verdict. `--filter` takes case names separated by commas; each is exact unless it holds `*`, a glob over the whole name: `--filter window-closed` runs only that case, `--filter "window-closed*"` also `window-closed-memory`. Before anything builds, strobe prints the cases the filter runs and skips, and for an exact name that other cases start with, the glob that would take them too; a filter that matches nothing is a usage error that lists the cases. `--target` takes target names the same way (comma-separated, globs with `*`). With `--base`: the A/B pipeline in §9. Without it: `--aa` runs `--head` against itself (§10.4), `--probes memory --repeat ` is a leak run (§8.6), and `--check` validates the file: one block per series on `--head`, one run per case, no warmup and no statistics (`--check --base --head `: each case on those two targets, checked as their implementation comparison measures them, 9.10). A case with a reset runs run → reset → run → reset: the second run, unmeasured, proves a run can start from what the reset leaves (a reset to about:blank, a relative `goto` after it) and gives the start check (`ResetIncomplete`) a second start to compare; the case's values are the first, cold run's, except that when a process of the target ended in it and strobe could not recover its CPU (§9.6) a case with a reset and `resetCheck` on measures the run after the reset instead (` · run 2` on its line, §9.1). A case without a reset keeps its cold run 1, and only the `cpu.*` that lost the process refuse `RunIncomplete` (a refused primary fails the check). The second run is marked `warm:` in the trace (no view counts it), and none of it reaches the block: its `expect.same` reads, input counts (`ops`), self cost and frames stay out. Each case passes, with its declared metrics' values from that run, or fails with the refusal, the page's URL and its screenshot; the next case still runs, and any failure exits 1. A script's or a windowless native app's case line also shows `cpu.main` and `cpu.main.busy` after its declared metrics when it did not declare them (read in every block; the line only, the JSON's values hold them either way), a command's (`cli()`) line its `memory.peak` where a run measured it, as do `record --bench`'s `check` line and a `--blocks` run's secondaries (adais: a spinning script `cpu.main 295 ms · cpu.main.busy 97.6%`, an idle tray app `cpu.main 1.31 ms · cpu.main.busy 0.13%`). A native case's pass line ends ` · cold shader cache` when its launch found the GPU shader caches cold (`gpu_cache` per series; said only of an app that did GPU work in the runs, or for a launch case one that opened a window, 13.2). `tty.latency.p50` and `.p95` on a check line say their inputs, `(N inputs[, B busy][, S silent])` (busy: written while the program still drew; silent: followed by no change; both left out), as `tty.latency.p50 20,700 µs (10 inputs)`. A failure keeps the fix strobe's driver gave (a missing permission and where to grant it) and names the hook once (`setup: …`, not `setup: setup: …`). Under each case, `--check` prints the last 10 lines the bench file's hooks wrote to the console during it (`console.log`/`error`; the rest stay in the experiment's `runner.log`, whose path it names). Each passing case keeps its block's recording and prints it (`rec rec_… · strobe query rec_… threads`), and `query ` on a check of one case answers from that recording, except that profile views such as `hotspots`, `layout` and `gc` use the case's attribution recording when the check kept one. A forwarded view names its case and recording. With several active cases, a shared batch, or several candidate launches, query lists only distinct recordings that answer the requested view, with its arguments, instead of choosing a case. Listing checks sources in series order without marking a recording used or building a trace. Only when style, trace inputs or trace-only runs need run marks does listing read the trace: once per recording generation, with the run keys and their invalidation flags cached atomically under strobe's run folder, never in the recording. If file identity or change information is unavailable, or the cache cannot be locked or written, listing reads the trace without relying on the cache. It does not execute the SQL being offered. Without a kept profile it explains how to collect one. For a profile view, `--case` on the check selects the case's own attribution recording. If all candidates fail identically, query returns the shared reason without singling out a recording for its fix. Otherwise it names each distinct reason with its first series and recording (`recording_errors` in JSON): exit 124 if any candidate timed out, else 255 if any engine was unavailable, else 1 if any had a read error, else 4 if all returned 4, else 2. The top-level validator is the shared validator, or null when candidates disagree. Local summaries and Markdown of a copy name its path in follow-up commands and never offer to resume it. There is no portable export of an experiment with its recordings. A separately copied recording is queried or reported by its own path. The summary and Markdown keep missing recordings' IDs as provenance without commands. These two renderers can link a validated adjacent recording by path; that lookup does not make the copy a bundle that other experiment commands can open. On a line with several recording views, each offered view must answer separately. Missing frame capture, for example, removes `frames` without hiding available stacks or threads. An untraced check does not recommend `frames` in its summary. A lean headless page check has no presented frames, even with `--trace full`. `--why always` keeps a separate attribution recording: its `frames` view reports compositor slices and their durations, not a display rate, dropped refreshes or screen latency. A lean check points to a matching kept profile when available. `inputs` refuses input-to-screen timings without display evidence. Before anything runs, every bench prints the builds it runs with their commits (`head app@f2a281b501 (dirty)`, or `base … → head …`; an implementation comparison names its sides as before), and a check's text starts with that line. A check's `result.json` has `env`, `args` (the command's arguments, as a comparison keeps them, for `query insights` to rerun), `variants` (`role`, `name`, `spec`, `label`, `kind`, `commit`, `dirty`, `target`), `stopped` and per case a `state` (`pass`, `fail`, `refused`, `gap`) with the `refusal` behind a failed or refused one. `stopped` (a check's, a comparison's and `--pixels-only`'s) holds `code`, `why`, `not_run` and the `validator` of a typed stop (`SessionLocked`, `WindowHidden`, or a refusal that ended the bench part way; null otherwise), since a check's series not run are in no `series` entry; bisect, track and MCP get such a stop as a typed error (exit 124 with its validator). **More runs in a check:** `--check --runs N` runs N runs in each case's one launch, still no warmup (a reset's extra run after them as before); each value is their median, and the check line adds `[lowest … highest run]` (result `runs`, `spread`; on Linux a counter block of its own adds its spread to the check's). With `--why always`, `--runs` stays the attribution block's and the check runs once. A launch case's one run is its launch: `--runs` does not repeat it (`LaunchRuns`, info). `--check --repeat R` (from 2) runs R fresh cold launches per case: `values` is the median over the launches, `repeat.values` each launch's (the check line lists them) and `repeat.recordings` their recordings (a `launches` line); a launch that fails fails the case (`launch k of R: …`); refused with `--batch` and `--video`. Neither decides anything: a check stays untimed and verdict-free. With `--runs N` (from 2) a check judges measured runs that stepped down as a timed block does (the first over twice the last half's median, by 20 ms or more) and warns `NotSteady` `: its measured runs stepped down (the first one's work X, the last half's median Y): the first runs did work the later ones did not (a cold start's compiles and caches, or launch work still running), and each value is the runs' median; a timed bench measures after warmup`; in a check against base the base side's say so too (` · base: its measured runs stepped down …`). An untimed block never restarts warmup for it. A single cold run is never judged for startup when it is under 2 s: strobe cannot tell launch work still running from the case's own first-run work inside it; `--runs N` (stepped) or a timed bench (warmup) separates them. Nor is run 1 judged in a check that measured its second run instead (a process of run 1 ended, in a case with a reset; page, native, script and terminal blocks). `--check --probes memory` exits 2: a check runs each case once and takes no heap snapshot; a leak run is `strobe bench --probes memory` (snapshots between runs), and one case's heap and retainers are `strobe record --bench #` (its attribution block ends with a heap snapshot). Before, the flag was ignored and a plain check ran. **A check against base** (`--check --base `: a folder, git ref, `app:`, `exe:`; a bare target name is the two-target check above) builds both and runs each case on base, then head, the same launches each (`--runs`, `--repeat`; one launch per case and side, so not with `--batch`). `--target` picks targets of the file as anywhere (refused only in a two-target check: `drop --target (it picks targets of a two-build check, --base --head .)`); a build on one side and a target on the other is a usage error naming both forms. The case's line is head's, with `base · rec ` under it (and what head's line shows undeclared, `memory.peak` of a command, `cpu.main` of a script, where base measured it). A base build error stops the check before that case runs on either side (exit 1). If the build succeeds but base cannot run the case, the case fails (exit 1). When head passed, the error is `base failed (), so head was checked against nothing` (base's first line, and the next one when the first ends in `:`, as a script host's load error does), its fix and refusal are base's (what `query insights` and the step summary read), the `base` line shows the whole error (`base failed: `, each later line under it) and base's fix when the case's line did not give it (`base fix: …`), a `head` line shows what head measured with its recording, and the summary counts it (`0 of 1 cases passed, 1 failed on base`). If both runs failed, head's own error leads and base's error appears under it. Through 0.33.0 a base run failure never failed the case (`base failed, so no counter of it is compared: `, empty when that line ended in `:`) and the check exited 0. A case that measures something only head has (the export, route or element the change adds) therefore fails `--check --base`, since base runs head's bench file and cannot run it: leave it out with `--filter` until the change merges, then check it against the merged base. A base side that lacks an adapter entry the case needs (`ParityGap`) is a gap on base, as head's is; a gap on head is a gap whatever base did, and base's recordings go with it. Without `--noworse` nothing is compared, and the summary ends `base and head each checked; nothing compared: the same command without --check times head against base`. With it, `--check` takes the flag's margins only, not the cases' own (a bare margin is each case's primary; a bare exact counter is `0%`: `--noworse count.git,count.items`), and exact counters only (`count.*`, `net.requests*`, `net.bytes*`, `tty.frames`, `dom.nodes`: `model::is_ci_counter`). Exit 2 before anything runs: a bare metric that is no exact counter (`--noworse: main.work needs a margin (for example main.work=2%); only an exact counter (count.*, net.requests, net.bytes, tty.frames, dom.nodes) defaults to 0%`; in every mode), a family's bare name (`--noworse: count is a family of metrics: name one of it, count. (for example count.items)`; also `phase`, `span`, `metric`, `app`, `launch.milestone`), any other metric with a margin (`--check --noworse main.work: a check compares exact counters only …`), and `--check --noworse` without `--base `. Each side then runs at least twice: when the case resets and the check measures two or more runs per launch, those runs establish stability; otherwise it uses at least two fresh launches. On both sides, each counter must hold one value per side (every launch's value, and with `--runs N` its lowest and highest run). Each counter of each case ends in one row (`noworse_state`): - `no_worse` (passes): each side held one value and head is at most its margin over base (an exact non-inferiority: a count's margin is at least 0.5, so `0%` means no more); - `worse`: head over base by more than its margin; - `unstable`: a side did not hold one value (`did not hold one value (1, 0)`); - `missing`: head measured no value, where base did; - `not_compared`: base failed where head measured it, base measured no value of it, or the case declares it and neither side measured it (each refused it for what its run did, not for what the side is). Every row but `no_worse` fails the case (exit 1), its progress line printed once judged. No row, and nothing failed: a counter only the flag names that neither side measured, and a counter both sides refuse for what they are (`EngineUnsupported`, `NotForTarget`, `NotOnThisOs`, `Headless`: its refusal is on the case's line). An exact counter only the flag names that the case's target cannot read is refused on that case as a declared one is (`count.*` keeps the rule below). Lines ` noworse count.items 3 → 5 · worse (>0.5) (exact)` (`!` when it fails). The summary ends: - `base and head each checked; only the --noworse counters compared, exactly` when some row compared (`no_worse`, `worse` or `unstable`); - when cases ran but no counter compared anywhere, the check exits **4**, not 0 (0.33.0 and earlier exited 0): it was asked to hold counters to base and held none. A failing case's exit 1 wins. The summary says which: `…; no --noworse counter was measured on both sides, so nothing was compared (exit 4): check each name against the runs' ctx.count calls first (the Uncounted note says which names the cases count); only a counter a run case counts on some path alone is declared in that case's metrics, spelled as its call spells it, to compare 0 with 0`; `…; no --noworse counter was measured on both sides, so nothing was compared (exit 4): only launch cases ran, and a count. counter is a run case's`; `…; every --noworse counter was refused on both sides (the refused lines say why), so nothing was compared (exit 4)`; and, when a declared counter's row failed, `…; no --noworse counter was compared, and a case that declares one failed for it (its line says why)` (exit 1). When every entry is a parity gap, no counter compared: exit 4 (§5.2). `--noworse` adds its margins to every series (in every mode). A `count.` only the flag names (not the case's primary, `metrics` or own `noworse`: `Series::flagged`) is decided per series, across both sides and every block or launch (`flagged_counts`): once any of them counted it, one that counted none of it (and refused nothing of it) reads 0, with a 0 spread where it keeps one (`--runs N`), since a code path that never ran on one side is a count of 0 there; the block's kept recording gets the same 0 in its `meta.json` values, so `query summary` agrees with result.json. When none counted it, the series has none of it and prints nothing about it (it is not in `declared`); a check whose head failed still lists one base counted. No block refuses such a counter for not counting it: not a launch case, not a native block (native, app and exe targets, a native side of an implementation comparison; through 0.29.0 refused `NotForTarget: count.x is not measured for a native app`). A name no case declares and no run of any case counted, in the whole experiment, warns `Uncounted` once (§10.2), leading with what is most likely: only launch cases ran (`a launch counts none`); the name is one typo from one the bench file counts or a case declares or counted (`--noworse count.falbacks: no run of any case counted it, so nothing compared it. Did you mean count.fallbacks? …`); the file counts it but no run did (declare it on the case whose run counts it); else `no case counts or declares it … check the name against the runs' ctx.count("…") calls (the cases declare count.items)`. The names it reads are the bench file's literal `count("…")` and `__strobeCount("…")` calls, outside comments (`counted_names`): a hint, never a verdict. A declared `count.` compared 0 → 0 whose name the file never counts, while it counts one a typo away that the case does not declare, passes with a warning: `--noworse count.falbacks: : compared 0 → 0, but the bench file counts count.fallbacks, never count.falbacks: did you mean count.fallbacks? … if count.falbacks is counted elsewhere (a helper module, the app's __strobeCount, a computed name), this is right`. Through 0.29.0 a counter only the flag names read 0 in a case that never counted it (`count.x 0 → 0 · no worse (exact)`). Now (adais and vps): base 0 → head 5 reads `count.fallbacks 0 → 5 · worse (exact)`, exit 1; 5 → 0 `no worse`; launches of 1 and 0 `did not hold one value (1, 0)`; a timed comparison lists it as a secondary. Checked with 0.34.0 on adais (a `cli()` file, `--base HEAD --head .`): `--noworse count.items` read `count.items 3 → 3 · no worse (≤0.5) (exact)`, exit 0; `--noworse count.nothing` (no case counts it) exit 4 with the summary above. In result.json the series keeps `base` (base's side, as head's fields), `base_values` and `counters` (`metric`, `margin` as a fraction, `state` `no_worse`, `worse`, `unstable`, `missing` or `not_compared`, `passed`, `stable`, `base` and `head` medians, `base_runs`, `head_runs`, `why`, `text`; an empty list on a case that ran with the flag and compared none; the whole check cannot pass if all lists are empty). This is the comparison a shared CI runner allows (§10.1 `SharedRunner`, §26). **Many cases per launch** (`--check --batch N`): run cases that one launch can hold share it, its `prepare` and its `setup`, up to N at a time, so a file of many small cases (a fuzzer's) checks in a fraction of the time. Cases share a launch when they run on one target (so one engine, headless mode, emulated device and motion) with the same params (a fixture's, a seed folder's and the hooks' own), GPU cache, way of measuring (from outside or not) and `setupKey` (below), in the series' order (the cases in the order the file lists them, then target and params), up to and including the first with `resetCheck: false`; a launch case, a `cli()` case and a Linux series that counts instructions in a block of its own each keep a launch of their own. Each case runs as alone: run → reset → run → reset when it has a reset (the file's `reset`, `bench({ reset })`, when the case has none of its own). After each case strobe checks before the next one runs: the page still answers (a renderer the case crashed fails it, `RunIncomplete`), the app is still running, the profile's guard holds, the case's start check against the launch's first start (`ResetIncomplete`, its message naming the case and its runs: "the next case of the launch would start, after X's run 2 and its reset, from a different state than A's run 1 did"), its `expect` after the runs (`WrongScreen`), the OS input it was given, the window's size (`page.window`), the window's visibility and the browser's own processes. A case that fails ends its launch, and the next case gets a fresh one; so does a case with `resetCheck: false` (it passes or fails on its own, and no case may start from what it built). A later case is not run on a screen its `expect` does not show, nor on another screen than the launch began on (another title, address path or heading, or pixels 20 or more of 64 hash bits apart): it runs in a fresh launch, and the case before gets the note `batch after its reset the screen was not one may start from …`. A disturbed guard fails the case after which it was found, and stops the bench. What the start check (`ResetIncomplete`) and the screen compare is all a batch holds the cases to: a case can still leave behind what neither sees, and the next case then starts from it. Not checked: the page's DOM beyond its editable controls, toggles, title, address, headings and pixels; JavaScript globals and the heap; event listeners, timers and pending work; `localStorage`, `sessionStorage`, IndexedDB, the Cache API, cookies and service workers; the HTTP and GPU caches; a native app's state beyond its tree's editable controls and toggles. A file whose cases share such state checks each case alone (no `--batch`). A launch that fails before any case ran (its launch, prepare or setup) fails its first case, as without `--batch`; three such launches in a row stop the bench (exit 1), as does a disturbed guard. `prepare`, a `url` function and `setup` run once for a launch's cases. A file whose cases need different setups says what its setup depends on with `setupKey` (§9.6): before anything builds (in every mode, with or without `--batch`) strobe asks the runner for each series' key as canonical text (`setup_keys`), and a key that is not plain JSON (a function, symbol, BigInt, non-finite number, class instance or a value that refers back to itself) or a `setupKey` that throws is a usage error (exit 2) naming the series and where in the key; `undefined` is a key of its own, not `null`'s. A launch hook that reads what differs per case (`ctx.case`, and `ctx.seed` and `ctx.random()`, which come from the case's seed) would set every case of its launch up as the first: in a file with `setupKey` that is refused (usage error, exit 2, at its first launch, naming the hook and the field: move the choice into `setupKey` and read `ctx.setupKey`); in a file without one the launch runs only its first case, the cases that would have shared it run a launch each, the first case gets the note `batch the bench file's setup reads ctx.case …`, the experiment one `Batch` info line per hook and field, and `result.json`'s `batch.unbatched_cases` counts the cases that ran so. `run` and `reset` get each case's own `ctx.case`, `ctx.seed` and `ctx.random()`. A launch keeps one recording, its trace marked `case:::start|end` around each case, its runs numbered across its cases (`run:`), its `meta.json` `batch` listing each case's label, measured runs and values (`query summary` lists them, and `query actions` shows each case only its own actions), its action log naming each run's and reset's case (§9.6), and with `--video` one film of the launch with each case's span. `result.json` has `batch` (`size`, `launches`, `cases`, `failed_launches`, `launch_ms_mean`: a launch's own time besides its cases, `saved_ms`: that time for each case after a launch's first, and `unbatched_cases`) and each series `batch_id` (its launch, from 1) and `batch_seq` (its place there); the text marks each case `· launch 3.2` and ends with `batch 40 cases in 10 launches (--batch 10): about 3.1 min saved against a launch per case (…)`. A later case runs in a page or app the cases before it warmed: its values are a check's, not a cold launch's. A batch's launches keep the machine (the bench slot, the power plan) as series do (`prepare_series`): it is given back only to build or serve a variant, or for a queued bench (adais, 6 cases with `--batch 2`: 3 power-plan switches before, 1 after). `--batch` is a usage error without `--check`, and with `--why always`, `--pixels`, `--pixels-only`, `--probes memory`, `--blocks`, `--aa` and `--base`: a timed block is one fresh launch per case (the unit of a verdict), and the others are one case's block each. `--check --why always` also runs one attribution block per passing series on `--head` (warmed as in a bench, then `--runs` measured runs, default 5; views name the warmup runs the trace also holds) and prints its recording, to profile one build before there is anything to compare. `--headless` (or a page target's `headless: true`) runs page (`web`, `url`) targets in a headless browser, in `--check` and in timed blocks alike: it draws to no display, so it runs through a locked session or on a device without one, and keeps what needs no display (CPU, main-thread work, counters, phases, memory, milestones, and strobe's own input pacing `input.rate` and `input.gap.p95`) while frames, input-to-screen latencies, scrolling, canvas frames, the display's rate, a launch's pixels (`launch.painted`, `launch.settled`) and GPU use are refused `Headless` ("the page ran headless"), each declared one (`--noworse` keys included); a software rasterizer refuses nothing there but GPU memory, which no GPU held. The report says "headless (no display)" where it would name the display's refresh, and the result has no `refresh_hz` when every series ran headless. A primary among them refuses its series before anything builds (`Headless`, naming CPU primaries to use instead; see "Refused series" below): no frame or latency verdict ever comes from a headless browser, while a CPU verdict (`cpu.work`, `main.work`) of a headless page is a CPU verdict of that headless page, and the series and the ledger say headless (a change of a page's engine, browser or mode is listed among what changed between attempts). `--headless` leaves targets that draw no window as they are (`cli()`, `script()`, `tty()` and `window: false` native targets), so a file that mixes them with pages runs whole; a selected series whose target opens its own window (a native, app, exe, webview or command target) is a usage error naming it (narrow the run with `--target` or `--filter`), in `--plan` too. `record --bench … --headless` is the same for one case. `--engine ` picks the browser engine of page targets (§13.1): when the bench file declares targets of engine `e` (`web({ engine: "firefox" })`), only those run; otherwise every page target runs in `e`. A run that would include an app, exe, native, command or script target under `--engine` is a usage error (narrow it with `--target`). `webkit` refuses on Windows for good (`EngineUnsupported`: no WebKit browser runs there for people), and with `--headless` on macOS (Safari has no headless mode); on Linux it is WebKitGTK, headed, or with `--headless` on the session's display (under `xvfb-run`); without a display WPE's headless view sees a touch device and refuses `DeviceMismatch` (§13.1). A primary the engine cannot measure refuses its series before anything builds (`EngineUnsupported`, with the closest metric it can), as does a launch case on WebKit (no preload script); `--probes attribution|memory` on a WebKit page refuses the series that use that target. Declared secondaries the engine cannot measure are refused per block. `web::refusal` (WebKit on Windows, Safari headless, WPE headless, a too-old Firefox, a headed page with no display), WebKit with a `scale`, and WebKit with `--probes attribution|memory` refuse the series that use that target, not the bench; the run refuses only when every series does. Only the targets the run's series and sides use resolve their `cwd` project (`target_projects`), so a target no series uses never stops the bench. **On a phone** (`--mobile[=]`, 13.1 "Chrome on a phone"): the file's phone targets (`emulate: "mobile"`) run in Chrome on an Android phone or emulator instead of being emulated; every other series is refused `NotForTarget` before anything builds. A timed bench (anything but `--check` and `--pixels-only`) refuses an emulator, a phone on Wi-Fi, Battery Saver or a low battery before anything builds; each block then holds the phone's slot and stay-awake setting, and a hot phone (`Thermal`, before the block or during it) is waited out holding nothing and the block runs again. A phone block reads nothing of this computer's (no OS sampler, process snapshots, CPU reservation or power-plan switch). `bench --plan --mobile` estimates from earlier runs on the same phone. **A declared metric no reading gave** ends every block refused, with why, never left out (`declared_state`, after every other check of the block, for every kind of target): a secondary no longer vanishes from the table, and a primary no longer runs pairs of nothing to `unclear`. The reasons (`unread_refusal`), first that applies: - a launch metric on a run case, a leak verdict (`js.heap.growth`, `dom.nodes.growth`, `leak.suspects.retained`) outside a leak run, `cpu.fixture` without a `fixture()`, `cpu.foreign*` unpinned: `NotForTarget`, each with its fix (`give the case launch: { until: … }`, `strobe bench --probes memory`, …); - one the target never gives, by the support matrix (`support::runtime_reads`, the side's runtime as it runs, its `app:` profile too): `NotForTarget`, `: a target never gives it; instead, measure or `; one it gives on other OSes: `NotOnThisOs`, `: a target gives it on other OSes, not on `. `instead` names up to two metrics the same target gives on this OS (left out when none does). Checked with 0.34.0 on adais: a script's `interaction.p50` and `gpu.vram` read `a script target never gives it; instead, measure cpu.main or cpu.work`, its `tty.frames` `a script target never gives it`; - an input latency or pacing (`interaction.*`, `scroll.latency*`, `input.rate`, `input.gap.p95`) of runs that sent no input: `NoInput`, `: no measured run sent the input it times`; - a frame metric of a page measured from outside: `NoPresentSource`; - else `NoMetric`, `: no measured run gave a value of it`. A check lists capability refusals such as `NotForTarget` separately. A missing phase or an incomplete run fails its case (exit 1). A comparison's or `--blocks` series is listed refused at block 1 (exit 4 when every series is). A primary the target can never give is refused before anything runs where strobe knows it then (a windowless app's frame primary, an engine's, `Headless`: listed `refused`, exit 0 beside the rest). Not checked: attribution, memory and pixels blocks (they feed no comparison), what the series' other kind of block measures (Linux counter blocks, HTTP probe blocks), and a `count.*` only `--noworse` names (decided across the series, below). A check's `no value (nothing in this block measured it)` no longer appears for a declared metric. **Refused series:** a series refused before anything builds (one case's Chromium-only primary in a file run on Firefox) is listed, never run, and the rest of the file runs: `--check` prints `refused : ` with its fix and counts it apart from the cases that passed or failed (exit 0 when the rest pass); a comparison lists it `not run: : · fix: …` with its primary refused (`verdict_kind: refused`, the refusal in `primary.refusal`, series `kind: "refused"`), undecided, and exits 0 unless `--require-verdict`, which exits 4 on it as on a parity gap; `--plan` lists it with no time. A comparison's series whose primary is refused after its pairs (a one-sided `Background`, lock states that differ) keeps its pairs and its kind, shows its primary `refused ()`, and counts as `refused` (never as a gap) where journeys are counted (`family::refused_at_run`). Either way the bench exits 0 unless every compared series' primary is refused (then 4, with `result.json` written) or `--require-verdict` (4). Its result keeps the series' `headless`, `engine`, `target_kind`, `journey` and `side_name`; the summary leaves out the series' label, which every listing prints beside it. In an implementation comparison both sides' targets are checked (the summary names the side) and a refused journey is not compared: it counts toward neither side nor the family's Holm denominator, as a gap. The parity matrix shows `refused `, reports `Not run ()` with the summary and the fix (and no pair panels), a heading of "Not measured" only when no series decided, and `track` records no point for it. Gaps are not checked. When all non-gap cases are refused a check writes the result and exits 4. A timed comparison ruled out in preflight can instead return the first refusal directly. **A series refused while it runs** (exit 4 of its own: `OverheadBudget`, `NoPhase`, an engine that cannot run here, …) ends alone in comparisons, `--blocks N` and leak runs. In `--check`, a run defect fails the case (1); a capability refusal stays refused even after launch. A refused secondary alone does not fail the case. In the other modes it is listed refused (kind `refused`; or, after some blocks, its kind with `stopped: refused`, its primary refused with the validator and its finished blocks kept) and the rest of the file runs. A primary a look already decided keeps its verdict; the refusal is a `Stopped` warning on the series. A series refused after some blocks drops its primary's numbers as a refused comparison does (`base`, `head`, `estimate`, `lo`, `hi`, `p`, `confidence`, `needed_pairs` null; `exact` false), and its `halt` keeps the refusal whole (summary, detail, fix). Refusals of the machine stop the bench as before (`ValidatorId::machine_wide`: `SessionLocked`, `Background`, `BackgroundPriority`, `Thermal`, `Throttled`, `DeviceAsleep`, `ConcurrentBench`, `PowerSave`, `LowBattery`, `Emulator`, `WirelessTransport`), except a headless Chromium page's wall-time primary refused `Throttled` with the display off, which ends that series alone (§10.1), as do a stop, `--max-time`, a lost phone (exit 255) and the user's state disturbed (no refusal). Exit 4 when every series that is not a gap is refused, including cases refused during a check. In a timed comparison or leak run, `--require-verdict` makes any refused primary exit 4. A refused series is checkpointed done: `--resume` can keep it. A `--blocks` run whose every series was refused still ends with ` · strobe query `. A bench in which nothing ran (no series finished, nothing saved) ends with its error and `stopped: nothing ran`, never a `--resume` hint. The check's summary counts them `N refused`. The engine, the browser's version and headless go into the rerun ledger's identity (§14.2) and track's fingerprints (§25): a run in another engine, version or mode is another experiment and another series. `--blocks N` measures `--head` alone: N timed blocks, each warmed until steady as a bench's, and their values, with nothing compared and nothing decided; the text gives the primary per block, each declared secondary's median and interval over the blocks (or the block's refusal), the kept recordings, and the blocks' median with the median's own distribution-free interval (order statistics, the sign test's: `stats::median_interval`; a Hodges–Lehmann interval beside a median could leave the median out, as 134 of 400 skewed samples of 26–40 values did in `scripts/dev/stats-check.ts`). With one value (`--blocks 1`, or one block of N that measured it) the primary line reads `★ (1 block)` / `(1 of N blocks)`; with none (every block refused or stopped before one) `★ no value`; progress lines say `1 block`. vps exp_e93d96 and adais exp_1d358d (`http-probe-example.bench.ts --head . --blocks 1`): `★ http.latency.p95 515 ms (1 block)` / `514 ms (1 block)` (adais's now prints `513,762 µs (1 block)`), where the value sat on a bare `blocks 844 ms` line before. It is what `track` records for a commit (§25.2); with `--base`, `--aa`, `--check`, `--probes memory`, or anything that compares or decides (`--require-verdict`, `--plan`, `--noworse`, `--pairs`, `--pixels`, `--pixels-only`) it is a usage error, as is N that is not a whole number from 1. The primary is refused as a comparison would refuse it (rule 1): a block's refusal, `TailTooShort` when a block had too few of a percentile's events, `NoPhase` when a measured run never entered its phase; and one build's analysers run on its blocks (`HighVariance`, `MultimodalDistribution`, `NotSteady`, `Background`, `StateDrift`, `DisplayOff`). A refused primary gives `track` no point. `report` draws its HTML and `--format md` (the text): the primary's median with its interval over the blocks (`95% interval` only from 6 blocks, its coverage reaching 95%; with 2–5 `range over n blocks (50 / 75 / 87.5 / 93.8% median coverage; 6 blocks needed for 95%)` in the hero, the band's tooltip (an outline below 95%, none at 2 blocks), the declared metrics' subtitle and each row; one block's value with `1 block`), each block's value on one axis, the declared metrics' medians and intervals or refusals (with the main thread's CPU the text shows for a windowless target, `cpu.main` and `cpu.main.busy`, so the HTML and the text hold the same rows; `scripts/dev/surfaces.ts` checks it), the kept recordings, head's attribution block when `--why always` ran one, and the HTTP probe's blocks; never a better, worse or same. `--format pr`, `card` and `badge` state a comparison and are refused: ` measured one build (bench --blocks): it compares nothing, so there is no --format of it` (fix: `strobe report experiment ` (HTML) or `--format md` shows its blocks). result.json is unchanged; the report payload adds `series[].blocks_summary` `{ blocks, primary, secondaries }` (each `{ metric, unit, values[], median, lo, hi, confidence, refused, refused_blocks, verdict }`). adais exp_d60e9b (`http-probe-example.bench.ts --blocks 3 --why always --runs 3`): text and HTML both `median 514,863 µs [507,161 µs … 534,237 µs] (75% over the blocks)` (HTML: `The median's range over 3 blocks (75% median coverage; 6 blocks needed for 95%) is 507,161 µs … 534,237 µs.`), secondaries `cpu.main` 2,197,247 µs [2,197,107 µs … 2,199,186 µs], `cpu.main.busy` 84.3% [84.3% … 84.4%]. Its timed blocks run no profiler (rule 3); `--blocks N --why always` then runs one attribution block per series on `--head`, as `--check --why always` does (warmed, then `--runs` measured runs), kept as the series' `attribution.head` and printed (`why attribution block: head rec_…`). `--why auto` runs none (nothing compared, so nothing to explain); a `--why` value other than `auto`, `always` or `never`, and a `cli()` case with `--why always` in a check or `--blocks N`, are usage errors before any series runs. A stop, or an unlock wait that ran out, during that attribution block ends the bench as in a comparison. `query ` of a `--blocks` run (beside series refused before they ran or not run) answers from its one attribution recording, lists them when there are several, and with none says that timed blocks keep no profile and names `--why always`; `query hotspots` (and `alloc`, `layout`, `style`, `gc`) of a bench's timed block says the same, naming its experiment, where other recordings still say `record --probes attribution`. `--base [=] --head [=]` (or `target:…`) names two targets of the bench file: an implementation comparison (§9.10). `--head` alone is an error that suggests `--base`, `--blocks` or `--check`. `--runs` is the measured runs per block (default 5). `--params all` adds the `more` values of every parameter (§9.6). Variants are described in §9.5. `--bisect` walks a commit range (§9.7). Running on mesh devices (§9.8) is not built. Text output (a full example): ``` strobe bench timeline.bench.ts · scroll · desktop · turns=200 base origin/dev@8b4d98c head lazy-tooltip@98acaa3 env Windows 11 26100 · Ryzen 9 9950X3D 16C/32T · RTX 5070 Ti app:desktop (isolated launch) · window 1600×1000 @1.5x · display 144 Hz plan pinned CCD0 (V-Cache) · power plan High performance (restored) · visible, unoccluded blocks 8 pairs (16 fresh launches) · warmup until steady · 5 runs per block · stopped: primary decided probes timing: frames, toplevel threads, os, gpu · overhead 0.6% main thread metric base head Δ CI verdict ★ frames.presented.p95 10,410 µs 9,610 µs −8% −11% … −4% faster frames.dropped 41 33 −8 −14 … −2 fewer main.script 412,300 µs 362,400 µs −12% −15% … −9% faster count.rows 160 115 −45 exact fewer (exact) memory.app.private 302 MB 301 MB −0.3% −1.2% … +0.6% same (±2%) frames.presented.p99 — — refused (TailTooShort): 310 frames < 1,000 ★ primary, declared before the run · secondaries gated on the primary, Holm-corrected warnings ! MultimodalDistribution main.script: 2 modes (mValue 3.4) ! Background other work of a quarter core or more ran on the measured CPUs in 1 of 16 blocks (6%; median 31%, peak 31% of a core in pair 3 head); the primary is not decided, and the other work may be why; close heavy apps or rerun when quieter i Attribution separate blocks: hotspots, allocation sites, counters → rec_91c0 ``` Each series' `warnings` print under it; the experiment's own (`Rerun`, `Battery`, `ReservedCpus`, `StaleCalibration`, …) print once, after every series (`warnings (the experiment's)`). The `window` part of a series' line is its own window (`window` in its result, from its blocks' content area, else the window it asked for, with its scale and emulation), never the last block's of another series. A `url()` target's series built from folders or refs is named by its page (` on both sides`), since no build of it ran. ### 5.7 `strobe query` ``` strobe query [--top ] [--json] [--by function|file|package|process|layer|allocator] [--native] [--thread ] [--pid ] [--phase ] [--sort self|total] [--case ] [--at ] [--source console|device|crash] [--reveal] [--timeline] strobe query sql "" strobe query history | changes | ser_… [history|changes] (§25.5) ``` Views and their flags in §11.1. `a..b` compares two recordings, one view side by side and per run. `exp_…` reads an experiment, including one still running (`progress`); a two-recording view of an experiment (`query exp_… hotspots`) answers with each series' base..head attribution recordings. Not built: `--max-bytes` (agents get top-K rows, §17). A handle may be a path: a `result.json` (an experiment's `series`, or a bisect's), a `meta.json`, or a folder holding either (`./exp_x/`, a copy, another machine's). `query`, `report` and MCP's `query` and `report` read a path through one resolver (`query::by_path`), the file parsed once; `report ` takes its view from what it holds. A folder with neither file, any other JSON object, and a meta.json that is not JSON, larger than 256 MB or nested past 128 levels are refused with why (exit 2), never read as a blank recording. A folder a person named is read as it is, and nothing in it reaches past it: - every entry, four folders down, must be a regular file or a real folder as its own metadata says (no symbolic link, junction or mount point, device, pipe or socket, no `:` stream; a file a cloud-sync client keeps and a hard-linked copy are regular files), else the folder is refused naming the entry (`a symbolic link, junction, mount point or special file`); the folder's parent, not the folder, is where `strobe.json`'s display unit is looked for; a name its files give for another of its files (a map index's `maps/0.map.gz`, `video.json`'s file, a check's actions file) is a plain relative name inside it; - a map's `map_dir` (where a stored recording's original sources are) is never read, nor a module it names (`Symbolizer::open_held`), also for `sql`'s Perfetto trace; its gzip files expand to at most 64 times their size (64 MiB to 4 GiB; the most a file strobe writes expands is 19 times) and its other JSON files are read up to 1 GiB; - what its files say never makes strobe look elsewhere: no `package.json` or `.git` up from a frame's file, no `.used` mark on a stored experiment it names, no existence check of a path from it on another machine (`//host/…`, `file:////host/…`, `\\?\UNC\…`: asking alone connects to that host), a `file_repo` only as a relative path of plain steps under the current folder; its token and run keys reach a trace query only as strobe writes them (a plain id; `run:3`), else it is refused; - a printed command or hint names it by its path, quoted for sh, zsh and PowerShell (§11.3); - nothing is written beside it or in it (no `.used`, `phases-cache.json` or symbol cache): `sql`'s built Perfetto trace goes to `/run/query/<12 hex of its folder and meta.json time>/`, a report to `/run/reports/` (§5.8). A file named alone (a `result.json` without its folder) is read the same way; what it names beside itself (a check's actions file) is read only as a plain name in its own folder. Any JSON strobe reads, stored or named, nests at most 512 levels (an allocation profile two per frame of V8's 128: measured on Windows release builds, a 1 MiB main thread overflows past 2,324 levels of a typed profile node and 3,018 of a `serde_json::Value`). A heap snapshot's `trace_tree` (one level per allocating frame, any depth) is read without recursion, and no count in a file reserves memory past a million entries up front. A store handle is a plain id (1–128 ASCII letters, digits, `_` or `-`); a unique prefix works, and a tracked-series handle starts with `ser_`. Experiment `--id` (`bench`, including `bench --bisect`, and paired `run`) is at most 96 characters. `record --id` requires `rec_` followed by 1–64 of the same characters. ### 5.8 `strobe report` ``` strobe report [experiment|recording|history] [--format html|md|pr] [--out ] [--open] [--json] strobe report experiment --format summary [--out ] strobe report experiment --format pr --update-pr [-R|--repo owner/repo] [--force] [--no-card] [--labels ,] strobe report experiment --format card|badge [--out ] [--theme light|dark|auto] [--badge-url] [--scoreboard] [--labels ,] ``` `--format summary` is an experiment as short Markdown for a CI job's summary page (§11.2, §26): a check's exact counters, never a time. A format the handle has none of is refused before anything is read or written (exit 2): `--format x: an experiment's report writes html (default), md, pr, summary, card or badge; a recording's html or md`, and a recording with `pr`, `summary`, `card` or `badge`: `--format x writes an experiment's result: give an exp_… handle (a recording's report is html or md)`. Through 0.29.0 an unknown format wrote an HTML file under its name and exited 0. `--labels` names the two sides of a comparison in the PR block and the card (§11.2, side names); without it a block calls them "Before" and "This PR" only when head is the working tree or `HEAD`, and with `--update-pr` at the pull request's head commit. An HTML report opens on the view its address names: `#series=`, `#scope=runs|host|startup|setup` (a script target's CPU profile: its measured runs, its whole host process, the host's startup or the bench file's setup alone, where the report has that profile) and `#metric=` (the metric pair-by-pair consistency shows), joined with `&` (`report.html#series=1&scope=host`); a headless `strobe look ` shoots that view. Every report prints times in the command's unit (§7.1 "Times"): the PR block, step summary, card and badge as written, and an HTML page (an experiment's, a recording's, `history.html`) opens in it (`default_unit` in the page's data; `us` for a page without it). The page's sticky header has a `µs | ms | s` switch, always shown, that draws every time again in place (headline, tables, charts and their axes, verdict bounds) and writes `#unit=` into the address; `#unit=` names the unit a page opens in, over `default_unit`, and an address without it falls back to `default_unit`. Wall-clock durations, configured values and stored text stay as written there too. Writes a self-contained artifact from a view in the catalog (§11.2) and prints its path (`md` and `pr` print the markdown on stdout and the path on stderr). The view follows from the handle when it is not named (`exp_…` an experiment, `rec_…` a recording, `ser_…` or a bench file a tracked history). A path to a `result.json`, a `meta.json` or the folder of either is read as `query` reads it (§5.7). For a path input, `md`, `pr` and `summary` print Markdown to stdout without writing a report file unless `--out` is given. HTML (the default), cards and badges go to `/run/reports//` unless `--out` names a destination. The key is the file's plain id, else `file-` and a 12-digit hex hash. Nothing is written beside the input by default. The result keeps its rerun ledger (this machine's index is not its history). `--open` opens it in the system browser. Agents show the path with the Review pane preview. ### 5.9 `strobe top` ``` strobe top [--target ] [--once] [--interval ] [--limit ] [--sort cpu|gpu|vram|mem] ``` Live terminal view: per-core load with CCD and frequency, process tree with CPU, memory, GPU engines and VRAM. Same samplers as the OS probes (on Linux and macOS each process's GPU time comes from the per-process collectors, DRM client statistics and IORegistry GPU time, since neither OS has an every-process counter as PDH is; a process's VRAM shows `-` on macOS, where GPU memory is shared). `--once` prints one view and exits. Not built: the focused target's frames. ### 5.10 `strobe update` / `strobe leave` - `update [] [--from ] [--check]`: install the latest published release over the installed copy, or that version, or with `--from` a local build (a strobe executable or the folder holding it and strobew). A release comes from npm (`@hona/strobe--`, the package `@hona/strobe` installs), its tarball checked against the registry's sha512 integrity. The running strobe copies each binary beside the installed one and swaps it in by rename (Windows lets a running executable be renamed, not overwritten; the old copy stays as `*.old` until nothing runs it, whatever the new build's own install code does), then runs each new copy (`--version`, the build id checked): a copy that does not run once in the install folder (an antivirus quarantined or blocked it; Defender has flagged unsigned builds) puts every previous binary back first, then fails with the reason, how to allow it, and what the rollback did (each binary that could not go back is named) (checked on vps with the install folder on a noexec mount: the previous binary came back byte for byte). `up` installs the same way. It then stops the daemon unless it is running an MCP bench. A shell bench does not prevent a manual update or setup; use the project's pinned binary directly and defer installation until it finishes. Then the installed new build runs its own `up --skip calibrate`: it writes its skill and MCP registration, starts its daemon, and refreshes the deep-mode helper only where one is installed (its UAC prompt only when it is out of date). The same build already installed (the same build id, below; the same bytes for copies without one): nothing to do. A daemon left running its MCP bench restarts as the installed build once it sees no registered bench, shell or MCP (it checks once a minute and runs the installed copy's `up --skip deep,mcp,calibrate`; only a daemon running from the install folder does). To install without asking the daemon to stop or start, run the **new** binary's `up --skip daemon,deep,mcp,calibrate` instead of `update`. This installs the binaries and skill but does not change autostart, refresh the helper or register MCP. It does not disable an installed daemon's own idle build check. **Build id.** `build.rs` hashes every source file of the binaries (FNV-1a over `src`, the bench SDK, the viewer, the skill, the calibration page, `Cargo.toml`, `Cargo.lock`), the target and the profile into `STROBE_BUILD_ID`. `strobe` and `strobew` come from one run of it, so they carry the same id, where a hash of each executable differed and the daemon (strobew on Windows) never matched the CLI. The id sits in the executable behind a `strobe-build-id:` marker, so `up` and `update` read an installed copy's id without running it. `strobe --version` prints `strobe 0.6.0 (a1b2c3d4e5, build 0123456789abcdef)`, `/health` the daemon's id, `status` whether the daemon is this build, and every bench's first line its version and commit. The commit (`STROBE_COMMIT`) is read again whenever HEAD moves: `build.rs` watches the checkout's `HEAD` and its log and, in the folder a linked worktree shares (`git rev-parse --git-common-dir`), the branch HEAD names and `packed-refs`, so a commit in a worktree, or a branch moved from another, stamps the next build (only paths that exist: a missing one would rerun the script on every build). `--check` says what is installed and what is available. `--on all` (every mesh device) is not built. Without a version or `--from`, an installed copy newer than the release, or a local build of the same version (other bytes), stays, with a usage error that says so: an update once put 0.4.0 over the main build another session's bench used, which then failed on an unknown metric. `--force` replaces it anyway. - `leave [--yes]`: undo `up` (refused while the daemon runs a bench: `bench --stop` it first): MCP registration, autostart (it asks the daemon to quit and waits up to 5 s for it, then removes the Run key, boots out and deletes the LaunchAgent, or disables and deletes the systemd unit), skill folder, helper, state dir (recordings included, after confirmation). Leaves the binary. The MCP entry in opencode's config is found by parsing its JSONC (strings, comments, trailing commas) as `mcp.strobe`, and cut or given `"oauth": false` in place; a `"strobe"` anywhere else is left alone. ### 5.11 `strobe daemon` / `strobe mcp` / `strobe helper` / `strobe stats` (hidden) `daemon`: §16. `mcp`: the MCP server on stdio, for debugging. `helper`: §13.4. `stats`: known-answer checks of §9.3 without a bench: one JSON request a line on stdin (`{"op": "wilcoxon", "d": […]}`, `hl`, `theil_sen`, `compare` with `metric`, `pairs`, `bound` and `noworse`, `repeated_p`, `pairs_needed`, `pairs_for`, `family_min_pairs`, `median_interval`, `pct`, `pct_ci`, `pct_vs`, `bound`, `value`, `signed`, `times` (times printed together, `signed`), `verdict` (a comparison as result.json keeps it, its words drawn again); each request may name `time_unit` (`us`, the default, `ms` or `s`) and `ascii`), one JSON answer a line on stdout, with the same code a bench runs; it reads and writes nothing else. `scripts/dev/stats-check.ts` feeds it synthetic pairs and checks the answers against brute force. `simulate` (`trials`, `seed`, `effect` or `effects` with `journeys`, `noise` `{kind: normal|t3|spike, sigma, p}` per block, `pairs`, `bound`, `noworse`, `first`, `discard`, `order`, `secondaries`, `looks` with `confidence`) runs synthetic series through the bench's own decision code (`bench::sequential`: looks, futility, run-on, closing verdict, gate and Holm; a family through `family::Board`) and counts verdicts, coverage and family claims; its counts depend only on the seed. `scripts/dev/stats-sim.ts` runs the rows §9 quotes. ### 5.12 `strobe look` `look [--until ] [--wait 1s] [--size 1280x800] [--out ] [--chars 4000] [--full | --element ] [--headed] [--timeout 30s]`: a page in a fresh headless Chromium (strobe's usual flags and a throwaway profile), nothing measured, for checking that a UI loads and says what it should (an agent on a locked session, a report or tool page) without writing a browser script. `--until` takes the page selector grammar (§9.6) and waits as `waitFor` does. After the load event (and `--until`'s element visible, then `--wait`) it prints the title, the address, the open dialogs and headings a case's `expect.heading` checks (the same probe, §9.6), the console messages and uncaught exceptions with where they came from, the page's visible text (`innerText`, the first `--chars`), and writes a screenshot (`--full`: the whole page; `--element`: only the first visible element the selector matches, at full size wherever it is on the page, so one section of a long report reads; a selector that matches nothing is a usage error). A local file opens as `file://`. A page-only flag given to a native look, or an app-only one (`--keys`, `--build`, `--app`, `--home`, `--windowless`) to a page's, a `tty:` look's or a `wir:` look's, is a usage error rather than ignored, and so is a second target or arguments after `--` (a native app's own). Each mode refuses the others' flags (exit 2): a native look `--element`, `--full`, `--size`, `--scale`, `--chars`, `--headed`, `--engine`, `--mobile`, `--type`, `--press`; a `tty:` look the page's and the app's flags, `--engine` and `--mobile`; a `wir:` look `--size`, `--scale`, `--headed`, `--engine`, `--mobile`, `--type`, `--press` and the app's flags. A target of another command is a usage error (exit 2) before anything launches, never an address to navigate to: `pid:`, `proc:`, `cdp:` and `ws://` name `strobe record `, and `node:` and `bun:` a bench file's `script()` target or `record pid:` (it navigated to the text: `net::ERR_ABORTED`, exit 124). Only a page look takes `--engine` (anything but `chromium` refused `EngineUnsupported`, exit 4). Exit 124 when the load event or `--until` never came; the page's own errors are listed, not an exit code. The MCP tool `look` (§17) is this command, so an agent checks a screen without a shell. `--mobile[=]`: the page in Chrome on an Android phone or emulator (13.1 "Chrome on a phone"), at the phone's own screen (`--size`, `--scale` and `--headed` are usage errors); a local file is served to the phone from its folder; the load line names the phone, its viewport and device pixel ratio, and the JSON keeps `phone`. A page look opens Chromium only: `--engine` naming another engine is refused `EngineUnsupported` ("look opens a page in Chromium only, not in firefox") before anything launches, never silently run in Chromium; its fix offers `strobe record --engine ` only where that engine's page runs here (`web::refusal`: not a Firefox before 148, not WebKit on Windows). `look app: | exe: [--app ] [--build] [--windowless] [--until ] [--wait 2s] [--keys 40] [--out ] [--timeout 30s] [-- ]`: a native app launched in an isolated home as a bench block launches it (its profile from `strobe.json`; everything after `--` goes to the app as its own arguments, after its profile's, as a `native(path, { args })` target's do), nothing measured, then closed with its home: after its main window opens (and `--until`'s element shows, then `--wait`) it prints the windows' titles, the accessibility tree's window names, open dialogs and headings (what a case's `expect: { title, heading }` matches, read the same way, §9.6), and the visible elements that carry a key, with role and name; the screen is read until two reads 250 ms apart agree on the tree's node count (a lazy provider fills its tree in after its first answer without a notification), and a web view that shows no heading is read again (§13.7 "Screen reads"). `--out ` writes the main window's client area as the window draws it (`host::window_image`: Windows `PrintWindow` in the window's own DPI awareness, so a DPI-unaware window at its own, smaller pixels), owner-only: text `shot`, JSON `screenshot` (its path, or null). JSON `web_view`: the main window is or holds one of Chromium's windows (Windows: a `Chrome_*` class); a web view with no heading gets its own `headings none` note (UI Automation gives a client that is not a screen reader the content's form controls, and its headings only once Chromium turns on its full tree; `expect.heading` refuses it `NotForTarget`). `--engine` and the other page flags are usage errors on a native look. The tree reads on a locked session too (input does not; Windows reads the window's pixels there too), so an `expect` can be checked before an unlock. `--until image=[,threshold=][,scale=]` (on a page too; the path relative to where strobe runs) waits for an image anchor (§9.6 "Image anchors") instead and lists every place it shows (`image`: score and rect in the client area or viewport); one that never shows says where the best place was (`AnchorNotFound`). A `tty:` look refuses it `NotForTarget`. Exit 124 when the tree or `--until` could not be read. When the executable is missing it says so and names the newest build of it a bench made (a directory or git-ref variant's output in strobe's `builds/out`), with the `look exe: --app ` that opens it. `--build` (with `app:`) first builds the checkout strobe runs in with strobe.json's `build`, as a bench of that folder builds it (an unchanged one is reused), and looks at that executable. A look stopped midway (Ctrl-C, SIGINT, SIGTERM), of a page or an app, ends every launch it owns (each one's tree, then its group or job: `spawn::end_launches`, which takes the launch registry `Owned::end` also takes, so a stop and a close never race), what still runs stamped with the app's home, and removes a home strobe made, before it exits 130 (`isolate::end_on_stop`; the watcher ends before the look closes what it launched). A browser profile left by a stop goes with the next strobe's `reap_dead_owners`. What a stopped look ends, and what strobe leaves alone, is §5 "Clean-up". The window (and `--until`) is waited for 30 s (`--timeout`). One that never shows, or an app that exits before it opened one, is said with how to look at an app that shows none: `--windowless`, or `window: false` in its profile (`--app ` of such a profile for an `exe:`). `--windowless` (and a profile's `window: false`): a tray app, a service or a command, launched as a windowless bench target is, with its stdout and stderr read through a pipe; nothing is waited for or read of a window (`--until`, `--out` and `--keys` are usage errors). It watches the app for `--wait` (2 s): whether it kept running (`running after 2.0 s · N processes`), else how it ended (`exited with code 0 after 25 ms`, wall-clock durations of the watch, polled every 100 ms, in every unit; a launcher that exits 0 and leaves the app running is no end), and the guards; then `window none (windowless)` and its last 20 output lines (`output`, with the count). Exit 0, also for a command that did its work and exited 0; 124 for a nonzero exit or a guard that stopped it (said on a `!` line). JSON `windowless: true`, `running`, `exit_code`, `ended_ms`, `processes`, `output`, `output_lines`, `problems`. Checked on adais with a console program that prints its arguments: `look app:` of a `window: false` profile and `look exe: --windowless -- a b` exit 0 with its lines, `--exit=3` exits 124, and without the flag the error's fix names it. `look tty: [--size 120x40] [--until text=] [--press ] [--type ] [--wait 1s] [--timeout 30s] [--home ]`: the command on strobe's own terminal (§13.9) in an isolated home, as a `tty()` block starts it, in the folder strobe runs in, nothing measured. Once `--until`'s text shows it sends `--press` (one key or chord) and `--type`, waits until the grid holds still for `--wait`, and prints the terminal's title, when the program first drew and when `--until` showed, its updates and synchronized frames, the bytes it sent, the modes it set (mouse reporting and SGR, the progressive keyboard flags it pushed, cursor keys) and the grid's rows. Exit 124 when `--until` never showed, the grid never held still or the program exited. Runs on a locked session and on a server without a display. ### 5.13 `strobe run` ``` strobe run [--pin auto|none|ccdN|] [--reserve-cpus on|off] [--power high|keep] [--max-time …] -- [args…] strobe run --base "" --head "" [--aa | --check] [--primary ] [--metrics metric.,…] [--pairs auto|N] [--runs 5] [--noworse 2%] [--max-time …] [--equivalence ] [--require-verdict] [--seed ] [--id …] [--pin …] [--reserve-cpus …] strobe run --check --head "" [--base ""] ``` - `strobe run` with neither: `run needs commands: strobe run --base "" --head "" times two against each other (add --check to run each once first), or strobe run [--pin auto] [--reserve-cpus on|off] [--power high|keep] -- [args…] runs your own harness under a bench's conditions` (exit 2). - `--base` and `--head` take command-line strings (§9.6, CLI targets). `a && b` runs both commands if `a` succeeds. A shell's startup counts and the result names it. By contrast, `run -- program arg…` takes already separated arguments: an argument containing `&&` stays an argument. - `strobe run -- ` runs an ad-hoc harness under a bench block's conditions: it takes the device's bench slot (so it never overlaps a timed block), switches to the fastest power plan, pins the command to the target's CPUs (`--pin auto`: as in §9.4; the child gets the mask before it runs, strobe's own threads keep theirs) and keeps this user's other processes off them (Windows and Linux, §9.4), and puts everything back when it exits; its exit code is strobe's. `--max-time` caps its wait for the slot (exit 124, `ConcurrentBench`), never the command. It measures nothing itself: the harness prints its own numbers. A harness timed the same build at 190 ms and 250–330 ms an hour later while other agents kept 60% of the CPUs busy; strobe's reservation covered only its benches. - `strobe run --base "" --head ""` (`--aa --head ""` for an A/A, `--check --head ""` to run it once) is a paired A/B of two command lines with everything a bench has: blocks of fresh runs alternating, warmup until steady, Pocock looks, `--noworse`, `verdict_kind`, the slot, power, pin and reserved CPUs, `report` and the PR block. Each run starts the command and waits for its exit, measured from outside (§9.6 "CLI targets"): `cpu.work` (the default primary, `--primary` another), `run.duration`, `cli.launch`, `cpu.wakes`, `memory.peak`, `io.*`, and `metric.` from the `strobe:metric = [lower|higher]` lines it prints (`--metrics metric.x,…` declares them, so they are tested). It is `bench` on a file strobe writes (`/run/cmd-/`) with `cmd:` variants, so each command runs in the folder strobe was started in (a `cmd:` variant's root); `--pairs`, `--runs`, `--equivalence`, `--max-time`, `--require-verdict`, `--seed` and `--id` are bench's. Checked on vps: a 20 M-iteration loop against 22 M read `cpu.work` +9.2% [+8.1 … +11] and `metric.loop_ms` +9.8% after 8 pairs (2.2 s). A/A, the same command on both sides: 100 experiments with `cpu.work` primary said `same` 99 times and `better` once (1%); 150 with `run.duration` of a 6 ms command and a 0.3% bound (so almost nothing is `same`, and most went to 12–40 pairs) said `better` 4 and `worse` 2 times (4.0%, nominal 5%). - Its text says `strobe run · exp_… · · seed …` (never the internal file's name), prints no `actions` line (its one action is the command the person typed), and gives the calibration note (`StaleCalibration`) only when a case runs a page, app or native target (a command runs no speed check). - `--check --head ""` runs the command once; `--check --base "" --head ""` runs each once (from 0.34.0; before, `--base` with `--check` was refused), base's line under head's, and ends `base and head each checked; nothing compared: the same command without --check times head against base` (exit 0), or fails when base fails (exit 1, §5.6). Without `--metrics` a check declares `run.duration` beside its primary `cpu.work`, and its line shows `memory.peak` too where a run measured it (undeclared: a small Linux command's masked peak would refuse it): `pass run · command cpu.work 22,423 µs · run.duration 35,830 µs · memory.peak 63.5 MB` (0.34.0, adais). With `--metrics` it declares those named only; a comparison declares only what `--metrics` names. - `--noworse`: a bare margin is `--primary`'s (else `cpu.work`). A name that is no metric exits 2 before anything runs: `--noworse: is no metric (a command gives cpu.work, run.duration, cli.launch, cpu.wakes, memory.peak, io.read, io.write and metric.)`. With `--check` every `--noworse` exits 2: a check runs each command once and compares only exact counters, which a command gives none of (`--check --noworse cpu.work: … cpu.work needs timed pairs: strobe run --base "" --head "" --noworse cpu.work=2%`; for an exact counter `--check --noworse count.x: a command gives no count.x …`). - `run` takes no `--plan`. The plan a `strobe run` comparison prints as it starts (§9.1 "Plan") reads only earlier runs and checks of one of its commands, never another command's; with none it says the block time is unknown. ### 5.14 `strobe prune` ``` strobe prune [builds|seeds|gpu-cache|symbols|sdk|engine|run|recordings|experiments …] [--all] [--older-than ] [--dry-run] [-f|--force] [--json] ``` Removes what strobe keeps and no process uses, like `docker system prune`. Everything strobe keeps falls in one category (`app::prune::CATEGORIES`): | Category | Class | What | |---|---|---| | `builds` | cache | a git ref's worktree (`builds/`, with its executable's copy, build key and log), a folder variant's executable copy (`out/`), a repository's compile cache (`cache/`), failed builds' logs | | `seeds` | cache | shared copies of large seed files (§13.2) | | `gpu-cache` | cache | GPU shader caches of warm launches (§13.2) | | `symbols` | cache | downloaded PDBs and gathered dSYMs (§12.2) | | `sdk` | cache | other strobe builds' bench SDK | | `engine` | cache | trace_processor versions this build does not use | | `run` | cache | launch folders of strobes that are gone, temporary files of processes that ended | | `recordings`, `experiments` | data | not made again | | `history`, `logs`, `other`, `install` | kept | tracked series, logs, settings and tokens, strobe's executables, helper and skill outside the state folder: never pruned | - **What goes.** With no category, every cache entry no process uses now (docker's "unused"; no age). Named categories narrow it. Data goes only when named or with `--all`, and only what was last used more than 30 days ago (`--older-than` sets the age for every category it names; for caches it narrows by last use). Last use: a cache entry's stamp (below; a GPU cache store's too, as a launch gives it back without writing its folder), else its newest write; a recording or experiment's newest file (`store::resolve` touches its `.used` on each use). A stamp whose entry is gone (a store an older strobe removed without it) is an entry of its own (`.used`), last used when it was touched, removed under its own hold (one a process leases before it makes the entry, a seed copy, stays). - **What stays, and why** (each said in the plan, counted by reason; the in-use ones by name): a build, seed copy, GPU cache store or `run/cmd-` folder a live process leases (below); every build, and every other engine version, while a bench of a strobe build before leases runs (its registration has no `leases`, or only `run/bench.lock` names it), and other engine versions while the daemon is another build; a seed copy linked into, or a GPU cache store lent to, a launch of a live strobe; an experiment's GPU cache store (`-`) while the experiment runs or can resume (`state.json` and no `result.json`), and a `run/cmd-` folder such an experiment names; this build's SDK and engine, and another build's SDK used within a day (older builds mark it only when a bench starts); a launch folder whose strobe runs; a temporary file named after a live strobe's pid, or, without a pid, from the last day; a recording folder from the last day without `meta.json` (being written) or made by a bench that runs; a running experiment; an experiment tracked history names (`history/`, `strobe history` drills into it); and what any experiment that stays names in its `result.json` or `state.json`, whatever the handle's prefix (`bench --id`): another experiment (a bisect's `_s` steps), again for what that one names, and the recordings any of them name (their views would break). The bench slot, the bench registry, phone journals and open trace sessions in `run/`, and `experiments/index.jsonl` (the rerun ledger counts pruned experiments too) are never entries. What started between the plan and the removal (a person may take minutes to answer) is read again before each entry: the bench registry and slot, the launch folders and what they borrow, the experiments that can resume (for a store or a `run/cmd-` folder); the daemon's build once. - **Leases.** A cache entry has a stamp beside it, `.used`: its time is the entry's last use, and a process using the entry holds a shared lock on it for as long as it does (a bench: each variant's worktree and executable copy until the experiment ends; a build: the repository's compile cache while it runs; a launch: its seed copies, from before a copy is made, and its GPU cache store; an experiment: the seed copies and GPU cache stores its launches took, until it ends (`host::cache::keep`), so its next block finds them warm; `look --build`: the executable copy it runs; `strobe run --base/--head`, and a bench of the file it wrote: `run/cmd-`). The OS drops a dead process's lock. `prune` removes an entry only while it holds the stamp's exclusive lock, so it never takes what a live process uses, and a bench that starts on an entry being removed waits for it and makes it again; the stamp goes with the entry, and a lease checks that the file it locked is still the one at the path (its volume and 128-bit id on Windows, as ReFS needs; device and inode on Unix). An entry that cannot be leased that way is an error (a seed is then copied, not shared), never a lease that holds nothing. pdb and dSYM hits touch their stamps. - **The plan first.** Per category: entries and bytes it frees, what stays and why, the in-use entries by name, the total, and with data the age. On a terminal it asks `Remove N entries, X? [y/N]`; `-f`/`--force` does not ask; with no terminal (or `--json`) and no `--force` it prints the plan, removes nothing, says `strobe prune --force removes these` and exits 3, so an agent never deletes by accident nor hangs on a prompt. `--dry-run` prints the plan only (exit 0), takes no lock and makes no lock file. After a removal: what went per category, what failed, and `Total reclaimed space: X`, the bytes of the files whose last name it removed, counted file by file as it went (not the plan's estimate). - **Safety.** Removal is `host::disk::remove`: every path is checked to lie inside its root (the state folder, Snap Firefox's profile folder, a worktree's record in its repository) with the folder above it canonicalized; a symbolic link, junction or mount point is removed itself, never followed; a filesystem mounted inside an entry or on the entry itself (a bind mount, a tmpfs) is never entered (its mount point is named as a failure and stays; the filesystem is the entry's parent folder's, and only folders are compared, as a file's device can be a lower layer's on overlayfs); a folder is emptied file by file (paths past 260 characters through `\\?\`, names kept as UTF-16 units), read-only files and folders made writable first, and a file that cannot go (held open, denied) is named while the rest goes. On Unix the removal goes through open folders (`openat` with `O_NOFOLLOW | O_DIRECTORY`, the folder's device and inode checked against what `fstatat` saw, then `unlinkat`), so a folder swapped for a link meanwhile is never entered; on Windows a folder is held open without `FILE_SHARE_DELETE` while what it holds goes (nobody can rename, move or delete it and put a junction there meanwhile), is checked through that handle to be a real folder and listed through it, and only while it is still the one the listing named (its id). One entry's failure does not stop the others; the exit code is 1 when any failed, and a failed entry keeps its stamp's earlier time. Recordings and experiments go through `store::remove`, their only writer. A launch folder's leftover processes end and its URL schemes are restored first (`isolate::release_gone`, as `reap_dead_owners` does). What a launched app can write in its home is never trusted: a `.strobe-shared` line counts only for a link inside the home to a copy inside `/seeds`, both one regular file (no read-only mark is changed through a link); `.strobe-gpu-cache` only for a store inside `/gpu-cache`, and only real folders of the home (no link) move back into it; a saved URL scheme only for a scheme's name that is no class Windows or the shell owns (`http`, `https`, `file`, `exefile`, `clsid`, `directory`, `folder`, `ms-*` …), saved in a regular file (no link), a URL scheme's key (`URL Protocol` in the saved `.reg`, or for one that did not exist, in the key there now), and a `.reg` that holds that scheme's key alone; macOS's `.strobe-macos-library.json` only as a regular file, its id and bundle name only as plain names (no `/`, no leading `-` or `.`), each place only when it is exactly one of that id's or name's Library folders, no link, and made after the home was (an older one is the user's), removed through `host::disk` (`defaults delete` only for a preferences file so made). Before this, a line of `.strobe-shared` naming any file made reaping a dead launch delete it. One prune at a time per state folder (`run/prune.lock`; another running is exit 1). Not an MCP tool. - **Worktrees.** After its folder goes, a worktree leaves its repository's list through `git --git-dir worktree remove --force --force ` (git accepts a folder already gone), and by hand only when git cannot and the record names that folder. The record (from the worktree's `.git`, relative to it with git's worktree.useRelativePaths) and the repository are resolved while the worktree is there, and trusted only as `/worktrees/` of a git folder with a `HEAD`; the record's own `gitdir` is read relative to the record. A bench that finds a git ref's folder gone but still listed (removed by hand) removes that one entry the same way before `git worktree add`, which otherwise fails (`missing but already registered worktree`). - **Sizes.** What files take on the disk (their allocation), each file once however many names it has, never through a link, on the volume of each walked path (a filesystem mounted inside is left out), with each folder's own blocks: its lstat on Unix, its index on NTFS, which the parent's listing gives as 0 (reading it from the folder's handle added 977 MB, 1.3%, to adais's builds: their node_modules folders). A file NTFS keeps inside its own records (under about 700 bytes) takes no allocation and counts 0, as Explorer's "size on disk" says; Git's `du` counts each 1 KB (35 k of them made a 3 GB worktree read 1.1% larger there). Windows lists no file's link count: a walk asks each file of 1 MB or more by name (`NtQueryInformationByName`, about 40 µs each) and counts a smaller one as having one name, once per walk by the id its folder's listing gives (a hard link inside one entry is not counted twice); asking all 2.1 M files of adais's 116 GB state folder took the walk from 14 s to 104 s, and moved its total by 0.02% and the reclaimable bytes by 0.03% (small files a compiler links under two names count twice: a 9.7 GB compile cache read 0.26% over `du`). A file with a name outside every walked path frees nothing when the entries go, and is not counted as reclaimable (exact on Unix; on Windows for the files asked). A recording or experiment (but one still being written or running, whose logs grow without moving its folder's time), and a build entry of 1000 files or more, keep their walk in `disk-cache.json`: one is counted from it while its fingerprint holds (a data folder's own time, which every file landing in it moves; a build's paths, what each holds directly, and their stamps, which every use touches) and for at most a day. A status's deadline holds inside each walk (no folder is listed past it), so one 300 k-file worktree cannot take it past its 4 s; an entry or a category's rest the deadline cut counts as unmeasured, its bytes a floor: JSON `bytes`, `total_bytes`, `reclaimable_bytes` and `outside_bytes` are null then, with `at_least_bytes` and `reclaimable_at_least_bytes` beside them and a per-category `unmeasured`, and the line says `at least` before each number. Snap Firefox's profiles (`~/snap/firefox/common/strobe`, `strobe-` for a state folder other than the default one, so a scratch state never reaps, counts or prunes the default one's) count as outside the state folder. - **Automatic retention.** Caches unused for 14 days go on their own, at most once a day, never beside a bench or a timed recording anywhere on the machine (`slot::busy`: a bench registered here, this folder's slot held, or the machine-wide lock held by another user's or state folder's), checked before it starts and again before each entry, so a bench that starts stops it there (the day is then not recorded): after a bench, track or run from the command line that succeeded (not `track --schedule`, `--schedules` or `--unschedule`, a stop or a plan; at a bench's start it would land on the timed blocks' disk and CPUs) and in the daemon's minute tick, with one line (`strobe: pruned 3 builds, 1 seeds unused for 14d: 12.4 GB …`) and `/prune.json`. `"prune": { "unused": "30d" }` in the user's own `/strobe.json` changes the age, `"off"` turns it off; any other value is a usage error of every command that reads strobe.json, and a project's strobe.json may not set it (compared with the user's file canonically, so `strobe config` in the state folder reads it). A user strobe.json that cannot be read (denied, a folder; UTF-16 after its byte-order mark, as Windows PowerShell 5.1's `>` writes, is read), or does not parse, refuses the automatic prune, as does `"0s"` (the hint says `"off"`), the daemon's tick too (a command that reads strobe.json fails on it first), and `status` says why (`disk.auto_refused` in JSON): the 14-day default never comes back behind an `"off"` the file meant. It replaces the GPU cache stores' week, the seed copies' week and other builds' SDKs' 30 days. - Checked on adais (Windows 11) and vps (Linux) with `scripts/dev/prune-check.ts` on a scratch state folder: status total within 0.03% of `du` (vps 0.006%); no terminal and no `--force`, and `--dry-run`, remove nothing; `--force` removes exactly the plan; the canary behind a junction (symbolic link on Linux) out of the folder, the leased worktree, the bench registry stay; on Windows the entry with a file held open fails by name (exit 1) and its neighbour goes; `git worktree list` has no stale entry; `Total reclaimed space` equals `du` before minus after within 0.03% (vps 0.05%); `--all` removes an old experiment and the recording only it names and keeps an old recording a new experiment names and an old experiment tracked history names. Since: a `status --fast` the deadline cut on a 30 k-folder compile cache (0.3 to 0.8 s) gives null `total_bytes` and builds' `bytes`, an `at_least_bytes`, and `at least` inside the parentheses too; `--dry-run` makes no `run/prune.lock`; a dead home's `.strobe-shared` lines naming a file outside and a path through a junction (symbolic link) out of the home delete nothing; a worktree git records with relative paths goes and leaves no stale entry (adais, git 2.56); a kept bisect's old `_s1` and the recording it names stay; a `run/cmd-` a resumable experiment names stays; on vps a tmpfs mounted inside an entry fails by name (`another filesystem is mounted here`), its file stays and its neighbour goes. Second review: on vps a tmpfs mounted on an entry itself fails the same way without being entered (its file stays); a stamp whose store is gone is planned and removed, one a live process locks stays `in use`; `prune gpu-cache --older-than 7d` takes a 20-day-old store and leaves one whose folder is 20 days old but whose stamp is from today; on adais every folder removal held its folder open without `FILE_SHARE_DELETE` (the checks above all pass through it). The hostile `.strobe-macos-library.json` cases (an id `-g`, an id `../Other`, a place outside the Library, a place through `..`, a link at an expected place, places older than the home) pass on air (macOS 27, M4): places older than the launch and another app's named in the record stay, the place the launch made goes, a link at an expected place is neither removed nor followed; there prune-check passes 34 of 34 (status and `Total reclaimed space` equal to `du -k`; macOS has no `flock` command, so its lease holders use perl's `flock`, the same flock(2)), prune-lease 11 of 11 (macOS moves no driver cache, so a prune between launches keeps the seed copy and no launch claims a GPU cache state) and prune-auto 11 of 11. air's real state, read only: `status --disk` 55.6 GB (binary; `du` 59.7 GB decimal, the same bytes), `prune --dry-run` 179 entries, 51.1 GB (builds 48.5 GB in 19), recordings and experiments kept. `scripts/dev/prune-lease.ts` on both: while a `bench --check` of a git ref runs, the plan keeps its worktree `in use` and `prune --force` leaves it; the bench made the worktree over a stale entry; once it ended the worktree was a cache the plan removes; the bench's `reap_dead_owners` of a dead home whose `.strobe-shared` and `.strobe-gpu-cache` aim outside touched nothing there; a `prune --force` between the launches of a windowless native `--check` with a 65 MB seed kept the GPU cache store and seed copy (`in use`), the launches after it were warm and the seed was not copied again; once the experiment ended, its store and the store's stamp were gone. `scripts/dev/prune-auto.ts` on both: a user strobe.json that does not parse made the daemon prune nothing and `status` say why; no automatic prune after a failed `run` or `track --schedules`; the daemon pruned nothing while a bench ran and pruned the old caches on its tick once it ended; a bench that started during an automatic prune stopped it after 5 to 8 of 300 entries (vps 13 to 16 of 600) with no day recorded; nor after `track --schedule nightly` (exit 0); `"0s"` and a strobe.json that cannot be read (a folder in its place) refuse it, said in `status`; a UTF-16 strobe.json saying `"off"` is read (the daemon pruned nothing, nothing refused). On vps under `script` (a terminal): `n` removes nothing, `y` removes the plan; automatic retention removed a 20-day-old compile cache and seed copy and kept a 5-day-old one, ran once a day, not at all with `"off"`, and at `"3d"` took the 5-day-old one. Again after the fixes (read only): adais 128.06 GB (decimal; complete), the fresh walk 22.9 s, then `status --fast` 0.78 s (0.26 s the disk) and `status` 3.5 s, a plain prune 96.2 GB; vps the state folder within 4 KB of `du`, a plain prune 1.68 GB, 2 GPU cache stores and a `run/cmd-` kept for experiments that can resume. The reviewer then ran a plain `strobe prune --force` on adais's real state from 5c86c84: 639 entries, 96.2 GB in 7 minutes, exit 0; `status` then 23.1 GB, no stale worktree entry. The real state folders, read only (`status`, `prune --dry-run`): adais 119 GB (2.1 M files; the first walk of everything 14 to 25 s, then `status --fast` 0.64 to 0.98 s with 0.14 to 0.24 s of it the disk, `status` 3.4 s; against `du`: seeds within 35 KB of 25.2 GB, recordings 0.01%, experiments 0.3%, builds 0.5% under it over the 64 GB `du` walked, all but four worktrees it took over 3 minutes each on, the tiny files above), a plain prune would free 96.2 GB (119 builds 70.6 GB, 37 seed copies 25.2 GB), `--all` the same (no data older than 30 days); vps 4.24 GB (4.22 GB in the state folder, `du` 4.22 GB, within 4 KB), a plain prune would free 1.68 GB (20 builds 1.66 GB), `--all` the same. ## 6. Clock One monotonic timeline per recording. - Chromium: trace timestamps (base::TimeTicks). strobe calls `Tracing.recordClockSyncMarker` at start and end and records the OS monotonic time of each call. - OS, GPU and helper samples: `QueryPerformanceCounter` (Windows), `CLOCK_MONOTONIC` (Linux), `mach_absolute_time` (macOS: the clock Metal's presented times, CoreAnimation and CVDisplayLink report in; it stops while the machine sleeps, which no valid run spans). - strobe's own trace packets would carry a `ClockSnapshot` that maps its clock to the trace clock (not built: strobe's own tracks are merged into `strobe open`'s trace instead, §11.4). - A recording whose sync markers disagree by more than 1 ms with the mapped OS clock fails `ClockUnsynced` (§10.1); built today for a page launch's `until` (its page clock against strobe's span, §9.6) and an app's `STROBE_EVENTS` that arrive later than this OS delivers them (§13.9). ## 7. Metrics ### 7.1 Names and properties Dotted names: `.[.]`, for example `frames.presented.p95`, `main.work`, `memory.app.private`, `gpu.process.3d`, `cpu.process.total`. Each metric declares: | Property | Values | |---|---| | unit | `ms`, `bytes`, `MB`, `MB/min` (growth), `count`, `count/min`, `score`, `%`, `mJ` (energy per run), `instructions` (per run), `/s` (wakes per second), `value` (a number a command or app printed, in its own units: `metric.`, `app.`, §9.6) | | better | `lower` or `higher` (`metric.`: as the command's lines say, default `lower`) | | kind | `timed` (wall or CPU time), `sampled` (statistical estimate), `exact` (a count, compared exactly when it is stable, §9.3) | | scale | `ratio` (compare log ratios) or `absolute` (compare differences); counts and scores are absolute, since 0 has no ratio | | minimum n | e.g. a percentile p needs `⌈10 / (1 − p)⌉` frames per block | | phase | which phases it covers; default the whole run | | across | how it compares between two implementations (§9.10): `compared` (the same instrument reads the same quantity on any implementation), `care` (only as a total, with a note saying what it sums on each side), `per_side` (one engine's own: kept per side, never compared across engines) | **Units.** Sizes are binary: a KB is 2^10 bytes, an MB 2^20 and a GB 2^30, in metric values (`memory.*`, `js.heap.*`, `gpu.vram`), views, reports and cards alike (`fmt::bytes`). `net.bytes` counts bytes. Counts in millions (`M instr`) are decimal. **Times.** A measured time prints in microseconds: `18,309,038 µs`, `42.0 µs`. `--unit ms` or `--unit s` prints milliseconds or seconds instead, never promoted to a larger unit as a value grows. The unit is, first to last: `--unit` (on `record`, `bench`, `plan`, `query`, `report`, `look`, `run`, `status`, `top`, `track` and `history`; `us` or `µs`, `ms`, `s`, else exit 2 `--unit is us, ms or s, not `), `"unit"` in the project's strobe.json, `"unit"` in the user's own `/strobe.json`, else `us`. The project is the one the command is about: the nearest strobe.json above the bench file it names (`bench`, `plan`, `track`, `record --bench`) or the bench file a queried or reported experiment ran (`result.json`'s `file`), else above the working folder; so the daemon and a command run from another folder take the project's unit. A command strobe runs for another (a bisect's steps, `record --bench`'s bench) keeps the unit it was given. A `"unit"` strobe does not have is a usage error with its place (`::: unit must be one of "us", "ms", "s", not …`); only `"unit"` is read for it, so another key's problem never fails a `query` or a `report`. `--unit` on a command that takes none is refused as any such flag is (`config has no flag --unit`), and `--help` prints the help whatever `--unit` says. An MCP call's `unit` is its own (§17). `fmt::time` prints them, and `report.html` and `history.html` print them alike, to the character: - Every whole digit, grouped by threes with `,`; then at least three significant digits (2 decimals from 1, 1 from 10, none from 100; below 1, as many as three significant digits need), and never more decimals than the unit's finest step: 0.01 µs, 0.0001 ms, 0.000001 s. The decimals are taken again from the rounded value: 99.96 ms prints `100 ms`, not `100.0 ms`. Rounding is Rust's `{:.d}`: the nearest decimal, an exact tie to even. - Exactly 0 is `0 µs`. A nonzero time that rounds to all zeros, or that is 0 only once scaled to the unit (an underflow), is the floor, `<0.01 µs`, `<0.0001 ms` or `<0.000001 s`: a measured time is never printed as nothing (rule 1); it keeps its sign (`−<0.000001 s`). Exactly 0 is the only time that prints as 0. A value that is not a finite number, or not one once scaled, prints `—`. Nothing prints as minus zero, of any kind (`0 µs`, `0 B`, `0 instr`, `0.0`). - A value too large to print exactly (|v| ≥ 2^53 in the shown unit) prints three significant digits in scientific notation with its usual unit (`1.00e16 µs`, `1.00e307 B`, `9.01e15` for a count); a percentage that overflows prints `—`. A frequency (`Hz`) prints whole (`60 Hz`). - A difference is signed: `+` above 0, `−` (U+2212) below, no sign on exactly 0 (`+<0.01 µs`, `−42.0 µs`, `0 µs`). - One ASCII space, then the unit: `µs` is U+00B5 MICRO SIGN (the cards' font has it, not U+03BC); Markdown table cells put U+00A0 there. A terminal whose locale is not UTF-8 (`LC_ALL`, else `LC_CTYPE`, else `LANG`, set and naming no UTF-8: `C`, `POSIX`; on Windows too) gets `us`, and `-` for the minus of times and percentages; nothing else changes. An unset locale is UTF-8 (a Windows console takes UTF-16, a pipe UTF-8), and what `report` writes or prints (Markdown, pages, cards) and MCP answers are always UTF-8. - Times printed together (a median and its interval, an estimate and its ends, `base → head`) each print as they would alone: `+3.20 µs [−1.20 µs … +12.4 µs]`. Shared decimals printed 0.05 ms beside 250 ms as `0.1 ms`, or digits a value does not have (`12.40`). - A time's absolute bound or margin in a verdict is a time (`same (±40.0 µs)`, `no worse (≤1,200 µs, faster)`); a stored verdict's words are drawn again from its `bound` (§9.3). | Stored (ms) | µs | ms | s | |---|---|---|---| | 0 | `0 µs` | `0 ms` | `0 s` | | 0.000004 | `<0.01 µs` | `<0.0001 ms` | `<0.000001 s` | | 0.0004 | `0.40 µs` | `0.0004 ms` | `<0.000001 s` | | 0.042 | `42.0 µs` | `0.0420 ms` | `0.000042 s` | | 0.5 | `500 µs` | `0.500 ms` | `0.000500 s` | | 0.99996 | `1,000 µs` | `1.00 ms` | `0.00100 s` | | 1.234 | `1,234 µs` | `1.23 ms` | `0.00123 s` | | 9.996 | `9,996 µs` | `10.0 ms` | `0.0100 s` | | 16.6667 | `16,667 µs` | `16.7 ms` | `0.0167 s` | | 99.96 | `99,960 µs` | `100 ms` | `0.100 s` | | 738.147 | `738,147 µs` | `738 ms` | `0.738 s` | | 1234.5678 | `1,234,568 µs` | `1,235 ms` | `1.23 s` | | 18309.038 | `18,309,038 µs` | `18,309 ms` | `18.3 s` | | −0.042 | `−42.0 µs` | `−0.0420 ms` | `−0.000042 s` | `scripts/dev/stats-check.ts` (`--only units`) holds `strobe stats` to this table and its edges in every unit (§5.11), and `scripts/dev/surfaces.ts` every surface of every experiment to it (§11.2). What is no measured time prints as before, in every unit: a wall-clock duration (a recording's length, a block's `time` line, an ETA, a wait, a run's length in a PR block's `per 77 ms run`: `850 ms`, `12.3 s`, `2.5 min`; a place on a timeline, a cohort's `run:3 +1.25 s` or `busy from 0.0 s to 2.0 s`; the OS input map's own wait clock, `pointer moves in 352 ms`), a configured value (a cadence, a timeout, a threshold such as the HTTP probe's `at least 50 ms`, `≥ 1 ms/run`, `by 20 ms or more`, a page hidden for 200 ms), a duration given to strobe (`--for 10s`), the names in `strobe open`'s trace, and text strobe stored when it measured: warnings, refusals and notes, a pixels run's text, a `--check --noworse` counter's `text`, calibration's `summary`. Those keep the unit of the strobe that wrote them. Outputs this file quotes from runs before the release that made microseconds the default (dated, or from a named machine) show times as that strobe printed them, in ms. Only what people read changes. `--json`, MCP `structuredContent`, `result.json`, `meta.json`, `history/` and every other stored number keep their metric's unit (a `ms` metric in ms; `sampling.*_us` in µs). `--json` output is the same bytes for every `--unit` and locale: numbers in their metric's unit, prose fields (`text`, a cohort's `says`, an insight's `title`, every other string) in µs and UTF-8; `report --json`'s `markdown` is the exception, the report as written, in the unit asked for. MCP answers follow the call's `unit` in their text only: `content` and the strings in `structuredContent`, never its numbers (§17). A `count.*` is a count (`count.main_thread_cpu_us` too): grouped (`1,234,567`), never scaled or labelled µs. `--unit` is not part of an experiment's identity (the rerun ledger, §14.2) and stays out of the PR block's rerun command; `track` keeps it in a schedule's flags, so its alerts print in it (§25.6). Stored results print in the unit asked: `query summary` and `report --format md` draw a comparison's, a check's and a bisect's text again from `result.json` (a bisect's steps with their verdicts redrawn), and a recording's from `meta.json`. The catalog lives in `src/model.rs` (each metric's unit, direction, kind, minimum n and `across`); `result.json` carries each comparison's unit and direction. Families resolve by their base: `count.` (what `ctx.count` or the page counted, §8.4), `net.requests` and `net.bytes` with an optional `{}` (§8.1), `phase.` and `span.` (§7.3), `metric.` (what a `cli()` target's command printed, §9.6), `app.` (what an app wrote on its `STROBE_EVENTS` stream, §13.9), and `launch.` (§9.6). Metrics a series declares beyond the defaults may switch on what measures them: `count.*` installs the page counter, `canvas.*` the canvas frame hook (§8.4), `gpu.compile` the trace's Dawn categories, `net.*` the Network domain, `layout.shift` the trace's `loading` category, `main.gc` its `disabled-by-default-v8.gc` category. Only that series' blocks pay for it, base and head alike. ### 7.2 Frames - A **frame** is a frame Chromium presented, from its frame reporting in the trace (the exact categories: PipelineReporter in `disabled-by-default-devtools.timeline.frame`, §8.1). Not a `requestAnimationFrame` callback. - **Dropped vsyncs**: vsync intervals in a phase with no presented frame while there was pending work. In Chromium's trace, `frames.dropped` counts the vsyncs PipelineReporter reports `STATE_DROPPED`, except one followed by a vsync where the page wanted no update (`STATE_NO_UPDATE_DESIRED`): no content waited for it, so nothing on screen was late (a page drawing at 30 fps on a 165 Hz display asks for a frame between its updates, and Chromium reported the vsync it missed as dropped: 20% "choppier" for updates that came every 36.4 ms, as before). `frames.partial` counts frames presented without all their updates, per run. - **Frame cost**: from the frame's begin to its presentation. - **Budget**: the refresh interval of the display the window is on (§13.1). No display, no budget metrics (`NoDisplayForBudget`). - `requestAnimationFrame` gaps would be a separate metric named `raf.gap.*` (not built; a WebGPU canvas's own frames are `canvas.*`, §8.4). - **No headless frame metric.** With no display the compositor still begins frames off a clock of its own (no vsync, no screen) and submits them where nobody sees. A frame's span from begin to submit there holds the wait for that clock's next begin whenever the main thread's part misses it: a page spinning 2 ms a frame read 2.5 ms in one run and 16.9 ms (one interval of the clock's 60 a second) in another. Its spread is the synthetic clock's, not the page's, so strobe keeps no such metric (rule 1). What a headless page's frames cost its main thread is `main.paint`, `main.style` and `main.layout` (§8.3), timed from the work itself. - **Pages in another engine** (Firefox, WebKit, headed): the browser reports no frames of its own to strobe, so a page's frames are the OS's presents of its processes, exactly as a native app's (below and §7.4): Windows gives intervals and drops, macOS and Linux X11 counts only. `frames.presented.p*` (begin to screen) is refused `EngineUnsupported`. Its `interaction.*` and `scroll.latency.*` join strobe's own inputs (a click's release, each key press, a scroll's first wheel step, host clock, taken right before strobe sends them, as for native targets) to the first present after them, counted only when the page changed within a second (a DOM mutation, an `input` or `change` event, a scroll: an observer strobe installs only for a series that declares such a metric; the page's epoch milliseconds, whole ones in Firefox and WebKit, are mapped to the host clock and taken as the end of their millisecond). The start includes the WebDriver command's way into the browser, as a person's input includes the OS's; it is never the page's own event time, which would leave out a blocked main thread's queueing (Safari's WebDriver events carry a `timeStamp` on another clock besides). Never the page's Event Timing, whose 16 ms threshold censors fast inputs. Headless there are none: refused `Headless`. - **Where a frame's time went** (Chromium pages and Electron apps): each `PipelineReporter` slice in the lean trace carries its stages as children, which tile it. A presented frame splits into its renderer side (`BeginImplFrameToSendBeginMainFrame`, `SendBeginMainFrameToCommit`, `Commit`, `Activation`, and `EndActivateToSubmitCompositorFrame` or, with the display tree in the GPU process, `EndActivateToSubmitUpdateDisplayTree`: the compositor and main threads) and its GPU process side (`EndCommitToActivation`, where tiles raster, plus the display compositor's `StartDrawToSwapStart` and the swap, `Swap` or its platform's parts `SwapStartToSwapEnd`, `SwapStartToBufferAvailable` and `BufferAvailableToBufferReady`, under `SubmitCompositorFrameToPresentationCompositorFrame` or `SubmitUpdateDisplayTreeToPresentationCompositorFrame`). The waits in between are in neither: the frame in flight to the display compositor, its deadline, the buffer's latch, and the display after the swap (on adais, `SwapEndToPresentationCompositorFrame` averaged 4.3 ms of a 6.9 ms frame, the vsync wait). `frames.renderer.p95` and `frames.viz.p95` are the 95th percentiles of the two sides' stage spans over the presented frames whose reports carry both (`frames.split.n`, their minimum n as other p95s). They name stages, not engines: a stage is a span of the pipeline, queueing included, not CPU or GPU busy time, so neither says a frame was CPU- or GPU-bound (no such share is given). Invariant: the two sides of a frame never add up to more than the frame; a block with any frame that breaks it splits none (`frames.split.bad`), and a block where under 95% of the presented frames carry both sides splits none either (a split of the frames that carry stages would describe another set of frames); both refuse the two (`NoPresentSource`, saying which and how many). A native app's presents carry no stages (`NotForTarget`). Both are one engine's own (`per_side`, §9.10). `query frames` prints the split and each worst frame's stages with their children. Checked on two 100 s lean recordings of pages (adais, 165 Hz): every presented frame in their traces carried both sides (6,716 of 6,716, and 26,393 of 26,393), none broke the invariant. The first: renderer p95 0.94 ms, GPU process p95 2.14 ms, and its worst frame (97 ms) spent 91 ms in the display compositor's draw (`StartDrawToSwapStart`); the second: renderer p95 6.25 ms, GPU process p95 0.74 ms. - **Native windows and any process from outside** (Windows): a frame is a present the OS put on the screen, from its present events (§8.1 `presents`): one record per present with its start, the screen time of its scanout (`displayed`), or `discarded` (replaced before scanout). "One present per surface per composed frame" keys each present by its window, else (a composition swapchain, which has no window) its composition surface, else its process and swapchain; never window 0. At the session's first completed present, only presents that started before every provider ran are dropped as partial. The app's processes' presents are its frames; from outside there is no frame begin, so `frames.presented.*` (begin to screen) is refused. - **Inputs to the screen** (the generic path, for any native app strobe drives): strobe stamps each input it sends (host clock); its frame is the first present of the app's processes that started after it, no earlier than the change it shows, and reached the screen (a discarded one is passed over: a later one shows it). An input counts only when no present of the app began or reached the screen in the two refreshes before it, and every earlier input's frame was on the screen before it: at rest, the next present is the input's; an app already drawing, a frame still on its way (a click's pressed look), or an earlier key whose frame has not begun yet (keys sent faster than the app shows them share its frames) may present a frame begun before the input, so that input is not counted, and neither is one after which the app's accessibility tree did not change (§7.4). An input with no sample because the target was busy says why (`BusyWhy`, `query inputs`): `mid_drawing` { `prev_frame_ago_ms` (negative: a frame begun before it still on its way), `quiet_ms` }, which needed `quiet − ago` more; `behind` { `prev_input`, `showed_after_ms` }, an earlier input's reaction showed after it, which needed `showed_after + quiet`; `ambiguous_press`, its change between press and release may be an earlier input's, announced late; and `multi_frame_pixel` { `frames` }, two or more frames between the captures that proved its pixels. The action log's `sample` stays `busy`. Known answer: `scripts/dev/inputs-busy.ts` (each why, its latency and needed delay as planted). The driver holds a click's press, and waits before a measured input, until the app settles (at most 300 ms), so a static pressed look is on the screen first. An accessibility event carries no source time, so a native frame is bounded only by the input: a frame the app draws at once for another reason (an unpress, a periodic redraw) before an asynchronous update is taken as the reaction (§7.4). An input with no displayed present within a second is unmatched; over 5% of them with no events lost refuses `interaction.*` (`PresentsUnmatched`). `interaction.*` is screen time − input time. - **Refresh**: `display.hz` and the refresh period are the target window's display path's (`MonitorFromWindow` → `QueryDisplayConfig`: its `VidPnTargetId` and nominal rate): the median spacing of that path's vertical blanks (`VSyncDPC` and, on multi-plane drivers, `VSyncDPCMultiPlane`), or its nominal rate when they are too few or disagree by over 10%. With no window, only when every display runs at one rate (`NoPresentSource` otherwise). - **Drops and intervals from screen times**: a frame is pending from its begin (outside-in: its present call's start). Between two frames shown at `a` and `b`, the refreshes in between were missed when `b` was pending before `a` plus half a refresh (an animation that fell behind); a later begin is the app at rest, so the gap is neither an interval nor a drop (as `STATE_NO_UPDATE_DESIRED` resets Chromium's intervals). `frames.dropped` adds discarded presents. Outside-in, a frame the app itself started late looks like rest: only a toolkit's begin times tell them apart. - **Gaps are their own n**: `frames.interval.*` are percentiles of the gaps, so `TailTooShort` counts the gaps (`frames.interval.n`, every target), not the frames: a page that updates in isolated frames (one frame, then nothing wanted) has many frames and no gap. Checked with `scripts/dev/derive` (`gaps`: 10 isolated updates and 5 pairs, headless): `frames.n` 20, `frames.interval.n` 5; the 20 frames met `frames.interval.p50`'s minimum of 20, its 5 gaps do not. - **Quantization**: anything that ends on a presented frame (`run.duration` with an `until`, `interaction.*`) moves in whole vsyncs (6.06 ms at 165 Hz): a tab switch measured 40 or 47 ms and nothing between. Such a metric is the user's truth but decides slowly; pick `main.work` (renderer main-thread task time per run, continuous) as the primary when the question is "does this cost less", and keep the duration as a secondary. ### 7.3 Phases Bench files (`phase(name, fn)`, §9.6) and `@hona/strobe-web` (`phase(name)`) emit phase marks. Run and phase boundaries are `performance.mark` calls in the page (`strobe:::…`), one `Runtime.evaluate` per boundary, placed before the first input and after `until` resolves. They land on the trace clock, so windows need no clock mapping. Every metric can be sliced by phase. A headed page's run ends on the page's next frame (what its last step drew); a headless page's run ends at its last step, and strobe waits for that frame after the run's end mark, untimed: a headless page's frames come from a clock of the browser's own (two animation frames took 60 to 165 ms on a locked Windows machine, 8 to 18 ms on vps), never a display's (7.2). A headless run whose last step changes the page without waiting for what it shows passes `{ until: "frame" }` to that step. strobe reads the Performance domain's counters after that frame, so `main.work` and its parts hold the work the run left for it every time, as a headed run's span holds its frame (they answer on the page's main thread: read before it, a frame that had begun was in them and one that had not was not, so a 7 ms run on vps read `main.work` 43 ms and `main.busy` 610%). The Performance domain's `main.busy`, in every page block, divides `main.work` by the wall time its counters covered, from asking for the first reading to the last answer (the run, its edges' reads and, headless, that frame), never by the run's alone, which holds less than the counters can. A run whose counted task time still passes the wall time the counters covered had another run's work counted in it (vps, headless: a 4 ms run whose 2,000 cells' layout spilled into the next read 385%): `main.busy` of that block is refused `RunIncomplete`, never printed. When the frame did at least 1.0 ms and 5% of `main.work` of style and layout after the run's own reading, or layouts where the run's `layout.count` or `main.layout` reads 0, and the series declares a metric read before that frame, the series warns `HeadlessFrame` with the time and those metrics (a step that wrote 2,000 cells' widths through `page.eval` and read nothing: 7.53 ms left to the frame on adais; the same change through a click is laid out inside the run, no warning). Read before the frame: `run.duration`, `cpu.*` (the run's edge), and the trace's metrics over the run (`main.paint`, `main.gc`, `layout.shift`, `gpu.compile`; with a full trace also `main.busy` and `main.tasks.long`). The counters' metrics hold the frame in every trace set (`main.work`, `main.script`, `main.layout`, `main.style`, `layout.count`, `style.count`, and a lean block's `main.busy`), so a series that declares only them gets no warning. An element wait ends at the first element it finds (and on a late frame, once the frame drew it): the frame after it can still hold the step's work (siblings' layout, effects run on mount), so a step that waits for an element and whose work the frame finishes passes `{ until: [selector, "frame"] }` (§9.6): the element, then the frame after it, inside the run. strobe never adds a frame to element waits by itself: they also aim every input, and a headless frame costs 8 to 18 ms on vps and 60 to 165 ms with the display off on Windows per wait. A native app's phase marks are strobe's own, stamped on the host clock (the clock of its inputs, OS present events and native samples) and kept in the recording's `frames.json`; `query hotspots --phase ` ranks the native samples inside that phase (§13.7). `phase.` is a phase's own wall time per run (ms, timed, lower is better), summed when a run enters it more than once: from the trace marks on pages, from strobe's host-clock marks on native and script targets. Each key's edges pair in time order (`metrics::phase_stretches`, and in the trace `metrics::windows_sql`): a phase entered twice in a row is two stretches, and one entered again inside itself (a recursive or concurrent `phase` of one name) counts its time once, as overlapping spans do; `query frames`, `query hotspots --phase`, the video's markers and `strobe open` pair them the same way. A trace window holds every renderer process a mark of the block landed in inside it (`_wp`): after a cross-site `goto` strobe marks the run in the new document, and every top-level document marks its own start (`strobe::doc`, an init script; no window pairs it), so the run's frames, tasks, inputs and spans in each renderer the page was in stay the run's, one it reached by itself too. `two hops` (the page goes to a second site and on by itself to a third, headless): 17 presented, 3 in the second renderer and 14 in the third by the trace, where the end mark alone gave 14 and the start's renderer alone 0. Checked with `scripts/dev/derive` (`phases`): 100 ms then 50 ms read `phase.twice` 153.5 ms, where pairing every start with every end of one name read 305.1 ms; a phase of 80 ms inside itself read 82.9 ms (328.4 ms before). `other site` (300 ms of animation on each side of a `goto` to another site, headless): `query frames` 10 presented (6 in the first renderer, 4 in the second, by the trace), where the first renderer's 6 alone were the run's before. strobe stamps both edges when the runner reports them, so a bench file can time a span inside a run (a command to its reply, a request to the state it waits for) without reporting a number of its own. A declared `phase.` that no measured run entered is refused (`NoPhase`). On a locked session it is an offscreen metric, like `run.duration`. `span.` is the app's own spans: the page's `performance.measure(name)` calls (pages and Electron apps) that overlap the run, as the time they cover inside it per run (ms, timed, lower is better): a measure that ends after the run's end mark, or began before its start, counts up to that edge (it used to be dropped, so a measure that ended 1 µs late read 0 or `NoSpan`); overlapping measures of one name count once, as nested GC slices do for `main.gc`, and a run that measured none reads 0. A 0 ms measure counts as measured (a measure is an async slice; a mark is an instant on its thread's own track). The browser timestamps them (`blink.user_timing`, already in the lean trace set, so declaring one costs nothing), so the bench file still cannot invent a number. A declared span that no measured run's page measured is refused (`NoSpan`), as is one on a target with no page (native, script). Checked on adais and air, headless: ten 5 ms measures a second read 50.6 to 55.0 ms per 1 s run; the same stretch measured twice read the same; ten 0 ms measures read 0.00 ms, not refused. ### 7.4 Native apps What strobe reads from a native app (§13.7), per run: - **From outside, always:** `cpu.work` and `cpu.app.total` (the cycles of the app's processes: its tree and everything launched for its home with their trees, §13.2, over the cycle clock, §13.6), `cpu.wakes` (context switches of the tree's threads per second: each a switch in to run, after a wait (a timer, an event, IO) or a preemption; at rest nearly all are waits. Driver and OS pool threads inside the app's processes count too, and a handoff between two of its threads counts twice. From two `NtQuerySystemInformation(SystemProcessInformation)` snapshots around the run, a thread or process born in the run counting from zero; one that exits in the run takes its last wakes with it, so the count is a lower bound then. A thread is its id and its start (Windows' create time, Linux's `stat` start, a macOS process's start): a thread under a reused id is another thread, from zero, and Windows' 32-bit count wraps rather than reading as a reset (which billed a thread's whole lifetime of CPU to one run). macOS holds a process's count at 2^31 − 1: past it `cpu.wakes` is refused `NoCounter`, never 0/s), `io.read` and `io.write` (bytes through read and write calls, the same snapshots; a file mapped into memory and read or written through its pages and console IO are not counted), `gpu.process.3d` (3D engine time summed over every adapter the processes used: one that draws on one GPU and computes on another uses both), `gpu.vram` (dedicated), `gpu.shared` (GPU memory in system memory: all of an integrated GPU's), `memory.app.private` (Windows: the commit charge, which includes the commit Windows charges for the processes' GPU allocations), `memory.app.host` (what the processes allocated in their own address space, without GPU memory: §8.6), `run.duration`. Per thread: wakes per second and CPU per run from the thread's cycle counter, named by `GetThreadDescription`, with the module it started in (its Win32 start address, `ThreadQuerySetWin32StartAddress`) and whose it is: `app`, `driver` (from the driver store, such as a GPU driver's workers) or `os` (the Windows folder: its thread pool and services) (`query threads`). Linux keeps no start address: the module is the first one named reading down from the top of the thread's stack (its oldest frames, with glibc's thread descriptor and its start routine above them), past the C and C++ runtimes' thread entry, through `/proc//task//syscall` (its stack pointer) and `process_vm_readv`, which ptrace's rule allows for what strobe started; a main thread is its executable's, and a thread on a CPU at the moment or a runtime with stacks of its own (Go) stays unknown. `driver` there is a DRI, Vulkan, Mesa or NVIDIA library (`*_dri.so`, a `dri/` folder, `libvulkan*`, `libgallium*`, `libEGL_mesa`, `libGLX_mesa`, `libnvidia*`, `libcuda*`, `libdrm*`) and `os` the C runtime or a session service's client (GLib's and D-Bus's workers, PulseAudio, PipeWire, ALSA, JACK, systemd). macOS keeps no start a user can read and counts wakes per process: each process row is named by its executable, `driver` from `/System/Library/Extensions` or `/Library/Extensions` and `os` from `/System`, `/usr/lib` or `/usr/libexec` (checked on vps: a GLib daemon's `gmain`, `gdbus` and pool threads read `os`, a Bun app's threads `app`; on air, Finder's row reads `os`). The `wakes-while-idle` insight counts the app's own threads and names driver and OS threads apart. Every adapter the app used is recorded (`ctx.gpu.devices`), each with its own engine use. - **A thread timeline** (native `record`, `record pid:`, native bench blocks that are not timed: `--check` and attribution, and script attribution blocks, §9.6): a thread of strobe's takes the same system snapshot every second from the launch (or the recording's start) to the last run's end, over the tree (and what runs from its home, read every 5 s), and keeps each thread's CPU (exact: Windows from each thread's cycle counter, a handle held while the thread lives, so one that exits between two snapshots keeps its last CPU; Linux from schedstat; macOS from the thread's own times; where none is exact, `cpu_refused` says why and the cells hold wakes only, as they do for a thread strobe may not open, `cpu_unknown`) and wakes per second, when it first and last showed, its name and its start module, read while it ran (`timeline.json.gz`). A run's edges take a snapshot each (`Timeline::mark_edge`), so no tick spans a run's start or end, and each tick keeps the span it covers. `query threads --timeline` draws one column per second (coarser past two minutes) per thread with the runs marked, each tick's CPU and wakes spread over the columns it covers in proportion (`TimelineData::binned`: a tick that came 2.6 s late fills each second it covers, never a 0% gap beside a 260% spike), so work that starts late and stops shows when it ran ("a driver thread from 23 s to 52 s"), and threads that exited are listed (`query threads` names them below the per-run list, with their CPU over the recording). On Linux and macOS a thread's CPU between its last snapshot and its exit is missing (at most one interval). Its own CPU is in the file: the system snapshot dominates (about 9 ms a second on adais with 350 processes and 8,000 threads), which is over the samplers' budget (§19), so timed blocks have none. - **The UI thread, from outside:** the thread that owns the main window is the app's UI thread (on macOS and Linux no thread owns a window: it is the process's main thread, where AppKit must run and X11 toolkits do). `ui.cpu` is its CPU per run from its cycle counter (the primary for "does this UI change cost less" from outside: continuous, not quantized to frames) and `ui.wakes` its context switches per second. The bridge's split of UI work into view, layout, text, paint and encode is gone: where the UI thread's time goes comes from native stacks in attribution blocks (§13.4), by function, file or package, per phase (`query hotspots --thread --phase `). - **The main thread** (`cpu.main`, `cpu.main.busy`; script and native targets, and `record pid:`): the thread the target's process started with, bound to that thread for good (`host::MainThread`: Windows the launch's suspended first thread's handle, or for an attached process its oldest thread when it was born within 5 ms of the process; Linux the thread whose id is the pid, its schedstat and stat held open; macOS its lowest 64-bit thread id), read from that one thread's own counter at the run's edges and each untimed stretch's (one system call, about 1 µs, so clean timing blocks read it too). The readings r₀ … r₂ₖ (the run's start, each untimed stretch's start and end, the run's end) are each a CPU count and the instant it is exact at; the share is Σ over the timed pairs of (cpu end − cpu start) / Σ (at end − at start), at most 1 (`host::main_share`). `cpu.main.busy` is 100 × the share (100% is an event loop that never waited) and `cpu.main` the share × the run's timed ms: the event loop alone, beside the whole tree's `cpu.work`, never its workers or children. On Windows and macOS the readings sit µs from the edges. Linux's schedstat (its first field, CPU ns) is added at switches and scheduler ticks, so a thread on a CPU reads short by up to one tick (4 ms at 250 Hz: a busy share of 101% on a 2-vCPU VM before this): a reading of a running thread is read again every 50 µs until its count moves (its next tick, or it leaves the CPU; at most 25 ms) and is exact then, one off a CPU is read twice; such a reading lies outside the timed part (before its start, after its end, inside an untimed stretch), so the pairs' span can be up to a tick per edge longer than the timed time. One `--check` run of a spinning thread can then read `cpu.main` up to a tick above `cpu.work`, whose process clock counts a thread on a CPU only to its last tick; strobe never clamps one to the other. Tested by default on script targets and windowless native apps (`cpu.main.busy` is context once `run.duration` moves); a windowed app's main thread is its UI thread (`ui.cpu`). Refused `NotOnThisOs` where no exact per-thread counter exists (Windows: cycles that are not time; Linux: no schedstat, decided once from strobe's own main thread, or tickless `nohz_full` CPUs; macOS: `EPERM`, `EINVAL`), never read from clock ticks; `RunIncomplete` when the thread or its process ended (a zombie leader too: a launcher that hands over refuses it) or the clock gave no rate. A script host names its runtime's process in its ready message: where that is not the process strobe started (a version manager's shim that runs the runtime as its child), strobe binds that process's main thread instead (attached, as for `record pid:`). `record pid:` lists the tree before its window, reads its processes again one by one at the window's edges with the clocks (§9.6 **CPU**), and the end's listing only adds processes born in the window; the main thread is read between the listings and the window (it used to be read outside both listings, so `cpu.main.busy`'s span held them). `record` of a launched native app and of a browser (Chromium `cpu.app.total`; Firefox `cpu.work` and `cpu.app.total`) read the same edges, at the paired clocks' rate. Known answer: `scripts/dev/main-thread.bench.ts` (a main-thread spin, a worker's, a child process's and a timer wait: `cpu.work` high for the first three, `cpu.main` high only for the first); `main-spin-30` (30 ms, then the host idles) checks the run edges: `cpu.work` never below `cpu.main`. - **Presented frames** (`frames.interval.*`, `frames.dropped*`, `frames.discarded`, `frames.presented.count`, `display.hz`) on Windows from the OS's present events (§7.2) of the app's processes; on macOS from ScreenCaptureKit and on Linux X11 from X Present (§13.6), which tell when a frame was shown but not when it was handed over, so there `frames.interval.*` and `frames.dropped*` are refused with that reason and the rest (counts, `frames.discarded` from `Skip`, `display.hz` from the display's mode) stands. Every native block with a window runs a present session when access allows (page waits read it live). `frames.presented.p*` (a frame's begin to its screen time) is refused: only a toolkit knows when it began a frame. A run in which the app presented more frames than the display refreshed, plus ten, refuses the frame metrics (`UnpacedSpin`); counted over the run's timed stretches, where its frames come from (an idle `ctx.untimed` stretch would thin a spin out of the timed part, and a spin only in one would refuse paced frames), as is the `UnpacedSpin` warning's rate. - **Interaction latency** (`interaction.p50|p95`, `interaction.n`; `scroll.latency.p50|p95`, `scroll.n`), from strobe's own inputs (§13.7) to the presents that showed them: - Inputs: a single click (below: its release, or its press when the app acted on the press), a mouse button's release by `up`, each key going down in `type`, `press` and `down`, and for `scroll.latency.*` the first wheel step of each `scroll` or `wheel`. Moves, hovers, mouse button presses, key releases, a drag's end, the releases `release_all` sends between runs, and a double or triple click's releases are never samples: a multi-click's presses follow a frame apart, so its pressed looks are still on their way at each release, and settling inside the gesture would stretch it past the OS's double-click time. Inputs given through accessibility actions (`page.act`) skip hit testing and pointer handling: they are never samples, and a series whose runs only acted refuses `interaction.*` (`PatternInput`). - The driver lets the target settle around them, on its own live copy of the present events (`driver::settle`): before a press, a measured input (each key of `type`, a `press` after the click into its field, a key or button `down`/`up`) or a scroll's first step, until no present of the target began or reached the screen in the last 2 refreshes plus 30 ms for the events to reach strobe, at most 300 ms. Before each key of `type` after the first (`driver::settle_key`), the first frame begun after the last key reaches the screen, then the settle, both inside one 300 ms budget (or `delay`, counted from the last key, when longer); once a key's settle ends still drawing (an animation that never rests), the call's later keys go `delay` apart without waiting (they are no samples either way). Where strobe follows no presents (headed Chromium, whose trace times each key's events, and every headless page, which draws to no display but still runs its animation frames), the page's own frames pace the keys the same way: the frame after the key, then until a frame passes with no DOM change (`__strobe.settled`, a timer when the page draws no frames), in the same budget. `type(sel, text, { burst: true })` sends the keys `delay` apart with no pacing; each key is marked (`frames::Sent::burst`), and `interaction.*` refuses `AmbiguousInteraction` for a block whose runs typed in a burst, in every engine and app (no frame is any one key's reaction; the fix names `cpu.work`, `main.work`, `ui.cpu`, `run.duration` or a phase). Sent back to back (0.14), the 9 keys of a search field that draws for 100 ms after each went out within about 10 ms, and 7 counted with a p50 of 9.7 ms: the frames already under way for the keys before them (none of the 9 was at rest); paced, all 9 are samples (p50 36.9 ms; `scripts/dev/type.bench.ts` on air). A caret's blink is one frame every 530-650 ms (Firefox, the fixture's caret mark; macOS 27's own text caret draws no frame strobe sees as the app's), so a quiet gap comes between blinks; a blink that began in the 2 refreshes before a key still makes that key no sample (its frame reached strobe after the key went out: 8 of 9 in a Firefox page field). A single click's press is held that way, at least 60 ms, so its pressed look (`:active`, a highlight) is on the screen and settled before the release; when the settle before the press already hit the cap (the target animates), the press is held the 60 ms only, not a second 300 ms. A double or triple click's presses are a frame apart with no settling. A click into a field before keys (`type`, `press` with a selector) settles at least 2 refreshes after its release, since its first frame may not have begun yet. A target still drawing after 300 ms (a ripple, a transition, a caret, an animation) is not at rest, and the input goes out anyway and is no sample (below). A blind 60 ms hold refused most clicks: the pressed look reached the screen 35–50 ms after the press (Safari, Firefox on air), within the 2 refreshes before the release. The settle reads the latest present without looking for new windows (reading the window list every poll raised the macOS capture's consumer past its budget); each run re-tracks the target's windows once before it starts, outside its span. The macOS capture streams no window under 32 points a side (a tooltip, a badge): a stream opened mid-block for a browser's 66×20 tooltip did the same. - An input reacts when the app changed its accessibility tree (a structure, property or focus event from its windows) within 1 s after strobe posted it. An input that changes no tree (a press highlight, a hover, a key the app ignores) is not a sample, so no later frame of something else (a caret blink, a timer) is ever attributed to it. A reaction that changes only pixels is measured when the input's `until` waits on them (`{ pixels: { changed } }` or an image, §9.6 "Image anchors"): the pixel proof replaces the tree change, and the sample is the one present of the app that reached the screen between the last capture without the reaction and the first with it (two or more: busy, no sample; none: unshown). As for any input, it counts only with the app at rest when it was sent and every earlier input's frame on the screen, and only a frame begun after the input's first edge (one already on its way cannot show it): else busy. A click's sample is from its press when that frame began before the release (as a tree's `pressed` decides it, by the frame's begin, not its screen time: a press-acting control whose frame began before a 60 ms hold and reached the screen 2 ms after the release reads 62 ms, not 2 ms), else from the release. A capture of the compositor's frame (Windows) or of the window server's copy of the window (macOS) holds a frame before the display shows it, so the first with the reaction counts up to a refresh after it ends: on air a reaction was in a capture that ended 9.8 ms before ScreenCaptureKit's displayed frame of it, which a bound at the capture's end left unshown. Without one, an app with no accessibility tree has no `interaction.*`. - Its latency is from the post time (host clock) to the screen time of the first present of the app's processes that started after it, and no earlier than its first change can have happened, and reached the screen, within 1 s. A frame begun before the model changed cannot show the change: a pressed look undone at the release (`:active` reverting a refresh later) is not the frame of a reaction that changes the model 100 ms on. Each change is kept as the earliest and latest it can have happened: a page's change (`CHANGES_SCRIPT`) between the host-clock reads around the page's clock; an accessibility event only by when strobe heard of it, with no earliest, since events carry no source time and a provider may announce a change long after drawing it (one toolkit's tree on macOS: 350–440 ms after its pane switch began to draw at 87 ms; bounding by the event took the frame at 420 ms). The latest decides whether it followed the input; the earliest bounds the frame. So a native app's frame may precede its change: the residual exposure is a frame the app draws at once and for another reason, before an asynchronous update (an immediate unpress frame, a periodic redraw), matched as the reaction. The settled press hold removes the pressed look's own frames; an unpress frame drawn with no model change is still taken. Pages have no such exposure: in an implementation comparison of a page with a native app, the page's side is bounded by its changes and the native side is not, so a native side whose app redraws at once may read faster (§9.10). After each native run, strobe waits (outside its span, before releasing keys, its reset or the next run) until the app announced a change after the run's last measured input, or a second after that input passed, and the run's changes end there: none of another step's join it, with a reset or without, and nothing is waited when the change already came. Each input is resolved on its own after the run: nothing pending carries over between inputs, runs or a reset, and a present after several inputs is the reaction of each (latency from each). - Changes are claimed in order: each input takes the first change (by when it is known to have happened) that no earlier input took, so a change announced late for one input is never another's too. - The press edge: many controls act on the mouse press, not the release (tabs, menus, sidebars, games, most native list and segment controls). The reaction then comes during the press hold, before the release, and a sample at the release has nothing left to show (one consumer's sidebar gave one sample a run, whatever the bench did). So a single click that acted on its press is one sample from its press: its latency is from the press to the first present that began after the press and no earlier than its change's earliest, and the at-rest check applies at the press (the settle before the press already waits for it). Otherwise it is the release's sample, as above; never both (`interaction.n.press` says how many of a block's samples were presses, `--check` prints it beside `interaction.p50` and `p95`, and `query frames` labels them `press`). A click acted on its press when, from what is provable: - its model changed between the press and the release, by a change that is no press feedback (focus moving: UI Automation's focus event and `HasKeyboardFocus`, AX's focused-element and focused-window notifications, AT-SPI's `Focus` events and `focused` state; and AT-SPI's `pressed` state, while `armed` is no change at all): a push button takes focus on the press and acts on the release; - and its model did not change again between the release and the next input (within a second): a release-acting control that shows press feedback in its model (a ripple element, `data-pressed`) is the release's sample, never its highlight's; - and a frame that shows the change began before the release: a reaction drawn only after it is timed from the release, not charged the hold; - and (an accessibility tree) every earlier input within a second already had its change before this press: a change in the hold may otherwise be one of theirs announced late (the toolkit above: 350–440 ms), so the click is no sample, counted as drawing (`AmbiguousInteraction` when over half are). A page says the first two in its own order of events (`CHANGES_SCRIPT`): a capture listener on its trusted `pointerdown` opens a press, one on `pointerup` or `pointercancel` closes it and opens the time after the release until the next trusted press, key or wheel turn, and each change the page's observers report marks whichever is open, so a mutation the press's own handlers made, or one queued after it, marks the press, and one from before it (the move's hover) cannot. strobe matches each click to the first of the page's presses not yet matched that can have followed its press and come before its next input, so a page whose main thread was busy past the release still has its press (seen late, in order). Clock bounds alone could not tell: Firefox's mousedown handler changed the page 1 to 3 ms after strobe sent the press, inside the 3.4 ms width of a change's bounds, so neither click of a check run counted as a press (air). Without the page's word (an accessibility tree, or a page that saw no trusted press), a change follows the press when it is known to (a page's earliest after it; an accessibility event heard after it, as for any input) and happened before the release when its latest is before it. A provider that announces late can announce a press's change after the release (the hold lasts 60–300 ms): that click stays a release sample, which has nothing new to show and is then usually no sample at all; a case lets the app go idle between its clicks (`ctx.untimed(() => page.idle())`, §9.6). The hold's timer starts at the press, and its settle never refuses: a press reaction still drawing at the release lengthens the hold at most to 300 ms and changes no press sample. Chromium times every event to the frame that showed it from its own trace (`EventLatency`), so there a click is two samples, its press and its release, each when it showed a frame; `interaction.n.press` counts the press events among them. Two pages on different engines are different instruments (Chromium's trace against OS presents): an implementation comparison of them measures both from outside (§9.10). - Refused when ambiguous: an input sent while the app was drawing is not a sample, since the next frame may be that drawing's: a present that began before the input and began or reached the screen within the 2 refreshes before it (one begun before and shown after the input is on its way at the input, whatever the source: macOS and X11 see only screen times, Windows sees the present call after the frame's work). More than half of a block's inputs like that refuses `interaction.*` (`AmbiguousInteraction`). `interaction.p95` needs 20 samples a block, as `scroll.latency.p95` does (`TailTooShort`: one sample is its own 95th percentile); `interaction.p50` takes one. Each timed block applies every percentile's minimum n itself: a value below it is dropped and refused, so a primary like that fails the first block, and a refused primary decides nothing (secondaries stay untested, no attribution, `--require-verdict` exits 4); a refused comparison carries no numbers (they print —). `--check` prints such a percentile as `interaction.p95 — (3 inputs < 20 in 1 check run)` and still passes. No reacting input or no shown frame refuses it (`NoPresentSource`, with the reason), as does a block without present events. - It is testable: `probe::present::frames::reactions` takes the posted inputs, the change times and the presents, all on the host clock, and `query frames` shows per run which inputs counted and why the others did not. - Every metric a native target cannot measure is listed refused with its reason, never dropped. ### 7.5 Counters: energy, instructions, idle wakes What the OS counts per process beyond time, read beside the wakes at both ends of each run (summed over the target's processes, as `cpu.work` is) in page, native and script blocks, and in a `record` of a native app or a `record pid:` (a page's `record` reads none). Never estimated: where the OS does not measure it per process, the metric is refused with the reason (`NoCounter`); a launch case reads none and refuses a declared one (`NoCounter`: counters are read around a case's runs). | Metric | macOS | Linux | Windows | |---|---|---|---| | `energy.cpu` (mJ per run) | `proc_pid_rusage(RUSAGE_INFO_V6)` `ri_energy_nj`: the energy of the CPU cores (performance and efficiency, user and kernel mode) that the kernel charges each thread from Apple silicon's energy counters at its context switches; not the GPU, Neural Engine, display, memory or IO. Refused on an Intel Mac | refused: RAPL counts a whole package (every process on it, strobe too), so no share of it is the app's; strobe never multiplies CPU time by a power | refused: no per-process measurement an unprivileged process can read (E3's figures are a model's estimates) | | `cpu.instructions` (per run) | `ri_instructions` (user and kernel mode), always, in every block | `perf_event_open` `PERF_COUNT_HW_INSTRUCTIONS`, user mode only (what `perf_event_paranoid` 2 allows), in counter blocks only | refused (the kernel trace's PMC counters on context switches would give it; not collected) | | `cpu.idle_wakes` (/s) | `ri_pkg_idle_wkups`: wakes of the CPU package from idle, what Activity Monitor's Energy Impact weighs | refused: no per-process count | refused | - **Whole runs or nothing.** macOS counts a process from its start: one started during the run counts from zero (it started after the run's processes were listed: the wall clock is read before the list, so a child started between the list and the read still counts), one that exited during it took its counts with it (XNU keeps no child instructions or energy), and one that joined the set late (a helper found in the app's home) was never read at the run's start. Either of the last two refuses the run's counters (`NoCounter`, naming the process) instead of a short count. A child that started and ended inside the run, seen by no snapshot, shows as its parent's reaped-child CPU time growing (`ri_child_user_time`, `ri_child_system_time`): that refuses `cpu.instructions` and `energy.cpu`, while `cpu.idle_wakes` adds the parent's `ri_child_pkg_idle_wkups` and stands (on air a run that spawned one shell read 5.56 M instructions before this rule; Linux counted the same run's shell in its parent's group: 2.32 G, A/A 0.0%). A child still unreaped (a zombie) at the run's end is in no snapshot and not yet in its parent's child time: its counts are missed. - **Linux counter blocks.** The perf events cost the target at each of its context switches (in a VM 4.4 µs a switch: 2000 short sleeps cost 36.8 ms of CPU per run with them, 19.3 ms without), so a timing block never opens them (rule 3). A series that declares `cpu.instructions` gets counter blocks, as attribution blocks are kept apart: perf events on, no trace and no present session, their time metrics dropped. - Counters only (no declared time or frame metric): the paired loop runs on counter blocks; the default secondaries' time metrics have no value there. - Counters and times: the timing pairs stay clean, then K paired counter blocks follow (base and head alternating, warmed up like any block), K the pairs Holm's first step needs across every tested secondary (Holm corrects across both kinds together: those the paired loop's blocks have and those the pass measures), at least the first look, at most the pairs the series ran. With `cpu.instructions` the primary, it is decided on counter blocks first and K clean timing pairs follow. The result keeps the other blocks under `pass`, and an info line says `Counters separate blocks: … → K pairs`. Every comparison, the descriptive ones too, reads the blocks of the kind that measures its metric, and the lock state (`SessionLocked`) and an implementation comparison's latency skew are judged over both kinds' pairs. - `--blocks N` runs N blocks of each kind and joins each counter block's counts to its timing block; `--check` runs one untimed counter block (beside the untimed block, for a series of both). - macOS and Windows have no counter blocks: rusage is free, and Windows refuses. - **Linux perf events.** One owner per counter block (`wakes::Perf`): a counting event with `inherit` on each thread of each of the target's processes at its first snapshot (strobe's open-file limit raised to its hard limit), so the threads and processes they start later count in them, live or exited, never twice. A process's threads are listed before and after its events open; when one started meanwhile (it may have inherited an event, or not) the group opens again, at most 3 times, else the run refuses. Every process alive is listed before and after a snapshot's groups open: a later child is counted in its parent's group when it is in neither list, gets a group of its own when it was alive before, and refuses when it started while they opened. After the first snapshot a new process whose parent is not counted (reparented to an init, or started outside the counted processes) refuses: it may count in a group strobe holds, or in none. Groups stay open between runs and close when the block ends. Refused besides: `perf_event_paranoid` above 2 (the fix names the sysctl or CAP_PERFMON), a CPU with no hardware counter (a VM without a virtual PMU), a process that refuses perf events (not dumpable: a sandboxed renderer), and any run whose counters were multiplexed (time running below time enabled): no count is ever scaled. The count is user mode only, so the switches' kernel work is not in it. - **Validated** (a script target's planted loop, 1× vs 2×; 8 pairs each): air (M4) `cpu.instructions` +100% [+99 … +100], `energy.cpu` +99% [+93 … +106], `cpu.work` +101%; vps (2 vCPU VM) `cpu.instructions` +100% [+100 … +100], `cpu.work` +104%. A/A per-block spread (CV over 16 blocks): air instructions 0.095% vs `cpu.work` 0.43% (energy 1.3%); vps instructions 0.022% vs 0.56%. With counter blocks on vps: `cpu.instructions` primary with `cpu.work` (8 counter pairs, then 8 timing pairs) read +100% [+100 … +100] and `cpu.work` +104% [+103 … +105]; `cpu.work` primary with `cpu.instructions` (8 timing pairs, then 8 counter pairs) +104% [+104 … +105] and +100%; `--blocks 2` joined 480.3 and 480.2 M instructions to timing blocks of 70.3 and 70.1 ms; `--check` of a series of both showed both. ## 8. Probes ### 8.1 Probe sets | Probe | What | Source | Block | Cost | |---|---|---|---|---| | `frames` | presented frames, frame phases, vsync | trace | timing | low | | `threads` | top-level tasks per thread by category, long tasks | trace | timing | low | | `os` | process tree CPU and memory, threads, per-core, IO | OS sampler (§13.6) | timing | low | | `gpu` | engines and VRAM per process, device clocks, power, PCIe | GPU sampler | timing | low | | `cpu` | JS stacks at 200 µs (400 µs on Windows) with line ticks | V8 profiler | attribution | med | | `alloc` | allocation sampling with stacks | HeapProfiler.startSampling | attribution | low–med | | `invalidation` | forced layouts and style invalidations with JS stacks | trace | attribution | med | | `counters` | `count.`: `ctx.count` in the bench file and the page's counter global (§8.4), per run | runner, page | timing, when declared | low | | `network` | `net.requests` and `net.bytes` per run, optionally by URL glob | Network domain | timing, when declared | low | | `shifts` | `layout.shift` per run; the largest shifts and the elements they moved | trace (`loading`), DOM | timing, when declared; names in attribution | low | | `coverage` | exact call and block counts (not built) | Profiler.startPreciseCoverage | attribution | high | | `census` | per-process memory account (§8.6) | CDP, OS, memory-infra | attribution | med | | `heap` | heap snapshots per runtime (§8.6) | CDP, Web Inspector (`wir:`) | attribution | high, pauses | | `presents` | presents to the screen: displayed (scanout time) or discarded, vertical blanks | ETW real-time session (§13.4; its consumer thread runs at above-normal priority on strobe's CPUs); ScreenCaptureKit (macOS), X Present (Linux X11), the compositor's own (strobe's Wayland compositor) | timing | low | | `deep` | native stacks (not yet: core residency) | helper (§13.4), strobe's own sampler (`stacks-unprivileged`), perf, `sample` | attribution | med | | `waits` | `deep` plus off-CPU waits: each switch-out and switch-in of the target's threads with the stack it waited at, and who woke it with theirs (§11.1 `waits`) | Linux perf tracepoints; Windows the helper's CSwitch and ReadyThread | attribution, when asked (`--probes waits`) | high: at every context switch | Sets: `timing` = frames, threads, os, gpu. `attribution` = cpu, alloc, invalidation, counters. `memory` = census, heap. `deep` = deep. Probes can be listed one by one. In Firefox, `attribution` is the Gecko profiler with its markers and about:memory, and `memory` about:memory alone (8.3, 8.6). Bench timing blocks trace **lean**: only `disabled-by-default-devtools.timeline.frame` (PipelineReporter), `input.scrolling` (EventLatency, every input type), `blink.user_timing` (marks) and `__metadata`. The full timeline set records `RunTask` on every thread of every process (340,000 of 1.1 million slices in a 22 s scroll block), which costs the target main-thread time: lean traces measured scroll `main.busy` 12% lower on the same build, and cut trace plus analysis from 4.0 s to under 1 s per block. Renderer main-thread busy then comes from `Performance.getMetrics` `TaskDuration` per run (it agreed with the trace's `RunTask` sum within 0.06 pp on every block checked). `--trace full` keeps the full set; `record` and attribution blocks always trace full. A timed block that traced the full set says so: its recording's `timing` is `full-trace` (a lean one's `clean`), the plan line ends `· full trace` (`plan.trace`), a Chromium-family series warns `ProbeOverhead` once, and `query a..b` of a lean and a full-trace recording warns that their times do not compare. Measured (8 alternating blocks each, headless): on adais `main.work` +0.8% on a click and +35% on an idle page, `cpu.work` +3.5%; on vps (unpinned) +1.2%, +15% and idle `cpu.work` +38%. A lean Chromium page block with nothing on screen (a headless page, or an app with `window: false`) takes no trace at all, from 0.34.0, unless a case of the series declares a metric read from the trace (`phase.*`, `span.*`, `layout.shift`, `main.gc`, `gpu.compile`, `main.paint`): nothing it draws reaches a display, and tracing costs the browser and strobe CPU in every run. Timed blocks with `--trace full`, attribution blocks and a page on screen trace as before; counter and memory blocks trace nothing. Its `main.work` and parts, counts, CPU, memory and network come from where they always did (the Performance domain, the OS). Its recording's `meta.json` says `"traced": false` (true for a block that traced; absent on older recordings) and has no `trace.pftrace`; no trace_processor runs for it. `query summary` works. Without a trace, `query frames` gives the recorded refusal: `Headless` for a headless page or `NotForTarget` for an app with `window: false` (exit 4). With `trace.pftrace`, the view reads Chromium's frame pipeline. Those rows are not evidence of presentation on a display: the bench still refuses off-screen frame metrics. `layout`, `style` and `gc` need an attribution recording; `threads` says it kept no thread counters when there is neither a trace nor `threads.json` (exit 2). `query .. threads`, or `frames` without a recorded `frames_refused` reason, says `this block took no trace` (exit 2). Use a timed `bench --trace full` or a headed run for trace data. A plain `--check` still uses lean probes even with `--trace full`; `--check --why always` keeps a separate attribution recording to query. Checked with 0.34.0 on adais (`url()` page, `--check --headless`). An undeclared `main.paint` (a descriptive row) is absent from such a comparison. A Chromium run's `goto` places no mark of strobe's in the page (each new document marks itself), so nothing of strobe's is billed to `main.work`. A trace whose buffer filled (200 MB; 1 GB for attribution and memory sets; Chromium's `Tracing.tracingComplete` says `dataLossOccurred`) is written and then refused `RunIncomplete`: Chromium drops what comes after, so the later runs' frames, tasks and marks are missing and every count from it would be short. It used to warn on stderr and keep the recording valid (a 20 s `record` of a page writing 20,000 user timing marks a frame lost its end mark: valid, with no `main.*` and no refusal). ### 8.2 Overhead Every page, native and script block records what strobe itself cost inside its measured runs (`overhead`, §19), and the budgets in §19 warn or refuse on it (`OverheadBudget`, `ObserverCost`). The probes' own cost on the target is kept small by the probe sets above: timing blocks trace lean, and what costs more (profilers, perf counters, waits) runs in blocks of its own. `ProbeOverhead` (§10.2) warns where a timing block paid for more than the lean set (`--trace full`). Reading a window's pixels from outside (§9.6) costs what it does inside the runs only: the capture opens before them when the case reads pixels, copies no frame it does not read, and reads the window's place and display again only when they changed (`pixel_pct`, §19). Not built: an empty-probe block per variant that would measure the timing probes' own cost against OS counters alone. ### 8.3 CPU samples 200 µs interval on macOS and Linux, 400 µs on Windows (`probe::cpu::interval_us`: pages, an Electron app's main process, script targets' `--cpu-prof-interval`), line ticks kept (at 50 µs the profiler raised a JS-heavy run's main-thread work by 35–45%, enough that one profiled launch moved against the timed pairs it was meant to explain). Windows' timers go no finer than about 0.5 ms and V8's sampler there wakes on the tick after its interval: Chromium on adais kept 0.52 ms when asked for 200, 300, 400 or 450 µs, 1.0 ms when asked for 500 µs and 1.5 ms for 1 ms, so 400 µs is the finest it keeps without asking for much more than it gives. Every hotspot row carries its sample count and an interval for its share. Native time stays `(program)` unless `deep` resolves it (§12.3). **Interval kept** (`probe::cpu::Sampling`): a sampler need not keep the interval asked of it, so strobe reports the one it kept: the median gap between consecutive samples of the page's main thread by time (the gap half the sampled time lies in gaps up to; a regular sampler's plain median, while by count a burst of extra samples decided it: a Windows V8 profile asked 200 µs held 915,000 gaps of 5 µs, 11% of its time, beside 60,000 of 0.3–1 ms, 78%, and read 0.01 ms where its time was sampled every 0.53 ms) in the attribution block (V8: the page's `timeDeltas`, never an app's main process's `cpu-main`; Firefox: its main threads' samples inside the measured runs), gaps over 50 ms left out (the thread was not sampled then: a runtime that skips ticks while idle, a paused process). Every attribution recording of a page keeps it in `meta.json` (`sampling`: `asked_us`, `median_us`, `gaps`; a Firefox one also in `gecko.json`), and every view says it: the block's line, the experiment's text and its result (`head JavaScript sampled every 2,000 µs (asked 1,000 µs: 2.0× coarser)`; `attribution_sampling`: the one sampling of a `--check`, else per variant, null when none), `record` and `record --bench` and `query summary` (`samples` line), `query hotspots` and Firefox's `query threads` (header, and `sampling` in their JSON; hotspots' `interval_us` is the median kept). A script target's is its measured runs' gaps on strobe's clock (§9.6: its profile is cut to them). A recording from before 2026-11-07 reads its own profiles' median gap, asked 200 µs as they all were. Checked on adais (Windows 11, headless, 2026-11-07): Chromium kept 0.52 ms asked 400 µs; Firefox 159 2.0 ms asked 1 ms; Nightly 162 0.53 ms asked 0.5 ms. What strobe claims from a profile holds at the interval kept: a sample weighs the gap to the next one (as V8 counts it), so a function's self time and the sampling interval of a change (`a..b`, Σ Δt²) are the kept gaps', not the asked interval's; a profile's last sample weighs its median gap (V8 and Firefox alike, in the views and in `strobe open`). What a coarser interval loses is the short: a function that runs for less than one gap shows only when a sample lands in it. Where the kept interval is more than 1.5 times the asked one, the text says how much coarser, `query hotspots` says what that costs, and the block adds an `Attribution` note (so `--json`, `--quiet` and the summary carry it); the `±` beside each row's share (from its sample count) says how sure it is. **What a Chromium attribution block profiles**: each measured run whole and only it, one CPU and one allocation profile per timed part of a run (`cpu-.cpuprofile`, `alloc-.heapprofile`), started just before the run's span opens and stopped just after it closes, never across its reset, and stopped at a `ctx.untimed` stretch's start and started again at its end (a run spinning 300 ms untimed read 1.3 s of that spin in its profile before; none now). Both profilers go on through the run's own navigations in one renderer (checked on vps and adais, about:blank → http and http → http: the load-time function sampled every time, its allocations too), so a load case's profile holds the page's load-time JavaScript. Each start reads the page's renderer (`Runtime.getIsolateId`); a run whose page is in another one at its end, after a `goto`, or at a stretch's start (a cross-site navigation under site isolation, A → B → A included), or whose renderer could not be read, left the profilers with the first: the block is refused `RunIncomplete`. Firefox's profiler runs from the steady point to the end of the runs, cut to the measured spans. **Source maps**: after the runs, while the target still serves its scripts, strobe reads each http(s) or file script and its map itself, because the page may be on about:blank after a reset. A file only for a page that was a file. For a page not on this machine, no local, link-local or private address, whatever name leads there. A map only from the scheme its script came from, with no redirect, under 128 MiB, and only a JSON object. strobe reads the map from the served folder on disk when it serves the page (a loopback address, on the same origin) and the script there matches the served one byte for byte; paths stay inside that folder. Otherwise it reads the map from the server. A script on a custom protocol, or one the server refused strobe (a cookie-gated map), is read through the page. It stores the maps in the recording (`maps/index.json`, gzip files). Next to each map it stores the script's line starts and V8's function ranges (`Profiler.getBestEffortCoverage`, which starts nothing), read right after the last measured run (and after every profiled run of a case with a reset, before the reset leaves the page), before each `goto` inside a profiled run (the document it leaves holds its scripts) and at each untimed stretch's start. Every view resolves frames at query time, so CPU samples, allocation sites and trace stacks resolve alike: `{ name, name_from, file, line, column, url, source_mapped }`, where `file` is the absolute path on disk when strobe knows the folder the origin serves (strobe.json `serve.dir`, else `build.output`; `record --served `) and the file exists, else the source path exactly as the map records it; line and column are 1-based. A function frame (a CPU or allocation profile's: V8 places both at the function's start, checked) is named by its original declaration where strobe can read it, on disk, else in the source the map carries (`sourcesContent`; some bundlers write maps with no `names` at all): `function name(`, a method `name(` with TypeScript's modifiers (`private`, `public`, `protected`, `override`, `readonly`, `static`, `export`), `name = (…) =>` and `name: Type = (…) =>`, generic `name(` and `(…) =>`, with `name_from: "source"`. Else the map's name at the function's start (`name_from: "map"`), except where the source shows the function anonymous: a callback in an argument list or at a call's callee, an arrow placed at its own parameter, or a map name that is the function's first parameter or one found further along the line (maps often name an arrow by its first parameter: `(event) =>` minified to `e=>`). A call site's position is never read as a declaration: a call site (trace stacks) is located at the call itself and named after the innermost function range that contains it, resolved the same way; a Firefox stack frame says which function it is in, so it is named after that function's start. A build without maps keeps minified names. Checked against 0.11.0's names on eight recordings: Bun bundles without `names` now read `plantedHotFunction`, `spin`, `spinMultiline` (were `c`, `o`, `U`); esbuild's maps that named functions by a parameter (`ms`, `dt`, `ctx`, `scene`) now read `spin`, `spinStatic`, `frame`, `draw`; two recordings of a web app whose sources are on disk read the same names as before, row for row. **Allocations** (`alloc`): `HeapProfiler.startSampling` every 32 KB on average, objects already collected by minor or major GC included, so short-lived garbage counts; `query alloc` ranks bytes by function, file or package with full stacks. Stacks keep their innermost 128 frames: the profile is a tree nested two levels per frame, and Chrome's protocol serializer refuses replies nested deeper than 300 levels. strobe parses CDP replies and stored profiles without a nesting limit (serde_json stops at 128 by default, which dropped such a reply and left the command waiting out its timeout), and a reply it cannot parse fails its command at once. **Forced layout and GC** (`invalidation`): attribution traces carry `Layout` and `UpdateLayoutTree` with the JavaScript stack that forced them (`devtools.timeline.stack`) and every V8 GC phase. `query layout` ranks forced layouts and style recalculations by call site; `query gc` gives collections, main-thread GC time (union of GC slices, incremental marking included) and helper thread GC time per measured run. **Firefox** (`probe::gecko`; attribution blocks and `record --engine firefox --probes attribution`). Firefox's own Gecko profiler, started at the steady point with WebDriver BiDi's `moz:profiler.start` (the chrome scope's `Services.profiler` where a Firefox lacks the module), with JavaScript, native stack walking and CPU use, on each process's main thread, the compositor, WebRender's renderer and DOM workers, every 200 µs on macOS and Linux (their samplers sleep in `nanosleep` and `usleep`). On Windows the interval follows the Firefox version (`probe::gecko::interval_us`, from the BiDi session's `browserVersion`; one strobe cannot read counts as the older): through Firefox 160, 1 ms, because `SamplerThread::SleepMicro` (`platform-win32.cpp`) busy-waits through any interval under 1 ms on purpose (bug 1349801; open bug 1574815), one core per profiled process (10.2 cores for a page's Firefox 159 on adais at 0.5 ms or 0.2 ms, 0.3-0.4 at 1 ms, §19), and its 1 ms is a `Sleep(1)` that samples every 2 ms; from Firefox 161, 500 µs, as the sampler waits on a high-resolution waitable timer (bug 2082760) and Windows' finest timer is about 0.5 ms (Nightly 162 kept 0.54 ms when asked for 0.2 ms). Strobe reports the interval kept, not the one asked (Interval kept, above): the profile's `meta.interval` says the one asked. `moz:profiler.stop` saves into the downloads folder, which `user.js` points at the launch's own profile (never the user's). Every process records its start on the host's monotonic clock (`startTimeAsQueryPerformanceCounterValue`, `startTimeAsClockMonotonicNanosecondsSinceBoot`, `startTimeAsMachAbsoluteTimeNanoseconds`, read as integers; checked in Firefox 159 on Windows, Linux and macOS), so every sample and marker lands on strobe's clock (in i64 ns) and counts only inside a measured run's own host-clock span; a profile without that start refuses `ClockUnsynced`. strobe marks each measured run in the page (`performance.mark` just before and after it, outside its span): every measured run must have both marks in the profile, else `RunIncomplete` (a full buffer drops its oldest data, a start mark first), and they must bracket the run's span within 1 ms, else `ClockUnsynced`, which checks the profile's clock against strobe's every time. The page's processes are those holding the marks (a run that loads another site moves the page). A measured run with no sample of the page's main thread refuses `RunIncomplete`. The recording keeps the profile as Firefox wrote it (`gecko-profile.json.gz`, owner-only, which the Firefox Profiler opens) and what strobe read from it: - `cpu-g-r.cpuprofile`: one V8-format profile per page thread (main and DOM workers) and measured run, so `hotspots`, `a..b`, the diff flame and the reports read them as Chromium's. The page's JavaScript sits at its function's start (SpiderMonkey places a function at its parameter list as V8 does) and resolves through the stored maps; the browser's labels for its own work (`Reflow /path`, `Styles`, `get HTMLElement.offsetWidth`, `Minor GC`; a URL in a label is cut to its path, so a row matches across runs served on other ports) and its own scripts (chrome, resource and self-hosted, named with their file) are frames without a script; native frames fold into the frame above them (Firefox ships no symbols); `(idle)` where the stack is in the Idle category, `(program)` where only native code ran, `(profiler)` where the profiler captured a marker's stack. A sample weighs the time to the next one (as V8 counts it), cut to the run's span and to 50 ms (the sampler is late at times: 1 ms at the 99th percentile on a busy adais, up to 30 ms; past that the thread was not being sampled); a thread's last sample weighs the median gap of the page's main threads (the interval kept). Times are whole µs on a rounded timeline: each sample's weight is the next delta, the last one's a trailing delta (`timeDeltas` is one longer than `samples`); where the 50 ms cut a weight, the timeline closes the gap. - `gecko.json`: the rows the views read from a trace's SQL, from the page's main threads: `Reflow` intervals as layouts and `Styles` as style recalcs (`query layout`: forced when the page's script is on the stack, a reflow's own, a style recalc's from the `DoFlushPendingNotifications` around it; a frame Firefox gives no running line for, as the innermost JIT-compiled one of a marker's stack in Firefox 159, is located at its function's start, which `query layout` says); `GCMinor` with the nursery's freed bytes, `GCSlice` (main-thread pauses) and `GCMajor` (counted once, with no time of its own: its slices are its pauses) for `query gc`, whose helper-thread time reads `—` (Firefox marks none); style, layout and display-list building per run for the summary; `LongTask` markers for insights; and every sampled thread's CPU over the measured runs, from the CPU each sample carries (`threadCPUDelta`: ns on Windows and Linux, µs on macOS), which `query threads` and `a..b threads` read (a sample carries the CPU since the one before, so a run's CPU is exact to one gap at each of its edges, which the view's header says with the interval kept); `interval_us` (the one asked) and `sampling` (asked, kept, and the gaps it is the median of). - Not from Firefox: allocation samples (`query alloc`), heap snapshots (`heap`, `leaks`) and style invalidation tracking (`style`: Firefox records only each restyle's first cause) refuse `EngineUnsupported` (exit 4) with what answers instead; neither views nor reports suggest them, or `query sql`, for a Firefox recording. A Firefox attribution block's `meta.json` says `timing: perturbed`, as every attribution block does (rule 3). Calibration (a page bundled and minified by Bun with a linked map that has no `names`; a click runs a 60 ms planted hot function, 40 planted forced reflows that write two styles and read `offsetWidth`, and object churn), headless: on adais (Windows) and air (macOS) `plantedHotFunction ../src/app.ts:11:35` is the first row after `(idle)` (292 ms over 5 runs of 60 ms); on vps (a 2-core Linux VM) it reads 292 ms too, but the planted reflows cost more there (the `Reflow` label 342 ms) and rank above it. `query layout` counts exactly 40 forced layouts and 40 forced style recalcs per run on all three, all at `plantedForcedReflow ../src/app.ts:20:36`, and `query gc` 3 minor collections per run (6 on vps). **Where work went, in every block**: `Performance.getMetrics` at each run's start and end gives `main.script`, `main.layout`, `main.style` (ms per run) and `layout.count`, `style.count`, cheap enough for timing blocks, so they carry intervals like any descriptive metric. Beside them `main.paint` (ms per run, Chromium): prepaint and paint on the renderer main thread, from the trace every page block takes. Each frame report (`PipelineReporter`) whose main thread ran carries a `SendBeginMainFrameToCommit` stage with its `send_begin_mainframe_to_commit_breakdown` (µs per lifecycle step); `main.paint` sums its `prepaint_us` and `paint_us` over the run's renderer's stages whose own slice starts inside the run's window and outside its `ctx.untimed` stretches (the report around it may have begun before the run), 0 for a run that painted nothing. A FORKED report (the compositor's part while the main thread's is pending) carries no breakdown, so no frame counts twice. The block's value is the median over its runs. It is the main thread's work, never a frame's wait for a clock, so it holds headless too (where frames are refused `Headless`) and at background priority (kept, with the `BackgroundPriority` warning, as `main.work`); Firefox and WebKit refuse it `EngineUnsupported`, as every `main.*`. **Complete over short** (for every view, because agents act on it): a frame is located by its absolute file, line and column, never a shortened path; hotspots by function carry their three heaviest full call stacks (root to leaf, every frame located), and the text view prints the heaviest one in full. Displays may abbreviate; the record does not. ### 8.4 Counters A counter is an exact metric per run, `count.`. Two sources add to it: - **Bench file:** `ctx.count(name, n = 1)` during `run`. Outside a run it counts nothing. - **Page:** `globalThis.__strobeCount?.(name, n)` in app code (what `@hona/strobe-web`'s `count` calls). When strobe is not attached it costs one optional call. strobe installs the global with `Page.addScriptToEvaluateOnNewDocument` only in blocks of a series that declares a `count.*` metric, resets the totals before each run's start mark, and reads them after its end mark, so neither costs the run anything. A run that loads a page reads the departing document's totals first, as for `main.work`. In Firefox (§13.1) the global comes from a BiDi preload script and the marks are strobe's own on the host clock, with no trace; the totals are read the same way, but a document the page replaces by itself (a link, `location.assign`) rather than through `page.goto` takes its uncounted totals with it. In WebKit (WebDriver Classic: no preload) the global arrives only once strobe first touches a document, after the page's own scripts, so `count.*` (and `canvas.*`) of a run that loaded a page are refused `EngineUnsupported`; keep such runs on one page. A counter the page never touched reads 0. Compare builds that both count: a base without the counter reads 0, not "unknown". **GPU compiles** (`gpu.compile`): the time the browser's GPU process spends creating pipelines (`DeviceBase::APICreate*Pipeline*`) and compiling shaders (`*Compile*`), per run, on any of its threads, outermost slices only so a compile inside a pipeline counts once, each cut to the run's timed time (the GPU process compiles on its own threads, across the run's end or an untimed stretch's edge: only the part inside counts, never the whole slice). It reads the Dawn trace category, on only for a series that declares it. A pipeline whose shaders come from the cache still costs its creation. The page's own threads never show this: a game's first frame once froze 4.4 s here while its main thread was idle. `strobe query gpu` lists the GPU process's work by name from attribution traces (which carry the Dawn category). **Canvas frames** (`canvas.frames`, `canvas.interval.p50|p95|p99|max`): the page's own frames, at its own frame rate. Presented frames count compositor vsyncs, so a game drawing 60 frames a second on a 165 Hz display, or a 30 fps idle animation, reads as the display's rate there. strobe installs a wrapper on `GPUCanvasContext.prototype.getCurrentTexture` at document start, only for a series that declares a `canvas.*` metric. Each task that takes a texture is one frame, however many canvases it draws. `canvas.frames` is per run. The interval percentiles pool the gaps between consecutive frames over the measured runs, as `frames.interval` pools presented frames, and need their minimum n of gaps (`TailTooShort`). A gap never spans two documents. WebGPU canvases only for now: a WebGL or 2D canvas has no per-frame call to hook. A page without WebGPU (an engine or OS that does not ship it, or an origin that is not a secure context: no `GPUCanvasContext` in the page) refuses `canvas.*` (`EngineUnsupported`), never reads 0. **Network** (`net.requests`, `net.bytes`): the Network domain, enabled only for a series that declares a `net.*` metric. A request belongs to the run whose span it started in; its bytes on the wire (`loadingFinished.encodedDataLength`, else its chunks so far) may arrive after the run and still count for it. Each hop of a redirect is a request of its own, with its own URL and bytes (CDP: the hop's `redirectResponse.encodedDataLength`; BiDi: each hop's `responseCompleted`), and of the run its first hop started in whenever the next comes: a glob matches the hops whose URL it matches, never a whole chain by one hop's URL. Checked with `scripts/dev/derive` (`redirect`, a 302 to 10,000 bytes): `net.requests` 2, `net.bytes{/hop}` 123, `net.bytes{/payload}` 10,130, where both globs read the final hop's 10,130 before. `data:` and `blob:` requests are not network requests. A glob without `://` matches the URL's path; `*` stays within a path segment, `**` crosses them, and the query string never takes part: `net.requests{/api/session/*/message}`. Firefox: WebDriver BiDi's `network.beforeRequestSent` and `network.responseCompleted` (`bytesReceived`) of the page's browsing context, the same rules; never Resource Timing's `transferSize` (zero across origins). WebKit: WebDriver Classic reports no requests, so `net.*` is refused `EngineUnsupported`. **Garbage collection** (`main.gc`): the time V8's `V8.GC_*` slices on the renderer main thread cover in the run's timed time (a GC slice inside another counts once, directly or under a slice of another name: the union of their spans, not their sum), the pauses a frame waits through. A slice across an edge counts its part inside (on the main thread none can be: strobe's marks are tasks there, as are the pauses). The one main-thread task that does cross an edge is strobe's own: the `Runtime.evaluate` task that writes the run's start (or end) mark begins before it and ends after, so its part after `t0` (before `t1`), a fraction of a millisecond, counts in `main.busy`. Concurrent marking on helper threads is not in it. An allocation fix shows here and in `main.work`; the attribution view `query gc` names the phases. **Layout shift** (`layout.shift`): the sum of `LayoutShift` scores in the run's span, from the trace's `loading` category. Shifts right after input count: a run starts with its input, and a tab that jumps after a click is what the metric is for. Attribution blocks of such a series keep the five largest shifts with the elements they moved (named over `DOM.describeNode` while the page is open), shown by `query shifts` and in the bench's why lines: the shifts the metric sums, with its score (weighted for a frame's shift, as it is) and none from an untimed stretch (they once listed one there beside a `layout.shift` of 0). ### 8.5 OS and GPU sampling One sampler thread, 100 ms ticks: per-CPU busy, and GPU engine time and VRAM for the target's process set (§13.2: its tree and everything launched for its home, so an app's detached service is counted). CPU frequency and Task Manager's utility once a second. The set is re-read every second, or less often so the re-read stays under 0.2% of a core (the first read, which reads every process's stamp once, does not set the pace). A process without a stamp is read again only while it is under 2 s old by its own start time (§13.2): read again for 2 s after strobe first saw it, every process that ran before the first scan was read again at the next one, which had made the sampler 1.0% of a core instead of 0.17% in a 5 s block on adais. The re-read takes the set's pids from the processes' stat lines alone (Linux: no `smaps_rollup`, no executable link), and Linux reads GPU statistics only when a device has a render node (`renderD*`): a display-only device (a VM's bochs-drm or hyperv_drm framebuffer, simpledrm) runs no process's GPU work, and listing every target process's file descriptors each second for its DRM files was most of the cost there. On vps (KVM, 2 vCPUs, a headless page's 9-17 s blocks) the sampler read 0.57-0.65% of a core: the set's re-read 0.21-0.26% (about 20 ms each, almost all of it the browser processes' `smaps_rollup` it never used), the DRM file scan of the display-only device 0.19-0.23%, `/proc/stat` each tick 0.08%, strobe's own readings 0.05%; hypervisor steal over those blocks was 0 (`/proc/stat`), so none of it was steal billed to the thread (KVM's steal clock keeps steal out of a thread's CPU time anyway). With both changes the same blocks read 0.31-0.40% (six blocks). Budget: 0.5% of one core, and the sampler measures its own CPU cycle-exact (rule 2); on adais it costs 0.23–0.42%. Samplers read OS APIs directly and never spawn processes (§13.6). On macOS the per-process GPU reader keeps the accelerators and the set's GPU clients open between ticks (each client's owner is read once, every client is listed again each second and when the set changes), so a tick reads only those clients' GPU time (about 25 µs on air, where matching the accelerators twice and walking all 71 GPU clients had cost 0.2 ms a tick, and every client's owner 0.3 ms a second). Bench blocks and recordings report `gpu.process.3d` (per run) and `gpu.vram` (after the runs); processes the sampler cannot open are listed, never counted as 0. The same sampler reads strobe's own process (rule 2): each tick every thread's CPU (% of one core, from the process's cycles against the TSC); about once a second, and at its last tick, its GPU engine time over the span since the last such reading (a per-process GPU reader of its own: on Windows a D3DKMT query per engine of every adapter strobe opened, which each tick had cost more than the rest of the tick) and its private memory as `memory.app.private` counts a target's (Windows' private commit, macOS's physical footprint, Linux's `smaps_rollup` anonymous and swapped pages, 94 µs a read on vps). Each reading stays on the tick that took it, with the span it covers; a window weights each by its overlap. strobe's own process is in no target's set (`host::process_set` leaves it out unless it is the root). A recording keeps them under `os.strobe` in `meta.json` (means over the recording, peak memory, and each tick), and `strobe open` draws them as the track "strobe itself (its own process)" (§11.4). ### 8.6 Memory: census and heap snapshots The memory probes generalize what a good in-app "capture heap snapshots" command writes (one folder with a backend's heap, every window's heap, the main process's heap, a memory-infra dump, per-window page stats, `app.getAppMetrics()` before and after, and GPU info) and add what makes it a measurement. **Census** (attribution, one moment): | Layer | Source | Notes | |---|---|---| | OS | private bytes, working set, commit per process | the truth for "how much RAM" | | Chromium allocators | memory-infra dump | use effective sizes from the dump's ownership edges; never sum allocators (cc, skia, gpu and web_cache overlap malloc, discardable and shared memory) | | JS heaps | V8 heap statistics per renderer and main; the server's `process.memoryUsage()` | post-GC only when the census forces a GC | | GPU | per-process dedicated and shared VRAM (§13.6) | | | page | elements, canvases, images | labelled `estimate` (decoded image sizes are not known from the page) | | unexplained | OS private − known layers | always shown; growth here means native memory | Rules: - Census readings come first. A process's readings taken after a heap snapshot of that process are discarded (`CensusContaminated`): a snapshot raises the memory of the process it serializes. - All processes' OS readings in one census are taken within 100 ms of each other. - `performance.memory` is quantized unless the app runs with `--enable-precise-memory-info`; without it strobe does not report it. As built (`probe::census`): the page's JS heap after a forced GC (`Runtime.getHeapUsage`), its DOM counters (`Memory.getDOMCounters`), and every target process's OS private bytes and working set labelled with Chromium's process type (`SystemInfo.getProcessInfo`). The allocator layer is a detailed memory-infra dump through a short trace of its own, read as effective sizes per process: top-level allocators are layers, their children allocators; the `tracing` allocator (the dump's own trace buffers) is left out; each `allocators[]` keeps the process's name in the dump's trace (`name`: `Browser`, `Renderer`, `GPU Process`, `Service: .mojom.`), the only name a phone's census has (§13.1 leak runs on a phone). The DOM counters are the page's own: strobe's listeners in the page (touch on a phone or emulated phone, 6; key evidence, 2; OS input, up to 10) run only in the top document and count themselves in `window.__strobeListeners`, which the census reads before its forced GC and takes off `jsEventListeners`, saturating (`scripts/dev/listeners.bench.ts`: an emulated phone's page with 0 or 3 listeners read 6 and 9 before, 0 and 3 now). Taken at each point of a memory recording, after every run of a leak run (with a dump at S0 and S2 only) and once at the end of every attribution block (before its heap snapshot). GPU and page (images, canvases) layers are not built. The page's renderer is the process that made a `performance.mark` the page makes inside the dump's trace (`blink.user_timing`), kept as the census's `page_pid`; `query memory` labels it `renderer (page)` and puts the CensusContaminated `*` on it. A census from before that mark names it only when sizes leave one choice (the only renderer, or the only one whose V8 layer holds 90% of the page's used heap); otherwise the view says the page's renderer is not known, never the nearest by size (which named a 1 MB V8 beside the page's 21 MB one). Allocators over a process's OS private bytes leave its `unexplained` empty (`—`), never negative. **Heap snapshots** (attribution only): - One process at a time: a snapshot can double a process's memory. Main last when the bridge serializes its own process. - Post-GC by construction. Never in a timing block; the target pauses while it serializes. - Sensitive (rule 9): stored gzip, owner-only. Views return classes, counts, sizes, dominators and retainer paths with string values redacted. - Compared only within one runtime: a renderer's V8 snapshot, the Electron main process's, and the Bun server's are separate series. **Leaks over time** (`strobe record --probes memory --for `, built): no trace; at the start, the middle and the end, a post-GC census (JS heap, DOM nodes; OS private bytes at the start only, since a snapshot inflates its process) and then a snapshot (S0, S1, S2). `query leaks` lists objects allocated between S0 and S1 that are alive in S2, by class, with retainer paths; `js.heap.growth` and `dom.nodes.growth` are per minute over windows where strobe never stopped the page: each half's wait, from a baseline read after the snapshot before it (one macrotask first, so what queued during the snapshot runs before it: `Runtime.evaluate` of `new Promise(r => setTimeout(r, 0))`, then a forced collection, the heap and the DOM nodes) to the next census's reading (stamped just before its forced collection), before that census's dump and snapshot: ((C1 − B0) + (C2 − B1)) over the two windows' time (`growth_ms`, each in `growth_windows`). A snapshot freezes the page for seconds, and a page driven by time (a timer that runs its missed ticks, a socket's queued messages, a step scaled by elapsed time) makes up for it afterwards, so a span holding a snapshot is wrong either way: per wall minute it read 3× low (snapshots of a large heap twice the recording's length), and per minute less the freeze high (+6% to +10% on adais, up to +200% when the snapshots outlast `--for`). On `wir:` the rate is still over the span between the first and last snapshot, less the middle `Heap.snapshot` call: WebKit gives the heap only through a snapshot, so there is no reading outside one to start a window from. Under 2 s of that time (`--for` under 2 s) both rates are refused `RunIncomplete` and the summary shows none (a rate per minute would stretch it over 30 times: `wir:` at `--for 0s` once printed +976,562 MB a minute). `query heap` reads S2. **Leaks** (`strobe bench --head --probes memory --repeat `, built; `--base` is optional and gets its own leak run, nothing is paired), in one block with no trace: 1. Warm up until steady, then census and snapshot S0. 2. Call `run` (and `reset`) N times (default 10), a post-GC census after each; S1. N more times; S2. 3. Growth per run of the post-GC JS heap and of DOM nodes is the Theil–Sen slope over the 2N + 1 censuses with Sen's 95% interval, and per half (runs 1–N, N–2N). Objects allocated between S0 and S1 that survive in S2, by class with retainer paths, are the suspects; they split into the app's objects (objects, closures, arrays, strings, DOM) and the engine's (compiled code, hidden internals, object shapes), which grow while the JIT compiles and optimizes. 4. Verdict per layer against a bound per run (max(16 KB, 0.1% of the heap); 1 DOM node): `leaks /run` when the whole interval is above the bound, the last N runs still grow and the app's suspects hold more than the bound per run; `no app leak: … V8's compiled code and internals` when the heap grows but the app's suspects stay within the bound; `grows, then levels off` when only the first N runs grow (a cache filling up); `no growth` within the bound; else `unclear`. OS memory per process is in each census, but the snapshotted renderer's readings after S0 are marked `os_after_snapshot` (CensusContaminated). Checked on a planted leak (800 objects kept per click, 5 clicks per run, N = 5): `leaks +356 KB/run [+345 … +378]`, 20,000 suspects of the planted class. A real scroll case without a leak grows about +30 KB/run, all compiled code: `no app leak`. **Native memory by what holds it** (`probe::memory`, built). A native app's private bytes are not its host memory: on Windows the commit charge includes the commit Windows charges for the process's GPU allocations, so a tray app holding a 725 MB model in VRAM read 841 MB "private" with 101 MB of its own. A reading splits each process of the tree, at sample points between runs (it walks the process's address space, which holds its memory lock for a moment; never inside a timed run): | Part | Windows (`VirtualQueryEx`, committed regions) | Linux (`/proc//smaps`) | |---|---|---| | `private` (`memory.app.private`) | the commit charge (`PrivateUsage`) | anonymous resident + swapped pages (`Anonymous` + `Swap`, as `smaps_rollup`) | | `host` (`memory.app.host`) | committed `MEM_PRIVATE` regions, write-combined and uncached ones left out | private pages of anonymous mappings (heap, stacks, `[anon:…]`) | | `gpu mapped` | committed regions mapped write-combined or uncached: a GPU driver's CPU view of the memory it allocated for the process (checked: an NVIDIA Vulkan app's VRAM, 727 MB against `gpu.vram` 725 MB) | resident pages of GPU device mappings (`/dev/dri/*`, `/dev/nvidia*`, …) | | `unmapped` | commit charge the walk cannot place: charge − private regions − copy-on-write pages of images and views (page tables, commit a driver holds) | — | | `shared` | committed views of pagefile-backed sections | resident shared memory (shared anonymous mappings, `/dev/shm`, memfd, SysV); a file mapped shared is in `files` | | `files`, `image` | committed views of data files; images (code and their data) | resident pages of other files; of files with an executable mapping | | working set | `WorkingSetSize` and `PrivateWorkingSetSize` (Windows 11) | `Rss`; private resident | `memory.app.host` is given only when every process of the tree was read (a process strobe cannot open is listed, never counted as 0). macOS gives another process's regions only to a debugger (its task port), so there is no reading there; `memory.app.private` is the physical footprint, GPU allocations included, and the dirty pages of shared anonymous memory (`MAP_SHARED | MAP_ANON`) charged once to the process that created it (air, `known shm 150`: 151 MB, where Windows' private commit and Linux's `Anonymous` leave it out). Readings are taken at the end of the last measured run of every native bench block (before its reset, §9.6) and after `record exe:`, `record app:` and `record pid:` windows, stored in `memory.json`; `query memory` shows a reading by process (default) or by part (`--by layer`), `--at ` picks one, `a..b memory` diffs two by part. **Native leak runs** (`bench --probes memory --repeat ` on a native target, built): warmup until steady, a reading, then 2N runs with a reading after each; growth per run of the tree's host memory (and of its private bytes) is the Theil–Sen slope over the 2N + 1 readings with Sen's interval, judged as for the JS heap against max(16 KB, 0.1% of host memory) per run. A native allocator keeps freed memory for reuse, so a heap that fills and holds reads `grows, then levels off`. No snapshots: the readings say how much, not which objects. Checked on a planted leak (a windowless app keeping 1 MB every 250 ms, 1 s runs, N = 4): `leaks +5.2 MB/run [+4.8 … +5.8]`; the same app without the leak: `unclear: +0 B … +201 KB/run`. **Firefox: about:memory** (`probe::gecko::census`, built). `nsIMemoryInfoDumper` in the chrome scope writes every process's memory reports after about:memory's "Minimize memory usage" (GC and CC in every process first); the report is kept beside the recording (`memory-.json.gz`). An attribution block and `record --probes attribution` take it once, right before the profiler starts: after a profile the profiler keeps its own memory (on Linux its unwinder's tables, 40-48 MB in every process on vps), which would read as the browser's; its `explicit/profiler` reports are left out of the layers anyway. A leak run takes it at S0 and after every run, with no profiler. Each census says when it was taken (`taken`), which `query memory` prints. As a census: every process's OS private bytes and working set with Firefox's process type (the page's content process, the `osPid` of strobe's own browsing context found by its BiDi id through the remote agent's `NavigableManager`, is `renderer`); the report and this file are owner-only (rule 9: about:memory names every page the browser holds); per process the `explicit/` tree by layer (its first level, plus `heap-unclassified`: heap allocated that no report claims) and by allocator (its second level), which `query memory --by layer|allocator` shows; the page's JavaScript memory (its window's `js-zone` and `js-realm` reports: objects, strings, shapes, scripts and their buffers; `unused-gc-things` counts toward the total only; SpiderMonkey reports JIT code per runtime, so none is in it); DOM nodes in the document, DOM bytes and `orphan-nodes` bytes (nodes no document holds). No heap snapshot: Firefox takes them inside the content process (`ChromeUtils.saveHeapSnapshot`), which BiDi's chrome scope does not reach, so `heap` and `leaks` refuse `EngineUnsupported`. **Firefox leak runs** (`bench --engine firefox --probes memory --repeat `, built): as Chromium's, with about:memory as the census: growth per run of the page's JavaScript memory and of DOM nodes, judged against the same bounds. No snapshots, so no suspects and no `no app leak` split (the series holds no JIT code to split off). DOM nodes are the document's: a detached node shows only in the census's `orphan-nodes` bytes, which the leak run's text names. A census that could not name the page's content process has no page JavaScript memory: the leak run refuses `RunIncomplete`. `record --engine firefox --probes memory` refuses `EngineUnsupported` and names the leak run. Checked on adais (N = 6): a 100 KB `Uint8Array` kept per run read `leaks +100 KB/run [+100 … +100]`, a kept 12,800-element array `+104 KB/run` (100 KB of elements and their capacity), 2,000 small objects per run `+316 KB/run`, and the same page's work case without a leak `no growth`; the array case read `+104 KB/run` on air too. **Leak runs on a phone** (`bench --mobile --probes memory --repeat `, and a memory recording `record --mobile --probes memory`, which keeps `phone` in its meta and refuses `memory.app.private`: not yet run on a phone or emulator): the page is read over DevTools as on a desktop: post-GC JS heap and DOM counters, heap snapshots S0/S1/S2 and Chrome's memory-infra dump, all the phone's; its census names no process of this computer's (`processes: null`). `query memory` then lists the page's processes from the dump, typed by `allocators[].name` (Browser → browser, Renderer → renderer, GPU Process → GPU, `Service: x.mojom.Y` → its service; a census from before names: a renderer by its V8 layer, else other; pid 0, the dump's global part, is no process), private and working set `—`, so `--by process|layer|allocator` and `a..b memory` render, each with one line: `the page's processes are on the phone: listed from Chromium's memory dump, with no OS private bytes or working set (strobe reads no process memory of the phone's)`. Without a dump the line says the census has none to list them and only the page's JS heap and DOM are there, with no table header. JSON `phone: true`, `processes_from: dump|none`, `note`; `a..b` adds `phone` (adais emulator, `listeners.bench.ts --probes memory --repeat 3 --target phone`: 0.27.0 an empty table, now browser, renderer, GPU and `renderer (page)` 67.4 MB by allocators). Heap censuses time nothing, so no phone refusal of timed runs applies (an emulator, Wi-Fi, Battery Saver, a low battery), nor the primary's phone refusal: the run reads the page's heap and DOM, never the primary (`prepared-servers.bench.ts` case `keeps host`, primary `cpu.work`). adais emulator (Android 15 Chrome, `prepared-servers.bench.ts --probes memory --repeat 3 --mobile=emulator-5590`, exp_72e42f): `keeps` (25,000 references a run, 4 bytes each with V8's compressed pointers = 97.7 KiB) `js heap leaks +98 KB/run · +98 KB [+98 KB … +98 KB]`, suspects 3 Arrays 293 KB; `clean` `no growth (within ±16 KB/run)`; DOM no growth on both. `record --mobile --probes memory` is still refused. ### 8.7 Imports (`file:`) `strobe record file:` turns an existing capture into a recording: a Chrome trace (JSON or proto), a `.cpuprofile`, a `.heapsnapshot`, or a folder of them (read through its `manifest.json`). Imported recordings get `valid: unknown`: nothing proves how they were taken. They work with every view, never with `bench`. ### 8.8 Video (`--video`) A video of what the target showed, for a person to watch beside the numbers (`probe::video`, `app::video`). Every source costs CPU and GPU time in strobe, the OS's compositor or encoder, or the target's own renderer, so a video is taken only where nothing is timed (rule 3): `record --video` (its `timing` is `perturbed`), `bench --check --video` (the check block films its run), and `bench --video` on a comparison: every timed pair runs clean first, then one untimed video block per variant (warmed, one measured run, kept, in no verdict), each side through the same capture. A filmed `--check` case's line says `· perturbed (filmed)`, its `self` line adds `encoder` (the encoder process's CPU over the video's span: ffmpeg's 281 ms over a 2.1 s headless film on adais, 13% of a core). Chromium's screencast starts and ends processes of the browser in the run (adais: one living ~100 ms in a filmed click run), so a filmed block's `cpu.*` can be refused `RunIncomplete`; that refusal names the film (`run --check without --video to check cpu.*`) and fails no check. Two kinds, named in `video.json` (`kind`), never compared side by side when they differ (the series gets `video.mixed` and a `Video` warning): | kind | source | works | |---|---|---| | `screen` | the OS's composited copy of the window: Windows.Graphics.Capture (Windows 10 1903+; no cursor, no border on 11) into the GPU's video processor (scale, NV12) and Media Foundation's H.264 encoder (the GPU's when it has one, else software); a ScreenCaptureKit stream of the window alone (`initWithDesktopIndependentWindow:`, 4:2:0) into AVAssetWriter H.264 (macOS 13+); the X server's copy of the window (`GetImage`) at 30 fps into ffmpeg (Linux X11) | headed targets with a window: native apps, headed pages, Electron and webview apps | | `renderer` | a Chromium page's own compositor frames: DevTools `Page.startScreencast` (JPEG, at most 1280 px), each at its swap time (`metadata.timestamp`, the page's wall clock, a phone's own for a phone's page, mapped to the host clock through the page's `performance.timeOrigin + performance.now()` read over DevTools at the start, the closest of five round trips; half that round trip adds to the video's precision; when that is 5 ms or more from the page's `Date.now()` (its monotonic part stopped while the device slept since the page loaded), `Date.now()` instead, and its millisecond adds too), in a Matroska stream of millisecond times into ffmpeg at a constant 30 fps (as Playwright's `recordVideo` feeds its ffmpeg) | Chromium, Electron, WebView2 over CDP; headless and on a locked session. Not proof that a frame reached a display | A headless page films its renderer; a headed one films the screen, and falls back to its renderer (saying why in `note`) when the OS capture cannot start. Refused (`record` exits 4; a bench says it once, `Video`, and goes on): a target with no window (`window: false`, script, cli: `NotForTarget`), an attached target (`cdp:`, `pid:`: `NotForTarget`), a headless page in Firefox or WebKit (`Headless`: stock builds have no screencast; Playwright films its own patched Firefox through Juggler's `Page.startScreencast` and its patched WebKit through `Screencast.startScreencast`, and its BiDi path polls `browsingContext.captureScreenshot` every 40 ms stamped on arrival, which re-renders the page per frame and is not a frame time), a native Wayland window of the user's compositor (`NoPresentSource`: another client's pixels come only through the portal's picker, rule 11; on strobe's own compositor, §13.7, the window is filmed from screencopy), no ffmpeg where one is needed (`NoPresentSource` with how to install it), and on macOS no Screen Recording permission. A `launch` case is not filmed (said once): it starts before the window or page every source films from exists, so its video would miss the launch it times. - Files: `video.mp4` (H.264; WebM VP9/VP8 when the only ffmpeg has no libx264, as Playwright's own build) and `video.json`, both in the block's recording, the video owner-only (`store::adopt`, rule 9), counted in the recordings' disk use like any file there. At most 1280 px on the long edge, at most 60 fps: OS captures write each frame at its own time (Windows holds a frame that comes within 1/60 s of the last and writes it 1/60 s after it, up to 16.7 ms late); ffmpeg writes a constant 30 fps, repeating a frame until the next (a frame lands up to half a frame from its time). Media time 0 is the capture's start: the first frame (the window or page as it was) stands from there, so no run or input sits before the video on an idle window. The last frame is held to the end of the recording. A video block warms up as a timing block does and films the run after the steady pair, never the cold first run. The file is written in a run-folder scratch directory (`probe::video::Scratch`) removed whatever happens (a failed capture, no frame, a block that ended early). Windows: a GPU encoder that fails its first frame gives way to Media Foundation's software encoder, which writes the file from the start. - `video.json`: `file`, `kind`, `about`, `source`, `encoder`, `note`, `width`, `height`, `t0_ns` and `t1_ns` (host clock at media time 0 and at the end), `fps` (constant) or `max_fps`, `precision_ms`, `frames` (what the file holds) and `source_frames` (what the source delivered), strobe's capture CPU (`cpu_ms`, `setup_cpu_ms`) and an ffmpeg encoder's (`encoder_cpu_ms`, from its own `wait4` rusage or process handle); `runs` (`run`, `start_s`, `end_s`, `measured`: warmup runs too) and `markers` (`t_s`, `press_s`, `end_s`, `kind`, `label`, `reaction_s`) on media time. A marker is a page call the block served (its name and what it aimed at: an element, a key, a position, a page's address without its query; typed text only by its length), at the input strobe sent inside it when the driver or the OS input logged one, else at the call's start; phases from strobe's host-clock edges (with a trace too: strobe notes each edge's host time beside the trace's mark); `reaction_s` from the recording's action log (`actions.json.gz`, its sample's latency, from the press for a sample timed from one), written before the video so it is there. - Usage errors, never ignored: `--video` with `--blocks`, `--pixels-only`, `--probes memory` or `--bisect`, and `record --video` of a `file:` import. `--video` is no part of the rerun ledger's identity (it changes no timed block). An implementation comparison judges each side's target on its own (one may be filmed, the other said why not); a stop (130) or a lock wait that ran out (124) during the video or attribution blocks ends the bench as during the pairs. - A locked Windows session: Windows.Graphics.Capture kept delivering a window's frames (a native fixture at 20 fps, 55 frames in 3.3 s, on adais), so a native series that runs locked is filmed; its `note` says these are the compositor's frames while the display showed the lock screen. - Cost, measured: adais (Windows 11, RTX 5070 Ti), a 466×312 native window at 20 fps, Media Foundation on the GPU's encoder: the capture thread 15 ms of CPU over 3.3 s (0.45% of a core) after a one-time 290 ms setup (device, encoder), and strobe's whole process in the check run 4.5% → 6.6% of a core (Media Foundation's threads are in it; two runs each). A headless page's renderer at 1280×744: strobe's thread 9 ms and ffmpeg (libx264) 406 ms of CPU over 3.2 s (13% of a core). vps (2 vCPU VM): Playwright's ffmpeg (VP8) 3.1 s of CPU over 3.5 s. air (M4, headless page at 60 fps, 1280×750): strobe's thread 6 ms, Homebrew's ffmpeg (libx264) 470 ms over 2.9 s. The capture thread waits for the GPU's copy by sleeping: the driver's own wait spun (350 ms over the same 3.3 s). ### 8.9 HTTP probe (`ctx.httpProbe`) Whether a server the target hosts could answer another client while the runs worked, on the timeline of its runs, threads and stacks (`probe::http_probe`, `app::query::http`). A bench file's `setup` asks for it once per block: `ctx.httpProbe({ url, method, cadenceMs, timeoutMs, idleMs, connection, maxInFlight, headers, authorization, expect })`. strobe sends the request from threads of its own on strobe's CPUs (off the target's, §9.4), on absolute deadlines: slot k is due at the probe's start + k × `cadenceMs` (default 100, at least 10) whatever the probes before it are doing (open loop). A scheduler thread hands each due slot to a free worker, which stamps `dispatch` before it takes or opens a connection, `sent` once the request is written and flushed, and `completed` once the whole response is read. Latency is `completed − dispatch`: from the dispatch, not the slot's due time, so up to the lateness allowed below of strobe's own lag never counts as the server's. - **Missed sends.** A slot whose dispatch comes more than min(cadence / 2, 10 ms) after it was due is never sent (`skipped_driver_late`, with its lag), and one due while every connection the probe may hold is open is not either (`skipped_busy`): strobe's lag, never billed to the server, never sent late, never a burst to catch up. `http.missed_sends` counts them; more than max(1, 5% of those due) in the measured runs refuses `http.latency.*` (`OverheadBudget`: a stall then would go unseen). - **Connections.** `"new"` (the default) opens a fresh TCP connection (Nagle off) per probe: a server whose JavaScript thread is held for 750 ms still has each probe accepted by the kernel on time, and answers the whole cohort once it is free: 750, 650, … 50 ms, never one slow sample and a gap. `"keep-alive"` takes a free connection the probe kept, else opens one; a kept one the server had closed is replaced within the same deadline. `maxInFlight` (default 64) must allow ⌈`timeoutMs` / `cadenceMs`⌉ + 1 probes open at once (usage error, exit 2, otherwise: the probe never holds a send back); strobe runs that many workers and two more. - **Options.** `url`: `http://` on this computer's loopback only (127.0.0.1, [::1], `localhost`): https, `user:pass@` and any other host are usage errors. `method`: GET (default), HEAD or POST (no body). `timeoutMs` (default 1000): from the dispatch to the whole response, connecting included. `expect`: `status` (a code or `[lo, hi]`, default 200–299), optionally `bodyContains` or `jsonPath` (`{ path: "$.status", equals: "ok" }`), read from a body of at most 1 MiB that strobe never keeps. A mistake in any option fails setup with exit 2, as do a second call, a call outside `setup`, and `headers` that carry a credential (`Authorization`, `Cookie`, `Proxy-Authorization`, `X-Api-Key`) or one strobe writes itself (`Host`, `Connection`, `Content-Length`, `Transfer-Encoding`, `User-Agent`). - **Secrets.** `authorization` (the Authorization header's value, or `{ header, value }` for another header) is held in strobe's memory only (`http_probe::Secret`: no `Serialize`, `Display` or `Clone`, `Debug` prints `[REDACTED]`, zeroed when it drops, as are the protocol line and request bytes that carried it). It reaches strobe over the runner's loopback socket (never argv, no CLI flag takes a credential) and never a recording, result, log, report, trace, MCP answer or panic message. The probe's request is no action of the block's log (§9.6). A URL's query values and extra headers' values are sent, never written: recordings keep the URL as `…?key=…` (a segment without `=` as `…`) and the headers' names. The target gets all of them, so strobe never writes a dump of the target's memory in a block whose probe carries one (`Runner::http_secret`: the authorization, any extra header's value, a query value of 8 characters or more): an attribution block takes no heap snapshot and says so (`Attribution`, info), and a Bun script host never runs `--heap-prof` (a heap snapshot; Node's is a sampling profile of allocation stacks, which keeps no value); `--probes memory` still takes them, owner-only (rule 9). Recordings are never rewritten. The experiment's text logs (what the target and the hooks printed: `runner.log` and `script.log`, owner-only) have each occurrence of those values, as sent and percent-decoded, made as many `*`s in place after every block whose setup asked for the probe, whether the probe ran in it or not (a clean timing, `--video`, `--pixels` or batch block) and whether the block passed or failed, and before a runner that died puts its log's tail in the error (`http_probe::scrub_logs`; a log it cannot write is said, `HttpProbe`, never removed). `scripts/dev/http-probe-check.ts` reads every file strobe wrote (a Chromium attribution block's too, and a quick timed `mixed` A/A with its report), and every answer it printed, for a fresh random token sent both ways. - **Idle control.** Once setup has settled (100 ms) and before warmup, the probe runs alone for `idleMs` (default 500; 0 skips it). Every probe due then must answer as `expect` asks, else the block is refused `RunIncomplete` before any run (a server that fails a probe idle fails it in every run). Its median is `http.idle.p50`: the server's own floor. - **Blocks** (rule 3). The probe's requests are work the server does, so it never runs in a clean timing block. A series that declares no `http.*` runs it only in the blocks that keep a recording to explain one: attribution blocks (`--why`, `record --bench`) and `--check` blocks, so stacks, the threads' timeline and the probe land on one recording. A series that declares `http.*` pairs probe blocks (`timing: http-probe`, no profilers, keeping only their `http.*` values): every block when it declares nothing else; otherwise its timing blocks stay clean and K pairs of probe blocks follow (with an `http.*` primary they come first and K timing pairs follow), K chosen as for counter blocks (§7.5), kept in `result.json` `pass` (`http: true` for probe blocks) and said once (`HttpProbe`, info). `--blocks N` runs N of each kind. Usage errors: `http.*` in a launch case (no setup), or beside `cpu.instructions` on Linux (one pass of other blocks per series). Such a series is never batched (§5.6); a batch's launch runs no probe. - **Metrics** (`src/model.rs`). Only probes due in a measured run's timed parts count: not the idle control, warmup runs, `ctx.untimed` stretches (from before strobe's readings at their start to after those at their end), resets or setup. `http.latency.p50` (minimum n 10), `.p95` (20) and `.max` (1), in ms, pooled over the block's measured runs; `http.timeouts`, `http.errors`, `http.missed_sends` and `http.probes` per measured run; `http.idle.p50`. Undeclared ones are context (descriptive) where every block has them. A response not as `expect` asks, or a connection that failed, is an error, never a latency, and any error refuses `http.latency.*` (`RunIncomplete`: a fast error is no fast answer). A timeout ranks above every answer. A quantile is the R-7 one (the value at position (n − 1) × p of the n ranked probes, interpolated between its two neighbours), so it is right-censored as soon as either neighbour is a timeout: p > (answers − 1) / (n − 1). It is then refused `RunIncomplete`, never read as the timeout and never taken from the answers alone (the looser 1 − timeouts / n would read p 0.89 of 9 answers and 1 timeout from a timeout); `http.timeouts` counts them. No probe due in a timed part refuses the latencies `TailTooShort`. `http.*` declared where setup never called `ctx.httpProbe`: `NoMetric`. - **Cost.** The probe's threads read their own CPU (`overhead.http_probe_pct`, % of one core over the probe's span; §19: 1%, over it a timed block warns `OverheadBudget`; over 2% a timed block refuses `http.latency.*` `OverheadBudget`, a `--check` warns that a timed bench would, an attribution block says nothing). Measured at the default 100 ms (2026-11-09): vps (2 vCPU, unpinned) 0.27–0.29% of a core with a new connection per probe and no missed send; adais (Windows 11) 0.56–0.58% with a new connection per probe (Windows' loopback connect and close, about 0.55 ms of CPU a probe) and 0.23% with `keep-alive`. - **Data.** `http_probe.json.gz` (owner-only) in the block's recording, or in one of its own when the block kept none (a script's `--check`, a probe pass's first pair; `probes: http-probe`): the setup without secrets (`config`), the probe's span and CPU, the idle window, each run's span with its untimed stretches and phases, and every probe (`seq`, `scheduled_ns`, `dispatch_ns`, `sent_ns`, `completed_ns`, `status`, `outcome`, `when`, `run`). Its summary is `meta.json` `http_probe` and each block's `http` in `result.json` (with the recording that keeps its probes). - **Views.** `query http [--phase ] [--top N]` (§11.1): the idle control against the measured runs, each fate, the quantiles with censoring said, the probe's CPU, and the stalled cohorts: a cohort ends where nothing out bridged a gap (every probe before one had its answer or its deadline when it was sent), and says one stall (`the server answered none for X`, `at least X`) or stalls back to back that held probes across a drain (`spanned N stalls over X (longest silence S; K probe(s) waited across a brief drain)`), with its timeouts wherever they fell (§11.1). Its latency quantiles read `—` (unread, with why) wherever the block refuses them (an error or bad answer, too many missed sends, no probe counted, a timed block's probe over 2% of a core), so every view agrees with the check's refusal. `open` (§11.4) draws the probe beside the runs and threads; `report` draws an `HTTP probe & stalled cohorts` card (§11.2). - **Calibration.** `scripts/dev/http-probe.bench.ts` (run through `http-probe-check.ts`): a 750 ms stall of the server's thread probed every 100 ms is one cohort of about 8 probes (the first ones timing out at 400 ms, then a falling staircase; with a 1500 ms timeout its slowest ≈750 ms), a 500 or a wrong JSON answer is counted and refuses the latencies, and strobe's own scheduler held 400 ms (`STROBE_PROBE_PAUSE=,`, calibration only: holds it once) is missed sends with no timeout or error. Cohorts (adais, 2026-11-10): `stalls-100ms-apart` (two 550 ms thread stalls 100 ms apart, 1000 ms timeout) is two cohorts a run, each `answered none for` ≈470–530 ms (exp_bd763c rec_a4aa5d); `stalls-back-to-back` (no gap: the loop reads no request between them) one cohort `answered none for 1033 ms · 1 timed out at 1000 ms`, the timeout first in it: true, nothing was answered (rec_090106); `held-gap-carried` (a server in the bench file's process holds each request to the end of its 550 ms window, 150 ms apart, a request in the gap to the second window's end, as a stalled loop's accept-then-read does): 0.25.0 read one cohort `12 probes: 473 375 274 175 74 673 573 … 75 ms · the server answered none for 1175 ms` (false: it answered the first five at ≈475 ms), now two, `answered none for 474 ms` and `7 probes: 673 … 75 ms · answered none for 675 ms` (rec_ebf6f8); `held-edge-carried` (the first window's last 100 ms carried too, 700 ms timeout): 0.25.0 `12 probes: 475 375 275 175 TIMEOUT 674 … 74 ms · the server answered none for 1175 ms` (false, and the timeout unsaid), now `spanned 2 stalls over 1175 ms (longest silence 700 ms; 1 probe waited across a brief drain) · 1 timed out at 700 ms` (rec_5cc77b). vps (exp_4fce2a): 0.25.0 misread `stalls-back-to-back`'s real thread stalls too, `10 probes: 467 367 267 167 68 519 419 319 219 119 ms · the server answered none for 1019 ms` (Linux's loop answered the first five between the calls), now two cohorts, 468 ms and 519 ms (rec_343e56); the held cases read as on adais (1161/1163 ms before, now 462 + 661 ms, and `spanned 2 stalls over 1163 ms, 1 timed out at 700 ms`). `held-hung` (one 1200 ms window, 100 ms timeout, 100 ms cadence: each timeout over before the next probe is sent; adais exp_b2044e rec_f14a84): `11 probes: TIMEOUT ×11 · the server answered none for at least 1100 ms · 11 timed out at 100 ms` and `12 probes: TIMEOUT ×11 94 ms · answered none for 1195 ms · 11 timed out at 100 ms`, where 015e5b9 showed no stall. `status-500` and `wrong-json`: `p50 — · p95 — · max — (unread: 3 errors or bad answers)` (0.25.0 printed 0.70 / 0.72 / 0.72 ms and 0.52 / 0.59 / 0.60 ms beside a check that refused them `RunIncomplete`); `driver-delayed` (`STROBE_PROBE_PAUSE=1000,400`, exp_95c469) `(unread: strobe missed 4 of 15 sends)` (0.25.0: 0.55 / 0.70 / 0.82 ms beside an `OverheadBudget` refusal). The example's 100 ms gap stays, and so does the default of a new connection per probe. - **Example.** The package README (`sdk/bench/README.md`) and the skill show a whole server bench: a `script("./server.ts")` harness whose `start(tokenHash)` serves `/health` to the bearer token with that SHA-256 only (the bench file makes the token in `setup` and passes the hash, so neither the harness's heap nor an action's arguments hold it), `ctx.httpProbe({ url, authorization, expect })` with the defaults (a probe every 100 ms, a new connection each, so a stalled `accept()` or a full listen backlog shows too; kept connections would be opened in the first run and hide it later), and a case whose run makes four 550 ms stalls of the server's thread 100 ms apart, with `http.latency.p95` as its primary beside `cpu.main` and `cpu.main.busy`: about 26 probes a run, enough for a p95 in a `--check`. Back to back, the next stall could start before the server answered what the last one held: on adais 2 of 22 probes waited out two stalls and timed out at 1000 ms, refusing the p95 (`RunIncomplete`, right-censored). `scripts/dev/http-probe-example.bench.ts` is that file, run against `http-probe-server.ts`; the snippets type-check against the SDK (`tsc` with the SDK's types and Bun's). Measured (2026-11-10): vps (2 vCPU) `--check` p95 502 ms, `cpu.main` 2202 ms, `cpu.main.busy` 84.5%, no timeout, the probe 0.28% of a core; `--blocks 1` p95 519 ms, probe 0.28%; `record --bench` and `query http`: 131 probes in 5 measured runs, none timed out, p95 526 ms, max 558 ms, a cohort of 5 per stall (about 520 ms down to 70–150 ms), probe 0.26%. adais (Windows 11) `--check` p95 522 ms, `cpu.main` 2158 ms, `cpu.main.busy` 82.7%, probe 0.66%; `--blocks 1` p95 538 ms, probe 0.65%; `record --bench`: 130 probes, none timed out, p95 516 ms, probe 0.62% (all under the 1% budget). ## 9. Experiments (`bench`) ### 9.1 Pipeline Per series (case × target × params): ```mermaid flowchart LR V[validate variants] --> B[build or resolve] B --> P[preflight: load, power, pin, display] P --> K[block: fresh launch of A or B] K --> W[prepare, setup, warmup until steady] W --> M[measured runs, then close] M --> S{pair complete: primary decided?} S -- no, more pairs useful --> K S -- yes, or power says no --> A[attribution blocks] A --> R[analyse, verdicts, report] ``` - **Block**: one fresh launch of one variant with a fresh isolated profile or home: `prepare`, `setup`, warmup runs, measured runs (`--runs`, default 5), close. Two builds never run at the same time. - **Order**: blocks alternate A B, B A, A B, …, so drift and launch-to-launch noise hit both variants equally. - **Warmup** decides on work, never wall time: a fixed-speed scroll or paced typing takes the same time cold or warm, while the work it costs settles over 1–4 runs (measured: scroll's main thread 17.3% → 15.7% after one run; streaming 7.2% → 5.4% after three). After every run strobe reads the renderer's main-thread task time (`Performance.getMetrics` `TaskDuration`, a cumulative counter). The counter starts again with every document, so when a run loads a page (`goto`), strobe reads it just before the old document goes and adds that part to the run: a load is one run, never a negative one. Warmup ends when two consecutive runs do the same work within 5% or within 1 ms (an idle run does about 3 ms, where 5% is noise and warmup once ran 55 s); both count as measured, and the block runs until `--runs` are measured. Runs before are relabelled `warm:` in the trace. For a page the work is the renderer's task time and, where it was read whole, the browser's process tree's CPU too: Chromium's after-startup chores (its network and storage services' deferred work, about 230 ms of CPU just after the first page loads) leave the renderer alone. Two runs agree only when a process of the target ended in both or in neither (pages, native and terminal targets): Chromium on Windows starts a utility process for its processor metrics just after the first load and ends it at once. A `--check` stays one cold run (no warmup; N runs with `--runs N`, §5.6), except that when a process of the target ended in it and its CPU was not recovered (§9.6), the case has a reset with `resetCheck` on and it is no pixels or filmed block, the run after the reset is measured instead, once. That run is warm, so the check says so: ` · run 2` on the case's line, a `run` line under it, the count in the closing line, and `measured_run` 1 in result.json. A case without a reset keeps run 1, and only the `cpu.*` that lost the process refuse `RunIncomplete` (a refused primary fails the check): its run 2 would start where run 1 ended, another stretch of the target. A game's 8 s wait, whose round goes on without a reset, read `main.work` 125 ms in 0.20.0 (run 1) and 181–191 ms in 0.21.0, which measured run 2 there unsaid; its first 8 s read 121–124 ms in both and the 8 s after 8 s of play 182–184 ms in both. After 10 unsteady runs strobe measures anyway and warns `NotSteady`, saying which way: work that grew run after run (Spearman over 0.5 across the warmup runs and the last 5% over the first; `warmup.unsteady` 2) is state the runs leave (add a reset, or, with one, fix what it leaves behind: DOM, listeners, caches); work that only swung (`warmup.unsteady` 1) is jitter (pin the CPUs, quiet the machine, more work per run). A steady pair may be a plateau of work the launch left running (a load that keeps a thread busy through the first runs): a measured run after the pair, up to `MAX_WARMUP`, that drops below half the pair's first run's work (and by 10 ms or more) shows it was no steady point, and warmup goes on from that run (pages, native, tty, script and cli). Both this guard and the stepped test below read the tree's CPU (`cpu`, a page's whole browser) beside the work where every run has it, so a worker's or a browser service's plateau that leaves the renderer's task time alone counts too (code read only: no live fixture). Measured runs that still step down (the first over twice the last half's median, by 20 ms or more; never in an untimed block, whose first run is cold by design) keep the block and warn `NotSteady` ` block(s)' measured runs stepped down (the most: the first measured run's work X, the last half's median Y): work left over from the launch ran into the first runs; wait in setup until the app is ready (ctx.waitForLine(, { since: "launch" })), or give the case more runs` (values `warmup.stepped.first` and `warmup.stepped.last`, cli blocks included). An untimed block (`--check`) whose run 1 is 2 s or longer warns `NotSteady`, never refuses, when a thread of the target (or its set) used a quarter of a core or more in the stretch before run 1 (up to 2 s) and in its first half (50 ms or more) and three times less in its second (`block::startup_spill`, one rule for every kind, on a thread timeline): ` · block: startup was still running in run 1: (, ) of process P used X% of a core in the 2 s before it, Y% in its first half and Z% in its second; busy from A s to B s after the launch, while run 1 ran from C s to D s, so run 1 measured startup work, not the case's; wait in setup until the target is ready (…)`. The stretch before run 1 is judged only when strobe watched 200 ms or more of it, or saw 50 ms of the thread's work in it (a fast target with no setup reads a few ms of init as busy), and the text gives its length when under 2 s (`in the 1.15 s before it`). It says busy `after the launch` (native, the block's thread timeline), `after the host started` (script: hosts run that timeline in untimed blocks too, about 1% of a core, in the check's self line) or `after the block began (before its first page loaded)` (an untimed page block not on a phone runs the browser's thread timeline from before the target's prepare and first page load, `before its prepare` for an app; strobe's own speed checks, the one run again after a slow one too, are left out of the stretch before run 1, their ticks counted neither busy nor watched; a run's start takes a tick, and its end another right after the measured window closes, before the reads after it and the reset, as native, script and terminal blocks' do, so no tick holds both a run's CPU and what came before or after it; the OS sampler starts there too, its other readers taking a run's window as before). A `tty()` check keeps a thread timeline of the program too and says `busy from 0.0 s to 2.0 s after the program started, while run 1 ran from 0.6 s to 3.1 s`. The timeline sees no other process, so other work on the target's CPUs cannot hold up the second half (in a `--check` nothing keeps it off them: a summed-busy test on the pinned CPUs missed a page's 4 s load on adais). On Linux a thread that exited loses its last tick's CPU (`/proc` keeps none of it; Windows keeps it through the held handle), so its busy stretch may end up to a second early (vps: `busy from 0.0 s to 3.0 s` for a 4 s load). A steady busy target (a game, an animation) is as busy in both halves and says nothing. Known answers (`scripts/dev/known/startup.bench.ts`, a thread busy 4 s from the launch, setup 1 s, run 5 s): adais 0.27.0 read `cpu.work` 2819 ms with no warning; now `NotSteady` naming `loader (known.exe, app)`, 100% before run 1, 99% in its first half and 15% in its second; two steady loads (50% on 2 threads, 100% on 1) read 4989 and 4995 ms with no warning; a script host whose import spawns a 4 s spin names the child's thread; vps the same. A page (`pages/startup.html?mode=load`, 90% of the main thread for 4 s from the load, headless): no warning before; now `CrRendererMain (chrome.exe, app) … 89% of a core in the 2 s before it, 43% in its first half and 0% in its second; busy from 0.0 s to 4.0 s after setup began, while run 1 ran from 2.5 s to 7.5 s` (0.29.0's words); `?mode=steady` (50% throughout) silent. A script that spins 1.5 s then sleeps 1.5 s with no setup (`#front`) warned falsely before the floor (`37% of a core in the 2 s before it`), now silent. The plateau guard (`--blocks 1`, 1 s runs, no setup, the same load): 0.27.0 `cpu.work` 999 ms, the load's plateau; now 0.00 ms, warmup went on past the load. Attribution blocks profile the measured runs after the steady point, each run whole and only the run (below). - **Warm-up blocks**: before pair 0 every timed series discards one block per side: GPU shader caches' (`gpuCache`, up to 3), a launch case's first launch, and for every other run or command case one block, since a series' first block ran slow, always on base (pair 0 goes base first): ln(head / base) at pair 0 averaged −3.4% over 100 A/A experiments of a 1.7 ms command on a VM (about 5 standard errors; every later pair within ±1.5%) and −0.3% on a desktop, from the power plan just switched and the CPUs just cleared. The rank test shrugs off one pair, but the error no longer split evenly: simulated with a +3.4% first block at σ 6%, false `faster` 2.53% against `slower` 1.45% (1.75% and 1.71% with the blocks discarded), and a `--noworse 0%` A/A's `no worse` 3.9% with a +30% first block (2.1% discarded). The series keeps what they read (`warmup_values`; for GPU warming, its last block's). A warm-up block runs as the timed blocks do (their trace set and counting). `--blocks N` (a tracked point) gets one too; a `--pairs N` smoke run below 6 pairs (no verdicts) gets none. Checked on vps (100 A/A of the same command, 2026-11-08): pair 0 now −0.57% (standard error 0.67%; −3.38% before), every pair within ±1.3%, and the discarded base block read 3.3% (se 0.9%) above pair 0's base: the slow first block is the one thrown away. - **Pinning** (`--pin auto` by default, §9.4) may narrow the target to fewer CPUs than the machine has; results from before that rule are not comparable on such machines. - **Stop rule** (`--pairs auto`): verdicts only at planned looks, after pairs 8, 12, 18, 26 and 40, each with a 98.42% interval (Pocock's constant level for 5 looks, 5% overall). Looking after every pair at 95% inflates false positives: simulated A/A runs came out "different" 10.9% of the time when the noise is near the bound (a pair's difference σ 2%; 4.0% at σ 1%), and one did on adais (scroll, +0.7% [+0.1 … +3.7] at pair 7). Five Pocock looks keep it at 1.6–4.2% (each side 0.7–2.2%; normal, t₃ and spiky noise, 12,000 series a row) and still decide a 3% effect by pair 8–9. These rows, and every other simulation this section quotes, run through the bench's own decision code (`bench::sequential`) with `bun scripts/dev/stats-sim.ts ` (the hidden `strobe stats` `simulate`, §5.11). At a look: stop when the primary has a verdict; from 12 pairs, stop as `underpowered` when the pairs needed exceed 40, or at `--max-time`: the experiment's deadline, and from the first look each series' own share of it, the time left when the series began divided among the series still to run (itself included), so the first series cannot spend the others' time and one that ends early leaves its time to those after it. The pairs a decided primary runs on for its secondaries end at either too (the primary keeps its verdict), and no series starts once the deadline passed (the `MaxTime` warning names them; the experiment is not decided). No wait for the machine's state runs past the deadline either (other work on the CPUs, a launch that runs again, a locked session, someone at the Mac, another app in front, the native speed check's retries and 2 minutes, a page speed check's second try and the quiet wait before a launch); the queue for the bench slot does. A native speed check the deadline cut short while it was still slow refuses nothing (neither `Throttled` nor `Background` was shown): it ends as such a wait does. A pair a wait held up is dropped and its series ends as at the deadline (its `stopped` reason `max time`, with the pairs it has; with none, it is listed in `MaxTime`). A series past the deadline after its pairs starts no untimed block (attribution, video, `--pixels` snapshots), and one whose wait ran into it there keeps its comparison with a `MaxTime` warning naming what was not run (in a Linux counter or HTTP probe pass: that pass's note says it was not finished): a finished comparison never exits 124 for it. Every other run (`--check`, `record --bench`, `--blocks`, a leak run, `--pixels-only`) starts no series past the deadline and stops in such a wait: exit 124 with what finished, `Stopped` naming the rest, resumable. A native `--check` once sat 20 minutes in `Background` waits past a `--max-time 14m`. Pairs needed are projected from a robust spread of the differences (1.4826·MAD; where over half the differences are one value, as a quantized metric's are, and the MAD is 0, the mean absolute deviation × √(π/2), since a spread of 0 projected 7 pairs for any series and so never stopped one), not the interval width: early intervals are [min, max] of few Walsh averages, and one odd pair once stopped a series (a tab switch, "~78 pairs needed") that its A/A twin decided as `same` at 19. The stop asks for the likely case (an even chance that the interval clears its boundary: give up only when even that needs more than the last look, and the ETA aims there); every `needed_pairs` a comparison keeps or prints, the pairs an `underpowered` verdict names among them, is for an 80% chance from the same robust spread, as `plan`'s are, so the two never differ by the factor (z + z₀.₈)²/z² ≈ 1.8 they once did, and a series whose 80% figure exceeds its pairs is `underpowered`, not `unclear`. `--pairs N` is one look at N at 95%. Secondaries and descriptive rows use 95% intervals at the final pair. When the primary is decided at a look with fewer pairs than the tested secondaries need to clear Holm (2·2⁻ⁿ ≤ 0.05/tests: 9 pairs from 7 tests), the series runs on to that many pairs for them and keeps the primary's verdict from its look (`--pairs N` runs exactly N). The primary's estimate and interval are that look's too: the series keeps `primary_pairs` (the pairs they come from) beside `pairs`, and the text says so, so a tally or plot of the primary reads the first `primary_pairs` pairs. A checkpoint keeps the look (`decided_n`), so `--resume` keeps the verdict rather than taking one again at a later look. A share primary (`%`, not CPU) at 80% or more on both sides at a look without a verdict stops as `saturated`: at its ceiling it moves with nothing the builds do (91.4% vs 91.6% dropped frames at background priority added looks at 2.5 min a pair), so the verdict says to choose another primary. A series that reaches the last look undecided says so: `unclear after 40 pairs, the last planned look`. - **Attribution blocks**: one per variant after timing, same case, with the attribution probes, only when the primary differs (`--why auto`): a `same` or `underpowered` result has nothing to explain. `--why always` runs them for every series, `--why never` skips them. A profiled launch does more main-thread work than the timed pairs (+12–15% on a scroll case with the default attribution trace, +39–52% with invalidation tracking, `blink` and `v8.execute`, which once flipped base and head): its numbers explain where work goes inside one launch; only the timed pairs say whether head is faster. On that case the trace's layouts and style recalcs per run matched the browser's `LayoutCount` and `RecalcStyleCount` within 0.2%, their time `LayoutDuration` + `RecalcStyleDuration` within 2.5%, and sampled allocations the bytes GC freed within 15%. - **Plan** (`strobe plan ` = `bench --plan`, with `bench`'s `--base`, `--head`, `--filter`, `--target`, `--params`, `--pairs`, `--noworse`; nothing runs): per series, the pairs and minutes a bench needs to decide at the expected effect, before anything builds. It refuses what the bench would refuse before anything builds (an implementation's sides, sides with and without anything on screen: `VariantMismatch`, §10.1). Inputs, in order of preference: - *Spread*: the primary's per-pair differences in earlier experiments of the same series (bench file name, series label, primary) on this device, each centred on its own median and pooled (1.4826·MAD, `stats::spread`), all in the newest experiment's scale (log ratios or differences, never both; differences scaled to its base); with fewer than 4 such pairs, other series measuring that metric on the same kind of target (labelled as such); else unknown. A `metric.` is better in the direction those experiments' lines gave (`higher`), else the catalog's. `--pilot` first runs 4 pairs per series (an A/A on head without `--base`, no attribution), stored like any experiment, and plans from them. - *Block time*: the mean of the earlier blocks' steps, else a `--check` of the case plus its warmup and runs (rough; an implementation comparison's journey takes the slower of either target's check), else unknown, never a guess: `block unknown: no earlier run or --check of this case here (a --check, a --pilot or this run's first pairs measure one)`, the series line ending `time unknown until a block of it ran here`, and the total `total unknown: no block of these series ran here yet (a --check or --pilot first measures one)` (or ` of the series`). Through 0.33.0 it took 22 s (a headed browser scroll's block) and printed `~7 min` for a 5 s command comparison. JSON: `series[].ms`, `block_ms`, `warmup_ms`, `attribution_ms` and the top-level `blocks_ms`, `total_ms` are null then, and `blocks_unknown` counts such series. A parity gap is `