@@ -128,6 +128,11 @@ static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None
/// [`restore_takeover_on_startup`] sets it for a stranded takeover it adopts: unmasking a unit we
/// never masked is a no-op, while missing one that IS masked leaves the box unable to enter its
/// own Game Mode until reboot.
///
/// ⚠ The takeover itself no longer masks anything — it idles the autologin session instead
/// ([`install_idle_dropin`]), because a masked unit FAILS and a failing unit is what the display
/// manager relogin-loops against. So this is now only ever true for a takeover adopted from a
/// host old enough to have laid one, and the lift paths stay for exactly that box.
static AUTOLOGIN_MASKED : std ::sync ::Mutex < bool > = std ::sync ::Mutex ::new ( false ) ;
/// mtime of the `steamos-session-select` sentinel as of the takeover — the baseline the in-stream
@@ -158,6 +163,10 @@ static SWITCH_HONORED_AT: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::
/// giving the DM's desktop session time to come up so re-detection follows it instead.
const SWITCH_HONOR_GRACE : Duration = Duration ::from_secs ( 120 ) ;
/// Whether [`install_idle_dropin`] has one outstanding. Process memory only — the sweep in
/// [`restore_takeover_on_startup`] is what covers a host that died holding one.
static IDLE_DROPIN_ARMED : std ::sync ::Mutex < bool > = std ::sync ::Mutex ::new ( false ) ;
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
@@ -373,6 +382,15 @@ pub fn restore_takeover_on_startup() {
) ;
systemctl_user ( & [ " daemon-reload " ] ) ;
}
// Same shape, same reason: a host that died mid-stream leaves the box's Game Mode replaced by
// a session that does nothing at all, which looks exactly like broken hardware. Runtime-dir
// state, so a reboot clears it too — this covers the restart that does not.
if remove_idle_dropin ( ) {
tracing ::warn! (
" gamescope: removed a leftover idle drop-in from a previous host instance — the box's \
own Game Mode session would have started and then done nothing "
) ;
}
let Ok ( bytes ) = std ::fs ::read ( takeover_state_path ( ) ) else {
return ; // no takeover file — clean start
} ;
@@ -1287,6 +1305,74 @@ fn legacy_session_plus_dropin_path() -> std::path::PathBuf {
. join ( " .config/systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-bind.conf " )
}
/// Where the takeover's IDLE drop-in lives. Same runtime-dir argument as
/// [`session_plus_dropin_path`], and here it is the safety property the mechanism rests on rather
/// than a tidiness one: this drop-in replaces the box's game-mode `ExecStart`, so a copy that
/// outlived the host would leave the box unable to enter Game Mode at all. Under
/// `$XDG_RUNTIME_DIR` it dies with the login session, and a reboot restores game mode by itself —
/// on top of the unconditional sweep [`restore_takeover_on_startup`] does.
fn idle_dropin_path ( ) -> std ::path ::PathBuf {
let base = crate ::session ::runtime_dir ( ) ;
std ::path ::Path ::new ( & base )
. join ( " systemd/user/gamescope-session-plus@.service.d/zz-punktfunk-idle.conf " )
}
/// `sleep`'s path on this box. The idle `ExecStart` must not be a command that can fail to
/// EXECUTE: a unit that dies on start is precisely the relogin storm this drop-in exists to avoid
/// ([`mask_unit`] has that chain), so resolve it instead of hardcoding one distro's layout.
fn sleep_binary ( ) -> & 'static str {
[ " /usr/bin/sleep " , " /bin/sleep " ]
. into_iter ( )
. find ( | p | std ::path ::Path ::new ( p ) . exists ( ) )
. unwrap_or ( " /usr/bin/sleep " )
}
/// Idle the box's autologin game session for the stream's duration: a drop-in over the
/// `gamescope-session-plus@` TEMPLATE (so it reaches whichever instance this box autologs into)
/// that replaces `ExecStart` with a process which merely sleeps.
///
/// This is what the takeover uses INSTEAD of stopping the display manager, and it satisfies all
/// three things that path has to get right at once. Steam is freed (the session runs nothing).
/// The DM does not storm: its autologin still SUCCEEDS, so there is no failed session to relogin
/// against — unlike a masked unit, which fails in milliseconds and is the storm's engine. And the
/// box keeps a live display manager, so a session switch the user asks for can still be serviced;
/// that is the one a stopped DM could not, and it stranded `.41` on Steam's "Switch to Desktop"
/// modal until a reboot.
///
/// Measured on that box: with this installed, `steam` is down, `sddm` stays active, the unit sits
/// `active (running)` with `NRestarts=0`, and a subsequent `switch-to-desktop-mode` brings Plasma
/// up in ~10 s.
fn install_idle_dropin ( ) -> Result < ( ) > {
let path = idle_dropin_path ( ) ;
let dir = path
. parent ( )
. context ( " the idle drop-in path has no parent directory " ) ? ;
std ::fs ::create_dir_all ( dir ) . with_context ( | | format! ( " create {} " , dir . display ( ) ) ) ? ;
std ::fs ::write (
& path ,
format! (
" [Service] \n ExecStart= \n ExecStart={} infinity \n " ,
sleep_binary ( )
) ,
)
. with_context ( | | format! ( " write {} " , path . display ( ) ) ) ? ;
systemctl_user ( & [ " daemon-reload " ] ) ;
* IDLE_DROPIN_ARMED . lock ( ) . unwrap_or_else ( | e | e . into_inner ( ) ) = true ;
Ok ( ( ) )
}
/// Remove the idle drop-in so the box's own Game Mode runs for real again; reports whether one was
/// there. Deliberately NOT gated on [`IDLE_DROPIN_ARMED`] — the flag is this process's memory, and
/// the drop-in outliving a host that died is exactly the case that has to be swept.
fn remove_idle_dropin ( ) -> bool {
let removed = std ::fs ::remove_file ( idle_dropin_path ( ) ) . is_ok ( ) ;
* IDLE_DROPIN_ARMED . lock ( ) . unwrap_or_else ( | e | e . into_inner ( ) ) = false ;
if removed {
systemctl_user ( & [ " daemon-reload " ] ) ;
}
removed
}
/// Write the box-session drop-in carrying the same two fixes the transient path gets: the bind, and
/// the WSI opt-out when the box's layer was built for a different gamescope. `PF_HZ`/`PF_HDR_ARGS`
/// ride along because the wrapper reads them (without `PF_HZ` it falls back to 60).
@@ -2230,16 +2316,24 @@ struct DmPlan {
/// no Steam, masking them while a DM is up is the relogin storm ([`mask_unit`]), and stopping
/// the DM would kill the user's live desktop for it.
skip : bool ,
/// Stop the DM for the stream's duration (only a live instance justifies it). Masking is not
/// a plan input: it is laid only once this stop has LANDED, so it can never substitute for it.
stop_dm : bool ,
/// A display manager drives this LIVE gaming session, so its autologin brings the session
/// straight back the moment we free Steam. That is what the idle drop-in answers
/// ([`install_idle_dropin`]) — not, any longer, stopping the DM.
///
/// Stopping it satisfied the same requirement and broke a different one: a box with no DM has
/// nothing that can start a desktop session, so the user's own "Switch to Desktop" hung on
/// Steam's modal until a reboot (field report 2026-08-18). It hung UNDETECTABLY, which is why
/// no amount of watching fixes it: on a `steamos-manager` box the switch is a D-Bus call whose
/// every trace — the sddm state file, the session units, the login mode — is written by the
/// display manager we had just stopped. Leave the DM up and there is nothing to detect.
dm_relogins : bool ,
}
/// See [`DmPlan`].
fn dm_plan ( dm : Option < & str > , any_live : bool ) -> DmPlan {
DmPlan {
skip : ! any_live ,
stop_dm : dm . is_some ( ) & & any_live ,
dm_relogins : dm . is_some ( ) & & any_live ,
}
}
@@ -2974,88 +3068,37 @@ fn stop_autologin_sessions() -> Result<()> {
if plan . skip {
return Ok ( ( ) ) ;
}
if plan . stop_dm {
let dm = dm . expect ( " stop_dm ⇒ Some " ) ;
// The DM stop ends this user's last login session. If our own lifetime hangs off the user
// manager and lingering can't be turned on, that stop kills the host ~10s later — with the
// box's display manager down and nobody left to bring it back.
//
// BOTH arms below now BAIL, on every DM flavor. They did not always: SDDM used to degrade
// to mask-only here, on the reasoning that the mask still protects Steam and the cost is
// just relogin churn. It is not just churn — the mask is IN sddm' s relogin path, so a
// mask without the stop is a 4– 5 logins/s fork storm that drops the pad from 250 Hz to
// 1.4 Hz ([`mask_unit`]). Degrading to attach costs the client's mode; degra ding to
// mask-only costs the user their input plane. Attach wins.
//
// Both bails quote the REASON they were handed rather than describing one.
// 0.26.0/0.27.0 described one — "the packaged pf-dm-helper polkit action is missing or was
// denied (reinstall the punktfunk package, or install the display-manager polkit rule from
// the docs)" — and on the box that produced it the action was installed, permissive,
// correctly annotated, and pkexec had already RUN the helper; the helper's refusal ("user
// 'x' is not in the 'punktfunk' group") was thrown away with its stderr. Both suggested
// remedies were dead ends: neither a reinstall nor a polkit rule adds anyone to a group.
if let Err ( why ) = ensure_host_survives_dm_stop ( ) {
// The reason goes LAST in both bails: the helper's own refusal ends in a command
// to paste, and burying that mid-sentence is how it stops being read.
bail! (
" stopping {dm} ends this user's last login session, and without lingering \
logind would stop the user manager — and this host with it — about 10 s \
later , leaving the box with no display manager and nothing to restore it ; \
lingering could not be enabled , so the managed takeover is unavailable . \
Either run ` sudo loginctl enable - linger $USER ` once , as the setup docs ask , \
and reconnect — or fix the privileged path : { why } "
) ;
}
if let Err ( why ) = try_stop_display_manager ( & dm ) {
// ERROR, not WARN, and it names the SHAPE: this is the branch whose silence cost an
// evening on .41 — the takeover degraded, nothing failed loudly, and the storm that
// followed read as a pad bug. The `bail!` below reaches the caller's own warn line;
// this one exists so the shape survives into the journal even if the caller's does
// not, because the four shapes need four different fixes.
tracing ::error! (
% dm ,
shape = why . shape ( ) ,
reason = % why ,
" the managed takeover planned to stop the display manager and could not — \
degrading to ATTACH rather than fighting its autologin : a killed session under \
a running DM relogin - loops at 4 - 5 / s and starves the box ' s input plane "
) ;
bail! (
" the box's gaming session is driven by {dm}, and stopping it for the stream needs \
privilege this host does not have ; taking over without stopping it would leave \
its autologin relogin - looping against us for the whole stream , so the managed \
takeover is unavailable — { why } "
) ;
}
tracing ::info! (
% dm ,
" freed Steam: stopped the display manager for this stream (its autologin \
Relogin loop would otherwise churn against the takeover ) "
) ;
// Baseline the switch sentinel HERE, not just at a successful launch: setting
// STOPPED_DM is what arms the honor gate, so from this instant an unbaselined
// sentinel would read as an in-stream "Switch to Desktop" — including the write from
// the switch that just brought the box INTO game mode. A successful launch
// re-baselines (tighter still).
record_session_select_baseline ( ) ;
* STOPPED_DM . lock ( ) . unwrap_or_else ( | e | e . into_inner ( ) ) = Some ( dm ) ;
// Already idled by an earlier connect in this stream's life? Then the session listed as "live"
// above is our own idled one — it holds no Steam and there is nothing left to free. Without
// this, every reconnect and every in-place rebuild would kill and restart the box's session
// again to accomplish exactly nothing.
if * IDLE_DROPIN_ARMED . lock ( ) . unwrap_or_else ( | e | e . into_inner ( ) ) {
return Ok ( ( ) ) ;
}
// The display manager STAYS UP. Freeing Steam means ending the session its autologin owns, and
// the two ways to stop that autologin fighting u s a re not equivalent: stopping the DM works
// until the user asks for a desktop session, at which point nothing on the box can give them
// one ([`DmPlan::dm_relogins`]). Idling the session instead keeps the autologin succee ding —
// no failed unit to relogin against, no storm — while leaving the DM able to service that
// switch.
if plan . dm_relogins {
install_idle_dropin ( ) . context ( " idling the box's autologin game session for the stream " ) ? ;
}
// Reaching here means no display manager can relogin against us: either there is none
// (`!plan.stop_dm` with `dm == None`), or the stop above LANDED — both failure arms bail. That
// is the precondition the mask needs, and the only one under which it is a defense rather than
// the storm's accelerator ([`mask_unit`]).
let units : Vec < String > = listed . into_iter ( ) . map ( | ( u , _ ) | u ) . collect ( ) ;
let mut stopped = Vec ::new ( ) ;
// Record that a mask is outstanding BEFORE laying it: every hand-back path lifts it off this
// flag, and one that ran between the mask and an unrecorded flag would leave it on forever.
* AUTOLOGIN_MASKED . lock ( ) . unwrap_or_else ( | e | e . into_inner ( ) ) = true ;
for unit in units {
mask_unit ( & unit ) ; // belt-and-braces: no DM is up to relogin through it
kill_unit ( & unit ) ; // SIGKILL teardown — avoid the F44 GPU-context leak
if plan . dm_relogins {
// Bring it back ourselves rather than waiting for the DM to notice: deterministic, and
// it closes the window in which the DM sees a dead session and starts churning. The
// drop-in above is already loaded, so what comes back runs nothing.
systemctl_user ( & [ " restart " , & unit ] ) ;
}
tracing ::info! (
% unit ,
dm_stopped = plan . stop_dm ,
" freed Steam: masked and stopped the autologin gaming session for this stream "
idled = plan . dm_relogins ,
" freed Steam: the box's autologin gaming session is idled for this stream (its \
display manager stays up , so the box can still switch sessions ) "
) ;
stopped . push ( unit ) ;
}
@@ -3591,6 +3634,17 @@ fn do_restore_tv_session() {
// rests on. It used to sit after the desktop-active and DM returns, so those two paths leaked
// it.
disarm_session_plus_dropin ( ) ;
// The idle drop-in belongs to the same rule and leaks the same way — worse, in fact: the bind
// one leaves the box's Game Mode running OUR gamescope, this one leaves it running NOTHING.
// The desktop-active return below is the live case (the user switched away, so we never
// restart the units), and a drop-in left there is a box whose Game Mode silently does nothing
// for the rest of the login.
if remove_idle_dropin ( ) {
tracing ::info! (
" gamescope: removed the takeover's idle drop-in — the box's own Game Mode runs for \
real again "
) ;
}
unset_forced_session_screen_env ( ) ;
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
// user switched to a desktop session (KDE/GNOME/wlroots/Hyprland) in the meantime, don't yank
@@ -3648,12 +3702,16 @@ fn do_restore_tv_session() {
clear_takeover ( ) ;
return ;
}
// (The idle drop-in is already gone — removed above every early return, so the restarts
// below bring the box's real session back rather than another idle one.)
for unit in units {
// Checked, not discarded: this call and the SteamOS `restart` above were the two places
// that logged an unconditional success over a thrown-away exit status. A `--user start`
// fails for reasons an operator can act on (the unit is masked, its start limit tripped),
// and the DM branch thirty lines up already shows the shape — say what happened.
match issue_restore_verb ( & [ " start " , & unit ] ) {
// `restart`, not `start`: the idle takeover leaves the unit ACTIVE, and `start` on an
// active unit is a no-op that would report success over a session still running nothing.
match issue_restore_verb ( & [ " restart " , & unit ] ) {
RestoreVerb ::Done = > tracing ::info! (
unit ,
" restored the TV's autologin gaming session (debounce elapsed, no client) "
@@ -5583,27 +5641,27 @@ mod tests {
}
#[ test ]
fn dm_plan_stops_any_dm_that_drove_a_live_session ( ) {
// A live gaming session behind a DM: stop the DM , whatever the flavor. The mask alone does
// NOT stop the relogin loop — on .41 it is what makes the loop fast, because the session
// script's last act is `systemctl --user --wait start gamescope-session-plus@…` and a
// masked unit fails that in milliseconds (4-5 logins/s, pad at 1.4 Hz, 2026-08-18) .
fn dm_plan_idles_any_dm_that_drove_a_live_session ( ) {
// A live gaming session behind a DM: idle it , whatever the flavor. Neither of the two
// things that do NOT work is flavor-dependent — a mask fails the unit in milliseconds and
// makes the relogin loop fast (4-5 logins/s, pad at 1.4 Hz, 2026-08-18), and stopping the
// DM leaves nothing able to start a desktop session when the user asks for one .
let p = dm_plan ( Some ( " sddm.service " ) , true ) ;
assert! ( ! p . skip & & p . stop_dm ) ;
assert! ( ! p . skip & & p . dm_relogins ) ;
// Flavor is no longer an input: plasmalogin gets the same plan as sddm. It used to differ
// only to pick a DEGRADED mode (mask-only for sddm), and that degrade is now gone —
// `stop_autologin_sessions` bails to ATTACH instead.
let q = dm_plan ( Some ( " plasmalogin.service " ) , true ) ;
assert! ( q . skip = = p . skip & & q . stop_dm = = p . stop_dm ) ;
assert! ( q . skip = = p . skip & & q . dm_relogins = = p . dm_relogins ) ;
// Nothing live, DM present: hands off entirely, on EVERY flavor. Killing loaded-but-
// inactive leftovers frees no Steam; masking them while the DM is up is the storm; and
// stopping the DM would kill the user's live desktop for it.
assert! ( dm_plan ( Some ( " sddm.service " ) , false ) . skip ) ;
assert! ( dm_plan ( Some ( " plasmalogin.service " ) , false ) . skip ) ;
// No DM at all (getty autologin), live: mask+kill, nothing to stop — masking is sound
// here precisely because no relogin loop exists to run into it .
// No DM at all (getty autologin), live: kill and leave it stopped. Nothing relogins, so
// t here is no autologin to idle — and no reason to leave a drop-in on the box .
let p = dm_plan ( None , true ) ;
assert! ( ! p . skip & & ! p . stop_dm ) ;
assert! ( ! p . skip & & ! p . dm_relogins ) ;
assert! ( dm_plan ( None , false ) . skip ) ;
}