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:
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user