fix(vdisplay/windows): exclusive topology gets re-asserted — a verified isolate is not durable
ci / docs-site (push) Successful in 1m8s
ci / web (push) Successful in 1m40s
apple / swift (push) Successful in 5m9s
ci / bench (push) Successful in 6m36s
ci / rust-arm64 (push) Successful in 10m12s
deb / build-publish (push) Successful in 9m32s
decky / build-publish (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
android / android (push) Canceled after 13m40s
apple / screenshots (push) Canceled after 8m25s
arch / build-publish (push) Canceled after 13m44s
ci / rust (push) Canceled after 13m45s
deb / build-publish-host (push) Canceled after 12m5s
deb / build-publish-client-arm64 (push) Canceled after 7m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 2s
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
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 2m28s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 1m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1s
windows-host / package (push) Canceled after 13m45s

The CCD isolate verifies "sole active desktop" at commit time and is never
checked again. On a hybrid Intel+NVIDIA box the internal panel is re-activated
moments later (the isolated topology is deliberately not saved to the CCD
database so teardown can restore the user's layout, and the post-isolate
resize/HDR churn — or the panel's own driver, or display-poller software —
re-resolves back to the stored layout). Proven on-glass: seconds after the
verify, CCD showed the panel ACTIVE beside the virtual display while the host
still believed it was exclusive — cursor and windows can land off-stream, and
the lock screen can land on the physical panel.

A group-scoped watchdog now re-queries every PUNKTFUNK_EXCLUSIVE_REASSERT_MS
(default 2 s, 0 disables) while an EXCLUSIVE isolate is live and re-issues the
isolate when a non-managed display crept back, logging who-is-fighting
escalation (3×WARN → 1×ERROR → DEBUG). Gated on a new GroupState.ccd_exclusive
flag so a Primary group's deliberately-lit panels are never "fixed". Cycles
take the state lock via try_lock with a sliced sleep, so teardown's stop+join
under the state lock cannot deadlock and is bounded by ~250 ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 16:53:19 +02:00
co-authored by Claude Fable 5
parent 6c2c8b6eab
commit 8362d57001
7 changed files with 379 additions and 5 deletions
@@ -159,6 +159,10 @@ struct GroupState {
/// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled at
/// the group's first isolate — last-member teardown re-enables them BEFORE the CCD restore.
pnp_disabled: Vec<String>,
/// Whether `ccd_saved` was captured by an EXCLUSIVE isolate (vs `Primary`, which also
/// snapshots but deliberately keeps the physical displays active) — gates the re-assert
/// watchdog, which must never "fix" a Primary group's lit panels. Cleared with the restore.
ccd_exclusive: bool,
}
/// The manager's guarded state: the slot map + the (single) group record. One lock for both — every
@@ -183,7 +187,8 @@ impl MgrInner {
}
/// The single device-level watchdog pinger, running while ANY slot lives (any IOCTL bumps the driver
/// watchdog, so one thread serves N monitors).
/// watchdog, so one thread serves N monitors). Also reused as the stop+join pair for the
/// exclusive-topology re-assert watchdog (same lifecycle shape).
struct Pinger {
stop: Arc<AtomicBool>,
thread: JoinHandle<()>,
@@ -240,6 +245,10 @@ pub struct VirtualDisplayManager {
idd_session_stops: Mutex<std::collections::HashMap<u32, Arc<AtomicBool>>>,
/// The device-level watchdog [`Pinger`], running while any slot lives.
pinger: Mutex<Option<Pinger>>,
/// The exclusive-topology re-assert watchdog (same stop+join shape as [`Pinger`]), running
/// while an EXCLUSIVE group isolate is live — a verified isolate is not durable on hybrid
/// boxes (see [`Self::ensure_exclusive_watch`]).
exclusive_watch: Mutex<Option<Pinger>>,
// The per-client stable monitor-id map is now the process-wide `super::identity::global()`
// (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE
// resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx
@@ -248,6 +257,19 @@ pub struct VirtualDisplayManager {
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
/// Bumped on every exclusive-topology EVICTION the re-assert watchdog performs. An eviction is a
/// real topology change, and the OS drives COMMIT_MODES on every live path for those — the IDD
/// display's swap-chain is recreated driver-side while the session's capture ring keeps waiting
/// on the old attachment, so frames stop silently (on-glass: panel evicted, stream frozen). The
/// manager can only announce; the SESSION owns the ring + encoder — stream loops sample this at
/// start and rebuild their capture attachment in place when it advances.
static TOPOLOGY_REASSERT_GEN: AtomicU64 = AtomicU64::new(0);
/// The current exclusive-eviction generation (see [`TOPOLOGY_REASSERT_GEN`]).
pub fn topology_reassert_gen() -> u64 {
TOPOLOGY_REASSERT_GEN.load(Ordering::Relaxed)
}
/// Initialise the process-wide manager with `driver` (the chosen backend) and return it. Idempotent: the
/// first backend to call wins (the host runs one backend per process), so a later call ignores its driver.
pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayManager {
@@ -262,6 +284,7 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
setup_lock: Mutex::new(()),
idd_session_stops: Mutex::new(std::collections::HashMap::new()),
pinger: Mutex::new(None),
exclusive_watch: Mutex::new(None),
})
}
@@ -775,6 +798,130 @@ impl VirtualDisplayManager {
}
}
/// Start the exclusive-topology re-assert watchdog (idempotent). A verified
/// [`isolate_displays_ccd`] is NOT durable: the isolated topology is deliberately not saved to
/// the CCD database (teardown must restore the user's layout), so any later topology
/// re-resolution — our own post-isolate resize/HDR commit, the deactivated panel's own driver,
/// display-poller software — can bring the stored layout back. Field-proven on a hybrid
/// Intel+NVIDIA box (`.221`): seconds after the commit-time verify reported "sole active
/// desktop", CCD showed the internal panel ACTIVE again beside the virtual display, and the
/// host never noticed. That silently un-does exclusive mode: cursor + windows can land
/// off-stream, and the secure desktop / lock screen can land on the physical panel.
///
/// The watchdog re-queries every [`knobs::exclusive_reassert_ms`] and, when a non-managed
/// display crept back, evicts it via the full [`isolate_displays_ccd`] — the forced
/// re-commit is required (it restarts OS presentation to the virtual display), and the
/// swap-chain bounce it causes is healed by the SESSION's in-place capture re-attach, keyed
/// off [`topology_reassert_gen`]. Each cycle takes the state lock via `try_lock` —
/// teardown stops+joins this thread while HOLDING the state lock, so a blocking `lock()` here
/// would deadlock; a contended cycle just skips (the slot transition that owns the lock
/// re-isolates itself). The sleep is sliced so that stop+join is bounded by ~250 ms, not a
/// full cycle.
fn ensure_exclusive_watch(&'static self) {
let interval = Duration::from_millis(knobs::exclusive_reassert_ms());
if interval.is_zero() {
return; // knob 0 = disabled (the pre-watchdog behavior)
}
let mut guard = self.exclusive_watch.lock().unwrap();
if guard.is_some() {
return;
}
let stop = Arc::new(AtomicBool::new(false));
let stop_t = stop.clone();
let thread = thread::Builder::new()
.name("vdisplay-exclusive-watch".into())
.spawn(move || {
// Consecutive cycles that found (and evicted) a non-managed display — resets when a
// cycle comes up clean, so an actor that re-adds the panel every few minutes logs a
// WARN each time, while one fighting every cycle escalates once and then goes quiet.
let mut fighting = 0u32;
'watch: loop {
let mut slept = Duration::ZERO;
while slept < interval {
if stop_t.load(Ordering::Relaxed) {
break 'watch;
}
let slice = Duration::from_millis(250).min(interval - slept);
thread::sleep(slice);
slept += slice;
}
let Ok(inner) = vdm().state.try_lock() else {
continue;
};
if inner.group.ccd_saved.is_none() || !inner.group.ccd_exclusive {
continue; // no exclusive isolate live right now
}
let keep = inner.target_ids();
if keep.is_empty() {
continue;
}
// SAFETY: `count_other_active` runs the CCD QueryDisplayConfig FFI over a
// borrowed slice of `Copy` target ids (owned result), under the `state` lock
// (`inner` guard held to the end of this iteration).
let survivors = unsafe { count_other_active(&keep) }.unwrap_or(0);
if survivors == 0 {
if fighting > 0 {
tracing::info!(
reasserts = fighting,
"exclusive topology stable again — no non-managed display active"
);
}
fighting = 0;
continue;
}
fighting += 1;
match fighting {
1..=3 => tracing::warn!(
survivors,
round = fighting,
"exclusive topology lost — a non-managed display re-activated after \
the verified isolate (hybrid-GPU driver / display-poller software \
restoring the saved layout?); re-asserting the isolate"
),
4 => tracing::error!(
survivors,
"exclusive topology keeps being re-activated (4 consecutive \
re-asserts) — something on this host is fighting the isolate; \
continuing to re-assert every cycle, further rounds log at DEBUG"
),
_ => tracing::debug!(
survivors,
round = fighting,
"re-asserting exclusive topology"
),
}
// SAFETY: `isolate_displays_ccd` drives the CCD query/apply FFI over a
// borrowed slice of `Copy` target ids, under the `state` lock — the sole
// topology mutator. The returned snapshot is discarded (the group restores
// the FIRST member's). The FULL isolate on purpose — its forced re-commit
// (SDC_FORCE_MODE_ENUMERATION → COMMIT_MODES → ASSIGN_SWAPCHAIN) is
// load-bearing here exactly as at first isolate: after the eviction's
// topology change the OS stops presenting to the virtual display until a
// forced mode re-commit restarts it (on-glass: a gentle supplied-config
// eviction left capture receiving ONE stashed frame and then nothing).
let _ = unsafe { isolate_displays_ccd(&keep) };
// That same forced re-commit hands the live IDD path a fresh swap-chain,
// orphaning the session's capture ring — announce it so the session rebuilds
// its capture attachment (same-mode ring recreate + driver re-attach + fresh
// encoder) instead of silently streaming a frozen frame. The two halves are
// a pair: eviction restarts presentation, the session re-attaches capture.
TOPOLOGY_REASSERT_GEN.fetch_add(1, Ordering::Relaxed);
}
})
.expect("spawn vdisplay-exclusive-watch");
*guard = Some(Pinger { stop, thread });
}
/// Stop + join the exclusive-topology watchdog (the group's exclusive isolate is being
/// restored). Safe under the state lock: the watchdog only ever `try_lock`s it, and its sliced
/// sleep bounds the join by ~250 ms.
fn stop_exclusive_watch(&self) {
if let Some(w) = self.exclusive_watch.lock().unwrap().take() {
w.stop.store(true, Ordering::Relaxed);
let _ = w.thread.join();
}
}
/// Arrange the live slots' desktop origins (design §6.2: the pure `vdisplay/layout.rs` engine —
/// `auto-row` default, console `manual` pins win) and commit them in one CCD apply. No-ops for a
/// single member (it sits at the origin), so the single-display path issues no positioning at
@@ -998,6 +1145,12 @@ impl VirtualDisplayManager {
);
}
}
// The verified isolate above is not durable — watch and re-assert
// while the exclusive group lives (see `ensure_exclusive_watch`).
inner.group.ccd_exclusive = inner.group.ccd_saved.is_some();
if inner.group.ccd_exclusive {
self.ensure_exclusive_watch();
}
} else {
// Grown set: re-isolate so the fresh member joins the composited set
// (its auto-activate may have lit nothing extra to deactivate, but the
@@ -1413,6 +1566,10 @@ impl VirtualDisplayManager {
// (stopped FIRST, as the per-monitor pinger was), and the group's topology restore runs
// — first-in captured it, last-out restores it (design §6.1).
self.stop_pinger();
// The exclusive re-assert watchdog must be gone BEFORE the restore below — it would
// read the restored (multi-display) topology as "lost exclusivity" and re-fight it.
// (Belt-and-braces: its cycles also gate on `ccd_exclusive`, cleared below.)
self.stop_exclusive_watch();
// EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let
// them re-arrive, so a CCD restore below re-activates paths whose monitors exist
// again (a disabled devnode would leave the restored path modeless/EDID-less).
@@ -1425,6 +1582,7 @@ impl VirtualDisplayManager {
}
// Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero
// displays.
inner.group.ccd_exclusive = false;
if let Some(saved) = inner.group.ccd_saved.take() {
restore_displays_ccd(&saved);
// EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and
@@ -34,6 +34,16 @@ pub(super) fn keep_alive_forever() -> bool {
.unwrap_or(false)
}
/// Cadence of the exclusive-topology re-assert watchdog (`PUNKTFUNK_EXCLUSIVE_REASSERT_MS`,
/// default 2000, `0` disables — the pre-watchdog behavior). Why it exists: a verified isolate is
/// not durable — see `VirtualDisplayManager::ensure_exclusive_watch` in the parent module.
pub(super) fn exclusive_reassert_ms() -> u64 {
std::env::var("PUNKTFUNK_EXCLUSIVE_REASSERT_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(2_000)
}
/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's
/// [`effective_topology`](crate::effective_topology) when configured, else the legacy
/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD