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
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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
/// presented on this connection. Valid after a successful `nativeConnect`; Kotlin pins it on a TOFU
|
||||
/// connect. `""` on a `0` handle.
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user