fix(gamestream): announce the stream marker + lifecycle events on the compat plane
Only the native punktfunk/1 loop announced the script-facing marker file, so a Moonlight session left it absent and wrapper scripts took their "not streaming" branch mid-stream. The GameStream plane now announces before run() (the marker must exist when the title's wrapper executes) and retracts before client.disconnected, keeping the native loop's event order; StreamRef call sites carry the plane/launch fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,25 +61,28 @@ pub fn start(
|
||||
// Windows HIGHEST + session tuning) — GameStream previously ran unboosted on Linux.
|
||||
crate::native::boost_thread_priority(true);
|
||||
tracing::info!(?cfg, "video stream starting");
|
||||
// Lifecycle events, plane parity with the native loop (RFC §4): the RTSP layer
|
||||
// carries no client device name, so `client` is empty here — the `plane` field is
|
||||
// what hooks key on. `client.connected` fires alongside `stream.started` because a
|
||||
// Moonlight client has no persistent connection to anchor it to.
|
||||
let event_stream = crate::events::StreamRef {
|
||||
mode: crate::events::mode_str(cfg.width, cfg.height, cfg.fps),
|
||||
// Lifecycle events + the script-facing marker file, plane parity with the native loop
|
||||
// (RFC §4): `announce` emits `stream.started`/`stream.stopped` and holds the marker for
|
||||
// the span between. It runs BEFORE `run` because `run` is what launches the app — the
|
||||
// marker has to exist by the time the title's own wrapper script executes, or the
|
||||
// wrapper takes its "not streaming" branch mid-stream. The RTSP layer carries no client
|
||||
// device name, so `client` is empty here — the `plane` field is what hooks key on.
|
||||
// `client.connected` fires alongside `stream.started` because a Moonlight client has no
|
||||
// persistent connection to anchor it to.
|
||||
let stream_marker = crate::stream_marker::announce(crate::stream_marker::StreamInfo {
|
||||
width: cfg.width,
|
||||
height: cfg.height,
|
||||
refresh_hz: cfg.fps,
|
||||
hdr: cfg.hdr,
|
||||
client: String::new(),
|
||||
app: app.as_ref().map(|a| a.title.clone()),
|
||||
launch: app.as_ref().map(|a| a.title.clone()),
|
||||
plane: crate::events::Plane::Gamestream,
|
||||
};
|
||||
});
|
||||
let event_client = crate::events::ClientRef {
|
||||
name: String::new(),
|
||||
fingerprint: None,
|
||||
plane: crate::events::Plane::Gamestream,
|
||||
};
|
||||
crate::events::emit(crate::events::EventKind::StreamStarted {
|
||||
stream: event_stream.clone(),
|
||||
});
|
||||
crate::events::emit(crate::events::EventKind::ClientConnected {
|
||||
client: event_client.clone(),
|
||||
});
|
||||
@@ -103,9 +106,9 @@ pub fn start(
|
||||
tracing::error!(error = %format!("{e:#}"), "video stream failed");
|
||||
}
|
||||
running.store(false, Ordering::SeqCst);
|
||||
crate::events::emit(crate::events::EventKind::StreamStopped {
|
||||
stream: event_stream,
|
||||
});
|
||||
// Retract the marker and fire `stream.stopped` — explicitly here, before
|
||||
// `client.disconnected`, so the compat plane keeps the native loop's event order.
|
||||
drop(stream_marker);
|
||||
crate::events::emit(crate::events::EventKind::ClientDisconnected {
|
||||
client: event_client,
|
||||
reason,
|
||||
|
||||
@@ -1173,6 +1173,7 @@ async fn serve_session(
|
||||
hdr: welcome.color.is_hdr(),
|
||||
client: hello.name.clone().unwrap_or_default(),
|
||||
launch: hello.launch.clone(),
|
||||
plane: crate::events::Plane::Native,
|
||||
});
|
||||
// The session's launch, threaded into the data plane. Windows carries the store-qualified id
|
||||
// (spawned into the interactive user session once capture is live); other hosts resolve the id
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
//!
|
||||
//! ## Semantics
|
||||
//!
|
||||
//! Both serving planes announce — the native punktfunk/1 loop and the GameStream/Moonlight compat
|
||||
//! plane — so a wrapper script behaves identically whichever client is connected. (Until 2026-07
|
||||
//! only the native loop did, so a Moonlight session left the marker absent and every wrapper took
|
||||
//! its "not streaming" branch mid-stream.)
|
||||
//!
|
||||
//! The marker reflects the **primary** session — the oldest still-live one — which is the single
|
||||
//! mode a single-user host is at. With several concurrent clients at different modes there is only
|
||||
//! one file, so `PF_STREAM_SESSIONS>1` is the script's cue that width/height/refresh describe just
|
||||
@@ -53,20 +58,25 @@ pub struct StreamInfo {
|
||||
pub hdr: bool,
|
||||
/// Client-supplied device name (may be empty); sanitized before it reaches the file.
|
||||
pub client: String,
|
||||
/// Store-qualified launch id this session requested, if any — carried on the stream
|
||||
/// lifecycle events, NOT written to the marker file (its key set is a stable contract).
|
||||
/// The launch this session requested, if any (store-qualified id on the native plane, app
|
||||
/// title on the compat plane) — carried on the stream lifecycle events, NOT written to the
|
||||
/// marker file (its key set is a stable contract).
|
||||
pub launch: Option<String>,
|
||||
/// Which protocol plane serves this session. Carried on the lifecycle events — hooks filter
|
||||
/// on it — and never written to the marker file: a launch wrapper branches on "is anything
|
||||
/// streaming", not on how the pixels leave the box.
|
||||
pub plane: crate::events::Plane,
|
||||
}
|
||||
|
||||
/// The announce points double as the `stream.started`/`stream.stopped` lifecycle fire sites
|
||||
/// (RFC §4) — only the native loop announces the marker today, hence the fixed plane.
|
||||
/// (RFC §4), for every plane that announces.
|
||||
fn stream_ref(info: &StreamInfo) -> crate::events::StreamRef {
|
||||
crate::events::StreamRef {
|
||||
mode: crate::events::mode_str(info.width, info.height, info.refresh_hz),
|
||||
hdr: info.hdr,
|
||||
client: info.client.clone(),
|
||||
app: info.launch.clone(),
|
||||
plane: crate::events::Plane::Native,
|
||||
plane: info.plane,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +281,7 @@ mod imp {
|
||||
hdr: true,
|
||||
client: "Couch'TV".to_string(),
|
||||
launch: None,
|
||||
plane: crate::events::Plane::Native,
|
||||
});
|
||||
rewrite_to(&path, ®);
|
||||
let text = std::fs::read_to_string(&path).expect("marker exists while streaming");
|
||||
@@ -290,6 +301,7 @@ mod imp {
|
||||
hdr: false,
|
||||
client: "Phone".to_string(),
|
||||
launch: None,
|
||||
plane: crate::events::Plane::Gamestream,
|
||||
});
|
||||
rewrite_to(&path, ®);
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
@@ -311,3 +323,35 @@ mod imp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::events::Plane;
|
||||
|
||||
/// The announcing plane must reach the lifecycle events. It was hardcoded to `Native` while the
|
||||
/// native loop was the only announcer, so every compat-plane session would report as native to
|
||||
/// a hook filtering on it.
|
||||
#[test]
|
||||
fn stream_ref_carries_the_announcing_plane() {
|
||||
let info = StreamInfo {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
refresh_hz: 60,
|
||||
hdr: false,
|
||||
client: String::new(),
|
||||
launch: Some("Hades".to_string()),
|
||||
plane: Plane::Gamestream,
|
||||
};
|
||||
let r = stream_ref(&info);
|
||||
assert_eq!(r.plane, Plane::Gamestream);
|
||||
assert_eq!(r.mode, "1920x1080@60");
|
||||
assert_eq!(r.app.as_deref(), Some("Hades"));
|
||||
|
||||
let native = stream_ref(&StreamInfo {
|
||||
plane: Plane::Native,
|
||||
..info
|
||||
});
|
||||
assert_eq!(native.plane, Plane::Native);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user