fix(vdisplay/windows): exclusive topology gets re-asserted — a verified isolate is not durable
ci / docs-site (push) Successful in 1m8s
ci / web (push) Successful in 1m40s
apple / swift (push) Successful in 5m9s
ci / bench (push) Successful in 6m36s
ci / rust-arm64 (push) Successful in 10m12s
deb / build-publish (push) Successful in 9m32s
decky / build-publish (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
android / android (push) Canceled after 13m40s
apple / screenshots (push) Canceled after 8m25s
arch / build-publish (push) Canceled after 13m44s
ci / rust (push) Canceled after 13m45s
deb / build-publish-host (push) Canceled after 12m5s
deb / build-publish-client-arm64 (push) Canceled after 7m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 2m28s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 1m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1s
windows-host / package (push) Canceled after 13m45s
ci / docs-site (push) Successful in 1m8s
ci / web (push) Successful in 1m40s
apple / swift (push) Successful in 5m9s
ci / bench (push) Successful in 6m36s
ci / rust-arm64 (push) Successful in 10m12s
deb / build-publish (push) Successful in 9m32s
decky / build-publish (push) Successful in 34s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 30s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 59s
android / android (push) Canceled after 13m40s
apple / screenshots (push) Canceled after 8m25s
arch / build-publish (push) Canceled after 13m44s
ci / rust (push) Canceled after 13m45s
deb / build-publish-host (push) Canceled after 12m5s
deb / build-publish-client-arm64 (push) Canceled after 7m5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 2m28s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 1m57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 1s
windows-host / package (push) Canceled after 13m45s
The CCD isolate verifies "sole active desktop" at commit time and is never checked again. On a hybrid Intel+NVIDIA box the internal panel is re-activated moments later (the isolated topology is deliberately not saved to the CCD database so teardown can restore the user's layout, and the post-isolate resize/HDR churn — or the panel's own driver, or display-poller software — re-resolves back to the stored layout). Proven on-glass: seconds after the verify, CCD showed the panel ACTIVE beside the virtual display while the host still believed it was exclusive — cursor and windows can land off-stream, and the lock screen can land on the physical panel. A group-scoped watchdog now re-queries every PUNKTFUNK_EXCLUSIVE_REASSERT_MS (default 2 s, 0 disables) while an EXCLUSIVE isolate is live and re-issues the isolate when a non-managed display crept back, logging who-is-fighting escalation (3×WARN → 1×ERROR → DEBUG). Gated on a new GroupState.ccd_exclusive flag so a Primary group's deliberately-lit panels are never "fixed". Cycles take the state lock via try_lock with a sliced sleep, so teardown's stop+join under the state lock cannot deadlock and is bounded by ~250 ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 →
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<AtomicBool>,
|
||||
thread: JoinHandle<()>,
|
||||
@@ -240,6 +245,10 @@ pub struct VirtualDisplayManager {
|
||||
idd_session_stops: Mutex<std::collections::HashMap<u32, Arc<AtomicBool>>>,
|
||||
/// The device-level watchdog [`Pinger`], running while any slot lives.
|
||||
pinger: Mutex<Option<Pinger>>,
|
||||
/// 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<Option<Pinger>>,
|
||||
// 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<VirtualDisplayManager> = 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<dyn VdisplayDriver>) -> &'static VirtualDisplayManager {
|
||||
@@ -262,6 +284,7 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<f32> {
|
||||
/// 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<u16> = gdi_name.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let mut dm = DEVMODEW {
|
||||
dmSize: size_of::<DEVMODEW>() 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<u16> = 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<SavedConfi
|
||||
Some(saved)
|
||||
}
|
||||
|
||||
// (A "gentle" eviction variant — deactivate the stray paths WITHOUT `SDC_FORCE_MODE_ENUMERATION`,
|
||||
// kept paths supplied verbatim — was tried for the re-assert watchdog and REMOVED: on-glass it
|
||||
// still bounced the live virtual display's swap-chain (a real topology change drives COMMIT_MODES
|
||||
// on its own) AND additionally left the OS not presenting to the virtual display — capture
|
||||
// received one stashed frame and then nothing. Eviction therefore always goes through
|
||||
// [`isolate_displays_ccd`], whose forced re-commit restarts presentation, and the SESSION pairs it
|
||||
// with an in-place capture re-attach; see the vdisplay manager's re-assert watchdog.)
|
||||
|
||||
/// Build the ESCALATED supplied config for [`isolate_displays_ccd`]: ONLY the paths still flagged
|
||||
/// ACTIVE (the keep set — the caller already cleared ACTIVE on every doomed path), with the mode
|
||||
/// table rebuilt to just the entries those paths reference (indexes remapped). Docs-wise the
|
||||
|
||||
@@ -1377,6 +1377,11 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
let mut cur_mode = mode;
|
||||
const MAX_CAPTURE_REBUILDS: u32 = 5;
|
||||
let mut capture_rebuilds: u32 = 0;
|
||||
// Exclusive-topology eviction generation last seen (Windows IDD-push; see the recovery block
|
||||
// in the loop): the vdisplay watchdog bumps it on every eviction, each of which drives
|
||||
// COMMIT_MODES on the live IDD path and orphans this pipeline's capture ring.
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut seen_reassert_gen = crate::vdisplay::manager::topology_reassert_gen();
|
||||
// Encode-stall watchdog: AMF/QSV (and async NVENC) poll non-blocking, so a wedged driver
|
||||
// shows up as poll() returning None forever while submits keep succeeding — `inflight` grows,
|
||||
// no AU ever reaches the send thread, and the client freezes on the last frame with nothing
|
||||
@@ -1605,6 +1610,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
plan,
|
||||
&quit,
|
||||
resize_trace.as_ref(),
|
||||
false,
|
||||
);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let fast_done = false;
|
||||
@@ -1701,6 +1707,55 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
resize_trace.finish("pipeline_rebuilt");
|
||||
}
|
||||
}
|
||||
// Exclusive-topology eviction recovery (Windows IDD-push): the vdisplay watchdog just
|
||||
// evicted a display that crept back into the "exclusive" desktop, via the full isolate —
|
||||
// its forced re-commit restarts OS presentation to the virtual display (a gentle
|
||||
// supplied-config eviction left capture one stashed frame and then nothing, on-glass),
|
||||
// but it also hands the live IDD path a fresh swap-chain while this pipeline's ring
|
||||
// keeps waiting on the old attachment; with an unchanged descriptor the poller's
|
||||
// two-strike debounce never trips, so frames would just stop. Rebuild the capture
|
||||
// attachment in place at the CURRENT mode (same-mode ring recreate + driver re-attach +
|
||||
// fresh encoder — the resize fast path's cost). If even that fails, end the session with
|
||||
// a clear error: the client's reconnect rebuilds from scratch, which beats streaming a
|
||||
// frozen image forever.
|
||||
#[cfg(target_os = "windows")]
|
||||
if plan.capture == crate::session_plan::CaptureBackend::IddPush {
|
||||
let reassert_gen = crate::vdisplay::manager::topology_reassert_gen();
|
||||
if reassert_gen != seen_reassert_gen {
|
||||
seen_reassert_gen = reassert_gen;
|
||||
tracing::info!(
|
||||
"exclusive-topology eviction bounced the virtual display's modes — rebuilding \
|
||||
the capture attachment in place at the current mode"
|
||||
);
|
||||
let trace = crate::bringup::Trace::start("reassert-recover", resize_ms.clone());
|
||||
if try_inplace_resize(
|
||||
&mut vd,
|
||||
&mut capturer,
|
||||
&mut enc,
|
||||
&mut frame,
|
||||
&mut interval,
|
||||
cur_mode,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
plan,
|
||||
&quit,
|
||||
trace.as_ref(),
|
||||
true,
|
||||
) {
|
||||
// The owed AUs died with the old encoder — same bookkeeping as a resize.
|
||||
inflight.clear();
|
||||
last_au_at = std::time::Instant::now();
|
||||
encoder_resets = 0;
|
||||
last_forced_idr = Some(std::time::Instant::now());
|
||||
trace.finish("pipeline_rebuilt");
|
||||
} else {
|
||||
return Err(anyhow!(
|
||||
"exclusive-topology eviction recovery failed — ending the session for a \
|
||||
clean reconnect (a fresh bring-up re-attaches capture)"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Adaptive bitrate: drain to the NEWEST requested rate (the client's controller may step
|
||||
// several times while we stream) and retarget the ENCODER ONLY — the mode didn't change,
|
||||
// so capture and the virtual output are untouched. Preferred lever: an IN-PLACE
|
||||
@@ -2859,6 +2914,10 @@ fn try_inplace_resize(
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
quit: &Arc<AtomicBool>,
|
||||
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).
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user