From c8f9032dec095032c38c9400d454fe5bb2164ba3 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Tue, 9 Jun 2026 22:15:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20M2=20=E2=80=94=20harden=20gamescope=20c?= =?UTF-8?q?apture=20path=20(blocked=20on=20gamescope=20=E2=89=A53.16.22=20?= =?UTF-8?q?upstream=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep investigation (gdb + daemon traces) proved the gamescope capture stall is a gamescope 3.16.20 bug, not ours: it calls pw_loop_iterate() without pw_loop_enter()/leave(), and under PipeWire 1.6's loop locking its main thread permanently holds the loop mutex — the pw thread deadlocks, gamescope never acks the daemon's port_set_param(Format), and the link parks in "negotiating" silently. Stock gst pipewiresrc fails identically. Fixed upstream by gamescope commit e3ed1ea7 ("pipewire: Fix pipewire loop locking", pipewire#5148); first release 3.16.22. Ubuntu 26.04 ships 3.16.20 (built ten days before the fix) — patch/upgrade required. Consumer-side improvements from the investigation (all verified correct vs gamescope's pods, and needed once the producer is fixed): - discover the node from gamescope's own "stream available on node ID: N" log line (its node.name appears on two objects; the advertised id is authoritative); pw-dump fallback - CPU path accepts mappable dmabufs: Buffers param now offers MemPtr|MemFd|DmaBuf (gamescope counter-offers exactly DmaBuf when its modifier pod wins, never MemPtr), mmap the fd ourselves when MAP_BUFFERS didn't (Vulkan-exported dmabufs aren't flagged mappable), and treat chunk.size==0 as the computed span - warn_once on every silent frame-drop path in the process callback - node.dont-reconnect on our capture streams: an orphaned stream re-targeted by wireplumber onto a fresh node wedges it — and a stuck link head-blocks the daemon's shared work queue, stalling ALL new link negotiation system-wide (this poisoned whole test sessions) - LUMEN_GAMESCOPE_NODE (attach to an existing gamescope) + LUMEN_PW_FIXED_POD (negotiation bisection) debug knobs KWin path regression-tested (zero-copy intact). gamescope end-to-end validation pending the patched gamescope build. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + crates/lumen-host/src/capture/linux.rs | 276 +++++++++++++++----- crates/lumen-host/src/vdisplay/gamescope.rs | 37 ++- 3 files changed, 249 insertions(+), 65 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8166486..40cee1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1506,6 +1506,7 @@ dependencies = [ "rustls", "rustls-pemfile", "rusty_enet", + "serde_json", "sha2", "tokio", "tracing", diff --git a/crates/lumen-host/src/capture/linux.rs b/crates/lumen-host/src/capture/linux.rs index 3c042e5..f76adea 100644 --- a/crates/lumen-host/src/capture/linux.rs +++ b/crates/lumen-host/src/capture/linux.rs @@ -368,6 +368,49 @@ mod pipewire { importer: Option, } + /// Log a frame-drop reason once per process (the process callback runs per frame; a stuck + /// pipeline must say why without flooding). + fn warn_once(msg: &'static str) { + use std::sync::Mutex; + static SEEN: Mutex> = Mutex::new(Vec::new()); + let mut seen = SEEN.lock().unwrap(); + if !seen.contains(&msg) { + seen.push(msg); + tracing::warn!("{msg}"); + } + } + + /// A read-only mmap of a dmabuf fd, unmapped on drop. Used when MAP_BUFFERS didn't map the + /// buffer (producers don't always flag dmabufs mappable, e.g. gamescope's Vulkan exports). + struct DmabufMap { + ptr: *mut std::ffi::c_void, + len: usize, + } + + impl DmabufMap { + fn new(fd: i32, len: usize) -> Option { + let ptr = unsafe { + libc::mmap( + std::ptr::null_mut(), + len, + libc::PROT_READ, + libc::MAP_SHARED, + fd, + 0, + ) + }; + (ptr != libc::MAP_FAILED).then_some(DmabufMap { ptr, len }) + } + } + + impl Drop for DmabufMap { + fn drop(&mut self) { + unsafe { + libc::munmap(self.ptr, self.len); + } + } + } + fn serialize_pod(obj: pw::spa::pod::Object) -> Result> { Ok(pw::spa::pod::serialize::PodSerializer::serialize( std::io::Cursor::new(Vec::new()), @@ -433,6 +476,90 @@ mod pipewire { serialize_pod(obj) } + /// The default (shm/CPU-path) format offer: raw video in any encoder-mappable layout, any + /// size, any framerate (0/1 = variable allowed — gamescope fixates exactly that). + fn build_default_format_obj() -> pw::spa::pod::Object { + pw::spa::pod::object!( + pw::spa::utils::SpaTypes::ObjectParamFormat, + pw::spa::param::ParamType::EnumFormat, + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::MediaType, + Id, + pw::spa::param::format::MediaType::Video + ), + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::MediaSubtype, + Id, + pw::spa::param::format::MediaSubtype::Raw + ), + // Offer the layouts the encoder can map to an NVENC input format. wlroots + // commonly fixates packed RGB (3 bpp); other compositors offer 4 bpp. Only + // these are requested, so negotiation fails loudly rather than handing us a + // format we'd misinterpret. + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::VideoFormat, + Choice, + Enum, + Id, + VideoFormat::RGB, + VideoFormat::RGB, + VideoFormat::BGR, + VideoFormat::RGBx, + VideoFormat::BGRx, + VideoFormat::RGBA, + VideoFormat::BGRA, + ), + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::VideoSize, + Choice, + Range, + Rectangle, + pw::spa::utils::Rectangle { + width: 1920, + height: 1080 + }, + pw::spa::utils::Rectangle { + width: 1, + height: 1 + }, + pw::spa::utils::Rectangle { + width: 8192, + height: 8192 + } + ), + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::VideoFramerate, + Choice, + Range, + Fraction, + pw::spa::utils::Fraction { num: 60, denom: 1 }, + pw::spa::utils::Fraction { num: 0, denom: 1 }, + pw::spa::utils::Fraction { num: 240, denom: 1 } + ), + ) + } + + /// Build a Buffers param for the CPU path accepting anything mappable: MemPtr, MemFd, and + /// DmaBuf. The DmaBuf bit matters for producers like gamescope whose format intersection + /// lands on their modifier-bearing (LINEAR) pod: they then offer *only* DmaBuf buffers, and + /// without this bit the buffer-type intersection is empty and the link silently stalls in + /// "negotiating". A LINEAR dmabuf is mmap-able by MAP_BUFFERS, so the CPU de-pad copy works. + fn build_mappable_buffers() -> Result> { + serialize_pod(pw::spa::pod::Object { + type_: pw::spa::utils::SpaTypes::ObjectParamBuffers.as_raw(), + id: pw::spa::param::ParamType::Buffers.as_raw(), + properties: vec![pw::spa::pod::Property { + key: pw::spa::sys::SPA_PARAM_BUFFERS_dataType, + flags: pw::spa::pod::PropertyFlags::empty(), + value: pw::spa::pod::Value::Int( + (1i32 << pw::spa::sys::SPA_DATA_MemPtr) + | (1i32 << pw::spa::sys::SPA_DATA_MemFd) + | (1i32 << pw::spa::sys::SPA_DATA_DmaBuf), + ), + }], + }) + } + /// Build a Buffers param requesting dmabuf-only buffers. fn build_dmabuf_buffers() -> Result> { serialize_pod(pw::spa::pod::Object { @@ -514,6 +641,11 @@ mod pipewire { *pw::keys::MEDIA_TYPE => "Video", *pw::keys::MEDIA_CATEGORY => "Capture", *pw::keys::MEDIA_ROLE => "Screen", + // Never let the session manager re-target this stream to a different node when + // its target goes away: an orphaned stream auto-linked to a fresh Video/Source + // wedges that node — and a stuck link head-blocks the PipeWire daemon's shared + // work queue, stalling ALL new link negotiation system-wide. + "node.dont-reconnect" => "true", }, ) .context("pw Stream")?; @@ -628,6 +760,10 @@ mod pipewire { } let d = &mut datas[0]; + // CPU path may also receive LINEAR dmabufs (gamescope offers only those once its + // modifier-bearing format pod wins); capture the fd before `data()` borrows `d`. + let dmabuf_fd = + (d.type_() == pw::spa::buffer::DataType::DmaBuf).then(|| d.fd()); let (size, offset, stride) = { let c = d.chunk(); ( @@ -640,14 +776,42 @@ mod pipewire { let bpp = fmt.bytes_per_pixel(); let row = w * bpp; let stride = if stride == 0 { row } else { stride }; - let Some(buf) = d.data() else { return }; + if stride < row { + warn_once("chunk stride < row — frames dropped"); + return; + } + let needed = stride * (h - 1) + row; + // dmabuf chunks commonly report size 0; fall back to the computed span. + let size = if size == 0 { needed } else { size }; + // MAP_BUFFERS only maps buffers flagged mappable; Vulkan-exported dmabufs + // (gamescope) usually aren't, so mmap the fd ourselves for the de-pad read. + let _mapping; // keeps a manual mmap alive for the copy below + let buf: &[u8] = if let Some(data) = d.data() { + data + } else if let Some(fd) = dmabuf_fd.filter(|&fd| fd > 0) { + match DmabufMap::new(fd, offset + needed) { + Some(m) => { + _mapping = m; + unsafe { + std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len) + } + } + None => { + warn_once("mmap(dmabuf) failed — frames dropped"); + return; + } + } + } else { + warn_once("buffer has no mappable data — frames dropped"); + return; + }; // Need stride*(h-1)+row valid bytes within [offset, offset+size). - if stride < row || offset > buf.len() { + if offset > buf.len() { return; } let avail = buf.len() - offset; - let needed = stride * (h - 1) + row; if needed > avail || needed > size { + warn_once("buffer smaller than frame span — frames dropped"); return; } let region = &buf[offset..offset + size.min(avail)]; @@ -678,65 +842,51 @@ mod pipewire { .register() .context("register stream listener")?; + // Debug knob: offer a single fixed format (LUMEN_PW_FIXED_POD="WxH") to bisect + // negotiation failures against a producer's exact EnumFormat (e.g. gamescope). + let fixed_pod: Option<(u32, u32)> = std::env::var("LUMEN_PW_FIXED_POD") + .ok() + .and_then(|v| v.split_once('x').map(|(w, h)| (w.parse(), h.parse()))) + .and_then(|(w, h)| Some((w.ok()?, h.ok()?))); + // Request raw video in any encoder-mappable layout, any size/framerate. - let obj = pw::spa::pod::object!( - pw::spa::utils::SpaTypes::ObjectParamFormat, - pw::spa::param::ParamType::EnumFormat, - pw::spa::pod::property!( - pw::spa::param::format::FormatProperties::MediaType, - Id, - pw::spa::param::format::MediaType::Video - ), - pw::spa::pod::property!( - pw::spa::param::format::FormatProperties::MediaSubtype, - Id, - pw::spa::param::format::MediaSubtype::Raw - ), - // Offer the layouts the encoder can map to an NVENC input format. wlroots - // commonly fixates packed RGB (3 bpp); other compositors offer 4 bpp. Only - // these are requested, so negotiation fails loudly rather than handing us a - // format we'd misinterpret. - pw::spa::pod::property!( - pw::spa::param::format::FormatProperties::VideoFormat, - Choice, - Enum, - Id, - VideoFormat::RGB, - VideoFormat::RGB, - VideoFormat::BGR, - VideoFormat::RGBx, - VideoFormat::BGRx, - VideoFormat::RGBA, - VideoFormat::BGRA, - ), - pw::spa::pod::property!( - pw::spa::param::format::FormatProperties::VideoSize, - Choice, - Range, - Rectangle, - pw::spa::utils::Rectangle { - width: 1920, - height: 1080 - }, - pw::spa::utils::Rectangle { - width: 1, - height: 1 - }, - pw::spa::utils::Rectangle { - width: 8192, - height: 8192 - } - ), - pw::spa::pod::property!( - pw::spa::param::format::FormatProperties::VideoFramerate, - Choice, - Range, - Fraction, - pw::spa::utils::Fraction { num: 60, denom: 1 }, - pw::spa::utils::Fraction { num: 0, denom: 1 }, - pw::spa::utils::Fraction { num: 240, denom: 1 } - ), - ); + let obj = if let Some((fw, fh)) = fixed_pod { + tracing::info!(fw, fh, "PW DEBUG: offering fixed BGRx pod"); + pw::spa::pod::object!( + pw::spa::utils::SpaTypes::ObjectParamFormat, + pw::spa::param::ParamType::EnumFormat, + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::MediaType, + Id, + pw::spa::param::format::MediaType::Video + ), + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::MediaSubtype, + Id, + pw::spa::param::format::MediaSubtype::Raw + ), + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::VideoFormat, + Id, + VideoFormat::BGRx + ), + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::VideoSize, + Rectangle, + pw::spa::utils::Rectangle { + width: fw, + height: fh + } + ), + pw::spa::pod::property!( + pw::spa::param::format::FormatProperties::VideoFramerate, + Fraction, + pw::spa::utils::Fraction { num: 0, denom: 1 } + ), + ) + } else { + build_default_format_obj() + }; // When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers // (offering shm too makes the compositor pick shm). The modifier list is advertised with @@ -750,7 +900,9 @@ mod pipewire { Some(build_dmabuf_buffers()?), ) } else { - (None, None) + // CPU path still accepts mappable dmabufs (gamescope offers only those once its + // modifier-bearing format pod wins the intersection). + (None, Some(build_mappable_buffers()?)) }; let mut byte_slices: Vec<&[u8]> = Vec::new(); diff --git a/crates/lumen-host/src/vdisplay/gamescope.rs b/crates/lumen-host/src/vdisplay/gamescope.rs index 2719e18..ac7c236 100644 --- a/crates/lumen-host/src/vdisplay/gamescope.rs +++ b/crates/lumen-host/src/vdisplay/gamescope.rs @@ -34,6 +34,19 @@ impl VirtualDisplay for GamescopeDisplay { } fn create(&mut self, mode: Mode) -> Result { + // Attach to an already-running gamescope (debug / Steam-launched session) instead of + // spawning one: LUMEN_GAMESCOPE_NODE=. + if let Ok(id) = std::env::var("LUMEN_GAMESCOPE_NODE") { + let node_id: u32 = id + .parse() + .context("LUMEN_GAMESCOPE_NODE must be a node id")?; + tracing::info!(node_id, "gamescope: attaching to existing PipeWire node"); + return Ok(VirtualOutput { + node_id, + remote_fd: None, + keepalive: Box::new(()), + }); + } let proc = GamescopeProc(spawn(mode.width, mode.height, mode.refresh_hz.max(1))?); // gamescope creates its PipeWire node a moment after start; poll for it (the proc is held // alive meanwhile, and killed if we give up). @@ -84,20 +97,38 @@ fn spawn(w: u32, h: u32, hz: u32) -> Result { .context("spawn gamescope (is it installed? `apt install gamescope`)") } -/// Poll `pw-dump` for gamescope's PipeWire node until it appears or `timeout` elapses. +/// Wait for gamescope to report its PipeWire node. Authoritative source: gamescope's own log +/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects +/// — the adapter and the inner stream — and only the advertised id is the correct capture +/// target). Falls back to `pw-dump` discovery if the log line doesn't show. fn wait_for_node(timeout: Duration) -> Option { let deadline = Instant::now() + timeout; loop { - if let Some(id) = find_gamescope_node() { + if let Some(id) = node_from_log() { return Some(id); } if Instant::now() >= deadline { - return None; + return find_gamescope_node(); // last-resort fallback } std::thread::sleep(Duration::from_millis(300)); } } +/// Parse `stream available on node ID: N` from the spawned gamescope's log (ANSI-colored). +fn node_from_log() -> Option { + let log = std::fs::read_to_string("/tmp/lumen-gamescope.log").ok()?; + for line in log.lines().rev() { + if let Some(pos) = line.find("stream available on node ID:") { + let tail = &line[pos + "stream available on node ID:".len()..]; + let digits: String = tail.chars().filter(|c| c.is_ascii_digit()).collect(); + if let Ok(id) = digits.parse() { + return Some(id); + } + } + } + None +} + /// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon. fn find_gamescope_node() -> Option { let out = Command::new("pw-dump").output().ok()?;