From 94e82df9f33f93f5012da8ec6ab3485e31bb6d82 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 25 Jun 2026 07:50:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(windows-host):=20STEP=204=20(3/n)=20?= =?UTF-8?q?=E2=80=94=20host=20pf=5Fvdisplay=20backend=20(talks=20to=20the?= =?UTF-8?q?=20new=20driver)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host can now drive the new pf-vdisplay IddCx driver instead of SudoVDA. Compiles clean on BOTH Windows (cargo check -p punktfunk-host green) and Linux (cfg(windows)-gated, main CI unaffected); adversarially reviewed (no blockers, lockstep with the driver). - new vdisplay/pf_vdisplay.rs: cloned from the proven sudovda.rs, repointed to pf_vdisplay_proto — interface GUID 70667664 (not e5bcc234), IOCTL 0x900-0x905 (not the gappy 0x800/0x888/0x8FF), AddRequest/AddReply/RemoveRequest/SetRenderAdapterRequest (bytemuck Pod, not the GUID-keyed AddParams), a u64 session_id monitor key (not a minted GUID), and a single IOCTL_GET_INFO handshake that HARD-asserts protocol_version (vs SudoVDA two-IOCTL best-effort). Full MGR/linger/refcount/teardown lifecycle preserved. - reuses sudovda.rs backend-neutral CCD/DXGI helpers (set_active_mode, isolate/restore_ displays_ccd, resolve_gdi_name, resolve_render_adapter_luid, MON_GEN/CURRENT_MON_GEN, SavedConfig) — widened to pub(crate), not duplicated. - vdisplay::open()/probe() select the backend: PUNKTFUNK_VDISPLAY=pf|sudovda forces one; default auto-detects (prefer pf-vdisplay if its interface enumerates, else SudoVDA stays the shipping fallback). Notes: SET_RENDER_ADAPTER is tolerated as the driver returns NOT_IMPLEMENTED today (STEP 4 tail); the cross-MGR wait_for_monitor_released only paces sudovda's MGR (benign until IDD-push lands on pf-vdisplay, STEP 6 — documented in-code). On-glass "monitor appears at WxH@Hz" gate is next. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/Cargo.toml | 6 + crates/punktfunk-host/src/vdisplay.rs | 34 +- .../src/vdisplay/pf_vdisplay.rs | 719 ++++++++++++++++++ crates/punktfunk-host/src/vdisplay/sudovda.rs | 18 +- 4 files changed, 769 insertions(+), 8 deletions(-) create mode 100644 crates/punktfunk-host/src/vdisplay/pf_vdisplay.rs diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 244e3e5..78c9281 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -188,6 +188,12 @@ nvidia-video-codec-sdk = { version = "0.4", features = ["ci-check"], optional = # same BtbN gpl-shared tree the Windows client uses) and pulls the shared `avcodec/avutil/...` DLLs # at runtime. `ffmpeg-sys-next` auto-detects the FFmpeg version (7.x/avcodec-61 or 8.x/62). ffmpeg-next = { version = "8", optional = true } +# Shared host<->driver wire contract for the pf-vdisplay IddCx virtual-display backend +# (vdisplay/pf_vdisplay.rs): the control-plane IOCTL codes + `#[repr(C)] Pod` request/reply structs, +# defined ONCE so host<->driver ABI drift is a compile error. `bytemuck` serializes those structs +# to/from the DeviceIoControl byte buffers. +pf-vdisplay-proto = { path = "../pf-vdisplay-proto" } +bytemuck = { version = "1.19", features = ["derive"] } [features] # NVENC hardware encode (Windows). OFF by default: it pulls the NVENC SDK, and the host then needs diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index f351fbe..e6dcfce 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -529,9 +529,15 @@ pub fn open(compositor: Compositor) -> Result> { } #[cfg(target_os = "windows")] { - // Windows has a single virtual-display backend (SudoVDA); the compositor arg is moot. + // Two virtual-display backends: the new pf-vdisplay IddCx driver (pf_vdisplay_proto) and the + // shipping SudoVDA fallback. The compositor arg is moot on Windows. PUNKTFUNK_VDISPLAY overrides; + // default auto-detects (prefer pf-vdisplay if its driver interface is present). let _ = compositor; - Ok(Box::new(sudovda::SudoVdaDisplay::new()?)) + if windows_use_pf_vdisplay() { + Ok(Box::new(pf_vdisplay::PfVdisplayDisplay::new()?)) + } else { + Ok(Box::new(sudovda::SudoVdaDisplay::new()?)) + } } #[cfg(not(any(target_os = "linux", target_os = "windows")))] { @@ -540,6 +546,22 @@ pub fn open(compositor: Compositor) -> Result> { } } +/// Pick the Windows virtual-display backend. `PUNKTFUNK_VDISPLAY=pf|pf-vdisplay|pfvd` forces the new +/// pf-vdisplay IddCx driver; `=sudovda|sudo` forces the shipping SudoVDA driver; anything else (the +/// default) auto-detects, preferring pf-vdisplay if its device interface is enumerable. +#[cfg(target_os = "windows")] +fn windows_use_pf_vdisplay() -> bool { + match std::env::var("PUNKTFUNK_VDISPLAY") + .ok() + .as_deref() + .map(str::trim) + { + Some("pf") | Some("pf-vdisplay") | Some("pfvd") => true, + Some("sudovda") | Some("sudo") => false, + _ => pf_vdisplay::is_available(), + } +} + /// Readiness probe for `compositor`: is it up and able to create a virtual output *right /// now*? A session-bringup script polls this (via `punktfunk-host probe-compositor`) to gate /// on actual readiness instead of racing the compositor with a blind sleep. @@ -560,7 +582,11 @@ pub fn probe(compositor: Compositor) -> Result<()> { #[cfg(target_os = "windows")] { let _ = compositor; - sudovda::probe() + if windows_use_pf_vdisplay() { + pf_vdisplay::probe() + } else { + sudovda::probe() + } } #[cfg(not(any(target_os = "linux", target_os = "windows")))] { @@ -608,6 +634,8 @@ mod kwin; #[cfg(target_os = "linux")] mod mutter; #[cfg(target_os = "windows")] +pub(crate) mod pf_vdisplay; +#[cfg(target_os = "windows")] pub(crate) mod sudovda; #[cfg(target_os = "linux")] mod wlroots; diff --git a/crates/punktfunk-host/src/vdisplay/pf_vdisplay.rs b/crates/punktfunk-host/src/vdisplay/pf_vdisplay.rs new file mode 100644 index 0000000..3735251 --- /dev/null +++ b/crates/punktfunk-host/src/vdisplay/pf_vdisplay.rs @@ -0,0 +1,719 @@ +//! Windows virtual-display backend driving **pf-vdisplay** — punktfunk's OWN IddCx Indirect Display +//! Driver (the clean-room replacement for SudoVDA). The Windows analogue of the Linux per-compositor +//! backends: [`create`](VirtualDisplay::create) adds a virtual monitor at the client's exact `WxH@Hz` +//! (the mode is baked into the ADD IOCTL — no EDID seeding), starts the mandatory watchdog ping, and +//! the returned [`VirtualOutput`]'s keepalive `Drop` removes it (RAII). +//! +//! Control surface: a device-interface-GUID + `CreateFileW` + `DeviceIoControl` IOCTL protocol, with +//! the wire contract OWNED by [`pf_vdisplay_proto::control`] (versioned + `#[repr(C)] Pod` structs, +//! NOT the SudoVDA ABI). No DLL, no named pipe. See `docs/windows-host-rewrite.md`. +//! +//! This is a faithful clone of [`super::sudovda`] (the shipping fallback) repointed at the new driver: +//! same reference-counted/lingering monitor lifecycle, same CCD isolation + active-mode forcing — those +//! backend-NEUTRAL helpers are REUSED from `sudovda` (a pf-vdisplay monitor's `target_id` is a real OS +//! target id, so the CCD/DXGI code works unchanged). Only the driver-specific bits (GUID, IOCTL codes, +//! request/reply structs, the version handshake) differ, per `pf_vdisplay_proto`. + +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 anyhow::{Context, Result}; +use windows::core::{GUID, PCWSTR}; +use windows::Win32::Devices::DeviceAndDriverInstallation::{ + SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW, + SetupDiGetDeviceInterfaceDetailW, DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, + SP_DEVICE_INTERFACE_DATA, SP_DEVICE_INTERFACE_DETAIL_DATA_W, +}; +use windows::Win32::Foundation::{CloseHandle, HANDLE, LUID}; +use windows::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, +}; +use windows::Win32::System::IO::DeviceIoControl; + +use pf_vdisplay_proto::control; + +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/CURRENT_MON_GEN generation +// counter is reused too, so the IDD-push stale-ring bail works regardless of which backend is active. +use super::sudovda::{ + isolate_displays_ccd, resolve_gdi_name, resolve_render_adapter_luid, restore_displays_ccd, + set_active_mode, SavedConfig, CURRENT_MON_GEN, MON_GEN, +}; + +// 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 +// any accidental coexistence with a real SudoVDA install. +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 { + std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() +} + +/// 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 +/// simple monotonic counter suffices. Unique per (process, session) within this host's lifetime. +static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1); +fn next_session_id() -> u64 { + NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed) +} + +/// One `DeviceIoControl` round trip (METHOD_BUFFERED). `input`/`output` may be empty. Identical to the +/// SudoVDA backend's wrapper; struct<->bytes conversion happens at the call sites via `bytemuck`. +unsafe fn ioctl(h: HANDLE, code: u32, input: &[u8], output: &mut [u8]) -> Result { + let mut returned = 0u32; + let inp = (!input.is_empty()).then_some(input.as_ptr() as *const c_void); + let outp = (!output.is_empty()).then_some(output.as_mut_ptr() as *mut c_void); + DeviceIoControl( + h, + code, + inp, + input.len() as u32, + outp, + output.len() as u32, + Some(&mut returned), + None, + ) + .with_context(|| format!("DeviceIoControl(code={code:#x})"))?; + Ok(returned) +} + +/// Pin the pf-vdisplay IddCx's RENDER GPU to `luid` (the analogue of Apollo's `SetRenderAdapter`). No +/// output buffer. Issued on the driver handle BEFORE `IOCTL_ADD` to steer which GPU the new target +/// renders on — on a multi-adapter box this stops DXGI from reparenting the virtual output onto a +/// different adapter than the one we duplicate/encode on (the ACCESS_LOST storm). +/// +/// NOTE: the pf-vdisplay driver currently returns `STATUS_NOT_IMPLEMENTED` for this IOCTL (a STEP-4 +/// stub), so this call WILL fail today. Callers tolerate the `Err` (warn + continue) — exactly as the +/// SudoVDA backend tolerated the driver IGNORING the pin. +unsafe fn set_render_adapter(h: HANDLE, luid: LUID) -> Result<()> { + let req = control::SetRenderAdapterRequest { + luid_low: luid.LowPart, + luid_high: luid.HighPart, + }; + let mut none: [u8; 0] = []; + ioctl( + h, + control::IOCTL_SET_RENDER_ADAPTER, + bytemuck::bytes_of(&req), + &mut none, + ) + .map(|_| ()) + .context("pf-vdisplay SET_RENDER_ADAPTER") +} + +unsafe fn open_device() -> Result { + let hdev = SetupDiGetClassDevsW( + Some(&PF_VDISPLAY_INTERFACE), + PCWSTR::null(), + None, + DIGCF_DEVICEINTERFACE | DIGCF_PRESENT, + ) + .context("SetupDiGetClassDevsW(pf-vdisplay) — is the pf-vdisplay driver installed?")?; + + let mut idata = SP_DEVICE_INTERFACE_DATA { + cbSize: size_of::() as u32, + ..Default::default() + }; + SetupDiEnumDeviceInterfaces(hdev, None, &PF_VDISPLAY_INTERFACE, 0, &mut idata) + .context("SetupDiEnumDeviceInterfaces(pf-vdisplay)")?; + + let mut required = 0u32; + let _ = SetupDiGetDeviceInterfaceDetailW(hdev, &idata, None, 0, Some(&mut required), None); + let mut buf = vec![0u8; required as usize]; + let detail = buf.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W; + (*detail).cbSize = size_of::() as u32; + SetupDiGetDeviceInterfaceDetailW(hdev, &idata, Some(detail), required, None, None) + .context("SetupDiGetDeviceInterfaceDetailW(pf-vdisplay)")?; + + let handle = CreateFileW( + PCWSTR((*detail).DevicePath.as_ptr()), + 0xC000_0000, // GENERIC_READ | GENERIC_WRITE + FILE_SHARE_READ | FILE_SHARE_WRITE, + None, + OPEN_EXISTING, + FILE_FLAGS_AND_ATTRIBUTES(0), + None, + ) + .context("CreateFileW(pf-vdisplay device)")?; + let _ = SetupDiDestroyDeviceInfoList(hdev); + 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 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 { + 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) + } +} + +/// 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). + let session_id = next_session_id(); + let add = control::AddRequest { + session_id, + width: mode.width, + height: mode.height, + refresh_hz: mode.refresh_hz, + _reserved: 0, + }; + // SET_RENDER_ADAPTER is OPT-IN. By default we do NOT pin the render adapter — let the IDD use + // its natural adapter (Apollo-parity; avoids the cross-GPU mismatch ACCESS_LOST storm). Opt in + // with PUNKTFUNK_RENDER_ADAPTER= or the IDD-push path (which MUST run NVENC on + // the discrete render GPU it pins here). NOTE: the pf-vdisplay driver currently returns + // STATUS_NOT_IMPLEMENTED for this IOCTL (a STEP-4 stub), so the call below is tolerated to fail. + let pinned = if std::env::var("PUNKTFUNK_RENDER_ADAPTER").is_ok() { + unsafe { resolve_render_adapter_luid() } + } else if std::env::var_os("PUNKTFUNK_IDD_PUSH").is_some() { + // 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 HONORS SET_RENDER_ADAPTER (once + // implemented), 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 { + 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" + ), + // The driver currently stubs this IOCTL (STATUS_NOT_IMPLEMENTED) — warn + continue, do + // NOT propagate. The natural-adapter path still works (Apollo-parity). + Err(e) => tracing::warn!( + "pf-vdisplay SET_RENDER_ADAPTER failed (driver stub / not implemented — \ + continuing): {e:#}" + ), + } + } + + let mut out = [0u8; size_of::()]; + 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. + let reply: control::AddReply = + bytemuck::pod_read_unaligned(&out[..size_of::()]); + let luid = LUID { + LowPart: reply.adapter_luid_low, + HighPart: reply.adapter_luid_high, + }; + tracing::info!( + "pf-vdisplay created {}x{}@{} (target_id={}, adapter_luid={:#x})", + mode.width, + mode.height, + mode.refresh_hz, + reply.target_id, + luid.LowPart + ); + if let Some(pin) = pinned { + if luid.LowPart == pin.LowPart && luid.HighPart == pin.HighPart { + tracing::info!("pf-vdisplay ADD render adapter matches the pinned GPU (pin took)"); + } else { + tracing::warn!( + add = format!("{:08x}:{:08x}", luid.HighPart, luid.LowPart), + pinned = format!("{:08x}:{:08x}", pin.HighPart, pin.LowPart), + "pf-vdisplay ADD render adapter DIFFERS from pinned — driver ignored SET_RENDER_ADAPTER?" + ); + } + } + + // Mandatory keepalive: ping inside the watchdog window or the driver tears all displays down. + let stop = Arc::new(AtomicBool::new(false)); + let device_raw = device; + let interval = Duration::from_millis(watchdog_s as u64 * 1000 / 3); + let stop_t = stop.clone(); + let pinger = thread::spawn(move || { + let h = HANDLE(device_raw as *mut c_void); + let mut warned = false; + while !stop_t.load(Ordering::Relaxed) { + let mut none: [u8; 0] = []; + match unsafe { ioctl(h, control::IOCTL_PING, &[], &mut none) } { + Ok(_) => 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, + 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); + } + let req = control::RemoveRequest { + session_id: self.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"); + } + } +} + +/// 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. + { + 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)"); + } + } + 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 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)); + } + } + + // 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; + CURRENT_MON_GEN.store(gen, Ordering::Relaxed); + 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; + CURRENT_MON_GEN.store(gen, Ordering::Relaxed); + 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); + } +} + +/// Readiness probe: can we open the pf-vdisplay control device? +pub fn probe() -> Result<()> { + let h = unsafe { open_device()? }; + unsafe { + let _ = CloseHandle(h); + } + Ok(()) +} + +/// Is the pf-vdisplay driver present (device interface enumerable)? +pub fn is_available() -> bool { + unsafe { open_device().map(|h| CloseHandle(h)).is_ok() } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 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). + #[test] + fn live_create_drop() { + if std::env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() { + return; + } + let mut vd = PfVdisplayDisplay::new().expect("open pf-vdisplay"); + let vout = vd + .create(Mode { + width: 1920, + height: 1080, + refresh_hz: 60, + }) + .expect("create virtual display"); + assert_eq!(vout.preferred_mode, Some((1920, 1080, 60))); + thread::sleep(Duration::from_secs(3)); + drop(vout); // triggers REMOVE + stops the pinger + } +} diff --git a/crates/punktfunk-host/src/vdisplay/sudovda.rs b/crates/punktfunk-host/src/vdisplay/sudovda.rs index db21005..a024b21 100644 --- a/crates/punktfunk-host/src/vdisplay/sudovda.rs +++ b/crates/punktfunk-host/src/vdisplay/sudovda.rs @@ -15,7 +15,9 @@ 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. -static MON_GEN: AtomicU64 = AtomicU64::new(1); +// 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); /// The gen of the CURRENTLY-active monitor. A session capturer captures this at open and re-checks it /// each frame; when it changes (a reconnect preempted + recreated the monitor), the old session bails @@ -345,7 +347,9 @@ pub(crate) unsafe fn advanced_color_enabled(target_id: u32) -> bool { /// ADVERTISES the mode; Windows otherwise activates an IDD target at a 1280x720 default, so the /// ACTIVE mode (what DXGI Desktop Duplication captures) must be set explicitly. CDS_TEST first so a /// mode the driver didn't advertise just leaves the default instead of erroring the session. -fn set_active_mode(gdi_name: &str, mode: Mode) { +// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD/GDI mode-set helper +// (a pf-vdisplay monitor's GDI name is a real OS device name, so it works unchanged). +pub(crate) fn set_active_mode(gdi_name: &str, mode: Mode) { let wname: Vec = gdi_name.encode_utf16().chain(std::iter::once(0)).collect(); // Enumerate the modes the driver actually advertises for this output and pick the best match for @@ -470,7 +474,8 @@ fn set_active_mode(gdi_name: &str, mode: Mode) { } /// Saved active display topology, for restoring on teardown. -type SavedConfig = (Vec, Vec); +// pub(crate) so vdisplay::pf_vdisplay's Monitor can hold the same saved-topology type. +pub(crate) type SavedConfig = (Vec, Vec); /// `DISPLAYCONFIG_PATH_ACTIVE` (wingdi.h) — the `flags` bit marking a path active. The `windows` crate /// doesn't export it, so define it here. @@ -483,7 +488,9 @@ const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001; /// sees every active path; we deactivate all of them EXCEPT the SudoVDA target's, leaving the virtual /// display as the sole desktop so ALL content (incl. Winlogon) renders to it. Apollo isolates the same /// way (CCD). Returns the original active config to restore on teardown. -unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option { +// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD isolation helper +// (it operates on a real OS target id — a pf-vdisplay monitor's target_id qualifies). +pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option { let mut np = 0u32; let mut nm = 0u32; if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() { @@ -554,7 +561,8 @@ unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option { /// Restore the topology saved by [`isolate_displays_ccd`] (teardown, before the virtual output is /// removed), re-activating the displays we deactivated. -unsafe fn restore_displays_ccd(saved: &SavedConfig) { +// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD restore helper. +pub(crate) unsafe fn restore_displays_ccd(saved: &SavedConfig) { let (paths, modes) = saved; if paths.is_empty() { return;