Skip to content

Commit dda4641

Browse files
sirily11claude
andcommitted
fix: reconnect mobile relay after returning from background
On foreground, the app called relay.connect(), which bails when a socket is still assigned (`guard task == nil`). iOS suspends the process in the background, so the WebSocket can die without the receive/ping failure callbacks ever firing to clear `task` and schedule a reconnect — leaving a stale dead socket that made connect() a permanent no-op. - Add RelayClient.reconnect(): tears down any existing socket, resets the backoff counter, and reopens. SyncClient.start() now uses it (at launch task is nil, so it behaves like a plain connect). - Serialize background/foreground transitions in MobileAppState via a lifecycleTask chain so a delayed stop() can't land after a later start() and disable the relay for good. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent babc6d0 commit dda4641

5 files changed

Lines changed: 94 additions & 10 deletions

File tree

Packages/Sources/RxCodeSync/SyncClient.swift

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,18 @@ public actor SyncClient: PeerManagerHost {
4646
}
4747

4848
public func start() async {
49-
await relay.connect()
49+
// `reconnect()` (not `connect()`) so a stale socket left behind by an
50+
// OS suspend is torn down and reopened. At initial startup `task` is nil,
51+
// so this behaves exactly like a plain connect.
52+
await relay.reconnect()
5053
guard directPathsEnabled else { return }
5154
startHubIfNeeded()
55+
// `stop()` cancelled every per-peer event pump and dropped the managers;
56+
// rebuild them from the persisted peer set so direct-path links — and the
57+
// inbound data (snapshots, history) they carry — come back after a
58+
// background cycle. Without this the LAN link re-promotes but its events
59+
// reach no subscriber, and the app appears connected yet loads nothing.
60+
for (hex, key) in peers { startManager(forHex: hex, key: key) }
5261
}
5362

5463
public func stop() async {
@@ -59,6 +68,12 @@ public actor SyncClient: PeerManagerHost {
5968
pathMonitor?.cancel(); pathMonitor = nil
6069
await advertiser?.stop()
6170
await browser?.stop()
71+
// Drop the per-peer managers. Their event pumps were just cancelled and
72+
// their direct sockets die when the OS suspends us; tearing them down and
73+
// rebuilding on `start()` avoids reusing a manager stuck with a dead
74+
// `active` transport (which blocks redial and silently swallows data).
75+
for manager in managers.values { await manager.teardown() }
76+
managers.removeAll()
6277
hubStarted = false
6378
}
6479

@@ -77,6 +92,13 @@ public actor SyncClient: PeerManagerHost {
7792
guard let raw = Data(hexString: pubkeyHex) else { throw SyncError.invalidPubkey }
7893
let key = try Curve25519.KeyAgreement.PublicKey(rawRepresentation: raw)
7994
peers[pubkeyHex] = key
95+
startManager(forHex: pubkeyHex, key: key)
96+
}
97+
98+
/// Create the per-peer manager and its event pump, unless direct paths are
99+
/// disabled or one is already running. Idempotent: called both when a peer is
100+
/// added and when `start()` rebuilds managers after a background cycle.
101+
private func startManager(forHex pubkeyHex: String, key: Curve25519.KeyAgreement.PublicKey) {
80102
guard directPathsEnabled, managers[pubkeyHex] == nil else { return }
81103
let manager = PeerConnectionManager(peerHex: pubkeyHex, peerKey: key, identity: identity, relay: relay, host: self)
82104
managers[pubkeyHex] = manager
@@ -85,8 +107,9 @@ public actor SyncClient: PeerManagerHost {
85107
for await event in stream { await self?.emitUp(event) }
86108
}
87109
// A peer added while the relay is already connected (e.g. right after
88-
// pairing) would otherwise miss the `.stateChanged(.connected)` that
89-
// triggers the first candidate offer. Offer immediately in that case.
110+
// pairing, or on a foreground rebuild) would otherwise miss the
111+
// `.stateChanged(.connected)` that triggers the first candidate offer.
112+
// Offer immediately in that case.
90113
track { [weak self, weak manager] in
91114
guard let self, let manager else { return }
92115
if await self.relayIsConnected { await manager.offerLocalCandidates() }

Packages/Sources/RxCodeSync/Transport/PeerConnectionManager.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,20 @@ public actor PeerConnectionManager {
8181

8282
public var currentPath: ConnectionPathKind { activePath }
8383

84+
/// Disconnect every link and clear connection state. Called when the hub
85+
/// stops (app backgrounding); the hub then drops this manager and builds a
86+
/// fresh one on the next start, so we close sockets cleanly rather than
87+
/// leaking dead `NWConnection`s across a suspend.
88+
public func teardown() async {
89+
retryTask?.cancel(); retryTask = nil
90+
for info in probing.values { await info.transport.disconnect() }
91+
probing.removeAll()
92+
await active?.disconnect()
93+
active = nil
94+
activePath = .relay
95+
knownTargets.removeAll()
96+
}
97+
8498
public func events() -> AsyncStream<TransportEvent> {
8599
AsyncStream { continuation in
86100
let id = UUID()

Packages/Sources/RxCodeSync/Transport/RelayClient.swift

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,30 @@ public actor RelayClient: Transport {
5656
}
5757

5858
public func connect() {
59-
guard task == nil else { return }
59+
guard task == nil else {
60+
logger.info("[Relay] connect skipped — socket already assigned relay=\(self.relayURL.absoluteString, privacy: .public) state=\(String(describing: self.state), privacy: .public)")
61+
return
62+
}
6063
shouldReconnect = true
6164
logger.info("[Relay] connect requested relay=\(self.relayURL.absoluteString, privacy: .public) localKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)")
6265
openSocket()
6366
}
6467

68+
/// Force a clean reconnect. Unlike `connect()`, this does not bail when a
69+
/// socket is still assigned — it tears down any existing (possibly stale)
70+
/// socket first, then reopens. This is the correct entry point on app
71+
/// foreground: iOS suspends the process in the background, so the socket can
72+
/// die without `receiveOne`/`sendPing` ever firing the failure callback that
73+
/// would clear `task`. On resume `task` may still reference a dead socket,
74+
/// which would make a plain `connect()` no-op forever.
75+
public func reconnect() {
76+
shouldReconnect = true
77+
logger.info("[Relay] reconnect requested relay=\(self.relayURL.absoluteString, privacy: .public) localKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)")
78+
closeSocketLocally()
79+
reconnectAttempt = 0
80+
openSocket()
81+
}
82+
6583
public func disconnect() {
6684
shouldReconnect = false
6785
logger.info("[Relay] disconnect requested relay=\(self.relayURL.absoluteString, privacy: .public) localKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public)")
@@ -105,10 +123,16 @@ public actor RelayClient: Transport {
105123
// the race. Opening another would register a second connection for the
106124
// same pubkey on the relay — the cause of duplicate registrations when
107125
// resuming from background. Never stack sockets.
108-
guard task == nil else { return }
126+
guard task == nil else {
127+
logger.info("[Relay] openSocket skipped — socket already assigned relay=\(self.relayURL.absoluteString, privacy: .public) state=\(String(describing: self.state), privacy: .public)")
128+
return
129+
}
109130
// A disconnect() may have landed while a scheduled reconnect was still
110131
// sleeping; honour it instead of reopening.
111-
guard shouldReconnect else { return }
132+
guard shouldReconnect else {
133+
logger.info("[Relay] openSocket skipped — shouldReconnect=false relay=\(self.relayURL.absoluteString, privacy: .public)")
134+
return
135+
}
112136

113137
updateState(.connecting)
114138

RxCodeMobile/RxCodeMobileApp.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ struct RxCodeMobileApp: App {
4141
handlePairingURL(url)
4242
}
4343
}
44-
.onChange(of: scenePhase) { _, newPhase in
44+
.onChange(of: scenePhase) { oldPhase, newPhase in
45+
NSLog("[Lifecycle] scenePhase \(String(describing: oldPhase)) -> \(String(describing: newPhase))")
4546
state.handleScenePhase(newPhase)
4647
}
4748
}

RxCodeMobile/State/MobileAppState.swift

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,10 @@ final class MobileAppState: ObservableObject {
270270
var client: SyncClient
271271
let logger = Logger(subsystem: "com.idealapp.RxCodeMobile", category: "MobileAppState")
272272
var eventTask: Task<Void, Never>?
273+
/// Serializes background/foreground relay transitions so a delayed
274+
/// `stop()` can never land after a later `start()` and leave the relay
275+
/// permanently disconnected.
276+
var lifecycleTask: Task<Void, Never>?
273277
var pairingTimeoutTask: Task<Void, Never>?
274278
var apnsTokenHex: String?
275279
var apnsEnvironment: String?
@@ -394,21 +398,39 @@ final class MobileAppState: ObservableObject {
394398
/// relay's registration table in sync with reality and gives a clean,
395399
/// single re-register on the next foreground.
396400
func handleScenePhase(_ phase: ScenePhase) {
397-
guard clientStarted else { return }
401+
guard clientStarted else {
402+
logger.info("[Lifecycle] handleScenePhase \(String(describing: phase), privacy: .public) ignored — clientStarted=false")
403+
return
404+
}
398405
switch phase {
399406
case .background:
400407
logger.info("[Lifecycle] entering background — disconnecting relay")
401-
Task { await client.stop() }
408+
enqueueLifecycle(label: "background/stop") { [client] in await client.stop() }
402409
case .active:
403410
logger.info("[Lifecycle] entering foreground — reconnecting relay")
404-
Task { await client.start() }
411+
enqueueLifecycle(label: "foreground/start") { [client] in await client.start() }
405412
case .inactive:
406413
break
407414
@unknown default:
408415
break
409416
}
410417
}
411418

419+
/// Chain a relay lifecycle transition after any in-flight one so they run
420+
/// strictly in the order the scene phases arrived. Two bare `Task {}`s race:
421+
/// a background `stop()` could otherwise complete after a foreground
422+
/// `start()` and disable the relay for good.
423+
private func enqueueLifecycle(label: String, _ work: @escaping @Sendable () async -> Void) {
424+
let previous = lifecycleTask
425+
lifecycleTask = Task { [logger] in
426+
logger.info("[Lifecycle] \(label, privacy: .public) queued — awaiting previous transition")
427+
await previous?.value
428+
logger.info("[Lifecycle] \(label, privacy: .public) running")
429+
await work()
430+
logger.info("[Lifecycle] \(label, privacy: .public) done")
431+
}
432+
}
433+
412434
func startClient() async {
413435
clientStarted = true
414436
for desktop in pairedDesktops {

0 commit comments

Comments
 (0)