-
Notifications
You must be signed in to change notification settings - Fork 404
Add thread safety to local push reconnection logic #4132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: local-push
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -21,7 +21,11 @@ final class NotificationManagerLocalPushInterfaceExtension: NSObject, Notificati | |||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Serial queue for thread-safe access to shared mutable state | ||||||||||||||||||||
| private let queue = DispatchQueue(label: "io.homeassistant.LocalPushInterface") | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Reconnection timer properties | ||||||||||||||||||||
| // These properties must only be accessed on the main queue since Timer.scheduledTimer requires main thread | ||||||||||||||||||||
| private var reconnectionTimer: Timer? | ||||||||||||||||||||
| private var reconnectionAttempt = 0 | ||||||||||||||||||||
| /// Backoff delays (in seconds) between reconnection attempts: | ||||||||||||||||||||
|
|
@@ -38,6 +42,7 @@ final class NotificationManagerLocalPushInterfaceExtension: NSObject, Notificati | |||||||||||||||||||
| ] | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Track servers that have failed connections | ||||||||||||||||||||
| // Access to this property is synchronized via the queue | ||||||||||||||||||||
| private var disconnectedServers = Set<Identifier<Server>>() | ||||||||||||||||||||
|
|
||||||||||||||||||||
| func status(for server: Server) -> NotificationManagerLocalPushStatus { | ||||||||||||||||||||
|
|
@@ -50,43 +55,52 @@ final class NotificationManagerLocalPushInterfaceExtension: NSObject, Notificati | |||||||||||||||||||
| Current.Log.verbose("Server \(server.identifier.rawValue) sync state: \(state)") | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Track disconnected state for reconnection logic | ||||||||||||||||||||
| switch state { | ||||||||||||||||||||
| case .unavailable: | ||||||||||||||||||||
| if !disconnectedServers.contains(server.identifier) { | ||||||||||||||||||||
| Current.Log.info("Server \(server.identifier.rawValue) local push became unavailable") | ||||||||||||||||||||
| Current.Log | ||||||||||||||||||||
| .verbose( | ||||||||||||||||||||
| "Adding server \(server.identifier.rawValue) to disconnected set. Current disconnected servers: \(disconnectedServers.map(\.rawValue))" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| disconnectedServers.insert(server.identifier) | ||||||||||||||||||||
| Current.Log.verbose("Disconnected servers after insert: \(disconnectedServers.map(\.rawValue))") | ||||||||||||||||||||
| scheduleReconnection() | ||||||||||||||||||||
| } else { | ||||||||||||||||||||
| Current.Log.verbose("Server \(server.identifier.rawValue) already in disconnected set") | ||||||||||||||||||||
| } | ||||||||||||||||||||
| case .available, .establishing: | ||||||||||||||||||||
| if disconnectedServers.contains(server.identifier) { | ||||||||||||||||||||
| Current.Log.info("Server \(server.identifier.rawValue) local push reconnected successfully") | ||||||||||||||||||||
| Current.Log | ||||||||||||||||||||
| .verbose( | ||||||||||||||||||||
| "Removing server \(server.identifier.rawValue) from disconnected set. Current disconnected servers: \(disconnectedServers.map(\.rawValue))" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| disconnectedServers.remove(server.identifier) | ||||||||||||||||||||
| Current.Log.verbose("Disconnected servers after remove: \(disconnectedServers.map(\.rawValue))") | ||||||||||||||||||||
| if disconnectedServers.isEmpty { | ||||||||||||||||||||
| Current.Log.verbose("All servers reconnected, cancelling reconnection timer") | ||||||||||||||||||||
| cancelReconnection() | ||||||||||||||||||||
| // Use queue to synchronize access to disconnectedServers | ||||||||||||||||||||
| queue.sync { | ||||||||||||||||||||
| switch state { | ||||||||||||||||||||
| case .unavailable: | ||||||||||||||||||||
| if !disconnectedServers.contains(server.identifier) { | ||||||||||||||||||||
| Current.Log.info("Server \(server.identifier.rawValue) local push became unavailable") | ||||||||||||||||||||
| Current.Log | ||||||||||||||||||||
| .verbose( | ||||||||||||||||||||
| "Adding server \(server.identifier.rawValue) to disconnected set. Current disconnected servers: \(disconnectedServers.map(\.rawValue))" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| disconnectedServers.insert(server.identifier) | ||||||||||||||||||||
| Current.Log | ||||||||||||||||||||
| .verbose("Disconnected servers after insert: \(disconnectedServers.map(\.rawValue))") | ||||||||||||||||||||
| DispatchQueue.main.async { [weak self] in | ||||||||||||||||||||
| self?.scheduleReconnection() | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+71
to
+73
|
||||||||||||||||||||
| } else { | ||||||||||||||||||||
| Current.Log.verbose("Server \(server.identifier.rawValue) already in disconnected set") | ||||||||||||||||||||
| } | ||||||||||||||||||||
| case .available, .establishing: | ||||||||||||||||||||
| if disconnectedServers.contains(server.identifier) { | ||||||||||||||||||||
| Current.Log.info("Server \(server.identifier.rawValue) local push reconnected successfully") | ||||||||||||||||||||
| Current.Log | ||||||||||||||||||||
| .verbose( | ||||||||||||||||||||
| "Removing server \(server.identifier.rawValue) from disconnected set. Current disconnected servers: \(disconnectedServers.map(\.rawValue))" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| disconnectedServers.remove(server.identifier) | ||||||||||||||||||||
| Current.Log | ||||||||||||||||||||
| .verbose("Disconnected servers after remove: \(disconnectedServers.map(\.rawValue))") | ||||||||||||||||||||
| if disconnectedServers.isEmpty { | ||||||||||||||||||||
| Current.Log.verbose("All servers reconnected, cancelling reconnection timer") | ||||||||||||||||||||
| DispatchQueue.main.async { [weak self] in | ||||||||||||||||||||
| self?.cancelReconnection() | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
Comment on lines
+89
to
+91
|
||||||||||||||||||||
| } else { | ||||||||||||||||||||
| Current.Log | ||||||||||||||||||||
| .verbose( | ||||||||||||||||||||
| "Still have \(disconnectedServers.count) disconnected server(s), keeping timer active" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } else { | ||||||||||||||||||||
| Current.Log | ||||||||||||||||||||
| .verbose( | ||||||||||||||||||||
| "Still have \(disconnectedServers.count) disconnected server(s), keeping timer active" | ||||||||||||||||||||
| "Server \(server.identifier.rawValue) is connected and was not in disconnected set" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } else { | ||||||||||||||||||||
| Current.Log | ||||||||||||||||||||
| .verbose( | ||||||||||||||||||||
| "Server \(server.identifier.rawValue) is connected and was not in disconnected set" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
@@ -99,12 +113,14 @@ final class NotificationManagerLocalPushInterfaceExtension: NSObject, Notificati | |||||||||||||||||||
| } else { | ||||||||||||||||||||
| // manager isn't running | ||||||||||||||||||||
| Current.Log.verbose("Server \(server.identifier.rawValue) has no active managers") | ||||||||||||||||||||
| if disconnectedServers.contains(server.identifier) { | ||||||||||||||||||||
| Current.Log | ||||||||||||||||||||
| .verbose( | ||||||||||||||||||||
| "Removing server \(server.identifier.rawValue) from disconnected set (manager not running)" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| disconnectedServers.remove(server.identifier) | ||||||||||||||||||||
| queue.sync { | ||||||||||||||||||||
| if disconnectedServers.contains(server.identifier) { | ||||||||||||||||||||
| Current.Log | ||||||||||||||||||||
| .verbose( | ||||||||||||||||||||
| "Removing server \(server.identifier.rawValue) from disconnected set (manager not running)" | ||||||||||||||||||||
| ) | ||||||||||||||||||||
| disconnectedServers.remove(server.identifier) | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| return .disabled | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
@@ -154,13 +170,19 @@ final class NotificationManagerLocalPushInterfaceExtension: NSObject, Notificati | |||||||||||||||||||
|
|
||||||||||||||||||||
| deinit { | ||||||||||||||||||||
| Current.Log.verbose("NotificationManagerLocalPushInterfaceExtension deinit, cleaning up reconnection timer") | ||||||||||||||||||||
| cancelReconnection() | ||||||||||||||||||||
| // Cancel timer on main thread since Timer must be invalidated on the thread it was created | ||||||||||||||||||||
| DispatchQueue.main.async { [reconnectionTimer] in | ||||||||||||||||||||
| reconnectionTimer?.invalidate() | ||||||||||||||||||||
|
Comment on lines
+174
to
+175
|
||||||||||||||||||||
| DispatchQueue.main.async { [reconnectionTimer] in | |
| reconnectionTimer?.invalidate() | |
| if Thread.isMainThread { | |
| reconnectionTimer?.invalidate() | |
| } else { | |
| let timer = reconnectionTimer | |
| DispatchQueue.main.sync { | |
| timer?.invalidate() | |
| } |
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The pattern of creating a tuple with (count: Int, identifiers: [String]) from disconnectedServers is repeated in multiple locations (lines 186-188, 216-218, 265-267, 309-311). Consider extracting this into a private helper method to reduce code duplication and improve maintainability.
Copilot
AI
Dec 22, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Accessing reconnectionAttempt in updateManagers() without thread safety. This method is called from background thread (NEAppPushManager.loadAllFromPreferences callback at line 272), but reconnectionAttempt is a main-thread-only property. This creates a race condition when the timer fires on the main thread and modifies reconnectionAttempt at the same time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comment states "These properties must only be accessed on the main queue since Timer.scheduledTimer requires main thread", but this is inaccurate. Timer.scheduledTimer does not require the main thread - it requires the thread with an active RunLoop. Timers can be scheduled on any thread with a RunLoop, though the main thread is most common for UI-related code.