fix(gamestream): tell the client when the HOST ends the session
ci / rust (push) Failing after 22m10s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m15s
apple / screenshots (push) Canceled after 0s
docker / build-push-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 10m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m13s
windows-host / package (push) Failing after 12s
windows-host / winget-source (push) Skipped
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m2s
ci / bench (push) Successful in 5m41s
ci / rust-arm64 (push) Successful in 9m26s
arch / build-publish (push) Successful in 12m29s
deb / build-publish (push) Successful in 8m57s
decky / build-publish (push) Successful in 32s
apple / swift (push) Successful in 5m57s
android / android (push) Successful in 19m51s
deb / build-publish-client-arm64 (push) Successful in 7m21s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
deb / build-publish-host (push) Successful in 9m34s
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 1m9s

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.
This commit is contained in:
2026-07-26 19:44:55 +02:00
parent 1839d7566b
commit ddc28de32a
@@ -154,6 +154,38 @@ pub fn spawn(state: Arc<AppState>) -> 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.
//