ccc4b08d45
windows/{win_display,monitor_devnode,display_events}.rs move into
crates/pf-win-display: the CCD/GDI path-activation + mode-set + HDR
advanced-colour + source-rect helpers, the PnP monitor-devnode enable/disable
lever, and the WM_DISPLAYCHANGE / device-arrival watch. The coming pf-capture
crate's IDD-push capturer consumes all three; the host's pf-vdisplay backend
consumes win_display + monitor_devnode. A leaf lets both depend on them as a
PEER instead of the capturer reaching back into the orchestrator (plan §W6).
win_display's one external tie (crate::vdisplay::Mode) becomes the underlying
punktfunk_core::Mode; the cluster is otherwise self-contained (pf-paths for the
state file, serde_json for it, windows). pub(crate) items bump to pub at the
boundary; win_display carries a module-level allow(missing_safety_doc) to keep
the pre-carve behavior (the FFI helpers were pub(crate) unsafe fn with prose
safety docs — the lint only fires once they're pub, and this is an internal
publish=false leaf). The host imports the three modules at its crate root, so
every crate::{win_display,monitor_devnode,display_events}::* path is unchanged.
Verified: Linux clippy -D warnings (leaf empty + host
nvenc,vulkan-encode,pyrowave --all-targets); Windows clippy -D warnings
(pf-win-display --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
338 lines
14 KiB
Rust
338 lines
14 KiB
Rust
//! OS display-event listener — the attribution sensor for the periodic-stutter disturbance class.
|
|
//!
|
|
//! The capture-stall watch (the IDD-push capturer in `pf-capture`) can SAY "DWM stopped composing
|
|
//! on a stable period", but not WHY. Field evidence (Apollo's Stuttering Clinic, Apollo #384,
|
|
//! Tom's HW "stutter from disabled-but-connected monitors") points at a connected-but-idle sink
|
|
//! (standby TV/monitor, active HDMI cable, KVM/AVR) re-probing the link every few seconds; the GPU
|
|
//! driver services each probe below the topology layer, and on some boxes Windows additionally
|
|
//! tears down + re-arrives the monitor's devnode each time. This module timestamps everything
|
|
//! Windows lets user mode see of that reaction so the stall log can name the disturbance instead
|
|
//! of guessing:
|
|
//!
|
|
//! - `WM_DEVICECHANGE` + `RegisterDeviceNotificationW(GUID_DEVINTERFACE_MONITOR)`: monitor device
|
|
//! interface arrival/removal — fires on devnode churn even when the final topology is unchanged
|
|
//! (the "reaction cascade" class `pnp_disable_monitors` suppresses), with the interface path
|
|
//! naming WHICH monitor pulsed.
|
|
//! - `DBT_DEVNODES_CHANGED`: the broadcast catch-all for PnP tree churn (no payload).
|
|
//! - `WM_DISPLAYCHANGE`: an actual mode/topology commit reached the desktop (this one does NOT
|
|
//! fire for a pure probe with no mode delta — its absence is itself a signal).
|
|
//!
|
|
//! A pure driver-internal probe (EDID/DDC read, DP link retrain) emits NONE of these — that
|
|
//! absence, paired with metronomic stalls, is what discriminates "driver services a standby sink
|
|
//! below the OS" from "Windows re-enumerates the monitor". Kernel-precise attribution (DxgKrnl ETW
|
|
//! event 272 `DxgkCbIndicateChildStatus`) is a possible follow-up; this listener is the cheap
|
|
//! always-on first stage.
|
|
//!
|
|
//! The listener thread also keeps a cached CCD target inventory (refreshed on each event + a slow
|
|
//! timer), so the capture thread can name connected-but-inactive external displays — the prime
|
|
//! suspects — without ever touching the CCD lock itself (the display-config lock is exactly what
|
|
//! stalls during churn; the capture thread must never block on it).
|
|
|
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
|
|
|
use std::collections::VecDeque;
|
|
use std::sync::{Mutex, Once, OnceLock};
|
|
use std::time::Instant;
|
|
|
|
use windows::core::PCWSTR;
|
|
use windows::Win32::Devices::Display::GUID_DEVINTERFACE_MONITOR;
|
|
use windows::Win32::Foundation::{HANDLE, HWND, LPARAM, LRESULT, WPARAM};
|
|
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
|
use windows::Win32::UI::WindowsAndMessaging::{
|
|
CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW, RegisterClassW,
|
|
RegisterDeviceNotificationW, SetTimer, DBT_DEVICEARRIVAL, DBT_DEVICEREMOVECOMPLETE,
|
|
DBT_DEVNODES_CHANGED, DBT_DEVTYP_DEVICEINTERFACE, DEVICE_NOTIFY_WINDOW_HANDLE,
|
|
DEV_BROADCAST_DEVICEINTERFACE_W, DEV_BROADCAST_HDR, MSG, WINDOW_EX_STYLE, WM_DEVICECHANGE,
|
|
WM_DISPLAYCHANGE, WM_TIMER, WNDCLASSW, WS_OVERLAPPED,
|
|
};
|
|
|
|
/// One OS-visible display event, timestamped at receipt.
|
|
#[derive(Clone)]
|
|
pub struct DisplayEvent {
|
|
pub at: Instant,
|
|
pub kind: DisplayEventKind,
|
|
/// Monitor device instance id for arrival/removal (e.g. `DISPLAY\GSM83CD\...`), else `None`.
|
|
pub detail: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
pub enum DisplayEventKind {
|
|
/// A monitor device interface ARRIVED — a sink (re)connected as Windows sees it.
|
|
MonitorArrival,
|
|
/// A monitor device interface was REMOVED — a sink dropped as Windows sees it.
|
|
MonitorRemoval,
|
|
/// PnP device-tree churn (broadcast, no payload) — re-enumeration passed through.
|
|
DevNodesChanged,
|
|
/// A mode/topology commit reached the desktop (resolution/layout actually changed).
|
|
DisplayChange,
|
|
}
|
|
|
|
impl DisplayEventKind {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::MonitorArrival => "monitor-arrival",
|
|
Self::MonitorRemoval => "monitor-removal",
|
|
Self::DevNodesChanged => "devnodes-changed",
|
|
Self::DisplayChange => "display-change",
|
|
}
|
|
}
|
|
}
|
|
|
|
struct State {
|
|
/// Recent events, oldest-first, capped at [`RING_CAP`].
|
|
events: VecDeque<DisplayEvent>,
|
|
/// Cached CCD target inventory (see module docs for why the cache exists).
|
|
inventory: Vec<crate::win_display::TargetInventory>,
|
|
}
|
|
|
|
/// Ring depth: at the observed worst case (a probe cycle every ~2 s, ≤4 events per cycle) this
|
|
/// holds well over a minute of history — the stall correlator only ever asks about the last gap.
|
|
const RING_CAP: usize = 128;
|
|
|
|
fn state() -> &'static Mutex<State> {
|
|
static STATE: OnceLock<Mutex<State>> = OnceLock::new();
|
|
STATE.get_or_init(|| {
|
|
Mutex::new(State {
|
|
events: VecDeque::with_capacity(RING_CAP),
|
|
inventory: Vec::new(),
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Start the listener thread (idempotent). Degraded-not-fatal: if window/registration creation
|
|
/// fails the ring just stays empty — the stall log then reports "listener unavailable" naturally
|
|
/// via empty summaries, and streaming is unaffected.
|
|
pub fn spawn_once() {
|
|
static ONCE: Once = Once::new();
|
|
ONCE.call_once(|| {
|
|
let spawned = std::thread::Builder::new()
|
|
.name("pf-display-events".into())
|
|
.spawn(pump);
|
|
if let Err(e) = spawned {
|
|
tracing::warn!(
|
|
error = %e,
|
|
"display-event listener thread failed to spawn — stall logs won't carry OS event attribution"
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Events with `from <= at <= to`, oldest-first.
|
|
pub fn events_between(from: Instant, to: Instant) -> Vec<DisplayEvent> {
|
|
let st = state().lock().unwrap();
|
|
st.events
|
|
.iter()
|
|
.filter(|e| e.at >= from && e.at <= to)
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
/// Compact one-line summary for log fields: `"monitor-removal x2 (DISPLAY\GSM83CD\...),
|
|
/// devnodes-changed x1"`; `"none"` when empty.
|
|
pub fn summarize(events: &[DisplayEvent]) -> String {
|
|
if events.is_empty() {
|
|
return "none".into();
|
|
}
|
|
let mut out: Vec<String> = Vec::new();
|
|
for kind in [
|
|
DisplayEventKind::MonitorArrival,
|
|
DisplayEventKind::MonitorRemoval,
|
|
DisplayEventKind::DevNodesChanged,
|
|
DisplayEventKind::DisplayChange,
|
|
] {
|
|
let hits: Vec<&DisplayEvent> = events.iter().filter(|e| e.kind == kind).collect();
|
|
if hits.is_empty() {
|
|
continue;
|
|
}
|
|
let detail = hits
|
|
.iter()
|
|
.rev()
|
|
.find_map(|e| e.detail.as_deref())
|
|
.map(|d| format!(" ({d})"))
|
|
.unwrap_or_default();
|
|
out.push(format!("{} x{}{}", kind.label(), hits.len(), detail));
|
|
}
|
|
out.join(", ")
|
|
}
|
|
|
|
/// The prime suspects for link-probe disturbances, from the cached inventory: external physical
|
|
/// displays that are CONNECTED but not part of the desktop (standby TV / input-switched monitor).
|
|
/// Rendered as `"<friendly> (<connector>)"`. Never blocks on the CCD lock.
|
|
pub fn connected_inactive_externals() -> Vec<String> {
|
|
let st = state().lock().unwrap();
|
|
st.inventory
|
|
.iter()
|
|
.filter(|t| t.external_physical && !t.active)
|
|
.map(|t| format!("{} ({})", t.friendly, t.tech))
|
|
.collect()
|
|
}
|
|
|
|
fn push_event(kind: DisplayEventKind, detail: Option<String>) {
|
|
let mut st = state().lock().unwrap();
|
|
if st.events.len() >= RING_CAP {
|
|
st.events.pop_front();
|
|
}
|
|
st.events.push_back(DisplayEvent {
|
|
at: Instant::now(),
|
|
kind,
|
|
detail,
|
|
});
|
|
}
|
|
|
|
fn refresh_inventory() {
|
|
// SAFETY: `target_inventory` only runs read-only CCD queries over locals (see its SAFETY doc);
|
|
// called from the listener thread, never the capture thread.
|
|
let inv = unsafe { crate::win_display::target_inventory() };
|
|
if !inv.is_empty() {
|
|
state().lock().unwrap().inventory = inv;
|
|
}
|
|
}
|
|
|
|
/// Inventory refresh timer: 15 s keeps the "connected-but-inactive" suspect list fresh enough for
|
|
/// a warn that rate-limits to 30 s, without measurable CCD traffic.
|
|
const INVENTORY_TIMER_MS: u32 = 15_000;
|
|
|
|
/// `DBT_DEVNODES_CHANGED` arrives as `wParam` on `WM_DEVICECHANGE` without a registration; the
|
|
/// interface arrivals/removals need the `RegisterDeviceNotificationW` below.
|
|
unsafe extern "system" fn wnd_proc(
|
|
hwnd: HWND,
|
|
msg: u32,
|
|
wparam: WPARAM,
|
|
lparam: LPARAM,
|
|
) -> LRESULT {
|
|
match msg {
|
|
WM_DISPLAYCHANGE => {
|
|
// lParam packs the new primary resolution — worth carrying: it distinguishes a real
|
|
// mode change from a same-mode re-commit when reading a field log.
|
|
let (w, h) = (
|
|
(lparam.0 & 0xffff) as u32,
|
|
((lparam.0 >> 16) & 0xffff) as u32,
|
|
);
|
|
push_event(DisplayEventKind::DisplayChange, Some(format!("{w}x{h}")));
|
|
refresh_inventory();
|
|
LRESULT(0)
|
|
}
|
|
WM_DEVICECHANGE => {
|
|
let event = wparam.0 as u32;
|
|
if event == DBT_DEVNODES_CHANGED {
|
|
push_event(DisplayEventKind::DevNodesChanged, None);
|
|
refresh_inventory();
|
|
} else if event == DBT_DEVICEARRIVAL || event == DBT_DEVICEREMOVECOMPLETE {
|
|
let kind = if event == DBT_DEVICEARRIVAL {
|
|
DisplayEventKind::MonitorArrival
|
|
} else {
|
|
DisplayEventKind::MonitorRemoval
|
|
};
|
|
// SAFETY: for these two events lParam is documented to point at a
|
|
// DEV_BROADCAST_HDR (or be 0 — checked). We only read the header fields, and only
|
|
// reinterpret as DEV_BROADCAST_DEVICEINTERFACE_W after checking dbch_devicetype,
|
|
// reading at most dbch_size bytes — the size the sender declared.
|
|
let detail = unsafe {
|
|
let hdr = lparam.0 as *const DEV_BROADCAST_HDR;
|
|
if hdr.is_null() || (*hdr).dbch_devicetype != DBT_DEVTYP_DEVICEINTERFACE {
|
|
None
|
|
} else {
|
|
let di = hdr as *const DEV_BROADCAST_DEVICEINTERFACE_W;
|
|
let head = std::mem::offset_of!(DEV_BROADCAST_DEVICEINTERFACE_W, dbcc_name);
|
|
let bytes = ((*hdr).dbch_size as usize).saturating_sub(head);
|
|
let name = std::slice::from_raw_parts(
|
|
std::ptr::addr_of!((*di).dbcc_name).cast::<u16>(),
|
|
(bytes / 2).min(512),
|
|
);
|
|
let end = name.iter().position(|&c| c == 0).unwrap_or(name.len());
|
|
crate::monitor_devnode::instance_id_from_interface_path(
|
|
&String::from_utf16_lossy(&name[..end]),
|
|
)
|
|
}
|
|
};
|
|
push_event(kind, detail);
|
|
refresh_inventory();
|
|
}
|
|
LRESULT(0)
|
|
}
|
|
WM_TIMER => {
|
|
refresh_inventory();
|
|
LRESULT(0)
|
|
}
|
|
// SAFETY: default handling for everything else — the standard wndproc tail call.
|
|
_ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) },
|
|
}
|
|
}
|
|
|
|
/// The listener thread: a hidden TOP-LEVEL window (message-ONLY windows receive neither
|
|
/// `WM_DISPLAYCHANGE` nor broadcast `WM_DEVICECHANGE` — the classic pitfall) + a device-interface
|
|
/// registration for monitors + a blocking message pump. Runs for the process lifetime.
|
|
fn pump() {
|
|
refresh_inventory(); // baseline before any event arrives
|
|
|
|
let class: Vec<u16> = "pf-display-events\0".encode_utf16().collect();
|
|
// SAFETY: straight-line Win32 window bring-up on this thread. `class` outlives every use of
|
|
// its pointer (it lives to the end of the fn, the pump loops forever). All handles passed on
|
|
// are the ones the preceding calls returned; failure of any step returns out of the thread
|
|
// (degraded mode, see `spawn_once`). The DEV_BROADCAST_DEVICEINTERFACE_W filter is a fully
|
|
// initialised local read synchronously by RegisterDeviceNotificationW.
|
|
unsafe {
|
|
let Ok(hinstance) = GetModuleHandleW(None) else {
|
|
tracing::warn!(
|
|
"display-event listener: GetModuleHandleW failed — no OS event attribution"
|
|
);
|
|
return;
|
|
};
|
|
let wc = WNDCLASSW {
|
|
lpfnWndProc: Some(wnd_proc),
|
|
hInstance: hinstance.into(),
|
|
lpszClassName: PCWSTR(class.as_ptr()),
|
|
..Default::default()
|
|
};
|
|
if RegisterClassW(&wc) == 0 {
|
|
tracing::warn!(
|
|
"display-event listener: RegisterClassW failed — no OS event attribution"
|
|
);
|
|
return;
|
|
}
|
|
let hwnd = match CreateWindowExW(
|
|
WINDOW_EX_STYLE(0),
|
|
PCWSTR(class.as_ptr()),
|
|
PCWSTR(class.as_ptr()),
|
|
WS_OVERLAPPED, // hidden: never shown, never gets focus — exists only to receive broadcasts
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
None,
|
|
None,
|
|
Some(wc.hInstance),
|
|
None,
|
|
) {
|
|
Ok(w) => w,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "display-event listener: CreateWindowExW failed — no OS event attribution");
|
|
return;
|
|
}
|
|
};
|
|
let filter = DEV_BROADCAST_DEVICEINTERFACE_W {
|
|
dbcc_size: std::mem::size_of::<DEV_BROADCAST_DEVICEINTERFACE_W>() as u32,
|
|
dbcc_devicetype: DBT_DEVTYP_DEVICEINTERFACE.0,
|
|
dbcc_classguid: GUID_DEVINTERFACE_MONITOR,
|
|
..Default::default()
|
|
};
|
|
if let Err(e) = RegisterDeviceNotificationW(
|
|
HANDLE(hwnd.0),
|
|
std::ptr::from_ref(&filter).cast(),
|
|
DEVICE_NOTIFY_WINDOW_HANDLE,
|
|
) {
|
|
// DBT_DEVNODES_CHANGED and WM_DISPLAYCHANGE still arrive — partial attribution.
|
|
tracing::warn!(error = %e, "display-event listener: monitor-interface registration failed — arrival/removal detail unavailable");
|
|
}
|
|
SetTimer(Some(hwnd), 1, INVENTORY_TIMER_MS, None);
|
|
tracing::debug!(
|
|
"display-event listener running (monitor hot-plug + display-change attribution)"
|
|
);
|
|
let mut msg = MSG::default();
|
|
while GetMessageW(&mut msg, None, 0, 0).as_bool() {
|
|
DispatchMessageW(&msg);
|
|
}
|
|
}
|
|
}
|