From f9ed1767d272f4ec2b509a29386f5b5cbf7564e4 Mon Sep 17 00:00:00 2001 From: Next1971 Date: Mon, 26 Oct 2026 22:18:37 +0300 Subject: [PATCH] Redial iOS QUIC after the extension wakes or the RTT stays at zero. The phone froze the packet tunnel and the session closed itself. Nothing called reconnect until the process was running again. Co-authored-by: Cursor --- ios/PacketTunnel/PacketTunnelProvider.swift | 32 +++++++++++++++++++++ ios/fastlane/Fastfile | 2 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/ios/PacketTunnel/PacketTunnelProvider.swift b/ios/PacketTunnel/PacketTunnelProvider.swift index 7c86694..b23ce6f 100644 --- a/ios/PacketTunnel/PacketTunnelProvider.swift +++ b/ios/PacketTunnel/PacketTunnelProvider.swift @@ -37,6 +37,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { private var lastPathKey = "" private var lastPhysicalIP = "" private var ignorePathChangesUntil = Date.distantPast + private var zeroRTTTicks = 0 override func startTunnel(options: [String: NSObject]?, completionHandler: @escaping (Error?) -> Void) { // A Packet Tunnel is jetsam-killed around 15 MB. Cap the Go heap before @@ -67,6 +68,7 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { stopping = false reconnecting = false everDialed = false + zeroRTTTicks = 0 routesApplied = false AppGroup.defaults.removeObject(forKey: AppGroup.defaultsLastError) @@ -812,6 +814,21 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { } } + /// iOS freezes this process and QUIC then dies after three quiet minutes. + /// wake() is the only signal that the process is running again. + override func wake() { + workQueue.async { [weak self] in + guard let self, !self.stopping, self.startCompleted, self.everDialed else { return } + guard Date() > self.ignorePathChangesUntil else { return } + let udpReady = self.udpSession?.state == .ready + let rtt = self.tunnel?.rttMillis() ?? 0 + if self.tunnel == nil || !udpReady || rtt == 0 { + self.zeroRTTTicks = 0 + self.scheduleReconnect("extension woke") + } + } + } + override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { workQueue.async { [weak self] in guard let self else { @@ -909,6 +926,21 @@ final class PacketTunnelProvider: NEPacketTunnelProvider { if ms > 0 { AppGroup.defaults.set(Int(ms), forKey: AppGroup.defaultsPing) } + self.workQueue.async { + guard !self.stopping, self.everDialed, !self.reconnecting else { return } + guard Date() > self.ignorePathChangesUntil else { return } + if ms > 0 { + self.zeroRTTTicks = 0 + return + } + self.zeroRTTTicks += 1 + // QUIC keepalive is 15s. A live session keeps a non-zero RTT. + // Zero for half a minute means the socket died while we slept. + if self.zeroRTTTicks >= 15 { + self.zeroRTTTicks = 0 + self.scheduleReconnect("QUIC went silent") + } + } let total = t.trafficBytes() if total > 0 { TrafficAccount.note(total: Int(total)) diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 56a38fb..4597e15 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -109,7 +109,7 @@ platform :ios do upload_to_testflight( api_key: api_key, skip_waiting_for_build_processing: true, - changelog: "2.1.1: same 2.1.0 UDP tunnel. Opening another app no longer tears the session down." + changelog: "2.1.1: same 2.1.0 UDP tunnel. After the phone sleeps, a dead QUIC session dials again." ) end end