fix(vdisplay): preserve FramePublisher across swap-chain reassign (sibling-join freeze)
windows-drivers / probe-and-proto (push) Successful in 28s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m0s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
decky / build-publish (push) Successful in 19s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
windows-drivers / driver-build (push) Successful in 1m35s
apple / swift (push) Successful in 4m34s
ci / bench (push) Successful in 5m39s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6m50s
docker / deploy-docs (push) Successful in 33s
windows-host / package (push) Successful in 10m8s
android / android (push) Successful in 12m24s
arch / build-publish (push) Successful in 16m41s
deb / build-publish (push) Successful in 17m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 12m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 18m21s
ci / rust (push) Successful in 23m52s
apple / screenshots (push) Successful in 20m9s

When a second client got its own virtual display mid-stream, the FIRST
client's IDD-push stream froze (video only; `new_fps=0 repeat_fps=240`
forever). Adding/removing/resizing a sibling display re-commits the CCD
topology, which makes the OS unassign+reassign the first monitor's IddCx
swap chain. `unassign_swap_chain` dropped the SwapChainProcessor, dropping
`run_core`'s local FramePublisher and closing the sealed-channel handles.
The fresh worker then polled the frame-channel stash — but that stash is
consumed once at session open, and the host only re-delivers on a ring
recreate (a descriptor change). The first monitor's descriptor didn't
change and WUDFHost stayed alive, so no watchdog fired: the driver drained
the swap chain without publishing and the host repeated its last frame
indefinitely. Confirmed twice on the .173 box (host.log 21:12 & 21:15).

Preserve the live FramePublisher across the flap instead of dropping it:
the host-owned ring (header/event/textures) it holds stays valid — only
the swap chain died.

- frame_transport.rs: FramePublisher records its render-adapter LUID +
  exposes render_adapter().
- monitor.rs: MonitorObject.preserved_publisher + preserve_publisher()
  (mirrors set_frame_channel) + take_preserved_publisher() (mirrors
  take_frame_channel). Monitor teardown drops the stashed publisher and
  closes its ring handles, so nothing leaks.
- swap_chain_processor.rs run_core: after SetDevice OK, re-adopt a
  preserved publisher ONLY when the new swap chain renders on the same
  LUID (same pooled Direct3DDevice → its context + opened textures are
  valid); on loop exit, stash the publisher back on the monitor.

Safe: the old worker is fully joined (drop-outside-lock discipline)
before the new one runs, so no concurrent context use; a stale re-adopted
publisher is superseded by the existing is_stale() + has_frame_channel()
newest-wins checks at the loop top.

Verified clippy -D warnings clean on rustc 1.96.0 via a faithful mock
crate (the real crate needs the WDK to compile). Needs a driver rebuild +
reinstall on the host to take effect; not yet hardware-validated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 00:19:20 +02:00
parent 05868ef634
commit cd701a9594
3 changed files with 106 additions and 1 deletions
@@ -241,7 +241,31 @@ impl SwapChainProcessor {
// the values via IOCTL_SET_FRAME_CHANNEL, which the control plane stashes on our monitor
// (`monitor::take_frame_channel`). Until a delivery lands we just drain — exactly the STEP-5
// behaviour — so a non-IDD-push session never stalls. The stash is polled every ~30 iterations.
let mut publisher: Option<FramePublisher> = None;
// STEP 6 sibling-join fix: re-adopt a FramePublisher PRESERVED across a swap-chain
// unassign→reassign flap. When a SIBLING display churns the desktop topology (a second client
// joining / leaving / resizing), the OS reassigns THIS monitor's swap-chain and the previous
// worker exited — but the host-owned ring it published into is still live, and the host only
// re-delivers the frame channel on a ring RECREATE (a descriptor change). Without this the
// fresh worker has nothing to attach to and the first client's stream freezes (repeat frames
// forever). Re-adopt ONLY when the freshly-assigned swap-chain renders on the SAME adapter as
// the preserved publisher (same pooled Direct3DDevice → its immediate context + opened ring
// textures are valid); a mismatch drops it and falls back to a fresh channel delivery. A
// preserved publisher that the host superseded meanwhile (ring recreate → is_stale, or a
// pending delivery) is dropped + replaced by the existing re-attach logic at the loop top.
let mut publisher: Option<FramePublisher> =
crate::monitor::take_preserved_publisher(target_id).and_then(|p| {
if p.render_adapter() == (render_luid_low, render_luid_high) {
dbglog!(
"[pf-vd] swap-chain run_core: re-adopted preserved publisher (target={target_id}) — resuming the host ring across the swap-chain flap"
);
Some(p)
} else {
dbglog!(
"[pf-vd] swap-chain run_core: preserved publisher's render adapter changed (target={target_id}) — dropping it, will re-attach from a fresh channel"
);
None
}
});
let mut frames_since_try: u32 = u32::MAX; // attach attempt on the first loop iteration
let mut logged_pending = false;
@@ -392,6 +416,17 @@ impl SwapChainProcessor {
break;
}
}
// STEP 6 sibling-join fix: the drain loop exited (the OS unassigned this swap-chain — typically
// because a SIBLING display churned the desktop topology — or it errored), but the host-owned
// ring the publisher holds is still live. Hand it to the monitor so the NEXT worker assigned to
// this monitor resumes publishing into the same ring instead of freezing (the host re-delivers
// the channel only on a ring recreate). If the monitor is GONE (a genuine teardown, not a flap),
// `preserve_publisher` hands the publisher back inside the `Err` and dropping the returned
// `Result` closes the ring handles here — no leak, no stale ring left behind.
if let Some(p) = publisher.take() {
let _ = crate::monitor::preserve_publisher(target_id, p);
}
}
}