fix(pf-zerocopy): one dmabuf timeout condemned every later capture on the host, forever

Wave-2 PW3.

The raw-dmabuf passthrough has two very different reasons to switch itself off, and they shared one
`AtomicBool`:

  * the encoder repeatedly failed to import what this compositor allocates — unrecoverable, a
    driver fact, and the reason this latch was written (it stops the encode-stall recovery
    rebuilding the same doomed encoder five times and then ending the session, on every connection,
    forever);
  * the dmabuf-only capture offer never negotiated — which can simply mean the compositor was
    mid-restart.

Sharing the flag made the second as permanent as the first. One timeout, and EVERY later session on
the host captured CPU frames until the process was restarted — including sessions against a
completely different compositor and a different node, which had never failed at anything. Nothing
said so; the arm line PW2 added would have shown `cpu` with no explanation.

Now the two causes have the lifetimes they should have, in a `RawDmabufLatch` that owns both:

  * Import failures stay sticky. Unchanged threshold (3 consecutive), unchanged hazard coverage.
  * Negotiation timeouts get a retry budget of 2 — one retry, deliberately small: each failure
    costs a ~10 s stall, so a larger budget is paid by the user in dead air. One retry survives the
    mid-restart transient; a compositor that genuinely never accepts keeps the same identity, so it
    latches on the second try, one extra stall per host lifetime versus the old behaviour.
  * A capture that negotiates credits the budget back, so an evening of reconnects against a
    compositor that failed once cannot accumulate its way into a latch.
  * BOTH are keyed to a capture identity (node id + portal bit). A new node — fresh virtual output,
    compositor restart, the Bazzite Gaming↔Desktop switch — is a genuinely different question and
    earns a fresh dmabuf attempt instead of inheriting a verdict about something else. The SAME
    capture keeps its verdict, which is what preserves the 10 s-stall protection the latch exists
    for.

The session-open line now carries the latch state, so `cpu` is no longer ambiguous between "this
host was never going to do dmabuf" and "something failed earlier and we are still living with the
verdict" — only the second is a bug worth chasing, and only the second is now visible as one.

Atomics rather than a lock because `note_import_ok` is on the per-frame import path; everything
else runs at pipeline build or on failure. The state machine is tested against a local instance
rather than the process-wide static — seven tests covering both lifetimes, the identity clear, the
same-identity hold, the budget credit, and the cause naming.

One honest note on the identity: it is the PipeWire node id, not the "(compositor-id, modifier
list)" pair the design sketched. Node id is what capture actually has at that point, and it changes
on exactly the events that matter here (new virtual output, compositor restart, session switch).
Keying on the modifier list too would need the list before the importer is built, which is the
wrong order.
This commit is contained in:
2026-08-08 15:40:55 +02:00
parent 767e67caf4
commit b815e00a87
3 changed files with 351 additions and 28 deletions
+23
View File
@@ -168,6 +168,10 @@ pub struct PortalCapturer {
/// 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,
/// PW3: this capture's dmabuf offer has been confirmed to negotiate (a frame arrived), so the
/// negotiation retry budget has already been credited back. One-shot — the credit is per
/// capture, not per frame.
negotiation_confirmed: 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
/// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta).
@@ -412,6 +416,7 @@ impl PwHandles {
signals: self.signals,
stall_since: None,
vaapi_dmabuf: self.vaapi_dmabuf,
negotiation_confirmed: false,
hdr_offer: self.hdr_offer,
hdr_source,
node_id,
@@ -468,6 +473,13 @@ fn spawn_pipewire(
} else {
want_hdr
};
// PW3: tell the raw-dmabuf latch which capture this is BEFORE reading its verdict below. A
// different node id is a different question — a fresh virtual output, a compositor restart,
// the Bazzite Gaming↔Desktop switch — and inheriting "dmabuf does not work here" from an
// unrelated capture is how one transient timeout used to cost a host CPU capture until it was
// restarted. The portal bit is in the key because a portal-fd capture and a virtual-output
// capture with the same node number are genuinely different sources.
pf_zerocopy::note_raw_dmabuf_capture(u64::from(node_id) | (u64::from(fd.is_some()) << 32));
// 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 {
@@ -705,6 +717,7 @@ impl PortalCapturer {
// The slot before the wakeup: a publish that coalesced its edge (or landed while we were
// not waiting) is still visible here.
if let Some(f) = self.take_frame() {
self.note_negotiation_confirmed();
return Ok(f);
}
let slice = Duration::from_millis(500)
@@ -728,6 +741,16 @@ impl PortalCapturer {
self.slot.lock().ok().and_then(|mut s| s.take())
}
/// PW3: a frame arrived, so this capture's dmabuf-only offer DID negotiate — credit the
/// negotiation retry budget back. Only meaningful for a capture that actually made that offer,
/// and only once per capture (the budget counts consecutive failed BUILDS, not frames).
fn note_negotiation_confirmed(&mut self) {
if self.vaapi_dmabuf && !self.negotiation_confirmed {
self.negotiation_confirmed = true;
pf_zerocopy::note_raw_dmabuf_negotiation_ok();
}
}
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
fn next_frame_timed_out(
+6
View File
@@ -1162,6 +1162,12 @@ pub fn pipewire_thread(
capture_arm = arm.as_str(),
consumer = consumer.as_str(),
modifier_count = modifiers.len(),
// PW3(c): the latch state belongs on the same line as the arm. A `cpu` arm has two very
// different explanations — "this host was never going to do dmabuf" and "something failed
// earlier and we are still living with the verdict" — and only the second is a bug worth
// chasing. Reading it here also means the retry/clear behaviour is observable rather than
// inferred.
raw_dmabuf_latch = pf_zerocopy::raw_dmabuf_latch_state(),
"capture pipeline resolved: {} → {}",
arm.as_str(),
consumer.as_str()