fix(gamestream/session): end the session when the client disconnects or vanishes
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m6s
apple / swift (push) Successful in 1m24s
decky / build-publish (push) Successful in 31s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
ci / bench (push) Successful in 7m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m8s
deb / build-publish (push) Successful in 9m35s
arch / build-publish (push) Successful in 12m19s
deb / build-publish-host (push) Successful in 12m46s
android / android (push) Successful in 16m5s
windows-host / package (push) Successful in 16m34s
apple / screenshots (push) Successful in 6m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m11s
ci / rust (push) Successful in 27m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m19s
docker / deploy-docs (push) Successful in 27s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m6s
apple / swift (push) Successful in 1m24s
decky / build-publish (push) Successful in 31s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
ci / bench (push) Successful in 7m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m8s
deb / build-publish (push) Successful in 9m35s
arch / build-publish (push) Successful in 12m19s
deb / build-publish-host (push) Successful in 12m46s
android / android (push) Successful in 16m5s
windows-host / package (push) Successful in 16m34s
apple / screenshots (push) Successful in 6m28s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m11s
ci / rust (push) Successful in 27m9s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m19s
docker / deploy-docs (push) Successful in 27s
GameStream sessions outlived their client: the only complete teardowns were the explicit ones (RTSP TEARDOWN, nvhttp /cancel, mgmt DELETE /session), and the only automatic detector was a media-UDP send error — which needs an ICMP port-unreachable, so a true vanish (Wi-Fi drop, sleep, power off, crash) left video+audio encoding into the void forever, and even a plain Moonlight quit (which sends neither TEARDOWN nor /cancel) leaked the session. The stale state then cascaded: a lingering launch 503-blocked a different client under mode_conflict=reject, and streaming=true made a reconnect's PLAY take its "stream already running" branch — no new threads, old threads still aimed at the dead endpoint, the reconnect got no media. ENet already detects all of this — the control peer's reliable-ping timeout (or clean disconnect) fires Event::Disconnect within ~5-30 s — but the handler only reset input state. Wire the real teardown into it: * AppState::end_session — THE compat-plane session teardown: stops both media threads (their flags), clears launch + negotiated stream config; idempotent. /cancel and mgmt stop_session now share it. * control.rs Disconnect → end_session. Gated on the TRACKED session peer, and Connect only tracks a peer from the /launch owner's IP (the same source-IP bind the RTSP/media plane uses), so an unauthenticated LAN peer connect+disconnect can't end a live session, and a fast reconnect's stale-peer timeout can't kill its successor. * Client-unreachable UDP send errors now end the whole session via an OnSessionLost callback (built at PLAY) instead of stopping only the plane that noticed — audio no longer keeps streaming after video detects the dead client, and vice versa. Linux check/clippy/tests green (53 gamestream tests incl. the new end_session regression test). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -83,10 +83,34 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
match host.service() {
|
||||
Ok(Some(event)) => match event {
|
||||
Event::Connect { peer: p, .. } => {
|
||||
tracing::info!("control: client connected");
|
||||
peer = Some(p.id());
|
||||
// Track this peer as THE session peer only if it comes from the
|
||||
// `/launch` owner's IP (when captured — `None` falls back to
|
||||
// trusting the connect, the pre-teardown behavior). The tracked
|
||||
// peer's disconnect now ENDS the session, so an unauthenticated
|
||||
// LAN peer that connects+disconnects on 47999 must not be able to
|
||||
// steal the slot and tear a live session down. Same source-IP
|
||||
// bind the RTSP/media plane uses (security-review #4).
|
||||
let owner_ip = state.launch.lock().unwrap().and_then(|s| s.peer_ip);
|
||||
let from = p.address().map(|a| a.ip());
|
||||
if owner_ip.is_some() && from.is_some() && owner_ip != from {
|
||||
tracing::warn!(
|
||||
?from,
|
||||
"control: peer connected from a non-owner IP — ignoring"
|
||||
);
|
||||
} else {
|
||||
tracing::info!("control: client connected");
|
||||
peer = Some(p.id());
|
||||
}
|
||||
}
|
||||
Event::Disconnect { .. } => {
|
||||
Event::Disconnect { peer: p, .. } => {
|
||||
// Gate on the TRACKED session peer: a stray probe peer (or the
|
||||
// OLD peer's late timeout after a fast reconnect replaced it in
|
||||
// the Connect arm) must neither clobber the live session's input
|
||||
// state nor end its session.
|
||||
if peer != Some(p.id()) {
|
||||
tracing::debug!("control: non-session peer disconnected");
|
||||
continue;
|
||||
}
|
||||
tracing::info!("control: client disconnected");
|
||||
detected = None;
|
||||
decrypt_fails = 0;
|
||||
@@ -97,6 +121,16 @@ pub fn spawn(state: Arc<AppState>) -> Result<()> {
|
||||
// uinput pen releases any held tool/tip kernel-side).
|
||||
pads = GamepadManager::new();
|
||||
pointer = super::pen::GsPointer::new();
|
||||
// The control stream is the session's liveness anchor — Moonlight
|
||||
// holds it for the whole stream, and ENet detects a vanished peer
|
||||
// via its reliable-ping timeout (~5–30 s), which ALSO lands here.
|
||||
// End the session: without this, a client that disconnects without
|
||||
// an explicit RTSP TEARDOWN / nvhttp `/cancel` (a network drop,
|
||||
// sleep, crash — or just a plain Moonlight quit, which sends
|
||||
// neither) left the media threads streaming at the dead endpoint
|
||||
// forever (a UDP send only errors on an ICMP port-unreachable) and
|
||||
// the stale launch/streaming state wedged every reconnect.
|
||||
state.end_session("control stream disconnected");
|
||||
}
|
||||
Event::Receive {
|
||||
channel_id, packet, ..
|
||||
|
||||
Reference in New Issue
Block a user