fix(host+vdisplay): a mode-switch replacement display inherits the group topology

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:43:23 +02:00
co-authored by Claude Fable 5
parent 315ffaf144
commit c29186bd1a
3 changed files with 36 additions and 6 deletions
+23 -4
View File
@@ -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<dyn super::VirtualDisplay>,
mode: super::Mode,
quit: std::sync::Arc<std::sync::atomic::AtomicBool>,
supersedes: Option<u64>,
) -> Result<super::VirtualOutput> {
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<dyn VirtualDisplay>,
mode: Mode,
quit: Arc<AtomicBool>,
supersedes: Option<u64>,
) -> Result<VirtualOutput> {
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);
@@ -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
+12 -2
View File
@@ -1559,6 +1559,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
bit_depth,
plan,
&quit,
// The display this rebuild supersedes (retired below once the new pipeline is
// up) — its replacement inherits the group topology (Primary/Exclusive).
cur_display_gen,
// No first-frame shortening here: this direct call has no retry wrapper to
// absorb an early bail, and the resize source is a live compositor (the
// takeover race doesn't apply) — keep the patient default.
@@ -2606,7 +2609,8 @@ fn try_inplace_resize(
// Acquire at the new mode: the manager's resize branch runs the in-place mode set (or its
// re-arrival fallback) and returns a +1-ref lease, released again when `vout` drops below —
// the capturer keeps holding its own original lease (`gen` is preserved by both paths).
let vout = match crate::vdisplay::registry::acquire(vd, new_mode, quit.clone()) {
// In-place resize keeps the SAME display (no supersede — the manager resizes the live monitor).
let vout = match crate::vdisplay::registry::acquire(vd, new_mode, quit.clone(), None) {
Ok(v) => 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<AtomicBool>,
// 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<u64>,
// 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");