xtask/tests/wgsl_compiles_in_the_browser.rs
//! **Every WGSL shader in the tree must compile in a BROWSER's validator, not only in naga.**
//!
//! # The defect this guards (measured 2026-09-18, loki, Chrome 140 on the built wheel)
//!
//! `facett-map3d/src/water.wgsl` sampled its reflection texture inside an `if`:
//!
//! ```text
//! if (ruv.x >= 0.0 && ruv.x <= 1.0 && …) { refl_col = textureSample(refl_tex, refl_smp, ruv).rgb; }
//! ```
//!
//! WGSL requires `textureSample` (implicit LOD → derivatives) to sit in UNIFORM control
//! flow. naga — the validator every native `wgpu` device and every native test in this
//! workspace runs — accepts that shader, with `ValidationFlags::all()`, today (checked
//! against naga 29.0.4 the day this file was written). Chrome's validator, Tint, rejects
//! the whole module:
//!
//! ```text
//! 135:24 'textureSample' must only be called from uniform control flow
//! ```
//!
//! So `map3d_water_pipeline` was INVALID in every browser — water did not draw, and the
//! only sign was a line in the browser console — while 100 % of the native proof stayed
//! green. Two validators, two verdicts, and only one of them ships to users.
//!
//! # What this test does
//!
//! It walks every `*.wgsl` under the workspace (skipping `target/`), inlines them into
//! one page, serves that page from a `TcpListener` on localhost, and runs headless Chrome
//! against it. The page asks the WebGPU adapter for a device, calls
//! `createShaderModule` + `getCompilationInfo()` on each source, and POSTs the verdicts
//! back. A shader that fails standalone is retried composed behind
//! `facett-core/src/render/gpu/common.wgsl`, because that is how `facett_core::render::wgsl`
//! builds the SDF / line / pick / OIT / draw modules — the map's `line.wgsl` is one of
//! those and calls `coverage_inside` from the prelude.
//!
//! Headless is enough HERE and not for the render robot: shader validation needs no
//! canvas and no readback, which are the two things headless cannot do (row 13 of
//! `facet-wrapped-wheel.md`).
//!
//! # The flags are browser_bench's, not a third list (2026-09-29, oden RTX 4090)
//!
//! This file used to spell its own `--headless=new --enable-unsafe-webgpu
//! --enable-features=Vulkan`, and on oden's RTX 4090 that Chrome answered `NOADAPTER`:
//! headless Chrome reaches no adapter without `--enable-gpu --use-angle=vulkan
//! --ignore-gpu-blocklist` and the render node. facett keeps ONE copy of the headless GPU
//! flag set — `facet_wrapped::browser_bench::headless_gpu_args` (the browser bench and
//! `examples/gallery_probe.rs --headless` take it too, daddd0a) — and this test takes it
//! from there. It then refuses a software adapter (`browser_bench::software_adapter`): a
//! SwiftShader verdict is not what a user's GPU process runs, and a flag set that silently
//! falls back to it is the drift this guard would otherwise hide.
//!
//! A fresh headless Chrome's GPU process is not ready for the first `requestAdapter()`
//! on the 4090 (it answered null on attempt 1, the NVIDIA adapter on attempt 2), so the
//! page asks with facet-wrapped's own bounded retry policy (`adapter_retry::MAX_ATTEMPTS`
//! / `backoff_ms`, the one a canvas mount uses) and prints `ATTEMPTS n`.
//!
//! # The guard proves it can see
//!
//! Before trusting a green, the page compiles a five-line shader that contains the exact
//! water defect and asserts Tint REJECTS it. A Chrome whose validator let that through
//! would make every other verdict on this page worthless, so that case fails the test
//! rather than passing it.
//!
//! # No browser → a recorded skip, never a pass
//!
//! Same rule as `facett_core::testmatrix::gpu_skip`: with no Chrome on the box the test
//! prints a SKIPPED line and returns — and under `CI` or `FACETT_REQUIRE_GPU` it FAILS,
//! because a host that is supposed to be able to run this and cannot is the defect this
//! file exists to catch.
use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().to_path_buf()
}
/// Every `.wgsl` under the workspace, repo-relative, sorted. `target/` is skipped.
fn shader_files() -> Vec<String> {
fn walk(dir: &Path, root: &Path, out: &mut Vec<String>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
if p.is_dir() {
if name == "target" || name == "node_modules" || name.starts_with('.') {
continue;
}
walk(&p, root, out);
} else if name.ends_with(".wgsl") {
out.push(p.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"));
}
}
}
let mut v = Vec::new();
walk(&root(), &root(), &mut v);
v.sort();
v
}
/// Fragments are checked in their declared compositions, not as standalone
/// shaders. Paths in a manifest are relative to the manifest's directory.
fn shader_sources() -> Vec<(String, String)> {
use std::collections::{BTreeMap, BTreeSet};
let files = shader_files();
let mut manifests = BTreeSet::new();
for file in &files {
let mut directory = root().join(file).parent().unwrap().to_path_buf();
while directory.starts_with(root()) {
let manifest = directory.join("wgsl-compositions.txt");
if manifest.is_file() { manifests.insert(manifest); }
if !directory.pop() { break; }
}
}
let mut fragments = BTreeSet::new();
let mut sources = BTreeMap::new();
for manifest in manifests {
let directory = manifest.parent().unwrap();
let contents = std::fs::read_to_string(&manifest).expect("read composition manifest");
for line in contents.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
let (name, paths) = line.split_once(':').expect("composition is name: paths");
assert!(!name.trim().is_empty(), "empty composition name in {}", manifest.display());
let mut source = String::new();
for path in paths.split_whitespace() {
let file = directory.join(path).canonicalize().expect("composition fragment exists");
assert!(file.starts_with(root().canonicalize().unwrap()), "fragment leaves workspace");
fragments.insert(file.clone());
source.push_str(&std::fs::read_to_string(file).expect("read composition fragment"));
source.push('\n');
}
assert!(!source.is_empty(), "empty composition in {}", manifest.display());
let key = format!("{}:{}", manifest.strip_prefix(root()).unwrap().display(), name.trim());
assert!(sources.insert(key, source).is_none(), "duplicate composition name");
}
}
for file in files {
let path = root().join(&file);
if !fragments.contains(&path.canonicalize().unwrap()) {
sources.insert(file, std::fs::read_to_string(path).expect("read standalone shader"));
}
}
sources.into_iter().collect()
}
/// The water defect, reduced to five lines. Tint MUST reject it.
const MUST_FAIL: &str = r#"
@group(0) @binding(0) var t: texture_2d<f32>;
@group(0) @binding(1) var s: sampler;
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
var c = vec4<f32>(0.0);
if (p.x > 10.0) { c = textureSample(t, s, p.xy); }
return c;
}
"#;
const COMMON: &str = "facett-core/src/render/gpu/common.wgsl";
fn page(files: &[(String, String)]) -> String {
let mut srcs = serde_json::Map::new();
for (name, source) in files {
srcs.insert(name.clone(), serde_json::Value::String(source.clone()));
}
let common = std::fs::read_to_string(root().join(COMMON)).unwrap_or_default();
format!(
r#"<!doctype html><html><head><meta charset=utf-8><title>tint</title></head><body><pre id=out></pre>
<script>
const SRCS = {srcs}; const COMMON = {common}; const MUST_FAIL = {must_fail}; const BACKOFF = {backoff};
const lines = [];
async function errs(dev, code) {{
dev.pushErrorScope('validation');
const m = dev.createShaderModule({{code}});
const info = await m.getCompilationInfo();
await dev.popErrorScope();
return info.messages.filter(x => x.type === 'error').map(x => `${{x.lineNum}}:${{x.linePos}} ${{x.message}}`);
}}
(async () => {{
try {{
let adapter = null, attempts = 0;
for (const wait of [0, ...BACKOFF]) {{
if (wait) await new Promise(r => setTimeout(r, wait));
attempts++;
adapter = await navigator.gpu.requestAdapter();
if (adapter) break;
}}
lines.push(`ATTEMPTS ${{attempts}}`);
if (!adapter) {{ lines.push('NOADAPTER'); return; }}
const dev = await adapter.requestDevice();
const ai = adapter.info || {{}};
lines.push(`ADAPTER ${{JSON.stringify({{vendor: ai.vendor||"", architecture: ai.architecture||"", device: ai.device||"", description: ai.description||""}})}}`);
const neg = await errs(dev, MUST_FAIL);
lines.push(neg.length ? `NEGATIVE-OK ${{neg[0]}}` : 'NEGATIVE-BLIND the validator accepted textureSample in non-uniform control flow');
const badComposition = 'struct CompositionInput {{ value: f32 }}\n' +
'@compute @workgroup_size(1) fn composition_probe() {{ let x = missing_fragment_function(); }}';
const compositionNeg = await errs(dev, badComposition);
lines.push(compositionNeg.length ? 'COMPOSITION-NEGATIVE-OK' : 'COMPOSITION-NEGATIVE-BLIND');
for (const [path, code] of Object.entries(SRCS)) {{
let e = await errs(dev, code);
let how = 'standalone';
if (e.length && COMMON && !path.includes('wgsl-compositions.txt:') && !path.endsWith('common.wgsl')) {{
const e2 = await errs(dev, COMMON + '\n' + code);
if (!e2.length) {{ e = []; how = 'composed-with-common'; }}
else e = e.concat(e2.map(x => 'composed: ' + x));
}}
lines.push(e.length ? `FAIL ${{path}}\n ` + e.join('\n ') : `OK ${{path}} (${{how}})`);
}}
}} catch (e) {{ lines.push('EXC ' + e); }}
finally {{
document.getElementById('out').textContent = lines.join('\n');
await fetch('/report', {{method:'POST', body: lines.join('\n')}}).catch(()=>{{}});
}}
}})();
</script><img src="/slow"></body></html>"#,
srcs = serde_json::Value::Object(srcs),
common = serde_json::Value::String(common),
must_fail = serde_json::Value::String(MUST_FAIL.to_string()),
backoff = serde_json::json!((2..=facet_wrapped::adapter_retry::MAX_ATTEMPTS).map(facet_wrapped::adapter_retry::backoff_ms).collect::<Vec<_>>()),
)
}
fn find_chrome() -> Option<PathBuf> {
if let Ok(c) = std::env::var("CHROME") {
return Some(PathBuf::from(c));
}
let path = std::env::var_os("PATH")?;
for name in ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "chrome"] {
for dir in std::env::split_paths(&path) {
let p = dir.join(name);
if p.is_file() {
return Some(p);
}
}
}
None
}
/// Serve `html` at `/`, hold `/slow` open until the page POSTs `/report` (which is what
/// keeps the load event — and therefore `--dump-dom`'s exit — from firing before the
/// async GPU work is done), and hand back the report.
fn serve_and_run(html: String, chrome: &Path, gpu_args: &[String]) -> Result<String, String> {
let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?;
let port = listener.local_addr().unwrap().port();
let state: Arc<(Mutex<Option<String>>, Condvar)> = Arc::new((Mutex::new(None), Condvar::new()));
let html = Arc::new(html);
{
let state = Arc::clone(&state);
std::thread::spawn(move || {
for stream in listener.incoming().flatten() {
let state = Arc::clone(&state);
let html = Arc::clone(&html);
std::thread::spawn(move || handle(stream, &html, &state));
}
});
}
let ud = root().join("target").join(format!("tint-chrome-{}", std::process::id()));
let _ = std::fs::create_dir_all(&ud);
let out = Command::new(chrome)
// The GPU gate runs every binary with `VK_LOADER_DRIVERS_SELECT=*nvidia*` so wgpu
// picks the discrete card; headless Chrome under that filter dies in
// `vkCreateInstance() failed: -7` (EXTENSION_NOT_PRESENT — the headless surface
// extension comes from a Mesa ICD the filter hid) and falls back to SwiftShader
// (MEASURED oden RTX 4090, 2026-09-29). Chrome picks its GPU by `--render-node-override`.
.env_remove("VK_LOADER_DRIVERS_SELECT")
.args(gpu_args)
.args(["--no-sandbox", "--disable-gpu-sandbox", "--enable-unsafe-webgpu", "--no-first-run", "--timeout=100000", "--dump-dom"])
.arg(format!("--user-data-dir={}", ud.display()))
// Diagnosis only, like browser_bench's FACETT_BENCH_CHROME_ARGS:
// `FACETT_TINT_CHROME_ARGS="--enable-logging=stderr --v=1"` also prints Chrome's stderr.
.args(std::env::var("FACETT_TINT_CHROME_ARGS").unwrap_or_default().split_whitespace())
.arg(format!("http://127.0.0.1:{port}/"))
.output()
.map_err(|e| format!("spawn {}: {e}", chrome.display()))?;
let _ = std::fs::remove_dir_all(&ud);
if std::env::var_os("FACETT_TINT_CHROME_ARGS").is_some() {
eprintln!("── chrome stderr ──\n{}", String::from_utf8_lossy(&out.stderr));
}
let (lock, _) = &*state;
let report = lock.lock().unwrap().clone();
report.ok_or_else(|| {
format!(
"the page never reported (chrome exit {:?}); stderr tail:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).chars().rev().take(1500).collect::<String>().chars().rev().collect::<String>()
)
})
}
fn handle(mut stream: std::net::TcpStream, html: &str, state: &(Mutex<Option<String>>, Condvar)) {
let _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
let mut buf = Vec::new();
let mut tmp = [0u8; 4096];
let (head_end, mut n_read) = loop {
let Ok(n) = stream.read(&mut tmp) else { return };
if n == 0 {
return;
}
buf.extend_from_slice(&tmp[..n]);
if let Some(i) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break (i + 4, buf.len());
}
};
let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
let line = head.lines().next().unwrap_or("").to_string();
let (lock, cv) = state;
if line.starts_with("POST /report") {
let len: usize = head
.lines()
.find_map(|l| l.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap_or(0)))
.unwrap_or(0);
while n_read - head_end < len {
let Ok(n) = stream.read(&mut tmp) else { break };
if n == 0 {
break;
}
buf.extend_from_slice(&tmp[..n]);
n_read = buf.len();
}
*lock.lock().unwrap() = Some(String::from_utf8_lossy(&buf[head_end..]).to_string());
cv.notify_all();
let _ = stream.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
} else if line.starts_with("GET /slow") {
let g = lock.lock().unwrap();
let _ = cv.wait_timeout_while(g, Duration::from_secs(90), |r| r.is_none());
let _ = stream.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
} else {
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
);
}
let _ = stream.flush();
}
#[test]
fn every_wgsl_in_the_tree_compiles_in_chrome() {
let Some(chrome) = find_chrome() else {
eprintln!(
"[browser-gate] xtask::wgsl_compiles_in_the_browser: SKIPPED — no Chrome/Chromium on PATH \
(set CHROME=/path/to/chrome). A skip is not a pass: naga accepts shaders Tint rejects."
);
assert!(
std::env::var("CI").is_err() && std::env::var("FACETT_REQUIRE_GPU").is_err(),
"CI / FACETT_REQUIRE_GPU says this host must be able to validate shaders in a browser, \
and it has no browser."
);
return;
};
let files = shader_sources();
assert!(files.len() >= 20, "expected the workspace's ~30 shaders, found {}: {files:?}", files.len());
// THE one headless GPU flag set (browser_bench); a box with no render node is a
// recorded skip like a box with no Chrome, and a failure where a GPU is required.
let gpu_args = match facet_wrapped::browser_bench::headless_gpu_args() {
Ok(a) => a,
Err(e) => {
eprintln!("[browser-gate] xtask::wgsl_compiles_in_the_browser: SKIPPED — {e}. A skip is not a pass.");
assert!(
std::env::var("CI").is_err() && std::env::var("FACETT_REQUIRE_GPU").is_err(),
"CI / FACETT_REQUIRE_GPU says this host must validate shaders on a GPU, and {e}"
);
return;
}
};
let report = serve_and_run(page(&files), &chrome, &gpu_args).unwrap_or_else(|e| panic!("{e}"));
eprintln!("{report}");
std::fs::write(root().join("shader-verdict.txt"), &report).unwrap();
assert!(
!report.contains("NOADAPTER"),
"headless Chrome offered no WebGPU adapter with browser_bench's headless flag set {gpu_args:?}\n{report}"
);
let adapter: serde_json::Value = report
.lines()
.find_map(|l| l.strip_prefix("ADAPTER "))
.and_then(|j| serde_json::from_str(j).ok())
.unwrap_or_else(|| panic!("the page named no adapter:\n{report}"));
if let Some(why) = facet_wrapped::browser_bench::software_adapter(&serde_json::json!({ "gpu_probe": adapter })) {
panic!("headless Chrome fell back to a SOFTWARE adapter ({why}), not the box's GPU: {adapter}\n{report}");
}
assert!(!report.contains("EXC "), "the page threw:\n{report}");
assert!(
report.lines().any(|line| line.starts_with("NEGATIVE-OK ")),
"the validator ACCEPTED the reduced water defect, so nothing on this page can be trusted:\n{report}"
);
assert!(report.contains("COMPOSITION-NEGATIVE-OK"), "broken shader composition was accepted:\n{report}");
let seen = report.lines().filter(|l| l.starts_with("OK ") || l.starts_with("FAIL ")).count();
assert_eq!(seen, files.len(), "every shader must get a verdict\n{report}");
let fails: Vec<&str> = report.lines().filter(|l| l.starts_with("FAIL ")).collect();
assert!(
fails.is_empty(),
"{} shader(s) are REJECTED by the browser's validator (naga would have said nothing):\n{report}",
fails.len()
);
}