From c29186bd1ae27f0eec883e2309965731d64105ff Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 22 Jul 2026 15:34:59 +0200 Subject: [PATCH] fix(host+vdisplay): a mode-switch replacement display inherits the group topology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resize rebuild creates the new virtual display BEFORE retiring the old one (create-before-drop), so the registry saw a live same-backend sibling — its own dying predecessor — and told the backend it was not first in group. Mutter then skipped the Primary/Exclusive apply ("joining an existing display group — extending") and the retiring owner took the topology with it: every resize silently demoted the virtual output to an extended, shell-less desktop. Thread the superseded pool gen through acquire so the replacement establishes topology, and stop counting kept (Lingering/Pinned) entries as demoting siblings — no session owns them, so there is no live desktop to clobber. Co-Authored-By: Claude Fable 5 --- crates/pf-vdisplay/src/vdisplay/registry.rs | 27 ++++++++++++++++--- .../punktfunk-host/src/gamestream/stream.rs | 1 + crates/punktfunk-host/src/native/stream.rs | 14 ++++++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/pf-vdisplay/src/vdisplay/registry.rs b/crates/pf-vdisplay/src/vdisplay/registry.rs index 2ee80fc5..0912f510 100644 --- a/crates/pf-vdisplay/src/vdisplay/registry.rs +++ b/crates/pf-vdisplay/src/vdisplay/registry.rs @@ -84,20 +84,28 @@ fn topology_str() -> String { /// `quit` is the session's deliberate-quit flag: when the session ends with it set (the client closed /// with the quit application code — a user "stop", not a network drop), the display is torn down /// **immediately**, skipping the keep-alive linger. A bare disconnect leaves it `false` → normal linger. +/// +/// `supersedes`: the pool gen of a display this acquire REPLACES (a mid-stream mode switch creates +/// the new display before retiring the old — create-before-drop). The replacement inherits group +/// topology ownership: without this, the dying predecessor counts as a live sibling and the new +/// display "extends" behind it, losing a Primary/Exclusive topology on every resize. `None` +/// everywhere else. pub fn acquire( vd: &mut Box, mode: super::Mode, quit: std::sync::Arc, + supersedes: Option, ) -> Result { let backend = vd.name(); #[cfg(target_os = "linux")] - let out = linux::acquire(vd, mode, quit); + let out = linux::acquire(vd, mode, quit, supersedes); #[cfg(not(target_os = "linux"))] let out = { // Windows leases in the manager (its own linger); its deliberate-quit skip is wired through // `VirtualDisplay::set_quit_flag` on the backend instance (set by the session before any - // `create`, so the retry-hold lease gets it too) — not through this parameter. - let _ = quit; + // `create`, so the retry-hold lease gets it too) — not through this parameter. The + // supersede handoff is Linux-pool-only too (the manager resizes in place). + let _ = (quit, supersedes); vd.create(mode) }; if out.is_ok() { @@ -421,6 +429,7 @@ mod linux { vd: &mut Box, mode: Mode, quit: Arc, + supersedes: Option, ) -> Result { ensure_timer(); let backend = vd.name(); @@ -535,9 +544,19 @@ mod linux { // §6.1) — so a topology-establishing backend (Mutter exclusive) extends into an already-exclusive // desktop rather than re-clobbering the first session's virtual. Best-effort (a concurrent create // is a narrow race); single-session is always `first == true` → today's behavior. + // Siblings that don't demote the newcomer: + // * the display this acquire SUPERSEDES (mode switch, create-before-drop) — still Active + // here because the old lease drops only after the new pipeline is up, but it's leaving, + // and deferring to it loses the group's Primary/Exclusive topology on every resize; + // * kept (Lingering/Pinned) entries — no session owns them, so there is no live desktop to + // clobber; a new session next to an unclaimed leftover should still establish topology. let first_in_group = { let es = r.entries.lock().unwrap(); - !es.iter().any(|e| e.backend == backend) + !es.iter().any(|e| { + e.backend == backend + && Some(e.gen) != supersedes + && matches!(e.life, lifecycle::State::Active { .. }) + }) }; vd.set_first_in_group(first_in_group); diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index 0e56e12a..150888fe 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -404,6 +404,7 @@ fn open_gs_virtual_source( // close code — wiring it to skip-linger is a follow-up, so this path keeps normal keep-alive // (a fresh, never-set flag). std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + None, // fresh session — no display superseded ) .context("create virtual output at client resolution")?; // HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 3ac255f6..cab78b62 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -1559,6 +1559,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option v, Err(e) => { tracing::warn!(error = %format!("{e:#}"), "in-place resize: acquire failed"); @@ -2858,6 +2862,7 @@ fn build_pipeline_with_retry( bit_depth, plan, quit, + None, // fresh bring-up — no display superseded first_frame_budget, trace, ) { @@ -2943,6 +2948,11 @@ fn build_pipeline( bit_depth: u8, plan: crate::session_plan::SessionPlan, quit: &Arc, + // The pool gen of the display this build REPLACES (`Some` only on the mode-switch full + // rebuild, which retires that gen once the new pipeline is up) — the registry lets the + // replacement inherit group topology ownership instead of "extending" behind its dying + // predecessor (the resize would silently demote a Primary/Exclusive virtual output). + supersedes: Option, // First-frame wait override (`None` = the backend's default 10 s): the retry loop shortens // its FIRST attempt so a stream stuck in the gamescope takeover race fails over to the // reconnect that fixes it (see FIRST_ATTEMPT_FRAME_BUDGET in `build_pipeline_with_retry`). @@ -2956,7 +2966,7 @@ fn build_pipeline( // session per policy); on Windows it delegates to `vd.create` (the manager already leases). The // returned `VirtualOutput`'s keepalive is a registry lease — the capturer holds it as before. The // `quit` flag rides into the lease so a deliberate-quit teardown skips the keep-alive linger. - let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone()) + let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone(), supersedes) .context("create virtual output")?; if let Some(t) = trace { t.mark("display_acquired");