From 76e8bd1b981f70c857220c5cec6f3d7f8e314bb8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 13:58:24 +0200 Subject: [PATCH 1/6] fix(host/gamelease): a game that exited stops counting as running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a launched game's processes are all gone, the watcher asks one last out-of-band question before ending the session: does the launcher still think the game is up? On Windows that reads Steam's per-app `Running` registry flag. It was only ever meant to be a tie-breaker for a scan that momentarily can't see the game — a launcher re-execing, an engine relaunching itself into a new pid. It had no bound. Honouring the flag reset the confirm window every pass, so a flag Steam left set — it does that whenever it doesn't cleanly observe the exit: it crashed, it was closed first, the game re-parented — pinned the lease in `running` for the life of the host. The console kept showing the game, `session_on_game_exit` never fired, and the only way to get the stream back was a manual "End". Reported from the field on Windows 0.24.0. `steam_running_hint` also believes the FIRST hive that says so, so a stale flag in any loaded profile was enough. The absence timer now keeps running instead of being reset, and that is what bounds it: past `VETO_LIMIT` (30 s) with nothing of the game on the box, the launcher's opinion is stale rather than early and the session ends anyway, logged at WARN so it is visible. Ending a moment early is the cheaper failure — the stream drops while the game lives, the user reconnects, and nothing is ever killed. Ending never was the bug. The rule is now a pure `exit_confirmed(gone_for, hint_running)` with a test. The watch loop polls a live process table and can't be unit-tested, which is exactly how an unbounded veto shipped unnoticed. --- crates/punktfunk-host/src/gamelease.rs | 108 +++++++++++++++++++++---- 1 file changed, 92 insertions(+), 16 deletions(-) diff --git a/crates/punktfunk-host/src/gamelease.rs b/crates/punktfunk-host/src/gamelease.rs index 975198a8..ed1885ec 100644 --- a/crates/punktfunk-host/src/gamelease.rs +++ b/crates/punktfunk-host/src/gamelease.rs @@ -52,6 +52,24 @@ const EXIT_CONFIRM: Duration = Duration::from_secs(3); const SHIM_WINDOW: Duration = Duration::from_secs(5); /// How long a game gets to close on its own after a polite request, before it is killed outright. const TERM_GRACE: Duration = Duration::from_secs(10); +/// How long [`crate::procscan::running_hint`] may hold off the exit once the game's processes have +/// all gone. +/// +/// The hint is a tie-breaker for a scan that momentarily cannot see the game — a launcher re-execing, +/// an engine relaunching itself into a new pid — and those gaps are over in seconds, an order of +/// magnitude inside this window. Past it, a game nothing can find is gone whatever the hint says. +/// +/// **Bounded because the hint's backing state is not guaranteed to be truthful.** Windows reads +/// Steam's per-app `Running` registry flag, which Steam leaves set whenever it does not cleanly +/// observe the exit (Steam crashed or was closed first, the game re-parented, a launcher appid stays +/// set) — and `steam_running_hint` believes the first hive that says so, including a stale one left +/// in another profile. An UNBOUNDED veto turns that into a session that never ends on its own: the +/// console shows the game running for as long as the host does, `session_on_game_exit` never fires, +/// and only a manual "End" gets the stream back (field report 2026-08-06, Windows host 0.24.0). +/// +/// Ending a moment too early is the cheaper failure: the stream drops while the game lives (the user +/// reconnects, and `finish` never kills anything). Ending never is the bug above. +const VETO_LIMIT: Duration = Duration::from_secs(30); /// A child process the host spawned for a launch, and what may safely be signalled for it. #[derive(Clone, Copy, Debug)] @@ -540,29 +558,59 @@ fn watch(shared: Arc, mut child: Option, on_ex gone_since = None; vetoed = false; shared.last_seen_ms.store(now_ms(), Ordering::Relaxed); - } else if gone_since.get_or_insert_with(Instant::now).elapsed() >= EXIT_CONFIRM { - // Last check before ending a session: does anything outside the process scan still think - // the game is up? Only a veto, never a reason to call it running — see - // `procscan::running_hint`. The failure mode of honoring it is a stream that stays up. - if crate::procscan::running_hint(&shared.spec) == Some(true) { - if !vetoed { - vetoed = true; - tracing::info!( - title = %shared.game.title, - "no game processes found, but its launcher still reports it running — not \ - ending the session" - ); + } else { + // How long the game's processes have been CONTINUOUSLY absent. Deliberately not reset by + // the veto below — letting it run on is exactly what bounds the veto. + let gone_for = gone_since.get_or_insert_with(Instant::now).elapsed(); + if gone_for >= EXIT_CONFIRM { + // Last check before ending a session: does anything outside the process scan still + // think the game is up? Only a veto, never a reason to call it running — see + // `procscan::running_hint`. + let hint_running = crate::procscan::running_hint(&shared.spec) == Some(true); + if !exit_confirmed(gone_for, hint_running) { + if !vetoed { + vetoed = true; + tracing::info!( + title = %shared.game.title, + veto_limit_s = VETO_LIMIT.as_secs(), + "no game processes found, but its launcher still reports it running — \ + holding off on ending the session" + ); + } + } else { + if hint_running { + // The veto outlived its usefulness: nothing this scan can see has existed + // for VETO_LIMIT, so the launcher's opinion is stale, not early. + tracing::warn!( + title = %shared.game.title, + gone_for_s = gone_for.as_secs(), + "its launcher still reports the game running, but nothing of it has \ + been on the box for {}s — treating that as a stale flag and ending \ + the session", + VETO_LIMIT.as_secs() + ); + } + finish(&shared, &on_exit, "the game exited"); + return; } - gone_since = None; - } else { - finish(&shared, &on_exit, "the game exited"); - return; } } std::thread::sleep(POLL); } } +/// Whether a game nothing can find any more counts as exited: absent for at least [`EXIT_CONFIRM`], +/// and either unopposed or absent long enough that the opposition ([`crate::procscan::running_hint`] +/// saying `Some(true)`) has been overruled by [`VETO_LIMIT`]. +/// +/// Split out of the watch loop because it is the one rule in this file whose *bound* is the fix: +/// the loop itself polls a live process table and cannot be unit-tested, which is how an unbounded +/// veto shipped. Pure, so the table below is the whole contract. +#[cfg(any(target_os = "linux", windows))] +fn exit_confirmed(gone_for: Duration, hint_running: bool) -> bool { + gone_for >= EXIT_CONFIRM && (!hint_running || gone_for >= VETO_LIMIT) +} + /// Record the exit and, unless the host itself ended the game, run the session-ending action. #[cfg(any(target_os = "linux", windows))] fn finish(shared: &Arc, on_exit: &OnExit, why: &str) { @@ -1037,6 +1085,34 @@ mod tests { .any(|(s, _)| s.game.id.as_deref() == Some(id)) } + /// The exit rule, including the thing that was missing: the veto ENDS. + /// + /// Field 2026-08-06 (Windows 0.24.0): Steam's per-app `Running` flag was left set after the game + /// exited, the watcher honoured it on every pass and reset its own confirm window each time, so + /// the game read as running for the life of the host and the stream never auto-ended. The last + /// case below is that regression. + #[cfg(any(target_os = "linux", windows))] + #[test] + fn the_launcher_veto_expires_instead_of_pinning_a_session_open() { + let brief = EXIT_CONFIRM / 2; + let confirmed = EXIT_CONFIRM + Duration::from_secs(1); + let long = VETO_LIMIT + Duration::from_secs(1); + + // Too early to call it either way — a process swap is still plausible. + assert!(!exit_confirmed(brief, false)); + assert!(!exit_confirmed(brief, true)); + // Gone past the confirm window with nothing objecting: exited. + assert!(exit_confirmed(confirmed, false)); + // Same, but the launcher objects — that is what the veto is FOR, so hold off. + assert!(!exit_confirmed(confirmed, true)); + // …and this is the bound. Still objecting, but nothing of the game has existed for + // VETO_LIMIT, so the objection is stale and the session ends anyway. + assert!(exit_confirmed(long, true)); + assert!(exit_confirmed(long, false)); + // (The middle two cases together also pin VETO_LIMIT > EXIT_CONFIRM: a veto that did not + // outlast the window it overrides could never hold anything off in the first place.) + } + #[test] fn kind_follows_what_the_launch_gave_us() { // Nested wins over everything: the display layer owns the lifetime. From ea762b849d61f3b258422baf0f4caaedd2477688 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 13:58:41 +0200 Subject: [PATCH 2/6] fix(client/ios): Escape stays in the game instead of freeing the pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Escape mid-stream on an iPad handed the mouse back to iPadOS: the captured cursor was swapped for the system one and the game stopped receiving relative motion, so aiming died until you clicked back in. Two previous attempts treated that release as unavoidable and built recovery around it — a re-lock burst, then a click that re-asks. Both came back from the field unchanged, because both fought the release after it had already happened, inside the cooldown the platform applies straight after its own "let me out" gesture. The release was never unavoidable. This app had no UIKit key handling at all: every key arrives on the GameController path, which is a parallel HID feed that does not consume the UIKit event, and the only thing that ever became first responder was the video view, and only to summon the soft keyboard. So every hardware Escape reached UIKit unclaimed — and an unclaimed key press is precisely what lets the system apply its own default for that key. Apps that read a hardware keyboard the ordinary way consume the event as a side effect and never see this. So claim it. The stream controller becomes first responder while capture is engaged and takes Escape in pressesBegan/pressesEnded, passing every other press to super untouched. Escape still reaches the host on the GameController path, so in-game menus open exactly as before; only the system's own interpretation is suppressed. Scoped to captured input, so Escape keeps dismissing sheets and leaving full screen whenever the stream doesn't own the keyboard, and the deliberate ways out are untouched — Cmd-Escape and Ctrl-Opt-Shift-Q are read off the same GameController path and clear capture themselves. The recovery path stays as a backstop and is retimed to match what was measured: the old burst spent its entire budget within ~0.6 s of the drop, i.e. wholly inside the cooldown, where the answer can only be no. Retries now continue at 1.2 s and 2.4 s, and quietly — they don't hide the cursor or mute pointer motion the way the burst does, so a longer recovery costs nothing when it fails. --- .../PunktfunkKit/Views/StreamViewIOS.swift | 131 +++++++++++++++++- 1 file changed, 128 insertions(+), 3 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index bc75217b..f0bca1e2 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -225,6 +225,15 @@ public final class StreamViewController: StreamViewControllerBase { /// How long an escalated attempt reports `prefersPointerLocked == false` before flipping back, /// so the system observes a real transition instead of coalescing the flip away. private static let pointerLockForcedOffHold: TimeInterval = 0.05 + /// Attempts spent in the QUIET tail (see `scheduleQuietRelock()`), reset with the burst. + private var pointerRelockQuietAttempt = 0 + /// When the quiet tail re-asks, measured from the drop. The visible burst above spends its whole + /// budget inside ~0.6 s — and the pointer-lock cooldown the platform applies right after its own + /// Escape gesture is about a second, so every one of those attempts asks while the answer can + /// only be no. These land AFTER it. They are "quiet" because unlike the burst they do not hide + /// the cursor or mute motion: the pointer behaves exactly as it does today while they run, so + /// stretching the recovery costs the user nothing if it also fails. + private static let pointerRelockQuietDelays: [TimeInterval] = [1.2, 2.4] #endif /// Reads whether the scene's pointer is actually locked right now; nil = state @@ -340,12 +349,80 @@ public final class StreamViewController: StreamViewControllerBase { // SwiftUI places us in the hierarchy AFTER start()'s setCaptured(true), and may reparent us // later — re-anchor the chain here so a lock requested before we had a parent still lands. updatePointerLockChain() + anchorKeyResponder() } public override func didMove(toParent parent: UIViewController?) { super.didMove(toParent: parent) updatePointerLockChain() // chain shape changed — re-anchor (or no-op if not yet in a window) } + + /// Put THIS controller on the responder chain for hardware key presses. + /// + /// Nothing of ours is otherwise a first responder during a normal stream: keys arrive on the + /// GameController (`GCKeyboard`) path, which is a parallel HID feed that does not consume the + /// UIKit event, and `StreamLayerUIView` only becomes first responder to summon the SOFT + /// keyboard (it is `UIKeyInput`, so making it one for any other reason would raise the on-screen + /// keyboard mid-game). With no responder of ours in the chain, every hardware key press reaches + /// UIKit unclaimed — and an unclaimed press is what lets the system apply its own default for + /// that key. `pressesBegan` below is where we claim Escape; this is what gets it delivered. + /// + /// A controller is not `UIKeyInput`, so being first responder raises no keyboard. Deferred to + /// the soft keyboard whenever the view has taken over, so the three-finger-swipe keyboard is + /// unaffected. + /// + /// Only while captured — the whole claim is scoped to "the stream owns the keyboard", and + /// holding the chain outside that would sit in front of SwiftUI's focus for no reason. Safe to + /// call from anywhere: `start()` engages capture BEFORE SwiftUI puts us in a window (where + /// `becomeFirstResponder` cannot succeed), so `viewDidAppear` calls it again to catch up. + private func anchorKeyResponder() { + guard captured, !streamView.isFirstResponder, !isFirstResponder else { return } + becomeFirstResponder() + } + + public override var canBecomeFirstResponder: Bool { true } + + /// Claim Escape while the stream owns the keyboard, so the SYSTEM never gets to act on it. + /// + /// This is the fix for "Escape hands the mouse back to iPadOS": the platform releases the + /// scene's pointer lock on an Escape that nothing claimed — the same "let me out" the web + /// Pointer Lock API mandates. Every recovery attempt before this one fought that release AFTER + /// the fact (a re-lock burst, then a click), and the platform's post-Escape cooldown means the + /// burst is refused by construction. Claiming the press means there is nothing to recover from. + /// + /// Escape is forwarded to the host on the GCKeyboard path, which is untouched by this — that + /// path never sees the UIKit responder chain, so the host still receives the keystroke and + /// in-game menus still open. Only the system's own interpretation is suppressed. + /// + /// Strictly scoped: only while `captured` (the stream owns input), and only Escape. Anything + /// else — including every key while the pointer is released — goes to `super` untouched, so + /// Escape still dismisses sheets, exits full screen and does everything else it should whenever + /// we are not holding the keyboard. The deliberate ways out are unaffected: ⌘⎋ and ⌃⌥⇧Q are + /// recognized on the GCKeyboard path and clear `captured` themselves. + public override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + let unclaimed = presses.filter { !claimsPress($0) } + if !unclaimed.isEmpty || presses.isEmpty { + super.pressesBegan(unclaimed, with: event) + } + } + + public override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + let unclaimed = presses.filter { !claimsPress($0) } + if !unclaimed.isEmpty || presses.isEmpty { + super.pressesEnded(unclaimed, with: event) + } + } + + public override func pressesCancelled(_ presses: Set, with event: UIPressesEvent?) { + // Never swallowed: a cancelled press is the system taking the key away from us, and + // dropping it here would strand UIKit's own bookkeeping for a press we did claim. + super.pressesCancelled(presses, with: event) + } + + /// Is this press one the stream owns outright (Escape while captured)? + private func claimsPress(_ press: UIPress) -> Bool { + captured && press.key?.keyCode == .keyboardEscape + } #endif #if os(tvOS) @@ -478,6 +555,7 @@ public final class StreamViewController: StreamViewControllerBase { if !down, self.wantsPointerLock, self.pointerLockWasEngaged, !self.pointerRelockPending, self.pointerLockEngaged() != true { self.pointerRelockAttempt = 0 + self.pointerRelockQuietAttempt = 0 // a real gesture buys a fresh tail too self.updatePointerLockChain() // a reparent since the drop would break the walk to us self.requestPointerRelock() } @@ -749,10 +827,16 @@ public final class StreamViewController: StreamViewControllerBase { guard captureEnabled, !captured, connection != nil else { return } inputCapture?.setForwarding(true, suppressClick: fromClick) captured = true + // Claim the responder chain for as long as we own the keyboard — `pressesBegan` has to + // be delivered to us before it can keep Escape away from the system. + anchorKeyResponder() } else { guard captured else { return } inputCapture?.setForwarding(false) captured = false + // Hand the chain back: released means Escape is the system's again, and staying first + // responder for a stream that no longer owns input would sit in front of SwiftUI focus. + if isFirstResponder { resignFirstResponder() } } setNeedsUpdateOfPrefersPointerLocked() updatePointerLockChain() // (re)anchor the SwiftUI ancestors so the lock actually resolves @@ -782,6 +866,7 @@ public final class StreamViewController: StreamViewControllerBase { pointerLockWasEngaged = true pointerRelockPending = false pointerRelockAttempt = 0 + pointerRelockQuietAttempt = 0 // granted — any scheduled tail finds nothing to do } else if wantsPointerLock, pointerLockWasEngaged { requestPointerRelock() } else { @@ -790,6 +875,7 @@ public final class StreamViewController: StreamViewControllerBase { if !wantsPointerLock { pointerLockWasEngaged = false } pointerRelockPending = false pointerRelockAttempt = 0 + pointerRelockQuietAttempt = 0 } let useGCMouse = captured && locked // Lock dropped (or capture ended) while the GCMouse path held a button down: once @@ -830,10 +916,12 @@ public final class StreamViewController: StreamViewControllerBase { pointerRelockAttempt = 0 } guard pointerRelockAttempt < Self.pointerRelockAttemptLimit else { - // Out of budget: fall back to exactly today's behavior — the iPadOS cursor comes back - // and a click into the video re-captures. The caller invalidates the interaction, so - // the cursor can never stay hidden on a lock the system won't grant. + // Out of VISIBLE budget: give the cursor straight back (the caller invalidates the + // interaction, so it can never stay hidden on a lock the system won't grant) and hand + // off to the quiet tail, which keeps asking after the platform's post-Escape cooldown + // without costing the user anything while it does. pointerRelockPending = false + scheduleQuietRelock() return } pointerRelockAttempt += 1 @@ -881,6 +969,43 @@ public final class StreamViewController: StreamViewControllerBase { } } } + + /// Keep asking for the lock after the visible burst has given up — past the cooldown the + /// platform applies to its own Escape gesture, which is the window the burst spends entirely. + /// + /// Deliberately NOT a longer burst. `pointerRelockPending` hides the cursor and mutes absolute + /// motion, which is only tolerable for the couple of frames a fast re-grab takes; holding that + /// for seconds would trade a released pointer for a frozen one. These attempts leave the + /// pointer fully usable — if they all fail the user sees exactly today's behaviour, and a click + /// is still the immediate way back. + /// + /// Each attempt presents a real false→true transition (the same escalation the burst uses on + /// its later tries) because re-asserting a value the system already holds is what didn't take. + /// A grant arrives as a `didChange` → `syncPointerLock`, which resets the counters, so a + /// successful attempt silently ends the tail. + private func scheduleQuietRelock() { + guard pointerRelockQuietAttempt < Self.pointerRelockQuietDelays.count else { return } + let delay = Self.pointerRelockQuietDelays[pointerRelockQuietAttempt] + pointerRelockQuietAttempt += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + // Still wanted, still ours to want, and still not held — otherwise the tail is moot. + guard self.wantsPointerLock, self.pointerLockWasEngaged, + self.pointerLockEngaged() != true, + self.view.window?.windowScene?.activationState == .foregroundActive + else { return } + self.pointerLockForcedOff = true + self.setNeedsUpdateOfPrefersPointerLocked() + self.updatePointerLockChain() + DispatchQueue.main.asyncAfter(deadline: .now() + Self.pointerLockForcedOffHold) { + [weak self] in + guard let self else { return } + self.pointerLockForcedOff = false + self.setNeedsUpdateOfPrefersPointerLocked() + self.scheduleQuietRelock() // no-op once the delays are spent, or once granted + } + } + } #endif deinit { From d4dd5f7a3d6575726d77a318b5b6a3a2f97c9bda Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 13:58:57 +0200 Subject: [PATCH 3/6] feat(client): a game exiting takes you back to its library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quit a game you launched from a host's library and the stream ended with "Session ended by ." 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. --- .../Sources/PunktfunkClient/ContentView.swift | 10 ++++++ .../Session/SessionModel.swift | 30 ++++++++++++++-- .../Connection/PunktfunkConnection.swift | 18 ++++++++++ crates/punktfunk-core/src/abi.rs | 36 +++++++++++++++++++ crates/punktfunk-core/src/client/mod.rs | 25 +++++++++++++ crates/punktfunk-core/src/client/pump.rs | 15 ++++++-- crates/punktfunk-core/src/client/worker.rs | 4 +++ crates/punktfunk-core/src/lib.rs | 8 ++++- include/punktfunk_core.h | 28 ++++++++++++++- 9 files changed, 168 insertions(+), 6 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index c28d6add..11c2471d 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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). diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 8cdf017f..26c51431 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 93d319f0..3cf6b5be 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -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). diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index bc1a9772..f894e340 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -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 diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index f9c15d8b..1db75644 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -180,6 +180,9 @@ pub struct NativeClient { /// Speed-test accumulator, shared with the data-plane pump + control task. probe: Arc>, shutdown: Arc, + /// Set with `shutdown` when the host's close carried [`crate::quic::APP_EXITED_CLOSE_CODE`] — + /// see [`NativeClient::ended_because_game_exited`]. + game_exited: Arc, /// 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::(CURSOR_STATE_QUEUE); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); 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 " 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 diff --git a/crates/punktfunk-core/src/client/pump.rs b/crates/punktfunk-core/src/client/pump.rs index e6ab8e58..08694306 100644 --- a/crates/punktfunk-core/src/client/pump.rs +++ b/crates/punktfunk-core/src/client/pump.rs @@ -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); }); } diff --git a/crates/punktfunk-core/src/client/worker.rs b/crates/punktfunk-core/src/client/worker.rs index 35685135..d8029e75 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -68,6 +68,10 @@ pub(crate) struct WorkerArgs { pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) ready_tx: std::sync::mpsc::Sender>, pub(crate) shutdown: Arc, + /// 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, /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. pub(crate) quit: Arc, pub(crate) mode_slot: Arc>, diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 122486f3..1c8530b9 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -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** diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 847b97ee..ce197656 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -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 From ec444962859ff8f11a09871e15e6cdfae6640831 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 14:30:33 +0200 Subject: [PATCH 4/6] feat(client): tell clients WHY a session ended, not just that it did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session ending was a single bit. A player quitting their game, an operator ending the session from the console, a stop the client itself asked for, a host crashing and a Wi-Fi drop all arrived as the same "closed" — so every client had to write one message covering all of them, and every client picked an error. That is how quitting your own game came to be reported as trouble on all three. The information was already there and thrown away: the host closes with APP_EXITED when a launched game exits, with 0 when it ends the session cleanly and 1 when it fails, and a link that simply dies never closes at all. The connection watcher now classifies that into a PunktfunkEndReason — local, game exited, host ended, host error, lost — and latches it before the shutdown flag, since the two are read by different threads and the reason must never arrive second. Exposed as punktfunk_connection_end_reason. This replaces the game-exited flag added a moment ago rather than joining it: that question is one row of this table, and it was never released. Still additive to any embedder that ignores it, and the host sends the same bytes either way, so the wire is untouched. `is_normal()` is the question nearly every caller actually has, so both the Rust and C surfaces answer it directly rather than making each client re-derive which of five values are worth alarming a user about. --- crates/punktfunk-core/cbindgen.toml | 5 + crates/punktfunk-core/src/abi.rs | 27 +++-- crates/punktfunk-core/src/client/mod.rs | 128 ++++++++++++++++++--- crates/punktfunk-core/src/client/pump.rs | 19 ++- crates/punktfunk-core/src/client/worker.rs | 7 +- crates/punktfunk-core/src/lib.rs | 13 ++- include/punktfunk_core.h | 96 +++++++++++++--- 7 files changed, 224 insertions(+), 71 deletions(-) diff --git a/crates/punktfunk-core/cbindgen.toml b/crates/punktfunk-core/cbindgen.toml index 25742f4d..428cd854 100644 --- a/crates/punktfunk-core/cbindgen.toml +++ b/crates/punktfunk-core/cbindgen.toml @@ -18,6 +18,11 @@ parse_deps = false # undefined and the C harness fails to compile: the Apple batched recv (transport/udp.rs # `recvmsg_x` + `MsghdrX`) and the Android bionic mmsg bindings (`android_mmsg` module). exclude = ["MsghdrX", "recvmsg_x", "mmsghdr", "sendmmsg", "recvmmsg"] +# Reached by no exported SIGNATURE, so cbindgen's sweep misses it — but a C embedder needs the +# vocabulary: `punktfunk_connection_end_reason` writes one of these as a bare byte (deliberately, +# so the JNI/Swift sides can marshal a `u8` rather than an enum), which without this would leave +# the header documenting names it never defines. +include = ["PunktfunkEndReason"] [export.rename] "InputEvent" = "PunktfunkInputEvent" diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index f894e340..686b6c38 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -2273,24 +2273,27 @@ 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. +/// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte +/// (`PUNKTFUNK_END_REASON_*`). 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. +/// Read it once a plane has returned [`PunktfunkStatus::Closed`] (or the embedder's own +/// end-of-session signal fired); before that it reads `NONE`. It latches, so it is still readable +/// while the connection is 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. +/// **Most endings are not failures.** Before this, a client had no way to tell a player quitting +/// their game from a host falling off the network, so every client wrote one message for all of +/// them and every client chose an error. Use `LOCAL`/`GAME_EXITED`/`HOST_ENDED` to stay quiet (and +/// `GAME_EXITED` to return to the library the title was launched from), and keep the alarming copy +/// for `HOST_ERROR` and `LOST`. +/// +/// Treat an unrecognized value as `NONE` — this crosses an ABI and the core may be newer than you. /// /// # 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( +pub unsafe extern "C" fn punktfunk_connection_end_reason( c: *mut PunktfunkConnection, out: *mut u8, ) -> PunktfunkStatus { @@ -2303,7 +2306,7 @@ pub unsafe extern "C" fn punktfunk_connection_game_exited( }; 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()) }; + unsafe { *out = c.inner.end_reason() as u8 }; } PunktfunkStatus::Ok }) diff --git a/crates/punktfunk-core/src/client/mod.rs b/crates/punktfunk-core/src/client/mod.rs index 1db75644..865f6b3f 100644 --- a/crates/punktfunk-core/src/client/mod.rs +++ b/crates/punktfunk-core/src/client/mod.rs @@ -110,6 +110,91 @@ pub struct MicUplinkStats { /// the control task is wedged, which callers treat as a closed session. const CTRL_QUEUE: usize = 32; +/// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the +/// C surface. +/// +/// The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a +/// player quitting their game and a host falling off the network both arrive as "the session +/// ended", and a client with no way to separate them has to word all of them the same. Every client +/// worded them as failures. +/// +/// Ordered loosely from "the user did this on purpose" to "something went wrong". Values are part +/// of the C ABI: append only, never renumber. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PunktfunkEndReason { + /// Not ended (or ended before a reason could be observed). Also what an unknown future value + /// decodes to, so an older client reading a newer core degrades to "no opinion". + None = 0, + /// **This client** closed the session — the user pressed stop, or the handle was dropped. + /// Nothing to report: the UI already knows, it initiated it. + Local = 1, + /// The host's launched game exited ([`crate::quic::APP_EXITED_CLOSE_CODE`]). A normal finish, + /// and the one reason a launcher client can act on: go back to the library the title was + /// launched from rather than all the way out to host selection. + GameExited = 2, + /// The host ended the session cleanly and deliberately — an operator "End" in the console, or + /// the session simply finishing. Normal; say so plainly or say nothing. + HostEnded = 3, + /// The host closed reporting a failure of its own. Worth showing, and the host's log has the + /// detail. + HostError = 4, + /// The connection died rather than being closed: idle timeout, reset, the network going away. + /// This — and only this — is the "the host may be asleep, wake it" case. + Lost = 5, +} + +impl PunktfunkEndReason { + /// Decode the wire/ABI byte. Unknown values become [`Self::None`] rather than panicking: this + /// crosses an ABI where the writer may be newer than the reader. + pub fn from_u8(v: u8) -> Self { + match v { + 1 => Self::Local, + 2 => Self::GameExited, + 3 => Self::HostEnded, + 4 => Self::HostError, + 5 => Self::Lost, + _ => Self::None, + } + } + + /// Whether this ending is an ordinary outcome rather than something to alarm the user about. + /// + /// The single question nearly every client actually asks. `Local`, `GameExited` and `HostEnded` + /// are all things that were *meant* to happen; only a host-side failure or a dead connection + /// are not. [`Self::None`] counts as normal — no evidence of trouble is not evidence of it. + pub fn is_normal(self) -> bool { + !matches!(self, Self::HostError | Self::Lost) + } +} + +#[cfg(feature = "quic")] +impl From<&quinn::ConnectionError> for PunktfunkEndReason { + /// Classify the QUIC close. + /// + /// Only two application codes ever arrive from a host at session end: `APP_EXITED` when the + /// game it launched quit, and the teardown's own `0` (clean) / `1` (the session returned an + /// error) from `native.rs`. Anything else with an application code is a deliberate host-side + /// close we do not have a name for, which is still closer to "the host ended it" than to a + /// dead link — but a code we have never issued is more likely a fault than a courtesy, so it + /// lands in `HostError` where it will at least be visible. + fn from(e: &quinn::ConnectionError) -> Self { + match e { + quinn::ConnectionError::LocallyClosed => Self::Local, + quinn::ConnectionError::ApplicationClosed(ac) => { + match u32::try_from(u64::from(ac.error_code)) { + Ok(crate::quic::APP_EXITED_CLOSE_CODE) => Self::GameExited, + Ok(0) => Self::HostEnded, + _ => Self::HostError, + } + } + // TimedOut, Reset, VersionMismatch, TransportError, CidsExhausted, and the peer's + // transport-level close: the link failed, nobody said goodbye. + _ => Self::Lost, + } + } +} + pub struct NativeClient { // Each plane's receiver sits behind its own mutex so `NativeClient` is `Sync` and Rust // embedders can share one `Arc` across their plane threads (the same @@ -180,9 +265,9 @@ pub struct NativeClient { /// Speed-test accumulator, shared with the data-plane pump + control task. probe: Arc>, shutdown: Arc, - /// Set with `shutdown` when the host's close carried [`crate::quic::APP_EXITED_CLOSE_CODE`] — - /// see [`NativeClient::ended_because_game_exited`]. - game_exited: Arc, + /// A [`PunktfunkEndReason`] as `u8`, latched with `shutdown` — see + /// [`NativeClient::end_reason`]. + end_reason: Arc, /// 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. @@ -451,7 +536,7 @@ impl NativeClient { std::sync::mpsc::sync_channel::(CURSOR_STATE_QUEUE); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let shutdown = Arc::new(AtomicBool::new(false)); - let game_exited = Arc::new(AtomicBool::new(false)); + let end_reason = Arc::new(AtomicU8::new(PunktfunkEndReason::None as u8)); 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())); @@ -467,7 +552,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 end_reason_w = end_reason.clone(); let quit_w = quit.clone(); let mode_slot_w = mode_slot.clone(); let probe_w = probe.clone(); @@ -543,7 +628,7 @@ impl NativeClient { clip_cmd_rx, ready_tx, shutdown: shutdown_w, - game_exited: game_exited_w, + end_reason: end_reason_w, quit: quit_w, mode_slot: mode_slot_w, probe: probe_w, @@ -597,7 +682,7 @@ impl NativeClient { host_caps: negotiated.host_caps, probe, shutdown, - game_exited, + end_reason, quit, worker: Some(worker), frames_dropped, @@ -816,22 +901,27 @@ 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. + /// WHY the session ended — see [`PunktfunkEndReason`]. /// - /// 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. + /// A refinement of [`is_session_ended`](Self::is_session_ended), never a substitute: it stays + /// [`PunktfunkEndReason::None`] until that is true, and every client that ignores it behaves + /// exactly as it did before this existed. /// - /// 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 " and dropping to host selection, which - /// reads as an error for something the player just did on purpose. + /// What it is FOR: **most endings are not failures.** A client that cannot tell them apart has + /// to pick one wording for all of them, and every such client picked an error — "Session ended + /// by ", "Connection lost — the host may be asleep" — including when the player quit the + /// game themselves. This is the discriminator that lets each client stay quiet for a normal + /// finish, return to its library when a launched game exits, and reserve the alarming copy for + /// an ending that actually deserves it. /// - /// 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. + /// Latches, so it is still readable while the connection is being torn down. + pub fn end_reason(&self) -> PunktfunkEndReason { + PunktfunkEndReason::from_u8(self.end_reason.load(Ordering::SeqCst)) + } + + /// Shorthand for the single most actionable reason: the host's launched game exited. pub fn ended_because_game_exited(&self) -> bool { - self.game_exited.load(Ordering::SeqCst) + self.end_reason() == PunktfunkEndReason::GameExited } /// Register the calling thread as latency-critical so a later diff --git a/crates/punktfunk-core/src/client/pump.rs b/crates/punktfunk-core/src/client/pump.rs index 08694306..8308f2e2 100644 --- a/crates/punktfunk-core/src/client/pump.rs +++ b/crates/punktfunk-core/src/client/pump.rs @@ -65,7 +65,7 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, ready_tx, shutdown, - game_exited, + end_reason, quit, mode_slot, probe, @@ -195,22 +195,17 @@ pub(super) async fn run_pump(args: WorkerArgs) { clip_cmd_rx, )); - // Watch for connection close → stop the pump, and record WHY if the host said so. + // Watch for connection close → stop the pump, and classify WHY. { let shutdown = shutdown.clone(); - let game_exited = game_exited.clone(); + let end_reason = end_reason.clone(); let conn = conn.clone(); tokio::spawn(async move { 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); - } - } + // Latch the reason BEFORE `shutdown`: the two are observed by different threads, and a + // client that reacts to the shutdown flag must never find the reason still unset. + let reason = crate::client::PunktfunkEndReason::from(&why); + end_reason.store(reason as u8, Ordering::SeqCst); shutdown.store(true, Ordering::SeqCst); }); } diff --git a/crates/punktfunk-core/src/client/worker.rs b/crates/punktfunk-core/src/client/worker.rs index d8029e75..0b8e0fa5 100644 --- a/crates/punktfunk-core/src/client/worker.rs +++ b/crates/punktfunk-core/src/client/worker.rs @@ -68,10 +68,9 @@ pub(crate) struct WorkerArgs { pub(crate) clip_cmd_rx: tokio::sync::mpsc::UnboundedReceiver, pub(crate) ready_tx: std::sync::mpsc::Sender>, pub(crate) shutdown: Arc, - /// 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, + /// A [`crate::client::PunktfunkEndReason`] as `u8`, classified from the connection's close and + /// latched alongside `shutdown` (see [`NativeClient::end_reason`]). + pub(crate) end_reason: Arc, /// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set. pub(crate) quit: Arc, pub(crate) mode_slot: Arc>, diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 1c8530b9..60b559dd 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -138,12 +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. -/// 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. +/// v17: added `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` vocabulary — asks, +/// once a session has ended, WHY: this client closed it, the host's launched game exited (its close +/// carried [`quic::APP_EXITED_CLOSE_CODE`], which the host has sent since long before this bump +/// with nothing consuming it), the host ended it cleanly, the host reported a failure, or the +/// connection was simply lost. 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. diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index ce197656..9bf9d924 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -76,12 +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. -// 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. +// v17: added `punktfunk_connection_end_reason` + the `PUNKTFUNK_END_REASON_*` vocabulary — asks, +// once a session has ended, WHY: this client closed it, the host's launched game exited (its close +// carried [`quic::APP_EXITED_CLOSE_CODE`], which the host has sent since long before this bump +// with nothing consuming it), the host ended it cleanly, the host reported a failure, or the +// connection was simply lost. 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. @@ -1622,6 +1623,63 @@ typedef uint8_t PunktfunkInputKind; #endif // __STDC_VERSION__ >= 202311L #endif // __cplusplus +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Why a session ended — [`NativeClient::end_reason`], and `punktfunk_connection_end_reason` on the +// C surface. +// +// The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a +// player quitting their game and a host falling off the network both arrive as "the session +// ended", and a client with no way to separate them has to word all of them the same. Every client +// worded them as failures. +// +// Ordered loosely from "the user did this on purpose" to "something went wrong". Values are part +// of the C ABI: append only, never renumber. +enum PunktfunkEndReason +#if defined(__cplusplus) || __STDC_VERSION__ >= 202311L + : uint8_t +#endif // defined(__cplusplus) || __STDC_VERSION__ >= 202311L + { +#if defined(PUNKTFUNK_FEATURE_QUIC) + // Not ended (or ended before a reason could be observed). Also what an unknown future value + // decodes to, so an older client reading a newer core degrades to "no opinion". + PUNKTFUNK_END_REASON_NONE = 0, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // **This client** closed the session — the user pressed stop, or the handle was dropped. + // Nothing to report: the UI already knows, it initiated it. + PUNKTFUNK_END_REASON_LOCAL = 1, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host's launched game exited ([`crate::quic::APP_EXITED_CLOSE_CODE`]). A normal finish, + // and the one reason a launcher client can act on: go back to the library the title was + // launched from rather than all the way out to host selection. + PUNKTFUNK_END_REASON_GAME_EXITED = 2, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host ended the session cleanly and deliberately — an operator "End" in the console, or + // the session simply finishing. Normal; say so plainly or say nothing. + PUNKTFUNK_END_REASON_HOST_ENDED = 3, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The host closed reporting a failure of its own. Worth showing, and the host's log has the + // detail. + PUNKTFUNK_END_REASON_HOST_ERROR = 4, +#endif +#if defined(PUNKTFUNK_FEATURE_QUIC) + // The connection died rather than being closed: idle timeout, reset, the network going away. + // This — and only this — is the "the host may be asleep, wake it" case. + PUNKTFUNK_END_REASON_LOST = 5, +#endif +}; +#ifndef __cplusplus +#if __STDC_VERSION__ >= 202311L +typedef enum PunktfunkEndReason PunktfunkEndReason; +#else +typedef uint8_t PunktfunkEndReason; +#endif // __STDC_VERSION__ >= 202311L +#endif // __cplusplus +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Per-session colour signalling (CICP / ITU-T H.273 code points) the host resolved for the // encoded video, carried on [`Welcome`]. A client configures its decoder/presenter from these @@ -2505,23 +2563,25 @@ PunktfunkStatus punktfunk_connection_audio_channels(PunktfunkConnection *c, uint #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. +// WHY this session ended: `*out` receives a [`PunktfunkEndReason`] byte +// (`PUNKTFUNK_END_REASON_*`). 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. +// Read it once a plane has returned [`PunktfunkStatus::Closed`] (or the embedder's own +// end-of-session signal fired); before that it reads `NONE`. It latches, so it is still readable +// while the connection is 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. +// **Most endings are not failures.** Before this, a client had no way to tell a player quitting +// their game from a host falling off the network, so every client wrote one message for all of +// them and every client chose an error. Use `LOCAL`/`GAME_EXITED`/`HOST_ENDED` to stay quiet (and +// `GAME_EXITED` to return to the library the title was launched from), and keep the alarming copy +// for `HOST_ERROR` and `LOST`. +// +// Treat an unrecognized value as `NONE` — this crosses an ABI and the core may be newer than you. // // # 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); +PunktfunkStatus punktfunk_connection_end_reason(PunktfunkConnection *c, uint8_t *out); #endif #if defined(PUNKTFUNK_FEATURE_QUIC) From 81b4f76c4d2130b43b89e0c2fb8c716b67caec6f Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 14:30:46 +0200 Subject: [PATCH 5/6] fix(client): a session ending on purpose stops reading as a failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop clients turned every host-side close into "Host ended the session", and a reason string means "abnormal" to everything downstream: the GTK and Windows shells raised a banner, the console overlay drew a status strip. Quitting a game you launched yourself produced all of that. Now only a host error or a lost connection carries a message; the deliberate endings return the silence those shells already give a clean exit, which is also what puts the console back in its library with nothing in the way. The Apple client gains the same distinction. It had one line for every ending — "Session ended by ." — which is fine for an operator stopping the session and wrong for a link that died, so each now says what happened. A game exiting stays silent and returns to the library it was launched from. Both read the reason while the connection is still up, because tearing it down is what makes it unreadable, and both fall back to their previous wording when there is no verdict — an older core, or a close that raced the read — rather than inventing a new one for a case they cannot see. --- .../Session/SessionModel.swift | 21 +++++-- .../Connection/PunktfunkConnection.swift | 55 ++++++++++++++----- crates/pf-client-core/src/session.rs | 22 +++++++- 3 files changed, 77 insertions(+), 21 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 26c51431..86dbb4cc 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -640,20 +640,31 @@ final class SessionModel: ObservableObject { 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 + let reason = conn.sessionEndReason // 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 - if gameExited { + switch reason { + case .gameExited: + // The player quit their own game. Not a failure, and they are probably after the next + // title — so no banner, and back to the library it came from. if cameFromLibrary, let host { returnToLibrary = host } - } else { + case .hostEnded, .local: + // Someone asked for this: an operator "End" on the host, or our own close racing in. + // Say it plainly, without the error framing. + errorMessage = "\(name) ended the session." + case .hostError: + errorMessage = "\(name) ended the session with an error." + case .lost: + errorMessage = "Lost the connection to \(name)." + case .none: + // No verdict (an older core, or the close raced the read): keep the wording this path + // has always used rather than inventing one. errorMessage = "Session ended by \(name)." } } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index 3cf6b5be..350b44f6 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -1430,24 +1430,49 @@ public final class PunktfunkConnection { } } - /// Did this session end because **the game the host launched for it exited**, rather than the - /// stream dropping out? + /// Why a stream session ended — the Swift mirror of `PunktfunkEndReason` (ABI v17). /// - /// 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 + /// The distinction that matters to a UI is normal vs alarming, and it is not a spectrum: a + /// player quitting their game and a host falling off the network both arrive as "the session + /// ended". Without this every client wrote one message for all of them, and every client chose + /// an error. + public enum SessionEndReason: UInt8, Sendable { + /// Not ended, or ended before a reason could be observed. Also the fallback for an + /// unrecognized value — the core may be newer than this code. + case none = 0 + /// This client closed the session. Nothing to report: the UI initiated it. + case local = 1 + /// The host's launched game exited. A normal finish, and the one reason worth acting on: + /// go back to the library the title was launched from. + case gameExited = 2 + /// The host ended the session deliberately (an operator "End", or it simply finished). + case hostEnded = 3 + /// The host closed reporting a failure of its own. + case hostError = 4 + /// The connection died rather than being closed: idle timeout, reset, network gone. This — + /// and only this — is the "the host may be asleep" case. + case lost = 5 + + /// Is this an ordinary outcome rather than something to alarm the user about? `.none` + /// counts as normal: no evidence of trouble is not evidence of it. + public var isNormal: Bool { self != .hostError && self != .lost } } + /// Why this session ended. Only meaningful once it HAS ended (a plane threw `.closed`, or + /// `onSessionEnd` fired) — before that it is `.none`. + /// + /// Read it before tearing the connection down: once `close()` has been requested this reports + /// `.none`, which is the safe direction (the caller falls back to its normal handling). + public var sessionEndReason: SessionEndReason { + guard let h = liveHandle() else { return .none } + var out: UInt8 = 0 + guard punktfunk_connection_end_reason(h, &out) == statusOK else { return .none } + return SessionEndReason(rawValue: out) ?? .none + } + + /// Shorthand for the single most actionable reason: the host's launched game exited. + public var endedBecauseGameExited: Bool { sessionEndReason == .gameExited } + deinit { close() } /// Snapshot the handle unless close is pending (callers hold their plane lock). diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index c0949c02..11449db1 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -908,7 +908,27 @@ fn pump( } } Err(PunktfunkError::NoFrame) => {} - Err(PunktfunkError::Closed) => break Some("Host ended the session".to_string()), + // The session ended. `None` here means "normal finish" to every embedder — the browse + // console returns to the library with no status strip, the one-shot binary exits 0 + // quietly — so only an ending that actually went wrong should carry a message. + // Previously EVERY close reported "Host ended the session", which put an error-shaped + // line in front of the player for quitting their own game. + Err(PunktfunkError::Closed) => { + use punktfunk_core::client::PunktfunkEndReason as End; + break match connector.end_reason() { + // The player quit the game the host launched. Nothing to report; a launcher + // embedder returns to its library, which is where they were headed anyway. + End::GameExited => None, + // We closed it, or the host closed cleanly (an operator "End", or the session + // simply finishing). Both were asked for. + End::Local | End::HostEnded => None, + End::HostError => Some("The host ended the session with an error".to_string()), + End::Lost => Some("Connection lost".to_string()), + // No verdict (an older core, or the close raced the read): keep the wording + // this arm has always used rather than inventing a new one. + End::None => Some("Host ended the session".to_string()), + }; + } Err(e) => break Some(format!("session: {e:?}")), } From 72777119fd39a38eafd0d0fff4df3785404bd552 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 6 Aug 2026 14:30:59 +0200 Subject: [PATCH 6/6] fix(client/android): stop reporting every disconnect as a lost connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stream watchdog polled a bare "has the session ended" boolean, so it had exactly one thing it could say and said it every time: "Connection lost — the host may be asleep. Wake it to reconnect." That ran when the player quit their game, when an operator ended the session from the console, and when they pressed Back themselves — telling them to go wake a host that was never asleep. It now reads the end reason. Only a connection that actually died gets that line, a host-side failure gets its own, and the three deliberate endings say nothing at all: leaving the stream is already the feedback, and a toast on top of it is just noise. A game launched from a library also returns to that library instead of host selection, which needs the intent hoisted out of the console shell: the stream replaces that shell in the composition, discarding the `remember`s holding its screen and host, so by the time the session ends there is nothing left to navigate back with. The parent holds it across the gap and the shell consumes it on the way in. The touch UI has no library — only the console shell does — so there it is the toast fix alone. --- .../src/main/kotlin/io/unom/punktfunk/App.kt | 44 ++++++++++++++- .../kotlin/io/unom/punktfunk/LibraryScreen.kt | 9 ++- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 43 ++++++++++---- .../io/unom/punktfunk/models/UiModels.kt | 10 ++++ .../io/unom/punktfunk/kit/NativeBridge.kt | 12 ++++ .../io/unom/punktfunk/kit/SessionEndReason.kt | 56 +++++++++++++++++++ clients/android/native/src/session/connect.rs | 25 +++++++++ 7 files changed, 186 insertions(+), 13 deletions(-) create mode 100644 clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt index 43a66b76..54c2f02e 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/App.kt @@ -47,6 +47,7 @@ import android.widget.Toast import io.unom.punktfunk.kit.link.DeepLinkResult import io.unom.punktfunk.kit.link.DeepLinks import io.unom.punktfunk.kit.link.HostResolution +import io.unom.punktfunk.kit.SessionEndReason import io.unom.punktfunk.kit.security.KnownHostStore import io.unom.punktfunk.models.ActiveSession import io.unom.punktfunk.models.Tab @@ -61,6 +62,11 @@ fun App(forceGamepadUi: Boolean = false) { // so the stream screen never re-reads the store behind its own connect's back. var session by remember { mutableStateOf(null) } var tab by remember { mutableStateOf(Tab.Connect) } + // Set when a session ends because its game exited and it began as a library launch: the host + // whose library the console shell should come back to. Held HERE because the shell's own + // navigation state does not outlive the stream. Cleared once the shell has consumed it, so a + // later manual Back out of the library is not undone by a stale value. + var reopenLibraryHostId by remember { mutableStateOf(null) } // Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is // a TV OR the dev force flag). Flips live as controllers connect/disconnect. @@ -107,7 +113,20 @@ fun App(forceGamepadUi: Boolean = false) { ) { active -> if (active != null) { // Immersive: the stream takes the whole screen, no bottom bar. - StreamScreen(active, onDisconnect = { session = null }) + StreamScreen(active) { reason -> + // A game launched from a library exiting is a normal finish, and the player is + // almost certainly after the next title — so send them back to that library rather + // than all the way out to host selection. The console shell's own screen state does + // not survive the stream (StreamScreen replaces it in the composition, discarding + // its `remember`s), so the intent is hoisted here and handed back on the way in. + reopenLibraryHostId = + if (reason == SessionEndReason.GAME_EXITED && active.launchedFromLibrary) { + active.hostId + } else { + null + } + session = null + } } else if (gamepadUi) { GamepadShell( settings = settings, @@ -115,6 +134,8 @@ fun App(forceGamepadUi: Boolean = false) { onConnected = { session = it }, deepLink = pendingLink, onDeepLinkHandled = { activity?.pendingDeepLink = null }, + reopenLibraryHostId = reopenLibraryHostId, + onReopenLibraryHandled = { reopenLibraryHostId = null }, ) } else { // Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail @@ -218,11 +239,32 @@ fun GamepadShell( onConnected: (ActiveSession) -> Unit, deepLink: String? = null, onDeepLinkHandled: () -> Unit = {}, + /** + * Open this saved host's library instead of Home on the way in — set when a game launched from + * it has just exited. Null (the default) starts on Home exactly as before. + */ + reopenLibraryHostId: String? = null, + onReopenLibraryHandled: () -> Unit = {}, ) { val context = LocalContext.current var screen by remember { mutableStateOf(GamepadScreen.Home) } var libraryHost by remember { mutableStateOf(null) } + // Consume the "come back to this library" intent once, on entry. Keyed on the id so a second + // game exit re-fires it; the parent clears it immediately, so a manual Back stays backed out. + // A host that has since been forgotten simply leaves us on Home rather than failing. + LaunchedEffect(reopenLibraryHostId) { + val id = reopenLibraryHostId ?: return@LaunchedEffect + // Navigate BEFORE acknowledging: acknowledging clears the parent's state, which re-keys + // this effect and cancels the coroutine running it. Nothing suspends in between today, so + // either order happens to work — but this one cannot be broken by a later edit that adds a + // suspending call. A host that has since been forgotten just leaves us on Home. + KnownHostStore(context).all() + .firstOrNull { it.id == id } + ?.let { libraryHost = it; screen = GamepadScreen.Library } + onReopenLibraryHandled() + } + // On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the // effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the // panel reports fewer dp than that; a low-density TV that's already spacious, and every phone / diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt index 93939b23..f54de80f 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/LibraryScreen.kt @@ -145,7 +145,14 @@ fun LibraryScreen( launching = false if (handle != 0L) { onLaunched( - ActiveSession(handle, settings, host.clipboardSync), + ActiveSession( + handle, + settings, + host.clipboardSync, + hostId = host.id, + // Where to come back to when this game exits. + launchedFromLibrary = true, + ), ) } else Toast.makeText( diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt index a1abd4ed..600804b7 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/StreamScreen.kt @@ -73,6 +73,7 @@ import io.unom.punktfunk.kit.GamepadRouter import io.unom.punktfunk.kit.deviceBodyVibrator import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.Sc2Capture +import io.unom.punktfunk.kit.SessionEndReason import io.unom.punktfunk.kit.VideoDecoders import io.unom.punktfunk.models.ActiveSession import java.util.concurrent.atomic.AtomicBoolean @@ -86,7 +87,7 @@ import kotlinx.coroutines.delay * the connect that produced this handle. */ @Composable -fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { +fun StreamScreen(session: ActiveSession, onSessionEnded: (SessionEndReason) -> Unit) { val handle = session.handle val initialSettings = session.settings val micEnabled = initialSettings.micEnabled @@ -200,12 +201,32 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { while (true) { delay(1000) if (NativeBridge.nativeSessionEnded(handle)) { - Toast.makeText( - context, - "Connection lost — the host may be asleep. Wake it to reconnect.", - Toast.LENGTH_LONG, - ).show() - onDisconnect() + // WHY it ended decides what the user is told. This used to show the "host may be + // asleep" line for EVERY ending — including a game the player had just quit and a + // session the host ended on purpose — which reads as a failure report for + // something nobody did wrong. Only a connection that actually died says that now. + val reason = SessionEndReason.fromNative(NativeBridge.nativeEndReason(handle)) + when (reason) { + SessionEndReason.LOST -> + Toast.makeText( + context, + "Connection lost — the host may be asleep. Wake it to reconnect.", + Toast.LENGTH_LONG, + ).show() + SessionEndReason.HOST_ERROR -> + Toast.makeText( + context, + "The host ended the session with an error.", + Toast.LENGTH_LONG, + ).show() + // Deliberate endings — the player quit the game, the host was stopped, or we + // closed it. Leaving the stream IS the feedback; a toast would only add noise. + SessionEndReason.GAME_EXITED, + SessionEndReason.HOST_ENDED, + SessionEndReason.LOCAL, + SessionEndReason.NONE -> {} + } + onSessionEnded(reason) return@LaunchedEffect } } @@ -330,7 +351,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // the keep-alive linger), unlike a host-ended / backgrounded drop. The router debounces it // (must be held ~1.5 s) and fires onExitChord on its main-thread timer, so leave the stream // the same way the Back gesture does. - activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } + activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) } router.onExitChord = { activity?.requestStreamExit?.invoke() } // Show a "hold to quit" hint the moment the chord completes (the router debounces the actual // exit); it clears when the buttons release early or the hold elapses. Runs on the main thread. @@ -617,7 +638,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { } // Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger). - BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } + BackHandler { NativeBridge.nativeDisconnectQuit(handle); onSessionEnded(SessionEndReason.LOCAL) } // Leaving the app (Home, task switch, screen off) MUST end the session. Android does not // suspend a process for going to background, so without this the native worker kept running and @@ -625,14 +646,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) { // host still saw a live client and held the session (and its display + encoder) open until the // OS eventually reclaimed the process, which on a TV box is effectively never. // - // Route it through `onDisconnect()` so the composable's `onDispose` above runs the one real + // Route it through `onSessionEnded()` so the composable's `onDispose` above runs the one real // teardown path. Deliberately NOT a `nativeDisconnectQuit`: backgrounding isn't a user "quit", // so the host should linger the display and make coming straight back a fast reconnect. DisposableEffect(handle) { val lifecycle = (context as? LifecycleOwner)?.lifecycle val obs = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_STOP) { - onDisconnect() + onSessionEnded(SessionEndReason.LOCAL) } } lifecycle?.addObserver(obs) diff --git a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt index b33f4ac2..7fe3d279 100644 --- a/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt +++ b/clients/android/app/src/main/kotlin/io/unom/punktfunk/models/UiModels.kt @@ -61,6 +61,16 @@ data class ActiveSession( * from "a different host" (a notice; a URL may never preempt a live session). */ val hostId: String? = null, + /** + * This session was started by launching a title from [hostId]'s library, rather than by + * connecting to the host's desktop. + * + * Decides where the client goes when the session ENDS: a title launched out of a library + * belongs back in that library when its game exits — one press from the next one — not on the + * host-selection screen. Only meaningful together with a + * [io.unom.punktfunk.kit.SessionEndReason.GAME_EXITED] ending. + */ + val launchedFromLibrary: Boolean = false, ) /** Trust state of a host, shown as a colored pill on its card. */ diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt index e8a93e60..77513e1f 100644 --- a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/NativeBridge.kt @@ -87,6 +87,18 @@ object NativeBridge { */ external fun nativeSessionEnded(handle: Long): Boolean + /** + * WHY the session ended, as a [SessionEndReason] ordinal — decode with + * [SessionEndReason.fromNative]. `0` (NONE) before it ends, or on a `0` handle. + * + * The companion to [nativeSessionEnded], which only says THAT it ended. Both are needed: the + * flag to leave a dead stream, this to decide what to tell the user. A player quitting their + * game and a host falling off the network both end the session, and with no way to separate + * them the watchdog said "the host may be asleep" for all of them — wrong for every deliberate + * ending. Cheap (one atomic load); UI-safe. + */ + external fun nativeEndReason(handle: Long): Int + /** * Run the SPAKE2 PIN ceremony, presenting [certPem]/[keyPem]. Returns the host's verified * fingerprint (64-hex) to persist + pin, or `""` on failure (wrong PIN / MITM / unreachable). diff --git a/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt new file mode 100644 index 00000000..f918cb53 --- /dev/null +++ b/clients/android/kit/src/main/kotlin/io/unom/punktfunk/kit/SessionEndReason.kt @@ -0,0 +1,56 @@ +package io.unom.punktfunk.kit + +/** + * Why a stream session ended — the Kotlin mirror of `punktfunk_core::client::PunktfunkEndReason`, + * read via [NativeBridge.nativeEndReason]. + * + * The distinction that matters to a UI is **normal vs alarming**, and it is not a spectrum: a + * player quitting their game and a host falling off the network both arrive as "the session + * ended". With no way to tell them apart this client showed one message for all of them — and it + * was the alarming one ("Connection lost — the host may be asleep"), in front of players who had + * just quit their own game. + * + * Ordinals are an ABI contract with the Rust side: append only, never renumber. + */ +enum class SessionEndReason { + /** Not ended, or ended before a reason could be observed. Also the fallback for an unknown value. */ + NONE, + + /** This client closed the session — the user pressed back or stop. Nothing to report. */ + LOCAL, + + /** + * The host's launched game exited. A normal finish, and the one reason worth acting on: go back + * to the library the title was launched from, so the next one is a tap away. + */ + GAME_EXITED, + + /** The host ended the session deliberately (an operator "End", or it simply finished). Normal. */ + HOST_ENDED, + + /** The host closed reporting a failure of its own. Worth showing; the host's log has the detail. */ + HOST_ERROR, + + /** + * The connection died rather than being closed: idle timeout, reset, the network going away. + * This — and only this — is the "the host may be asleep, wake it" case. + */ + LOST; + + /** + * Is this an ordinary outcome rather than something to alarm the user about? + * + * The question nearly every caller actually asks. [LOCAL], [GAME_EXITED] and [HOST_ENDED] were + * all meant to happen. [NONE] counts as normal — no evidence of trouble is not evidence of it. + */ + val isNormal: Boolean + get() = this != HOST_ERROR && this != LOST + + companion object { + /** + * Decode the JNI byte. An unrecognized value becomes [NONE] rather than throwing: this + * crosses an ABI where the native side may be newer than this code. + */ + fun fromNative(v: Int): SessionEndReason = entries.getOrNull(v) ?: NONE + } +} diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 982400f7..98480361 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -404,6 +404,31 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSessionEnde }) } +/// `NativeBridge.nativeEndReason(handle): Int` — WHY the session ended, as a +/// `punktfunk_core::client::PunktfunkEndReason` byte (Kotlin mirrors it in `SessionEndReason`). +/// +/// Companion to `nativeSessionEnded`, which only says THAT it ended. Kotlin's watchdog needs both: +/// the flag to leave a dead stream, and this to decide what — if anything — to tell the user. A +/// player quitting their game and a host dropping off the network both end the session, and until +/// this existed the watchdog worded them identically ("the host may be asleep"), which is wrong for +/// every deliberate ending. `0` (NONE) on a `0` handle or before the session ends. Cheap (one +/// atomic load); safe on the UI thread. +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeEndReason( + _env: JNIEnv, + _this: JObject, + handle: jlong, +) -> jint { + jni_guard(0, || { + if handle == 0 { + return 0; + } + // SAFETY: live handle per the nativeConnect/nativeClose contract. + let h = unsafe { &*(handle as *const SessionHandle) }; + h.client.end_reason() as jint + }) +} + /// `NativeBridge.nativePair(host, port, certPem, keyPem, pin, name): String` — run the SPAKE2 PIN /// ceremony, presenting our persistent identity. On success returns the host's verified fingerprint /// (64-hex) to persist + pin; on any failure (wrong PIN / MITM / host reject / unreachable) returns