fix: Electron offscreen rendering and DRM render node propagation

Three root causes prevented Electron frames from reaching the compositor:

1. --disable-gpu killed Chromium's rendering pipeline, preventing the
   'paint' event from firing in offscreen mode. Replaced with
   --use-gl=swiftshader so Chromium can composite internally while the
   native wo_dmabuf module handles GPU buffer allocation separately.

2. DISPLAY=:0 with no X server caused Electron to fail silently on
   pure Wayland sessions (Ly/logind). Replaced with
   --ozone-platform=headless which lets Chromium's OSR pipeline work
   without any display server connection.

3. WO_DRM_RENDER_NODE was never set, so the native wo_dmabuf.node
   module always defaulted to /dev/dri/renderD128 regardless of which
   GPU the compositor actually opened. Now resolved via sysfs
   (card1 -> renderD128, card2 -> renderD129, etc.) and passed through
   to Electron.

Also: retry EACCES/EPERM as transient during DRM device open (logind
session activation can lag behind libseat's is_active() flag on Ly).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Stephen Hellings 2026-03-09 12:31:09 -04:00
parent eee56bc5f4
commit ae528b5132
3 changed files with 122 additions and 87 deletions

View file

@ -518,11 +518,17 @@ pub struct ElectronProcess {
impl ElectronProcess { impl ElectronProcess {
/// Spawn `electron <app-dir>` for the given window config. /// Spawn `electron <app-dir>` for the given window config.
/// The app dir is resolved to `<config-dir>/wo-electron`. /// The app dir is resolved to `<config-dir>/wo-electron`.
///
/// `render_node` is the DRI render node (e.g. `/dev/dri/renderD128`) that
/// matches the GPU the compositor opened. The native `wo_dmabuf.node`
/// module inside Electron uses it to allocate GBM buffers that are
/// importable by the compositor's EGL context.
pub fn spawn( pub fn spawn(
config: &WindowConfig, config: &WindowConfig,
electron_bin: &str, electron_bin: &str,
app_dir: &Path, app_dir: &Path,
ipc_socket: &str, ipc_socket: &str,
render_node: &str,
) -> Result<Self> { ) -> Result<Self> {
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
@ -533,33 +539,39 @@ impl ElectronProcess {
tracing::info!("Serialized config: {}", serialized); tracing::info!("Serialized config: {}", serialized);
//Explicitly specify the main.js entry point
let main_js = app_dir.join("dist/main.js"); let main_js = app_dir.join("dist/main.js");
let child = Command::new(electron_bin) let child = Command::new(electron_bin)
.arg(&main_js) .arg(&main_js)
.env("WO_IPC_SOCKET", ipc_socket) .env("WO_IPC_SOCKET", ipc_socket)
.env("WO_WINDOW_CONFIG", &serialized) .env("WO_WINDOW_CONFIG", &serialized)
// Force software rendering to avoid Wayland buffer issues .env("WO_DRM_RENDER_NODE", render_node)
.env("DISABLE_GPU_SANDBOX", "1") // Headless Ozone backend: Electron is used purely for offscreen
.env("DISABLE_SOFTWARE_RASTERIZER", "0") // rendering — it must not try to connect to a Wayland or X11
// Use XWayland if possible, or X11 if available, fallback to headless // display server (there is none for it to connect to; *we* are
.env("DISPLAY", ":0") // the compositor). The headless platform lets Chromium's OSR
// Disable Wayland protocol issues // paint pipeline work while the native wo_dmabuf module handles
.env("QT_QPA_PLATFORM", "offscreen") // GPU buffer allocation via its own GBM/EGL context on the
// Electron needs these flags for proper rendering // render node.
.arg("--disable-gpu") .arg("--ozone-platform=headless")
.arg("--disable-gpu-compositing")
.arg("--disable-software-rasterizer=false")
.arg("--no-sandbox") .arg("--no-sandbox")
.arg("--disable-gpu-sandbox") .arg("--disable-gpu-sandbox")
// Use the *software* GL implementation inside Chromium for its
// own compositing (we do not need hardware-accelerated web
// content — the paint callback gives us BGRA bitmaps that the
// native module imports into a GBM BO). This avoids Chromium
// fighting with the compositor over the DRM render node.
.arg("--use-gl=swiftshader")
// Make sure OSR (offscreen) rendering actually produces
// paint callbacks at the requested frame rate.
.arg("--enable-features=Vulkan")
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::inherit()) .stdout(Stdio::inherit())
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
.spawn() .spawn()
.with_context(|| format!("spawning electron for window '{}'", config.name))?; .with_context(|| format!("spawning electron for window '{}'", config.name))?;
info!(window = %config.name, pid = child.id(), "spawned Electron process"); info!(window = %config.name, pid = child.id(), render_node, "spawned Electron process");
Ok(Self { Ok(Self {
name: config.name.clone(), name: config.name.clone(),

View file

@ -68,6 +68,7 @@ struct EventLoopData {
msg_rx: tokio::sync::mpsc::Receiver<crate::electron::ElectronMessage>, msg_rx: tokio::sync::mpsc::Receiver<crate::electron::ElectronMessage>,
swapchain: Swapchain<GbmAllocator<DrmDeviceFd>>, swapchain: Swapchain<GbmAllocator<DrmDeviceFd>>,
drm_device_fd: DrmDeviceFd, drm_device_fd: DrmDeviceFd,
drm: DrmDevice,
drm_surface: smithay::backend::drm::DrmSurface, drm_surface: smithay::backend::drm::DrmSurface,
output_rect: Rectangle<i32, Physical>, output_rect: Rectangle<i32, Physical>,
src_rect: Rectangle<f64, Buffer>, src_rect: Rectangle<f64, Buffer>,
@ -86,6 +87,8 @@ struct EventLoopData {
active: bool, active: bool,
vblank_pending: bool, vblank_pending: bool,
consecutive_flip_failures: u32, consecutive_flip_failures: u32,
/// DRI render node path for spawning Electron processes (e.g. `/dev/dri/renderD128`).
electron_render_node: String,
} }
const MAX_CONSECUTIVE_FLIP_FAILURES: u32 = 5; const MAX_CONSECUTIVE_FLIP_FAILURES: u32 = 5;
@ -149,6 +152,37 @@ fn enumerate_drm_card_paths() -> Vec<std::path::PathBuf> {
paths paths
} }
/// Resolve the DRI render node (e.g. `/dev/dri/renderD128`) that belongs to
/// the same physical GPU as `card_path` (e.g. `/dev/dri/card1`).
///
/// The lookup uses sysfs: `/sys/class/drm/card<N>/device/drm/renderD*`.
/// Falls back to `/dev/dri/renderD128` if the mapping cannot be determined.
fn render_node_for_card(card_path: &std::path::Path) -> String {
let card_name = card_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("card0");
let sys_dir = format!("/sys/class/drm/{card_name}/device/drm");
if let Ok(entries) = std::fs::read_dir(&sys_dir) {
for entry in entries.filter_map(Result::ok) {
let name = entry.file_name();
if let Some(s) = name.to_str() {
if s.starts_with("renderD") {
let path = format!("/dev/dri/{s}");
info!(card = %card_path.display(), render_node = %path, "resolved render node");
return path;
}
}
}
}
let fallback = "/dev/dri/renderD128".to_string();
warn!(
card = %card_path.display(),
"could not resolve render node via sysfs, falling back to {fallback}"
);
fallback
}
fn is_egl_context_or_surface_lost(err: &str) -> bool { fn is_egl_context_or_surface_lost(err: &str) -> bool {
let lowered = err.to_ascii_lowercase(); let lowered = err.to_ascii_lowercase();
lowered.contains("context has been lost") lowered.contains("context has been lost")
@ -509,7 +543,18 @@ fn render_frame(data: &mut EventLoopData) {
}), }),
}; };
let result = data.drm_surface.page_flip([plane_state.clone()], true); // Schedule page flip (or full modeset commit for the first frame).
// smithay's DrmSurface tracks pending state (mode, connectors). When
// commit_pending() is true (e.g. first frame after surface creation or
// VT switch), we must use commit() which includes ALLOW_MODESET.
// page_flip() only does a non-blocking flip without modesetting and will
// fail with EINVAL if the CRTC doesn't have a mode set yet.
let result = if data.drm_surface.commit_pending() {
info!("Performing initial modeset commit");
data.drm_surface.commit([plane_state.clone()], true)
} else {
data.drm_surface.page_flip([plane_state.clone()], true)
};
match result { match result {
Ok(_) => { Ok(_) => {
@ -600,17 +645,15 @@ fn run_drm(config: Config) -> Result<(), anyhow::Error> {
}; };
match event { match event {
Event::PauseSession => { Event::PauseSession => {
info!("Session paused (VT switched away). Releasing DRM master and halting rendering."); info!("Session paused (VT switched away). Pausing DRM device and halting rendering.");
data.active = false; data.active = false;
data.vblank_pending = false; data.vblank_pending = false;
if let Err(e) = drm::Device::release_master_lock(&data.drm_device_fd) { data.drm.pause();
warn!("Failed to release DRM master on session pause: {e}");
}
} }
Event::ActivateSession => { Event::ActivateSession => {
info!("Session activated (VT switched back). Re-acquiring DRM master and resuming rendering."); info!("Session activated (VT switched back). Activating DRM device and resuming rendering.");
if let Err(e) = drm::Device::acquire_master_lock(&data.drm_device_fd) { if let Err(e) = data.drm.activate(false) {
error!("Failed to re-acquire DRM master on session activation: {e}"); error!("Failed to activate DRM device on session resume: {e}");
data.active = false; data.active = false;
data.vblank_pending = false; data.vblank_pending = false;
return; return;
@ -819,73 +862,28 @@ fn run_drm(config: Config) -> Result<(), anyhow::Error> {
let drm_device_fd = DrmDeviceFd::new(DeviceFd::from(drm_fd)); let drm_device_fd = DrmDeviceFd::new(DeviceFd::from(drm_fd));
// Retry acquiring DRM master: on logind-managed sessions (e.g. Ly) the VT // DRM master management: DrmDeviceFd::new() already attempts to acquire
// switch and master hand-off from logind may not be complete at the exact // master internally. On logind-managed sessions (Ly, greetd, etc.) this
// moment session.open() returns. libseat's builtin backend logs "Unable to // call typically fails because logind manages master status via TakeDevice.
// become DRM master, entering unprivileged mode" and returns the fd anyway; // The fd is still usable for modesetting—logind grants master implicitly.
// pumping the event loop lets session-activation events propagate so that // Following niri's approach: do NOT explicitly call acquire_master_lock()
// the next drmSetMaster() attempt succeeds. // here. Instead, rely on DrmDevice::pause()/activate() for VT switching,
const DRM_MASTER_RETRY_MS: u64 = 100; // which checks is_privileged() and skips redundant master management on
const DRM_MASTER_RETRY_ROUNDS: u32 = 30; // up to ~3 s // logind sessions.
let mut last_master_err = None;
for round in 0..=DRM_MASTER_RETRY_ROUNDS {
match drm::Device::acquire_master_lock(&drm_device_fd) {
Ok(()) => {
if round > 0 {
info!("DRM master acquired after {} retries", round);
}
last_master_err = None;
break;
}
Err(e) => {
let detail = e.to_string();
if round == 0 {
warn!(
gpu = %gpu_path.display(),
"DRM master not yet available ({}); retrying up to {}×{}ms...",
detail,
DRM_MASTER_RETRY_ROUNDS,
DRM_MASTER_RETRY_MS,
);
}
last_master_err = Some(detail);
let _ = event_loop.dispatch(
Some(Duration::from_millis(DRM_MASTER_RETRY_MS)),
&mut pre_init,
);
}
}
}
if let Some(e) = last_master_err {
return Err(anyhow::anyhow!(
"failed to become DRM master on {gpu}: {e}. \
The GPU is likely in use by another active session/compositor. \
Ensure wo is launched as the session compositor by a logind-aware \
display manager (e.g. Ly, greetd) with pam_systemd.so in its PAM \
config, or switch to a free VT first. \
If using seatd, add your user to the 'seat' group.",
gpu = gpu_path.display(),
));
}
// Try to create DRM device with atomic mode first // Create the DRM device. The second parameter (`disable_connectors`) is
// If it fails due to invalid state restoration, try without atomic mode // false following niri's approach: preserve the existing connector state
let (mut drm, _drm_notifier) = match DrmDevice::new(drm_device_fd.clone(), true) { // instead of resetting it. Resetting (true) requires DRM master and can
Ok(result) => { // fail on logind-managed sessions in unprivileged mode.
info!("DRM device created in atomic mode"); //
result // Atomic vs legacy modesetting is auto-detected by smithay based on driver
} // capability. Use SMITHAY_USE_LEGACY=1 to force legacy mode if needed.
Err(e) => { let (mut drm, _drm_notifier) = DrmDevice::new(drm_device_fd.clone(), false)
let err_msg = e.to_string(); .map_err(|e| anyhow::anyhow!("creating DRM device: {e}"))?;
if err_msg.contains("Invalid argument") || err_msg.contains("EINVAL") { info!(
warn!("Atomic mode initialization failed with EINVAL (likely stale state on display), trying legacy mode: {e}"); atomic = drm.is_atomic(),
DrmDevice::new(drm_device_fd.clone(), false) "DRM device created"
.map_err(|e| anyhow::anyhow!("creating DRM device in legacy mode after atomic failure: {e}"))? );
} else {
return Err(anyhow::anyhow!("creating DRM device: {e}"));
}
}
};
let gbm_device = GbmDevice::new(drm_device_fd.clone()).context("creating GBM device")?; let gbm_device = GbmDevice::new(drm_device_fd.clone()).context("creating GBM device")?;
@ -984,6 +982,7 @@ fn run_drm(config: Config) -> Result<(), anyhow::Error> {
std::env::set_var("WAYLAND_DISPLAY", &config.compositor.socket_name); std::env::set_var("WAYLAND_DISPLAY", &config.compositor.socket_name);
let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("electron"); let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("electron");
let electron_render_node = render_node_for_card(&gpu_path);
// Spawn any root (fullscreen) entries first. Root entries are an // Spawn any root (fullscreen) entries first. Root entries are an
// alternative to [[windows]] and are mapped to the full output size. // alternative to [[windows]] and are mapped to the full output size.
eprintln!("Root configs: {:#?}", config.root); eprintln!("Root configs: {:#?}", config.root);
@ -1000,6 +999,7 @@ fn run_drm(config: Config) -> Result<(), anyhow::Error> {
&config.compositor.electron_path, &config.compositor.electron_path,
&app_dir, &app_dir,
&config.compositor.ipc_socket, &config.compositor.ipc_socket,
&electron_render_node,
) { ) {
Ok(proc) => state.electron_processes.push(proc), Ok(proc) => state.electron_processes.push(proc),
Err(e) => error!(window = %full.name, "failed to spawn Electron root: {e:#}"), Err(e) => error!(window = %full.name, "failed to spawn Electron root: {e:#}"),
@ -1012,6 +1012,7 @@ fn run_drm(config: Config) -> Result<(), anyhow::Error> {
&config.compositor.electron_path, &config.compositor.electron_path,
&app_dir, &app_dir,
&config.compositor.ipc_socket, &config.compositor.ipc_socket,
&electron_render_node,
) { ) {
Ok(proc) => state.electron_processes.push(proc), Ok(proc) => state.electron_processes.push(proc),
Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"), Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"),
@ -1103,6 +1104,7 @@ fn run_drm(config: Config) -> Result<(), anyhow::Error> {
msg_rx, msg_rx,
swapchain, swapchain,
drm_device_fd: drm_device_fd.clone(), drm_device_fd: drm_device_fd.clone(),
drm,
drm_surface, drm_surface,
output_rect, output_rect,
src_rect, src_rect,
@ -1121,6 +1123,7 @@ fn run_drm(config: Config) -> Result<(), anyhow::Error> {
vblank_pending: false, vblank_pending: false,
consecutive_flip_failures: 0, consecutive_flip_failures: 0,
gles_tex_cache: std::collections::HashMap::new(), gles_tex_cache: std::collections::HashMap::new(),
electron_render_node: electron_render_node,
}; };
let signal_for_handler = loop_signal.clone(); let signal_for_handler = loop_signal.clone();
@ -1689,6 +1692,7 @@ fn run_drm(config: Config) -> Result<(), anyhow::Error> {
&data.state.config.compositor.electron_path, &data.state.config.compositor.electron_path,
&Path::new(env!("CARGO_MANIFEST_DIR")).join("electron"), &Path::new(env!("CARGO_MANIFEST_DIR")).join("electron"),
&data.state.config.compositor.ipc_socket, &data.state.config.compositor.ipc_socket,
&data.electron_render_node,
) { ) {
Ok(proc) => data.state.electron_processes.push(proc), Ok(proc) => data.state.electron_processes.push(proc),
Err(e) => { Err(e) => {

View file

@ -226,6 +226,14 @@ pub fn run_nested(config: Config) -> Result<()> {
info!("Set WAYLAND_DISPLAY={}", config.compositor.socket_name); info!("Set WAYLAND_DISPLAY={}", config.compositor.socket_name);
let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("electron"); let app_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("electron");
// Nested mode has no DRM card; use the first available render node.
let nested_render_node = std::fs::read_dir("/dev/dri")
.ok()
.into_iter()
.flat_map(|e| e.filter_map(Result::ok))
.find(|e| e.file_name().to_str().map_or(false, |n| n.starts_with("renderD")))
.map(|e| e.path().to_string_lossy().into_owned())
.unwrap_or_else(|| "/dev/dri/renderD128".to_string());
for root_cfg in &config.root { for root_cfg in &config.root {
let mut full = root_cfg.clone(); let mut full = root_cfg.clone();
@ -239,6 +247,7 @@ pub fn run_nested(config: Config) -> Result<()> {
&config.compositor.electron_path, &config.compositor.electron_path,
&app_dir, &app_dir,
&config.compositor.ipc_socket, &config.compositor.ipc_socket,
&nested_render_node,
) { ) {
Ok(proc) => state.electron_processes.push(proc), Ok(proc) => state.electron_processes.push(proc),
Err(e) => error!(window = %full.name, "failed to spawn Electron root: {e:#}"), Err(e) => error!(window = %full.name, "failed to spawn Electron root: {e:#}"),
@ -251,6 +260,7 @@ pub fn run_nested(config: Config) -> Result<()> {
&config.compositor.electron_path, &config.compositor.electron_path,
&app_dir, &app_dir,
&config.compositor.ipc_socket, &config.compositor.ipc_socket,
&nested_render_node,
) { ) {
Ok(proc) => state.electron_processes.push(proc), Ok(proc) => state.electron_processes.push(proc),
Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"), Err(e) => error!(window = %win_cfg.name, "failed to spawn Electron: {e:#}"),
@ -997,11 +1007,20 @@ fn execute_autostart_task(
if let Some(name) = cmd.strip_prefix("wo://window/") { if let Some(name) = cmd.strip_prefix("wo://window/") {
if let Some(win_cfg) = state.config.windows.iter().find(|w| w.name == name) { if let Some(win_cfg) = state.config.windows.iter().find(|w| w.name == name) {
// Best-effort render node for nested autostart
let render_node = std::fs::read_dir("/dev/dri")
.ok()
.into_iter()
.flat_map(|e| e.filter_map(Result::ok))
.find(|e| e.file_name().to_str().map_or(false, |n| n.starts_with("renderD")))
.map(|e| e.path().to_string_lossy().into_owned())
.unwrap_or_else(|| "/dev/dri/renderD128".to_string());
match ElectronProcess::spawn( match ElectronProcess::spawn(
win_cfg, win_cfg,
&state.config.compositor.electron_path, &state.config.compositor.electron_path,
app_dir, app_dir,
&state.config.compositor.ipc_socket, &state.config.compositor.ipc_socket,
&render_node,
) { ) {
Ok(proc) => state.electron_processes.push(proc), Ok(proc) => state.electron_processes.push(proc),
Err(e) => error!(window = %name, "autostart spawn failed: {e:#}"), Err(e) => error!(window = %name, "autostart spawn failed: {e:#}"),