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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user