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
+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");
}
}