facet-wrapped/src/browser_bench.rs
//! **Browser bench harness** — the wrapped bundle, a large graph, a real WebGPU browser.
//!
//! Native-only (never compiled into the wasm). It answers the questions a native bench
//! cannot: how long a 100 000-node graph takes to reach the first frame in a browser,
//! how long its layout takes to settle, and how many frames per second the canvas
//! paints while the user drags it — on the device the browser actually opened.
//!
//! One run is:
//!
//! 1. a deterministic graph fixture is generated here, in Rust ([`graph_fixture`]);
//! 2. a `TcpListener` on `127.0.0.1:0` serves the built bundle (`facet_wrapped.js` +
//! `facet_wrapped_bg.wasm` from a `dist/` directory), the fixture and a bench page;
//! 3. HEADFUL Chrome opens the page (headless Chrome only offers SwiftShader, which is
//! a CPU rasteriser and says nothing about a GPU); the page walks the same steps the
//! Streamlit loader walks — `JSON.parse` of the host's payload, `JSON.stringify`,
//! `facet_check`, `facet_mount_with_data` — timing each, waits for the first frame and
//! for the layout to settle, then drags the canvas and measures frame intervals;
//! 4. the page POSTs one JSON result back and the harness returns it.
//!
//! The harness adds nothing to the component: it calls the same wasm exports a host
//! calls. The page is inline below so the bench cannot drift from a file nobody reads.
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
/// Which adapter the browser should hand WebGPU.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Power {
/// `force-high-performance` — the discrete GPU when there is one.
HighPerformance,
/// `force-low-power` — the integrated GPU when there is one.
LowPower,
}
impl Power {
/// The value Chrome's `--use-webgpu-power-preference` takes.
#[must_use]
pub fn chrome_value(self) -> &'static str {
match self {
Power::HighPerformance => "force-high-performance",
Power::LowPower => "force-low-power",
}
}
/// A short tag for bench rows.
#[must_use]
pub fn tag(self) -> &'static str {
match self {
Power::HighPerformance => "high-performance",
Power::LowPower => "low-power",
}
}
}
/// One browser run.
#[derive(Clone, Debug)]
pub struct BrowserRun {
/// Directory holding `facet_wrapped.js` + `facet_wrapped_bg.wasm`.
pub dist: PathBuf,
/// Registry component name (`graphnav`, `graph3d`, `graphview`, …).
pub component: String,
/// Node count of the generated fixture.
pub nodes: usize,
/// Adapter preference handed to Chrome.
pub power: Power,
/// How long the page drags the canvas while counting frames.
pub drag_ms: u32,
/// Give up after this long.
pub timeout: Duration,
/// The Chrome binary.
pub chrome: PathBuf,
/// Scratch directory for the throwaway Chrome profile.
pub scratch: PathBuf,
/// Port to serve on (`127.0.0.1:<port>`); `0` lets the OS pick. A shared box hands
/// each lane a fixed port range, so the harness must be able to stay inside it.
pub port: u16,
/// Open Chrome HEADLESS on the box's GPU render node instead of a window on the
/// desktop session — for a box someone is working on (the t14s). Plain headless
/// Chrome gives SwiftShader; the flag set in `headless_gpu_args` (measured on the
/// t14s, Radeon 880M, 2026-09-29) gives the hardware adapter.
pub headless: bool,
}
/// The first Chrome on `PATH` (or `$CHROME`).
#[must_use]
pub fn find_chrome() -> Option<PathBuf> {
if let Ok(p) = std::env::var("CHROME") {
let p = PathBuf::from(p);
if p.is_file() {
return Some(p);
}
}
let path = std::env::var_os("PATH")?;
for name in ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser"] {
for dir in std::env::split_paths(&path) {
let c = dir.join(name);
if c.is_file() {
return Some(c);
}
}
}
None
}
/// The desktop session Chrome is opened on — found on the box, not assumed, so the same
/// staged binary runs on any Linux box with a GPU and a logged-in desktop (loki's KDE
/// Wayland, a laptop's GNOME, an X11 session), also from an ssh shell that has none of
/// the session's variables.
struct SessionDisplay {
/// `--ozone-platform=` value: `wayland` or `x11`.
ozone: &'static str,
/// `$XDG_RUNTIME_DIR`, else `/run/user/<uid of this process>`.
runtime_dir: PathBuf,
/// `WAYLAND_DISPLAY` or `DISPLAY` for the child.
env: Vec<(String, String)>,
}
/// Find the session: `$WAYLAND_DISPLAY`, else the first `wayland-N` socket in the
/// runtime dir, else `$DISPLAY`, else the first `/tmp/.X11-unix/XN` socket.
fn session_display() -> Result<SessionDisplay, String> {
let runtime_dir = std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from).unwrap_or_else(|| {
// Real uid from /proc — no libc call, no shell.
let uid = std::fs::read_to_string("/proc/self/status")
.ok()
.and_then(|s| s.lines().find_map(|l| l.strip_prefix("Uid:").map(|r| r.split_whitespace().next().unwrap_or("").to_string())))
.unwrap_or_else(|| "1000".into());
PathBuf::from(format!("/run/user/{uid}"))
});
let first_socket = |dir: &Path, prefix: &str| -> Option<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.ok()?
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n.strip_prefix(prefix).is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())))
.collect();
names.sort();
names.into_iter().next()
};
if let Some(w) = std::env::var("WAYLAND_DISPLAY").ok().filter(|w| !w.is_empty()).or_else(|| first_socket(&runtime_dir, "wayland-")) {
return Ok(SessionDisplay { ozone: "wayland", runtime_dir, env: vec![("WAYLAND_DISPLAY".into(), w)] });
}
if let Some(d) = std::env::var("DISPLAY")
.ok()
.filter(|d| !d.is_empty())
.or_else(|| first_socket(Path::new("/tmp/.X11-unix"), "X").map(|x| format!(":{}", &x[1..])))
{
return Ok(SessionDisplay { ozone: "x11", runtime_dir, env: vec![("DISPLAY".into(), d)] });
}
Err(format!(
"no desktop session: no WAYLAND_DISPLAY, no wayland-N socket in {}, no DISPLAY, no /tmp/.X11-unix/XN — headful Chrome needs a logged-in desktop (headless offers only SwiftShader, which is not a GPU)",
runtime_dir.display()
))
}
/// `/proc/loadavg`'s first three fields, or `""` where there is none. Every measured
/// number is recorded next to this, because the box it runs on is shared.
#[must_use]
pub fn load_average() -> String {
std::fs::read_to_string("/proc/loadavg")
.map(|s| s.split_whitespace().take(3).collect::<Vec<_>>().join(" "))
.unwrap_or_default()
}
/// A deterministic graph fixture in the shared graph contract (`facet-wrapped`'s
/// `graph` module): `nodes` nodes, about `2 × nodes` edges.
///
/// The shape is a community graph — nodes fall into `sqrt(nodes)` communities, each a
/// ring with local chords, plus one long-range edge per 16 nodes — so a force layout
/// has real structure to find and the edge count grows linearly with the node count.
/// Eight entity labels give the palette something to colour.
#[must_use]
pub fn graph_fixture(nodes: usize, seed: u64) -> String {
const LABELS: [&str; 8] =
["Person", "Company", "Vessel", "Account", "Address", "Phone", "Case", "Device"];
let mut rng = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
let mut next = move || {
rng = rng.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
(rng >> 33) as usize
};
let communities = (nodes as f64).sqrt().ceil().max(1.0) as usize;
let per = nodes.div_ceil(communities).max(1);
let mut s = String::with_capacity(nodes * 110);
s.push_str("{\"title\":\"bench ");
s.push_str(&nodes.to_string());
s.push_str("\",\"nodes\":[");
for i in 0..nodes {
if i > 0 {
s.push(',');
}
let label = LABELS[(i / per) % LABELS.len()];
s.push_str("{\"id\":");
s.push_str(&i.to_string());
s.push_str(",\"label\":\"");
s.push_str(label);
s.push_str("\",\"key\":\"");
s.push_str(label);
s.push('/');
s.push_str(&i.to_string());
s.push_str("\"}");
}
s.push_str("],\"edges\":[");
let mut first = true;
let mut edge = |s: &mut String, a: usize, b: usize| {
if a == b {
return;
}
if !first {
s.push(',');
}
first = false;
s.push_str("{\"src\":");
s.push_str(&a.to_string());
s.push_str(",\"dst\":");
s.push_str(&b.to_string());
s.push_str(",\"rel\":\"REL\"}");
};
for c in 0..communities {
let lo = c * per;
let hi = ((c + 1) * per).min(nodes);
if hi <= lo {
break;
}
let len = hi - lo;
for i in lo..hi {
edge(&mut s, i, lo + (i - lo + 1) % len);
if len > 3 {
edge(&mut s, i, lo + (i - lo + 2 + next() % (len - 2)) % len);
}
}
}
for i in (0..nodes).step_by(16) {
edge(&mut s, i, next() % nodes);
}
s.push_str("]}");
s
}
/// The bench page. It imports the bundle's own wasm-bindgen JS and drives the same
/// exports the loader drives.
const PAGE: &str = r#"<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>facet-wrapped bench</title>
<style>html,body{margin:0;height:100%;background:#0b0d10;overflow:hidden}
#facet_canvas{position:absolute;inset:0;width:100%;height:100%}</style></head>
<body><canvas id="facet_canvas"></canvas>
<script type="module">
import init, * as fw from "./facet_wrapped.js";
const q = new URLSearchParams(location.search);
const comp = q.get("component");
const dragMs = +(q.get("drag_ms") || 3000);
const post = (path, obj) => fetch(path, { method: "POST", body: JSON.stringify(obj) });
const log = (m) => post("/log", { m: String(m) });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const raf = () => new Promise((r) => requestAnimationFrame(r));
async function until(pred, ms) {
const t = performance.now();
while (performance.now() - t < ms) { if (pred()) return true; await raf(); }
return false;
}
const perf = () => { try { return fw.facet_perf ? JSON.parse(fw.facet_perf()) : null; } catch (_) { return null; } };
function stats(xs) {
if (!xs.length) return null;
const s = [...xs].sort((a, b) => a - b);
const pick = (p) => s[Math.min(s.length - 1, Math.floor(p * s.length))];
const mean = xs.reduce((a, b) => a + b, 0) / xs.length;
return { n: xs.length, mean, p50: pick(0.5), p95: pick(0.95), max: s[s.length - 1] };
}
(async () => {
if (q.get("probe") === "all") {
const out = {};
for (const pp of ["default", "high-performance", "low-power", "default"]) {
const t = performance.now();
let a = null, err = null;
try { a = await navigator.gpu.requestAdapter(pp === "default" ? undefined : { powerPreference: pp }); } catch (e) { err = String(e); }
out[pp + "_" + Object.keys(out).length] = { ms: performance.now() - t, adapter: a ? (a.info.vendor + " " + a.info.architecture) : null, err };
}
await post("/result", { probe: out });
return;
}
const R = { component: comp, ua: navigator.userAgent, isolated: self.crossOriginIsolated === true,
hardware_concurrency: navigator.hardwareConcurrency };
try {
R.page_start_ms = performance.now();
try {
// WARM-UP. On loki the FIRST requestAdapter of a fresh Chrome returns null after
// ~30 s (the GPU process is still coming up) and every later one answers at once;
// the component's own request would take that hit. Ask until one answers, and
// report the wait separately so it is not billed to the mount.
let a = null;
for (let k = 0; k < 4 && !a && navigator.gpu; k++) {
a = await navigator.gpu.requestAdapter(q.get("power") === "low" ? { powerPreference: "low-power" } : undefined);
}
R.gpu_probe = a ? { vendor: a.info.vendor, architecture: a.info.architecture, device: a.info.device, description: a.info.description } : (navigator.gpu ? "requestAdapter returned null" : "navigator.gpu absent");
} catch (e) { R.gpu_probe = "requestAdapter threw: " + e; }
R.gpu_probe_ms = performance.now() - R.page_start_ms;
log("adapter " + JSON.stringify(R.gpu_probe) + " after " + Math.round(R.gpu_probe_ms) + " ms");
let t = performance.now();
const w = await init();
R.init_ms = performance.now() - t;
log("wasm init " + Math.round(R.init_ms) + " ms");
t = performance.now();
const text = await (await fetch("/fixture.json")).text();
R.fetch_ms = performance.now() - t; R.fixture_bytes = text.length;
// The Streamlit road: the host posts an OBJECT, the loader stringifies it.
t = performance.now(); const obj = JSON.parse(text); R.js_parse_ms = performance.now() - t;
t = performance.now(); const payload = JSON.stringify(obj); R.js_stringify_ms = performance.now() - t;
log("fixture " + text.length + " bytes; checking");
t = performance.now(); const why = fw.facet_check(comp, payload); R.check_ms = performance.now() - t;
log("check " + Math.round(R.check_ms) + " ms; mounting");
if (why) throw new Error("refused: " + why);
const tm = performance.now();
fw.facet_mount_with_data("facet_canvas", comp, payload);
R.mount_call_ms = performance.now() - tm;
if (!(await until(() => fw.facet_mounted() === comp || fw.facet_error(), 120000))) throw new Error("never mounted");
if (fw.facet_error()) throw new Error("mount error: " + fw.facet_error());
R.first_frame_ms = performance.now() - tm;
log("first frame " + Math.round(R.first_frame_ms) + " ms");
R.perf_first = perf();
// Settle: graphnav publishes its layout lane; everything else is settled at first frame.
const settleT = performance.now();
let st = null, reads = [];
for (;;) {
const tr = performance.now();
try { st = JSON.parse(fw.facet_state()); } catch (_) { st = null; }
reads.push(performance.now() - tr);
const c = st && st.canvas;
if (!c || c.layout_lane === undefined) break;
if (c.layout_lane && !c.layout_pending) break;
if (performance.now() - settleT > 180000) { R.settle_timeout = true; break; }
await sleep(100);
}
R.settle_ms = performance.now() - tm;
log("settled " + Math.round(R.settle_ms) + " ms lane=" + (st && st.canvas ? st.canvas.layout_lane : "-"));
R.state_read_ms = stats(reads);
if (st && st.canvas) {
R.layout_lane = st.canvas.layout_lane; R.layout_fallback = st.canvas.layout_fallback; R.layout_kernel = st.canvas.layout_kernel;
R.total_nodes = st.canvas.total_nodes; R.total_edges = st.canvas.total_edges;
}
if (st && st.legibility) R.legibility = st.legibility.frame;
for (const k of ["threads", "threads_reason", "gpu", "gpu_reason"]) if (st && st[k] !== undefined) R[k] = st[k];
// Idle frames for a baseline, then a drag across the canvas.
const cv = document.getElementById("facet_canvas");
const r = cv.getBoundingClientRect();
let x = r.left + r.width * 0.3, y = r.top + r.height * 0.5;
const p0 = perf();
const opt = (xx, yy, b) => ({ clientX: xx, clientY: yy, button: 0, buttons: b, bubbles: true, pointerId: 1, pointerType: "mouse", isPrimary: true });
cv.dispatchEvent(new PointerEvent("pointerdown", opt(x, y, 1)));
const iv = []; let last = performance.now(); const t1 = last;
while (performance.now() - t1 < dragMs) {
x += 2; if (x > r.left + r.width * 0.7) x = r.left + r.width * 0.3;
cv.dispatchEvent(new MouseEvent("mousemove", opt(x, y, 1)));
await raf();
const now = performance.now(); iv.push(now - last); last = now;
}
cv.dispatchEvent(new PointerEvent("pointerup", opt(x, y, 0)));
const wall = performance.now() - t1;
const p1 = perf();
R.drag = { wall_ms: wall, raf_interval_ms: stats(iv), raf_fps: iv.length / (wall / 1000) };
if (p0 && p1) {
const frames = p1.frames - p0.frames;
R.drag.app_frames = frames;
R.drag.app_fps = frames / (wall / 1000);
R.drag.app_ui_ms_mean = frames > 0 ? (p1.ui_ms_total - p0.ui_ms_total) / frames : null;
R.drag.app_ui_ms_max = p1.ui_ms_max;
}
R.perf_end = p1;
R.wasm_memory_bytes = w.memory.buffer.byteLength;
if (performance.memory) R.js_heap_used_bytes = performance.memory.usedJSHeapSize;
try { R.adapter = JSON.parse(fw.facet_adapter()); } catch (_) {}
R.ok = true;
} catch (e) {
R.ok = false; R.error = String(e && e.stack || e);
}
await post("/result", R);
})();
</script></body></html>"#;
struct Shared {
result: Mutex<Option<String>>,
cv: Condvar,
}
fn content_type(path: &str) -> &'static str {
if path.ends_with(".wasm") {
"application/wasm"
} else if path.ends_with(".js") {
"text/javascript"
} else if path.ends_with(".json") {
"application/json"
} else if path.ends_with(".html") {
"text/html; charset=utf-8"
} else {
"application/octet-stream"
}
}
fn respond(stream: &mut TcpStream, status: &str, ctype: &str, body: &[u8]) {
let head = format!(
"HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(head.as_bytes());
let _ = stream.write_all(body);
}
fn serve_one(mut stream: TcpStream, dist: &Path, fixture: &[u8], shared: &Shared) {
let mut reader = BufReader::new(match stream.try_clone() {
Ok(s) => s,
Err(_) => return,
});
let mut line = String::new();
if reader.read_line(&mut line).is_err() {
return;
}
let mut parts = line.split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let target = parts.next().unwrap_or("/").to_string();
let mut len = 0usize;
loop {
let mut h = String::new();
if reader.read_line(&mut h).is_err() || h == "\r\n" || h.is_empty() {
break;
}
let lower = h.to_ascii_lowercase();
if let Some(v) = lower.strip_prefix("content-length:") {
len = v.trim().parse().unwrap_or(0);
}
}
let mut body = vec![0u8; len];
if len > 0 && reader.read_exact(&mut body).is_err() {
return;
}
let path = target.split('?').next().unwrap_or("/");
if std::env::var_os("FACETT_BENCH_VERBOSE").is_some() {
eprintln!(" http: {method} {path} ({len} bytes in)");
}
match (method.as_str(), path) {
("POST", "/result") => {
respond(&mut stream, "200 OK", "text/plain", b"ok");
*shared.result.lock().unwrap() = Some(String::from_utf8_lossy(&body).into_owned());
shared.cv.notify_all();
}
("POST", "/log") => {
eprintln!(" page: {}", String::from_utf8_lossy(&body));
respond(&mut stream, "200 OK", "text/plain", b"ok");
}
("GET", "/bench.html") => respond(&mut stream, "200 OK", content_type(".html"), PAGE.as_bytes()),
("GET", "/fixture.json") => respond(&mut stream, "200 OK", content_type(".json"), fixture),
("GET", p) => {
let name = p.trim_start_matches('/');
if name.contains("..") || name.is_empty() {
respond(&mut stream, "404 Not Found", "text/plain", b"no");
return;
}
match std::fs::read(dist.join(name)) {
Ok(bytes) => respond(&mut stream, "200 OK", content_type(name), &bytes),
Err(_) => respond(&mut stream, "404 Not Found", "text/plain", b"no"),
}
}
_ => respond(&mut stream, "405 Method Not Allowed", "text/plain", b"no"),
}
}
struct KillOnDrop(Child);
impl Drop for KillOnDrop {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
/// Run one bench in a headful Chrome and return the page's JSON result, stamped with
/// the load average before and after, the fixture size and the adapter preference.
///
/// # Errors
/// The bundle is missing, the listener cannot bind, Chrome cannot start, or the page
/// did not report within [`BrowserRun::timeout`].
pub fn run(r: &BrowserRun) -> Result<serde_json::Value, String> {
for f in ["facet_wrapped.js", "facet_wrapped_bg.wasm"] {
if !r.dist.join(f).is_file() {
return Err(format!("{} is missing — build the bundle first (`cargo xtask wrap-wasm`)", r.dist.join(f).display()));
}
}
let load_before = load_average();
let fixture = Arc::new(graph_fixture(r.nodes, 7).into_bytes());
let listener = TcpListener::bind(("127.0.0.1", r.port)).map_err(|e| format!("bind: {e}"))?;
let port = listener.local_addr().map_err(|e| e.to_string())?.port();
let shared = Arc::new(Shared { result: Mutex::new(None), cv: Condvar::new() });
{
let (dist, fixture, shared) = (r.dist.clone(), fixture.clone(), shared.clone());
std::thread::spawn(move || {
for s in listener.incoming().flatten() {
let (dist, fixture, shared) = (dist.clone(), fixture.clone(), shared.clone());
std::thread::spawn(move || serve_one(s, &dist, &fixture, &shared));
}
});
}
let profile = r.scratch.join(format!("chrome-bench-{}-{port}", std::process::id()));
let _ = std::fs::create_dir_all(&profile);
let url = format!(
"http://127.0.0.1:{port}/bench.html?component={}&drag_ms={}{}{}",
r.component,
r.drag_ms,
if r.power == Power::LowPower { "&power=low" } else { "" },
std::env::var("FACETT_BENCH_QUERY").unwrap_or_default()
);
let display = if r.headless { None } else { Some(session_display()?) };
let mut args: Vec<String> = vec![
format!("--user-data-dir={}", profile.display()),
"--enable-precise-memory-info".into(),
"--no-first-run".into(),
"--no-default-browser-check".into(),
"--disable-background-timer-throttling".into(),
"--disable-renderer-backgrounding".into(),
"--disable-backgrounding-occluded-windows".into(),
"--window-size=1600,1000".into(),
// A fresh profile otherwise asks the desktop keyring for a password store and
// waits on that prompt forever (MEASURED on loki, KDE, 2026-09-28).
"--password-store=basic".into(),
"--disable-extensions".into(),
"--disable-sync".into(),
"--disable-component-update".into(),
];
match &display {
Some(d) => args.extend(headful_gpu_args(d.ozone)),
None => {
args.push("--enable-unsafe-webgpu".into());
args.extend(headless_gpu_args()?);
}
}
// The discrete GPU is Chrome's default pick; the integrated one needs asking for.
if r.power == Power::LowPower {
args.push(format!("--use-webgpu-power-preference={}", r.power.chrome_value()));
}
// Extra flags for a diagnosis run (`FACETT_BENCH_CHROME_ARGS="--enable-logging=stderr --v=1"`).
if let Ok(extra) = std::env::var("FACETT_BENCH_CHROME_ARGS") {
args.extend(extra.split_whitespace().map(str::to_string));
}
args.push(url);
let log = std::fs::File::create(r.scratch.join(format!("chrome-bench-{port}.log"))).ok();
let mut cmd = Command::new(&r.chrome);
cmd.args(&args);
if let Some(d) = &display {
cmd.env("XDG_RUNTIME_DIR", &d.runtime_dir).envs(d.env.iter().map(|(k, v)| (k.as_str(), v.as_str())));
}
let child = cmd
.stdout(Stdio::null())
.stderr(log.map_or_else(Stdio::null, Stdio::from))
.spawn()
.map_err(|e| format!("spawn {}: {e}", r.chrome.display()))?;
let _guard = KillOnDrop(child);
let started = Instant::now();
let mut got = shared.result.lock().unwrap();
while got.is_none() {
let left = r.timeout.saturating_sub(started.elapsed());
if left.is_zero() {
return Err(format!("the page did not report within {:?}", r.timeout));
}
got = shared.cv.wait_timeout(got, left).unwrap().0;
}
let text = got.take().unwrap_or_default();
drop(got);
let _ = std::fs::remove_dir_all(&profile);
let mut v: serde_json::Value =
serde_json::from_str(&text).map_err(|e| format!("page result is not JSON: {e}: {text}"))?;
if let Some(o) = v.as_object_mut() {
o.insert("nodes".into(), serde_json::json!(r.nodes));
o.insert("power".into(), serde_json::json!(r.power.tag()));
o.insert("load_before".into(), serde_json::json!(load_before));
o.insert("load_after".into(), serde_json::json!(load_average()));
o.insert("wall_s".into(), serde_json::json!(started.elapsed().as_secs_f64()));
o.insert("mode".into(), serde_json::json!(if r.headless { "headless" } else { "headful" }));
}
// A CPU rasteriser is not a GPU (owner ruling 2026-09-28): a run that landed on
// SwiftShader or llvmpipe has no numbers to give, whatever the page measured.
if let Some(why) = software_adapter(&v) {
return Err(format!(
"the browser's WebGPU adapter is not a GPU ({why}) — no numbers from this run: {}",
v.get("gpu_probe").map(ToString::to_string).unwrap_or_default()
));
}
Ok(v)
}
/// THE headful flag set — `browser_bench` and `examples/gallery_probe.rs` both take it
/// from here, so the two cannot drift. Owner order 2026-09-29 (oden, RTX 4090): a
/// headful window opened with `--ozone-platform=wayland` and no Vulkan flags got
/// "WebGPU is exposed but gave this page no adapter" on every mount of the gallery
/// probe (0/48), so every headful Chrome carries Vulkan + ANGLE-on-Vulkan + the GPU
/// switches. `ozone` is the session's platform (`wayland`, or `x11` on an X session).
#[must_use]
pub fn headful_gpu_args(ozone: &str) -> Vec<String> {
vec![
"--enable-unsafe-webgpu".into(),
"--enable-features=Vulkan".into(),
"--use-angle=vulkan".into(),
"--enable-gpu".into(),
"--ignore-gpu-blocklist".into(),
format!("--ozone-platform={ozone}"),
]
}
/// The headless flag set that reaches the hardware adapter: `--headless=new` alone
/// gives SwiftShader; `--enable-gpu` + ANGLE on Vulkan + the render node give the real
/// one (MEASURED t14s, Radeon 880M, vendor amd / rdna-3, 2026-09-29). `--use-gl=angle`
/// and `--ozone-platform=headless` crash the GPU process there, so neither is used.
///
/// THE one copy: `examples/gallery_probe.rs --headless` uses it too (2026-09-29).
/// `--enable-features=Vulkan` is in the set because the coordinator's measured set had
/// it and gallery_probe's 45/45 run on the t14s Radeon (lane A, `f5696bd`) used it.
pub fn headless_gpu_args() -> Result<Vec<String>, String> {
headless_gpu_args_on(None)
}
/// [`headless_gpu_args`] with the render node named by the caller (`gallery_probe
/// --render-node`); `None` = `$FACETT_BENCH_RENDER_NODE`, else the first
/// `/dev/dri/renderD*`.
pub fn headless_gpu_args_on(render_node: Option<&str>) -> Result<Vec<String>, String> {
let node = render_node.map(str::to_string).or_else(|| std::env::var("FACETT_BENCH_RENDER_NODE").ok()).or_else(|| {
let mut n: Vec<String> = std::fs::read_dir("/dev/dri")
.ok()?
.flatten()
.map(|e| e.path().display().to_string())
.filter(|p| p.contains("renderD"))
.collect();
n.sort();
n.into_iter().next()
});
let node = node.ok_or(
"headless mode needs a GPU render node: no /dev/dri/renderD* on this box (set FACETT_BENCH_RENDER_NODE)",
)?;
Ok(vec![
"--headless=new".into(),
"--enable-gpu".into(),
"--ignore-gpu-blocklist".into(),
"--disable-software-rasterizer".into(),
"--enable-features=Vulkan".into(),
"--use-angle=vulkan".into(),
format!("--render-node-override={node}"),
])
}
/// `Some(why)` when the page found no adapter, or one that names a CPU rasteriser.
///
/// The verdict is facett-core's (`adapter_retry::software_adapter` over
/// `render::adapter::is_software_rasteriser`) — the same one the demo and every
/// facet-wrapped mount refuse a software adapter with at start, so a probe and a page
/// cannot disagree about what a GPU is.
pub fn software_adapter(v: &serde_json::Value) -> Option<String> {
let probe = v.get("gpu_probe")?;
if let Some(s) = probe.as_str() {
return Some(s.to_string());
}
// facett's own recorded verdict (`facet_adapter()`'s `software`, from
// `is_software_rasteriser`) counts as much as the words.
if probe.get("software") == Some(&serde_json::Value::Bool(true)) {
return Some(format!("software adapter {}", probe.get("name").map(ToString::to_string).unwrap_or_default()));
}
let s = |k: &str| probe.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
// The page's probe carries the four `GPUAdapterInfo` strings; `facet_adapter()`'s JSON
// carries a composed `name` and a `driver` instead.
let description = [s("description"), s("name")].join(" ");
let vendor = [s("vendor"), s("driver")].join(" ");
let fallback = probe.get("is_fallback") == Some(&serde_json::Value::Bool(true));
facett_core::adapter_retry::software_adapter(&vendor, &s("architecture"), &s("device"), &description, fallback)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cpu_rasteriser_or_no_adapter_yields_no_numbers() {
let swift = serde_json::json!({ "gpu_probe": { "vendor": "google", "architecture": "swiftshader" } });
assert!(software_adapter(&swift).is_some_and(|w| w.contains("swiftshader")));
let none = serde_json::json!({ "gpu_probe": "requestAdapter returned null" });
assert!(software_adapter(&none).is_some());
let amd = serde_json::json!({ "gpu_probe": { "vendor": "amd", "architecture": "rdna-3" } });
assert_eq!(software_adapter(&amd), None);
// gallery_probe hands in facet_adapter()'s JSON: the recorded flag alone refuses it.
let flagged = serde_json::json!({ "gpu_probe": { "name": "", "software": true } });
assert!(software_adapter(&flagged).is_some());
let named = serde_json::json!({ "gpu_probe": { "name": "llvmpipe (LLVM 21)", "software": false } });
assert!(software_adapter(&named).is_some_and(|w| w.contains("llvmpipe")));
}
#[test]
fn the_headless_set_takes_the_named_render_node() {
let a = headless_gpu_args_on(Some("/dev/dri/renderD129")).expect("a named node needs no /dev/dri");
assert!(a.contains(&"--render-node-override=/dev/dri/renderD129".to_string()));
for f in ["--headless=new", "--enable-gpu", "--use-angle=vulkan", "--enable-features=Vulkan"] {
assert!(a.iter().any(|x| x == f), "{f} in {a:?}");
}
}
#[test]
fn the_headful_set_carries_vulkan_angle_and_the_session_platform() {
let a = headful_gpu_args("wayland");
for f in [
"--enable-unsafe-webgpu",
"--enable-features=Vulkan",
"--use-angle=vulkan",
"--enable-gpu",
"--ignore-gpu-blocklist",
"--ozone-platform=wayland",
] {
assert!(a.iter().any(|x| x == f), "{f} in {a:?}");
}
assert!(headful_gpu_args("x11").contains(&"--ozone-platform=x11".to_string()));
}
#[test]
fn the_fixture_is_the_graph_contract_at_the_asked_size() {
let text = graph_fixture(1000, 7);
let v: serde_json::Value = serde_json::from_str(&text).expect("valid JSON");
let p = crate::graph::GraphPayload::parse(&v).expect("parses as the graph contract");
assert_eq!(p.nodes.len(), 1000);
assert!(p.edges.len() > 1500 && p.edges.len() < 2200, "about 2 edges per node, got {}", p.edges.len());
assert_eq!(graph_fixture(1000, 7), text, "deterministic");
}
}