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:
parent
eee56bc5f4
commit
ae528b5132
3 changed files with 122 additions and 87 deletions
|
|
@ -518,11 +518,17 @@ pub struct ElectronProcess {
|
|||
impl ElectronProcess {
|
||||
/// Spawn `electron <app-dir>` for the given window config.
|
||||
/// 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(
|
||||
config: &WindowConfig,
|
||||
electron_bin: &str,
|
||||
app_dir: &Path,
|
||||
ipc_socket: &str,
|
||||
render_node: &str,
|
||||
) -> Result<Self> {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
|
|
@ -533,33 +539,39 @@ impl ElectronProcess {
|
|||
|
||||
tracing::info!("Serialized config: {}", serialized);
|
||||
|
||||
//Explicitly specify the main.js entry point
|
||||
let main_js = app_dir.join("dist/main.js");
|
||||
|
||||
let child = Command::new(electron_bin)
|
||||
.arg(&main_js)
|
||||
.env("WO_IPC_SOCKET", ipc_socket)
|
||||
.env("WO_WINDOW_CONFIG", &serialized)
|
||||
// Force software rendering to avoid Wayland buffer issues
|
||||
.env("DISABLE_GPU_SANDBOX", "1")
|
||||
.env("DISABLE_SOFTWARE_RASTERIZER", "0")
|
||||
// Use XWayland if possible, or X11 if available, fallback to headless
|
||||
.env("DISPLAY", ":0")
|
||||
// Disable Wayland protocol issues
|
||||
.env("QT_QPA_PLATFORM", "offscreen")
|
||||
// Electron needs these flags for proper rendering
|
||||
.arg("--disable-gpu")
|
||||
.arg("--disable-gpu-compositing")
|
||||
.arg("--disable-software-rasterizer=false")
|
||||
.env("WO_DRM_RENDER_NODE", render_node)
|
||||
// Headless Ozone backend: Electron is used purely for offscreen
|
||||
// rendering — it must not try to connect to a Wayland or X11
|
||||
// display server (there is none for it to connect to; *we* are
|
||||
// the compositor). The headless platform lets Chromium's OSR
|
||||
// paint pipeline work while the native wo_dmabuf module handles
|
||||
// GPU buffer allocation via its own GBM/EGL context on the
|
||||
// render node.
|
||||
.arg("--ozone-platform=headless")
|
||||
.arg("--no-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())
|
||||
.stdout(Stdio::inherit())
|
||||
.stderr(Stdio::inherit())
|
||||
.spawn()
|
||||
.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 {
|
||||
name: config.name.clone(),
|
||||
|
|
|
|||
152
src/main.rs
152
src/main.rs
|
|
@ -68,6 +68,7 @@ struct EventLoopData {
|
|||
msg_rx: tokio::sync::mpsc::Receiver<crate::electron::ElectronMessage>,
|
||||
swapchain: Swapchain<GbmAllocator<DrmDeviceFd>>,
|
||||
drm_device_fd: DrmDeviceFd,
|
||||
drm: DrmDevice,
|
||||
drm_surface: smithay::backend::drm::DrmSurface,
|
||||
output_rect: Rectangle<i32, Physical>,
|
||||
src_rect: Rectangle<f64, Buffer>,
|
||||
|
|
@ -86,6 +87,8 @@ struct EventLoopData {
|
|||
active: bool,
|
||||
vblank_pending: bool,
|
||||
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;
|
||||
|
|
@ -149,6 +152,37 @@ fn enumerate_drm_card_paths() -> Vec<std::path::PathBuf> {
|
|||
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 {
|
||||
let lowered = err.to_ascii_lowercase();
|
||||
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 {
|
||||
Ok(_) => {
|
||||
|
|
@ -600,17 +645,15 @@ fn run_drm(config: Config) -> Result<(), anyhow::Error> {
|
|||
};
|
||||
match event {
|
||||
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.vblank_pending = false;
|
||||
if let Err(e) = drm::Device::release_master_lock(&data.drm_device_fd) {
|
||||
warn!("Failed to release DRM master on session pause: {e}");
|
||||
}
|
||||
data.drm.pause();
|
||||
}
|
||||
Event::ActivateSession => {
|
||||
info!("Session activated (VT switched back). Re-acquiring DRM master and resuming rendering.");
|
||||
if let Err(e) = drm::Device::acquire_master_lock(&data.drm_device_fd) {
|
||||
error!("Failed to re-acquire DRM master on session activation: {e}");
|
||||
info!("Session activated (VT switched back). Activating DRM device and resuming rendering.");
|
||||
if let Err(e) = data.drm.activate(false) {
|
||||
error!("Failed to activate DRM device on session resume: {e}");
|
||||
data.active = false;
|
||||
data.vblank_pending = false;
|
||||
return;
|
||||
|
|
@ -819,73 +862,28 @@ fn run_drm(config: Config) -> Result<(), anyhow::Error> {
|
|||
|
||||
let drm_device_fd = DrmDeviceFd::new(DeviceFd::from(drm_fd));
|
||||
|
||||
// Retry acquiring DRM master: on logind-managed sessions (e.g. Ly) the VT
|
||||
// switch and master hand-off from logind may not be complete at the exact
|
||||
// moment session.open() returns. libseat's builtin backend logs "Unable to
|
||||
// become DRM master, entering unprivileged mode" and returns the fd anyway;
|
||||
// pumping the event loop lets session-activation events propagate so that
|
||||
// the next drmSetMaster() attempt succeeds.
|
||||
const DRM_MASTER_RETRY_MS: u64 = 100;
|
||||
const DRM_MASTER_RETRY_ROUNDS: u32 = 30; // up to ~3 s
|
||||
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(),
|
||||
));
|
||||
}
|
||||
// DRM master management: DrmDeviceFd::new() already attempts to acquire
|
||||
// master internally. On logind-managed sessions (Ly, greetd, etc.) this
|
||||
// call typically fails because logind manages master status via TakeDevice.
|
||||
// The fd is still usable for modesetting—logind grants master implicitly.
|
||||
// Following niri's approach: do NOT explicitly call acquire_master_lock()
|
||||
// here. Instead, rely on DrmDevice::pause()/activate() for VT switching,
|
||||
// which checks is_privileged() and skips redundant master management on
|
||||
// logind sessions.
|
||||
|
||||
// Try to create DRM device with atomic mode first
|
||||
// If it fails due to invalid state restoration, try without atomic mode
|
||||
let (mut drm, _drm_notifier) = match DrmDevice::new(drm_device_fd.clone(), true) {
|
||||
Ok(result) => {
|
||||
info!("DRM device created in atomic mode");
|
||||
result
|
||||
}
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
if err_msg.contains("Invalid argument") || err_msg.contains("EINVAL") {
|
||||
warn!("Atomic mode initialization failed with EINVAL (likely stale state on display), trying legacy mode: {e}");
|
||||
DrmDevice::new(drm_device_fd.clone(), false)
|
||||
.map_err(|e| anyhow::anyhow!("creating DRM device in legacy mode after atomic failure: {e}"))?
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("creating DRM device: {e}"));
|
||||
}
|
||||
}
|
||||
};
|
||||
// Create the DRM device. The second parameter (`disable_connectors`) is
|
||||
// false following niri's approach: preserve the existing connector state
|
||||
// instead of resetting it. Resetting (true) requires DRM master and can
|
||||
// fail on logind-managed sessions in unprivileged mode.
|
||||
//
|
||||
// Atomic vs legacy modesetting is auto-detected by smithay based on driver
|
||||
// capability. Use SMITHAY_USE_LEGACY=1 to force legacy mode if needed.
|
||||
let (mut drm, _drm_notifier) = DrmDevice::new(drm_device_fd.clone(), false)
|
||||
.map_err(|e| anyhow::anyhow!("creating DRM device: {e}"))?;
|
||||
info!(
|
||||
atomic = drm.is_atomic(),
|
||||
"DRM device created"
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
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
|
||||
// alternative to [[windows]] and are mapped to the full output size.
|
||||
eprintln!("Root configs: {:#?}", config.root);
|
||||
|
|
@ -1000,6 +999,7 @@ fn run_drm(config: Config) -> Result<(), anyhow::Error> {
|
|||
&config.compositor.electron_path,
|
||||
&app_dir,
|
||||
&config.compositor.ipc_socket,
|
||||
&electron_render_node,
|
||||
) {
|
||||
Ok(proc) => state.electron_processes.push(proc),
|
||||
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,
|
||||
&app_dir,
|
||||
&config.compositor.ipc_socket,
|
||||
&electron_render_node,
|
||||
) {
|
||||
Ok(proc) => state.electron_processes.push(proc),
|
||||
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,
|
||||
swapchain,
|
||||
drm_device_fd: drm_device_fd.clone(),
|
||||
drm,
|
||||
drm_surface,
|
||||
output_rect,
|
||||
src_rect,
|
||||
|
|
@ -1121,6 +1123,7 @@ fn run_drm(config: Config) -> Result<(), anyhow::Error> {
|
|||
vblank_pending: false,
|
||||
consecutive_flip_failures: 0,
|
||||
gles_tex_cache: std::collections::HashMap::new(),
|
||||
electron_render_node: electron_render_node,
|
||||
};
|
||||
|
||||
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,
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR")).join("electron"),
|
||||
&data.state.config.compositor.ipc_socket,
|
||||
&data.electron_render_node,
|
||||
) {
|
||||
Ok(proc) => data.state.electron_processes.push(proc),
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -226,6 +226,14 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
info!("Set WAYLAND_DISPLAY={}", config.compositor.socket_name);
|
||||
|
||||
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 {
|
||||
let mut full = root_cfg.clone();
|
||||
|
|
@ -239,6 +247,7 @@ pub fn run_nested(config: Config) -> Result<()> {
|
|||
&config.compositor.electron_path,
|
||||
&app_dir,
|
||||
&config.compositor.ipc_socket,
|
||||
&nested_render_node,
|
||||
) {
|
||||
Ok(proc) => state.electron_processes.push(proc),
|
||||
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,
|
||||
&app_dir,
|
||||
&config.compositor.ipc_socket,
|
||||
&nested_render_node,
|
||||
) {
|
||||
Ok(proc) => state.electron_processes.push(proc),
|
||||
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(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(
|
||||
win_cfg,
|
||||
&state.config.compositor.electron_path,
|
||||
app_dir,
|
||||
&state.config.compositor.ipc_socket,
|
||||
&render_node,
|
||||
) {
|
||||
Ok(proc) => state.electron_processes.push(proc),
|
||||
Err(e) => error!(window = %name, "autostart spawn failed: {e:#}"),
|
||||
|
|
|
|||
Loading…
Reference in a new issue