fix(host/gamelease): a game that exited stops counting as running
When a launched game's processes are all gone, the watcher asks one last out-of-band question before ending the session: does the launcher still think the game is up? On Windows that reads Steam's per-app `Running` registry flag. It was only ever meant to be a tie-breaker for a scan that momentarily can't see the game — a launcher re-execing, an engine relaunching itself into a new pid. It had no bound. Honouring the flag reset the confirm window every pass, so a flag Steam left set — it does that whenever it doesn't cleanly observe the exit: it crashed, it was closed first, the game re-parented — pinned the lease in `running` for the life of the host. The console kept showing the game, `session_on_game_exit` never fired, and the only way to get the stream back was a manual "End". Reported from the field on Windows 0.24.0. `steam_running_hint` also believes the FIRST hive that says so, so a stale flag in any loaded profile was enough. The absence timer now keeps running instead of being reset, and that is what bounds it: past `VETO_LIMIT` (30 s) with nothing of the game on the box, the launcher's opinion is stale rather than early and the session ends anyway, logged at WARN so it is visible. Ending a moment early is the cheaper failure — the stream drops while the game lives, the user reconnects, and nothing is ever killed. Ending never was the bug. The rule is now a pure `exit_confirmed(gone_for, hint_running)` with a test. The watch loop polls a live process table and can't be unit-tested, which is exactly how an unbounded veto shipped unnoticed.
This commit is contained in:
@@ -52,6 +52,24 @@ const EXIT_CONFIRM: Duration = Duration::from_secs(3);
|
||||
const SHIM_WINDOW: Duration = Duration::from_secs(5);
|
||||
/// How long a game gets to close on its own after a polite request, before it is killed outright.
|
||||
const TERM_GRACE: Duration = Duration::from_secs(10);
|
||||
/// How long [`crate::procscan::running_hint`] may hold off the exit once the game's processes have
|
||||
/// all gone.
|
||||
///
|
||||
/// The hint is a tie-breaker for a scan that momentarily cannot see the game — a launcher re-execing,
|
||||
/// an engine relaunching itself into a new pid — and those gaps are over in seconds, an order of
|
||||
/// magnitude inside this window. Past it, a game nothing can find is gone whatever the hint says.
|
||||
///
|
||||
/// **Bounded because the hint's backing state is not guaranteed to be truthful.** Windows reads
|
||||
/// Steam's per-app `Running` registry flag, which Steam leaves set whenever it does not cleanly
|
||||
/// observe the exit (Steam crashed or was closed first, the game re-parented, a launcher appid stays
|
||||
/// set) — and `steam_running_hint` believes the first hive that says so, including a stale one left
|
||||
/// in another profile. An UNBOUNDED veto turns that into a session that never ends on its own: the
|
||||
/// console shows the game running for as long as the host does, `session_on_game_exit` never fires,
|
||||
/// and only a manual "End" gets the stream back (field report 2026-08-06, Windows host 0.24.0).
|
||||
///
|
||||
/// Ending a moment too early is the cheaper failure: the stream drops while the game lives (the user
|
||||
/// reconnects, and `finish` never kills anything). Ending never is the bug above.
|
||||
const VETO_LIMIT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// A child process the host spawned for a launch, and what may safely be signalled for it.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -540,29 +558,59 @@ fn watch(shared: Arc<LeaseShared>, mut child: Option<std::process::Child>, on_ex
|
||||
gone_since = None;
|
||||
vetoed = false;
|
||||
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
|
||||
} else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM {
|
||||
// Last check before ending a session: does anything outside the process scan still think
|
||||
// the game is up? Only a veto, never a reason to call it running — see
|
||||
// `procscan::running_hint`. The failure mode of honoring it is a stream that stays up.
|
||||
if crate::procscan::running_hint(&shared.spec) == Some(true) {
|
||||
if !vetoed {
|
||||
vetoed = true;
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
"no game processes found, but its launcher still reports it running — not \
|
||||
ending the session"
|
||||
);
|
||||
} else {
|
||||
// How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by
|
||||
// the veto below — letting it run on is exactly what bounds the veto.
|
||||
let gone_for = gone_since.get_or_insert_with(Instant::now).elapsed();
|
||||
if gone_for >= EXIT_CONFIRM {
|
||||
// Last check before ending a session: does anything outside the process scan still
|
||||
// think the game is up? Only a veto, never a reason to call it running — see
|
||||
// `procscan::running_hint`.
|
||||
let hint_running = crate::procscan::running_hint(&shared.spec) == Some(true);
|
||||
if !exit_confirmed(gone_for, hint_running) {
|
||||
if !vetoed {
|
||||
vetoed = true;
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
veto_limit_s = VETO_LIMIT.as_secs(),
|
||||
"no game processes found, but its launcher still reports it running — \
|
||||
holding off on ending the session"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if hint_running {
|
||||
// The veto outlived its usefulness: nothing this scan can see has existed
|
||||
// for VETO_LIMIT, so the launcher's opinion is stale, not early.
|
||||
tracing::warn!(
|
||||
title = %shared.game.title,
|
||||
gone_for_s = gone_for.as_secs(),
|
||||
"its launcher still reports the game running, but nothing of it has \
|
||||
been on the box for {}s — treating that as a stale flag and ending \
|
||||
the session",
|
||||
VETO_LIMIT.as_secs()
|
||||
);
|
||||
}
|
||||
finish(&shared, &on_exit, "the game exited");
|
||||
return;
|
||||
}
|
||||
gone_since = None;
|
||||
} else {
|
||||
finish(&shared, &on_exit, "the game exited");
|
||||
return;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(POLL);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a game nothing can find any more counts as exited: absent for at least [`EXIT_CONFIRM`],
|
||||
/// and either unopposed or absent long enough that the opposition ([`crate::procscan::running_hint`]
|
||||
/// saying `Some(true)`) has been overruled by [`VETO_LIMIT`].
|
||||
///
|
||||
/// Split out of the watch loop because it is the one rule in this file whose *bound* is the fix:
|
||||
/// the loop itself polls a live process table and cannot be unit-tested, which is how an unbounded
|
||||
/// veto shipped. Pure, so the table below is the whole contract.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn exit_confirmed(gone_for: Duration, hint_running: bool) -> bool {
|
||||
gone_for >= EXIT_CONFIRM && (!hint_running || gone_for >= VETO_LIMIT)
|
||||
}
|
||||
|
||||
/// Record the exit and, unless the host itself ended the game, run the session-ending action.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
fn finish(shared: &Arc<LeaseShared>, on_exit: &OnExit, why: &str) {
|
||||
@@ -1037,6 +1085,34 @@ mod tests {
|
||||
.any(|(s, _)| s.game.id.as_deref() == Some(id))
|
||||
}
|
||||
|
||||
/// The exit rule, including the thing that was missing: the veto ENDS.
|
||||
///
|
||||
/// Field 2026-08-06 (Windows 0.24.0): Steam's per-app `Running` flag was left set after the game
|
||||
/// exited, the watcher honoured it on every pass and reset its own confirm window each time, so
|
||||
/// the game read as running for the life of the host and the stream never auto-ended. The last
|
||||
/// case below is that regression.
|
||||
#[cfg(any(target_os = "linux", windows))]
|
||||
#[test]
|
||||
fn the_launcher_veto_expires_instead_of_pinning_a_session_open() {
|
||||
let brief = EXIT_CONFIRM / 2;
|
||||
let confirmed = EXIT_CONFIRM + Duration::from_secs(1);
|
||||
let long = VETO_LIMIT + Duration::from_secs(1);
|
||||
|
||||
// Too early to call it either way — a process swap is still plausible.
|
||||
assert!(!exit_confirmed(brief, false));
|
||||
assert!(!exit_confirmed(brief, true));
|
||||
// Gone past the confirm window with nothing objecting: exited.
|
||||
assert!(exit_confirmed(confirmed, false));
|
||||
// Same, but the launcher objects — that is what the veto is FOR, so hold off.
|
||||
assert!(!exit_confirmed(confirmed, true));
|
||||
// …and this is the bound. Still objecting, but nothing of the game has existed for
|
||||
// VETO_LIMIT, so the objection is stale and the session ends anyway.
|
||||
assert!(exit_confirmed(long, true));
|
||||
assert!(exit_confirmed(long, false));
|
||||
// (The middle two cases together also pin VETO_LIMIT > EXIT_CONFIRM: a veto that did not
|
||||
// outlast the window it overrides could never hold anything off in the first place.)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_follows_what_the_launch_gave_us() {
|
||||
// Nested wins over everything: the display layer owns the lifetime.
|
||||
|
||||
Reference in New Issue
Block a user