diff --git a/api/openapi.json b/api/openapi.json index cc07406e..8a173ae5 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -665,6 +665,58 @@ } } }, + "/api/v1/game/end": { + "post": { + "tags": [ + "session" + ], + "summary": "End a launched game", + "description": "Ends a game whose session has already gone and which is waiting out its reconnect window — the\nconsole's \"End now\" for a game the host is about to close anyway. `app_id` picks one title; omit it\nto end every waiting game.\n\nThis does **not** touch a game whose session is still live: ending that is session management\n(`DELETE /session`), and how the game is treated then follows the operator's policy.", + "operationId": "endGame", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EndGameRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "How many waiting games were ended", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EndGameResult" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "No game is waiting to be ended", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/gpus": { "get": { "tags": [ @@ -2226,7 +2278,7 @@ "session" ], "summary": "Stop the active session", - "description": "Kicks the connected client: stops the video/audio stream threads and clears the launch\nstate. Idempotent — succeeds even when nothing is streaming.", + "description": "Kicks the connected client: stops the video/audio stream threads and clears the launch\nstate. Idempotent — succeeds even when nothing is streaming.\n\nCounts as a **deliberate** stop, exactly like a client pressing Stop: the display skips its\nkeep-alive linger, and the end-game-on-session-end policy (if the operator enabled one) applies.", "operationId": "stopSession", "responses": { "204": { @@ -2280,6 +2332,98 @@ } } }, + "/api/v1/session/settings": { + "get": { + "tags": [ + "session" + ], + "summary": "Session⇄game lifetime settings", + "description": "Whether a launched game's exit ends the streaming session, and whether a session ending ends the\ngame (with the reconnect window that protects a dropped client's unsaved progress). See\n`design/session-game-lifetime.md`.", + "operationId": "getSessionSettings", + "responses": { + "200": { + "description": "Stored settings + which axes this build enforces", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionSettingsState" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + }, + "put": { + "tags": [ + "session" + ], + "summary": "Set the session⇄game lifetime settings", + "description": "Persists the settings (clamped) and applies them from the next decision — including to a session\nthat is already streaming, since the policy is read when a session ends rather than when it starts.", + "operationId": "setSessionSettings", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionSettings" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Settings stored; the new state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionSettingsState" + } + } + } + }, + "400": { + "description": "Malformed settings body", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Settings could not be persisted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/stats/capture/live": { "get": { "tags": [ @@ -3251,6 +3395,67 @@ }, "components": { "schemas": { + "ActiveGame": { + "type": "object", + "description": "One launched game, for the console's running-game card.", + "required": [ + "client", + "title", + "plane", + "state" + ], + "properties": { + "app_id": { + "type": [ + "string", + "null" + ], + "description": "Store-qualified library id (`steam:570`) — the key the console matches against `GET /library`\nto show box art. Absent for an operator-typed GameStream command." + }, + "client": { + "type": "string", + "description": "Client-supplied device name of the session that launched it; may be empty." + }, + "grace_remaining_s": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "Seconds until this game is ended — only present on a `grace` row.", + "minimum": 0 + }, + "plane": { + "$ref": "#/components/schemas/Plane", + "description": "`native` or `gamestream`." + }, + "session_id": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "The session streaming it; `null` for a game waiting out its reconnect window.", + "minimum": 0 + }, + "state": { + "type": "string", + "description": "`launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is\ngone and it will be ended when the reconnect window closes).", + "example": "running" + }, + "store": { + "type": [ + "string", + "null" + ], + "description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known." + }, + "title": { + "type": "string", + "description": "Display title." + } + } + }, "ApiActiveGpu": { "type": "object", "description": "The GPU live sessions are encoding on right now.", @@ -4126,6 +4331,33 @@ } } }, + "EndGameRequest": { + "type": "object", + "description": "Request body for `endGame`.", + "properties": { + "app_id": { + "type": [ + "string", + "null" + ], + "description": "Store-qualified library id (`steam:570`) to end; omit to end every waiting game." + } + } + }, + "EndGameResult": { + "type": "object", + "description": "Result of an `endGame`.", + "required": [ + "ended" + ], + "properties": { + "ended": { + "type": "integer", + "description": "How many waiting games were ended.", + "minimum": 0 + } + } + }, "EventKind": { "oneOf": [ { @@ -4240,6 +4472,48 @@ } } }, + { + "type": "object", + "description": "A launched game was confirmed running — fires once per launch, after the host has actually\nseen the game's process (not merely spawned its launcher).", + "required": [ + "game", + "kind" + ], + "properties": { + "game": { + "$ref": "#/components/schemas/GameRefPayload" + }, + "kind": { + "type": "string", + "enum": [ + "game.running" + ] + } + } + }, + { + "type": "object", + "description": "A launched game is gone. `reason` distinguishes the player quitting from the host ending it\nper the lifetime policy.", + "required": [ + "game", + "reason", + "kind" + ], + "properties": { + "game": { + "$ref": "#/components/schemas/GameRefPayload" + }, + "kind": { + "type": "string", + "enum": [ + "game.exited" + ] + }, + "reason": { + "$ref": "#/components/schemas/GameEndReason" + } + } + }, { "type": "object", "required": [ @@ -4432,6 +4706,14 @@ ], "description": "The event catalog (RFC §4). Serialized internally tagged as `\"kind\": \".\"`,\nflattened into [`HostEvent`]. **Additive-only** within [`SCHEMA_VERSION`]." }, + "GameEndReason": { + "type": "string", + "description": "Why a launched game is no longer running.", + "enum": [ + "exited", + "terminated" + ] + }, "GameEntry": { "type": "object", "description": "One title in the unified library, regardless of which store it came from.", @@ -4478,6 +4760,51 @@ } } }, + "GameOnSessionEnd": { + "type": "string", + "description": "What to do with the launched game when its session ends.", + "enum": [ + "keep", + "on_quit", + "always" + ] + }, + "GameRefPayload": { + "type": "object", + "description": "A launched game, as the `game.*` events see it.", + "required": [ + "title", + "client", + "plane" + ], + "properties": { + "app": { + "type": [ + "string", + "null" + ], + "description": "Store-qualified library id (`steam:570`). Absent for an operator-typed GameStream\n`apps.json` command, which has no library entry behind it." + }, + "client": { + "type": "string", + "description": "Client-supplied device name of the session that launched it; may be empty." + }, + "plane": { + "$ref": "#/components/schemas/Plane" + }, + "store": { + "type": [ + "string", + "null" + ], + "description": "Which store surfaced it (`steam`, `heroic`, `custom`, …), when known." + }, + "title": { + "type": "string", + "description": "Display title." + } + } + }, "GameSession": { "type": "string", "description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).", @@ -5732,7 +6059,8 @@ "audio_streaming", "pin_pending", "paired_clients", - "active_sessions" + "active_sessions", + "games" ], "properties": { "active_sessions": { @@ -5745,6 +6073,13 @@ "type": "boolean", "description": "True while the audio stream thread is running." }, + "games": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ActiveGame" + }, + "description": "Every launched game the host is tracking: one row per live session that launched a title, plus\nany game whose session has ended and which is waiting out its reconnect window before being\nended (`state: \"grace\"`). Empty when nothing was launched — a plain desktop stream has no game." + }, "paired_clients": { "type": "integer", "format": "int32", @@ -5914,6 +6249,57 @@ } } }, + "SessionSettings": { + "type": "object", + "description": "The persisted settings.", + "properties": { + "disconnect_grace_seconds": { + "type": "integer", + "format": "int32", + "description": "How long a vanished client has to reconnect before `Always` ends its game. Ignored by the\nother two policies.", + "minimum": 0 + }, + "game_on_session_end": { + "$ref": "#/components/schemas/GameOnSessionEnd", + "description": "End the launched game when the session ends. See [`GameOnSessionEnd`]." + }, + "session_on_game_exit": { + "type": "boolean", + "description": "End the streaming session when the launched game exits." + }, + "version": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, + "SessionSettingsState": { + "type": "object", + "description": "The session⇄game lifetime settings, plus which axes this build acts on.", + "required": [ + "settings", + "configured", + "enforced" + ], + "properties": { + "configured": { + "type": "boolean", + "description": "Whether an operator has ever saved these settings (`false` ⇒ `settings` are the defaults)." + }, + "enforced": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Which fields this build actually enforces. Empty on a platform with no launch path (macOS),\nso the console can say so instead of offering a switch that does nothing." + }, + "settings": { + "$ref": "#/components/schemas/SessionSettings", + "description": "The stored settings (or the built-in defaults when this host has never been configured)." + } + } + }, "SetGpuPreference": { "type": "object", "description": "Request body for `setGpuPreference`.", diff --git a/crates/punktfunk-host/src/events.rs b/crates/punktfunk-host/src/events.rs index 32f4ac15..a11861d9 100644 --- a/crates/punktfunk-host/src/events.rs +++ b/crates/punktfunk-host/src/events.rs @@ -110,6 +110,33 @@ pub struct StreamRef { pub plane: Plane, } +/// A launched game, as the `game.*` events see it. +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +pub struct GameRefPayload { + /// Store-qualified library id (`steam:570`). Absent for an operator-typed GameStream + /// `apps.json` command, which has no library entry behind it. + #[serde(skip_serializing_if = "Option::is_none")] + pub app: Option, + /// Display title. + pub title: String, + /// Which store surfaced it (`steam`, `heroic`, `custom`, …), when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub store: Option, + /// Client-supplied device name of the session that launched it; may be empty. + pub client: String, + pub plane: Plane, +} + +/// Why a launched game is no longer running. +#[derive(Serialize, Deserialize, ToSchema, Clone, Copy, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum GameEndReason { + /// The player quit it (or it crashed) — the host did not ask. + Exited, + /// The host ended it, per the session⇄game lifetime policy. + Terminated, +} + /// A device in the pairing flow. #[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] pub struct DeviceRef { @@ -140,6 +167,17 @@ pub enum EventKind { StreamStarted { stream: StreamRef }, #[serde(rename = "stream.stopped")] StreamStopped { stream: StreamRef }, + /// A launched game was confirmed running — fires once per launch, after the host has actually + /// seen the game's process (not merely spawned its launcher). + #[serde(rename = "game.running")] + GameRunning { game: GameRefPayload }, + /// A launched game is gone. `reason` distinguishes the player quitting from the host ending it + /// per the lifetime policy. + #[serde(rename = "game.exited")] + GameExited { + game: GameRefPayload, + reason: GameEndReason, + }, #[serde(rename = "pairing.pending")] PairingPending { device: DeviceRef }, #[serde(rename = "pairing.completed")] @@ -196,6 +234,8 @@ impl EventKind { EventKind::SessionEnded { .. } => "session.ended", EventKind::StreamStarted { .. } => "stream.started", EventKind::StreamStopped { .. } => "stream.stopped", + EventKind::GameRunning { .. } => "game.running", + EventKind::GameExited { .. } => "game.exited", EventKind::PairingPending { .. } => "pairing.pending", EventKind::PairingCompleted { .. } => "pairing.completed", EventKind::PairingDenied { .. } => "pairing.denied", @@ -224,6 +264,9 @@ impl EventKind { EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => { Some(&stream.client) } + EventKind::GameRunning { game } | EventKind::GameExited { game, .. } => { + Some(&game.client) + } EventKind::PairingPending { device } | EventKind::PairingCompleted { device } | EventKind::PairingDenied { device } => Some(&device.name), @@ -251,6 +294,9 @@ impl EventKind { EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => { Some(stream.plane) } + EventKind::GameRunning { game } | EventKind::GameExited { game, .. } => { + Some(game.plane) + } EventKind::PairingPending { device } | EventKind::PairingCompleted { device } | EventKind::PairingDenied { device } => Some(device.plane), @@ -264,6 +310,11 @@ impl EventKind { EventKind::StreamStarted { stream } | EventKind::StreamStopped { stream } => { stream.app.as_deref() } + // A `game.*` event without a library id came from an operator-typed command; its title + // is the only handle a hook filter has, so fall back to it rather than matching nothing. + EventKind::GameRunning { game } | EventKind::GameExited { game, .. } => { + game.app.as_deref().or(Some(&game.title)) + } _ => None, } } @@ -522,6 +573,84 @@ mod tests { serde_json::to_string(&ev).unwrap(), r#"{"seq":3,"ts_ms":1700000000000,"schema":1,"kind":"plugins.changed","id":"rom-manager"}"# ); + + let ev = HostEvent { + seq: 5, + ts_ms: 1_700_000_000_000, + schema: 1, + kind: EventKind::GameRunning { + game: GameRefPayload { + app: Some("steam:570".into()), + title: "Dota 2".into(), + store: Some("steam".into()), + client: "Living Room TV".into(), + plane: Plane::Native, + }, + }, + }; + assert_eq!( + serde_json::to_string(&ev).unwrap(), + r#"{"seq":5,"ts_ms":1700000000000,"schema":1,"kind":"game.running","game":{"app":"steam:570","title":"Dota 2","store":"steam","client":"Living Room TV","plane":"native"}}"# + ); + + // A game the host ended itself, and one with no library entry behind it (an operator-typed + // GameStream command) — the optional ids are omitted, not nulled. + let ev = HostEvent { + seq: 6, + ts_ms: 1_700_000_000_000, + schema: 1, + kind: EventKind::GameExited { + game: GameRefPayload { + app: None, + title: "Big Picture".into(), + store: None, + client: String::new(), + plane: Plane::Gamestream, + }, + reason: GameEndReason::Terminated, + }, + }; + assert_eq!( + serde_json::to_string(&ev).unwrap(), + r#"{"seq":6,"ts_ms":1700000000000,"schema":1,"kind":"game.exited","game":{"title":"Big Picture","client":"","plane":"gamestream"},"reason":"terminated"}"# + ); + } + + /// The `game.*` events must be reachable by the same hook/SSE filters as every other kind — a + /// filterable event nobody can select is not a feature. + #[test] + fn game_events_are_filterable() { + let running = EventKind::GameRunning { + game: GameRefPayload { + app: Some("steam:570".into()), + title: "Dota 2".into(), + store: Some("steam".into()), + client: "Deck".into(), + plane: Plane::Native, + }, + }; + assert_eq!(running.name(), "game.running"); + assert!(kind_matches("game.*", running.name())); + assert!(kind_matches("game.running", running.name())); + assert!(!kind_matches("gamestream.*", running.name())); + assert_eq!(running.client_name(), Some("Deck")); + assert_eq!(running.plane(), Some(Plane::Native)); + assert_eq!(running.app(), Some("steam:570")); + + // With no library id, the title is the filterable handle — otherwise an `apps.json` launch + // would be unmatchable by app. + let exited = EventKind::GameExited { + game: GameRefPayload { + app: None, + title: "Big Picture".into(), + store: None, + client: String::new(), + plane: Plane::Gamestream, + }, + reason: GameEndReason::Exited, + }; + assert_eq!(exited.app(), Some("Big Picture")); + assert_eq!(exited.fingerprint(), None); } #[test] diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs new file mode 100644 index 00000000..f7dd7db2 --- /dev/null +++ b/crates/punktfunk-host/src/gamelease.rs @@ -0,0 +1,986 @@ +//! 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, + /// Which store surfaced it (`steam`, `heroic`, `custom`, …), when known. + pub store: Option, + /// 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, + /// 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_uptime: Option, + /// The child the host spawned for this launch, when it spawned one ([`LeaseKind::Child`]). + /// Cleared once that child is reaped. + child: Mutex>, + /// 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 { + *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, + /// Joined on drop only to stop the thread promptly; the watcher self-terminates on `cancel`. + watcher: Option>, +} + +impl GameLease { + /// The shared state, for the status surface and the grace registry. + pub fn shared(&self) -> Arc { + 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_uptime: Option, +} + +/// The reference instant for adopting this launch's processes, in seconds since boot. Call it +/// **before** anything spawns; see [`LeaseRequest::launch_uptime`]. +pub fn launch_clock() -> Option { + #[cfg(target_os = "linux")] + { + crate::procscan::Scanner::system().uptime() + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +/// What the watcher does when it concludes the game has exited. +pub type OnExit = Box; + +/// 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_uptime, + } = 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_uptime, + 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); + 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, + child: Option, + on_exit: OnExit, +) -> Option> { + // 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; + } + #[cfg(not(target_os = "linux"))] + { + // Windows lands in phase 2 (Job Objects + Toolhelp matching); until then a lease exists for + // the status surface but nothing polls. + let _ = (child, on_exit); + return None; + } + #[cfg(target_os = "linux")] + { + 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(target_os = "linux")] +fn watch(shared: Arc, mut child: Option, 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(); + + // ---- 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. + let child_alive = matches!(kind, LeaseKind::Child) && child.is_some(); + let live = scanner.find(&shared.spec, shared.launch_uptime); + if !live.is_empty() || child_alive { + 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 = None; + 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(); + // Re-scan rather than only re-checking known pids: a game that re-execs (a launcher stub + // becoming the real binary, an engine relaunching itself) keeps the same identity. + let live = scanner.find(&shared.spec, shared.launch_uptime); + if !live.is_empty() || child_alive { + gone_since = None; + shared.last_seen_ms.store(now_ms(), Ordering::Relaxed); + } else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM { + 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(target_os = "linux")] +fn finish(shared: &Arc, 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, 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)); +} + +/// 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. It refuses displays + // with live sessions, so this only ever retires the *kept* display this ended session + // left behind — which is exactly the one still holding the game. + 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); + } + 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_uptime)) + .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_uptime)) + .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" + ); +} + +// --------------------------------------------------------------------------------------------- +// 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, + pub deadline: Instant, + /// The client fingerprint that may re-adopt this lease (identity match on reconnect). + pub fingerprint: Option, +} + +fn registry() -> &'static Mutex> { + static REG: OnceLock>> = 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, fingerprint: Option, 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, 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> = { + let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner()); + let (hit, keep): (Vec, Vec) = 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> = { + let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner()); + let (due, keep): (Vec, Vec) = 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| { + // 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()), + ); + } + } + } +} + +#[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_uptime: 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); + } + + #[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); + } +} diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index 1438139c..6dc51b28 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -22,6 +22,7 @@ pub(crate) use utoipa::ToSchema; mod art; mod custom; +mod detect; #[cfg(windows)] mod epic; #[cfg(windows)] @@ -38,6 +39,7 @@ mod xbox; pub use art::*; pub use custom::*; +pub use detect::*; #[cfg(windows)] pub use epic::*; #[cfg(windows)] @@ -102,6 +104,15 @@ pub struct GameEntry { /// console uses it for attribution; `GET /library?provider=` filters on it. #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, + /// How to recognize this title's process(es) once it is running ([`DetectSpec`]) — filled in by + /// each provider from paths it already read while scanning. + /// + /// **Host-internal: never serialized.** It names local filesystem paths, so it stays out of both + /// the catalog JSON the client renders and the OpenAPI schema; it rides here only so the + /// providers that already hold this data don't have to be re-scanned. + #[serde(skip)] + #[schema(ignore)] + pub detect: DetectSpec, } /// A store that contributes titles to the library. The trait is the extension point for future diff --git a/crates/punktfunk-host/src/library/custom.rs b/crates/punktfunk-host/src/library/custom.rs index bca027c6..960571de 100644 --- a/crates/punktfunk-host/src/library/custom.rs +++ b/crates/punktfunk-host/src/library/custom.rs @@ -61,6 +61,16 @@ pub struct ProviderEntryInput { impl From for GameEntry { fn from(c: CustomEntry) -> Self { + // A custom/provider entry is spawned by the host itself, so its own child process is the + // primary lifetime signal; this is the fallback for a command that hands off and exits (a + // launcher script, a `flatpak run`). An absolute exe in the command line is the only thing + // safe to infer — providers can supply richer signals explicitly (design §9, phase 4). + let detect = c + .launch + .as_ref() + .filter(|l| l.kind == "command") + .map(|l| crate::library::spec_from_command(&l.value)) + .unwrap_or_default(); GameEntry { id: format!("custom:{}", c.id), store: "custom".into(), @@ -68,6 +78,7 @@ impl From for GameEntry { art: c.art, launch: c.launch, provider: c.provider, + detect, } } } diff --git a/crates/punktfunk-host/src/library/detect.rs b/crates/punktfunk-host/src/library/detect.rs new file mode 100644 index 00000000..7d0b0265 --- /dev/null +++ b/crates/punktfunk-host/src/library/detect.rs @@ -0,0 +1,199 @@ +//! How to **recognize** a launched title's running process(es) — the read-side counterpart to +//! [`super::launch`], which only knows how to *start* one. +//! +//! A launch tells the host what to run; it does not tell it what "the game" looks like once the +//! launcher has handed off. Every store here therefore contributes whatever identifying signals it +//! already has on disk (design §4): a Steam appid, an install directory, a concrete executable, an +//! environment marker. [`crate::procscan`] turns those into live pids, and +//! [`crate::gamelease`] turns pids into a lifetime. +//! +//! The signals are a **union**, not a ladder: any process matching any signal belongs to the game. +//! That is what lets one recipe (`install_dir`) cover the stores that expose nothing else, while a +//! store with something sharper (Steam's launch reaper) still gets the precise answer. +//! +//! `DetectSpec` is **host-internal and never crosses the wire** — it names local filesystem paths, +//! which no client has any business seeing. It rides on [`super::GameEntry`] as a `#[serde(skip)]` +//! field purely so the providers that already read these paths during their scan don't have to be +//! walked a second time. + +use super::*; + +/// An environment variable a launcher stamps onto the game's process, identifying it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EnvMarker { + /// The variable name (e.g. `HEROIC_GAME_ID`). + pub key: String, + /// The exact value to require, when the launcher's value identifies *this* title. `None` matches + /// the key's mere presence — only safe for launchers that run one game at a time. + pub value: Option, +} + +/// The signals that identify a launched title's process(es). Every field is optional and +/// independent; an all-`None` spec means "this title can't be tracked" (the lease degrades to +/// [`crate::gamelease::LeaseKind::Untracked`] and both lifetime behaviors stay inert for it). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DetectSpec { + /// Steam appid, for titles Steam itself installed (never for non-Steam shortcuts, whose reaper + /// appid semantics differ — those carry an [`exe`](Self::exe) instead). On Linux this is the + /// sharpest signal available: Steam wraps every launch — native or Proton — in + /// `reaper SteamLaunch AppId=`, whose lifetime is exactly the game's. + pub steam_appid: Option, + /// A launcher-stamped environment marker. + pub env_marker: Option, + /// The game's own executable, when a store resolves one exactly. + pub exe: Option, + /// The game's install directory — the universal recipe. A process whose image path (or, for + /// Proton/Wine, whose command line) sits under this directory is part of the game. + pub install_dir: Option, +} + +impl DetectSpec { + /// A spec with no signals at all — nothing to track. + pub fn is_empty(&self) -> bool { + self.steam_appid.is_none() + && self.env_marker.is_none() + && self.exe.is_none() + && self.install_dir.is_none() + } + + /// Just a Steam appid (the manifest path; art/shortcut scanning fills the rest). + pub fn steam(appid: u32) -> Self { + Self { + steam_appid: Some(appid), + ..Default::default() + } + } + + /// Just an install directory — the universal recipe most stores land on. + pub fn dir(dir: impl Into) -> Self { + Self { + install_dir: Some(dir.into()), + ..Default::default() + } + } + + /// Just a concrete executable. + pub fn exe(exe: impl Into) -> Self { + Self { + exe: Some(exe.into()), + ..Default::default() + } + } + + /// Add an install directory to an existing spec (Steam pairs one with its appid so the Windows + /// matcher, which has no reaper, still has something to go on). + pub fn with_dir(mut self, dir: impl Into) -> Self { + self.install_dir = Some(dir.into()); + self + } + + /// Add an environment marker to an existing spec. + pub fn with_env(mut self, key: impl Into, value: Option) -> Self { + self.env_marker = Some(EnvMarker { + key: key.into(), + value, + }); + self + } +} + +/// Derive a detect spec from an operator-typed shell command (the custom store's `command` kind, and +/// the provider-plugin entries that reuse it): if the command's first token is an absolute path to an +/// existing file, that's the game's executable. +/// +/// Deliberately conservative — a bare `dolphin-emu --batch` yields nothing (a PATH lookup would guess +/// at which of several installs the launcher will pick, and a wrong exe is worse than none: the lease +/// would call a running game exited). Custom entries are spawned by the host anyway, so their primary +/// tracking is the child process itself; this is only the fallback for a command that shims out. +pub fn spec_from_command(cmd: &str) -> DetectSpec { + let Some(first) = shell_first_token(cmd) else { + return DetectSpec::default(); + }; + let p = Path::new(&first); + if p.is_absolute() && p.is_file() { + DetectSpec::exe(p) + } else { + DetectSpec::default() + } +} + +/// The first token of a shell command, honoring single/double quotes around it (`"/opt/My Game/run"`). +/// Not a shell parser — just enough to recover a quoted absolute path, which is the only case that +/// matters here. +fn shell_first_token(cmd: &str) -> Option { + let cmd = cmd.trim_start(); + let mut chars = cmd.chars(); + match chars.next()? { + q @ ('"' | '\'') => { + let rest: String = chars.collect(); + let end = rest.find(q)?; + Some(rest[..end].to_string()) + } + _ => cmd.split_whitespace().next().map(str::to_string), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_spec_is_untrackable() { + assert!(DetectSpec::default().is_empty()); + assert!(!DetectSpec::steam(570).is_empty()); + assert!(!DetectSpec::dir("/games/x").is_empty()); + assert!(!DetectSpec::exe("/games/x/run").is_empty()); + assert!(!DetectSpec::default() + .with_env("HEROIC_GAME_ID", Some("abc".into())) + .is_empty()); + } + + #[test] + fn builders_compose() { + let s = DetectSpec::steam(570).with_dir("/games/dota"); + assert_eq!(s.steam_appid, Some(570)); + assert_eq!(s.install_dir.as_deref(), Some(Path::new("/games/dota"))); + let h = DetectSpec::dir("/games/quail").with_env("HEROIC_GAME_ID", Some("Quail".into())); + assert_eq!( + h.env_marker, + Some(EnvMarker { + key: "HEROIC_GAME_ID".into(), + value: Some("Quail".into()) + }) + ); + } + + #[test] + fn command_spec_only_trusts_an_absolute_existing_file() { + // A PATH-relative command is not guessed at. + assert!(spec_from_command("dolphin-emu --batch").is_empty()); + // An absolute path that doesn't exist is not asserted either. + assert!(spec_from_command("/nope/not/here --x").is_empty()); + // A real absolute file (this test binary) is picked up, quoted or bare. + let me = std::env::current_exe().expect("current exe"); + let bare = format!("{} --flag", me.display()); + assert_eq!(spec_from_command(&bare).exe.as_deref(), Some(me.as_path())); + let quoted = format!("\"{}\" --flag", me.display()); + assert_eq!( + spec_from_command("ed).exe.as_deref(), + Some(me.as_path()) + ); + assert!(spec_from_command(" ").is_empty()); + } + + #[test] + fn first_token_handles_quotes_and_spaces() { + assert_eq!( + shell_first_token("\"/opt/My Game/run\" -w").as_deref(), + Some("/opt/My Game/run") + ); + assert_eq!( + shell_first_token("'/opt/My Game/run'").as_deref(), + Some("/opt/My Game/run") + ); + assert_eq!(shell_first_token(" plain --x").as_deref(), Some("plain")); + assert_eq!(shell_first_token("").as_deref(), None); + // An unterminated quote yields nothing rather than a bogus token. + assert_eq!(shell_first_token("\"/opt/oops").as_deref(), None); + } +} diff --git a/crates/punktfunk-host/src/library/epic.rs b/crates/punktfunk-host/src/library/epic.rs index 4e4db50b..f1bd846e 100644 --- a/crates/punktfunk-host/src/library/epic.rs +++ b/crates/punktfunk-host/src/library/epic.rs @@ -88,6 +88,16 @@ fn epic_entry( } else { app_name.clone() }; + // Detect signals: the manifest's own `LaunchExecutable` (relative to the install dir) is exact + // when present; the install dir covers the rest (Epic hands off to its launcher, so the host + // never owns the game's process). + let detect = match s("LaunchExecutable") + .map(|rel| Path::new(install).join(rel)) + .filter(|p| p.is_file()) + { + Some(exe) => DetectSpec::exe(exe).with_dir(install), + None => DetectSpec::dir(install), + }; Some(GameEntry { provider: None, id: format!("epic:{app_name}"), @@ -98,6 +108,7 @@ fn epic_entry( kind: "epic".into(), value, }), + detect, }) } diff --git a/crates/punktfunk-host/src/library/gog.rs b/crates/punktfunk-host/src/library/gog.rs index 2c1bf9e6..ed117209 100644 --- a/crates/punktfunk-host/src/library/gog.rs +++ b/crates/punktfunk-host/src/library/gog.rs @@ -52,6 +52,9 @@ fn gog_games() -> Vec { // Art (public api.gog.com) is resolved off the hot path by the background warmer; read // whatever it has cached (title-only until warmed). let art = cached_art(&id).unwrap_or_default(); + // GOG launches the game's exe directly (no Galaxy), so the host owns the process and the + // spec is only the fallback for a stub launcher that hands off; both signals are exact here. + let detect = DetectSpec::exe(&exe).with_dir(&path); out.push(GameEntry { provider: None, id, @@ -62,6 +65,7 @@ fn gog_games() -> Vec { kind: "gog".into(), value: format!("{exe}\t{args}\t{workdir}"), }), + detect, }); } out diff --git a/crates/punktfunk-host/src/library/heroic.rs b/crates/punktfunk-host/src/library/heroic.rs index bb9b9a54..495443ab 100644 --- a/crates/punktfunk-host/src/library/heroic.rs +++ b/crates/punktfunk-host/src/library/heroic.rs @@ -72,14 +72,16 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result anyhow::Result Option { command_for(&spec) } +/// Everything a session needs about the title it is launching, resolved in **one** library scan: +/// what to run, what to call it, and how to recognize it once it is running. +/// +/// Enumerating the library touches every installed store's on-disk metadata, so the launch path +/// resolves this once at handshake time and threads it into the data plane rather than looking the +/// same id up again per use. +pub struct LaunchTarget { + /// Identity for the status surface and the `game.*` events. + pub game: crate::gamelease::GameRef, + /// How to recognize the running game ([`DetectSpec`]); empty when the store offers nothing. + pub detect: DetectSpec, + /// The resolved shell command. `Some` on Linux (where the host runs it); `None` on Windows, + /// which launches by library id through the interactive-session spawner instead. + pub command: Option, +} + +/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) against the host's +/// **own** library. `None` = unknown id, or — on Linux — a title with no runnable recipe. +/// +/// This is the single lookup: the client sends only an id, and everything the session does with the +/// title afterwards comes from what the host itself knows about it. +pub fn resolve_launch(id: &str) -> Option { + let entry = all_games().into_iter().find(|g| g.id == id)?; + let game = crate::gamelease::GameRef { + id: Some(entry.id.clone()), + store: Some(entry.store.clone()), + title: entry.title.clone(), + }; + #[cfg(not(windows))] + { + // Linux runs the command itself, so a title without one has nothing to launch — same answer + // (and same warning path) as before this resolution existed. + let command = entry.launch.as_ref().and_then(command_for)?; + Some(LaunchTarget { + game, + detect: entry.detect, + command: Some(command), + }) + } + #[cfg(windows)] + { + // Windows resolves the concrete process at launch time (`launch_title`), which is also where + // a missing recipe is reported — so an entry with no Windows recipe still yields a target and + // the existing warning fires there. + Some(LaunchTarget { + game, + detect: entry.detect, + command: None, + }) + } +} + /// Map a resolved [`LaunchSpec`] to its shell command (pure — the unit-testable core of /// [`launch_command`], split out so the appid-validation can be tested without a Steam install). #[cfg(not(windows))] @@ -172,6 +224,21 @@ pub fn launch_gamestream_library(id: &str) -> Result<()> { launch_title(id) } +/// The child a session launch produced. +/// +/// Handed back to the caller so the game's lifetime can be tracked +/// (design/session-game-lifetime.md) instead of the process being forgotten the moment it starts. +#[cfg(target_os = "linux")] +pub struct SpawnedLaunch { + pub child: std::process::Child, + /// Whether the child leads its own process group — true for the plain session spawn in [`launch_session_command`], which + /// deliberately creates one so the whole wrapper tree can be signalled as a unit. False for the + /// gamescope-session spawn, which shares the host's group (see + /// [`crate::gamelease::OwnedChild::group_leader`]: a non-leader must never be signalled by + /// negative pid). + pub group_leader: bool, +} + /// Launch a resolved shell command into the **live Linux session** for the session's compositor — /// the one launch entry point shared by the native (punktfunk/1) and GameStream planes, called /// AFTER capture is up so the app renders onto the streamed output. The command is host-resolved @@ -190,18 +257,29 @@ pub fn launch_gamestream_library(id: &str) -> Result<()> { /// * **gamescope (bare spawn)** — not routed here: the command was nested into the fresh gamescope /// via `set_launch_command` (the caller gates on `vdisplay::launch_is_nested`). #[cfg(target_os = "linux")] -pub fn launch_session_command(compositor: crate::vdisplay::Compositor, cmd: &str) -> Result<()> { +pub fn launch_session_command( + compositor: crate::vdisplay::Compositor, + cmd: &str, +) -> Result { + use std::os::unix::process::CommandExt; let cmd = cmd.trim(); anyhow::ensure!(!cmd.is_empty(), "empty command"); - let child = match compositor { + let (child, group_leader) = match compositor { crate::vdisplay::Compositor::Gamescope => { - crate::vdisplay::launch_into_gamescope_session(cmd)? + (crate::vdisplay::launch_into_gamescope_session(cmd)?, false) } - _ => std::process::Command::new("sh") - .arg("-c") - .arg(cmd) - .spawn() - .context("spawn launch command")?, + _ => ( + std::process::Command::new("sh") + .arg("-c") + .arg(cmd) + // Its own process group, so ending this game later signals the shell *and* the game + // it exec'd or forked — the whole tree the host started — and nothing else. Also + // detaches it from any signal the host's own group receives. + .process_group(0) + .spawn() + .context("spawn launch command")?, + true, + ), }; tracing::info!( command = %cmd, @@ -209,7 +287,10 @@ pub fn launch_session_command(compositor: crate::vdisplay::Compositor, cmd: &str compositor = compositor.id(), "launched app into the live session" ); - Ok(()) + Ok(SpawnedLaunch { + child, + group_leader, + }) } /// Resolve the launch command for a session app selection on Linux: a store-qualified library id diff --git a/crates/punktfunk-host/src/library/lutris.rs b/crates/punktfunk-host/src/library/lutris.rs index 25f9d5bd..fc000ff2 100644 --- a/crates/punktfunk-host/src/library/lutris.rs +++ b/crates/punktfunk-host/src/library/lutris.rs @@ -55,20 +55,33 @@ fn lutris_games(db: &Path) -> rusqlite::Result> { OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, )? }; - let mut stmt = conn.prepare( - "SELECT id, slug, name FROM games \ + // `directory` (the game's install dir — our detect signal) is not load-bearing for the library, so + // a pga.db schema without it must not cost the whole Lutris store: try the richer query first and + // fall back to the historical one on any prepare error. + const SELECT_WITH_DIR: &str = "SELECT id, slug, name, directory FROM games \ WHERE installed = 1 AND name IS NOT NULL AND name <> '' \ - ORDER BY name COLLATE NOCASE", - )?; + ORDER BY name COLLATE NOCASE"; + const SELECT_PLAIN: &str = "SELECT id, slug, name, NULL FROM games \ + WHERE installed = 1 AND name IS NOT NULL AND name <> '' \ + ORDER BY name COLLATE NOCASE"; + let mut stmt = match conn.prepare(SELECT_WITH_DIR) { + Ok(s) => s, + Err(e) => { + tracing::warn!(error = %e, "lutris pga.db has no `directory` column — listing without \ + install dirs (game-exit detection unavailable for Lutris titles)"); + conn.prepare(SELECT_PLAIN)? + } + }; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, Option>(1)?, row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, )) })?; let mut games = Vec::new(); - for (id, slug, name) in rows.flatten() { + for (id, slug, name, directory) in rows.flatten() { games.push(GameEntry { provider: None, id: format!("lutris:{id}"), @@ -79,6 +92,12 @@ fn lutris_games(db: &Path) -> rusqlite::Result> { kind: "lutris_id".into(), value: id.to_string(), }), + // Lutris stamps no per-game env marker we can rely on, so the install dir is the whole + // recipe; a game with none (an emulator entry pointing at a bare ROM) stays untracked. + detect: directory + .filter(|d| !d.trim().is_empty()) + .map(DetectSpec::dir) + .unwrap_or_default(), }); } Ok(games) diff --git a/crates/punktfunk-host/src/library/steam.rs b/crates/punktfunk-host/src/library/steam.rs index d39b4cd8..c3942715 100644 --- a/crates/punktfunk-host/src/library/steam.rs +++ b/crates/punktfunk-host/src/library/steam.rs @@ -17,25 +17,32 @@ impl LibraryProvider for SteamProvider { } fn list(&self) -> Vec { - let mut by_appid: std::collections::BTreeMap = Default::default(); + let mut by_appid: std::collections::BTreeMap = Default::default(); for steamapps in steam_library_dirs() { - for (appid, name) in scan_manifests(&steamapps) { - by_appid.entry(appid).or_insert(name); // first library wins; dedups shared appids + for app in scan_manifests(&steamapps) { + // First library wins; dedups appids present in several libraries. + by_appid.entry(app.appid).or_insert(app); } } let mut games: Vec = by_appid - .into_iter() - .filter(|(appid, name)| !is_steam_tool(*appid, name)) - .map(|(appid, title)| GameEntry { + .into_values() + .filter(|app| !is_steam_tool(app.appid, &app.name)) + .map(|app| GameEntry { provider: None, - id: format!("steam:{appid}"), + id: format!("steam:{}", app.appid), store: "steam".into(), - title, - art: steam_art(appid), + art: steam_art(app.appid), launch: Some(LaunchSpec { kind: "steam_appid".into(), - value: appid.to_string(), + value: app.appid.to_string(), }), + // The appid alone is authoritative on Linux (Steam's launch reaper); the install dir + // is what the Windows matcher — which has no reaper to watch — keys off instead. + detect: match app.install_dir { + Some(dir) => DetectSpec::steam(app.appid).with_dir(dir), + None => DetectSpec::steam(app.appid), + }, + title: app.name, }) .collect(); // Non-Steam shortcuts have no `appmanifest` — [`scan_manifests`] can't see them, so the @@ -274,8 +281,17 @@ fn vdf_value<'a>(line: &'a str, key: &str) -> Option<&'a str> { Some(&after[..after.find('"')?]) } -/// Scan a `steamapps` dir for `appmanifest_*.acf` files → (appid, name) of installed titles. -fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> { +/// One installed Steam title, as read from its `appmanifest_.acf`. +struct Installed { + appid: u32, + name: String, + /// `/common/`, when the manifest names one and it exists on disk — the + /// game's own files, used to recognize its processes ([`DetectSpec::install_dir`]). + install_dir: Option, +} + +/// Scan a `steamapps` dir for `appmanifest_*.acf` files → the installed titles it describes. +fn scan_manifests(steamapps: &Path) -> Vec { let Ok(rd) = std::fs::read_dir(steamapps) else { return Vec::new(); }; @@ -290,7 +306,17 @@ fn scan_manifests(steamapps: &Path) -> Vec<(u32, String)> { let appid = text.lines().find_map(|l| vdf_value(l.trim(), "appid")); let name = text.lines().find_map(|l| vdf_value(l.trim(), "name")); if let (Some(Ok(appid)), Some(name)) = (appid.map(str::parse::), name) { - out.push((appid, name.to_string())); + // `installdir` is a bare folder name relative to this library's `common/`. + let install_dir = text + .lines() + .find_map(|l| vdf_value(l.trim(), "installdir")) + .map(|d| steamapps.join("common").join(d)) + .filter(|p| p.is_dir()); + out.push(Installed { + appid, + name: name.to_string(), + install_dir, + }); } } } @@ -318,6 +344,9 @@ struct Shortcut { appid: u32, /// Display name (`AppName`). name: String, + /// The shortcut's target (`Exe`), as stored — Steam quotes it. This *is* the game (a shortcut + /// points straight at it, with no launcher in between), so it doubles as the detect signal. + exe: String, /// Whether Steam has this shortcut hidden from the library (`IsHidden`) — we honor that. hidden: bool, } @@ -361,9 +390,22 @@ fn shortcut_entry(sc: Shortcut) -> Option { kind: "steam_appid".into(), value: shortcut_gameid(sc.appid).to_string(), }), + detect: shortcut_detect(&sc.exe), }) } +/// Detect signals for a non-Steam shortcut: its `Exe` target is the game itself, so the executable +/// (and its folder, which catches a launcher script that execs a sibling binary) identifies it. Steam +/// stores the target quoted and may include trailing arguments; only an existing absolute path is +/// asserted — a guess would be worse than no tracking at all. +fn shortcut_detect(exe: &str) -> DetectSpec { + let mut spec = crate::library::spec_from_command(exe); + if let Some(dir) = spec.exe.as_deref().and_then(Path::parent) { + spec.install_dir = Some(dir.to_path_buf()); + } + spec +} + /// Every `userdata//config/shortcuts.vdf` under each Steam root — one file per Steam account /// that has signed in on this host. fn shortcuts_files() -> Vec { @@ -489,6 +531,7 @@ fn parse_one_shortcut(buf: &[u8], pos: &mut usize) -> Option { Some(Shortcut { appid, name, + exe, hidden, }) } @@ -704,6 +747,7 @@ mod tests { let sc = Shortcut { appid: 2_456_789_012, name: "My Emulator".into(), + exe: "\"/opt/emu/run.sh\"".into(), hidden: false, }; let entry = shortcut_entry(sc).unwrap(); diff --git a/crates/punktfunk-host/src/library/xbox.rs b/crates/punktfunk-host/src/library/xbox.rs index 51f4250e..67e3e8f5 100644 --- a/crates/punktfunk-host/src/library/xbox.rs +++ b/crates/punktfunk-host/src/library/xbox.rs @@ -78,6 +78,9 @@ fn xbox_games() -> Vec { kind: "aumid".into(), value: format!("{pfn}!{app_id}"), }), + // AUMID activation goes through the shell, so the host never owns the process: the + // title's `Content` dir (which holds the game's binaries) is the detect signal. + detect: DetectSpec::dir(title_dir.join("Content")), }); } } diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 9d77827f..15860fad 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -41,6 +41,9 @@ mod encode { pub(crate) use pf_encode::*; } mod events; +// The lifetime of a launched game: whether it is running, when it exits (which can end the session), +// and how to end it (which a session ending can ask for) — design/session-game-lifetime.md. +mod gamelease; mod gamestream; #[cfg(target_os = "linux")] #[path = "linux/gpuclocks.rs"] @@ -66,11 +69,17 @@ mod native; mod native_pairing; mod pipeline; mod plugins; +// Finding a launched game's processes from its store's detect signals — the read side of the +// session⇄game lifetime binding (design/session-game-lifetime.md §4). +#[cfg(target_os = "linux")] +mod procscan; mod send_pacing; #[cfg(target_os = "windows")] #[path = "windows/service.rs"] mod service; mod session_plan; +// Operator policy for the session⇄game lifetime binding (`session-settings.json`). +mod session_settings; mod session_status; mod sleep_inhibit; mod spike; diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 4dcf9336..120e67e3 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -214,6 +214,11 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(native::deny_pending_device)) .routes(routes!(session::stop_session)) .routes(routes!(session::request_idr)) + .routes(routes!( + session::get_session_settings, + session::set_session_settings + )) + .routes(routes!(session::end_game)) .routes(routes!(library::get_library)) .routes(routes!(library::list_library_scanners)) .routes(routes!(library::set_library_scanner)) diff --git a/crates/punktfunk-host/src/mgmt/host.rs b/crates/punktfunk-host/src/mgmt/host.rs index f8fa6a16..c8db0832 100644 --- a/crates/punktfunk-host/src/mgmt/host.rs +++ b/crates/punktfunk-host/src/mgmt/host.rs @@ -112,6 +112,38 @@ pub(crate) struct RuntimeStatus { /// The active stream's parameters — RTSP-negotiated for GameStream, or the live native session's /// mode/codec/bitrate. `null` when nothing is streaming. stream: Option, + /// Every launched game the host is tracking: one row per live session that launched a title, plus + /// any game whose session has ended and which is waiting out its reconnect window before being + /// ended (`state: "grace"`). Empty when nothing was launched — a plain desktop stream has no game. + games: Vec, +} + +/// One launched game, for the console's running-game card. +#[derive(Serialize, ToSchema)] +pub(crate) struct ActiveGame { + /// The session streaming it; `null` for a game waiting out its reconnect window. + #[serde(skip_serializing_if = "Option::is_none")] + session_id: Option, + /// Client-supplied device name of the session that launched it; may be empty. + client: String, + /// Store-qualified library id (`steam:570`) — the key the console matches against `GET /library` + /// to show box art. Absent for an operator-typed GameStream command. + #[serde(skip_serializing_if = "Option::is_none")] + app_id: Option, + /// Display title. + title: String, + /// Which store surfaced it (`steam`, `heroic`, `custom`, …), when known. + #[serde(skip_serializing_if = "Option::is_none")] + store: Option, + /// `native` or `gamestream`. + plane: crate::events::Plane, + /// `launching` (launched, not seen running yet), `running`, `exited`, or `grace` (its session is + /// gone and it will be ended when the reconnect window closes). + #[schema(example = "running")] + state: String, + /// Seconds until this game is ended — only present on a `grace` row. + #[serde(skip_serializing_if = "Option::is_none")] + grace_remaining_s: Option, } /// Client-requested launch parameters (key material is never exposed here). @@ -373,6 +405,19 @@ pub(crate) async fn get_status(State(st): State>) -> Json>) -> StatusCode // Native plane: the GameStream teardown above doesn't reach it (it runs its own loops off the shared // session registry), so signal every live native session to tear down too. let native = crate::session_status::count(); - crate::session_status::stop_all(); + crate::session_status::stop_all_quit(); tracing::info!( was_streaming, native_sessions = native, @@ -32,6 +35,126 @@ pub(crate) async fn stop_session(State(st): State>) -> StatusCode StatusCode::NO_CONTENT } +/// End a launched game +/// +/// Ends a game whose session has already gone and which is waiting out its reconnect window — the +/// console's "End now" for a game the host is about to close anyway. `app_id` picks one title; omit it +/// to end every waiting game. +/// +/// This does **not** touch a game whose session is still live: ending that is session management +/// (`DELETE /session`), and how the game is treated then follows the operator's policy. +#[utoipa::path( + post, + path = "/game/end", + tag = "session", + operation_id = "endGame", + request_body = EndGameRequest, + responses( + (status = OK, description = "How many waiting games were ended", body = EndGameResult), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = CONFLICT, description = "No game is waiting to be ended", body = ApiError), + ) +)] +pub(crate) async fn end_game(ApiJson(req): ApiJson) -> Response { + let ended = crate::gamelease::end_pending(req.app_id.as_deref()); + if ended == 0 { + return api_error(StatusCode::CONFLICT, "no game is waiting to be ended"); + } + tracing::info!(app_id = ?req.app_id, ended, "management API: game ended"); + Json(EndGameResult { ended }).into_response() +} + +/// Request body for `endGame`. +#[derive(Deserialize, ToSchema)] +pub(crate) struct EndGameRequest { + /// Store-qualified library id (`steam:570`) to end; omit to end every waiting game. + #[serde(default)] + pub app_id: Option, +} + +/// Result of an `endGame`. +#[derive(Serialize, ToSchema)] +pub(crate) struct EndGameResult { + /// How many waiting games were ended. + ended: usize, +} + +/// The session⇄game lifetime settings, plus which axes this build acts on. +#[derive(Serialize, ToSchema)] +pub(crate) struct SessionSettingsState { + /// The stored settings (or the built-in defaults when this host has never been configured). + settings: crate::session_settings::SessionSettings, + /// Whether an operator has ever saved these settings (`false` ⇒ `settings` are the defaults). + configured: bool, + /// Which fields this build actually enforces. Empty on a platform with no launch path (macOS), + /// so the console can say so instead of offering a switch that does nothing. + enforced: Vec, +} + +fn session_settings_state() -> SessionSettingsState { + let store = crate::session_settings::store(); + SessionSettingsState { + settings: store.get(), + configured: store.configured(), + enforced: crate::session_settings::enforced(), + } +} + +/// Session⇄game lifetime settings +/// +/// Whether a launched game's exit ends the streaming session, and whether a session ending ends the +/// game (with the reconnect window that protects a dropped client's unsaved progress). See +/// `design/session-game-lifetime.md`. +#[utoipa::path( + get, + path = "/session/settings", + tag = "session", + operation_id = "getSessionSettings", + responses( + (status = OK, description = "Stored settings + which axes this build enforces", body = SessionSettingsState), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn get_session_settings() -> Json { + Json(session_settings_state()) +} + +/// Set the session⇄game lifetime settings +/// +/// Persists the settings (clamped) and applies them from the next decision — including to a session +/// that is already streaming, since the policy is read when a session ends rather than when it starts. +#[utoipa::path( + put, + path = "/session/settings", + tag = "session", + operation_id = "setSessionSettings", + request_body = crate::session_settings::SessionSettings, + responses( + (status = OK, description = "Settings stored; the new state", body = SessionSettingsState), + (status = BAD_REQUEST, description = "Malformed settings body", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Settings could not be persisted", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn set_session_settings( + ApiJson(settings): ApiJson, +) -> Response { + if let Err(e) = crate::session_settings::store().set(settings) { + return api_error( + StatusCode::INTERNAL_SERVER_ERROR, + &format!("persist session settings: {e:#}"), + ); + } + let state = session_settings_state(); + tracing::info!( + game_on_session_end = state.settings.game_on_session_end.as_str(), + session_on_game_exit = state.settings.session_on_game_exit, + grace_s = state.settings.disconnect_grace_seconds, + "management API: session⇄game lifetime settings updated" + ); + Json(state).into_response() +} + /// Force a keyframe /// /// Asks the encoder for an IDR frame on the active video stream (what a client requests diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 1100c6b8..8d8e73a8 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -306,17 +306,20 @@ fn fake_native_session( fps: u32, ) -> crate::session_status::LiveSessionGuard { let packed = ((width as u64) << 32) | ((height as u64) << 16) | fps as u64; - crate::session_status::register( - Arc::new(std::sync::atomic::AtomicU64::new(packed)), - Arc::new(std::sync::atomic::AtomicU32::new(20_000)), - Codec::H265, - Arc::new(std::sync::atomic::AtomicBool::new(false)), - Arc::new(std::sync::atomic::AtomicBool::new(false)), - "test-client".into(), - false, - Arc::new(std::sync::atomic::AtomicU32::new(0)), - Arc::new(std::sync::atomic::AtomicU32::new(0)), - ) + crate::session_status::register(crate::session_status::Registration { + mode: Arc::new(std::sync::atomic::AtomicU64::new(packed)), + bitrate_kbps: Arc::new(std::sync::atomic::AtomicU32::new(20_000)), + codec: Codec::H265, + stop: Arc::new(std::sync::atomic::AtomicBool::new(false)), + quit: Arc::new(std::sync::atomic::AtomicBool::new(false)), + force_idr: Arc::new(std::sync::atomic::AtomicBool::new(false)), + client: "test-client".into(), + hdr: false, + ttff_ms: Arc::new(std::sync::atomic::AtomicU32::new(0)), + last_resize_ms: Arc::new(std::sync::atomic::AtomicU32::new(0)), + // No launch: a desktop stream, which must show no game row. + game: None, + }) } /// A native (punktfunk/1) session — the DEFAULT plane — must read as streaming in the tray's diff --git a/crates/punktfunk-host/src/native.rs b/crates/punktfunk-host/src/native.rs index 8dd2aff3..98599f51 100644 --- a/crates/punktfunk-host/src/native.rs +++ b/crates/punktfunk-host/src/native.rs @@ -1303,24 +1303,43 @@ async fn serve_session( // to its shell command HERE against the host's own library — a client can only ever pick an // existing title, never send a command — and the data plane runs it per-backend (nested into a // bare-spawn gamescope, or spawned into the live session once capture is up). + // ONE library lookup for the whole session: enumerating the installed stores touches every + // launcher's on-disk metadata, and the data plane needs three things out of it — what to run, what + // to call the title, and how to recognize its process once a launcher has handed off + // (design/session-game-lifetime.md §4). + let launch_target = + hello + .launch + .as_deref() + .and_then(|id| match crate::library::resolve_launch(id) { + Some(t) => { + tracing::info!( + launch_id = id, + title = %t.game.title, + command = t.command.as_deref().unwrap_or("-"), + "resolved library launch for this session" + ); + Some(t) + } + None => { + tracing::warn!( + launch_id = id, + "client requested a launch id not in this host's library — ignoring" + ); + None + } + }); #[cfg(target_os = "windows")] - let launch_for_dp = hello.launch.clone(); + let launch_for_dp = launch_target.as_ref().and(hello.launch.clone()); #[cfg(not(target_os = "windows"))] - let launch_for_dp = hello.launch.as_deref().and_then(|id| { - match crate::library::launch_command(id) { - Some(cmd) => { - tracing::info!(launch_id = id, command = %cmd, "resolved library launch for this session"); - Some(cmd) - } - None => { - tracing::warn!( - launch_id = id, - "client requested a launch id not in this host's library — ignoring" - ); - None - } - } - }); + let launch_for_dp = launch_target.as_ref().and_then(|t| t.command.clone()); + // A client reconnecting inside its game's reconnect window takes the game back: nothing is ended, + // and this session adopts it. Matched on (this client, this title) so it can only ever reclaim its + // own game. + if let Some(target) = launch_target.as_ref() { + let fp = punktfunk_core::quic::endpoint::peer_fingerprint(&conn).map(hex::encode); + crate::gamelease::readopt(fp.as_deref(), target.game.id.as_deref()); + } // Per-title prep steps (RFC §6) for a launched CUSTOM library title: run synchronously // before the data plane starts (so before the display opens and the title spawns); the // guard's drop — any serve_session exit — runs the undos in reverse, best-effort. @@ -1466,6 +1485,7 @@ async fn serve_session( stats: stats_dp, client_label, launch: launch_for_dp, + launch_target, client_hdr, bringup: bringup_dp, resize_ms: resize_ms_dp, diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 3782fe7a..98e7bf96 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -879,6 +879,30 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender, stop: Arc, + /// Hex client fingerprint, so a reconnecting client can reclaim its own game and nothing else. + fingerprint: Option, +} + +impl Drop for GameLifetime { + fn drop(&mut self) { + crate::gamelease::on_session_end( + &self.lease, + self.quit.load(Ordering::SeqCst), + self.fingerprint.as_deref(), + ); + } +} + /// All per-session inputs for [`virtual_stream`], bundled so the session entry /// is one moved value instead of a 13-positional-argument `#[allow(too_many_arguments)]` signature /// (Goal-1 stage 4, plan §2.4). Everything is **owned** — the receivers move in (`virtual_stream` is their @@ -988,6 +1012,10 @@ pub(super) struct SessionContext { /// command already resolved against the host's own library — nested into gamescope's bare spawn /// via `set_launch_command`, or spawned into the live session once capture is up. pub(super) launch: Option, + /// Identity + detection metadata for the launched title, resolved once at handshake time + /// alongside `launch`. `None` when nothing was launched. Drives the game's lifetime — its exit + /// can end this session, and this session ending can end it (design/session-game-lifetime.md). + pub(super) launch_target: Option, /// The client display's HDR colour volume (`Hello::display_hdr`; `None` = older client / SDR). /// Threaded into the vdisplay backend before `create` (→ the pf-vdisplay EDID's CTA HDR block, /// so host apps tone-map to the client's real panel) and preferred over the generic baseline @@ -1077,10 +1105,17 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option { tracing::info!(command = %cmd, "launch nested into the per-session gamescope"); - } else if let Err(e) = crate::library::launch_session_command(compositor, cmd) { - tracing::warn!(command = %cmd, error = %e, "could not launch requested title into the session"); + None } - } + Some(cmd) => match crate::library::launch_session_command(compositor, cmd) { + Ok(spawned) => Some(spawned), + Err(e) => { + tracing::warn!(command = %cmd, error = %e, "could not launch requested title into the session"); + None + } + }, + None => None, + }; #[cfg(not(any(target_os = "windows", target_os = "linux")))] let _ = &launch; - // Dedicated Steam launch: end the stream cleanly when the LAUNCHED GAME exits. The node-death - // check in the capture-loss branch below can't see this for Steam — the nested `steam` is the - // resident client and stays up after a game quits, so gamescope (and its node) never dies and the - // stream would sit on a hidden Steam session forever. Watch the game process directly (Steam's - // `SteamLaunch AppId=` reaper, whose lifetime == the game's) and close with APP_EXITED when - // it's gone, so a launcher client returns to its library. Non-Steam nested launches keep the - // node-death path (gamescope's child IS the game). Cancelled via `stop` when the session ends for - // another reason first; the thread self-terminates, so we don't join it. - #[cfg(target_os = "linux")] - let _game_watch = launch - .as_deref() - .filter(|_| crate::vdisplay::launch_is_nested(compositor)) - .and_then(crate::vdisplay::steam_appid_from_launch) - .map(|appid| { + // The launched game's lifetime, in both directions (design/session-game-lifetime.md): + // + // * **its exit ends this session** — so a client returns to its library instead of sitting on a + // hidden launcher or a bare desktop. This generalizes what used to be a Steam-and-gamescope-only + // watch: the game is now recognized from whatever its store told us (appid, install dir, exe, + // env marker), which covers every compositor and every store. The node-death check in the + // capture-loss branch below stays as the backstop for a nested launch we can't otherwise see. + // * **this session ending can end it** — never by default; only when the operator asked, and for + // a mere disconnect only after a reconnect window (`_game_life`'s drop, below). + let game_lease = launch_target.as_ref().map(|target| { + #[cfg(target_os = "linux")] + let nested = crate::vdisplay::launch_is_nested(compositor); + #[cfg(not(target_os = "linux"))] + let nested = false; + #[cfg(target_os = "linux")] + let child = spawned_launch.map(|s| (s.child, s.group_leader)); + #[cfg(not(target_os = "linux"))] + let child = None; + + let on_exit: crate::gamelease::OnExit = { let conn = conn.clone(); let stop = stop.clone(); let quit = quit.clone(); - std::thread::Builder::new() - .name("pf1-gamewatch".into()) - .spawn(move || { - if crate::vdisplay::watch_steam_game_exit(appid, &stop) { - tracing::info!( - appid, - "dedicated Steam game exited — ending the session cleanly (APP_EXITED)" - ); - // Close FIRST so APP_EXITED is the winning close code (quinn keeps the first - // application close), then set the flags: `quit` skips the display lease's - // keep-alive linger and `stop` wakes the encode/send loops out. - conn.close( - punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(), - b"game exited", - ); - quit.store(true, Ordering::SeqCst); - stop.store(true, Ordering::SeqCst); - } - }) - .ok() - }); + Box::new(move || { + // Read the setting at fire time, so flipping it mid-session takes effect. The lease + // itself keeps running either way — the status surface still reports the game. + if !crate::session_settings::get().session_on_game_exit { + tracing::info!( + "the launched game exited, but ending the session on game exit is off — \ + leaving the stream up" + ); + return; + } + tracing::info!( + "the launched game exited — ending the session cleanly (APP_EXITED)" + ); + // Close FIRST so APP_EXITED is the winning close code (quinn keeps the first + // application close), then set the flags: `quit` skips the display lease's + // keep-alive linger and `stop` wakes the encode/send loops out. + conn.close( + punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(), + b"game exited", + ); + quit.store(true, Ordering::SeqCst); + stop.store(true, Ordering::SeqCst); + }) + }; + crate::gamelease::open( + crate::gamelease::LeaseRequest { + game: target.game.clone(), + client: client_label.clone(), + plane: crate::events::Plane::Native, + spec: target.detect.clone(), + nested, + child, + launch_uptime, + }, + on_exit, + ) + }); + let game_shared = game_lease.as_ref().map(|l| l.shared()); + // Declared here so it drops *after* the live-session registration below (reverse declaration + // order): `session.ended` fires first, then the game policy runs — the order an operator reading + // the log expects. The fingerprint is what lets a reconnecting client reclaim its own game and + // nothing else. + let _game_life = game_lease.map(|lease| GameLifetime { + lease, + quit: quit.clone(), + fingerprint: endpoint::peer_fingerprint(&conn).map(hex::encode), + }); let perf = pf_host_config::config().perf; // Microburst cap (applied in send_loop/paced_submit): a frame ≤ the cap bursts out @@ -1329,17 +1403,19 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option/stat` field 22 — start time in clock ticks since boot. Unique per (pid, boot). + pub start_ticks: u64, +} + +/// Largest `cmdline`/`environ` blob we will read from a single process. Both are bounded by `ARG_MAX` +/// in practice; the cap exists so a pathological process can't make the host allocate without bound +/// during a once-a-second scan. +const MAX_PROC_BLOB: u64 = 512 * 1024; + +/// Tolerance on the "started after the launch" test, in seconds. The launch timestamp is taken just +/// before the spawn, but clock-tick granularity (`starttime` is quantized to ~10 ms) and a launcher +/// that raced ahead of our own bookkeeping mean an exact comparison would occasionally reject the +/// real game. Two seconds is far below the time any launcher takes to bring a game up, so it cannot +/// let a *pre-existing* instance through. +const START_SLACK_SECS: f64 = 2.0; + +/// A `/proc` reader. Parameterized on its root, uid filter, and clock rate so the matching logic can +/// be unit-tested against a fixture tree instead of the live system. +pub struct Scanner { + root: PathBuf, + /// Only consider processes owned by this uid. `None` (tests only) considers all. + uid: Option, + ticks_per_sec: f64, +} + +impl Scanner { + /// The live system: `/proc`, this host process's own uid, the kernel's clock rate. + pub fn system() -> Self { + // SAFETY: a parameterless POSIX call that always succeeds and touches no memory. + let uid = unsafe { libc::getuid() }; + // SAFETY: `sysconf` reads a static system limit by name; no memory of ours is involved, and a + // non-positive answer (which the caller below handles) is its documented failure signal. + let ticks = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }; + Self { + root: PathBuf::from("/proc"), + uid: Some(uid), + // A non-positive sysconf answer would poison every start-time comparison; fall back to + // the universal Linux value rather than divide by nonsense. + ticks_per_sec: if ticks > 0 { ticks as f64 } else { 100.0 }, + } + } + + /// Seconds since boot, on the same timeline as a process's start time. `None` when `/proc/uptime` + /// can't be read (the caller then skips start-time filtering rather than rejecting everything). + pub fn uptime(&self) -> Option { + let text = std::fs::read_to_string(self.root.join("uptime")).ok()?; + text.split_whitespace().next()?.parse().ok() + } + + /// Every process matching any of `spec`'s signals, restricted to those that started at or after + /// `min_start` (seconds since boot; `None` disables the filter). + /// + /// The signals are a union: a Steam appid, an env marker, an exact executable, or an install + /// directory each independently qualify a process. That is deliberate — the store with the + /// sharpest signal wins, but a store that only knows its install directory is still covered. + pub fn find(&self, spec: &DetectSpec, min_start: Option) -> Vec { + if spec.is_empty() { + return Vec::new(); + } + // Resolve symlinks in the install dir once per scan: a game reached through a symlinked + // library folder would otherwise never prefix-match its own canonical image path. + let dir = spec + .install_dir + .as_deref() + .map(|d| d.canonicalize().unwrap_or_else(|_| d.to_path_buf())); + let exe = spec + .exe + .as_deref() + .map(|e| e.canonicalize().unwrap_or_else(|_| e.to_path_buf())); + let steam_tok = spec.steam_appid.map(|id| format!("AppId={id}")); + + let Ok(entries) = std::fs::read_dir(&self.root) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for e in entries.flatten() { + let name = e.file_name(); + let Some(pid) = name.to_str().and_then(|s| s.parse::().ok()) else { + continue; // not a pid dir + }; + let dir_path = e.path(); + if let Some(uid) = self.uid { + let owned = std::fs::metadata(&dir_path) + .map(|m| { + use std::os::unix::fs::MetadataExt; + m.uid() == uid + }) + .unwrap_or(false); + if !owned { + continue; + } + } + let Some(start_ticks) = self.start_ticks(&dir_path) else { + continue; // vanished mid-scan, or an unparseable stat + }; + if let Some(min) = min_start { + let started = start_ticks as f64 / self.ticks_per_sec; + if started + START_SLACK_SECS < min { + continue; // predates this launch — never ours (rule 1) + } + } + if self.matches( + &dir_path, + spec, + steam_tok.as_deref(), + exe.as_deref(), + dir.as_deref(), + ) { + out.push(ProcRef { pid, start_ticks }); + } + } + out + } + + /// Which of `procs` are still the same live processes — pid present **and** start time unchanged, + /// so a recycled pid is never reported alive (rule 2). + pub fn alive(&self, procs: &[ProcRef]) -> Vec { + procs + .iter() + .copied() + .filter(|p| self.start_ticks(&self.root.join(p.pid.to_string())) == Some(p.start_ticks)) + .collect() + } + + /// Does this process match any of the spec's signals? + fn matches( + &self, + dir_path: &Path, + spec: &DetectSpec, + steam_tok: Option<&str>, + exe: Option<&Path>, + install_dir: Option<&Path>, + ) -> bool { + // The process's own image, resolved through /proc//exe. Absent for a kernel thread or a + // process whose binary was replaced. + let image = std::fs::read_link(dir_path.join("exe")).ok(); + if let Some(want) = exe { + if image.as_deref() == Some(want) { + return true; + } + } + if let Some(dir) = install_dir { + if image.as_deref().is_some_and(|i| i.starts_with(dir)) { + return true; + } + } + + // The command line covers what the image can't: a Proton/Wine title's image is the *runtime* + // (`…/proton`, `wine64-preloader`), and the game only appears as an argument; Steam's launch + // reaper is likewise identified by its argv, not its binary. + let cmdline = read_capped(&dir_path.join("cmdline")); + if let Some(cmdline) = cmdline.as_deref() { + if let Some(tok) = steam_tok { + // Both tokens together, exact-matched, so `AppId=57` never satisfies appid 570 and + // Steam's own (non-reaper) helper steps aren't mistaken for the game. + let mut launch = false; + let mut appid = false; + for arg in cmdline.split(|&b| b == 0) { + if arg == b"SteamLaunch" { + launch = true; + } else if arg == tok.as_bytes() { + appid = true; + } + } + if launch && appid { + return true; + } + } + if let Some(dir) = install_dir { + let needle = dir.as_os_str().as_encoded_bytes(); + for arg in cmdline.split(|&b| b == 0) { + if arg.starts_with(needle) { + return true; + } + } + } + if let Some(want) = exe { + let needle = want.as_os_str().as_encoded_bytes(); + if cmdline.split(|&b| b == 0).any(|arg| arg == needle) { + return true; + } + } + } + + // Env markers last: reading another process's environment is the most invasive of these + // reads, so it only happens for a spec that actually asked for one. The contents are matched + // and discarded — never logged. + if let Some(marker) = &spec.env_marker { + if let Some(env) = read_capped(&dir_path.join("environ")) { + let want: Vec = match &marker.value { + Some(v) => format!("{}={v}", marker.key).into_bytes(), + None => format!("{}=", marker.key).into_bytes(), + }; + let hit = env.split(|&b| b == 0).any(|kv| match marker.value { + // An exact `KEY=VALUE` entry. + Some(_) => kv == want.as_slice(), + // Presence of the key with any value. + None => kv.starts_with(want.as_slice()), + }); + if hit { + return true; + } + } + } + false + } + + /// `/proc//stat` field 22 (`starttime`). The `comm` field is parenthesized and may itself + /// contain spaces and parentheses, so fields are counted from the **last** `)`: the token right + /// after it is field 3 (`state`), which puts `starttime` at index 19 of the remainder. + fn start_ticks(&self, dir_path: &Path) -> Option { + let stat = std::fs::read_to_string(dir_path.join("stat")).ok()?; + let tail = &stat[stat.rfind(')')? + 1..]; + tail.split_whitespace().nth(19)?.parse().ok() + } +} + +/// Read a `/proc` blob with a hard size cap (see [`MAX_PROC_BLOB`]). `None` when the process vanished +/// or the file is unreadable — both routine during a scan. +fn read_capped(path: &Path) -> Option> { + use std::io::Read; + let file = std::fs::File::open(path).ok()?; + let mut buf = Vec::new(); + file.take(MAX_PROC_BLOB).read_to_end(&mut buf).ok()?; + Some(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::library::DetectSpec; + + /// Build a fake `/proc` tree. Each process gets a `stat` (with a deliberately nasty `comm`), and + /// optionally a `cmdline`, an `environ`, and an `exe` symlink. + struct FakeProc { + pid: u32, + start_ticks: u64, + exe: Option, + cmdline: Vec<&'static str>, + environ: Vec<&'static str>, + } + + impl FakeProc { + fn new(pid: u32, start_ticks: u64) -> Self { + Self { + pid, + start_ticks, + exe: None, + cmdline: Vec::new(), + environ: Vec::new(), + } + } + fn exe(mut self, p: impl Into) -> Self { + self.exe = Some(p.into()); + self + } + fn cmdline(mut self, args: &[&'static str]) -> Self { + self.cmdline = args.to_vec(); + self + } + fn environ(mut self, kvs: &[&'static str]) -> Self { + self.environ = kvs.to_vec(); + self + } + } + + fn fake_proc_root(uptime: f64, procs: &[FakeProc]) -> tempfile::TempDir { + let td = tempfile::tempdir().expect("tempdir"); + std::fs::write(td.path().join("uptime"), format!("{uptime} 1000.0\n")).unwrap(); + // Non-pid entries must be skipped, not parsed. + std::fs::create_dir_all(td.path().join("self")).unwrap(); + std::fs::write(td.path().join("cmdline"), b"").unwrap(); + for p in procs { + let dir = td.path().join(p.pid.to_string()); + std::fs::create_dir_all(&dir).unwrap(); + // `stat` is `pid (comm) state ppid … starttime …`, with starttime as field 22. Counting + // from the last ')', index 0 is `state` (field 3), so starttime lands at index 19. The + // comm is hostile on purpose — a space and a close-paren inside the parens, which is + // exactly what naive field-splitting gets wrong. + let mut tail = vec!["0".to_string(); 20]; + tail[0] = "S".to_string(); // field 3 + tail[19] = p.start_ticks.to_string(); // field 22 + std::fs::write( + dir.join("stat"), + format!("{} (evil ) name) {}\n", p.pid, tail.join(" ")), + ) + .unwrap(); + if !p.cmdline.is_empty() { + let mut blob = Vec::new(); + for a in &p.cmdline { + blob.extend_from_slice(a.as_bytes()); + blob.push(0); + } + std::fs::write(dir.join("cmdline"), blob).unwrap(); + } + if !p.environ.is_empty() { + let mut blob = Vec::new(); + for a in &p.environ { + blob.extend_from_slice(a.as_bytes()); + blob.push(0); + } + std::fs::write(dir.join("environ"), blob).unwrap(); + } + if let Some(exe) = &p.exe { + std::os::unix::fs::symlink(exe, dir.join("exe")).unwrap(); + } + } + td + } + + fn scanner(root: &Path) -> Scanner { + Scanner { + root: root.to_path_buf(), + uid: None, // the fixture's owner is the test user; don't couple the test to it + ticks_per_sec: 100.0, + } + } + + fn pids(mut v: Vec) -> Vec { + v.sort_by_key(|p| p.pid); + v.into_iter().map(|p| p.pid).collect() + } + + #[test] + fn empty_spec_matches_nothing() { + let td = fake_proc_root(100.0, &[FakeProc::new(1, 100).exe("/games/x/run")]); + let s = scanner(td.path()); + assert!(s.find(&DetectSpec::default(), None).is_empty()); + } + + #[test] + fn matches_exact_exe_and_install_dir() { + let td = fake_proc_root( + 1000.0, + &[ + FakeProc::new(10, 50_000).exe("/games/hades/Hades"), + FakeProc::new(11, 50_000).exe("/games/hades/tools/crash.bin"), + FakeProc::new(12, 50_000).exe("/usr/bin/firefox"), + ], + ); + let s = scanner(td.path()); + // Exact exe → only that process. + assert_eq!( + pids(s.find(&DetectSpec::exe("/games/hades/Hades"), None)), + vec![10] + ); + // Install dir → every process running out of the game's tree, but nothing outside it. + assert_eq!( + pids(s.find(&DetectSpec::dir("/games/hades"), None)), + vec![10, 11] + ); + } + + #[test] + fn matches_proton_style_game_only_in_the_cmdline() { + // The image is the Proton runtime; the game appears only as an argument. + let td = fake_proc_root( + 1000.0, + &[FakeProc::new(20, 50_000) + .exe("/steam/runtime/proton") + .cmdline(&["proton", "waitforexitandrun", "/games/elden/eldenring.exe"])], + ); + let s = scanner(td.path()); + assert_eq!( + pids(s.find(&DetectSpec::dir("/games/elden"), None)), + vec![20] + ); + } + + #[test] + fn matches_steam_reaper_exactly() { + let td = fake_proc_root( + 1000.0, + &[ + FakeProc::new(30, 50_000).cmdline(&[ + "reaper", + "SteamLaunch", + "AppId=570", + "--", + "dota", + ]), + // A different appid that shares a prefix must NOT match (AppId=57 vs 570). + FakeProc::new(31, 50_000).cmdline(&[ + "reaper", + "SteamLaunch", + "AppId=57", + "--", + "other", + ]), + // `SteamLaunch` without the appid, and the appid without `SteamLaunch`, are both no. + FakeProc::new(32, 50_000).cmdline(&["reaper", "SteamLaunch", "--", "shader"]), + FakeProc::new(33, 50_000).cmdline(&["something", "AppId=570"]), + ], + ); + let s = scanner(td.path()); + assert_eq!(pids(s.find(&DetectSpec::steam(570), None)), vec![30]); + assert_eq!(pids(s.find(&DetectSpec::steam(57), None)), vec![31]); + } + + #[test] + fn matches_env_marker_by_exact_value_or_presence() { + let td = fake_proc_root( + 1000.0, + &[ + FakeProc::new(40, 50_000).environ(&["HOME=/home/u", "HEROIC_APP_NAME=Quail"]), + FakeProc::new(41, 50_000).environ(&["HEROIC_APP_NAME=OtherGame"]), + FakeProc::new(42, 50_000).environ(&["HOME=/home/u"]), + ], + ); + let s = scanner(td.path()); + let exact = DetectSpec::default().with_env("HEROIC_APP_NAME", Some("Quail".to_string())); + assert_eq!(pids(s.find(&exact, None)), vec![40]); + // Presence-only matches any value, but still nothing that lacks the key. + let any = DetectSpec::default().with_env("HEROIC_APP_NAME", None); + assert_eq!(pids(s.find(&any, None)), vec![40, 41]); + } + + #[test] + fn never_adopts_a_process_that_predates_the_launch() { + // Uptime 1000 s; the launch happened at t=900. pid 50 started at t=500 (a copy the player + // already had open), pid 51 at t=950 (ours). + let td = fake_proc_root( + 1000.0, + &[ + FakeProc::new(50, 50_000).exe("/games/hades/Hades"), + FakeProc::new(51, 95_000).exe("/games/hades/Hades"), + ], + ); + let s = scanner(td.path()); + let spec = DetectSpec::dir("/games/hades"); + assert_eq!(pids(s.find(&spec, Some(900.0))), vec![51]); + // Without the filter both are visible — proving the filter is what excluded it. + assert_eq!(pids(s.find(&spec, None)), vec![50, 51]); + } + + #[test] + fn start_slack_tolerates_tick_granularity() { + // Started a hair (0.5 s) BEFORE the recorded launch instant: still ours. + let td = fake_proc_root(1000.0, &[FakeProc::new(60, 89_950).exe("/games/x/run")]); + let s = scanner(td.path()); + assert_eq!( + pids(s.find(&DetectSpec::dir("/games/x"), Some(900.0))), + vec![60] + ); + // A full minute early is not. + assert!(s.find(&DetectSpec::dir("/games/x"), Some(960.0)).is_empty()); + } + + #[test] + fn alive_rejects_a_recycled_pid() { + let td = fake_proc_root(1000.0, &[FakeProc::new(70, 50_000).exe("/games/x/run")]); + let s = scanner(td.path()); + let same = ProcRef { + pid: 70, + start_ticks: 50_000, + }; + let recycled = ProcRef { + pid: 70, + start_ticks: 99_999, + }; + let gone = ProcRef { + pid: 71, + start_ticks: 50_000, + }; + assert_eq!(s.alive(&[same]), vec![same]); + assert!(s.alive(&[recycled]).is_empty()); + assert!(s.alive(&[gone]).is_empty()); + } + + #[test] + fn parses_uptime_and_hostile_comm() { + let td = fake_proc_root(4321.5, &[FakeProc::new(80, 1234)]); + let s = scanner(td.path()); + assert_eq!(s.uptime(), Some(4321.5)); + // The comm field contains a space and a ')' — start time must still parse. + assert_eq!(s.start_ticks(&td.path().join("80")), Some(1234)); + } +} diff --git a/crates/punktfunk-host/src/session_settings.rs b/crates/punktfunk-host/src/session_settings.rs new file mode 100644 index 00000000..71c27285 --- /dev/null +++ b/crates/punktfunk-host/src/session_settings.rs @@ -0,0 +1,294 @@ +//! Session⇄game lifetime settings (`/session-settings.json`). +//! +//! Two operator choices, both about what a session and its game owe each other +//! (design/session-game-lifetime.md §8): +//! +//! * [`SessionSettings::session_on_game_exit`] — end the streaming session when the launched game +//! exits, so the client returns to its library instead of a bare desktop. **On by default**: it is +//! what a player expects, and it is already the shipped behavior for dedicated game sessions. +//! * [`SessionSettings::game_on_session_end`] — end the launched game when the session ends. +//! **Off by default** ([`GameOnSessionEnd::Keep`]), because ending a game can cost unsaved +//! progress. `Always` additionally waits out [`SessionSettings::disconnect_grace_seconds`] so a +//! network blip never costs a save. +//! +//! Deliberately its own small store rather than more axes on the display policy: keep-alive is about +//! how long a *display* survives a disconnect (default 10 s), while this is about a *game* and the +//! player's unsaved progress (default 5 minutes). Sharing one number would force one of them wrong. +//! +//! Storage follows the same shape as `DisplayPolicyStore` / `pf_gpu::GpuPrefStore`: a missing **or** +//! corrupt file means "unconfigured" with a warning (a settings file must never fail host startup), +//! writes are private-dir + temp + atomic rename, and the in-memory value changes only after the +//! write lands. + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use utoipa::ToSchema; + +/// What to do with the launched game when its session ends. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum GameOnSessionEnd { + /// Leave it running (the historical behavior, and the default — nothing is ever killed unless + /// the operator asks for it). + #[default] + Keep, + /// End it when the client stops the session deliberately, but leave it running if the client + /// merely vanished — so a dropped connection stays reconnectable. + OnQuit, + /// End it whenever the session ends. A deliberate stop ends it at once; a drop starts the + /// reconnect window and ends it only if nobody comes back. + Always, +} + +impl GameOnSessionEnd { + pub fn as_str(self) -> &'static str { + match self { + Self::Keep => "keep", + Self::OnQuit => "on_quit", + Self::Always => "always", + } + } +} + +/// The default reconnect window before `Always` ends a game — long enough to cover a Wi-Fi +/// roam, a client crash-and-restart, or a walk to another room, because the cost of being wrong is +/// the player's unsaved progress. +const DEFAULT_GRACE_SECS: u32 = 300; +/// Clamp bounds for the window. The floor keeps a mis-typed `1` from making `Always` behave like an +/// instant kill; the ceiling (24 h) keeps a stray large value from pinning a lease forever. +const MIN_GRACE_SECS: u32 = 10; +const MAX_GRACE_SECS: u32 = 86_400; + +fn default_grace() -> u32 { + DEFAULT_GRACE_SECS +} + +fn default_true() -> bool { + true +} + +fn one() -> u32 { + 1 +} + +/// The persisted settings. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct SessionSettings { + #[serde(default = "one")] + pub version: u32, + /// End the launched game when the session ends. See [`GameOnSessionEnd`]. + #[serde(default)] + pub game_on_session_end: GameOnSessionEnd, + /// End the streaming session when the launched game exits. + #[serde(default = "default_true")] + pub session_on_game_exit: bool, + /// How long a vanished client has to reconnect before `Always` ends its game. Ignored by the + /// other two policies. + #[serde(default = "default_grace")] + pub disconnect_grace_seconds: u32, +} + +impl Default for SessionSettings { + fn default() -> Self { + Self { + version: 1, + game_on_session_end: GameOnSessionEnd::default(), + session_on_game_exit: true, + disconnect_grace_seconds: DEFAULT_GRACE_SECS, + } + } +} + +impl SessionSettings { + /// Clamp anything a hand-edited file (or a plugin) could get wrong. + pub fn sanitized(mut self) -> Self { + self.version = 1; + self.disconnect_grace_seconds = self + .disconnect_grace_seconds + .clamp(MIN_GRACE_SECS, MAX_GRACE_SECS); + self + } +} + +/// The store: the loaded file value (`None` when no file exists) behind its path. +pub struct SessionSettingsStore { + path: PathBuf, + cur: Mutex>, +} + +impl SessionSettingsStore { + /// Load from `path`. Missing ⇒ unconfigured; corrupt ⇒ unconfigured **with a warning**, never a + /// startup failure. + pub fn load_from(path: PathBuf) -> Self { + let cur = match std::fs::read(&path) { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(s) => Some(s.sanitized()), + Err(e) => { + tracing::warn!(path = %path.display(), + "session-settings.json unreadable — using built-in defaults: {e}"); + None + } + }, + Err(_) => None, + }; + Self { + path, + cur: Mutex::new(cur), + } + } + + /// The stored settings, or the defaults when unconfigured. + pub fn get(&self) -> SessionSettings { + self.cur + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone() + .unwrap_or_default() + } + + /// Whether an operator has ever configured this host (drives the console's "using defaults" hint). + pub fn configured(&self) -> bool { + self.cur.lock().unwrap_or_else(|e| e.into_inner()).is_some() + } + + /// Persist + adopt (sanitized first). Memory changes only if the write lands, so a full disk + /// can't leave the running host disagreeing with its own file. + pub fn set(&self, settings: SessionSettings) -> Result<()> { + let settings = settings.sanitized(); + if let Some(dir) = self.path.parent() { + pf_paths::create_private_dir(dir)?; + } + let tmp = self.path.with_extension("json.tmp"); + pf_paths::write_secret_file(&tmp, &serde_json::to_vec_pretty(&settings)?)?; + std::fs::rename(&tmp, &self.path)?; + *self.cur.lock().unwrap_or_else(|e| e.into_inner()) = Some(settings); + Ok(()) + } +} + +/// The process-wide store, loaded once on first access — the same global-accessor shape as +/// `vdisplay::prefs()` / `pf_gpu::prefs()`, because the lifetime decisions happen deep in the session +/// teardown path where no app state is threaded. +pub fn store() -> &'static SessionSettingsStore { + static STORE: OnceLock = OnceLock::new(); + STORE.get_or_init(|| { + SessionSettingsStore::load_from(pf_paths::config_dir().join("session-settings.json")) + }) +} + +/// The effective settings (shorthand for `store().get()`). +pub fn get() -> SessionSettings { + store().get() +} + +/// Which lifetime axes this build actually acts on, for the console to grey out the rest. Both +/// directions need a launch path and a way to see processes; macOS has neither today. +pub fn enforced() -> Vec { + #[cfg(any(target_os = "linux", target_os = "windows"))] + { + vec![ + "session_on_game_exit".to_string(), + "game_on_session_end".to_string(), + "disconnect_grace_seconds".to_string(), + ] + } + #[cfg(not(any(target_os = "linux", target_os = "windows")))] + { + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_the_documented_ones() { + let d = SessionSettings::default(); + // End-session-on-game-exit ships ON; ending games ships OFF. + assert!(d.session_on_game_exit); + assert_eq!(d.game_on_session_end, GameOnSessionEnd::Keep); + assert_eq!(d.disconnect_grace_seconds, 300); + } + + #[test] + fn an_empty_object_decodes_to_the_defaults() { + // Every field is `#[serde(default)]`, so a file written by an older/partial writer keeps + // working instead of failing the load. + let s: SessionSettings = serde_json::from_str("{}").expect("empty object"); + assert!(s.session_on_game_exit); + assert_eq!(s.game_on_session_end, GameOnSessionEnd::Keep); + assert_eq!(s.disconnect_grace_seconds, 300); + assert_eq!(s.version, 1); + } + + #[test] + fn policy_names_are_snake_case_on_the_wire() { + // These strings are the API contract with the console. + let json = serde_json::to_string(&GameOnSessionEnd::OnQuit).unwrap(); + assert_eq!(json, "\"on_quit\""); + let back: GameOnSessionEnd = serde_json::from_str("\"always\"").unwrap(); + assert_eq!(back, GameOnSessionEnd::Always); + assert_eq!(GameOnSessionEnd::Keep.as_str(), "keep"); + } + + #[test] + fn grace_is_clamped_both_ways() { + let low = SessionSettings { + disconnect_grace_seconds: 0, + ..Default::default() + } + .sanitized(); + assert_eq!(low.disconnect_grace_seconds, MIN_GRACE_SECS); + let high = SessionSettings { + disconnect_grace_seconds: u32::MAX, + ..Default::default() + } + .sanitized(); + assert_eq!(high.disconnect_grace_seconds, MAX_GRACE_SECS); + } + + #[test] + fn missing_file_is_unconfigured_and_corrupt_file_does_not_fail() { + let td = tempfile::tempdir().unwrap(); + let path = td.path().join("session-settings.json"); + let store = SessionSettingsStore::load_from(path.clone()); + assert!(!store.configured()); + assert_eq!(store.get().game_on_session_end, GameOnSessionEnd::Keep); + + std::fs::write(&path, b"{ not json").unwrap(); + let store = SessionSettingsStore::load_from(path.clone()); + assert!( + !store.configured(), + "corrupt file must read as unconfigured" + ); + assert!(store.get().session_on_game_exit, "defaults still apply"); + } + + #[test] + fn set_persists_sanitized_and_round_trips() { + let td = tempfile::tempdir().unwrap(); + let path = td.path().join("session-settings.json"); + let store = SessionSettingsStore::load_from(path.clone()); + store + .set(SessionSettings { + version: 99, + game_on_session_end: GameOnSessionEnd::Always, + session_on_game_exit: false, + disconnect_grace_seconds: 5, // below the floor + }) + .expect("write"); + assert!(store.configured()); + let got = store.get(); + assert_eq!(got.game_on_session_end, GameOnSessionEnd::Always); + assert!(!got.session_on_game_exit); + assert_eq!(got.disconnect_grace_seconds, MIN_GRACE_SECS); + assert_eq!(got.version, 1, "version is normalized, not echoed"); + // A fresh load sees the same thing, and no temp file is left behind. + let reloaded = SessionSettingsStore::load_from(path.clone()); + assert_eq!(reloaded.get().game_on_session_end, GameOnSessionEnd::Always); + assert!(!path.with_extension("json.tmp").exists()); + } +} diff --git a/crates/punktfunk-host/src/session_status.rs b/crates/punktfunk-host/src/session_status.rs index 2d882563..2a53b485 100644 --- a/crates/punktfunk-host/src/session_status.rs +++ b/crates/punktfunk-host/src/session_status.rs @@ -33,6 +33,10 @@ struct LiveSession { codec: Codec, /// The session's teardown flag ([`stop_all`] → mgmt `DELETE /session`). stop: Arc, + /// The session's *deliberate*-stop flag ([`stop_all_quit`]). Distinct from `stop`: it marks the + /// teardown as intended rather than a drop, which is what skips the display's keep-alive linger + /// and what the end-game-on-session-end policy keys off. + quit: Arc, /// One-shot force-keyframe flag ([`force_idr_all`] → mgmt `POST /session/idr`); the encode loop /// drains it alongside a client's decode-recovery keyframe request. force_idr: Arc, @@ -45,6 +49,9 @@ struct LiveSession { ttff_ms: Arc, /// Most recent completed mid-stream resize (reconfigure → pipeline rebuilt), ms; 0 = none yet. last_resize_ms: Arc, + /// The launched game's lease, when this session launched a title — so `/status` can report what + /// is actually running and the console can show it (design/session-game-lifetime.md §8a). + game: Option>, } /// A resolved read of one live session, for the `/status` view. @@ -82,21 +89,49 @@ fn session_ref(s: &LiveSession) -> crate::events::SessionRef { } } +/// What [`register`] needs to publish a session. A named struct rather than a positional argument +/// list: half of these are same-typed `Arc` handles, so a transposed pair would compile +/// cleanly and quietly report the wrong number on the Dashboard. +pub struct Registration { + /// Packed `w:16|h:16|hz:16` ([`crate::native::pack_mode`]); updated on a mode switch. + pub mode: Arc, + /// Live encoder target (kbps); updated on an adaptive-bitrate change. + pub bitrate_kbps: Arc, + pub codec: Codec, + /// Teardown flag ([`stop_all`]). + pub stop: Arc, + /// Deliberate-stop flag ([`stop_all_quit`]). + pub quit: Arc, + /// One-shot force-keyframe flag ([`force_idr_all`]). + pub force_idr: Arc, + /// Short client label (cert-fingerprint prefix / peer IP). + pub client: String, + pub hdr: bool, + /// Bring-up total slot (hello → first packet), ms. + pub ttff_ms: Arc, + /// Most recent mid-stream resize total, ms. + pub last_resize_ms: Arc, + /// The launched game's lease, when this session launched a title. + pub game: Option>, +} + /// Registers a live native session; the returned guard removes it on drop (session end). /// Emits the `session.started` lifecycle event; the guard's drop emits `session.ended` — RAII, /// so every exit path (return, `?`, panic-unwind) pairs them. -#[allow(clippy::too_many_arguments)] -pub fn register( - mode: Arc, - bitrate_kbps: Arc, - codec: Codec, - stop: Arc, - force_idr: Arc, - client: String, - hdr: bool, - ttff_ms: Arc, - last_resize_ms: Arc, -) -> LiveSessionGuard { +pub fn register(reg: Registration) -> LiveSessionGuard { + let Registration { + mode, + bitrate_kbps, + codec, + stop, + quit, + force_idr, + client, + hdr, + ttff_ms, + last_resize_ms, + game, + } = reg; let id = next_id(); let session = LiveSession { id, @@ -104,11 +139,13 @@ pub fn register( bitrate_kbps, codec, stop, + quit, force_idr, client, hdr, ttff_ms, last_resize_ms, + game, }; crate::events::emit(crate::events::EventKind::SessionStarted { session: session_ref(&session), @@ -167,14 +204,89 @@ pub fn snapshot() -> Vec { .collect() } -/// Signals every live native session to tear down (mgmt `DELETE /session`). Best-effort: the video -/// + send loops observe the flag and exit, ending the stream; the guard then clears the entry. +/// One launched game, as `/status` reports it. +pub struct GameSnapshot { + /// The session streaming it, or `None` for a game whose session is gone and which is waiting out + /// its reconnect window. + pub session_id: Option, + pub client: String, + pub app_id: Option, + pub title: String, + pub store: Option, + pub plane: crate::events::Plane, + /// `launching` / `running` / `exited`, or `grace` for a game on its reconnect window. + pub state: &'static str, + /// Seconds left before the game is ended, for a `grace` row. + pub grace_remaining_s: Option, +} + +/// Every launched game the host currently knows about: the live sessions' games first, then any game +/// whose session has ended and which is waiting out its reconnect window before being ended. +/// +/// The two sources are deliberately separate — a grace-pending game has no session to attribute it +/// to, and hiding it would make "the host is about to close my game" invisible in the UI. +pub fn games() -> Vec { + let mut out: Vec = registry() + .lock() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .filter_map(|s| { + let g = s.game.as_ref()?; + Some(GameSnapshot { + session_id: Some(s.id), + client: g.client.clone(), + app_id: g.game.id.clone(), + title: g.game.title.clone(), + store: g.game.store.clone(), + plane: g.plane, + state: g.state().as_str(), + grace_remaining_s: None, + }) + }) + .collect(); + out.extend( + crate::gamelease::pending_snapshot() + .into_iter() + .map(|(g, remaining)| GameSnapshot { + session_id: None, + client: g.client.clone(), + app_id: g.game.id.clone(), + title: g.game.title.clone(), + store: g.game.store.clone(), + plane: g.plane, + state: "grace", + grace_remaining_s: Some(remaining), + }), + ); + out +} + +/// Signals every live native session to tear down. Best-effort: the video + send loops observe the +/// flag and exit, ending the stream; the guard then clears the entry. +/// +/// This is the *drop*-flavored stop — the session ends, but nothing treats it as intended. Prefer +/// [`stop_all_quit`] for an operator action. pub fn stop_all() { for s in registry().lock().unwrap().iter() { s.stop.store(true, Ordering::SeqCst); } } +/// Signals every live native session to tear down **deliberately** (mgmt `DELETE /session`, the +/// console's and a plugin's Stop). +/// +/// An operator pressing Stop is a decision, and the host now says so: `quit` before `stop` makes the +/// teardown look exactly like a client's own Stop, so the display skips its keep-alive linger and the +/// end-game-on-session-end policy sees an intent rather than a network drop. (Before this, a +/// management stop was indistinguishable from a client vanishing, which left the display lingering +/// for a session nobody was coming back to.) +pub fn stop_all_quit() { + for s in registry().lock().unwrap().iter() { + s.quit.store(true, Ordering::SeqCst); + s.stop.store(true, Ordering::SeqCst); + } +} + /// Requests a forced keyframe on every live native session (mgmt `POST /session/idr`). The encode /// loop drains the flag exactly like a client decode-recovery request. pub fn force_idr_all() {