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.
This commit is contained in:
2026-07-26 18:28:13 +02:00
parent ed4e3d3ab6
commit 98121ccd53
10 changed files with 497 additions and 118 deletions
+45
View File
@@ -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<AtomicBool>,
/// Hex client fingerprint, so a reconnecting client can reclaim its own game and nothing else.
fingerprint: Option<String>,
}
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<AtomicBool>, fingerprint: Option<String>) -> Self {
Self {
lease,
quit,
fingerprint,
}
}
/// The lease's shared state, for the status surface.
pub fn shared(&self) -> Arc<LeaseShared> {
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::*;
@@ -156,6 +156,15 @@ pub struct AppState {
pub audio_params: std::sync::Mutex<audio::AudioParams>,
/// True while the video stream thread is running (also its keep-running flag).
pub streaming: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// 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<std::sync::atomic::AtomicBool>,
/// True while the audio stream thread is running (also its keep-running flag).
pub audio_streaming: std::sync::Arc<std::sync::atomic::AtomicBool>,
/// 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))]
+11 -3
View File
@@ -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("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\"><cancel>1</cancel></root>\n".to_string())
}
@@ -279,6 +279,20 @@ fn handle_request(req: &Request, state: &Arc<AppState>, peer: Option<SocketAddr>
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"),
+248 -35
View File
@@ -46,6 +46,21 @@ pub type CapturerSlot = Arc<std::sync::Mutex<Option<PooledCapturer>>>;
/// control plane and drained by the video thread (see [`AppState::rfi_range`](super::AppState)).
pub type RfiSlot = Arc<std::sync::Mutex<Option<(i64, i64)>>>;
/// 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<AtomicBool>,
/// 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<String>,
/// 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<crate::stats_recorder::StatsRecorder>,
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<crate::stats_recorder::StatsRecorder>,
// 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<String>,
}
/// 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<GsApp> {
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<AtomicBool>,
) -> Result<(Box<dyn Capturer>, 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]
+18 -46
View File
@@ -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>` (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<String> {
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<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.
/// 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<LaunchTarget> {
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<LaunchTarget> {
}
/// 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>` (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<String> {
match spec.kind.as_str() {
@@ -99,7 +91,7 @@ fn command_for(spec: &LaunchSpec) -> Option<String> {
}
/// 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<String> {
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::*;
+1 -1
View File
@@ -22,7 +22,7 @@ use std::sync::atomic::Ordering;
)
)]
pub(crate) async fn stop_session(State(st): State<Arc<MgmtState>>) -> 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();
@@ -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;
+6 -28
View File
@@ -879,30 +879,6 @@ 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
@@ -1328,10 +1304,12 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// 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 _game_life = game_lease.map(|lease| {
crate::gamelease::SessionGuard::new(
lease,
quit.clone(),
endpoint::peer_fingerprint(&conn).map(hex::encode),
)
});
let perf = pf_host_config::config().perf;
+103 -3
View File
@@ -220,10 +220,38 @@ pub struct GameSnapshot {
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 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<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()
@@ -244,6 +272,24 @@ pub fn games() -> Vec<GameSnapshot> {
})
})
.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");
}
}