Skip to content

Commit c8e939c

Browse files
committed
fixup: Improve the auto connect and timeout for syncing
Test: Just reconnect with android, it won't disconnect automatically after a few seconds. [Which was irritating TBH]
1 parent 19740a4 commit c8e939c

3 files changed

Lines changed: 79 additions & 18 deletions

File tree

airsync-mac/Core/Discovery/UDPDiscoveryManager.swift

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ struct DiscoveredDevice: Identifiable, Equatable, Hashable {
1616
}
1717

1818
var isActive: Bool {
19-
return Date().timeIntervalSince(lastSeen) < 14
19+
return Date().timeIntervalSince(lastSeen) < 20
2020
}
2121

2222
static func == (lhs: DiscoveredDevice, rhs: DiscoveredDevice) -> Bool {
@@ -39,6 +39,8 @@ class UDPDiscoveryManager: ObservableObject {
3939
private let broadcastPort: NWEndpoint.Port = 8889
4040
private var cancellables = Set<AnyCancellable>()
4141
private var isListening = false
42+
private var lastBroadcastTime: Date = .distantPast
43+
private var networkChangePendingWork: DispatchWorkItem?
4244

4345
private init() {
4446
// Init logic only
@@ -81,10 +83,20 @@ class UDPDiscoveryManager: ObservableObject {
8183
networkMonitor = NWPathMonitor()
8284
networkMonitor?.pathUpdateHandler = { [weak self] path in
8385
guard let self = self else { return }
84-
if path.status == .satisfied {
85-
print("[Discovery] Network change detected: \(path.status)")
86+
guard path.status == .satisfied else { return }
87+
88+
// Debounce: cancel any pending burst and schedule a new one 2 s out.
89+
// This prevents a flood of UDP sends during a rapid network transition.
90+
self.networkChangePendingWork?.cancel()
91+
let work = DispatchWorkItem { [weak self] in
92+
guard let self = self else { return }
93+
// Skip if we already sent a burst very recently (e.g. from wake handler)
94+
guard Date().timeIntervalSince(self.lastBroadcastTime) >= 2.0 else { return }
95+
print("[Discovery] Network change detected – broadcasting presence")
8696
self.broadcastBurst()
8797
}
98+
self.networkChangePendingWork = work
99+
queue.asyncAfter(deadline: .now() + 2.0, execute: work)
88100
}
89101
networkMonitor?.start(queue: queue)
90102
}
@@ -105,6 +117,7 @@ class UDPDiscoveryManager: ObservableObject {
105117
/// Sends a rapid burst of broadcasts to ensure delivery (Active Burst support)
106118
func broadcastBurst() {
107119
print("[Discovery] Triggering broadcast burst")
120+
lastBroadcastTime = Date()
108121

109122
// Send 3 packets with slight delay
110123
for i in 0..<3 {
@@ -377,8 +390,8 @@ class UDPDiscoveryManager: ObservableObject {
377390
DispatchQueue.main.async {
378391
withAnimation(.easeInOut(duration: 0.6)) {
379392
let initialCount = self.discoveredDevices.count
380-
self.discoveredDevices = self.discoveredDevices.filter {
381-
now.timeIntervalSince($0.lastSeen) <= 20
393+
self.discoveredDevices = self.discoveredDevices.filter {
394+
now.timeIntervalSince($0.lastSeen) <= 35
382395
}
383396

384397
let newCount = self.discoveredDevices.count

airsync-mac/Core/WebSocket/WebSocketServer+Ping.swift

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,14 @@ extension WebSocketServer {
3333

3434
/// Performs a session health check.
3535
/// Identifies and forcibly disconnects stale sessions that have exceeded the activity timeout.
36+
/// Only the *primary* session going stale triggers a full server restart. Non-primary zombie
37+
/// sessions are force-closed silently to avoid cascading restarts.
3638
func performPing() {
3739
self.lock.lock()
3840
let sessions = activeSessions
3941
let timeout = self.activityTimeout
4042
let key = self.symmetricKey
43+
let primary = self.primarySessionID
4144
self.lock.unlock()
4245

4346
if sessions.isEmpty { return }
@@ -55,17 +58,27 @@ extension WebSocketServer {
5558
let isStale = now.timeIntervalSince(lastDate) > timeout
5659

5760
if isStale {
58-
print("[websocket] Session \(sessionId) is stale. Performing hard reset and discovery restart.")
59-
DispatchQueue.main.async {
60-
// Disconnect and restart
61-
AppState.shared.disconnectDevice()
62-
ADBConnector.disconnectADB()
63-
AppState.shared.adbConnected = false
64-
65-
self.stop()
66-
self.start(port: self.localPort ?? Defaults.serverPort)
61+
let isPrimary = (sessionId == primary)
62+
if isPrimary {
63+
// Primary session has gone silent — full reconnect cycle
64+
print("[websocket] Primary session \(sessionId) is stale (>\(Int(timeout))s). Restarting server.")
65+
DispatchQueue.main.async {
66+
AppState.shared.disconnectDevice()
67+
ADBConnector.disconnectADB()
68+
AppState.shared.adbConnected = false
69+
self.restartServer()
70+
}
71+
return // Let the restart handle everything; stop iterating
72+
} else {
73+
// Non-primary zombie — just evict it without touching app state
74+
print("[websocket] Non-primary session \(sessionId) is stale. Force-closing silently.")
75+
self.lock.lock()
76+
self.activeSessions.removeAll { $0 === session }
77+
self.lastActivity.removeValue(forKey: sessionId)
78+
self.lock.unlock()
79+
session.writeBinary([]) // Force-close
80+
continue
6781
}
68-
return
6982
}
7083

7184
let pingJson = "{\"type\":\"ping\",\"data\":{}}"

airsync-mac/Core/WebSocket/WebSocketServer.swift

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class WebSocketServer: ObservableObject {
2020
internal var pingTimer: Timer?
2121
internal let pingInterval: TimeInterval = 5.0
2222
internal var lastActivity: [ObjectIdentifier: Date] = [:]
23-
internal let activityTimeout: TimeInterval = 11.0
23+
internal let activityTimeout: TimeInterval = 35.0
2424

2525
@Published var symmetricKey: SymmetricKey?
2626
@Published var localPort: UInt16?
@@ -30,6 +30,7 @@ class WebSocketServer: ObservableObject {
3030
@Published var deviceStatus: DeviceStatus?
3131

3232
internal var lastKnownIP: String?
33+
internal var isRestarting: Bool = false
3334
internal var networkMonitorTimer: Timer?
3435
internal let networkCheckInterval: TimeInterval = 10.0
3536
internal let lock = NSRecursiveLock()
@@ -234,8 +235,8 @@ class WebSocketServer: ObservableObject {
234235
AppState.shared.disconnectDevice()
235236
ADBConnector.disconnectADB()
236237
AppState.shared.adbConnected = false
237-
self.stop()
238-
self.start(port: self.localPort ?? Defaults.serverPort)
238+
// Guard against cascading restarts from multiple disconnected callbacks
239+
self.restartServer()
239240
}
240241
}
241242
}
@@ -277,4 +278,38 @@ class WebSocketServer: ObservableObject {
277278
func wakeUpLastConnectedDevice() {
278279
QuickConnectManager.shared.wakeUpLastConnectedDevice()
279280
}
281+
282+
// MARK: - Restart Helper
283+
284+
/// Single entry-point for all server restart logic.
285+
/// Guarded by `isRestarting` to prevent cascading calls from multiple
286+
/// simultaneous `disconnected` callbacks or stale-ping handlers.
287+
/// Waits 1.5 s before restarting so any remaining callbacks finish first,
288+
/// then re-broadcasts presence so Android can rediscover the Mac.
289+
func restartServer() {
290+
self.lock.lock()
291+
guard !isRestarting else {
292+
self.lock.unlock()
293+
print("[websocket] Restart already in progress – skipping duplicate request")
294+
return
295+
}
296+
isRestarting = true
297+
let port = self.localPort ?? Defaults.serverPort
298+
self.lock.unlock()
299+
300+
print("[websocket] Scheduling server restart in 1.5 s…")
301+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
302+
self.stop()
303+
self.start(port: port)
304+
305+
// Re-announce presence immediately after restart so Android can find us
306+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
307+
UDPDiscoveryManager.shared.broadcastBurst()
308+
self.lock.lock()
309+
self.isRestarting = false
310+
self.lock.unlock()
311+
print("[websocket] Server restart complete. Presence re-broadcast sent.")
312+
}
313+
}
314+
}
280315
}

0 commit comments

Comments
 (0)