From 4c18bb80caf86ba2c62eddecb001db5690699182 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 11 Jul 2026 14:34:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(resize/win):=20mid-stream=20resize=20on-gl?= =?UTF-8?q?ass=20fixes=20=E2=80=94=20corrective-ack=20actual=20res=20+=20m?= =?UTF-8?q?onitor=20re-arrival?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-glass validation on the .173 Windows IDD-push host confirmed the Reconfigure protocol + host rebuild work end-to-end and genuinely change pixels for an advertised mode (1920x1080 -> 1280x720: two SPS/IDR sets, ffprobe both res). It also surfaced two gaps for out-of-EDID-list target modes, both fixed here. Fix 2 (corrective ack carries the ACTUAL resolution): the H2/H3 corrective ack recovered only the achieved REFRESH (interval_hz), taking width/height straight from the request — so when a backend delivered a different RESOLUTION (Windows pf-vdisplay falling back to its advertised mode) the client was told it got a size it never received, and by the D2 discipline never re-asked. New `delivered_mode(frame.{w,h}, interval)` derives the ack from the captured frame's real dims (what the encoder opened at / the client decodes) in both the success and rollback branches. Unit-tested. Fix 1 (reach arbitrary mid-stream modes via monitor RE-ARRIVAL): the pf-vdisplay driver freezes a monitor's advertised mode list at IOCTL_ADD, and IddCx exposes no live update-modes DDI, so an in-place ChangeDisplaySettingsExW to a mode not advertised at arrival returns DISP_CHANGE_BADMODE. The manager's mid-stream reconfigure now REMOVEs + re-ADDs the driver monitor at the exact new mode, reusing the slot's stable per-client id (EDID serial / ContainerId) so the OS keeps identity + saved DPI. The rebuilt Monitor PRESERVES gen (lease/refcount continuity) and the group restore snapshot; reisolate_after_swap re-isolates the new target without recapturing it. Host-only — no driver change. One monitor hotplug per switch (the design's accepted "re-arrival for everything"). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-host/src/punktfunk1.rs | 91 ++++-- .../src/vdisplay/windows/manager.rs | 302 +++++++++++++----- 2 files changed, 294 insertions(+), 99 deletions(-) diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 00ff6b2d..64ccfee0 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -3002,7 +3002,9 @@ struct SendStats { /// stats-mode slot — one store/load instead of three racy ones. Every dimension fits: the codec /// max dimension caps w/h well under 2^16 (`validate_dimensions`), refresh likewise. fn pack_mode(width: u32, height: u32, refresh_hz: u32) -> u64 { - ((width as u64 & 0xffff) << 32) | ((height as u64 & 0xffff) << 16) | (refresh_hz as u64 & 0xffff) + ((width as u64 & 0xffff) << 32) + | ((height as u64 & 0xffff) << 16) + | (refresh_hz as u64 & 0xffff) } /// Unpack a [`pack_mode`] word back into `(width, height, refresh_hz)`. @@ -3022,6 +3024,27 @@ fn interval_hz(interval: std::time::Duration) -> u32 { (1.0 / interval.as_secs_f64()).round() as u32 } +/// The mode a pipeline is ACTUALLY delivering, for the H2/H3 corrective ack: the captured frame's +/// real dimensions (`build_pipeline` opens the encoder at `frame.{width,height}`, so this is exactly +/// what the client decodes) paced at the rate the pipeline achieved ([`interval_hz`]). It diverges +/// from the requested mode when a backend can't honor it: KWin caps a virtual output's refresh, or — +/// the case this exists for — Windows pf-vdisplay rejects an in-place `SetMode` to a resolution not +/// in the running monitor's advertised EDID list and the host falls back to the actual display mode +/// (`capture::idd_push`: "sizing the ring to the display's actual mode"). Comparing this against the +/// already-acked request decides whether a corrective `Reconfigured` ack is owed so the client +/// doesn't believe it got a resolution it never received. +fn delivered_mode( + frame_width: u32, + frame_height: u32, + interval: std::time::Duration, +) -> punktfunk_core::Mode { + punktfunk_core::Mode { + width: frame_width, + height: frame_height, + refresh_hz: interval_hz(interval), + } +} + #[allow(clippy::too_many_arguments)] fn send_loop( mut session: Session, @@ -3703,7 +3726,10 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { Ok((new_vd, pipe)) })(); match rebuilt { - Ok((new_vd, (new_cap, new_enc, new_frame, new_interval, new_node_id, new_gen))) => { + Ok(( + new_vd, + (new_cap, new_enc, new_frame, new_interval, new_node_id, new_gen), + )) => { // Replace the pipeline first (drops the old capturer → old PipeWire stream + // virtual output), then the factory (drops e.g. the old KWin connection). capturer = new_cap; @@ -3764,15 +3790,14 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { if let Some(g) = old_display_gen.filter(|g| cur_display_gen != Some(*g)) { crate::vdisplay::registry::retire(g); } - // H2/H3: the backend may have honored a different refresh than requested (KWin - // caps virtual outputs it can't drive faster). Publish the ACTUAL mode to the - // live stats slot, and correct the client's mode slot when it differs from the - // accept ack it already got. - let actual = punktfunk_core::Mode { - width: new_mode.width, - height: new_mode.height, - refresh_hz: interval_hz(interval), - }; + // H2/H3: the backend may have honored a different mode than requested — KWin + // caps a virtual output's refresh, or Windows pf-vdisplay rejects an in-place + // SetMode to a resolution its running monitor doesn't advertise and the host + // falls back to the actual display mode. `frame` is the NEW pipeline's first + // frame (just rebound above), so its dims are what the client actually decodes. + // Publish that ACTUAL mode to the live stats slot, and correct the client's mode + // slot when it differs from the accept ack it already got. + let actual = delivered_mode(frame.width, frame.height, interval); live_mode.store( pack_mode(actual.width, actual.height, actual.refresh_hz), Ordering::Relaxed, @@ -3796,15 +3821,12 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> { // H2 rollback: the control task acked the switch BEFORE this rebuild, so the // client's mode slot already flipped to `new_mode`. A second accepted ack // carrying the still-live mode corrects it (any accepted ack means "the active - // mode is now X" client-side; old clients just log it). Refresh from the OLD - // pipeline's interval — the still-running one — in case its build was capped. + // mode is now X" client-side; old clients just log it). `frame` is untouched + // here (the destructure only runs on the Ok arm), so it's still the OLD + // pipeline's frame — its real dims + interval are exactly what's still on glass. let _ = reconfig_result_tx.send(Reconfigured { accepted: true, - mode: punktfunk_core::Mode { - width: cur_mode.width, - height: cur_mode.height, - refresh_hz: interval_hz(interval), - }, + mode: delivered_mode(frame.width, frame.height, interval), }); } } @@ -4560,6 +4582,39 @@ mod tests { } } + #[test] + fn delivered_mode_reports_captured_dims_and_triggers_corrective_ack() { + let hz60 = std::time::Duration::from_secs_f64(1.0 / 60.0); + let requested = punktfunk_core::Mode { + width: 2560, + height: 1440, + refresh_hz: 60, + }; + + // Honored: the captured frame matches the request → no corrective ack owed (`== requested`). + let honored = delivered_mode(2560, 1440, hz60); + assert_eq!(honored, requested); + + // Resolution fallback (Windows pf-vdisplay rejected the out-of-list SetMode, host stayed at + // the actual display mode): the frame's real dims flow through, so the delivered mode differs + // from the acked request and a corrective ack IS owed — the exact gap this fixes. + let fell_back = delivered_mode(1920, 1080, hz60); + assert_ne!(fell_back, requested); + assert_eq!( + fell_back, + punktfunk_core::Mode { + width: 1920, + height: 1080, + refresh_hz: 60 + } + ); + + // Refresh cap (KWin) is still caught: same dims, achieved rate recovered from the interval. + let capped = delivered_mode(2560, 1440, std::time::Duration::from_secs_f64(1.0 / 30.0)); + assert_ne!(capped, requested); + assert_eq!(capped.refresh_hz, 30); + } + #[test] fn pad_snapshot_replaces_state_and_seq_gates() { use punktfunk_core::input::{gamepad, GamepadSnapshot}; diff --git a/crates/punktfunk-host/src/vdisplay/windows/manager.rs b/crates/punktfunk-host/src/vdisplay/windows/manager.rs index 8e5ac2a3..3dfcda70 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -557,31 +557,64 @@ impl VirtualDisplayManager { // This slot already has a live monitor — join it (refcount++). Covers same-client concurrent // sessions AND the build-then-drop overlap of a mid-stream Reconfigure (the new lease is taken - // while the old is still held). Reconfigure the shared monitor if the requested mode differs. - if let Some(SlotState::Active { mon, refs }) = inner.slots.get_mut(&slot) { - *refs += 1; - let reconfigured = mon.mode != mode; - if reconfigured { - // SAFETY: `reconfigure` only manipulates the live display topology via the CCD/GDI - // helpers and needs an exclusive `&mut Monitor`. `mon` is the `&mut` into this slot's - // `Active` state, held under the `state` lock, so nothing else reconfigures it - // concurrently. - unsafe { self.reconfigure(mon, mode) }; + // while the old is still held). + if matches!(inner.slots.get(&slot), Some(SlotState::Active { .. })) { + // A DIFFERENT mode is a mid-stream resize (Reconfigure). The pf-vdisplay driver freezes its + // advertised mode list at ADD time, so we can't reach an arbitrary new mode in place — RE- + // ARRIVE the monitor at the exact mode instead (Fix 1). Own the slot for the swap: `re_add` + // needs `&mut inner` for the topology re-isolate, which the borrowed `mon` would block. + let cur_mode = match inner.slots.get(&slot) { + Some(SlotState::Active { mon, .. }) => mon.mode, + _ => unreachable!("just matched Active"), + }; + if cur_mode != mode { + let Some(SlotState::Active { mon, refs }) = inner.slots.remove(&slot) else { + unreachable!("just matched Active"); + }; + // SAFETY: `dev` is the handle `ensure_device()` returned above; `re_add` touches the + // live topology under the held `state` lock. `mon` is owned here (removed from the map). + let new_mon = match unsafe { self.re_add(dev, &mut inner, slot, &mon, mode, client_hdr) } + { + Ok(m) => m, + Err(e) => { + // The re-arrival failed — put the OLD monitor back so the session keeps + // streaming its current mode (the control task already acked the switch; the + // rebuild reuses the old target and Fix 2's corrective ack tells the client the + // resolution didn't change). Its `gen`/`refs` are intact, so leases stay valid. + inner.slots.insert(slot, SlotState::Active { mon, refs }); + return Err(e).context("mid-stream resize re-arrival"); + } + }; + // `re_add` preserved `gen`, so both the old session's lease and this new one match on + // release. +1 ref for the new (build-then-drop overlap) lease. + let out = self.output_for(slot, &new_mon, quit); + inner + .slots + .insert(slot, SlotState::Active { mon: new_mon, refs: refs + 1 }); + // The width changed — re-arrange the group so auto-row siblings don't overlap the + // resized display (no-op for a single member). + self.apply_group_layout(&mut inner); + tracing::info!( + slot, + refs = refs + 1, + backend = self.driver.name(), + "virtual monitor re-arrived for a mid-stream resize" + ); + return Ok(out); } + // Same mode — a plain concurrent-session JOIN (refcount++), no re-arrival. + let Some(SlotState::Active { mon, refs }) = inner.slots.get_mut(&slot) else { + unreachable!("just matched Active"); + }; + *refs += 1; tracing::info!( slot, refs = *refs, backend = self.driver.name(), - "virtual monitor reused (concurrent / reconfigure session)" + "virtual monitor reused (concurrent session)" ); warn_if_pick_moved(mon); - let out = self.output_for(slot, mon, quit); - if reconfigured { - // A mode change alters this member's width — re-arrange the group so auto-row - // siblings don't overlap the resized display (no-op for a single member). - self.apply_group_layout(&mut inner); - } - return Ok(out); + return Ok(self.output_for(slot, mon, quit)); } // Display budget (Stage W3): a display we can't afford is DECLINED at admission @@ -761,6 +794,53 @@ impl VirtualDisplayManager { } /// Create a fresh monitor at `mode` for `slot` (the client's stable identity slot, `0` = auto): + /// Wait for Windows to auto-activate a freshly-ADDed IDD target into its OWN display path and + /// return its GDI name — the capture target. Shared by the fresh CREATE and the mid-stream + /// re-arrival ([`re_add`](Self::re_add)). + /// + /// The IDD comes up EXTENDED alongside any existing/basic display; the caller then promotes it to + /// primary / isolates it. Returns `None` on a GPU-less box (target added but not WDDM-activated) — + /// the capture backend re-resolves once a GPU is present. + /// + /// We do NOT force a topology change FIRST: the bare `SDC_TOPOLOGY_EXTEND` preset is ACCESS_DENIED + /// from our Session-0 service context on a headless box and BREAKS this auto-activate (it regressed + /// the headless path — the IDD then never gets its own path → "not an active display path" → black). + /// force-EXTEND is only the FALLBACK, for an integrated-screen box (e.g. a laptop panel) where a + /// fresh IDD is CLONED onto the existing display, sharing its source, so it never gets its own + /// committed path (observed on an Intel-iGPU + NVIDIA-Optimus laptop, commit 8e87e61): + /// `resolve_gdi_name` stays None → the `is_none()` fallback force-EXTENDs to de-clone and the + /// second resolve finds the now-committed path. Headless/extended boxes resolve on the first loop + /// and skip it — which is the point, since force-EXTEND is ACCESS_DENIED from our service context + /// there. + /// + /// CAVEAT (unobserved for IddCx, untested across GPU/driver/OS): textbook CCD also lets a clone + /// appear as a *shared-source ACTIVE* path (resolve → Some), which the `is_none()` gate would NOT + /// catch. If that ever shows up, widen the gate to also fire when the IDD target's source is shared + /// with another active path (a `target_is_cloned` helper) — needs on-laptop validation first. + /// + /// # Safety + /// Runs the CCD (QueryDisplayConfig / SetDisplayConfig) FFI; call under the `state` lock. + unsafe fn resolve_target_gdi(&self, target_id: u32) -> Option { + for _ in 0..15 { + thread::sleep(Duration::from_millis(200)); + // SAFETY: `resolve_gdi_name` is `unsafe` for its CCD FFI; it takes a plain `Copy` `u32` + // target id by value and returns an owned `String`, so no caller memory is borrowed. + if let Some(n) = unsafe { resolve_gdi_name(target_id) } { + return Some(n); + } + } + // SAFETY: `force_extend_topology` only calls `SetDisplayConfig` (CCD) with no borrowed memory. + unsafe { force_extend_topology() }; + for _ in 0..15 { + thread::sleep(Duration::from_millis(200)); + // SAFETY: as the resolve loop above. + if let Some(n) = unsafe { resolve_gdi_name(target_id) } { + return Some(n); + } + } + None + } + /// ADD via the driver (pinning the discrete render GPU under the usual conditions), ensure the /// device-level watchdog pinger, resolve the GDI name, force the mode + apply the GROUP topology /// (first member isolates and captures the restore; a later member re-issues the isolate with @@ -796,53 +876,10 @@ impl VirtualDisplayManager { self.ensure_pinger(); // Resolve the capture target — wait for Windows to auto-activate the freshly-ADDed IDD into its - // OWN display path (it comes up EXTENDED alongside any existing/basic display; `set_active_mode` - // below then promotes it to primary and `isolate_displays_ccd` makes it the sole composited - // desktop — the proven flow). May be None on a GPU-less box (target added but not WDDM-activated); - // the capture backend re-resolves once a GPU is present. - // - // We do NOT force a topology change FIRST: the bare `SDC_TOPOLOGY_EXTEND` preset is ACCESS_DENIED - // from our Session-0 service context on a headless box and BREAKS this auto-activate (it regressed - // the headless path — the IDD then never gets its own path → "not an active display path" → black). - // force-EXTEND is only the FALLBACK below, for an integrated-screen box where a fresh IDD is CLONED - // onto the panel (shares its source) instead of getting its own path. - let mut gdi_name = None; - for _ in 0..15 { - thread::sleep(Duration::from_millis(200)); - // SAFETY: `resolve_gdi_name` is `unsafe` for its CCD (QueryDisplayConfig) FFI; it takes a - // plain `Copy` `u32` target id by value and returns an owned `String`, so no caller memory - // is borrowed across the call. - if let Some(n) = unsafe { resolve_gdi_name(added.target_id) } { - gdi_name = Some(n); - break; - } - } - - // Fallback for an integrated-screen box (e.g. a laptop panel): Windows CLONES a freshly-added - // IDD onto the existing display, sharing its source, so it never gets its own committed path. On - // the IddCx clone behaviour observed live (commit 8e87e61, an Intel-iGPU + NVIDIA-Optimus laptop) - // `resolve_gdi_name` then stays None — so this `is_none()` fallback fires, force-EXTENDs to - // de-clone, and the second resolve finds the now-committed path. Headless/extended boxes already - // resolved above (the IDD auto-activates with its OWN source) and skip this — which is the whole - // point, since force-EXTEND's bare preset is ACCESS_DENIED from our service context there. - // - // CAVEAT (unobserved for IddCx, untested across GPU/driver/OS): textbook CCD also lets a clone - // appear as a *shared-source ACTIVE* path (resolve → Some), which this `is_none()` gate would NOT - // catch. If that ever shows up, widen the gate to also fire when the IDD target's source is shared - // with another active path (a `target_is_cloned` helper) — needs on-laptop validation first. - if gdi_name.is_none() { - // SAFETY: as above — `force_extend_topology` only calls `SetDisplayConfig` (CCD) with no - // borrowed caller memory, under the `state` lock. - unsafe { force_extend_topology() }; - for _ in 0..15 { - thread::sleep(Duration::from_millis(200)); - // SAFETY: as the resolve loop above. - if let Some(n) = unsafe { resolve_gdi_name(added.target_id) } { - gdi_name = Some(n); - break; - } - } - } + // OWN display path, with the integrated-screen clone fallback (shared by the re-arrival path). + // SAFETY: `resolve_target_gdi` runs the CCD FFI (a `Copy` `u32` target by value, owned return), + // under the `state` lock. + let gdi_name = unsafe { self.resolve_target_gdi(added.target_id) }; match &gdi_name { Some(n) => { tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id); @@ -974,28 +1011,131 @@ impl VirtualDisplayManager { }) } - /// Re-apply a (possibly new) mode to a reused monitor on reconnect, re-resolving its GDI name. + /// Mid-stream resize by monitor RE-ARRIVAL (`design/midstream-resolution-resize.md` Fix 1). + /// + /// The pf-vdisplay driver freezes a monitor's advertised mode list at `IOCTL_ADD` time (the + /// requested mode + `default_modes()`), so a plain `ChangeDisplaySettingsExW` can only reach a + /// mode the monitor advertised on arrival — an out-of-list target (e.g. a session that arrived at + /// 1080p resizing to 1440p) returns `DISP_CHANGE_BADMODE`. IddCx exposes no live "update modes" + /// DDI, so to follow the client to an ARBITRARY new mode we REMOVE the driver monitor and ADD a + /// fresh one at the new mode, reusing the slot's stable per-client id (EDID serial / ConnectorIndex + /// / ContainerId) so the OS keeps the monitor's identity + saved per-monitor DPI. The visible cost + /// is one monitor hotplug per switch (the design's accepted "re-arrival for everything"). + /// + /// Refcount/lease continuity: the rebuilt `Monitor` PRESERVES the old `gen`, so the outstanding + /// session lease(s) still match on release — the linger/refcount machine is untouched. The group + /// restore snapshot (`group.ccd_saved` / DDC / PnP) is likewise PRESERVED (a mid-session swap, not + /// a first-member create): [`reisolate_after_swap`](Self::reisolate_after_swap) re-isolates the new + /// target without recapturing it. Caller owns the slot's `Monitor` + `refs` across this call. /// /// # Safety - /// Touches the live display topology via the CCD/GDI helpers. - unsafe fn reconfigure(&self, mon: &mut Monitor, mode: Mode) { + /// `dev` must be the live control handle; touches the live display topology via CCD/GDI. + unsafe fn re_add( + &'static self, + dev: HANDLE, + inner: &mut MgrInner, + slot: u32, + old: &Monitor, + mode: Mode, + client_hdr: Option, + ) -> Result { tracing::info!( - old = format!( - "{}x{}@{}", - mon.mode.width, mon.mode.height, mon.mode.refresh_hz - ), - new = format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz), - "virtual-display: reconfiguring reused monitor to the new client mode" + slot, + old = %format!("{}x{}@{}", old.mode.width, old.mode.height, old.mode.refresh_hz), + new = %format!("{}x{}@{}", mode.width, mode.height, mode.refresh_hz), + old_target = old.target_id, + "virtual-display: re-arriving monitor for a mid-stream resize (exact mode)" ); - // SAFETY: `resolve_gdi_name` is `unsafe` for its CCD FFI; it takes the `Copy` `u32` - // `mon.target_id` by value and returns an owned `String`, so nothing borrowed crosses the call. - if let Some(n) = unsafe { resolve_gdi_name(mon.target_id) } { - mon.gdi_name = Some(n); + // 1. Depart the OLD driver monitor — a bare REMOVE IOCTL (no topology restore, pinger stays + // up): the surviving/grown-set re-isolate happens after the new ADD. Frees the preferred id + // so the ADD below can reuse the same stable identity. Best-effort — a REMOVE failure still + // lets the ADD proceed (the driver reaps a stale same-id monitor on the next create anyway). + // SAFETY: `dev` is the live control handle (this fn's contract); `&old.key` borrows the + // still-owned `MonitorKey`, alive across the synchronous IOCTL. + if let Err(e) = unsafe { self.driver.remove_monitor(dev, &old.key) } { + tracing::warn!(old_target = old.target_id, "re-arrival REMOVE failed (continuing to ADD): {e:#}"); } - if let Some(n) = &mon.gdi_name { - set_active_mode(n, mode); + // Let the OS finish the ASYNC monitor departure before the ADD — a back-to-back REMOVE→ADD + // races the teardown and the ADD is rejected under churn (same 400 ms settle as the reconnect + // preempt path). + thread::sleep(Duration::from_millis(400)); + // 2. ADD a fresh monitor at the NEW mode, reusing the slot as the preferred (stable) id. + let render_pin = resolve_render_pin(); + // SAFETY: `dev` is the live control handle; `render_pin`/`client_hdr` are owned `Copy`/`Option` + // values passed by value — no borrow crosses the call. + let added = unsafe { + self.driver + .add_monitor(dev, mode, render_pin, slot, client_hdr) + .context("re-arrival ADD at the new mode")? + }; + self.ensure_pinger(); + // 3. Resolve the NEW target's GDI name (target_id changes across a re-arrival). + // SAFETY: CCD FFI over a `Copy` target id, under the `state` lock. + let gdi_name = unsafe { self.resolve_target_gdi(added.target_id) }; + match &gdi_name { + Some(n) => { + tracing::info!(backend = self.driver.name(), "re-arrival target {} -> {n}", added.target_id); + // ADD only advertises the mode; force it active so DXGI/IDD captures the new size. + set_active_mode(n, mode); + // 4. Re-isolate the composited set with the NEW target replacing the old — preserving + // the group's first-member restore snapshot. + // SAFETY: CCD FFI over borrowed Copy target ids, under the `state` lock. + unsafe { self.reisolate_after_swap(inner, added.target_id) }; + thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture reopens + } + None => tracing::warn!( + "re-arrival target {} not yet an active display path (needs a WDDM GPU to activate)", + added.target_id + ), + } + // 5. Rebuild the Monitor from the ADD reply, PRESERVING `gen` (lease/refcount continuity) and + // the group-layout `position`. A fresh `gen` would strand the old session's lease release. + Ok(Monitor { + key: added.key, + target_id: added.target_id, + luid: added.luid, + render_pin, + wudf_pid: added.wudf_pid, + gdi_name, + mode, + resolved_monitor_id: added.resolved_monitor_id, + position: old.position, + gen: old.gen, + }) + } + + /// Re-isolate the composited display set after a mid-stream monitor re-arrival ([`re_add`]) put a + /// NEW target in place of the old one — WITHOUT recapturing the group restore snapshot (the first + /// member captured it at session start; teardown restores that, not the mid-session state). The + /// old slot has already been removed from the map by the caller, so `inner.target_ids()` is the + /// surviving siblings; the new target joins them. + /// + /// # Safety + /// Drives the CCD topology FFI; call under the `state` lock. + unsafe fn reisolate_after_swap(&self, inner: &mut MgrInner, new_target: u32) { + use crate::vdisplay::policy::Topology; + match topology_action() { + Topology::Exclusive => { + // Grown-set semantics: isolate to the surviving siblings + the new target. The returned + // snapshot is DISCARDED — the group keeps the first member's (design §6.1). + let mut keep = inner.target_ids(); + keep.push(new_target); + // SAFETY: borrowed slice of Copy target ids, owned return, under the `state` lock. + let _ = unsafe { isolate_displays_ccd(&keep) }; + } + Topology::Primary => { + // Make the new target primary again (its predecessor held primary), preserving the + // original restore snapshot: `set_virtual_primary_ccd` recaptures one, so save + restore + // the group's around the call. + let keep_saved = inner.group.ccd_saved.take(); + // SAFETY: `Copy` target id by value, owned return, under the `state` lock. + let _ = unsafe { set_virtual_primary_ccd(new_target) }; + inner.group.ccd_saved = keep_saved; + } + Topology::Extend | Topology::Auto => { + // The re-ADDed target auto-activates extended — nothing to isolate/promote. + } } - mon.mode = mode; } /// Tear down `mon`, which the caller has ALREADY removed from `inner.slots`: on the LAST member