diff --git a/crates/pf-capture/Cargo.toml b/crates/pf-capture/Cargo.toml index c2ea5cba..0a08f2ba 100644 --- a/crates/pf-capture/Cargo.toml +++ b/crates/pf-capture/Cargo.toml @@ -53,6 +53,7 @@ windows = { version = "0.62", features = [ "Win32_System_LibraryLoader", "Win32_System_StationsAndDesktops", "Win32_System_Memory", + "Win32_System_Performance", "Win32_System_Threading", "Win32_UI_HiDpi", "Win32_UI_Input_KeyboardAndMouse", diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index db39c2ac..a573df2f 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -54,6 +54,8 @@ use windows::Win32::Security::Authorization::{ ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, }; use windows::Win32::Security::{PSECURITY_DESCRIPTOR, SECURITY_ATTRIBUTES}; +use windows::Win32::System::Performance::{QueryPerformanceCounter, QueryPerformanceFrequency}; + use windows::Win32::System::Memory::{ CreateFileMappingW, MapViewOfFile, UnmapViewOfFile, FILE_MAP_ALL_ACCESS, MEMORY_MAPPED_VIEW_ADDRESS, PAGE_READWRITE, @@ -324,7 +326,7 @@ mod descriptor; mod stall; use channel::ChannelBroker; use descriptor::{DescriptorPoller, DisplayDescriptor}; -use stall::StallWatch; +use stall::{StallEvidence, StallWatch}; pub struct IddPushCapturer { device: ID3D11Device, @@ -482,6 +484,11 @@ pub struct IddPushCapturer { /// during active flow and warns when they turn metronomic — the sole-virtual-display /// periodic-stutter diagnostic. stall_watch: StallWatch, + /// v2 driver-telemetry trackers feeding [`stall::StallEvidence`]: the header's + /// `offered_total` as of the last fresh frame, and the stalest the driver's drain heartbeat + /// ever read (µs) while the host starved since then. Rolled at every fresh frame. + offered_at_fresh: u64, + max_hb_age_us: u64, /// Host-owned ROTATING output ring NVENC encodes (one YUV texture per slot). Rotating it per frame /// is the precondition for pipelining the encode loop: while NVENC encodes frame N's texture on the /// ASIC, frame N+1's convert writes a DIFFERENT texture — the two overlap. Format = `out_format()`: @@ -532,6 +539,47 @@ impl IddPushCapturer { } } + /// Age of a driver-stamped QPC value in microseconds (QPC is system-wide, so cross-process + /// comparison is sound); 0 when the stamp reads ahead of us (a benign race with the writer). + fn qpc_age_us(stamp: u64) -> u64 { + static FREQ: std::sync::OnceLock = std::sync::OnceLock::new(); + let freq = *FREQ.get_or_init(|| { + let mut f = 0i64; + // SAFETY: plain FFI; `f` is a valid local out-param. The frequency is fixed at boot and + // cannot fail on any OS we run on; 0 would only mean the call failed — guarded below. + let _ = unsafe { QueryPerformanceFrequency(&mut f) }; + f.max(0) as u64 + }); + if freq == 0 { + return 0; + } + let mut now = 0i64; + // SAFETY: plain FFI; `now` is a valid local out-param. + if unsafe { QueryPerformanceCounter(&mut now) }.is_err() { + return 0; + } + (now as u64).saturating_sub(stamp).saturating_mul(1_000_000) / freq + } + + /// The header's v2 telemetry tail — `(drain_heartbeat_qpc, offered_total)`; `None` until a + /// telemetry-capable driver writes its first heartbeat (the host always creates the v2 layout, + /// so a zero heartbeat means the attached driver predates it). + #[inline] + fn telemetry(&self) -> Option<(u64, u64)> { + // SAFETY: like `latest` — the header stays mapped for the capturer's lifetime, both fields + // are 8-aligned `u64`s within the v2 layout the host itself created, and Relaxed suffices + // for best-effort diagnostics (the same contract the driver writes them under). + let (hb, offered) = unsafe { + ( + (*(std::ptr::addr_of!((*self.header).drain_heartbeat_qpc) as *const AtomicU64)) + .load(Ordering::Relaxed), + (*(std::ptr::addr_of!((*self.header).offered_total) as *const AtomicU64)) + .load(Ordering::Relaxed), + ) + }; + (hb != 0).then_some((hb, offered)) + } + /// Log the driver's status once it first reports (the only driver-visibility channel we have). fn log_driver_status_once(&mut self) { if self.status_logged { @@ -1380,6 +1428,14 @@ impl IddPushCapturer { ); } } + // Stall-attribution evidence (v2 telemetry): record the STALEST the driver's drain + // heartbeat ever reads between fresh frames. A heartbeat that goes quiet for the hole + // convicts our worker (starved/dead WUDFHost); one that stays fresh through it acquits the + // driver and indicts the compose/present path. Two Relaxed loads + a QPC read per consume + // tick; rolled at every fresh frame below. + if let Some((hb, _)) = self.telemetry() { + self.max_hb_age_us = self.max_hb_age_us.max(Self::qpc_age_us(hb)); + } let latest = self.latest(); // `latest` is the proto publish token `(generation << 40) | (seq << 8) | slot`. Reject any publish // whose generation isn't our CURRENT ring (a stale old-ring publish racing a recreate, or the 0 @@ -1529,10 +1585,29 @@ impl IddPushCapturer { // the running correlated/total tally — lives on `StallWatch` (sweep Phase 5.4). It was // ~65 lines of log prose inside `try_consume`, which is the hot loop, and its two // counters were capturer fields that nothing else touched. - self.stall_watch.report(&stall, now); + let evidence = StallEvidence { + // A publisher re-attach restarts `offered_total` near zero; a ring recreate resets + // the stall watch before that can matter, but guard the delta anyway (a restarted + // counter reads as "frames offered since the restart", never as a u64 underflow). + offered_delta: self.telemetry().map(|(_, offered)| { + if offered >= self.offered_at_fresh { + offered - self.offered_at_fresh + } else { + offered + } + }), + max_heartbeat_age_ms: self.max_hb_age_us / 1_000, + }; + self.stall_watch.report(&stall, now, &evidence); } if !regen { - self.last_fresh = now; // feeds the driver-death watch + // A fresh driver frame: feed the driver-death watch and roll the stall-evidence + // trackers (a regen re-encodes OLD content — it is not evidence of driver progress). + self.last_fresh = now; + if let Some((_, offered)) = self.telemetry() { + self.offered_at_fresh = offered; + } + self.max_hb_age_us = 0; } // Build the frame. For PyroWave the encode input is the Y plane // (`texture`) + the CbCr plane & fence in `pyro`; signal the shared fence @@ -2037,4 +2112,35 @@ mod tests { "detection re-armed after the reset" ); } + + /// [`stall::attribute`]'s verdict table — the Branch-1/Branch-2 fork, per evidence shape. + #[test] + fn stall_attribution_verdicts() { + use super::stall::{attribute, StallVerdict}; + let verdict = |gap_ms: u64, offered: Option, hb_age_ms: u64| { + attribute( + Duration::from_millis(gap_ms), + &StallEvidence { + offered_delta: offered, + max_heartbeat_age_ms: hb_age_ms, + }, + ) + }; + // Pre-telemetry driver: no verdict, whatever the heartbeat tracker read. + assert_eq!(verdict(300, None, 500), StallVerdict::NoTelemetry); + // Heartbeat silent for most of the hole → the worker starved, wherever the frames were. + assert_eq!(verdict(600, Some(0), 400), StallVerdict::WorkerStalled); + assert_eq!(verdict(600, Some(50), 300), StallVerdict::WorkerStalled); + // A scheduled worker heartbeats every ≤16 ms — 200 ms of silence on a 300 ms gap is under + // the max(gap/2, 250 ms) bar, so the verdict falls through to the offered-frames fork. + assert_eq!(verdict(300, Some(1), 200), StallVerdict::ComposeSilence); + // The stall-ending frame (+ a small resume burst) does not acquit DWM... + assert_eq!(verdict(300, Some(3), 20), StallVerdict::ComposeSilence); + // ...but sustained composition through the hole does: the frames existed, WE lost them. + assert_eq!(verdict(300, Some(8), 20), StallVerdict::DeliveryLeg); + assert_eq!(verdict(2_000, Some(120), 30), StallVerdict::DeliveryLeg); + // Long holes scale the worker-stalled bar: 900 ms of silence on a 3 s gap is not half. + assert_eq!(verdict(3_000, Some(2), 900), StallVerdict::ComposeSilence); + assert_eq!(verdict(3_000, Some(2), 1_600), StallVerdict::WorkerStalled); + } } diff --git a/crates/pf-capture/src/windows/idd_push/open.rs b/crates/pf-capture/src/windows/idd_push/open.rs index 6103bd44..8b49356a 100644 --- a/crates/pf-capture/src/windows/idd_push/open.rs +++ b/crates/pf-capture/src/windows/idd_push/open.rs @@ -633,6 +633,8 @@ impl IddPushCapturer { last_liveness: Instant::now(), last_kick: Instant::now(), stall_watch: StallWatch::new(), + offered_at_fresh: 0, + max_hb_age_us: 0, out_ring: Vec::new(), out_idx: 0, video_conv: None, diff --git a/crates/pf-capture/src/windows/idd_push/stall.rs b/crates/pf-capture/src/windows/idd_push/stall.rs index 3b054c95..054c7c27 100644 --- a/crates/pf-capture/src/windows/idd_push/stall.rs +++ b/crates/pf-capture/src/windows/idd_push/stall.rs @@ -16,6 +16,69 @@ pub(super) struct Stall { pub(super) metronomic: Option, } +/// Driver-telemetry evidence for one stall window (the v2 header tail — see +/// `pf_driver_proto::frame::SharedHeader`), sampled by the capturer between the last pre-gap +/// frame and the frame that ended the stall. +pub(super) struct StallEvidence { + /// Surfaces the driver OFFERED to the ring publisher during the window (delta of + /// `offered_total`); `None` = pre-telemetry driver (it never wrote the tail). + pub(super) offered_delta: Option, + /// The STALEST the driver's drain heartbeat ever read while the host starved (max of + /// now − heartbeat over the window), in milliseconds. + pub(super) max_heartbeat_age_ms: u64, +} + +/// The attribution a stall's evidence supports — the Branch-1/Branch-2 fork of the +/// vdisplay-disturbance-immunity program, computed per stall instead of argued per field report. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum StallVerdict { + /// Pre-telemetry driver: no verdict, the log says only what the host observed. + NoTelemetry, + /// The drain worker's heartbeat went silent for a large share of the hole — the swap-chain + /// thread starved (CPU/MMCSS) or the WUDFHost died. Ours, host/driver side. + WorkerStalled, + /// The worker drained E_PENDING throughout: DWM composed NOTHING for the hole. The + /// disturbance is below capture (adapter servicing / DDC lock / present clock) — the + /// micro-probe + ETW phases discriminate further. + ComposeSilence, + /// DWM composed frames all through the hole and the driver offered them, but none became a + /// consumable ring slot — OUR publish/ring/consume leg lost them. Fully killable. + DeliveryLeg, +} + +impl std::fmt::Display for StallVerdict { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::NoTelemetry => "pre-telemetry driver (no verdict)", + Self::WorkerStalled => "driver-worker-stalled (heartbeat silent) — host CPU/MMCSS or a dead WUDFHost, NOT the display path", + Self::ComposeSilence => "compose-silence (driver drained E_PENDING) — DWM composed nothing; the disturbance is below capture", + Self::DeliveryLeg => "delivery-leg (frames were composed + offered but never consumable) — OUR ring/publish/consume leg", + }) + } +} + +/// Turn one stall window's evidence into a [`StallVerdict`]. Pure — unit-tested beside the +/// [`StallWatch`] tests. +/// +/// Thresholds: a heartbeat that was ever `max(gap/2, 250 ms)` stale convicts the worker (its +/// scheduled cadence is ≤16 ms, so 250 ms of silence is real starvation, and gap/2 scales the bar +/// for long holes); `offered_delta ≥ 8` acquits DWM (8 composed frames during the "hole" mirrors +/// [`StallWatch::RECENT`]'s definition of sustained flow — the stall-ending frame plus a resume +/// burst stay well under it). +pub(super) fn attribute(gap: Duration, evidence: &StallEvidence) -> StallVerdict { + let Some(offered) = evidence.offered_delta else { + return StallVerdict::NoTelemetry; + }; + let gap_ms = gap.as_millis() as u64; + if evidence.max_heartbeat_age_ms >= (gap_ms / 2).max(250) { + StallVerdict::WorkerStalled + } else if offered >= 8 { + StallVerdict::DeliveryLeg + } else { + StallVerdict::ComposeSilence + } +} + /// 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). @@ -35,6 +98,10 @@ pub(super) struct StallWatch { /// [`Self::report`] uses. They were capturer fields that nothing outside the report touched. seen: u32, with_os_events: u32, + /// Running per-verdict tally (worker-stalled / compose-silence / delivery-leg / no-telemetry), + /// in [`StallVerdict`] order — the metronomic WARN prints it, so one pasted line attributes the + /// whole session's beat, not just the stall that tripped the metronome. + verdicts: [u32; 4], } impl StallWatch { @@ -54,6 +121,7 @@ impl StallWatch { cadence: pf_frame::metronome::Metronome::new(), seen: 0, with_os_events: 0, + verdicts: [0; 4], } } @@ -92,7 +160,10 @@ impl StallWatch { /// a running tally, all of it about stalls and none of it about consuming a frame, in a function /// that runs per frame. `now` is the instant of the frame that ENDED the stall — the same one /// passed to [`Self::note_fresh`] — which is what bounds the event-correlation window. - pub(super) fn report(&mut self, stall: &Stall, now: Instant) { + /// `evidence` is the capturer's driver-telemetry sample for the window; its [`attribute`] + /// verdict rides every stall line, so a field log names which leg lost the frames instead of + /// leaving it to hypothesis. + pub(super) fn report(&mut self, stall: &Stall, now: Instant, evidence: &StallEvidence) { // OS display events inside the gap (plus a lead-in margin: the event that CAUSED the // hole lands just before DWM stops delivering) — the attribution that turns "DWM // stopped composing" into "…because Windows re-enumerated SAMSUNG on HDMI". @@ -105,14 +176,24 @@ impl StallWatch { if !events.is_empty() { self.with_os_events = self.with_os_events.saturating_add(1); } + let verdict = attribute(stall.gap, evidence); + self.verdicts[match verdict { + StallVerdict::NoTelemetry => 0, + StallVerdict::WorkerStalled => 1, + StallVerdict::ComposeSilence => 2, + StallVerdict::DeliveryLeg => 3, + }] += 1; // debug (not warn): a single hole also happens when content legitimately pauses; // the reportable signal is the metronomic cycle below. Mounjay-class triage runs // at debug level, and the web-console debug ring captures these. tracing::debug!( gap_ms = stall.gap.as_millis() as u64, os_display_events = %pf_win_display::display_events::summarize(&events), - "IDD-push capture stall — the desktop was composing at speed, then DWM \ - delivered no frame for the gap; the present path stalled below capture" + verdict = %verdict, + offered_during_gap = evidence.offered_delta, + max_heartbeat_age_ms = evidence.max_heartbeat_age_ms, + "IDD-push capture stall — the desktop was composing at speed, then the ring \ + delivered no frame for the gap; the verdict names the leg that lost them" ); if let Some(period) = stall.metronomic { let suspects = pf_win_display::display_events::connected_inactive_physicals(); @@ -122,6 +203,11 @@ impl StallWatch { suspects.join(", ") }; let correlated = format!("{}/{}", self.with_os_events, self.seen); + // The session's attribution in one token: which leg the evidence convicted, per stall. + let verdict_tally = format!( + "worker-stalled {}, compose-silence {}, delivery-leg {}, no-telemetry {}", + self.verdicts[1], self.verdicts[2], self.verdicts[3], self.verdicts[0] + ); // Half-or-more of the stalls carrying a coinciding OS event = the reaction // cascade is OS-visible; otherwise the disturbance never surfaces above the // driver. Different classes, different cures — say which one this box has. @@ -130,6 +216,7 @@ impl StallWatch { period_s = format!("{:.2}", period.as_secs_f64()), os_correlated = correlated, connected_inactive = %suspects, + verdicts = %verdict_tally, "capture stalls are METRONOMIC and coincide with Windows monitor \ hot-plug/re-enumeration events — a connected display (or its \ cable/switch/AVR) re-probes the link on a timer and Windows re-reacts \ @@ -145,6 +232,7 @@ impl StallWatch { period_s = format!("{:.2}", period.as_secs_f64()), os_correlated = correlated, connected_inactive = %suspects, + verdicts = %verdict_tally, "capture stalls are METRONOMIC with NO coinciding OS display event — \ the disturbance is BELOW Windows: the GPU driver servicing a \ connected-but-asleep sink (standby HPD/DDC/link probing), \ diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index f4c9b559..23883c90 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -490,8 +490,17 @@ pub mod frame { /// Header magic (`"PFVD"` LE). The host stamps it LAST (after the ring textures exist) so the driver /// only attaches to a fully-published ring. pub const MAGIC: u32 = 0x4456_4650; - /// Frame-plane version (independent bump of the header layout). - pub const VERSION: u32 = 1; + /// Frame-plane version (independent bump of the header layout). v2 appended the stall-attribution + /// telemetry tail (`drain_heartbeat_qpc`/`last_acquire_qpc`/`offered_total`); see + /// [`VERSION_TELEMETRY`] for the compatibility contract. + pub const VERSION: u32 = 2; + /// The header version that grew the telemetry tail. Compatibility is gated on the HOST-stamped + /// `version` field, not on mapping sizes: a v2 driver writes the tail only when + /// `version >= VERSION_TELEMETRY` (a v1 host created a 64-byte layout — never write past it), + /// and a v2 host reads `drain_heartbeat_qpc == 0` as "pre-telemetry driver, no verdict" (the + /// same zero-means-absent convention as [`OPENED_DETAIL_LIVE`]). Neither side rejects the + /// other's version — the tail is diagnostics, not frame-plane semantics. + pub const VERSION_TELEMETRY: u32 = 2; /// Ring slots. Headroom so the driver's 0 ms-timeout publish always finds a free slot while the host /// holds one across the convert/copy + the pipelined encode. MUST be identical on both sides — it is, /// because both read this one constant. @@ -587,6 +596,22 @@ pub mod frame { /// token blocks file writes, so this header is how the driver reports state). pub driver_status: u32, pub driver_status_detail: u32, + /// v2 telemetry tail (stall attribution, Phase A.1 of the vdisplay-disturbance-immunity + /// program): driver-written on every drain-loop pass, host-read when a capture stall ends. + /// All three are best-effort Relaxed atomic stores (the `driver_status` visibility + /// contract); `0` means a pre-v2 driver never wrote them. QPC of the swap-chain worker's + /// most recent drain-loop iteration — E_PENDING passes included, so a fresh heartbeat over + /// a stale [`Self::last_acquire_qpc`] reads "the worker is running and DWM is composing + /// nothing", while a stale heartbeat reads "our worker starved". + pub drain_heartbeat_qpc: u64, + /// QPC of the most recent SUCCESSFUL swap-chain acquire — the last instant DWM actually + /// composed this display. The Branch-1/Branch-2 fork in one field. + pub last_acquire_qpc: u64, + /// Wrapping count of surfaces offered to the ring publisher — the full-width sibling of + /// the packed 15-bit [`OPENED_DETAIL_LIVE`] counter (which saturates and cannot be + /// delta'd over a stall window). The host snapshots it per consumed frame; the delta + /// across a stall says whether frames existed that never reached the ring. + pub offered_total: u64, } /// Why the driver's publisher must NOT attach a delivered channel to its monitor's ring — the @@ -663,7 +688,7 @@ pub mod frame { const _: () = { use core::mem::{offset_of, size_of}; - assert!(size_of::() == 64); + assert!(size_of::() == 88); assert!(offset_of!(SharedHeader, magic) == 0); assert!(offset_of!(SharedHeader, version) == 4); assert!(offset_of!(SharedHeader, generation) == 8); @@ -678,6 +703,9 @@ pub mod frame { assert!(offset_of!(SharedHeader, driver_render_luid_high) == 52); assert!(offset_of!(SharedHeader, driver_status) == 56); assert!(offset_of!(SharedHeader, driver_status_detail) == 60); + assert!(offset_of!(SharedHeader, drain_heartbeat_qpc) == 64); + assert!(offset_of!(SharedHeader, last_acquire_qpc) == 72); + assert!(offset_of!(SharedHeader, offered_total) == 80); }; } @@ -1426,14 +1454,15 @@ mod tests { } #[test] - fn shared_header_is_pod_and_64_bytes() { + fn shared_header_is_pod_and_88_bytes() { let mut h = frame::SharedHeader::zeroed(); h.magic = frame::MAGIC; h.width = 5120; h.height = 1440; h.target_id = 262; + h.drain_heartbeat_qpc = 0x1234_5678_9abc_def0; let bytes = bytemuck::bytes_of(&h); - assert_eq!(bytes.len(), 64); + assert_eq!(bytes.len(), 88); let back: frame::SharedHeader = *bytemuck::from_bytes(bytes); assert_eq!(back.magic, frame::MAGIC); assert_eq!(back.width, 5120); @@ -1441,6 +1470,11 @@ mod tests { // v3: the monitor binding occupies the old `_pad` slot at offset 28 — byte-compatible (a v2 // host left it zero there). assert_eq!(bytes[28..32], 262u32.to_le_bytes()); + // Header v2: the telemetry tail is appended — the first 64 bytes ARE the v1 layout, so a + // pre-telemetry driver reading its 64-byte view sees exactly what it always saw. + assert_eq!(bytes[64..72], 0x1234_5678_9abc_def0u64.to_le_bytes()); + assert_eq!(back.last_acquire_qpc, 0); + assert_eq!(back.offered_total, 0); } #[test] diff --git a/packaging/windows/drivers/pf-vdisplay/Cargo.toml b/packaging/windows/drivers/pf-vdisplay/Cargo.toml index 26d98a69..884d44bb 100644 --- a/packaging/windows/drivers/pf-vdisplay/Cargo.toml +++ b/packaging/windows/drivers/pf-vdisplay/Cargo.toml @@ -35,6 +35,7 @@ features = [ "Win32_Foundation", "Win32_Security", "Win32_System_Memory", + "Win32_System_Performance", "Win32_System_Threading", "Win32_Graphics_Direct3D", "Win32_Graphics_Direct3D11", diff --git a/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs b/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs index 8ab4eebf..06ae7df8 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/frame_transport.rs @@ -31,7 +31,8 @@ use std::time::Instant; use pf_driver_proto::control::SetFrameChannelRequest; use pf_driver_proto::frame::{ AttachReject, DRV_STATUS_BIND_FAIL, DRV_STATUS_NO_DEVICE1, DRV_STATUS_OPENED, - DRV_STATUS_TEX_FAIL, FrameToken, RING_LEN, SharedHeader, check_attach, pack_opened_detail, + DRV_STATUS_TEX_FAIL, FrameToken, RING_LEN, SharedHeader, VERSION_TELEMETRY, check_attach, + pack_opened_detail, }; use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::Graphics::Direct3D11::{ @@ -43,6 +44,7 @@ use windows::Win32::Graphics::Dxgi::IDXGIKeyedMutex; use windows::Win32::System::Memory::{ FILE_MAP_READ, FILE_MAP_WRITE, MEMORY_MAPPED_VIEW_ADDRESS, MapViewOfFile, UnmapViewOfFile, }; +use windows::Win32::System::Performance::QueryPerformanceCounter; use windows::Win32::System::Threading::SetEvent; use windows::core::Interface; @@ -292,6 +294,13 @@ pub struct FramePublisher { /// "DWM never composed" from "every compose mismatched the ring". offered: u32, mismatch_drops: u32, + /// Whether the HOST created a telemetry-capable (v2, 88-byte) header — stamped `version >= + /// VERSION_TELEMETRY` at attach. Gates every write to the telemetry tail: a v1 host's header + /// is 64 bytes, and the tail fields would land past the layout it reads. + telemetry: bool, + /// Full-width wrapping sibling of `offered` (which packs to 15 saturating bits) — mirrored + /// into `SharedHeader::offered_total` so the host can delta it across a stall window. + offered_total: u64, /// The slot of the most recent successful publish + when it happened — what [`Self::harvest_into`] /// reads when this publisher is superseded. `None` until the first publish. last_published: Option<(u32, Instant)>, @@ -327,20 +336,16 @@ impl FramePublisher { // 1. Map the header from the duplicated section handle (ours from here on). let map = FrameChannel::take(&mut channel.header); - // SAFETY: `map` is the live section handle the host duplicated into this process; mapping - // size_of::() bytes of it (the host created the mapping at >= that size). The null - // `view.Value` is checked below. + // SAFETY: `map` is the live section handle the host duplicated into this process; a byte + // count of 0 maps the WHOLE section — a v1 host created a 64-byte (pre-telemetry) header, + // so requesting size_of::() (the v2 88 bytes) could exceed what that host + // declared, while 0 always fits and always covers the layout the host actually built (the + // `telemetry` version gate keeps our writes inside it). The null `view.Value` is checked below. let view = unsafe { // Read/write only — the host now duplicates the header handle with least access // (`SECTION_MAP_READ | SECTION_MAP_WRITE`), so `FILE_MAP_ALL_ACCESS` would exceed the // granted rights and fail. We read the layout + write status/publish-token fields; RW covers it. - MapViewOfFile( - map, - FILE_MAP_READ | FILE_MAP_WRITE, - 0, - 0, - core::mem::size_of::(), - ) + MapViewOfFile(map, FILE_MAP_READ | FILE_MAP_WRITE, 0, 0, 0) }; if view.Value.is_null() { let err = windows::core::Error::from_win32(); @@ -370,15 +375,16 @@ impl FramePublisher { // into another client's ring. The shared `check_attach` (unit-tested in pf-driver-proto) // owns the precedence: staleness first, binding second. // SAFETY: `header` is the mapped host header; `magic`/`generation` live within it and are read - // atomically (Acquire) to pair with the host's Release publishes; `target_id` is a plain - // in-bounds u32 read, stamped before the magic the Acquire load ordered us behind. - let (magic, header_gen, header_target) = unsafe { + // atomically (Acquire) to pair with the host's Release publishes; `target_id`/`version` are + // plain in-bounds u32 reads, stamped before the magic the Acquire load ordered us behind. + let (magic, header_gen, header_target, header_version) = unsafe { ( (*(core::ptr::addr_of!((*header).magic) as *const AtomicU32)) .load(Ordering::Acquire), (*(core::ptr::addr_of!((*header).generation) as *const AtomicU32)) .load(Ordering::Acquire), (*header).target_id, + (*header).version, ) }; match check_attach( @@ -518,14 +524,46 @@ impl FramePublisher { mismatch_logged: false, offered: 0, mismatch_drops: 0, + telemetry: header_version >= VERSION_TELEMETRY, + offered_total: 0, last_published: None, render_luid_low, render_luid_high, }) } + /// v2 telemetry tail, drain side (stall attribution): stamp the heartbeat on EVERY drain-loop + /// pass — and the last-acquire on a pass that actually acquired a composed frame — so the host + /// can split a capture stall into "our worker starved" (heartbeat went stale) vs "the worker + /// drained E_PENDING the whole hole — DWM composed nothing" (heartbeat fresh, last-acquire + /// stale). Gated on the host's stamped header version (see the `telemetry` field docs); + /// best-effort Relaxed stores, the `driver_status` visibility contract. + pub fn note_drain(&self, acquired: bool) { + if !self.telemetry { + return; + } + let mut qpc = 0i64; + // SAFETY: plain FFI; `qpc` is a valid local out-param. QPC cannot fail on any OS we load on. + if unsafe { QueryPerformanceCounter(&mut qpc) }.is_err() { + return; + } + // SAFETY: `self.header` stays mapped for the publisher's lifetime and the version gate above + // proves the host built the v2 (88-byte) layout; both fields are naturally-aligned u64s + // within it, valid for `AtomicU64` views (the same pattern as `latest_cell`). + unsafe { + (*(core::ptr::addr_of!((*self.header).drain_heartbeat_qpc) as *const AtomicU64)) + .store(qpc as u64, Ordering::Relaxed); + if acquired { + (*(core::ptr::addr_of!((*self.header).last_acquire_qpc) as *const AtomicU64)) + .store(qpc as u64, Ordering::Relaxed); + } + } + } + /// Mirror the live diagnostic counters into the header's detail word (proto - /// `pack_opened_detail`) — read by the host's first-frame timeout to name a no-frames failure. + /// `pack_opened_detail`) — read by the host's first-frame timeout to name a no-frames failure — + /// plus, on a telemetry-capable (v2) header, the full-width `offered_total` the host deltas + /// across a stall window (the packed 15-bit counter saturates, so it can't be delta'd). #[inline] fn write_opened_detail(&self) { // SAFETY: `self.header` stays mapped for the publisher's lifetime (unmapped only in Drop); @@ -534,6 +572,14 @@ impl FramePublisher { (*self.header).driver_status_detail = pack_opened_detail(self.offered, self.mismatch_drops); } + if self.telemetry { + // SAFETY: the version gate proves the host built the v2 (88-byte) layout; + // `offered_total` is a naturally-aligned u64 within it (the `latest_cell` pattern). + unsafe { + (*(core::ptr::addr_of!((*self.header).offered_total) as *const AtomicU64)) + .store(self.offered_total, Ordering::Relaxed); + } + } } #[inline] @@ -624,6 +670,7 @@ impl FramePublisher { // header's detail word — what lets the host's first-frame timeout tell "DWM never composed" // from "every compose mismatched the ring". Written once per call, after the outcome is known. self.offered = self.offered.saturating_add(1); + self.offered_total = self.offered_total.wrapping_add(1); if desc.Format.0 as u32 != self.ring_format || desc.Width != rw || desc.Height != rh { self.mismatch_drops = self.mismatch_drops.saturating_add(1); self.write_opened_detail(); diff --git a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs index ab113241..dba2a84b 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs @@ -485,20 +485,22 @@ pub fn set_cursor_channel( /// genuine teardown, not a flap: the entry was already removed) so the caller drops it, closing the ring /// handles. Replacing an already-stashed publisher (should not happen — one worker exits at a time) /// drops the old one, so it can never accumulate. Returning the publisher in the `Err` makes the -/// `Result` itself `#[must_use]`, so a caller can't silently drop the not-preserved publisher. +/// `Result` itself `#[must_use]`, so a caller can't silently drop the not-preserved publisher; it +/// rides boxed (the struct outgrew clippy's 128-byte `result_large_err` bar with the v2 telemetry +/// fields, and this path runs once per worker exit — the allocation is free). pub fn preserve_publisher( target_id: u32, publisher: crate::frame_transport::FramePublisher, -) -> Result<(), crate::frame_transport::FramePublisher> { +) -> Result<(), Box> { if target_id == 0 { - return Err(publisher); + return Err(Box::new(publisher)); } let mut lock = lock_monitors(); if let Some(m) = lock.iter_mut().find(|m| m.target_id == target_id) { m.preserved_publisher = Some(publisher); Ok(()) } else { - Err(publisher) + Err(Box::new(publisher)) } } diff --git a/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs b/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs index c7ac3516..85b08043 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/swap_chain_processor.rs @@ -408,6 +408,15 @@ impl SwapChainProcessor { ) }; + // v2 telemetry (stall attribution): stamp the drain heartbeat — plus the last-acquire + // on a pass that got a composed frame — into the shared header EVERY pass, E_PENDING + // included (the wait below is ≤16 ms, so the heartbeat cadence bounds how stale it can + // read while this thread is scheduled). What lets the host split a capture stall into + // worker-starved / DWM-composed-nothing / our-delivery-leg. + if let Some(p) = publisher.as_ref() { + p.note_drain(hr_success(hr)); + } + if (hr as u32) == E_PENDING { if !logged_pending { dbglog!(