From 1945052e6675d68c29082302c23f0c6e2ac92587 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 27 Jul 2026 15:37:05 +0200 Subject: [PATCH] fix(capture/vdisplay): descriptor-following defers while a topology reassert is in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exclusive-topology reassert's forced re-commit transiently bounces the virtual display's mode; the descriptor poller can sample that EVICTION state and recreate the ring at a mode the recovery chain is about to undo (field log 2026-07-27 10:30:44Z: hdr=true → false → recreate → recovery restores true → second recreate). New pf-win-display::topology_churn latch (deadline semantics — self-expiring, so a lost release can never wedge descriptor- following off): the watchdog holds it for interval+3s each fighting round and releases on "stable again"; poll_display_hdr consumes samples but acts on nothing while held — which also keeps the negotiated-depth pin-back from issuing a CCD write mid-eviction, where it would fight the reassert itself. The deliberate recreate_ring_in_place recovery path is unaffected. Co-Authored-By: Claude Fable 5 --- crates/pf-capture/src/windows/idd_push.rs | 12 +++ .../src/vdisplay/windows/manager.rs | 10 +++ crates/pf-win-display/src/lib.rs | 3 + crates/pf-win-display/src/topology_churn.rs | 74 +++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 crates/pf-win-display/src/topology_churn.rs diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index 3b62ded0..ce010548 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -732,6 +732,18 @@ impl IddPushCapturer { return; // no new sample since last consume } self.desc_seq = seq; + // A topology reassert is in flight (the exclusive watchdog announced it): every sample in + // this window is potentially the TRANSIENT eviction state, and acting on one recreates + // the ring at a mode the reassert's recovery chain (`recreate_ring_in_place`, keyed off + // the reassert generation) is about to undo — the field hdr=true→false→true double + // recreate. Consume the sample, disarm the debounce, act on nothing; a REAL change that + // races the window survives it (the descriptor still differs once the hold clears, and + // the poller re-samples in ~250 ms). This also keeps the negotiated-depth pin-back below + // from issuing a CCD write mid-eviction, where it would fight the reassert itself. + if pf_win_display::topology_churn::held() { + self.pending_desc = None; + return; + } // Two cases re-assert the NEGOTIATED depth instead of following a mid-session "Use HDR" // flip — flip the display back and treat the descriptor as the negotiated state (so the ring // is never recreated at the wrong format): diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs index 859ceb7b..70ed2617 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs @@ -865,11 +865,21 @@ impl VirtualDisplayManager { reasserts = fighting, "exclusive topology stable again — no non-managed display active" ); + // Close the churn window early — descriptor-following resumes now + // instead of waiting out the hold's self-expiry. + pf_win_display::topology_churn::release(); } fighting = 0; continue; } fighting += 1; + // Announce the churn BEFORE evicting: every descriptor the capturer's poller + // samples from here until "stable again" (or self-expiry) is potentially the + // TRANSIENT eviction state — acting on it would recreate the ring at a mode + // the recovery chain is about to undo (the field hdr=true→false→true double + // recreate). Window = watchdog interval + recovery/debounce margin, refreshed + // every fighting round. + pf_win_display::topology_churn::hold(interval + Duration::from_secs(3)); match fighting { 1..=3 => tracing::warn!( survivors, diff --git a/crates/pf-win-display/src/lib.rs b/crates/pf-win-display/src/lib.rs index ddcee95f..eba7e641 100644 --- a/crates/pf-win-display/src/lib.rs +++ b/crates/pf-win-display/src/lib.rs @@ -16,6 +16,9 @@ pub mod display_events; mod input_desktop; #[cfg(target_os = "windows")] pub mod monitor_devnode; +/// Cross-crate "topology churn in flight" latch (pure std — no Windows surface, so unconditionally +/// compiled and unit-tested on every platform). +pub mod topology_churn; #[cfg(target_os = "windows")] pub mod win_display; diff --git a/crates/pf-win-display/src/topology_churn.rs b/crates/pf-win-display/src/topology_churn.rs new file mode 100644 index 00000000..924d1430 --- /dev/null +++ b/crates/pf-win-display/src/topology_churn.rs @@ -0,0 +1,74 @@ +//! Cross-crate "topology churn in flight" latch (stall-immunity program): the exclusive-topology +//! reassert watchdog (pf-vdisplay's manager) announces that it is evicting/restoring displays, and +//! the IDD-push capturer's descriptor follower (pf-capture) defers acting on descriptor changes +//! while the window is open. +//! +//! Why: the reassert's forced re-commit transiently bounces the virtual display's mode — the +//! descriptor poller can sample the EVICTION state (field log 2026-07-27 10:30:44Z: `hdr=true` → +//! `hdr=false` → recreate → recovery restores `hdr=true` → second recreate). Every descriptor +//! sampled inside the window is potentially that transient, and acting on it recreates the ring at +//! a mode the recovery chain is about to undo. The deliberate recovery rebuild +//! (`recreate_ring_in_place`, keyed off the reassert generation) is NOT affected — only the +//! passive descriptor-following is. +//! +//! Deadline semantics, not a flag: a `hold()` self-expires, so a holder that dies mid-churn (or a +//! release lost to a teardown race) can never wedge descriptor-following off forever. `release()` +//! just expires the deadline early on the watchdog's "stable again" observation. + +use std::sync::{ + atomic::{AtomicU64, Ordering}, + OnceLock, +}; +use std::time::{Duration, Instant}; + +/// Deadline as milliseconds on the process-local [`clock`]; `0` = no hold. +static HOLD_UNTIL_MS: AtomicU64 = AtomicU64::new(0); + +/// Process-local monotonic epoch (an `Instant` cannot live in an atomic). +fn clock() -> u64 { + static EPOCH: OnceLock = OnceLock::new(); + EPOCH.get_or_init(Instant::now).elapsed().as_millis() as u64 +} + +/// Open (or extend) the churn window for `dur` from now. `fetch_max` so overlapping holders — a +/// reassert round racing a slot transition — never shorten each other's window. +pub fn hold(dur: Duration) { + let until = clock().saturating_add(dur.as_millis() as u64); + HOLD_UNTIL_MS.fetch_max(until, Ordering::Relaxed); +} + +/// Expire the window now (the watchdog observed a stable topology again). +pub fn release() { + HOLD_UNTIL_MS.store(0, Ordering::Relaxed); +} + +/// Is a churn window open? Cheap enough for a per-frame path (one atomic load + a monotonic read). +#[must_use] +pub fn held() -> bool { + clock() < HOLD_UNTIL_MS.load(Ordering::Relaxed) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The latch's contract end-to-end: closed at rest, open after `hold`, extended by the longer + /// of two overlapping holds, closed by `release`, and self-expiring without one. + #[test] + fn hold_release_expire() { + release(); + assert!(!held()); + hold(Duration::from_secs(60)); + assert!(held()); + // A shorter overlapping hold must not shorten the window. + hold(Duration::from_millis(1)); + assert!(held()); + release(); + assert!(!held()); + // Self-expiry: a millisecond-scale hold lapses on its own. + hold(Duration::from_millis(30)); + assert!(held()); + std::thread::sleep(Duration::from_millis(60)); + assert!(!held()); + } +}