fix(capture/vdisplay): descriptor-following defers while a topology reassert is in flight

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 15:37:05 +02:00
co-authored by Claude Fable 5
parent 9e1a686795
commit 1945052e66
4 changed files with 99 additions and 0 deletions
+12
View File
@@ -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):
@@ -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,
+3
View File
@@ -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;
@@ -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<Instant> = 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());
}
}