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

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:
2026-07-26 16:53:19 +02:00
co-authored by Claude Fable 5
parent 6c2c8b6eab
commit 8362d57001
7 changed files with 379 additions and 5 deletions
+14
View File
@@ -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 →
+43
View 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
+55 -3
View File
@@ -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
+97 -1
View File
@@ -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).
+1
View File
@@ -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. |