Networking & Reliability

Reconnecting Without Hammering the Server: Exponential Backoff with NetworkMonitor

When an iOS device loses its WebSocket connection to the relay, the app enters a reconnect loop. The reconnect strategy uses exponential backoff on the iOS side, immediate reconnect on network change, and a 300-second total timeout before giving up.

iOS Reconnect State

iOSRelayConnection tracks reconnection state:

// ios_remote_dev_ios_app/RemoteDevApp/Network/iOSRelayConnection.swift
private var reconnectAttempt: Int = 0
private var reconnectStartTime: Date?
private let reconnectTotalTimeout: TimeInterval = 300  // default: 5 minutes
@Published var isReconnecting = false
@Published var reconnectingDuration: TimeInterval = 0

When reconnecting is active, isReconnecting = true drives the UI to show a reconnecting state.

Input Buffering During Reconnect

Keystrokes typed during a reconnect are buffered, not dropped:

func sendInput(_ data: Data, sessionId: String = "") {
    InputHistory.shared.record(data: data, sessionId: sessionId)
    if state != .authenticated {
        if pendingInputBuffer.count < Self.maxInputBufferSize {
            pendingInputBuffer.append((data, sessionId))
        }
        return
    }
    let msg = AppMessage.ptyInput(data: data.base64EncodedString(), sessionId: sessionId)
    try? sendEncrypted(msg)
}

pendingInputBuffer holds up to 100 input events (.maxInputBufferSize = 100). When the connection is re-authenticated, the buffer is flushed in order.

Network Monitor for Immediate Reconnect

Instead of waiting for the next backoff interval, the app reconnects immediately when the network path changes:

// ios_remote_dev_ios_app/RemoteDevApp/Network/iOSRelayConnection.swift
private func startNetworkMonitor() {
    let monitor = NWPathMonitor()
    monitor.pathUpdateHandler = { [weak self] path in
        guard let self else { return }
        // trigger immediate reconnect attempt on network path change
    }
    monitor.start(queue: DispatchQueue(label: "network-monitor"))
    self.networkMonitor = monitor
}

On the Mac side, the same pattern uses both NWPathMonitor and macOS wake/sleep notifications:

// mac_agent/Sources/MacAgentLib/RelayConnection.swift
NotificationCenter.default.addObserver(
    forName: NSWorkspace.didWakeNotification, ...) { [weak self] _ in
    log("[relay] Mac woke from sleep — forcing immediate reconnect")
    self?.reconnectDelay = 0
    self?.ws.disconnect()
}

monitor.pathUpdateHandler = { [weak self] path in
    if path.status != prev.status ||
       Set(path.availableInterfaces.map(\.name)) != Set(prev.availableInterfaces.map(\.name)) {
        log("[relay] network path changed — forcing reconnect")
        self?.reconnectDelay = 0
        self?.ws.disconnect()
    }
}

The condition checks both status (satisfied/unsatisfied) and the set of available interface names. A WiFi-to-cellular switch, for example, changes the interface set but might leave status as .satisfied, so both are checked.

Scene Phase Handling

When the iOS app goes to background while reconnecting, the reconnect loop is paused. On return to foreground, it resumes:

func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) {
    if newPhase == .background {
        if wasAuthenticated || wasReconnecting {
            // Cancel in-flight reconnect
            reconnectTask?.cancel()
            connectionTask?.cancel()
            ws.disconnect()
            state = .disconnected
            isReconnecting = true  // keep (or set) reconnecting flag
        }
    } else if newPhase == .active {
        if isReconnecting {
            startReconnectLoop()
            startNetworkMonitor()
        }
    }
}

The app doesn’t attempt background network reconnections. When it returns to foreground, isReconnecting = true triggers startReconnectLoop() to resume where it left off.

Total Timeout

The iOS reconnect loop has a hard total timeout:

init(..., reconnectTotalTimeout: TimeInterval = 300)

If 300 seconds elapse without a successful reconnection, the loop stops and isReconnecting is set to false. The user must manually reconnect. This prevents the app from endlessly attempting connections when the Mac is genuinely offline.

The disconnectedDueToTimeout flag is set to true in this case, allowing the UI to distinguish a user-initiated disconnect from a timeout-triggered one, and to show an appropriate action (“try again” vs no prompt).