diff --git a/crates/punktfunk-host/src/capture/windows/idd_push.rs b/crates/punktfunk-host/src/capture/windows/idd_push.rs index efa4b9d2..a8bcf0ec 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push.rs +++ b/crates/punktfunk-host/src/capture/windows/idd_push.rs @@ -323,384 +323,15 @@ pub(crate) unsafe fn verify_is_wudfhost(process: HANDLE, wudf_pid: u32, what: &s Ok(()) } -/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects -/// are unnamed, so the ONLY way the driver can reach them is handles this broker duplicates into its -/// WUDFHost process and delivers — as bare handle VALUES — over the SYSTEM-only control device -/// (`IOCTL_SET_FRAME_CHANNEL`). Ownership is a strict hand-off: on IOCTL success the DRIVER owns the -/// duplicates (it closes them); on any failure [`Self::send`] reaps every duplicate it already made -/// (`DUPLICATE_CLOSE_SOURCE`), so a half-delivered channel never leaks handles in WUDFHost. -struct ChannelBroker { - /// `PROCESS_DUP_HANDLE | SYNCHRONIZE` handle to the driver's WUDFHost (pid from the ADD reply; - /// `ProcessSharingDisabled` makes that process exclusively pf-vdisplay's). `SYNCHRONIZE` lets the - /// handle double as the driver-death probe ([`Self::driver_alive`]). - process: OwnedHandle, - /// The WUDFHost pid `process` refers to (diagnostics for the driver-death bail). - wudf_pid: u32, - /// The pf-vdisplay control device — owned by the `VirtualDisplayManager`, never closed for the - /// process lifetime (a dead one is retired, kept alive), so holding the bare `HANDLE` is sound. - control: HANDLE, -} - -impl ChannelBroker { - /// Open the duplication target. Fails when the driver predates the sealed channel (`wudf_pid == 0` - /// can't survive the v2 version handshake, but guard anyway) or the WUDFHost is gone (device - /// restart mid-open) — either way the caller fails the capture open cleanly. - /// - /// `wudf_pid` comes from the driver's ADD reply, so before we duplicate whole-desktop frame handles - /// INTO it we VERIFY it is a genuine system WUDFHost ([`verify_is_wudfhost`]). Without that check a - /// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a - /// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes - /// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening). - fn open(wudf_pid: u32) -> Result { - if wudf_pid == 0 { - bail!("driver reported no WUDFHost pid for the frame channel"); - } - let control = crate::vdisplay::manager::control_device_handle().context( - "pf-vdisplay control device not open (monitor not created via the manager?)", - )?; - // SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and - // moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it - // for the duration of the synchronous check and forms no lasting alias. - let process = unsafe { - let h = OpenProcess( - PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, - false, - wudf_pid, - ) - .context("OpenProcess(PROCESS_DUP_HANDLE) on the driver's WUDFHost")?; - let process = OwnedHandle::from_raw_handle(h.0 as _); - verify_is_wudfhost(HANDLE(process.as_raw_handle()), wudf_pid, "frame-channel")?; - process - }; - Ok(Self { - process, - wudf_pid, - control, - }) - } - - /// Whether the driver's WUDFHost is still alive. The pinned process handle doubles as the - /// liveness probe (`SYNCHRONIZE` requested at open): signaled ⇔ the process exited. This is the - /// definitive "driver died mid-session" signal — at the ring, a dead driver and an idle desktop - /// are indistinguishable (both simply stop publishing). - fn driver_alive(&self) -> bool { - // SAFETY: `process` is the live `OwnedHandle` this broker owns (borrowed for this synchronous - // call); a 0 ms wait only reads the handle's signaled state. - unsafe { WaitForSingleObject(HANDLE(self.process.as_raw_handle()), 0) != WAIT_OBJECT_0 } - } - - /// Duplicate `h` into the WUDFHost handle table, returning the handle VALUE valid there (and only - /// there — the value is meaningless in any other process). `access = Some(rights)` grants the - /// driver's handle exactly those rights (least privilege — see [`SECTION_MAP_RW`]); - /// `access = None` copies the source handle's access (`DUPLICATE_SAME_ACCESS`), used only where the - /// source is already scoped (the DXGI shared-texture handles, minted by `CreateSharedHandle` with - /// just `DXGI_SHARED_RESOURCE_READ|WRITE`). - /// - /// # Safety - /// `h` must be a live handle of the current process. - unsafe fn dup_into(&self, h: HANDLE, access: Option) -> Result { - let mut out = HANDLE::default(); - let (desired, options) = match access { - Some(rights) => (rights, DUPLICATE_HANDLE_OPTIONS(0)), - None => (0, DUPLICATE_SAME_ACCESS), - }; - // SAFETY: `h` is live per the contract; `self.process` is the live PROCESS_DUP_HANDLE target; - // `&mut out` is a valid out-param. Either an explicit least-privilege access mask (options == 0) - // or `DUPLICATE_SAME_ACCESS` (desired ignored) — never both. - unsafe { - DuplicateHandle( - GetCurrentProcess(), - h, - HANDLE(self.process.as_raw_handle()), - &mut out, - desired, - false, - options, - ) - } - .context("DuplicateHandle into the driver's WUDFHost")?; - Ok(out.0 as usize as u64) - } - - /// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE` - /// with no target closes the source handle regardless of the (ignored) result. - fn close_remote(&self, value: u64) { - if value == 0 { - return; - } - // SAFETY: `self.process` is the live duplication target and `value` is a handle value THIS - // broker just created in that process's table (callers only pass back `dup_into` results the - // driver never received); closing it there cannot touch any other process's handles. - unsafe { - let _ = DuplicateHandle( - HANDLE(self.process.as_raw_handle()), - HANDLE(value as usize as *mut core::ffi::c_void), - HANDLE::default(), - std::ptr::null_mut(), - 0, - false, - DUPLICATE_CLOSE_SOURCE, - ); - } - } - - /// Duplicate the whole ring (header + event + every slot texture) into WUDFHost and deliver the - /// values via `IOCTL_SET_FRAME_CHANNEL`. All-or-nothing: on any failure every duplicate already - /// made is reaped remotely and an error returns (the caller fails the open / logs the recreate). - /// The ownership contract with the driver is adopt-on-success only — it closes the handles iff the - /// IOCTL succeeded, we reap them iff it didn't, so no value is ever closed twice. - /// - /// # Safety - /// `header` and `event` must be live handles of the current process (the capturer's own section + - /// event, borrowed for this synchronous call). - unsafe fn send( - &self, - target_id: u32, - generation: u32, - header: HANDLE, - event: HANDLE, - slots: &[HostSlot], - ) -> Result<()> { - debug_assert!(slots.len() <= control::RING_LEN_USIZE); - let mut req = control::SetFrameChannelRequest { - target_id, - generation, - ring_len: slots.len() as u32, - _pad: 0, - header_handle: 0, - event_handle: 0, - texture_handles: [0; control::RING_LEN_USIZE], - }; - // SAFETY: `header`/`event` are live per this fn's contract; each slot's `shared` is the live - // `OwnedHandle` the slot keeps for exactly this purpose. - let result = unsafe { self.duplicate_and_deliver(&mut req, header, event, slots) }; - if result.is_err() { - // The driver never adopted the delivery — reap every remote duplicate so nothing lingers. - self.close_remote(req.header_handle); - self.close_remote(req.event_handle); - for v in req.texture_handles { - self.close_remote(v); - } - } - result - } - - /// The fallible middle of [`Self::send`]: fill `req` with fresh duplicates, then issue the IOCTL. - /// Split out so `send` can reap whatever landed in `req` when any step errors. - /// - /// # Safety - /// As [`Self::send`]. - unsafe fn duplicate_and_deliver( - &self, - req: &mut control::SetFrameChannelRequest, - header: HANDLE, - event: HANDLE, - slots: &[HostSlot], - ) -> Result<()> { - // SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live - // handles of this process, and `self.control` is the manager's control handle, never closed for - // the process lifetime (`send_frame_channel`'s precondition). - unsafe { - // Least privilege per handle: the header maps read/write, the event is only signalled, and - // the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`). - req.header_handle = self.dup_into(header, Some(SECTION_MAP_RW))?; - req.event_handle = self.dup_into(event, Some(EVENT_MODIFY_STATE))?; - for (k, s) in slots.iter().enumerate() { - req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?; - } - crate::vdisplay::pf_vdisplay::send_frame_channel(self.control, req) - } - } -} - -/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`]. -/// The display descriptor the capture loop follows: live HDR state + active resolution of the -/// virtual target. -#[derive(Clone, Copy, PartialEq, Eq)] -struct DisplayDescriptor { - hdr: bool, - width: u32, - height: u32, -} - -/// Off-thread poller for [`DisplayDescriptor`]. The CCD queries behind it (`QueryDisplayConfig`, -/// twice per sample) serialize on the session-global display-configuration lock, which display- -/// topology events and third-party display-poller software (the SteelSeries-GG class) can hold -/// for tens-to-hundreds of milliseconds at a time. Polled inline — the old design — that stall -/// landed ON the capture/encode thread: a periodic frame hitch on an otherwise healthy host, and -/// invisible in any log. Now a dedicated thread samples every [`Self::INTERVAL`] and publishes a -/// snapshot; the capture thread's per-frame cost is one uncontended mutex read, and a slow CCD -/// sample is *measured and logged* instead of silently stalling the stream. -/// -/// Failure policy is last-known-good, per field: a transient CCD failure — including the target -/// briefly missing from the active-path list during a topology re-probe — keeps the previous -/// value instead of reading as `hdr = false` (the old behavior, which on an HDR session turned -/// every blip into TWO ring recreates: false, then true again a poll later). `seq` bumps only -/// when at least one query succeeded, so the consumer's debounce counts real observations, never -/// failures. -struct DescriptorPoller { - /// Latest merged sample + its sequence number; the poller holds the lock only to copy it. - snap: Arc>, - stop: Arc, - thread: Option>, -} - -impl DescriptorPoller { - /// Poll cadence — the old inline throttle. With the consumer's two-strikes debounce on top, a - /// real "Use HDR" flip or mode-set is acted on within ~2 samples (≈ ½ s). - const INTERVAL: Duration = Duration::from_millis(250); - /// A sample slower than this means something is sitting on the display-config lock (topology - /// churn / display-poller software) — the disturbance class behind periodic virtual-display - /// stream hitches. Logged (rate-limited) so an affected host self-diagnoses. - const SLOW: Duration = Duration::from_millis(50); - - fn spawn(target_id: u32, initial: DisplayDescriptor) -> Self { - let snap = Arc::new(Mutex::new((initial, 0u64))); - let stop = Arc::new(AtomicBool::new(false)); - let (snap_t, stop_t) = (snap.clone(), stop.clone()); - let thread = std::thread::Builder::new() - .name("pf-idd-desc-poll".into()) - .spawn(move || { - let mut last = initial; - let mut seq = 0u64; - let mut last_slow_log: Option = None; - while !stop_t.load(Ordering::Relaxed) { - let t = Instant::now(); - // SAFETY: both are read-only CCD queries taking only a copy of the plain `u32` - // target id (see their own SAFETY docs); nothing is borrowed across the calls. - let (hdr, res) = unsafe { - ( - crate::win_display::advanced_color_enabled(target_id), - crate::win_display::active_resolution(target_id), - ) - }; - let took = t.elapsed(); - if took >= Self::SLOW - && last_slow_log.is_none_or(|t| t.elapsed() >= Duration::from_secs(10)) - { - last_slow_log = Some(Instant::now()); - tracing::warn!( - took_ms = took.as_millis() as u64, - target_id, - "slow display-descriptor poll — something is holding the Windows \ - display-config lock (topology churn / display-poller software); on \ - a host with periodic stream hitches, correlate this cadence" - ); - } - if hdr.is_some() || res.is_some() { - if let Some(hdr) = hdr { - last.hdr = hdr; - } - if let Some((width, height)) = res { - last.width = width; - last.height = height; - } - seq += 1; - *snap_t.lock().unwrap() = (last, seq); - } - // Park (not sleep) so `drop` wakes the thread immediately via `unpark`. - std::thread::park_timeout(Self::INTERVAL); - } - }) - .map_err(|e| { - // Degraded, not fatal: the session streams, it just never follows a mid-session - // HDR flip / mode-set (seq stays 0 → the consumer sees no changes). - tracing::warn!(error = %e, "IDD push: descriptor-poller thread failed to spawn — mid-session HDR/mode changes won't be followed"); - }) - .ok(); - Self { snap, stop, thread } - } - - /// The latest sample (lock held only for the copy — the poller writes at 4 Hz). - fn snapshot(&self) -> (DisplayDescriptor, u64) { - *self.snap.lock().unwrap() - } -} - -impl Drop for DescriptorPoller { - fn drop(&mut self) { - self.stop.store(true, Ordering::Relaxed); - if let Some(t) = self.thread.take() { - t.thread().unpark(); - let _ = t.join(); - } - } -} - -/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the -/// desktop was actively composing right beforehand (see [`StallWatch`]). -struct Stall { - /// How long the hole lasted (last fresh frame → the frame that ended it). - gap: Duration, - /// `Some(mean period)` when this stall completes a metronomic cycle (see - /// [`crate::metronome::Metronome`]). - metronomic: Option, -} - -/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive -/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present -/// path BELOW capture and only while no physical output is active). -/// -/// On a damage-driven capture an idle desktop legitimately goes quiet (no damage → no frames), so a -/// gap only counts as a stall when the [`Self::RECENT`] frames before it all arrived within -/// [`Self::ACTIVE_SPAN`] — sustained ≥ ~20 fps flow (a game or video), not a blinking caret or a -/// mouse twitch. Each stall feeds a [`crate::metronome::Metronome`], so periodic stalls self-diagnose -/// in the log WITHOUT needing any client keyframe request — discriminating "DWM stopped composing" -/// from encode/network causes that the recovery-cadence detector covers. Pure logic — unit-tested -/// below; the caller does the logging. -struct StallWatch { - /// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate). - recent: std::collections::VecDeque, - cadence: crate::metronome::Metronome, -} - -impl StallWatch { - /// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus - /// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed. - const RECENT: usize = 8; - /// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow — - /// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage). - const ACTIVE_SPAN: Duration = Duration::from_millis(400); - /// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the - /// reported 300–700 ms freezes, above encode/present jitter. - const STALL_MIN: Duration = Duration::from_millis(150); - - fn new() -> Self { - Self { - recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1), - cadence: crate::metronome::Metronome::new(), - } - } - - /// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without - /// the reset the first post-recreate frame would read as one). - fn reset(&mut self) { - self.recent.clear(); - } - - /// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall. - fn note_fresh(&mut self, now: Instant) -> Option { - let was_active = self.recent.len() == Self::RECENT - && self - .recent - .back() - .zip(self.recent.front()) - .is_some_and(|(b, f)| b.duration_since(*f) <= Self::ACTIVE_SPAN); - let gap = self.recent.back().map(|last| now.duration_since(*last)); - self.recent.push_back(now); - if self.recent.len() > Self::RECENT { - self.recent.pop_front(); - } - let gap = gap?; - if !was_active || gap < Self::STALL_MIN { - return None; - } - Some(Stall { - gap, - metronomic: self.cadence.note(now), - }) - } -} +#[path = "idd_push/channel.rs"] +mod channel; +#[path = "idd_push/descriptor.rs"] +mod descriptor; +#[path = "idd_push/stall.rs"] +mod stall; +use channel::ChannelBroker; +use descriptor::{DescriptorPoller, DisplayDescriptor}; +use stall::StallWatch; pub struct IddPushCapturer { device: ID3D11Device, @@ -1923,6 +1554,7 @@ impl Drop for IddPushCapturer { #[cfg(test)] mod tests { + use super::stall::Stall; use super::*; /// Feed a [`StallWatch`] fresh frames at the given offsets (ms from a common origin) and diff --git a/crates/punktfunk-host/src/capture/windows/idd_push/channel.rs b/crates/punktfunk-host/src/capture/windows/idd_push/channel.rs new file mode 100644 index 00000000..f50997c6 --- /dev/null +++ b/crates/punktfunk-host/src/capture/windows/idd_push/channel.rs @@ -0,0 +1,198 @@ +//! The sealed frame channel's handle-duplication broker (plan §W4, carved out of the IDD-push +//! capturer): duplicates the unnamed shared header / ring / event handles into the driver's WUDFHost +//! and delivers them as bare handle values over the SYSTEM-only control device. + +// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). +#![deny(clippy::undocumented_unsafe_blocks)] + +use super::*; + +/// The sealed channel's handle-duplication broker (`design/idd-push-security.md`): the frame objects +/// are unnamed, so the ONLY way the driver can reach them is handles this broker duplicates into its +/// WUDFHost process and delivers — as bare handle VALUES — over the SYSTEM-only control device +/// (`IOCTL_SET_FRAME_CHANNEL`). Ownership is a strict hand-off: on IOCTL success the DRIVER owns the +/// duplicates (it closes them); on any failure [`Self::send`] reaps every duplicate it already made +/// (`DUPLICATE_CLOSE_SOURCE`), so a half-delivered channel never leaks handles in WUDFHost. +pub(super) struct ChannelBroker { + /// `PROCESS_DUP_HANDLE | SYNCHRONIZE` handle to the driver's WUDFHost (pid from the ADD reply; + /// `ProcessSharingDisabled` makes that process exclusively pf-vdisplay's). `SYNCHRONIZE` lets the + /// handle double as the driver-death probe ([`Self::driver_alive`]). + process: OwnedHandle, + /// The WUDFHost pid `process` refers to (diagnostics for the driver-death bail). + pub(super) wudf_pid: u32, + /// The pf-vdisplay control device — owned by the `VirtualDisplayManager`, never closed for the + /// process lifetime (a dead one is retired, kept alive), so holding the bare `HANDLE` is sound. + control: HANDLE, +} + +impl ChannelBroker { + /// Open the duplication target. Fails when the driver predates the sealed channel (`wudf_pid == 0` + /// can't survive the v2 version handshake, but guard anyway) or the WUDFHost is gone (device + /// restart mid-open) — either way the caller fails the capture open cleanly. + /// + /// `wudf_pid` comes from the driver's ADD reply, so before we duplicate whole-desktop frame handles + /// INTO it we VERIFY it is a genuine system WUDFHost ([`verify_is_wudfhost`]). Without that check a + /// spoofed devnode (same interface GUID) could name an arbitrary process and receive the frames; a + /// fully-compromised REAL pf_vdisplay driver is already a frame endpoint, so this specifically closes + /// the reachable-without-owning-the-driver case (`design/idd-push-security.md` §hardening). + pub(super) fn open(wudf_pid: u32) -> Result { + if wudf_pid == 0 { + bail!("driver reported no WUDFHost pid for the frame channel"); + } + let control = crate::vdisplay::manager::control_device_handle().context( + "pf-vdisplay control device not open (monitor not created via the manager?)", + )?; + // SAFETY: plain FFI; `wudf_pid` is a copy. The handle (checked by `?`) is owned solely here and + // moved into the `OwnedHandle` (single owner, closes on drop); `verify_is_wudfhost` borrows it + // for the duration of the synchronous check and forms no lasting alias. + let process = unsafe { + let h = OpenProcess( + PROCESS_DUP_HANDLE | PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, + false, + wudf_pid, + ) + .context("OpenProcess(PROCESS_DUP_HANDLE) on the driver's WUDFHost")?; + let process = OwnedHandle::from_raw_handle(h.0 as _); + verify_is_wudfhost(HANDLE(process.as_raw_handle()), wudf_pid, "frame-channel")?; + process + }; + Ok(Self { + process, + wudf_pid, + control, + }) + } + + /// Whether the driver's WUDFHost is still alive. The pinned process handle doubles as the + /// liveness probe (`SYNCHRONIZE` requested at open): signaled ⇔ the process exited. This is the + /// definitive "driver died mid-session" signal — at the ring, a dead driver and an idle desktop + /// are indistinguishable (both simply stop publishing). + pub(super) fn driver_alive(&self) -> bool { + // SAFETY: `process` is the live `OwnedHandle` this broker owns (borrowed for this synchronous + // call); a 0 ms wait only reads the handle's signaled state. + unsafe { WaitForSingleObject(HANDLE(self.process.as_raw_handle()), 0) != WAIT_OBJECT_0 } + } + + /// Duplicate `h` into the WUDFHost handle table, returning the handle VALUE valid there (and only + /// there — the value is meaningless in any other process). `access = Some(rights)` grants the + /// driver's handle exactly those rights (least privilege — see [`SECTION_MAP_RW`]); + /// `access = None` copies the source handle's access (`DUPLICATE_SAME_ACCESS`), used only where the + /// source is already scoped (the DXGI shared-texture handles, minted by `CreateSharedHandle` with + /// just `DXGI_SHARED_RESOURCE_READ|WRITE`). + /// + /// # Safety + /// `h` must be a live handle of the current process. + unsafe fn dup_into(&self, h: HANDLE, access: Option) -> Result { + let mut out = HANDLE::default(); + let (desired, options) = match access { + Some(rights) => (rights, DUPLICATE_HANDLE_OPTIONS(0)), + None => (0, DUPLICATE_SAME_ACCESS), + }; + // SAFETY: `h` is live per the contract; `self.process` is the live PROCESS_DUP_HANDLE target; + // `&mut out` is a valid out-param. Either an explicit least-privilege access mask (options == 0) + // or `DUPLICATE_SAME_ACCESS` (desired ignored) — never both. + unsafe { + DuplicateHandle( + GetCurrentProcess(), + h, + HANDLE(self.process.as_raw_handle()), + &mut out, + desired, + false, + options, + ) + } + .context("DuplicateHandle into the driver's WUDFHost")?; + Ok(out.0 as usize as u64) + } + + /// Close a handle VALUE inside the WUDFHost table (the failure-path reaper): `DUPLICATE_CLOSE_SOURCE` + /// with no target closes the source handle regardless of the (ignored) result. + fn close_remote(&self, value: u64) { + if value == 0 { + return; + } + // SAFETY: `self.process` is the live duplication target and `value` is a handle value THIS + // broker just created in that process's table (callers only pass back `dup_into` results the + // driver never received); closing it there cannot touch any other process's handles. + unsafe { + let _ = DuplicateHandle( + HANDLE(self.process.as_raw_handle()), + HANDLE(value as usize as *mut core::ffi::c_void), + HANDLE::default(), + std::ptr::null_mut(), + 0, + false, + DUPLICATE_CLOSE_SOURCE, + ); + } + } + + /// Duplicate the whole ring (header + event + every slot texture) into WUDFHost and deliver the + /// values via `IOCTL_SET_FRAME_CHANNEL`. All-or-nothing: on any failure every duplicate already + /// made is reaped remotely and an error returns (the caller fails the open / logs the recreate). + /// The ownership contract with the driver is adopt-on-success only — it closes the handles iff the + /// IOCTL succeeded, we reap them iff it didn't, so no value is ever closed twice. + /// + /// # Safety + /// `header` and `event` must be live handles of the current process (the capturer's own section + + /// event, borrowed for this synchronous call). + pub(super) unsafe fn send( + &self, + target_id: u32, + generation: u32, + header: HANDLE, + event: HANDLE, + slots: &[HostSlot], + ) -> Result<()> { + debug_assert!(slots.len() <= control::RING_LEN_USIZE); + let mut req = control::SetFrameChannelRequest { + target_id, + generation, + ring_len: slots.len() as u32, + _pad: 0, + header_handle: 0, + event_handle: 0, + texture_handles: [0; control::RING_LEN_USIZE], + }; + // SAFETY: `header`/`event` are live per this fn's contract; each slot's `shared` is the live + // `OwnedHandle` the slot keeps for exactly this purpose. + let result = unsafe { self.duplicate_and_deliver(&mut req, header, event, slots) }; + if result.is_err() { + // The driver never adopted the delivery — reap every remote duplicate so nothing lingers. + self.close_remote(req.header_handle); + self.close_remote(req.event_handle); + for v in req.texture_handles { + self.close_remote(v); + } + } + result + } + + /// The fallible middle of [`Self::send`]: fill `req` with fresh duplicates, then issue the IOCTL. + /// Split out so `send` can reap whatever landed in `req` when any step errors. + /// + /// # Safety + /// As [`Self::send`]. + unsafe fn duplicate_and_deliver( + &self, + req: &mut control::SetFrameChannelRequest, + header: HANDLE, + event: HANDLE, + slots: &[HostSlot], + ) -> Result<()> { + // SAFETY: forwarded from the caller's contract — `header`/`event`/each `slot.shared` are live + // handles of this process, and `self.control` is the manager's control handle, never closed for + // the process lifetime (`send_frame_channel`'s precondition). + unsafe { + // Least privilege per handle: the header maps read/write, the event is only signalled, and + // the textures keep their already-scoped `CreateSharedHandle` access (see `dup_into`). + req.header_handle = self.dup_into(header, Some(SECTION_MAP_RW))?; + req.event_handle = self.dup_into(event, Some(EVENT_MODIFY_STATE))?; + for (k, s) in slots.iter().enumerate() { + req.texture_handles[k] = self.dup_into(HANDLE(s.shared.as_raw_handle()), None)?; + } + crate::vdisplay::pf_vdisplay::send_frame_channel(self.control, req) + } + } +} diff --git a/crates/punktfunk-host/src/capture/windows/idd_push/descriptor.rs b/crates/punktfunk-host/src/capture/windows/idd_push/descriptor.rs new file mode 100644 index 00000000..7f1d97b5 --- /dev/null +++ b/crates/punktfunk-host/src/capture/windows/idd_push/descriptor.rs @@ -0,0 +1,121 @@ +//! Off-thread display-descriptor polling (plan §W4, carved out of the IDD-push capturer): the +//! live HDR state + active resolution of the virtual target, sampled off the capture loop via CCD. + +// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). +#![deny(clippy::undocumented_unsafe_blocks)] + +use super::*; + +/// Creates + owns the shared ring; yields the driver's frames as [`FramePayload::D3d11`]. +/// The display descriptor the capture loop follows: live HDR state + active resolution of the +/// virtual target. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) struct DisplayDescriptor { + pub(super) hdr: bool, + pub(super) width: u32, + pub(super) height: u32, +} + +/// Off-thread poller for [`DisplayDescriptor`]. The CCD queries behind it (`QueryDisplayConfig`, +/// twice per sample) serialize on the session-global display-configuration lock, which display- +/// topology events and third-party display-poller software (the SteelSeries-GG class) can hold +/// for tens-to-hundreds of milliseconds at a time. Polled inline — the old design — that stall +/// landed ON the capture/encode thread: a periodic frame hitch on an otherwise healthy host, and +/// invisible in any log. Now a dedicated thread samples every [`Self::INTERVAL`] and publishes a +/// snapshot; the capture thread's per-frame cost is one uncontended mutex read, and a slow CCD +/// sample is *measured and logged* instead of silently stalling the stream. +/// +/// Failure policy is last-known-good, per field: a transient CCD failure — including the target +/// briefly missing from the active-path list during a topology re-probe — keeps the previous +/// value instead of reading as `hdr = false` (the old behavior, which on an HDR session turned +/// every blip into TWO ring recreates: false, then true again a poll later). `seq` bumps only +/// when at least one query succeeded, so the consumer's debounce counts real observations, never +/// failures. +pub(super) struct DescriptorPoller { + /// Latest merged sample + its sequence number; the poller holds the lock only to copy it. + snap: Arc>, + stop: Arc, + thread: Option>, +} + +impl DescriptorPoller { + /// Poll cadence — the old inline throttle. With the consumer's two-strikes debounce on top, a + /// real "Use HDR" flip or mode-set is acted on within ~2 samples (≈ ½ s). + const INTERVAL: Duration = Duration::from_millis(250); + /// A sample slower than this means something is sitting on the display-config lock (topology + /// churn / display-poller software) — the disturbance class behind periodic virtual-display + /// stream hitches. Logged (rate-limited) so an affected host self-diagnoses. + const SLOW: Duration = Duration::from_millis(50); + + pub(super) fn spawn(target_id: u32, initial: DisplayDescriptor) -> Self { + let snap = Arc::new(Mutex::new((initial, 0u64))); + let stop = Arc::new(AtomicBool::new(false)); + let (snap_t, stop_t) = (snap.clone(), stop.clone()); + let thread = std::thread::Builder::new() + .name("pf-idd-desc-poll".into()) + .spawn(move || { + let mut last = initial; + let mut seq = 0u64; + let mut last_slow_log: Option = None; + while !stop_t.load(Ordering::Relaxed) { + let t = Instant::now(); + // SAFETY: both are read-only CCD queries taking only a copy of the plain `u32` + // target id (see their own SAFETY docs); nothing is borrowed across the calls. + let (hdr, res) = unsafe { + ( + crate::win_display::advanced_color_enabled(target_id), + crate::win_display::active_resolution(target_id), + ) + }; + let took = t.elapsed(); + if took >= Self::SLOW + && last_slow_log.is_none_or(|t| t.elapsed() >= Duration::from_secs(10)) + { + last_slow_log = Some(Instant::now()); + tracing::warn!( + took_ms = took.as_millis() as u64, + target_id, + "slow display-descriptor poll — something is holding the Windows \ + display-config lock (topology churn / display-poller software); on \ + a host with periodic stream hitches, correlate this cadence" + ); + } + if hdr.is_some() || res.is_some() { + if let Some(hdr) = hdr { + last.hdr = hdr; + } + if let Some((width, height)) = res { + last.width = width; + last.height = height; + } + seq += 1; + *snap_t.lock().unwrap() = (last, seq); + } + // Park (not sleep) so `drop` wakes the thread immediately via `unpark`. + std::thread::park_timeout(Self::INTERVAL); + } + }) + .map_err(|e| { + // Degraded, not fatal: the session streams, it just never follows a mid-session + // HDR flip / mode-set (seq stays 0 → the consumer sees no changes). + tracing::warn!(error = %e, "IDD push: descriptor-poller thread failed to spawn — mid-session HDR/mode changes won't be followed"); + }) + .ok(); + Self { snap, stop, thread } + } + + /// The latest sample (lock held only for the copy — the poller writes at 4 Hz). + pub(super) fn snapshot(&self) -> (DisplayDescriptor, u64) { + *self.snap.lock().unwrap() + } +} + +impl Drop for DescriptorPoller { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(t) = self.thread.take() { + t.thread().unpark(); + let _ = t.join(); + } + } +} diff --git a/crates/punktfunk-host/src/capture/windows/idd_push/stall.rs b/crates/punktfunk-host/src/capture/windows/idd_push/stall.rs new file mode 100644 index 00000000..42e16fd6 --- /dev/null +++ b/crates/punktfunk-host/src/capture/windows/idd_push/stall.rs @@ -0,0 +1,82 @@ +//! Capture-stall detection (plan §W4, carved out of the IDD-push capturer): flags multi-hundred-ms +//! holes in DWM frame delivery that open while the desktop was actively composing. + +// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program). +#![deny(clippy::undocumented_unsafe_blocks)] + +use super::*; + +/// A detected capture stall: a multi-hundred-ms hole in DWM's frame delivery that opened while the +/// desktop was actively composing right beforehand (see [`StallWatch`]). +pub(super) struct Stall { + /// How long the hole lasted (last fresh frame → the frame that ended it). + pub(super) gap: Duration, + /// `Some(mean period)` when this stall completes a metronomic cycle (see + /// [`crate::metronome::Metronome`]). + pub(super) metronomic: Option, +} + +/// Capture-stall watch — the "sole virtual display" stutter diagnostic (field reports: Exclusive +/// topology = periodic double-jolt, Extend = smooth, i.e. the disturbance lives in the display/present +/// path BELOW capture and only while no physical output is active). +/// +/// On a damage-driven capture an idle desktop legitimately goes quiet (no damage → no frames), so a +/// gap only counts as a stall when the [`Self::RECENT`] frames before it all arrived within +/// [`Self::ACTIVE_SPAN`] — sustained ≥ ~20 fps flow (a game or video), not a blinking caret or a +/// mouse twitch. Each stall feeds a [`crate::metronome::Metronome`], so periodic stalls self-diagnose +/// in the log WITHOUT needing any client keyframe request — discriminating "DWM stopped composing" +/// from encode/network causes that the recovery-cadence detector covers. Pure logic — unit-tested +/// below; the caller does the logging. +pub(super) struct StallWatch { + /// The last [`Self::RECENT`] fresh-frame instants (pre-gap history for the activity gate). + recent: std::collections::VecDeque, + cadence: crate::metronome::Metronome, +} + +impl StallWatch { + /// Frames of pre-gap history that must be tight for flow to count as active. Stalls are thus + /// naturally spaced ≥ RECENT frame times apart — no extra log rate limit needed. + const RECENT: usize = 8; + /// The RECENT pre-gap frames must all fit in this span (8 frames in 400 ms ≈ ≥ 20 fps flow — + /// loose enough for a 30 fps-capped game, tight enough to reject idle-desktop damage). + const ACTIVE_SPAN: Duration = Duration::from_millis(400); + /// The smallest hole that counts as a stall (~9 missed frames at 60 Hz) — well below the + /// reported 300–700 ms freezes, above encode/present jitter. + const STALL_MIN: Duration = Duration::from_millis(150); + + pub(super) fn new() -> Self { + Self { + recent: std::collections::VecDeque::with_capacity(Self::RECENT + 1), + cadence: crate::metronome::Metronome::new(), + } + } + + /// Forget the flow history (a ring recreate's gap is self-inflicted, not a DWM stall — without + /// the reset the first post-recreate frame would read as one). + pub(super) fn reset(&mut self) { + self.recent.clear(); + } + + /// Record a fresh driver frame at `now`; `Some` exactly when it ended a stall. + pub(super) fn note_fresh(&mut self, now: Instant) -> Option { + let was_active = self.recent.len() == Self::RECENT + && self + .recent + .back() + .zip(self.recent.front()) + .is_some_and(|(b, f)| b.duration_since(*f) <= Self::ACTIVE_SPAN); + let gap = self.recent.back().map(|last| now.duration_since(*last)); + self.recent.push_back(now); + if self.recent.len() > Self::RECENT { + self.recent.pop_front(); + } + let gap = gap?; + if !was_active || gap < Self::STALL_MIN { + return None; + } + Some(Stall { + gap, + metronomic: self.cadence.note(now), + }) + } +}