diff --git a/README.md b/README.md index 7353c6e..09dece6 100644 --- a/README.md +++ b/README.md @@ -753,7 +753,9 @@ driver that imports them, and NV12 capture and VideoToolbox decode surfaces on macOS. A browser source's page is copied on the GPU from the shared texture CEF paints, given the same Vulkan DMA-BUF import. Every other source, every source on Windows or on a GPU that cannot import, and every still image delivers its -frames from the CPU, which the compositor uploads. +frames from the CPU, which the compositor uploads. A browser source on that +path renders its page in software, WebGL included, so a WebGL page runs +slower there. ## Replay diff --git a/quest/m0/README.md b/quest/m0/README.md index 94c8e65..e4c1103 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -103,7 +103,6 @@ repository. - [Virtual camera](/quest/m0/vcam/README.md) - a canvas's program exposed as a system camera on Linux, Windows, and macOS - [Recover crashed recordings](/quest/m0/recording-repair.md) - a recording a crash left unfinished is repaired beside the original at launch - [Premultiplied source sampling](/quest/m0/premultiply.md) - scaled transparent sources draw without edge fringes -- [Deterministic browser source startup](/quest/m0/cef-startup.md) - CEF on the CPU path paints its first frame without the GPU fallback hang - [A browser renderer that dies on its first page](/quest/m0/browser-first-crash.md) - a renderer lost during the first navigation fails its source - [Browser sources without an X display](/quest/m0/cef-headless.md) - CEF runs headless, so Wayland sessions and tests need no X server - [Signing identities are available](/quest/m0/signing.md) - the Apple and Windows code-signing credentials packaging and the macOS camera need diff --git a/quest/m0/cef-headless.md b/quest/m0/cef-headless.md index 28f6cfa..0134084 100644 --- a/quest/m0/cef-headless.md +++ b/quest/m0/cef-headless.md @@ -26,7 +26,3 @@ Check first, and record the result in the PR: Then drop the `xvfb-run` wrapper from the `render-sw` recipe in the `justfile` (the nightly job inherits it through the recipe), and say in `README.md` that browser sources need no X server. - -## Required - -- [Deterministic browser source startup](/quest/m0/cef-startup.md) - the `--disable-gpu` CPU path this builds on diff --git a/quest/m0/cef-startup.md b/quest/m0/cef-startup.md deleted file mode 100644 index 690380b..0000000 --- a/quest/m0/cef-startup.md +++ /dev/null @@ -1,32 +0,0 @@ -# [S] Deterministic browser source startup - -## Goal - -A browser source paints its first frame within a bounded, repeatable time -when its frames come back over the CPU path, and the browser source tests -no longer take 4 to 33 seconds or hit their budget. - -## Plan - -From [moq-dev/studio#8](https://github.com/moq-dev/studio/pull/8). - -On the CPU path (no shared GPU texture, and CI under `just render-sw`), this -build's CEF cannot load GL. Chromium walks its GPU fallback chain, and in the -slow runs a fallback GPU process fails "Failed to create shared context for -virtualization", then hangs until Chromium's GPU watchdog kills it, 4 to 11 -seconds later, sometimes twice. - -Decided 2026-11-04 with the maintainer (/quest-plan): - -- ✅ **Pass `--disable-gpu` on the CPU path.** The frames come back on the - CPU anyway, so software raster gives deterministic startup. WebGL pages - run slower on that path. - Rejected: packaging GL and EGL for CEF (keeps WebGL accelerated, but adds - packaging and driver variance). - -Also check whether CEF starts without an X display under this flag -(`--ozone-platform=headless` started before, but its GPU process failed -EGL), and record the result in the quest's PR. - -Verify by running `a_click_reaches_the_page` many times under -`just render-sw`, and report the startup time's spread before and after. diff --git a/rs/moq-scene/src/browser/host.rs b/rs/moq-scene/src/browser/host.rs index 9c20ec7..c2db188 100644 --- a/rs/moq-scene/src/browser/host.rs +++ b/rs/moq-scene/src/browser/host.rs @@ -13,7 +13,7 @@ use std::sync::{Arc, Mutex, mpsc}; use cef::*; use super::outbox::Commands; -use super::protocol::{Area, Channel, Command, Event, Format, Id, Layer, Open, Plane, Start, Texture}; +use super::protocol::{Area, Channel, Command, Event, Format, Id, Layer, Open, Plane, Raster, Start, Texture}; use super::{Button, Input, Modifiers}; /// Run one of CEF's subprocesses (renderer, GPU, utility, zygote). @@ -101,7 +101,7 @@ fn serve() -> Result<(), String> { let shared = Arc::new(Shared { writer: Mutex::new(writer), painted: Mutex::new(painted), - accelerated: start.accelerated, + accelerated: matches!(start.raster, Raster::Gpu { .. }), }); // The studio shows why, rather than only that the helper exited. browse(&shared, start, reader, painted_tx).inspect_err(|reason| _ = shared.send(&Event::Fatal(reason.clone()), &[])) @@ -112,7 +112,7 @@ fn serve() -> Result<(), String> { fn browse(shared: &Arc, start: Start, reader: Channel, painted_tx: mpsc::Sender<()>) -> Result<(), String> { let _ = api_hash(sys::CEF_API_VERSION_LAST, 0); let args = args::Args::new(); - let mut app = HelperApp::new(HelperProcess::new(), start.render_node); + let mut app = HelperApp::new(HelperProcess::new(), start.raster); let profile = CefString::from(start.profile.as_str()); let settings = Settings { windowless_rendering_enabled: 1, @@ -405,7 +405,7 @@ wrap_task! { wrap_app! { struct HelperApp { process: BrowserProcessHandler, - render_node: Option, + raster: Raster, } impl App { @@ -426,9 +426,23 @@ wrap_app! { Some(&"autoplay-policy".into()), Some(&"no-user-gesture-required".into()), ); - // Paint on the studio's GPU, which is the only one it imports from. - if let Some(node) = &self.render_node { - command_line.append_switch_with_value(Some(&"render-node-override".into()), Some(&node.as_str().into())); + match &self.raster { + // Paint on the studio's GPU, which is the only one it imports + // from. + Raster::Gpu { render_node: Some(node) } => { + command_line.append_switch_with_value(Some(&"render-node-override".into()), Some(&node.as_str().into())); + } + Raster::Gpu { render_node: None } => {} + // The pictures come back in memory anyway. Left to find a GPU, + // Chromium walks its fallbacks, and one can hang until its + // watchdog kills it seconds later, delaying the first paint. + // WebGL then needs SwiftShader, which Chromium runs only when + // told to, since it JITs page shaders in the GPU process. A + // browser source runs the pages its user picked, not the web. + Raster::Cpu => { + command_line.append_switch(Some(&"disable-gpu".into())); + command_line.append_switch(Some(&"enable-unsafe-swiftshader".into())); + } } } } diff --git a/rs/moq-scene/src/browser/import.rs b/rs/moq-scene/src/browser/import.rs index fe6b3d7..0e0f4dc 100644 --- a/rs/moq-scene/src/browser/import.rs +++ b/rs/moq-scene/src/browser/import.rs @@ -2,20 +2,9 @@ use std::os::fd::OwnedFd; -use super::Helper; use super::protocol::Texture; use crate::wgpu; -/// Whether the studio's device can import the helper's shared textures, so -/// the helper should paint into them. -pub(super) fn supported(helper: &Helper) -> bool { - cfg!(feature = "cef") - && helper - .device - .features() - .contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF) -} - /// Copy the DMA-BUF `texture` describes, whose plane descriptors are `fds`, /// into a texture of the studio's own on `device`, waiting until the copy is /// done. diff --git a/rs/moq-scene/src/browser/mod.rs b/rs/moq-scene/src/browser/mod.rs index 15d2f7c..560ab07 100644 --- a/rs/moq-scene/src/browser/mod.rs +++ b/rs/moq-scene/src/browser/mod.rs @@ -87,6 +87,9 @@ pub struct Helper { profile: PathBuf, device: wgpu::Device, queue: wgpu::Queue, + /// Whether pages paint into shared textures the device imports, rather + /// than into memory it uploads. + shared: bool, /// The device's DRM render node, which CEF must paint on for the studio to /// import what it paints. render_node: Option, @@ -111,17 +114,25 @@ impl Helper { device: &wgpu::Device, queue: &wgpu::Queue, ) -> Self { + let info = adapter.get_info(); + // A software device holds a shared texture in memory anyway, so there + // the page paints in software too. + let shared = cfg!(feature = "cef") + && info.device_type != wgpu::DeviceType::Cpu + && device + .features() + .contains(wgpu::Features::VULKAN_EXTERNAL_MEMORY_DMA_BUF); // A machine with two GPUs would otherwise paint on whichever CEF // picks, and the studio cannot import from another GPU. - let bus = adapter.get_info().device_pci_bus_id; - let render_node = (!bus.is_empty()) - .then(|| std::fs::canonicalize(format!("/dev/dri/by-path/pci-{bus}-render")).ok()) + let render_node = (!info.device_pci_bus_id.is_empty()) + .then(|| std::fs::canonicalize(format!("/dev/dri/by-path/pci-{}-render", info.device_pci_bus_id)).ok()) .flatten(); Self { program: program.into(), profile: profile.into(), device: device.clone(), queue: queue.clone(), + shared, render_node, #[cfg(target_os = "linux")] running: Default::default(), diff --git a/rs/moq-scene/src/browser/protocol.rs b/rs/moq-scene/src/browser/protocol.rs index 426da8c..dfacf28 100644 --- a/rs/moq-scene/src/browser/protocol.rs +++ b/rs/moq-scene/src/browser/protocol.rs @@ -50,16 +50,24 @@ pub(crate) enum Command { /// How a helper runs every page it opens. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub(crate) struct Start { - /// Whether to paint into shared textures the studio imports, rather than - /// into memory it uploads. - pub accelerated: bool, - /// The DRM render node to paint on: the studio's GPU. - pub render_node: Option, + pub raster: Raster, /// The profile every page shares: cookies, storage, and cache. CEF lets /// one process at a time hold it. pub profile: String, } +/// Where a helper's pages paint. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub(crate) enum Raster { + /// On the GPU, into shared textures the studio imports: on the DRM render + /// node named, the studio's GPU, or else on the one CEF picks. + Gpu { render_node: Option }, + /// In software, into memory the studio uploads. Startup never waits on a + /// GPU process CEF cannot start, and WebGL runs on SwiftShader. + #[default] + Cpu, +} + /// A page to open. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub(crate) struct Open { diff --git a/rs/moq-scene/src/browser/run.rs b/rs/moq-scene/src/browser/run.rs index d5a1f38..27e20f7 100644 --- a/rs/moq-scene/src/browser/run.rs +++ b/rs/moq-scene/src/browser/run.rs @@ -16,7 +16,7 @@ use moq_audio::bus::Channel as Sound; use super::layers::{Gpu, Layers}; use super::outbox::Commands; -use super::protocol::{Area, Channel, Command, Event, Id, Layer, Open, Start}; +use super::protocol::{Area, Channel, Command, Event, Id, Layer, Open, Raster, Start}; use super::{ARG, Helper, Page, Paint}; use crate::Error; use crate::source::{Mailbox, Picture, Status}; @@ -198,12 +198,16 @@ impl Instance { .and_then(|()| std::fs::set_permissions(&profile, std::fs::Permissions::from_mode(0o700))) .map_err(|err| failed(&format!("cannot create the browser profile {}", profile.display()), err))?; let start = Start { - accelerated: super::import::supported(helper), - render_node: helper - .render_node - .as_ref() - .and_then(|node| node.to_str()) - .map(str::to_string), + raster: match helper.shared { + true => Raster::Gpu { + render_node: helper + .render_node + .as_ref() + .and_then(|node| node.to_str()) + .map(str::to_string), + }, + false => Raster::Cpu, + }, profile: profile .to_str() .ok_or_else(|| Error::Browser(format!("the browser profile {} is not UTF-8", profile.display())))? diff --git a/rs/moq-studio/tests/browser.rs b/rs/moq-studio/tests/browser.rs index f8ddd74..40d8c1c 100644 --- a/rs/moq-studio/tests/browser.rs +++ b/rs/moq-studio/tests/browser.rs @@ -239,6 +239,24 @@ async fn a_page_draws_and_plays() { .await; } +/// A page gets WebGL and WebGL2 contexts, and what it draws with them shows. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "needs CEF and a Vulkan device; run by just render-sw"] +async fn a_webgl_page_draws() { + local(async { + // Red unless both contexts exist, and then green from WebGL2. + let html = "\ + "; + let mut studio = Studio::new(&[("page", page(html))]).await; + studio.until_center(GREEN, &[]).await; + }) + .await; +} + /// A click reaches the page where the preview put it, and a popup it asks for /// opens nothing. #[tokio::test(flavor = "multi_thread")]