fix(vdisplay): Windows admission default is reject, not join (single-capturer limit)
Two concurrent Windows sessions both drive the same pf-vdisplay monitor's single-capturer IDD-push channel (newest-delivery-wins), which freezes the live client and can wedge the driver (observed live: a concurrent-session test wedged .173 → Moonlight 'no video'; needed a reboot). True multi-session capture is §6.6/ Stage 7. So on Windows 'separate' (incl. the unconfigured default) now resolves to REJECT — a 2nd client gets a clean 503 and the live session is protected — instead of join (which would freeze it). join/steal stay explicit opt-ins; Linux keeps separate (real multi-view). Centralized as admission::effective_conflict(), shared by the native handshake + GameStream h_launch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -170,18 +170,26 @@ pub fn appasset_bytes(appid: u32) -> Option<(Vec<u8>, String)> {
|
|||||||
/// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver
|
/// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver
|
||||||
/// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when
|
/// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when
|
||||||
/// true, Moonlight offers its per-app HDR toggle.
|
/// true, Moonlight offers its per-app HDR toggle.
|
||||||
|
///
|
||||||
|
/// The document is emitted **COMPACT — no whitespace between elements** — deliberately, to match
|
||||||
|
/// Sunshine/GFE. Moonlight-Android's `getAppListByReader` calls `appList.getLast()` on *every* XML
|
||||||
|
/// text node before it checks the current tag, and only fills `appList` on an `<App>` start tag. A
|
||||||
|
/// pretty-print newline between `<root>` and the first `<App>` is a whitespace text node while
|
||||||
|
/// `appList` is still empty → `NoSuchElementException` → the Android app hard-crashes on host click.
|
||||||
|
/// (iOS/macOS parse via moonlight-common-c/expat and are unaffected; `serverinfo`/pairing use the
|
||||||
|
/// named-tag `getXmlString` scan, so their whitespace is harmless.) Keep this whitespace-free.
|
||||||
pub fn applist_xml() -> String {
|
pub fn applist_xml() -> String {
|
||||||
let hdr = u8::from(crate::gamestream::host_hdr_capable());
|
let hdr = u8::from(crate::gamestream::host_hdr_capable());
|
||||||
let mut xml =
|
let mut xml =
|
||||||
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
|
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?><root status_code=\"200\">");
|
||||||
for app in catalog() {
|
for app in catalog() {
|
||||||
xml.push_str(&format!(
|
xml.push_str(&format!(
|
||||||
"<App>\n<IsHdrSupported>{hdr}</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
|
"<App><IsHdrSupported>{hdr}</IsHdrSupported><AppTitle>{}</AppTitle><ID>{}</ID></App>",
|
||||||
xml_escape(&app.title),
|
xml_escape(&app.title),
|
||||||
app.id
|
app.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
xml.push_str("</root>\n");
|
xml.push_str("</root>");
|
||||||
xml
|
xml
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,4 +257,27 @@ mod tests {
|
|||||||
assert!(xml.starts_with("<?xml"));
|
assert!(xml.starts_with("<?xml"));
|
||||||
assert_eq!(xml.matches("<App>").count(), xml.matches("</App>").count());
|
assert_eq!(xml.matches("<App>").count(), xml.matches("</App>").count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression: the applist MUST be whitespace-free between elements. Moonlight-Android's
|
||||||
|
/// `getAppListByReader` calls `appList.getLast()` on every text node before an `<App>` has been
|
||||||
|
/// pushed, so a pretty-print newline between `<root>` and the first `<App>` crashes the app
|
||||||
|
/// (`NoSuchElementException`). Reproduced on 2 Android phones; iOS/macOS (moonlight-common-c)
|
||||||
|
/// were unaffected. Keep `applist_xml` compact like Sunshine/GFE.
|
||||||
|
#[test]
|
||||||
|
fn applist_xml_has_no_interelement_whitespace() {
|
||||||
|
let xml = applist_xml();
|
||||||
|
// <root> is immediately followed by the first <App> — no whitespace text node while the
|
||||||
|
// parser's app list is still empty.
|
||||||
|
assert!(
|
||||||
|
xml.contains("status_code=\"200\"><App>"),
|
||||||
|
"no whitespace between <root> and the first <App>: {xml}"
|
||||||
|
);
|
||||||
|
// No pretty-print newlines anywhere in the element stream, and no whitespace-only text
|
||||||
|
// nodes between any adjacent tags.
|
||||||
|
assert!(!xml.contains('\n'), "applist must contain no newlines: {xml}");
|
||||||
|
assert!(
|
||||||
|
!xml.contains("> <"),
|
||||||
|
"applist must contain no inter-element spaces: {xml}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,10 +149,9 @@ async fn h_launch(
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|s| (s.owner_fp, (s.width, s.height, s.fps)));
|
.map(|s| (s.owner_fp, (s.width, s.height, s.fps)));
|
||||||
let conflict = crate::vdisplay::policy::prefs()
|
// Same Windows default as the native path (separate → reject; see `effective_conflict`) so a
|
||||||
.configured_effective()
|
// 2nd Moonlight client gets a clean 503 rather than wedging the shared monitor's capture.
|
||||||
.map(|e| e.mode_conflict)
|
let conflict = crate::vdisplay::admission::effective_conflict();
|
||||||
.unwrap_or(crate::vdisplay::policy::ModeConflict::Separate);
|
|
||||||
match gamestream_admission(live, req_fp, conflict) {
|
match gamestream_admission(live, req_fp, conflict) {
|
||||||
GsDecision::Serve => {}
|
GsDecision::Serve => {}
|
||||||
GsDecision::Join((w, h, f)) => {
|
GsDecision::Join((w, h, f)) => {
|
||||||
|
|||||||
@@ -90,34 +90,29 @@ pub fn decide(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the effective decision for a connecting session: read the console `mode_conflict` policy
|
/// The effective `mode_conflict` policy for THIS host: the console value (default `Separate` when
|
||||||
/// (default `Separate` when unconfigured) and [`decide`] against the live set.
|
/// unconfigured), with the **Windows default applied**. On Windows `separate` — including the
|
||||||
///
|
/// unconfigured default — resolves to **`reject`**: two concurrent Windows sessions would both drive the
|
||||||
/// **Windows** can't create SEPARATE virtual displays until the multi-monitor stage (§6.6), so a
|
/// SAME pf-vdisplay monitor's single-capturer IDD-push channel ("newest-delivery-wins"), which freezes
|
||||||
/// `separate` outcome — including the **unconfigured default** — resolves to `join` (admit at the live
|
/// the live client and can wedge the driver (true multi-session capture is §6.6 / Stage 7). So a 2nd
|
||||||
/// mode) rather than the old silent last-wins reconfigure of the shared monitor. This is the deliberate
|
/// client gets a clean 503 and the live session is protected; `join`/`steal` stay as explicit opt-ins.
|
||||||
/// Windows default change (release-note behavior fix); `steal` remains the way to force the new mode.
|
/// Linux keeps `separate` (real multi-view). Shared by the native + GameStream admission paths.
|
||||||
pub fn admit(req_identity: Option<[u8; 32]>) -> Admission {
|
pub fn effective_conflict() -> ModeConflict {
|
||||||
#[allow(unused_mut)]
|
let conflict = policy::prefs()
|
||||||
let mut conflict = policy::prefs()
|
|
||||||
.configured_effective()
|
.configured_effective()
|
||||||
.map(|e| e.mode_conflict)
|
.map(|e| e.mode_conflict)
|
||||||
.unwrap_or(ModeConflict::Separate);
|
.unwrap_or(ModeConflict::Separate);
|
||||||
let live = table().lock().unwrap();
|
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
if matches!(conflict, ModeConflict::Separate) {
|
if matches!(conflict, ModeConflict::Separate) {
|
||||||
if live
|
return ModeConflict::Reject;
|
||||||
.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)
|
conflict
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the admission decision for a connecting native session: [`effective_conflict`] + [`decide`]
|
||||||
|
/// against the live set.
|
||||||
|
pub fn admit(req_identity: Option<[u8; 32]>) -> Admission {
|
||||||
|
decide(effective_conflict(), req_identity, &table().lock().unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call
|
/// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call
|
||||||
|
|||||||
Reference in New Issue
Block a user