feat(session): bind a session's life to its game's, in both directions (Linux)
Two of the most-asked-for behaviors, and until now the host could only manage a
sliver of one of them: end the streaming session when the launched game exits,
and — when the operator asks — end the game when the session ends.
Ending the session on game exit already worked, but only for a Steam title under
a bare-spawn gamescope. Everywhere else the host spawned a launch and forgot it:
the pid was logged and dropped, so on KWin, Mutter, wlroots or gamescope-attach a
finished game left the client staring at a desktop. Ending a game was not
possible at all — there was no primitive for it.
The missing piece was never plumbing, it was knowledge: a launch says what to
run, not what the game looks like once a launcher has handed off. So each store
now also reports how to recognize its game (DetectSpec, previous commit's
subject matter, folded in here), procscan turns that into live pids on Linux, and
gamelease turns pids into a lifetime — with four kinds covering how the game got
started:
nested gamescope owns it; its display teardown ends the game
child the host spawned it, in its own process group
matched a launcher owns it; recognized by its store's signals
untracked nothing identifies it — both behaviors stay off, and the host
says so once rather than guessing
A child that exits successfully within 5s was a launcher handing off, not the
game: the lease re-resolves to matched (or to untracked) instead of reporting an
exit. That single rule is what keeps steam://, epic:// and playnite:// launches
from ending a session the moment they start.
Ending a game is destructive, so three rules bound it. It is opt-in
(game_on_session_end defaults to keep). A drop is not a decision — `always`
waits out a reconnect window (5 min) that a returning client cancels by
reclaiming its own game, matched on fingerprint and title. And only ever this
session's game: a pid is adopted only if it started after the launch, so a copy
the player already had open is never touched, and every pid is re-verified
against its start time immediately before being signalled, so a recycled pid
never is. Termination asks first (SIGTERM to the group, 10s) and only then
insists.
Also here, because they are the same decision seen from other angles:
- A management stop is now a deliberate stop. `DELETE /session` sets quit before
stop, so it behaves like a client pressing Stop — the display skips its
keep-alive linger instead of lingering for a session nobody is coming back to.
- `/status` reports what is running (games[]), including a game whose session has
gone and which is waiting out its window, so the console can show it and offer
`POST /game/end`.
- New game.running / game.exited events, filterable like every other kind, which
is the whole polling loop of the community plugin this replaces.
- session_status::register takes a named struct: eleven positional arguments,
half of them same-typed Arc<Atomic…>, is a transposition waiting to happen.
Gates on Linux (.21): check, clippy --all-targets, fmt, and 291 tests green.
Windows (Job Objects, Toolhelp matching) and the GameStream plane are phases 2
and 3; both are inert here, as is macOS, which has no launch path at all.
Design + plan: punktfunk-planning/design/session-game-lifetime{,-implementation-plan}.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+388
-2
@@ -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\": \"<domain>.<verb>\"`,\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`.",
|
||||
|
||||
@@ -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<String>,
|
||||
/// Display title.
|
||||
pub title: String,
|
||||
/// Which store surfaced it (`steam`, `heroic`, `custom`, …), when known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub store: Option<String>,
|
||||
/// 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]
|
||||
|
||||
@@ -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<String>,
|
||||
/// Which store surfaced it (`steam`, `heroic`, `custom`, …), when known.
|
||||
pub store: Option<String>,
|
||||
/// Display title. Falls back to the id, then to the command, so this is never empty in the UI.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
/// How this lease tracks its game.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum LeaseKind {
|
||||
/// A bare-spawn gamescope owns the game as its own nested child: gamescope exits when the game
|
||||
/// does, so its PipeWire node dying *is* the exit signal, and releasing the display ends the
|
||||
/// game. Detection and termination both stay with the display layer.
|
||||
Nested,
|
||||
/// The host spawned the command itself and holds the child. Its process group is the game (until
|
||||
/// it proves to be a shim, at which point the lease re-resolves to [`LeaseKind::Matched`]).
|
||||
Child,
|
||||
/// A launcher owns the game; it is recognized by its [`DetectSpec`].
|
||||
Matched,
|
||||
/// Nothing identifies this title's process — no detect signals and no child we own. Both
|
||||
/// lifetime behaviors stay inert for it, and the host says so once in the log rather than
|
||||
/// guessing.
|
||||
Untracked,
|
||||
}
|
||||
|
||||
impl LeaseKind {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Nested => "nested",
|
||||
Self::Child => "child",
|
||||
Self::Matched => "matched",
|
||||
Self::Untracked => "untracked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a lease is in its life.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum GameState {
|
||||
/// Launched; the game has not been seen running yet.
|
||||
Launching = 0,
|
||||
/// Seen running.
|
||||
Running = 1,
|
||||
/// Confirmed gone.
|
||||
Exited = 2,
|
||||
}
|
||||
|
||||
impl GameState {
|
||||
fn from_u8(v: u8) -> Self {
|
||||
match v {
|
||||
1 => Self::Running,
|
||||
2 => Self::Exited,
|
||||
_ => Self::Launching,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Launching => "launching",
|
||||
Self::Running => "running",
|
||||
Self::Exited => "exited",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of a lease that outlives its session: identity, live state, and enough to end the game.
|
||||
///
|
||||
/// Held behind an `Arc` by the session, the watcher thread, and — after a disconnect that armed a
|
||||
/// grace window — the [`grace`] registry.
|
||||
pub struct LeaseShared {
|
||||
/// The title this lease is for.
|
||||
pub game: GameRef,
|
||||
/// The client that asked for it, for the same reason.
|
||||
pub client: String,
|
||||
/// Which plane the session was on.
|
||||
pub plane: crate::events::Plane,
|
||||
kind: LeaseKind,
|
||||
state: AtomicU8,
|
||||
/// Signals the watcher thread to stop (session ended, or the lease was terminated).
|
||||
cancel: Arc<AtomicBool>,
|
||||
/// How to recognize the game. Empty for [`LeaseKind::Nested`]/[`LeaseKind::Untracked`].
|
||||
spec: DetectSpec,
|
||||
/// Seconds-since-boot at launch: the floor for adopting a process (`None` = the uptime clock was
|
||||
/// unreadable, so no start-time filtering is possible and only signals are used).
|
||||
launch_uptime: Option<f64>,
|
||||
/// The child the host spawned for this launch, when it spawned one ([`LeaseKind::Child`]).
|
||||
/// Cleared once that child is reaped.
|
||||
child: Mutex<Option<OwnedChild>>,
|
||||
/// Whether this lease's game has been asked to end (so a second request is a no-op, and the
|
||||
/// watcher's exit doesn't look like the player quitting).
|
||||
terminating: AtomicBool,
|
||||
/// Monotonic-ish clock for the status surface: unix ms when the lease was created.
|
||||
pub created_ms: u64,
|
||||
/// Set when the game was seen running at least once — distinguishes "exited" from "never
|
||||
/// started" for logging and for the exit decision.
|
||||
was_running: AtomicBool,
|
||||
/// Wall-clock ms the game was last seen running (0 = never), for diagnostics.
|
||||
last_seen_ms: AtomicU64,
|
||||
}
|
||||
|
||||
impl LeaseShared {
|
||||
pub fn state(&self) -> GameState {
|
||||
GameState::from_u8(self.state.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> &LeaseKind {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
/// True once the game has been asked to end (by policy, grace expiry, or the API).
|
||||
pub fn is_terminating(&self) -> bool {
|
||||
self.terminating.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Can this lease's game be ended at all? `false` for a title the host can't identify.
|
||||
pub fn is_trackable(&self) -> bool {
|
||||
!matches!(self.kind, LeaseKind::Untracked)
|
||||
}
|
||||
|
||||
fn set_state(&self, s: GameState) {
|
||||
self.state.store(s as u8, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// The host's own child for this launch, while it is still running.
|
||||
fn owned_child(&self) -> Option<OwnedChild> {
|
||||
*self.child.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Drop the record of the child once it has been reaped — its pid must never be signalled after
|
||||
/// that, since the kernel is free to hand that number to something else.
|
||||
fn forget_child(&self) {
|
||||
*self.child.lock().unwrap_or_else(|e| e.into_inner()) = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Unix ms now — the same clock the event bus and status API use.
|
||||
fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A session's handle on its game. Dropping it stops the watcher; what happens to the *game* is the
|
||||
/// caller's decision, taken through [`crate::session_settings`] at teardown (see
|
||||
/// [`GameLease::on_session_end`]).
|
||||
pub struct GameLease {
|
||||
shared: Arc<LeaseShared>,
|
||||
/// Joined on drop only to stop the thread promptly; the watcher self-terminates on `cancel`.
|
||||
watcher: Option<std::thread::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl GameLease {
|
||||
/// The shared state, for the status surface and the grace registry.
|
||||
pub fn shared(&self) -> Arc<LeaseShared> {
|
||||
self.shared.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GameLease {
|
||||
fn drop(&mut self) {
|
||||
self.shared.cancel.store(true, Ordering::SeqCst);
|
||||
// The watcher wakes at most `POLL` later; don't block teardown waiting for it.
|
||||
drop(self.watcher.take());
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to open a lease. Built by the launch site, which is the only place that knows
|
||||
/// all of it.
|
||||
pub struct LeaseRequest {
|
||||
pub game: GameRef,
|
||||
pub client: String,
|
||||
pub plane: crate::events::Plane,
|
||||
pub spec: DetectSpec,
|
||||
/// The game's own compositor-nested-ness: `true` when a bare-spawn gamescope owns it.
|
||||
pub nested: bool,
|
||||
/// The child the host spawned for this launch, when it spawned one directly, and whether it
|
||||
/// leads its own process group (see [`OwnedChild::group_leader`]).
|
||||
pub child: Option<(std::process::Child, bool)>,
|
||||
/// Seconds-since-boot from **before** the launch ([`launch_clock`]): the floor for adopting a
|
||||
/// process, which is what keeps a copy of the game the player already had open from being
|
||||
/// mistaken for this session's. `None` disables the filter (no readable uptime clock).
|
||||
pub launch_uptime: Option<f64>,
|
||||
}
|
||||
|
||||
/// 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<f64> {
|
||||
#[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<dyn Fn() + Send + Sync>;
|
||||
|
||||
/// Open a lease and start watching. `on_exit` fires **at most once**, and only when the game was
|
||||
/// seen running and then confirmed gone — never for a game that never started, never for a shim, and
|
||||
/// never when the host itself asked the game to end.
|
||||
pub fn open(req: LeaseRequest, on_exit: OnExit) -> GameLease {
|
||||
let LeaseRequest {
|
||||
game,
|
||||
client,
|
||||
plane,
|
||||
spec,
|
||||
nested,
|
||||
child,
|
||||
launch_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<LeaseShared>,
|
||||
child: Option<std::process::Child>,
|
||||
on_exit: OnExit,
|
||||
) -> Option<std::thread::JoinHandle<()>> {
|
||||
// An untracked lease has nothing to observe (it still exposes state for the status surface).
|
||||
//
|
||||
// A *nested* lease is watched whenever its store gave us something to look for. The display
|
||||
// layer's node-death check would eventually notice a nested game exiting, but it can't see the
|
||||
// case that matters most: a Steam launch nests the resident Steam *client*, which stays up after
|
||||
// the game quits, so gamescope never dies. Watching the game itself is what covers that — and it
|
||||
// notices every other nested title sooner than node death does. Node death remains the backstop
|
||||
// for a nested title with no signals at all.
|
||||
if matches!(shared.kind, LeaseKind::Untracked) {
|
||||
return None;
|
||||
}
|
||||
if matches!(shared.kind, LeaseKind::Nested) && shared.spec.is_empty() {
|
||||
return None;
|
||||
}
|
||||
#[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<LeaseShared>, mut child: Option<std::process::Child>, on_exit: OnExit) {
|
||||
let scanner = crate::procscan::Scanner::system();
|
||||
let cancelled = || shared.cancel.load(Ordering::Relaxed);
|
||||
let spawned_at = Instant::now();
|
||||
let mut kind = shared.kind.clone();
|
||||
|
||||
// ---- 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<Instant> = 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<LeaseShared>, on_exit: &OnExit, why: &str) {
|
||||
shared.set_state(GameState::Exited);
|
||||
let terminated = shared.is_terminating();
|
||||
crate::events::emit(crate::events::EventKind::GameExited {
|
||||
game: game_event_ref(shared),
|
||||
reason: if terminated {
|
||||
crate::events::GameEndReason::Terminated
|
||||
} else {
|
||||
crate::events::GameEndReason::Exited
|
||||
},
|
||||
});
|
||||
if terminated {
|
||||
// The host asked for this. Ending the session too would be double-counting: whatever asked
|
||||
// for the termination is already tearing down (or deliberately isn't).
|
||||
tracing::info!(title = %shared.game.title, "the game the host asked to end is gone");
|
||||
return;
|
||||
}
|
||||
tracing::info!(title = %shared.game.title, reason = why, "the launched game exited");
|
||||
on_exit();
|
||||
}
|
||||
|
||||
/// The event-payload view of a lease.
|
||||
pub fn game_event_ref(shared: &LeaseShared) -> crate::events::GameRefPayload {
|
||||
crate::events::GameRefPayload {
|
||||
app: shared.game.id.clone(),
|
||||
title: shared.game.title.clone(),
|
||||
store: shared.game.store.clone(),
|
||||
client: shared.client.clone(),
|
||||
plane: shared.plane,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Termination
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// End the game this lease tracks: politely first, then decisively. Runs on a detached thread — a
|
||||
/// caller is usually a teardown path or a `Drop`, and neither may block on a game's shutdown.
|
||||
///
|
||||
/// Idempotent: a lease already terminating is left alone.
|
||||
pub fn terminate(shared: Arc<LeaseShared>, why: &'static str) {
|
||||
if !shared.is_trackable() {
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
"asked to end an untracked title — nothing to end"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if shared.terminating.swap(true, Ordering::SeqCst) {
|
||||
return; // already on its way out
|
||||
}
|
||||
tracing::info!(title = %shared.game.title, reason = why, "ending the launched game");
|
||||
let name = "pf1-gameterm".to_string();
|
||||
let _ = std::thread::Builder::new()
|
||||
.name(name)
|
||||
.spawn(move || terminate_blocking(&shared));
|
||||
}
|
||||
|
||||
/// 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<LeaseShared>,
|
||||
pub deadline: Instant,
|
||||
/// The client fingerprint that may re-adopt this lease (identity match on reconnect).
|
||||
pub fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
fn registry() -> &'static Mutex<Vec<Pending>> {
|
||||
static REG: OnceLock<Mutex<Vec<Pending>>> = OnceLock::new();
|
||||
REG.get_or_init(|| Mutex::new(Vec::new()))
|
||||
}
|
||||
|
||||
/// Put a lease on probation: its game ends in `grace` unless the same client reconnects first.
|
||||
pub fn arm_grace(shared: Arc<LeaseShared>, fingerprint: Option<String>, grace: Duration) {
|
||||
if !shared.is_trackable() {
|
||||
return;
|
||||
}
|
||||
tracing::info!(
|
||||
title = %shared.game.title,
|
||||
grace_s = grace.as_secs(),
|
||||
"the client is gone — the game ends when the reconnect window closes"
|
||||
);
|
||||
registry()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(Pending {
|
||||
shared,
|
||||
deadline: Instant::now() + grace,
|
||||
fingerprint,
|
||||
});
|
||||
start_reaper();
|
||||
}
|
||||
|
||||
/// A reconnecting client takes its game back: drops any pending termination for `fingerprint` whose
|
||||
/// title matches `app`. Returns the number of leases reprieved.
|
||||
pub fn readopt(fingerprint: Option<&str>, app: Option<&str>) -> usize {
|
||||
let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let before = reg.len();
|
||||
reg.retain(|p| {
|
||||
let same_client = match (&p.fingerprint, fingerprint) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
// With no fingerprint on either side there is nothing to match on; be conservative and
|
||||
// keep the lease pending rather than let any reconnect reprieve any game.
|
||||
_ => false,
|
||||
};
|
||||
let same_app = p.shared.game.id.as_deref() == app;
|
||||
if same_client && same_app {
|
||||
tracing::info!(
|
||||
title = %p.shared.game.title,
|
||||
"the client reconnected inside the window — the game keeps running"
|
||||
);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
before - reg.len()
|
||||
}
|
||||
|
||||
/// Every lease currently on probation, with the time left, for the status surface.
|
||||
pub fn pending_snapshot() -> Vec<(Arc<LeaseShared>, u64)> {
|
||||
let now = Instant::now();
|
||||
registry()
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.map(|p| {
|
||||
(
|
||||
p.shared.clone(),
|
||||
p.deadline.saturating_duration_since(now).as_secs(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// End the games of pending leases immediately — the console's "End now" for a game whose session is
|
||||
/// already gone. `app` filters to one title; `None` ends all of them. Returns how many were ended.
|
||||
pub fn end_pending(app: Option<&str>) -> usize {
|
||||
let taken: Vec<Arc<LeaseShared>> = {
|
||||
let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (hit, keep): (Vec<Pending>, Vec<Pending>) = std::mem::take(&mut *reg)
|
||||
.into_iter()
|
||||
.partition(|p| app.is_none() || p.shared.game.id.as_deref() == app);
|
||||
*reg = keep;
|
||||
hit.into_iter().map(|p| p.shared).collect()
|
||||
};
|
||||
let n = taken.len();
|
||||
for shared in taken {
|
||||
terminate(shared, "ended from the management API");
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// The single background thread that ends pending leases when their window closes. Started on demand
|
||||
/// and lives for the process, sleeping while the registry is empty.
|
||||
fn start_reaper() {
|
||||
static STARTED: OnceLock<()> = OnceLock::new();
|
||||
STARTED.get_or_init(|| {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("pf1-gamegrace".into())
|
||||
.spawn(|| loop {
|
||||
std::thread::sleep(POLL);
|
||||
let now = Instant::now();
|
||||
let due: Vec<Arc<LeaseShared>> = {
|
||||
let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (due, keep): (Vec<Pending>, Vec<Pending>) = std::mem::take(&mut *reg)
|
||||
.into_iter()
|
||||
.partition(|p| now >= p.deadline);
|
||||
*reg = keep;
|
||||
due.into_iter().map(|p| p.shared).collect()
|
||||
};
|
||||
for shared in due {
|
||||
// The player may have quit the game themselves during the window; terminate is a
|
||||
// no-op then (nothing matches the spec any more).
|
||||
terminate(shared, "the reconnect window closed with no client");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// What a session should do with its game when it ends. The policy lives in
|
||||
/// [`crate::session_settings`]; this is the one place that turns it into an action, so both planes
|
||||
/// behave identically.
|
||||
pub fn on_session_end(lease: &GameLease, deliberate: bool, fingerprint: Option<&str>) {
|
||||
use crate::session_settings::GameOnSessionEnd;
|
||||
let settings = crate::session_settings::get();
|
||||
let shared = lease.shared();
|
||||
if !shared.is_trackable() || shared.state() == GameState::Exited {
|
||||
return; // nothing to end (or it already ended on its own)
|
||||
}
|
||||
let end_now = |shared: Arc<LeaseShared>| {
|
||||
// A deliberate stop already forces this session's display down immediately (the `quit` flag
|
||||
// beats keep-alive linger), and for a nested launch that teardown *is* what ends the game —
|
||||
// gamescope exits with its display and takes its child with it. Asking again here would race
|
||||
// that teardown for a display that still has a live session, which the registry refuses
|
||||
// anyway. So: let it happen, and say so.
|
||||
if matches!(shared.kind, LeaseKind::Nested) {
|
||||
tracing::debug!(
|
||||
title = %shared.game.title,
|
||||
"the nested session's display teardown ends its game — nothing extra to do"
|
||||
);
|
||||
return;
|
||||
}
|
||||
terminate(shared, "the session was stopped deliberately");
|
||||
};
|
||||
match settings.game_on_session_end {
|
||||
GameOnSessionEnd::Keep => {}
|
||||
GameOnSessionEnd::OnQuit => {
|
||||
if deliberate {
|
||||
end_now(shared);
|
||||
}
|
||||
}
|
||||
GameOnSessionEnd::Always => {
|
||||
if deliberate {
|
||||
end_now(shared);
|
||||
} else {
|
||||
// A drop is not a decision. Give the client its window back — and note that for a
|
||||
// nested lease the *display* may outlive the window under its own keep-alive policy,
|
||||
// which is exactly why the expiry path releases it.
|
||||
arm_grace(
|
||||
shared,
|
||||
fingerprint.map(str::to_string),
|
||||
Duration::from_secs(settings.disconnect_grace_seconds.into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// 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
|
||||
|
||||
@@ -61,6 +61,16 @@ pub struct ProviderEntryInput {
|
||||
|
||||
impl From<CustomEntry> 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<CustomEntry> for GameEntry {
|
||||
art: c.art,
|
||||
launch: c.launch,
|
||||
provider: c.provider,
|
||||
detect,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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=<appid>`, whose lifetime is exactly the game's.
|
||||
pub steam_appid: Option<u32>,
|
||||
/// A launcher-stamped environment marker.
|
||||
pub env_marker: Option<EnvMarker>,
|
||||
/// The game's own executable, when a store resolves one exactly.
|
||||
pub exe: Option<PathBuf>,
|
||||
/// 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<PathBuf>,
|
||||
}
|
||||
|
||||
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<PathBuf>) -> Self {
|
||||
Self {
|
||||
install_dir: Some(dir.into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Just a concrete executable.
|
||||
pub fn exe(exe: impl Into<PathBuf>) -> 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<PathBuf>) -> 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<String>, value: Option<String>) -> 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<String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ fn gog_games() -> Vec<GameEntry> {
|
||||
// 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<GameEntry> {
|
||||
kind: "gog".into(),
|
||||
value: format!("{exe}\t{args}\t{workdir}"),
|
||||
}),
|
||||
detect,
|
||||
});
|
||||
}
|
||||
out
|
||||
|
||||
@@ -72,14 +72,16 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
||||
{
|
||||
continue; // the cache also lists owned-but-not-installed titles
|
||||
}
|
||||
let install_ok = g
|
||||
// The install dir doubles as this title's detect signal (Heroic hands off to
|
||||
// legendary/gogdl/nile, so the host never sees the game's own process any other way).
|
||||
let install_path = g
|
||||
.get("install")
|
||||
.and_then(|i| i.get("install_path"))
|
||||
.and_then(|p| p.as_str())
|
||||
.is_some_and(|p| Path::new(p).is_dir());
|
||||
if !install_ok {
|
||||
.filter(|p| Path::new(p).is_dir());
|
||||
let Some(install_path) = install_path else {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(app_name) = g
|
||||
.get("app_name")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -115,6 +117,11 @@ fn heroic_games(path: &Path, runner: &str, key: &str) -> anyhow::Result<Vec<Game
|
||||
kind: "heroic".into(),
|
||||
value: format!("{runner}:{app_name}"),
|
||||
}),
|
||||
// The install dir is the reliable signal. `HEROIC_APP_NAME` is also stamped on the game's
|
||||
// env by Heroic's launch path; it is carried as a second, cheap signal (a union — if a
|
||||
// Heroic version doesn't set it, the install dir still matches).
|
||||
detect: DetectSpec::dir(install_path)
|
||||
.with_env("HEROIC_APP_NAME", Some(app_name.to_string())),
|
||||
});
|
||||
}
|
||||
Ok(games)
|
||||
|
||||
@@ -26,6 +26,58 @@ pub fn launch_command(id: &str) -> Option<String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<LaunchTarget> {
|
||||
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<SpawnedLaunch> {
|
||||
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
|
||||
|
||||
@@ -55,20 +55,33 @@ fn lutris_games(db: &Path) -> rusqlite::Result<Vec<GameEntry>> {
|
||||
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<String>>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, Option<String>>(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<Vec<GameEntry>> {
|
||||
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)
|
||||
|
||||
@@ -17,25 +17,32 @@ impl LibraryProvider for SteamProvider {
|
||||
}
|
||||
|
||||
fn list(&self) -> Vec<GameEntry> {
|
||||
let mut by_appid: std::collections::BTreeMap<u32, String> = Default::default();
|
||||
let mut by_appid: std::collections::BTreeMap<u32, Installed> = 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<GameEntry> = 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_<appid>.acf`.
|
||||
struct Installed {
|
||||
appid: u32,
|
||||
name: String,
|
||||
/// `<steamapps>/common/<installdir>`, 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<PathBuf>,
|
||||
}
|
||||
|
||||
/// Scan a `steamapps` dir for `appmanifest_*.acf` files → the installed titles it describes.
|
||||
fn scan_manifests(steamapps: &Path) -> Vec<Installed> {
|
||||
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::<u32>), 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<GameEntry> {
|
||||
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/<id>/config/shortcuts.vdf` under each Steam root — one file per Steam account
|
||||
/// that has signed in on this host.
|
||||
fn shortcuts_files() -> Vec<PathBuf> {
|
||||
@@ -489,6 +531,7 @@ fn parse_one_shortcut(buf: &[u8], pos: &mut usize) -> Option<Shortcut> {
|
||||
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();
|
||||
|
||||
@@ -78,6 +78,9 @@ fn xbox_games() -> Vec<GameEntry> {
|
||||
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")),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -214,6 +214,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, 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))
|
||||
|
||||
@@ -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<StreamInfo>,
|
||||
/// 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<ActiveGame>,
|
||||
}
|
||||
|
||||
/// 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<u64>,
|
||||
/// 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<String>,
|
||||
/// Display title.
|
||||
title: String,
|
||||
/// Which store surfaced it (`steam`, `heroic`, `custom`, …), when known.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
store: Option<String>,
|
||||
/// `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<u64>,
|
||||
}
|
||||
|
||||
/// Client-requested launch parameters (key material is never exposed here).
|
||||
@@ -373,6 +405,19 @@ pub(crate) async fn get_status(State(st): State<Arc<MgmtState>>) -> Json<Runtime
|
||||
active_sessions: native.len() as u32 + u32::from(gs_video),
|
||||
session,
|
||||
stream,
|
||||
games: crate::session_status::games()
|
||||
.into_iter()
|
||||
.map(|g| ActiveGame {
|
||||
session_id: g.session_id,
|
||||
client: g.client,
|
||||
app_id: g.app_id,
|
||||
title: g.title,
|
||||
store: g.store,
|
||||
plane: g.plane,
|
||||
state: g.state.to_string(),
|
||||
grace_remaining_s: g.grace_remaining_s,
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ use std::sync::atomic::Ordering;
|
||||
///
|
||||
/// Kicks the connected client: stops the video/audio stream threads and clears the launch
|
||||
/// state. Idempotent — succeeds even when nothing is streaming.
|
||||
///
|
||||
/// Counts as a **deliberate** stop, exactly like a client pressing Stop: the display skips its
|
||||
/// keep-alive linger, and the end-game-on-session-end policy (if the operator enabled one) applies.
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/session",
|
||||
@@ -23,7 +26,7 @@ pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> 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<Arc<MgmtState>>) -> 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<EndGameRequest>) -> 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<SessionSettingsState> {
|
||||
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<crate::session_settings::SessionSettings>,
|
||||
) -> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -879,6 +879,30 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender<SessionSwitch>, stop: Arc<At
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the end-game-on-session-end policy when the session's video loop exits, whichever way it
|
||||
/// exits (`return`, `?`, a `break` out of the loop, panic-unwind) — the same RAII shape the per-title
|
||||
/// prep/undo guard uses, for the same reason: teardown paths are many and easy to miss one of.
|
||||
///
|
||||
/// Reading `quit` at **drop** time is the point: it is what distinguishes a client that deliberately
|
||||
/// stopped (or an operator who did) from one that merely vanished, and that distinction is the whole
|
||||
/// safety story for `Always` — a vanish gets a reconnect window, a deliberate stop does not.
|
||||
struct GameLifetime {
|
||||
lease: crate::gamelease::GameLease,
|
||||
quit: Arc<AtomicBool>,
|
||||
/// Hex client fingerprint, so a reconnecting client can reclaim its own game and nothing else.
|
||||
fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
/// 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<crate::library::LaunchTarget>,
|
||||
/// 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<PreparedDispl
|
||||
stats,
|
||||
client_label,
|
||||
launch,
|
||||
launch_target,
|
||||
client_hdr,
|
||||
bringup,
|
||||
resize_ms,
|
||||
} = ctx;
|
||||
// Reference point for adopting the launched game's processes: anything the host will call "this
|
||||
// session's game" has to have started after this instant. Taken HERE, before the display (and
|
||||
// therefore before a bare-spawn gamescope's nested child) exists, because a reading taken after
|
||||
// the launch would reject the very process it is meant to find. Erring early is the safe
|
||||
// direction: it can only ever include more of our own launch, never a copy from before it.
|
||||
let launch_uptime = crate::gamelease::launch_clock();
|
||||
// Streamed-AU wire mode: the client's cap AND the host escape hatch (`PUNKTFUNK_STREAMED_AU=0`
|
||||
// reverts to whole-AU sends without touching the encoder's slicing knobs). The third gate —
|
||||
// whether the ENCODER actually chunks — is dynamic (`supports_chunked_poll`, per AU).
|
||||
@@ -1211,54 +1246,93 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Some(cmd) = launch.as_deref() {
|
||||
if crate::vdisplay::launch_is_nested(compositor) {
|
||||
let spawned_launch = match launch.as_deref() {
|
||||
Some(cmd) if crate::vdisplay::launch_is_nested(compositor) => {
|
||||
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=<id>` 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<PreparedDispl
|
||||
// (`GET /status`) shows the native stream — resolution/fps/codec/bitrate resolve live from the
|
||||
// same handles a mid-stream mode switch / adaptive-bitrate change updates. The guard clears the
|
||||
// entry when this loop exits (return / `?` / panic), so the Dashboard tracks the session's life.
|
||||
let _live_session = crate::session_status::register(
|
||||
live_mode.clone(),
|
||||
live_bitrate.clone(),
|
||||
plan.codec,
|
||||
stop.clone(),
|
||||
force_idr.clone(),
|
||||
client_label,
|
||||
plan.hdr,
|
||||
bringup.total_slot(),
|
||||
resize_ms.clone(),
|
||||
);
|
||||
let _live_session = crate::session_status::register(crate::session_status::Registration {
|
||||
mode: live_mode.clone(),
|
||||
bitrate_kbps: live_bitrate.clone(),
|
||||
codec: plan.codec,
|
||||
stop: stop.clone(),
|
||||
quit: quit.clone(),
|
||||
force_idr: force_idr.clone(),
|
||||
client: client_label,
|
||||
hdr: plan.hdr,
|
||||
ttff_ms: bringup.total_slot(),
|
||||
last_resize_ms: resize_ms.clone(),
|
||||
game: game_shared,
|
||||
});
|
||||
|
||||
// Mid-stream session-switch watcher (opt-in via PUNKTFUNK_SESSION_WATCH; never under an explicit
|
||||
// PUNKTFUNK_COMPOSITOR pin). It self-baselines and signals the loop below to swap the backend in
|
||||
@@ -2057,8 +2133,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// write-only variable under `-D warnings` (the `let _ = &launch` idiom above).
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = &cur_node_id;
|
||||
// Backstop for a nested launch the lease can't recognize (no detect signals): a
|
||||
// bare-spawn gamescope exits with its child, so its node staying gone means the game
|
||||
// quit. Honors the same operator setting as the lease's own exit path — with
|
||||
// end-session-on-game-exit off, a lost capture is just a rebuild.
|
||||
#[cfg(target_os = "linux")]
|
||||
if launch.is_some()
|
||||
&& crate::session_settings::get().session_on_game_exit
|
||||
&& crate::vdisplay::launch_is_nested(compositor)
|
||||
&& crate::vdisplay::dedicated_game_exited(cur_node_id)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
//! Finding a launched game's processes on Linux, from the signals its store gave us
|
||||
//! ([`crate::library::DetectSpec`]).
|
||||
//!
|
||||
//! Everything here reads `/proc` and nothing else — no ptrace, no injection, no handles kept. The
|
||||
//! host only ever looks at processes owned by its **own uid**, and only ever *reads* metadata it
|
||||
//! already has permission to see.
|
||||
//!
|
||||
//! Two rules keep this honest, and both matter more than the matching itself:
|
||||
//!
|
||||
//! 1. **Never adopt a process that predates the launch.** A player may already have the game open
|
||||
//! when a session starts; treating that instance as "this session's game" would let a session end
|
||||
//! kill a process it never started. Candidates are filtered by start time
|
||||
//! ([`Scanner::find`]'s `min_start`).
|
||||
//! 2. **Never trust a bare pid.** Pids are recycled, and a lease can outlive its game by a grace
|
||||
//! window, so every remembered process carries its start time and is re-verified against it
|
||||
//! ([`Scanner::alive`]) before it is ever counted as running — or signalled.
|
||||
|
||||
use crate::library::DetectSpec;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A process the matcher adopted: its pid plus the start time that pins that pid to *this*
|
||||
/// process, so a recycled pid can never be mistaken for it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ProcRef {
|
||||
pub pid: u32,
|
||||
/// `/proc/<pid>/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<u32>,
|
||||
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<f64> {
|
||||
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<f64>) -> Vec<ProcRef> {
|
||||
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::<u32>().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<ProcRef> {
|
||||
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/<pid>/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<u8> = 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/<pid>/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<u64> {
|
||||
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<Vec<u8>> {
|
||||
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<PathBuf>,
|
||||
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<PathBuf>) -> 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<ProcRef>) -> Vec<u32> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//! Session⇄game lifetime settings (`<config>/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<Option<SessionSettings>>,
|
||||
}
|
||||
|
||||
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::<SessionSettings>(&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<SessionSettingsStore> = 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<String> {
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,10 @@ struct LiveSession {
|
||||
codec: Codec,
|
||||
/// The session's teardown flag ([`stop_all`] → mgmt `DELETE /session`).
|
||||
stop: Arc<AtomicBool>,
|
||||
/// 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<AtomicBool>,
|
||||
/// 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<AtomicBool>,
|
||||
@@ -45,6 +49,9 @@ struct LiveSession {
|
||||
ttff_ms: Arc<AtomicU32>,
|
||||
/// Most recent completed mid-stream resize (reconfigure → pipeline rebuilt), ms; 0 = none yet.
|
||||
last_resize_ms: Arc<AtomicU32>,
|
||||
/// 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<Arc<crate::gamelease::LeaseShared>>,
|
||||
}
|
||||
|
||||
/// 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<Atomic…>` 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<AtomicU64>,
|
||||
/// Live encoder target (kbps); updated on an adaptive-bitrate change.
|
||||
pub bitrate_kbps: Arc<AtomicU32>,
|
||||
pub codec: Codec,
|
||||
/// Teardown flag ([`stop_all`]).
|
||||
pub stop: Arc<AtomicBool>,
|
||||
/// Deliberate-stop flag ([`stop_all_quit`]).
|
||||
pub quit: Arc<AtomicBool>,
|
||||
/// One-shot force-keyframe flag ([`force_idr_all`]).
|
||||
pub force_idr: Arc<AtomicBool>,
|
||||
/// 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<AtomicU32>,
|
||||
/// Most recent mid-stream resize total, ms.
|
||||
pub last_resize_ms: Arc<AtomicU32>,
|
||||
/// The launched game's lease, when this session launched a title.
|
||||
pub game: Option<Arc<crate::gamelease::LeaseShared>>,
|
||||
}
|
||||
|
||||
/// 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<AtomicU64>,
|
||||
bitrate_kbps: Arc<AtomicU32>,
|
||||
codec: Codec,
|
||||
stop: Arc<AtomicBool>,
|
||||
force_idr: Arc<AtomicBool>,
|
||||
client: String,
|
||||
hdr: bool,
|
||||
ttff_ms: Arc<AtomicU32>,
|
||||
last_resize_ms: Arc<AtomicU32>,
|
||||
) -> 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<SessionSnapshot> {
|
||||
.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<u64>,
|
||||
pub client: String,
|
||||
pub app_id: Option<String>,
|
||||
pub title: String,
|
||||
pub store: Option<String>,
|
||||
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<u64>,
|
||||
}
|
||||
|
||||
/// 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<GameSnapshot> {
|
||||
let mut out: Vec<GameSnapshot> = 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() {
|
||||
|
||||
Reference in New Issue
Block a user