diff --git a/api/openapi.json b/api/openapi.json index 232b5eca..4e324dae 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -4617,6 +4617,15 @@ "format": "int32", "minimum": 0 }, + "last_resize_ms": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Most recent mid-stream resize total, reconfigure → pipeline rebuilt, in ms (native sessions;\n`null` when no resize happened / GameStream).", + "minimum": 0 + }, "min_fec": { "type": "integer", "format": "int32", @@ -4629,6 +4638,15 @@ "description": "Video payload size per packet (bytes).", "minimum": 0 }, + "time_to_first_frame_ms": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "Session bring-up total, hello → first video packet, in ms (native sessions; `null` on the\nGameStream plane or while the session is still bringing up).", + "minimum": 0 + }, "width": { "type": "integer", "format": "int32", diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs index 5d63300f..9498c829 100644 --- a/crates/pf-capture/src/lib.rs +++ b/crates/pf-capture/src/lib.rs @@ -56,6 +56,24 @@ pub trait Capturer: Send { fn pipeline_depth(&self) -> usize { 1 } + + /// The OS display-target id this capturer is bound to (Windows IDD-push), so the resize path + /// can verify the display it just reconfigured is STILL the one this capturer serves (an + /// in-place resize keeps the target; a re-arrival fallback mints a new one, which needs a + /// fresh capturer). `None` = the backend has no such identity (every non-IDD backend). + fn capture_target_id(&self) -> Option { + None + } + + /// HOST-INITIATED output resize (latency plan P2.3): the session's resize handler has ALREADY + /// committed the display's new mode (the manager's in-place mode set), so a capable capturer + /// re-sizes its capture surface NOW — no descriptor-poll debounce (that machinery stays, for + /// EXTERNAL changes only) and no teardown: the capture pipeline and its send thread survive; + /// only the encoder is swapped by the caller once the first new-size frame arrives. Returns + /// `true` when handled; `false` (the default) routes the caller to the full-rebuild path. + fn resize_output(&mut self, _width: u32, _height: u32) -> bool { + false + } } /// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file → diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index 01b18daf..f4f57452 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -698,8 +698,24 @@ impl IddPushCapturer { let enabled_hdr = client_10bit && pf_win_display::win_display::set_advanced_color(target.target_id, true); if enabled_hdr { - // Let the colorspace change settle before the driver composes + we size the ring. - std::thread::sleep(Duration::from_millis(250)); + // Let the colorspace change settle before the driver composes + we size the ring: + // poll the CCD advanced-color state instead of a fixed sleep (latency plan P0.4), + // ceiling = the old 250 ms. A read that never flips within the ceiling proceeds + // exactly like the fixed sleep did — the ring is sized FP16 from `enabled_hdr` + // either way (the set succeeded; only the driver's compose flip may lag, which the + // stash/format-guard machinery absorbs). + let hdr_settle = Instant::now(); + while hdr_settle.elapsed() < Duration::from_millis(250) { + if crate::win_display::advanced_color_enabled(target.target_id) == Some(true) { + break; + } + std::thread::sleep(Duration::from_millis(25)); + } + tracing::debug!( + target_id = target.target_id, + settle_ms = hdr_settle.elapsed().as_millis() as u64, + "IDD push: advanced-color (HDR) enable settle" + ); } // A failed open-time read defaults to SDR (unless the 10-bit path enabled HDR above) — // there is no "last known" yet; the descriptor poller corrects a wrong guess mid-session. @@ -983,7 +999,14 @@ impl IddPushCapturer { self.no_first_frame_diagnosis(st) ); } - std::thread::sleep(Duration::from_millis(20)); + // Event-driven wait (latency plan P0.6): the driver signals the frame-ready event on + // every publish, so wake on it instead of a blind sleep — the 20 ms timeout keeps the + // driver_status polls above live (status writes don't signal the event). Consuming a + // signal here is fine: `next_frame` re-checks the atomic `latest` token, never the + // event, for truth. + // SAFETY: `self.event` is this capturer's owned, live auto-reset event handle; + // `WaitForSingleObject` only reads the handle and the 20 ms timeout bounds the wait. + let _ = unsafe { WaitForSingleObject(HANDLE(self.event.as_raw_handle()), 20) }; } } @@ -1615,6 +1638,39 @@ impl Capturer for IddPushCapturer { // always has its own texture). pf_host_config::config().idd_depth.clamp(1, OUT_RING) } + + fn capture_target_id(&self) -> Option { + Some(self.target_id) + } + + fn resize_output(&mut self, width: u32, height: u32) -> bool { + // Host-initiated resize (latency plan P2.3): the session's resize handler has already + // committed the display's new mode (the manager's in-place mode set), so recreate the ring + // at the new size NOW — no DescriptorPoller two-strike debounce (that stays, unchanged, + // for EXTERNAL changes: HDR flips, game mode-sets). The driver re-attaches to the fresh + // ring and republishes; on an in-place mode set the OS's mode-set full redraw gives the + // stash/first frame within the recover window. Same recover-or-drop arming as the + // poller-driven recreate, so a ring that can't re-attach still fails the session cleanly + // instead of freezing. + if (width, height) == (self.width, self.height) { + return true; // already at the requested size (refresh-only change) — nothing to do + } + tracing::info!( + target_id = self.target_id, + from = format!("{}x{}", self.width, self.height), + to = format!("{width}x{height}"), + "IDD push: host-initiated resize — recreating the ring at the new mode" + ); + self.recovering_since.get_or_insert_with(Instant::now); + if let Err(e) = self.recreate_ring(self.display_hdr, width, height) { + tracing::warn!( + error = %format!("{e:#}"), + "IDD push: host-initiated ring recreate failed — falling back to a full rebuild" + ); + return false; + } + true + } } /// A 4:4:4 session while the display is HDR: there is no 10-bit full-chroma source (the FP16 diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 6aab89c9..0a5522bc 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -59,7 +59,19 @@ pub const fn interface_guid_fields() -> (u32, u16, u16, [u8; 8]) { /// attach a ring naming a different monitor ([`frame::DRV_STATUS_BIND_FAIL`], the gamepad channel's /// `pad_index` validation applied to frames). A v2 host never stamps the field, so a v3 driver /// against a v2 host would refuse every attach — lockstep by the handshake, as ever. -pub const PROTOCOL_VERSION: u32 = 3; +/// v4: ADDITIVE — [`control::IOCTL_UPDATE_MODES`] (the in-place mid-stream resize, +/// `design/first-frame-and-resize-latency.md` P2): the driver refreshes a LIVE monitor's advertised +/// target-mode list (`IddCxMonitorUpdateModes2`) so the OS can mode-set to an arbitrary new mode +/// without a REMOVE→ADD monitor hotplug. Nothing existing changed, so the host accepts a v3 driver +/// too ([`MIN_DRIVER_PROTOCOL_VERSION`]) and simply falls back to the re-arrival resize against it; +/// a v4 driver serving an older (v3-asserting) host fails that host's strict handshake — ship +/// driver+host together, as ever. +pub const PROTOCOL_VERSION: u32 = 4; + +/// The OLDEST driver protocol this host still drives (v4 is additive over v3 — see the v4 note on +/// [`PROTOCOL_VERSION`]): a v3 driver lacks only `IOCTL_UPDATE_MODES`, which the host gates on the +/// handshake-reported version and covers with the re-arrival fallback. +pub const MIN_DRIVER_PROTOCOL_VERSION: u32 = 3; /// `CTL_CODE(FILE_DEVICE_UNKNOWN = 0x22, func, METHOD_BUFFERED = 0, FILE_ANY_ACCESS = 0)`. pub const fn ctl_code(func: u32) -> u32 { @@ -91,6 +103,13 @@ pub mod control { /// host duplicated into the driver's WUDFHost process. Input [`SetFrameChannelRequest`]. Sent once /// after the ring is created and again on every mid-session ring recreate (HDR-mode flip). pub const IOCTL_SET_FRAME_CHANNEL: u32 = ctl_code(0x906); + /// Refresh a LIVE monitor's advertised target-mode list to a new preferred mode (+ the built-in + /// fallbacks) via `IddCxMonitorUpdateModes2` — the in-place mid-stream resize (v4, + /// `design/first-frame-and-resize-latency.md` P2). Input [`UpdateModesRequest`]. The host then + /// CCD-forces the new mode active on the SAME monitor: no REMOVE→ADD hotplug, the monitor's OS + /// identity (saved per-monitor DPI) and the driver's swap-chain/stash machinery survive. A v3 + /// driver fails this unknown IOCTL → the host falls back to the re-arrival resize. + pub const IOCTL_UPDATE_MODES: u32 = ctl_code(0x907); /// `IOCTL_ADD` input. A monotonic `session_id` keys the monitor (the host's refcount manager owns /// collision safety — no more SudoVDA's 16-byte GUID + pid-mangling). The driver advertises this @@ -164,6 +183,22 @@ pub mod control { pub session_id: u64, } + /// `IOCTL_UPDATE_MODES` input (v4): the live monitor (by its ADD `session_id`) and the new + /// preferred mode its target-mode list should lead with. The driver replaces the stored list + /// (new mode first, then its built-in fallbacks — the same shape ADD produces) and pushes it to + /// the OS via `IddCxMonitorUpdateModes2`; success means the OS accepted the new list, after + /// which the host force-sets the mode via CCD/GDI as usual. + #[repr(C)] + #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] + pub struct UpdateModesRequest { + pub session_id: u64, + pub width: u32, + pub height: u32, + pub refresh_hz: u32, + /// Pads the `u64`-aligned struct to a multiple of 8 (Pod forbids implicit tail padding). + pub _reserved: u32, + } + /// `IOCTL_SET_RENDER_ADAPTER` input (the GPU the IddCx swap-chain should render on). #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable, Debug, PartialEq, Eq)] @@ -253,6 +288,12 @@ pub mod control { assert!(size_of::() == 8); assert!(offset_of!(RemoveRequest, session_id) == 0); + assert!(size_of::() == 24); + assert!(offset_of!(UpdateModesRequest, session_id) == 0); + assert!(offset_of!(UpdateModesRequest, width) == 8); + assert!(offset_of!(UpdateModesRequest, height) == 12); + assert!(offset_of!(UpdateModesRequest, refresh_hz) == 16); + assert!(size_of::() == 8); assert!(offset_of!(SetRenderAdapterRequest, luid_low) == 0); assert!(offset_of!(SetRenderAdapterRequest, luid_high) == 4); @@ -1104,6 +1145,27 @@ mod tests { assert_eq!(bytes[32..40], 0x2000u64.to_le_bytes()); } + #[test] + fn update_modes_request_roundtrips_and_versions_cohere() { + let req = control::UpdateModesRequest { + session_id: 42, + width: 2560, + height: 1409, // deliberately arbitrary — the in-place path serves window-drag modes + refresh_hz: 120, + _reserved: 0, + }; + let bytes = bytemuck::bytes_of(&req); + assert_eq!(bytes.len(), 24); + assert_eq!( + *bytemuck::from_bytes::(bytes), + req + ); + assert_eq!(bytes[8..12], 2560u32.to_le_bytes()); + // The compat window: v4 is additive over v3, so the host floor stays one below. + assert_eq!(PROTOCOL_VERSION, 4); + assert_eq!(MIN_DRIVER_PROTOCOL_VERSION, 3); + } + #[test] fn gamepad_names_and_magics_are_stable() { assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0"); @@ -1145,6 +1207,7 @@ mod tests { control::IOCTL_GET_INFO, control::IOCTL_CLEAR_ALL, control::IOCTL_SET_FRAME_CHANNEL, + control::IOCTL_UPDATE_MODES, ]; for (i, a) in all.iter().enumerate() { for b in &all[i + 1..] { diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs index d075a6cd..9fdeaeac 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs @@ -35,7 +35,8 @@ use windows::Win32::System::Threading::{ use super::{DisplayOwnership, Mode, VirtualOutput}; use pf_win_display::win_display::{ count_other_active, force_extend_topology, isolate_displays_ccd, resolve_gdi_name, - restore_displays_ccd, set_active_mode, set_virtual_primary_ccd, SavedConfig, + restore_displays_ccd, set_active_mode, set_virtual_primary_ccd, wait_mode_settled, + wait_target_departed, SavedConfig, }; #[path = "manager/driver.rs"] @@ -204,6 +205,16 @@ pub struct VirtualDisplayManager { /// `&'static` singleton with no raw-handle smuggling. device: Mutex, watchdog_s: AtomicU32, + /// The driver's handshake-reported protocol version (0 until the first open). The in-place + /// resize (latency plan P2) gates its UPDATE_MODES attempt on `>= 4`; a v3 driver keeps the + /// already-advertised fast path + the re-arrival fallback. + driver_proto: AtomicU32, + /// Latched `true` after an UPDATE_MODES round-trip failed to make the new mode settable — + /// on-glass (build 26200) the OS pins a monitor's settable set at ARRIVAL (it re-parses our + /// description + re-queries target modes, then ignores both), so every further attempt for an + /// out-of-arrival-list mode would only waste ~1 s per resize before the same re-arrival + /// fallback. One attempt per process, in case a future OS build honors the refresh. + update_modes_futile: AtomicBool, /// Monotonic lease-generation counter (was the `MON_GEN` global). gen: AtomicU64, state: Mutex, @@ -234,6 +245,8 @@ pub(crate) fn init(driver: Box) -> &'static VirtualDisplayMa driver, device: Mutex::new(DeviceSlot::default()), watchdog_s: AtomicU32::new(3), + driver_proto: AtomicU32::new(0), + update_modes_futile: AtomicBool::new(false), gen: AtomicU64::new(1), state: Mutex::new(MgrInner::default()), setup_lock: Mutex::new(()), @@ -321,9 +334,10 @@ impl VirtualDisplayManager { // FFI in the caller's apartment; the `device` mutex (held here) serializes it, so there is no // concurrent open. `open` has no handle precondition to uphold, and the `OwnedHandle` it // returns is the sole owner of the device. - let (handle, watchdog_s) = unsafe { self.driver.open(reap)? }; + let (handle, watchdog_s, driver_proto) = unsafe { self.driver.open(reap)? }; slot.opened_once = true; self.watchdog_s.store(watchdog_s, Ordering::Relaxed); + self.driver_proto.store(driver_proto, Ordering::Relaxed); let raw = HANDLE(handle.as_raw_handle()); slot.current = Some(Arc::new(handle)); if !reap { @@ -418,9 +432,10 @@ impl VirtualDisplayManager { if let Some(SlotState::Lingering { mon, .. } | SlotState::Pinned { mon }) = inner.slots.remove(&slot) { + let old_target = mon.target_id; tracing::info!( slot, - old_target = mon.target_id, + old_target, "IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one" ); // SAFETY: `teardown_removed` requires `dev` to be a valid control handle; `dev` is the @@ -430,7 +445,16 @@ impl VirtualDisplayManager { unsafe { self.teardown_removed(dev, &mut inner, mon) }; // Let the OS finish the ASYNC monitor departure before the next ADD; a back-to-back // REMOVE→ADD races the teardown and the ADD IOCTL is rejected under reconnect churn. - thread::sleep(Duration::from_millis(400)); + // Verified-state wait, ceiling = the old fixed 400 ms settle (latency plan P0.3). + // SAFETY: CCD query FFI over a `Copy` target id, under the held `state` lock. + let departed = + unsafe { wait_target_departed(old_target, Duration::from_millis(400)) }; + if !departed { + tracing::debug!( + old_target, + "preempted monitor still in the active CCD set after the departure ceiling" + ); + } } } @@ -446,9 +470,10 @@ impl VirtualDisplayManager { if matches!(inner.slots.get(&slot), Some(SlotState::Active { mon, .. }) if !wudf_alive(mon.wudf_pid)) { if let Some(SlotState::Active { mon, .. }) = inner.slots.remove(&slot) { + let old_target = mon.target_id; tracing::warn!( slot, - old_target = mon.target_id, + old_target, wudf_pid = mon.wudf_pid, "virtual monitor's WUDFHost is gone — preempting the dead monitor, recreating" ); @@ -457,8 +482,9 @@ impl VirtualDisplayManager { // retired, kept alive; see `DeviceSlot`). `mon` was just removed from the map, so it // is exclusively owned here — no aliasing. unsafe { self.teardown_removed(dev, &mut inner, mon) }; - // Same async-departure settle as the reconnect preempt above. - thread::sleep(Duration::from_millis(400)); + // Same async-departure settle as the reconnect preempt above (verified wait, P0.3). + // SAFETY: CCD query FFI over a `Copy` target id, under the held `state` lock. + let _ = unsafe { wait_target_departed(old_target, Duration::from_millis(400)) }; } } @@ -475,6 +501,57 @@ impl VirtualDisplayManager { _ => unreachable!("just matched Active"), }; if cur_mode != mode { + // IN-PLACE mode set first (latency plan P2): an already-advertised resolution + // (arrival list + the driver's same-id mode history) is CCD-forced on the SAME + // monitor — no REMOVE→ADD, so the monitor's OS identity (saved per-monitor DPI), + // the driver-side swap-chain machinery and the retained frame stash all survive, + // and the whole hotplug cost (departure settle + activation ladder + re-isolate) + // disappears. An out-of-list mode fails FAST (see `resize_in_place`) and falls + // through to the proven re-arrival below. + { + let in_place = { + let Some(SlotState::Active { mon, refs }) = inner.slots.get_mut(&slot) + else { + unreachable!("just matched Active"); + }; + // SAFETY: `dev` is the handle `ensure_device()` returned above; the CCD + // waits inside run under the held `state` lock (this fn's discipline). + match unsafe { self.resize_in_place(dev, mon, mode) } { + Ok(()) => { + // Same join semantics as the re-arrival: +1 ref for the new + // (build-then-drop overlap) lease; `gen` untouched, so the old + // session's lease stays valid. + *refs += 1; + let refs = *refs; + let out = self.output_for(slot, mon, quit.clone()); + tracing::info!( + slot, + refs, + backend = self.driver.name(), + "virtual monitor resized IN PLACE (identity + swap-chain kept)" + ); + Some(out) + } + Err(e) => { + // Expected-normal for a first-seen arbitrary size (the OS pins + // settable modes at arrival; the re-arrival teaches it) — info, + // not warn. + tracing::info!( + slot, + reason = %format!("{e:#}"), + "in-place resize not possible — monitor re-arrival" + ); + None + } + } + }; + if let Some(out) = in_place { + // The width changed — re-arrange the group so auto-row siblings don't + // overlap the resized display (no-op for a single member). + self.apply_group_layout(&mut inner); + return Ok(out); + } + } let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else { unreachable!("just matched Active"); }; @@ -736,8 +813,12 @@ impl VirtualDisplayManager { /// # Safety /// Runs the CCD (QueryDisplayConfig / SetDisplayConfig) FFI; call under the `state` lock. unsafe fn resolve_target_gdi(&self, target_id: u32) -> Option { - for _ in 0..15 { - thread::sleep(Duration::from_millis(200)); + // 50 ms sampling (latency plan P0.5): the SAME 3 s per-stage ceilings — the 3-stage ladder + // structure encodes real failure modes (headless auto-activate, integrated-panel clone, + // lid-closed path activation) and is untouched — but a typical activation resolves on an + // early poll, so finer sampling shaves ~150 ms off every stage crossing. + for _ in 0..60 { + thread::sleep(Duration::from_millis(50)); // SAFETY: `resolve_gdi_name` is `unsafe` for its CCD FFI; it takes a plain `Copy` `u32` // target id by value and returns an owned `String`, so no caller memory is borrowed. if let Some(n) = unsafe { resolve_gdi_name(target_id) } { @@ -746,8 +827,8 @@ impl VirtualDisplayManager { } // SAFETY: `force_extend_topology` only calls `SetDisplayConfig` (CCD) with no borrowed memory. unsafe { force_extend_topology() }; - for _ in 0..15 { - thread::sleep(Duration::from_millis(200)); + for _ in 0..60 { + thread::sleep(Duration::from_millis(50)); // SAFETY: as the resolve loop above. if let Some(n) = unsafe { resolve_gdi_name(target_id) } { return Some(n); @@ -756,8 +837,8 @@ impl VirtualDisplayManager { // SAFETY: `activate_target_path` runs the CCD query/apply FFI with owned local buffers; the // `Copy` target id is passed by value, under the `state` lock — the sole topology mutator. if unsafe { pf_win_display::win_display::activate_target_path(target_id) } { - for _ in 0..15 { - thread::sleep(Duration::from_millis(200)); + for _ in 0..60 { + thread::sleep(Duration::from_millis(50)); // SAFETY: as the resolve loops above. if let Some(n) = unsafe { resolve_gdi_name(target_id) } { return Some(n); @@ -919,19 +1000,40 @@ impl VirtualDisplayManager { ); } } - thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens + // Topology settle before capture opens: verified-state wait (latency plan P0.2) — + // poll until the target's path + active mode are committed, ceiling = the old fixed + // 1500 ms sleep (a rejected mode / slow third-party CCD-lock holder burns the + // ceiling and proceeds, exactly like the sleep it replaces). + let settle_start = std::time::Instant::now(); + // SAFETY: CCD/GDI query FFI over a `Copy` target id, under the held `state` lock. + let settled = unsafe { + wait_mode_settled(added.target_id, mode, Duration::from_millis(1500)) + }; + tracing::info!( + settle_ms = settle_start.elapsed().as_millis() as u64, + verified = settled, + "topology settle (verified-state wait)" + ); // EXPERIMENTAL `pnp_disable_monitors`, second selector (ANY topology): monitors // that are connected but NOT part of the desktop — the standby TV/monitor the // deactivated-set selector above structurally misses (it never had an active path // to deactivate), yet whose periodic standby wake events drive the same Windows // reaction cascade (rationale in `windows/monitor_devnode.rs`). Runs AFTER the - // settle sleep so the active flags it reads are the committed ones (a display - // still mid-activation from the primary topology's force-EXTEND must not read as - // inactive and get disabled); in Extend the active physical panels are untouched - // by construction. First member only — the sweep is group-scoped like the - // isolate; later members join an already-swept desktop. + // settle so the active flags it reads are the committed ones (a display still + // mid-activation from the primary topology's force-EXTEND must not read as + // inactive and get disabled) — and since the verified wait above only confirms + // OUR target (not a physical still lighting up from force-EXTEND), this opt-in + // sweep keeps the old FULL settle as its floor before reading those flags. + // In Extend the active physical panels are untouched by construction. First + // member only — the sweep is group-scoped like the isolate; later members join + // an already-swept desktop. if first_member && crate::policy::prefs().pnp_disable_monitors() { + if let Some(rest) = + Duration::from_millis(1500).checked_sub(settle_start.elapsed()) + { + thread::sleep(rest); + } let mut keep = inner.target_ids(); keep.push(added.target_id); for id in pf_win_display::monitor_devnode::disable_connected_inactive(&keep) { @@ -962,6 +1064,101 @@ impl VirtualDisplayManager { }) } + /// Mid-stream resize IN PLACE (latency plan P2): the driver refreshes the LIVE monitor's + /// advertised target-mode list to lead with `mode` (`IOCTL_UPDATE_MODES` → + /// `IddCxMonitorUpdateModes2`, protocol v4), the OS re-enumerates the target's settable modes + /// (waited on, bounded), and the usual CCD/GDI force-set + verified settle (P0.2) commit it — + /// on the SAME monitor: target id, GDI name, saved per-monitor DPI, the driver's swap-chain + /// worker and its retained frame stash all survive (the OS reassigns the swap-chain across a + /// mode set; the preserved-publisher/stash hand-off covers that flap — what it was built for). + /// On success `mon.mode` is updated in place; any failure leaves `mon` untouched (still at the + /// old mode) and the caller falls back to [`re_add`](Self::re_add). + /// + /// # Safety + /// `dev` must be the live control handle; runs the CCD/GDI FFI under the `state` lock. + unsafe fn resize_in_place(&self, dev: HANDLE, mon: &mut Monitor, mode: Mode) -> Result<()> { + let gdi = mon + .gdi_name + .clone() + .context("in-place resize needs a resolved GDI name")?; + let t0 = Instant::now(); + // FAST PATH (driver-independent): the OS already offers this resolution — the monitor's + // arrival list, which since the driver's mode-history union contains every size this + // identity ever served — so a plain CCD mode set reaches it with no driver round-trip. + let already = crate::win_display::wait_mode_advertised(&gdi, mode, Duration::ZERO); + if !already { + // Out-of-arrival-list mode. On-glass (build 26200) the OS re-parses our description + // AND re-queries target modes after UpdateModes2 — our callbacks served the fresh + // list — yet the SETTABLE set stays pruned to the arrival list: the monitor + // source-mode set is pinned at arrival. So one bounded UPDATE_MODES attempt per + // process (in case a future build honors the refresh), then latch it futile and fail + // fast to the re-arrival — whose same-id history union makes THIS size settable in + // place from then on. + if self.driver_proto.load(Ordering::Relaxed) < 4 { + anyhow::bail!( + "{}x{} is not in the advertised mode set (v3 driver: in-place reaches only \ + arrival-list modes)", + mode.width, + mode.height + ); + } + if self.update_modes_futile.load(Ordering::Relaxed) { + anyhow::bail!( + "{}x{} is not in the advertised mode set (UPDATE_MODES latched futile — the \ + OS pins settable modes at monitor arrival; the re-arrival teaches this size \ + to the identity's history)", + mode.width, + mode.height + ); + } + tracing::info!( + old = format!( + "{}x{}@{}", + mon.mode.width, mon.mode.height, mon.mode.refresh_hz + ), + new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz), + target = mon.target_id, + "virtual-display: updating the live monitor's modes for an in-place resize" + ); + // SAFETY: `dev` is the live control handle (this fn's contract); `update_modes` + // forwards it to a synchronous IOCTL with owned/borrowed locals only. + unsafe { self.driver.update_modes(dev, &mon.key, mode) }?; + // SAFETY: CCD query/apply FFI under the held `state` lock (this fn's contract). + unsafe { crate::win_display::force_mode_reenumeration() }; + if !crate::win_display::wait_mode_advertised(&gdi, mode, Duration::from_millis(800)) { + self.update_modes_futile.store(true, Ordering::Relaxed); + anyhow::bail!( + "OS did not advertise {}x{} within {}ms of the driver mode-list update \ + (offers: {:?}) — latching UPDATE_MODES off for this process", + mode.width, + mode.height, + t0.elapsed().as_millis(), + crate::win_display::advertised_resolutions(&gdi) + ); + } + } + let advertised_ms = t0.elapsed().as_millis() as u64; + set_active_mode(&gdi, mode); + // Verified-state settle (P0.2): the same committed-state predicate as the create paths. A + // mode set that did not commit within the ceiling routes to the re-arrival fallback. + let settle_start = Instant::now(); + // SAFETY: CCD/GDI query FFI over a `Copy` target id, under the held `state` lock. + let settled = + unsafe { wait_mode_settled(mon.target_id, mode, Duration::from_millis(1500)) }; + if !settled { + anyhow::bail!( + "in-place mode set did not commit within 1.5s (advertised after {advertised_ms} ms)" + ); + } + tracing::info!( + advertised_ms, + settle_ms = settle_start.elapsed().as_millis() as u64, + "in-place resize committed (verified-state wait)" + ); + mon.mode = mode; + Ok(()) + } + /// Mid-stream resize by monitor RE-ARRIVAL (`design/midstream-resolution-resize.md` Fix 1). /// /// The pf-vdisplay driver freezes a monitor's advertised mode list at `IOCTL_ADD` time (the @@ -1013,9 +1210,17 @@ impl VirtualDisplayManager { ); } // Let the OS finish the ASYNC monitor departure before the ADD — a back-to-back REMOVE→ADD - // races the teardown and the ADD is rejected under churn (same 400 ms settle as the reconnect - // preempt path). - thread::sleep(Duration::from_millis(400)); + // races the teardown and the ADD is rejected under churn. Verified departure wait, ceiling = + // the old fixed 400 ms settle (latency plan P0.3); the driver's ghost-reap ADD retry remains + // the backstop for a departure the CCD reports early. + let depart_start = std::time::Instant::now(); + // SAFETY: CCD query FFI over a `Copy` target id, under the held `state` lock. + let departed = unsafe { wait_target_departed(old.target_id, Duration::from_millis(400)) }; + tracing::info!( + depart_ms = depart_start.elapsed().as_millis() as u64, + verified = departed, + "re-arrival: old monitor departure settle" + ); // 2. ADD a fresh monitor at the NEW mode, reusing the slot as the preferred (stable) id. let render_pin = resolve_render_pin(); // SAFETY: `dev` is the live control handle; `render_pin`/`client_hdr` are owned `Copy`/`Option` @@ -1042,7 +1247,18 @@ impl VirtualDisplayManager { // the group's first-member restore snapshot. // SAFETY: CCD FFI over borrowed Copy target ids, under the `state` lock. unsafe { self.reisolate_after_swap(inner, added.target_id) }; - thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture reopens + // Topology settle before capture reopens: verified-state wait, ceiling = the old + // fixed 1500 ms sleep (latency plan P0.2 — the re-arrival twin). + let settle_start = std::time::Instant::now(); + // SAFETY: CCD/GDI query FFI over a `Copy` target id, under the held `state` lock. + let settled = unsafe { + wait_mode_settled(added.target_id, mode, Duration::from_millis(1500)) + }; + tracing::info!( + settle_ms = settle_start.elapsed().as_millis() as u64, + verified = settled, + "re-arrival topology settle (verified-state wait)" + ); } None => tracing::warn!( "re-arrival target {} not yet an active display path (auto-activate, EXTEND preset \ diff --git a/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs b/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs index dc006b5f..3a7f6e15 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/pf_vdisplay.rs @@ -341,7 +341,7 @@ impl VdisplayDriver for PfVdisplayDriver { "pf-vdisplay" } - unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)> { + unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)> { // SAFETY: `open_device` is `unsafe` only because it issues SetupAPI enumeration + `CreateFileW` // FFI; it takes no arguments and returns an owned raw `HANDLE` (or `Err`). Called here on the // backend-init thread, with no precondition beyond a valid thread context. @@ -397,27 +397,43 @@ impl VdisplayDriver for PfVdisplayDriver { } let info: control::InfoReply = bytemuck::pod_read_unaligned(&info_buf[..size_of::()]); - if info.protocol_version != pf_driver_proto::PROTOCOL_VERSION { + // HARD floor/ceiling instead of strict equality since v4: v4 is ADDITIVE over v3 + // (IOCTL_UPDATE_MODES — the in-place resize), so this host still drives a v3 driver and + // simply gates the in-place path on the reported version (re-arrival fallback). Anything + // below the floor or ABOVE this host's own version stays a loud failure. + if info.protocol_version < pf_driver_proto::MIN_DRIVER_PROTOCOL_VERSION + || info.protocol_version > pf_driver_proto::PROTOCOL_VERSION + { anyhow::bail!( - "pf-vdisplay protocol mismatch: host expects {}, driver reports {} — install matching \ - host + driver", + "pf-vdisplay protocol mismatch: host drives {}..={}, driver reports {} — install \ + matching host + driver", + pf_driver_proto::MIN_DRIVER_PROTOCOL_VERSION, pf_driver_proto::PROTOCOL_VERSION, info.protocol_version ); } let watchdog_s = info.watchdog_timeout_s.max(1); - tracing::info!( - "pf-vdisplay protocol {} (watchdog timeout {}s)", - info.protocol_version, - watchdog_s - ); + if info.protocol_version < pf_driver_proto::PROTOCOL_VERSION { + tracing::warn!( + "pf-vdisplay protocol {} (host supports {}): driver lacks the in-place resize — \ + mid-stream resizes use the monitor re-arrival path until the driver is updated", + info.protocol_version, + pf_driver_proto::PROTOCOL_VERSION + ); + } else { + tracing::info!( + "pf-vdisplay protocol {} (watchdog timeout {}s)", + info.protocol_version, + watchdog_s + ); + } // Reap monitors orphaned by a crashed previous host — a FIRST-CLASS op (driver returns // SUCCESS). FIRST open of the process only: a REOPEN (the manager retired a dead handle after // a driver upgrade / WUDFHost restart) can race sessions that still believe they are live, and // an unconditional CLEAR_ALL there would raze them. if !reap_orphans { reap_ghost_monitors(); - return Ok((device, watchdog_s)); + return Ok((device, watchdog_s, info.protocol_version)); } let mut none: [u8; 0] = []; // SAFETY: `raw` borrows the live `OwnedHandle` above. `IOCTL_CLEAR_ALL` has no input and no @@ -434,7 +450,7 @@ impl VdisplayDriver for PfVdisplayDriver { // monitor-slot budget — prevents the 0x80070490 slot-exhaustion wedge from carrying across // restarts (the reason a restart's CLEAR_ALL alone never recovered it before). reap_ghost_monitors(); - Ok((device, watchdog_s)) + Ok((device, watchdog_s, info.protocol_version)) } unsafe fn add_monitor( @@ -592,6 +608,38 @@ impl VdisplayDriver for PfVdisplayDriver { }) } + unsafe fn update_modes(&self, dev: HANDLE, key: &MonitorKey, mode: Mode) -> Result<()> { + let MonitorKey::Session(session_id) = key else { + anyhow::bail!("pf-vdisplay: unexpected monitor key kind"); + }; + let req = control::UpdateModesRequest { + session_id: *session_id, + width: mode.width, + height: mode.height, + refresh_hz: mode.refresh_hz, + _reserved: 0, + }; + let mut none: [u8; 0] = []; + // SAFETY: per `update_modes`'s contract `dev` is the live control handle. `bytes_of(&req)` + // borrows the local `UpdateModesRequest` for the duration of this synchronous call as the + // input bytes; `none` is empty, so there is no output buffer. + unsafe { + ioctl( + dev, + control::IOCTL_UPDATE_MODES, + bytemuck::bytes_of(&req), + &mut none, + ) + } + .map(|_| ()) + .with_context(|| { + format!( + "pf-vdisplay UPDATE_MODES {}x{}@{}", + mode.width, mode.height, mode.refresh_hz + ) + }) + } + unsafe fn remove_monitor(&self, dev: HANDLE, key: &MonitorKey) -> Result<()> { let MonitorKey::Session(session_id) = key else { anyhow::bail!("pf-vdisplay: unexpected monitor key kind"); @@ -740,4 +788,80 @@ mod tests { thread::sleep(Duration::from_secs(3)); drop(vout); // triggers REMOVE + stops the pinger } + + /// Live in-place resize spike — skipped unless `PUNKTFUNK_PF_VDISPLAY_LIVE=1` (needs a v4 + /// pf-vdisplay driver installed + the host service STOPPED, single-instance guard). Answers the + /// P2 open questions on real glass with no streaming client: create at one mode, then acquire + /// the SAME session's slot at a DIFFERENT mode — the manager's resize branch runs UPDATE_MODES + /// → mode-advertised wait → set_active_mode → verified settle. In-place success is visible as + /// the SAME OS target id on the second output (a re-arrival fallback mints a new one) plus the + /// committed active resolution; the test reports which path ran and asserts the mode landed. + #[test] + fn live_inplace_resize() { + if std::env::var("PUNKTFUNK_PF_VDISPLAY_LIVE").is_err() { + return; + } + // Live-run diagnostics: surface the manager/backend tracing (activation ladder, settle + // waits, UPDATE_MODES) on stdout — a bare test harness has no subscriber, which made the + // first on-glass run blind. + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "debug".into()), + ) + .try_init(); + // Context probe: can this process see the CCD active-path set at all? (`None` = the query + // itself fails in this session/window-station — the whole ladder would be blind, and a + // "monitor never activated" verdict would be an artifact of the test context.) + // SAFETY: CCD query over an owned empty slice (test-only diagnostics). + let active0 = unsafe { crate::win_display::count_other_active(&[]) }; + println!("spike: CCD active paths visible before create: {active0:?}"); + let mut vd = PfVdisplayDisplay::new().expect("open pf-vdisplay"); + let first = vd + .create(Mode { + width: 1920, + height: 1080, + refresh_hz: 60, + }) + .expect("create virtual display"); + let t1 = first + .win_capture + .as_ref() + .expect("no capture target") + .target_id; + thread::sleep(Duration::from_secs(2)); // let the activation/settle fully quiesce + // A deliberately arbitrary (window-drag-shaped) mode the ADD never advertised. + let t0 = std::time::Instant::now(); + let second = vd + .create(Mode { + width: 2356, + height: 1332, + refresh_hz: 60, + }) + .expect("in-place resize acquire"); + let resize_ms = t0.elapsed().as_millis(); + let t2 = second + .win_capture + .as_ref() + .expect("no capture target") + .target_id; + let in_place = t1 == t2; + // SAFETY: CCD query over a Copy target id (test-only diagnostics). + let active = unsafe { crate::win_display::active_resolution(t2) }; + println!( + "in-place resize spike: in_place={in_place} (target {t1} -> {t2}) took {resize_ms} ms, \ + active resolution now {active:?}" + ); + assert_eq!( + active, + Some((2356, 1332)), + "the new mode did not become the active resolution" + ); + assert!( + in_place, + "the resize fell back to re-arrival (target id changed) — UPDATE_MODES path not taken" + ); + drop(second); + drop(first); + } } diff --git a/crates/pf-win-display/src/win_display.rs b/crates/pf-win-display/src/win_display.rs index db42019c..e2b6fac7 100644 --- a/crates/pf-win-display/src/win_display.rs +++ b/crates/pf-win-display/src/win_display.rs @@ -232,6 +232,169 @@ pub unsafe fn active_resolution(target_id: u32) -> Option<(u32, u32)> { Some((dm.dmPelsWidth, dm.dmPelsHeight)) } +/// Verified-state topology-settle wait (latency plan P0.2): poll the CCD state until the target is +/// actually COMMITTED — an active path exists (the GDI name resolves) and the active resolution +/// equals the requested one — instead of sleeping a fixed interval. The conditions are exactly what +/// `resolve_gdi_name`/`set_active_mode` already established once; this waits until the OS reports +/// them stable. `ceiling` (the old fixed sleep) is the worst-case bound: a mode the driver rejected +/// (`set_active_mode` left the OS default) or a slow third-party CCD-lock holder (SteelSeries +/// class) burns the ceiling and proceeds — behavior identical to the fixed sleep it replaces. +/// Returns `true` when the state verified (typical: one or two 25 ms polls), `false` on ceiling. +/// +/// # Safety +/// Runs the CCD/GDI query FFI; call under the manager `state` lock like the callers it serves. +pub(crate) unsafe fn wait_mode_settled( + target_id: u32, + mode: Mode, + ceiling: std::time::Duration, +) -> bool { + let deadline = std::time::Instant::now() + ceiling; + loop { + // SAFETY (both calls): CCD/GDI FFI over a `Copy` target id, owned returns — the callers' + // own safety contract (under the `state` lock) covers them. + if resolve_gdi_name(target_id).is_some() + && active_resolution(target_id) == Some((mode.width, mode.height)) + { + return true; + } + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } +} + +/// Re-commit the CURRENT active config with `SDC_FORCE_MODE_ENUMERATION` — the nudge that makes +/// the OS re-query an indirect display's target modes. Observed on-glass (P2): after +/// `IddCxMonitorUpdateModes2` the OS did NOT re-enumerate on its own within 2 s, so a freshly +/// advertised mode never became settable; the isolate/layout paths already re-commit with this +/// flag for the same "the OS won't re-evaluate unless told" class. Best-effort. +/// +/// # Safety +/// Runs the CCD query/apply FFI; call under the manager `state` lock (sole topology mutator). +pub(crate) unsafe fn force_mode_reenumeration() -> bool { + let Some((paths, modes)) = query_active_config() else { + return false; + }; + let rc = SetDisplayConfig( + Some(paths.as_slice()), + Some(modes.as_slice()), + SDC_APPLY + | SDC_USE_SUPPLIED_DISPLAY_CONFIG + | SDC_ALLOW_CHANGES + | SDC_FORCE_MODE_ENUMERATION, + ); + if rc != 0 { + tracing::debug!("force mode re-enumeration: SetDisplayConfig rc={rc:#x}"); + } + rc == 0 +} + +/// The distinct resolutions `gdi_name` currently advertises (diagnostics for the in-place-resize +/// path: what the OS actually offers when a requested mode never shows up). +pub(crate) fn advertised_resolutions(gdi_name: &str) -> Vec<(u32, u32)> { + let wname: Vec = gdi_name.encode_utf16().chain(std::iter::once(0)).collect(); + let mut set = std::collections::BTreeSet::new(); + let mut i = 0u32; + loop { + let mut dm = DEVMODEW { + dmSize: size_of::() as u16, + ..Default::default() + }; + // SAFETY: `wname` is a live NUL-terminated UTF-16 device name; `&mut dm` is a live, + // size-stamped DEVMODEW the API fills for mode index `i`. Both outlive the call. + let ok = unsafe { + EnumDisplaySettingsW( + PCWSTR(wname.as_ptr()), + ENUM_DISPLAY_SETTINGS_MODE(i), + &mut dm, + ) + } + .as_bool(); + if !ok { + break; + } + set.insert((dm.dmPelsWidth, dm.dmPelsHeight)); + i += 1; + } + set.into_iter().collect() +} + +/// Wait (bounded) until `gdi_name` ADVERTISES `mode`'s resolution in its display-mode list — the +/// gate between a driver-side mode-list refresh (`IOCTL_UPDATE_MODES`, latency plan P2) and the +/// CCD/GDI force-set: the OS re-evaluates an indirect display's settable modes asynchronously after +/// `IddCxMonitorUpdateModes2`, so an immediate `set_active_mode` could race the re-enumeration and +/// silently leave the old mode. Returns `true` once any refresh at the requested WxH is enumerable. +pub(crate) fn wait_mode_advertised( + gdi_name: &str, + mode: Mode, + ceiling: std::time::Duration, +) -> bool { + let wname: Vec = gdi_name.encode_utf16().chain(std::iter::once(0)).collect(); + let deadline = std::time::Instant::now() + ceiling; + loop { + let mut i = 0u32; + loop { + let mut dm = DEVMODEW { + dmSize: size_of::() as u16, + ..Default::default() + }; + // SAFETY: `wname` is a live NUL-terminated UTF-16 device name whose pointer stays valid + // for the call; `&mut dm` is a live, size-stamped DEVMODEW the API fills for mode index + // `i`. Both outlive this synchronous call. + let ok = unsafe { + EnumDisplaySettingsW( + PCWSTR(wname.as_ptr()), + ENUM_DISPLAY_SETTINGS_MODE(i), + &mut dm, + ) + } + .as_bool(); + if !ok { + break; + } + if dm.dmPelsWidth == mode.width && dm.dmPelsHeight == mode.height { + return true; + } + i += 1; + } + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } +} + +/// Monitor-departure wait (latency plan P0.3): after a REMOVE, poll until the target has left the +/// ACTIVE CCD set — two consecutive absent samples, so one transient query failure mid-teardown +/// can't read as "gone" — instead of sleeping the fixed departure settle. `ceiling` (the old fixed +/// sleep) bounds the worst case. The OS-side departure may still be finishing driver-side when the +/// CCD stops listing the target; the ADD path's ghost-reap retry (pf_vdisplay) remains the backstop +/// for that rare race, exactly as it was for a settle that expired. Returns `true` when departure +/// was observed, `false` on ceiling. +/// +/// # Safety +/// Runs the CCD query FFI; call under the manager `state` lock like the callers it serves. +pub(crate) unsafe fn wait_target_departed(target_id: u32, ceiling: std::time::Duration) -> bool { + let deadline = std::time::Instant::now() + ceiling; + let mut absent_streak = 0u32; + loop { + // SAFETY: CCD FFI over a `Copy` target id, owned return, under the caller's `state` lock. + if resolve_gdi_name(target_id).is_none() { + absent_streak += 1; + if absent_streak >= 2 { + return true; + } + } else { + absent_streak = 0; + } + if std::time::Instant::now() >= deadline { + return false; + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } +} + /// Toggle the virtual-display target's advanced-color (HDR) state via the CCD API. Disabling HDR while on the /// secure (Winlogon) desktop makes it render SDR/composed so DXGI Desktop Duplication can capture it /// (the HDR fullscreen independent-flip otherwise storms `ACCESS_LOST` → black); re-enable on return so diff --git a/crates/punktfunk-host/src/bringup.rs b/crates/punktfunk-host/src/bringup.rs new file mode 100644 index 00000000..6320883c --- /dev/null +++ b/crates/punktfunk-host/src/bringup.rs @@ -0,0 +1,89 @@ +//! Session-transition latency trace (design/first-frame-and-resize-latency.md P0.1). +//! +//! One [`Trace`] per transition — session bring-up (`hello → … → first_packet`) or a mid-stream +//! resize (`reconfigure_received → … → pipeline_rebuilt`) — collects millisecond stage stamps +//! across the threads a transition crosses (handshake task, display-prep/encode thread, send +//! thread) and emits ONE summary `info!` line when the transition completes, so every landed +//! latency change is measured against a number instead of vibes. The completed total also lands +//! in a shared slot [`crate::session_status`] exposes (`time_to_first_frame_ms` / +//! `last_resize_ms`), so the web-console Dashboard and future regressions can read it per session. +//! +//! Deliberately coarse: stages are stamped where the session layer can see them; layers the trace +//! doesn't reach (the Windows display manager's activation ladder / settle waits) log their own +//! per-stage deltas and correlate by wall clock. + +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Instant; + +/// A single transition's stage trace. Cheap and thread-safe: `mark` is a mutex push, `finish` +/// emits the one summary line (exactly once — later calls no-op, so an abandoned trace stays +/// silent). +pub(crate) struct Trace { + /// Which transition this traces (`"bringup"` / `"resize"`) — the summary line's `kind`. + kind: &'static str, + origin: Instant, + /// `(stage, ms since origin)` in stamp order. + stages: Mutex>, + finished: AtomicBool, + /// Where the completed total lands — shared with [`crate::session_status`]. + total_ms: Arc, +} + +impl Trace { + /// Start a trace at "now" (= the first stage's zero point). `total_ms` is the shared slot the + /// completed total is stored into (0 until the transition finishes). + pub(crate) fn start(kind: &'static str, total_ms: Arc) -> Arc { + Arc::new(Self { + kind, + origin: Instant::now(), + stages: Mutex::new(Vec::new()), + finished: AtomicBool::new(false), + total_ms, + }) + } + + /// The shared slot the completed total is stored into (for `session_status::register`). + pub(crate) fn total_slot(&self) -> Arc { + self.total_ms.clone() + } + + /// Stamp a stage at "now" — first occurrence only (a retried build re-crosses its stamp + /// points; the first crossing is the one the transition timeline wants). No-op after + /// [`finish`](Self::finish), so steady-state paths that also cross a stamped point stay free. + pub(crate) fn mark(&self, stage: &'static str) { + if self.finished.load(Ordering::Relaxed) { + return; + } + let ms = self.origin.elapsed().as_millis().min(u32::MAX as u128) as u32; + let mut stages = self.stages.lock().unwrap(); + if stages.iter().any(|(s, _)| *s == stage) { + return; + } + stages.push((stage, ms)); + } + + /// Stamp the final stage and emit the one-line summary (first call only). The final stage's + /// offset is the transition total, stored into the shared slot. + pub(crate) fn finish(&self, stage: &'static str) { + if self.finished.swap(true, Ordering::Relaxed) { + return; + } + let total = self.origin.elapsed().as_millis().min(u32::MAX as u128) as u32; + let mut stages = self.stages.lock().unwrap(); + stages.push((stage, total)); + let line = stages + .iter() + .map(|(s, ms)| format!("{s}+{ms}")) + .collect::>() + .join(" "); + drop(stages); + self.total_ms.store(total.max(1), Ordering::Relaxed); + tracing::info!( + kind = self.kind, + total_ms = total, + stages = %line, + "session-transition trace" + ); + } +} diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 5c5dad74..d60137bc 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -19,6 +19,7 @@ #![deny(clippy::undocumented_unsafe_blocks)] mod audio; +mod bringup; mod capture; mod detect; mod devtest; diff --git a/crates/punktfunk-host/src/mgmt/host.rs b/crates/punktfunk-host/src/mgmt/host.rs index 301f6f43..0cea157a 100644 --- a/crates/punktfunk-host/src/mgmt/host.rs +++ b/crates/punktfunk-host/src/mgmt/host.rs @@ -134,6 +134,12 @@ pub(crate) struct StreamInfo { /// Client's parity floor per FEC block (`minRequiredFecPackets`). min_fec: u8, codec: ApiCodec, + /// Session bring-up total, hello → first video packet, in ms (native sessions; `null` on the + /// GameStream plane or while the session is still bringing up). + time_to_first_frame_ms: Option, + /// Most recent mid-stream resize total, reconfigure → pipeline rebuilt, in ms (native sessions; + /// `null` when no resize happened / GameStream). + last_resize_ms: Option, } /// Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values, @@ -332,6 +338,9 @@ pub(crate) async fn get_status(State(st): State>) -> Json>) -> Json 0) + .then_some(s.time_to_first_frame_ms), + last_resize_ms: (s.last_resize_ms > 0).then_some(s.last_resize_ms), }) }); Json(RuntimeStatus { diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index c8ceca87..332a4bed 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -777,14 +777,56 @@ async fn serve_session( let source = opts.source; let frames = opts.frames; let data_port = opts.data_port; - let (hello, welcome, udp_port, data_sock, direct, start, compositor) = tokio::time::timeout( - HANDSHAKE_TIMEOUT, - handshake::negotiate( - &conn, &mut send, &mut recv, &first, source, frames, data_port, - ), - ) - .await - .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; + // Session-transition trace (latency plan P0.1): zeroed here — the Hello is in hand, pairing + // gates are behind us — and finished by the send thread when the FIRST video packet leaves. + // The completed totals surface per session in `session_status` (→ mgmt `/status`). + let bringup = crate::bringup::Trace::start("bringup", Arc::new(AtomicU32::new(0))); + // The mid-stream resize counterpart: each accepted Reconfigure runs its own trace into this + // shared slot (latest wins), registered alongside the bring-up total. + let resize_ms: Arc = Arc::new(AtomicU32::new(0)); + + // Stop signal: stream duration elapsed or the client went away. Created (with its watcher) + // BEFORE the handshake so the Welcome-time display prep can already observe a client that + // vanished mid-handshake (its build-retry loop aborts on `stop`). + let stop = Arc::new(AtomicBool::new(false)); + // Deliberate-quit signal: set (before `stop`, so the display lease reads it on teardown) when + // the client closed the connection with `QUIT_CODE` — a user "stop", which skips the + // keep-alive linger. A bare disconnect / idle timeout leaves it false → the display lingers + // for a reconnect. + let quit = Arc::new(AtomicBool::new(false)); + { + let stop = stop.clone(); + let quit = quit.clone(); + let conn = conn.clone(); + tokio::spawn(async move { + let reason = conn.closed().await; + if matches!(&reason, quinn::ConnectionError::ApplicationClosed(ac) + if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE)) + { + quit.store(true, Ordering::SeqCst); + } + stop.store(true, Ordering::SeqCst); + }); + } + + let (hello, welcome, udp_port, data_sock, direct, start, compositor, prep) = + tokio::time::timeout( + HANDSHAKE_TIMEOUT, + handshake::negotiate( + &conn, + &mut send, + &mut recv, + &first, + source, + frames, + data_port, + &bringup, + quit.clone(), + stop.clone(), + ), + ) + .await + .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; let (ctrl_send, ctrl_recv) = (send, recv); // Can this session's backend live-reconfigure (mid-stream Reconfigure)? Gated OFF for: // * gamescope (all sub-modes): a spawn respawn restarts the game, managed restarts the box's @@ -949,12 +991,8 @@ async fn serve_session( ); }); - // Stop signal: stream duration elapsed or the client went away. - let stop = Arc::new(AtomicBool::new(false)); - // Deliberate-quit signal: set (before `stop`, so the display lease reads it on teardown) when the - // client closed the connection with `QUIT_CODE` — a user "stop", which skips the keep-alive linger. - // A bare disconnect / idle timeout leaves it false → the display lingers for a reconnect. - let quit = Arc::new(AtomicBool::new(false)); + // (The stop/quit flags + their disconnect watcher are created above, before the handshake, so + // the Welcome-time display prep can observe a mid-handshake disconnect.) // Lifecycle events (RFC §4): this point — handshake complete, pairing/admission passed — is // where the client counts as CONNECTED; the close watcher below pairs it with the // disconnect + its decoded reason. A client rejected earlier never emits either. @@ -967,17 +1005,9 @@ async fn serve_session( client: event_client.clone(), }); { - let stop = stop.clone(); - let quit = quit.clone(); let conn = conn.clone(); tokio::spawn(async move { let reason = conn.closed().await; - if matches!(&reason, quinn::ConnectionError::ApplicationClosed(ac) - if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE)) - { - quit.store(true, Ordering::SeqCst); - } - stop.store(true, Ordering::SeqCst); let why = match &reason { quinn::ConnectionError::ApplicationClosed(ac) if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE) => @@ -1175,6 +1205,10 @@ async fn serve_session( let client_label = endpoint::peer_fingerprint(&conn) .map(|fp| fingerprint_hex(&fp)[..12].to_string()) .unwrap_or_else(|| conn.remote_address().ip().to_string()); + // Transition-trace handles for the data plane (P0.1): the punch stamp + the virtual-stream + // stages ride the same per-session trace; resizes write their totals into the shared slot. + let bringup_dp = bringup.clone(); + let resize_ms_dp = resize_ms.clone(); let result: Result<()> = async { tokio::task::spawn_blocking(move || -> Result<()> { // Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly @@ -1204,6 +1238,7 @@ async fn serve_session( return Err(anyhow::Error::new(e)).context("bind data plane"); } }; + bringup_dp.mark("punch_done"); tracing::info!( %client_udp, udp_port, @@ -1229,7 +1264,7 @@ async fn serve_session( Punktfunk1Source::Virtual => { let compositor = compositor .expect("the Virtual source resolves a compositor during the handshake"); - virtual_stream(SessionContext { + let ctx = SessionContext { session, mode, seconds, @@ -1256,7 +1291,29 @@ async fn serve_session( client_label, launch: launch_for_dp, client_hdr, - }) + bringup: bringup_dp, + resize_ms: resize_ms_dp, + }; + match prep { + // P1.1: the display prep started at Welcome on its own thread — hand it + // the post-punch context and adopt its result as the stream result (that + // thread runs `virtual_stream` on the pipeline it already built). + Some((ctx_tx, prep_thread)) => match ctx_tx.send(ctx) { + Ok(()) => match prep_thread.join() { + Ok(r) => r, + Err(_) => Err(anyhow!("prepared stream thread panicked")), + }, + // The prep thread died before the hand-off (panicked during prep — + // its guard/lease unwound): run the stream inline instead. + Err(std::sync::mpsc::SendError(ctx)) => { + tracing::warn!( + "display-prep thread gone before hand-off — building inline" + ); + virtual_stream(ctx, None) + } + }, + None => virtual_stream(ctx, None), + } } } }) diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index cd15c083..210dbf05 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -19,6 +19,14 @@ pub(super) async fn negotiate( source: Punktfunk1Source, frames: u32, data_port: Option, + // Session bring-up trace (latency plan P0.1): `welcome`/`start` stamps land here, and the + // Welcome-time display prep threads it into the pipeline-build stages. + bringup: &Arc, + // The session's quit/stop flags — created BEFORE the handshake so the Welcome-time display + // prep below can observe a client that vanished mid-handshake (its build retry aborts on + // `stop`; `quit` rides into the display lease). + quit: Arc, + stop: Arc, ) -> Result<( Hello, Welcome, @@ -27,6 +35,7 @@ pub(super) async fn negotiate( bool, Start, Option, + Option, )> { let peer = conn.remote_address(); let mut hello = Hello::decode(first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; @@ -377,10 +386,74 @@ pub(super) async fn negotiate( }, }; io::write_msg(send, &welcome.encode()).await?; + bringup.mark("welcome"); + + // P1.1/P1.2 (latency plan): kick the display prep NOW — the negotiated mode is final in + // the Welcome just sent, and nothing in monitor create → activation → settle → capture + // attach → encoder open needs the client's Start or the punched socket. The prep thread + // BECOMES the stream thread: the data plane hands it the post-punch SessionContext and it + // runs `virtual_stream` on the warm pipeline, so the whole display bring-up hides behind + // the Start RTT + the (up to 2.5 s) hole-punch wait. If the session dies before its data + // plane comes up (handshake timeout, client vanished), the channel drops and the prep + // result is released — the monitor lands in the keep-alive machinery exactly like a + // normal session end (and `stop`, watched by the caller, aborts a still-running build + // retry). Windows native path only: the Linux backends bind launch semantics before create + // (gamescope nests the launch command), which must not run for a client that never sends + // Start; GameStream has neither a Start gate nor a punch. + #[cfg(target_os = "windows")] + let prep: Option = match (source, compositor) { + (Punktfunk1Source::Virtual, Some(comp)) => { + let (ctx_tx, ctx_rx) = std::sync::mpsc::sync_channel::(1); + let client_identity = endpoint::peer_fingerprint(conn); + let client_hdr = hello.display_hdr; + let (mode, shard_payload) = (hello.mode, welcome.shard_payload); + let trace = bringup.clone(); + std::thread::Builder::new() + .name("punktfunk1-stream".into()) + .spawn(move || -> Result<()> { + let prepared = super::stream::prepare_display( + comp, + mode, + client_identity, + client_hdr, + bitrate_kbps, + bit_depth, + chroma, + codec, + shard_payload, + &quit, + &stop, + &trace, + ); + let Ok(ctx) = ctx_rx.recv() else { + // No data plane ever came (handshake abort / punch failure): drop + // `prepared` — its lease release hands the monitor to keep-alive + // policy, exactly like a normal session end. + return Ok(()); + }; + match prepared { + Ok(p) => virtual_stream(ctx, Some(p)), + Err(e) => Err(e), + } + }) + .map(|handle| (ctx_tx, handle)) + .map_err(|e| { + tracing::warn!(error = %e, + "display-prep thread spawn failed — falling back to inline bring-up") + }) + .ok() + } + _ => None, + }; + #[cfg(not(target_os = "windows"))] + let prep: Option = None; + #[cfg(not(target_os = "windows"))] + let _ = (quit, stop); let start = Start::decode(&io::read_msg(recv).await?).map_err(|e| anyhow!("Start decode: {e:?}"))?; + bringup.mark("start"); Ok::<_, anyhow::Error>(( - hello, welcome, udp_port, data_sock, direct, start, compositor, + hello, welcome, udp_port, data_sock, direct, start, compositor, prep, )) } diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index f1132a06..1c1dfbd3 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -310,6 +310,10 @@ struct SendStats { /// Live encoder bitrate (kbps) — the capture thread updates it on a mid-stream adaptive /// bitrate change, so the web-console sample reports what the encoder is ACTUALLY targeting. bitrate_kbps: Arc, + /// The session's bring-up trace (P0.1): the send thread FINISHES it — `first_packet` — the + /// moment the first video AU's packets have fully left the socket (finish is once-only, so + /// the per-frame call is a cheap no-op afterwards). + bringup: Arc, } /// Whether a session on `compositor` (`None` = the synthetic source) with a `per_client_mode` @@ -389,6 +393,11 @@ fn send_loop( burst_cap, ) { Ok(stat) => { + // First VIDEO packets are on the wire — complete the bring-up trace (P0.1; + // once-only, no-op on every later frame). Speed-test filler isn't video. + if msg.flags & FLAG_PROBE as u32 == 0 { + stats.bringup.finish("first_packet"); + } // Host timing (0xCF): stamped now — the AU's packets have fully left the // socket — against the same capture anchor the wire pts carries, so the // client's per-frame math tiles exactly (network = its host+network − this). @@ -743,9 +752,15 @@ pub(super) struct SessionContext { /// so host apps tone-map to the client's real panel) and preferred over the generic baseline /// for the 0xCE mastering metadata. pub(super) client_hdr: Option, + /// The session's bring-up trace (latency plan P0.1): the pipeline-build stages stamp into it + /// and the send thread finishes it when the first video packet leaves. + pub(super) bringup: Arc, + /// Shared slot the latest completed mid-stream resize total (ms) lands in — registered with + /// `session_status` so the Dashboard shows it. + pub(super) resize_ms: Arc, } -pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> { +pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option) -> Result<()> { // This thread runs the capture+encode loop (single-process — the only topology: Linux portal / // synthetic, Windows in-process IDD-push). Elevate it so a CPU-heavy game can't deschedule our GPU // submission. @@ -792,6 +807,8 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> { client_label, launch, client_hdr, + bringup, + resize_ms, } = ctx; tracing::info!( compositor = compositor.id(), @@ -800,54 +817,79 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> { bit_depth, "punktfunk/1 virtual display" ); - // Open the backend FIRST — on Windows this constructs the vdisplay backend, which initialises the - // host-lifetime VirtualDisplayManager (§2.5). It does NO monitor work, so it must precede the IDD-push - // preempt below (which reaches the manager) — otherwise `vdm()` is called before init and panics. - let mut vd = crate::vdisplay::open(compositor)?; - // Per-client STABLE monitor identity (Phase 2): hand the backend the connecting client's cert - // fingerprint so a freshly CREATED virtual monitor gets this client's persistent id — Windows then - // reapplies the client's saved per-monitor config (DPI scaling) on reconnect. No-op on Linux backends - // and for anonymous/GameStream clients (no fingerprint → the driver auto-allocates). - vd.set_client_identity(endpoint::peer_fingerprint(&conn)); - // The client display's HDR volume (Hello) → a freshly created virtual monitor's EDID CTA HDR - // block (pf-vdisplay), so host apps + the OS tone-map to the client's real panel instead of the - // driver's built-in ~1000-nit placeholder. No-op on Linux backends and for older/SDR clients. - vd.set_client_hdr(client_hdr); - // Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the backend mints — - // the retry-hold below AND the capturer's — carries the session's quit flag, so a user "stop" - // (⌘D → the QUIT close code) tears the virtual monitor down the moment the pipeline drops instead - // of lingering 10 s. The reconnect then finds the manager Idle and does a clean fresh ADD (with - // the user's think-time as driver settle) rather than the Lingering-preempt's REMOVE→ADD churn. - // `keep_alive = forever` (gaming-rig) outranks the quit — the monitor pins as before. - vd.set_quit_flag(quit.clone()); - // Per-session launch (non-Windows): hand the resolved command to the backend instance so - // gamescope's bare spawn nests it — per-instance, no process-global env, so concurrent sessions - // can't stomp each other's launch target. The other backends' default `set_launch_command` is a - // no-op; they get the command spawned into the live session after capture is up (below). - #[cfg(not(target_os = "windows"))] - vd.set_launch_command(launch.clone()); - // IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): serialize setup so a - // reconnect FLOOD can't run concurrent monitor create/teardown, STOP the prior session + WAIT for it - // to release its monitor (instead of tearing a monitor out from under a still-live session), and - // register THIS session's stop. The returned guard holds the setup lock across the pipeline build; - // dropping it lets the next reconnect begin (and preempt us). Held BEFORE the monitor is created - // (build_pipeline → vd.create), so the preempt still precedes this session's monitor creation. - // SLOT-scoped (Stage W1): the preempt targets only a prior session holding THIS client's slot — - // a different identity's session is an admission question, never a preempt. - #[cfg(target_os = "windows")] - let _idd_setup_guard = - (plan.capture == crate::session_plan::CaptureBackend::IddPush).then(|| { - let slot = crate::vdisplay::manager::slot_id_for( - endpoint::peer_fingerprint(&conn), - (mode.width, mode.height), - ); - crate::vdisplay::manager::vdm().begin_idd_setup(slot, stop.clone()) - }); + // The vdisplay backend + built pipeline: either PREPARED at Welcome time on this very thread + // (P1.1/P1.2 — the display bring-up already overlapped the Start RTT + hole-punch), or built + // inline now (Linux, synthetic-adjacent paths, prep fallback). + let (mut vd, pipe) = match prepared { + Some(p) => (p.vd, p.pipeline), + None => { + // Open the backend FIRST — on Windows this constructs the vdisplay backend, which + // initialises the host-lifetime VirtualDisplayManager (§2.5). It does NO monitor work, + // so it must precede the IDD-push preempt below (which reaches the manager) — + // otherwise `vdm()` is called before init and panics. + let mut vd = crate::vdisplay::open(compositor)?; + // Per-client STABLE monitor identity (Phase 2): hand the backend the connecting + // client's cert fingerprint so a freshly CREATED virtual monitor gets this client's + // persistent id — Windows then reapplies the client's saved per-monitor config (DPI + // scaling) on reconnect. No-op on Linux backends and for anonymous/GameStream clients + // (no fingerprint → the driver auto-allocates). + vd.set_client_identity(endpoint::peer_fingerprint(&conn)); + // The client display's HDR volume (Hello) → a freshly created virtual monitor's EDID + // CTA HDR block (pf-vdisplay), so host apps + the OS tone-map to the client's real + // panel instead of the driver's built-in ~1000-nit placeholder. No-op on Linux + // backends and for older/SDR clients. + vd.set_client_hdr(client_hdr); + // Deliberate-quit wiring (Windows pf-vdisplay; no-op elsewhere): every lease the + // backend mints — the retry-hold below AND the capturer's — carries the session's quit + // flag, so a user "stop" (⌘D → the QUIT close code) tears the virtual monitor down the + // moment the pipeline drops instead of lingering 10 s. The reconnect then finds the + // manager Idle and does a clean fresh ADD (with the user's think-time as driver + // settle) rather than the Lingering-preempt's REMOVE→ADD churn. `keep_alive = forever` + // (gaming-rig) outranks the quit — the monitor pins as before. + vd.set_quit_flag(quit.clone()); + // Per-session launch (non-Windows): hand the resolved command to the backend instance + // so gamescope's bare spawn nests it — per-instance, no process-global env, so + // concurrent sessions can't stomp each other's launch target. The other backends' + // default `set_launch_command` is a no-op; they get the command spawned into the live + // session after capture is up (below). + #[cfg(not(target_os = "windows"))] + vd.set_launch_command(launch.clone()); + // IDD-push reconnect preempt (the dance now lives in the manager, Goal-1 §2.5): + // serialize setup so a reconnect FLOOD can't run concurrent monitor create/teardown, + // STOP the prior session + WAIT for it to release its monitor (instead of tearing a + // monitor out from under a still-live session), and register THIS session's stop. The + // returned guard holds the setup lock across the pipeline build; dropping it (end of + // this arm) lets the next reconnect begin (and preempt us). Held BEFORE the monitor is + // created (build_pipeline → vd.create), so the preempt still precedes this session's + // monitor creation. SLOT-scoped (Stage W1): the preempt targets only a prior session + // holding THIS client's slot — a different identity's session is an admission + // question, never a preempt. + #[cfg(target_os = "windows")] + let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush) + .then(|| { + let slot = crate::vdisplay::manager::slot_id_for( + endpoint::peer_fingerprint(&conn), + (mode.width, mode.height), + ); + crate::vdisplay::manager::vdm().begin_idd_setup(slot, stop.clone()) + }); + let pipe = build_pipeline_with_retry( + &mut vd, + mode, + bitrate_kbps, + bit_depth, + plan, + &quit, + &stop, + Some(bringup.as_ref()), + )?; + // Setup done — the IDD-push setup lock releases as the guard leaves this arm's scope, + // so the next reconnect can begin (and preempt us). + (vd, pipe) + } + }; let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id, mut cur_display_gen) = - build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit, &stop)?; - // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us). - #[cfg(target_os = "windows")] - drop(_idd_setup_guard); + pipe; // Capture is live — launch the requested title so it renders onto the streamed output and // grabs focus. Windows spawns the library id into the interactive user session; Linux spawns @@ -914,6 +956,7 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> { codec: plan.codec.label(), client: client_label.clone(), bitrate_kbps: live_bitrate.clone(), + bringup: bringup.clone(), }; let send_thread = std::thread::Builder::new() .name("punktfunk-send".into()) @@ -949,6 +992,8 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> { force_idr.clone(), client_label, plan.hdr, + bringup.total_slot(), + resize_ms.clone(), ); // Mid-stream session-switch watcher (opt-in via PUNKTFUNK_SESSION_WATCH; never under an explicit @@ -1081,6 +1126,7 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> { plan, &quit, &stop, + None, )?; Ok((new_vd, pipe)) })(); @@ -1131,6 +1177,10 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> { } if let Some(new_mode) = want { tracing::info!(?new_mode, "rebuilding pipeline for mode switch"); + // Resize trace (P0.1): reconfigure-received → pipeline rebuilt (incl. the first + // new-mode frame — `build_pipeline` waits for it). Total lands in the shared + // `resize_ms` slot (→ `session_status`); a failed rebuild abandons it silently. + let resize_trace = crate::bringup::Trace::start("resize", resize_ms.clone()); // PyroWave's Automatic bitrate is a per-mode ~1.6 bpp pin (resolve_bitrate_kbps_for) — // a resolution change moves the operating point (1080p→4K quadruples the pixel rate), // so re-resolve it for the new mode. Explicit client rates stay put (the operator knows @@ -1140,72 +1190,111 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> { } else { bitrate_kbps }; - // Build the new pipeline BEFORE dropping the old one: the host already acked - // the switch as accepted, so a rebuild failure must not kill an otherwise + // IN-PLACE fast path first (latency plan P2.3, Windows IDD-push): keep the capturer + + // send thread, mode-set the SAME monitor in place (P2.1/P2.2), resize the ring, swap + // only the encoder. Any decline (v3 driver → the manager re-arrived, ring recreate + // failed, no new-size frame) falls through to the full rebuild below. + #[cfg(target_os = "windows")] + let fast_done = plan.capture == crate::session_plan::CaptureBackend::IddPush + && try_inplace_resize( + &mut vd, + &mut capturer, + &mut enc, + &mut frame, + &mut interval, + new_mode, + mode_bitrate, + bit_depth, + plan, + &quit, + resize_trace.as_ref(), + ); + #[cfg(not(target_os = "windows"))] + let fast_done = false; + // Full rebuild — build the new pipeline BEFORE dropping the old one: the host already + // acked the switch as accepted, so a rebuild failure must not kill an otherwise // healthy session — keep streaming the current mode and log instead. - match build_pipeline(&mut vd, new_mode, mode_bitrate, bit_depth, plan, &quit) { - Ok(next_pipe) => { - if mode_bitrate != bitrate_kbps { - tracing::info!( - from_kbps = bitrate_kbps, - to_kbps = mode_bitrate, - "pinned PyroWave bitrate re-resolved for the new mode" - ); - bitrate_kbps = mode_bitrate; - live_bitrate.store(mode_bitrate, Ordering::Relaxed); + let rebuilt = fast_done + || match build_pipeline( + &mut vd, + new_mode, + mode_bitrate, + bit_depth, + plan, + &quit, + Some(resize_trace.as_ref()), + ) { + Ok(next_pipe) => { + let old_display_gen = cur_display_gen; + // The destructuring assignment drops the OLD capturer (→ its display lease) + // as each binding is replaced — the new pipeline is already up + // (create-before-drop). + (capturer, enc, frame, interval, cur_node_id, cur_display_gen) = next_pipe; + // H4: the old display's lease drop above is indistinguishable from a + // disconnect to the keep-alive machinery — under linger/forever policies + // every resize would ACCUMULATE kept monitors at stale modes. Retire the + // superseded entry now (a no-op when it was already torn down under + // `immediate`, or off Linux; the in-place fast path keeps the SAME display, + // so it has nothing to retire). + if let Some(g) = old_display_gen.filter(|g| cur_display_gen != Some(*g)) { + crate::vdisplay::registry::retire(g); + } + true } - let old_display_gen = cur_display_gen; - // The destructuring assignment drops the OLD capturer (→ its display lease) as - // each binding is replaced — the new pipeline is already up (create-before-drop). - (capturer, enc, frame, interval, cur_node_id, cur_display_gen) = next_pipe; - cur_mode = new_mode; - next = std::time::Instant::now(); - // H4: the old display's lease drop above is indistinguishable from a disconnect - // to the keep-alive machinery — under linger/forever policies every resize would - // ACCUMULATE kept monitors at stale modes. Retire the superseded entry now (a - // no-op when it was already torn down under `immediate`, or off Linux). - if let Some(g) = old_display_gen.filter(|g| cur_display_gen != Some(*g)) { - crate::vdisplay::registry::retire(g); - } - // H2/H3: the backend may have honored a different mode than requested — KWin - // caps a virtual output's refresh, or Windows pf-vdisplay rejects an in-place - // SetMode to a resolution its running monitor doesn't advertise and the host - // falls back to the actual display mode. `frame` is the NEW pipeline's first - // frame (just rebound above), so its dims are what the client actually decodes. - // Publish that ACTUAL mode to the live stats slot, and correct the client's mode - // slot when it differs from the accept ack it already got. - let actual = delivered_mode(frame.width, frame.height, interval); - live_mode.store( - pack_mode(actual.width, actual.height, actual.refresh_hz), - Ordering::Relaxed, - ); - if actual != new_mode { + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), ?new_mode, + "mode-switch rebuild failed — staying on the current mode"); + // H2 rollback: the control task acked the switch BEFORE this rebuild, so the + // client's mode slot already flipped to `new_mode`. A second accepted ack + // carrying the still-live mode corrects it (any accepted ack means "the + // active mode is now X" client-side; old clients just log it). `frame` is + // untouched here (the fast path returned false before swapping anything and + // the destructure only runs on the Ok arm), so it's still the OLD + // pipeline's frame — its real dims + interval are what's still on glass. let _ = reconfig_result_tx.send(Reconfigured { accepted: true, - mode: actual, + mode: delivered_mode(frame.width, frame.height, interval), }); + false } - // The owed AUs died with the old encoder — drop their in-flight records - // and restart the encode-stall clock for the fresh one. - inflight.clear(); - last_au_at = std::time::Instant::now(); - encoder_resets = 0; - last_forced_idr = Some(std::time::Instant::now()); // fresh encoder opens on an IDR — anchor the cooldown + }; + if rebuilt { + if mode_bitrate != bitrate_kbps { + tracing::info!( + from_kbps = bitrate_kbps, + to_kbps = mode_bitrate, + "pinned PyroWave bitrate re-resolved for the new mode" + ); + bitrate_kbps = mode_bitrate; + live_bitrate.store(mode_bitrate, Ordering::Relaxed); } - Err(e) => { - tracing::warn!(error = %format!("{e:#}"), ?new_mode, - "mode-switch rebuild failed — staying on the current mode"); - // H2 rollback: the control task acked the switch BEFORE this rebuild, so the - // client's mode slot already flipped to `new_mode`. A second accepted ack - // carrying the still-live mode corrects it (any accepted ack means "the active - // mode is now X" client-side; old clients just log it). `frame` is untouched - // here (the destructure only runs on the Ok arm), so it's still the OLD - // pipeline's frame — its real dims + interval are exactly what's still on glass. + cur_mode = new_mode; + next = std::time::Instant::now(); + // H2/H3: the backend may have honored a different mode than requested — KWin caps + // a virtual output's refresh, or Windows pf-vdisplay rejects a resolution its + // running monitor doesn't advertise and the host falls back to the actual display + // mode. `frame` is the NEW pipeline's first frame (just rebound above), so its + // dims are what the client actually decodes. Publish that ACTUAL mode to the live + // stats slot, and correct the client's mode slot when it differs from the accept + // ack it already got. + let actual = delivered_mode(frame.width, frame.height, interval); + live_mode.store( + pack_mode(actual.width, actual.height, actual.refresh_hz), + Ordering::Relaxed, + ); + if actual != new_mode { let _ = reconfig_result_tx.send(Reconfigured { accepted: true, - mode: delivered_mode(frame.width, frame.height, interval), + mode: actual, }); } + // The owed AUs died with the old encoder — drop their in-flight records + // and restart the encode-stall clock for the fresh one. + inflight.clear(); + last_au_at = std::time::Instant::now(); + encoder_resets = 0; + last_forced_idr = Some(std::time::Instant::now()); // fresh encoder opens on an IDR — anchor the cooldown + resize_trace.finish("pipeline_rebuilt"); } } // Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step @@ -1499,6 +1588,7 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> { plan, &quit, &stop, + None, ) { Ok(p) => break p, Err(e2) => { @@ -1743,6 +1833,7 @@ pub(super) fn virtual_stream(ctx: SessionContext) -> Result<()> { }; // Hand to the send thread; this blocks (backpressure) if it's behind. An Err means it // exited (send failure / stop) — end the encode loop too. + bringup.mark("first_au"); // P0.1 (first-crossing only; free afterwards) if frame_tx.send(msg).is_err() { send_gone = true; break; @@ -1849,6 +1940,191 @@ type Pipeline = ( Option, ); +/// The in-place resize fast path (latency plan P2.3, Windows IDD-push): the manager mode-sets the +/// SAME monitor in place (driver protocol v4 — `IOCTL_UPDATE_MODES`; internally falls back to +/// re-arrival against an older driver), then the existing capturer re-sizes its ring immediately +/// (no descriptor-poll debounce) and only the ENCODER is swapped once the first new-size frame +/// arrives — the capture pipeline, its send thread and the whole session transport survive. +/// Returns `true` when the stream is now delivering the new mode on the same capturer; `false` +/// routes the caller to the full rebuild (which is also the correct path when the manager had to +/// re-arrive a fresh monitor — this capturer's ring/broker are bound to the departed target). +#[cfg(target_os = "windows")] +#[allow(clippy::too_many_arguments)] +fn try_inplace_resize( + vd: &mut Box, + capturer: &mut Box, + enc: &mut Box, + frame: &mut crate::capture::CapturedFrame, + interval: &mut std::time::Duration, + new_mode: punktfunk_core::Mode, + bitrate_kbps: u32, + bit_depth: u8, + plan: crate::session_plan::SessionPlan, + quit: &Arc, + trace: &crate::bringup::Trace, +) -> bool { + let Some(cur_target) = capturer.capture_target_id() else { + return false; // not an IDD-push capturer — nothing to reuse + }; + // Acquire at the new mode: the manager's resize branch runs the in-place mode set (or its + // re-arrival fallback) and returns a +1-ref lease, released again when `vout` drops below — + // the capturer keeps holding its own original lease (`gen` is preserved by both paths). + let vout = match crate::vdisplay::registry::acquire(vd, new_mode, quit.clone()) { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), "in-place resize: acquire failed"); + return false; + } + }; + trace.mark("display_resized"); + let effective_hz = vout + .preferred_mode + .map(|(_, _, hz)| hz) + .filter(|&hz| hz > 0) + .unwrap_or(new_mode.refresh_hz); + if vout.win_capture.as_ref().map(|t| t.target_id) != Some(cur_target) { + // The manager re-arrived a fresh monitor (old driver / in-place failure): this capturer is + // bound to the departed target. The full rebuild re-acquires (JOINing the already-resized + // monitor) with a fresh capturer. + tracing::info!( + "resize: monitor re-arrived (no in-place support) — running the full pipeline rebuild" + ); + return false; + } + if !capturer.resize_output(new_mode.width, new_mode.height) { + return false; + } + trace.mark("ring_recreated"); + // Bounded wait for the first frame at the new size (the driver re-attaches to the fresh ring; + // the mode-set full redraw composes promptly). Mirrors the capturer's own 3 s recover-or-drop. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + let new_frame = loop { + match capturer.try_latest() { + Ok(Some(f)) if (f.width, f.height) == (new_mode.width, new_mode.height) => break f, + Ok(_) => { + if std::time::Instant::now() >= deadline { + tracing::warn!( + "resize: no new-size frame within 3s of the in-place mode set — running \ + the full pipeline rebuild" + ); + return false; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), + "resize: capture failed after the in-place mode set — running the full rebuild"); + return false; + } + } + }; + trace.mark("first_new_frame"); + // Fresh encoder at the delivered size — the one component that can't follow a resolution + // change in place today (P2.4 stays unimplemented: `open_video` is ms-scale, measured). + let mut new_enc = match crate::encode::open_video( + plan.codec, + new_frame.format, + new_frame.width, + new_frame.height, + effective_hz, + bitrate_kbps as u64 * 1000, + new_frame.is_cuda(), + bit_depth, + plan.chroma, + ) { + Ok(e) => e, + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), + "resize: encoder open failed after the in-place mode set — running the full rebuild"); + return false; + } + }; + if let Some(c) = plan.wire_chunk { + new_enc.set_wire_chunking(c); + } + *enc = new_enc; + *frame = new_frame; + *interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64); + trace.mark("encoder_open"); + true +} + +/// The Welcome-time display-prep hand-off (latency plan P1.1/P1.2): the opened vdisplay backend + +/// the fully built pipeline — monitor create, activation, settle, capture attach, first frame, +/// encoder open — produced on the prep/stream thread while the client's Start round-trip and the +/// UDP hole-punch are still in flight, so the entire display bring-up hides behind the network +/// waits. Constructed on the Windows native path only today: the Linux backends bind launch +/// semantics before create (gamescope nests the launch command), which must not run for a client +/// that never sends Start. +pub(super) struct PreparedDisplay { + pub(super) vd: Box, + pub(super) pipeline: Pipeline, +} + +/// The prep thread's hand-off pair: the sender delivers the post-punch [`SessionContext`] to the +/// thread (which then runs [`virtual_stream`] on its prepared display); the join handle returns +/// the stream result. Dropping the sender un-received aborts the prep cleanly (the prepared +/// display's lease releases into keep-alive policy). +pub(super) type PrepHandle = ( + std::sync::mpsc::SyncSender, + std::thread::JoinHandle>, +); + +/// Build the session's display + pipeline at Welcome time (latency plan P1.1/P1.2), before the +/// client's `Start` and the hole-punch — the negotiated mode is final once the Welcome is built, +/// and nothing in monitor create → activation → settle → capture attach → encoder open needs the +/// punched socket. Mirrors `virtual_stream`'s inline bring-up exactly: same backend setters, same +/// slot-scoped `begin_idd_setup` serialization (the guard releases when this returns), same +/// retry-wrapped build. The caller threads the SAME values the Welcome committed, so the prepared +/// pipeline and the later `SessionContext` can never disagree. +#[cfg(target_os = "windows")] +#[allow(clippy::too_many_arguments)] +pub(super) fn prepare_display( + compositor: crate::vdisplay::Compositor, + mode: punktfunk_core::Mode, + client_identity: Option<[u8; 32]>, + client_hdr: Option, + bitrate_kbps: u32, + bit_depth: u8, + chroma: crate::encode::ChromaFormat, + codec: crate::encode::Codec, + shard_payload: u16, + quit: &Arc, + stop: &Arc, + trace: &crate::bringup::Trace, +) -> Result { + // Same plan resolution as `virtual_stream` (pure in these inputs + host config), including + // PyroWave's datagram-aligned wire mode — `Session::shard_payload()` echoes the negotiated + // Welcome value passed here. + let mut plan = crate::session_plan::SessionPlan::resolve(bit_depth, chroma, codec); + if codec == crate::encode::Codec::PyroWave { + plan.wire_chunk = Some(shard_payload as usize); + } + let mut vd = crate::vdisplay::open(compositor)?; + vd.set_client_identity(client_identity); + vd.set_client_hdr(client_hdr); + vd.set_quit_flag(quit.clone()); + // Slot-scoped setup serialization + reconnect preempt — see the inline arm in + // `virtual_stream` for the full rationale; released when this fn returns. + let _idd_setup_guard = + (plan.capture == crate::session_plan::CaptureBackend::IddPush).then(|| { + let slot = + crate::vdisplay::manager::slot_id_for(client_identity, (mode.width, mode.height)); + crate::vdisplay::manager::vdm().begin_idd_setup(slot, stop.clone()) + }); + let pipeline = build_pipeline_with_retry( + &mut vd, + mode, + bitrate_kbps, + bit_depth, + plan, + quit, + stop, + Some(trace), + )?; + Ok(PreparedDisplay { vd, pipeline }) +} + /// Build the pipeline, retrying *transient* failures with bounded exponential backoff. /// /// Bringing a virtual output to first-frame races several async steps — the compositor parenting @@ -1859,6 +2135,7 @@ type Pipeline = ( /// error chain is classified and permanent ones short-circuit. Each failed attempt drops its /// capturer, which (via `PortalCapturer::Drop`) tears the PipeWire thread + virtual output down /// before the next attempt — no leak across retries. +#[allow(clippy::too_many_arguments)] fn build_pipeline_with_retry( vd: &mut Box, mode: punktfunk_core::Mode, @@ -1867,6 +2144,9 @@ fn build_pipeline_with_retry( plan: crate::session_plan::SessionPlan, quit: &Arc, stop: &Arc, + // Transition trace (P0.1): `Some` for the traced builds (bring-up, resize); each stage stamps + // once (first crossing) so the retry loop can pass it through unconditionally. + trace: Option<&crate::bringup::Trace>, ) -> Result { // ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed // gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take @@ -1904,7 +2184,7 @@ fn build_pipeline_with_retry( attempt - 1 ); } - match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) { + match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit, trace) { Ok(pipe) => { if attempt > 1 { tracing::info!(attempt, "pipeline up after retry"); @@ -1979,6 +2259,7 @@ fn reset_stalled_encoder( true } +#[allow(clippy::too_many_arguments)] fn build_pipeline( vd: &mut Box, mode: punktfunk_core::Mode, @@ -1986,6 +2267,9 @@ fn build_pipeline( bit_depth: u8, plan: crate::session_plan::SessionPlan, quit: &Arc, + // Transition trace (P0.1): stamps the build's stages (display acquire, capture attach, first + // frame, encoder open) into the bring-up/resize timeline. `None` on untraced rebuilds. + trace: Option<&crate::bringup::Trace>, ) -> Result { // Acquire through the registry (design/display-management.md): on Linux this pools the display // for keep-alive (reuse a kept one, or create + keep the backend's keepalive so it outlives the @@ -1994,6 +2278,9 @@ fn build_pipeline( // `quit` flag rides into the lease so a deliberate-quit teardown skips the keep-alive linger. let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone()) .context("create virtual output")?; + if let Some(t) = trace { + t.mark("display_acquired"); + } // A2: if this was a REUSED kept display and its first frame fails, tear the (dead) pool entry down // so the retry loop's next acquire creates fresh instead of re-wedging on the same corpse. Read the // gen BEFORE `capture_virtual_output` consumes `vout`. (Linux-only — the pool is Linux.) @@ -2031,6 +2318,9 @@ fn build_pipeline( let mut capturer = crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture) .context("capture virtual output")?; + if let Some(t) = trace { + t.mark("capture_attached"); + } capturer.set_active(true); let frame = match capturer.next_frame().context("first frame") { Ok(f) => f, @@ -2043,6 +2333,9 @@ fn build_pipeline( return Err(e); } }; + if let Some(t) = trace { + t.mark("first_frame"); + } // `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client // advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome. let mut enc = crate::encode::open_video( @@ -2057,6 +2350,9 @@ fn build_pipeline( plan.chroma, ) .context("open video encoder")?; + if let Some(t) = trace { + t.mark("encoder_open"); + } if let Some(c) = plan.wire_chunk { enc.set_wire_chunking(c); } diff --git a/crates/punktfunk-host/src/session_status.rs b/crates/punktfunk-host/src/session_status.rs index 01f24327..0e35c8da 100644 --- a/crates/punktfunk-host/src/session_status.rs +++ b/crates/punktfunk-host/src/session_status.rs @@ -40,6 +40,11 @@ struct LiveSession { client: String, /// Whether the session negotiated HDR — carried on the lifecycle events. hdr: bool, + /// Completed bring-up total (hello → first packet), ms; 0 until the first packet left. Written + /// once by the session's [`crate::bringup::Trace`] (latency plan P0.1). + ttff_ms: Arc, + /// Most recent completed mid-stream resize (reconfigure → pipeline rebuilt), ms; 0 = none yet. + last_resize_ms: Arc, } /// A resolved read of one live session, for the `/status` view. @@ -50,6 +55,10 @@ pub struct SessionSnapshot { pub fps: u32, pub bitrate_kbps: u32, pub codec: Codec, + /// Bring-up total (hello → first packet), ms; 0 while still bringing up (latency plan P0.1). + pub time_to_first_frame_ms: u32, + /// Most recent mid-stream resize total, ms; 0 = no resize this session. + pub last_resize_ms: u32, } fn registry() -> &'static Mutex> { @@ -84,6 +93,8 @@ pub fn register( force_idr: Arc, client: String, hdr: bool, + ttff_ms: Arc, + last_resize_ms: Arc, ) -> LiveSessionGuard { let id = next_id(); let session = LiveSession { @@ -95,6 +106,8 @@ pub fn register( force_idr, client, hdr, + ttff_ms, + last_resize_ms, }; crate::events::emit(crate::events::EventKind::SessionStarted { session: session_ref(&session), @@ -140,6 +153,8 @@ pub fn snapshot() -> Vec { fps, bitrate_kbps: s.bitrate_kbps.load(Ordering::Relaxed), codec: s.codec, + time_to_first_frame_ms: s.ttff_ms.load(Ordering::Relaxed), + last_resize_ms: s.last_resize_ms.load(Ordering::Relaxed), } }) .collect() diff --git a/packaging/windows/build-gamepad-drivers.ps1 b/packaging/windows/build-gamepad-drivers.ps1 index e76deaa1..6487911b 100644 --- a/packaging/windows/build-gamepad-drivers.ps1 +++ b/packaging/windows/build-gamepad-drivers.ps1 @@ -14,7 +14,7 @@ --gamepad` consumes (per-driver .inf/.cat/.dll + one shared punktfunk-driver.cer). Output (-Out): pf_dualsense.{dll,inf,cat} + pf_xusb.{dll,inf,cat} + pf_mouse.{dll,inf,cat} + - punktfunk-driver.cer. (pf_mouse is the resident virtual HID pointer, not a gamepad — it shares + punktfunk-driver.cer. (pf_mouse is the resident virtual HID pointer, not a gamepad - it shares this pipeline + the --gamepad install path.) .EXAMPLE diff --git a/packaging/windows/drivers/pf-vdisplay/src/callbacks.rs b/packaging/windows/drivers/pf-vdisplay/src/callbacks.rs index 7001e45c..c3109b74 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/callbacks.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/callbacks.rs @@ -129,6 +129,16 @@ pub unsafe extern "C" fn parse_monitor_description2( return STATUS_NOT_FOUND; }; let count = crate::monitor::flatten(&modes).count() as u32; + // Bring-up/diagnostic visibility (P2): does the OS ever RE-parse the description after an + // UPDATE_MODES? The head mode names which list generation this call served. + if let Some(head) = crate::monitor::flatten(&modes).next() { + dbglog!( + "[pf-vd] parse_monitor_description2(id={id}): {count} modes, head {}x{}@{}", + head.width, + head.height, + head.refresh_rate + ); + } out_args.MonitorModeBufferOutputCount = count; if in_args.MonitorModeBufferInputCount < count { // A zero input count is a count-only probe (success); a non-zero too-small buffer is an error. @@ -204,6 +214,17 @@ pub unsafe extern "C" fn monitor_query_modes2( return STATUS_NOT_FOUND; }; let count = crate::monitor::flatten(&modes).count() as u32; + // Diagnostic visibility (P2): shows whether/when the OS re-queries target modes after an + // UPDATE_MODES (the head mode names the list generation this call served). + if let Some(head) = crate::monitor::flatten(&modes).next() { + dbglog!( + "[pf-vd] monitor_query_modes2: {count} modes, head {}x{}@{} (fill={})", + head.width, + head.height, + head.refresh_rate, + in_args.TargetModeBufferInputCount >= count + ); + } out_args.TargetModeBufferOutputCount = count; if in_args.TargetModeBufferInputCount >= count { // SAFETY: `pTargetModes` points to >= `count` IDDCX_TARGET_MODE2 entries. diff --git a/packaging/windows/drivers/pf-vdisplay/src/control.rs b/packaging/windows/drivers/pf-vdisplay/src/control.rs index 414c5a97..8c59796f 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/control.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/control.rs @@ -95,6 +95,8 @@ pub unsafe fn dispatch(request: WDFREQUEST, ioctl_code: u32) { control::IOCTL_SET_RENDER_ADAPTER => unsafe { set_render_adapter(request) }, // SAFETY: `request` is the framework WDFREQUEST. control::IOCTL_SET_FRAME_CHANNEL => unsafe { set_frame_channel(request) }, + // SAFETY: `request` is the framework WDFREQUEST. + control::IOCTL_UPDATE_MODES => unsafe { update_modes(request) }, _ => complete(request, STATUS_NOT_FOUND), } } @@ -198,6 +200,28 @@ unsafe fn set_frame_channel(request: WDFREQUEST) { } } +/// `IOCTL_UPDATE_MODES` (v4): refresh a LIVE monitor's target-mode list to a new preferred mode — +/// the in-place mid-stream resize (`design/first-frame-and-resize-latency.md` P2). The monitor is +/// NOT departed: its OS identity, swap-chain machinery and retained frame stash all survive; the +/// host force-sets the freshly-advertised mode afterwards. +/// +/// # Safety +/// `request` is the framework `WDFREQUEST`. +unsafe fn update_modes(request: WDFREQUEST) { + // SAFETY: `request` is the framework WDFREQUEST. + let Some(req) = (unsafe { read_input::(request) }) else { + complete(request, STATUS_INVALID_PARAMETER); + return; + }; + if !valid_mode(req.width, req.height, req.refresh_hz) { + complete(request, STATUS_INVALID_PARAMETER); + return; + } + let st = + crate::monitor::update_monitor_modes(req.session_id, req.width, req.height, req.refresh_hz); + complete(request, st); +} + /// `IOCTL_REMOVE`: depart + drop the monitor for the given session id. /// /// # Safety diff --git a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs index 513f4426..31c72542 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs @@ -7,7 +7,7 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; -use wdk_sys::{WDFOBJECT, call_unsafe_wdf_function_binding, iddcx}; +use wdk_sys::{NTSTATUS, WDFOBJECT, call_unsafe_wdf_function_binding, iddcx}; /// One resolution with the refresh rates it supports. #[derive(Clone)] @@ -146,6 +146,41 @@ pub fn reap_orphaned(grace: Duration) -> usize { n } +/// Append `from`'s modes to `into`, skipping resolutions already present, capped at +/// [`MODE_LIST_CAP`] — the accumulate half of the union semantics (see [`update_monitor_modes`]). +fn union_modes(into: &mut Vec, from: &[Mode]) { + for m in from { + if into.len() >= MODE_LIST_CAP { + break; + } + if !into + .iter() + .any(|e| (e.width, e.height) == (m.width, m.height)) + { + into.push(m.clone()); + } + } +} + +/// The last advertised mode list of a DEPARTED monitor, per monitor id — consumed by the next +/// same-id [`create_monitor`] so a re-arrived monitor's ARRIVAL list already contains every mode +/// its predecessor ever served. The OS pins a monitor's settable set at arrival (see +/// [`update_monitor_modes`]), so this is what makes a windowed↔fullscreen cycle (or any return to +/// a previously-used size) an IN-PLACE mode set instead of another hotplug. In-process only (a +/// WUDFHost restart forgets it — harmless, the next resizes re-teach it); bounded: ≤ 16 ids × +/// [`MODE_LIST_CAP`] modes. +static MODE_HISTORY: Mutex)>> = Mutex::new(Vec::new()); + +/// Record a departing monitor's advertised list for its id ([`MODE_HISTORY`]). +fn remember_modes(id: u32, modes: &[Mode]) { + let mut hist = MODE_HISTORY.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(slot) = hist.iter_mut().find(|(i, _)| *i == id) { + slot.1 = modes.to_vec(); + } else { + hist.push((id, modes.to_vec())); + } +} + /// Fallback modes appended after the requested mode, so a topology change still has options. fn default_modes() -> Vec { vec![ @@ -365,9 +400,7 @@ pub fn preserve_publisher( /// swap-chain's render adapter matches the publisher's ([`FramePublisher::render_adapter`]) — same /// pooled device, so its context + opened ring textures are still valid; on a mismatch the caller drops /// it and waits for a fresh channel delivery instead. `None` until a worker has stashed one. -pub fn take_preserved_publisher( - target_id: u32, -) -> Option { +pub fn take_preserved_publisher(target_id: u32) -> Option { if target_id == 0 { return None; } @@ -496,6 +529,15 @@ pub fn create_monitor( let id = { let mut lock = lock_monitors(); let id = resolve_id(&lock, preferred_id); + // Same-id mode history (P2 union semantics): a RE-ARRIVED monitor advertises every mode + // its departed predecessor served, so the OS's arrival-pinned settable set already + // contains them — a return to any previously-used size is then an IN-PLACE mode set. + { + let hist = MODE_HISTORY.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((_, prev)) = hist.iter().find(|(i, _)| *i == id) { + union_modes(&mut modes, prev); + } + } lock.push(MonitorObject { object: None, id, @@ -600,6 +642,77 @@ pub fn create_monitor( Some((id, target_id, luid_low, luid_high)) } +/// How many distinct resolutions a monitor's advertised list may accumulate (the requested head + +/// history + the built-in fallbacks). Bounds the union growth across many resizes; the OLDEST +/// history entries fall off first. +const MODE_LIST_CAP: usize = 12; + +/// `IOCTL_UPDATE_MODES` (v4): refresh the LIVE monitor's advertised mode list to lead with a new +/// preferred mode and push the new TARGET mode list to the OS via `IddCxMonitorUpdateModes2` — +/// the in-place mid-stream resize (`design/first-frame-and-resize-latency.md` P2). No departure: +/// the monitor's OS identity, its swap-chain worker and the retained frame stash all survive. +/// The `*2` (HDR) DDI matches the `*2` mode/buffer family this driver already requires +/// (IddCx 1.10), so it adds no new OS floor. +/// +/// UNION semantics (on-glass finding, build 26200): the OS re-parses the description AND +/// re-queries target modes after `UpdateModes2` — our callbacks served the fresh list — yet the +/// SETTABLE set stays pruned to the modes known at monitor ARRIVAL (the monitor source-mode set +/// is pinned then). So replacing the list can only ever LOSE settable modes (v1 of this op +/// dropped the arrival mode from the target list, breaking even a resize BACK to it); the update +/// therefore accumulates — new mode first, every previously-advertised mode kept (deduped by +/// resolution, capped at [`MODE_LIST_CAP`]) — and the real payoff is at the NEXT re-arrival, +/// where [`create_monitor`]'s same-id history union makes every previously-used mode settable. +/// +/// The stored list is updated FIRST (under the lock) so any OS re-query through the mode DDIs +/// ([`modes_for_object`]/[`modes_for_id`]) sees the new list, and REVERTED if the DDI fails — the +/// OS then still holds the old list and the two stay coherent. The DDI itself is called OUTSIDE +/// the lock (it may re-enter the mode-query callbacks, which lock [`MONITOR_MODES`]). +pub fn update_monitor_modes(session_id: u64, width: u32, height: u32, refresh: u32) -> NTSTATUS { + // Swap the stored list (union — see above) + grab the live handle under the lock. + let (object, old_modes, new_modes) = { + let mut lock = lock_monitors(); + let Some(m) = lock.iter_mut().find(|m| m.session_id == session_id) else { + return crate::STATUS_NOT_FOUND; + }; + let Some(object) = m.object else { + return crate::STATUS_NOT_FOUND; // created but not yet arrived — nothing to update + }; + let mut new_modes = vec![Mode { + width, + height, + refresh_rates: vec![refresh], + }]; + union_modes(&mut new_modes, &m.modes); + let old = core::mem::replace(&mut m.modes, new_modes.clone()); + (object, old, new_modes) + }; + + // The OS's target-mode list for this monitor (the `*2`/HDR shape, like `monitor_query_modes2`). + let mut targets: Vec = flatten(&new_modes) + .map(|item| target_mode2(item.width, item.height, item.refresh_rate)) + .collect(); + let mut in_args = pod_init!(iddcx::IDARG_IN_UPDATEMODES2); + in_args.Reason = iddcx::IDDCX_UPDATE_REASON::IDDCX_UPDATE_REASON_OTHER; + in_args.TargetModeCount = targets.len() as u32; + in_args.pTargetModes = targets.as_mut_ptr(); + // SAFETY: `object` is a live IddCx monitor handle (arrived — checked above; a concurrent REMOVE + // is serialized by the host, which only ever resizes a monitor its own session holds a lease + // on). `in_args` points at valid local storage (`targets` outlives the synchronous DDI call). + let st = unsafe { wdk_iddcx::IddCxMonitorUpdateModes2(object, &in_args) }; + dbglog!( + "[pf-vd] IddCxMonitorUpdateModes2(session={session_id}, {width}x{height}@{refresh}) -> {st:#x}" + ); + if !wdk_iddcx::nt_success(st) { + // Keep the stored list coherent with what the OS actually holds (the old one). + let mut lock = lock_monitors(); + if let Some(m) = lock.iter_mut().find(|m| m.session_id == session_id) { + m.modes = old_modes; + } + return st; + } + crate::STATUS_SUCCESS +} + /// `IOCTL_REMOVE`: depart + drop the monitor for `session_id`. Returns true if one was removed. pub fn remove_monitor(session_id: u64) -> bool { // Pull out the IddCx handle AND the swap-chain processor under the lock, but drop the processor @@ -611,6 +724,9 @@ pub fn remove_monitor(session_id: u64) -> bool { return false; }; let mut entry = lock.remove(pos); + // Keep the departing monitor's advertised list for its id — the next same-id create + // unions it back in (P2 mode history; see MODE_HISTORY). + remember_modes(entry.id, &entry.modes); (entry.object, entry.swap_chain_processor.take()) }; // Drop the worker FIRST (it joins + deletes the swap-chain), THEN depart the monitor. diff --git a/packaging/windows/drivers/wdk-iddcx/src/lib.rs b/packaging/windows/drivers/wdk-iddcx/src/lib.rs index 600bd6a9..ac8325fb 100644 --- a/packaging/windows/drivers/wdk-iddcx/src/lib.rs +++ b/packaging/windows/drivers/wdk-iddcx/src/lib.rs @@ -140,6 +140,16 @@ iddcx_ddi!( in_args: *const iddcx::IDARG_IN_ADAPTERSETRENDERADAPTER, ) @ IddCxAdapterSetRenderAdapterTableIndex as PFN_IDDCXADAPTERSETRENDERADAPTER -> () ); +iddcx_ddi!( + /// Refresh a LIVE monitor's target-mode list (the HDR `*2` variant, IddCx 1.10 — the same API + /// family as the `*2` mode/buffer DDIs this driver already requires): the OS re-evaluates which + /// modes the target supports WITHOUT a monitor departure, so the host can then mode-set to a + /// freshly-advertised mode in place (the mid-stream resize, latency plan P2). + IddCxMonitorUpdateModes2( + monitor: iddcx::IDDCX_MONITOR, + in_args: *const iddcx::IDARG_IN_UPDATEMODES2, + ) @ IddCxMonitorUpdateModes2TableIndex as PFN_IDDCXMONITORUPDATEMODES2 +); iddcx_ddi!( /// Bind a D3D device to an assigned swap-chain. HRESULT-shaped (0x887A0026 → retry on monitor flap). IddCxSwapChainSetDevice(