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:
@@ -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))]
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user