feat(client): a game exiting takes you back to its library

Quit a game you launched from a host's library and the stream ended with
"Session ended by <host>." on the host-selection screen — an error
report for something you had just done on purpose, and several taps away
from starting the next title.

The host has always said what happened: it closes the connection with
APP_EXITED when the game it launched for a session exits, and that
code's own documentation describes this feature. Nothing ever read it —
a search across every client found zero consumers. (It also could not
reach anyone until the previous commit, since the close only happens
once the lease declares the game gone.)

The core now records the reason as it observes the close, latched before
the shutdown flag because different threads watch the two, and exposes
it as punktfunk_connection_game_exited. Purely additive: a client that
never asks behaves exactly as before, the host sends identical bytes,
and the wire version is untouched — ABI 17.

The Apple client asks while the connection is still up, then treats a
game exit as the normal finish it is: no error banner, and if the
session began as a library launch it reopens that library so the next
title is one tap away. Any other ending — a stop, the host going away,
network loss — is unchanged. The other clients keep their existing
end-of-session behaviour; the call is there when they want it.
This commit is contained in:
2026-08-06 13:58:57 +02:00
parent ea762b849d
commit d4dd5f7a3d
9 changed files with 168 additions and 6 deletions
@@ -335,6 +335,16 @@ struct ContentView: View {
active: fullscreenForSession && model.connection != nil,
isFullscreen: $isFullscreen))
#endif
// A game launched from the library just exited, so the session ended on purpose: put the
// player back in that host's library rather than on host selection. Set on the outer Group
// (like the sheets below) so it survives the streaming home transition the disconnect
// drives, and consumed here the model hands the host over once and we clear it, so a
// later manual dismiss of the library can't be undone by a stale value.
.onChange(of: model.returnToLibrary) { _, host in
guard let host else { return }
model.returnToLibrary = nil
libraryTarget = host
}
// On the outer Group so the sheet survives the trust-prompt home transition
// (the "Pair with PIN instead" path disconnects first the host's accept loop
// is sequential, a pairing connection would queue behind the live session).
@@ -65,6 +65,14 @@ final class SessionModel: ObservableObject {
@Published private(set) var connection: PunktfunkConnection?
/// The host this session is for (a value copy; identity = id).
@Published private(set) var activeHost: StoredHost?
/// The library entry this session was launched with (`connect(launchID:)`), or nil if the user
/// just connected to the host's desktop. Kept because where the client should go when the
/// session ends depends on where it came FROM: a title launched out of the library belongs back
/// in that library when its game exits, not on the host-selection screen.
private var launchedTitleID: String?
/// Set when a session ended because its game exited and it began as a library launch: the host
/// whose library to reopen. The view layer consumes it and sets it back to nil.
@Published var returnToLibrary: StoredHost?
/// The settings THIS session runs on the globals with its profile overlaid, resolved once at
/// connect (design/client-settings-profiles.md §4.2). Also mirrored into `SessionSettings` for
/// the readers that live in PunktfunkKit and can't see this model.
@@ -249,6 +257,7 @@ final class SessionModel: ObservableObject {
guard phase == .idle else { return }
phase = .connecting
activeHost = host
launchedTitleID = launchID
errorMessage = nil
settings = effective
statsVerbosity = StatsVerbosity(rawValue: effective.statsVerbosity) ?? .normal
@@ -607,6 +616,8 @@ final class SessionModel: ObservableObject {
}
connection = nil
activeHost = nil
// Read by `sessionEnded` BEFORE it calls us, so clearing here can't rob it of the answer.
launchedTitleID = nil
phase = .idle
fps = 0
mbps = 0
@@ -626,10 +637,25 @@ final class SessionModel: ObservableObject {
/// Called (via the main actor) when the pump hits end-of-session.
func sessionEnded() {
guard connection != nil else { return }
guard let conn = connection else { return }
let name = activeHost?.displayName ?? "host"
// WHY it ended, asked while the connection is still up `disconnect` tears it down.
// The host closes with APP_EXITED when the game it launched for this session quit, which is
// a normal finish the player just performed, not a failure to report.
let gameExited = conn.endedBecauseGameExited
// Where a game exit sends us: back into the library this title was launched from, so the
// next one is a tap away. Only for a launch that CAME from the library a game exiting in
// a plain desktop session has no library to return to.
let host = activeHost
let cameFromLibrary = launchedTitleID != nil
disconnect(deliberate: false) // host/network ended it keep the linger for a reconnect
errorMessage = "Session ended by \(name)."
if gameExited {
if cameFromLibrary, let host {
returnToLibrary = host
}
} else {
errorMessage = "Session ended by \(name)."
}
}
/// Resize overlay START (main actor from the Match-window follower's `onResizeTarget`): the
@@ -1430,6 +1430,24 @@ public final class PunktfunkConnection {
}
}
/// Did this session end because **the game the host launched for it exited**, rather than the
/// stream dropping out?
///
/// Only meaningful once the session HAS ended (a plane threw `.closed`, or `onSessionEnd`
/// fired) before that it is simply false. False also covers every other ending: a user stop,
/// the host going away, network loss, an idle timeout. Read it before tearing the connection
/// down; once `close()` has been requested this reports false like any other ending, which is
/// the safe direction (the caller falls back to its normal end-of-session handling).
///
/// A game ending is a normal finish, not a failure that is the whole point of asking. See
/// `punktfunk_connection_game_exited` (ABI v17).
public var endedBecauseGameExited: Bool {
guard let h = liveHandle() else { return false }
var out: UInt8 = 0
guard punktfunk_connection_game_exited(h, &out) == statusOK else { return false }
return out != 0
}
deinit { close() }
/// Snapshot the handle unless close is pending (callers hold their plane lock).
+36
View File
@@ -2273,6 +2273,42 @@ pub unsafe extern "C" fn punktfunk_connection_audio_channels(
})
}
/// Did this session end because **the game the host launched for it exited**? `*out` is set to 1
/// when it did and 0 otherwise; the return status reports only whether the handle was usable.
///
/// A refinement of "the session ended", never a substitute — read it only once a plane has
/// returned [`PunktfunkStatus::Closed`] (or the embedder's own end-of-session signal fired), and
/// treat 0 as "ended for some other reason" (user stop, host gone, network loss, idle timeout).
/// It latches, so it is still readable while the connection is being torn down, and a client that
/// never calls it behaves exactly as it did before this existed.
///
/// The point is that a game ending is a normal finish, not a failure: a launcher client can send
/// the player back to the host's library — one tap from the next title — rather than reporting an
/// error and dropping to host selection for something the player just did on purpose.
///
/// # Safety
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_game_exited(
c: *mut PunktfunkConnection,
out: *mut u8,
) -> PunktfunkStatus {
guard(|| {
// SAFETY: per the ABI contract - an opaque handle from a `*_new`/`*_pair` that the caller
// has not yet freed, or null, which `as_ref` reports as `None` and the `match` handles.
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
if !out.is_null() {
// SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
unsafe { *out = u8::from(c.inner.ended_because_game_exited()) };
}
PunktfunkStatus::Ok
})
}
/// One decoded audio frame from [`punktfunk_connection_next_audio_pcm`]: interleaved 32-bit
/// float PCM at 48 kHz, in the canonical wire channel order `FL FR FC LFE RL RR SL SR` (the
/// first `channels` of it). `samples` points at `frame_count * channels` floats and borrows
+25
View File
@@ -180,6 +180,9 @@ pub struct NativeClient {
/// Speed-test accumulator, shared with the data-plane pump + control task.
probe: Arc<Mutex<ProbeState>>,
shutdown: Arc<AtomicBool>,
/// Set with `shutdown` when the host's close carried [`crate::quic::APP_EXITED_CLOSE_CODE`] —
/// see [`NativeClient::ended_because_game_exited`].
game_exited: Arc<AtomicBool>,
/// Deliberate-quit flag: [`NativeClient::disconnect_quit`] sets it, so the worker closes the QUIC
/// connection with [`crate::quic::QUIT_CLOSE_CODE`] (a user "stop") instead of code 0 — telling the
/// host to skip the keep-alive linger. A plain drop leaves it false → an unwanted-disconnect close.
@@ -448,6 +451,7 @@ impl NativeClient {
std::sync::mpsc::sync_channel::<crate::quic::CursorState>(CURSOR_STATE_QUEUE);
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
let shutdown = Arc::new(AtomicBool::new(false));
let game_exited = Arc::new(AtomicBool::new(false));
let quit = Arc::new(AtomicBool::new(false));
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
let probe = Arc::new(Mutex::new(ProbeState::default()));
@@ -463,6 +467,7 @@ impl NativeClient {
let host = host.to_string();
let frame_chan_w = frame_chan.clone();
let shutdown_w = shutdown.clone();
let game_exited_w = game_exited.clone();
let quit_w = quit.clone();
let mode_slot_w = mode_slot.clone();
let probe_w = probe.clone();
@@ -538,6 +543,7 @@ impl NativeClient {
clip_cmd_rx,
ready_tx,
shutdown: shutdown_w,
game_exited: game_exited_w,
quit: quit_w,
mode_slot: mode_slot_w,
probe: probe_w,
@@ -591,6 +597,7 @@ impl NativeClient {
host_caps: negotiated.host_caps,
probe,
shutdown,
game_exited,
quit,
worker: Some(worker),
frames_dropped,
@@ -809,6 +816,24 @@ impl NativeClient {
self.shutdown.load(Ordering::SeqCst)
}
/// Whether the session ended because **the game the host launched for it exited** — the host
/// closed with [`crate::quic::APP_EXITED_CLOSE_CODE`] rather than dropping out.
///
/// A refinement of [`is_session_ended`](Self::is_session_ended), never a substitute: it is only
/// ever true once that is, and false covers every other ending (user stop, host gone, network
/// loss, idle timeout) — so a client that ignores it behaves exactly as before.
///
/// What it is FOR: a game ending is a normal, expected finish, not a failure. A launcher client
/// can read this and go back to the host's library — where the player is one tap from the next
/// title — instead of showing "session ended by <host>" and dropping to host selection, which
/// reads as an error for something the player just did on purpose.
///
/// Poll it after the session ends (a `Closed` on any plane, or `is_session_ended`); it latches,
/// so it is still readable while the connection is being torn down.
pub fn ended_because_game_exited(&self) -> bool {
self.game_exited.load(Ordering::SeqCst)
}
/// Register the calling thread as latency-critical so a later
/// [`hot_thread_ids`](Self::hot_thread_ids) includes it. An embedder calls this from its own
/// plane threads (e.g. the Android client's decode + audio threads) to fold them into the same
+13 -2
View File
@@ -65,6 +65,7 @@ pub(super) async fn run_pump(args: WorkerArgs) {
clip_cmd_rx,
ready_tx,
shutdown,
game_exited,
quit,
mode_slot,
probe,
@@ -194,12 +195,22 @@ pub(super) async fn run_pump(args: WorkerArgs) {
clip_cmd_rx,
));
// Watch for connection close → stop the pump.
// Watch for connection close → stop the pump, and record WHY if the host said so.
{
let shutdown = shutdown.clone();
let game_exited = game_exited.clone();
let conn = conn.clone();
tokio::spawn(async move {
conn.closed().await;
let why = conn.closed().await;
// The host closes with APP_EXITED when the game it launched for this session exited.
// Latch that before `shutdown`, so any client that reacts to the shutdown flag can
// already read the reason — the two are observed by different threads.
if let quinn::ConnectionError::ApplicationClosed(ac) = &why {
if u32::try_from(u64::from(ac.error_code)) == Ok(crate::quic::APP_EXITED_CLOSE_CODE)
{
game_exited.store(true, Ordering::SeqCst);
}
}
shutdown.store(true, Ordering::SeqCst);
});
}
@@ -68,6 +68,10 @@ pub(crate) struct WorkerArgs {
pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver<ClipCommand>,
pub(crate) ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
pub(crate) shutdown: Arc<AtomicBool>,
/// Set alongside `shutdown` when the HOST's close carried
/// [`crate::quic::APP_EXITED_CLOSE_CODE`] — the launched game exited (see
/// [`NativeClient::ended_because_game_exited`]).
pub(crate) game_exited: Arc<AtomicBool>,
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
pub(crate) quit: Arc<AtomicBool>,
pub(crate) mode_slot: Arc<std::sync::Mutex<Mode>>,
+7 -1
View File
@@ -138,7 +138,13 @@ pub use stats::Stats;
/// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
/// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
/// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 16;
/// v17: added `punktfunk_connection_game_exited` — asks, once a session has ended, whether it
/// ended because the game the host launched for it EXITED (the host's close carried
/// [`quic::APP_EXITED_CLOSE_CODE`], which it has sent since long before this bump; nothing
/// consumed it). Purely a read of state the core already had: no new call is required of an
/// embedder, a client that never calls it is unchanged, and the host sends exactly the same bytes
/// either way, so [`WIRE_VERSION`] is unchanged.
pub const ABI_VERSION: u32 = 17;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
+27 -1
View File
@@ -76,7 +76,13 @@
// capability-gated end to end: the wire grows a new datagram tag (0xD1) an old client never
// receives (double-gated caps), a new 0xCD kind (0x06, dropped as unknown by old clients) and
// arrival flag bits 8/9 sent only toward a capable host, so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 16
// v17: added `punktfunk_connection_game_exited` — asks, once a session has ended, whether it
// ended because the game the host launched for it EXITED (the host's close carried
// [`quic::APP_EXITED_CLOSE_CODE`], which it has sent since long before this bump; nothing
// consumed it). Purely a read of state the core already had: no new call is required of an
// embedder, a client that never calls it is unchanged, and the host sends exactly the same bytes
// either way, so [`WIRE_VERSION`] is unchanged.
#define PUNKTFUNK_ABI_VERSION 17
// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
@@ -2498,6 +2504,26 @@ PunktfunkStatus punktfunk_connection_next_audio(PunktfunkConnection *c,
PunktfunkStatus punktfunk_connection_audio_channels(PunktfunkConnection *c, uint8_t *out);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Did this session end because **the game the host launched for it exited**? `*out` is set to 1
// when it did and 0 otherwise; the return status reports only whether the handle was usable.
//
// A refinement of "the session ended", never a substitute — read it only once a plane has
// returned [`PunktfunkStatus::Closed`] (or the embedder's own end-of-session signal fired), and
// treat 0 as "ended for some other reason" (user stop, host gone, network loss, idle timeout).
// It latches, so it is still readable while the connection is being torn down, and a client that
// never calls it behaves exactly as it did before this existed.
//
// The point is that a game ending is a normal finish, not a failure: a launcher client can send
// the player back to the host's library — one tap from the next title — rather than reporting an
// error and dropping to host selection for something the player just did on purpose.
//
// # Safety
// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
PunktfunkStatus punktfunk_connection_game_exited(PunktfunkConnection *c,
uint8_t *out);
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// Pull the next audio frame and **decode it in-core** to interleaved f32 PCM — for embedders
// without a multistream-capable Opus decoder (e.g. Apple, whose AudioToolbox Opus path is