Gamescope streams could tear pink at 120 fps — zero-copy handed the capture buffer back while the VCN was still reading it #188

Merged
enricobuehler merged 1 commits from worktree-gamescope-tear-race into main 2026-08-13 07:24:08 +00:00
Owner

Field report

Nobara user, gamescope mode at 120 fps: intermittent pink/magenta tint plus blocky corruption on the stream ("I haven't noticed in KDE but I've seen it a few times in gamescope"). Screenshot signature: luma detail preserved under a magenta wash (chroma corruption) plus displaced-macroblock bands (damage propagating through the P-chain until the next intra).

Root cause

The raw-dmabuf passthrough requeued the SPA buffer to gamescope inside .process, before the encode thread had imported — let alone read — the dmabuf, and nothing anywhere ordered the producer's writes against the consumer's read:

  • no explicit sync (SPA_META_SyncTimeline unimplemented), and the implicit-fence wait measures NoFence on every compositor×vendor pairing we have (the in-tree matrix in pipewire.rs);
  • the encoder's FOREIGN-queue acquire carries src_stage = NONE — a visibility barrier, not a temporal one;
  • on the direct-VCN arms (producer-native NV12, RGB-direct EFC) the captured buffer is the encode source for the whole ring-2-deep encode, plus the phase-lock hold before submit — one to two frame periods of exposure.

At 120 fps the producer's pool wrap time halves while that window stays constant, so gamescope cycles back into a buffer the VCN is still reading. A mid-encode rewrite of an NV12 buffer desynchronizes Y from UV — magenta with intact luma — and the damaged reference propagates. KDE sessions were clean because cursor_blend routes them to the compute-CSC copy arm, whose read window is microseconds, on top of a mostly-static desktop barely cycling the pool. The repeat path was worse still: with no fresh frame, the host re-submitted the old DmabufFrame, whose buffer gamescope had long since rewritten.

The race was already documented in prose in three places (pipewire.rs, pw_pods.rs — "Nothing bounds that window"); this closes it.

The fix — defer the requeue (zero-copy preserved)

A published passthrough frame now carries a FrameHold (new field on DmabufFrame), and the buffer rejoins the producer's pool only when the last clone drops:

  • pf-capture: a per-stream HoldBook tracks withheld buffers (loop-thread-only mutations). Releases travel over a pw::channel back onto the loop thread — the only place buffers are legally requeued. A per-hold generation tag keeps a stale release from requeueing a renegotiated pool's reused address. Budget: at most pool_depth − 2 buffers withheld (HOLD_POOL_RESERVE), so the producer can never starve; a pool of ≤ 2 cannot spare any and falls back to the old racy contract with one WARN. PUNKTFUNK_ZEROCOPY_HOLD=0 (env_on grammar) restores the old behavior outright for field bisects.
  • pf-encode (Vulkan): the ring slot clones the hold at submit (Frame::src_hold) and drops it when the slot's fence retires (poll / backpressure / reset) — extending "the producer must not rewrite this" across exactly the GPU read. The host's own clone drops as soon as it takes the next frame, which with a ring of 2 is before the encode finished; the slot clone is what makes the window airtight. The stale "the capture layer guarantees the buffer is ready at hand-off" comment is corrected.
  • Repeat path: fixed by the same mechanism — a repeated frame aliases a buffer the producer never got back, so it re-encodes the last frame's actual content.
  • PyroWave (sync at depth 1) and the libav VAAPI backend are covered by the frame's own clone; the CUDA-import and CPU arms complete their read inside the callback and are untouched.

Gates

On .25 (Ubuntu): cargo check + clippy --all-targets -D warnings across pf-frame / pf-capture / pf-encode / punktfunk-host; pf-capture 68/68 (4 new HoldBook unit tests: budget cap, exactly-once release, generation-vs-address-reuse, duplicate-hold refusal); pf-encode 75/75 (+15 ignored, host-feature set); workspace cargo fmt --check clean.

Pre-existing, unrelated: punktfunk-host's hooks::prep_runs_do_in_order_and_undo_in_reverse fails on that box on pristine main too (crate untouched here).

Field verification owed

Reporter retest on a build with this change; PUNKTFUNK_ZEROCOPY_HOLD=0 bringing the artifacts back doubles as the diagnosis confirmation. Also worth capturing his pipewire buffer pool negotiated log line — if gamescope allocates ≤ 2 buffers, the hold is inert for him and the buffer-count request needs a rethink.

## Field report Nobara user, gamescope mode at 120 fps: intermittent pink/magenta tint plus blocky corruption on the stream ("I haven't noticed in KDE but I've seen it a few times in gamescope"). Screenshot signature: luma detail preserved under a magenta wash (chroma corruption) plus displaced-macroblock bands (damage propagating through the P-chain until the next intra). ## Root cause The raw-dmabuf passthrough requeued the SPA buffer to gamescope **inside `.process`**, before the encode thread had imported — let alone read — the dmabuf, and nothing anywhere ordered the producer's writes against the consumer's read: - no explicit sync (`SPA_META_SyncTimeline` unimplemented), and the implicit-fence wait measures `NoFence` on every compositor×vendor pairing we have (the in-tree matrix in `pipewire.rs`); - the encoder's FOREIGN-queue acquire carries `src_stage = NONE` — a visibility barrier, not a temporal one; - on the direct-VCN arms (producer-native NV12, RGB-direct EFC) the captured buffer **is the encode source** for the whole ring-2-deep encode, plus the phase-lock hold before submit — one to two frame periods of exposure. At 120 fps the producer's pool wrap time halves while that window stays constant, so gamescope cycles back into a buffer the VCN is still reading. A mid-encode rewrite of an NV12 buffer desynchronizes Y from UV — magenta with intact luma — and the damaged reference propagates. KDE sessions were clean because `cursor_blend` routes them to the compute-CSC copy arm, whose read window is microseconds, on top of a mostly-static desktop barely cycling the pool. The repeat path was worse still: with no fresh frame, the host re-submitted the *old* `DmabufFrame`, whose buffer gamescope had long since rewritten. The race was already documented in prose in three places (`pipewire.rs`, `pw_pods.rs` — "Nothing bounds that window"); this closes it. ## The fix — defer the requeue (zero-copy preserved) A published passthrough frame now carries a **`FrameHold`** (new field on `DmabufFrame`), and the buffer rejoins the producer's pool only when the last clone drops: - **pf-capture**: a per-stream `HoldBook` tracks withheld buffers (loop-thread-only mutations). Releases travel over a `pw::channel` back onto the loop thread — the only place buffers are legally requeued. A per-hold **generation tag** keeps a stale release from requeueing a renegotiated pool's reused address. Budget: at most `pool_depth − 2` buffers withheld (`HOLD_POOL_RESERVE`), so the producer can never starve; a pool of ≤ 2 cannot spare any and falls back to the old racy contract with one WARN. `PUNKTFUNK_ZEROCOPY_HOLD=0` (env_on grammar) restores the old behavior outright for field bisects. - **pf-encode (Vulkan)**: the ring slot clones the hold at submit (`Frame::src_hold`) and drops it when the slot's **fence retires** (poll / backpressure / reset) — extending "the producer must not rewrite this" across exactly the GPU read. The host's own clone drops as soon as it takes the next frame, which with a ring of 2 is before the encode finished; the slot clone is what makes the window airtight. The stale "the capture layer guarantees the buffer is ready at hand-off" comment is corrected. - **Repeat path**: fixed by the same mechanism — a repeated frame aliases a buffer the producer never got back, so it re-encodes the last frame's actual content. - PyroWave (sync at depth 1) and the libav VAAPI backend are covered by the frame's own clone; the CUDA-import and CPU arms complete their read inside the callback and are untouched. ## Gates On .25 (Ubuntu): `cargo check` + `clippy --all-targets -D warnings` across pf-frame / pf-capture / pf-encode / punktfunk-host; pf-capture **68/68** (4 new `HoldBook` unit tests: budget cap, exactly-once release, generation-vs-address-reuse, duplicate-hold refusal); pf-encode **75/75** (+15 ignored, host-feature set); workspace `cargo fmt --check` clean. Pre-existing, unrelated: `punktfunk-host`'s `hooks::prep_runs_do_in_order_and_undo_in_reverse` fails on that box on pristine main too (crate untouched here). ## Field verification owed Reporter retest on a build with this change; `PUNKTFUNK_ZEROCOPY_HOLD=0` bringing the artifacts back doubles as the diagnosis confirmation. Also worth capturing his `pipewire buffer pool negotiated` log line — if gamescope allocates ≤ 2 buffers, the hold is inert for him and the buffer-count request needs a rethink.
enricobuehler added 1 commit 2026-08-13 07:01:07 +00:00
fix(pf-capture): withhold zero-copy buffers from the producer until the encoder is done reading them
ci / bun-nix (pull_request) Successful in 29s
ci / web (pull_request) Successful in 1m8s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
ci / docs-site (pull_request) Successful in 4m14s
ci / rust-arm64 (pull_request) Successful in 4m52s
ci / rust (pull_request) Successful in 4m57s
android / android (pull_request) Successful in 7m6s
a9a1b923a2
The raw-dmabuf passthrough handed the SPA buffer back to gamescope at
.process return while the encode thread had not yet imported - let alone
read - its dmabuf, and nothing ordered the producer's writes against the
consumer's read (no explicit sync; the implicit-fence wait measures
NoFence on every compositor x vendor pairing we have). On the direct-VCN
arms (native NV12, RGB-direct EFC) the captured buffer IS the encode
source for the whole ring-2-deep encode plus the phase-lock hold, so at
120 fps gamescope cycles back into the buffer mid-encode and the stream
ships torn frames: luma/chroma desync (magenta tint) plus block
corruption propagating through the P-chain until the next intra. Field
report: Nobara, gamescope mode at 120 fps - KDE sessions were clean
because cursor_blend routes them to the compute-CSC copy arm whose read
window is microseconds.

The fix defers the requeue: a published passthrough frame carries a
FrameHold (new on DmabufFrame), and the buffer rejoins the producer's
pool only when the last clone drops. The Vulkan encoder clones the hold
into the ring slot at submit and releases it when the slot's fence
retires (poll/backpressure/reset), extending "the producer must not
rewrite this" across exactly the GPU read. The host loop's repeat path
is fixed by the same mechanism: a re-submitted frame now aliases a
buffer the producer never got back, instead of whatever gamescope last
composited into it.

Bookkeeping lives in a per-stream HoldBook (loop-thread mutations only):
holds release through a pw channel onto the loop thread, a generation
tag keeps a stale release from requeueing a renegotiated pool's reused
address, and at most pool_depth - HOLD_POOL_RESERVE buffers are ever
withheld - a pool at the old floor of 2 cannot spare any and falls back
(with one warn) to the previous racy contract. PUNKTFUNK_ZEROCOPY_HOLD=0
restores the old behavior outright for field bisects.

Gates (.25): cargo check + clippy --all-targets -D warnings on
pf-frame/pf-capture/pf-encode/punktfunk-host; pf-capture 68/68 (4 new
HoldBook tests), pf-encode 75/75 (+15 ignored, host-feature set);
workspace cargo fmt --check clean. punktfunk-host's
hooks::prep_runs_do_in_order_and_undo_in_reverse fails on that box on
pristine main too (pre-existing; crate untouched here).
enricobuehler merged commit 981f32b8f6 into main 2026-08-13 07:24:08 +00:00
enricobuehler deleted branch worktree-gamescope-tear-race 2026-08-13 07:24:09 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#188