diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs index 313a9bb6..0d9686b4 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs @@ -421,6 +421,26 @@ 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)? +/// +/// 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 { + match VDM.get() { + // Before the first backend open there is nothing to protect. + None => true, + Some(m) => m + .state + .lock() + .unwrap_or_else(|e| e.into_inner()) + .slots + .is_empty(), + } +} + pub fn control_device_handle() -> Option { VDM.get().and_then(VirtualDisplayManager::device_handle) } diff --git a/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs b/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs index 3a0cb0aa..5bd6b696 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs @@ -181,6 +181,57 @@ enum AdapterCycle { /// /// Returns `true` only when pnputil reported success. Best-effort: a failure just leaves the /// adapter as it was (sessions then self-composite exactly as before). +/// Has this host process DECLARED an IddCx hardware cursor since the adapter was last restarted? +/// Set by [`note_cursor_declared`] when a session delivers a cursor channel; cleared when +/// [`clean_cursor_for_next_session`] restarts the device. This is the host's own mirror of the +/// driver's `DECLARED_TARGETS` — cheaper than probing, and it only ever needs to be right about +/// "did WE dirty it", because a declare from an earlier BOOT is handled by the start-up clean. +static CURSOR_DECLARED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Record that a session declared the hardware cursor (it delivered a cursor channel), so the next +/// session that does NOT want one knows the adapter needs cleaning. +pub fn note_cursor_declared() { + CURSOR_DECLARED.store(true, std::sync::atomic::Ordering::Relaxed); +} + +/// Give the NEXT session back the lossless cursor: if an earlier session on this host declared the +/// hardware cursor and this one does not want it, restart the device to clear the sticky declare. +/// +/// This is the case the start-up clean cannot reach — **run a desktop-mode session, disconnect, +/// reconnect in capture mode**. Same host process, so the adapter is still dirty from the first +/// session and the capture session would self-composite the pointer for its whole life. Declaring +/// is one-way and adapter-wide (`pf-driver-proto` v6), so the only way back is a device restart — +/// 0.07 s, measured. +/// +/// Must be called BEFORE this session creates its display, and only when nothing else holds one: +/// the restart takes every monitor on the adapter with it. +/// +/// Returns `true` only when it actually restarted. +pub fn clean_cursor_for_next_session(session_wants_declare: bool) -> bool { + use std::sync::atomic::Ordering; + 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() { + 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" + ); + return false; + } + if restart_device_for_clean_cursor() { + CURSOR_DECLARED.store(false, Ordering::Relaxed); + tracing::info!( + "cursor: cleared the previous session's hardware-cursor declare — this capture-mode session gets the OS's own pointer compositing back" + ); + return true; + } + false +} + pub fn restart_device_for_clean_cursor() -> bool { if std::env::var("PUNKTFUNK_CURSOR_CLEAN_START").is_ok_and(|v| v == "0") { tracing::info!( diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index d4226972..0fd96610 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -231,6 +231,12 @@ pub fn capture_virtual_output( // Cursor-forward sessions (M2c): hand the capturer the v5 cursor-channel delivery closure — // its presence opts the session in (the capturer creates + delivers the CursorShm section, // the driver declares the IddCx hardware cursor). Built exactly like `sender` above. + // Remember that this host declared, so the NEXT session that wants no hardware cursor knows to + // clear it (`clean_cursor_for_next_session`) instead of self-compositing for its whole life. + #[cfg(target_os = "windows")] + if want.hw_cursor { + crate::vdisplay::driver::note_cursor_declared(); + } let cursor_sender: Option = want.hw_cursor.then(|| { std::sync::Arc::new( move |req: &pf_driver_proto::control::SetCursorChannelRequest| { diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 18a76d5d..601eee6d 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -686,6 +686,13 @@ pub(super) async fn negotiate( // The bit the Welcome just advertised — read back rather than recomputed, so the // prepared display and the session wiring cannot disagree with it. let cursor_fw = welcome.host_caps & punktfunk_core::quic::HOST_CAP_CURSOR != 0; + // Give a capture-mode session back the LOSSLESS pointer: if an earlier session on this + // host declared the hardware cursor (desktop mouse model) and this one did not ask for + // it, clear the sticky declare now — BEFORE this session's display is created, which is + // the only safe moment, and exactly the "desktop session, disconnect, reconnect in + // capture mode" case the start-up clean cannot reach. No-op when nothing declared, when + // this session wants the channel, or when any display is still held. + pf_vdisplay::driver::clean_cursor_for_next_session(cursor_fw); // Same bit the data plane's SessionContext reads — the prepared plan and the // session wiring must agree on the slicing ceiling (an encoder rebuilt from the // prepared plan with a DIFFERENT max_slices would change the wire shape mid-flow).