fix(windows/cursor): gate the between-session clean on STREAMING sessions, not keep-alive

Measured on .173: the guard never fired in the one case it exists for. After a
desktop-mode session disconnects its monitor LINGERS, so `no_live_displays()`
— which counted keep-alive slots as held — refused the restart on exactly the
reconnect that needed it, and the capture session went back to
`cursor_excluded=true` and forced compositing.

Only a streaming session (SlotState::Active) can be damaged by the restart. A
lingering/pinned monitor has no session attached and a reconnect preempts and
recreates it anyway ("a reused IddCx swap-chain is dead"), so the restart
destroys nothing that was going to survive.
This commit is contained in:
2026-08-08 18:36:41 +02:00
parent 537a1852ed
commit b20184462d
2 changed files with 22 additions and 13 deletions
@@ -421,23 +421,31 @@ pub fn hw_cursor_capable() -> bool {
m.driver_proto.load(Ordering::Relaxed) >= 5
}
/// Does this host currently hold NO virtual display at all (no live session, no keep-alive slot)?
/// Is NO session currently streaming to a virtual display?
///
/// The safety question for anything that tears the adapter down — notably
/// [`crate::driver::clean_cursor_for_next_session`], whose `pnputil /restart-device` would take
/// every monitor on the adapter with it. Deliberately counts KEPT slots as well as streaming ones:
/// a keep-alive monitor belongs to a client that is expected back, and destroying it under them is
/// exactly the kind of cross-session damage the cursor clean-up exists to avoid causing.
pub fn no_live_displays() -> bool {
/// [`crate::driver::clean_cursor_for_next_session`], whose `pnputil /restart-device` takes every
/// monitor on the adapter with it. Only [`SlotState::Active`] counts: that is a session with live
/// references, and destroying its monitor mid-stream is the cross-session damage worth refusing.
///
/// `Lingering`/`Pinned` slots deliberately do NOT count. They are keep-alive monitors with no
/// session attached, and a reconnect **already** preempts and recreates them — "a reused IddCx
/// swap-chain is dead" (see [`SlotState::Pinned`]) — so a device restart destroys nothing the
/// reconnect was not going to destroy anyway. Counting them was too conservative to be useful: the
/// case this gate exists for is exactly *disconnect from a desktop session, reconnect in capture
/// mode*, and the disconnected session's monitor is lingering at precisely that moment, so the
/// clean-up could never fire when it was most wanted (observed on `.173`, 2026-08-08).
pub fn no_active_sessions() -> bool {
match VDM.get() {
// Before the first backend open there is nothing to protect.
None => true,
Some(m) => m
Some(m) => !m
.state
.lock()
.unwrap_or_else(|e| e.into_inner())
.slots
.is_empty(),
.values()
.any(|s| matches!(s, SlotState::Active { .. })),
}
}
@@ -212,11 +212,12 @@ pub fn clean_cursor_for_next_session(session_wants_declare: bool) -> bool {
if session_wants_declare || !CURSOR_DECLARED.load(Ordering::Relaxed) {
return false;
}
// Conservative: a KEPT keep-alive slot counts as held, so a client that is expected back does
// not lose its monitor to another client's cursor preference. The cost of being wrong here is
// someone else's session dying; the cost of skipping is one session compositing its own
// pointer, which is merely the old behaviour.
if !super::manager::no_live_displays() {
// Refuse only while another session is STREAMING — a keep-alive (lingering/pinned) monitor has
// no session attached and a reconnect recreates it regardless, so restarting the adapter costs
// it nothing. Gating on keep-alive too made this dead code in the one case it exists for: after
// a desktop session disconnects its monitor LINGERS, which is exactly when the next
// capture-mode connect needs the declare gone (observed on .173).
if !super::manager::no_active_sessions() {
tracing::info!(
"cursor: this session wants no hardware cursor and an earlier one declared, but a display is still held (live or keep-alive) — skipping the adapter restart, so the pointer stays host-composited for this session"
);