refactor(gamestream): extract + unit-test gamestream_admission (Stage 4)
Pull the GameStream mode-conflict decision out of h_launch into a pure gamestream_admission(live, req_fp, policy) -> GsDecision so the 503/join/take-over logic is unit-tested (no live session / same-client → Serve; different client → Reject/Join/Serve per policy; anonymous requester treated as different) — the GameStream path can't be driven without a Moonlight client, so this covers the logic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -138,44 +138,32 @@ async fn h_launch(
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mode-conflict ADMISSION (Stage 4). GameStream is single-session (`st.launch`), so when a
|
// Mode-conflict ADMISSION (Stage 4) — GameStream is single-session (`st.launch`), so a DIFFERENT
|
||||||
// DIFFERENT paired client launches while a session is live, the `mode_conflict` policy governs:
|
// paired client launching while a session is live is governed by `mode_conflict` (see
|
||||||
// `reject` → 503 (Moonlight shows "host is busy"); `join` → serve at the live session's mode;
|
// [`gamestream_admission`]). Snapshot the live owner + mode (Copy) so the lock isn't held over it.
|
||||||
// `steal`/`separate` (GameStream can't do separate) / unconfigured → take over (today's last-wins).
|
|
||||||
// A same-client re-launch is never a conflict.
|
|
||||||
let mut forced_mode: Option<(u32, u32, u32)> = None;
|
let mut forced_mode: Option<(u32, u32, u32)> = None;
|
||||||
{
|
{
|
||||||
let cur = st.launch.lock().unwrap();
|
let live = st
|
||||||
if let Some(s) = cur.as_ref() {
|
.launch
|
||||||
let different = match (&s.owner_fp, &req_fp) {
|
.lock()
|
||||||
(Some(owner), Some(req)) => owner != req,
|
.unwrap()
|
||||||
_ => true, // unknown owner or anonymous requester → treat as a different client
|
.as_ref()
|
||||||
};
|
.map(|s| (s.owner_fp, (s.width, s.height, s.fps)));
|
||||||
if different {
|
let conflict = crate::vdisplay::policy::prefs()
|
||||||
use crate::vdisplay::policy::{self, ModeConflict};
|
.configured_effective()
|
||||||
let conflict = policy::prefs()
|
.map(|e| e.mode_conflict)
|
||||||
.configured_effective()
|
.unwrap_or(crate::vdisplay::policy::ModeConflict::Separate);
|
||||||
.map(|e| e.mode_conflict)
|
match gamestream_admission(live, req_fp, conflict) {
|
||||||
.unwrap_or(ModeConflict::Separate);
|
GsDecision::Serve => {}
|
||||||
match conflict {
|
GsDecision::Join((w, h, f)) => {
|
||||||
ModeConflict::Reject => {
|
forced_mode = Some((w, h, f));
|
||||||
tracing::warn!(
|
tracing::info!("GameStream launch JOIN — admitting at the live session's mode {w}x{h}@{f}");
|
||||||
"GameStream launch REJECTED — host busy streaming {}x{}@{} to another client",
|
}
|
||||||
s.width, s.height, s.fps
|
GsDecision::Reject => {
|
||||||
);
|
tracing::warn!(
|
||||||
return (StatusCode::SERVICE_UNAVAILABLE, xml(error_xml())).into_response();
|
"GameStream launch REJECTED — host busy (mode_conflict=reject, session owned by another client)"
|
||||||
}
|
);
|
||||||
ModeConflict::Join => {
|
return (StatusCode::SERVICE_UNAVAILABLE, xml(error_xml())).into_response();
|
||||||
forced_mode = Some((s.width, s.height, s.fps));
|
|
||||||
tracing::info!(
|
|
||||||
"GameStream launch JOIN — admitting at the live session's mode {}x{}@{}",
|
|
||||||
s.width, s.height, s.fps
|
|
||||||
);
|
|
||||||
}
|
|
||||||
ModeConflict::Steal | ModeConflict::Separate => tracing::info!(
|
|
||||||
"GameStream launch STEAL — a different client is taking over the live session"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,6 +267,48 @@ fn parse_mode(mode: &str) -> Option<(u32, u32, u32)> {
|
|||||||
Some((w, h, fps))
|
Some((w, h, fps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A live GameStream session's `(owner cert fingerprint, mode)` snapshot for [`gamestream_admission`].
|
||||||
|
type LiveGs = (Option<[u8; 32]>, (u32, u32, u32));
|
||||||
|
|
||||||
|
/// The outcome of [`gamestream_admission`].
|
||||||
|
enum GsDecision {
|
||||||
|
/// Proceed with the launch (no live session, a same-client re-launch, or `steal`/`separate`
|
||||||
|
/// taking over the single session).
|
||||||
|
Serve,
|
||||||
|
/// Serve at the live session's mode (`join` — honest-downgrade).
|
||||||
|
Join((u32, u32, u32)),
|
||||||
|
/// Refuse with a 503 (`reject`).
|
||||||
|
Reject,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The GameStream single-session mode-conflict decision (Stage 4, pure so it's unit-tested). `live`
|
||||||
|
/// is the currently-live session's `(owner_fp, mode)` (`None` ⇒ no session live). No session or a
|
||||||
|
/// same-client re-launch ⇒ `Serve`; a DIFFERENT client launching applies `policy` — `reject` ⇒
|
||||||
|
/// `Reject`, `join` ⇒ `Join` the live mode, `steal`/`separate` (GameStream has no separate) ⇒ `Serve`
|
||||||
|
/// (take over the one session).
|
||||||
|
fn gamestream_admission(
|
||||||
|
live: Option<LiveGs>,
|
||||||
|
req_fp: Option<[u8; 32]>,
|
||||||
|
policy: crate::vdisplay::policy::ModeConflict,
|
||||||
|
) -> GsDecision {
|
||||||
|
use crate::vdisplay::policy::ModeConflict;
|
||||||
|
let Some((owner, mode)) = live else {
|
||||||
|
return GsDecision::Serve;
|
||||||
|
};
|
||||||
|
let different = match (owner, req_fp) {
|
||||||
|
(Some(o), Some(r)) => o != r,
|
||||||
|
_ => true, // unknown owner or anonymous requester → treat as a different client
|
||||||
|
};
|
||||||
|
if !different {
|
||||||
|
return GsDecision::Serve;
|
||||||
|
}
|
||||||
|
match policy {
|
||||||
|
ModeConflict::Reject => GsDecision::Reject,
|
||||||
|
ModeConflict::Join => GsDecision::Join(mode),
|
||||||
|
ModeConflict::Steal | ModeConflict::Separate => GsDecision::Serve,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn session_url_xml(st: &AppState, tag: &str) -> String {
|
fn session_url_xml(st: &AppState, tag: &str) -> String {
|
||||||
format!(
|
format!(
|
||||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
|
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
|
||||||
@@ -405,4 +435,43 @@ mod tests {
|
|||||||
"a non-pinned cert stays rejected"
|
"a non-pinned cert stays rejected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gamestream_admission_policy_matrix() {
|
||||||
|
use crate::vdisplay::policy::ModeConflict;
|
||||||
|
let (a, b) = ([1u8; 32], [2u8; 32]);
|
||||||
|
let live = Some((Some(a), (2560, 1440, 120)));
|
||||||
|
// No live session → always Serve.
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(None, Some(b), ModeConflict::Reject),
|
||||||
|
GsDecision::Serve
|
||||||
|
));
|
||||||
|
// Same-client re-launch → Serve regardless of policy.
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(live, Some(a), ModeConflict::Reject),
|
||||||
|
GsDecision::Serve
|
||||||
|
));
|
||||||
|
// A DIFFERENT client applies the policy.
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(live, Some(b), ModeConflict::Reject),
|
||||||
|
GsDecision::Reject
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(live, Some(b), ModeConflict::Join),
|
||||||
|
GsDecision::Join((2560, 1440, 120))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(live, Some(b), ModeConflict::Steal),
|
||||||
|
GsDecision::Serve
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(live, Some(b), ModeConflict::Separate),
|
||||||
|
GsDecision::Serve
|
||||||
|
));
|
||||||
|
// Anonymous requester (no cert presented) is treated as a different client.
|
||||||
|
assert!(matches!(
|
||||||
|
gamestream_admission(live, None, ModeConflict::Reject),
|
||||||
|
GsDecision::Reject
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user