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:
@@ -45,6 +45,7 @@ pub type RfiSlot = Arc<std::sync::Mutex<Option<(i64, i64)>>>;
|
||||
/// Spawn the video stream thread (idempotent via `running`). Stops when `running` clears.
|
||||
/// `force_idr` is set by the control stream on a client recovery request; `video_cap` holds
|
||||
/// the persistent capturer the thread borrows for the stream's duration.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn start(
|
||||
cfg: StreamConfig,
|
||||
app: Option<super::apps::AppEntry>,
|
||||
@@ -53,6 +54,7 @@ pub fn start(
|
||||
rfi_range: RfiSlot,
|
||||
video_cap: CapturerSlot,
|
||||
stats: Arc<crate::stats_recorder::StatsRecorder>,
|
||||
on_lost: super::OnSessionLost,
|
||||
) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("punktfunk-video".into())
|
||||
@@ -103,6 +105,7 @@ pub fn start(
|
||||
&rfi_range,
|
||||
&video_cap,
|
||||
&stats,
|
||||
&on_lost,
|
||||
);
|
||||
// A clean return is a stop (RTSP teardown / cancel / client unreachable) → `quit`;
|
||||
// an error return is `error`. The compat plane can't tell a user stop from an idle
|
||||
@@ -137,6 +140,8 @@ fn run(
|
||||
// Shared stats recorder for the web-console capture/graph. Threaded into `stream_body` (the
|
||||
// encode loop); per-frame sample emission is wired by a later pass.
|
||||
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
||||
// Whole-session teardown for the send thread's client-unreachable detection.
|
||||
on_lost: &super::OnSessionLost,
|
||||
) -> Result<()> {
|
||||
// GameStream capture/encode thread: apply Windows session tuning (no-op off Windows).
|
||||
pf_frame::session_tuning::on_hot_thread();
|
||||
@@ -252,6 +257,7 @@ fn run(
|
||||
rfi_range,
|
||||
stats,
|
||||
&client_label,
|
||||
on_lost,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -299,6 +305,7 @@ fn run(
|
||||
rfi_range,
|
||||
stats,
|
||||
&client_label,
|
||||
on_lost,
|
||||
);
|
||||
capturer.set_active(false);
|
||||
*video_cap.lock().unwrap() = Some((capturer, cfg.hdr));
|
||||
@@ -572,12 +579,15 @@ fn spawn_packetizer(
|
||||
/// shared [`send_pacing`](crate::send_pacing) policy at the GameStream parameterization: no
|
||||
/// microburst stage, a BOUNDED step count (≤ 12, chunk ≥ 16, see the policy's docs for the
|
||||
/// "send queue full" history that bound guards), each step ending in a sleep toward its slice
|
||||
/// of the fixed budget. On send failure (client gone) it clears `running`.
|
||||
/// of the fixed budget. On send failure (client gone) it ends the whole session via `on_lost` —
|
||||
/// not just this thread: audio would otherwise keep streaming at the dead endpoint and the stale
|
||||
/// launch state would wedge the next connect (see `AppState::end_session`).
|
||||
fn spawn_sender(
|
||||
sock: UdpSocket,
|
||||
rx: std::sync::mpsc::Receiver<PacketBatch>,
|
||||
frame_interval: Duration,
|
||||
running: Arc<AtomicBool>,
|
||||
on_lost: super::OnSessionLost,
|
||||
) -> Result<()> {
|
||||
std::thread::Builder::new()
|
||||
.name("punktfunk-send".into())
|
||||
@@ -613,8 +623,9 @@ fn spawn_sender(
|
||||
},
|
||||
);
|
||||
if let Err(e) = r {
|
||||
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
|
||||
tracing::info!(error = %e, sent, "video: client unreachable — ending session");
|
||||
running.store(false, Ordering::SeqCst);
|
||||
on_lost();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -645,6 +656,8 @@ fn stream_body(
|
||||
stats: &Arc<crate::stats_recorder::StatsRecorder>,
|
||||
// Short client label (peer IP) seeded into the capture meta on the first armed registration.
|
||||
client_label: &str,
|
||||
// Whole-session teardown, handed to the send thread's client-unreachable detection.
|
||||
on_lost: &super::OnSessionLost,
|
||||
) -> Result<()> {
|
||||
// The first frame establishes the authoritative size/format for the encoder.
|
||||
let mut frame = capturer.next_frame().context("capture first frame")?;
|
||||
@@ -708,6 +721,7 @@ fn stream_body(
|
||||
batch_rx,
|
||||
Duration::from_secs_f64(1.0 / target_fps as f64),
|
||||
running.clone(),
|
||||
on_lost.clone(),
|
||||
)?;
|
||||
let (raw_tx, raw_rx) = std::sync::mpsc::sync_channel::<RawFrame>(2);
|
||||
spawn_packetizer(raw_rx, batch_tx, pk, goodput.clone())?;
|
||||
@@ -1075,6 +1089,7 @@ mod tests {
|
||||
rx,
|
||||
Duration::from_millis(8), // ~120fps frame interval
|
||||
running.clone(),
|
||||
Arc::new(|| {}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user