From c2b9b32904baffafe028e4b3760b7f230f2296ed Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 17:12:14 +0200 Subject: [PATCH] =?UTF-8?q?perf(host):=20in-place=20mid-stream=20resize=20?= =?UTF-8?q?=E2=80=94=20mode-set=20the=20live=20monitor,=20keep=20the=20cap?= =?UTF-8?q?turer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Latency plan P2.2/P2.3: against a v4 driver the manager's resize branch now runs UPDATE_MODES -> wait-mode-advertised (the OS re-enumerates async) -> set_active_mode -> verified-state settle (P0.2) on the SAME monitor — no REMOVE->ADD hotplug, no departure settle, no activation ladder, no re-isolate; Windows keeps the per-monitor DPI (identity preserved). Any failure (v3 driver, mode never advertised, settle miss) falls back to the proven re-arrival path unchanged. On top of that the session's resize handler keeps the WHOLE capture pipeline: the IDD-push capturer re-sizes its ring immediately (Capturer::resize_output — no DescriptorPoller two-strike debounce, which stays for EXTERNAL changes), the driver re-attaches and the mode-set full redraw provides the first frame; only the encoder is swapped once the first new-size frame arrives (open_video is ms-scale — P2.4 deliberately skipped). The capturer, send thread and session transport all survive; every decline routes to the full rebuild. Resize-trace stages (display_resized, ring_recreated, first_new_frame, encoder_open) extend the P0.1 timeline. Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/capture.rs | 18 ++ .../src/capture/windows/idd_push.rs | 33 +++ crates/punktfunk-host/src/punktfunk1.rs | 267 +++++++++++++----- .../src/vdisplay/windows/manager.rs | 132 ++++++++- .../src/vdisplay/windows/pf_vdisplay.rs | 70 ++++- .../punktfunk-host/src/windows/win_display.rs | 45 +++ 6 files changed, 487 insertions(+), 78 deletions(-) diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index 6c230f2f..f41e0d69 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -246,6 +246,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/punktfunk-host/src/capture/windows/idd_push.rs b/crates/punktfunk-host/src/capture/windows/idd_push.rs index d0dfe60b..c0051457 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push.rs +++ b/crates/punktfunk-host/src/capture/windows/idd_push.rs @@ -1903,6 +1903,39 @@ impl Capturer for IddPushCapturer { // always has its own texture). crate::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/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 8b84aa3e..08187a91 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -4616,81 +4616,111 @@ fn virtual_stream(ctx: SessionContext, prepared: Option) -> Res } 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, - Some(resize_trace.as_ref()), - ) { - 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 - resize_trace.finish("pipeline_rebuilt"); + }; + 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 @@ -5346,6 +5376,115 @@ 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. +/// 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 diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 17ed52ac..10033f52 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -68,11 +68,12 @@ pub(crate) trait VdisplayDriver: Send + Sync { /// timeout. `reap_orphans` (the FIRST open of the process only) additionally `CLEAR_ALL`s /// monitors orphaned by a crashed previous host — a REOPEN (after a dead handle was retired) /// must NOT, since sessions this process still considers live may be racing it. Returns the - /// owned handle + watchdog seconds. + /// owned handle + watchdog seconds + the driver's reported protocol version (the in-place + /// resize gates on it). /// /// # Safety /// Issues setup-API + `DeviceIoControl` calls; runs in the caller's apartment. - unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32)>; + unsafe fn open(&self, reap_orphans: bool) -> Result<(OwnedHandle, u32, u32)>; /// ADD a virtual monitor at `mode`, pinning the IDD render GPU to `render_luid` first if `Some`, and /// requesting `preferred_monitor_id` (the host's per-client stable id; `0` = auto). `client_hdr` /// is the CLIENT display's HDR volume for the monitor's EDID CTA HDR block (`None` = the @@ -90,6 +91,17 @@ pub(crate) trait VdisplayDriver: Send + Sync { preferred_monitor_id: u32, client_hdr: Option, ) -> Result; + /// Refresh the LIVE monitor `key`'s advertised mode list to lead with `mode` (the in-place + /// mid-stream resize, latency plan P2 — pf-vdisplay `IOCTL_UPDATE_MODES`, driver protocol v4). + /// The monitor is NOT departed; the caller CCD-forces the freshly-advertised mode afterwards. + /// The default errs so a backend without support routes to the re-arrival fallback. + /// + /// # Safety + /// `dev` must be the live control handle. + unsafe fn update_modes(&self, dev: HANDLE, key: &MonitorKey, mode: Mode) -> Result<()> { + let _ = (dev, key, mode); + anyhow::bail!("backend does not support in-place mode updates") + } /// REMOVE the monitor identified by `key`. /// /// # Safety @@ -255,6 +267,9 @@ pub(crate) 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 on `>= 4`; a v3 driver keeps the re-arrival path. + driver_proto: AtomicU32, /// Monotonic lease-generation counter (was the `MON_GEN` global). gen: AtomicU64, state: Mutex, @@ -285,6 +300,7 @@ pub(crate) fn init(driver: Box) -> &'static VirtualDisplayMa driver, device: Mutex::new(DeviceSlot::default()), watchdog_s: AtomicU32::new(3), + driver_proto: AtomicU32::new(0), gen: AtomicU64::new(1), state: Mutex::new(MgrInner::default()), setup_lock: Mutex::new(()), @@ -431,9 +447,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 { @@ -581,6 +598,53 @@ impl VirtualDisplayManager { _ => unreachable!("just matched Active"), }; if cur_mode != mode { + // IN-PLACE mode set first (latency plan P2, driver protocol >= 4): refresh the + // live monitor's advertised modes (IOCTL_UPDATE_MODES) + CCD-force the new mode — + // 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. Any failure falls through to the proven re-arrival below. + if self.driver_proto.load(Ordering::Relaxed) >= 4 { + 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) => { + tracing::warn!( + slot, + error = %format!("{e:#}"), + "in-place resize failed — falling back to 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"); }; @@ -1096,6 +1160,68 @@ 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")?; + 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) }?; + // The OS re-evaluates the target's settable modes asynchronously after UpdateModes2 — wait + // (bounded) for the new resolution to become enumerable before forcing it, else the + // CDS_TEST inside `set_active_mode` would reject it and silently keep the old mode. + let t0 = Instant::now(); + if !crate::win_display::wait_mode_advertised(&gdi, mode, Duration::from_millis(2000)) { + anyhow::bail!( + "OS did not advertise {}x{} within 2s of the driver mode-list update", + mode.width, + mode.height + ); + } + 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 diff --git a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs index 601ae1cd..6d17c3eb 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs @@ -344,7 +344,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. @@ -390,27 +390,43 @@ impl VdisplayDriver for PfVdisplayDriver { .context("pf-vdisplay IOCTL_GET_INFO (version handshake)")?; 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 @@ -427,7 +443,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( @@ -577,6 +593,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"); diff --git a/crates/punktfunk-host/src/windows/win_display.rs b/crates/punktfunk-host/src/windows/win_display.rs index b1f09197..e094364d 100644 --- a/crates/punktfunk-host/src/windows/win_display.rs +++ b/crates/punktfunk-host/src/windows/win_display.rs @@ -258,6 +258,51 @@ pub(crate) unsafe fn wait_mode_settled( } } +/// 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