feat(clients): signal explicit exit (QUIT_CLOSE_CODE) on deliberate disconnect
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 5m5s
arch / build-publish (push) Successful in 5m28s
ci / web (push) Successful in 1m10s
windows-host / package (push) Successful in 7m9s
ci / docs-site (push) Successful in 1m21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m19s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m11s
release / apple (push) Successful in 8m40s
ci / rust (push) Successful in 4m58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 49s
apple / screenshots (push) Failing after 1m12s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 53s
ci / bench (push) Successful in 5m2s
decky / build-publish (push) Successful in 13s
deb / build-publish (push) Successful in 3m22s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
flatpak / build-publish (push) Successful in 4m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 12m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m1s
docker / deploy-docs (push) Successful in 18s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 13:37:45 +00:00
parent a2723e34a1
commit 6081502949
9 changed files with 91 additions and 4 deletions
@@ -150,7 +150,9 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
activity?.streamHandle = handle // route hardware keys to this session activity?.streamHandle = handle // route hardware keys to this session
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes 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 activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close. // Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
val feedback = GamepadFeedback(handle).also { it.start() } 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()) { Box(modifier = Modifier.fillMaxSize()) {
AndroidView( AndroidView(
@@ -82,6 +82,14 @@ object NativeBridge {
name: String, name: String,
): 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`. */ /** Tear down a session handle returned by [nativeConnect]. No-op on `0`. */
external fun nativeClose(handle: Long) external fun nativeClose(handle: Long)
@@ -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<SessionHandle>` — 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 /// `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 /// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
/// connect. `""` on a `0` handle. /// connect. `""` on a `0` handle.
@@ -276,7 +276,10 @@ final class SessionModel: ObservableObject {
disconnect() 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?.invalidate()
statsTimer = nil statsTimer = nil
let audio = self.audio let audio = self.audio
@@ -294,6 +297,8 @@ final class SessionModel: ObservableObject {
Task.detached { Task.detached {
audio?.stop() audio?.stop()
feedback?.stop() feedback?.stop()
// Deliberate user quit tell the host to skip the keep-alive linger (must precede close).
if deliberate { conn.disconnectQuit() }
conn.close() conn.close()
} }
} else { } else {
@@ -321,7 +326,7 @@ final class SessionModel: ObservableObject {
func sessionEnded() { func sessionEnded() {
guard connection != nil else { return } guard connection != nil else { return }
let name = activeHost?.displayName ?? "host" let name = activeHost?.displayName ?? "host"
disconnect() disconnect(deliberate: false) // host/network ended it keep the linger for a reconnect
errorMessage = "Session ended by \(name)." errorMessage = "Session ended by \(name)."
} }
@@ -759,6 +759,17 @@ public final class PunktfunkConnection {
_ = punktfunk_connection_send_input(h, &ev) _ = 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 /// Close the connection and free the handle. Safe from any thread, idempotent; waits
/// for in-flight pulls ( their timeouts) before tearing down. /// for in-flight pulls ( their timeouts) before tearing down.
public func close() { public func close() {
+5
View File
@@ -817,6 +817,9 @@ fn attach_keyboard(
// the capture toggle alone can't end a stream, so this is the keyboard's explicit exit. // 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 { if state.contains(chord) && keyval.to_lower() == gdk::Key::d {
cap.release(); 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); stop_kb.store(true, Ordering::SeqCst);
return glib::Propagation::Stop; return glib::Propagation::Stop;
} }
@@ -1024,6 +1027,8 @@ fn spawn_disconnect_watch(
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
if disconnect_rx.recv().await.is_ok() { if disconnect_rx.recv().await.is_ok() {
cap.release(); cap.release();
// Deliberate user exit (the controller escape chord) → QUIT_CLOSE_CODE, host skips linger.
cap.connector.disconnect_quit();
if window.is_fullscreen() { if window.is_fullscreen() {
window.unfullscreen(); window.unfullscreen();
} }
+3
View File
@@ -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. // 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 { if !up && vk == VK_D.0 && st.ctrl && st.alt && st.shift {
set_captured(st, false); 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); st.stop.store(true, Ordering::SeqCst);
tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)"); tracing::info!("disconnect requested (Ctrl+Alt+Shift+D)");
return LRESULT(1); return LRESULT(1);
+16
View File
@@ -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. /// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
/// ///
/// # Safety /// # Safety
+12
View File
@@ -1501,6 +1501,18 @@ PunktfunkStatus punktfunk_connection_probe_result(const PunktfunkConnection *c,
PunktfunkProbeResult *out); PunktfunkProbeResult *out);
#endif #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) #if defined(PUNKTFUNK_FEATURE_QUIC)
// Close the connection and free the handle (joins the internal threads). NULL is a no-op. // Close the connection and free the handle (joins the internal threads). NULL is a no-op.
// //