| name | strobe |
|---|---|
| description | Measure and explain performance of web pages, Electron apps, native apps, terminal programs, Bun or Node processes and any process. Use when the user asks whether a change is faster, why something is slow, janky or heavy, where memory goes, what the CPU or GPU is doing, or to write a benchmark. |
strobe records a target on one timeline and answers with verdicts and handles (rec_…, exp_…) to
drill into. It refuses measurements it cannot defend: a refused run is an answer, never "no value".
The design and every rule behind this file: SPEC.md.
bun add -d @hona/strobepins strobe for the project (bunx strobe …then runs that version).strobe initwritesstrobe.json(its$schemalinked) and a runnableperf/smoke.bench.tsfrom the folder's package.json (build,preview,start,dev,exports,module,types,main,bin, an electron dependency), Cargo.toml binaries or index.html.--target web|lib|server|native|electron|tty|clioverrides the guess: web serves the build withperf/serve.ts(a cold load, an idle second, and a commented reload: a small page's reload is mostly strobe's own driver work,OverheadBudget), lib imports the package's entry fromperf/lib.ts(a cold import per run; a commentedworkcase calls an export withctx.call), server hosts it inperf/server.ts(PORT), native builds and launches the Cargo app, cli runs the command to its exit. It never replaces a file without--force.- Run what it prints:
strobe config, thenstrobe bench perf/smoke.bench.ts --head . --check(--headlesswith no display or a locked session). Then edit the cases to what users do.
- Write or find a bench file (below;
strobe initwrites a first one). Check it first:strobe bench <file> --head . --check(each case once, pass or fail with a screenshot). strobe plan <file> --base origin/dev --head .says pairs and minutes per series.strobe bench <file> --base origin/dev --head .: a verdict per series (case × target × params).- Read the ★ primary's verdict:
faster/slower(less/more,fewer),same (±2%),unclear,underpowered(more pairs or a quieter machine, not "no change"),refused. - Secondaries get words only after the primary decided (Holm-corrected);
·is descriptive. ledger 3rd experiment on these variants; earlier: …: reruns count. Paste it as it is.strobe query <exp> insights: per series the verdict (or a check's status), ranked suspects with their source, the warnings that bear on it, and commands to run next as printed (--pairs N,record --bench <file>#<case>for a side nothing profiled). Any kind: check,--blocks, leak run. Head's hits read(over its threshold on head only),(… on base too)or(base not read for it); besidesame,unclearor an A/A no mover is a suspect (two launches differ anyway).
- Read the ★ primary's verdict:
- Put the evidence in the PR (below).
- Refactor or dependency bump?
--noworse 2%(ornoworse: "2%"on the case;--noworse 2%,cpu.wakes=5%per metric):no worse (≤2%),worse (>2%)orunclear. strobe run --base "<old command>" --head "<new command>": the same A/B for two command lines.--aa --head .checks the method on this machine (same build both sides).- Long benches keep running in the daemon:
strobe query exp_… progress. Ctrl-C, SIGTERM orstrobe bench --stop <exp>stop after the block (exit 130, what finished is kept);strobe bench --resume <exp>goes on.
bench … --why always(an attribution block per series),bench … --check --why always(one build), orstrobe record <target> --probes attribution/strobe record --bench <file>#<case>. Then:query <rec> hotspots(CPU by function, full stacks, source-mapped file:line:column;--by file|package;--sort totalfor callers; native:--thread <name>,--pid <pid|exe>,--phase <name>).query <rec> layout(forced layouts by their call site),style(what invalidated style: attribution blocks trace it for a series declaringmain.styleorstyle.count, or with--style),alloc(who allocates),gc,summary(per run: style, layout, paint ms),insights.- A page served from your own folder (
record https://localhost:… --served <dir>): its source-mapped frames resolve to files on disk. - What changed:
query <exp> hotspots(each series' base..head) orquery <recA>..<recB> hotspots;~marks a change inside its sampling interval (one launch a side: not a verdict).
strobe open <rec>(or<exp>/head): the whole recording in a local Perfetto UI. For seeing; numbers come from strobe's views. The "strobe itself" track is strobe's own cost: check it first.- A thread waiting (a lock, IO, a timer) and who wakes it:
record pid:<n> --probes waits --for 10s, thenquery <rec> waits --thread <name>(Linux needs CAP_PERFMON, Windows the deep helper; macOS refusesNoWaits). - A slow page frame in the renderer or the GPU process:
frames.renderer.p95againstframes.viz.p95, andquery <rec> frames(each worst frame's stages). - Inputs with no latency sample (
busy), or one that looks wrong:query <rec> inputs [--case <c>](the first 100;--top N,--jsonevery one): each input's run and time, what proved its change, the frames around it, its latency or why it was busy (mid_drawing,behind,ambiguous_press,multi_frame_pixel) and how much later it had to come. Give the case that wait before such inputs. - A profiled launch is slower than a timed one: compare totals only from the timed pairs.
query <rec> memory(per process;--by layer|allocatorfor Chromium's accounting). A native app: what holds it (host heap, GPU memory mapped in, unmapped commit, shared, files) besidegpu.vram. On Windowsmemory.app.privatecounts VRAM a driver maps in: quotememory.app.host. A phone's page: processes from Chromium's dump (no OS memory); listeners are the page's own.query <rec> heap: classes by retained size, detached DOM, retainer paths;query <a>..<b> heap.- A case's leak:
bench <file> --filter <case> --head . --probes memory --repeat 10: growth per run with an interval, andquery <rec> leaksfor what from the first half is still alive. - An app over time:
record <target> --probes memory --for 60swhile it does the suspect thing. - A script target:
js.heap.retained(what a run keeps after a collection, buffers counted once). On a Node host it adds ArrayBuffer contents in releases after 0.31.0 (which left them out): compare across that upgrade as two histories (trackstarts a new series,metric revision 2; Bun series keep theirs). It does not see add-ons' native memory, mapped files, or on Node WebAssembly memory and strings outside the heap: readmemory.app.privatefor those. On macOS a Bun host'smemory.app.privateis refused (EngineUnsupported): Bun's allocator hides memory from the OS. - Sizes are binary: KB, MB and GB are 2^10, 2^20 and 2^30 bytes.
record app:<id> --for 10s(a strobe.json profile) orrecord exe:<path> --native: CPU, thread wakes, IO, GPU, memory, the UI thread and what reached the screen.--windowlessfor a tray app (look … --windowlessfirst: does it stay up, what does it print?).record pid:<n>orproc:<name>for a process strobe did not start.query <rec> threads: wakes/s and CPU per thread, the module each started in, whose (app, driver, os,unknown (?));--timelineper second.query <rec> insightsrules include dominant-thread, off-cpu-bound, style-layout-dominant, wakes-while-idle (idle blocks too), hot-function (a named function of the target's over 25% of busy samples, one hit each; a native symbol without line info counts), http-stalls (measured runs only).- Native hotspots of a tree pool every process: read
by process, then--pid <pid|exe>. A native attribution recording (record --bench,--why) keeps frames too:query <rec> inputs.! Coverage: samples cover under 80% of the CPU (read shares, not totals); it names what the sampler counted of the rest (late_msbefore a thread was opened,forgone_ms, unopened threads, lost events). A launched app's children are sampled from their start. Two resolved shares (leaf, all frames): a low all-frame share means the app's own binary has no symbols. macOS samples are untimed snapshots: counts, never ms. record pid:… --probes deep|waitsprintscost;! CaptureCost: the capture slowed a switch-heavy process, so its stacks describe that slower process.- Benching it:
native("my-app")in a bench file (below).
- One bench file, one target per implementation, the same cases (journeys):
bench <file> --base original --head rewrite(rewrite=git:main,original=exe:/path;target:<name>when a folder has that name). Each target'snameis what reports call it. - Pick a primary both sides measure the same way:
cpu.work,run.duration,launch.ready,memory.app.private.main.workorui.cpuacross engines exits 2 (NotComparable). - Give each target a
uiadapter for what differs (ui: { row: ".row", open: ({ page }) => … }); journeys say@row,await ctx.ui.open(). A journey one side cannot run is a parity gap (unsupported: { rewrite: "why" }, or an@nameitsuilacks): listed, never run. expect: { same: ["@topRow"] }: both sides must read it alike after setup, each run and reset, orNotEquivalent. Both talk to onefixture("<server> --port {port}")({fixture.url}in args/env).- A page against a native app (or another engine) gets OS input: run it headed, window in front;
page.acton one side andclickon the other isInputMismatch. --check --base a --head bprints the parity matrix;--why alwaysprofiles each side once.- Over time:
track <file> --base original=exe:/path --head rewrite=git:main --schedule commits.
bench <file> --filter <case> --bisect <good>..<bad>: one series; each step is a full experiment; the culprit is benched against its own parent.track <file> --range <old>..<branch> --every 3(or--scheduleon a branch), thenhistory <file>orquery changes. Onlyconfirmedchanges are findings.
- GitHub Actions:
uses: anomalyco/strobe@v0.29withbench: perf/app.bench.ts(inputs: action.yml). The defaultmode: checkruns each case once, headless, and fails the job on a failed case.noworse: net.requests=0,count.items=0also runs the base commit and fails a case whose exact counter is over base's or varies between runs. The step summary isreport experiment <exp> --format summary. - A shared runner (GitHub-hosted,
CI_SHARED_ENVIRONMENT=true, or a CI job in a VM) refuses timing:SharedRunner, exit 4, for a timed bench,--blocks,track,--bisect,plan --pilotand a timedrecord.statussays so. A dedicated CI VM saysRUNNER_ENVIRONMENT=self-hostedorCI_SHARED_ENVIRONMENT=false(neither overrides GitHub-hosted). There:--check(--basewith counter--noworse), leak runs (--probes memory),--pixels-only,record --probes attribution. - Timing verdicts in CI:
mode: compareon a self-hosted runner that runs one job at a time; the block goes in a same-repository PR's body (never a fork's). Neverpull_request_target. - The action installs nothing into the runner user's profile (its own strobe, no PATH entry or skill):
it runs
up --skip self,skill,daemon,mcp,deep,calibrate, which only fetches the engine. - The step summary, the job's log and the artifacts never name the machine's other processes, other
apps' windows or its user: the uploaded result.json is a scrubbed copy and its HTML report is drawn
from that copy alone (no recordings, so no hotspots; the runner keeps them,
report-path). The PR block, summary, card and badge name no other process, window title or person's folder either: every string of the result is scrubbed before they read it (a covering window by its process id, a plain folder variant as<base folder>/<head folder>; SPEC §11.2).
--headlessruns page cases (check or timed) with no display: frames, latency, scroll, canvas and GPU use refuseHeadless; CPU, main-thread work, counters, phases and memory stay. Headless, what frames cost ismain.paintbesidemain.styleandmain.layout.- A locked session runs what needs no presenting window: scripts,
cli(),tty(), leak runs, headless pages,window: falsenative cases measuringcpu.*,memory.*,gpu.vram,io.*,count.*,phase.*, and any native case under--check. Compare them only with locked runs. - The rest waits for an unlock (a line a minute).
--unlock-wait 2hcaps it: then those series are not run (exit 124, resumable) and the windowless ones still run. - Locked, a native app's tree,
page.actand keys work; clicks, moves and wheels refuseSessionLocked. On Windows its pixels still read (image anchors,--pixels). - Linux without
DISPLAY: native apps run on strobe's headless Wayland compositor (install sway, dbus-daemon and at-spi2-core); frames and latency refuseHeadless.
- Host the server in a
script()target. Insetup,await ctx.httpProbe({ url, authorization }): strobe probes it everycadenceMs(100) from its own threads on fixed deadlines,timeoutMs1000. The whole runnable example (server.tstoo):node_modules/@hona/strobe/README.md.
import { bench, script } from "@hona/strobe"
export default bench({
targets: { server: script("./server.ts") }, // exports start(tokenHash) and stall(ms)
async setup(ctx) {
const token = crypto.randomUUID() // the server gets its hash only
const hash = new Bun.CryptoHasher("sha256").update(`Bearer ${token}`).digest("hex")
const url = await ctx.call<string>("start", hash)
const expect = { jsonPath: { path: "$.status", equals: "ok" } }
await ctx.httpProbe({ url, authorization: `Bearer ${token}`, expect }) // every 100 ms, new connections
},
cases: {
"slow handler": {
primary: "http.latency.p95", // 4 stalls 100 ms apart: ~26 probes a run, enough for a p95
metrics: ["cpu.main", "cpu.main.busy", "http.timeouts"],
run: async (ctx) => {
for (let i = 0; i < 4; i++) {
await ctx.call("stall", 550) // back to back, a probe can wait out two stalls
await Bun.sleep(100)
}
},
},
},
})record --bench <file>#<case>(or--check,--why always): stacks, the threads' timeline and the probe on one recording.query <rec> http: idle against the runs, timeouts, errors and the stalled cohorts: one stall (answered none for X) or stalls back to back (spanned N stalls …), timeouts wherever they fell; a quantile the block refuses reads—with why.strobe open <rec>draws the probes beside the runs and threads, andreportan HTTP probe card.- To compare it, declare
http.latency.p95(orhttp.timeouts,http.errors): the probe then runs in probe blocks of its own. A clean timing block never probes. - A timeout ranks above every answer: a quantile on one refuses
RunIncomplete(raisetimeoutMs). A 500 or a failedexpectrefuses the latencies.http.missed_sendsis strobe's lag, not the server's. authorizationstays in strobe's memory: never in a file, report, trace or answer. Loopback only.cpu.mainis the main thread alone besidecpu.work(the tree):cpu.main.busynear 100% is a saturated event loop; a worker or a child process shows incpu.workonly. A script's (or a windowless native app's) check line shows both even when the case does not declare them.- A script's
query <rec> hotspotsis its measured runs;--source hostthe whole host process (start, setup, warmup, resets), itsby lifecyclelines each part's wall time against the JavaScript sampled in it;--source startupthe host's start and the target's static imports,--source setupthe bench file's setup (a server's dynamic imports);--phase <name>one phase.- Slow startup with few rows:
N modules evaluated(each module is a stack of its own:--by filesums them). Lifecycle shares are of wall time: unsampled wall is mostly waiting, and on Bun native work (transpiling, module loading) too, which--cpu-profnever samples. ! Missed: the main thread used CPU (cpu.main) the profile has no sample of, so rows undercount (Node labels short timer callbacks(idle); Bun never samples its event loop's own work).! Waited: rows include time the thread waited for a CPU (samples weigh wall time).query <rec> threads --timeline: which thread was busy when, runs marked.
- Slow startup with few rows:
- Emulated:
web({ emulate: "mobile" })is a phone (390×844 @3x, touch, headless):clickis a tap,draga finger,scrollswipes (Chromium only);hover,move,wheeland mouse buttons refuseDeviceMismatch. Headless, soframes.*andinteraction.*refuseHeadless: measuremain.work,cpu.*orrun.durationthere, and frames and latency on a real phone (--mobile)."touch"is a desktop with a touch screen: the mouse stays its primary pointer. - A real Android phone:
--mobile(or--mobile=<serial>) runs the file'semulate: "mobile"targets in Chrome on the phone over adb;look --mobile,record --mobiletoo.- Give strobe a channel once: install Chrome Canary (or Dev, Beta), open it once, then
strobe up --mobile=<serial> --channel canary. strobe never drives the phone's own Chrome. - An emulator runs
--check,look, attribution and leak runs (--probes memory) only (Emulator); timed runs refuse Wi-Fi adb, Battery Saver, under 20% battery, and wait up to 3 min for a hot phone. - Leak runs and
record --mobile --probes memorywork on a phone (the phone's heap and snapshots; nomemory.app.private: readjs.heap.used, its growth and the leak suspects, or a desktop browser's memory recording). Loopback addressespreparereturns (an API, a WebSocket) are reversed to the phone, likeserve. cpu.*,memory.*,gpu.*refuseNotForTarget(this computer's processes): measuremain.work,frames.*,interaction.*,run.duration. A lost phone ends the bench (exit 255).
- Give strobe a channel once: install Chrome Canary (or Dev, Beta), open it once, then
- An iPhone on a cable:
look wir:andrecord wir:read an open Safari tab or WKWebView over Web Inspector, read-only (hotspots,heap,memory,log --source console|device|crash,network);strobe statuslists the phones and their pages.
- Discovery: the strobe command and recording id that pointed at the problem, and what it showed.
- The change: what the code does differently.
- Results:
strobe report experiment <exp> --format pr --update-pr <n>puts strobe's block in PR n's body, led by its result card (or paste the--format proutput).- The block is delimited (
<!-- strobe:report <exp> v1 begin sha256:… -->… "end of generated report"). Never edit inside it: no retyped, rounded or dropped numbers, no rewritten verdicts; strobe refuses to update a hand-edited block. Write your reading above or below it, and say there when a metric was refused orunclear. One block per experiment. - Never put a card or badge in a PR without the block.
--format card|badgeis for elsewhere. - The sides read "Before" and "This PR" only when head is the working tree or
HEAD, and with--update-prat the PR's head commit; an A/B of other builds reads by commit, an A/A asBase (A/A)andHead (A/A). Name them with--labels <base>,<head>(--labels v1.4,v1.5).
- The block is delimited (
- The bench: the case's code, or a link to it. A new case ships in the PR of the first change it measures.
Never put hand-timed numbers, single recordings or --check values in a results table: label them
noisy discovery evidence, or leave them out.
- Exit 4 names the validator and the fix: fix the setup, never work around it. Common ones:
WindowHidden,SessionLocked: the window must show; wait or unlock.Background: other work on the target's CPUs (named); a timed bench waits it out before refusing.Throttled: the speed check stayed slow on quiet CPUs, a Linux CPU quota throttled the runs, or a headless page ran with the display off.- A
--checknever waits on the speed check: a slow one is a! Backgroundor! Throttledline and the case runs at once. Its values are then a loaded machine's. RunIncompleteoncpu.renderer/cpu.gpu/cpu.browsernames why: a process of that role ended in the run, or ended processes (their CPU pooled) used over 1% of it.cpu.workkeeps their CPU: compare that.RunIncomplete: a run threw, timed out, itsuntilnever came, or the app exited. "The host wrote none": a script host's profile never came; it says how the host ended and endsscript.log.ResetIncomplete: a run started from another state than run 1 (the reset did not restore …).TailTooShort: a percentile lacks its minimum n (interaction.p95needs 20 samples a block).AmbiguousInteraction: over half the inputs came while the target was drawing.NotOnThisOs,NotForTarget,EngineUnsupported,Headless,NoCounter: this OS, target, engine or mode cannot give that metric; pick another primary (the message names one). A page (or an app over DevTools) has nocpu.wakes,io.*orui.*(on macOS:cpu.idle_wakes).WindowHiddenon a page: it stayed hidden 200 ms at a frame wait (awindow.openpopup hides its opener on macOS): refused at once, never waited out. Close a popup the case opened before such a wait, or measure the case without frame metrics.- Exit 3 naming the step: macOS held the launch (Gatekeeper after a download or update): open the app once from the Finder.
OverheadBudget: strobe's own work disturbed timed runs (a--checkonly warns).VariantMismatch,DisplayTooSmall,DeviceMismatch,InputMismatch: the sides differ.SharedRunner: a CI runner other jobs share; no timing there.--check, counters against base, leak runs and attribution work (In CI, above); timing needs a machine of its own.
- A refused primary refuses its series; the rest of the file runs. A series refused while it runs
(
OverheadBudget,NoPhase) ends alone too:refusedin the listing,--resumekeeps it. Only the machine's refusals (SessionLocked,Background,Thermal, …) stop the bench. Every series refused: exit 4. !lines warn next to a kept number;ilines inform. Read them before trusting a number:NotSteady,HighVariance,ScreenChanged,EndedCpu,HeadlessFrame,ObserverCost,Rerun,StaleCalibration,DisplayOff,TimerCoalescing. SPEC §10 lists them all.ObserverCoston a script case says whether strobe relayedctx.callor the bench file's ownrun()(afetchclient) was near a core: the latter is your load generator, not strobe.
strobe status [--fast] [--calibrate] [--disk]
strobe prune [builds|seeds|run|recordings|experiments …] [--all] [--older-than 7d] [--dry-run] [-f]
strobe record <target> [--probes timing|attribution|memory] [--for 10s] [--headless]
[--engine chromium|firefox|webkit] [--emulate mobile|touch] [--mobile[=<serial>]] [--video]
strobe record app:<id>|exe:<path> [--native] [--windowless] [--until-line "<re>"] [--stop-line "<re>"]
strobe record pid:<n>|proc:<name> [--probes timing|deep|waits|stacks-unprivileged] [--for 10s]
strobe record wir:[<selector>] [--probes attribution|memory] [--for 10s]
strobe record --bench <file.bench.ts>#<case> [--head <variant>] [--target <name>] [--max-time 10m]
strobe bench <file.bench.ts> --head <variant> [--base <variant> | --aa | --check [--batch N] [--runs N]
[--repeat R] [--base <ref> --noworse count.x=0%,dom.nodes=0%] | --blocks N]
[--filter <cases>] [--target <names>] [--params k=v|all] [--primary <metric>]
[--pairs auto|N] [--runs 5] [--noworse 2%] [--why auto|always|never] [--trace lean|full]
[--probes memory --repeat 10] [--bisect good..bad] [--pixels | --pixels-only]
[--engine chromium|firefox|webkit] [--headless] [--wayland] [--mobile] [--video]
[--pin auto|none|ccdN|<cpus>] [--reserve-cpus auto|on|off] [--power high|keep]
[--unlock-wait 2h] [--max-time 30m] [--seed N] [--require-verdict]
strobe bench --stop <exp> | --resume <exp>
strobe plan <file.bench.ts> [--base …] [--head …] [--filter …] [--effect 3%] [--pilot]
strobe run [--pin auto] [--reserve-cpus on|off] -- <command>
strobe run --base "<command>" --head "<command>" [--aa | --check] [--metrics metric.<name>,…]
strobe query <rec_…|exp_…|recA..recB> <view> [--top N] [--by …] [--reveal] [--case <name>]
strobe report <exp_…|rec_…|ser_…|result.json> [--format html|md|pr|summary|card|badge] [--open]
(summary: short GitHub Markdown for $GITHUB_STEP_SUMMARY; a check's exact counters, never
a time)
strobe report experiment <exp> --format pr --update-pr <n> [-R owner/repo] [--force] [--no-card]
[--labels <base>,<head>]
(a --blocks run: HTML and md, its median and interval, no verdict; pr/card/badge refused;
--blocks 1 prints ★ <metric> <value> (1 block))
(HTML: report.html#series=1&scope=runs|host|startup|setup&metric=<name> opens that view;
strobe look file:///…/report.html#scope=host shoots it headless; #unit=ms, or the
page's µs | ms | s switch, shows its times in ms)
strobe open <rec_…|exp_…[/head|base|<block>|<case>]> [--no-browser | --no-server]
strobe look <url|file|wir:…|app:<id>|exe:<path>|tty:<command>> [--until <selector>] [--out shot.png]
strobe look app:<id>|exe:<path> [--windowless] [--wait 2s] [--timeout 30s] [-- <args…>]
strobe init [<dir>] [--target web|lib|server|native|electron|tty|cli] [--force]
strobe track <file> [--ref <rev> | --range a..b [--every N]] [--schedule commits|nightly|6h]
strobe history [<ser_…|file>]
strobe config [<dir>]
strobe update [<version>] [--from <path>] [--check] [--force]
strobe <command> --helplists that command's flags. An unknown flag exits 2 naming the closest; so does another command's flag (status --runs 5: which commands take it), never ignored.--max-timeis the run's deadline,--checkandrecord --benchtoo: no series starts and no wait (the bench slot, busy CPUs, a locked session, a launch retried) runs past it. A comparison keeps its verdicts; a check stops with exit 124 and--resumeruns the rest. On a timedrecordandrun -- <command>it caps the wait for the bench slot (exit 124,ConcurrentBench).strobe statuslists the benches holding or queued for the slot, and aslotline when a bench of another user or state folder holds it.status'sdiskline is everything strobe keeps and whatstrobe prunefrees. Prune removes caches no process uses; data only when named or--all. Without a terminal it deletes only with--force(exit 3).record --benchprints the bench's warnings (Attribution,ProbeOverhead,ObserverCost,TimerCoalescing);--jsonmarks each recordingkind: "attribution".- Every duration needs its unit (
500ms,10s,2m,2h,1d):--for 2000exits 2. - Times print in microseconds, digits grouped:
18,309,038 µs,42.0 µs,<0.01 µs(nonzero, under the finest step),0 µs(exactly 0).--unit msor--unit s(or"unit": "ms"in strobe.json) prints milliseconds or seconds; never promoted as a value grows. Text only:--jsonis the same in every unit (numbers in the metric's unit, ms; its prose in µs), as are result.json and MCPstructuredContent's numbers. Read numbers from--json, not the text. - Unchanged in every unit: wall-clock durations (
time 19.5 min, ETA, waits,per 77 ms run), configured values (at least 50 ms), and stored warning text, written in the unit of the strobe that measured it (base pair 6 (15985 ms)). --filter,--target,--params,--metricsand--noworsegiven twice add up; any other flag given twice with another value exits 2.--params n=1,n=3runs both values.-q: stdout holds only the handles (rec_…,exp_…), orreport --format md|pr|summary's markdown; a failure says why on stderr.- Progress goes to stderr. Windows PowerShell 5.1 with
2>&1turns each stderr line into an error and exits 1: read$LASTEXITCODE, or pass--progress stdout. - Query views:
summaryframesinputsthreadsgpuhotspotswaitsalloclayoutstyleshiftsgcheapleaksmemoryhttplognetworkinsightsactionssqlruns(every run, warmup too, and which were measured);exp_…alsoprogressandhttp.sqlworks on any recording (a script's or a native one's: on the tracestrobe openbuilds). A script's hotspots leave out strobe's host frames (--source hostkeeps them).a..bandexp_…diffhotspotsalloclayoutstylethreadsframessummaryheapmemory.query <rec> summaryof a large recording is slow once, then cached. query <rec|exp> actions [--reveal]: what a block's hooks sent the target, in order, with the element hit and the latency sample.look: a page in a headless browser, a native app, an iPhone tab or a terminal program, nothing measured: titles, headings (whatexpectchecks), visible text or keyed elements, console errors and a screenshot. Use it to check a UI loads; it works on a locked session. A page look is Chromium only (--engine firefoxis refused);look app:<id> --out shot.pngwrites the native window's pixels;look exe:<path> -- <args…>passes the app its own arguments. A native window holding a Chromium web view may show no headings through UI Automation: check its content from awebview()orapp()target, or expect a title or a visible control.- A native look waits 30 s for the window (
--timeout). An app with none (a tray app, a service, a command):--windowless, or a profile'swindow: false. It printswindow none (windowless), whether the app kept running over--waitor how it ended, and its last 20 output lines; exit 0, or 124 for a nonzero exit or a guard that stopped it. - On a laptop with two GPUs, read
gpu.*with the adapter the target drew on: thescreenline (drawn on …),query <rec> gpuandscreen.gpuin the JSON;CrossAdapterScanoutsays when another adapter drives the window's display. - MCP tools
status,record,bench,query,report,look,config,init: the CLI's names, arguments and JSON. Through the daemon,benchneeds an absolute file andhead;configandinitan absolutedir;lookabsolute paths, and notty:orbuild(use the CLI orstrobe mcp).unit: "ms"onstatus,record,bench,query,report,lookprints that call's times in ms. - Exit codes:
0·1error ·2usage ·3needs a human ·4refused ·5inconclusive (with--require-verdict) ·124timeout or an unlock wait that ran out ·130stopped ·255unreachable. - result.json (
--json): read each comparison'sverdict_kind(better,worse,same,no_worse,unclear,underpowered,refused,untested, …) and a refused metric'srefusal.validator, never theverdictwords. Schema:node_modules/@hona/strobe/schema/result.v1.json.
https://…orpage.html: Chromium (STROBE_BROWSER, else Chrome/Edge/Chromium, else Playwright's),--engine firefox(stock Firefox 148 or later; an older one is refusedEngineUnsupportedbefore it launches),--engine webkit(Safari headed on macOS; WebKitGTK on Linux, headed, or--headlessunderxvfb-run; WPE headless sees a touch device and refusesDeviceMismatch; never Windows).app:<id>: a strobe.json profile (Electron,kind: "native"orkind: "webview"), launched in an isolated home.exe:<path>(--nativefor a native app,--app <id>borrows a profile).cdp:<port>,cdp:ws://…: attach.pid:<n>,proc:<name>: from outside.file:<path>: import a trace,.cpuprofileor.heapsnapshot.wir:[<selector>]: an iPhone's Safari tab.- Probe sets:
timing(default),attribution(stacks, allocations, layouts, GC, a heap snapshot; timing perturbed),memory(heap snapshots for leaks),deep(native stacks),waits(deepplus off-CPU waits, at every context switch). - Windows present events (frames) and the deep helper need
strobe up's one UAC prompt. Without it native stacks come from strobe's own sampler (x64, user frames only, 2 ms of a thread's CPU a sample);record https://… --os-framesadds OS presents to a Chromium recording.
import { bench, web, app } from "@hona/strobe"
import { mockFeed } from "../e2e/mocks"
export default bench({
targets: {
web: web({
viewport: { width: 1366, height: 768 },
prepare: ({ mock, url }) => mockFeed(mock, { server: new URL(url).origin }),
url: ({ prepared }) => prepared.path,
}),
desktop: app("desktop", { home: "perf/fixtures/home" }),
},
params: { items: [24, 200] },
async setup({ page }) {
await page.waitFor("[data-feed]")
await page.idle()
},
cases: {
scroll: {
primary: "main.busy",
run: async ({ page, phase }) => {
await phase("up", () => page.scroll("[data-feed]", { by: -4000, speed: 3000 }))
},
},
type: {
primary: "interaction.p95",
run: ({ page }) => page.type("[contenteditable]", "hello", { delay: 80 }),
reset: async ({ page }) => {
await page.press("ControlOrMeta+a")
await page.press("Backspace")
},
},
},
})- strobe refuses a key it does not read (in the file, a target, a case, a launch) with the nearest
key: a case's
setupis the file's. Cases run in the file's order. - Targets:
web()(each variant's build, served by strobe.jsonbuildandserve; aurlfunction may return an absolute address itspreparestarted),url(href)(a running page: the same for every build, so--base <ref> --head .on it refusesVariantMismatch),app(id),exe(path),native(id | path, { window?, output?, display? }),webview(id | path),command(run, { cwd, env, unset })(attaches atDevTools listening on ws://…),script(entry)(a Bun or Node module;ctx.call(name, …)),cli(run?),tty(run, { cols, rows }).fixture(command)at the top level starts a server per block for both sides. cli("./target/release/tool input.txt"): one command perctx.exec()(run: (ctx) => ctx.exec()), run to its exit in the variant's root (the checkout's top folder, a git ref's worktree), measured from outside:cpu.work,run.duration,cli.launch,cpu.wakes(not Windows),memory.peak,io.*, andmetric.<name>fromstrobe:metric <name>=<value> [higher]lines it prints (declare one inmetricsto test it). A nonzero exit, a timeout or a process left running fails the run.params: { files: { run: [100, 10_000], more: [1, 1_000] } }:moreonly with--params all.bun add -d @hona/strobepins strobe for the project and gives editors the types; strobe runs a bench file with its own SDK copy either way.- Source maps without changing what ships: let the bundler read
STROBE_SOURCEMAP=hidden(Vite:build: { sourcemap: process.env.STROBE_SOURCEMAP }).
goto,click(sel, { button, modifiers, count }),hover,move(x, y),type(sel, text, { delay }),press("ControlOrMeta+Shift+P"),down(key)/up(key)(also"Mouse",{ at }),drag(sel, path, { duration }),scroll,wheel(sel, { dy }),window("maximize" | "restore" | { resize }),act(sel, "invoke" | "toggle" | "setValue", { value })(native),query(sel?),waitFor(sel, { text, until }),idle,wait,snapshot(name)(for--pixels).- Input calls take
{ until }:"idle","frame","animations"(pages: no finite animation running), a selector, or a list run in order under one timeout:{ until: ["@row", "frame"] }. - Clicks aim at the visible part of the element: nothing visible is
OutOfView, something over itCovered.typeis paced (one key a frame, each aninteraction.*sample);{ burst: true }refusesinteraction.*(measurecpu.workor a phase). - A key call with no selector whose
untilfails saysthe page had no focus …only when no key reached the page and it had no focus: click the page first, or pass{ selector }. page.evaltakes a function; never call it inrun(main.scriptrefuses after it).- Headless pages: a run ends at its last step;
! HeadlessFramesays the frame after it did the step's style and layout: give that step{ until: [selector, "frame"] }. - Selectors are one grammar on pages, native apps and terminals: a base (CSS, an accessibility key or
key path, grid text) then
role=,text=(text==the whole text),index=,:focus; or{ selector, role, text, exact, index }. Points:page.click([x, y]). - No DOM or tree (a canvas, a game): image anchors,
page.click({ image: "play.png" }),until: { image },{ pixels: { changed: true } };AnchorNotFound,AnchorAmbiguous. Pixel reads cost (ObserverCostpast 8 a run); find images in setup and click points in runs. Compare their cost across builds bydriver.pixel_cpu_ms(ms a run), not the share of a run's time.resetPixels: { region, ignore, tolerance }on a target or case: runs that start from other pixels areResetIncomplete.
setuponce per block,runmeasured,resetbetween runs (never measured),teardownafter. What a run leaves is read before its reset (dom.nodes,js.heap.used,memory.app.*).- Every run must start from run 1's state: editable controls and toggles are compared
(
ResetIncomplete).resetCheck: falseon a case whose runs build on each other on purpose. Select-all isControlOrMeta+a. - Waits that are not the journey:
await ctx.untimed(() => page.idle()). Its time, CPU, frames and inputs leave the run;gpu.process.3d,energy.*,net.*,canvas.*refuse (Untimed). ctx.addInitScript(fn, arg)andctx.mock(pattern, handler)inpreparework in every engine; Playwright (ctx.playwright) only on Chromium, and only under Bun 1.4.0 or later (bun upgrade).- Random workloads: draw from
ctx.random()(each hook call replays the same stream), neverMath.random();--seed Nreplays;ctx.seedseeds the app only if you pass it in. expect: { title, heading, url, visible }on a case or target: a block on another screen isWrongScreen, not timed. Check what shows withstrobe look.- Launch cases:
launch: { until, milestones? }instead ofrun: one cold launch per block, timed from spawn tountil(launch.ready),launch.painted/launch.settledfrom the window's pixels,until: { milestone: "up" }for a tray app. --check --batch 10: up to 10 run cases share a launch (same target, params andsetupKey); each must leave the launch's start state. A setup that differs per case:setupKey: (ctx) => …and readctx.setupKeyinprepare,urlandsetup. Only--check: a verdict needs a fresh launch.- Several untimed known-answer runs without a verdict:
--check --runs 5(5 runs in the one launch, the median and[lowest … highest run]) or--check --repeat 3(3 fresh cold launches per case, each launch's value listed). Still a check: nothing is decided. With--runs Na first run that did work the later ones did not warnsNotSteady … stepped down(base's too, with--base); one cold run under 2 s cannot tell startup from the case's own first-run work.--check --probes memoryexits 2: a case's heap isrecord --bench <file>#<case>, a leak runbench <file> --probes memory. ! HarnessOverlay: the bench's folder (and strobe.json'sharnesspaths) is copied onto base, so a file there that ascript(),cli()ortty()target (or acli()case'sctx.execline) runs is the same on both sides. Keep the code under test out ofperf/, or import the variant's own file from there.script("../src/x.mjs")outside the harness runs each side's own copy;i HarnessOverlaymeans strobe placed a file base lacked, so both run that one.- A
count.*only--noworsenames is decided per series over both sides: once any block counted it, a block that counted none reads 0 (0 → 5 · worse); a case that never counts it prints nothing of it (native cases too).! Uncounted: no run of any case counted it, so nothing compared it: it never happened on either side, or the name is wrong (the warning lists the counters the cases declare).
native("my-app")(a profile withkind: "native") is driven through the OS accessibility tree: UI Automation and posted messages (Windows, never takes focus), AX and Quartz events (macOS: needs Accessibility for the app strobe runs under;statusnames it), AT-SPI and XTest (Linux X11), or strobe's headless Wayland compositor (--wayland, or noDISPLAY).- Measures
cpu.work,ui.cpu(the UI thread: the primary for "does it cost less"),cpu.wakes,io.*,gpu.*,memory.app.*, and from OS presentsframes.*,interaction.p95. A click that acts on the press is timed from it. ctx.waitForLine(/saved/, { in: "output" })(withnative(…, { output: true })) or a log glob. A ready line printed once at startup:ctx.waitForLine(/ready/, { since: "launch" })in setup, so run 1 does not measure the launch's work (NotSteady … startup was still running in run 1).ctx.home,ctx.env,ctx.exedrive the copy through its own CLI where no driver reaches.webview("my-app"): an app on WebView2 (Windows), its page over CDP; Linux and macOS webviews refuseEngineUnsupported: usenative().tty(["my-tui"]): a program on strobe's own terminal; look first withstrobe look tty:"my-tui" --until text=Ready. Metricstty.latency.*,tty.settle.*,tty.frames,tty.bytes,cpu.work(a check line counts the latencies' inputs:(10 inputs, 2 busy); busy and silent ones are left out); mouse calls need the program's mouse mode (MouseNotEnabled). Events: the program appends JSON lines toSTROBE_EVENTS({"strobe":"state","name":"done"}foruntil: { state: "done" }). Wait in setup for what arrives late (a status line), and leave a clock out of the reset check withignore: [[col, row, cols, rows]]. A program that prints and exits isRunIncomplete("the app exited (code 0)…"): time it withcli(). A tty target'scwdis where its program runs, under the variant's folder.- A directory or git variant runs strobe.json
buildand launches itsoutput;gpuCache: "warm"(the default) keeps each variant's GPU shader caches across launches. A debug build is refused. A--checkline ending· cold shader cacheholds a shader compile a warm bench will not.
- Primary:
main.work(page main-thread work: "does it cost less"),ui.cpuorcpu.work(native),cpu.wakes(idle native),main.busy(scrolling, streaming),interaction.p95(typing, clicks),frames.dropped.pct(when frames drop),launch.ready(launches),cpu.work(scripts, commands). Fewer than 6 pairs never gets a verdict. - Where CPU goes:
cpu.renderer,cpu.gpu,cpu.browser. Where a frame goes:frames.renderer.p95,frames.viz.p95. Main thread:main.script,main.layout,main.style,main.paint,main.gc. - Your own spans:
phase("name", fn)givesphase.name; the app'sperformance.measure("x")givesspan.x. Counters:ctx.count(name)orglobalThis.__strobeCount?.(name)→count.<name>,net.requests{/api/*},net.bytes,layout.shift. A stable count compares exactly. - macOS also gives
energy.cpu(mJ a run),cpu.instructions(tight run to run: small CPU changes decide in fewer pairs) andcpu.idle_wakes; Linuxcpu.instructionsin counter blocks of their own; Windows refuses all three (NoCounter). - A process that ends inside a run: Linux and Windows add the CPU it used (
i EndedCpu); macOS and targets strobe did not launch refusecpu.*(RunIncomplete).· run 2on a check line: run 1 lost a process, so strobe measured the run after the reset.query <rec> summarylists per run what ended (endedlines: executable, count, CPU).
- Benches take the device's slot per block, switch to the fastest power plan (put back after, also
after a kill;
--power keep), pin the target (--pin auto: the V-Cache CCD, a hybrid CPU's fast cores, else all but the last 1–2 cores; unpinned under 4 cores, over 64 CPUs, or on macOS) and keep this user's other processes off its CPUs (--reserve-cpus, Windows and Linux). - Unpinned, strobe and the runner share the target's CPUs: over 5% of a core warns
OverheadBudget, over 20% refuses a timed bench's times (--checkwarns and passes). Fixes: 4 or more cores, prebuiltctx.mockresponses, a heavy mock server infixture(), orcpu.work. - Never bench under background priority (
taskpolicy -b, nice 19, Windows' idle class): frame and latency metrics refuseBackgroundPriority. - macOS: a strobe started by a launchd job without
ProcessType(a remote shell's daemon) runs with coalesced timers (TimerCoalescing, atimersline instatus); strobe's own daemon does not. - macOS frames come from ScreenCaptureKit (Screen Recording), Linux X11 frames from X Present: screen
times only, so
frames.interval.*andframes.dropped*refuse there while counts andinteraction.*stand. - A strobe that died may leave its app running: the next
bench,recordorlookends it. Everything strobe launches carriesSTROBE_HOMEandSTROBE_OWNER;LiveChannelnames a copy that is yours or another strobe's.
- Everything about the app under test lives in its repo:
buildandservefor web targets,appsprofiles (kind,exeper OS,env,unset,files,guard,protocols,extends).build.windows|macos|linuxoverlaybuild(envmerged). Strobe itself names no product. - A relative
exe(./app.exe,build/app) is the project's build, from strobe.json's folder; a bare name is a file there, else the program on PATH.exe:<path>resolves from the current folder.strobe configshows the file each resolves to. - The nearest strobe.json upward from the bench file wins;
build.cwdandserve.cwdresolve from the checkout's root.//comments and trailing commas are fine; an unknown key exits 2. "$schema": "./node_modules/@hona/strobe/schema/strobe.v1.json".strobe configchecks it;strobe initwrites a first one."unit": "us" | "ms" | "s": the unit times print in (defaultus); the project's beats the user's own<state>/strobe.json, and--unitbeats both.- If an isolated launch disturbs your own state, strobe stops: add what leaked to the profile.