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

nordisk/facett

facet-wrapped/examples/demo_pages_probe.rs

//! **Demo pages probe** — the BUILT facett-demo pages bundle (`dist/`: `index.html` +
//! `facett-demo.js` + `facett-demo_bg.wasm`) in a real WebGPU browser, one deep-linked
//! graph tab at a time, measured from what the running app publishes
//! (`window.__facett_state()`).
//!
//! `browser_bench` measures one wrapped component; this measures the demo the pages
//! site ships, through the same doors a visitor uses: `index.html?gen=<source>:<size>#<tab>`
//! (the size + generator deep link) in an iframe of a driver page served from the same
//! `127.0.0.1` listener (a secure context, so WebGPU is available). Per scenario it
//! records the time to the first published state, to the tab's first drawn GPU frame
//! (the app's own `first_frame_ms`), the settle, the lane the tab reports (`gpu` /
//! `no GPU` — a CPU lane is a defect, a missing adapter is said plainly), then drags the
//! canvas and counts the app's frames. Chrome's flag set and the software-adapter check
//! are `facet_wrapped::browser_bench`'s, so the probes cannot drift.
//!
//! ```bash
//! cargo ops demo-wasm            # → dist/
//! cargo run -p facet-wrapped --example demo_pages_probe -- --dist dist --headless \
//!     --scenario Graphs:community:100000 --scenario Universe:crates:100000 --out probe.json
//! ```
//!
//! Exit 0 only when every scenario drew on the GPU of a hardware adapter.

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

use facet_wrapped::browser_bench::{find_chrome, headless_gpu_args_on, load_average, software_adapter};

struct Args {
    dist: PathBuf,
    chrome: Option<PathBuf>,
    scenarios: Vec<String>,
    headless: bool,
    render_node: Option<String>,
    out: Option<PathBuf>,
    timeout_s: u64,
    drag_ms: u32,
    port: u16,
    scratch: PathBuf,
    /// `--no-gpu-flags`: headless Chrome WITHOUT the GPU switches — the SwiftShader
    /// repro (a plain `--headless=new` falls back to the software adapter). The page must
    /// then say "no GPU" plainly; the probe records it and exits 2 (not a GPU).
    no_gpu_flags: bool,
}

fn args() -> Args {
    let mut a = Args {
        dist: PathBuf::from("dist"),
        chrome: None,
        scenarios: Vec::new(),
        headless: false,
        render_node: None,
        out: None,
        timeout_s: 300,
        drag_ms: 4000,
        port: 0,
        scratch: std::env::temp_dir(),
        no_gpu_flags: false,
    };
    let mut it = std::env::args().skip(1);
    while let Some(k) = it.next() {
        let mut v = || it.next().unwrap_or_else(|| panic!("{k} needs a value"));
        match k.as_str() {
            "--dist" => a.dist = PathBuf::from(v()),
            "--chrome" => a.chrome = Some(PathBuf::from(v())),
            "--scenario" => a.scenarios.push(v()),
            "--headless" => a.headless = true,
            "--render-node" => a.render_node = Some(v()),
            "--out" => a.out = Some(PathBuf::from(v())),
            "--timeout" => a.timeout_s = v().parse().expect("--timeout <s>"),
            "--drag-ms" => a.drag_ms = v().parse().expect("--drag-ms <ms>"),
            "--port" => a.port = v().parse().expect("--port <n>"),
            "--scratch" => a.scratch = PathBuf::from(v()),
            "--no-gpu-flags" => a.no_gpu_flags = true,
            other => panic!("unknown argument {other}"),
        }
    }
    if a.scenarios.is_empty() {
        a.scenarios = vec!["Graphs:community:100000".into(), "Universe:crates:100000".into()];
    }
    a
}

/// The CPU package temperature (°C) from the `k10temp`/`coretemp` hwmon, read from
/// `/sys` (no `sensors` binary). `None` where there is none.
fn package_temp_c() -> Option<f64> {
    for e in std::fs::read_dir("/sys/class/hwmon").ok()?.flatten() {
        let name = std::fs::read_to_string(e.path().join("name")).unwrap_or_default();
        if matches!(name.trim(), "k10temp" | "coretemp" | "zenpower") {
            let t = std::fs::read_to_string(e.path().join("temp1_input")).ok()?;
            return t.trim().parse::<f64>().ok().map(|m| m / 1000.0);
        }
    }
    None
}

/// The driver: one iframe per scenario, read back through `__facett_state()`.
const PAGE: &str = r#"<!doctype html><html><head><meta charset="utf-8"><title>demo pages probe</title>
<style>html,body{margin:0;background:#0b0d10}iframe{width:1600px;height:1000px;border:0}</style></head>
<body><iframe id="f"></iframe>
<script type="module">
const q = new URLSearchParams(location.search);
const scen = JSON.parse(q.get("s"));
const dragMs = +(q.get("drag_ms") || 4000);
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));
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))];
  return { n: xs.length, mean: xs.reduce((a, b) => a + b, 0) / xs.length, p50: pick(0.5), p95: pick(0.95), max: s[s.length - 1] };
}
const fr = document.getElementById("f");
function state() {
  try { const w = fr.contentWindow; const s = w && w.__facett_state && w.__facett_state(); return s ? JSON.parse(s) : null; } catch (_) { return null; }
}
async function until(pred, ms, what) {
  const t = performance.now(); let next = 15000;
  while (performance.now() - t < ms) {
    const v = pred(); if (v) return v; await sleep(100);
    if (performance.now() - t > next) { log("waiting " + (what || "") + " " + Math.round((performance.now() - t) / 1000) + " s"); next += 15000; }
  }
  return null;
}
(async () => {
  const out = { ua: navigator.userAgent, runs: [] };
  try {
    let a = null;
    for (let k = 0; k < 4 && !a && navigator.gpu; k++) a = await navigator.gpu.requestAdapter();
    out.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) { out.gpu_probe = "requestAdapter threw: " + e; }
  for (const sc of scen) {
    const R = { scenario: sc };
    const [tab, src, size] = sc.split(":");
    try {
      const t0 = performance.now();
      fr.src = "index.html?gen=" + src + ":" + size + ":1#" + encodeURIComponent(tab);
      // A page with no GPU publishes `{"gpu":"no GPU","gpu_reason":…}` and shows the
      // reason in #facett_no_gpu (a software adapter included) — recorded, never waited out.
      const st0 = await until(() => { const s = state(); return s && ((s.deck && s.deck.facets) || s.gpu === "no GPU") ? s : null; }, 180000, "first state");
      if (!st0) throw new Error("no state within 180 s");
      if (st0.gpu === "no GPU") {
        const d = fr.contentDocument && fr.contentDocument.getElementById("facett_no_gpu");
        R.no_gpu = { gpu_reason: st0.gpu_reason, adapter_attempts: st0.adapter_attempts, page_text: d ? d.textContent : null };
        throw new Error("no GPU: " + st0.gpu_reason);
      }
      R.first_state_ms = performance.now() - t0;
      R.active = st0.deck.active;
      const f = () => { const s = state(); return s && s.deck.facets[tab]; };
      // Universe reports its own lane + frames; a GenTab reports them under `gen`.
      const drawn = await until(() => {
        const x = f(); if (!x) return null;
        const g = x.gen || {};
        const frames = tab === "Universe" ? x.frames : g.frames;
        if (tab === "Universe" && x.lane === "no GPU") return x;
        return frames >= 3 ? x : null;
      }, 180000, "3 frames");
      if (!drawn) throw new Error("tab never drew 3 frames");
      R.drawn_ms = performance.now() - t0;
      // Settle: the navigator's layout lane, when it has one (compact state refreshes ≤ 2 s).
      const settled = await until(() => { const x = f(); const c = x && x.canvas; return (!c || c.layout_lane === undefined || (c.layout_lane && !c.layout_pending)) ? x : null; }, 180000, "layout settle");
      R.layout_settled_wall_ms = performance.now() - t0;
      await sleep(1500);
      const doc = fr.contentDocument;
      const cv = doc.getElementById("facett_demo");
      const r = cv.getBoundingClientRect();
      let x = r.left + r.width * 0.35, y = r.top + r.height * 0.6;
      const before = f();
      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 += 3; if (x > r.left + r.width * 0.65) x = r.left + r.width * 0.35;
        cv.dispatchEvent(new PointerEvent("pointermove", opt(x, y, 1)));
        cv.dispatchEvent(new MouseEvent("mousemove", opt(x, y, 1)));
        await new Promise((res) => fr.contentWindow.requestAnimationFrame(res));
        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 after = f();
      const fb = tab === "Universe" ? before.frames : before.gen.frames;
      const fa = tab === "Universe" ? after.frames : after.gen.frames;
      R.drag = { wall_ms: wall, raf: stats(iv), app_frames: fa - fb, app_fps: (fa - fb) / (wall / 1000) };
      const g = after.gen || {};
      R.tab = tab === "Universe"
        ? { lane: after.lane, gpu_cloud: after.gpu_cloud, nodes: after.nodes, edges: after.edges, placement: after.placement,
            first_frame_ms: after.first_frame_ms, settle_ms: after.settle_ms, fps: after.fps, gen_ms: g.gen_ms, build_ms: g.build_ms }
        : { nodes: g.nodes, edges: g.edges, gen_ms: g.gen_ms, build_ms: g.build_ms, first_frame_ms: g.first_frame_ms, settle_ms: g.settle_ms, fps: g.fps,
            layout_lane: after.canvas && after.canvas.layout_lane, layout_pending: after.canvas && after.canvas.layout_pending,
            layout_fallback: after.canvas && after.canvas.layout_fallback, legibility: after.legibility };
      R.summary = after;
      try { await new Promise((res) => fr.contentWindow.requestAnimationFrame(() => { fr.contentWindow.requestAnimationFrame(() => { post("/shot", { scenario: sc, png: cv.toDataURL("image/png") }); res(); }); })); } catch (e) { log("shot failed " + e); }
      { const s = state(); if (s) { R.menu = s.deck.menu; R.shell = s.shell; R.show_about = s.screen && s.screen.show_about; R.screen_keys = s.screen ? Object.keys(s.screen) : null; R.vp = { iw: fr.contentWindow.innerWidth, ih: fr.contentWindow.innerHeight, dpr: fr.contentWindow.devicePixelRatio, cw: fr.contentDocument.getElementById("facett_demo").width, ch: fr.contentDocument.getElementById("facett_demo").height }; } }
      R.ok = true;
    } catch (e) { R.ok = false; R.error = String(e && e.stack || e); }
    log(JSON.stringify(R));
    out.runs.push(R);
  }
  if (performance.memory) out.js_heap_used_bytes = performance.memory.usedJSHeapSize;
  await post("/result", out);
})();
</script></body></html>"#;

static SHOTS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);

struct Shared {
    result: Mutex<Option<String>>,
    cv: Condvar,
}

fn content_type(path: &str) -> &'static str {
    match Path::new(path).extension().and_then(|e| e.to_str()) {
        Some("wasm") => "application/wasm",
        Some("js") => "text/javascript",
        Some("json") => "application/json",
        Some("html") => "text/html; charset=utf-8",
        Some("png") => "image/png",
        Some("svg") => "image/svg+xml",
        _ => "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, 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;
        }
        if let Some(v) = h.to_ascii_lowercase().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("/");
    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", "/shot") => {
            let n = SHOTS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
            let _ = std::fs::write(format!("shot-{n}.json"), &body);
            respond(&mut stream, "200 OK", "text/plain", b"ok");
        }
        ("POST", "/log") => {
            eprintln!("  page: {}", String::from_utf8_lossy(&body));
            respond(&mut stream, "200 OK", "text/plain", b"ok");
        }
        ("GET", "/probe.html") => respond(&mut stream, "200 OK", content_type("p.html"), PAGE.as_bytes()),
        ("GET", p) => {
            let name = p.trim_start_matches('/');
            let name = if name.is_empty() { "index.html" } else { name };
            if name.contains("..") {
                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();
    }
}

fn main() {
    let a = args();
    for f in ["index.html", "facett-demo.js", "facett-demo_bg.wasm"] {
        assert!(a.dist.join(f).is_file(), "{} is missing — build the pages bundle first (`cargo ops demo-wasm`)", a.dist.join(f).display());
    }
    let chrome = a.chrome.clone().or_else(find_chrome).expect("no Chrome on PATH (or $CHROME / --chrome)");
    let listener = TcpListener::bind(("127.0.0.1", a.port)).expect("bind 127.0.0.1");
    let port = listener.local_addr().unwrap().port();
    let shared = Arc::new(Shared { result: Mutex::new(None), cv: Condvar::new() });
    {
        let (dist, shared) = (a.dist.clone(), shared.clone());
        std::thread::spawn(move || {
            for s in listener.incoming().flatten() {
                let (dist, shared) = (dist.clone(), shared.clone());
                std::thread::spawn(move || serve_one(s, &dist, &shared));
            }
        });
    }
    let (load_before, temp_before) = (load_average(), package_temp_c());
    let profile = a.scratch.join(format!("chrome-demo-probe-{}-{port}", std::process::id()));
    let _ = std::fs::create_dir_all(&profile);
    let scen = serde_json::to_string(&a.scenarios).unwrap();
    let url = format!(
        "http://127.0.0.1:{port}/probe.html?drag_ms={}&s={}",
        a.drag_ms,
        scen.bytes().map(|b| if b.is_ascii_alphanumeric() { (b as char).to_string() } else { format!("%{b:02X}") }).collect::<String>()
    );
    let mut cargs: 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=1700,1100".into(),
        "--password-store=basic".into(),
        "--disable-extensions".into(),
        "--disable-sync".into(),
        "--disable-component-update".into(),
        "--enable-unsafe-webgpu".into(),
        // The page's console (a wasm panic, a WebGPU validation error) into the log file.
        "--enable-logging=stderr".into(),
        "--v=0".into(),
    ];
    assert!(a.headless, "only --headless is supported by this probe (the owner's laptop is never given a window)");
    if a.no_gpu_flags {
        cargs.push("--headless=new".into());
    } else {
        cargs.extend(headless_gpu_args_on(a.render_node.as_deref()).expect("headless GPU flags"));
    }
    cargs.push(url);
    let log = std::fs::File::create(a.scratch.join(format!("chrome-demo-probe-{port}.log"))).ok();
    let child = Command::new(&chrome)
        .args(&cargs)
        .stdout(Stdio::null())
        .stderr(log.map_or_else(Stdio::null, Stdio::from))
        .spawn()
        .unwrap_or_else(|e| panic!("spawn {}: {e}", chrome.display()));
    let guard = KillOnDrop(child);
    let started = Instant::now();
    let timeout = Duration::from_secs(a.timeout_s);
    // HEAT GUARD (the t14s rule: stop above 95 °C Tctl): checked every
    // second while the page runs; Chrome is killed by the guard's drop on the way out.
    let mut got = shared.result.lock().unwrap();
    while got.is_none() {
        let left = timeout.saturating_sub(started.elapsed());
        assert!(!left.is_zero(), "the probe page did not report within {timeout:?}");
        got = shared.cv.wait_timeout(got, left.min(Duration::from_secs(1))).unwrap().0;
        if let Some(t) = package_temp_c()
            && t > 95.0
        {
            drop(got);
            drop(guard);
            eprintln!("STOPPED: CPU package at {t:.1} °C (> 95) — Chrome killed, no numbers from this run");
            std::process::exit(3);
        }
    }
    let text = got.take().unwrap_or_default();
    drop(got);
    drop(guard);
    let _ = std::fs::remove_dir_all(&profile);
    let mut v: serde_json::Value = serde_json::from_str(&text).expect("page result is JSON");
    if let Some(o) = v.as_object_mut() {
        o.insert("load_before".into(), load_before.into());
        o.insert("load_after".into(), load_average().into());
        o.insert("temp_c_before".into(), temp_before.into());
        o.insert("temp_c_after".into(), package_temp_c().into());
        o.insert("wall_s".into(), started.elapsed().as_secs_f64().into());
        o.insert("mode".into(), "headless".into());
    }
    let pretty = serde_json::to_string_pretty(&v).unwrap();
    println!("{pretty}");
    if let Some(p) = &a.out {
        std::fs::write(p, &pretty).expect("write --out");
    }
    if let Some(why) = software_adapter(&v) {
        eprintln!("FAIL: the browser's WebGPU adapter is not a GPU ({why})");
        std::process::exit(2);
    }
    let bad: Vec<String> = v["runs"]
        .as_array()
        .into_iter()
        .flatten()
        .filter(|r| r["ok"] != true || r["tab"]["lane"].as_str().is_some_and(|l| l != "gpu"))
        .map(|r| format!("{} {}", r["scenario"], r["error"].as_str().unwrap_or(r["tab"]["lane"].as_str().unwrap_or(""))))
        .collect();
    if !bad.is_empty() {
        eprintln!("FAIL: {bad:?}");
        std::process::exit(1);
    }
}