feat(host): KDE-reliability phase 2 — pipeline retry, graceful capture teardown, refresh reconcile
Hardens the virtual-display → capture → encode bring-up against the transient failures that surfaced as black screens / wrong refresh on cold KDE sessions. - m3: build_pipeline_with_retry wraps the initial vd.create() + first-frame with bounded exponential backoff (4 attempts, 500ms→2s). is_permanent_build_error classifies config/version/missing-tool failures so they fail fast instead of burning the retry budget. Encoder + frame clock now pace to the *achieved* refresh reported in VirtualOutput::preferred_mode, not the requested rate. - capture/linux: PortalCapturer::Drop sends a pipewire channel quit and joins the thread, so a dropped/failed/retried capturer releases its PipeWire thread + EGL/ CUDA context promptly instead of leaking it to process exit. First-frame timeout now reports the node id and distinguishes "format never negotiated" from "negotiated but no buffers arrived" via a negotiated flag set in param_changed. - vdisplay/kwin: set_custom_refresh reads back the active mode from kscreen-doctor and returns the refresh KWin actually gave us (a rejected custom mode silently leaves the output at 60Hz); create() carries it into preferred_mode. - vdisplay/gamescope: find_gamescope_node requires the Video/Source object (the node.name=gamescope tag is on two objects; the other wedges the link); a version check warns on <3.16.22 (the PipeWire-1.6 capture-deadlock signature). Live-validated against headless KWin: 720p120 build with requested=120 achieved=120, zero-copy CUDA frames, and no per-session thread accumulation across back-to-back sessions. Tests: +3 unit (retry classifier, gamescope version parse); 49 host tests green, clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,9 +11,11 @@
|
|||||||
//! frames leave the pipewire thread over a bounded channel. The authoritative frame size
|
//! 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.
|
//! 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
|
//! Cleanup: the pipewire thread is stopped deterministically — [`PortalCapturer`]'s `Drop`
|
||||||
//! graceful stop (pipewire `channel` quit + Session close) belongs with the M2 session
|
//! sends a pipewire `channel` quit and joins the thread, so dropping a capturer (session end,
|
||||||
//! lifecycle.
|
//! 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 super::{CapturedFrame, Capturer, FramePayload, PixelFormat};
|
||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
@@ -31,6 +33,19 @@ use std::time::Duration;
|
|||||||
pub struct PortalCapturer {
|
pub struct PortalCapturer {
|
||||||
frames: Receiver<CapturedFrame>,
|
frames: Receiver<CapturedFrame>,
|
||||||
active: Arc<AtomicBool>,
|
active: Arc<AtomicBool>,
|
||||||
|
/// 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<AtomicBool>,
|
||||||
|
/// 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<thread::JoinHandle<()>>,
|
||||||
/// Owns the virtual output (if this capturer was built from one) — dropped when the capturer
|
/// 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
|
/// 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).
|
/// portal source (its session ends with the portal thread's zbus connection).
|
||||||
@@ -64,12 +79,7 @@ impl PortalCapturer {
|
|||||||
node_id,
|
node_id,
|
||||||
"ScreenCast portal session started; connecting PipeWire"
|
"ScreenCast portal session started; connecting PipeWire"
|
||||||
);
|
);
|
||||||
let (frames, active) = spawn_pipewire(Some(fd), node_id, None)?;
|
Ok(spawn_pipewire(Some(fd), node_id, None)?.into_capturer(node_id, None))
|
||||||
Ok(PortalCapturer {
|
|
||||||
frames,
|
|
||||||
active,
|
|
||||||
_keepalive: None,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]):
|
/// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]):
|
||||||
@@ -81,40 +91,84 @@ impl PortalCapturer {
|
|||||||
node_id = vout.node_id,
|
node_id = vout.node_id,
|
||||||
"connecting PipeWire to virtual output"
|
"connecting PipeWire to virtual output"
|
||||||
);
|
);
|
||||||
let (frames, active) = spawn_pipewire(vout.remote_fd, vout.node_id, vout.preferred_mode)?;
|
let node_id = vout.node_id;
|
||||||
Ok(PortalCapturer {
|
Ok(
|
||||||
frames,
|
spawn_pipewire(vout.remote_fd, node_id, vout.preferred_mode)?
|
||||||
active,
|
.into_capturer(node_id, Some(vout.keepalive)),
|
||||||
_keepalive: 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<CapturedFrame>,
|
||||||
|
active: Arc<AtomicBool>,
|
||||||
|
negotiated: Arc<AtomicBool>,
|
||||||
|
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<Box<dyn Send>>) -> 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` =
|
/// 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.
|
/// default daemon) and return its [`PwHandles`]. `preferred` seeds the format negotiation's
|
||||||
/// `preferred` seeds the format negotiation's default size/framerate — for Mutter virtual
|
/// default size/framerate — for Mutter virtual monitors this is what actually sizes the monitor.
|
||||||
/// monitors this is what actually sizes the monitor.
|
|
||||||
fn spawn_pipewire(
|
fn spawn_pipewire(
|
||||||
fd: Option<OwnedFd>,
|
fd: Option<OwnedFd>,
|
||||||
node_id: u32,
|
node_id: u32,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
) -> Result<(Receiver<CapturedFrame>, Arc<AtomicBool>)> {
|
) -> Result<PwHandles> {
|
||||||
// Frames flow from the pipewire thread over a small bounded channel.
|
// Frames flow from the pipewire thread over a small bounded channel.
|
||||||
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
|
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
|
||||||
let active = Arc::new(AtomicBool::new(false));
|
let active = Arc::new(AtomicBool::new(false));
|
||||||
let active_cb = active.clone();
|
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();
|
let zerocopy = crate::zerocopy::enabled();
|
||||||
thread::Builder::new()
|
let join = thread::Builder::new()
|
||||||
.name("punktfunk-pipewire".into())
|
.name("punktfunk-pipewire".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
if let Err(e) =
|
if let Err(e) = pipewire::pipewire_thread(
|
||||||
pipewire::pipewire_thread(fd, node_id, frame_tx, active_cb, zerocopy, preferred)
|
fd,
|
||||||
{
|
node_id,
|
||||||
|
frame_tx,
|
||||||
|
active_cb,
|
||||||
|
negotiated_cb,
|
||||||
|
zerocopy,
|
||||||
|
preferred,
|
||||||
|
quit_rx,
|
||||||
|
) {
|
||||||
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
|
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.context("spawn pipewire thread")?;
|
.context("spawn pipewire thread")?;
|
||||||
Ok((frame_rx, active))
|
Ok(PwHandles {
|
||||||
|
frames: frame_rx,
|
||||||
|
active,
|
||||||
|
negotiated,
|
||||||
|
quit: quit_tx,
|
||||||
|
join,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Capturer for PortalCapturer {
|
impl Capturer for PortalCapturer {
|
||||||
@@ -122,8 +176,30 @@ impl Capturer for PortalCapturer {
|
|||||||
// First frame can lag behind format negotiation; later frames arrive at ~fps.
|
// First frame can lag behind format negotiation; later frames arrive at ~fps.
|
||||||
match self.frames.recv_timeout(Duration::from_secs(10)) {
|
match self.frames.recv_timeout(Duration::from_secs(10)) {
|
||||||
Ok(frame) => Ok(frame),
|
Ok(frame) => Ok(frame),
|
||||||
Err(RecvTimeoutError::Timeout) => Err(anyhow!("no PipeWire frame within 10s")),
|
Err(RecvTimeoutError::Timeout) => {
|
||||||
Err(RecvTimeoutError::Disconnected) => Err(anyhow!("PipeWire capture thread ended")),
|
// 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
|
/// 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.
|
/// PipeWire remote, hand the fd + node id back, then keep the session alive.
|
||||||
fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>) {
|
fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>) {
|
||||||
@@ -369,6 +461,9 @@ mod pipewire {
|
|||||||
tx: SyncSender<CapturedFrame>,
|
tx: SyncSender<CapturedFrame>,
|
||||||
/// When false (no active stream), skip the de-pad copy — the buffer is just released.
|
/// When false (no active stream), skip the de-pad copy — the buffer is just released.
|
||||||
active: Arc<AtomicBool>,
|
active: Arc<AtomicBool>,
|
||||||
|
/// 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<AtomicBool>,
|
||||||
/// Present when zero-copy is enabled: imports a dmabuf → CUDA device buffer.
|
/// Present when zero-copy is enabled: imports a dmabuf → CUDA device buffer.
|
||||||
importer: Option<crate::zerocopy::EglImporter>,
|
importer: Option<crate::zerocopy::EglImporter>,
|
||||||
}
|
}
|
||||||
@@ -583,17 +678,28 @@ mod pipewire {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn pipewire_thread(
|
pub fn pipewire_thread(
|
||||||
fd: Option<OwnedFd>,
|
fd: Option<OwnedFd>,
|
||||||
node_id: u32,
|
node_id: u32,
|
||||||
tx: SyncSender<CapturedFrame>,
|
tx: SyncSender<CapturedFrame>,
|
||||||
active: Arc<AtomicBool>,
|
active: Arc<AtomicBool>,
|
||||||
|
negotiated: Arc<AtomicBool>,
|
||||||
zerocopy: bool,
|
zerocopy: bool,
|
||||||
preferred: Option<(u32, u32, u32)>,
|
preferred: Option<(u32, u32, u32)>,
|
||||||
|
quit_rx: pw::channel::Receiver<()>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
crate::pwinit::ensure_init();
|
crate::pwinit::ensure_init();
|
||||||
|
|
||||||
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
|
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")?;
|
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
|
// 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.
|
// virtual-output source has no fd — its node lives on the user's default daemon.
|
||||||
@@ -647,6 +753,7 @@ mod pipewire {
|
|||||||
modifier: 0,
|
modifier: 0,
|
||||||
tx,
|
tx,
|
||||||
active,
|
active,
|
||||||
|
negotiated,
|
||||||
importer,
|
importer,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -687,6 +794,7 @@ mod pipewire {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ud.info.parse(param).is_ok() {
|
if ud.info.parse(param).is_ok() {
|
||||||
|
ud.negotiated.store(true, Ordering::Relaxed);
|
||||||
let sz = ud.info.size();
|
let sz = ud.info.size();
|
||||||
ud.format = map_format(ud.info.format());
|
ud.format = map_format(ud.info.format());
|
||||||
ud.modifier = ud.info.modifier();
|
ud.modifier = ud.info.modifier();
|
||||||
|
|||||||
@@ -822,7 +822,8 @@ fn virtual_stream(
|
|||||||
let compositor = crate::vdisplay::detect().context("detect compositor")?;
|
let compositor = crate::vdisplay::detect().context("detect compositor")?;
|
||||||
tracing::info!(?compositor, ?mode, "punktfunk/1 virtual display");
|
tracing::info!(?compositor, ?mode, "punktfunk/1 virtual display");
|
||||||
let mut vd = crate::vdisplay::open(compositor)?;
|
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 deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64);
|
||||||
let mut next = std::time::Instant::now();
|
let mut next = std::time::Instant::now();
|
||||||
@@ -885,11 +886,98 @@ type Pipeline = (
|
|||||||
std::time::Duration,
|
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<dyn crate::vdisplay::VirtualDisplay>,
|
||||||
|
mode: punktfunk_core::Mode,
|
||||||
|
) -> Result<Pipeline> {
|
||||||
|
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(
|
fn build_pipeline(
|
||||||
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
||||||
mode: punktfunk_core::Mode,
|
mode: punktfunk_core::Mode,
|
||||||
) -> Result<Pipeline> {
|
) -> Result<Pipeline> {
|
||||||
let vout = vd.create(mode).context("create virtual output")?;
|
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 =
|
let mut capturer =
|
||||||
crate::capture::capture_virtual_output(vout).context("capture virtual output")?;
|
crate::capture::capture_virtual_output(vout).context("capture virtual output")?;
|
||||||
capturer.set_active(true);
|
capturer.set_active(true);
|
||||||
@@ -899,12 +987,12 @@ fn build_pipeline(
|
|||||||
frame.format,
|
frame.format,
|
||||||
frame.width,
|
frame.width,
|
||||||
frame.height,
|
frame.height,
|
||||||
mode.refresh_hz,
|
effective_hz,
|
||||||
20_000_000,
|
20_000_000,
|
||||||
frame.is_cuda(),
|
frame.is_cuda(),
|
||||||
)
|
)
|
||||||
.context("open NVENC")?;
|
.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))
|
Ok((capturer, enc, frame, interval))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,6 +1000,29 @@ fn build_pipeline(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn gp(kind: InputKind, code: u32, x: i32, pad: u32) -> InputEvent {
|
||||||
InputEvent {
|
InputEvent {
|
||||||
kind,
|
kind,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ impl VirtualDisplay for GamescopeDisplay {
|
|||||||
keepalive: Box::new(()),
|
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))?);
|
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
|
// gamescope creates its PipeWire node a moment after start; poll for it (the proc is held
|
||||||
// alive meanwhile, and killed if we give up).
|
// alive meanwhile, and killed if we give up).
|
||||||
@@ -147,24 +148,92 @@ fn node_from_log() -> Option<u32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon.
|
/// 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<u32> {
|
fn find_gamescope_node() -> Option<u32> {
|
||||||
let out = Command::new("pw-dump").output().ok()?;
|
let out = Command::new("pw-dump").output().ok()?;
|
||||||
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).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") {
|
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 props = obj.get("info").and_then(|i| i.get("props"));
|
||||||
let name = props
|
let name = props
|
||||||
.and_then(|p| p.get("node.name"))
|
.and_then(|p| p.get("node.name"))
|
||||||
.and_then(|n| n.as_str())
|
.and_then(|n| n.as_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
let class = props
|
let class = props
|
||||||
.and_then(|p| p.get("media.class"))
|
.and_then(|p| p.get("media.class"))
|
||||||
.and_then(|n| n.as_str())
|
.and_then(|n| n.as_str())
|
||||||
.unwrap_or("");
|
.unwrap_or("")
|
||||||
if name == "gamescope" || (class == "Video/Source" && name.contains("gamescope")) {
|
.to_string();
|
||||||
return obj.get("id").and_then(|i| i.as_u64()).map(|x| x as u32);
|
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
|
None
|
||||||
@@ -179,3 +248,28 @@ impl Drop for GamescopeProc {
|
|||||||
let _ = self.0.wait();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,17 +91,22 @@ impl VirtualDisplay for KwinDisplay {
|
|||||||
};
|
};
|
||||||
tracing::info!(node_id, width, height, "KWin virtual output ready");
|
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
|
// 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
|
// refresh argument, so above 60 Hz we install + select a custom mode (supported on virtual
|
||||||
// (supported on virtual outputs since KWin 6.6). Done before capture connects PipeWire so
|
// outputs since KWin 6.6) before capture connects PipeWire, so the stream negotiates at the
|
||||||
// the stream negotiates at the higher rate. First cut shells out to kscreen-doctor; the
|
// higher rate. First cut shells out to kscreen-doctor; the in-process
|
||||||
// in-process kde_output_management_v2 client is a follow-up.
|
// kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back and
|
||||||
if mode.refresh_hz > 60 {
|
// returns what KWin *actually* achieved so the encoder paces to the real source rate (a
|
||||||
set_custom_refresh(width, height, mode.refresh_hz);
|
// 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 {
|
Ok(VirtualOutput {
|
||||||
node_id,
|
node_id,
|
||||||
remote_fd: None,
|
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)),
|
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
|
/// 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-<VOUT_NAME>`,
|
/// installing + selecting a custom mode via `kscreen-doctor` (the output is `Virtual-<VOUT_NAME>`,
|
||||||
/// refresh given in mHz). Failure leaves the source at 60 Hz — the stream still works, just capped.
|
/// refresh given in mHz), then **read back the active mode** and return the refresh KWin actually
|
||||||
fn set_custom_refresh(width: u32, height: u32, hz: u32) {
|
/// 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 output = format!("Virtual-{VOUT_NAME}");
|
||||||
let mhz = hz.saturating_mul(1000);
|
let mhz = hz.saturating_mul(1000);
|
||||||
let run = |arg: String| {
|
let run = |arg: String| {
|
||||||
@@ -124,15 +132,68 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32) {
|
|||||||
let _ = run(format!(
|
let _ = run(format!(
|
||||||
"output.{output}.addCustomMode.{width}.{height}.{mhz}.full"
|
"output.{output}.addCustomMode.{width}.{height}.{mhz}.full"
|
||||||
));
|
));
|
||||||
if run(format!("output.{output}.mode.{width}x{height}@{hz}")) {
|
let applied = run(format!("output.{output}.mode.{width}x{height}@{hz}"));
|
||||||
tracing::info!(output, hz, "KWin virtual output: custom refresh applied");
|
match read_active_refresh(&output) {
|
||||||
} else {
|
Some(achieved) if achieved >= hz => {
|
||||||
|
tracing::info!(
|
||||||
|
output,
|
||||||
|
requested = hz,
|
||||||
|
achieved,
|
||||||
|
"KWin virtual output: custom refresh applied"
|
||||||
|
);
|
||||||
|
achieved
|
||||||
|
}
|
||||||
|
Some(achieved) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
output,
|
output,
|
||||||
hz,
|
requested = hz,
|
||||||
"kscreen-doctor refresh set failed — source stays 60 Hz (is kscreen-doctor installed?)"
|
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<u32> {
|
||||||
|
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<String> {
|
||||||
|
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
|
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
|
||||||
|
|||||||
Reference in New Issue
Block a user