Files
punktfunk/crates/punktfunk-host/src/gamelease.rs
T
enricobuehler 980b399ea8
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m8s
apple / swift (push) Successful in 5m53s
ci / bench (push) Successful in 5m59s
android / android (push) Successful in 13m13s
arch / build-publish (push) Successful in 13m16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
decky / build-publish (push) Successful in 20s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
deb / build-publish-client-arm64 (push) Successful in 7m53s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
docker / build-push-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 24s
ci / rust-arm64 (push) Successful in 15m10s
deb / build-publish (push) Successful in 14m12s
deb / build-publish-host (push) Successful in 15m13s
windows-host / package (push) Successful in 19m0s
windows-host / winget-source (push) Skipped
ci / rust (push) Successful in 28m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m30s
apple / screenshots (push) Successful in 24m16s
test(session/gamelease): the live-process test waits out the shim window
Its child is `sh` running a script that `exec`s a binary outside the install
dir, so neither the image path nor the command line carries the directory — the
host's own child is the only signal there is. Which is exactly the case the shim
window now gates: a live child that might still turn out to be a launcher is not
called the game until that window passes.

So "running" legitimately arrives up to SHIM_WINDOW later than it used to, and
the assertion at 1.5 s was asserting the old behavior. Waiting it out is the
point now, not an accident of timing.

The delay is the deliberate cost of the fix: until the window passes there is no
way to tell "the child IS the game" from "the child is about to hand off". It
shows as a few seconds of `launching` in the console, and only for a title whose
store gave us signals.

.25: the ignored live test passes (Child -> Running -> terminate via the
SIGTERM->SIGKILL group ladder -> Exited, session-end action correctly NOT fired),
full suite 305 passed.
2026-07-26 22:33:42 +02:00

1304 lines
55 KiB
Rust

//! The lifetime of a launched game, and the two behaviors bound to it
//! (design/session-game-lifetime.md).
//!
//! A session that launches a title takes a **lease** on the resulting game. The lease knows three
//! things the host previously had no way to know:
//!
//! * whether the game has actually started (as opposed to a launcher shim that exits immediately),
//! * when it exits — which ends the streaming session, so the client returns to its library
//! instead of staring at a desktop,
//! * how to end it — so a session ending can take the game with it, when the operator asks for that.
//!
//! ### Why a lease rather than a pid
//!
//! Most stores never hand the host the game's process. `steam://rungameid/…`, Epic's launcher URI,
//! an AUMID activation and Playnite's `playnite://` all hand off to a launcher that starts the game
//! somewhere else entirely and exits. So a lease is defined by *how to recognize* the game
//! ([`crate::library::DetectSpec`]), not by a pid we happen to hold — and a lease we do hold a child
//! for ([`LeaseKind::Child`]) falls back to recognition the moment that child turns out to be a shim.
//!
//! ### Safety posture
//!
//! Ending a game is destructive: it can cost unsaved progress. Three rules bound it.
//!
//! 1. **Opt-in.** [`GameOnSessionEnd::Keep`] is the default; nothing is ever ended unless the
//! operator asked ([`crate::session_settings`]).
//! 2. **Never a surprise on a network blip.** `Always` waits out a reconnect grace window before
//! ending anything, and a reconnecting client re-adopts its lease.
//! 3. **Only ever this session's game.** Every pid is adopted only if it started *after* this
//! session's launch, and re-verified against its start time before being signalled
//! ([`crate::procscan`]) — so a pre-existing copy of the same game, or a recycled pid, is never
//! touched.
use crate::library::DetectSpec;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicU8, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
/// Cold-start budget: a launcher may take minutes to bring a game up (a Steam cold boot plus
/// first-run shader precompile is the worst case). A game that never appears in this window leaves
/// the session alone — the player is still looking at the launcher, which is a legitimate thing to
/// stream. Matches the shipped dedicated-session watcher, which this generalizes.
const START_GRACE: Duration = Duration::from_secs(300);
/// Poll interval for the watcher. One second is imperceptible against a game's startup or shutdown
/// and keeps a `/proc` sweep to a fraction of a percent of one core.
const POLL: Duration = Duration::from_secs(1);
/// The game must stay unseen across this window before it counts as exited, so a process swap (a
/// launcher re-execing, a mid-game restart) can't end the session early.
const EXIT_CONFIRM: Duration = Duration::from_secs(3);
/// A child that exits *successfully* within this window of being spawned was a launcher handing off,
/// not the game. Its exit must never end the session. (Apollo calls the same heuristic `auto_detach`;
/// the window matches.)
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);
/// A child process the host spawned for a launch, and what may safely be signalled for it.
#[derive(Clone, Copy, Debug)]
pub struct OwnedChild {
pub pid: u32,
/// Whether the child leads its own process group.
///
/// Load-bearing for termination: `kill(-pid, …)` addresses the *process group* whose id is
/// `pid`, which only means "this child and its descendants" if the child was made a group
/// leader. For a child that shares the host's group, that same call would signal an unrelated
/// group — so a non-leader is only ever signalled by its own pid.
pub group_leader: bool,
}
/// What the host knows about a launched title, for display and for identity.
#[derive(Clone, Debug, Default)]
pub struct GameRef {
/// Store-qualified library id (`steam:570`). `None` for an operator-typed GameStream
/// `apps.json` command, which has no library entry behind it.
pub id: Option<String>,
/// Which store surfaced it (`steam`, `heroic`, `custom`, …), when known.
pub store: Option<String>,
/// Display title. Falls back to the id, then to the command, so this is never empty in the UI.
pub title: String,
}
/// How this lease tracks its game.
#[derive(Clone, Debug)]
pub enum LeaseKind {
/// A bare-spawn gamescope owns the game as its own nested child: gamescope exits when the game
/// does, so its PipeWire node dying *is* the exit signal, and releasing the display ends the
/// game. Detection and termination both stay with the display layer.
Nested,
/// The host spawned the command itself and holds the child. Its process group is the game (until
/// it proves to be a shim, at which point the lease re-resolves to [`LeaseKind::Matched`]).
Child,
/// A launcher owns the game; it is recognized by its [`DetectSpec`].
Matched,
/// Nothing identifies this title's process — no detect signals and no child we own. Both
/// lifetime behaviors stay inert for it, and the host says so once in the log rather than
/// guessing.
Untracked,
}
impl LeaseKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Nested => "nested",
Self::Child => "child",
Self::Matched => "matched",
Self::Untracked => "untracked",
}
}
}
/// Where a lease is in its life.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum GameState {
/// Launched; the game has not been seen running yet.
Launching = 0,
/// Seen running.
Running = 1,
/// Confirmed gone.
Exited = 2,
}
impl GameState {
fn from_u8(v: u8) -> Self {
match v {
1 => Self::Running,
2 => Self::Exited,
_ => Self::Launching,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Launching => "launching",
Self::Running => "running",
Self::Exited => "exited",
}
}
}
/// The part of a lease that outlives its session: identity, live state, and enough to end the game.
///
/// Held behind an `Arc` by the session, the watcher thread, and — after a disconnect that armed a
/// grace window — the [`grace`] registry.
pub struct LeaseShared {
/// The title this lease is for.
pub game: GameRef,
/// The client that asked for it, for the same reason.
pub client: String,
/// Which plane the session was on.
pub plane: crate::events::Plane,
kind: LeaseKind,
state: AtomicU8,
/// Signals the watcher thread to stop (session ended, or the lease was terminated).
cancel: Arc<AtomicBool>,
/// How to recognize the game. Empty for [`LeaseKind::Nested`]/[`LeaseKind::Untracked`].
spec: DetectSpec,
/// Seconds-since-boot at launch: the floor for adopting a process (`None` = the uptime clock was
/// unreadable, so no start-time filtering is possible and only signals are used).
launch_stamp: Option<f64>,
/// The child the host spawned for this launch, when it spawned one ([`LeaseKind::Child`]).
/// Cleared once that child is reaped.
child: Mutex<Option<OwnedChild>>,
/// Whether this lease's game has been asked to end (so a second request is a no-op, and the
/// watcher's exit doesn't look like the player quitting).
terminating: AtomicBool,
/// Monotonic-ish clock for the status surface: unix ms when the lease was created.
pub created_ms: u64,
/// Set when the game was seen running at least once — distinguishes "exited" from "never
/// started" for logging and for the exit decision.
was_running: AtomicBool,
/// Wall-clock ms the game was last seen running (0 = never), for diagnostics.
last_seen_ms: AtomicU64,
}
impl LeaseShared {
pub fn state(&self) -> GameState {
GameState::from_u8(self.state.load(Ordering::Relaxed))
}
pub fn kind(&self) -> &LeaseKind {
&self.kind
}
/// True once the game has been asked to end (by policy, grace expiry, or the API).
pub fn is_terminating(&self) -> bool {
self.terminating.load(Ordering::Relaxed)
}
/// Can this lease's game be ended at all? `false` for a title the host can't identify.
pub fn is_trackable(&self) -> bool {
!matches!(self.kind, LeaseKind::Untracked)
}
fn set_state(&self, s: GameState) {
self.state.store(s as u8, Ordering::Relaxed);
}
/// The host's own child for this launch, while it is still running.
fn owned_child(&self) -> Option<OwnedChild> {
*self.child.lock().unwrap_or_else(|e| e.into_inner())
}
/// Drop the record of the child once it has been reaped — its pid must never be signalled after
/// that, since the kernel is free to hand that number to something else.
fn forget_child(&self) {
*self.child.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
}
/// Unix ms now — the same clock the event bus and status API use.
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// A session's handle on its game. Dropping it stops the watcher; what happens to the *game* is the
/// caller's decision, taken through [`crate::session_settings`] at teardown (see
/// [`GameLease::on_session_end`]).
pub struct GameLease {
shared: Arc<LeaseShared>,
/// Joined on drop only to stop the thread promptly; the watcher self-terminates on `cancel`.
watcher: Option<std::thread::JoinHandle<()>>,
}
impl GameLease {
/// The shared state, for the status surface and the grace registry.
pub fn shared(&self) -> Arc<LeaseShared> {
self.shared.clone()
}
}
impl Drop for GameLease {
fn drop(&mut self) {
self.shared.cancel.store(true, Ordering::SeqCst);
// The watcher wakes at most `POLL` later; don't block teardown waiting for it.
drop(self.watcher.take());
}
}
/// Everything needed to open a lease. Built by the launch site, which is the only place that knows
/// all of it.
pub struct LeaseRequest {
pub game: GameRef,
pub client: String,
pub plane: crate::events::Plane,
pub spec: DetectSpec,
/// The game's own compositor-nested-ness: `true` when a bare-spawn gamescope owns it.
pub nested: bool,
/// The child the host spawned for this launch, when it spawned one directly, and whether it
/// leads its own process group (see [`OwnedChild::group_leader`]).
pub child: Option<(std::process::Child, bool)>,
/// Seconds-since-boot from **before** the launch ([`launch_clock`]): the floor for adopting a
/// process, which is what keeps a copy of the game the player already had open from being
/// mistaken for this session's. `None` disables the filter (no readable uptime clock).
pub launch_stamp: Option<f64>,
}
/// The reference instant for adopting this launch's processes, in seconds since boot. Call it
/// **before** anything spawns; see [`LeaseRequest::launch_stamp`].
pub fn launch_clock() -> Option<f64> {
// Delegate unconditionally: `procscan::launch_stamp` already answers `None` on a platform with
// no matcher. Gating this on Linux here silently disabled the start-time floor on Windows —
// `find(spec, None)` skips the filter entirely — so a copy of the game the player already had
// open was adopted and then ended with the session (caught on glass, .173: Steam focused a
// running instance instead of starting one, and the lease took it). The Windows matcher is
// exactly where that rule matters most, since the host is SYSTEM and can see every process.
crate::procscan::launch_stamp()
}
/// What the watcher does when it concludes the game has exited.
pub type OnExit = Box<dyn Fn() + Send + Sync>;
/// Open a lease and start watching. `on_exit` fires **at most once**, and only when the game was
/// seen running and then confirmed gone — never for a game that never started, never for a shim, and
/// never when the host itself asked the game to end.
pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
let LeaseRequest {
game,
client,
plane,
spec,
nested,
child,
launch_stamp,
} = req;
let kind = if nested {
LeaseKind::Nested
} else if child.is_some() {
LeaseKind::Child
} else if !spec.is_empty() {
LeaseKind::Matched
} else {
LeaseKind::Untracked
};
let owned = child.as_ref().map(|(c, leader)| OwnedChild {
pid: c.id(),
group_leader: *leader,
});
let child = child.map(|(c, _)| c);
let shared = Arc::new(LeaseShared {
game,
client,
plane,
kind: kind.clone(),
state: AtomicU8::new(GameState::Launching as u8),
cancel: Arc::new(AtomicBool::new(false)),
spec,
launch_stamp,
child: Mutex::new(owned),
terminating: AtomicBool::new(false),
created_ms: now_ms(),
was_running: AtomicBool::new(false),
last_seen_ms: AtomicU64::new(0),
});
if matches!(kind, LeaseKind::Untracked) {
tracing::info!(
title = %shared.game.title,
app = shared.game.id.as_deref().unwrap_or("-"),
"this title exposes nothing the host can use to recognize its process — \
game-exit detection and end-game-on-disconnect stay off for it"
);
} else {
tracing::info!(
title = %shared.game.title,
app = shared.game.id.as_deref().unwrap_or("-"),
kind = kind.as_str(),
"watching the launched game"
);
}
let watcher = spawn_watcher(shared.clone(), child, on_exit);
if watcher.is_none() {
// Nothing is polling this lease (no signals to poll, or a platform without a matcher yet), so
// its state will never advance on its own. Report it as running rather than leaving the
// console showing "launching" forever: the host did just launch it, and with no watcher it is
// making no claim about noticing the exit. The row disappears when the session ends.
shared.set_state(GameState::Running);
}
GameLease { shared, watcher }
}
/// Start the per-lease watch thread. Off Linux (and for an untracked/nested lease with nothing to
/// poll) there is nothing to watch, so no thread is spawned at all.
fn spawn_watcher(
shared: Arc<LeaseShared>,
child: Option<std::process::Child>,
on_exit: OnExit,
) -> Option<std::thread::JoinHandle<()>> {
// An untracked lease has nothing to observe (it still exposes state for the status surface).
//
// A *nested* lease is watched whenever its store gave us something to look for. The display
// layer's node-death check would eventually notice a nested game exiting, but it can't see the
// case that matters most: a Steam launch nests the resident Steam *client*, which stays up after
// the game quits, so gamescope never dies. Watching the game itself is what covers that — and it
// notices every other nested title sooner than node death does. Node death remains the backstop
// for a nested title with no signals at all.
if matches!(shared.kind, LeaseKind::Untracked) {
return None;
}
if matches!(shared.kind, LeaseKind::Nested) && shared.spec.is_empty() {
return None;
}
// Platforms with no matcher at all (macOS has no launch path either) keep a lease for the status
// surface, but nothing polls it.
#[cfg(not(any(target_os = "linux", windows)))]
{
let _ = (child, on_exit);
return None;
}
#[cfg(any(target_os = "linux", windows))]
{
std::thread::Builder::new()
.name("pf1-gamelease".into())
.spawn(move || watch(shared, child, on_exit))
.ok()
}
}
/// The watch loop: wait for the game to appear, then for it to go away.
#[cfg(any(target_os = "linux", windows))]
fn watch(shared: Arc<LeaseShared>, mut child: Option<std::process::Child>, on_exit: OnExit) {
let scanner = crate::procscan::Scanner::system();
let cancelled = || shared.cancel.load(Ordering::Relaxed);
let spawned_at = Instant::now();
let mut kind = shared.kind.clone();
// The game's processes as last seen. Phase 2 re-verifies these rather than re-scanning the whole
// process table each second: `alive` costs one query per known process, while `find` costs one per
// process on the box (and on Windows that means an `OpenProcess` each). A full re-scan still
// happens the moment they all vanish, which is what catches a game that re-execs into a new pid.
//
// Deliberately uninitialized: the only way out of phase 1 and into phase 2 is the branch that
// assigns it, so an initial value would be dead.
let mut known: Vec<crate::procscan::ProcRef>;
// ---- Phase 1: wait for the game to show up. ----
let start_deadline = spawned_at + START_GRACE;
loop {
if cancelled() {
return;
}
// A `Child` lease's own child is the primary signal; a shim exit re-resolves the lease.
if matches!(kind, LeaseKind::Child) {
match child.as_mut().map(|c| c.try_wait()) {
Some(Ok(Some(status))) => {
let quick = spawned_at.elapsed() < SHIM_WINDOW;
child = None; // reaped
shared.forget_child();
if quick && status.success() {
// A launcher that handed the game off and exited. Fall back to recognizing
// the game by its store's signals; with none, stop tracking entirely rather
// than pretend the shim's exit was the game's.
kind = if shared.spec.is_empty() {
tracing::info!(
title = %shared.game.title,
"the launch command exited immediately (a launcher handing off) and \
this title has no detect signals — stopping game tracking for it"
);
LeaseKind::Untracked
} else {
tracing::debug!(
title = %shared.game.title,
"the launch command handed off and exited — recognizing the game by \
its store signals instead"
);
LeaseKind::Matched
};
if matches!(kind, LeaseKind::Untracked) {
return;
}
} else {
// It ran long enough to have BEEN the game (or failed outright). Either way
// the game is gone; only a success after a real run counts as "played".
if shared.spec.is_empty() {
if spawned_at.elapsed() >= SHIM_WINDOW {
shared.was_running.store(true, Ordering::Relaxed);
finish(&shared, &on_exit, "the launched process exited");
}
return;
}
// With signals available, let the scan below decide — the command may have
// been a wrapper whose game is still up.
}
}
Some(Err(e)) => {
tracing::debug!(error = %e, "could not poll the launched child — falling back to scanning");
child = None;
kind = if shared.spec.is_empty() {
LeaseKind::Untracked
} else {
LeaseKind::Matched
};
if matches!(kind, LeaseKind::Untracked) {
return;
}
}
_ => {}
}
}
// The child being alive counts as the game running for a `Child` lease, so a title with no
// detect signals is still fully tracked.
//
// But a launcher that is about to hand off and exit looks *exactly* like the game for its
// first few seconds. When the store gave us signals to recognize the real game by, wait out
// the shim window before believing this child is it — otherwise the lease leaves this phase
// on its very first poll, the reclassification above never gets to run, and the hand-off
// that follows is read as the game exiting. On Linux that ended a session ~7 s after
// launching any Steam title, before the game had even started (on glass, .41).
//
// With no signals the child is all we have, so it still counts immediately: a custom command
// is tracked exactly as before.
let child_alive = matches!(kind, LeaseKind::Child)
&& child.is_some()
&& (shared.spec.is_empty() || spawned_at.elapsed() >= SHIM_WINDOW);
let live = scanner.find(&shared.spec, shared.launch_stamp);
if !live.is_empty() || child_alive {
known = live.clone();
shared.was_running.store(true, Ordering::Relaxed);
shared.last_seen_ms.store(now_ms(), Ordering::Relaxed);
shared.set_state(GameState::Running);
crate::events::emit(crate::events::EventKind::GameRunning {
game: game_event_ref(&shared),
});
tracing::info!(
title = %shared.game.title,
kind = kind.as_str(),
procs = live.len(),
"the launched game is running"
);
break;
}
if Instant::now() >= start_deadline {
tracing::info!(
title = %shared.game.title,
grace_s = START_GRACE.as_secs(),
"the launched game never appeared — leaving the session alone"
);
return;
}
std::thread::sleep(POLL);
}
// ---- Phase 2: wait for it to go away, confirmed across a window. ----
let mut gone_since: Option<Instant> = None;
let mut vetoed = false;
loop {
if cancelled() {
return;
}
if matches!(kind, LeaseKind::Child) {
if let Some(Ok(Some(_))) = child.as_mut().map(|c| c.try_wait()) {
child = None;
shared.forget_child();
if shared.spec.is_empty() {
finish(&shared, &on_exit, "the launched process exited");
return;
}
}
}
let child_alive = matches!(kind, LeaseKind::Child) && child.is_some();
// Cheap first: are the processes we already know about still there? Only when none of them is
// do we pay for a full scan — which is also what notices a game that re-exec'd into a new pid
// (a launcher stub becoming the real binary, an engine relaunching itself).
let live = {
let still = scanner.alive(&known);
if still.is_empty() {
scanner.find(&shared.spec, shared.launch_stamp)
} else {
still
}
};
if !live.is_empty() || child_alive {
known = live;
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"
);
}
gone_since = None;
} else {
finish(&shared, &on_exit, "the game exited");
return;
}
}
std::thread::sleep(POLL);
}
}
/// 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) {
shared.set_state(GameState::Exited);
let terminated = shared.is_terminating();
crate::events::emit(crate::events::EventKind::GameExited {
game: game_event_ref(shared),
reason: if terminated {
crate::events::GameEndReason::Terminated
} else {
crate::events::GameEndReason::Exited
},
});
if terminated {
// The host asked for this. Ending the session too would be double-counting: whatever asked
// for the termination is already tearing down (or deliberately isn't).
tracing::info!(title = %shared.game.title, "the game the host asked to end is gone");
return;
}
tracing::info!(title = %shared.game.title, reason = why, "the launched game exited");
on_exit();
}
/// The event-payload view of a lease.
pub fn game_event_ref(shared: &LeaseShared) -> crate::events::GameRefPayload {
crate::events::GameRefPayload {
app: shared.game.id.clone(),
title: shared.game.title.clone(),
store: shared.game.store.clone(),
client: shared.client.clone(),
plane: shared.plane,
}
}
// ---------------------------------------------------------------------------------------------
// Termination
// ---------------------------------------------------------------------------------------------
/// End the game this lease tracks: politely first, then decisively. Runs on a detached thread — a
/// caller is usually a teardown path or a `Drop`, and neither may block on a game's shutdown.
///
/// Idempotent: a lease already terminating is left alone.
pub fn terminate(shared: Arc<LeaseShared>, why: &'static str) {
if !shared.is_trackable() {
tracing::debug!(
title = %shared.game.title,
"asked to end an untracked title — nothing to end"
);
return;
}
if shared.terminating.swap(true, Ordering::SeqCst) {
return; // already on its way out
}
tracing::info!(title = %shared.game.title, reason = why, "ending the launched game");
let name = "pf1-gameterm".to_string();
let _ = std::thread::Builder::new().name(name).spawn(move || {
terminate_blocking(&shared);
// A lease whose session is still up has a live watcher, which reports the exit itself
// (with the right reason) once it confirms the game is gone. A lease whose session has
// already ended — every grace expiry and every `POST /game/end` — has no watcher left, so
// this is the only place its exit can be reported from.
if shared.cancel.load(Ordering::Relaxed) {
shared.set_state(GameState::Exited);
crate::events::emit(crate::events::EventKind::GameExited {
game: game_event_ref(&shared),
reason: crate::events::GameEndReason::Terminated,
});
}
});
}
/// The ladder itself. Separate so it can be driven synchronously from a test.
fn terminate_blocking(shared: &LeaseShared) {
match shared.kind {
// gamescope owns the game: releasing the display tears the nested session — and therefore
// the game — down. One mechanism for both, so a kept display can't outlive an ended game.
LeaseKind::Nested => {
// The same force-release the `/display/release` endpoint performs: gamescope exits with
// its display and takes its nested game with it. It refuses displays that still have live
// sessions, so this only reaches the *kept* display this ended session left behind.
//
// But it is not slot-targeted — it retires every kept display — and a lease has no handle
// on which one is its own. So while anyone else is streaming, leave it alone: the cost of
// being wrong here is disturbing an unrelated client, and the cost of doing nothing is a
// game that keeps running, which is also the default everywhere else.
let others = crate::session_status::count();
if others > 0 {
tracing::info!(
live_sessions = others,
title = %shared.game.title,
"not releasing kept displays to end this game — another session is streaming and \
the release is not per-display"
);
return;
}
let released = crate::vdisplay::registry::release(None);
tracing::info!(
released,
title = %shared.game.title,
"released the nested session's kept display to end its game"
);
}
LeaseKind::Child | LeaseKind::Matched => {
#[cfg(target_os = "linux")]
unix_term_ladder(shared);
#[cfg(windows)]
windows_term_ladder(shared);
}
LeaseKind::Untracked => {}
}
}
/// SIGTERM everything that belongs to the game, wait, then SIGKILL whatever ignored it.
///
/// Every pid is re-verified against its recorded start time immediately before each signal, so a pid
/// recycled during the grace window is never signalled ([`crate::procscan::Scanner::alive`]).
#[cfg(target_os = "linux")]
fn unix_term_ladder(shared: &LeaseShared) {
let scanner = crate::procscan::Scanner::system();
let owned = shared.owned_child();
// Signal the host's own child, as a group when it leads one. A group signal is what catches a
// shell wrapper's grandchildren — the actual game — which a per-pid sweep would miss.
let signal_child = |sig: i32| -> bool {
let Some(c) = owned else { return false };
let target = if c.group_leader {
-(c.pid as i32)
} else {
c.pid as i32
};
// SAFETY: `kill` returns a status code and touches no memory of ours. A negative target is
// only ever used for a group this host created with the child as its leader (see
// `OwnedChild::group_leader`) — never for a child sharing the host's own group.
unsafe { libc::kill(target, sig) == 0 }
};
let signal_matched = |sig: i32| -> usize {
// Re-scan and re-verify immediately before signalling, so a pid recycled since the last
// sweep is never hit.
scanner
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
.into_iter()
// SAFETY: as above, for a single pid just re-verified to be the process we adopted.
.filter(|p| unsafe { libc::kill(p.pid as i32, sig) == 0 })
.count()
};
let signal_all = |sig: i32| -> usize { usize::from(signal_child(sig)) + signal_matched(sig) };
let asked = signal_all(libc::SIGTERM);
tracing::debug!(
title = %shared.game.title,
signalled = asked,
grace_s = TERM_GRACE.as_secs(),
"asked the game to close"
);
// Give it a chance to save and exit on its own.
let deadline = Instant::now() + TERM_GRACE;
while Instant::now() < deadline {
std::thread::sleep(POLL);
let still = scanner
.alive(&scanner.find(&shared.spec, shared.launch_stamp))
.len();
// Signal 0 only probes for existence — the child (or its group) is gone once it fails.
let child_gone = !signal_child(0);
if still == 0 && child_gone {
tracing::info!(title = %shared.game.title, "the game closed when asked");
return;
}
}
let killed = signal_all(libc::SIGKILL);
tracing::warn!(
title = %shared.game.title,
killed,
grace_s = TERM_GRACE.as_secs(),
"the game did not close when asked — killed it"
);
}
/// Windows: ask the game's windows to close, wait, then terminate what's left.
///
/// Same shape as the Unix ladder, different primitives — and one structural difference: the host never
/// holds a child here. Every Windows launch goes through a launcher or the shell
/// (`steam://`, `com.epicgames.launcher://`, `shell:AppsFolder\…`), so the game is always recognized
/// rather than owned, and the pid set comes entirely from the matcher.
#[cfg(windows)]
fn windows_term_ladder(shared: &LeaseShared) {
let scanner = crate::procscan::Scanner::system();
let live = || scanner.alive(&scanner.find(&shared.spec, shared.launch_stamp));
let pids: Vec<u32> = live().into_iter().map(|p| p.pid).collect();
if pids.is_empty() {
tracing::info!(title = %shared.game.title, "the game is already gone — nothing to end");
return;
}
// Polite first: a `WM_CLOSE` is what clicking the window's X does, so the game runs its own
// shutdown and can save.
let asked = crate::game_term::request_close(&pids);
tracing::debug!(
title = %shared.game.title,
windows_asked = asked,
procs = pids.len(),
grace_s = TERM_GRACE.as_secs(),
"asked the game to close"
);
let deadline = Instant::now() + TERM_GRACE;
while Instant::now() < deadline {
std::thread::sleep(POLL);
if live().is_empty() {
tracing::info!(title = %shared.game.title, "the game closed when asked");
return;
}
}
// Re-verify (pid, creation time) one last time, then insist. Windows recycles pids briskly, so the
// list handed to `kill` must be freshly checked rather than the one gathered above.
let remaining: Vec<u32> = live().into_iter().map(|p| p.pid).collect();
let killed = crate::game_term::kill(&remaining);
tracing::warn!(
title = %shared.game.title,
killed,
grace_s = TERM_GRACE.as_secs(),
"the game did not close when asked — killed it"
);
}
// ---------------------------------------------------------------------------------------------
// The grace registry: leases whose session is gone but whose game is on probation
// ---------------------------------------------------------------------------------------------
/// A lease waiting out its reconnect window. If the client comes back before the deadline the lease
/// is handed to the new session and nothing is ended; if it doesn't, the game ends.
pub struct Pending {
pub shared: Arc<LeaseShared>,
pub deadline: Instant,
/// The client fingerprint that may re-adopt this lease (identity match on reconnect).
pub fingerprint: Option<String>,
}
fn registry() -> &'static Mutex<Vec<Pending>> {
static REG: OnceLock<Mutex<Vec<Pending>>> = OnceLock::new();
REG.get_or_init(|| Mutex::new(Vec::new()))
}
/// Put a lease on probation: its game ends in `grace` unless the same client reconnects first.
pub fn arm_grace(shared: Arc<LeaseShared>, fingerprint: Option<String>, grace: Duration) {
if !shared.is_trackable() {
return;
}
tracing::info!(
title = %shared.game.title,
grace_s = grace.as_secs(),
"the client is gone — the game ends when the reconnect window closes"
);
registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(Pending {
shared,
deadline: Instant::now() + grace,
fingerprint,
});
start_reaper();
}
/// A reconnecting client takes its game back: drops any pending termination for `fingerprint` whose
/// title matches `app`. Returns the number of leases reprieved.
pub fn readopt(fingerprint: Option<&str>, app: Option<&str>) -> usize {
let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner());
let before = reg.len();
reg.retain(|p| {
let same_client = match (&p.fingerprint, fingerprint) {
(Some(a), Some(b)) => a == b,
// With no fingerprint on either side there is nothing to match on; be conservative and
// keep the lease pending rather than let any reconnect reprieve any game.
_ => false,
};
let same_app = p.shared.game.id.as_deref() == app;
if same_client && same_app {
tracing::info!(
title = %p.shared.game.title,
"the client reconnected inside the window — the game keeps running"
);
false
} else {
true
}
});
before - reg.len()
}
/// Every lease currently on probation, with the time left, for the status surface.
pub fn pending_snapshot() -> Vec<(Arc<LeaseShared>, u64)> {
let now = Instant::now();
registry()
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|p| {
(
p.shared.clone(),
p.deadline.saturating_duration_since(now).as_secs(),
)
})
.collect()
}
/// End the games of pending leases immediately — the console's "End now" for a game whose session is
/// already gone. `app` filters to one title; `None` ends all of them. Returns how many were ended.
pub fn end_pending(app: Option<&str>) -> usize {
let taken: Vec<Arc<LeaseShared>> = {
let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner());
let (hit, keep): (Vec<Pending>, Vec<Pending>) = std::mem::take(&mut *reg)
.into_iter()
.partition(|p| app.is_none() || p.shared.game.id.as_deref() == app);
*reg = keep;
hit.into_iter().map(|p| p.shared).collect()
};
let n = taken.len();
for shared in taken {
terminate(shared, "ended from the management API");
}
n
}
/// The single background thread that ends pending leases when their window closes. Started on demand
/// and lives for the process, sleeping while the registry is empty.
fn start_reaper() {
static STARTED: OnceLock<()> = OnceLock::new();
STARTED.get_or_init(|| {
let _ = std::thread::Builder::new()
.name("pf1-gamegrace".into())
.spawn(|| loop {
std::thread::sleep(POLL);
let now = Instant::now();
let due: Vec<Arc<LeaseShared>> = {
let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner());
let (due, keep): (Vec<Pending>, Vec<Pending>) = std::mem::take(&mut *reg)
.into_iter()
.partition(|p| now >= p.deadline);
*reg = keep;
due.into_iter().map(|p| p.shared).collect()
};
for shared in due {
// The player may have quit the game themselves during the window; terminate is a
// no-op then (nothing matches the spec any more).
terminate(shared, "the reconnect window closed with no client");
}
});
});
}
/// What a session should do with its game when it ends. The policy lives in
/// [`crate::session_settings`]; this is the one place that turns it into an action, so both planes
/// behave identically.
pub fn on_session_end(lease: &GameLease, deliberate: bool, fingerprint: Option<&str>) {
use crate::session_settings::GameOnSessionEnd;
let settings = crate::session_settings::get();
let shared = lease.shared();
if !shared.is_trackable() || shared.state() == GameState::Exited {
return; // nothing to end (or it already ended on its own)
}
let end_now = |shared: Arc<LeaseShared>| {
// A deliberate stop already forces this session's display down immediately (the `quit` flag
// beats keep-alive linger), and for a nested launch that teardown *is* what ends the game —
// gamescope exits with its display and takes its child with it. Asking again here would race
// that teardown for a display that still has a live session, which the registry refuses
// anyway. So: let it happen, and say so.
if matches!(shared.kind, LeaseKind::Nested) {
tracing::debug!(
title = %shared.game.title,
"the nested session's display teardown ends its game — nothing extra to do"
);
return;
}
terminate(shared, "the session was stopped deliberately");
};
match settings.game_on_session_end {
GameOnSessionEnd::Keep => {}
GameOnSessionEnd::OnQuit => {
if deliberate {
end_now(shared);
}
}
GameOnSessionEnd::Always => {
if deliberate {
end_now(shared);
} else {
// A drop is not a decision. Give the client its window back — and note that for a
// nested lease the *display* may outlive the window under its own keep-alive policy,
// which is exactly why the expiry path releases it.
arm_grace(
shared,
fingerprint.map(str::to_string),
Duration::from_secs(settings.disconnect_grace_seconds.into()),
);
}
}
}
}
/// Applies [`on_session_end`] when a session's stream loop exits, whichever way it exits (`return`,
/// `?`, a `break` out of the loop, a panic-unwind) — the same RAII shape the per-title prep/undo
/// guard uses, for the same reason: teardown paths are many and easy to miss one of.
///
/// Reading `quit` at **drop** time is the point: it is what distinguishes a client that deliberately
/// stopped (or an operator who did) from one that merely vanished, and that distinction is the whole
/// safety story for [`GameOnSessionEnd::Always`](crate::session_settings::GameOnSessionEnd::Always) —
/// a vanish gets a reconnect window, a deliberate stop does not.
///
/// Both planes use it, so "the session ended" means the same thing to a game whichever way the client
/// was talking to the host.
pub struct SessionGuard {
lease: GameLease,
quit: Arc<AtomicBool>,
/// Hex client fingerprint, so a reconnecting client can reclaim its own game and nothing else.
fingerprint: Option<String>,
}
impl SessionGuard {
/// Bind `lease` to the calling session's lifetime. `quit` is the session's deliberate-stop flag,
/// read at drop; `fingerprint` identifies the client allowed to reclaim the game on reconnect.
pub fn new(lease: GameLease, quit: Arc<AtomicBool>, fingerprint: Option<String>) -> Self {
Self {
lease,
quit,
fingerprint,
}
}
/// The lease's shared state, for the status surface.
pub fn shared(&self) -> Arc<LeaseShared> {
self.lease.shared()
}
}
impl Drop for SessionGuard {
fn drop(&mut self) {
on_session_end(
&self.lease,
self.quit.load(Ordering::SeqCst),
self.fingerprint.as_deref(),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A lease request for `id`. The id is a parameter because the grace registry is process-wide and
/// these tests run in parallel: each must arm and inspect an entry only it can name, or one test's
/// re-adoption would reprieve another's lease.
fn req(id: &str, spec: DetectSpec, nested: bool) -> LeaseRequest {
LeaseRequest {
game: GameRef {
id: Some(id.to_string()),
store: Some("steam".into()),
title: format!("Test Title {id}"),
},
client: "Deck".into(),
plane: crate::events::Plane::Native,
spec,
nested,
child: None,
// No start-time floor: these leases are never matched against real processes.
launch_stamp: None,
}
}
/// Is a lease for `id` currently on probation?
fn is_pending(id: &str) -> bool {
pending_snapshot()
.iter()
.any(|(s, _)| s.game.id.as_deref() == Some(id))
}
#[test]
fn kind_follows_what_the_launch_gave_us() {
// Nested wins over everything: the display layer owns the lifetime.
let l = open(
req("steam:100", DetectSpec::steam(100), true),
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Nested));
// With signals but no child and no nesting, the game is recognized.
let l = open(
req("steam:101", DetectSpec::steam(101), false),
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Matched));
// With neither, the lease is inert and says so.
let l = open(
req("steam:102", DetectSpec::default(), false),
Box::new(|| {}),
);
assert!(matches!(l.shared().kind(), LeaseKind::Untracked));
assert!(!l.shared().is_trackable());
}
#[test]
fn an_untracked_lease_is_never_terminated() {
let l = open(
req("steam:110", DetectSpec::default(), false),
Box::new(|| {}),
);
let shared = l.shared();
terminate(shared.clone(), "test");
assert!(!shared.is_terminating(), "untracked leases must stay inert");
}
#[test]
fn terminate_is_idempotent() {
// Nested, so the ladder is a display release rather than a signal to a real process.
let l = open(
req("steam:120", DetectSpec::steam(120), true),
Box::new(|| {}),
);
let shared = l.shared();
terminate(shared.clone(), "first");
assert!(shared.is_terminating());
// A second request must not re-run the ladder; the flag was already set.
terminate(shared.clone(), "second");
assert!(shared.is_terminating());
}
#[test]
fn grace_readoption_matches_client_and_title() {
let id = "steam:130";
let l = open(req(id, DetectSpec::steam(130), false), Box::new(|| {}));
arm_grace(
l.shared(),
Some("fp-130".into()),
Duration::from_secs(3_600),
);
// A different client, or a different title, does not reprieve it.
assert_eq!(readopt(Some("fp-other"), Some(id)), 0);
assert_eq!(readopt(Some("fp-130"), Some("steam:9999")), 0);
// A missing fingerprint on either side must not reprieve anything either — otherwise any
// unidentified reconnect could keep any game alive.
assert_eq!(readopt(None, Some(id)), 0);
assert!(is_pending(id), "none of those should have reprieved it");
// The right client coming back for the right title does.
assert_eq!(readopt(Some("fp-130"), Some(id)), 1);
assert!(!is_pending(id));
}
#[test]
fn pending_snapshot_reports_the_window_left() {
let id = "steam:140";
let l = open(req(id, DetectSpec::steam(140), false), Box::new(|| {}));
arm_grace(l.shared(), Some("fp-140".into()), Duration::from_secs(300));
let mine = pending_snapshot()
.into_iter()
.find(|(s, _)| s.game.id.as_deref() == Some(id))
.expect("armed lease is pending");
assert!(mine.1 > 290 && mine.1 <= 300, "remaining was {}", mine.1);
// Leave the registry as we found it, so a sibling test's sweep can't see this entry.
assert_eq!(readopt(Some("fp-140"), Some(id)), 1);
}
#[test]
fn end_pending_only_takes_the_named_title() {
let (a, b) = ("steam:150", "steam:151");
let la = open(req(a, DetectSpec::steam(150), true), Box::new(|| {}));
let lb = open(req(b, DetectSpec::steam(151), true), Box::new(|| {}));
arm_grace(
la.shared(),
Some("fp-150".into()),
Duration::from_secs(3_600),
);
arm_grace(
lb.shared(),
Some("fp-151".into()),
Duration::from_secs(3_600),
);
// Ending one title leaves the other waiting.
assert_eq!(end_pending(Some(a)), 1);
assert!(!is_pending(a));
assert!(is_pending(b));
assert!(la.shared().is_terminating());
assert!(!lb.shared().is_terminating());
// An id nobody is waiting on ends nothing.
assert_eq!(end_pending(Some("steam:99999")), 0);
assert_eq!(readopt(Some("fp-151"), Some(b)), 1);
}
/// A launcher that hands off and exits must never be mistaken for the game.
///
/// This is the `steam steam://rungameid/…` shape: the host spawns a launcher as its own child,
/// the launcher tells the already-running Steam to start the game and exits within a couple of
/// seconds, and the game itself appears later (or, here, never). Ending the session on that exit
/// is the failure this guards — it killed Linux Steam launches ~7 s in, before the game started.
///
/// The spec deliberately matches nothing: what is asserted is that a *quick, successful* child
/// exit is treated as a hand-off to wait out, not as the game being gone.
#[cfg(target_os = "linux")]
#[test]
fn a_launcher_that_hands_off_and_exits_is_not_the_game_exiting() {
use std::sync::atomic::AtomicUsize;
static EXITS: AtomicUsize = AtomicUsize::new(0);
EXITS.store(0, Ordering::SeqCst);
let child = std::process::Command::new("/bin/true")
.spawn()
.expect("spawn the fake launcher");
let lease = open(
LeaseRequest {
game: GameRef {
id: Some("steam:999001".into()),
store: Some("steam".into()),
title: "Handoff".into(),
},
client: "test".into(),
plane: crate::events::Plane::Native,
// A real signal that no process will ever match — the game never shows up.
spec: DetectSpec::steam(999_001),
nested: false,
child: Some((child, false)),
launch_stamp: None,
},
Box::new(|| {
EXITS.fetch_add(1, Ordering::SeqCst);
}),
);
// Past the shim window plus the exit-confirmation window: comfortably longer than the buggy
// path took to declare the game gone.
std::thread::sleep(SHIM_WINDOW + EXIT_CONFIRM + Duration::from_secs(2));
assert_eq!(
EXITS.load(Ordering::SeqCst),
0,
"a launcher handing off must not end the session"
);
assert_ne!(
lease.shared().state(),
GameState::Exited,
"the game never ran, so it cannot have exited"
);
}
/// The whole point of the module, against a real process: a `Child` lease sees its game running,
/// notices when it exits, and reports that exit exactly once.
///
/// Ignored by default because it must outlive [`SHIM_WINDOW`] to prove the game was not mistaken
/// for a launcher shim, and then wait out [`EXIT_CONFIRM`] — about 12 s. Run it on a Linux box
/// with `cargo test -p punktfunk-host -- --ignored gamelease`.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "drives a real process for ~12s (shim window + exit confirmation)"]
fn a_real_child_is_tracked_from_running_to_exited() {
use std::os::unix::process::CommandExt;
use std::sync::atomic::AtomicUsize;
let td = tempfile::tempdir().expect("tempdir");
let script = td.path().join("game.sh");
std::fs::write(&script, "#!/bin/sh\nexec sleep 30\n").unwrap();
let launch_stamp = launch_clock();
let child = std::process::Command::new("/bin/sh")
.arg(&script)
.process_group(0)
.spawn()
.expect("spawn the fake game");
static EXITS: AtomicUsize = AtomicUsize::new(0);
EXITS.store(0, Ordering::SeqCst);
let lease = open(
LeaseRequest {
game: GameRef {
id: Some("custom:live".into()),
store: Some("custom".into()),
title: "Live Child".into(),
},
client: "test".into(),
plane: crate::events::Plane::Native,
spec: DetectSpec::dir(td.path()),
nested: false,
child: Some((child, true)),
launch_stamp,
},
Box::new(|| {
EXITS.fetch_add(1, Ordering::SeqCst);
}),
);
let shared = lease.shared();
assert!(matches!(shared.kind(), LeaseKind::Child));
// This script `exec`s a binary OUTSIDE the install dir, so neither the image path nor the
// command line carries the directory: the host's own child is the only signal there is.
// Which means the shim window gates it — a live child that might still turn out to be a
// launcher is not called the game until that window has passed. Waiting it out is the point
// of the assertion, not an accident of timing.
std::thread::sleep(SHIM_WINDOW + Duration::from_millis(1_500));
assert_eq!(
shared.state(),
GameState::Running,
"a child that outlives the shim window IS the game"
);
assert_eq!(EXITS.load(Ordering::SeqCst), 0, "nothing has exited yet");
terminate(shared.clone(), "test asked");
// The ladder asks politely first; `sleep` dies on SIGTERM, so this resolves well inside the
// termination grace, and the watcher then confirms it gone.
let deadline = Instant::now() + TERM_GRACE + EXIT_CONFIRM + Duration::from_secs(4);
while Instant::now() < deadline && shared.state() != GameState::Exited {
std::thread::sleep(Duration::from_millis(250));
}
assert_eq!(shared.state(), GameState::Exited, "the game should be gone");
// The host asked for this exit, so it must NOT also end the session — that is the difference
// between "the player quit" and "we closed it".
assert_eq!(
EXITS.load(Ordering::SeqCst),
0,
"a host-requested end must not fire the session-ending action"
);
}
/// Wherever the host can match processes at all, a launch MUST have a reference instant to
/// match them against — it is the only thing standing between this feature and ending a copy of
/// the game the player already had open. `None` here doesn't fail loudly; it silently turns the
/// filter off ([`crate::procscan::Scanner::find`] skips it), so nothing downstream can notice.
#[cfg(any(target_os = "linux", windows))]
#[test]
fn a_launch_always_gets_a_reference_instant_to_adopt_against() {
assert!(
launch_clock().is_some(),
"no launch reference on a platform that matches processes — every pre-existing \
instance of a title would be adoptable, and endable"
);
}
#[test]
fn state_strings_are_stable() {
// These reach the API and the console; renaming one is a wire change.
assert_eq!(GameState::Launching.as_str(), "launching");
assert_eq!(GameState::Running.as_str(), "running");
assert_eq!(GameState::Exited.as_str(), "exited");
assert_eq!(GameState::from_u8(1), GameState::Running);
assert_eq!(GameState::from_u8(99), GameState::Launching);
}
}