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
+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).