feat(windows/cursor): give the pointer back after a desktop session — clear the declare between sessions

The start-up clean (5819cf05) cannot reach the case that actually bites: run a
desktop-mode session, disconnect, reconnect with mouse capture. Same host
process, so the adapter is still carrying the first session's hardware-cursor
declare — and because that declare is irrevocable and adapter-wide, the capture
session self-composites the pointer for its entire life. It pays a full-frame
copy for every frame with a visible pointer and draws our straight-alpha
approximation of an XOR cursor, when the OS would do it natively, for free, and
exactly right.

So clear it at the start of the session that does not want it. The host records
a declare when it delivers a cursor channel (`note_cursor_declared`), and the
next session whose Welcome carries no HOST_CAP_CURSOR restarts the device to
drop it. `pnputil /restart-device` recycles the WUDFHost process the driver's
DECLARED_TARGETS lives in — 0.07 s, measured on .173.

Placement is load-bearing: it runs in the handshake right after the Welcome is
sent and BEFORE the display prep is kicked. That is the one moment this
session's display does not exist yet, and the restart takes every monitor on
the adapter with it.

The guard is deliberately conservative. `manager::no_live_displays()` counts
KEEP-ALIVE slots as held, not just streaming ones: a kept monitor belongs to a
client that is expected back, and the cost of being wrong is that someone
else's session dies, against a skip costing only the old self-composited
behaviour. That does mean the clean will not fire while the previous session's
monitor is still kept — telling a kept slot apart from a live one needs
SlotState internals, and is the obvious follow-up.

Verified: `scripts/xcheck.sh windows` green; `cargo fmt --all` clean; full
`cargo build -p punktfunk-host --release --features nvenc` on .173. The
desktop -> disconnect -> capture sequence is NOT yet exercised on glass.
This commit is contained in:
2026-08-08 18:29:18 +02:00
parent 5819cf054b
commit 537a1852ed
4 changed files with 84 additions and 0 deletions
@@ -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<HANDLE> {
VDM.get().and_then(VirtualDisplayManager::device_handle)
}
@@ -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!(
+6
View File
@@ -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<pf_capture::CursorChannelSender> = want.hw_cursor.then(|| {
std::sync::Arc::new(
move |req: &pf_driver_proto::control::SetCursorChannelRequest| {
@@ -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).