fix(pf-capture): portal teardown, observable death, one negotiation resolver (sweep 2.1–2.3)

**2.1 (L1) — the portal thread leaked a live screencast session.** It parked on
`future::pending()` and `Drop` stopped only the PipeWire thread, so every dropped portal capturer
permanently leaked a thread + a 2-worker tokio runtime + a zbus connection — and because dropping
that connection is what ends the compositor's cast (ashpd's `Session` has no `Drop`), the
COMPOSITOR-side session leaked too. GameStream drops the pooled capturer on every HDR↔SDR
reconnect flip and opens a fresh one, so the sessions accumulated: exactly the "second conflicting
screencast" the pooling exists to prevent. Both portal threads now park on a `oneshot::Receiver`
and `PortalSession::drop` fires it, then waits — BOUNDED (750 ms, then detach with a warning) —
for a completion signal the thread sends after its runtime has been dropped. Bounded because
teardown runs on the session path and the thread may be inside a D-Bus round-trip: an unbounded
`join()` there would hang the host rather than leak. Every early-return path in `open` drops the
session too, so a failed PipeWire spawn no longer strands a fresh cast.

**2.2 (L2) — a dead capturer was pooled forever.** All three of the portal backend's terminal
states are sticky and observable only by consuming a frame, and `Capturer` exposed no health
predicate, so GameStream re-pooled unconditionally — after `result` was already an `Err`. One
zerocopy-worker death or one compositor restart therefore wedged GameStream portal video at 10 s
per reconnect attempt, permanently (that path has no rebuild closure). Adds
`Capturer::is_alive()` (default `true`), implemented for `PortalCapturer` as
`!broken && streaming && !thread.is_finished()` — the third term catches a dead PipeWire thread,
which is otherwise indistinguishable from an idle desktop. A static desktop stays `Streaming`, so
an idle-but-healthy capture is never reported dead. The host re-pools only on
`result.is_ok() && capturer.is_alive()` and logs which of the two retired it. Both halves ship
together: either alone leaves the wedge reachable.

**2.3 (L3/F1) — a "VAAPI" downgrade latch was really a global zero-copy kill switch.**
`spawn_pipewire` hand-mirrored the thread's `vaapi_passthrough` decision and the copy omitted
`raw_dmabuf_import_disabled`, so once that latch fired the capturer still believed it had made the
passthrough offer while the thread had already fallen back — and its timeout branch then latched a
downgrade for an offer nobody made. That downgrade was `note_vaapi_dmabuf_failed`, which fed
`pf_zerocopy::enabled()`, so ONE failed negotiation dropped every later session on the host — the
NVENC EGL→CUDA path included — to CPU capture until restart. And since the raw-passthrough offer
is also the PyroWave one on ANY vendor, a single PyroWave negotiation timeout was enough to do it
on an NVIDIA box.

Two fixes, both structural:
  - `negotiation_plan(NegotiationInputs) -> NegotiationPlan` is now the ONE resolver, consumed by
    the thread AND by `spawn_pipewire` — the mirror is deleted, not re-synced (WP7.6's shape), so
    the drift class is unrepresentable. It is pure (every env/latch read is an input), which is
    what makes it testable; the runtime-dependent half stays a method (`want_dmabuf` needs the
    modifier list the importer's construction actually yielded). The redundant `importer.is_none()`
    term goes: `build_importer` already excludes the passthrough, and that term is what made the
    decision look impure enough to "need" a mirror. `PUNKTFUNK_FORCE_SHM` was ALSO read in both
    places; now once.
  - the capture-side downgrade becomes `pf_zerocopy::note_raw_dmabuf_negotiation_failed`, which
    latches `RAW_DMABUF_DISABLED` — gating only the raw-passthrough decision. `enabled()` is now
    the env var alone, and `VAAPI_DMABUF_FAILED` is deleted.

Also (L13, needed by 2.3's plan inputs): `PUNKTFUNK_PIPEWIRE_NV12` honoured only the exact string
`"0"`, so `"0 "` or `"false"` read as force-ON — the bug class of ed525c4c. pf-host-config's
explicit-off grammar is now exported as `env_on()` and the four in-crate copies (`PUNKTFUNK_
ZEROCOPY`/`_10BIT`/`_444`/`_CHACHA20`) plus this new caller share it.

7 new tests pin the four invariants that were prose-only comments, plus the latch property that
made the mirror wrong. pf-capture 13/13; workspace clippy --all-targets clean on Linux and on
x86_64-pc-windows-msvc. On-glass validation of 2.1/2.2/2.3 is owed (sweep Phase 7.1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 10:41:53 +02:00
co-authored by Claude Opus 5
parent 54a37aeb46
commit 17eb8100a3
5 changed files with 630 additions and 158 deletions
+539 -112
View File
@@ -2,7 +2,7 @@
//!
//! Two dedicated threads, because both stacks are tied to their thread:
//! * **portal thread** drives the async ashpd handshake on a multi-thread tokio runtime
//! (control plane — never the per-frame path), then parks on a pending future so the
//! (control plane — never the per-frame path), then parks on a oneshot receiver so the
//! `proxy` + its zbus connection stay alive (the cast is torn down when that connection
//! drops; ashpd's `Session` has no `Drop`);
//! * **pipewire thread** owns the (`!Send`) MainLoop/Stream and pumps frames.
@@ -11,11 +11,12 @@
//! 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: 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.
//! Cleanup: BOTH threads are stopped deterministically — [`PortalCapturer`]'s `Drop` sends a
//! pipewire `channel` quit and joins that thread (releasing its EGL importer / CUDA context
//! promptly instead of leaking it to process exit), and [`PortalSession`]'s `Drop` fires the
//! portal thread's quit oneshot and waits, bounded, for it to unwind — which drops the zbus
//! connection and so ENDS the compositor's ScreenCast session. Dropping a capturer (session end,
//! or a retried/failed pipeline build) therefore leaves nothing behind on either side.
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
@@ -67,10 +68,13 @@ pub struct PortalCapturer {
/// motion on a static desktop; the frame-attached overlay alone goes stale between damage
/// frames (the same gap the Windows IddCx channel fills, and why the tick prefers LIVE).
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
/// True when this capture runs the VAAPI dmabuf passthrough (a LINEAR-dmabuf-only offer). If
/// that offer never negotiates, [`next_frame`](Capturer::next_frame)'s timeout branch latches
/// the process-wide downgrade ([`pf_zerocopy::note_vaapi_dmabuf_failed`]) so the pipeline
/// rebuild retries on the CPU offer instead of failing identically forever.
/// True when this capture runs the raw-dmabuf passthrough offer (VAAPI backend, or ANY vendor on
/// a PyroWave session). Taken verbatim from the ONE
/// [`NegotiationPlan`](pipewire::NegotiationPlan) the thread also consumes — never re-derived
/// here, which is what let this bool drift from the thread's real decision. If the offer never
/// negotiates, [`next_frame`](Capturer::next_frame)'s timeout branch latches the scoped
/// downgrade ([`pf_zerocopy::note_raw_dmabuf_negotiation_failed`]) so the pipeline rebuild
/// retries on the CPU offer instead of failing identically forever.
vaapi_dmabuf: bool,
/// This capture ran the HDR (10-bit PQ/BT.2020 dmabuf) offer — see [`Self::open`]'s
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the
@@ -92,6 +96,10 @@ pub struct PortalCapturer {
/// 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).
_keepalive: Option<Box<dyn Send>>,
/// The portal control-plane thread's teardown handles ([`PortalSession`]). `Some` only on the
/// [`open`](Self::open) path; `None` for a virtual-output capturer (no portal thread). Held
/// purely for its `Drop` (like `_keepalive`) — that is what ends the compositor's screencast.
_portal: Option<PortalSession>,
/// The gamescope XFixes cursor reader (remote-desktop-sweep Phase C), when this capturer
/// serves a gamescope node. `Some` after
/// [`attach_gamescope_cursor`](Capturer::attach_gamescope_cursor); its `Drop` stops the reader
@@ -100,6 +108,50 @@ pub struct PortalCapturer {
_gs_cursor: Option<xfixes_cursor::XFixesCursorSource>,
}
/// Teardown handles for the portal control-plane thread. Firing `quit` un-parks
/// `portal_thread`(`_remote_desktop`), so `rt.block_on` returns, the tokio runtime and the zbus
/// connection drop — and **dropping that connection is what ends the compositor's ScreenCast
/// session** (ashpd's `Session` has no `Drop`). Without this the thread parked forever on
/// `future::pending()`, so every dropped portal capturer permanently leaked a thread, a 2-worker
/// runtime, a zbus connection AND a live screencast: a GameStream HDR↔SDR reconnect drops the
/// pooled capturer and opens a fresh session, so the sessions accumulated — the exact "second
/// conflicting screencast" the capturer pooling exists to prevent.
struct PortalSession {
/// Un-parks the portal thread. `Option` so `Drop` can take (and thereby fire) it; dropping the
/// sender without a send resolves the receiver with `Err`, which the thread treats identically.
quit: Option<tokio::sync::oneshot::Sender<()>>,
/// Signalled by the spawned closure after the runtime has been dropped — lets `Drop` bound its
/// wait instead of blocking on `join()` behind a wedged D-Bus round-trip.
done: Receiver<()>,
join: Option<thread::JoinHandle<()>>,
}
impl Drop for PortalSession {
fn drop(&mut self) {
// Un-park the thread, then wait — BOUNDED — for it to unwind. The bound matters because the
// thread may be inside a D-Bus round-trip against a wedged portal, and teardown runs on the
// session path: an unbounded `join()` there would hang the host, not just leak. On timeout
// we detach (the thread owns only its own runtime + connection and exits on its own once the
// call returns), having at least fired the quit.
drop(self.quit.take()); // send-or-drop: both resolve the receiver
let joinable = match self.done.recv_timeout(Duration::from_millis(750)) {
Ok(()) => true,
Err(_) => {
tracing::warn!(
"portal thread did not unwind within 750ms — detaching it (the compositor's \
ScreenCast session may linger until the host exits)"
);
false
}
};
if let Some(join) = self.join.take() {
if joinable {
let _ = join.join(); // returns immediately: `done` already fired
}
}
}
}
impl PortalCapturer {
/// `anchored` drives ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits the
/// RemoteDesktop grant and never raises a separate ScreenCast dialog; `false` uses a plain
@@ -108,16 +160,29 @@ impl PortalCapturer {
pub fn open(anchored: bool, want_hdr: bool, policy: ZeroCopyPolicy) -> Result<PortalCapturer> {
// Portal handshake (async) on its own thread; hands back the PW fd + node id.
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
thread::Builder::new()
// Teardown plumbing (see `PortalSession`): `quit_rx` parks the thread once the handshake is
// done, `done_tx` reports that it has fully unwound.
let (quit_tx, quit_rx) = tokio::sync::oneshot::channel::<()>();
let (done_tx, done_rx) = sync_channel::<()>(1);
let join = thread::Builder::new()
.name("punktfunk-portal".into())
.spawn(move || {
if anchored {
portal_thread_remote_desktop(setup_tx)
portal_thread_remote_desktop(setup_tx, quit_rx)
} else {
portal_thread(setup_tx)
portal_thread(setup_tx, quit_rx)
}
// After the runtime has been dropped inside the fn above, so a successful
// `recv_timeout` in `Drop` means the zbus connection is really gone. Covers the
// early-return paths too (a runtime that failed to build).
let _ = done_tx.send(());
})
.context("spawn portal thread")?;
let portal = PortalSession {
quit: Some(quit_tx),
done: done_rx,
join: Some(join),
};
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
Ok(Ok(v)) => v,
@@ -130,6 +195,8 @@ impl PortalCapturer {
"ScreenCast portal session started; connecting PipeWire"
);
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
// NOTE the `?`: on a PipeWire-spawn failure `portal` drops here, which fires the quit sender
// and tears the just-created screencast session down instead of stranding it.
Ok(spawn_pipewire(
Some(fd),
node_id,
@@ -140,7 +207,7 @@ impl PortalCapturer {
policy,
false,
)?
.into_capturer(node_id, None))
.into_capturer(node_id, None, Some(portal)))
}
/// Build a capturer from an already-created virtual output's PipeWire node. The host facade
@@ -181,7 +248,9 @@ impl PortalCapturer {
policy,
expect_exact_dims,
)?
.into_capturer(node_id, Some(keepalive)))
// No portal thread on this path: the node belongs to a virtual output the caller created,
// and `keepalive` is what releases it.
.into_capturer(node_id, Some(keepalive), None))
}
}
@@ -210,8 +279,14 @@ struct PwHandles {
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 {
/// `keepalive` owns the virtual output (drops after the PipeWire thread is joined); `portal`
/// carries the control-plane thread's teardown handles on the [`PortalCapturer::open`] path.
fn into_capturer(
self,
node_id: u32,
keepalive: Option<Box<dyn Send>>,
portal: Option<PortalSession>,
) -> PortalCapturer {
PortalCapturer {
frames: self.frames,
pending: None,
@@ -228,6 +303,7 @@ impl PwHandles {
quit: Some(self.quit),
join: Some(self.join),
_keepalive: keepalive,
_portal: portal,
_gs_cursor: None,
}
}
@@ -293,12 +369,24 @@ fn spawn_pipewire(
} else {
want_hdr
};
// Mirror of the thread's `vaapi_passthrough` decision (deterministic from here: on a VAAPI
// backend or a PyroWave session the EGL→CUDA importer is never built) — kept on the capturer
// so `next_frame`'s negotiation-timeout branch knows a failed negotiation was the raw-dmabuf
// passthrough offer.
let vaapi_dmabuf =
zerocopy && !force_shm && (policy.backend_is_vaapi || policy.pyrowave_session);
// THE negotiation decision, resolved once here and handed to the thread — no mirror (L3/F1).
// Every environment/latch read the decision depends on happens at this single point.
let plan = pipewire::negotiation_plan(pipewire::NegotiationInputs {
zerocopy,
force_shm,
want_hdr,
want_444,
backend_is_vaapi: policy.backend_is_vaapi,
pyrowave_session: policy.pyrowave_session,
native_nv12_session: policy.native_nv12_session,
raw_dmabuf_import_disabled: pf_zerocopy::raw_dmabuf_import_disabled(),
gpu_import_disabled: pf_zerocopy::gpu_import_disabled(),
// Default ON; `PUNKTFUNK_PIPEWIRE_NV12=0` (or any falsy spelling — the shared parser, not a
// bare `!= "0"` string compare) restores the packed-RGB negotiation.
native_nv12_env_on: pf_host_config::env_on("PUNKTFUNK_PIPEWIRE_NV12").unwrap_or(true),
});
// The capturer's timeout diagnosis reads the SAME resolved bool the thread acts on.
let vaapi_dmabuf = plan.vaapi_passthrough;
let join = thread::Builder::new()
.name("punktfunk-pipewire".into())
.spawn(move || {
@@ -312,7 +400,7 @@ fn spawn_pipewire(
broken_cb,
hdr_negotiated_cb,
cursor_live_cb,
zerocopy,
plan,
want_444,
want_hdr,
preferred,
@@ -437,6 +525,22 @@ impl Capturer for PortalCapturer {
self.active.store(active, Ordering::Relaxed);
}
/// All three of this backend's terminal states, checked without consuming a frame — every one
/// of them is sticky, so an `Err` from `next_frame`/`try_latest` here is permanent:
/// * `broken` — the zero-copy GPU import is irrecoverably gone for this stream (worker death,
/// or a tiled-import failure streak). Never cleared.
/// * the PipeWire thread has exited (its `Drop`-less death is otherwise indistinguishable from
/// an idle desktop: the frame channel just goes quiet, and `streaming` keeps its last value).
/// * the stream left `Streaming` for good — the compositor or virtual output went away.
///
/// A static desktop stays `Streaming` (it simply sends no buffers), so an idle-but-healthy
/// capture is never reported dead.
fn is_alive(&self) -> bool {
!self.broken.load(Ordering::Relaxed)
&& self.streaming.load(Ordering::Relaxed)
&& self.join.as_ref().is_some_and(|j| !j.is_finished())
}
/// Generic HDR10 mastering metadata once the stream negotiated a 10-bit PQ format. Mutter
/// exposes no per-monitor mastering volume through the screencast, so this is the standard
/// HDR10 default block (BT.2020 primaries, D65 white, 1000 / 0.005 cd/m², CLL unknown) — the
@@ -522,15 +626,20 @@ impl PortalCapturer {
self.node_id
))
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
// The LINEAR-dmabuf-only offer (VAAPI passthrough default) was never accepted.
// Latch the process-wide downgrade so the encode loop's pipeline rebuild
// retries on the CPU offer instead of failing this same negotiation forever.
pf_zerocopy::note_vaapi_dmabuf_failed();
// The dmabuf-only raw-passthrough offer was never accepted. Latch the
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer
// instead of failing this same negotiation forever. The latch is SCOPED to the
// raw-passthrough decision: it used to be `note_vaapi_dmabuf_failed`, which fed
// `pf_zerocopy::enabled()` and therefore dropped every later session on this
// host — NVENC's EGL→CUDA path included — to CPU capture. Since this offer is
// also the PyroWave one (any vendor), a single PyroWave negotiation timeout was
// enough to do that.
pf_zerocopy::note_raw_dmabuf_negotiation_failed();
Err(anyhow!(
"no PipeWire frame within {within}s (node {}): the compositor never \
accepted the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this \
host to the CPU capture path; the pipeline rebuild will renegotiate \
without dmabuf",
accepted the dmabuf-only offer (raw-dmabuf passthrough) — downgrading \
THIS path to CPU capture for the rest of the process; the pipeline \
rebuild will renegotiate without dmabuf",
self.node_id
))
} else {
@@ -691,8 +800,12 @@ async fn choose_cursor_mode(
}
/// 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<Result<(OwnedFd, u32), String>>) {
/// PipeWire remote, hand the fd + node id back, then keep the session alive until `quit_rx`
/// resolves (the capturer's `Drop` — see [`PortalSession`]).
fn portal_thread(
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
quit_rx: tokio::sync::oneshot::Receiver<()>,
) {
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
use ashpd::enumflags2::BitFlags;
@@ -762,9 +875,9 @@ fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String
// Keep `proxy` + `session` (and the underlying zbus connection) alive for the
// capture; the cast is torn down when the connection drops (ashpd's `Session`
// has no `Drop`), which here happens at process exit.
// has no `Drop`) which now happens when this park returns, not at process exit.
let _keep_alive = (&proxy, &session);
std::future::pending::<()>().await;
let _ = quit_rx.await;
Ok(())
}
.await;
@@ -773,6 +886,10 @@ fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String
let _ = err_tx.send(Err(format!("{e:#}")));
}
});
// Drop the runtime HERE, before the caller signals completion: shutting the 2 workers down is
// what finishes releasing the zbus connection, so a `done` signal sent after this means the
// compositor-side session is really gone (see `PortalSession::drop`).
drop(rt);
}
/// Combined RemoteDesktop+ScreenCast portal setup (KWin/GNOME). ScreenCast sources are selected
@@ -780,8 +897,11 @@ fn portal_thread(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String
/// pre-authorized headlessly via the `kde-authorized` permission, exactly like the libei input
/// path — also covers screen capture, with no separate ScreenCast dialog (which has no such
/// bypass). Yields the same PipeWire fd + node id as the standalone path; the consumer is
/// identical.
fn portal_thread_remote_desktop(setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>) {
/// identical, as is the `quit_rx` teardown park (see [`PortalSession`]).
fn portal_thread_remote_desktop(
setup_tx: std::sync::mpsc::Sender<Result<(OwnedFd, u32), String>>,
quit_rx: tokio::sync::oneshot::Receiver<()>,
) {
use ashpd::desktop::remote_desktop::{DeviceType, RemoteDesktop, SelectDevicesOptions};
use ashpd::desktop::screencast::{Screencast, SelectSourcesOptions, SourceType};
use ashpd::desktop::PersistMode;
@@ -861,9 +981,10 @@ fn portal_thread_remote_desktop(setup_tx: std::sync::mpsc::Sender<Result<(OwnedF
.send(Ok((fd, node_id)))
.map_err(|_| anyhow!("capturer dropped before setup completed"))?;
// Keep the proxies + session (and their zbus connection) alive for the capture.
// Keep the proxies + session (and their zbus connection) alive for the capture, until
// the capturer's `Drop` fires the quit channel.
let _keep_alive = (&remote, &screencast, &session);
std::future::pending::<()>().await;
let _ = quit_rx.await;
Ok(())
}
.await;
@@ -872,6 +993,8 @@ fn portal_thread_remote_desktop(setup_tx: std::sync::mpsc::Sender<Result<(OwnedF
let _ = err_tx.send(Err(format!("{e:#}")));
}
});
// See `portal_thread`: drop the runtime before the caller's completion signal.
drop(rt);
}
mod pipewire {
@@ -1022,6 +1145,120 @@ mod pipewire {
gate_since: Option<std::time::Instant>,
}
/// Everything the zero-copy negotiation decision depends on, gathered at ONE point in time.
/// Split out from [`pipewire_thread`]'s prologue so the decision is a pure function of these
/// facts — testable, and consumable by both the thread and `spawn_pipewire` (see
/// [`NegotiationPlan`]).
#[derive(Debug, Clone, Copy)]
pub(super) struct NegotiationInputs {
/// `allow_zerocopy && pf_zerocopy::enabled()`.
pub zerocopy: bool,
/// `PUNKTFUNK_FORCE_SHM` — the race-free download path.
pub force_shm: bool,
/// This session offers the 10-bit PQ/BT.2020 formats.
pub want_hdr: bool,
/// This session negotiated full-chroma 4:4:4.
pub want_444: bool,
/// [`ZeroCopyPolicy::backend_is_vaapi`].
pub backend_is_vaapi: bool,
/// [`ZeroCopyPolicy::pyrowave_session`].
pub pyrowave_session: bool,
/// [`ZeroCopyPolicy::native_nv12_session`].
pub native_nv12_session: bool,
/// `pf_zerocopy::raw_dmabuf_import_disabled()` — the scoped raw-passthrough latch.
pub raw_dmabuf_import_disabled: bool,
/// `pf_zerocopy::gpu_import_disabled()` — repeated import-worker deaths.
pub gpu_import_disabled: bool,
/// `PUNKTFUNK_PIPEWIRE_NV12` (default ON) — allow the producer-side NV12 preference.
pub native_nv12_env_on: bool,
}
/// The resolved zero-copy negotiation decision — **one resolver, consumed by the PipeWire thread
/// AND by `spawn_pipewire`.**
///
/// `spawn_pipewire` used to hand-mirror `vaapi_passthrough` so the capturer's
/// negotiation-timeout branch could tell which offer had failed, and the copy drifted: it omitted
/// `raw_dmabuf_import_disabled`, so after that latch fired the mirror still said "raw
/// passthrough" while the thread had already fallen back — and the timeout branch then latched a
/// downgrade for an offer it had not made. Deriving both consumers from this struct is what makes
/// that class of drift unrepresentable rather than merely fixed (same shape as WP7.6's single
/// Linux encode-backend resolver).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct NegotiationPlan {
/// Build the EGL→CUDA importer for this capture.
pub build_importer: bool,
/// Hand raw dmabufs straight to the encoder instead of importing to CUDA.
pub vaapi_passthrough: bool,
/// Offer gamescope's producer-side NV12 pod ahead of packed RGB.
pub prefer_native_nv12: bool,
/// Carried through so [`want_dmabuf`](Self::want_dmabuf) needs no second copy of it.
pub force_shm: bool,
/// Diagnostic: this capture WOULD have taken the raw passthrough, but its scoped latch is
/// set (the encoder repeatedly failed to import, or a previous negotiation timed out).
pub raw_dmabuf_latched: bool,
/// Diagnostic: this capture WOULD have built the EGL→CUDA importer, but repeated
/// import-worker deaths latched the GPU import off.
pub gpu_import_latched: bool,
}
/// Resolve the negotiation plan. **Pure** — every environment read is already in `i`.
///
/// The four invariants this encodes were previously prose-only comments spread across the
/// prologue; `negotiation_plan_invariants` in the tests below pins each one:
/// 1. HDR never builds the EGL→CUDA importer (its de-tile blit is 8-bit RGBA8 → silent depth
/// loss); the HDR consumers are the CPU mmap path and the raw passthrough.
/// 2. 4:4:4 never prefers producer NV12 (a 4:4:4 session must not be subsampled).
/// 3. Producer-native NV12 only on a `native_nv12_session` under an active raw passthrough
/// (libav VAAPI would misread the two-plane buffer; the CUDA importer expects packed RGB).
/// 4. The raw passthrough is off whenever its own latch has fired.
pub(super) fn negotiation_plan(i: NegotiationInputs) -> NegotiationPlan {
// The frames' consumer imports raw dmabufs itself: the VAAPI backend (libva import + GPU
// CSC) or a PyroWave session (the wavelet encoder's own Vulkan device, any vendor).
let raw_passthrough = i.backend_is_vaapi || i.pyrowave_session;
// Building the EGL→CUDA importer would waste a CUDA probe under a raw passthrough — or
// worse, succeed and produce CUDA payloads only NVENC can consume. HDR is excluded by
// invariant 1. `gpu_import_disabled` is the repeated-worker-death latch (a wedged GPU stack
// must not crash-loop).
let build_importer =
i.zerocopy && !raw_passthrough && !i.want_hdr && !i.gpu_import_disabled;
// Note there is no `importer.is_none()` term, unlike the expression this replaces: it was
// redundant (`build_importer` already excludes `raw_passthrough`, so the importer is
// necessarily absent here) and it is what made the decision look impure — the reason
// `spawn_pipewire` "had to" mirror it by hand.
let vaapi_passthrough =
i.zerocopy && !i.force_shm && raw_passthrough && !i.raw_dmabuf_import_disabled;
let prefer_native_nv12 = i.native_nv12_env_on
&& i.native_nv12_session
&& i.backend_is_vaapi
&& vaapi_passthrough
&& !i.pyrowave_session
&& !i.want_444
&& !i.want_hdr;
NegotiationPlan {
build_importer,
vaapi_passthrough,
prefer_native_nv12,
force_shm: i.force_shm,
raw_dmabuf_latched: i.zerocopy
&& !i.force_shm
&& raw_passthrough
&& i.raw_dmabuf_import_disabled,
gpu_import_latched: i.zerocopy
&& !raw_passthrough
&& !i.want_hdr
&& i.gpu_import_disabled,
}
}
impl NegotiationPlan {
/// Whether to request dmabuf buffers. Not part of the plan proper: it depends on whether the
/// importer actually CONSTRUCTED (`have_importer` — a GPU/driver fact) and on the modifier
/// list that construction yielded.
pub(super) fn want_dmabuf(&self, have_importer: bool, modifiers: &[u64]) -> bool {
(have_importer || self.vaapi_passthrough) && !modifiers.is_empty() && !self.force_shm
}
}
/// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before
/// the stream is poisoned for rebuild. A tiled import failure must NEVER fall through to the
/// CPU mmap path — de-padding tiled bytes as linear produces a scrambled image — so after a
@@ -2109,7 +2346,9 @@ mod pipewire {
// LIVE cursor publisher (see `PortalCapturer::cursor_live`): refreshed from every
// dequeued buffer's cursor meta, frames or not.
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
zerocopy: bool,
// THE zero-copy negotiation decision, resolved once by `spawn_pipewire` (which consumes the
// same struct for the capturer's timeout diagnosis) — never re-derived here.
plan: NegotiationPlan,
// 4:4:4 session: tiled dmabufs take the worker's planar-YUV444 GPU convert.
want_444: bool,
// HDR session: offer ONLY the 10-bit PQ/BT.2020 formats as LINEAR dmabufs (see
@@ -2147,75 +2386,35 @@ mod pipewire {
.context("pw connect (default daemon)")?,
};
// Build the GPU importer up front — normally the ISOLATED worker process
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's
// dmabuf kills the worker, not this host. If it fails, log and fall back to the CPU path
// (we simply won't request dmabuf below). Skipped entirely when the frames go to the
// raw-dmabuf passthrough — the encode backend is VAAPI, or the SESSION encodes PyroWave
// (its Vulkan device imports raw dmabufs on any vendor): building the importer there
// would waste a CUDA probe — or worse, succeed and produce CUDA payloads only NVENC can
// consume. Also skipped once repeated worker deaths latched the import off (a wedged GPU
// stack must not crash-loop).
// The negotiation decision arrives pre-resolved in `plan` (see `negotiation_plan`): what to
// build, which offer to make, and whether to prefer producer NV12. Nothing here re-derives
// any of it — that duplication is what F1/L3 was.
let backend_is_vaapi = policy.backend_is_vaapi;
let raw_passthrough = backend_is_vaapi || policy.pyrowave_session;
// HDR never builds the EGL→CUDA importer: its de-tile blit renders into 8-bit RGBA8,
// which would silently crush the 10-bit depth. The HDR consumers are the CPU mmap path
// (LINEAR de-pad → X2Rgb10 CPU frames) and the VAAPI raw-dmabuf passthrough.
let mut importer = if zerocopy && !raw_passthrough && !want_hdr {
if pf_zerocopy::gpu_import_disabled() {
tracing::warn!(
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
);
None
} else {
match pf_zerocopy::Importer::new_for_capture() {
Ok(i) => Some(i),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "zero-copy import unavailable — using CPU path");
None
}
let force_shm = plan.force_shm;
let vaapi_passthrough = plan.vaapi_passthrough;
let prefer_native_nv12 = plan.prefer_native_nv12;
// Build the GPU importer — normally the ISOLATED worker process
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's dmabuf
// kills the worker, not this host. If construction fails, log and fall back to the CPU path
// (we simply won't request dmabuf below); `plan.build_importer` already encodes WHEN to try
// at all (not under a raw passthrough, not for HDR, not once repeated worker deaths latched
// the import off — see `negotiation_plan`).
if plan.gpu_import_latched {
tracing::warn!(
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
);
}
let mut importer = if plan.build_importer {
match pf_zerocopy::Importer::new_for_capture() {
Ok(i) => Some(i),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "zero-copy import unavailable — using CPU path");
None
}
}
} else {
None
};
// PUNKTFUNK_FORCE_SHM=1 forces the race-free download path (SHM, no dmabuf) — a manual
// escape hatch, mainly for Mutter+NVIDIA: that combo has no implicit dmabuf fence, so
// zero-copy capture can in principle race the compositor's render and show stale frames.
// Zero-copy is the Mutter+NVIDIA default (no unconditional override) since live retesting
// found no visible staleness; set this if you do see flashing/stale content on such a
// host. KWin/gamescope don't need it (they blit into the buffer, so no read-before-render
// race).
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
// Raw-dmabuf zero-copy passthrough: zero-copy on, no EGL→CUDA importer, and the frames'
// consumer imports raw dmabufs itself — the VAAPI backend (libva import + GPU CSC) or a
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
// dmabuf straight to the encoder.
//
// ...unless the encoder already proved it cannot import them here. A driver that refuses
// the compositor's buffers refuses them identically on every retry, so without this the
// session died on its first frame and every reconnect repeated it. The latch (set by the
// encode side after consecutive import failures) is what turns that into one bad session
// followed by a working, if slower, host.
let raw_dmabuf_off = raw_passthrough && pf_zerocopy::raw_dmabuf_import_disabled();
let vaapi_passthrough =
zerocopy && !force_shm && importer.is_none() && raw_passthrough && !raw_dmabuf_off;
// Producer-side NV12 (default-on; PUNKTFUNK_PIPEWIRE_NV12=0 escapes): gamescope offers a
// one-fd LINEAR NV12 image when the consumer asks — its compositor pass does the RGB→YUV,
// and the Vulkan Video encoder imports the buffer as its encode source directly (no host
// CSC at all). `native_nv12_session` restricts this to sessions whose encoder can ingest
// it (Linux vulkan-encode H265/AV1 — never H264/libav-VAAPI, GameStream-resolve, or
// PyroWave, whose Vulkan compute CSC ingests packed RGB only). Raw passthrough is
// required because the CUDA importer expects packed RGB, and 4:4:4/HDR must not be
// silently subsampled/downconverted. Non-NV12 compositors (KWin/GNOME) simply match the
// packed-RGB fallback pod.
let prefer_native_nv12 = std::env::var("PUNKTFUNK_PIPEWIRE_NV12").as_deref() != Ok("0")
&& policy.native_nv12_session
&& backend_is_vaapi
&& vaapi_passthrough
&& !policy.pyrowave_session
&& !want_444
&& !want_hdr;
if prefer_native_nv12 {
tracing::info!(
"zero-copy: preferring gamescope producer-side NV12 LINEAR DMA-BUF (no host \
@@ -2251,19 +2450,20 @@ mod pipewire {
"zero-copy: advertising the PyroWave device's Vulkan-importable dmabuf modifiers"
);
}
let want_dmabuf =
(importer.is_some() || vaapi_passthrough) && !modifiers.is_empty() && !force_shm;
// The one runtime-dependent half of the decision (see `NegotiationPlan::want_dmabuf`): it
// needs the modifier list the importer's construction actually yielded.
let want_dmabuf = plan.want_dmabuf(importer.is_some(), &modifiers);
if force_shm {
tracing::info!(
"capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)"
);
} else if raw_dmabuf_off {
} else if plan.raw_dmabuf_latched {
tracing::warn!(
"zero-copy raw-dmabuf passthrough disabled after repeated encoder import failures \
— capturing CPU frames instead (this host's GPU driver would not import the \
compositor's buffers)"
"zero-copy raw-dmabuf passthrough disabled for this host process (repeated encoder \
import failures, or a previous dmabuf negotiation timeout) — capturing CPU frames \
instead"
);
} else if zerocopy && !want_dmabuf {
} else if !want_dmabuf && (plan.build_importer || plan.vaapi_passthrough) {
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
} else if vaapi_passthrough {
// The raw-passthrough advertisement. Covers the PyroWave case too: its extra
@@ -2288,6 +2488,8 @@ mod pipewire {
// zero-copy path), so this genuinely IS the CPU capture path: a VAAPI session then pays
// three full-frame CPU touches (mmap de-pad + swscale RGB→NV12 + surface upload) —
// make the silent fallback visible.
// The `raw_dmabuf_latched` arm above catches the latched downgrade, so by here zero-copy
// is off at the source: the env var, or the session's own output format.
tracing::warn!(
"VAAPI encode with the CPU capture path (per-frame de-pad + swscale CSC + \
upload) — zero-copy is off for this capture ({}); clear PUNKTFUNK_ZEROCOPY to \
@@ -2295,8 +2497,7 @@ mod pipewire {
if std::env::var_os("PUNKTFUNK_ZEROCOPY").is_some() {
"PUNKTFUNK_ZEROCOPY is set falsy"
} else {
"a latched downgrade after a failed dmabuf negotiation, or this session's \
output format asked for CPU frames"
"this session's output format asked for CPU frames"
}
);
}
@@ -2772,6 +2973,232 @@ mod pipewire {
#[cfg(test)]
mod tests {
use super::{negotiation_plan, NegotiationInputs};
/// A healthy NVENC session: zero-copy on, no latches, SDR 4:2:0, non-VAAPI backend.
fn nvenc() -> NegotiationInputs {
NegotiationInputs {
zerocopy: true,
force_shm: false,
want_hdr: false,
want_444: false,
backend_is_vaapi: false,
pyrowave_session: false,
native_nv12_session: false,
raw_dmabuf_import_disabled: false,
gpu_import_disabled: false,
native_nv12_env_on: true,
}
}
/// A gamescope-style VAAPI session that CAN take producer-native NV12.
fn vaapi_native_nv12() -> NegotiationInputs {
NegotiationInputs {
backend_is_vaapi: true,
native_nv12_session: true,
..nvenc()
}
}
/// The four invariants that were prose-only comments in `pipewire_thread`'s prologue.
#[test]
fn negotiation_plan_invariants() {
// 1. HDR NEVER builds the EGL→CUDA importer — its de-tile blit is 8-bit RGBA8, so an
// importer here would silently crush the 10-bit depth.
for want_444 in [false, true] {
let p = negotiation_plan(NegotiationInputs {
want_hdr: true,
want_444,
..nvenc()
});
assert!(!p.build_importer, "HDR must not build the importer");
}
// …on every backend, including the ones that take the raw passthrough.
assert!(
!negotiation_plan(NegotiationInputs {
want_hdr: true,
..vaapi_native_nv12()
})
.build_importer
);
// 2. 4:4:4 never prefers producer NV12 (a 4:4:4 session must not be subsampled).
let p = negotiation_plan(NegotiationInputs {
want_444: true,
..vaapi_native_nv12()
});
assert!(!p.prefer_native_nv12, "4:4:4 must not take NV12");
// Nor does HDR (no 10-bit NV12 path).
assert!(
!negotiation_plan(NegotiationInputs {
want_hdr: true,
..vaapi_native_nv12()
})
.prefer_native_nv12
);
// 3. Producer-native NV12 needs a `native_nv12_session` AND an active raw passthrough:
// libav VAAPI would misread the two-plane buffer, and the CUDA importer expects
// packed RGB.
assert!(negotiation_plan(vaapi_native_nv12()).prefer_native_nv12);
assert!(
!negotiation_plan(NegotiationInputs {
native_nv12_session: false,
..vaapi_native_nv12()
})
.prefer_native_nv12,
"a session whose encoder can't ingest NV12 must never be offered it"
);
assert!(
!negotiation_plan(NegotiationInputs {
force_shm: true,
..vaapi_native_nv12()
})
.prefer_native_nv12,
"no passthrough (force_shm) ⇒ no native NV12"
);
// A PyroWave session takes the passthrough but its CSC ingests packed RGB only.
assert!(
!negotiation_plan(NegotiationInputs {
pyrowave_session: true,
..vaapi_native_nv12()
})
.prefer_native_nv12
);
// 4. The pyrowave-modifier extension (and the passthrough generally) is off whenever
// the raw-dmabuf latch has fired.
let p = negotiation_plan(NegotiationInputs {
raw_dmabuf_import_disabled: true,
..vaapi_native_nv12()
});
assert!(!p.vaapi_passthrough, "latched ⇒ no raw passthrough");
assert!(!p.prefer_native_nv12);
assert!(p.raw_dmabuf_latched, "…and the operator gets told why");
}
/// The drift F1 was about: `spawn_pipewire` derived `vaapi_passthrough` by hand and its copy
/// omitted the raw-dmabuf latch, so after that latch fired the capturer believed it had made
/// the passthrough offer while the thread had already fallen back — and a timeout then
/// latched a downgrade for an offer nobody made. With one resolver the two cannot disagree,
/// so this pins the property that made the mirror wrong: the latch MUST move the decision.
#[test]
fn the_raw_dmabuf_latch_moves_the_passthrough_decision() {
for pyrowave in [false, true] {
let base = NegotiationInputs {
backend_is_vaapi: !pyrowave,
pyrowave_session: pyrowave,
..nvenc()
};
assert!(negotiation_plan(base).vaapi_passthrough);
assert!(
!negotiation_plan(NegotiationInputs {
raw_dmabuf_import_disabled: true,
..base
})
.vaapi_passthrough
);
}
}
/// A PyroWave session on an NVIDIA box takes the raw passthrough (the wavelet encoder's own
/// Vulkan device imports dmabufs on any vendor) and therefore must NOT also build the
/// EGL→CUDA importer — whose payloads only NVENC can consume.
#[test]
fn a_pyrowave_session_passes_through_without_a_cuda_importer() {
let p = negotiation_plan(NegotiationInputs {
pyrowave_session: true,
..nvenc()
});
assert!(p.vaapi_passthrough);
assert!(!p.build_importer);
}
/// `force_shm` is the race-free download path: no passthrough, and `want_dmabuf` stays false
/// even with an importer and a full modifier list.
#[test]
fn force_shm_wins_over_every_dmabuf_path() {
let p = negotiation_plan(NegotiationInputs {
force_shm: true,
..vaapi_native_nv12()
});
assert!(!p.vaapi_passthrough);
assert!(!p.want_dmabuf(true, &[0, 1, 2]));
// …and an SHM-forced NVENC session may still build the importer (it just won't be fed
// dmabufs), which is why `want_dmabuf` — not `build_importer` — is the gate.
let p = negotiation_plan(NegotiationInputs {
force_shm: true,
..nvenc()
});
assert!(p.build_importer);
assert!(!p.want_dmabuf(true, &[0]));
}
/// `want_dmabuf` needs a real modifier list: an importer that constructed but advertised
/// nothing importable falls back to the CPU path.
#[test]
fn want_dmabuf_needs_both_a_consumer_and_a_modifier() {
let p = negotiation_plan(nvenc());
assert!(p.want_dmabuf(true, &[0]));
assert!(!p.want_dmabuf(true, &[]), "no modifiers ⇒ CPU path");
assert!(
!p.want_dmabuf(false, &[0]),
"importer failed to construct and no passthrough ⇒ CPU path"
);
// The passthrough needs no importer at all.
let p = negotiation_plan(vaapi_native_nv12());
assert!(p.want_dmabuf(false, &[0]));
}
/// The GPU-import death latch stops the importer being rebuilt, and says so.
#[test]
fn the_gpu_import_death_latch_skips_the_importer() {
let p = negotiation_plan(NegotiationInputs {
gpu_import_disabled: true,
..nvenc()
});
assert!(!p.build_importer);
assert!(p.gpu_import_latched);
// It is NOT reported for a capture that would never have built one anyway (HDR, or a
// raw passthrough) — that would misdirect the operator.
assert!(
!negotiation_plan(NegotiationInputs {
gpu_import_disabled: true,
want_hdr: true,
..nvenc()
})
.gpu_import_latched
);
assert!(
!negotiation_plan(NegotiationInputs {
gpu_import_disabled: true,
..vaapi_native_nv12()
})
.gpu_import_latched
);
}
/// Zero-copy off ⇒ nothing at all: no importer, no passthrough, no NV12 preference.
#[test]
fn zerocopy_off_disables_every_branch() {
for i in [
NegotiationInputs {
zerocopy: false,
..nvenc()
},
NegotiationInputs {
zerocopy: false,
..vaapi_native_nv12()
},
] {
let p = negotiation_plan(i);
assert!(!p.build_importer);
assert!(!p.vaapi_passthrough);
assert!(!p.prefer_native_nv12);
assert!(!p.want_dmabuf(false, &[0]));
}
}
/// Pin our hand-written PQ transfer id against the real libspa binding. We can't take the
/// constant from `pw::spa::sys` directly (older distro headers don't export it — see
/// [`super::SPA_VIDEO_TRANSFER_SMPTE2084`]), so assert the two agree wherever the symbol