diff --git a/crates/punktfunk-host/src/capture/linux.rs b/crates/punktfunk-host/src/capture/linux.rs index 9c2ae75..eddd053 100644 --- a/crates/punktfunk-host/src/capture/linux.rs +++ b/crates/punktfunk-host/src/capture/linux.rs @@ -11,9 +11,11 @@ //! frames leave the pipewire thread over a bounded channel. The authoritative frame size //! comes from the negotiated PipeWire format, not the portal's size hint. //! -//! Cleanup note (M0): the two threads are detached and torn down at process exit. A -//! graceful stop (pipewire `channel` quit + Session close) belongs with the M2 session -//! lifecycle. +//! Cleanup: the pipewire thread is stopped deterministically — [`PortalCapturer`]'s `Drop` +//! sends a pipewire `channel` quit and joins the thread, so dropping a capturer (session end, +//! or a retried/failed pipeline build) releases its EGL importer / CUDA context promptly +//! instead of leaking it to process exit. The portal thread (when used) still parks on its zbus +//! connection until process exit. use super::{CapturedFrame, Capturer, FramePayload, PixelFormat}; use anyhow::{anyhow, Context, Result}; @@ -31,6 +33,19 @@ use std::time::Duration; pub struct PortalCapturer { frames: Receiver, active: Arc, + /// Set true once the PipeWire stream agrees a video format. Read in [`next_frame`]'s timeout + /// branch to tell "format never negotiated" (modifier/format mismatch) apart from "negotiated + /// but no buffers arrived" (compositor idle/unmapped) — the two black-screen root causes. + negotiated: Arc, + /// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis. + node_id: u32, + /// Stops the PipeWire loop on teardown (sent in `Drop`). Without it a dropped or failed + /// capturer leaks its PipeWire thread — and its EGL importer / CUDA context — because + /// `mainloop.run()` otherwise blocks until process exit. `Option` so `Drop` can take it. + quit: Option<::pipewire::channel::Sender<()>>, + /// Joined in `Drop` (after `quit`) so teardown is synchronous: the importer/CUDA context is + /// released before the next pipeline builds, not left racing it. + join: Option>, /// Owns the virtual output (if this capturer was built from one) — dropped when the capturer /// is, releasing the compositor-side output via the keepalive's own `Drop`. `None` for the /// portal source (its session ends with the portal thread's zbus connection). @@ -64,12 +79,7 @@ impl PortalCapturer { node_id, "ScreenCast portal session started; connecting PipeWire" ); - let (frames, active) = spawn_pipewire(Some(fd), node_id, None)?; - Ok(PortalCapturer { - frames, - active, - _keepalive: None, - }) + Ok(spawn_pipewire(Some(fd), node_id, None)?.into_capturer(node_id, None)) } /// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]): @@ -81,40 +91,84 @@ impl PortalCapturer { node_id = vout.node_id, "connecting PipeWire to virtual output" ); - let (frames, active) = spawn_pipewire(vout.remote_fd, vout.node_id, vout.preferred_mode)?; - Ok(PortalCapturer { - frames, - active, - _keepalive: Some(vout.keepalive), - }) + let node_id = vout.node_id; + Ok( + spawn_pipewire(vout.remote_fd, node_id, vout.preferred_mode)? + .into_capturer(node_id, Some(vout.keepalive)), + ) + } +} + +/// Live PipeWire-thread handles returned by [`spawn_pipewire`]: the frame channel, the +/// activation flag the per-frame copy gates on, a "format negotiated" flag (timeout diagnostics), +/// a quit sender that stops the loop, and the thread's join handle (synchronous teardown). +struct PwHandles { + frames: Receiver, + active: Arc, + negotiated: Arc, + quit: ::pipewire::channel::Sender<()>, + join: thread::JoinHandle<()>, +} + +impl PwHandles { + /// Assemble a [`PortalCapturer`] around these handles. `node_id` is carried for diagnostics; + /// `keepalive` owns the virtual output (drops after the PipeWire thread is joined). + fn into_capturer(self, node_id: u32, keepalive: Option>) -> PortalCapturer { + PortalCapturer { + frames: self.frames, + active: self.active, + negotiated: self.negotiated, + node_id, + quit: Some(self.quit), + join: Some(self.join), + _keepalive: keepalive, + } } } /// Spawn the PipeWire consumer thread for `node_id` (fd `Some` = portal remote, `None` = -/// default daemon) and return the frame channel + the activation flag it gates on. -/// `preferred` seeds the format negotiation's default size/framerate — for Mutter virtual -/// monitors this is what actually sizes the monitor. +/// default daemon) and return its [`PwHandles`]. `preferred` seeds the format negotiation's +/// default size/framerate — for Mutter virtual monitors this is what actually sizes the monitor. fn spawn_pipewire( fd: Option, node_id: u32, preferred: Option<(u32, u32, u32)>, -) -> Result<(Receiver, Arc)> { +) -> Result { // Frames flow from the pipewire thread over a small bounded channel. let (frame_tx, frame_rx) = sync_channel::(8); let active = Arc::new(AtomicBool::new(false)); let active_cb = active.clone(); + let negotiated = Arc::new(AtomicBool::new(false)); + let negotiated_cb = negotiated.clone(); + // pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the + // sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the + // inner `mod pipewire` shadows the crate name at this scope. + let (quit_tx, quit_rx) = ::pipewire::channel::channel::<()>(); let zerocopy = crate::zerocopy::enabled(); - thread::Builder::new() + let join = thread::Builder::new() .name("punktfunk-pipewire".into()) .spawn(move || { - if let Err(e) = - pipewire::pipewire_thread(fd, node_id, frame_tx, active_cb, zerocopy, preferred) - { + if let Err(e) = pipewire::pipewire_thread( + fd, + node_id, + frame_tx, + active_cb, + negotiated_cb, + zerocopy, + preferred, + quit_rx, + ) { tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed"); } }) .context("spawn pipewire thread")?; - Ok((frame_rx, active)) + Ok(PwHandles { + frames: frame_rx, + active, + negotiated, + quit: quit_tx, + join, + }) } impl Capturer for PortalCapturer { @@ -122,8 +176,30 @@ impl Capturer for PortalCapturer { // First frame can lag behind format negotiation; later frames arrive at ~fps. match self.frames.recv_timeout(Duration::from_secs(10)) { Ok(frame) => Ok(frame), - Err(RecvTimeoutError::Timeout) => Err(anyhow!("no PipeWire frame within 10s")), - Err(RecvTimeoutError::Disconnected) => Err(anyhow!("PipeWire capture thread ended")), + Err(RecvTimeoutError::Timeout) => { + // Split the two black-screen root causes apart so the operator gets a cause, not + // just a symptom: did the format negotiate (compositor produced no buffers) or + // not (no acceptable format / node never emitted a param)? + if self.negotiated.load(Ordering::Relaxed) { + Err(anyhow!( + "no PipeWire frame within 10s (node {}): format negotiated but no buffers \ + arrived — the compositor produced no frames (virtual output idle/unmapped, \ + or capture never started)", + self.node_id + )) + } else { + Err(anyhow!( + "no PipeWire frame within 10s (node {}): format negotiation never \ + completed — the compositor offered no format this consumer accepts \ + (pixel-format/modifier mismatch) or the node never emitted a Format param", + self.node_id + )) + } + } + Err(RecvTimeoutError::Disconnected) => Err(anyhow!( + "PipeWire capture thread ended before a frame (node {})", + self.node_id + )), } } @@ -148,6 +224,22 @@ impl Capturer for PortalCapturer { } } +impl Drop for PortalCapturer { + fn drop(&mut self) { + // Stop the PipeWire loop and wait for the thread to unwind BEFORE the keepalive (virtual + // output) drops: quit → join releases the EGL importer / CUDA context, then field-drop + // order releases the output. Without this, `mainloop.run()` blocks until process exit, so + // every dropped/failed capturer (e.g. a retried first-frame attempt) leaks a thread + GPU + // context. `send` errors only if the thread already exited — then `join` returns at once. + if let Some(quit) = self.quit.take() { + let _ = quit.send(()); + } + if let Some(join) = self.join.take() { + let _ = join.join(); + } + } +} + /// The portal handshake: connect ScreenCast, select a single monitor, start, open the /// PipeWire remote, hand the fd + node id back, then keep the session alive. fn portal_thread(setup_tx: std::sync::mpsc::Sender>) { @@ -369,6 +461,9 @@ mod pipewire { tx: SyncSender, /// When false (no active stream), skip the de-pad copy — the buffer is just released. active: Arc, + /// Set once a video format is agreed (`param_changed`), so a first-frame timeout can tell + /// "format never negotiated" apart from "negotiated but no buffers arrived". + negotiated: Arc, /// Present when zero-copy is enabled: imports a dmabuf → CUDA device buffer. importer: Option, } @@ -583,17 +678,28 @@ mod pipewire { }) } + #[allow(clippy::too_many_arguments)] pub fn pipewire_thread( fd: Option, node_id: u32, tx: SyncSender, active: Arc, + negotiated: Arc, zerocopy: bool, preferred: Option<(u32, u32, u32)>, + quit_rx: pw::channel::Receiver<()>, ) -> Result<()> { crate::pwinit::ensure_init(); let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?; + // A quit signal (capturer `Drop`) lands here on the loop thread and stops `run()` so the + // thread unwinds instead of blocking to process exit. Hold the attachment for the loop's + // life; the cloned loop handle is the one the callback quits. + let quit_loop = mainloop.clone(); + let _quit_attach = quit_rx.attach(mainloop.loop_(), move |()| { + tracing::debug!("pipewire: quit signal received — stopping capture loop"); + quit_loop.quit(); + }); let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?; // A portal source hands us an fd to a (sandboxed) PipeWire remote; the KWin // virtual-output source has no fd — its node lives on the user's default daemon. @@ -647,6 +753,7 @@ mod pipewire { modifier: 0, tx, active, + negotiated, importer, }; @@ -687,6 +794,7 @@ mod pipewire { return; } if ud.info.parse(param).is_ok() { + ud.negotiated.store(true, Ordering::Relaxed); let sz = ud.info.size(); ud.format = map_format(ud.info.format()); ud.modifier = ud.info.modifier(); diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index 8f20be7..b7daf7a 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -822,7 +822,8 @@ fn virtual_stream( let compositor = crate::vdisplay::detect().context("detect compositor")?; tracing::info!(?compositor, ?mode, "punktfunk/1 virtual display"); let mut vd = crate::vdisplay::open(compositor)?; - let (mut capturer, mut enc, mut frame, mut interval) = build_pipeline(&mut vd, mode)?; + let (mut capturer, mut enc, mut frame, mut interval) = + build_pipeline_with_retry(&mut vd, mode)?; let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64); let mut next = std::time::Instant::now(); @@ -885,11 +886,98 @@ type Pipeline = ( std::time::Duration, ); +/// Build the pipeline, retrying *transient* failures with bounded exponential backoff. +/// +/// Bringing a virtual output to first-frame races several async steps — the compositor parenting +/// the output, the portal/RemoteDesktop grant, PipeWire format negotiation — any of which can +/// momentarily time out on a cold session. A single timed-out attempt shouldn't abort the whole +/// punktfunk/1 session. But a *permanent* failure (unsupported compositor/mode, a KWin too old to +/// create virtual outputs, a missing tool) must fail fast instead of burning the budget — so the +/// error chain is classified and permanent ones short-circuit. Each failed attempt drops its +/// capturer, which (via `PortalCapturer::Drop`) tears the PipeWire thread + virtual output down +/// before the next attempt — no leak across retries. +fn build_pipeline_with_retry( + vd: &mut Box, + mode: punktfunk_core::Mode, +) -> Result { + const MAX_ATTEMPTS: u32 = 4; + let mut backoff = std::time::Duration::from_millis(500); + for attempt in 1..=MAX_ATTEMPTS { + match build_pipeline(vd, mode) { + Ok(pipe) => { + if attempt > 1 { + tracing::info!(attempt, "pipeline up after retry"); + } + return Ok(pipe); + } + Err(e) => { + let chain = format!("{e:#}"); + let permanent = is_permanent_build_error(&chain); + if permanent || attempt == MAX_ATTEMPTS { + let why = if permanent { + "permanent" + } else { + "out of retries" + }; + return Err(e).with_context(|| { + format!("pipeline build failed ({why}) after {attempt} attempt(s)") + }); + } + tracing::warn!( + attempt, + max = MAX_ATTEMPTS, + backoff_ms = backoff.as_millis() as u64, + error = %chain, + "pipeline build failed — retrying" + ); + std::thread::sleep(backoff); + backoff = (backoff * 2).min(std::time::Duration::from_secs(2)); + } + } + } + unreachable!("the final attempt returns inside the loop") +} + +/// Is a pipeline-build error permanent (retrying won't help within this session)? Matches the +/// error chain against signatures that don't change between attempts: unsupported compositor or +/// mode, a KWin too old to expose virtual outputs, a missing/unparseable config, a tool that +/// isn't installed. Everything else — portal/PipeWire negotiation timeouts, "no frame within +/// 10s", transient node races — is treated as transient and retried. Biased toward "transient": +/// a misjudged permanent error only costs a few seconds before it fails anyway. +fn is_permanent_build_error(chain: &str) -> bool { + const PERMANENT: &[&str] = &[ + "virtual displays require linux", + "unknown punktfunk_compositor", + "could not detect compositor", + "could not find output", // KWin < 6.5.6: createVirtualOutput unsupported + "must be a node id", // PUNKTFUNK_GAMESCOPE_NODE not an integer + "is it installed", // gamescope / kscreen-doctor not on PATH + ]; + let lower = chain.to_ascii_lowercase(); + PERMANENT.iter().any(|p| lower.contains(p)) +} + fn build_pipeline( vd: &mut Box, mode: punktfunk_core::Mode, ) -> Result { let vout = vd.create(mode).context("create virtual output")?; + // The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a + // virtual output at 60 Hz if the custom-mode install was rejected). Pace the encoder + frame + // clock to that, not the requested rate, so we don't emit phantom duplicate frames over a + // slower source. Falls back to the requested rate when a backend reports nothing. + let effective_hz = vout + .preferred_mode + .map(|(_, _, hz)| hz) + .filter(|&hz| hz > 0) + .unwrap_or(mode.refresh_hz); + if effective_hz != mode.refresh_hz { + tracing::warn!( + requested = mode.refresh_hz, + effective = effective_hz, + "compositor did not honor the requested refresh — encoding at the achieved rate" + ); + } let mut capturer = crate::capture::capture_virtual_output(vout).context("capture virtual output")?; capturer.set_active(true); @@ -899,12 +987,12 @@ fn build_pipeline( frame.format, frame.width, frame.height, - mode.refresh_hz, + effective_hz, 20_000_000, frame.is_cuda(), ) .context("open NVENC")?; - let interval = std::time::Duration::from_secs_f64(1.0 / mode.refresh_hz.max(1) as f64); + let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64); Ok((capturer, enc, frame, interval)) } @@ -912,6 +1000,29 @@ fn build_pipeline( mod tests { use super::*; + #[test] + fn permanent_errors_short_circuit_retry() { + // Permanent: config / version / missing-tool — retrying within a session can't fix these. + assert!(is_permanent_build_error( + "create virtual output: KWin virtual output failed: Could not find output" + )); + assert!(is_permanent_build_error( + "unknown PUNKTFUNK_COMPOSITOR 'foo' (kwin|wlroots|mutter|gamescope)" + )); + assert!(is_permanent_build_error( + "spawn gamescope (is it installed? `apt install gamescope`)" + )); + assert!(is_permanent_build_error("virtual displays require Linux")); + // Transient: negotiation/timeout races — exactly what backoff is for. + assert!(!is_permanent_build_error( + "first frame: no PipeWire frame within 10s (node 42): format negotiation never completed" + )); + assert!(!is_permanent_build_error( + "create virtual output: timed out creating the KWin virtual output" + )); + assert!(!is_permanent_build_error("open NVENC: device busy")); + } + fn gp(kind: InputKind, code: u32, x: i32, pad: u32) -> InputEvent { InputEvent { kind, diff --git a/crates/punktfunk-host/src/vdisplay/gamescope.rs b/crates/punktfunk-host/src/vdisplay/gamescope.rs index e5f7043..8630f27 100644 --- a/crates/punktfunk-host/src/vdisplay/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/gamescope.rs @@ -48,6 +48,7 @@ impl VirtualDisplay for GamescopeDisplay { keepalive: Box::new(()), }); } + check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions 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). @@ -147,24 +148,92 @@ fn node_from_log() -> Option { } /// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon. +/// +/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only +/// the one whose `media.class` is `Video/Source` is a valid capture target — connecting to the +/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match +/// only if no class-tagged node is present (older gamescope that doesn't set media.class). fn find_gamescope_node() -> Option { let out = Command::new("pw-dump").output().ok()?; let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; - for obj in dump.as_array()? { + let nodes = dump.as_array()?; + let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String)> { if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") { - continue; + return None; } + let id = obj.get("id").and_then(|i| i.as_u64())? as u32; let props = obj.get("info").and_then(|i| i.get("props")); let name = props .and_then(|p| p.get("node.name")) .and_then(|n| n.as_str()) - .unwrap_or(""); + .unwrap_or("") + .to_string(); let class = props .and_then(|p| p.get("media.class")) .and_then(|n| n.as_str()) - .unwrap_or(""); - if name == "gamescope" || (class == "Video/Source" && name.contains("gamescope")) { - return obj.get("id").and_then(|i| i.as_u64()).map(|x| x as u32); + .unwrap_or("") + .to_string(); + Some((id, name, class)) + }; + // Preferred: a Video/Source node named (or containing) "gamescope". + for obj in nodes { + if let Some((id, name, class)) = node_props(obj) { + if class == "Video/Source" && (name == "gamescope" || name.contains("gamescope")) { + return Some(id); + } + } + } + // Fallback: a node literally named "gamescope" with no usable class tag. + for obj in nodes { + if let Some((id, name, _)) = node_props(obj) { + if name == "gamescope" { + tracing::warn!( + node_id = id, + "gamescope node has no media.class=Video/Source tag — capturing it anyway" + ); + return Some(id); + } + } + } + None +} + +/// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks +/// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon. +const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22); + +/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing +/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a +/// gate. Returns the parsed version when it could read one. +fn check_gamescope_version() -> Option<(u32, u32, u32)> { + let out = Command::new("gamescope").arg("--version").output().ok()?; + // gamescope prints the version banner to stderr on some builds, stdout on others. + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let ver = parse_version(&text)?; + if ver < MIN_GAMESCOPE { + tracing::warn!( + found = %format!("{}.{}.{}", ver.0, ver.1, ver.2), + min = %format!("{}.{}.{}", MIN_GAMESCOPE.0, MIN_GAMESCOPE.1, MIN_GAMESCOPE.2), + "gamescope is older than the minimum for reliable headless capture — expect a \ + capture deadlock against PipeWire ≥ 1.6 (a wedged link head-blocks the daemon); \ + upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter" + ); + } + Some(ver) +} + +/// Extract the first `X.Y.Z` version triple from arbitrary text (e.g. `gamescope version 3.16.22`). +fn parse_version(text: &str) -> Option<(u32, u32, u32)> { + for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) { + let mut parts = token.split('.'); + let (a, b, c) = (parts.next()?, parts.next(), parts.next()); + let (Some(b), Some(c)) = (b, c) else { continue }; + if let (Ok(a), Ok(b), Ok(c)) = (a.parse(), b.parse(), c.parse()) { + return Some((a, b, c)); } } None @@ -179,3 +248,28 @@ impl Drop for GamescopeProc { let _ = self.0.wait(); } } + +#[cfg(test)] +mod tests { + use super::{parse_version, MIN_GAMESCOPE}; + + #[test] + fn parses_version_banner() { + assert_eq!(parse_version("gamescope version 3.16.22"), Some((3, 16, 22))); + assert_eq!( + parse_version("gamescope: version v3.15.9 (no PipeWire)"), + Some((3, 15, 9)) + ); + assert_eq!(parse_version("3.16.20-1.fc41"), Some((3, 16, 20))); + assert_eq!(parse_version("no version here"), None); + assert_eq!(parse_version("only 3.16 here"), None); // needs a full triple + } + + #[test] + fn flags_known_bad_versions() { + // The 26.04-shipped 3.16.20 is below the minimum (PipeWire 1.6 deadlock). + assert!(parse_version("gamescope version 3.16.20").unwrap() < MIN_GAMESCOPE); + assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE); + assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE); + } +} diff --git a/crates/punktfunk-host/src/vdisplay/kwin.rs b/crates/punktfunk-host/src/vdisplay/kwin.rs index 931a153..afd5853 100644 --- a/crates/punktfunk-host/src/vdisplay/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/kwin.rs @@ -91,17 +91,22 @@ impl VirtualDisplay for KwinDisplay { }; tracing::info!(node_id, width, height, "KWin virtual output ready"); // KWin creates virtual outputs at a hardcoded 60 Hz and `stream_virtual_output` has no - // refresh argument, so when the client wants more we install + select a custom mode - // (supported on virtual outputs since KWin 6.6). Done before capture connects PipeWire so - // the stream negotiates at the higher rate. First cut shells out to kscreen-doctor; the - // in-process kde_output_management_v2 client is a follow-up. - if mode.refresh_hz > 60 { - set_custom_refresh(width, height, mode.refresh_hz); - } + // refresh argument, so above 60 Hz we install + select a custom mode (supported on virtual + // outputs since KWin 6.6) before capture connects PipeWire, so the stream negotiates at the + // higher rate. First cut shells out to kscreen-doctor; the in-process + // kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back and + // returns what KWin *actually* achieved so the encoder paces to the real source rate (a + // rejected custom mode leaves the output at 60 Hz). At ≤60 Hz there's nothing to install — + // the source runs 60 Hz and the encoder downsamples — so carry the requested rate through. + let achieved_hz = if mode.refresh_hz > 60 { + set_custom_refresh(width, height, mode.refresh_hz) + } else { + mode.refresh_hz + }; Ok(VirtualOutput { node_id, remote_fd: None, - preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)), + preferred_mode: Some((mode.width, mode.height, achieved_hz)), keepalive: Box::new(StopGuard(stop)), }) } @@ -109,8 +114,11 @@ impl VirtualDisplay for KwinDisplay { /// Best-effort: raise the just-created virtual output's refresh above KWin's default 60 Hz by /// installing + selecting a custom mode via `kscreen-doctor` (the output is `Virtual-`, -/// refresh given in mHz). Failure leaves the source at 60 Hz — the stream still works, just capped. -fn set_custom_refresh(width: u32, height: u32, hz: u32) { +/// refresh given in mHz), then **read back the active mode** and return the refresh KWin actually +/// gave us. The apply command can report success yet leave the output at 60 Hz (mode rejected), +/// and a silent rate mismatch surfaces downstream as judder / duplicated frames — so the caller +/// paces the encoder to the *achieved* rate, not the requested one. +fn set_custom_refresh(width: u32, height: u32, hz: u32) -> u32 { let output = format!("Virtual-{VOUT_NAME}"); let mhz = hz.saturating_mul(1000); let run = |arg: String| { @@ -124,17 +132,70 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32) { let _ = run(format!( "output.{output}.addCustomMode.{width}.{height}.{mhz}.full" )); - if run(format!("output.{output}.mode.{width}x{height}@{hz}")) { - tracing::info!(output, hz, "KWin virtual output: custom refresh applied"); - } else { - tracing::warn!( - output, - hz, - "kscreen-doctor refresh set failed — source stays 60 Hz (is kscreen-doctor installed?)" - ); + let applied = run(format!("output.{output}.mode.{width}x{height}@{hz}")); + match read_active_refresh(&output) { + Some(achieved) if achieved >= hz => { + tracing::info!( + output, + requested = hz, + achieved, + "KWin virtual output: custom refresh applied" + ); + achieved + } + Some(achieved) => { + tracing::warn!( + output, + requested = hz, + achieved, + applied, + "KWin virtual output refresh below requested — pacing the encoder to the achieved \ + rate (custom-mode install rejected? is kscreen-doctor up to date?)" + ); + achieved.max(1) + } + None => { + tracing::warn!( + output, + requested = hz, + applied, + "could not read back KWin virtual output refresh — assuming 60 Hz (is \ + kscreen-doctor installed?)" + ); + 60 + } } } +/// Read the active refresh (Hz, rounded) of `output` from `kscreen-doctor -j`. `None` if the +/// tool, the output, or its current mode can't be found. Mode/output ids come through as either +/// JSON strings or numbers depending on the KWin version, so both are accepted. +fn read_active_refresh(output: &str) -> Option { + let out = std::process::Command::new("kscreen-doctor") + .arg("-j") + .output() + .ok()?; + let doc: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; + let as_id = |v: &serde_json::Value| -> Option { + v.as_str() + .map(|s| s.to_string()) + .or_else(|| v.as_u64().map(|n| n.to_string())) + }; + let o = doc + .get("outputs")? + .as_array()? + .iter() + .find(|o| o.get("name").and_then(|n| n.as_str()) == Some(output))?; + let current = o.get("currentModeId").and_then(as_id)?; + let mode = o + .get("modes")? + .as_array()? + .iter() + .find(|m| m.get("id").and_then(as_id).as_deref() == Some(current.as_str()))?; + let hz = mode.get("refreshRate").and_then(|r| r.as_f64())?; + Some(hz.round() as u32) +} + /// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which /// drops the Wayland connection and makes KWin reclaim the output. struct StopGuard(Arc);