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