From 4ed5b88407e8bae8ad8da6eaf8641bc113d2f4b2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 16:44:57 +0200 Subject: [PATCH 01/15] perf(host): replace the Windows bring-up/resize fixed sleeps with verified-state waits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Latency plan P0.2/P0.3/P0.5 (design/first-frame-and-resize-latency.md): - topology settle: the unconditional 1500 ms sleeps after create_monitor's group-topology apply and re_add's reisolate become a 25 ms poll for the committed state (active path + active mode == requested), ceiling 1500 ms — worst case identical, typical case saves ~1.2-1.4 s on every fresh create AND every mid-stream resize. The experimental pnp_disable_monitors sweep keeps the full settle as its floor (it reads OTHER displays' active flags, which the target-scoped wait doesn't verify). - monitor departure: the fixed 400 ms REMOVE settles (re_add + both preempt paths) become a 25 ms poll until the target leaves the active CCD set (2 consecutive absent samples), ceiling 400 ms; the driver-side ghost-reap ADD retry stays the backstop. - activation ladder: 200 ms -> 50 ms sampling, same 3 s per-stage ceilings and the same 3-stage structure. Co-Authored-By: Claude Fable 5 --- .../src/vdisplay/windows/manager.rs | 101 ++++++++++++++---- .../punktfunk-host/src/windows/win_display.rs | 62 +++++++++++ 2 files changed, 141 insertions(+), 22 deletions(-) diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index f2a786e7..17ed52ac 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -35,7 +35,8 @@ use windows::Win32::System::Threading::{ use super::{DisplayOwnership, Mode, VirtualOutput}; use crate::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, }; /// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by @@ -511,9 +512,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 @@ -523,7 +525,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" + ); + } } } @@ -539,9 +550,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" ); @@ -550,8 +562,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)) }; } } @@ -832,8 +845,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) } { @@ -842,8 +859,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); @@ -852,8 +869,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 { crate::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); @@ -1015,19 +1032,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::vdisplay::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 crate::monitor_devnode::disable_connected_inactive(&keep) { @@ -1109,9 +1147,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` @@ -1138,7 +1184,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/punktfunk-host/src/windows/win_display.rs b/crates/punktfunk-host/src/windows/win_display.rs index 51aa019f..b1f09197 100644 --- a/crates/punktfunk-host/src/windows/win_display.rs +++ b/crates/punktfunk-host/src/windows/win_display.rs @@ -226,6 +226,68 @@ pub(crate) 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)); + } +} + +/// 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 From e62cd5448ef7ee5345c6087e26608ba419db1213 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 16:45:16 +0200 Subject: [PATCH 02/15] =?UTF-8?q?perf(host):=20IDD-push=20open=20=E2=80=94?= =?UTF-8?q?=20poll=20the=20HDR-enable=20settle,=20wait=20on=20the=20frame?= =?UTF-8?q?=20event?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Latency plan P0.4/P0.6: the fixed 250 ms advanced-color settle becomes a 25 ms poll of the CCD state (ceiling 250 ms, ring still sized FP16 from the successful enable either way), and wait_for_attach waits on the driver's frame-ready event (20 ms cap for the status-code polls) instead of a blind 20 ms sleep, which also sharpens the P0.1 stage stamps. Co-Authored-By: Claude Fable 5 --- .../src/capture/windows/idd_push.rs | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/punktfunk-host/src/capture/windows/idd_push.rs b/crates/punktfunk-host/src/capture/windows/idd_push.rs index 86994c86..d0dfe60b 100644 --- a/crates/punktfunk-host/src/capture/windows/idd_push.rs +++ b/crates/punktfunk-host/src/capture/windows/idd_push.rs @@ -1028,8 +1028,24 @@ impl IddPushCapturer { let enabled_hdr = client_10bit && crate::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. @@ -1296,7 +1312,14 @@ impl IddPushCapturer { (fullscreen game?); falling back" ); } - 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) }; } } From 8374dfedf30e11f280378d738dfdf595acbd4fe0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 16:45:34 +0200 Subject: [PATCH 03/15] perf(host): session-transition trace + Welcome-time display prep (native path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Latency plan P0.1 + P1.1/P1.2 (design/first-frame-and-resize-latency.md): P0.1 — every native session runs a bringup::Trace (hello -> welcome -> start -> punch_done -> display_acquired -> capture_attached -> first_frame -> encoder_open -> first_au -> first_packet), one summary info! line when the first video packet leaves; each accepted resize runs its own trace (reconfigure -> pipeline_rebuilt). Totals surface per session as time_to_first_frame_ms / last_resize_ms in session_status -> mgmt /status, so every subsequent latency change is measured, not vibed. (The Windows manager logs its own activation/settle deltas — correlate by wall clock.) P1.1/P1.2 — on the Windows native path the display bring-up no longer serializes behind the Start round-trip and the up-to-2.5 s hole-punch wait: a prep thread kicks off at Welcome (mode is final there) and runs monitor create -> activation -> verified settle -> capture attach -> first frame -> encoder open while the network waits are in flight; the data plane hands it the post-punch SessionContext and it becomes the stream thread on a warm pipeline. Abort between Welcome and Start drops the hand-off channel and the prep result releases into the keep-alive machinery (stop/quit + watcher are created pre-handshake so a vanished client also aborts the build retries). Same slot-scoped begin_idd_setup serialization as the inline path. Linux keeps the inline bring-up (launch semantics bind before create); GameStream untouched. Co-Authored-By: Claude Fable 5 --- crates/punktfunk-host/src/bringup.rs | 89 +++++ crates/punktfunk-host/src/main.rs | 1 + crates/punktfunk-host/src/mgmt.rs | 12 + crates/punktfunk-host/src/punktfunk1.rs | 408 ++++++++++++++++---- crates/punktfunk-host/src/session_status.rs | 15 + 5 files changed, 451 insertions(+), 74 deletions(-) create mode 100644 crates/punktfunk-host/src/bringup.rs 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 944f8154..e7a95c67 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 config; mod detect; diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 50f08e36..79e0c6a6 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -391,6 +391,12 @@ 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, @@ -1480,6 +1486,9 @@ async fn get_status(State(st): State>) -> Json { packet_size: c.packet_size as u32, min_fec: c.min_fec, codec: c.codec.into(), + // Transition latencies are traced on the native plane only (latency plan P0.1). + time_to_first_frame_ms: None, + last_resize_ms: None, }) .or_else(|| { native.first().map(|s| StreamInfo { @@ -1492,6 +1501,9 @@ async fn get_status(State(st): State>) -> Json { packet_size: 0, min_fec: 0, codec: s.codec.into(), + time_to_first_frame_ms: (s.time_to_first_frame_ms > 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/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index b6d7e51b..8b84aa3e 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -815,6 +815,38 @@ async fn serve_session( let source = opts.source; let frames = opts.frames; let data_port = opts.data_port; + // 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 below 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 handshake = async { let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; if hello.abi_version != punktfunk_core::WIRE_VERSION { @@ -1149,14 +1181,76 @@ async fn serve_session( host_caps: punktfunk_core::quic::HOST_CAP_GAMEPAD_STATE, }; io::write_msg(&mut 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 above, 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 (quit, stop, trace) = (quit.clone(), stop.clone(), bringup.clone()); + std::thread::Builder::new() + .name("punktfunk1-stream".into()) + .spawn(move || -> Result<()> { + let prepared = 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; let start = Start::decode(&io::read_msg(&mut 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, )) }; - let (hello, welcome, udp_port, data_sock, direct, start, compositor) = + let (hello, welcome, udp_port, data_sock, direct, start, compositor, prep) = tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake) .await .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; @@ -1467,26 +1561,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)); - { - 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); - }); - } + // (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.) // Register this now-live session for mode-conflict admission (Stage 4): carry its identity, the // negotiated mode, and its stop flag so a LATER connecting client's admission can see it and @@ -1655,6 +1731,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 @@ -1684,6 +1764,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, @@ -1709,7 +1790,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, @@ -1736,7 +1817,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), + } } } }) @@ -3588,6 +3691,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, } /// Pack a `(width, height, refresh_hz)` mode into one atomic word (w:16|h:16|hz:16) for the live @@ -3714,6 +3821,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). @@ -4068,9 +4180,15 @@ struct SessionContext { /// so host apps tone-map to the client's real panel) and preferred over the generic baseline /// for the 0xCE mastering metadata. 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. + bringup: Arc, + /// Shared slot the latest completed mid-stream resize total (ms) lands in — registered with + /// `session_status` so the Dashboard shows it. + resize_ms: Arc, } -fn virtual_stream(ctx: SessionContext) -> Result<()> { +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. @@ -4117,6 +4235,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { client_label, launch, client_hdr, + bringup, + resize_ms, } = ctx; tracing::info!( compositor = compositor.id(), @@ -4125,54 +4245,79 @@ 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 @@ -4239,6 +4384,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { codec: plan.codec.label(), client: client_label, bitrate_kbps: live_bitrate.clone(), + bringup: bringup.clone(), }; let send_thread = std::thread::Builder::new() .name("punktfunk-send".into()) @@ -4272,6 +4418,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { plan.codec, stop.clone(), force_idr.clone(), + bringup.total_slot(), + resize_ms.clone(), ); // Mid-stream session-switch watcher (opt-in via PUNKTFUNK_SESSION_WATCH; never under an explicit @@ -4404,6 +4552,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { plan, &quit, &stop, + None, )?; Ok((new_vd, pipe)) })(); @@ -4454,6 +4603,10 @@ 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 @@ -4466,7 +4619,15 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // 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) { + 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!( @@ -4514,6 +4675,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { 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"); } Err(e) => { tracing::warn!(error = %format!("{e:#}"), ?new_mode, @@ -4822,6 +4984,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { plan, &quit, &stop, + None, ) { Ok(p) => break p, Err(e2) => { @@ -5066,6 +5229,7 @@ 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; @@ -5182,6 +5346,83 @@ 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 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. +struct PreparedDisplay { + vd: Box, + 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). +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)] +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 }) +} + +#[allow(clippy::too_many_arguments)] fn build_pipeline_with_retry( vd: &mut Box, mode: punktfunk_core::Mode, @@ -5190,6 +5431,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 @@ -5227,7 +5471,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"); @@ -5302,6 +5546,7 @@ fn reset_stalled_encoder( true } +#[allow(clippy::too_many_arguments)] fn build_pipeline( vd: &mut Box, mode: punktfunk_core::Mode, @@ -5309,6 +5554,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 @@ -5317,6 +5565,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.) @@ -5354,6 +5605,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, @@ -5366,6 +5620,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( @@ -5380,6 +5637,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 f7c6b5cd..5faee2ab 100644 --- a/crates/punktfunk-host/src/session_status.rs +++ b/crates/punktfunk-host/src/session_status.rs @@ -36,6 +36,11 @@ struct LiveSession { /// One-shot force-keyframe flag ([`force_idr_all`] → mgmt `POST /session/idr`); the encode loop /// drains it alongside a client's decode-recovery keyframe request. force_idr: Arc, + /// 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. @@ -46,6 +51,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> { @@ -65,6 +74,8 @@ pub fn register( codec: Codec, stop: Arc, force_idr: Arc, + ttff_ms: Arc, + last_resize_ms: Arc, ) -> LiveSessionGuard { let id = next_id(); registry().lock().unwrap().push(LiveSession { @@ -74,6 +85,8 @@ pub fn register( codec, stop, force_idr, + ttff_ms, + last_resize_ms, }); LiveSessionGuard { id } } @@ -109,6 +122,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() From 32ffe7d634dac33b705edd2fd0a03ca2a71b9549 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 16:48:35 +0200 Subject: [PATCH 04/15] chore(api): regenerate openapi.json (transition-latency fields + held drift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds StreamInfo.time_to_first_frame_ms / last_resize_ms (latency plan P0.1) and folds in the drift the spec already owed from the held working-tree consolidation (version 0.12.0, pnp_disable_monitors description, the conflicting-host 'conflicts' summary field) — the drift test was already red before this branch; it is green at this commit. Co-Authored-By: Claude Fable 5 --- api/openapi.json | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index fc9665d1..bd682363 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.11.0" + "version": "0.12.0" }, "paths": { "/api/v1/clients": { @@ -2546,7 +2546,7 @@ }, "pnp_disable_monitors": { "type": "boolean", - "description": "EXPERIMENTAL (Windows): after an `Exclusive` isolate deactivates the physical monitors,\nadditionally DISABLE their PnP device nodes (persistently, so a standby monitor/TV whose\nhot-plug events re-arrive stays disabled) and re-enable them at restore. Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off." + "description": "EXPERIMENTAL (Windows): DISABLE physical monitors' PnP device nodes for the stream's\nduration (persistently, so a standby monitor/TV whose hot-plug events re-arrive stays\ndisabled) and re-enable them at teardown. Two selectors: the monitors an `Exclusive`\nisolate deactivated, plus — in ANY topology — external monitors that are connected but not\npart of the desktop (the standby TV that was never active, whose input auto-scan /\ninstant-on HPD cycling re-probes the link every few seconds). Targets the same\n\"connected-but-dark head\" periodic-stutter class as [`Self::ddc_power_off`], but at the\nWindows-reaction level: a disabled devnode's wake events trigger no PnP arrival, no CCD\nre-evaluation, no DWM invalidation. A crash-recovery journal re-enables leftovers on host\nstartup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off." }, "preset": { "$ref": "#/components/schemas/Preset" @@ -2990,6 +2990,13 @@ "type": "boolean", "description": "True while the audio stream thread is running." }, + "conflicts": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Other Moonlight-compatible hosts (Sunshine/Apollo/…) detected on this machine at startup —\nrunning one alongside Punktfunk is unsupported. Compact labels (e.g. `Sunshine (running)`);\nthe tray/console surface them so the clash is visible before pairing silently fails." + }, "kept_displays": { "type": "integer", "format": "int32", @@ -3675,6 +3682,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", @@ -3687,6 +3703,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", From 0899e539030379fd65b695057736dfe44b3dc3da Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 17:12:13 +0200 Subject: [PATCH 05/15] =?UTF-8?q?feat(driver):=20pf-vdisplay=20IOCTL=5FUPD?= =?UTF-8?q?ATE=5FMODES=20=E2=80=94=20live=20monitor=20mode-list=20refresh?= =?UTF-8?q?=20(proto=20v4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Latency plan P2.1 (design/first-frame-and-resize-latency.md): a new additive control-plane op lets the host refresh a LIVE monitor's advertised target-mode list to lead with an arbitrary new mode (IddCxMonitorUpdateModes2 — the same IddCx 1.10 *2 family this driver already requires, so no new OS floor). This removes the 'mode list frozen at ADD' constraint that forced the mid-stream resize through a REMOVE->ADD monitor hotplug: the monitor's OS identity, its swap-chain worker and the retained FrameStash all survive an in-place mode set. Protocol v4 is ADDITIVE over v3: the host's handshake floor stays at v3 (MIN_DRIVER_PROTOCOL_VERSION) and gates the in-place path on the reported version, keeping re-arrival as the permanent fallback. The driver's stored mode list is swapped before the DDI and reverted if it fails, so the OS and the mode-DDI callbacks always agree. Co-Authored-By: Claude Fable 5 --- crates/pf-driver-proto/src/lib.rs | 66 ++++++++++++++++++- .../drivers/pf-vdisplay/src/control.rs | 24 +++++++ .../drivers/pf-vdisplay/src/monitor.rs | 65 ++++++++++++++++-- .../windows/drivers/wdk-iddcx/src/lib.rs | 10 +++ 4 files changed, 160 insertions(+), 5 deletions(-) diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 283f585f..56cb18de 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); @@ -889,6 +930,28 @@ 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); + assert!(MIN_DRIVER_PROTOCOL_VERSION <= PROTOCOL_VERSION); + } + #[test] fn gamepad_names_and_magics_are_stable() { assert_eq!(gamepad::xusb_boot_name(0), "Global\\pfxusb-boot-0"); @@ -930,6 +993,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/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..a92ced32 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)] @@ -365,9 +365,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; } @@ -600,6 +598,65 @@ pub fn create_monitor( Some((id, target_id, luid_low, luid_high)) } +/// `IOCTL_UPDATE_MODES` (v4): refresh the LIVE monitor's advertised mode list to lead with a new +/// preferred mode (+ the same [`default_modes`] fallbacks ADD produces) 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 OS re-evaluates the target's +/// settable modes and the HOST then CCD-forces the new mode active. The `*2` (HDR) DDI matches the +/// `*2` mode/buffer family this driver already requires (IddCx 1.10), so it adds no new OS floor. +/// +/// 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 { + let mut new_modes = vec![Mode { + width, + height, + refresh_rates: vec![refresh], + }]; + new_modes.extend(default_modes()); + + // Swap the stored list + grab the live handle under the lock. + let (object, old_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 old = core::mem::replace(&mut m.modes, new_modes.clone()); + (object, old) + }; + + // 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 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( From c2b9b32904baffafe028e4b3760b7f230f2296ed Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 17:12:14 +0200 Subject: [PATCH 06/15] =?UTF-8?q?perf(host):=20in-place=20mid-stream=20res?= =?UTF-8?q?ize=20=E2=80=94=20mode-set=20the=20live=20monitor,=20keep=20the?= =?UTF-8?q?=20capturer?= 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 From c95e9125b9b47222df7f65e553941293261c62a7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 17:17:34 +0200 Subject: [PATCH 07/15] test(host): live in-place resize spike (PUNKTFUNK_PF_VDISPLAY_LIVE) Answers the P2 open questions on real glass with no streaming client: a second same-slot acquire at a different (never-advertised) mode drives the manager's resize branch; in-place success = same OS target id + the new active resolution, with the elapsed ms printed. Co-Authored-By: Claude Fable 5 --- .../src/vdisplay/windows/pf_vdisplay.rs | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs index 6d17c3eb..ff788ac8 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs @@ -773,4 +773,65 @@ 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; + } + 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); + } } From f910d23fb2850ba82e5202e849e456ba58fd1d76 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 17:18:06 +0200 Subject: [PATCH 08/15] fix(proto): drop the constant assertion clippy rejects (CI parity) Co-Authored-By: Claude Fable 5 --- crates/pf-driver-proto/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/pf-driver-proto/src/lib.rs b/crates/pf-driver-proto/src/lib.rs index 56cb18de..4f7e2e1a 100644 --- a/crates/pf-driver-proto/src/lib.rs +++ b/crates/pf-driver-proto/src/lib.rs @@ -949,7 +949,6 @@ mod tests { // 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); - assert!(MIN_DRIVER_PROTOCOL_VERSION <= PROTOCOL_VERSION); } #[test] From 55e59458a28cdb76714f3ed2cba89983765aad10 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 17:36:16 +0200 Subject: [PATCH 09/15] test(host): instrument the live resize spike (tracing + CCD-visibility probe) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-glass finding: from an ssh/schtasks session-0 context QueryDisplayConfig returns nothing at all — the activation ladder is blind there, so the live tests can only run from an INTERACTIVE (desktop) admin prompt on the box; the probe line makes that precondition self-diagnosing. Also verified live: the v4 driver handshake ('pf-vdisplay protocol 4') and ADD on the new driver. Co-Authored-By: Claude Fable 5 --- .../src/vdisplay/windows/pf_vdisplay.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs index ff788ac8..ce3d0ca2 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/pf_vdisplay.rs @@ -786,6 +786,21 @@ mod tests { 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 { From a738de6cd8f4ee3d30c3ee55f896461131caeec0 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 17:48:44 +0200 Subject: [PATCH 10/15] fix(host): force a CCD mode re-enumeration after UPDATE_MODES (in-place resize) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First on-glass run: the driver accepted every UpdateModes2 (0x0 in the driver log) but the OS never re-enumerated the target's settable modes on its own — 'OS did not advertise 800x1050 within 2s' → re-arrival fallback every time. Re-commit the current config with SDC_FORCE_MODE_ENUMERATION (the same nudge the isolate/layout paths already rely on) before the advertised-wait, re-kick up to 3x, and log the actually-offered resolutions when it still misses. Driver: dbglog the *2 mode-query/parse callbacks so the re-enumeration story is visible in pfvd-driver.log. Co-Authored-By: Claude Fable 5 --- .../src/vdisplay/windows/manager.rs | 30 ++++++++-- .../punktfunk-host/src/windows/win_display.rs | 56 +++++++++++++++++++ .../drivers/pf-vdisplay/src/callbacks.rs | 21 +++++++ 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 10033f52..98a44e85 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -1189,15 +1189,33 @@ impl VirtualDisplayManager { // 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. + // The OS does NOT re-evaluate an indirect display's settable modes on its own after + // UpdateModes2 (on-glass: the new mode never became enumerable within 2 s) — force a mode + // re-enumeration by re-committing the current config (the same SDC_FORCE_MODE_ENUMERATION + // re-commit the isolate/layout paths use), then wait for the new resolution to appear, + // re-kicking a couple of times. Without it the CDS_TEST inside `set_active_mode` would + // reject the mode and silently keep the old one. let t0 = Instant::now(); - if !crate::win_display::wait_mode_advertised(&gdi, mode, Duration::from_millis(2000)) { + let mut advertised = false; + for kick in 0..3u32 { + // 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(1000)) { + advertised = true; + break; + } + tracing::debug!( + kick, + "in-place resize: new mode not yet enumerable — forcing another mode re-enumeration" + ); + } + if !advertised { anyhow::bail!( - "OS did not advertise {}x{} within 2s of the driver mode-list update", + "OS did not advertise {}x{} within {}ms of the driver mode-list update (offers: {:?})", mode.width, - mode.height + mode.height, + t0.elapsed().as_millis(), + crate::win_display::advertised_resolutions(&gdi) ); } let advertised_ms = t0.elapsed().as_millis() as u64; diff --git a/crates/punktfunk-host/src/windows/win_display.rs b/crates/punktfunk-host/src/windows/win_display.rs index e094364d..91059199 100644 --- a/crates/punktfunk-host/src/windows/win_display.rs +++ b/crates/punktfunk-host/src/windows/win_display.rs @@ -258,6 +258,62 @@ pub(crate) unsafe fn wait_mode_settled( } } +/// 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 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. From 45c29a99d54ff2e369d2f4aa9f935b784d989800 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 16 Jul 2026 18:01:36 +0200 Subject: [PATCH 11/15] perf(host+driver): in-place resize = advertised-mode fast path + mode-history union MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-glass round 2 settled the mechanism: after UpdateModes2 the OS re-parses our description AND re-queries target modes (driver log — both 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, below anything the driver can refresh. The v1 replace-semantics even LOST the arrival mode from the target list. Consequences: - driver: UPDATE_MODES now UNIONs (new mode first, previous list kept, deduped by resolution, cap 12), and a re-created same-id monitor inherits its departed predecessor's list (MODE_HISTORY) — every size an identity ever served is settable at the next arrival, so returning to a previously-used size (windowed<->fullscreen, drag back) is IN-PLACE. - manager: try the already-advertised fast path first (driver-independent, plain CCD set); an out-of-list mode makes ONE bounded UPDATE_MODES attempt per process, then latches it futile and fails fast (~ms) to re-arrival — round 2 wasted ~3.1 s per arbitrary resize on the doomed wait. Fallback log demoted warn->info (expected-normal for first-seen sizes). Co-Authored-By: Claude Fable 5 --- .../src/vdisplay/windows/manager.rs | 121 +++++++++++------- .../drivers/pf-vdisplay/src/monitor.rs | 91 ++++++++++--- 2 files changed, 149 insertions(+), 63 deletions(-) diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 98a44e85..a8ba2e30 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -268,8 +268,15 @@ pub(crate) struct VirtualDisplayManager { 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. + /// 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, @@ -301,6 +308,7 @@ pub(crate) fn init(driver: Box) -> &'static VirtualDisplayMa 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(()), @@ -598,13 +606,14 @@ 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 { + // 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 { @@ -629,10 +638,13 @@ impl VirtualDisplayManager { Some(out) } Err(e) => { - tracing::warn!( + // 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, - error = %format!("{e:#}"), - "in-place resize failed — falling back to monitor re-arrival" + reason = %format!("{e:#}"), + "in-place resize not possible — monitor re-arrival" ); None } @@ -1177,46 +1189,61 @@ impl VirtualDisplayManager { .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 does NOT re-evaluate an indirect display's settable modes on its own after - // UpdateModes2 (on-glass: the new mode never became enumerable within 2 s) — force a mode - // re-enumeration by re-committing the current config (the same SDC_FORCE_MODE_ENUMERATION - // re-commit the isolate/layout paths use), then wait for the new resolution to appear, - // re-kicking a couple of times. Without it the CDS_TEST inside `set_active_mode` would - // reject the mode and silently keep the old one. let t0 = Instant::now(); - let mut advertised = false; - for kick in 0..3u32 { + // 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(1000)) { - advertised = true; - break; + 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) + ); } - tracing::debug!( - kick, - "in-place resize: new mode not yet enumerable — forcing another mode re-enumeration" - ); - } - if !advertised { - anyhow::bail!( - "OS did not advertise {}x{} within {}ms of the driver mode-list update (offers: {:?})", - 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); diff --git a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs index a92ced32..31c72542 100644 --- a/packaging/windows/drivers/pf-vdisplay/src/monitor.rs +++ b/packaging/windows/drivers/pf-vdisplay/src/monitor.rs @@ -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![ @@ -494,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, @@ -598,28 +642,34 @@ 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 (+ the same [`default_modes`] fallbacks ADD produces) 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 OS re-evaluates the target's -/// settable modes and the HOST then CCD-forces the new mode active. The `*2` (HDR) DDI matches the -/// `*2` mode/buffer family this driver already requires (IddCx 1.10), so it adds no new OS floor. +/// 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 { - let mut new_modes = vec![Mode { - width, - height, - refresh_rates: vec![refresh], - }]; - new_modes.extend(default_modes()); - - // Swap the stored list + grab the live handle under the lock. - let (object, old_modes) = { + // 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; @@ -627,8 +677,14 @@ pub fn update_monitor_modes(session_id: u64, width: u32, height: u32, refresh: u 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) + (object, old, new_modes) }; // The OS's target-mode list for this monitor (the `*2`/HDR shape, like `monitor_query_modes2`). @@ -668,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. From 18a5d93ae3f62911b441fc3fb915b7dc127c22a8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 16:09:57 +0200 Subject: [PATCH 12/15] fix(host): allow too_many_arguments on the two fns the v4 merge grew Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/native/handshake.rs | 2 +- crates/punktfunk-host/src/session_status.rs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 210dbf05..64614c25 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -10,7 +10,7 @@ use super::*; /// Run the Hello→Welcome→Start negotiation. Borrows the control streams (the caller keeps them for /// mid-stream renegotiation afterwards). `first` is the already-read first control message. -#[allow(clippy::type_complexity)] +#[allow(clippy::type_complexity, clippy::too_many_arguments)] pub(super) async fn negotiate( conn: &quinn::Connection, send: &mut quinn::SendStream, diff --git a/crates/punktfunk-host/src/session_status.rs b/crates/punktfunk-host/src/session_status.rs index 0e35c8da..1524d9c0 100644 --- a/crates/punktfunk-host/src/session_status.rs +++ b/crates/punktfunk-host/src/session_status.rs @@ -85,6 +85,7 @@ fn session_ref(s: &LiveSession) -> crate::events::SessionRef { /// Registers a live native session; the returned guard removes it on drop (session end). /// Emits the `session.started` lifecycle event; the guard's drop emits `session.ended` — RAII, /// so every exit path (return, `?`, panic-unwind) pairs them. +#[allow(clippy::too_many_arguments)] pub fn register( mode: Arc, bitrate_kbps: Arc, From 11974152167762f60cc564c34750919111a95ba2 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 16:11:34 +0200 Subject: [PATCH 13/15] fix(pf-vdisplay,pf-win-display): v4 trait surface on the extracted driver.rs + cross-crate visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The W-refactor extracted VdisplayDriver into manager/driver.rs (the merge resolution assumed it deleted) — carry the v4 changes there: open() returns the driver's protocol version, update_modes() default-errs to the re-arrival fallback. wait_target_departed goes pub for the manager's cross-crate call. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/vdisplay/windows/manager/driver.rs | 16 ++++++++++++++-- crates/pf-win-display/src/win_display.rs | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs index 9d208ef6..ccc8f40d 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager/driver.rs @@ -34,11 +34,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 @@ -56,6 +57,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 diff --git a/crates/pf-win-display/src/win_display.rs b/crates/pf-win-display/src/win_display.rs index e2b6fac7..523477cd 100644 --- a/crates/pf-win-display/src/win_display.rs +++ b/crates/pf-win-display/src/win_display.rs @@ -375,7 +375,7 @@ pub(crate) fn wait_mode_advertised( /// /// # 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 { +pub 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 { From c8ee4b99023bd99592c5e476eba5461fc7f3b7ca Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 16:13:15 +0200 Subject: [PATCH 14/15] fix(pf-vdisplay,pf-capture,pf-win-display): pre-split paths in the auto-merged v4 code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rename-followed perf hunks still said crate::win_display:: (the pre-W6 layout) — point them at pf_win_display::win_display:: and widen the four helpers they call cross-crate. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/pf-capture/src/windows/idd_push.rs | 4 +++- .../pf-vdisplay/src/vdisplay/windows/manager.rs | 12 ++++++++---- crates/pf-win-display/src/win_display.rs | 16 ++++------------ 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index f4f57452..6626fcc6 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -706,7 +706,9 @@ impl IddPushCapturer { // 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) { + if pf_win_display::win_display::advanced_color_enabled(target.target_id) + == Some(true) + { break; } std::thread::sleep(Duration::from_millis(25)); diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs index 9fdeaeac..bc62ac21 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs @@ -1085,7 +1085,7 @@ impl VirtualDisplayManager { // 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); + let already = pf_win_display::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 @@ -1124,8 +1124,12 @@ impl VirtualDisplayManager { // 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)) { + unsafe { pf_win_display::win_display::force_mode_reenumeration() }; + if !pf_win_display::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 \ @@ -1133,7 +1137,7 @@ impl VirtualDisplayManager { mode.width, mode.height, t0.elapsed().as_millis(), - crate::win_display::advertised_resolutions(&gdi) + pf_win_display::win_display::advertised_resolutions(&gdi) ); } } diff --git a/crates/pf-win-display/src/win_display.rs b/crates/pf-win-display/src/win_display.rs index 523477cd..39b44174 100644 --- a/crates/pf-win-display/src/win_display.rs +++ b/crates/pf-win-display/src/win_display.rs @@ -243,11 +243,7 @@ pub unsafe fn active_resolution(target_id: u32) -> Option<(u32, u32)> { /// /// # 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 { +pub 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' @@ -272,7 +268,7 @@ pub(crate) unsafe fn wait_mode_settled( /// /// # Safety /// Runs the CCD query/apply FFI; call under the manager `state` lock (sole topology mutator). -pub(crate) unsafe fn force_mode_reenumeration() -> bool { +pub unsafe fn force_mode_reenumeration() -> bool { let Some((paths, modes)) = query_active_config() else { return false; }; @@ -292,7 +288,7 @@ pub(crate) unsafe fn force_mode_reenumeration() -> bool { /// 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)> { +pub 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; @@ -325,11 +321,7 @@ pub(crate) fn advertised_resolutions(gdi_name: &str) -> Vec<(u32, u32)> { /// 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 { +pub 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 { From 6c976e9dc55f302307f89f38c393361093055854 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 17 Jul 2026 16:20:39 +0200 Subject: [PATCH 15/15] fix(apple): land the RenderScale/DefaultsKeys definitions the client refactor references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The W7/W8 client reconciliation committed the CONSUMERS of the render-scale setting and the toggle-fullscreen notification (ContentView/FullscreenController et al.) while their definitions were still uncommitted working-tree state from the Apple-features effort — apple.yml red on main (run 10657). Lands the two definition files (PunktfunkShared/RenderScale.swift + the DefaultsKeys additions) so main builds; the rest of that effort's WIP stays in its working tree. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../PunktfunkShared/DefaultsKeys.swift | 29 ++++++++ .../Sources/PunktfunkShared/RenderScale.swift | 72 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 clients/apple/Sources/PunktfunkShared/RenderScale.swift diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index ae3a9d40..f7c9073b 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -21,6 +21,14 @@ public enum DefaultsKey { /// is native either way, so this degenerates to Auto-native there). Read per session by the /// stream views' `MatchWindowFollower`. public static let matchWindow = "punktfunk.matchWindow" + /// Render-resolution multiplier (a `RenderScale` value, default 1.0): the client asks the host + /// to render/encode at `chosen resolution × scale`, then the presenter downscales the larger + /// decoded frame to this display in one Catmull-Rom pass. > 1 supersamples (sharper, at the cost + /// of more bandwidth AND client decode — both grow ∝ scale²); < 1 renders below native for a + /// weak host GPU / constrained link (the presenter upscales). Purely client-side — the host just + /// sees a normal (larger/smaller) `Mode`, and Automatic bitrate scales with it. Clamped even + + /// to the codec's max dimension at connect. Applies to the fixed mode and the match-window path. + public static let renderScale = "punktfunk.renderScale" public static let compositor = "punktfunk.compositor" public static let gamepadType = "punktfunk.gamepadType" public static let gamepadID = "punktfunk.gamepadID" @@ -69,6 +77,21 @@ public enum DefaultsKey { public static let hosts = "punktfunk.hosts" /// Client-side cursor mode: "auto" (shown only in gamescope sessions), "always", "never". public static let cursorMode = "punktfunk.cursorMode" + /// Invert the scroll-wheel / two-finger-scroll direction sent to the host (both axes). Off by + /// default: the local (natural-scrolling) sign passes through untouched. When on, the sign is + /// negated at the single scroll sink (`InputCapture.sendScroll`), so it flips consistently across + /// the macOS wheel, the iOS trackpad pan, and a GCMouse wheel. For users whose host expects the + /// opposite convention from their local OS preference. + public static let invertScroll = "punktfunk.invertScroll" + /// Location-based modifier mapping (a `ModifierLayout` value, default `.mac`): which Windows VK + /// each PHYSICAL modifier position forwards to the host. `.mac` keeps ⌥ Option → Alt and + /// ⌘ Command → Super/Win (the Apple positions). `.windows` swaps the Alt/Super ROLE between the + /// Option and Command keys — preserving side (L/R) — so the key nearest the space bar acts as + /// Alt and the next one as the Windows key, matching a Windows keyboard's `Ctrl / ⊞ / Alt` row. + /// Only what's FORWARDED changes; client-local shortcuts (⌘⎋ &co.) stay on the physical ⌘ key. + /// Read live at the wire boundary by `InputCapture`. Control/Shift never move (same position on + /// both keyboards). + public static let modifierLayout = "punktfunk.modifierLayout" /// iPad: capture the mouse/trackpad pointer (pointer lock → relative movement) for games, /// rather than forwarding an absolute cursor position. On by default. Only meaningful on iPad /// with a hardware mouse/trackpad; the system grants the lock only to a full-screen, frontmost @@ -132,6 +155,12 @@ extension Notification.Name { /// discoverable menu-bar surface. public static let punktfunkReleaseCapture = Notification.Name("io.unom.punktfunk.release-capture") + /// Posted by the app's Stream menu ("Toggle Fullscreen", ⌃⌘F) and by InputCapture's monitor + /// when the same combo fires while input is captured (the menu key-equivalent never reaches a + /// captured stream view). The key window's `FullscreenController` flips the window's fullscreen + /// state. macOS only. + public static let punktfunkToggleFullscreen = Notification.Name("io.unom.punktfunk.toggle-fullscreen") + /// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`, /// which runs in the app's process): the app tears the active session down deliberately /// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` — diff --git a/clients/apple/Sources/PunktfunkShared/RenderScale.swift b/clients/apple/Sources/PunktfunkShared/RenderScale.swift new file mode 100644 index 00000000..57c93a16 --- /dev/null +++ b/clients/apple/Sources/PunktfunkShared/RenderScale.swift @@ -0,0 +1,72 @@ +// Render-resolution scaling — the pure geometry behind `DefaultsKey.renderScale`. The client asks +// the host to render/encode at `chosen resolution × scale` and lets the presenter downscale the +// larger decoded frame to the display (a Catmull-Rom minification, > 1 = supersampling for +// sharpness) or upscale a smaller one (< 1 = a performance mode for a weak host GPU / thin link). +// +// This is where the multiplier is turned into a host-valid `Mode` dimension: multiply, preserve the +// aspect ratio, floor to even (the host's `validate_dimensions` rejects odd sizes), and clamp to the +// codec's per-axis ceiling so the connect can't ask for something the encoder will reject. Kept +// dependency-free + side-effect-free so it's unit-tested (`RenderScaleTests`) and reused by both the +// fixed-mode connect and the match-window follower. + +import Foundation + +public enum RenderScale { + /// The supported multiplier range. Below 1 renders under native (upscaled on present); above 1 + /// supersamples. The UI clamps its slider to this and the connect clamps the raw stored value. + public static let range: ClosedRange = 0.5...4.0 + + /// The multipliers the picker offers. 1.0 (Native) is the default; the rest are the round stops + /// users reason about. + public static let presets: [Double] = [0.5, 0.67, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0, 4.0] + + /// The encoder/host per-axis ceiling for a codec preference string (`DefaultsKey.codec`). H.264 + /// tops out at 4096 px/axis; HEVC / AV1 / PyroWave (and "auto", which negotiates one of those in + /// practice) at 8192. The host enforces the same walls in `codec.rs::validate_dimensions`. + public static func maxDimension(codec: String) -> Int { + codec == "h264" ? 4096 : 8192 + } + + /// A compact user-facing label for a multiplier: "Native (1×)", "1.5×", "2× · supersample". + /// Shared by every platform's picker so the wording stays identical. + public static func label(_ scale: Double) -> String { + if scale == 1.0 { return "Native (1×)" } + let magnitude = String(format: "%g×", scale) + return scale > 1 ? "\(magnitude) · supersample" : magnitude + } + + /// Clamp a raw stored multiplier into `range`, treating a missing/zero value as 1.0 (Native). + public static func sanitize(_ raw: Double) -> Double { + guard raw > 0 else { return 1.0 } + return min(max(raw, range.lowerBound), range.upperBound) + } + + /// Apply `scale` to a base pixel size, preserving aspect, even-flooring each axis, and clamping + /// uniformly so neither axis exceeds `maxDimension` (the larger axis lands on the cap, the ratio + /// is kept). Also floors each axis at `minWidth`/`minHeight` (the host never accepts < 320×200). + /// The result is a directly host-valid `Mode` width/height. + public static func apply( + baseWidth: Int, + baseHeight: Int, + scale rawScale: Double, + maxDimension: Int, + minWidth: Int = 320, + minHeight: Int = 200 + ) -> (width: UInt32, height: UInt32) { + let scale = sanitize(rawScale) + var w = Double(max(baseWidth, 1)) * scale + var h = Double(max(baseHeight, 1)) * scale + // Uniform down-clamp if either axis blew past the ceiling — keep the aspect ratio intact. + let cap = Double(maxDimension) + let over = max(w / cap, h / cap) + if over > 1 { + w /= over + h /= over + } + let evenFloor: (Double, Int) -> UInt32 = { value, minimum in + let clamped = max(Int(value.rounded(.down)), minimum) + return UInt32(clamped / 2 * 2) + } + return (evenFloor(w, minWidth), evenFloor(h, minHeight)) + } +}