diff --git a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift index 431f3a4..a2add75 100644 --- a/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift +++ b/airsync-mac/Core/Discovery/UDPDiscoveryManager.swift @@ -16,7 +16,7 @@ struct DiscoveredDevice: Identifiable, Equatable, Hashable { } var isActive: Bool { - return Date().timeIntervalSince(lastSeen) < 14 + return Date().timeIntervalSince(lastSeen) < 20 } static func == (lhs: DiscoveredDevice, rhs: DiscoveredDevice) -> Bool { @@ -39,6 +39,8 @@ class UDPDiscoveryManager: ObservableObject { private let broadcastPort: NWEndpoint.Port = 8889 private var cancellables = Set() private var isListening = false + private var lastBroadcastTime: Date = .distantPast + private var networkChangePendingWork: DispatchWorkItem? private init() { // Init logic only @@ -81,10 +83,20 @@ class UDPDiscoveryManager: ObservableObject { networkMonitor = NWPathMonitor() networkMonitor?.pathUpdateHandler = { [weak self] path in guard let self = self else { return } - if path.status == .satisfied { - print("[Discovery] Network change detected: \(path.status)") + guard path.status == .satisfied else { return } + + // Debounce: cancel any pending burst and schedule a new one 2 s out. + // This prevents a flood of UDP sends during a rapid network transition. + self.networkChangePendingWork?.cancel() + let work = DispatchWorkItem { [weak self] in + guard let self = self else { return } + // Skip if we already sent a burst very recently (e.g. from wake handler) + guard Date().timeIntervalSince(self.lastBroadcastTime) >= 2.0 else { return } + print("[Discovery] Network change detected – broadcasting presence") self.broadcastBurst() } + self.networkChangePendingWork = work + queue.asyncAfter(deadline: .now() + 2.0, execute: work) } networkMonitor?.start(queue: queue) } @@ -105,6 +117,7 @@ class UDPDiscoveryManager: ObservableObject { /// Sends a rapid burst of broadcasts to ensure delivery (Active Burst support) func broadcastBurst() { print("[Discovery] Triggering broadcast burst") + lastBroadcastTime = Date() // Send 3 packets with slight delay for i in 0..<3 { @@ -377,8 +390,8 @@ class UDPDiscoveryManager: ObservableObject { DispatchQueue.main.async { withAnimation(.easeInOut(duration: 0.6)) { let initialCount = self.discoveredDevices.count - self.discoveredDevices = self.discoveredDevices.filter { - now.timeIntervalSince($0.lastSeen) <= 20 + self.discoveredDevices = self.discoveredDevices.filter { + now.timeIntervalSince($0.lastSeen) <= 35 } let newCount = self.discoveredDevices.count diff --git a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift index a5ae0cb..7011528 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift @@ -33,11 +33,14 @@ extension WebSocketServer { /// Performs a session health check. /// Identifies and forcibly disconnects stale sessions that have exceeded the activity timeout. + /// Only the *primary* session going stale triggers a full server restart. Non-primary zombie + /// sessions are force-closed silently to avoid cascading restarts. func performPing() { self.lock.lock() let sessions = activeSessions let timeout = self.activityTimeout let key = self.symmetricKey + let primary = self.primarySessionID self.lock.unlock() if sessions.isEmpty { return } @@ -55,17 +58,27 @@ extension WebSocketServer { let isStale = now.timeIntervalSince(lastDate) > timeout if isStale { - print("[websocket] Session \(sessionId) is stale. Performing hard reset and discovery restart.") - DispatchQueue.main.async { - // Disconnect and restart - AppState.shared.disconnectDevice() - ADBConnector.disconnectADB() - AppState.shared.adbConnected = false - - self.stop() - self.start(port: self.localPort ?? Defaults.serverPort) + let isPrimary = (sessionId == primary) + if isPrimary { + // Primary session has gone silent — full reconnect cycle + print("[websocket] Primary session \(sessionId) is stale (>\(Int(timeout))s). Restarting server.") + DispatchQueue.main.async { + AppState.shared.disconnectDevice() + ADBConnector.disconnectADB() + AppState.shared.adbConnected = false + self.restartServer() + } + return // Let the restart handle everything; stop iterating + } else { + // Non-primary zombie — just evict it without touching app state + print("[websocket] Non-primary session \(sessionId) is stale. Force-closing silently.") + self.lock.lock() + self.activeSessions.removeAll { $0 === session } + self.lastActivity.removeValue(forKey: sessionId) + self.lock.unlock() + session.writeBinary([]) // Force-close + continue } - return } let pingJson = "{\"type\":\"ping\",\"data\":{}}" diff --git a/airsync-mac/Core/WebSocket/WebSocketServer.swift b/airsync-mac/Core/WebSocket/WebSocketServer.swift index c990389..ffdefbe 100644 --- a/airsync-mac/Core/WebSocket/WebSocketServer.swift +++ b/airsync-mac/Core/WebSocket/WebSocketServer.swift @@ -20,7 +20,7 @@ class WebSocketServer: ObservableObject { internal var pingTimer: Timer? internal let pingInterval: TimeInterval = 5.0 internal var lastActivity: [ObjectIdentifier: Date] = [:] - internal let activityTimeout: TimeInterval = 11.0 + internal let activityTimeout: TimeInterval = 35.0 @Published var symmetricKey: SymmetricKey? @Published var localPort: UInt16? @@ -30,6 +30,7 @@ class WebSocketServer: ObservableObject { @Published var deviceStatus: DeviceStatus? internal var lastKnownIP: String? + internal var isRestarting: Bool = false internal var networkMonitorTimer: Timer? internal let networkCheckInterval: TimeInterval = 10.0 internal let lock = NSRecursiveLock() @@ -234,8 +235,8 @@ class WebSocketServer: ObservableObject { AppState.shared.disconnectDevice() ADBConnector.disconnectADB() AppState.shared.adbConnected = false - self.stop() - self.start(port: self.localPort ?? Defaults.serverPort) + // Guard against cascading restarts from multiple disconnected callbacks + self.restartServer() } } } @@ -277,4 +278,38 @@ class WebSocketServer: ObservableObject { func wakeUpLastConnectedDevice() { QuickConnectManager.shared.wakeUpLastConnectedDevice() } + + // MARK: - Restart Helper + + /// Single entry-point for all server restart logic. + /// Guarded by `isRestarting` to prevent cascading calls from multiple + /// simultaneous `disconnected` callbacks or stale-ping handlers. + /// Waits 1.5 s before restarting so any remaining callbacks finish first, + /// then re-broadcasts presence so Android can rediscover the Mac. + func restartServer() { + self.lock.lock() + guard !isRestarting else { + self.lock.unlock() + print("[websocket] Restart already in progress – skipping duplicate request") + return + } + isRestarting = true + let port = self.localPort ?? Defaults.serverPort + self.lock.unlock() + + print("[websocket] Scheduling server restart in 1.5 s…") + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.stop() + self.start(port: port) + + // Re-announce presence immediately after restart so Android can find us + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + UDPDiscoveryManager.shared.broadcastBurst() + self.lock.lock() + self.isRestarting = false + self.lock.unlock() + print("[websocket] Server restart complete. Presence re-broadcast sent.") + } + } + } }