Files
punktfunk/crates/punktfunk-host/src/session_status.rs
T
enricobuehler 98121ccd53 feat(session/gamestream): the compat plane learns a game's lifetime too
Moonlight sessions had neither direction of the session⇄game binding: a game
exiting left the player looking at a desktop, and a session ending never touched
the game. The machinery for both landed with the native plane; this wires it up.

The compat plane had no way to say "this is over" — RTSP carries no close code —
so `/cancel`, the management stop and a game exiting all looked exactly like a
client vanishing. They now set a session quit flag, which is what lets the
virtual display skip its keep-alive linger for a real stop and what the
end-game-on-session-end policy reads to tell a decision from a network blip. A
drop still lingers, and still gets its reconnect window.

Moonlight can't resume a session, so a client coming back for a game it left
behind does it by launching the title again — that reclaims the waiting game
before anything starts, rather than letting the old window close on the copy the
player is now holding.

Both planes now resolve a launch through one lookup (`resolve_launch`), so a
GameStream title arrives with the same identity and the same detect signals a
native one does; `resolve_session_launch`/`launch_command` were the older,
identity-less half of that and are gone. A Moonlight game also has no
live-session entry to hang off, so the status surface gains a slot for it —
without it the Dashboard would show a stream with no game while one was running.

Gates on .21: check + clippy --all-targets clean, 296 tests (293 + 3), fmt
CI-parity.
2026-07-26 18:28:13 +02:00

397 lines
16 KiB
Rust

//! Plane-neutral live-session status for the management `/status` (web-console Dashboard) view.
//!
//! The GameStream media pipeline records its session in `AppState.{launch, stream, streaming}`
//! (consumed by RTSP/media), but the native punktfunk/1 plane never touches `AppState` — by design
//! it is handed only the shared stats recorder ([`crate::native::serve`]). So a native session,
//! which is the DEFAULT plane (GameStream is opt-in, `--gamestream`), was invisible on the Dashboard:
//! `GET /status` reported `video_streaming: false` and no session/stream card while a client was
//! actively streaming (the Stats page worked because it shares the recorder — hence the confusing
//! "stats move but the dashboard says idle").
//!
//! This module is the small shared surface the native video loop ([`crate::native::virtual_stream`])
//! publishes a live snapshot to, keyed per session so CONCURRENT native sessions each get an entry
//! (the native server admits up to `max_sessions`, unbounded by default). The loop registers on
//! stream start and the returned [`LiveSessionGuard`] removes the entry on ANY scope exit (return,
//! `?`, panic — RAII). `/status` reads [`snapshot`]/[`count`]; the Dashboard's session-control
//! buttons reach a native session through [`stop_all`] (stop) and [`force_idr_all`] (request IDR),
//! so surfacing the session doesn't leave those buttons dead.
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use crate::encode::Codec;
/// A single live native session. Holds the SAME live Arc handles the video loop already maintains,
/// so a mid-stream mode switch / adaptive-bitrate change is reflected on the Dashboard with no
/// second update path — plus the flags the mgmt API flips to control the session.
struct LiveSession {
id: u64,
/// Packed `w:16|h:16|hz:16` ([`crate::native::pack_mode`]); updated on a mode switch.
mode: Arc<AtomicU64>,
/// Live encoder target (kbps); updated on an adaptive-bitrate change.
bitrate_kbps: Arc<AtomicU32>,
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>,
/// Short client label (cert-fingerprint prefix / peer IP) — carried on the lifecycle events.
client: String,
/// Whether the session negotiated HDR — carried on the lifecycle events.
hdr: bool,
/// Completed bring-up total (hello → first packet), ms; 0 until the first packet left. Written
/// once by the session's [`crate::bringup::Trace`] (latency plan P0.1).
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.
#[derive(Clone, Copy)]
pub struct SessionSnapshot {
pub width: u32,
pub height: u32,
pub fps: u32,
pub bitrate_kbps: u32,
pub codec: Codec,
/// Bring-up total (hello → first packet), ms; 0 while still bringing up (latency plan P0.1).
pub time_to_first_frame_ms: u32,
/// Most recent mid-stream resize total, ms; 0 = no resize this session.
pub last_resize_ms: u32,
}
fn registry() -> &'static Mutex<Vec<LiveSession>> {
static REG: OnceLock<Mutex<Vec<LiveSession>>> = OnceLock::new();
REG.get_or_init(|| Mutex::new(Vec::new()))
}
fn next_id() -> u64 {
static ID: AtomicU64 = AtomicU64::new(1);
ID.fetch_add(1, Ordering::Relaxed)
}
/// Resolves one session's [`crate::events::SessionRef`] (mode read live) for the lifecycle events.
fn session_ref(s: &LiveSession) -> crate::events::SessionRef {
let (width, height, fps) = crate::native::unpack_mode(s.mode.load(Ordering::Relaxed));
crate::events::SessionRef {
id: s.id,
client: s.client.clone(),
mode: crate::events::mode_str(width, height, fps),
hdr: s.hdr,
}
}
/// 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.
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,
mode,
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),
});
registry().lock().unwrap().push(session);
LiveSessionGuard {
id,
_sleep: crate::sleep_inhibit::hold(),
}
}
/// Removes its session from the registry when dropped (any scope exit of the native video loop).
pub struct LiveSessionGuard {
id: u64,
/// While any native session lives, the box must not auto-suspend under a passive viewer
/// ([`crate::sleep_inhibit`]) — refcounted, released with the guard.
_sleep: crate::sleep_inhibit::StreamHold,
}
impl Drop for LiveSessionGuard {
fn drop(&mut self) {
let mut reg = registry().lock().unwrap();
if let Some(pos) = reg.iter().position(|s| s.id == self.id) {
let session = reg.remove(pos);
drop(reg); // emit outside the registry lock — the bus takes its own
crate::events::emit(crate::events::EventKind::SessionEnded {
session: session_ref(&session),
});
}
}
}
/// The number of live native sessions.
pub fn count() -> usize {
registry().lock().unwrap().len()
}
/// A resolved snapshot of every live native session (mode/bitrate read live), newest last.
pub fn snapshot() -> Vec<SessionSnapshot> {
registry()
.lock()
.unwrap()
.iter()
.map(|s| {
let (width, height, fps) = crate::native::unpack_mode(s.mode.load(Ordering::Relaxed));
SessionSnapshot {
width,
height,
fps,
bitrate_kbps: s.bitrate_kbps.load(Ordering::Relaxed),
codec: s.codec,
time_to_first_frame_ms: s.ttff_ms.load(Ordering::Relaxed),
last_resize_ms: s.last_resize_ms.load(Ordering::Relaxed),
}
})
.collect()
}
/// 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>,
}
/// The compat plane's launched game, while it has one.
///
/// GameStream sessions are not in the live-session registry above — that registry holds the native
/// loop's own `Arc` handles (mode, bitrate, the stop/quit flags), none of which the compat plane has.
/// It is single-session by construction (one `AppState.launch`), so one slot is the whole story, and
/// this is what keeps a Moonlight client's game visible on the Dashboard alongside a native one.
fn gs_game() -> &'static Mutex<Option<Arc<crate::gamelease::LeaseShared>>> {
static SLOT: OnceLock<Mutex<Option<Arc<crate::gamelease::LeaseShared>>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(None))
}
/// Publish the compat plane's game for the status surface; the returned guard retracts it when the
/// stream loop exits by any path (the plane's counterpart to [`LiveSessionGuard`]).
pub fn publish_gamestream_game(shared: Arc<crate::gamelease::LeaseShared>) -> GamestreamGameGuard {
*gs_game().lock().unwrap_or_else(|e| e.into_inner()) = Some(shared);
GamestreamGameGuard
}
/// Clears the compat plane's published game on drop.
pub struct GamestreamGameGuard;
impl Drop for GamestreamGameGuard {
fn drop(&mut self) {
*gs_game().lock().unwrap_or_else(|e| e.into_inner()) = None;
}
}
/// Every launched game the host currently knows about: the live sessions' games first, then the
/// compat plane's, then any game whose session has ended and which is waiting out its reconnect
/// window before being ended.
///
/// The 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(
gs_game()
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|g| GameSnapshot {
// The compat plane has no session id to attribute it to; the console tells it from a
// grace row by the state, which is never `grace` while a session is streaming it.
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: g.state().as_str(),
grace_remaining_s: None,
}),
);
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() {
for s in registry().lock().unwrap().iter() {
s.force_idr.store(true, Ordering::Relaxed);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A Moonlight client's game has no live-session entry to hang off, so without the compat-plane
/// slot it would be missing from `/status` entirely — the Dashboard would show a stream with no
/// game while one was plainly running. Publishing must also be strictly scoped to the stream: the
/// row has to vanish with the guard, or a finished session leaves a ghost game on the console.
#[test]
fn a_gamestream_game_is_visible_only_while_its_stream_runs() {
let id = "steam:1701";
let mine = || {
games()
.into_iter()
.find(|g| g.app_id.as_deref() == Some(id))
};
let lease = crate::gamelease::open(
crate::gamelease::LeaseRequest {
game: crate::gamelease::GameRef {
id: Some(id.to_string()),
store: Some("steam".into()),
title: "Test Title".into(),
},
client: "192.0.2.7".into(),
plane: crate::events::Plane::Gamestream,
// No signals: an inert lease, so no watcher thread races this test's assertions.
spec: crate::library::DetectSpec::default(),
nested: false,
child: None,
launch_stamp: None,
},
Box::new(|| {}),
);
assert!(mine().is_none(), "not published yet");
{
let _pub = publish_gamestream_game(lease.shared());
let row = mine().expect("the compat plane's game is reported");
assert_eq!(
row.session_id, None,
"no live-session entry to attribute it to"
);
assert_eq!(row.plane, crate::events::Plane::Gamestream);
assert_eq!(row.client, "192.0.2.7");
assert_eq!(row.title, "Test Title");
// Never `grace` while the stream is up — that state is what the console keys its
// countdown and "End now" off.
assert_ne!(row.state, "grace");
}
assert!(mine().is_none(), "the row goes with the stream");
}
}