Skip to content

Conversation

Copy link
Contributor

Copilot AI commented Dec 22, 2025

Summary

The status() method in NotificationManagerLocalPushInterfaceExtension can be called from multiple threads via sync state observers and KVO callbacks, but was accessing shared mutable state without synchronization. This creates race conditions when multiple threads simultaneously modify disconnectedServers or schedule/cancel timers.

Changes:

  • Added serial DispatchQueue to synchronize all access to disconnectedServers
  • Enforced main thread execution for timer operations using dispatchPrecondition
  • Thread transitions use DispatchQueue.main.async when hopping from background to main thread

Key implementation details:

  • Cannot use @MainActor annotation since status() is a synchronous protocol method
  • Timer-related properties (reconnectionTimer, reconnectionAttempt) remain main-thread-only
  • All disconnectedServers reads/writes wrapped in queue.sync blocks
  • Fixed deinit to safely invalidate timer on main thread

Screenshots

N/A - internal logic change with no UI impact

Link to pull request in Documentation repository

Documentation: home-assistant/companion.home-assistant#

Any other notes

This PR merges into #4113 to address feedback on thread safety from the code review.


💬 We'd love your input! Share your thoughts on Copilot coding agent in our 2 minute survey.

- Introduce serial DispatchQueue for synchronizing access to disconnectedServers
- Wrap all disconnectedServers access in queue.sync blocks
- Ensure timer methods (scheduleReconnection, attemptReconnection, cancelReconnection) run on main thread with dispatchPrecondition
- Use DispatchQueue.main.async to hop to main thread before calling timer methods from status()
- Fix deinit to safely invalidate timer on main thread
- Add comprehensive comments documenting thread safety requirements

Co-authored-by: bgoncal <[email protected]>
Copilot AI changed the title [WIP] Update local push reliability and reconnection improvements Add thread safety to local push reconnection logic Dec 22, 2025
Copilot AI requested a review from bgoncal December 22, 2025 11:36
@bgoncal bgoncal requested a review from Copilot December 22, 2025 11:36
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds thread safety mechanisms to the local push notification reconnection logic to prevent race conditions when status() is called from multiple threads via sync state observers and KVO callbacks.

Key Changes:

  • Introduced a serial DispatchQueue to synchronize access to the disconnectedServers shared mutable state
  • Added dispatchPrecondition checks to enforce main thread execution for timer-related operations
  • Modified deinit to safely invalidate the reconnection timer on the main thread

Comment on lines +76 to +78
DispatchQueue.main.async { [weak self] in
self?.cancelReconnection()
}
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using DispatchQueue.main.async inside queue.sync can lead to deadlocks if the serial queue is blocked waiting for the main queue, while the main queue needs to acquire the serial queue lock. Consider restructuring this code to release the queue lock before dispatching to main, or move the decision logic outside the sync block.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Dec 22, 2025

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.

Suggested change
// These properties must only be accessed on the main queue since Timer.scheduledTimer requires main thread
// These properties must only be accessed on a thread with an active RunLoop (typically the main queue),
// since Timer.scheduledTimer relies on a RunLoop rather than strictly requiring the main thread

Copilot uses AI. Check for mistakes.
Comment on lines +186 to +188
let serverInfo = queue.sync { () -> (count: Int, identifiers: [String]) in
(disconnectedServers.count, disconnectedServers.map(\.rawValue))
}
Copy link

Copilot AI Dec 22, 2025

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 uses AI. Check for mistakes.
}

Current.Log.verbose("Current disconnected servers: \(disconnectedServerIds)")
Current.Log.verbose("Reconnection attempt count: \(reconnectionAttempt)")
Copy link

Copilot AI Dec 22, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +161 to +162
DispatchQueue.main.async { [reconnectionTimer] in
reconnectionTimer?.invalidate()
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deinit captures reconnectionTimer in a closure that is dispatched asynchronously to the main queue. If deinit is called from a background thread and the object is deallocated before the async block executes, the captured timer reference may become invalid. Consider using a pattern that safely invalidates the timer synchronously on the main thread if already on main, or dispatch sync to main if on a background thread.

Suggested change
DispatchQueue.main.async { [reconnectionTimer] in
reconnectionTimer?.invalidate()
if Thread.isMainThread {
reconnectionTimer?.invalidate()
} else {
let timer = reconnectionTimer
DispatchQueue.main.sync {
timer?.invalidate()
}

Copilot uses AI. Check for mistakes.
Comment on lines +59 to +61
DispatchQueue.main.async { [weak self] in
self?.scheduleReconnection()
}
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using DispatchQueue.main.async inside queue.sync can lead to deadlocks if the serial queue is blocked waiting for the main queue, while the main queue needs to acquire the serial queue lock. Consider restructuring this code to release the queue lock before dispatching to main, or move the decision logic outside the sync block.

Copilot uses AI. Check for mistakes.
@github-actions
Copy link
Contributor

github-actions bot commented Dec 22, 2025

⚠️ Unused L10n strings detected

Found 1 unused localization strings in the codebase.

Click to see details
Parsing Strings.swift...
Found 1274 L10n strings

Reading all Swift source code...
Read 4760121 characters of Swift code

Checking for unused strings...
Checked 100/1274 strings...
Checked 200/1274 strings...
Checked 300/1274 strings...
Checked 400/1274 strings...
Checked 500/1274 strings...
Checked 600/1274 strings...
Checked 700/1274 strings...
Checked 800/1274 strings...
Checked 900/1274 strings...
Checked 1000/1274 strings...
Checked 1100/1274 strings...
Checked 1200/1274 strings...

================================================================================
UNUSED STRINGS REPORT
================================================================================

Found 1 unused strings:


WATCH:
  - L10n.Watch.Labels.noAction
    Key: watch.labels.no_action
    Line: 3913

================================================================================
Total unused: 1
================================================================================

To clean up these strings, manually remove them from the Localizable.strings files
and regenerate Strings.swift using SwiftGen.

@codecov
Copy link

codecov bot commented Dec 22, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (local-push@51f9a98). Learn more about missing BASE report.

Additional details and impacted files
@@              Coverage Diff              @@
##             local-push    #4132   +/-   ##
=============================================
  Coverage              ?   45.52%           
=============================================
  Files                 ?      242           
  Lines                 ?    13736           
  Branches              ?        0           
=============================================
  Hits                  ?     6253           
  Misses                ?     7483           
  Partials              ?        0           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants