fix(quic/apple): QUIC keep-alive + reconnect input re-engage
ci / rust (push) Failing after 36s
ci / web (push) Failing after 51s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
ci / docs-site (push) Failing after 40s
apple / swift (push) Successful in 1m16s
docker / deploy-docs (push) Successful in 17s

Three native-client bugs isolated against a stock Moonlight client (which
stays connected / keeps input working under the same actions):

- Connection drops mid-stream: the quinn endpoints (host + client) ran with
  default transport config, so keep_alive_interval was OFF. Any quiet stretch
  (no input, audio muted/stalled, a capture hiccup, a mode change) let the
  idle timer expire and quinn closed the session -> next_au=Closed -> "Session
  ended". Moonlight's ENet sends keepalive pings; we sent nothing. Add a shared
  TransportConfig (keep-alive 4s under an explicit 20s idle timeout) to both
  endpoint::server_from_der and endpoint::client_pinned_with_identity.

- Reconnect input dead (macOS): the session-start auto-capture one-shot was
  consumed even when engageCapture(fromClick:false) was refused (window not key
  yet at the instant of reconnect), with no retry -> capture stayed off and
  input never forwarded. Clear the one-shot only on a successful engage, and
  retry on NSWindow.didBecomeKey. Stays scoped to session start, so it does not
  resurrect the rejected auto-grab-on-activation behavior.

- Reconnect input dead (iOS): wasCapturedOnResign leaked stale state across
  sessions and the foreground-restore could fire before this session's
  InputCapture was wired (setForwarding no-ops on nil). Reset it per session in
  start() and guard the didBecomeActive restore on inputCapture != nil.

Validated: cargo build -p punktfunk-core --features quic; swift build;
swift test (39 passed, 0 failures); xcframework rebuilt (all 5 slices), no
ABI/header drift.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 23:07:17 +02:00
parent a8a6224fd8
commit dea749186d
3 changed files with 50 additions and 4 deletions
@@ -197,6 +197,17 @@ public final class StreamLayerView: NSView {
self?.releaseCapture()
})
}
// Becoming key RETRIES a still-pending session-start auto-capture the case where a
// session began (reconnect) while this window wasn't key yet, so engageCapture(fromClick:
// false) was refused by its key-window guard and, with no retry, capture stayed off and
// input dead. This is a no-op once capture engaged (pendingAutoCapture is cleared) and
// after a manual /focus-loss release (the flag is already false), so it does NOT
// resurrect the deliberately-rejected "auto-grab on every activation" behavior.
windowObservers.append(NotificationCenter.default.addObserver(
forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main
) { [weak self] _ in
self?.attemptPendingCapture()
})
attemptPendingCapture()
}
@@ -301,8 +312,13 @@ public final class StreamLayerView: NSView {
private func attemptPendingCapture() {
guard pendingAutoCapture, window != nil, bounds.width > 0 else { return }
pendingAutoCapture = false // one shot, even if the engage below is refused
engageCapture(fromClick: false)
// Clear the one-shot only once it ACTUALLY engaged. If the engage was refused the
// app/window isn't key yet (common right after a reconnect), or the cursor grab raced
// app activation leave it armed so didBecomeKey (or the next layout pass) retries.
// This stays scoped to session start: a later manual release (, focus loss) doesn't
// re-arm it, so it never resurrects auto-grab-on-activation.
if captured { pendingAutoCapture = false }
}
private func engageCapture(fromClick: Bool) {
@@ -172,6 +172,10 @@ public final class StreamViewController: UIViewController {
self.connection = connection
loadViewIfNeeded()
#if os(iOS)
// Fresh session: drop any resign/foreground capture-restore state left over from a
// prior session (stop() doesn't clear it). Otherwise a stale `true` could later
// re-engage capture on a foreground that the new session never asked for.
wasCapturedOnResign = false
// Read the LIVE mode per touch batch an accepted requestMode() mid-stream
// changes the letterbox, and touches must follow it.
streamView.currentHostMode = { [weak connection] in
@@ -247,7 +251,10 @@ public final class StreamViewController: UIViewController {
observers.append(NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification, object: nil, queue: .main
) { [weak self] _ in
guard let self, self.wasCapturedOnResign, self.captureEnabled, self.connection != nil
// inputCapture != nil: don't try to restore before this session's capture is wired
// up setForwarding would silently no-op on the nil handlers and leave input dead.
guard let self, self.wasCapturedOnResign, self.captureEnabled,
self.connection != nil, self.inputCapture != nil
else { return }
self.setCaptured(true)
})