From 02237114e3b52df70fa2557aa189b2ceaf962a75 Mon Sep 17 00:00:00 2001 From: Konstantin Polin Date: Thu, 8 Jan 2026 20:01:35 -0800 Subject: [PATCH] Implemented Peripheral wrapper identity map --- .../ActorCoreBluetooth/BluetoothCentral.swift | 121 ++++++++++++++++-- 1 file changed, 108 insertions(+), 13 deletions(-) diff --git a/Sources/ActorCoreBluetooth/BluetoothCentral.swift b/Sources/ActorCoreBluetooth/BluetoothCentral.swift index 3d7b6ea..36b4c46 100644 --- a/Sources/ActorCoreBluetooth/BluetoothCentral.swift +++ b/Sources/ActorCoreBluetooth/BluetoothCentral.swift @@ -11,6 +11,13 @@ import CoreBluetooth @MainActor public final class BluetoothCentral { + private final class WeakBox { + weak var value: T? + init(_ value: T) { + self.value = value + } + } + private static let powerOnWaitAttempts = 50 private var cbCentralManager: CBCentralManager? @@ -30,6 +37,12 @@ public final class BluetoothCentral { // Connected peripherals registry private var connectedPeripherals: [UUID: CBPeripheral] = [:] + // Peripheral wrapper identity map - single source of truth for ConnectedPeripheral instances + // Invariant: at most one live wrapper per CBPeripheral.identifier + // This prevents delegate overwrite and zombie sessions when retrieveConnectedPeripherals is called repeatedly + private var peripheralWrappers: [UUID: WeakBox] = [:] + private static let peripheralWrappersCleanupSizeThreshold = 50 + public init(logger: BluetoothLogger? = OSLogBluetoothLogger()) { self.logger = logger logger?.centralInfo("BluetoothCentral initialized") @@ -96,6 +109,54 @@ public final class BluetoothCentral { logger?.centralNotice("Bluetooth central manager is ready") } + /// Get or create a ConnectedPeripheral wrapper for a CBPeripheral. + /// Ensures there is at most one live wrapper per CBPeripheral.identifier. + /// This prevents delegate overwrite and zombie sessions. + private func getOrCreateWrapper(for cbPeripheral: CBPeripheral) -> ConnectedPeripheral { + let identifier = cbPeripheral.identifier + + // Clean up dead weak references periodically + if peripheralWrappers.count > Self.peripheralWrappersCleanupSizeThreshold { + peripheralWrappers = peripheralWrappers.filter { $0.value.value != nil } + } + + if let existingBox = peripheralWrappers[identifier], let existingWrapper = existingBox.value { + if existingWrapper.cbPeripheral !== cbPeripheral { + logPeripheralInstanceMismatch( + reason: "Duplicated cbPeripheral objects detected for existing wrapper", + peripheralID: identifier, + incoming: cbPeripheral, + canonical: existingWrapper.cbPeripheral + ) + } + logger?.internalDebug("Returning existing ConnectedPeripheral wrapper", context: [ + "peripheralID": identifier.uuidString, + "peripheralName": cbPeripheral.name ?? "Unknown" + ]) + return existingWrapper + } + + logger?.internalDebug("Creating new ConnectedPeripheral wrapper", context: [ + "peripheralID": identifier.uuidString, + "peripheralName": cbPeripheral.name ?? "Unknown" + ]) + + if let existingPeripheral = connectedPeripherals[identifier], existingPeripheral !== cbPeripheral { + logPeripheralInstanceMismatch( + reason: "Duplicated cbPeripheral objects detected for new wrapper", + peripheralID: identifier, + incoming: cbPeripheral, + canonical: existingPeripheral + ) + } + + let canonicalPeripheral = connectedPeripherals[identifier] ?? cbPeripheral + let wrapper = ConnectedPeripheral(cbPeripheral: canonicalPeripheral, logger: logger) + peripheralWrappers[identifier] = WeakBox(wrapper) + + return wrapper + } + /// Scan for peripherals advertising specified services public func scanForPeripherals(withServices services: [String]? = nil, timeout: TimeInterval? = nil) async throws -> [DiscoveredPeripheral] { try await ensureCentralManagerInitialized() @@ -222,7 +283,7 @@ public final class BluetoothCentral { "peripheralID": peripheral.identifier.uuidString ]) connectedPeripherals[peripheral.identifier] = peripheral.cbPeripheral.value - return ConnectedPeripheral(cbPeripheral: peripheral.cbPeripheral.value, logger: logger) + return getOrCreateWrapper(for: peripheral.cbPeripheral.value) } let wrappedPeripheral = try await withCheckedThrowingContinuation { continuation in @@ -260,7 +321,7 @@ public final class BluetoothCentral { "peripheralName": peripheral.name ?? "Unknown", "peripheralID": peripheral.identifier.uuidString ]) - return ConnectedPeripheral(cbPeripheral: cbPeripheral, logger: logger) + return getOrCreateWrapper(for: cbPeripheral) } /// Connect to a previously connected peripheral for reconnection @@ -439,6 +500,10 @@ public final class BluetoothCentral { /// Retrieve and connect to peripherals that are already connected at the system level /// This is useful for peripherals connected by other apps or the system + /// + /// This method is idempotent: repeated calls will return the same wrapper instances + /// for already-wrapped peripherals and will not disrupt active sessions by + /// overwriting delegates. public func retrieveConnectedPeripherals( withServices services: [String], timeout: TimeInterval? = nil @@ -641,6 +706,9 @@ public final class BluetoothCentral { // Clear peripheral registry connectedPeripherals.removeAll() + // Clear peripheral wrapper cache + peripheralWrappers.removeAll() + logger?.centralNotice("BluetoothCentral cleanup completed") } @@ -660,6 +728,20 @@ public final class BluetoothCentral { // MARK: - Internal Delegate Handling Methods + /// Log a warning when multiple CBPeripheral instances exist for the same UUID + private func logPeripheralInstanceMismatch( + reason: String, + peripheralID: UUID, + incoming: CBPeripheral, + canonical: CBPeripheral + ) { + logger?.internalWarning(reason, context: [ + "peripheralID": peripheralID.uuidString, + "incomingName": incoming.name ?? "Unknown", + "canonicalName": canonical.name ?? "Unknown" + ]) + } + /// Handle peripheral state changes, managing both operation completion and state monitoring internal func handlePeripheralStateChange(for peripheralID: UUID, newState: PeripheralState, cbPeripheral: CBPeripheral, error: Error? = nil) { @@ -822,8 +904,18 @@ public final class BluetoothCentral { logger?.connectionNotice("Peripheral already connected at CB level, registering with library", context: [ "peripheralID": cbPeripheral.identifier.uuidString ]) - connectedPeripherals[cbPeripheral.identifier] = cbPeripheral - return ConnectedPeripheral(cbPeripheral: cbPeripheral, logger: logger) + let wrapper = getOrCreateWrapper(for: cbPeripheral) + if wrapper.cbPeripheral !== cbPeripheral { + logPeripheralInstanceMismatch( + reason: "Reconnect returned a different CBPeripheral instance than the canonical wrapper uses", + peripheralID: wrapper.identifier, + incoming: cbPeripheral, + canonical: wrapper.cbPeripheral + ) + } + + connectedPeripherals[cbPeripheral.identifier] = wrapper.cbPeripheral + return wrapper } let wrappedPeripheral = try await withCheckedThrowingContinuation { continuation in @@ -855,20 +947,23 @@ public final class BluetoothCentral { ]) cbCentralManager.connect(cbPeripheral, options: nil) } - - let connectedPeripheral = wrappedPeripheral.value logger?.connectionNotice("Successfully reconnected", context: [ "peripheralName": cbPeripheral.name ?? originalName ?? "Unknown", "peripheralID": cbPeripheral.identifier.uuidString ]) - connectedPeripherals[cbPeripheral.identifier] = cbPeripheral - logger?.internalDebug("Updated peripheral registry for future reconnections", context: [ - "peripheralID": cbPeripheral.identifier.uuidString, - "peripheralName": cbPeripheral.name ?? "Unknown" - ]) - - return ConnectedPeripheral(cbPeripheral: connectedPeripheral, logger: logger) + let connectedPeripheral = wrappedPeripheral.value + let wrapper = getOrCreateWrapper(for: connectedPeripheral) + if wrapper.cbPeripheral !== connectedPeripheral { + logPeripheralInstanceMismatch( + reason: "Reconnect returned a different CBPeripheral instance than the canonical wrapper uses", + peripheralID: wrapper.identifier, + incoming: connectedPeripheral, + canonical: wrapper.cbPeripheral + ) + } + connectedPeripherals[wrapper.identifier] = wrapper.cbPeripheral + return wrapper } }