diff --git a/crates/punktfunk-host/src/punktfunk1.rs b/crates/punktfunk-host/src/punktfunk1.rs index 64ccfee0..21a362d3 100644 --- a/crates/punktfunk-host/src/punktfunk1.rs +++ b/crates/punktfunk-host/src/punktfunk1.rs @@ -1101,7 +1101,7 @@ async fn serve_session( let per_client_mode_identity = crate::vdisplay::policy::prefs() .configured_effective() .is_some_and(|e| e.identity == crate::vdisplay::policy::Identity::PerClientMode); - compositor != Some(crate::vdisplay::Compositor::Gamescope) && !per_client_mode_identity + reconfig_allowed(compositor, per_client_mode_identity) }; // Negotiated codec (HEVC / H.264 / AV1), derived from the Welcome. `Copy`, so the control task's // `async move` captures a copy and it stays usable for the data-plane SessionContext below. @@ -3045,6 +3045,22 @@ fn delivered_mode( } } +/// Whether a session on `compositor` (`None` = the synthetic source) with a `per_client_mode` +/// identity policy may LIVE-reconfigure — accept a mid-stream `Reconfigure` +/// (design/midstream-resolution-resize.md H1/H5). Gated OFF for: +/// * **gamescope** (every sub-mode): a resize would respawn the nested game / restart the box's +/// game-mode session — it must never relaunch the title, so the client keeps scaling client-side. +/// * a **per-client-mode identity** policy: the mode is part of the display-identity slot key, so a +/// resize resolves a DIFFERENT slot (a fresh Windows monitor / a differently-named KWin output), +/// defeating the policy — honest downgrade is to reject and let the client scale. +/// Every other compositor (and the synthetic protocol-test source) with the default identity accepts. +fn reconfig_allowed( + compositor: Option, + per_client_mode: bool, +) -> bool { + compositor != Some(crate::vdisplay::Compositor::Gamescope) && !per_client_mode +} + #[allow(clippy::too_many_arguments)] fn send_loop( mut session: Session, @@ -4615,6 +4631,29 @@ mod tests { assert_eq!(capped.refresh_hz, 30); } + #[test] + fn reconfig_allowed_gates_gamescope_and_per_client_mode() { + use crate::vdisplay::Compositor::{Gamescope, Hyprland, Kwin, Mutter, Wlroots}; + // gamescope ALWAYS rejects — a resize would respawn the nested game (H1/D3), regardless of + // the identity policy. + assert!(!reconfig_allowed(Some(Gamescope), false)); + assert!(!reconfig_allowed(Some(Gamescope), true)); + // A per-client-mode identity policy rejects on every backend — the resize resolves a + // different display-identity slot (H5). + assert!(!reconfig_allowed(Some(Kwin), true)); + assert!(!reconfig_allowed(Some(Mutter), true)); + assert!(!reconfig_allowed(None, true)); + // Every other compositor with the default identity ACCEPTS (recreate / re-arrival / in-place). + for c in [Kwin, Mutter, Wlroots, Hyprland] { + assert!( + reconfig_allowed(Some(c), false), + "{c:?} should allow live reconfigure" + ); + } + // The synthetic source (no compositor) is the protocol-test path — always reconfigurable. + assert!(reconfig_allowed(None, false)); + } + #[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 3dfcda70..b96614c5 100644 --- a/crates/punktfunk-host/src/vdisplay/windows/manager.rs +++ b/crates/punktfunk-host/src/vdisplay/windows/manager.rs @@ -573,24 +573,28 @@ impl VirtualDisplayManager { }; // 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"); - } - }; + 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 }); + 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); @@ -1053,7 +1057,10 @@ impl VirtualDisplayManager { // 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:#}"); + tracing::warn!( + old_target = old.target_id, + "re-arrival REMOVE failed (continuing to ADD): {e:#}" + ); } // 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