Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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");
    }
}