From 9d970ad7d95052843c3695332655fbc88817fb1e Mon Sep 17 00:00:00 2001 From: imaznation Date: Thu, 21 May 2026 12:16:22 -0700 Subject: [PATCH] Bluetooth: hop blocking IOBluetooth calls off the main actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Services/BluetoothService.swift | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/Sources/EdgeControl/Services/BluetoothService.swift b/Sources/EdgeControl/Services/BluetoothService.swift index d5c686f..70a9bc5 100644 --- a/Sources/EdgeControl/Services/BluetoothService.swift +++ b/Sources/EdgeControl/Services/BluetoothService.swift @@ -29,6 +29,18 @@ public final class BluetoothService: ObservableObject { private var timer: Timer? + /// Dedicated utility-QoS queue for IOBluetooth calls. `IOBluetoothDevice + /// .pairedDevices()` is synchronous and can block for several seconds — + /// most painfully on the first call after wake/unlock, when + /// `IOBluetoothCoreBluetoothCoordinator init` parks on a semaphore until + /// CoreBluetooth finishes bringing the subsystem back up. Calling it on + /// the main actor froze the UI = beachball-on-hover the user reported + /// 2026-05-21. Keep IOBluetooth strictly on this queue. + private static let bluetoothQueue = DispatchQueue( + label: "dev.imaznation.edgecontrol.bluetooth", + qos: .utility + ) + public init() {} public func start() { @@ -47,13 +59,28 @@ public final class BluetoothService: ObservableObject { } private func sample() { - guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else { - isAvailable = false - return + Self.bluetoothQueue.async { + // collectPairedDevices is nonisolated + returns Sendable values; + // safe to call from a background dispatch queue, and the result + // hops back to the main actor for the @Published assignment. + let snapshot = Self.collectPairedDevices() + Task { @MainActor [weak self] in + guard let self else { return } + self.isAvailable = snapshot.available + self.devices = snapshot.devices + } } + } - isAvailable = true - devices = pairedDevices.compactMap { device in + /// Background-thread helper: calls the blocking IOBluetooth API and + /// extracts Sendable BTDevice values so nothing non-Sendable crosses + /// actor boundaries. nonisolated so the enclosing @MainActor class + /// doesn't pull this back onto the main thread. + nonisolated private static func collectPairedDevices() -> (available: Bool, devices: [BTDevice]) { + guard let pairedDevices = IOBluetoothDevice.pairedDevices() as? [IOBluetoothDevice] else { + return (false, []) + } + let list = pairedDevices.compactMap { device -> BTDevice? in guard let name = device.name, !name.isEmpty else { return nil } let deviceClass = device.deviceClassMajor @@ -76,5 +103,6 @@ public final class BluetoothService: ObservableObject { ) } .sorted { ($0.isConnected ? 0 : 1) < ($1.isConnected ? 0 : 1) } + return (true, list) } }