diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs index 3366a11f..dd6848ca 100644 --- a/crates/pf-capture/src/lib.rs +++ b/crates/pf-capture/src/lib.rs @@ -179,6 +179,20 @@ pub trait Capturer: Send { fn resize_output(&mut self, _width: u32, _height: u32) -> bool { false } + + /// Recreate the delivery ring at the CURRENT mode and re-run the driver attach handshake — + /// the recovery half of a swap-chain bounce the descriptor poller cannot see: an + /// exclusive-topology eviction (the vdisplay re-assert watchdog) is a real topology change, + /// so the OS drives COMMIT_MODES on the live virtual display too and the driver's swap-chain + /// is recreated while this capturer keeps waiting on the old ring attachment — frames stop + /// with an unchanged descriptor (same mode, same HDR), so the two-strike debounce never + /// trips. Arms the same recover-or-drop window as a real resize, so a driver that cannot + /// re-attach still fails the session cleanly. Returns `true` when handled; `false` (the + /// default) means the backend has no in-place ring recovery and the caller should treat the + /// pipeline as unrecoverable in place. + fn recreate_ring_in_place(&mut self) -> bool { + false + } } /// A deterministic moving test pattern (BGRx). Lets the spike exercise the encode → file → diff --git a/crates/pf-capture/src/windows/idd_push.rs b/crates/pf-capture/src/windows/idd_push.rs index 0a2c2198..3b62ded0 100644 --- a/crates/pf-capture/src/windows/idd_push.rs +++ b/crates/pf-capture/src/windows/idd_push.rs @@ -1786,6 +1786,49 @@ impl Capturer for IddPushCapturer { } true } + + fn recreate_ring_in_place(&mut self) -> bool { + // Same-mode ring recreate (trait doc: swap-chain bounce recovery) — deliberately NOT + // routed through `resize_output`, whose same-size fast path would no-op exactly the + // case this exists for. Same recover-or-drop arming as the resize recreate. + // + // Restart OS presentation FIRST: the eviction's topology commit leaves DWM not + // presenting to this display, so a re-attached ring would only ever receive the + // driver's stash (measured: new_fps=0 forever after the re-attach). CDS_RESET forces a + // real mode-set at the CURRENT mode — the same lever bring-up's ADD path relies on — + // and the ring recreate below then re-attaches after that churn, not before it. + // SAFETY: `resolve_gdi_name` runs the CCD query FFI over a `Copy` target id (owned + // return) — same contract as every sibling call in this file. + match unsafe { pf_win_display::win_display::resolve_gdi_name(self.target_id) } { + Some(gdi) => { + if !pf_win_display::win_display::force_mode_reset(&gdi) { + tracing::warn!( + target_id = self.target_id, + "IDD push: presentation-restart mode reset failed — re-attaching anyway" + ); + } + } + None => tracing::warn!( + target_id = self.target_id, + "IDD push: no GDI name for the presentation-restart mode reset — re-attaching \ + anyway" + ), + } + tracing::info!( + target_id = self.target_id, + mode = format!("{}x{}", self.width, self.height), + "IDD push: same-mode ring recreate — re-running the driver attach handshake" + ); + self.recovering_since.get_or_insert_with(Instant::now); + if let Err(e) = self.recreate_ring(self.display_hdr, self.width, self.height) { + tracing::warn!( + error = %format!("{e:#}"), + "IDD push: same-mode ring recreate failed" + ); + return false; + } + true + } } /// A 4:4:4 session while the display is HDR: there is no 10-bit full-chroma source (the FP16 diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs index def7f9b8..859ceb7b 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager.rs @@ -159,6 +159,10 @@ struct GroupState { /// PnP instance ids of monitor devnodes the EXPERIMENTAL `pnp_disable_monitors` axis disabled at /// the group's first isolate — last-member teardown re-enables them BEFORE the CCD restore. pnp_disabled: Vec, + /// Whether `ccd_saved` was captured by an EXCLUSIVE isolate (vs `Primary`, which also + /// snapshots but deliberately keeps the physical displays active) — gates the re-assert + /// watchdog, which must never "fix" a Primary group's lit panels. Cleared with the restore. + ccd_exclusive: bool, } /// The manager's guarded state: the slot map + the (single) group record. One lock for both — every @@ -183,7 +187,8 @@ impl MgrInner { } /// The single device-level watchdog pinger, running while ANY slot lives (any IOCTL bumps the driver -/// watchdog, so one thread serves N monitors). +/// watchdog, so one thread serves N monitors). Also reused as the stop+join pair for the +/// exclusive-topology re-assert watchdog (same lifecycle shape). struct Pinger { stop: Arc, thread: JoinHandle<()>, @@ -240,6 +245,10 @@ pub struct VirtualDisplayManager { idd_session_stops: Mutex>>, /// The device-level watchdog [`Pinger`], running while any slot lives. pinger: Mutex>, + /// The exclusive-topology re-assert watchdog (same stop+join shape as [`Pinger`]), running + /// while an EXCLUSIVE group isolate is live — a verified isolate is not durable on hybrid + /// boxes (see [`Self::ensure_exclusive_watch`]). + exclusive_watch: Mutex>, // The per-client stable monitor-id map is now the process-wide `super::identity::global()` // (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE // resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx @@ -248,6 +257,19 @@ pub struct VirtualDisplayManager { static VDM: OnceLock = OnceLock::new(); +/// Bumped on every exclusive-topology EVICTION the re-assert watchdog performs. An eviction is a +/// real topology change, and the OS drives COMMIT_MODES on every live path for those — the IDD +/// display's swap-chain is recreated driver-side while the session's capture ring keeps waiting +/// on the old attachment, so frames stop silently (on-glass: panel evicted, stream frozen). The +/// manager can only announce; the SESSION owns the ring + encoder — stream loops sample this at +/// start and rebuild their capture attachment in place when it advances. +static TOPOLOGY_REASSERT_GEN: AtomicU64 = AtomicU64::new(0); + +/// The current exclusive-eviction generation (see [`TOPOLOGY_REASSERT_GEN`]). +pub fn topology_reassert_gen() -> u64 { + TOPOLOGY_REASSERT_GEN.load(Ordering::Relaxed) +} + /// Initialise the process-wide manager with `driver` (the chosen backend) and return it. Idempotent: the /// first backend to call wins (the host runs one backend per process), so a later call ignores its driver. pub(crate) fn init(driver: Box) -> &'static VirtualDisplayManager { @@ -262,6 +284,7 @@ pub(crate) fn init(driver: Box) -> &'static VirtualDisplayMa setup_lock: Mutex::new(()), idd_session_stops: Mutex::new(std::collections::HashMap::new()), pinger: Mutex::new(None), + exclusive_watch: Mutex::new(None), }) } @@ -775,6 +798,130 @@ impl VirtualDisplayManager { } } + /// Start the exclusive-topology re-assert watchdog (idempotent). A verified + /// [`isolate_displays_ccd`] is NOT durable: the isolated topology is deliberately not saved to + /// the CCD database (teardown must restore the user's layout), so any later topology + /// re-resolution — our own post-isolate resize/HDR commit, the deactivated panel's own driver, + /// display-poller software — can bring the stored layout back. Field-proven on a hybrid + /// Intel+NVIDIA box (`.221`): seconds after the commit-time verify reported "sole active + /// desktop", CCD showed the internal panel ACTIVE again beside the virtual display, and the + /// host never noticed. That silently un-does exclusive mode: cursor + windows can land + /// off-stream, and the secure desktop / lock screen can land on the physical panel. + /// + /// The watchdog re-queries every [`knobs::exclusive_reassert_ms`] and, when a non-managed + /// display crept back, evicts it via the full [`isolate_displays_ccd`] — the forced + /// re-commit is required (it restarts OS presentation to the virtual display), and the + /// swap-chain bounce it causes is healed by the SESSION's in-place capture re-attach, keyed + /// off [`topology_reassert_gen`]. Each cycle takes the state lock via `try_lock` — + /// teardown stops+joins this thread while HOLDING the state lock, so a blocking `lock()` here + /// would deadlock; a contended cycle just skips (the slot transition that owns the lock + /// re-isolates itself). The sleep is sliced so that stop+join is bounded by ~250 ms, not a + /// full cycle. + fn ensure_exclusive_watch(&'static self) { + let interval = Duration::from_millis(knobs::exclusive_reassert_ms()); + if interval.is_zero() { + return; // knob 0 = disabled (the pre-watchdog behavior) + } + let mut guard = self.exclusive_watch.lock().unwrap(); + if guard.is_some() { + return; + } + let stop = Arc::new(AtomicBool::new(false)); + let stop_t = stop.clone(); + let thread = thread::Builder::new() + .name("vdisplay-exclusive-watch".into()) + .spawn(move || { + // Consecutive cycles that found (and evicted) a non-managed display — resets when a + // cycle comes up clean, so an actor that re-adds the panel every few minutes logs a + // WARN each time, while one fighting every cycle escalates once and then goes quiet. + let mut fighting = 0u32; + 'watch: loop { + let mut slept = Duration::ZERO; + while slept < interval { + if stop_t.load(Ordering::Relaxed) { + break 'watch; + } + let slice = Duration::from_millis(250).min(interval - slept); + thread::sleep(slice); + slept += slice; + } + let Ok(inner) = vdm().state.try_lock() else { + continue; + }; + if inner.group.ccd_saved.is_none() || !inner.group.ccd_exclusive { + continue; // no exclusive isolate live right now + } + let keep = inner.target_ids(); + if keep.is_empty() { + continue; + } + // SAFETY: `count_other_active` runs the CCD QueryDisplayConfig FFI over a + // borrowed slice of `Copy` target ids (owned result), under the `state` lock + // (`inner` guard held to the end of this iteration). + let survivors = unsafe { count_other_active(&keep) }.unwrap_or(0); + if survivors == 0 { + if fighting > 0 { + tracing::info!( + reasserts = fighting, + "exclusive topology stable again — no non-managed display active" + ); + } + fighting = 0; + continue; + } + fighting += 1; + match fighting { + 1..=3 => tracing::warn!( + survivors, + round = fighting, + "exclusive topology lost — a non-managed display re-activated after \ + the verified isolate (hybrid-GPU driver / display-poller software \ + restoring the saved layout?); re-asserting the isolate" + ), + 4 => tracing::error!( + survivors, + "exclusive topology keeps being re-activated (4 consecutive \ + re-asserts) — something on this host is fighting the isolate; \ + continuing to re-assert every cycle, further rounds log at DEBUG" + ), + _ => tracing::debug!( + survivors, + round = fighting, + "re-asserting exclusive topology" + ), + } + // SAFETY: `isolate_displays_ccd` drives the CCD query/apply FFI over a + // borrowed slice of `Copy` target ids, under the `state` lock — the sole + // topology mutator. The returned snapshot is discarded (the group restores + // the FIRST member's). The FULL isolate on purpose — its forced re-commit + // (SDC_FORCE_MODE_ENUMERATION → COMMIT_MODES → ASSIGN_SWAPCHAIN) is + // load-bearing here exactly as at first isolate: after the eviction's + // topology change the OS stops presenting to the virtual display until a + // forced mode re-commit restarts it (on-glass: a gentle supplied-config + // eviction left capture receiving ONE stashed frame and then nothing). + let _ = unsafe { isolate_displays_ccd(&keep) }; + // That same forced re-commit hands the live IDD path a fresh swap-chain, + // orphaning the session's capture ring — announce it so the session rebuilds + // its capture attachment (same-mode ring recreate + driver re-attach + fresh + // encoder) instead of silently streaming a frozen frame. The two halves are + // a pair: eviction restarts presentation, the session re-attaches capture. + TOPOLOGY_REASSERT_GEN.fetch_add(1, Ordering::Relaxed); + } + }) + .expect("spawn vdisplay-exclusive-watch"); + *guard = Some(Pinger { stop, thread }); + } + + /// Stop + join the exclusive-topology watchdog (the group's exclusive isolate is being + /// restored). Safe under the state lock: the watchdog only ever `try_lock`s it, and its sliced + /// sleep bounds the join by ~250 ms. + fn stop_exclusive_watch(&self) { + if let Some(w) = self.exclusive_watch.lock().unwrap().take() { + w.stop.store(true, Ordering::Relaxed); + let _ = w.thread.join(); + } + } + /// Arrange the live slots' desktop origins (design §6.2: the pure `vdisplay/layout.rs` engine — /// `auto-row` default, console `manual` pins win) and commit them in one CCD apply. No-ops for a /// single member (it sits at the origin), so the single-display path issues no positioning at @@ -998,6 +1145,12 @@ impl VirtualDisplayManager { ); } } + // The verified isolate above is not durable — watch and re-assert + // while the exclusive group lives (see `ensure_exclusive_watch`). + inner.group.ccd_exclusive = inner.group.ccd_saved.is_some(); + if inner.group.ccd_exclusive { + self.ensure_exclusive_watch(); + } } else { // Grown set: re-isolate so the fresh member joins the composited set // (its auto-activate may have lit nothing extra to deactivate, but the @@ -1413,6 +1566,10 @@ impl VirtualDisplayManager { // (stopped FIRST, as the per-monitor pinger was), and the group's topology restore runs // — first-in captured it, last-out restores it (design §6.1). self.stop_pinger(); + // The exclusive re-assert watchdog must be gone BEFORE the restore below — it would + // read the restored (multi-display) topology as "lost exclusivity" and re-fight it. + // (Belt-and-braces: its cycles also gate on `ccd_exclusive`, cleared below.) + self.stop_exclusive_watch(); // EXPERIMENTAL `pnp_disable_monitors` restore: re-enable the devnodes FIRST and let // them re-arrive, so a CCD restore below re-activates paths whose monitors exist // again (a disabled devnode would leave the restored path modeless/EDID-less). @@ -1425,6 +1582,7 @@ impl VirtualDisplayManager { } // Re-attach detached display(s) BEFORE the REMOVE so the box is never left with zero // displays. + inner.group.ccd_exclusive = false; if let Some(saved) = inner.group.ccd_saved.take() { restore_displays_ccd(&saved); // EXPERIMENTAL `ddc_power_off` wake: the restore re-activated the physical paths, and diff --git a/crates/pf-vdisplay/src/vdisplay/windows/manager/knobs.rs b/crates/pf-vdisplay/src/vdisplay/windows/manager/knobs.rs index dc8ce4fb..1c0f0241 100644 --- a/crates/pf-vdisplay/src/vdisplay/windows/manager/knobs.rs +++ b/crates/pf-vdisplay/src/vdisplay/windows/manager/knobs.rs @@ -34,6 +34,16 @@ pub(super) fn keep_alive_forever() -> bool { .unwrap_or(false) } +/// Cadence of the exclusive-topology re-assert watchdog (`PUNKTFUNK_EXCLUSIVE_REASSERT_MS`, +/// default 2000, `0` disables — the pre-watchdog behavior). Why it exists: a verified isolate is +/// not durable — see `VirtualDisplayManager::ensure_exclusive_watch` in the parent module. +pub(super) fn exclusive_reassert_ms() -> u64 { + std::env::var("PUNKTFUNK_EXCLUSIVE_REASSERT_MS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(2_000) +} + /// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's /// [`effective_topology`](crate::effective_topology) when configured, else the legacy /// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD diff --git a/crates/pf-win-display/src/win_display.rs b/crates/pf-win-display/src/win_display.rs index 5d1a99d6..74677ccc 100644 --- a/crates/pf-win-display/src/win_display.rs +++ b/crates/pf-win-display/src/win_display.rs @@ -43,9 +43,9 @@ use windows::Win32::Devices::Display::{ }; use windows::Win32::Foundation::POINTL; use windows::Win32::Graphics::Gdi::{ - ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW, - DISP_CHANGE_FAILED, DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, - DM_PELSWIDTH, ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE, + ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_RESET, CDS_TEST, CDS_UPDATEREGISTRY, + DEVMODEW, DISP_CHANGE_FAILED, DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, + DM_PELSHEIGHT, DM_PELSWIDTH, ENUM_CURRENT_SETTINGS, ENUM_DISPLAY_SETTINGS_MODE, }; use punktfunk_core::Mode; @@ -540,6 +540,50 @@ pub unsafe fn sdr_white_level_scale(target_id: u32) -> Option { /// mode the driver didn't advertise just leaves the default instead of erroring the session. // pub so vdisplay::pf_vdisplay can reuse this backend-neutral CCD/GDI mode-set helper // (a pf-vdisplay monitor's GDI name is a real OS device name, so it works unchanged). +/// Force a REAL mode-set at the output's CURRENT mode — `CDS_RESET` applies even when nothing +/// changed (a plain re-apply of the same mode is treated as a no-op by the OS). This is the +/// presentation-restart hammer for a virtual display DWM silently stopped composing to after an +/// exclusive-eviction topology commit: measured on-glass, the eviction's forced re-commit +/// reassigned the swap-chain and the ring re-attach delivered the driver's stashed frame, but the +/// source's new-frame rate stayed 0 forever — only a real mode-set (the lever bring-up's ADD path +/// relies on via [`set_active_mode`]) makes the OS present again. Same input-desktop retry as the +/// mode-set proper. Returns `true` when the reset applied. +pub fn force_mode_reset(gdi_name: &str) -> bool { + let wname: Vec = gdi_name.encode_utf16().chain(std::iter::once(0)).collect(); + let mut dm = DEVMODEW { + dmSize: size_of::() as u16, + ..Default::default() + }; + // SAFETY: `wname` is a live NUL-terminated UTF-16 device name and `&mut dm` a live DEVMODEW + // out-param with `dmSize` set; the synchronous query only reads the name and fills `dm`. + let ok = + unsafe { EnumDisplaySettingsW(PCWSTR(wname.as_ptr()), ENUM_CURRENT_SETTINGS, &mut dm) } + .as_bool(); + if !ok { + tracing::warn!("{gdi_name}: force_mode_reset — no current mode to re-apply"); + return false; + } + // SAFETY: same liveness as the query above; CDS_RESET re-applies the identical mode, the two + // trailing args are null, and the API only reads its inputs. The input-desktop retry mirrors + // `set_active_mode` (a CDS write off the input desktop is refused with DISP_CHANGE_FAILED). + let rc = crate::input_desktop::retry_on_input_desktop( + |rc| *rc == DISP_CHANGE_FAILED, + || unsafe { + ChangeDisplaySettingsExW(PCWSTR(wname.as_ptr()), Some(&dm), None, CDS_RESET, None) + }, + ); + if rc != DISP_CHANGE_SUCCESSFUL { + tracing::warn!( + result = rc.0, + "{gdi_name}: force_mode_reset rejected ({})", + disp_change_reason(rc.0) + ); + return false; + } + tracing::info!("{gdi_name}: forced same-mode reset applied (presentation restart)"); + true +} + pub fn set_active_mode(gdi_name: &str, mode: Mode) { let wname: Vec = gdi_name.encode_utf16().chain(std::iter::once(0)).collect(); @@ -1045,6 +1089,14 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option, trace: &crate::bringup::Trace, + // Same-mode swap-chain recovery (the exclusive re-assert bounced the IDD's modes): recreate + // the ring even though the size is unchanged — `resize_output`'s same-size fast path would + // no-op exactly the case being recovered. + recover_ring: bool, ) -> bool { let Some(cur_target) = capturer.capture_target_id() else { return false; // not an IDD-push capturer — nothing to reuse @@ -2889,7 +2948,12 @@ fn try_inplace_resize( ); return false; } - if !capturer.resize_output(new_mode.width, new_mode.height) { + let ring_ok = if recover_ring { + capturer.recreate_ring_in_place() + } else { + capturer.resize_output(new_mode.width, new_mode.height) + }; + if !ring_ok { return false; } trace.mark("ring_recreated"); @@ -2916,6 +2980,38 @@ fn try_inplace_resize( } } }; + // Liveness gate for the eviction recovery: the driver re-delivers its STASH on re-attach, so + // the first frame proves only the ring — not that the OS resumed presenting (measured: the + // stash arrives in ~50 ms, then new_fps=0 forever). Require a SECOND, newer present — the + // forced mode reset just triggered a full redraw, so a live display produces one promptly — + // before declaring recovery; a stash-only re-attach must FAIL so the caller ends the session + // cleanly (a reconnect's fresh bring-up always recovers) instead of streaming a frozen frame. + let new_frame = if recover_ring { + let first_pts = new_frame.pts_ns; + let live_deadline = std::time::Instant::now() + std::time::Duration::from_millis(1500); + loop { + match capturer.try_latest() { + Ok(Some(f)) if f.pts_ns != first_pts => break f, + Ok(_) => { + if std::time::Instant::now() >= live_deadline { + tracing::warn!( + "eviction recovery: ring re-attached but only the stashed frame \ + arrived — the OS is not presenting; failing the in-place recovery" + ); + return false; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(e) => { + tracing::warn!(error = %format!("{e:#}"), + "eviction recovery: capture failed while waiting for a live frame"); + return false; + } + } + } + } else { + new_frame + }; 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). diff --git a/docs-site/content/docs/configuration.md b/docs-site/content/docs/configuration.md index 47916e23..84dc0952 100644 --- a/docs-site/content/docs/configuration.md +++ b/docs-site/content/docs/configuration.md @@ -117,6 +117,7 @@ See your desktop page ([KDE](/docs/kde), [GNOME](/docs/gnome)) for when to set t | `PUNKTFUNK_VDISPLAY` | `pf` | Virtual-display backend. The bundled pf-vdisplay IddCx driver is the only backend now — informational; leave as `pf`. | | `PUNKTFUNK_SECURE_DDA` | `1` | Capture the secure desktop (UAC / lock / login) so the stream survives those transitions. | | `PUNKTFUNK_MONITOR_LINGER_MS` | ms (default `10000`) | Defer tearing a per-client virtual display down after disconnect. A reconnect inside the window preempts it and creates a fresh one (a reused IddCx swap-chain is dead); the stable per-client monitor id keeps Windows' saved display config applying either way. Superseded by the console's **Keep alive** setting — see [Virtual displays](/docs/virtual-displays). | +| `PUNKTFUNK_EXCLUSIVE_REASSERT_MS` | ms (default `2000`), `0` = off | How often the host re-checks that **exclusive** display topology actually held. Windows (or a GPU driver / display-poller tool) can quietly re-activate a physical panel moments after the host disabled it — seen on hybrid Intel+NVIDIA laptops — putting windows, the cursor, and the lock screen on a screen that isn't streamed. The host re-asserts and logs when that happens; `0` restores the old fire-and-forget behavior. | | `PUNKTFUNK_RENDER_ADAPTER` | description substring | Multi-GPU boxes only: force the NVENC/capture GPU by adapter Description substring (e.g. `4090`). Leave unset on single-GPU machines. | | `PUNKTFUNK_HOST_CMD` | e.g. `serve --gamestream` | The host subcommand the service launches. Default `serve --gamestream`; use `serve` for a secure native-only host. |