Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 108 additions & 13 deletions Sources/ActorCoreBluetooth/BluetoothCentral.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import CoreBluetooth

@MainActor
public final class BluetoothCentral {
private final class WeakBox<T: AnyObject> {
weak var value: T?
init(_ value: T) {
self.value = value
}
}

private static let powerOnWaitAttempts = 50

private var cbCentralManager: CBCentralManager?
Expand All @@ -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<ConnectedPeripheral>] = [:]
private static let peripheralWrappersCleanupSizeThreshold = 50

public init(logger: BluetoothLogger? = OSLogBluetoothLogger()) {
self.logger = logger
logger?.centralInfo("BluetoothCentral initialized")
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -650,6 +715,9 @@ public final class BluetoothCentral {
// Clear peripheral registry
connectedPeripherals.removeAll()

// Clear peripheral wrapper cache
peripheralWrappers.removeAll()

logger?.centralNotice("BluetoothCentral cleanup completed")
}

Expand All @@ -669,6 +737,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) {

Expand Down Expand Up @@ -831,8 +913,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
Expand Down Expand Up @@ -864,20 +956,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
}
}

Expand Down