fix(vdisplay/gamescope): capture-loss rebuild probes must not steal the box session
Observed live on a Deck host: the user switched Game Mode → Desktop, KDE came up in 0.8 s — and the capture-loss rebuild's FIRST detection ran inside that gap, still said Gaming, and its gamescope re-acquire restarted gamescope-session.target. On SteamOS that steals the seat: the user was yanked out of the KDE session they had just chosen, the two session managers fought (job canceled / dependency failed), no gamescope node appeared within 30 s, and the stream died at the 40 s rebuild budget. A rebuild probe acting on possibly-stale detection must never stop, relaunch, or take over box sessions. New pf_vdisplay::rebuild_probe_scope (RAII, counted): while held, the gamescope managed/takeover create paths only attach to a live node and fail fast otherwise — no stop_autologin_sessions, no session-plus relaunch, no gamescope-session.target restart. The capture-loss loop holds it for the first 4 s after a loss (the detection-ambiguity window); after that, destructive rebuilds are allowed again, so a genuine switch INTO Game Mode still gets its headless takeover. Probe failures are instant, so the loop now paces itself at ~2 Hz instead of spinning. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -254,6 +254,34 @@ pub fn detect() -> Result<Compositor> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach-only probes: while any scope is held, backend `create` paths must not stop, relaunch,
|
||||
/// or take over box sessions — they may only attach to an already-live output, and fail fast
|
||||
/// otherwise. The capture-loss rebuild holds one for its first seconds: right after a capture
|
||||
/// loss the active-session detection can be STALE (a Game→Desktop switch observed live: the
|
||||
/// probe's gamescope re-acquire restarted `gamescope-session.target` and yanked the user out of
|
||||
/// the KDE session they had just switched to). A counter, so overlapping scopes compose.
|
||||
static REBUILD_PROBES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// RAII scope marking pipeline builds as attach-only probes (see [`rebuild_probe_active`]).
|
||||
pub struct RebuildProbeScope(());
|
||||
|
||||
pub fn rebuild_probe_scope() -> RebuildProbeScope {
|
||||
REBUILD_PROBES.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
RebuildProbeScope(())
|
||||
}
|
||||
|
||||
impl Drop for RebuildProbeScope {
|
||||
fn drop(&mut self) {
|
||||
REBUILD_PROBES.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is any [`rebuild_probe_scope`] active? Destructive session operations (stop/relaunch/
|
||||
/// takeover-restart) must be skipped while true.
|
||||
pub fn rebuild_probe_active() -> bool {
|
||||
REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0
|
||||
}
|
||||
|
||||
/// Open the virtual-display driver for `compositor`.
|
||||
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -325,6 +325,29 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
||||
if steamos_session_present() {
|
||||
return create_managed_session_steamos(mode);
|
||||
}
|
||||
// Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box
|
||||
// sessions — right after a capture loss the caller's session detection can be stale, and a
|
||||
// destructive rebuild here would fight the session the user just switched to.
|
||||
if crate::rebuild_probe_active() {
|
||||
let guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let same_mode = guard.as_ref().is_some_and(|s| {
|
||||
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
|
||||
});
|
||||
if same_mode {
|
||||
if let Some(node_id) = find_gamescope_node() {
|
||||
point_injector_at_eis();
|
||||
tracing::info!(
|
||||
node_id,
|
||||
"gamescope session: attach-only probe reusing live node"
|
||||
);
|
||||
return Ok(managed_output(node_id, mode));
|
||||
}
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"gamescope session has no attachable live node — attach-only rebuild probe refuses \
|
||||
to stop/relaunch box sessions (re-detection follows the live session)"
|
||||
));
|
||||
}
|
||||
// Steam is single-instance: if the box autologged into gaming mode on a physical display (the
|
||||
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
|
||||
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
||||
@@ -650,6 +673,16 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
|
||||
}
|
||||
*guard = None; // tracked session lost its node — fall through to a clean restart
|
||||
}
|
||||
// Attach-only rebuild probe: the reuse path above may attach, but a restart of the session
|
||||
// target is out of bounds — observed live on a Deck: a stale post-capture-loss detection made
|
||||
// this restart steal the seat back from the KDE session the user had just switched to.
|
||||
if crate::rebuild_probe_active() {
|
||||
return Err(anyhow!(
|
||||
"gamescope has no live node and this is an attach-only rebuild probe — refusing to \
|
||||
restart {STEAMOS_SESSION_TARGET} (the box may be mid-switch to another session; \
|
||||
re-detection follows it)"
|
||||
));
|
||||
}
|
||||
let shim_dir = write_headless_shim()?;
|
||||
write_steamos_dropin(&shim_dir, mode)?;
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
|
||||
@@ -1884,7 +1884,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// connected, frozen on the last frame, and the stream resumes when the new output
|
||||
// appears — no reconnect.
|
||||
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
||||
let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET;
|
||||
// Attach-only holdoff: for the first seconds after a capture loss the session
|
||||
// detection can be STALE (the new session isn't up yet), and a rebuild acting on
|
||||
// a stale "Gaming" answer restarts gamescope-session.target — which on SteamOS
|
||||
// steals the seat back from the session the user just switched to (observed
|
||||
// live). While the holdoff lasts, builds run under a vdisplay rebuild-probe
|
||||
// scope: attach to live outputs only, never stop/relaunch/take over sessions.
|
||||
const PROBE_HOLDOFF: std::time::Duration = std::time::Duration::from_secs(4);
|
||||
let loss_at = std::time::Instant::now();
|
||||
let rebuild_deadline = loss_at + REBUILD_BUDGET;
|
||||
let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop {
|
||||
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
||||
// retargeting (then we stick to the pinned backend and just rebuild it).
|
||||
@@ -1918,6 +1926,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
}
|
||||
}
|
||||
let _probe = (loss_at.elapsed() < PROBE_HOLDOFF)
|
||||
.then(crate::vdisplay::rebuild_probe_scope);
|
||||
match build_pipeline_with_retry(
|
||||
&mut vd,
|
||||
cur_mode,
|
||||
@@ -1948,6 +1958,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
tracing::warn!(error = %format!("{e2:#}"),
|
||||
"capture lost — new session not up yet, retrying");
|
||||
// Probe failures are instant (attach-only bail) — pace the loop so
|
||||
// re-detection runs at ~2 Hz instead of spinning.
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user