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`.
|
/// Open the virtual-display driver for `compositor`.
|
||||||
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
|
|||||||
@@ -325,6 +325,29 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
|||||||
if steamos_session_present() {
|
if steamos_session_present() {
|
||||||
return create_managed_session_steamos(mode);
|
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
|
// 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
|
// 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
|
// 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
|
*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()?;
|
let shim_dir = write_headless_shim()?;
|
||||||
write_steamos_dropin(&shim_dir, mode)?;
|
write_steamos_dropin(&shim_dir, mode)?;
|
||||||
systemctl_user(&["daemon-reload"]);
|
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
|
// connected, frozen on the last frame, and the stream resumes when the new output
|
||||||
// appears — no reconnect.
|
// appears — no reconnect.
|
||||||
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
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 {
|
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
|
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
||||||
// retargeting (then we stick to the pinned backend and just rebuild it).
|
// 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(
|
match build_pipeline_with_retry(
|
||||||
&mut vd,
|
&mut vd,
|
||||||
cur_mode,
|
cur_mode,
|
||||||
@@ -1948,6 +1958,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
|||||||
}
|
}
|
||||||
tracing::warn!(error = %format!("{e2:#}"),
|
tracing::warn!(error = %format!("{e2:#}"),
|
||||||
"capture lost — new session not up yet, retrying");
|
"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