The hand-back never checked that the panel came back, and a crashed host left game mode asleep #375
@@ -367,6 +367,38 @@ fn takeover_state_is_live(state: &TakeoverState) -> bool {
|
||||
|| state.forced_screen_env
|
||||
}
|
||||
|
||||
/// Restart the box's own autologin gaming session(s) after a leftover idle drop-in was swept off
|
||||
/// a host that died holding one ([`restore_takeover_on_startup`]).
|
||||
///
|
||||
/// Gated on the box actually being dark ([`box_session_live`]): if the user is already in game mode
|
||||
/// or on a desktop, the drop-in we removed was inert and bouncing their session would be the bug.
|
||||
/// Only an ACTIVE instance is restarted — under a just-removed idle drop-in, active means "running
|
||||
/// the sleep"; an inactive one is a leftover the display manager will handle on its own.
|
||||
fn hand_back_idled_units_after_crash() {
|
||||
if box_session_live() {
|
||||
return; // something is already drawing — the drop-in was inert
|
||||
}
|
||||
let units: Vec<String> = listed_autologin_units()
|
||||
.into_iter()
|
||||
.filter(|(_, active)| active == "active")
|
||||
.map(|(unit, _)| unit)
|
||||
.collect();
|
||||
if units.is_empty() {
|
||||
return;
|
||||
}
|
||||
tracing::warn!(
|
||||
?units,
|
||||
"gamescope: the box's Game Mode is running the dead host's idle placeholder and its panel \
|
||||
is dark — restarting it"
|
||||
);
|
||||
for unit in &units {
|
||||
if let RestoreVerb::Failed(why) = issue_restore_verb(&["restart", unit]) {
|
||||
tracing::error!(unit, status = %why, "gamescope: could not restart it");
|
||||
}
|
||||
}
|
||||
ensure_box_session_or_escalate(&units);
|
||||
}
|
||||
|
||||
/// On host startup, restore the TV's gaming session if a previous host instance took it over and
|
||||
/// crashed before restoring (`design/gamemode-and-dedicated-sessions.md` A3). Loads the persisted
|
||||
/// [`TakeoverState`] into the statics and schedules a restore after a short reconnect grace (so a
|
||||
@@ -399,6 +431,13 @@ pub fn restore_takeover_on_startup() {
|
||||
"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"
|
||||
);
|
||||
// Removing the FILE does not touch the unit RUNNING under it. That unit's `ExecStart` was
|
||||
// replaced with a sleep, so it is `active` and drawing nothing, and nothing below will
|
||||
// restart it: the takeover file may be absent, unparseable, or not `takeover_state_is_live`
|
||||
// — and all three of those exits used to leave the box sitting on a dark panel with its
|
||||
// Game Mode "running". A host killed mid-stream (SIGKILL, OOM, a yanked update) lands
|
||||
// exactly there, and on glass it is indistinguishable from broken hardware. Hand it back.
|
||||
hand_back_idled_units_after_crash();
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(takeover_state_path()) else {
|
||||
return; // no takeover file — clean start
|
||||
@@ -2984,6 +3023,48 @@ fn replay_switch_under_restored_dm(dm: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The box's autologin gaming instances and their ACTIVE state, as `(unit, active)` pairs — the
|
||||
/// `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION, so the state is the third.
|
||||
///
|
||||
/// An unanswered query reads as "none listed", which is the safe direction for both callers: the
|
||||
/// takeover then frees nothing rather than killing a session it could not see properly, and the
|
||||
/// crash hand-back restarts nothing rather than bouncing one.
|
||||
fn listed_autologin_units() -> Vec<(String, String)> {
|
||||
let Ok(out) = crate::proc::output_within(
|
||||
Command::new("systemctl").args([
|
||||
"--user",
|
||||
"list-units",
|
||||
"--type=service",
|
||||
"--all",
|
||||
"--no-legend",
|
||||
"--plain",
|
||||
"gamescope-session-plus@*.service",
|
||||
]),
|
||||
UNIT_QUERY_BUDGET,
|
||||
) else {
|
||||
return Vec::new();
|
||||
};
|
||||
parse_listed_units(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
/// [`listed_autologin_units`]'s parser (the unit-testable core). Which column the ACTIVE state is
|
||||
/// in decides whether the takeover can tell a live gaming session from a dead leftover, and
|
||||
/// getting that wrong is silent in both directions — a live session read as dead leaves Steam
|
||||
/// holding the instance our own launch then collides with, and a dead one read as live idles a
|
||||
/// session nobody was in.
|
||||
fn parse_listed_units(stdout: &str) -> Vec<(String, String)> {
|
||||
stdout
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut cols = l.split_whitespace();
|
||||
let unit = cols.next()?;
|
||||
let active = cols.nth(1).unwrap_or("");
|
||||
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
|
||||
.then(|| (unit.to_string(), active.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
|
||||
/// single-instance Steam is free for our own host-managed session. Records the units so
|
||||
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
|
||||
@@ -3011,33 +3092,9 @@ fn replay_switch_under_restored_dm(dm: &str) {
|
||||
/// The ORDER is therefore load-bearing and not a style choice: stop the DM, bail if it did not
|
||||
/// land, and only then mask. A mask laid before a stop that never arrives is the storm.
|
||||
fn stop_autologin_sessions() -> Result<()> {
|
||||
let Ok(out) = crate::proc::output_within(
|
||||
Command::new("systemctl").args([
|
||||
"--user",
|
||||
"list-units",
|
||||
"--type=service",
|
||||
"--all",
|
||||
"--no-legend",
|
||||
"--plain",
|
||||
"gamescope-session-plus@*.service",
|
||||
]),
|
||||
UNIT_QUERY_BUDGET,
|
||||
) else {
|
||||
return Ok(());
|
||||
};
|
||||
// `(unit, ACTIVE state)` — the `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION.
|
||||
let listed: Vec<(String, String)> = String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut cols = l.split_whitespace();
|
||||
let unit = cols.next()?;
|
||||
let active = cols.nth(1).unwrap_or("");
|
||||
(unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
|
||||
.then(|| (unit.to_string(), active.to_string()))
|
||||
})
|
||||
.collect();
|
||||
let listed = listed_autologin_units();
|
||||
if listed.is_empty() {
|
||||
return Ok(()); // nothing autologged in — Steam is already free
|
||||
return Ok(()); // nothing autologged in (or the query failed) — Steam is already free
|
||||
}
|
||||
let dm = display_manager_unit();
|
||||
// Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive
|
||||
@@ -3439,7 +3496,13 @@ pub fn restore_takeover_now() {
|
||||
}
|
||||
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = None; // doing it right here
|
||||
tracing::info!("gamescope: host is shutting down — restoring the box's own session first");
|
||||
do_restore_tv_session();
|
||||
// `verify: false` — the escalation ladder waits up to a minute, and this runs inside
|
||||
// `native.rs`'s 20 s `SHUTDOWN_RESTORE_GRACE`, after which `exit(0)` runs no destructors.
|
||||
// Spending that grace watching instead of restoring would COST the hand-back, not check it.
|
||||
// The next host start is what covers a shutdown that left the box dark
|
||||
// ([`restore_takeover_on_startup`], which now hands the box back rather than only sweeping the
|
||||
// drop-in off it).
|
||||
do_restore_tv_session(false);
|
||||
}
|
||||
|
||||
/// What a bounded `systemctl --user` lifecycle verb on the RESTORE path actually did. Three states,
|
||||
@@ -3503,11 +3566,168 @@ fn connected_connector_under(base: &std::path::Path) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// How long a hand-back waits for the box to show something on its own panel before it starts
|
||||
/// escalating. Generous on purpose: the unit's `ExecStart` is a whole gamescope + Steam start, and
|
||||
/// on a cold box that is not quick — while a false escalation costs the user a session bounce.
|
||||
const HANDBACK_GRACE: Duration = Duration::from_secs(25);
|
||||
|
||||
/// How long each escalation rung gets. Shorter than [`HANDBACK_GRACE`]: by the time a rung runs,
|
||||
/// the ordinary start has already had its full grace and not delivered.
|
||||
const HANDBACK_RUNG_GRACE: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Poll slice for the two waits above.
|
||||
const HANDBACK_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Is ANYTHING driving the box's own panel right now — its game mode, or a desktop it switched to?
|
||||
///
|
||||
/// [`super::detect_active_session`] answers precisely the question the symptom asks: it reports the
|
||||
/// running compositor of our uid, and [`super::ActiveKind::None`] means nothing is drawing
|
||||
/// anywhere. Only sound AFTER `stop_session(SESSION_UNIT)` has killed our own managed session —
|
||||
/// that kill is a synchronous SIGKILL ([`kill_unit`]), so by the restore's escalation point our
|
||||
/// gamescope cannot still be answering for the box.
|
||||
fn box_session_live() -> bool {
|
||||
super::detect_active_session().kind != super::ActiveKind::None
|
||||
}
|
||||
|
||||
/// Poll [`box_session_live`] until it is true or `grace` runs out. [`HandbackWait::Superseded`]
|
||||
/// means a client reconnected and took the box over again — the hand-back we were checking is moot,
|
||||
/// and every remedy below would now be fighting a live stream for the box's session.
|
||||
enum HandbackWait {
|
||||
Live,
|
||||
Superseded,
|
||||
TimedOut,
|
||||
}
|
||||
|
||||
fn wait_for_box_session(grace: Duration) -> HandbackWait {
|
||||
let deadline = Instant::now() + grace;
|
||||
loop {
|
||||
if takeover_live() {
|
||||
return HandbackWait::Superseded;
|
||||
}
|
||||
if box_session_live() {
|
||||
return HandbackWait::Live;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return HandbackWait::TimedOut;
|
||||
}
|
||||
std::thread::sleep(HANDBACK_POLL);
|
||||
}
|
||||
}
|
||||
|
||||
/// **The hand-back's last line of defence for a dark panel**, and the only part of this file that
|
||||
/// checks whether the restore it just performed actually WORKED.
|
||||
///
|
||||
/// Everything above issues a lifecycle verb and reports what systemd said about the JOB. That is
|
||||
/// not the same question as "does the box show a picture again", and the gap between the two is
|
||||
/// where every "my screen stays black after disconnecting" report lives — including ones whose
|
||||
/// trigger nobody has reproduced. So stop inferring the outcome and measure it: if nothing is
|
||||
/// driving the panel a full [`HANDBACK_GRACE`] after the hand-back, climb a ladder of remedies,
|
||||
/// each of which is a mechanism measured on both distro families (Bazzite `44.20260818`, Nobara
|
||||
/// f44, 2026-08-22), and say loudly at every rung what is happening.
|
||||
///
|
||||
/// 1. **`stop` the autologin unit.** Its login session's script is parked on
|
||||
/// `systemctl --user --wait start <unit>` (verified on both images), so stopping the unit
|
||||
/// releases that wait, the session exits, and `Relogin=true` logs straight back in — starting
|
||||
/// the unit inside a fresh login session with a seat. `stop`, never `restart`: a restart does
|
||||
/// NOT release the parked waiter (measured), which is exactly why it cannot rescue a box the
|
||||
/// ordinary restart already failed to bring back.
|
||||
/// 2. **Restart the display manager.** What the pre-0.31.0 takeover did on every disconnect, and
|
||||
/// proven to return the box to game mode. Needs privilege, so it can honestly fail.
|
||||
/// 3. **`PUNKTFUNK_RECOVER_SESSION_CMD`**, then an ERROR naming the command a human must run.
|
||||
///
|
||||
/// **Detached**, and that is not incidental. The restore runs under [`RESTORE_FLIGHT`], which a
|
||||
/// reconnecting client must take before it can re-take the box; watching for up to a minute while
|
||||
/// holding it would put that whole wait in front of every reconnect. So the caller fires this and
|
||||
/// returns, and the watcher stands down by itself the moment [`takeover_live`] says a new takeover
|
||||
/// armed — the box belongs to that stream now, and a remedy fired into it would be the bug.
|
||||
/// Call it AFTER `clear_takeover()`, or the very first poll reads our own finished takeover as a
|
||||
/// new one and stands down immediately.
|
||||
///
|
||||
/// A box that was already fine costs one [`box_session_live`] call and the thread exits.
|
||||
fn ensure_box_session_or_escalate(units: &[String]) {
|
||||
let units: Vec<String> = units.to_vec();
|
||||
std::thread::spawn(move || handback_watch(&units));
|
||||
}
|
||||
|
||||
fn handback_watch(units: &[String]) {
|
||||
match wait_for_box_session(HANDBACK_GRACE) {
|
||||
HandbackWait::Live => {
|
||||
tracing::info!(
|
||||
"gamescope: the box is driving its own panel again — hand-back complete"
|
||||
);
|
||||
return;
|
||||
}
|
||||
HandbackWait::Superseded => return,
|
||||
HandbackWait::TimedOut => {}
|
||||
}
|
||||
tracing::warn!(
|
||||
secs = HANDBACK_GRACE.as_secs(),
|
||||
units = ?units,
|
||||
"gamescope: NOTHING is driving the box's panel {}s after the hand-back — its screen is \
|
||||
dark. Escalating: stopping the autologin unit so the display manager relogins into a \
|
||||
session with a seat",
|
||||
HANDBACK_GRACE.as_secs()
|
||||
);
|
||||
// Rung 1 — release the login session's parked `--wait start` and let the DM relogin.
|
||||
for unit in units {
|
||||
if let RestoreVerb::Failed(why) = issue_restore_verb(&["stop", unit]) {
|
||||
tracing::warn!(unit, status = %why, "gamescope: could not stop the autologin unit");
|
||||
}
|
||||
}
|
||||
match wait_for_box_session(HANDBACK_RUNG_GRACE) {
|
||||
HandbackWait::Live => {
|
||||
tracing::info!(
|
||||
"gamescope: the display manager relogged the box into its own session — panel back"
|
||||
);
|
||||
return;
|
||||
}
|
||||
HandbackWait::Superseded => return,
|
||||
HandbackWait::TimedOut => {}
|
||||
}
|
||||
// Rung 2 — put the display manager itself through a restart.
|
||||
if let Some(dm) = display_manager_unit() {
|
||||
tracing::warn!(
|
||||
%dm,
|
||||
"gamescope: the box is still dark — restarting its display manager"
|
||||
);
|
||||
match restore_display_manager(&dm) {
|
||||
Ok(()) => match wait_for_box_session(HANDBACK_RUNG_GRACE) {
|
||||
HandbackWait::Live => {
|
||||
tracing::info!(%dm, "gamescope: the display manager brought the box back");
|
||||
return;
|
||||
}
|
||||
HandbackWait::Superseded => return,
|
||||
HandbackWait::TimedOut => {}
|
||||
},
|
||||
Err(why) => tracing::warn!(
|
||||
%dm,
|
||||
shape = why.shape(),
|
||||
reason = %why,
|
||||
"gamescope: could not restart the display manager"
|
||||
),
|
||||
}
|
||||
}
|
||||
// Rung 3 — the operator's own escape hatch, then say what is left to do by hand.
|
||||
if crate::try_recover_session() {
|
||||
tracing::warn!(
|
||||
"gamescope: fired PUNKTFUNK_RECOVER_SESSION_CMD to bring the box's session back"
|
||||
);
|
||||
return;
|
||||
}
|
||||
tracing::error!(
|
||||
units = ?units,
|
||||
"gamescope: the box has NO session driving its panel and every automatic remedy failed — \
|
||||
its screen stays dark until someone runs `systemctl --user restart <unit>` for one of \
|
||||
these, or `sudo systemctl restart display-manager.service`. Set \
|
||||
PUNKTFUNK_RECOVER_SESSION_CMD to let the host do this itself"
|
||||
);
|
||||
}
|
||||
|
||||
/// Tear down our host-managed session (freeing Steam) and restart the autologin gaming session(s)
|
||||
/// we stopped on connect — so the TV returns to gaming mode when no one is streaming. Invoked by
|
||||
/// [`start_restore_worker`] once the debounce deadline passes; takes the stopped-unit list so a
|
||||
/// cancelled+reconnected window keeps the list for a later real restore.
|
||||
fn do_restore_tv_session() {
|
||||
fn do_restore_tv_session(verify: bool) {
|
||||
// SteamOS: we reconfigured `gamescope-session.target` headless via a drop-in. Restore = remove
|
||||
// the drop-in + restart the target (back to the physical panel) — unless the user switched to a
|
||||
// desktop session meanwhile, in which case drop the override and leave the desktop alone.
|
||||
@@ -3574,6 +3794,9 @@ fn do_restore_tv_session() {
|
||||
),
|
||||
}
|
||||
clear_takeover(); // A3: consumed — after the restart, not before it
|
||||
if verify {
|
||||
ensure_box_session_or_escalate(&[STEAMOS_SESSION_TARGET.to_string()]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3699,14 +3922,14 @@ fn do_restore_tv_session() {
|
||||
}
|
||||
// (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 {
|
||||
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.
|
||||
// `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]) {
|
||||
match issue_restore_verb(&["restart", unit]) {
|
||||
RestoreVerb::Done => tracing::info!(
|
||||
unit,
|
||||
"restored the TV's autologin gaming session (debounce elapsed, no client)"
|
||||
@@ -3731,6 +3954,12 @@ fn do_restore_tv_session() {
|
||||
}
|
||||
}
|
||||
clear_takeover(); // A3: consumed — and only now, with the restarts actually issued
|
||||
// …and CHECK that the restart above actually put a picture back on the box's panel, rather
|
||||
// than trusting the job status to mean that. AFTER `clear_takeover`, which is what makes a
|
||||
// later `takeover_live()` mean "a client reconnected" — see [`ensure_box_session_or_escalate`].
|
||||
if verify {
|
||||
ensure_box_session_or_escalate(&units);
|
||||
}
|
||||
}
|
||||
|
||||
/// Host-lifetime worker that fires a pending [`schedule_restore_tv_session`] once its debounce
|
||||
@@ -3767,7 +3996,10 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
|
||||
}
|
||||
};
|
||||
if still_due {
|
||||
do_restore_tv_session();
|
||||
// The disconnect restore: verified. This is the path the field reports
|
||||
// are about, it is on a worker thread with no deadline over it, and a box
|
||||
// left dark here stays dark until someone walks up to it.
|
||||
do_restore_tv_session(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5334,12 +5566,12 @@ mod tests {
|
||||
classify_output_size, connected_connector_under, display_manager_unit_under, dm_plan,
|
||||
game_hz, gamescope_output_size, hdr_args, idle_dropin_body, idle_dropin_path,
|
||||
install_idle_dropin, is_steam_launch, mask_unit, missing_flags, mode_mismatch,
|
||||
nested_wrapper_script, our_wsi_layer_dir, plan_bind, release_autologin_mask,
|
||||
remove_idle_dropin, 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,
|
||||
nested_wrapper_script, our_wsi_layer_dir, parse_listed_units, plan_bind,
|
||||
release_autologin_mask, remove_idle_dropin, 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};
|
||||
|
||||
@@ -5538,6 +5770,39 @@ mod tests {
|
||||
/// drop-in APPENDS the sleep to the box's own session command and both run — the takeover
|
||||
/// would then be fighting the very Steam it set out to free, and nothing on the box would say
|
||||
/// why. Pins the reset, its order, and that the resolved `sleep` is the one that gets run.
|
||||
/// The `--plain` column the ACTIVE state lives in, pinned against real `systemctl --user
|
||||
/// list-units` output from both distro families. Read the wrong column and a live gaming
|
||||
/// session looks dead (Steam stays held, and our launch collides with it) or a dead leftover
|
||||
/// looks live (the takeover idles a session nobody was in) — both silent on glass.
|
||||
#[test]
|
||||
fn listed_units_take_the_active_column_not_the_load_column() {
|
||||
// Bazzite 44.20260818 and Nobara f44, verbatim (unit / LOAD / ACTIVE / SUB / description).
|
||||
let out = "gamescope-session-plus@ogui-steam.service loaded active running Gamescope Session Plus\n\
|
||||
gamescope-session-plus@steam.service loaded inactive dead Gamescope Session Plus\n";
|
||||
assert_eq!(
|
||||
parse_listed_units(out),
|
||||
vec![
|
||||
(
|
||||
"gamescope-session-plus@ogui-steam.service".to_string(),
|
||||
"active".to_string()
|
||||
),
|
||||
(
|
||||
"gamescope-session-plus@steam.service".to_string(),
|
||||
"inactive".to_string()
|
||||
),
|
||||
]
|
||||
);
|
||||
// `loaded` is the LOAD column and must never be mistaken for the state — that is the
|
||||
// off-by-one this pins.
|
||||
assert!(parse_listed_units(out).iter().all(|(_, a)| a != "loaded"));
|
||||
// Anything that is not one of our template's instances is not ours to touch.
|
||||
assert!(
|
||||
parse_listed_units("plasma-plasmashell.service loaded active running Shell\n")
|
||||
.is_empty()
|
||||
);
|
||||
assert!(parse_listed_units("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_dropin_replaces_exec_start_rather_than_appending() {
|
||||
let body = idle_dropin_body("/usr/bin/sleep");
|
||||
|
||||
Reference in New Issue
Block a user