feat(vdisplay): finish Stage 4 — typed reject, Windows join-default, GameStream 503

Completes the mode-conflict admission surface deferred from the initial Stage 4:

- REJECT now delivers the reason to the client: punktfunk/1 closes the QUIC
  connection with a distinct BUSY code (0x42) + the 'host busy: streaming WxH@Hz to
  <client>' string, which the client reads from ApplicationClosed (validated on
  loopback: the probe logs 'closed by peer: host busy … (code 66)').
- Windows default: separate (incl. the unconfigured default) resolves to JOIN — the
  Windows native host admits a second client at the live mode instead of the old
  silent last-wins reconfigure of the shared monitor (release-note behavior fix; the
  reconfigure is now opt-in as steal). separate stays multi-view on Linux.
- GameStream 503: h_launch tracks the session owner fp (LaunchSession.owner_fp, kept
  [u8;32] for Copy) and applies the policy when a DIFFERENT paired client launches —
  reject → 503 (Moonlight 'host busy'), join → serve the live mode, steal/separate →
  take over. Same-client re-launch is never a conflict.

Native reject-reason loopback-validated; Windows join-default pending .173 rebuild;
GameStream 503 pending a Moonlight client (can't drive /launch autonomously).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 10:34:49 +00:00
parent 42b1158ea7
commit cfad0cf7ee
5 changed files with 101 additions and 8 deletions
@@ -108,6 +108,11 @@ pub struct LaunchSession {
/// unpaired RTSP peer cannot ride a paired client's launch (security-review 2026-06-28 #4).
/// `None` if the address could not be captured (then RTSP falls back to launch-present only).
pub peer_ip: Option<std::net::IpAddr>,
/// SHA-256 cert fingerprint of the paired client that owns this session — mode-conflict admission
/// (Stage 4) compares it against a launching client to tell a same-client re-launch (always
/// allowed) from a DIFFERENT client (subject to the `mode_conflict` policy). `[u8; 32]` keeps
/// [`LaunchSession`] `Copy`; `None` when the peer cert couldn't be read.
pub owner_fp: Option<[u8; 32]>,
}
/// Shared control-plane state used as the axum app state.
+60 -4
View File
@@ -126,15 +126,70 @@ async fn h_launch(
peer: Option<Extension<PeerCertFingerprint>>,
addr: Option<Extension<PeerAddr>>,
Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse {
) -> Response {
if !peer_is_paired(&peer, &st) {
tracing::warn!("launch rejected — client is not paired");
return xml(error_xml());
return xml(error_xml()).into_response();
}
let req_fp: Option<[u8; 32]> = match &peer {
Some(Extension(PeerCertFingerprint(Some(fp)))) => {
hex::decode(fp).ok().and_then(|v| <[u8; 32]>::try_from(v).ok())
}
_ => None,
};
// Mode-conflict ADMISSION (Stage 4). GameStream is single-session (`st.launch`), so when a
// DIFFERENT paired client launches while a session is live, the `mode_conflict` policy governs:
// `reject` → 503 (Moonlight shows "host is busy"); `join` → serve at the live session's mode;
// `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 cur = st.launch.lock().unwrap();
if let Some(s) = cur.as_ref() {
let different = match (&s.owner_fp, &req_fp) {
(Some(owner), Some(req)) => owner != req,
_ => true, // unknown owner or anonymous requester → treat as a different client
};
if different {
use crate::vdisplay::policy::{self, ModeConflict};
let conflict = policy::prefs()
.configured_effective()
.map(|e| e.mode_conflict)
.unwrap_or(ModeConflict::Separate);
match conflict {
ModeConflict::Reject => {
tracing::warn!(
"GameStream launch REJECTED — host busy streaming {}x{}@{} to another client",
s.width, s.height, s.fps
);
return (StatusCode::SERVICE_UNAVAILABLE, xml(error_xml())).into_response();
}
ModeConflict::Join => {
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"
),
}
}
}
}
match launch(&st, &q) {
Ok(mut session) => {
// Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP.
session.peer_ip = addr.map(|Extension(PeerAddr(a))| a.ip());
session.owner_fp = req_fp;
if let Some((w, h, f)) = forced_mode {
session.width = w;
session.height = h;
session.fps = f;
}
*st.launch.lock().unwrap() = Some(session);
tracing::info!(
w = session.width,
@@ -144,11 +199,11 @@ async fn h_launch(
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
st.host.local_ip
);
xml(session_url_xml(&st, "gamesession"))
xml(session_url_xml(&st, "gamesession")).into_response()
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "launch failed");
xml(error_xml())
xml(error_xml()).into_response()
}
}
}
@@ -211,6 +266,7 @@ fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession>
fps,
appid,
peer_ip: None, // set by `h_launch` from the verified HTTPS peer address
owner_fp: None, // set by `h_launch` from the verified HTTPS peer cert fingerprint
})
}
+2
View File
@@ -2498,6 +2498,7 @@ mod tests {
fps: 120,
appid: 1,
peer_ip: None,
owner_fp: None,
});
state.streaming.store(true, Ordering::SeqCst);
@@ -2624,6 +2625,7 @@ mod tests {
fps: 60,
appid: 1,
peer_ip: None,
owner_fp: None,
});
let del = axum::http::Request::delete("/api/v1/session")
+10
View File
@@ -341,6 +341,11 @@ pub(crate) async fn serve(
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal,
/// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct
/// code lets a client tell "host busy" apart from a transport failure.
const REJECT_BUSY_CODE: u32 = 0x42;
/// Encoder bitrate (kbps) the host falls back to when the client expresses no preference
/// (`Hello::bitrate_kbps == 0`) — the long-standing 20 Mbps default. A client that knows its
/// link (e.g. after a speed test) requests an explicit rate instead.
@@ -718,6 +723,11 @@ async fn serve_session(
}
Admission::Reject(reason) => {
tracing::warn!("mode-conflict: REJECT — {reason}");
// Deliver the reason to the client as a TYPED refusal: close the QUIC connection
// with the BUSY application code + the reason bytes, which the client reads from
// the `ApplicationClosed` error (so its UI can say "host is streaming X to <name>")
// instead of seeing a bare connection drop. Then end the handshake.
conn.close(REJECT_BUSY_CODE.into(), reason.as_bytes());
anyhow::bail!("{reason}");
}
}
@@ -91,13 +91,33 @@ pub fn decide(
}
/// Resolve the effective decision for a connecting session: read the console `mode_conflict` policy
/// (default `Separate` when unconfigured — no behavior change) and [`decide`] against the live set.
/// (default `Separate` when unconfigured) and [`decide`] against the live set.
///
/// **Windows** can't create SEPARATE virtual displays until the multi-monitor stage (§6.6), so a
/// `separate` outcome — including the **unconfigured default** — resolves to `join` (admit at the live
/// mode) rather than the old silent last-wins reconfigure of the shared monitor. This is the deliberate
/// Windows default change (release-note behavior fix); `steal` remains the way to force the new mode.
pub fn admit(req_identity: Option<[u8; 32]>) -> Admission {
let conflict = policy::prefs()
#[allow(unused_mut)]
let mut conflict = policy::prefs()
.configured_effective()
.map(|e| e.mode_conflict)
.unwrap_or(ModeConflict::Separate);
decide(conflict, req_identity, &table().lock().unwrap())
let live = table().lock().unwrap();
#[cfg(windows)]
if matches!(conflict, ModeConflict::Separate) {
if live
.iter()
.any(|s| !same_client(s.identity, req_identity))
{
tracing::warn!(
"mode_conflict=separate is not yet supported on Windows (multi-monitor is §6.6) — \
JOINing the live session's mode instead"
);
}
conflict = ModeConflict::Join;
}
decide(conflict, req_identity, &live)
}
/// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call