From 98121ccd5316410f8ca87930ff008b27355ae5f7 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 26 Jul 2026 17:54:59 +0200 Subject: [PATCH] feat(session/gamestream): the compat plane learns a game's lifetime too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/punktfunk-host/src/gamelease.rs | 45 +++ crates/punktfunk-host/src/gamestream/mod.rs | 47 +++ .../punktfunk-host/src/gamestream/nvhttp.rs | 14 +- crates/punktfunk-host/src/gamestream/rtsp.rs | 14 + .../punktfunk-host/src/gamestream/stream.rs | 283 +++++++++++++++--- crates/punktfunk-host/src/library/launch.rs | 64 ++-- crates/punktfunk-host/src/mgmt/session.rs | 2 +- crates/punktfunk-host/src/native/handshake.rs | 6 +- crates/punktfunk-host/src/native/stream.rs | 34 +-- crates/punktfunk-host/src/session_status.rs | 106 ++++++- 10 files changed, 497 insertions(+), 118 deletions(-) diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs index 2a12fd43..899cdff8 100644 --- a/crates/punktfunk-host/src/gamelease.rs +++ b/crates/punktfunk-host/src/gamelease.rs @@ -950,6 +950,51 @@ pub fn on_session_end(lease: &GameLease, deliberate: bool, fingerprint: Option<& } } +/// Applies [`on_session_end`] when a session's stream loop exits, whichever way it exits (`return`, +/// `?`, a `break` out of the loop, a panic-unwind) — the same RAII shape the per-title prep/undo +/// guard uses, for the same reason: teardown paths are many and easy to miss one of. +/// +/// Reading `quit` at **drop** time is the point: it is what distinguishes a client that deliberately +/// stopped (or an operator who did) from one that merely vanished, and that distinction is the whole +/// safety story for [`GameOnSessionEnd::Always`](crate::session_settings::GameOnSessionEnd::Always) — +/// a vanish gets a reconnect window, a deliberate stop does not. +/// +/// Both planes use it, so "the session ended" means the same thing to a game whichever way the client +/// was talking to the host. +pub struct SessionGuard { + lease: GameLease, + quit: Arc, + /// Hex client fingerprint, so a reconnecting client can reclaim its own game and nothing else. + fingerprint: Option, +} + +impl SessionGuard { + /// Bind `lease` to the calling session's lifetime. `quit` is the session's deliberate-stop flag, + /// read at drop; `fingerprint` identifies the client allowed to reclaim the game on reconnect. + pub fn new(lease: GameLease, quit: Arc, fingerprint: Option) -> Self { + Self { + lease, + quit, + fingerprint, + } + } + + /// The lease's shared state, for the status surface. + pub fn shared(&self) -> Arc { + self.lease.shared() + } +} + +impl Drop for SessionGuard { + fn drop(&mut self) { + on_session_end( + &self.lease, + self.quit.load(Ordering::SeqCst), + self.fingerprint.as_deref(), + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/punktfunk-host/src/gamestream/mod.rs b/crates/punktfunk-host/src/gamestream/mod.rs index 4e371aa4..542362f0 100644 --- a/crates/punktfunk-host/src/gamestream/mod.rs +++ b/crates/punktfunk-host/src/gamestream/mod.rs @@ -156,6 +156,15 @@ pub struct AppState { pub audio_params: std::sync::Mutex, /// True while the video stream thread is running (also its keep-running flag). pub streaming: std::sync::Arc, + /// Whether the current session is ending **deliberately** — the compat plane's answer to the + /// native plane's `QUIT_CODE` close, which RTSP has no equivalent of. + /// + /// Set by the three things that mean "this is over": the client's `/cancel` (Moonlight's Quit + /// App), the management API's stop, and the launched game exiting. An ENet vanish or an + /// unreachable client leaves it clear — those are drops, and the difference decides whether the + /// virtual display skips its keep-alive linger and whether the end-game policy sees an intent or + /// a network blip. Cleared by `/launch`, which is where a session begins. + pub quit: std::sync::Arc, /// True while the audio stream thread is running (also its keep-running flag). pub audio_streaming: std::sync::Arc, /// Set by the control stream when the client requests an IDR / invalidates reference @@ -222,6 +231,17 @@ impl AppState { was_streaming } + /// End the session as a **decision** rather than a drop: mark it deliberate, then tear it down. + /// + /// This is what a client's `/cancel`, the management stop, and a launched game's exit all use. + /// The flag is read by the virtual display's keep-alive lease (skip the linger — nobody is coming + /// back) and, at the video thread's teardown, by the end-game-on-session-end policy (which gives a + /// mere drop a reconnect window first). See [`AppState::quit`]. + pub(crate) fn quit_session(&self, reason: &str) -> bool { + self.quit.store(true, std::sync::atomic::Ordering::SeqCst); + self.end_session(reason) + } + /// Fresh control-plane state: no active session; the pairing allow-list is loaded from /// disk (pairings persist across restarts). `stats` is the shared recorder handed to both the /// mgmt API and the streaming loops. @@ -239,6 +259,7 @@ impl AppState { stream: std::sync::Mutex::new(None), audio_params: std::sync::Mutex::new(audio::AudioParams::default()), streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + quit: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), audio_streaming: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), force_idr: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), rfi_range: std::sync::Arc::new(std::sync::Mutex::new(None)), @@ -529,6 +550,32 @@ mod session_tests { // Idempotent: a second end (e.g. `/cancel` racing the ENet Disconnect) is a no-op. assert!(!state.end_session("test again")); } + + /// The compat plane has no close code, so the difference between "the player stopped" and "the + /// client vanished" lives entirely in this flag — and it decides whether a display lingers and + /// whether an operator's end-game policy sees a decision or a network blip. A teardown that + /// forgets to set it silently downgrades a deliberate stop to a drop. + #[test] + fn quit_marks_a_teardown_deliberate_and_a_plain_end_does_not() { + use std::sync::atomic::Ordering; + let state = test_state(); + assert!( + !state.quit.load(Ordering::SeqCst), + "a fresh session is undecided" + ); + + // A drop (ENet vanish / unreachable client) must leave it clear. + state.streaming.store(true, Ordering::SeqCst); + state.end_session("client unreachable"); + assert!(!state.quit.load(Ordering::SeqCst)); + + // `/cancel`, the management stop and a game exiting all go through `quit_session`. + state.streaming.store(true, Ordering::SeqCst); + assert!(state.quit_session("client /cancel"), "video was live"); + assert!(state.quit.load(Ordering::SeqCst)); + // …and it still performs the full teardown. + assert!(!state.streaming.load(Ordering::SeqCst)); + } } #[cfg(all(test, unix))] diff --git a/crates/punktfunk-host/src/gamestream/nvhttp.rs b/crates/punktfunk-host/src/gamestream/nvhttp.rs index 3a0bc694..9b13986b 100644 --- a/crates/punktfunk-host/src/gamestream/nvhttp.rs +++ b/crates/punktfunk-host/src/gamestream/nvhttp.rs @@ -201,6 +201,11 @@ async fn h_launch( session.height = h; session.fps = f; } + // A new session starts undecided: whatever ended the last one says nothing about this + // one (see `AppState::quit`). The game this launch reclaims — if this client left one + // waiting out its reconnect window — is reprieved by the stream thread, which resolves + // the title anyway and so needs no second library scan here. + st.quit.store(false, std::sync::atomic::Ordering::SeqCst); *st.launch.lock().unwrap() = Some(session); tracing::info!( w = session.width, @@ -250,9 +255,12 @@ async fn h_cancel( tracing::warn!("cancel rejected — caller does not own the session"); return xml(error_xml()); } - // Quit semantics: the shared full teardown (launch cleared + both media threads stop on - // their flags) — the virtual output/gamescope teardown follows via the capturer's RAII. - st.end_session("client /cancel"); + // Quit semantics, and now literally so: `/cancel` is Moonlight's "Quit App" — a decision, not a + // drop. The shared full teardown (launch cleared + both media threads stop on their flags) runs + // with the session's quit flag set, so the virtual display skips its keep-alive linger and the + // end-game-on-session-end policy treats this as the operator asking. The virtual + // output/gamescope teardown follows via the capturer's RAII. + st.quit_session("client /cancel"); xml("\n1\n".to_string()) } diff --git a/crates/punktfunk-host/src/gamestream/rtsp.rs b/crates/punktfunk-host/src/gamestream/rtsp.rs index 8bf85dea..88fa2b87 100644 --- a/crates/punktfunk-host/src/gamestream/rtsp.rs +++ b/crates/punktfunk-host/src/gamestream/rtsp.rs @@ -279,6 +279,20 @@ fn handle_request(req: &Request, state: &Arc, peer: Option state.video_cap.clone(), state.stats.clone(), on_lost.clone(), + // The launched game's lifetime wiring. A game *exiting* is a deliberate end + // (the player finished) — the same distinction the native plane draws with + // its close code, and what the end-game-on-session-end policy keys off at + // teardown. + stream::GameLifetime { + quit: state.quit.clone(), + fingerprint: ls.owner_fp.map(hex::encode), + on_game_exit: { + let st = state.clone(); + Arc::new(move || { + st.quit_session("game exited"); + }) + }, + }, ); } Some(_) => tracing::info!("RTSP PLAY — stream already running"), diff --git a/crates/punktfunk-host/src/gamestream/stream.rs b/crates/punktfunk-host/src/gamestream/stream.rs index c1af33ab..a4fa1512 100644 --- a/crates/punktfunk-host/src/gamestream/stream.rs +++ b/crates/punktfunk-host/src/gamestream/stream.rs @@ -46,6 +46,21 @@ pub type CapturerSlot = Arc>>; /// control plane and drained by the video thread (see [`AppState::rfi_range`](super::AppState)). pub type RfiSlot = Arc>>; +/// What the stream thread needs to give the launched game a lifetime +/// (design/session-game-lifetime.md). Bundled because the three only exist together — the control +/// plane builds them from the live `AppState` at RTSP PLAY, and the stream thread is where they are +/// spent. +pub struct GameLifetime { + /// The session's deliberate-quit flag ([`super::AppState::quit`]), read when the stream ends: a + /// decision may end the game, a drop gets a reconnect window first. + pub quit: Arc, + /// Hex cert fingerprint of the paired client that owns the launch, so only it can reclaim its own + /// game. `None` when the peer cert couldn't be read. + pub fingerprint: Option, + /// Ends the whole session, deliberately — the action for "the launched game exited". + pub on_game_exit: super::OnSessionLost, +} + /// Spawn the video stream thread (idempotent via `running`). Stops when `running` clears. /// `force_idr` is set by the control stream on a client recovery request; `video_cap` holds /// the persistent capturer the thread borrows for the stream's duration. @@ -59,6 +74,7 @@ pub fn start( video_cap: CapturerSlot, stats: Arc, on_lost: super::OnSessionLost, + life: GameLifetime, ) { let _ = std::thread::Builder::new() .name("punktfunk-video".into()) @@ -110,6 +126,7 @@ pub fn start( &video_cap, &stats, &on_lost, + &life, ); // A clean return is a stop (RTSP teardown / cancel / client unreachable) → `quit`; // an error return is `error`. The compat plane can't tell a user stop from an idle @@ -146,6 +163,8 @@ fn run( stats: &Arc, // Whole-session teardown for the send thread's client-unreachable detection. on_lost: &super::OnSessionLost, + // The launched game's lifetime wiring (quit flag, launch owner, game-exit teardown). + life: &GameLifetime, ) -> Result<()> { // GameStream capture/encode thread: apply Windows session tuning (no-op off Windows). pf_frame::session_tuning::on_hot_thread(); @@ -185,6 +204,29 @@ fn run( // `video_cap`, since a reconnect at a different resolution needs a freshly-sized output; the // output is released when this capturer drops at stream end (RAII via its keepalive). if pf_host_config::config().video_source.as_deref() == Some("virtual") { + // 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 prep + // steps, before the source (a bare-spawn gamescope nests the game inside it), before the + // launch — because a reading taken later would reject the very process it is meant to find. + // Erring early can only ever include more of our own launch, never a copy from before it. + let launch_stamp = crate::gamelease::launch_clock(); + // Everything the host knows about the title being launched, resolved in ONE library scan: + // what to run, what to call it, and how to recognize it once it is up. + let target = resolve_gs_app(app); + // Moonlight has no session resume, so a client coming back for a game it left behind does it + // by launching the title again. Reprieve that game before anything starts, so the copy the + // player is about to be handed isn't killed out from under them when the old window closes. + if let Some(t) = target.as_ref() { + let reprieved = + crate::gamelease::readopt(life.fingerprint.as_deref(), t.game.id.as_deref()); + if reprieved > 0 { + tracing::info!( + reprieved, + title = %t.game.title, + "gamestream: this client came back for its game — keeping it" + ); + } + } // Per-app prep steps (RFC §6): the entry's own `prep` plus a custom library title's, // run synchronously BEFORE the virtual output opens or anything launches (an HDR // toggle / sink switch must land first — and gamescope's nested launch happens inside @@ -204,7 +246,8 @@ fn run( // exactly like the native plane), create a virtual output at the client mode, and capture it. // Re-runnable: the encode loop calls it again on a mid-stream capture loss to FOLLOW a // Desktop<->Game switch. - let (mut capturer, compositor) = open_gs_virtual_source(cfg, app)?; + let (mut capturer, compositor) = + open_gs_virtual_source(cfg, app, target.as_ref(), &life.quit)?; tracing::info!( ?compositor, app = ?app.map(|a| &a.title), @@ -221,36 +264,105 @@ fn run( // existing title, never inject a command). An apps.json entry instead carries an // operator-typed `cmd`. Library id wins when both are set. #[cfg(windows)] - { - if let Some(lib_id) = app.and_then(|a| a.library_id.as_deref()) { - if let Err(e) = crate::library::launch_gamestream_library(lib_id) { - tracing::warn!(library_id = lib_id, error = %e, "gamestream: could not launch library title"); - } - } else if let Some(cmd) = app - .and_then(|a| a.cmd.as_deref()) - .filter(|c| !c.trim().is_empty()) - { - if let Err(e) = crate::library::launch_gamestream_command(cmd) { - tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app"); - } + if let Some(t) = target.as_ref() { + // A library title launches by its store-qualified id (the interactive-session spawner + // resolves the store's own recipe); an operator-typed command runs as itself. + let launched = match (t.game.id.as_deref(), t.command.as_deref()) { + (Some(id), _) => crate::library::launch_gamestream_library(id), + (None, Some(cmd)) => crate::library::launch_gamestream_command(cmd), + (None, None) => Ok(()), + }; + if let Err(e) = launched { + tracing::warn!(title = %t.game.title, error = %e, "gamestream: could not launch app"); } } + // Linux keeps the spawned child rather than dropping it: it is the primary liveness signal + // for a title whose store told us nothing else, and the handle the termination ladder + // signals. A gamescope bare spawn already nested the command (`set_launch_command` in the + // source open), so launching again would start it twice. #[cfg(target_os = "linux")] - if !crate::vdisplay::launch_is_nested(compositor) { - if let Some(cmd) = crate::library::resolve_session_launch( - app.and_then(|a| a.library_id.as_deref()), - app.and_then(|a| a.cmd.as_deref()), - ) { - if let Err(e) = crate::library::launch_session_command(compositor, &cmd) { + let spawned_launch = match target.as_ref().and_then(|t| t.command.as_deref()) { + Some(_) if crate::vdisplay::launch_is_nested(compositor) => None, + Some(cmd) => match crate::library::launch_session_command(compositor, cmd) { + Ok(spawned) => Some(spawned), + Err(e) => { tracing::warn!(command = %cmd, error = %e, "gamestream: could not launch app"); + None } - } - } + }, + None => None, + }; + + // The launched game's lifetime, in both directions (design/session-game-lifetime.md) — the + // compat plane's half of what the native plane already does: + // + // * **its exit ends the session**, so Moonlight returns to its app list instead of leaving + // the player on a bare desktop or a hidden launcher. + // * **this session ending can end it** — never by default; only when the operator asked, and + // for a mere drop only after a reconnect window (the guard's drop). Moonlight can't resume + // a session, but the window still protects unsaved progress on a network blip, and a + // relaunch of the same title reclaims the game (above). + let _game_life = target.as_ref().map(|t| { + #[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 on_game_exit = life.on_game_exit.clone(); + 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"); + // Deliberate: the player finished. The display skips its keep-alive linger and + // the launch state is cleared, so Moonlight's next `/launch` starts cleanly. + on_game_exit(); + }) + }; + let lease = crate::gamelease::open( + crate::gamelease::LeaseRequest { + game: t.game.clone(), + // RTSP carries no client device name, so the peer IP is the best label there is + // (the same one the stats capture uses). + client: client_label.clone(), + plane: crate::events::Plane::Gamestream, + spec: t.detect.clone(), + nested, + child, + launch_stamp, + }, + on_exit, + ); + // Declared first so it drops first: the console loses the live row before the policy + // below can replace it with a `grace` one, rather than briefly showing both. + let published = crate::session_status::publish_gamestream_game(lease.shared()); + ( + published, + crate::gamelease::SessionGuard::new( + lease, + life.quit.clone(), + life.fingerprint.clone(), + ), + ) + }); // Rebuild closure: re-open the source on a mid-stream capture loss, RE-DETECTING the live // compositor — so a Desktop<->Game switch (at the client's fixed mode) is FOLLOWED in place // without a Moonlight reconnect. (A resolution change can't be followed mid-stream on // GameStream — WxH is locked at ANNOUNCE — but a session toggle keeps the negotiated mode.) - let rebuild = || open_gs_virtual_source(cfg, app).map(|(c, _)| c); + let rebuild = + || open_gs_virtual_source(cfg, app, target.as_ref(), &life.quit).map(|(c, _)| c); return stream_body( &mut capturer, Some(&rebuild), @@ -366,6 +478,64 @@ fn run( result } +/// What the compat plane resolved about the app a client launched: identity for the lease, the status +/// surface and the `game.*` events; the signals that recognize the running game; and the command to +/// run it. +struct GsApp { + game: crate::gamelease::GameRef, + detect: crate::library::DetectSpec, + /// The resolved shell command. `Some` on Linux, which runs it itself; `None` for a Windows + /// library title, which launches by id through the interactive-session spawner instead. + command: Option, +} + +/// Resolve a `/launch`ed catalog entry against the host's **own** library — the client sends only an +/// appid, and everything the session does with the title afterwards comes from what the host knows +/// about it. +/// +/// A library pick carries its store's detect signals. An operator-typed `apps.json` command has no +/// library entry behind it, so its title is the whole identity and its own first token — when that is +/// an absolute executable — the only signal there is; the host spawns it directly anyway, so the child +/// is the primary tracking either way. `None` = nothing to launch (Desktop, or an unresolvable entry). +fn resolve_gs_app(app: Option<&super::apps::AppEntry>) -> Option { + let app = app?; + if let Some(id) = app.library_id.as_deref() { + match crate::library::resolve_launch(id) { + Some(t) => { + return Some(GsApp { + game: t.game, + detect: t.detect, + command: t.command, + }) + } + // Same fallback (and same warning) the plain command resolution has always had, so a + // client picking a stale title sees why nothing started. + None => tracing::warn!( + launch_id = id, + "requested launch id not in this host's library (or no launch recipe) — ignoring" + ), + } + } + let cmd = app + .cmd + .as_deref() + .map(str::trim) + .filter(|c| !c.is_empty())?; + Some(GsApp { + game: crate::gamelease::GameRef { + id: None, + store: None, + title: if app.title.trim().is_empty() { + cmd.to_string() + } else { + app.title.clone() + }, + }, + detect: crate::library::spec_from_command(cmd), + command: Some(cmd.to_string()), + }) +} + /// Open the virtual-display video source for a GameStream session: pick the LIVE compositor + normalize /// the session env (apply_session_env/apply_input_env — gamescope ATTACH/resize, KWin/Mutter /// retargeting) exactly like the native plane (native.rs resolve_compositor), create a virtual @@ -377,6 +547,12 @@ fn run( fn open_gs_virtual_source( cfg: StreamConfig, app: Option<&super::apps::AppEntry>, + // The resolved title (see [`resolve_gs_app`]) — its command is what a bare-spawn gamescope nests + // and what decides whether this session is a dedicated game session at all. Resolved once by the + // caller, so a mid-stream rebuild can't re-resolve to something different. + launch: Option<&GsApp>, + // The session's deliberate-quit flag, handed to the display's keep-alive lease. + quit: &Arc, ) -> Result<(Box, crate::vdisplay::Compositor)> { let compositor = if let Some(c) = app.and_then(|a| a.compositor) { c @@ -403,11 +579,7 @@ fn open_gs_virtual_source( // id / apps.json command), under `game_session=dedicated` with gamescope available, gets its // own headless gamescope spawn at the client mode — same routing as the native plane. Gate on // the resolved command so an unresolvable entry falls back to auto routing (review #9). - let has_launch = crate::library::resolve_session_launch( - app.and_then(|a| a.library_id.as_deref()), - app.and_then(|a| a.cmd.as_deref()), - ) - .is_some(); + let has_launch = launch.and_then(|t| t.command.as_deref()).is_some(); if crate::vdisplay::wants_dedicated_game_session(has_launch) { crate::vdisplay::apply_input_env(crate::vdisplay::Compositor::Gamescope, true); crate::vdisplay::Compositor::Gamescope @@ -428,10 +600,7 @@ fn open_gs_virtual_source( // library title exactly like an apps.json command (it previously nested only `cmd`, silently // dropping library picks). #[cfg(target_os = "linux")] - vd.set_launch_command(crate::library::resolve_session_launch( - app.and_then(|a| a.library_id.as_deref()), - app.and_then(|a| a.cmd.as_deref()), - )); + vd.set_launch_command(launch.and_then(|t| t.command.clone())); #[cfg(not(target_os = "linux"))] vd.set_launch_command(app.and_then(|a| a.cmd.clone())); // Serialize with the punktfunk/1 plane's IDD-push setup dance (Goal-1 §2.5). A GameStream @@ -461,10 +630,11 @@ fn open_gs_virtual_source( height: cfg.height, refresh_hz: cfg.fps, }, - // GameStream's deliberate quit is the Moonlight "Quit App" (nvhttp `h_cancel`), not a QUIC - // close code — wiring it to skip-linger is a follow-up, so this path keeps normal keep-alive - // (a fresh, never-set flag). - std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + // GameStream's deliberate quit is the Moonlight "Quit App" (nvhttp `h_cancel`), the + // management stop, or the launched game exiting — not a QUIC close code. All three set the + // session's quit flag ([`super::AppState::quit`]), so the display skips its keep-alive linger + // for a stop the way it does on the native plane, and only a real drop lingers. + quit.clone(), None, // fresh session — no display superseded ) .context("create virtual output at client resolution")?; @@ -1229,6 +1399,49 @@ fn stream_body( mod tests { use super::*; + fn entry(title: &str, cmd: Option<&str>) -> super::super::apps::AppEntry { + super::super::apps::AppEntry { + id: 1, + title: title.to_string(), + compositor: None, + cmd: cmd.map(str::to_string), + library_id: None, + prep: Vec::new(), + } + } + + /// What the compat plane decides to track. The negative cases are the load-bearing ones: a + /// Desktop entry launches nothing, so it must take no lease at all — the feature has to stay + /// completely inert for a plain desktop stream. + #[test] + fn only_an_entry_that_launches_something_gets_tracked() { + // Nothing selected (no `/launch` app) → nothing to track. + assert!(resolve_gs_app(None).is_none()); + // Desktop: a title with no command and no library id. + assert!(resolve_gs_app(Some(&entry("Desktop", None))).is_none()); + // A blank command is the same as none. + assert!(resolve_gs_app(Some(&entry("Blank", Some(" ")))).is_none()); + + // An operator-typed apps.json command: no library entry behind it, so the title is the whole + // identity and there is no store-qualified id for the console to match box art on. + let t = resolve_gs_app(Some(&entry( + "Steam Big Picture", + Some(" steam -gamepadui "), + ))) + .expect("a command entry is tracked"); + assert_eq!(t.command.as_deref(), Some("steam -gamepadui")); + assert_eq!(t.game.id, None); + assert_eq!(t.game.store, None); + assert_eq!(t.game.title, "Steam Big Picture"); + // `steam` is a PATH lookup, not an absolute executable, so nothing is asserted about the + // process — the host's own child is what tracks it (see `library::spec_from_command`). + assert!(t.detect.is_empty()); + + // A titleless entry still shows up as something a human can read. + let t = resolve_gs_app(Some(&entry("", Some("/opt/game/run")))).expect("tracked"); + assert_eq!(t.game.title, "/opt/game/run"); + } + /// End-to-end check of the send thread: batches pushed on the channel arrive, complete and /// byte-identical, at a peer socket via the paced sendmmsg path. #[test] diff --git a/crates/punktfunk-host/src/library/launch.rs b/crates/punktfunk-host/src/library/launch.rs index a24339a9..d289bdf0 100644 --- a/crates/punktfunk-host/src/library/launch.rs +++ b/crates/punktfunk-host/src/library/launch.rs @@ -8,24 +8,6 @@ use super::*; #[cfg(windows)] use super::{epic::epic_launch_uri, gog::gog_spawn}; -/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) to the shell -/// command the host should run for it — looked up in the host's OWN library so a client can only -/// pick an existing title, never inject a command. `None` = unknown id, no launch recipe, or a -/// malformed Steam appid. -/// -/// **Linux only**: the resolved command is run nested inside the per-session gamescope. On Windows -/// there is no gamescope to nest into; the host launches a title into the interactive user session -/// via [`launch_title`] instead. -/// -/// - `steam_appid` → `steam steam://rungameid/` (appid validated as digits). -/// - `command` → the stored command verbatim. This string comes from the host's own custom store -/// (added by the host operator via the admin UI), never from the client, so it is trusted. -#[cfg(not(windows))] -pub fn launch_command(id: &str) -> Option { - let spec = all_games().into_iter().find(|g| g.id == id)?.launch?; - 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. /// @@ -42,11 +24,17 @@ pub struct LaunchTarget { pub command: Option, } -/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`) against the host's -/// **own** library. `None` = unknown id, or — on Linux — a title with no runnable recipe. +/// Resolve a store-qualified library id (as sent by a client in `Hello::launch`, or carried on a +/// GameStream catalog entry) against the host's **own** library — so a client can only pick an +/// existing title, never inject a command. `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. +/// This is the single lookup, shared by both planes: the client sends only an id, and everything the +/// session does with the title afterwards comes from what the host itself knows about it. +/// +/// **Linux**: the resolved command is run by the host (nested into a per-session gamescope, or +/// spawned into the live session). **Windows** has no gamescope to nest into and resolves the +/// concrete process at launch time instead, via [`launch_title`]. pub fn resolve_launch(id: &str) -> Option { let entry = all_games().into_iter().find(|g| g.id == id)?; let game = crate::gamelease::GameRef { @@ -79,7 +67,11 @@ pub fn resolve_launch(id: &str) -> Option { } /// 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). +/// [`resolve_launch`], split out so the appid-validation can be tested without a Steam install). +/// +/// - `steam_appid` → `steam steam://rungameid/` (appid validated as digits). +/// - `command` → the stored command verbatim. This string comes from the host's own custom store +/// (added by the host operator via the admin UI), never from the client, so it is trusted. #[cfg(not(windows))] fn command_for(spec: &LaunchSpec) -> Option { match spec.kind.as_str() { @@ -99,7 +91,7 @@ fn command_for(spec: &LaunchSpec) -> Option { } /// Windows: launch a store-qualified library id into the **interactive user session** — the Windows -/// analogue of the Linux gamescope-nested [`launch_command`]. The id is resolved against the host's +/// analogue of the Linux gamescope-nested [`resolve_launch`]. The id is resolved against the host's /// OWN library (the client never sends a command), mapped to a concrete process by /// [`windows_launch_for`], and spawned via [`crate::interactive::spawn_in_active_session`]. /// @@ -218,7 +210,7 @@ pub fn launch_gamestream_command(cmd: &str) -> Result<()> { /// on the `AppEntry`, resolved from the numeric Moonlight appid) into the interactive Windows user /// session ([`launch_title`]). The id is resolved against the host's OWN library, so a client can /// only ever pick an existing title — never inject a command. Linux resolves the id via -/// [`launch_command`] and goes through [`launch_session_command`] instead. +/// [`resolve_launch`] and goes through [`launch_session_command`] instead. #[cfg(windows)] pub fn launch_gamestream_library(id: &str) -> Result<()> { launch_title(id) @@ -242,7 +234,7 @@ pub struct SpawnedLaunch { /// 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 -/// (a library id via [`launch_command`], or an operator-typed apps.json/custom command) — never a +/// (a library id via [`resolve_launch`], or an operator-typed apps.json/custom command) — never a /// client-sent string. Best-effort by contract: a failure leaves the user on the (streamed) /// desktop/session rather than tearing the stream down. /// @@ -293,26 +285,6 @@ pub fn launch_session_command( }) } -/// Resolve the launch command for a session app selection on Linux: a store-qualified library id -/// (from either plane) wins, else the operator-typed command. `None` = nothing to launch (or an -/// unknown/recipe-less id — warned, so a client picking a stale title sees why nothing started). -#[cfg(target_os = "linux")] -pub fn resolve_session_launch(library_id: Option<&str>, command: Option<&str>) -> Option { - if let Some(id) = library_id { - match launch_command(id) { - Some(cmd) => return Some(cmd), - None => tracing::warn!( - launch_id = id, - "requested launch id not in this host's library (or no launch recipe) — ignoring" - ), - } - } - command - .map(str::trim) - .filter(|c| !c.is_empty()) - .map(str::to_string) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/punktfunk-host/src/mgmt/session.rs b/crates/punktfunk-host/src/mgmt/session.rs index 1081fafa..e3c57b08 100644 --- a/crates/punktfunk-host/src/mgmt/session.rs +++ b/crates/punktfunk-host/src/mgmt/session.rs @@ -22,7 +22,7 @@ use std::sync::atomic::Ordering; ) )] pub(crate) async fn stop_session(State(st): State>) -> StatusCode { - let was_streaming = st.app.end_session("management API stop"); + let was_streaming = st.app.quit_session("management API stop"); // 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(); diff --git a/crates/punktfunk-host/src/native/handshake.rs b/crates/punktfunk-host/src/native/handshake.rs index 2a9da219..c802a536 100644 --- a/crates/punktfunk-host/src/native/handshake.rs +++ b/crates/punktfunk-host/src/native/handshake.rs @@ -205,12 +205,14 @@ pub(super) async fn negotiate( // (gamescope available) gets its own headless gamescope spawn at the client mode. Gate on // whether the launch id actually RESOLVES to a command in the host's library — an unknown // id must fall back to normal auto routing, not a blank "sleep infinity" gamescope - // (review #9). (dedicated is Linux-only; the resolver is the non-Windows launch_command.) + // (review #9). (dedicated is Linux-only, and only there does `resolve_launch` carry a + // command — on Windows the concrete process is resolved at launch time instead.) #[cfg(not(target_os = "windows"))] let has_resolvable_launch = hello .launch .as_deref() - .and_then(crate::library::launch_command) + .and_then(crate::library::resolve_launch) + .and_then(|t| t.command) .is_some(); #[cfg(target_os = "windows")] let has_resolvable_launch = false; diff --git a/crates/punktfunk-host/src/native/stream.rs b/crates/punktfunk-host/src/native/stream.rs index 01434d53..0be9248a 100644 --- a/crates/punktfunk-host/src/native/stream.rs +++ b/crates/punktfunk-host/src/native/stream.rs @@ -879,30 +879,6 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender, stop: Arc, - /// Hex client fingerprint, so a reconnecting client can reclaim its own game and nothing else. - fingerprint: Option, -} - -impl Drop for GameLifetime { - fn drop(&mut self) { - crate::gamelease::on_session_end( - &self.lease, - self.quit.load(Ordering::SeqCst), - self.fingerprint.as_deref(), - ); - } -} - /// All per-session inputs for [`virtual_stream`], bundled so the session entry /// is one moved value instead of a 13-positional-argument `#[allow(too_many_arguments)]` signature /// (Goal-1 stage 4, plan §2.4). Everything is **owned** — the receivers move in (`virtual_stream` is their @@ -1328,10 +1304,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option, } -/// Every launched game the host currently knows about: the live sessions' games first, then any game -/// whose session has ended and which is waiting out its reconnect window before being ended. +/// The compat plane's launched game, while it has one. /// -/// The two sources are deliberately separate — a grace-pending game has no session to attribute it +/// 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>> { + static SLOT: OnceLock>>> = 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) -> 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 { let mut out: Vec = registry() @@ -244,6 +272,24 @@ pub fn games() -> Vec { }) }) .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() @@ -294,3 +340,57 @@ pub fn force_idr_all() { 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"); + } +}