Skip to content

BluetoothService: hop blocking IOBluetooth calls off the main actor#1

Open
imaznation wants to merge 1 commit into
kemalandic:mainfrom
imaznation:proposal/bluetooth-off-main
Open

BluetoothService: hop blocking IOBluetooth calls off the main actor#1
imaznation wants to merge 1 commit into
kemalandic:mainfrom
imaznation:proposal/bluetooth-off-main

Conversation

@imaznation
Copy link
Copy Markdown

Symptom

EdgeControl beachballs whenever the cursor enters its window. Beachball cursor only shows over an unresponsive window, so hover was the trigger of the observation — the actual freeze is the main thread itself.

Root cause

Confirmed via sample(1) — 100% of a 3-second sample on this stack:

Main → BluetoothService.sample()
     → +[IOBluetoothDevice pairedDevices]
     → +[IOBluetoothCoreBluetoothCoordinator sharedInstance]
     → -[IOBluetoothCoreBluetoothCoordinator init]
     → _dispatch_semaphore_wait_slow → semaphore_wait_trap

IOBluetoothDevice.pairedDevices() is synchronous and parks on a semaphore held by CoreBluetooth's subsystem-init coordinator until that's done bringing the stack up. Worst after wake/unlock; the 10-second repeat timer also re-hangs the UI periodically thereafter. BluetoothService was @MainActor so every call sat on the main runloop.

Fix

Extract a nonisolated static collectPairedDevices() and dispatch it onto a dedicated utility-QoS queue. The result is a tuple of Sendable BTDevice values that hops back to the main actor for the @Published assignment — no non-Sendable IOBluetooth objects cross actor boundaries.

Verification

Post-fix main-thread sample shows only NSApplication.run + SwiftUI layout frames — no IOBluetooth on main.

Symptom: EdgeControl beachballed whenever the cursor entered the
window. Beachball cursor only shows over an unresponsive window, so
hover was the trigger of the *observation* — the actual freeze was the
main thread itself.

Root cause (confirmed by sample(1) trace, 100% of 3s on this stack):
  Main → BluetoothService.sample()
       → +[IOBluetoothDevice pairedDevices]
       → +[IOBluetoothCoreBluetoothCoordinator sharedInstance]
       → -[IOBluetoothCoreBluetoothCoordinator init]
       → _dispatch_semaphore_wait_slow → semaphore_wait_trap

IOBluetooth.pairedDevices() is synchronous and parks on a semaphore
held by CoreBluetooth's subsystem-init coordinator until that's done
bringing the stack up. Worst after wake/unlock; the 10-second repeat
timer also re-hung the UI periodically thereafter. BluetoothService
was @mainactor so every call sat on the main runloop.

Fix: extract a nonisolated static `collectPairedDevices()` and dispatch
it onto a dedicated utility-QoS queue. The result is a tuple of
Sendable BTDevice values that hops back to the main actor for the
@published assignment — no non-Sendable IOBluetooth objects cross
actor boundaries.

Verified post-fix: main thread sample shows only NSApplication.run +
SwiftUI layout frames — no IOBluetooth on main.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant