csbench serve starts a small local web server on top of a results directory
and serves an embedded single-page viewer. It shows you a run while it is
happening, helps you read the runs that have already finished, and — only
when you ask for it — lets you edit a target config and start a run.
Read-only is the default, and that is the design. Without --allow-write
every write route answers 405 and names the flag, so “not allowed here” never
reads as “broken”. The one exception predates the flag: a zero-byte cancel
marker for a run that is still going, see Stopping a run.
Sealed records are never writable, flag or no flag. Every record carries a
record_digest that covers its measurements; an edit route would make “this
result can be re-checked” false, and that is the promise the whole tool rests
on. See Writing.
Usage
(results: results, read-only) or
read+write — because that is the server’s whole security argument and
someone reading their scrollback should be able to tell which one is up.
The command prints the actual bound address (useful with --port 0) and runs
until Ctrl-C.
The header carries the five sections — Suites, Platforms, Runs,
Observe, Config — plus a language switcher (EN | 中文; the choice persists in localStorage and
defaults to the browser language), an engineer-view toggle and a theme
toggle (follows prefers-color-scheme until toggled, then persists). The Live
tab carries a count badge only while something is actually running; there is no
permanent “0”.
Watching a run happen
Start a run in one terminal andcsbench serve in another. The run appears on
its own — the viewer polls /api/progress every few seconds, and a strip
appears above the board while anything is in flight.
#/live— everything in flight at once. With exactly one run it forwards straight to that run’s page.#/live/:run_id— one run, fed by a single SSE connection to/api/progress/<run_id>/stream.
- Header — benchmark, platform, status, an elapsed clock that ticks once a second while the run is running, and the Stop run button.
- Progress — the current stage in plain language (from the same stage
glossary the record page uses), and the current phase as
12 / 99 querywith a bar. An estimate of the time left is labelled est. and is withheld until at least three units have completed (web/src/lib/eta.ts): a confidently wrong “3 seconds left” costs more than a blank. - Execution, as it happens — a waterfall of the run so far, fed by
stepevents instead of spans. (It is its own renderer: the sealed trace page moved to the zoomable tree described under Reading results and no longer draws this waterfall.) A live step names its parent (a suite knows what it called the enclosing phase, not what span id the tracer will mint later), so nesting is resolved by name. - Numbers so far — computed in the browser from the steps that have finished: how many steps are done, the median and p99 step, failed steps, the slowest step so far.
- Log — the tail of this run’s
clousight_benchlog records, filterable by level. Auto-scroll is sticky-to-bottom: scroll up and it stops yanking you back.
”Preliminary” means it is not a measurement
Every tile in Numbers so far carries a preliminary chip, and the pane says outright where the numbers came from. That is a contract, not modesty:- a measurement is produced by an
Evaluator, offline, over the sealed evidence, after the run has ended — it is what lands in the record and whatrecord_digestcovers; - the live tiles are a lagging browser-side arithmetic over whatever events have arrived so far, from a stream that is explicitly allowed to drop events.
sample event. A screenshot of the live
page is a screenshot of a partial run, and the UI is built so a reader cannot
mistake it for a result.
Why a measured window shows an indeterminate bar
A phase can declare that it knows its size but cannot tick through it:progress.phase("Throughput Test", total=396, unit="query", reports_progress=False).
The viewer then draws an indeterminate bar and says
“no progress reported from inside a measured window”, instead of a 0% bar
that reads as a hang.
The reason is in the suite, not the UI. In the TPC official mode the Throughput
test’s own wall clock is the metric (Throughput@Size / the T_TT term),
so writing progress files inside that window would put the viewer’s I/O into
the number. The suite therefore reports nothing from inside it and replays the
per-stream, per-query steps from the intervals it already measured once the
window has closed — same numbers, same layout, a few seconds late. See
reporting progress for the rule a
suite author has to follow.
Stopping a run
The Stop button is the viewer’s only mutating control, and it is deliberately two-step: arm, then confirm. Pressing itPOSTs to
/api/progress/<run_id>/cancel, which creates a zero-byte cancel file in the
run’s progress directory. Nothing else happens over HTTP.
The run picks it up on its own schedule — the orchestrator checks at every
stage boundary, and a wired suite polls should_cancel() inside its loops (a
cached stat, at most four per second). When it fires, the run raises
RunCancelled, which is a KeyboardInterrupt subclass, so the cancel takes the
path a Ctrl-C already took:
- teardown still runs — nothing provisioned is orphaned or left billing;
- an
interruptedrecord is persisted, with whatever stages completed; - the live page swaps to a handoff card pointing at that record.
When the run ends
The page does not go blank or 404. The progress directory survives forTERMINAL_GRACE_S (120 s by default) precisely so a subscriber can still read
the terminal done event and the sealed record’s path out of it, and the page
swaps to a handoff card linking to #/record/<run_id>.
If some events were dropped to stay inside the stream caps, the page says so
and says which kinds — and says that the sealed record is unaffected.
Reading results
Four pages, in the order a question narrows:
A run is one object with four faces, selected by
?tab=: overview, per-item
detail (absent on suites that emit no items), the execution trace, and the
frozen configuration snapshot. Its identity bar answers “what am I looking at”
before any tab is pressed, and carries Create like this, which seeds a new
run from this one.
Every older spelling still resolves and rewrites itself in the address bar:
#/, #/record/:id[/trace|/items], #/suite/:d/:s, #/live[/:id].
- Board groups every record by domain and then by benchmark, showing each
benchmark’s platform count, run count and its newest run’s headline metrics.
Domains and suites are ordered by most recent activity, and a domain card
shows five benchmarks before offering to expand: a results tree that has
accumulated a long tail of retired benchmarks should not push what you ran
today off the first screen. A record that does not name both a domain and a
suite (a schema-0.1-era run summarises to empty strings) is not folded in —
a nameless, valueless card is worse than an absence — it stays visible under
#/runs. - Suite comparison leads with the table a selection decision is made on:
the latest run on each platform, one column per metric, ordered by the
glossary (headline metrics first) rather than alphabetically. Only metrics
some platform’s latest run actually produced get a column, so a TPC-H
referencerun and anofficialrun do not produce a table of em-dashes. Trend charts for up to four metrics follow, over a bounded history (the last 30 runs per platform). - Record reads top-down: what it found, whether the machinery behaved, what it measured, whether the numbers can be trusted. The eleven recorded lifecycle stages collapse into one health line — “Everything behaved · 11 stages, all passed” — which expands into the full four-phase stage strip; when something failed, the line names the stage instead.
- Trace is one zoomable tree of the run’s spans. Drag the overview strip to narrow the time window and every bar re-draws against it — the axis moves, rather than the spans outside it merely dimming. Selecting a row docks its detail on the right without pushing any row out of view, children that ran concurrently pack into lanes you can drill into, and each row prints two durations: the part inside the window, and the span’s unmoving total. The aggregate and table tabs follow underneath, over the spans the window contains. Every run has one — see the execution trace.
#/runs is the old flat table of every run, with a client-side filter box. It
is genuinely useful once you already know which run you are hunting for, which
is why it is still there and no longer the front door.
Writing
Started with--allow-write, the console can do two things. Both are fenced
the same way: the shared Host guard, a required X-Csbench-Write: 1 header
(a custom header is unreachable from a cross-origin <form>, which closes
simple-form CSRF), and a 64 KiB cap read off Content-Length before a byte of
the body is read.
Targets (configs/*.yaml) can be created, edited and deleted. A name off
the wire is a name, never a path: it must match ^[A-Za-z0-9._-]{1,64}$ (with
. and .. excluded, since both match that pattern and name a directory) and
the resolved path must still be inside the configs directory — checked after
resolve(), because a symlink passes any name check. YAML is parsed before it
is written, so a file that will not load never reaches the disk.
Values whose key looks like a credential (secret, token, password, key,
credential) come back as ***, and when that happens the file’s raw text is
withheld entirely — raw text is the one thing that cannot be redacted safely,
so such a target is shown but not editable here. A PUT carrying *** is
refused: that body is a redacted read on its way back, and saving it would
overwrite a real credential with three asterisks.
Runs can be started. The request body is a fixed set of fields — domain,
benchmark, platform, target name, scalar params, repeat, warmup — validated
against the live plugin registry. There is no field for a command line and
none for a path. repeat is capped (20) because it is what turns one click
into an afternoon of billable work, and a fifth concurrent run is refused with
429 rather than queued. The run is a detached subprocess
(sys.executable -m clousight_bench run --run-id ...), so the reply can name
a run before it has started and the browser can open the live view on it
immediately.
What the form deliberately does not enforce is that a benchmark “belongs”
on a platform: nothing in the registry pairs them, so any rule would be
invented. It shows where each benchmark has actually run, warns before a
platform that bills or an adapter that is still a skeleton, and lets you
decide.
The console keeps its own notes under results/.launches/ — what it was asked
to start, beside the results rather than in them. That is what lets a target
say how many runs used it; a sealed record does not carry the name of the
config file it was handed.
Plain language
The viewer renders internal vocabulary through one de-jargon layer,web/src/lib/glossary.ts, under two rules.
Two layers, never one. The human label leads and the raw key stays visible
underneath in mono — Typical query time over tpc-h.geomean_latency_ms,
Seal evidence over SEAL. Nothing becomes unauditable, and an engineer can
still match a field name at a glance. This is not affected by any toggle.
Never invent. An unrecognised measurement key still renders: it gets a
prettified label and a format inferred from its declared unit or, cautiously,
from its suffix. It never gets a direction. betterIs stays "none" unless a
human wrote the spec, because calling a rising number “better” when it is
actually a regression is worse than saying nothing — so an unknown metric is
simply never bolded as a leader in the comparison table.
The same layer explains the two things that reliably confuse readers:
invalid (we never measured anything — not a failure of the system under test)
versus failed (we measured and it broke), and PUBLISH: skipped, which is a
durable denial that any publisher touched the sealed record, not an error.
Engineer view is a persisted toggle in the header. Off, the record page
shows conclusions. On, it adds a panel with the record’s fingerprints,
identity, environment and extensions in full — the fields that make a run
reproducible and comparable, and that the previous viewer never rendered at
all. What the toggle does not hide either way is the raw key under every
label.
The progress plane on disk
Live progress is a second, deliberately separate plane from the sealed record (src/clousight_bench/core/progress.py):
- Disposable, and outside the record. Nothing here is covered by
record_digest, nothing here can move a verdict, and the whole directory is swept once the sealed record exists. Deletingresults/.progress/at any time loses nothing but a live view. - Dot-prefixed on purpose. A
state.jsonwith astatusfield sits one carelessrglob("*.json")away from being read as a result record. - Best-effort. Every write is contained: an IO failure logs at debug level, disables the plane for the rest of the run, and the benchmark carries on.
- Bounded. Past the caps, the writer drops rather than grows, and it only
ever drops the droppable kinds —
logandsample.stageandstepevents are never dropped; they are the shape of the run. Dropped counts are published instate.jsonand surfaced in the UI. - Reaped. A stale directory is collected when a run starts and when the
viewer starts, so a machine that lost power mid-benchmark never shows a
phantom “running” row. A snapshot that still claims to be running while its
pid is gone is reported as
abandoned, not as running forever. - Versioned.
state.jsoncarries"schema": "progress/1"; a reader that does not recognise the schema ignores the directory rather than guessing.
API
Anything else is a 404 with a JSON body — the app is a hash router, so there is
no SPA fallback that would turn a typo into an HTTP 200.
The SSE stream sends
state frames when the snapshot changes, events frames
carrying stream events, a : heartbeat comment every 15 s so an idle proxy does
not drop the connection, and a final done frame. It polls the two files every
400 ms rather than watching them — the plane is deliberately plain files with no
notification channel. Each stream owns a server thread for its whole life, so
there is a hard cap of 8 concurrent streams (a 9th gets a 503) and a 6
hour lifetime cap per stream. Every event carries a seq, which the browser
uses as a resume token after a reconnect.
Development and build
The viewer is a React + TypeScript + Vite + Tailwind app underweb/. Its
production build is committed at
src/clousight_bench/resources/viewer/dist/ and shipped inside the wheel, so
installing the package never requires Node.
web/, rebuild and commit the regenerated
dist/ in the same change — tests validate the committed artifact, not the
sources alone. CI’s viewer-dist job runs npm test, rebuilds, and fails if
the committed dist/ is not byte-identical to the rebuild.
Scope and security
- Binds
127.0.0.1by default. It has no authentication — do not expose it on a public interface. - Requests whose
Hostheader is not the bound host or a localhost alias get a 403 (DNS-rebinding guard). The guard sits above the method split, soPOSTcan never end up behind a differently-worded copy of it. - Write routes are off unless
--allow-writeis passed, and answer 405 naming the flag when they are. Reading targets is not gated: a Targets page that could list nothing on a read-only server would be a page about a flag. Sealed records have no write route at all, in either mode. - The cancel endpoint predates the flag and is fenced independently: the shared Host guard, a required
X-Csbench-Progress: 1header (a custom header is unreachable from a cross-origin<form>, which closes simple-form CSRF), a refusal of any request body, and a run_id that must be a plain[A-Za-z0-9._-]+token before it can name anything on disk. All it can ever do is create a zero-byte marker for a run that is still running. - Cache policy:
no-storefor the document and every JSON API response — it is all live state; the SSE stream sendsno-cache— andpublic, max-age=31536000, immutablefor/assets/*, whose filenames are content-hashed by the Vite build. Without the first half, upgradingcsbenchand reloading would serve a cachedindex.htmlnaming an asset hash the new wheel does not contain: a white page with nothing in the console to explain it. - The built app is offline-first: everything (React, ECharts, styles, both
locale dictionaries) is bundled into the committed
dist/, system font stack only, and no external requests — a build-time guard plus a test enforce that no dist file contains an absolute URL. - It is served under a strict CSP (
default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self') with nothing inline (tests enforce thatindex.htmlhas no inline scripts, styles, or handlers), and server-provided strings are always rendered as text, never as markup (dangerouslySetInnerHTMLis banned by test). - Static serving is containment-checked inside the packaged
dist/directory before any path joining;.., separators, colons and unknown extensions return 404. - Artifact and trace reads are resolved and must stay inside the results
directory.
/api/metapublishes the results directory’s basename only, never its full path — and the record path in a progress handoff is relative for the same reason.
The execution trace
A run’s trace is one tree:csbench.run at the root, its lifecycle stages
beneath, and — for a benchmark that reports them — that benchmark’s own spans
nested inside the stage that produced them, down to a single query.
That took two things, and neither was obvious from the outside.
The stages needed a real timeline. emit_run_trace used to lay them
end-to-end from their durations, so only how long a stage took was real and
when it happened was invented. run.stage_spans now records each timed
stage’s real [start_ms, end_ms] from the run’s start. Two consequences: a
suite’s spans, which carry their own timestamps, land where they belong; and a
gap between two stages is visible, which a list of durations cannot show — the
first run with real marks revealed five seconds before PREFLIGHT that nobody
could previously see. The field is optional and additive, and a record written
without it still renders through the old reconstruction.
The two halves had to be joined. They were already the same trace — a suite
receives the run’s trace_id on its DriverContext — but the suite’s root span
had no parent and lived in its own file, so the viewer showed one or the other.
emit_run_trace now re-emits the suite’s trajectory as children of
csbench.stage.EXECUTE. The suite’s own artifact is left byte-identical: it is
the SUT’s unmodified account and its sha256 is pinned in the record, so the
run trace becomes the merged view while the artifact stays the primary source.
A span whose timestamps fall outside the stage window keeps them — clamping
would hide exactly the disagreement worth seeing.
What a reader gets
/api/record/<run_id>/trajectory says in its source field which of three
things it returned, decided by looking at the spans rather than at a version:
full—results/traces/<trace_id>.jsonlwith the benchmark’s spans merged in. The whole chain. This is the normal case.artifact— the record’skind=trajectorysidecar, for a record written before the merge existed. Its detail is there; the stages are not.lifecycle— a run trace with no benchmark spans in it, because the benchmark reported none. Every run has at least this.
Which benchmarks report
A benchmark gets spans insideEXECUTE by writing a kind=trajectory
artifact. The bundled ones do, on their real paths:
Mock paths seal nothing. The bundled fixtures carry canned latencies, and a
waterfall of invented numbers is worse than no waterfall, so a
mode: mock run
shows lifecycle and says so.
See reporting progress for writing
one. The same rule governs both: a span may be recorded from marks a measured
region already took — the per-query spans above are literally the intervals
latency_ms was computed from — but nothing may be written inside a window
whose own wall clock is the measurement.
MMLU and GSM8K are the one documented exception to the first half of that rule.
Their live item step is drawn from the item loop’s own marks, around the whole
call, while the sealed latency_ms is a separate reading chat_once takes
from just before the HTTP request to just after the response is parsed. The two
differ on every run by the per-call setup between them, and under an enabled
params.retry by the failed attempts and backoff sleeps as well — so the live
number is always the larger of the two. That is deliberate: the live sample
answers “how long is this item taking”, the sealed one answers “how fast is the
endpoint”.
Span shape
The primary span shape is the v3 OTel-native span (core/sut_span.py):
span_id / trace_id / parent_span_id / name / start_unix_nano / end_unix_nano / status / attributes, where status is OTel OK / ERROR / UNSET and
attributes must contain at least one semconv-namespaced key as the kind
discriminator — gen_ai.* (model and tool calls), db.* (queries) or
csbench.* (phases, streams, and the run’s own lifecycle). Legacy v2 spans
(parent_id, t_start / t_end, attrs, a literal kind) are still accepted
and normalized by the viewer.
The viewer colours by that kind, and treats the run’s own lifecycle as chrome
rather than as a series: it takes the recessive axis colour, so the frame does
not compete with the work hanging inside it. See the result-record schema
(src/clousight_bench/resources/schemas/result-record-0.4.schema.json) for how
artifacts are referenced from a record.