From 4b56c895a0fa3121917a7e726c24f1dc6bb5e01a Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 25 Jun 2026 19:50:34 +0000 Subject: [PATCH] =?UTF-8?q?refactor(windows-host):=20=C2=A72.5=20step=202?= =?UTF-8?q?=20=E2=80=94=20unify=20both=20backends=20behind=20VirtualDispla?= =?UTF-8?q?yManager=20(OnceLock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two Windows virtual-display backends (sudovda + pf_vdisplay) carried VERBATIM-DUPLICATED ~250-line Idle/Active/Lingering refcount state machines in two `MGR: Mutex` globals, each smuggling the control HANDLE across the pinger/linger threads as a raw `isize` (HANDLE is !Send). New `vdisplay/windows/manager.rs`: one host-lifetime `VirtualDisplayManager` (OnceLock singleton, user-approved) owns the earned state machine + the linger timer + a TYPED `Arc` control device (the raw-isize smuggle is gone — OwnedHandle is Send+Sync and also CloseHandle's the device on drop, fixing a latent leak). The only backend-specific code left is the IOCTL surface behind a small `VdisplayDriver` trait (open/add_monitor/remove_monitor/ping) + the per-monitor REMOVE key (`MonitorKey::Guid` for sudovda, `::Session(u64)` for pf-vdisplay). The render-adapter pin decision, the GDI/CCD glue (crate::win_display), and the gen-stamped MonitorLease are backend-neutral and live once in the manager. * sudovda.rs / pf_vdisplay.rs: shrink to a `VdisplayDriver` impl + a thin `VirtualDisplay` wrapper (new() -> manager::init(driver); create() -> manager::vdm().acquire(mode)). Their IOCTL ops + structs + open_device stay in place (no transcription). * MON_GEN -> a manager field; the preempt's wait_for_monitor_released moves onto the manager (punktfunk1 calls vdm().wait_for_monitor_released). MonitorLease.drop -> vdm().release(gen), with the stale-lease no-op preserved verbatim. Behaviour-preserving: the state machine (acquire/release/reconfigure/teardown/linger/preempt) is the canonical sudovda copy with the IOCTLs routed through the driver seam. Box build to follow (Windows-only; Linux check is a no-op for these files). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/punktfunk1.rs | 3 +- crates/punktfunk-host/src/vdisplay.rs | 3 + .../src/vdisplay/windows/manager.rs | 496 +++++++++++++++ .../src/vdisplay/windows/pf_vdisplay.rs | 563 +++-------------- .../src/vdisplay/windows/sudovda.rs | 587 +++--------------- 5 files changed, 671 insertions(+), 981 deletions(-) create mode 100644 crates/punktfunk-host/src/vdisplay/windows/manager.rs diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 7d4a0bd..2eebf59 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -2255,7 +2255,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { let prev = IDD_SESSION_STOP.lock().unwrap().replace(stop.clone()); if let Some(prev_stop) = prev { prev_stop.store(true, Ordering::SeqCst); - crate::vdisplay::sudovda::wait_for_monitor_released(std::time::Duration::from_secs(3)); + crate::vdisplay::manager::vdm() + .wait_for_monitor_released(std::time::Duration::from_secs(3)); } } let mut vd = crate::vdisplay::open(compositor)?; diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index 582f435..e1606f8 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -635,6 +635,9 @@ mod kwin; #[path = "vdisplay/linux/mutter.rs"] mod mutter; #[cfg(target_os = "windows")] +#[path = "vdisplay/windows/manager.rs"] +pub(crate) mod manager; +#[cfg(target_os = "windows")] #[path = "vdisplay/windows/pf_vdisplay.rs"] pub(crate) mod pf_vdisplay; #[cfg(target_os = "windows")] diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs new file mode 100644 index 0000000..94a37e1 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -0,0 +1,496 @@ +//! Host-lifetime virtual-display **ownership model** (Goal-1 §2.5). One reference-counted monitor +//! lifecycle, shared by both Windows backends (SudoVDA + pf-vdisplay) instead of the two verbatim- +//! duplicated `MGR: Mutex` globals each backend used to carry. +//! +//! [`VirtualDisplayManager`] owns the earned Idle/Active/Lingering refcount machine + the linger timer + +//! a **typed** [`OwnedHandle`] control device (no more raw `isize` smuggled across the pinger/linger +//! threads). The backend differences — the IOCTL protocol and the per-monitor REMOVE key — are the only +//! thing behind the [`VdisplayDriver`] seam; the state machine, the render-adapter pin decision, the +//! GDI/CCD glue (`crate::win_display`), and the generation-stamped [`MonitorLease`] are backend-neutral. +//! +//! It's a process-wide singleton ([`vdm`]) initialised once with the chosen backend's driver — the +//! host runs exactly one virtual-display backend per process. The session holds a [`MonitorLease`]; +//! its `Drop` releases the refcount (a *stale* lease — its monitor was preempted + recreated under it — +//! is a no-op, so it can never tear down the live monitor). + +use std::ffi::c_void; +use std::os::windows::io::{AsRawHandle, OwnedHandle}; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, Once, OnceLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use windows::Win32::Foundation::{HANDLE, LUID}; + +use super::{Mode, VirtualOutput}; +use crate::win_display::{ + isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, set_active_mode, SavedConfig, +}; + +/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by +/// a fresh `GUID`; pf-vdisplay keys them by a monotonic `u64` session id. +#[derive(Clone, Copy)] +pub(crate) enum MonitorKey { + Guid(windows::core::GUID), + Session(u64), +} + +/// What a backend's `add_monitor` returns: the REMOVE key + the OS target id + the render LUID. +pub(crate) struct AddedMonitor { + pub key: MonitorKey, + pub target_id: u32, + pub luid: LUID, +} + +/// The backend-specific IOCTL surface — the *only* thing that differs between SudoVDA and pf-vdisplay. +/// Everything else (the refcount machine, the linger, the pinger, the CCD/GDI glue) is shared in +/// [`VirtualDisplayManager`]. `Send + Sync` because the manager (and so the boxed driver) is a +/// `&'static` singleton reached from the pinger + linger threads. +pub(crate) trait VdisplayDriver: Send + Sync { + fn name(&self) -> &'static str; + /// Find + open the control device, validate it (version handshake), read the watchdog timeout, and + /// reap monitors orphaned by a crashed previous host (`CLEAR_ALL`). Returns the owned handle + + /// watchdog seconds. + /// + /// # Safety + /// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment. + unsafe fn open(&self) -> Result<(OwnedHandle, u32)>; + /// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`. + /// Returns the REMOVE key + target id + the adapter LUID the driver actually used. + /// + /// # Safety + /// `dev` must be the live control handle from [`open`](Self::open). + unsafe fn add_monitor(&self, dev: HANDLE, mode: Mode, render_luid: Option) + -> Result; + /// REMOVE the monitor identified by `key`. + /// + /// # Safety + /// `dev` must be the live control handle. + unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()>; + /// Watchdog keepalive PING (issued every `watchdog/3` from the pinger thread). + /// + /// # Safety + /// `dev` must be the live control handle. + unsafe fn ping(&self, dev: HANDLE) -> Result<()>; +} + +/// The resources backing one live virtual monitor (owned by the [`VirtualDisplayManager`] state, not by +/// any session). No `Drop` impl — [`teardown`](VirtualDisplayManager::teardown) must be called so the +/// REMOVE IOCTL fires (a bare drop would orphan the driver-side monitor). +struct Monitor { + key: MonitorKey, + target_id: u32, + luid: LUID, + gdi_name: Option, + mode: Mode, + stop: Arc, + pinger: Option>, + ccd_saved: Option, + /// Generation stamp; a [`MonitorLease`] only releases if its gen still matches (stale-lease no-op). + gen: u64, +} + +impl Monitor { + /// The capture target handed to a session (`None` until the GDI name resolves on a WDDM GPU). + fn target(&self) -> Option { + self.gdi_name + .clone() + .map(|n| crate::capture::dxgi::WinCaptureTarget { + adapter_luid: crate::capture::dxgi::pack_luid(self.luid), + gdi_name: n, + target_id: self.target_id, + }) + } +} + +enum MgrState { + Idle, + Active { mon: Monitor, refs: u32 }, + Lingering { mon: Monitor, until: Instant }, +} + +/// The host-lifetime virtual-display manager: the single owner of the monitor lifecycle. +pub(crate) struct VirtualDisplayManager { + driver: Box, + /// Control device, opened once on first acquire. Typed + `Send+Sync`, so the pinger/linger threads + /// share it via the `&'static` singleton with no raw-handle smuggling. + device: OnceLock>, + watchdog_s: AtomicU32, + /// Monotonic lease-generation counter (was the `MON_GEN` global). + gen: AtomicU64, + state: Mutex, +} + +static VDM: OnceLock = OnceLock::new(); + +/// 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) -> &'static VirtualDisplayManager { + VDM.get_or_init(|| VirtualDisplayManager { + driver, + device: OnceLock::new(), + watchdog_s: AtomicU32::new(3), + gen: AtomicU64::new(1), + state: Mutex::new(MgrState::Idle), + }) +} + +/// The process-wide manager. Panics if reached before a backend called [`init`] — by construction a +/// session is only ever created after `vdisplay::open` constructed the backend (which calls `init`). +pub(crate) fn vdm() -> &'static VirtualDisplayManager { + VDM.get().expect("VirtualDisplayManager used before a backend initialised it") +} + +impl VirtualDisplayManager { + pub(crate) fn backend_name(&self) -> &'static str { + self.driver.name() + } + + /// Open + cache the control device (once). Called under the `state` lock so two racing acquires can't + /// double-open. + fn ensure_device(&self) -> Result { + if let Some(d) = self.device.get() { + return Ok(HANDLE(d.as_raw_handle() as *mut c_void)); + } + let (handle, watchdog_s) = unsafe { self.driver.open()? }; + self.watchdog_s.store(watchdog_s, Ordering::Relaxed); + let raw = HANDLE(handle.as_raw_handle() as *mut c_void); + let _ = self.device.set(Arc::new(handle)); + Ok(raw) + } + + /// The live control handle for the pinger/linger threads (lock-free: the device never changes once + /// opened). `None` only before the first acquire opened it. + fn device_handle(&self) -> Option { + self.device + .get() + .map(|d| HANDLE(d.as_raw_handle() as *mut c_void)) + } + + /// Open + initialise the backend (validates the driver is present). Mirrors the old + /// `SudoVdaDisplay::new`/`PfVdisplayDisplay::new`. + pub(crate) fn open_backend(&self) -> Result<()> { + let _ = self.state.lock().unwrap(); + self.ensure_device().map(|_| ()) + } + + /// Acquire the shared monitor for a new session: preempt-recreate under IDD-push, join a live one + /// (refcount++), reuse a lingering one, or create one. The returned [`MonitorLease`] releases the + /// refcount on drop. + pub(crate) fn acquire(&'static self, mode: Mode) -> Result { + self.ensure_linger_timer(); + let mut state = self.state.lock().unwrap(); + let dev = self.ensure_device()?; + + // IDD-push: a new connection while a monitor is live is a single-client RECONNECT (the prior + // client is gone). A REUSED IddCx swap-chain is DEAD, so joining it hands a black screen — + // PREEMPT: tear the old monitor down (its key/topology are restored) and create a fresh one. The + // old session's lease is gen-stamped, so its later drop is a no-op and can't tear down the new one. + if idd_push_mode() + && matches!(*state, MgrState::Active { .. } | MgrState::Lingering { .. }) + { + if let MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } = + std::mem::replace(&mut *state, MgrState::Idle) + { + tracing::info!( + old_target = mon.target_id, + "IDD-push reconnect — preempting the prior session, recreating a fresh monitor" + ); + unsafe { self.teardown(dev, mon) }; + // Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back + // REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn. + thread::sleep(Duration::from_millis(400)); + } + } + + // A live monitor already exists — join it (refcount++). Covers concurrent sessions AND the + // build-then-drop overlap of a mid-stream Reconfigure (the new lease is taken while the old is + // still held). Reconfigure the shared monitor if the requested mode differs. + if let MgrState::Active { mon, refs } = &mut *state { + *refs += 1; + if mon.mode != mode { + unsafe { self.reconfigure(mon, mode) }; + } + tracing::info!(refs = *refs, backend = self.driver.name(), "virtual monitor reused (concurrent / reconfigure session)"); + return Ok(self.output_for(mon)); + } + + // Idle or Lingering: repurpose a lingering monitor / create a fresh one → Active{refs:1}. + let mon = match std::mem::replace(&mut *state, MgrState::Idle) { + MgrState::Lingering { mut mon, .. } => { + tracing::info!(backend = self.driver.name(), "virtual monitor reused (reconnect within the linger window)"); + if mon.mode != mode { + unsafe { self.reconfigure(&mut mon, mode) }; + } + mon + } + MgrState::Idle => unsafe { self.create_monitor(dev, mode)? }, + MgrState::Active { .. } => unreachable!("handled above"), + }; + let out = self.output_for(&mon); + *state = MgrState::Active { mon, refs: 1 }; + Ok(out) + } + + /// Build the [`VirtualOutput`] (preferred mode + capture target + a fresh gen-stamped lease) for `mon`. + fn output_for(&'static self, mon: &Monitor) -> VirtualOutput { + VirtualOutput { + node_id: 0, + preferred_mode: Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)), + win_capture: mon.target(), + keepalive: Box::new(MonitorLease { + mgr: self, + gen: mon.gen, + }), + } + } + + /// Create a fresh monitor at `mode`: ADD via the driver (pinning the discrete render GPU under the + /// usual conditions), start the watchdog pinger, resolve the GDI name, force the mode + isolate to a + /// sole composited display. + /// + /// # Safety + /// `dev` must be the live control handle. + unsafe fn create_monitor(&'static self, dev: HANDLE, mode: Mode) -> Result { + let added = unsafe { self.driver.add_monitor(dev, mode, resolve_render_pin())? }; + + // Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down. + // The pinger reaches the singleton for both the device + the driver — no raw-handle smuggle. + let stop = Arc::new(AtomicBool::new(false)); + let interval = Duration::from_millis(self.watchdog_s.load(Ordering::Relaxed) as u64 * 1000 / 3); + let stop_t = stop.clone(); + let pinger = thread::spawn(move || { + let mut warned = false; + while !stop_t.load(Ordering::Relaxed) { + if let Some(h) = vdm().device_handle() { + match unsafe { vdm().driver.ping(h) } { + Ok(()) => warned = false, + Err(e) => { + if !warned { + tracing::warn!("virtual-display keepalive PING failed (control handle lost?): {e:#}"); + warned = true; + } + } + } + } + thread::sleep(interval); + } + }); + + // Resolve the capture target. May be None on a GPU-less box (target added but not WDDM-activated); + // the capture backend re-resolves once a GPU is present. + let mut gdi_name = None; + for _ in 0..15 { + thread::sleep(Duration::from_millis(200)); + if let Some(n) = unsafe { resolve_gdi_name(added.target_id) } { + gdi_name = Some(n); + break; + } + } + let mut ccd_saved: Option = None; + match &gdi_name { + Some(n) => { + tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id); + // ADD only advertises the mode; force it active so DXGI captures the requested size. + set_active_mode(n, mode); + // Make the virtual display the SOLE active output (default): an EXTENDED (non-primary) IDD + // isn't DWM-composited on this box → Desktop Duplication born-losts. Deactivating the other + // display(s) first via the atomic CCD path promotes the IDD to a composited primary with no + // MODE_CHANGE storm. Opt out with PUNKTFUNK_NO_ISOLATE=1. + if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() { + ccd_saved = unsafe { isolate_displays_ccd(added.target_id) }; + } else { + tracing::info!("display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended"); + } + thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens + } + None => tracing::warn!( + "virtual-display target {} not yet an active display path (needs a WDDM GPU to activate)", + added.target_id + ), + } + + Ok(Monitor { + key: added.key, + target_id: added.target_id, + luid: added.luid, + gdi_name, + mode, + stop, + pinger: Some(pinger), + ccd_saved, + gen: self.gen.fetch_add(1, Ordering::Relaxed), + }) + } + + /// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name. + /// + /// # Safety + /// Touches the live display topology via the CCD/GDI helpers. + unsafe fn reconfigure(&self, mon: &mut Monitor, mode: Mode) { + tracing::info!( + old = format!("{}x{}@{}", mon.mode.width, mon.mode.height, mon.mode.refresh_hz), + new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz), + "virtual-display: reconfiguring reused monitor to the new client mode" + ); + if let Some(n) = unsafe { resolve_gdi_name(mon.target_id) } { + mon.gdi_name = Some(n); + } + if let Some(n) = &mon.gdi_name { + set_active_mode(n, mode); + } + mon.mode = mode; + } + + /// Stop the watchdog ping, re-attach the displays we detached, then REMOVE the monitor. Consumes it. + /// + /// # Safety + /// `dev` must be the live control handle. + unsafe fn teardown(&self, dev: HANDLE, mut mon: Monitor) { + mon.stop.store(true, Ordering::Relaxed); + if let Some(j) = mon.pinger.take() { + let _ = j.join(); + } + // Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays. + if let Some(saved) = &mon.ccd_saved { + restore_displays_ccd(saved); + } + if let Err(e) = unsafe { self.driver.remove_monitor(dev, &mon.key) } { + tracing::warn!("virtual-display REMOVE failed: {e:#}"); + } else { + tracing::info!(backend = self.driver.name(), "virtual-display monitor removed"); + } + } + + /// Release a session's hold (the [`MonitorLease`] `Drop`): refcount-- ; the last session leaving + /// LINGERs before teardown. A STALE lease (its monitor was preempted + recreated under it) is a + /// no-op, so it can't tear down the CURRENT monitor. + fn release(&self, gen: u64) { + let mut state = self.state.lock().unwrap(); + let stale = match &*state { + MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen, + MgrState::Idle => true, + }; + if stale { + return; + } + *state = match std::mem::replace(&mut *state, MgrState::Idle) { + MgrState::Active { mon, refs } if refs > 1 => MgrState::Active { mon, refs: refs - 1 }, + MgrState::Active { mon, .. } => { + let ms = linger_ms(); + tracing::info!(linger_ms = ms, "virtual-display: last session left — lingering before teardown"); + MgrState::Lingering { + mon, + until: Instant::now() + Duration::from_millis(ms), + } + } + other => other, + }; + } + + /// Wait (up to `timeout`) for the active monitor to be RELEASED (the MGR is no longer `Active`). + /// Used by the IDD-push reconnect preempt: after signalling the old session to stop, wait here so it + /// tears its monitor down cleanly before we acquire a fresh one. + pub(crate) fn wait_for_monitor_released(&self, timeout: Duration) { + let deadline = Instant::now() + timeout; + loop { + if !matches!(*self.state.lock().unwrap(), MgrState::Active { .. }) { + return; + } + if Instant::now() >= deadline { + tracing::warn!( + "IDD-push preempt: prior session didn't release the monitor within {timeout:?} — proceeding" + ); + return; + } + thread::sleep(Duration::from_millis(25)); + } + } + + /// Background timer (started once): tear down a monitor that has lingered past its deadline (→ Idle), + /// so a physical-screen user gets their screen back after they stop streaming. + fn ensure_linger_timer(&'static self) { + static TIMER: Once = Once::new(); + TIMER.call_once(|| { + thread::Builder::new() + .name("vdisplay-linger".into()) + .spawn(move || loop { + thread::sleep(Duration::from_millis(500)); + let due = { + let g = self.state.lock().unwrap(); + matches!(&*g, MgrState::Lingering { until, .. } if Instant::now() >= *until) + }; + if !due { + continue; + } + let Some(dev) = self.device_handle() else { + continue; + }; + let taken = { + let mut g = self.state.lock().unwrap(); + if matches!(&*g, MgrState::Lingering { until, .. } if Instant::now() >= *until) { + if let MgrState::Lingering { mon, .. } = + std::mem::replace(&mut *g, MgrState::Idle) + { + Some(mon) + } else { + None + } + } else { + None + } + }; + if let Some(mon) = taken { + unsafe { self.teardown(dev, mon) }; + } + }) + .ok(); + }); + } +} + +/// The session's refcount handle. `Drop` releases the manager's refcount; a stale lease (its monitor was +/// preempted + recreated under it) is a no-op. +struct MonitorLease { + mgr: &'static VirtualDisplayManager, + gen: u64, +} + +impl Drop for MonitorLease { + fn drop(&mut self) { + self.mgr.release(self.gen); + } +} + +/// IDD-push mode: a new client connection preempts + recreates the monitor (single-client reconnect), +/// because a REUSED IddCx monitor's swap-chain is dead. Off → monitors are shared across sessions. +fn idd_push_mode() -> bool { + crate::config::config().idd_push +} + +/// The render-GPU pin decision (backend-neutral): pin the discrete render GPU when explicitly requested, +/// or under IDD-push (the host runs NVENC on the render adapter, so it MUST be the discrete encoder GPU +/// on a hybrid box). `None` = let the IDD use its natural adapter (Apollo parity — avoids the cross-GPU +/// ACCESS_LOST storm SudoVDA hit when pinned). +fn resolve_render_pin() -> Option { + if crate::config::config().render_adapter.is_some() { + unsafe { crate::win_adapter::resolve_render_adapter_luid() } + } else if crate::config::config().idd_push { + tracing::info!("IDD push: pinning the discrete render GPU (SET_RENDER_ADAPTER)"); + unsafe { crate::win_adapter::resolve_render_adapter_luid() } + } else { + tracing::info!( + "SET_RENDER_ADAPTER skipped (Apollo-parity: no render pin; set PUNKTFUNK_RENDER_ADAPTER= to force one)" + ); + None + } +} + +/// Linger window before a session-less monitor is torn down (default 10 s; `PUNKTFUNK_MONITOR_LINGER_MS`). +fn linger_ms() -> u64 { + std::env::var("PUNKTFUNK_MONITOR_LINGER_MS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(10_000) +} diff --git a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs index cd7c87c..bffef78 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs @@ -16,10 +16,8 @@ use std::ffi::c_void; use std::mem::size_of; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, Once}; -use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; +use std::os::windows::io::{FromRawHandle, OwnedHandle}; +use std::sync::atomic::{AtomicU64, Ordering}; use anyhow::{Context, Result}; use windows::core::{GUID, PCWSTR}; @@ -36,15 +34,8 @@ use windows::Win32::System::IO::DeviceIoControl; use pf_vdisplay_proto::control; +use super::manager::{AddedMonitor, MonitorKey, VdisplayDriver}; use super::{Mode, VirtualDisplay, VirtualOutput}; -// Backend-NEUTRAL CCD/DXGI helpers reused from the SudoVDA backend (a pf-vdisplay monitor's target_id -// is a real OS target id, so these operate identically). The shared MON_GEN lease-generation counter is -// reused too, so a stale preempted lease can't tear down the live monitor regardless of which backend is active. -use super::sudovda::MON_GEN; -use crate::win_adapter::resolve_render_adapter_luid; -use crate::win_display::{ - isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, set_active_mode, SavedConfig, -}; // pf-vdisplay device-interface GUID (pf_vdisplay_proto::PF_VDISPLAY_INTERFACE_GUID_U128). Deliberately // NOT SudoVDA's `{e5bcc234-…}` — we own this driver, so a private interface GUID signals it and avoids @@ -52,12 +43,6 @@ use crate::win_display::{ const PF_VDISPLAY_INTERFACE: GUID = GUID::from_u128(pf_vdisplay_proto::PF_VDISPLAY_INTERFACE_GUID_U128); -/// IDD-push mode: a new client connection preempts + recreates the monitor (single-client reconnect), -/// because a REUSED IddCx monitor's swap-chain is dead. Off → monitors are shared across sessions. -fn idd_push_mode() -> bool { - crate::config::config().idd_push -} - /// Monotonic per-session id keying a pf-vdisplay monitor for `IOCTL_ADD`/`IOCTL_REMOVE`. Unlike /// SudoVDA's 16-byte GUID + pid-mangling, the proto keys monitors by a plain `u64` — the host-level /// refcount manager (MGR) owns collision safety (a stale session can never REMOVE a live one), so a @@ -149,92 +134,60 @@ unsafe fn open_device() -> Result { Ok(handle) } -// ── Host-level reference-counted pf-vdisplay monitor lifecycle ─────────────────────────────────── -// -// The virtual monitor is created on the first session and REUSED across sessions. When the last -// session disconnects the monitor LINGERS for a grace window (PUNKTFUNK_MONITOR_LINGER_MS, default -// 10 s): a reconnect within the window reuses it instantly (no new screen, no PnP connect/disconnect -// chime, no teardown/recreate kernel churn); after the window a background timer REMOVEs it so a -// physical-screen user gets their screen back. Overlapping sessions share one monitor via the -// refcount (teardown only at refs==0 + expired grace), so a stale session can never REMOVE a live -// session's monitor. The control-device HANDLE is opened once and kept for the host lifetime — it's a -// handle, not a screen, so it creates no phantom display. +/// The pf-vdisplay IOCTL surface behind the shared [`VirtualDisplayManager`](super::manager::VirtualDisplayManager) +/// (Goal-1 §2.5) — the wire contract is owned by `pf_vdisplay_proto::control` (versioned, hard-checked). +pub(crate) struct PfVdisplayDriver; -/// The resources backing one live pf-vdisplay monitor (owned by [`MGR`], not by any session). -struct Monitor { - /// Per-session key for `IOCTL_ADD`/`IOCTL_REMOVE` (the proto keys monitors by a plain `u64`). - session_id: u64, - target_id: u32, - luid: LUID, - gdi_name: Option, - mode: Mode, - stop: Arc, - pinger: Option>, - ccd_saved: Option, - /// Generation stamp (shared [`MON_GEN`]); a [`MonitorLease`] only releases if its gen still matches. - gen: u64, -} - -enum MgrState { - Idle, - Active { mon: Monitor, refs: u32 }, - Lingering { mon: Monitor, until: Instant }, -} - -struct Mgr { - /// Control-device handle (raw isize; `HANDLE` isn't `Send`). Opened once, kept for the host life. - device: Option, - watchdog_s: u32, - state: MgrState, -} - -static MGR: Mutex = Mutex::new(Mgr { - device: None, - watchdog_s: 10, - state: MgrState::Idle, -}); - -/// The Windows pf-vdisplay backend. A marker — the monitor lifecycle lives in the global [`MGR`]. -pub struct PfVdisplayDisplay; - -impl PfVdisplayDisplay { - pub fn new() -> Result { - // Open the control device once (validates the driver is present + version-matches) + log the - // watchdog timeout. - let mut g = MGR.lock().unwrap(); - mgr_ensure_device(&mut g)?; - Ok(Self) - } -} - -impl Drop for PfVdisplayDisplay { - fn drop(&mut self) { - // Nothing: the control device + monitor lifecycle are host-level (owned by MGR) and - // deliberately outlive any single session so a reconnect can reuse the monitor. - } -} - -impl VirtualDisplay for PfVdisplayDisplay { +impl VdisplayDriver for PfVdisplayDriver { fn name(&self) -> &'static str { "pf-vdisplay" } - fn create(&mut self, mode: Mode) -> Result { - // Delegate to the host-level manager: create the monitor, reuse a lingering one on reconnect, - // or join the live one — and hand back a lease whose Drop releases the refcount. - mgr_acquire(mode) + unsafe fn open(&self) -> Result<(OwnedHandle, u32)> { + let device = unsafe { open_device()? }; + // HARD protocol-version check (unlike SudoVDA's best-effort log): a mismatched host/driver pair + // fails loudly here rather than corrupting the IOCTL stream. + let mut info_buf = [0u8; size_of::()]; + unsafe { ioctl(device, control::IOCTL_GET_INFO, &[], &mut info_buf) } + .context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?; + let info: control::InfoReply = + bytemuck::pod_read_unaligned(&info_buf[..size_of::()]); + if info.protocol_version != pf_vdisplay_proto::PROTOCOL_VERSION { + unsafe { + let _ = CloseHandle(device); + } + anyhow::bail!( + "pf-vdisplay protocol mismatch: host expects {}, driver reports {} — install matching \ + host + driver", + pf_vdisplay_proto::PROTOCOL_VERSION, + info.protocol_version + ); + } + let watchdog_s = info.watchdog_timeout_s.max(1); + tracing::info!( + "pf-vdisplay protocol {} (watchdog timeout {}s)", + info.protocol_version, + watchdog_s + ); + // Reap monitors orphaned by a crashed previous host — a FIRST-CLASS op (driver returns SUCCESS). + let mut none: [u8; 0] = []; + if unsafe { ioctl(device, control::IOCTL_CLEAR_ALL, &[], &mut none) }.is_ok() { + tracing::info!("cleared orphaned virtual monitors on host startup"); + } else { + tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)"); + } + Ok(( + unsafe { OwnedHandle::from_raw_handle(device.0 as _) }, + watchdog_s, + )) } -} -/// Create a fresh pf-vdisplay monitor at `mode` on the (host-level) control `device`. ADD the target, -/// start the watchdog ping, resolve the GDI name, force the client mode + (default) isolate to a sole -/// composited display. Returns the [`Monitor`] resources; the manager tracks its lifecycle -/// (refcount + linger). -unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result { - let dev = HANDLE(device as *mut c_void); - { - // Fresh session id per created monitor (the manager refcount, not the id, prevents the - // cross-session REMOVE collision). + unsafe fn add_monitor( + &self, + dev: HANDLE, + mode: Mode, + render_luid: Option, + ) -> Result { let session_id = next_session_id(); let add = control::AddRequest { session_id, @@ -243,54 +196,30 @@ unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result or the IDD-push path (which MUST run NVENC on - // the discrete render GPU it pins here). The pf-vdisplay driver now IMPLEMENTS this IOCTL - // (IddCxAdapterSetRenderAdapter); a failure is still tolerated (the driver also reports its real - // render LUID in the shared header, so the host binds to the right GPU regardless). - let pinned = if crate::config::config().render_adapter.is_some() { - unsafe { resolve_render_adapter_luid() } - } else if crate::config::config().idd_push { - // P2 direct frame push: the host opens the driver's shared textures AND runs NVENC on the - // RENDER adapter, so on a hybrid box (dGPU + iGPU) it MUST be the discrete encoder GPU — an - // iGPU-rendered surface is untouchable by NVENC. pf-vdisplay now IMPLEMENTS - // SET_RENDER_ADAPTER, so pin the discrete GPU; the driver also reports the resulting render LUID - // in the shared header, so the host binds correctly even if this is overridden. - tracing::info!("IDD push: pinning the discrete render GPU (SET_RENDER_ADAPTER)"); - unsafe { resolve_render_adapter_luid() } - } else { - tracing::info!( - "pf-vdisplay SET_RENDER_ADAPTER skipped (no render pin — avoids cross-GPU mismatch; \ - set PUNKTFUNK_RENDER_ADAPTER= to force a specific render GPU)" - ); - None - }; - if let Some(luid) = pinned { + // SET_RENDER_ADAPTER (opt-in; pf-vdisplay IMPLEMENTS it). Non-fatal on failure: the driver reports + // its real render LUID in the shared header, so the host binds correctly even if this is ignored. + if let Some(luid) = render_luid { match unsafe { set_render_adapter(dev, luid) } { Ok(()) => tracing::info!( luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart), "pf-vdisplay SET_RENDER_ADAPTER: pinned IDD render GPU" ), - // Non-fatal: warn + continue (do NOT propagate). The driver reports its real render LUID - // in the shared header and the host binds to that, so the natural-adapter path still works. Err(e) => tracing::warn!( "pf-vdisplay SET_RENDER_ADAPTER failed (continuing on the natural adapter): {e:#}" ), } } - let mut out = [0u8; size_of::()]; - unsafe { ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out) } - .with_context(|| { + unsafe { ioctl(dev, control::IOCTL_ADD, bytemuck::bytes_of(&add), &mut out) }.with_context( + || { format!( "pf-vdisplay ADD {}x{}@{}", mode.width, mode.height, mode.refresh_hz ) - })?; - // `pod_read_unaligned` (NOT `from_bytes`): `out` is a stack `[u8; N]` with no guaranteed - // 4-byte alignment, and `from_bytes` PANICS on an alignment mismatch. This copies the bytes - // into a properly-aligned `AddReply` value. + }, + )?; + // `pod_read_unaligned` (NOT `from_bytes`): `out` is a stack `[u8; N]` with no guaranteed 4-byte + // alignment, and `from_bytes` PANICS on a mismatch. This copies into an aligned `AddReply`. let reply: control::AddReply = bytemuck::pod_read_unaligned(&out[..size_of::()]); let luid = LUID { @@ -305,7 +234,7 @@ unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result Result warned = false, - // A persistently failing PING means the cached control handle went invalid — the - // driver watchdog will then tear the monitor down mid-session. Surface it once. - Err(e) => { - if !warned { - tracing::warn!( - "pf-vdisplay keepalive PING failed (control handle lost?): {e:#}" - ); - warned = true; - } - } - } - thread::sleep(interval); - } - }); - - // Resolve the capture target. May be None on a GPU-less box (target added but not activated - // into a WDDM path); the Windows capture backend will re-resolve once a GPU is present. - let mut gdi_name = None; - for _ in 0..15 { - thread::sleep(Duration::from_millis(200)); - if let Some(n) = unsafe { resolve_gdi_name(reply.target_id) } { - gdi_name = Some(n); - break; - } - } - let mut ccd_saved: Option = None; - match &gdi_name { - Some(n) => { - tracing::info!("pf-vdisplay target {} -> {n}", reply.target_id); - // ADD only advertises the mode; force it active so DXGI captures the requested size. - set_active_mode(n, mode); - // Make the pf-vdisplay the SOLE active display (default). An EXTENDED (non-primary) IDD - // is NOT DWM-composited → Desktop Duplication gets a born-lost ACCESS_LOST; deactivating - // the other display(s) FIRST (CCD, atomic) leaves the virtual output as the sole → - // primary → composited desktop, so all content (incl. Winlogon) renders to it without a - // MODE_CHANGE_IN_PROGRESS storm. Opt out with PUNKTFUNK_NO_ISOLATE=1 (a box with a real - // second monitor to keep live). - if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() { - ccd_saved = unsafe { isolate_displays_ccd(reply.target_id) }; - } else { - tracing::info!( - "display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended" - ); - } - thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens - } - None => tracing::warn!( - "pf-vdisplay target {} not yet an active display path (needs a WDDM GPU to activate)", - reply.target_id - ), - } - - Ok(Monitor { - session_id, + Ok(AddedMonitor { + key: MonitorKey::Session(session_id), target_id: reply.target_id, luid, - gdi_name, - mode, - stop, - pinger: Some(pinger), - ccd_saved, - gen: MON_GEN.fetch_add(1, Ordering::Relaxed), }) } -} -impl Monitor { - /// The capture target handed to a session (`None` until the GDI name resolves). - fn target(&self) -> Option { - self.gdi_name - .clone() - .map(|n| crate::capture::dxgi::WinCaptureTarget { - adapter_luid: crate::capture::dxgi::pack_luid(self.luid), - gdi_name: n, - // target_id is stable across secure-desktop topology rebuilds; the GDI name is NOT, - // so capture re-resolves the name from this on every recovery. - target_id: self.target_id, - }) - } - - /// Stop the watchdog ping, re-attach the displays we detached, then REMOVE the monitor (by session - /// id). `device` is the host-level control handle. Consumes the monitor. - unsafe fn teardown(mut self, device: isize) { - self.stop.store(true, Ordering::Relaxed); - if let Some(j) = self.pinger.take() { - let _ = j.join(); - } - // Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays. - if let Some(saved) = &self.ccd_saved { - restore_displays_ccd(saved); - } + unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()> { + let MonitorKey::Session(session_id) = key else { + anyhow::bail!("pf-vdisplay: unexpected monitor key kind"); + }; let req = control::RemoveRequest { - session_id: self.session_id, + session_id: *session_id, }; let mut none: [u8; 0] = []; - let h = HANDLE(device as *mut c_void); - if let Err(e) = ioctl( - h, - control::IOCTL_REMOVE, - bytemuck::bytes_of(&req), - &mut none, - ) { - tracing::warn!("pf-vdisplay REMOVE failed: {e:#}"); - } else { - tracing::info!("pf-vdisplay monitor removed"); - } + unsafe { ioctl(dev, control::IOCTL_REMOVE, bytemuck::bytes_of(&req), &mut none) }.map(|_| ()) } -} -/// Open the control device once + version/watchdog handshake; cache the handle (raw isize) in `g`. -fn mgr_ensure_device(g: &mut Mgr) -> Result { - if let Some(d) = g.device { - return Ok(d); - } - let device = unsafe { open_device()? }; - // Single version+watchdog handshake. The proto intends a HARD protocol-version check (unlike - // SudoVDA's best-effort log) — a mismatched host/driver pair fails loudly here rather than - // corrupting the IOCTL stream. - let mut info_buf = [0u8; size_of::()]; - unsafe { ioctl(device, control::IOCTL_GET_INFO, &[], &mut info_buf) } - .context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?; - // `pod_read_unaligned` (see the AddReply note): copies out of the unaligned stack buffer. - let info: control::InfoReply = - bytemuck::pod_read_unaligned(&info_buf[..size_of::()]); - if info.protocol_version != pf_vdisplay_proto::PROTOCOL_VERSION { - // Close the handle before bailing so a retry re-opens cleanly. - unsafe { - let _ = CloseHandle(device); - } - anyhow::bail!( - "pf-vdisplay protocol mismatch: host expects {}, driver reports {} — install matching \ - host + driver", - pf_vdisplay_proto::PROTOCOL_VERSION, - info.protocol_version - ); - } - g.watchdog_s = info.watchdog_timeout_s.max(1); - tracing::info!( - "pf-vdisplay protocol {} (watchdog timeout {}s)", - info.protocol_version, - g.watchdog_s - ); - // Reap monitors orphaned by a crashed/killed previous host instance before we create ours. This is - // a FIRST-CLASS op on pf-vdisplay (the driver returns SUCCESS), NOT a "send-and-hope" hack: without - // it an orphan lingers until the driver watchdog fires — but a still-pinging new session keeps - // resetting that watchdog, so orphans could accumulate. - { + unsafe fn ping(&self, dev: HANDLE) -> Result<()> { let mut none: [u8; 0] = []; - if unsafe { ioctl(device, control::IOCTL_CLEAR_ALL, &[], &mut none) }.is_ok() { - tracing::info!("cleared orphaned virtual monitors on host startup"); - } else { - tracing::warn!("pf-vdisplay IOCTL_CLEAR_ALL failed on startup (continuing)"); - } + unsafe { ioctl(dev, control::IOCTL_PING, &[], &mut none) }.map(|_| ()) } - let raw = device.0 as isize; - g.device = Some(raw); - Ok(raw) } -/// Linger window before a session-less monitor is torn down. A reconnect within it reuses the -/// monitor (no new screen / PnP chime); after it the monitor is REMOVEd so a physical screen returns. -fn linger_ms() -> u64 { - std::env::var("PUNKTFUNK_MONITOR_LINGER_MS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(10_000) +/// The Windows pf-vdisplay virtual-display backend. A marker — the lifecycle lives in the shared +/// [`VirtualDisplayManager`](super::manager::VirtualDisplayManager). +pub struct PfVdisplayDisplay; + +impl PfVdisplayDisplay { + pub fn new() -> Result { + super::manager::init(Box::new(PfVdisplayDriver)).open_backend()?; + Ok(Self) + } } -/// Acquire the shared monitor for a new session: join the live one (refcount++), reuse a lingering -/// one (reconfiguring if the client mode changed), or create one. The returned [`MonitorLease`] -/// releases the refcount on drop. -fn mgr_acquire(mode: Mode) -> Result { - ensure_linger_timer(); - let mut g = MGR.lock().unwrap(); - let device = mgr_ensure_device(&mut g)?; - let watchdog_s = g.watchdog_s; - - // IDD-push: a new connection while a monitor is live = a single-client RECONNECT (the prior client - // is gone — IDD-push is one display, no concurrency). A REUSED IddCx monitor's swap-chain is DEAD, - // so joining it would hand the new client a black screen until the old session times out. PREEMPT: - // tear the old monitor down (its teardown restores topology + IOCTL_REMOVEs) and fall through to - // create a FRESH one. The old session's lease is gen-stamped, so its later drop is ignored - // (mgr_release no-op) and can't tear down the new monitor. - if idd_push_mode() - && matches!( - g.state, - MgrState::Active { .. } | MgrState::Lingering { .. } - ) - { - if let MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } = - std::mem::replace(&mut g.state, MgrState::Idle) - { - tracing::info!( - old_target = mon.target_id, - "IDD-push reconnect — preempting the prior session, recreating a fresh monitor" - ); - // teardown() — NOT drop() — sends IOCTL_REMOVE (and restores topology). `Monitor` has NO - // `Drop` impl, so a bare `drop(mon)` would orphan the IddCx monitor in the driver (never - // departed → leaks a live D3D device + a stuck swap-chain processor thread per reconnect). - unsafe { mon.teardown(device) }; - // Let the OS finish the ASYNC IddCx monitor departure before the next ADD. A back-to-back - // REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn. - thread::sleep(Duration::from_millis(400)); - } +impl VirtualDisplay for PfVdisplayDisplay { + fn name(&self) -> &'static str { + "pf-vdisplay" } - // A live monitor already exists — join it (refcount++). This covers a concurrent session AND the - // build-then-drop overlap of a mid-stream Reconfigure / secure-return (the new lease is taken while - // the old is still held). If the requested mode differs, reconfigure the shared monitor to it so a - // Reconfigure actually applies (one shared monitor → sessions necessarily share a mode). - if let MgrState::Active { mon, refs } = &mut g.state { - *refs += 1; - let changed = mon.mode.width != mode.width - || mon.mode.height != mode.height - || mon.mode.refresh_hz != mode.refresh_hz; - if changed { - unsafe { mgr_reconfigure(mon, mode) }; - } - tracing::info!( - refs = *refs, - "pf-vdisplay monitor reused (concurrent / reconfigure session)" - ); - let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)); - let target = mon.target(); - let gen = mon.gen; - return Ok(VirtualOutput { - node_id: 0, - preferred_mode: pm, - win_capture: target, - keepalive: Box::new(MonitorLease { gen }), - }); - } - - // Idle or Lingering: repurpose/create a monitor → Active{refs:1}. - let mon = match std::mem::replace(&mut g.state, MgrState::Idle) { - MgrState::Lingering { mut mon, .. } => { - tracing::info!("pf-vdisplay monitor reused (reconnect within the linger window)"); - let changed = mon.mode.width != mode.width - || mon.mode.height != mode.height - || mon.mode.refresh_hz != mode.refresh_hz; - if changed { - unsafe { mgr_reconfigure(&mut mon, mode) }; - } - mon - } - MgrState::Idle => unsafe { create_monitor(device, mode, watchdog_s)? }, - MgrState::Active { .. } => unreachable!("handled above"), - }; - let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)); - let target = mon.target(); - let gen = mon.gen; - g.state = MgrState::Active { mon, refs: 1 }; - Ok(VirtualOutput { - node_id: 0, - preferred_mode: pm, - win_capture: target, - keepalive: Box::new(MonitorLease { gen }), - }) -} - -/// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name. -unsafe fn mgr_reconfigure(mon: &mut Monitor, mode: Mode) { - tracing::info!( - old = format!( - "{}x{}@{}", - mon.mode.width, mon.mode.height, mon.mode.refresh_hz - ), - new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz), - "pf-vdisplay: reconfiguring reused monitor to the new client mode" - ); - if let Some(n) = resolve_gdi_name(mon.target_id) { - mon.gdi_name = Some(n); - } - if let Some(n) = &mon.gdi_name { - set_active_mode(n, mode); - } - mon.mode = mode; -} - -/// Release a session's hold: refcount-- ; when the last session leaves, LINGER before teardown. -/// `gen` is the lease's monitor generation: a STALE lease (its monitor was already torn down + -/// recreated under it — the IDD-push reconnect-preempt path) does nothing, so it can't decrement the -/// CURRENT (fresh) monitor's refcount and tear it down. -fn mgr_release(gen: u64) { - let mut g = MGR.lock().unwrap(); - let stale = match &g.state { - MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen, - MgrState::Idle => true, - }; - if stale { - return; - } - g.state = match std::mem::replace(&mut g.state, MgrState::Idle) { - MgrState::Active { mon, refs } if refs > 1 => MgrState::Active { - mon, - refs: refs - 1, - }, - MgrState::Active { mon, .. } => { - let ms = linger_ms(); - tracing::info!( - linger_ms = ms, - "pf-vdisplay: last session left — lingering before teardown" - ); - MgrState::Lingering { - mon, - until: Instant::now() + Duration::from_millis(ms), - } - } - other => other, - }; -} - -// NOTE: `wait_for_monitor_released` is NOT redefined here. Its only caller (`punktfunk1.rs`, the -// IDD-push reconnect preempt) reaches it as `crate::vdisplay::sudovda::wait_for_monitor_released`, and -// pf_vdisplay.rs never calls it internally (the preempt is done inline in `mgr_acquire` above), so a -// second copy here would be dead code waiting on the (separate) pf-vdisplay MGR. The two backends keep -// independent MGRs but only one is ever active — see the cross-MGR caveat in the implementation report. - -/// Background timer (started once): tear down a monitor that has lingered past its deadline (→ Idle), -/// so a physical-screen user gets their screen back after they stop streaming. -fn ensure_linger_timer() { - static TIMER: Once = Once::new(); - TIMER.call_once(|| { - let _ = thread::Builder::new() - .name("pf-vdisplay-linger".into()) - .spawn(|| loop { - thread::sleep(Duration::from_millis(500)); - let mut g = MGR.lock().unwrap(); - let due = matches!(&g.state, MgrState::Lingering { until, .. } if Instant::now() >= *until); - if due { - let device = g.device.unwrap_or(0); - if let MgrState::Lingering { mon, .. } = - std::mem::replace(&mut g.state, MgrState::Idle) - { - drop(g); // release the lock before the REMOVE IOCTL + display restore - unsafe { mon.teardown(device) }; - } - } - }); - }); -} - -/// A session's lease on the shared monitor. Drop releases the refcount (→ linger when it hits 0), -/// UNLESS the monitor was already torn down + recreated under it (gen mismatch — the IDD-push -/// reconnect-preempt path), in which case the drop is a no-op so it can't tear down the new monitor. -struct MonitorLease { - gen: u64, -} -impl Drop for MonitorLease { - fn drop(&mut self) { - mgr_release(self.gen); + fn create(&mut self, mode: Mode) -> Result { + super::manager::vdm().acquire(mode) } } @@ -700,6 +307,8 @@ pub fn is_available() -> bool { #[cfg(test)] mod tests { use super::*; + use std::thread; + use std::time::Duration; /// Live hardware round trip — skipped unless `PUNKTFUNK_PF_VDISPLAY_LIVE=1` (needs the pf-vdisplay /// driver installed). Exercises the real trait path: open -> create -> hold -> drop (REMOVE). diff --git a/crates/punktfunk-host/src/vdisplay/windows/sudovda.rs b/crates/punktfunk-host/src/vdisplay/windows/sudovda.rs index 51c1af8..372488e 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/sudovda.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/sudovda.rs @@ -9,23 +9,8 @@ use std::ffi::c_void; use std::mem::size_of; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, Once}; - -/// Monotonic monitor generation. Each [`create_monitor`] stamps the next value onto the [`Monitor`] -/// and its [`MonitorLease`]s, so a lease whose monitor was already torn down + recreated (the IDD-push -/// reconnect-preempt path) is ignored on drop instead of decrementing the NEW monitor's refcount. -// pub(crate) so vdisplay::pf_vdisplay can reuse this shared generation counter (one counter across both -// backends keeps the idd_push stale-ring bail working regardless of which backend is active). -pub(crate) static MON_GEN: AtomicU64 = AtomicU64::new(1); - -/// IDD-push mode: a new client connection preempts + recreates the monitor (single-client reconnect), -/// because a REUSED IddCx monitor's swap-chain is dead. Off → monitors are shared across sessions. -fn idd_push_mode() -> bool { - crate::config::config().idd_push -} -use std::thread::{self, JoinHandle}; -use std::time::{Duration, Instant}; +use std::os::windows::io::{FromRawHandle, OwnedHandle}; +use std::sync::atomic::Ordering; use anyhow::{Context, Result}; use windows::core::{GUID, PCWSTR}; @@ -41,6 +26,7 @@ use windows::Win32::Storage::FileSystem::{ }; use windows::Win32::System::IO::DeviceIoControl; +use super::manager::{AddedMonitor, MonitorKey, VdisplayDriver}; use super::{Mode, VirtualDisplay, VirtualOutput}; // SudoVDA device-interface GUID (Common/Include/sudovda-ioctl.h). @@ -116,11 +102,6 @@ unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> { .context("SudoVDA SET_RENDER_ADAPTER") } -// `resolve_render_adapter_luid` moved to the backend-neutral `crate::win_adapter` (audit §9 / Goal 2: -// it is display-utility, not SudoVDA-specific). Re-exported so this backend's own callers keep the short -// name; external callers (idd_push, pf_vdisplay) use `crate::win_adapter` directly. -pub(crate) use crate::win_adapter::resolve_render_adapter_luid; - #[repr(C)] struct RemoveParams { guid: GUID, @@ -145,14 +126,6 @@ unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result Ok(returned) } -// The CCD/GDI display helpers (resolve_gdi_name, set_advanced_color, advanced_color_enabled, -// set_active_mode, isolate/restore_displays_ccd) + SavedConfig moved to the backend-neutral -// `crate::win_display` (audit §9 / Goal 2). Re-exported so this backend's own callers keep the short -// names; external callers use `crate::win_display` directly. -pub(crate) use crate::win_display::{ - isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd, set_active_mode, SavedConfig, -}; - unsafe fn open_device() -> Result { let hdev = SetupDiGetClassDevsW( Some(&SUVDA_INTERFACE), @@ -191,93 +164,65 @@ unsafe fn open_device() -> Result { Ok(handle) } -// ── Host-level reference-counted SudoVDA monitor lifecycle ────────────────────────────────────── -// -// The virtual monitor is created on the first session and REUSED across sessions. When the last -// session disconnects the monitor LINGERS for a grace window (PUNKTFUNK_MONITOR_LINGER_MS, default -// 10 s): a reconnect within the window reuses it instantly (no new screen, no PnP connect/disconnect -// chime, no teardown/recreate kernel churn); after the window a background timer REMOVEs it so a -// physical-screen user gets their screen back. Overlapping sessions share one monitor via the -// refcount (teardown only at refs==0 + expired grace), so a stale session can never REMOVE a live -// session's monitor (the earlier collision). The control-device HANDLE is opened once and kept for -// the host lifetime — it's a handle, not a screen, so it creates no phantom display. +/// The SudoVDA IOCTL surface behind the shared [`VirtualDisplayManager`](super::manager::VirtualDisplayManager) +/// (Goal-1 §2.5) — the only SudoVDA-specific code left; the monitor lifecycle is the shared state machine. +pub(crate) struct SudoVdaDriver; -/// The resources backing one live SudoVDA monitor (owned by [`MGR`], not by any session). -struct Monitor { - guid: GUID, - target_id: u32, - luid: LUID, - gdi_name: Option, - mode: Mode, - stop: Arc, - pinger: Option>, - ccd_saved: Option, - /// Generation stamp ([`MON_GEN`]); a [`MonitorLease`] only releases if its gen still matches. - gen: u64, -} - -enum MgrState { - Idle, - Active { mon: Monitor, refs: u32 }, - Lingering { mon: Monitor, until: Instant }, -} - -struct Mgr { - /// Control-device handle (raw isize; `HANDLE` isn't `Send`). Opened once, kept for the host life. - device: Option, - watchdog_s: u32, - state: MgrState, -} - -static MGR: Mutex = Mutex::new(Mgr { - device: None, - watchdog_s: 3, - state: MgrState::Idle, -}); - -/// The Windows virtual-display backend. A marker — the monitor lifecycle lives in the global [`MGR`]. -pub struct SudoVdaDisplay; - -impl SudoVdaDisplay { - pub fn new() -> Result { - // Open the control device once (validates the driver is present) + log version/watchdog. - let mut g = MGR.lock().unwrap(); - mgr_ensure_device(&mut g)?; - Ok(Self) - } -} - -impl Drop for SudoVdaDisplay { - fn drop(&mut self) { - // Nothing: the control device + monitor lifecycle are host-level (owned by MGR) and - // deliberately outlive any single session so a reconnect can reuse the monitor. - } -} - -impl VirtualDisplay for SudoVdaDisplay { +impl VdisplayDriver for SudoVdaDriver { fn name(&self) -> &'static str { "sudovda" } - fn create(&mut self, mode: Mode) -> Result { - // Delegate to the host-level manager: create the monitor, reuse a lingering one on reconnect, - // or join the live one — and hand back a lease whose Drop releases the refcount. - mgr_acquire(mode) + unsafe fn open(&self) -> Result<(OwnedHandle, u32)> { + let device = unsafe { open_device()? }; + let mut ver = [0u8; 4]; + if unsafe { ioctl(device, IOCTL_GET_VERSION, &[], &mut ver) }.is_ok() { + tracing::info!( + "SudoVDA protocol {}.{}.{} (test={})", + ver[0], + ver[1], + ver[2], + ver[3] + ); + } + let mut wd = [0u8; 8]; + let watchdog_s = if unsafe { ioctl(device, IOCTL_GET_WATCHDOG, &[], &mut wd) }.is_ok() { + u32::from_le_bytes([wd[0], wd[1], wd[2], wd[3]]).max(1) + } else { + 3 + }; + tracing::info!("SudoVDA watchdog timeout {}s", watchdog_s); + // Reap monitors orphaned by a crashed previous host (SudoVDA returns invalid for CLEAR_ALL — + // ignored; pf-vdisplay honors it). + let mut none: [u8; 0] = []; + if unsafe { ioctl(device, IOCTL_CLEAR_ALL, &[], &mut none) }.is_ok() { + tracing::info!("cleared orphaned virtual monitors on host startup"); + } + // Take ownership — the OwnedHandle CloseHandle's the control device on drop (it was leaked before). + Ok((unsafe { OwnedHandle::from_raw_handle(device.0 as _) }, watchdog_s)) } -} -/// Create a fresh SudoVDA monitor at `mode` on the (host-level) control `device`. The old per-session -/// `create()` body, now owned by the manager: ADD the target, start the watchdog ping, resolve the -/// GDI name, force the client mode + (default) isolate to a sole composited display. Returns the -/// [`Monitor`] resources; the manager tracks its lifecycle (refcount + linger). -unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result { - let dev = HANDLE(device as *mut c_void); - { + unsafe fn add_monitor( + &self, + dev: HANDLE, + mode: Mode, + render_luid: Option, + ) -> Result { + // SET_RENDER_ADAPTER (opt-in). On this box SudoVDA IGNORES the pin and the IDD lands on a different + // adapter than its DXGI output is enumerated under — the cross-GPU ACCESS_LOST source — so the + // manager only pins under PUNKTFUNK_RENDER_ADAPTER / IDD-push. + if let Some(luid) = render_luid { + match unsafe { set_render_adapter(dev, luid) } { + Ok(()) => tracing::info!( + luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart), + "SudoVDA SET_RENDER_ADAPTER: pinned IDD render GPU" + ), + Err(e) => tracing::warn!("SudoVDA SET_RENDER_ADAPTER failed (continuing): {e:#}"), + } + } let mut device_name = [0u8; 14]; let nm = b"punktfunk"; device_name[..nm.len()].copy_from_slice(nm); - // Fresh GUID per created monitor (the manager refcount, not the GUID, prevents the - // cross-session REMOVE collision now). let session_guid = next_monitor_guid(); let add = AddParams { width: mode.width, @@ -287,41 +232,6 @@ unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result only on a box that genuinely needs steering. - let pinned = if crate::config::config().render_adapter.is_some() { - unsafe { resolve_render_adapter_luid() } - } else if crate::config::config().idd_push { - // P2 direct frame push: the host opens the driver's shared textures AND runs NVENC on the - // RENDER adapter, so on a hybrid box (4090 + iGPU) it MUST be the discrete encoder GPU — - // an iGPU-rendered surface is untouchable by NVENC. pf-vdisplay HONORS SET_RENDER_ADAPTER - // (SudoVDA ignored it), so pin the discrete GPU. The driver also reports the resulting - // render LUID in the shared header, so the host binds correctly even if this is overridden. - tracing::info!("IDD push: pinning the discrete render GPU (SET_RENDER_ADAPTER)"); - unsafe { resolve_render_adapter_luid() } - } else { - tracing::info!( - "SudoVDA SET_RENDER_ADAPTER skipped (Apollo-parity: no render pin — avoids cross-GPU \ - mismatch; set PUNKTFUNK_RENDER_ADAPTER= to force a specific render GPU)" - ); - None - }; - if let Some(luid) = pinned { - match unsafe { set_render_adapter(dev, luid) } { - Ok(()) => tracing::info!( - luid = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart), - "SudoVDA SET_RENDER_ADAPTER: pinned IDD render GPU" - ), - Err(e) => tracing::warn!("SudoVDA SET_RENDER_ADAPTER failed (continuing): {e:#}"), - } - } - let add_bytes = unsafe { std::slice::from_raw_parts(&add as *const _ as *const u8, size_of::()) }; @@ -341,7 +251,7 @@ unsafe fn create_monitor(device: isize, mode: Mode, watchdog_s: u32) -> Result Result warned = false, - // A persistently failing PING means the cached control handle went invalid — the - // driver watchdog will then tear the monitor down mid-session. Surface it once - // (the old `let _ =` swallowed it, which masked exactly this during the bad-state churn). - Err(e) => { - if !warned { - tracing::warn!( - "SudoVDA keepalive PING failed (control handle lost?): {e:#}" - ); - warned = true; - } - } - } - thread::sleep(interval); - } - }); - - // Resolve the capture target. May be None on a GPU-less box (target added but not activated - // into a WDDM path); the Windows capture backend will re-resolve once a GPU is present. - let mut gdi_name = None; - for _ in 0..15 { - thread::sleep(Duration::from_millis(200)); - if let Some(n) = unsafe { resolve_gdi_name(ao.target_id) } { - gdi_name = Some(n); - break; - } - } - let mut ccd_saved: Option = None; - match &gdi_name { - Some(n) => { - tracing::info!("SudoVDA target {} -> {n}", ao.target_id); - // ADD only advertises the mode; force it active so DXGI captures the requested size. - set_active_mode(n, mode); - // Make the SudoVDA the SOLE active display (default). On this box an EXTENDED - // (non-primary) IDD is NOT DWM-composited → Desktop Duplication gets a born-lost - // ACCESS_LOST (measured live: MODE_CHANGE storm fixed, but the extended IDD then - // born-lost). Apollo reaches the same end state ("Virtual Desktop: WxH" — the IDD is the - // whole desktop, hence primary + composited) via Windows AUTO-promoting the real WDDM - // display over the box's leftover 1024x768 basic display; Windows does NOT auto-promote - // for us, so we deactivate the other display(s) explicitly via the clean atomic CCD path. - // Deactivating FIRST means set_active_mode's primary-promotion has nothing to contest → - // no MODE_CHANGE_IN_PROGRESS storm (that storm came from promoting primary WHILE the - // basic display stayed active). Opt out with PUNKTFUNK_NO_ISOLATE=1 (a box with a real - // second monitor to keep live). The legacy GDI detach is skipped — it misses - // iGPU-attached monitors on a hybrid box and churns per-device; CCD is atomic. - if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() { - ccd_saved = unsafe { isolate_displays_ccd(ao.target_id) }; - } else { - tracing::info!( - "display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended" - ); - } - thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens - } - None => tracing::warn!( - "SudoVDA target {} not yet an active display path (needs a WDDM GPU to activate)", - ao.target_id - ), - } - - Ok(Monitor { - guid: session_guid, + Ok(AddedMonitor { + key: MonitorKey::Guid(session_guid), target_id: ao.target_id, luid: ao.luid, - gdi_name, - mode, - stop, - pinger: Some(pinger), - ccd_saved, - gen: MON_GEN.fetch_add(1, Ordering::Relaxed), }) } -} -impl Monitor { - /// The capture target handed to a session (`None` until the GDI name resolves). - fn target(&self) -> Option { - self.gdi_name - .clone() - .map(|n| crate::capture::dxgi::WinCaptureTarget { - adapter_luid: crate::capture::dxgi::pack_luid(self.luid), - gdi_name: n, - // target_id is stable across secure-desktop topology rebuilds; the GDI name is NOT, - // so capture re-resolves the name from this on every recovery. - target_id: self.target_id, - }) - } - - /// Stop the watchdog ping, re-attach the displays we detached, then REMOVE the monitor (by GUID). - /// `device` is the host-level control handle. Consumes the monitor. - unsafe fn teardown(mut self, device: isize) { - self.stop.store(true, Ordering::Relaxed); - if let Some(j) = self.pinger.take() { - let _ = j.join(); - } - // Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero displays. - if let Some(saved) = &self.ccd_saved { - restore_displays_ccd(saved); - } - let rp = RemoveParams { guid: self.guid }; - let rp_bytes = - std::slice::from_raw_parts(&rp as *const _ as *const u8, size_of::()); + unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()> { + let MonitorKey::Guid(guid) = key else { + anyhow::bail!("sudovda: unexpected monitor key kind"); + }; + let rp = RemoveParams { guid: *guid }; + let rp_bytes = unsafe { + std::slice::from_raw_parts(&rp as *const _ as *const u8, size_of::()) + }; let mut none: [u8; 0] = []; - let h = HANDLE(device as *mut c_void); - if let Err(e) = ioctl(h, IOCTL_REMOVE, rp_bytes, &mut none) { - tracing::warn!("SudoVDA REMOVE failed: {e:#}"); - } else { - tracing::info!("SudoVDA monitor removed"); - } + unsafe { ioctl(dev, IOCTL_REMOVE, rp_bytes, &mut none) }.map(|_| ()) } -} -/// Open the control device once + read version/watchdog; cache the handle (raw isize) in `g`. -fn mgr_ensure_device(g: &mut Mgr) -> Result { - if let Some(d) = g.device { - return Ok(d); - } - let device = unsafe { open_device()? }; - let mut ver = [0u8; 4]; - if unsafe { ioctl(device, IOCTL_GET_VERSION, &[], &mut ver) }.is_ok() { - tracing::info!( - "SudoVDA protocol {}.{}.{} (test={})", - ver[0], - ver[1], - ver[2], - ver[3] - ); - } - let mut wd = [0u8; 8]; - g.watchdog_s = if unsafe { ioctl(device, IOCTL_GET_WATCHDOG, &[], &mut wd) }.is_ok() { - u32::from_le_bytes([wd[0], wd[1], wd[2], wd[3]]).max(1) - } else { - 3 - }; - tracing::info!("SudoVDA watchdog timeout {}s", g.watchdog_s); - // Reap monitors orphaned by a crashed/killed previous host instance before we create ours. - // pf-vdisplay honors IOCTL_CLEAR_ALL; SudoVDA returns invalid (ignored). Without it an orphan - // lingers until the driver watchdog fires — but a still-pinging new session keeps resetting that - // watchdog, so orphans could accumulate (the "5-6 stale monitors that never tear down" failure). - { + unsafe fn ping(&self, dev: HANDLE) -> Result<()> { let mut none: [u8; 0] = []; - if unsafe { ioctl(device, IOCTL_CLEAR_ALL, &[], &mut none) }.is_ok() { - tracing::info!("cleared orphaned virtual monitors on host startup"); - } - } - let raw = device.0 as isize; - g.device = Some(raw); - Ok(raw) -} - -/// Linger window before a session-less monitor is torn down. A reconnect within it reuses the -/// monitor (no new screen / PnP chime); after it the monitor is REMOVEd so a physical screen returns. -fn linger_ms() -> u64 { - std::env::var("PUNKTFUNK_MONITOR_LINGER_MS") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(10_000) -} - -/// Acquire the shared monitor for a new session: join the live one (refcount++), reuse a lingering -/// one (reconfiguring if the client mode changed), or create one. The returned [`MonitorLease`] -/// releases the refcount on drop. -fn mgr_acquire(mode: Mode) -> Result { - ensure_linger_timer(); - let mut g = MGR.lock().unwrap(); - let device = mgr_ensure_device(&mut g)?; - let watchdog_s = g.watchdog_s; - - // IDD-push: a new connection while a monitor is live = a single-client RECONNECT (the prior client - // is gone — IDD-push is one display, no concurrency). A REUSED IddCx monitor's swap-chain is DEAD, - // so joining it would hand the new client a black screen until the old session times out. PREEMPT: - // tear the old monitor down (its Drop restores topology + IOCTL_REMOVEs) and fall through to create - // a FRESH one. The old session's lease is gen-stamped, so its later drop is ignored (mgr_release - // no-op) and can't tear down the new monitor. - if idd_push_mode() - && matches!( - g.state, - MgrState::Active { .. } | MgrState::Lingering { .. } - ) - { - if let MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } = - std::mem::replace(&mut g.state, MgrState::Idle) - { - tracing::info!( - old_target = mon.target_id, - "IDD-push reconnect — preempting the prior session, recreating a fresh monitor" - ); - // teardown() — NOT drop() — sends IOCTL_REMOVE (and restores topology). `Monitor` has NO - // `Drop` impl, so a bare `drop(mon)` orphaned the IddCx monitor in the driver: it was never - // departed, so it kept a live D3D device + a stuck swap-chain processor thread, and these - // accumulated every reconnect (the driver-side churn leak: +1 device, ~36 nvwgf2umx threads, - // ~50 MB VRAM per session, until it choked). teardown frees it via the driver's do_remove. - unsafe { mon.teardown(device) }; - // Let the OS finish the ASYNC IddCx monitor departure before the next ADD. A back-to-back - // REMOVE→ADD races the teardown and the ADD IOCTL is rejected (`DeviceIoControl failed`) - // under reconnect churn. Held under the MGR lock, but IDD-push setup is already serialized - // (IDD_SETUP_LOCK), so this only paces the recreate — exactly what a reconnect flood needs. - thread::sleep(Duration::from_millis(400)); - } - } - - // A live monitor already exists — join it (refcount++). This covers a concurrent session AND the - // build-then-drop overlap of a mid-stream Reconfigure / secure-return (the new lease is taken while - // the old is still held). If the requested mode differs, reconfigure the shared monitor to it so a - // Reconfigure actually applies (one shared monitor → sessions necessarily share a mode). - if let MgrState::Active { mon, refs } = &mut g.state { - *refs += 1; - let changed = mon.mode.width != mode.width - || mon.mode.height != mode.height - || mon.mode.refresh_hz != mode.refresh_hz; - if changed { - unsafe { mgr_reconfigure(mon, mode) }; - } - tracing::info!( - refs = *refs, - "SudoVDA monitor reused (concurrent / reconfigure session)" - ); - let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)); - let target = mon.target(); - let gen = mon.gen; - return Ok(VirtualOutput { - node_id: 0, - preferred_mode: pm, - win_capture: target, - keepalive: Box::new(MonitorLease { gen }), - }); - } - - // Idle or Lingering: repurpose/create a monitor → Active{refs:1}. - let mon = match std::mem::replace(&mut g.state, MgrState::Idle) { - MgrState::Lingering { mut mon, .. } => { - tracing::info!("SudoVDA monitor reused (reconnect within the linger window)"); - let changed = mon.mode.width != mode.width - || mon.mode.height != mode.height - || mon.mode.refresh_hz != mode.refresh_hz; - if changed { - unsafe { mgr_reconfigure(&mut mon, mode) }; - } - mon - } - MgrState::Idle => unsafe { create_monitor(device, mode, watchdog_s)? }, - MgrState::Active { .. } => unreachable!("handled above"), - }; - let pm = Some((mon.mode.width, mon.mode.height, mon.mode.refresh_hz)); - let target = mon.target(); - let gen = mon.gen; - g.state = MgrState::Active { mon, refs: 1 }; - Ok(VirtualOutput { - node_id: 0, - preferred_mode: pm, - win_capture: target, - keepalive: Box::new(MonitorLease { gen }), - }) -} - -/// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name. -unsafe fn mgr_reconfigure(mon: &mut Monitor, mode: Mode) { - tracing::info!( - old = format!( - "{}x{}@{}", - mon.mode.width, mon.mode.height, mon.mode.refresh_hz - ), - new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz), - "SudoVDA: reconfiguring reused monitor to the new client mode" - ); - if let Some(n) = resolve_gdi_name(mon.target_id) { - mon.gdi_name = Some(n); - } - if let Some(n) = &mon.gdi_name { - set_active_mode(n, mode); - } - mon.mode = mode; -} - -/// Release a session's hold: refcount-- ; when the last session leaves, LINGER before teardown. -/// `gen` is the lease's monitor generation: a STALE lease (its monitor was already torn down + -/// recreated under it — the IDD-push reconnect-preempt path) does nothing, so it can't decrement the -/// CURRENT (fresh) monitor's refcount and tear it down. -fn mgr_release(gen: u64) { - let mut g = MGR.lock().unwrap(); - let stale = match &g.state { - MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen, - MgrState::Idle => true, - }; - if stale { - return; - } - g.state = match std::mem::replace(&mut g.state, MgrState::Idle) { - MgrState::Active { mon, refs } if refs > 1 => MgrState::Active { - mon, - refs: refs - 1, - }, - MgrState::Active { mon, .. } => { - let ms = linger_ms(); - tracing::info!( - linger_ms = ms, - "SudoVDA: last session left — lingering before teardown" - ); - MgrState::Lingering { - mon, - until: Instant::now() + Duration::from_millis(ms), - } - } - other => other, - }; -} - -/// Wait (up to `timeout`) for the active monitor to be RELEASED — i.e. the MGR is no longer `Active` -/// (the prior session dropped its lease → `Lingering`/`Idle`). Used by the IDD-push reconnect preempt: -/// after signalling the old session to stop, we wait here so it tears its monitor down CLEANLY (while -/// frames still flow) before we acquire a fresh one — instead of dropping the monitor out from under a -/// still-live session, which churns the driver's ADD/REMOVE path and wedges it under rapid reconnects. -pub(crate) fn wait_for_monitor_released(timeout: Duration) { - let deadline = Instant::now() + timeout; - loop { - if !matches!(MGR.lock().unwrap().state, MgrState::Active { .. }) { - return; - } - if Instant::now() >= deadline { - tracing::warn!( - "IDD-push preempt: prior session didn't release the monitor within {timeout:?} — \ - proceeding (mgr_acquire will preempt it)" - ); - return; - } - thread::sleep(Duration::from_millis(25)); + unsafe { ioctl(dev, IOCTL_DRIVER_PING, &[], &mut none) }.map(|_| ()) } } -/// Background timer (started once): tear down a monitor that has lingered past its deadline (→ Idle), -/// so a physical-screen user gets their screen back after they stop streaming. -fn ensure_linger_timer() { - static TIMER: Once = Once::new(); - TIMER.call_once(|| { - let _ = thread::Builder::new() - .name("sudovda-linger".into()) - .spawn(|| loop { - thread::sleep(Duration::from_millis(500)); - let mut g = MGR.lock().unwrap(); - let due = matches!(&g.state, MgrState::Lingering { until, .. } if Instant::now() >= *until); - if due { - let device = g.device.unwrap_or(0); - if let MgrState::Lingering { mon, .. } = - std::mem::replace(&mut g.state, MgrState::Idle) - { - drop(g); // release the lock before the REMOVE IOCTL + display restore - unsafe { mon.teardown(device) }; - } - } - }); - }); +/// The Windows SudoVDA virtual-display backend. A marker — the lifecycle lives in the shared +/// [`VirtualDisplayManager`](super::manager::VirtualDisplayManager). +pub struct SudoVdaDisplay; + +impl SudoVdaDisplay { + pub fn new() -> Result { + super::manager::init(Box::new(SudoVdaDriver)).open_backend()?; + Ok(Self) + } } -/// A session's lease on the shared monitor. Drop releases the refcount (→ linger when it hits 0), -/// UNLESS the monitor was already torn down + recreated under it (gen mismatch — the IDD-push -/// reconnect-preempt path), in which case the drop is a no-op so it can't tear down the new monitor. -struct MonitorLease { - gen: u64, -} -impl Drop for MonitorLease { - fn drop(&mut self) { - mgr_release(self.gen); +impl VirtualDisplay for SudoVdaDisplay { + fn name(&self) -> &'static str { + "sudovda" + } + + fn create(&mut self, mode: Mode) -> Result { + super::manager::vdm().acquire(mode) } } @@ -746,6 +325,8 @@ pub fn is_available() -> bool { #[cfg(test)] mod tests { use super::*; + use std::thread; + use std::time::Duration; /// Live hardware round trip — skipped unless `PUNKTFUNK_SUDOVDA_LIVE=1` (needs the SudoVDA /// driver installed). Exercises the real trait path: open -> create -> hold -> drop (REMOVE).