facett-core/src/adapter_retry.rs
//! **The bounded adapter retry of a canvas mount** (lane A, 2026-09-29).
//!
//! `gallery_probe` (headful Chrome on loki, 2026-09-28 20:45) saw `osm2d` fail with
//! `WebGPU renderer failed to start: No suitable graphics adapter found` while `osm3d`
//! on the same page got the RTX. That text is wgpu's `RequestAdapterError::NotFound`
//! from `WebRunner::start`: `navigator.gpu.requestAdapter()` answered null for that one
//! iframe, before any lane existed. `osm2d` was the FIRST mount of the run, and a fresh
//! Chrome's GPU process is not always ready for the first request.
//!
//! So a mount that is refused an adapter asks again, a few times, with a short backoff —
//! and says so: the attempt count is in the component state (`adapter_attempts`), and a
//! final refusal still reads **"no GPU"** plainly. Only `NotFound` is retried: a device
//! that was found and then failed is a different defect and is reported at once.
//!
//! Host-compilable on purpose (the wasm halves are facet-wrapped `wasm::start` and
//! facett-demo `wasm_start`), so the policy is
//! unit-tested on a box with no GPU and no browser.
use std::sync::atomic::{AtomicU32, Ordering};
/// Attempts in all (the first one included).
pub const MAX_ATTEMPTS: u32 = 4;
/// Wait before attempt `next` (2-based: the wait after attempt 1 is `backoff_ms(2)`).
/// 250, 500, 1000 ms — about 1.75 s in all, short beside a page load.
#[must_use]
pub fn backoff_ms(next: u32) -> u32 {
250 << next.saturating_sub(2).min(4)
}
/// Is this start error "the browser gave no adapter" (the retryable one)?
#[must_use]
pub fn is_no_adapter(err: &str) -> bool {
err.contains("No suitable graphics adapter") || err.contains("NotFound")
}
/// Why a page cannot even ASK for an adapter — decided before any request, never retried.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Preflight {
/// `navigator.gpu` is missing: WebGPU is off in this browser.
NoWebGpu,
/// The page is not a secure context (WebGPU exists only on https / localhost).
NotSecure,
}
/// The pre-flight verdict from the two facts a page can read synchronously.
/// `None` = the page may ask for an adapter.
#[must_use]
pub fn preflight(has_navigator_gpu: bool, secure_context: bool) -> Option<Preflight> {
if !secure_context {
Some(Preflight::NotSecure)
} else if !has_navigator_gpu {
Some(Preflight::NoWebGpu)
} else {
None
}
}
/// The plain message for a pre-flight refusal (`facet_error()` and `gpu_reason`).
#[must_use]
pub fn preflight_message(p: Preflight) -> &'static str {
match p {
Preflight::NoWebGpu => {
"no GPU: this browser exposes no WebGPU (navigator.gpu missing) — Linux Chrome needs \
chrome://flags/#enable-unsafe-webgpu and #enable-vulkan"
}
Preflight::NotSecure => {
"no GPU: this page is not a secure context, so the browser hides WebGPU — open it via \
localhost or https"
}
}
}
/// **A software adapter is not a GPU** (owner ruling 2026-09-28, restated 2026-10-02).
///
/// Lane L (loki, 2026-10-02): with `VK_LOADER_DRIVERS_SELECT='*nvidia*'` Chrome's
/// Vulkan instance fails and WebGPU falls back to SwiftShader; a plain headless Chrome
/// without `--enable-gpu` does the same. `requestAdapter()` then answers — a CPU
/// rasteriser — and the demo installed its GPU lanes on it and PANICKED in the first
/// resident upload (`createBuffer` of 1.6 MB with `mappedAtCreation`, "too large for
/// the implementation") instead of saying "no GPU".
///
/// So a page reads the adapter's own `info` BEFORE any lane is installed and refuses a
/// software one here. `Some(what)` names it (the composed adapter name, or "fallback
/// adapter" when the browser flags it so and masks the rest); `None` = a hardware
/// adapter, or one the browser masks entirely (that is not evidence of software).
/// The words are [`crate::render::adapter::is_software_rasteriser`]'s — the same
/// predicate the native selector ranks with and the probes refuse runs with.
#[must_use]
pub fn software_adapter(
vendor: &str,
architecture: &str,
device: &str,
description: &str,
is_fallback: bool,
) -> Option<String> {
use crate::render::adapter::{facts_from_browser_info, is_software_rasteriser};
let facts = facts_from_browser_info(vendor, architecture, device, description, "BrowserWebGpu");
if is_software_rasteriser(&facts) {
return Some(facts.name);
}
is_fallback.then(|| if facts.name.is_empty() { "fallback adapter".to_string() } else { format!("fallback adapter {}", facts.name) })
}
/// The plain refusal for a software adapter (`facet_error()`, `gpu_reason`, the page).
#[must_use]
pub fn software_adapter_message(what: &str) -> String {
format!(
"no GPU: software adapter (SwiftShader/llvmpipe) — the browser's only WebGPU adapter is a CPU \
rasteriser ({what}), and facett does not draw on one. Linux Chrome reaches the card with \
--enable-gpu --enable-features=Vulkan --use-angle=vulkan (headless: also \
--render-node-override=/dev/dri/renderD*), and VK_LOADER_DRIVERS_SELECT must not exclude \
the card's driver"
)
}
/// The state a page publishes when it has no GPU (`window.__facett_state()` in the
/// demo): `{"gpu": "no GPU", "gpu_reason": msg, "adapter_attempts": n}`.
#[must_use]
pub fn no_gpu_state(msg: &str) -> serde_json::Value {
serde_json::json!({ "gpu": "no GPU", "gpu_reason": msg, "adapter_attempts": attempts() })
}
/// What to do after attempt `attempt` failed with `err`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Next {
/// Wait this many ms, then try again.
RetryAfter(u32),
/// Give up with this message for `facet_error()`.
Fail(String),
}
/// The decision after a failed start.
#[must_use]
pub fn after_failure(attempt: u32, err: &str) -> Next {
if is_no_adapter(err) {
if attempt < MAX_ATTEMPTS {
Next::RetryAfter(backoff_ms(attempt + 1))
} else {
Next::Fail(format!(
"no GPU: WebGPU is exposed but gave this page no adapter after {attempt} attempts \
(requestAdapter() returned null) — Linux Chrome needs Vulkan: \
--enable-features=Vulkan --use-angle=vulkan, or chrome://flags/#enable-vulkan \
(last error: {err})"
))
}
} else {
Next::Fail(format!("WebGPU renderer failed to start: {err}"))
}
}
/// Why the last mount has no GPU, in plain words (`""` when it has one).
static REASON: std::sync::Mutex<String> = std::sync::Mutex::new(String::new());
/// Record why this mount has no GPU (`""` clears it).
pub fn record_reason(why: &str) {
if let Ok(mut r) = REASON.lock() {
r.clear();
r.push_str(why);
}
}
/// The recorded reason (`gpu_reason` in the component state).
#[must_use]
pub fn reason() -> String {
REASON.lock().map(|r| r.clone()).unwrap_or_default()
}
/// Attempts the LAST mount made (0 before any mount).
static ATTEMPTS: AtomicU32 = AtomicU32::new(0);
/// Record the attempt count of the current mount.
pub fn record_attempts(n: u32) {
ATTEMPTS.store(n, Ordering::Relaxed);
}
/// Attempts the last mount made (`adapter_attempts` in the component state).
#[must_use]
pub fn attempts() -> u32 {
ATTEMPTS.load(Ordering::Relaxed)
}
#[cfg(test)]
mod tests {
use super::*;
const NOT_FOUND: &str = "RequestAdapterError(NotFound { active_backends: BROWSER_WEBGPU })";
#[test]
fn no_adapter_is_retried_with_a_growing_backoff_then_says_no_gpu() {
assert_eq!(after_failure(1, NOT_FOUND), Next::RetryAfter(250));
assert_eq!(after_failure(2, NOT_FOUND), Next::RetryAfter(500));
assert_eq!(after_failure(3, NOT_FOUND), Next::RetryAfter(1000));
match after_failure(MAX_ATTEMPTS, NOT_FOUND) {
Next::Fail(m) => {
assert!(m.starts_with("no GPU"), "the final refusal says no GPU plainly: {m}");
assert!(m.contains("4 attempts"), "{m}");
}
other => panic!("retried past the bound: {other:?}"),
}
}
#[test]
fn the_wgpu_display_text_is_recognised() {
assert!(is_no_adapter("No suitable graphics adapter found; active backends: BROWSER_WEBGPU"));
}
#[test]
fn a_device_failure_is_not_retried() {
let err = "RequestDeviceError(LimitsExceeded)";
assert!(!is_no_adapter(err));
assert_eq!(after_failure(1, err), Next::Fail(format!("WebGPU renderer failed to start: {err}")));
}
#[test]
fn the_whole_wait_stays_short() {
let total: u32 = (2..=MAX_ATTEMPTS).map(backoff_ms).sum();
assert_eq!(total, 1750);
}
#[test]
fn a_browser_without_webgpu_or_off_https_fails_at_once_with_the_cause() {
assert_eq!(preflight(true, true), None, "may ask");
assert_eq!(preflight(false, true), Some(Preflight::NoWebGpu));
assert_eq!(preflight(false, false), Some(Preflight::NotSecure), "insecure hides navigator.gpu");
assert!(preflight_message(Preflight::NoWebGpu).contains("navigator.gpu missing"));
assert!(preflight_message(Preflight::NotSecure).contains("localhost or https"));
for p in [Preflight::NoWebGpu, Preflight::NotSecure] {
assert!(preflight_message(p).starts_with("no GPU"));
}
}
#[test]
fn the_final_null_adapter_message_names_the_vulkan_remedy() {
match after_failure(MAX_ATTEMPTS, NOT_FOUND) {
Next::Fail(m) => assert!(m.contains("--enable-features=Vulkan"), "{m}"),
other => panic!("{other:?}"),
}
}
#[test]
fn the_reason_is_recorded_and_cleared() {
record_reason("no GPU: x");
assert_eq!(reason(), "no GPU: x");
record_reason("");
assert_eq!(reason(), "");
}
#[test]
fn a_software_adapter_is_refused_with_no_gpu_and_a_card_is_not() {
// What Chrome's GPUAdapter.info said on loki under VK_LOADER_DRIVERS_SELECT='*nvidia*'
// and on the t14s without --enable-gpu: vendor google, architecture swiftshader.
let what = software_adapter("google", "swiftshader", "", "", false).expect("SwiftShader is software");
assert!(what.contains("swiftshader"), "{what}");
let msg = software_adapter_message(&what);
assert!(msg.starts_with("no GPU: software adapter (SwiftShader/llvmpipe)"), "{msg}");
assert!(msg.contains("swiftshader"), "the message names what the browser offered: {msg}");
let st = no_gpu_state(&msg);
assert_eq!(st["gpu"], "no GPU");
assert_eq!(st["gpu_reason"], msg.as_str());
// llvmpipe / lavapipe through a description, and a flagged fallback with masked fields.
assert!(software_adapter("mesa", "", "", "llvmpipe (LLVM 21.1.8, 256 bits)", false).is_some());
assert!(software_adapter("mesa", "lavapipe", "", "", false).is_some());
assert_eq!(software_adapter("", "", "", "", true).as_deref(), Some("fallback adapter"));
// Hardware is not refused — nor is a browser that masks every field.
assert_eq!(software_adapter("nvidia", "blackwell", "", "", false), None);
assert_eq!(software_adapter("amd", "rdna-3", "", "", false), None);
assert_eq!(software_adapter("", "", "", "", false), None);
}
#[test]
fn attempts_are_recorded() {
record_attempts(3);
assert_eq!(attempts(), 3);
}
}