From 57f21119c0859fa9443ed3353a19205c36005924 Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 25 Oct 2026 22:53:31 +0200 Subject: [PATCH 1/2] fix: run Hyprland and COSMIC on hybrid NVIDIA hosts and stop leaking Weston On a host with Intel and NVIDIA GPUs, Hyprland's private KWin renderer opened the NVIDIA render node, whose GBM cannot allocate Hyprland's buffers, so the output never got a mode. That renderer now runs in a bubblewrap namespace that exposes only the other GPU's render node. Single-vendor hosts are unchanged. COSMIC's software-rendered Weston parent, compositor and XWayland now load only Mesa's EGL vendor; NVIDIA's EGL made XWayland crash at startup. COSMIC's Weston parent outlived a failed start or a killed daemon: bwrap's --die-with-parent does not stop the sandboxed process without a PID namespace. The parent now gets one, and shutdown signals its whole process group. --- docs/hyprland.md | 6 +++++ docs/native-cosmic.md | 3 ++- src/session/cosmic.rs | 40 +++++++++++++++++++++------- src/session/hyprland.rs | 59 ++++++++++++++++++++++++++++++++++++++++- src/session/private.rs | 16 +++++++++++ 5 files changed, 112 insertions(+), 12 deletions(-) diff --git a/docs/hyprland.md b/docs/hyprland.md index 521c241..3881d15 100644 --- a/docs/hyprland.md +++ b/docs/hyprland.md @@ -12,6 +12,12 @@ The backend targets Hyprland 0.53 and KWin 6.6. It requires `Hyprland`, Aquamarine requires a renderer-backed allocator even with headless outputs. The workspace starts a private KWin virtual compositor to supply that allocator, then creates a Hyprland headless output and removes the nested Wayland output. + +KWin's virtual compositor uses the first DRM device that opens, and Hyprland +allocates its buffers on that device. NVIDIA's GBM cannot allocate them. On a +host with an NVIDIA GPU and another GPU, the private KWin compositor runs in a +bubblewrap namespace whose `/dev/dri` holds only the other GPU's render node. +Hosts with one GPU vendor use every render node as before. KWin supplies no desktop shell. Neither compositor connects to a host display, input device, system bus, or DRM card. Weston 14 cannot serve as the parent: Aquamarine requests `wl_compositor` version 6 while Weston advertises version 5. diff --git a/docs/native-cosmic.md b/docs/native-cosmic.md index c19511f..b4ca48d 100644 --- a/docs/native-cosmic.md +++ b/docs/native-cosmic.md @@ -27,7 +27,8 @@ output scale and EI input checks succeed. The backend uses COSMIC toplevel information and management, ext image-copy capture, wlr output management, and COSMIC's private D-Bus EI input socket. -Capture uses shared memory and does not require a GPU. Window identifiers are +Capture uses shared memory and does not require a GPU. The compositor, its Weston parent and XWayland +load only Mesa's EGL vendor, so an installed NVIDIA driver is never used. Window identifiers are COSMIC's stable foreign-toplevel identifiers. PID is unavailable from this protocol and remains unset. diff --git a/src/session/cosmic.rs b/src/session/cosmic.rs index 68eaba5..842b324 100644 --- a/src/session/cosmic.rs +++ b/src/session/cosmic.rs @@ -335,12 +335,15 @@ fn xwayland_exec() -> Result<()> { .create(true) .append(true) .open(runtime.join("xwayland.log"))?; - let error = Command::new(executable) + let mut command = Command::new(executable); + command .args(args) .arg("-auth") .arg(authority) - .stderr(Stdio::from(stderr)) - .exec(); + .stderr(Stdio::from(stderr)); + // The compositor does not pass its environment on to XWayland. + super::private::use_mesa_egl(&mut command); + let error = command.exec(); Err(error).context("exec authenticated COSMIC XWayland") } fn set_cookie(authority: &Path, display: &str, cookie: &str) -> Result<()> { @@ -448,6 +451,12 @@ fn compositor_namespace(runtime: &Path, wrapper: Option<&Path>) -> Result Vec { blockers } +/// KWin's virtual renderer takes the first DRM device that opens, and +/// Hyprland allocates its buffers on that renderer's device. NVIDIA's GBM +/// cannot allocate them, so on a host that also has another GPU the renderer +/// sees only that GPU's render node. Other hosts keep every node. +fn parent_render_node() -> Option { + let mut nodes: Vec<(PathBuf, bool)> = fs::read_dir("/sys/class/drm") + .ok()? + .flatten() + .filter_map(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + name.strip_prefix("renderD") + .is_some_and(|n| !n.is_empty() && n.bytes().all(|c| c.is_ascii_digit())) + .then_some(())?; + let driver = fs::read_link(entry.path().join("device/driver")).ok()?; + let nvidia = driver.file_name().is_some_and(|name| name == "nvidia"); + let node = Path::new("/dev/dri").join(&name); + let path = std::ffi::CString::new(node.as_os_str().as_encoded_bytes()).ok()?; + (unsafe { libc::access(path.as_ptr(), libc::R_OK | libc::W_OK) } == 0) + .then_some((node, nvidia)) + }) + .collect(); + nodes.sort(); + if !nodes.iter().any(|(_, nvidia)| *nvidia) { + return None; + } + nodes + .into_iter() + .find(|(_, nvidia)| !nvidia) + .map(|(node, _)| node) +} + pub(crate) fn configure_process_environment( command: &mut Command, runtime: &Path, @@ -206,7 +237,33 @@ impl HyprlandSession { private, filter: None, }; - let mut parent = Command::new("kwin_wayland"); + let mut parent = match parent_render_node() { + Some(node) => { + let bwrap = command_path_check("bwrap"); + if !bwrap.ok { + bail!( + "bwrap is required to keep Hyprland's renderer off the NVIDIA render node" + ); + } + let mut command = Command::new(bwrap.detail); + command + .args([ + "--dev-bind", + "/", + "/", + "--proc", + "/proc", + "--tmpfs", + "/dev/dri", + ]) + .arg("--dev-bind") + .arg(&node) + .arg(&node) + .args(["--unshare-pid", "--die-with-parent", "--", "kwin_wayland"]); + command + } + None => Command::new("kwin_wayland"), + }; configure_process_environment(&mut parent, runtime, "", &options.xauthority_path); parent .env_remove("WAYLAND_DISPLAY") diff --git a/src/session/private.rs b/src/session/private.rs index ededff6..87af4d6 100644 --- a/src/session/private.rs +++ b/src/session/private.rs @@ -337,6 +337,22 @@ fn start_private_bus( Ok(child) } +/// Restrict EGL to Mesa for a software-rendered process. glvnd otherwise +/// tries the NVIDIA vendor first, which fails or crashes without its devices. +/// Hosts without Mesa's vendor file keep the default vendor list. +pub(crate) fn use_mesa_egl(command: &mut Command) { + if let Some(vendor) = [ + "/usr/share/glvnd/egl_vendor.d/50_mesa.json", + "/etc/glvnd/egl_vendor.d/50_mesa.json", + ] + .into_iter() + .map(Path::new) + .find(|path| path.is_file()) + { + command.env("__EGL_VENDOR_LIBRARY_FILENAMES", vendor); + } +} + pub(crate) fn prepare_child(command: &mut Command) { let parent = std::process::id(); command.stdin(Stdio::null()).process_group(0); From fe7ca98e035fc47cce379c5e4ecaa5b7d08e55ef Mon Sep 17 00:00:00 2001 From: Avi Fenesh Date: Sun, 25 Oct 2026 23:41:19 +0200 Subject: [PATCH 2/2] fix: keep the COSMIC Weston parent unreaped during startup --- src/session/cosmic.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/session/cosmic.rs b/src/session/cosmic.rs index 842b324..f4fb08b 100644 --- a/src/session/cosmic.rs +++ b/src/session/cosmic.rs @@ -611,7 +611,11 @@ impl CosmicSession { wait_for( Duration::from_secs(10), || { - if let Some(status) = session.parent.as_mut().unwrap().try_wait()? { + // Observe without reaping, so shutdown's group signals cannot + // reach a recycled process group. + if let super::private::UnreapedExit::Exited(status) = + super::private::unreaped_exit(session.parent.as_ref().unwrap().id()) + { bail!("private COSMIC parent exited: {status}"); } Ok(parent_socket.exists())