From 60815029496d86ceaa25d502dc4508cf8242db33 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 6 Jul 2026 13:37:45 +0000 Subject: [PATCH] feat(clients): signal explicit exit (QUIT_CLOSE_CODE) on deliberate disconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core's deliberate-quit close (NativeClient::disconnect_quit → QUIT_CLOSE_CODE, host skips the keep-alive linger) was implemented but never called by any client. Wire it to each client's explicit user-disconnect action — NOT to a network drop / host-ended / app-background (those keep the linger for a reconnect): - core: new C-ABI punktfunk_connection_disconnect_quit(c) for the ABI clients - Linux (direct-core): Ctrl+Alt+Shift+D + the controller escape chord - Windows (direct-core): Ctrl+Alt+Shift+D - Apple (C-ABI): PunktfunkConnection.disconnectQuit() + a `deliberate` flag on SessionModel.disconnect() (sessionEnded passes false → keeps the linger) - Android (JNI): new nativeDisconnectQuit export, called from the back gesture + the Select+Start+L1+R1 chord (not the host-gone watchdog) - probe already did this via --quit (b71dc94) Verified: core + Linux client + Android (cargo-ndk + gradle) build clean; Windows/Apple compile-checked by CI. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/io/unom/punktfunk/StreamScreen.kt | 7 ++++-- .../io/unom/punktfunk/kit/NativeBridge.kt | 8 +++++++ clients/android/native/src/session/connect.rs | 24 +++++++++++++++++++ .../Session/SessionModel.swift | 9 +++++-- .../Connection/PunktfunkConnection.swift | 11 +++++++++ clients/linux/src/ui_stream.rs | 5 ++++ clients/windows/src/input.rs | 3 +++ crates/punktfunk-core/src/abi.rs | 16 +++++++++++++ include/punktfunk_core.h | 12 ++++++++++ 9 files changed, 91 insertions(+), 4 deletions(-) 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 65370fe..67be750 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 @@ -150,7 +150,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE activity?.streamHandle = handle // route hardware keys to this session activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes - activity?.requestStreamExit = onDisconnect // Select+Start+L1+R1 chord leaves the stream + // Select+Start+L1+R1 chord leaves the stream — a deliberate quit (signal it so the host skips + // the keep-alive linger), unlike a host-ended / backgrounded drop. + activity?.requestStreamExit = { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate // Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close. val feedback = GamepadFeedback(handle).also { it.start() } @@ -179,7 +181,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) { } } - BackHandler { onDisconnect() } + // Back gesture = a deliberate exit → signal the quit so the host tears down now (no linger). + BackHandler { NativeBridge.nativeDisconnectQuit(handle); onDisconnect() } Box(modifier = Modifier.fillMaxSize()) { AndroidView( 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 7a1c514..9619871 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 @@ -82,6 +82,14 @@ object NativeBridge { name: String, ): String + /** + * Signal a **deliberate** user disconnect on [handle] before [nativeClose]: the session closes + * with `QUIT_CLOSE_CODE` so the host tears it down immediately instead of holding the keep-alive + * linger for a reconnect. Call from an explicit disconnect gesture only — NOT from a + * host-ended/network-drop end or an app-background (those keep the linger). No-op on `0`. + */ + external fun nativeDisconnectQuit(handle: Long) + /** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */ external fun nativeClose(handle: Long) diff --git a/clients/android/native/src/session/connect.rs b/clients/android/native/src/session/connect.rs index 845481a..d92fe9d 100644 --- a/clients/android/native/src/session/connect.rs +++ b/clients/android/native/src/session/connect.rs @@ -179,6 +179,30 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeClose( }) } +/// `NativeBridge.nativeDisconnectQuit(handle)` — signal a DELIBERATE user quit before `nativeClose`, +/// so the session closes with `QUIT_CLOSE_CODE` and the host tears it down immediately instead of +/// holding the keep-alive linger for a reconnect. Call from an explicit disconnect action only (a +/// plain drop / app-background keeps the linger). The handle is only BORROWED (not freed). No-op on `0`. +/// +/// # Safety contract +/// `handle` must be `0` or a live handle from [`Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect`], +/// not freed / closed concurrently with this call (Kotlin still owns it and closes it via `nativeClose`). +#[no_mangle] +pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeDisconnectQuit( + _env: JNIEnv, + _this: JObject, + handle: jlong, +) { + jni_guard((), || { + if handle != 0 { + // SAFETY: per the contract, `handle` is a live `Box` — we only borrow it + // (no drop), so it stays owned by Kotlin for the later `nativeClose`. + let sh = unsafe { &*(handle as *const SessionHandle) }; + sh.client.disconnect_quit(); + } + }) +} + /// `NativeBridge.nativeHostFingerprint(handle): String` — the SHA-256 (64-hex) of the cert the host /// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU /// connect. `""` on a `0` handle. diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 599b2f5..d794aad 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -276,7 +276,10 @@ final class SessionModel: ObservableObject { disconnect() } - func disconnect() { + /// Tear the session down. `deliberate` (the default) means a user-initiated quit — signal + /// `disconnectQuit()` so the host skips the keep-alive linger; `sessionEnded()` (a host-ended / + /// dropped session) passes `false` to leave the linger intact. + func disconnect(deliberate: Bool = true) { statsTimer?.invalidate() statsTimer = nil let audio = self.audio @@ -294,6 +297,8 @@ final class SessionModel: ObservableObject { Task.detached { audio?.stop() feedback?.stop() + // Deliberate user quit → tell the host to skip the keep-alive linger (must precede close). + if deliberate { conn.disconnectQuit() } conn.close() } } else { @@ -321,7 +326,7 @@ final class SessionModel: ObservableObject { func sessionEnded() { guard connection != nil else { return } let name = activeHost?.displayName ?? "host" - disconnect() + disconnect(deliberate: false) // host/network ended it — keep the linger for a reconnect errorMessage = "Session ended by \(name)." } diff --git a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift index edc725f..e0d7b34 100644 --- a/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/Connection/PunktfunkConnection.swift @@ -759,6 +759,17 @@ public final class PunktfunkConnection { _ = punktfunk_connection_send_input(h, &ev) } + /// Signal a **deliberate** user-initiated quit before ``close()``: the connection closes with + /// `QUIT_CLOSE_CODE` (81) so the host tears the session down immediately instead of holding the + /// keep-alive linger for a reconnect. Call only from an explicit "Disconnect" action — NOT from a + /// network drop / host-ended / app-background (those keep the linger). Idempotent, safe pre-close. + public func disconnectQuit() { + abiLock.lock() + defer { abiLock.unlock() } + guard let h = handle, !closeRequested else { return } + punktfunk_connection_disconnect_quit(h) + } + /// Close the connection and free the handle. Safe from any thread, idempotent; waits /// for in-flight pulls (≤ their timeouts) before tearing down. public func close() { diff --git a/clients/linux/src/ui_stream.rs b/clients/linux/src/ui_stream.rs index 2fd0e57..07f86d9 100644 --- a/clients/linux/src/ui_stream.rs +++ b/clients/linux/src/ui_stream.rs @@ -817,6 +817,9 @@ fn attach_keyboard( // the capture toggle alone can't end a stream, so this is the keyboard's explicit exit. if state.contains(chord) && keyval.to_lower() == gdk::Key::d { cap.release(); + // Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session down + // immediately instead of holding the keep-alive linger for a reconnect. + cap.connector.disconnect_quit(); stop_kb.store(true, Ordering::SeqCst); return glib::Propagation::Stop; } @@ -1024,6 +1027,8 @@ fn spawn_disconnect_watch( glib::spawn_future_local(async move { if disconnect_rx.recv().await.is_ok() { cap.release(); + // Deliberate user exit (the controller escape chord) → QUIT_CLOSE_CODE, host skips linger. + cap.connector.disconnect_quit(); if window.is_fullscreen() { window.unfullscreen(); } diff --git a/clients/windows/src/input.rs b/clients/windows/src/input.rs index 018388b..f9df590 100644 --- a/clients/windows/src/input.rs +++ b/clients/windows/src/input.rs @@ -281,6 +281,9 @@ unsafe extern "system" fn kbd_proc(code: i32, wparam: WPARAM, lparam: LPARAM) -> // the cursor is free while the session winds down and the UI navigates home. if !up && vk == VK_D.0 && st.ctrl && st.alt && st.shift { set_captured(st, false); + // Deliberate user exit → close with QUIT_CLOSE_CODE so the host tears the session + // down immediately instead of holding the keep-alive linger for a reconnect. + st.connector.disconnect_quit(); st.stop.store(true, Ordering::SeqCst); tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)"); return LRESULT(1); diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index 111b730..8ff562b 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -2432,6 +2432,22 @@ pub unsafe extern "C" fn punktfunk_connection_probe_result( }) } +/// Signal a **deliberate quit** (a user "stop", not a network drop) before closing: the connection +/// closes with [`QUIT_CLOSE_CODE`] instead of code 0, so the host tears the session down immediately +/// (skips the keep-alive linger) rather than holding it for a reconnect. Call this right before +/// [`punktfunk_connection_close`] on a user-initiated disconnect; a plain close (network drop, +/// backgrounding) leaves the linger intact. NULL is a no-op. +/// +/// # Safety +/// `c` was returned by [`punktfunk_connect`] and remains valid (closed via `punktfunk_connection_close`). +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_disconnect_quit(c: *mut PunktfunkConnection) { + if let Some(c) = unsafe { c.as_ref() } { + c.inner.disconnect_quit(); + } +} + /// Close the connection and free the handle (joins the internal threads). NULL is a no-op. /// /// # Safety diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 75376a5..a5cb183 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -1501,6 +1501,18 @@ PunktfunkStatus punktfunk_connection_probe_result(const PunktfunkConnection *c, PunktfunkProbeResult *out); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Signal a **deliberate quit** (a user "stop", not a network drop) before closing: the connection +// closes with [`QUIT_CLOSE_CODE`] instead of code 0, so the host tears the session down immediately +// (skips the keep-alive linger) rather than holding it for a reconnect. Call this right before +// [`punktfunk_connection_close`] on a user-initiated disconnect; a plain close (network drop, +// backgrounding) leaves the linger intact. NULL is a no-op. +// +// # Safety +// `c` was returned by [`punktfunk_connect`] and remains valid (closed via `punktfunk_connection_close`). +void punktfunk_connection_disconnect_quit(PunktfunkConnection *c); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Close the connection and free the handle (joins the internal threads). NULL is a no-op. //