Merge pull request 'Gamescope streams could tear pink at 120 fps — zero-copy handed the capture buffer back while the VCN was still reading it' (#188) from worktree-gamescope-tear-race into main
ci / bun-nix (push) Successful in 24s
ci / rust-arm64 (push) Successful in 1m29s
apple / swift (push) Successful in 1m44s
ci / web (push) Successful in 4m45s
ci / docs-site (push) Successful in 6m3s
ci / rust (push) Successful in 6m19s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m25s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 30s
deb / build-publish (push) Successful in 7m18s
apple / screenshots (push) Successful in 6m25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 26s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 39s
deb / build-publish-host (push) Successful in 8m3s
deb / build-publish-client-arm64 (push) Successful in 4m57s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m54s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 15s
docker / builders-arm64cross (push) Successful in 16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3m25s
android / android (push) Successful in 11m52s
arch / build-publish (push) Successful in 14m31s
windows-host / package (push) Successful in 13m55s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 33s
docker / deploy-docs (push) Successful in 7m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m41s

Reviewed-on: #188
This commit was merged in pull request #188.
This commit is contained in:
2026-08-13 07:24:07 +00:00
7 changed files with 384 additions and 34 deletions
+318 -25
View File
@@ -96,6 +96,10 @@ struct UserData {
/// into the first-frame-timeout retry loop; the promised renegotiation normally lands
/// within a frame or two).
gate_since: Option<std::time::Instant>,
/// Deferred requeue of raw-passthrough buffers (see [`DeferredRequeue`]): the encode thread
/// reads the dmabuf long after `.process` returns, so the buffer must not rejoin the
/// producer's pool until the frame's [`BufferHold`] drops.
defer: std::sync::Arc<DeferredRequeue>,
}
impl UserData {
@@ -113,6 +117,46 @@ impl UserData {
}
let _ = self.wake.try_send(());
}
/// Withhold the raw-passthrough buffer from the producer's pool until the returned hold
/// drops — the deferred requeue that closes the rewrite-while-the-encoder-reads race.
/// `None` (pool too shallow, or `PUNKTFUNK_ZEROCOPY_HOLD=0`) falls back to the immediate
/// `.process`-epilogue requeue, i.e. the old racy contract; said once per session.
fn try_defer(&mut self, pw_buf: *mut pw::sys::pw_buffer) -> Option<pf_frame::FrameHold> {
if !zerocopy_hold_enabled() {
return None;
}
let buf = pw_buf as usize;
let pool_live = self.pool.live;
let generation = self.defer.book.lock().ok()?.try_hold(buf, pool_live);
let Some(generation) = generation else {
if !self.defer.logged_shallow.swap(true, Ordering::Relaxed) {
tracing::warn!(
pool_depth = pool_live,
reserve = HOLD_POOL_RESERVE,
"zero-copy: the producer's buffer pool cannot spare a buffer to hold across \
the encode — falling back to the immediate requeue, which the producer may \
rewrite mid-encode (torn/discolored frames under load); PUNKTFUNK_FORCE_SHM=1 \
trades CPU for a race-free capture if artifacts appear"
);
}
return None;
};
if !self.defer.logged_active.swap(true, Ordering::Relaxed) {
tracing::info!(
pool_depth = pool_live,
reserve = HOLD_POOL_RESERVE,
"zero-copy: withholding each published buffer from the producer until the \
encoder releases it (deferred requeue — the producer can no longer rewrite a \
frame mid-encode); PUNKTFUNK_ZEROCOPY_HOLD=0 restores the immediate requeue"
);
}
Some(std::sync::Arc::new(BufferHold {
defer: self.defer.clone(),
buf,
generation,
}))
}
}
/// Everything the zero-copy negotiation decision depends on, gathered at ONE point in time.
@@ -510,11 +554,12 @@ impl FenceWaitStats {
/// PW5 stage 1: how many buffers the producer actually allocated for this stream.
///
/// **Nothing in this codebase had ever counted them.** The zero-copy path dups the dmabuf fd and
/// publishes the frame while the SPA buffer is handed straight back to the producer at `.process`
/// return — so the only thing keeping capture untorn is that the producer round-robins a pool
/// deeper than our import+encode window. That depth was an unmeasured assumption; this makes it a
/// logged number, on every producer, before anything is built on it.
/// **Nothing in this codebase had ever counted them.** The zero-copy path used to hand the SPA
/// buffer straight back to the producer at `.process` return, leaving pool depth as the only
/// thing keeping capture untorn. The deferred requeue ([`DeferredRequeue`]) now withholds
/// published buffers until the consumer is done, but the depth still matters twice over: it is
/// the budget `HoldBook::try_hold` spends (a pool of ≤ [`HOLD_POOL_RESERVE`] cannot defer at
/// all and runs the old race), and for un-deferred frames it remains the race window.
///
/// `live` is maintained by the `add_buffer`/`remove_buffer` stream callbacks, which PipeWire fires
/// on the loop thread as the pool is allocated (and again, remove-then-add, on a renegotiation that
@@ -586,6 +631,104 @@ impl PassthroughFallbacks {
/// short streak of dropped frames the capturer fails loudly and the session renegotiates.
const IMPORT_FAIL_POISON: u32 = 3;
/// Buffers the deferred requeue always leaves in the producer's pool. One for the frame the
/// producer is rendering right now, one in transit — withholding past that would make the
/// producer skip frames whenever our holds are at their worst (host frame + up to two encoder
/// ring slots), which is a pacing hiccup, not corruption, but there is no reason to court it.
const HOLD_POOL_RESERVE: u32 = 2;
/// `PUNKTFUNK_ZEROCOPY_HOLD=0` restores the immediate `.process`-return requeue (the racy
/// pre-hold behavior) — a field bisect lever, not a tuning knob. `env_on` grammar like every
/// other capture knob (a bare `== "0"` compare is the trap `PUNKTFUNK_FORCE_SHM` already fell in).
fn zerocopy_hold_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| pf_host_config::env_on("PUNKTFUNK_ZEROCOPY_HOLD").unwrap_or(true))
}
/// Pure bookkeeping for the deferred requeue: which buffers are currently withheld from the
/// producer, each under a per-hold generation so a pointer-value reuse across a pool
/// renegotiation can never satisfy a stale hold's release (see `complete`).
///
/// Threading contract (what makes the single-requeue invariant hold with no atomics): entries are
/// INSERTED (`try_hold`) and REMOVED (`complete` via the requeue channel's callback, `purge` via
/// `remove_buffer`) only on the PipeWire loop thread; a dropping [`BufferHold`] on any other
/// thread only *sends* the release message. So between a hold's creation and the loop servicing
/// its release, `contains` is stable — which is exactly what the `.process` epilogue relies on to
/// decide "requeue now" vs "the hold owns the requeue".
#[derive(Default)]
struct HoldBook {
/// Withheld buffers: `*mut pw_buffer` as usize → the generation of the hold that owns it.
out: std::collections::HashMap<usize, u64>,
/// Last issued hold generation (monotonic per stream).
last_gen: u64,
}
impl HoldBook {
/// Withhold `buf` if the pool can spare it: at most `pool_live - HOLD_POOL_RESERVE` buffers
/// out at once. Returns the generation to release with, or `None` (pool too shallow / buffer
/// somehow already out — the caller falls back to the immediate requeue).
fn try_hold(&mut self, buf: usize, pool_live: u32) -> Option<u64> {
let cap = pool_live.saturating_sub(HOLD_POOL_RESERVE) as usize;
if self.out.len() >= cap || self.out.contains_key(&buf) {
return None;
}
self.last_gen += 1;
self.out.insert(buf, self.last_gen);
Some(self.last_gen)
}
/// A hold released: take `buf` out of the book iff this generation still owns it. `true` ⇒
/// the caller must requeue the buffer; `false` ⇒ the entry was purged (pool renegotiated —
/// the pointer may even be a NEW buffer under a reused address) and the buffer must NOT be
/// touched.
fn complete(&mut self, buf: usize, generation: u64) -> bool {
match self.out.get(&buf) {
Some(&g) if g == generation => {
self.out.remove(&buf);
true
}
_ => false,
}
}
/// `remove_buffer`: the buffer is being freed under us (renegotiation/teardown) — forget it.
/// Its hold's later release finds the generation gone and becomes a no-op.
fn purge(&mut self, buf: usize) {
self.out.remove(&buf);
}
fn contains(&self, buf: usize) -> bool {
self.out.contains_key(&buf)
}
}
/// Shared between the loop thread ([`HoldBook`] ops) and the [`BufferHold`] guards riding
/// published frames to the encode thread.
struct DeferredRequeue {
book: std::sync::Mutex<HoldBook>,
/// Wakes the loop to requeue `(buffer, generation)`. Send failure = the loop (and with it
/// the stream and every buffer) is gone — nothing to release.
tx: pw::channel::Sender<(usize, u64)>,
/// One-per-session lines: the first successful defer, and the shallow-pool fallback.
logged_active: std::sync::atomic::AtomicBool,
logged_shallow: std::sync::atomic::AtomicBool,
}
/// The concrete [`pf_frame::FrameHold`]: releases its buffer back to the producer when the last
/// clone drops. Send-only from the dropping thread — the actual `pw_stream_queue_buffer` runs in
/// the requeue channel's loop-thread callback.
struct BufferHold {
defer: std::sync::Arc<DeferredRequeue>,
buf: usize,
generation: u64,
}
impl Drop for BufferHold {
fn drop(&mut self) {
let _ = self.defer.tx.send((self.buf, self.generation));
}
}
/// Log a frame-drop reason once per process (the process callback runs per frame; a stuck
/// pipeline must say why without flooding).
fn warn_once(msg: &'static str) {
@@ -644,7 +787,14 @@ impl Drop for DmabufMap {
/// `.process` callback with the NEWEST drained buffer (latest-frame-only). `datas` is sourced
/// via the same transparent cast libspa's `Buffer::datas_mut` performs, so the safe `Data`
/// accessors (`.type_()`, `.chunk()`, `.data()`, `.fd()`, `.as_raw()`) keep working.
fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
///
/// `pw_buf` is the buffer's `pw_buffer` handle (`spa_buf`'s owner), used only as the identity a
/// raw-passthrough publish withholds via [`UserData::try_defer`] — never dereferenced here.
fn consume_frame(
ud: &mut UserData,
spa_buf: *mut spa::sys::spa_buffer,
pw_buf: *mut pw::sys::pw_buffer,
) {
// No active stream: release the buffer without the (expensive at 5K) de-pad.
if !ud.signals.active.load(Ordering::Relaxed) {
return;
@@ -822,8 +972,11 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
None
};
// dup the fd so it survives the SPA buffer recycle — the encode thread
// imports it. Content stability across the brief import/encode window relies
// on the compositor's buffer-pool depth, like any zero-copy capture.
// imports it. Content stability across the read window comes from the deferred
// requeue below (`try_defer` — the producer does not get this buffer back until
// the frame's hold drops); with no hold (shallow pool / PUNKTFUNK_ZEROCOPY_HOLD=0)
// it falls back to the compositor's pool depth outrunning the encode, the old
// racy contract.
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
@@ -836,6 +989,7 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let hold = ud.try_defer(pw_buf);
ud.publish(CapturedFrame {
width: w as u32,
height: h as u32,
@@ -852,6 +1006,7 @@ fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
offset,
stride,
plane1,
hold,
}),
// Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope
// embeds its pointer in the produced pixels, so native NV12 has none.
@@ -1434,6 +1589,18 @@ pub fn pipewire_thread(
);
}
// Deferred requeue (the rewrite-while-encoding fix): holds riding published frames release
// their buffers through this channel from whatever thread drops them last; the receiver —
// attached to the loop below, after the stream exists — is the single place a withheld
// buffer rejoins the producer's pool.
let (requeue_tx, requeue_rx) = pw::channel::channel::<(usize, u64)>();
let defer = std::sync::Arc::new(DeferredRequeue {
book: std::sync::Mutex::new(HoldBook::default()),
tx: requeue_tx,
logged_active: std::sync::atomic::AtomicBool::new(false),
logged_shallow: std::sync::atomic::AtomicBool::new(false),
});
let data = UserData {
info: VideoInfoRaw::default(),
format: None,
@@ -1459,6 +1626,7 @@ pub fn pipewire_thread(
},
gate_skips: 0,
gate_since: None,
defer: defer.clone(),
};
let stream = pw::stream::StreamBox::new(
@@ -1562,10 +1730,18 @@ pub fn pipewire_thread(
}
})
// PW5 stage 1 — the pool census. PipeWire fires these on the loop thread as it allocates
// (and, on a renegotiation, frees then re-allocates) the stream's buffers. Counting only:
// the buffer pointer is not touched, so no lifetime question arises here.
// (and, on a renegotiation, frees then re-allocates) the stream's buffers. The census only
// counts; `remove_buffer` additionally purges the buffer from the deferred-requeue book —
// the buffer is being freed under any hold still riding a frame, so that hold's later
// release must become a no-op (the generation check in `HoldBook::complete` also covers
// the freed address being reused by a new pool's buffer).
.add_buffer(|_stream, ud, _buf| ud.pool.add())
.remove_buffer(|_stream, ud, _buf| ud.pool.remove())
.remove_buffer(|_stream, ud, buf| {
ud.pool.remove();
if let Ok(mut book) = ud.defer.book.lock() {
book.purge(buf as usize);
}
})
.process(|stream, ud| {
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and recycles its
// pool; an older queued buffer carries a STALE frame. Drain all queued buffers, requeue
@@ -1598,19 +1774,19 @@ pub fn pipewire_thread(
// value. MEASURED, not requested: `build_dmabuf_buffers` asks for a range and the
// producer picks — this line is the only place the picked number is visible.
//
// Why it matters beyond curiosity: `stream.queue_raw_buffer(newest)` at the end of this
// callback hands the buffer back while the encode thread may still be importing and
// reading its dmabuf, so content stability rests entirely on the producer not cycling
// back to this buffer before we are done with it. That window is `pool_depth` buffer
// periods wide. A pool of 2 has essentially none.
// Why it matters beyond curiosity: the depth is the budget the deferred requeue
// (`HoldBook::try_hold`) spends withholding published buffers from the producer
// while the encoder reads them. A pool of ≤ HOLD_POOL_RESERVE cannot defer at all —
// those sessions run the old contract, where a requeued buffer may be rewritten
// mid-encode and only pool depth keeps frames untorn.
if let Some(depth) = ud.pool.note_frame() {
tracing::info!(
pool_depth = depth,
high_water = ud.pool.high_water,
drained,
"pipewire buffer pool negotiated — this is the producer's ACTUAL count \
(add_buffer/remove_buffer), the window in which a buffer we handed back may \
be rewritten while the encoder still reads it"
"pipewire buffer pool negotiated — the producer's ACTUAL count \
(add_buffer/remove_buffer): the deferred-requeue budget, and the rewrite \
window for any frame published without a hold"
);
}
// Sacrificial-mode gate (kwin.rs `create`): until the producer renegotiates to the
@@ -1766,14 +1942,30 @@ pub fn pipewire_thread(
return;
}
consume_frame(ud, spa_buf);
consume_frame(ud, spa_buf, newest);
}));
// Hand `newest` back to the stream exactly once, on EVERY path — normal, corrupted-skip,
// or a caught panic in the closure above. This single requeue is what keeps the fixed
// buffer pool from draining.
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame) completed
// inside the closure above; `newest` was dequeued from this stream and not yet requeued.
unsafe { stream.queue_raw_buffer(newest) };
// or a caught panic in the closure above — UNLESS a raw-passthrough publish withheld it
// (`try_defer` put it in the hold book): then the requeue duty belongs to the frame's
// `BufferHold`, and requeueing here too would hand the producer the same buffer twice.
// The book is stable across this check: only this thread removes entries (the requeue
// channel's callback / `remove_buffer`), and neither can run inside `.process` — a
// consumer racing the frame to its drop merely queues the release message. A panic
// AFTER the publish leaves the hold live on the published frame, so skipping the
// immediate requeue remains correct on that path too.
let withheld = ud
.defer
.book
.lock()
.map(|b| b.contains(newest as usize))
.unwrap_or(false);
if !withheld {
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame)
// completed inside the closure above; `newest` was dequeued from this stream,
// not yet requeued, and — per the `withheld` check — carries no hold that would
// requeue it a second time.
unsafe { stream.queue_raw_buffer(newest) };
}
if outcome.is_err() {
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad
// format) would fire this every frame, so power-of-two throttle it — enough to
@@ -1789,6 +1981,34 @@ pub fn pipewire_thread(
.register()
.context("register stream listener")?;
// The deferred-requeue service. A `BufferHold` dropping on any thread only *sends*
// `(buffer, generation)`; this callback — on the loop thread, like every other stream op —
// is where a withheld buffer actually rejoins the producer's pool. `HoldBook::complete`
// makes a release for a renegotiated-away buffer (or a freed address reused by a new
// pool's buffer) a no-op, so a stale hold can never queue somebody else's buffer.
let defer_cb = defer.clone();
let stream_ptr = stream.as_raw_ptr() as usize;
let _requeue_attach = requeue_rx.attach(mainloop.loop_(), move |(buf, generation)| {
let requeue = defer_cb
.book
.lock()
.map(|mut b| b.complete(buf, generation))
.unwrap_or(false);
if requeue {
// SAFETY: `complete` returned true ⇒ this buffer was withheld by exactly this hold
// and no `remove_buffer` has freed it since (that purges the book), so the pointer
// is a live buffer of this stream that we own (dequeued, never requeued). The
// stream outlives this attached receiver (declared after it, dropped before it),
// and the loop stops dispatching once `run()` returns.
let _ = unsafe {
pw::sys::pw_stream_queue_buffer(
stream_ptr as *mut pw::sys::pw_stream,
buf as *mut pw::sys::pw_buffer,
)
};
}
});
// Debug knob: offer a single fixed format (PUNKTFUNK_PW_FIXED_POD="WxH") to bisect
// negotiation failures against a producer's exact EnumFormat (e.g. gamescope).
let fixed_pod: Option<(u32, u32)> = std::env::var("PUNKTFUNK_PW_FIXED_POD")
@@ -2479,4 +2699,77 @@ mod tests {
assert_eq!(p.note_frame(), Some(0));
assert_eq!(p.high_water, 0);
}
use super::{HoldBook, HOLD_POOL_RESERVE};
/// The book must always leave [`HOLD_POOL_RESERVE`] buffers with the producer: an 8-pool
/// spares 6, and the pools at or below the reserve spare NOTHING — those sessions must fall
/// back to the immediate requeue rather than starve the compositor of render targets.
#[test]
fn hold_book_spends_at_most_pool_minus_reserve() {
let mut b = HoldBook::default();
for i in 0..6 {
assert!(
b.try_hold(0x1000 + i, 8).is_some(),
"hold {i} within budget"
);
}
assert!(
b.try_hold(0x2000, 8).is_none(),
"7th of 8 exceeds the budget"
);
assert!(
HoldBook::default()
.try_hold(0x1000, HOLD_POOL_RESERVE)
.is_none(),
"a pool of exactly the reserve cannot spare a buffer"
);
assert!(
HoldBook::default()
.try_hold(0x1000, HOLD_POOL_RESERVE + 1)
.is_some(),
"one past the reserve spares exactly one"
);
}
/// One hold ⇒ one requeue: the first `complete` releases, a duplicate release (a bug shape,
/// but also the benign stale-message case) must NOT requeue a second time — handing the
/// producer the same buffer twice corrupts its pool.
#[test]
fn hold_book_releases_exactly_once() {
let mut b = HoldBook::default();
let g = b.try_hold(0x1000, 8).unwrap();
assert!(b.complete(0x1000, g), "first release requeues");
assert!(!b.complete(0x1000, g), "second release is a no-op");
assert!(!b.contains(0x1000));
}
/// The renegotiation hazard the generation exists for: the pool is replaced (`remove_buffer`
/// purges), a NEW buffer lands on the SAME address and is withheld, and only then does the
/// OLD hold's release arrive. Matching by pointer alone would requeue the new tenant while
/// its own hold is still out — the mid-encode rewrite race, reintroduced by the fix itself.
#[test]
fn hold_book_generation_outlives_an_address_reuse() {
let mut b = HoldBook::default();
let old = b.try_hold(0x1000, 8).unwrap();
b.purge(0x1000); // remove_buffer: pool renegotiated away under the hold
assert!(!b.complete(0x1000, old), "purged hold releases nothing");
let new = b.try_hold(0x1000, 8).unwrap(); // new pool's buffer, same address
assert!(
!b.complete(0x1000, old),
"the OLD hold cannot release the NEW tenant"
);
assert!(b.contains(0x1000), "new tenant still withheld");
assert!(b.complete(0x1000, new), "its own hold releases it");
}
/// A buffer already out cannot be withheld again (one requeue duty per buffer): `.process`
/// can only re-see an address after its requeue, so a duplicate try_hold means state
/// confusion — refuse it and let the epilogue requeue immediately.
#[test]
fn hold_book_refuses_a_buffer_already_out() {
let mut b = HoldBook::default();
b.try_hold(0x1000, 8).unwrap();
assert!(b.try_hold(0x1000, 8).is_none());
}
}
+8 -6
View File
@@ -322,12 +322,14 @@ pub(super) fn build_shm_only_buffers() -> Result<Vec<u8>> {
/// PW5 stage 2: the buffer-pool depth we ASK for on the zero-copy path, as a Choice range.
///
/// The zero-copy path hands the SPA buffer back to the producer at `.process` return, while the
/// encode thread still holds a dup of its dmabuf fd and has not yet imported, let alone read, the
/// contents. Nothing bounds that window — see the `queue_raw_buffer` comment in `pipewire.rs` — so
/// the only thing that keeps capture untorn is the producer round-robining a pool deeper than our
/// import+encode latency. Until PW5 stage 1 nobody had ever counted what that pool was; we never
/// even asked for a size (`build_dmabuf_buffers` set `dataType` and nothing else).
/// The raw-passthrough arm now WITHHOLDS each published buffer from the producer until the
/// consumer's hold drops (`DeferredRequeue` in `pipewire.rs` — the fix for the producer
/// rewriting a buffer mid-encode), spending up to `pool - HOLD_POOL_RESERVE` buffers of this
/// depth. A pool at the old floor of 2 has nothing to spend and falls back to the racy
/// immediate requeue, where only the producer round-robining a pool deeper than our
/// import+encode latency keeps capture untorn. Until PW5 stage 1 nobody had ever counted what
/// that pool was; we never even asked for a size (`build_dmabuf_buffers` set `dataType` and
/// nothing else).
///
/// A **range**, deliberately, not a fixed count: SPA intersects the consumer's and producer's
/// Buffers params, so a fixed 8 against a producer that can only afford 4 empties the intersection
@@ -2803,6 +2803,7 @@ mod tests {
plane1: None,
offset: 0,
stride: 64 * 4,
hold: None,
}
};
let fd_count = || std::fs::read_dir("/proc/self/fd").expect("procfs").count();
@@ -936,6 +936,7 @@ mod tests {
plane1: None,
offset: 0,
stride: 1920 * 4,
hold: None,
}),
cursor,
}
+30 -1
View File
@@ -586,6 +586,12 @@ struct Frame {
pts_ns: u64,
keyframe: bool,
recovery_anchor: bool,
/// The captured dmabuf's deferred-requeue hold ([`pf_frame::FrameHold`]), cloned at submit and
/// dropped when this slot retires (fence signaled — `poll`/backpressure/`reset`). This is what
/// extends "the producer must not rewrite the buffer" across the whole asynchronous GPU read:
/// the host's own clone only lives until it takes the NEXT frame, which with a ring of 2 is
/// before this slot's encode finished. `None` for non-dmabuf sources or un-held frames.
src_hold: Option<pf_frame::FrameHold>,
}
pub struct VulkanVideoEncoder {
@@ -2274,7 +2280,9 @@ impl VulkanVideoEncoder {
// First import: acquire from the foreign producer (UNDEFINED preserves the modifier-tiled
// bytes). Cached re-read: we still own it, so no queue-family transfer — just a visibility
// barrier so the shader read sees the content the producer wrote out-of-band this frame
// (single-GPU coherent; the capture layer guarantees the buffer is ready at hand-off).
// (single-GPU coherent). The barrier orders nothing against the PRODUCER — content
// stability across this read is the frame's deferred-requeue hold (`Frame::src_hold`):
// the producer does not get the buffer back to rewrite until this slot's fence retires.
let (old, src_qf, dst_qf) = if fresh {
(
vk::ImageLayout::UNDEFINED,
@@ -3875,11 +3883,24 @@ impl VulkanVideoEncoder {
),
Err(e) => return Err(e.into()),
}
// Fence signaled ⟹ the GPU is done reading this slot's captured dmabuf — release
// its hold so the capture layer requeues the producer's buffer.
self.frames[slot].src_hold = None;
let done = self.read_slot(slot)?;
self.pending.push_back(done);
}
let slot = self.ring;
self.ring = (self.ring + 1) % self.frames.len();
// Take over the frame's deferred-requeue hold for this occupancy BEFORE recording: the
// producer must not get the buffer back until this slot's fence retires (poll /
// backpressure / reset), because the encode reads the imported dmabuf for its whole
// duration — the host's own clone drops as soon as it takes the next frame. Assigned
// even if `record_submit` then fails: an over-hold until the slot's next tenant is
// harmless, a released-while-referenced buffer is the exact race this closes.
self.frames[slot].src_hold = match &frame.payload {
FramePayload::Dmabuf(d) => d.hold.clone(),
_ => None,
};
self.record_submit(slot, frame, wire)?;
self.in_flight.push_back(slot);
Ok(())
@@ -4002,6 +4023,9 @@ impl Encoder for VulkanVideoEncoder {
Err(e) => return Err(e.into()),
}
self.in_flight.pop_front();
// Fence signaled ⟹ the GPU is done reading this slot's captured dmabuf — release its
// hold so the capture layer requeues the producer's buffer.
self.frames[slot].src_hold = None;
// SAFETY: fence signaled ⟹ this slot's CSC+encode is complete; read its bitstream.
Ok(Some(unsafe { self.read_slot(slot)? }))
}
@@ -4064,6 +4088,11 @@ impl Encoder for VulkanVideoEncoder {
}
self.in_flight.clear();
self.pending.clear();
// The waits above proved every slot's GPU read is done — release the captured-dmabuf
// holds so the capture layer (possibly mid-rebuild itself) gets its buffers back.
for f in &mut self.frames {
f.src_hold = None;
}
self.ring = 0;
self.first_frame = true;
self.force_kf = false;
+3
View File
@@ -692,6 +692,9 @@ fn encode_one(
plane1: req.plane1,
offset: req.offset,
stride: req.stride,
// The deferred-requeue hold stays host-side: this backend is synchronous at depth 1
// (see below), so the host's frame — hold and all — outlives the whole encode.
hold: None,
}),
cursor,
};
+23 -2
View File
@@ -231,6 +231,23 @@ pub struct CapturedFrame {
pub cursor: Option<CursorOverlay>,
}
/// Keeps the producer's buffer behind a zero-copy frame OUT of the producer's pool.
///
/// The fd on a [`DmabufFrame`] only keeps the buffer object from being *freed*; nothing stops the
/// compositor from *re-rendering into it* once the capture layer hands the buffer back — which it
/// used to do at `.process` return, before the encoder had even imported the dmabuf (the
/// gamescope-at-120fps torn-frame race). This handle is the fix: the PipeWire capture attaches one
/// to every raw-passthrough frame (pool depth permitting) and defers the requeue until the LAST
/// clone drops. A consumer that reads the dmabuf asynchronously (the Vulkan encoder's ring) clones
/// it into whatever tracks the read (its ring slot) and drops it when the GPU is provably done
/// (the slot's fence), so content stability covers exactly the read window. Consumers that finish
/// their read while the frame is alive need to do nothing — the frame's own clone is enough.
///
/// Opaque on purpose: the concrete guard lives in the capture crate; everyone else only clones and
/// drops.
#[cfg(target_os = "linux")]
pub type FrameHold = std::sync::Arc<dyn std::any::Any + Send + Sync>;
/// A captured frame still living in a DMA-BUF. Packed RGB uses one plane. Native Linux NV12
/// (gamescope PipeWire) travels in ONE fd: Y starts at `offset`, and the interleaved UV plane
/// lives at `plane1`'s offset/stride when the producer reported them — else at the contiguous
@@ -238,8 +255,9 @@ pub struct CapturedFrame {
///
/// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be
/// imported there without the compositor's buffer being closed underneath it. Content stability
/// across the brief import window relies on the compositor's buffer pool depth, like any zero-copy
/// capture.
/// across the read window comes from [`hold`](Self::hold) when present (the producer does not get
/// the buffer back until the hold drops); a `None` hold falls back to the old contract — the
/// compositor's pool depth outrunning the import+encode window.
#[cfg(target_os = "linux")]
pub struct DmabufFrame {
pub fd: std::os::fd::OwnedFd,
@@ -253,6 +271,9 @@ pub struct DmabufFrame {
pub plane1: Option<(u32, u32)>,
pub offset: u32,
pub stride: u32,
/// Deferred-requeue hold on the producer's buffer (see [`FrameHold`]); `None` when the
/// capture could not spare a buffer from the pool (shallow pool, or `PUNKTFUNK_ZEROCOPY_HOLD=0`).
pub hold: Option<FrameHold>,
}
/// Where a captured frame's pixels live.