From ddc28de32a8056b71260ace25a46a5da15e09337 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 26 Jul 2026 19:44:49 +0200 Subject: [PATCH] fix(gamestream): tell the client when the HOST ends the session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-glass on .173: a launched game exited, the host tore the session down correctly — and the client froze on its last frame instead of returning to its app list. The host's half was right; it just never said anything. On the native plane ending a session closes the QUIC connection with APP_EXITED, which every client renders as "game ended". The compat plane has no such channel: `end_session` stops the media threads, and stopping a UDP sender is silence, not a signal. Moonlight holds the ENet control stream for the whole session, so with no word from us it sat there until its own timeout eventually fired. The control loop already reacted to the CLIENT disconnecting; it had no path for the reverse. It now watches for its session being cleared out from under it and disconnects the peer, which is what Moonlight reads as "the session is over" — landing the player back in the app list, the same place quitting puts them. This is the signal every host-side end needed, not just a game exiting: the management stop and a `/cancel` racing another connection had the same silence. --- .../punktfunk-host/src/gamestream/control.rs | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/punktfunk-host/src/gamestream/control.rs b/crates/punktfunk-host/src/gamestream/control.rs index f64d4383..2dfaa2dc 100644 --- a/crates/punktfunk-host/src/gamestream/control.rs +++ b/crates/punktfunk-host/src/gamestream/control.rs @@ -154,6 +154,38 @@ pub fn spawn(state: Arc) -> Result<()> { } } } + // A session can also end from the HOST side: the launched game exited, the operator + // stopped it, or `/cancel` arrived on another connection. Tearing the media threads + // down is *silence*, not a signal — Moonlight holds this control stream for the + // whole session, so with no word from us it sits on its last frame until its own + // timeout eventually fires. The client froze instead of ending (on-glass, .173). + // + // Disconnecting the peer is how it learns. Moonlight treats a control-stream + // disconnect as the session being over and returns to its app list — the same place + // it lands when the user quits, which is exactly where "the game exited" should + // leave them. + // + // `end_session` clears `launch`, so a tracked peer with no launch behind it *is* + // the ended session. Clearing `peer` first makes this fire once; the real + // `Disconnect` event that follows then takes the non-session-peer branch, which is + // why this arm repeats that branch's per-connection cleanup. + if let Some(pid) = peer { + if state + .launch + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_none() + { + tracing::info!("control: the session ended — disconnecting the client"); + host.peer_mut(pid).disconnect(0); + peer = None; + detected = None; + decrypt_fails = 0; + hdr_sent = false; + pads = GamepadManager::new(); + pointer = super::pen::GsPointer::new(); + } + } // Service the pads' force-feedback protocol every tick (games block inside // EVIOCSFF until answered) and relay mixed rumble levels to the client. //