An in-flight TV restore raced a reconnect's takeover and resurrected sddm under the mask #246

Merged
enricobuehler merged 1 commits from worktree-dm-restore-reconnect-race into main 2026-08-15 10:57:05 +00:00
@@ -164,6 +164,26 @@ const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
/// [`start_restore_worker`] thread.
static PENDING_RESTORE: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::new(None);
/// Serializes an IN-FLIGHT restore against a (re)connect's takeover. Clearing [`PENDING_RESTORE`]
/// only cancels a restore that hasn't STARTED; under `keep_alive=off` the debounce is 0 s, so the
/// worker routinely pops the deadline before the reconnect's cancel arrives, and the restore then
/// runs CONCURRENTLY with the new takeover. That interleaving is what put a box into
/// "display manager up + autologin unit masked" (field, .41 2026-08-15): the new takeover found the
/// autologin unit inactive (the restore hadn't restarted it yet) so [`dm_plan`] correctly refused
/// to stop the DM for a dead unit — and the in-flight restore then brought SDDM back underneath the
/// mask, whose Relogin loop churned ~5 sessions/s until someone stopped it by hand (SDDM's helper
/// execs the session script directly, so the mask stops only the final unit start, never the loop —
/// see `mask_unit`).
///
/// Holding this across [`do_restore_tv_session`] makes the race a plain ordering: either the cancel
/// wins (no restore starts, warm reuse) or the restore wins (the connect waits, then takes over a
/// fully restored box — where the autologin unit IS live again, so the DM stop engages normally).
///
/// LOCK ORDER: OUTERMOST — taken only at the entry points ([`start_restore_worker`]'s pop,
/// [`cancel_pending_restore`], [`restore_takeover_now`]), and NEVER while any other static in this
/// module is held. Everything [`do_restore_tv_session`] locks nests inside it.
static RESTORE_FLIGHT: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// How long to wait after the last disconnect before restoring the TV's autologin gaming session —
/// long enough that a quick reconnect (e.g. a controller hiccup) reuses the warm managed session
/// instead of triggering a stop/relaunch.
@@ -626,7 +646,10 @@ impl VirtualDisplay for GamescopeDisplay {
fn create_managed_session(client: &str, mode: Mode, hdr: bool) -> Result<VirtualOutput> {
// A (re)connect cancels any pending debounced TV-restore: we're about to (re)use the managed
// session, so the autologin must stay stopped and the warm session stays up (no stop/relaunch).
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None;
// Via [`cancel_pending_restore`], NOT a bare `PENDING_RESTORE` clear — the cancel also waits
// out a restore that already popped the deadline (keep_alive=off makes that the COMMON
// reconnect case: debounce 0 s), so the takeover below never interleaves with it.
cancel_pending_restore();
// SteamOS (the real Steam Deck) has no session-plus: take over its `gamescope-session.target`
// headless at the client's mode instead of launching a separate managed session.
if steamos_session_present() {
@@ -3088,6 +3111,22 @@ fn pid_running(pid: u32) -> bool {
/// Called from the connect path (native `resolve_compositor`, GameStream `open_gs_virtual_source`).
/// No-op when nothing is pending; the stopped-unit list stays armed for a later real disconnect.
pub fn cancel_pending_restore() {
// Serialize against an IN-FLIGHT restore (see [`RESTORE_FLIGHT`]): a cancel can only win
// before the worker pops the deadline. Once the restore is running, the correct move is to
// WAIT it out — the takeover that follows then finds the box fully restored (autologin unit
// live again), so it re-takes it through the ordinary path, DM stop included. Racing past it
// instead is how a box ended up with SDDM back up underneath a fresh mask (relogin storm).
let _flight = match RESTORE_FLIGHT.try_lock() {
Ok(g) => g,
Err(std::sync::TryLockError::Poisoned(e)) => e.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => {
tracing::info!(
"gamescope: a TV-session restore is in flight — the (re)connect waits for it, \
then takes the restored session over from scratch"
);
RESTORE_FLIGHT.lock().unwrap_or_else(|e| e.into_inner())
}
};
let mut g = PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner());
if g.is_some() {
*g = None;
@@ -3203,6 +3242,10 @@ fn takeover_live() -> bool {
/// `keep_alive=forever` pins a session for the NEXT client, which is meaningless once the host
/// that would serve them is exiting. No-op when nothing was taken over.
pub fn restore_takeover_now() {
// Take the flight lock BEFORE reading the takeover state: if the worker's debounced restore is
// mid-run, this waits it out and then finds `takeover_live()` false — one restore, not two
// interleaved ones (the worker's is bounded by the same verb budgets this one would be).
let _flight = RESTORE_FLIGHT.lock().unwrap_or_else(|e| e.into_inner());
if !takeover_live() {
return;
}
@@ -3496,18 +3539,31 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
.spawn(move || {
while weak.upgrade().is_some() {
std::thread::sleep(Duration::from_millis(100));
let due = {
let mut g = PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner());
match *g {
Some(deadline) if Instant::now() >= deadline => {
*g = None;
true
}
_ => false,
}
};
// PEEK first, POP only under RESTORE_FLIGHT: popping out here re-opens the window
// this lock exists to close — a reconnect's cancel between the pop and the restore
// would no-op (nothing pending) while the restore still ran against the new
// takeover. Under the flight lock the re-check is authoritative: a cancel that got
// the lock first has cleared the deadline, and one that arrives later blocks until
// this restore is done.
let due = PENDING_RESTORE
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some_and(|deadline| Instant::now() >= deadline);
if due {
do_restore_tv_session();
let _flight = RESTORE_FLIGHT.lock().unwrap_or_else(|e| e.into_inner());
let still_due = {
let mut g = PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner());
match *g {
Some(deadline) if Instant::now() >= deadline => {
*g = None;
true
}
_ => false,
}
};
if still_due {
do_restore_tv_session();
}
}
}
})
@@ -4923,16 +4979,18 @@ impl Drop for GamescopeProc {
#[cfg(test)]
mod tests {
use super::{
any_output_size_is, cgroup_is_punktfunk_owned, cgroup_under_user_manager,
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
dm_survives_masked_unit, game_hz, gamescope_output_size, hdr_args, is_steam_launch,
mask_unit, missing_flags, mode_mismatch, nested_wrapper_script, our_wsi_layer_dir,
plan_bind, release_autologin_mask, script_hardcodes_gamescope, sentinel_advanced,
shape_dedicated_command, switch_ends_mask_window, takeover_state_is_live, unmask_unit,
xwayland_refusal_marker, BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind,
TakeoverState, WsiPlan, AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, STOPPED_AUTOLOGIN,
WSI_OFF_ENV, X11_SOCKET_DIR,
any_output_size_is, cancel_pending_restore, cgroup_is_punktfunk_owned,
cgroup_under_user_manager, classify_output_size, connected_connector_under,
display_manager_unit_under, dm_plan, dm_survives_masked_unit, game_hz,
gamescope_output_size, hdr_args, is_steam_launch, mask_unit, missing_flags, mode_mismatch,
nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
script_hardcodes_gamescope, sentinel_advanced, shape_dedicated_command,
switch_ends_mask_window, takeover_state_is_live, unmask_unit, xwayland_refusal_marker,
BindOff, BindPlan, BoxOutputSize, DmHelperError, SessionBind, TakeoverState, WsiPlan,
AUTOLOGIN_MASKED, DISTRO_GAMESCOPE_PATH, PENDING_RESTORE, RESTORE_FLIGHT,
STOPPED_AUTOLOGIN, WSI_OFF_ENV, X11_SOCKET_DIR,
};
use std::time::{Duration, Instant};
fn argv(s: &str) -> Vec<String> {
s.split_whitespace().map(String::from).collect()
@@ -5252,6 +5310,41 @@ mod tests {
assert!(!p.skip && p.mask && !p.stop_dm);
}
#[test]
fn reconnect_cancel_waits_out_an_in_flight_restore() {
// The .41 storm (2026-08-15): under keep_alive=off the worker pops the 0 s deadline before
// the reconnect's cancel arrives, and the restore then ran CONCURRENTLY with the new
// takeover — resurrecting SDDM underneath the fresh mask. The fix is RESTORE_FLIGHT:
// while a restore holds it (the worker's do_restore window), `cancel_pending_restore`
// must BLOCK, and must complete once the restore releases it. `do_restore_tv_session`
// itself shells out to systemctl, so the test models its lock window directly.
let flight = RESTORE_FLIGHT.lock().unwrap_or_else(|e| e.into_inner());
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
Some(Instant::now() + Duration::from_secs(300));
let cancel = std::thread::spawn(cancel_pending_restore);
// The cancel must not race past the in-flight restore. A sleep-based "still running"
// probe is the wrong shape (a slow scheduler passes it vacuously); the load-bearing
// assertions are the two below — pending survives while the flight lock is held, and the
// cancel completes (no deadlock) and clears it once released.
std::thread::sleep(Duration::from_millis(50));
assert!(
PENDING_RESTORE
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some(),
"cancel cleared the pending restore while the restore was still in flight"
);
drop(flight);
cancel.join().expect("cancel thread panicked");
assert!(
PENDING_RESTORE
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_none(),
"cancel returned without clearing the pending restore"
);
}
#[test]
fn only_a_desktop_switch_ends_the_mask_window() {
use crate::ActiveKind;