feat: M2 — harden gamescope capture path (blocked on gamescope ≥3.16.22 upstream fix)

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:15:41 +00:00
parent 0d0aa43441
commit e9f9e14da5
3 changed files with 249 additions and 65 deletions
+34 -3
View File
@@ -34,6 +34,19 @@ impl VirtualDisplay for GamescopeDisplay {
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Attach to an already-running gamescope (debug / Steam-launched session) instead of
// spawning one: LUMEN_GAMESCOPE_NODE=<pipewire node id>.
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<Child> {
.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<u32> {
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<u32> {
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<u32> {
let out = Command::new("pw-dump").output().ok()?;