refactor(host/W4): split the IDD-push capturer's peripheral concerns into submodules
apple / swift (push) Successful in 1m24s
apple / screenshots (push) Successful in 4m12s
windows-host / package (push) Successful in 8m56s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
arch / build-publish (push) Successful in 11m2s
ci / bench (push) Successful in 5m16s
decky / build-publish (push) Successful in 19s
android / android (push) Successful in 17m20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / rust (push) Successful in 17m55s
deb / build-publish (push) Successful in 11m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m36s
docker / deploy-docs (push) Successful in 24s
apple / swift (push) Successful in 1m24s
apple / screenshots (push) Successful in 4m12s
windows-host / package (push) Successful in 8m56s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
arch / build-publish (push) Successful in 11m2s
ci / bench (push) Successful in 5m16s
decky / build-publish (push) Successful in 19s
android / android (push) Successful in 17m20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
ci / rust (push) Successful in 17m55s
deb / build-publish (push) Successful in 11m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m45s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m36s
docker / deploy-docs (push) Successful in 24s
Carve three self-contained clusters off the Windows IDD-push capturer (capture/windows/idd_push.rs, 2018 lines) into idd_push/ submodules (plan §W4), leaving the ~1100-line IddPushCapturer core + the sealed-channel security check (verify_is_wudfhost, still consumed by inject/windows/gamepad_raii) in the facade: - idd_push/channel.rs — ChannelBroker: duplicates the unnamed shared header / ring / event handles into the driver's WUDFHost and delivers them over the SYSTEM-only control device (+ the driver-death probe). - idd_push/descriptor.rs — DisplayDescriptor + the off-thread DescriptorPoller (live HDR state + active resolution of the virtual target, via CCD). - idd_push/stall.rs — Stall + StallWatch: the DWM-composition-hole diagnostic. Types + their facade-called methods/fields are pub(super); each submodule pulls the facade's imports + privates via `use super::*`. Pure move; no behavior change. Windows host clippy (nvenc,amf-qsv, all-targets) + fmt green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<Self> {
|
||||
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<u32>) -> Result<u64> {
|
||||
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<Mutex<(DisplayDescriptor, u64)>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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<Instant> = 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<Duration>,
|
||||
}
|
||||
|
||||
/// 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<Instant>,
|
||||
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<Stall> {
|
||||
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
|
||||
|
||||
@@ -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<Self> {
|
||||
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<u32>) -> Result<u64> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Mutex<(DisplayDescriptor, u64)>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
thread: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
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<Instant> = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Duration>,
|
||||
}
|
||||
|
||||
/// 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<Instant>,
|
||||
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<Stall> {
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user