Client stress-test reports
This document owns the cross-file contract for the shareable client-only Hellhole benchmark:
/stress-test, StressTestRunner, the stress-test HTTP API, and client_stress_tests persistence.
Route and workload
/stress-test automatically launches the checked-in fixed-roster-hellhole.rtsstream through
SnapshotStreamNet. It uses the normal snapshot decode, GameState, Match, Pixi module worker, fog, HUD,
minimap, and animation-frame paths, but never opens a WebSocket or runs a server simulation.
The canonical recording starts with 389 projected entities: the fixed 77-unit-per-player 2v2 armies and structures
plus Player 1’s 120 map-wide, deterministically scattered Tank Traps.
The roster is an authored mix—1 Worker, 1 Golem, 8 Riflemen, 8 Machine Gunners, 8 Panzerfausts,
8 Anti-Tank Guns, 8 Mortar Teams, 1 Artillery, 9 Scout Cars, 16 Tanks, and 9 Command Cars per
player. Supply costs are not an input to composition, so balance changes cannot silently alter the
unit count or ratio used for performance comparisons.
The static benchmark map carries two long parallel five-level ridges and a northwesterly sunset sun
at 12 degrees above the horizon. This intentionally keeps terrain relief, long terrain shadows, and
the active GPU unit-shadow path inside the canonical renderer workload rather than measuring the
flat-map bypass. Static terrain visibility is a four-samples-per-tile light-space cache built once
when that map enters the worker; it is invariant under benchmark viewport, DPR, zoom, and frame
count. Dynamic unit receivers still update every submitted frame.
?label=<text> adds a bounded human label to every artifact. ?seconds=<2..25> exists for local
iteration; the shareable default is five seconds after a three-second warmup. The cap keeps the
measurement inside the finite 30-second recording.
The route response alone sends Document-Policy: js-profiling. The pinned Pixi ESM module is loaded
inside the render worker. Chromium browsers with the JS Self-Profiling API collect the main-page
10 ms sampled trace and SVG flame graph; that browser API does not make the page trace a worker CPU
profile. Browsers without it still complete the run with the same bounded main-thread phase/frame
summary, render-worker queue/timing diagnostics, and long-frame observations. The canonical CLI
harness profiles the page and render worker independently.
Measurement and validity
The runner mounts only a compact progress/result status surface; there is no preflight form. It
starts after App.onStart has constructed the ordinary Match, warms the renderer and assets for
three seconds, then resets Match.frameProfiler immediately before measuring. Stream download,
parsing, initial Pixi allocation, and shader warmup are therefore outside the result.
The report includes main-thread frame-work, renderer submission, fog, scheduling, and
diagnostic-counter summaries from FrameProfiler; a separately reset render-worker measurement
window with submitted/completed/superseded/failed counts and
queue/display/main-submit/worker-update/worker-present timings; actual average
completed-presentation throughput; the static stream
identity; Long Tasks and Long Animation Frames
when supported; and a JS trace/flame graph when supported. The result UI reports the p95 frame-work
tier and the approximate work reduction or headroom against 16.67 ms. This is a relative
frame-work indicator, not a claim that the display actually presented at 120 or 240 Hz.
The host requestAnimationFrame callback rate is labeled separately and must never be reported as
rendered or presented throughput. The server’s indexed average-throughput headline is derived from
worker completed, not host frame count. Submitted frames that the one-in-flight worker queue
supersedes remain visible in the artifact rather than inflating throughput.
The local harness has an opt-in uncapped capacity lane (RTS_CLIENT_PERF_UNCAPPED=1) for the
canonical stream. It pauses only Match’s rAF ownership, keeps the snapshot stream at its authored
30 Hz wall cadence, and drives the exact live frame path with a completion-paced two-frame pipeline.
Every iteration still performs input, camera, entity-view preparation, fog, immutable presentation
assembly and cloning, worker update/present, HUD, minimap, health, and observer work. The lane must
finish with submitted = dispatched = completed, zero superseded/failed/stale responses, and no
in-flight or pending frame. Its match.liveFrame.workerAcknowledged headline is independent of
display refresh but remains a pipelined WebGL capacity measurement. Set
RTS_CLIENT_PERF_GPU_COMPLETE=1 to add gl.finish() before each worker acknowledgment; this emits
the separately labeled conservative match.liveFrame.gpuComplete floor and must not be presented
as ordinary gameplay FPS because it deliberately removes normal CPU/GPU overlap.
For exact shadow-cost comparisons, RTS_CLIENT_PERF_SHADOW_MODE=none|terrain|full selects no cast
shadows, the once-per-map terrain cache only, or cached terrain plus dynamic projected unit shadows.
All three modes retain the same current ridge map, authored sun, elevation relief, entity stream,
viewport, and presentation cadence. Historical pre-shadow fixtures were flat and are not a causal
comparison for the current shadow workload.
If reset occurs while a worker frame is already in flight, that job retains its prior-window epoch.
Diagnostics expose it as carriedInFlight and, after settlement, carriedCompleted; it is excluded
from new-window completion counts and timings. Thus every measurement window preserves
completed <= submitted without cancelling or double-submitting a presentation.
Opt-in renderer experiments use AsyncGpuTimerQueries with
EXT_disjoint_timer_query_webgl2. Query results are polled only after the browser reports them
available; disjoint intervals are discarded, pending query and retained-sample counts are bounded,
and teardown deletes every query. runRafIndependentGpuSamples schedules a fixed number of warmup
and measured callbacks through independent tasks, so a diagnostic sample count does not inherit the
host display’s rAF ceiling. It is a diagnostic building block: each renderer experiment still owns
the exact draw callback and must report its GPU interval separately from worker CPU update/present
and end-to-end display age.
Set RTS_CLIENT_PERF_DETAILED_SHADOWS=1 when invoking the performance harness or deterministic
parity runner to enable the production detailed-unit-shadow preference in both the workload and
its render worker. The action is recorded in the workload artifact so detailed-shadow results
cannot be mistaken for the default-off path.
Set RTS_CLIENT_PERF_GPU_TIMING=1 on the performance harness to opt into bounded asynchronous GPU
queries before renderer startup. Harness diagnostic resets also reset pending worker queries and
samples; each presented acknowledgment returns the current bounded summary, which is persisted as
renderWorker.gpuShadowTiming in the workload artifact. Unsupported implementations report
supported: false rather than blocking or substituting a CPU timer.
Worker display age covers the complete interval from host acceptance through acknowledgment,
including bounded host-pending time, message construction/cloning, dispatch, worker update, and
presentation. Queue age uses the same acceptance boundary through worker task start.
Warmup and measurement require an uninterrupted visible, focused tab. If either condition changes, the current attempt and its browser profile are discarded without uploading; the runner waits for the tab to return, rebuilds the match from the recording’s first snapshot, repeats the full warmup, and starts a fresh five-second measurement. A completed foreground window with at least one rendered frame is accepted, so a truly slow machine remains measurable; fewer frames trigger another local restart instead of a misleading zero-FPS artifact.
Identification and privacy
The page accepts an explicit label, creates a random stable device id in localStorage, and hashes
a coarse environment fingerprint. It records browser/UA client hints when available, OS/platform,
CPU architecture/bitness, logical cores, approximate memory, WebGL vendor/renderer, screen and
viewport dimensions, DPR, estimated refresh rate, locale/timezone, and coarse Network Information
API values. Browsers do not expose an OS username or personal name. The route does not request
geolocation permission and the server does not store a raw client IP in the artifact.
HTTP and persistence
POST /api/stress-tests accepts schema v1 and issues both an unguessable run id and a filename-safe
artifact label. GET /api/stress-tests/{run_id} downloads the complete labeled JSON artifact;
GET /api/stress-tests/{run_id}/flamegraph.svg downloads the SVG when a JS profile exists. The
server logs one structured headline row for every accepted report and retains the latest 64
artifacts in process memory, so local end-to-end testing works without a database.
Postgres persistence is independent of match-history writes. It occurs only when both
DATABASE_URL is available and RTS_RECORD_STRESS_TESTS is truthy. Local cargo run should leave
the gate off; beta/mainline should enable it when shareable artifacts must survive restarts. The
client_stress_tests table stores indexed throughput/p95 headline columns plus the complete JSON artifact. Run-id
lookups check the bounded memory cache first and Postgres second whenever a database is configured,
even when new writes are gated off. There is intentionally no public list endpoint.
Untrusted-input limits
The POST body is capped at 2 MiB before JSON extraction. The server validates the workload and schema, label/device/fingerprint lengths and character sets, 1.5–30 second duration, status, invalid-reason count, profile kind, trace-table sizes (4,000 samples, 12,000 stacks/frames, 1,000 resources), and a 750 KiB SVG cap. The server, not the browser, generates run ids, durable artifact labels, build ids, and receive timestamps. The SVG validator rejects scriptable elements, event-handler attributes, and links before the same-origin download endpoint can serve it. The SVG response also applies a restrictive CSP sandbox and MIME-sniffing protection. Retrieval accepts only the run-id shape generated by the server.