fix(capture): a refused EGL→CUDA dmabuf offer stops being asked
ci / rust (push) Failing after 41s
android / android (push) Canceled after 1m19s
apple / screenshots (push) Canceled after 0s
apple / swift (push) Canceled after 1m20s
arch / build-publish (push) Canceled after 1m23s
ci / rust-arm64 (push) Canceled after 1m24s
ci / web (push) Canceled after 1m24s
ci / docs-site (push) Canceled after 1m23s
ci / bench (push) Canceled after 1m22s
deb / build-publish-client-arm64 (push) Canceled after 0s
deb / build-publish (push) Canceled after 46s
deb / build-publish-host (push) Canceled after 2s
decky / build-publish (push) Canceled after 1s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Canceled after 0s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-host / package (push) Canceled after 1m35s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5s

The raw-dmabuf passthrough has had a negotiation-timeout latch since the
hybrid-Intel case: one conclusive timeout and later captures negotiate the
CPU path instead of re-paying it. The EGL→CUDA dmabuf-only offer had no
equivalent — a compositor that accepts none of the importer's modifiers
timed out the same 10 s negotiation on every session, forever, under the
generic 'format negotiation never completed' diagnosis.

Now the symmetric latch (note_gpu_dmabuf_negotiation_failed) gates
build_importer in the one negotiation resolver, scoped to this offer alone
(raw passthrough, worker-death latch, encoder untouched), with the same
operator escape as the raw arm: an explicit PUNKTFUNK_ZEROCOPY=1 keeps
erroring loudly instead of downgrading.

One correctness detail beyond the handoff's sketch: whether the GPU offer
was ACTUALLY advertised is a runtime fact of the PipeWire thread (the
importer may fail to construct — no CUDA — in which case no dmabuf was
offered and a timeout must not latch it off). plan.build_importer alone
cannot answer that, so the thread records the made-offer on CaptureSignals
(gpu_dmabuf_offer) and the timeout diagnosis branches on the offer that
really happened, not the plan's intent.

Also renames vaapi_dmabuf_forced → zerocopy_forced (it now guards both
timeout arms; single caller). New plan invariant pinned in tests: the
latch gates only the importer, never the raw passthrough.

(V3 from design/pf-zerocopy-sweep-handoff.md — the deferred design call,
now decided in favour of the symmetric latch.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 13:15:36 +02:00
co-authored by Claude Fable 5
parent e92a0aaa00
commit 9b3ec9204c
3 changed files with 116 additions and 13 deletions
+37 -6
View File
@@ -60,7 +60,8 @@ fn flag(name: &str) -> bool {
/// True when `PUNKTFUNK_ZEROCOPY` is explicitly truthy — the operator forced the dmabuf offer, so
/// a failed negotiation keeps erroring loudly instead of silently downgrading to the CPU path.
pub fn vaapi_dmabuf_forced() -> bool {
/// Read by BOTH negotiation-timeout latches (the raw passthrough's and the EGL→CUDA offer's).
pub fn zerocopy_forced() -> bool {
flag_opt("PUNKTFUNK_ZEROCOPY") == Some(true)
}
@@ -70,11 +71,12 @@ pub fn vaapi_dmabuf_forced() -> bool {
/// Vulkan (LINEAR) imports now run in a per-capture worker subprocess
/// (`design/zerocopy-worker-isolation.md`), so a driver fault on a producer-invalidated dmabuf kills
/// the worker and the host degrades to its capture-loss rebuild instead of dying — the reason the
/// NVENC path stayed opt-in is gone. Fallbacks stay in place: VAAPI has a one-shot CPU downgrade if
/// the LINEAR-dmabuf offer never negotiates ([`note_raw_dmabuf_negotiation_failed`]); NVENC falls back per
/// capture when no importer/importable modifier is available and latches the import off after
/// repeated worker deaths. `PUNKTFUNK_ZEROCOPY=0` opts out; `PUNKTFUNK_FORCE_SHM` forces the
/// race-free SHM path.
/// NVENC path stayed opt-in is gone. Fallbacks stay in place: VAAPI has a one-shot CPU downgrade
/// if the LINEAR-dmabuf offer never negotiates ([`note_raw_dmabuf_negotiation_failed`]); NVENC
/// falls back per capture when no importer/importable modifier is available, and latches the
/// import off after repeated worker deaths or its own negotiation timeout
/// ([`note_gpu_dmabuf_negotiation_failed`]). `PUNKTFUNK_ZEROCOPY=0` opts out;
/// `PUNKTFUNK_FORCE_SHM` forces the race-free SHM path.
///
/// This is the GLOBAL switch and nothing but the env var moves it. It used to fall back to
/// `!VAAPI_DMABUF_FAILED`, a latch a capture-side dmabuf *negotiation* timeout set — which made one
@@ -311,6 +313,35 @@ pub fn raw_dmabuf_import_disabled() -> bool {
RAW_DMABUF_DISABLED.load(Ordering::Relaxed)
}
/// The EGL→CUDA twin of the raw-passthrough negotiation latch: the capture advertised the GPU
/// importer's dmabuf-only offer and the compositor never accepted it. Without this, the raw
/// passthrough stopped being asked after one timeout while the GPU-import offer re-ran the same
/// 10 s negotiation timeout on every session, forever — the mirror image of the hybrid-Intel case
/// [`note_raw_dmabuf_negotiation_failed`] was written for.
static GPU_DMABUF_NEGOTIATION_FAILED: AtomicBool = AtomicBool::new(false);
/// Latch the EGL→CUDA dmabuf offer off because it *never negotiated*. One timeout is conclusive
/// for this offer too: a compositor that cannot allocate any of the advertised EGL-importable
/// modifiers refuses them identically on every retry. Scoped deliberately — this gates only
/// `build_importer` (the capture-side EGL→CUDA offer); the raw passthrough, the worker-death
/// latch, and the encoder are untouched.
pub fn note_gpu_dmabuf_negotiation_failed() {
if !GPU_DMABUF_NEGOTIATION_FAILED.swap(true, Ordering::Relaxed) {
tracing::warn!(
"zero-copy EGL→CUDA dmabuf offer disabled for this host process: the compositor never \
accepted the GPU importer's dmabuf-only capture offer, so later captures negotiate \
the CPU path instead of repeating that timeout (the raw-dmabuf passthrough is NOT \
affected)"
);
}
}
/// True once a negotiation timeout latched the EGL→CUDA dmabuf offer off (see
/// [`note_gpu_dmabuf_negotiation_failed`]).
pub fn gpu_dmabuf_negotiation_disabled() -> bool {
GPU_DMABUF_NEGOTIATION_FAILED.load(Ordering::Relaxed)
}
/// DRM FourCC for a packed 32-bit format name (little-endian, e.g. `b"XR24"`).
const fn fourcc(c: &[u8; 4]) -> u32 {
(c[0] as u32) | ((c[1] as u32) << 8) | ((c[2] as u32) << 16) | ((c[3] as u32) << 24)