Skip to content

Commit 509ea77

Browse files
committed
Add error handling and call management for BLEActorSystem
1 parent 2f585c8 commit 509ea77

18 files changed

Lines changed: 3360 additions & 193 deletions

Sources/Bleu/Core/BLEActorSystem.swift

Lines changed: 181 additions & 50 deletions
Large diffs are not rendered by default.

Sources/Bleu/Core/BleuTypes.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ public enum BLEEvent: Sendable {
193193
case peripheralDisconnected(UUID, Error?)
194194
case serviceDiscovered(UUID, [ServiceMetadata])
195195
case characteristicDiscovered(UUID, UUID, [CharacteristicMetadata])
196-
case characteristicValueUpdated(UUID, UUID, UUID, Data?)
196+
case characteristicValueUpdated(UUID, UUID, UUID, Data?, Error?) // Added Error parameter for ATT error propagation
197197
case characteristicWriteCompleted(UUID, UUID, UUID, Error?)
198198
case notificationStateChanged(UUID, UUID, UUID, Bool)
199199
case centralSubscribed(UUID, UUID, UUID)

Sources/Bleu/Implementations/CoreBluetoothCentralManager.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,11 +446,13 @@ public actor CoreBluetoothCentralManager: BLECentralManagerProtocol {
446446
}
447447
let charUUID = UUID(uuidString: characteristicUUID) ?? UUID.deterministic(from: characteristicUUID)
448448

449+
// Include error in event for proper ATT error propagation
449450
await messageChannel.send(.characteristicValueUpdated(
450451
peripheralID,
451452
svcUUID,
452453
charUUID,
453-
value
454+
value,
455+
error // Now propagate ATT errors to BLEActorSystem
454456
))
455457
}
456458
}

Sources/Bleu/Implementations/CoreBluetoothPeripheralManager.swift

Lines changed: 101 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@ public actor CoreBluetoothPeripheralManager: BLEPeripheralManagerProtocol {
1818
private var services: [UUID: CBMutableService] = [:]
1919
private var characteristics: [UUID: CBMutableCharacteristic] = [:]
2020
private var subscribedCentrals: [UUID: Set<CBCentral>] = [:]
21+
private var subscribedCentralIDs: [UUID: Set<UUID>] = [:] // Track central UUIDs for API compatibility
2122
private var rpcCharacteristics: Set<UUID> = [] // Track RPC characteristics
2223

2324
// Continuations for async operations
2425
private var stateContinuations: [CheckedContinuation<CBManagerState, Never>] = []
2526
private var advertisingContinuation: CheckedContinuation<Void, Error>?
27+
private var serviceAddContinuation: CheckedContinuation<Void, Error>?
2628

2729
// MARK: - Initialization
2830

@@ -104,8 +106,11 @@ public actor CoreBluetoothPeripheralManager: BLEPeripheralManagerProtocol {
104106
cbService.characteristics = cbCharacteristics
105107
services[service.uuid] = cbService
106108

107-
// Add service to peripheral manager
108-
peripheralManager?.add(cbService)
109+
// Add service to peripheral manager and wait for completion
110+
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
111+
serviceAddContinuation = continuation
112+
peripheralManager?.add(cbService)
113+
}
109114
}
110115

111116
public func startAdvertising(_ data: AdvertisementData) async throws {
@@ -165,30 +170,59 @@ public actor CoreBluetoothPeripheralManager: BLEPeripheralManagerProtocol {
165170
throw BleuError.characteristicNotFound(characteristicUUID)
166171
}
167172

173+
guard let peripheralManager = peripheralManager else {
174+
throw BleuError.bluetoothUnavailable
175+
}
176+
168177
// Convert UUID array to CBCentral array if provided
169-
// Note: We can only send to centrals we know about through subscriptions
170-
let centralsToUpdate: [CBCentral]?
171-
if centrals != nil {
172-
// Filter subscribedCentrals to only those in the requested list
178+
// Filter by central.identifier to respect the centrals parameter
179+
let centralsToUpdate: [CBCentral]
180+
if let requestedCentralIDs = centrals {
173181
let allSubscribed = subscribedCentrals[characteristicUUID] ?? []
174-
centralsToUpdate = Array(allSubscribed)
175-
// Note: We cannot filter by UUID as CBCentral doesn't expose its identifier in peripheral role
182+
// Filter to only centrals whose identifier is in the requested list
183+
let filtered = Array(allSubscribed.filter { requestedCentralIDs.contains($0.identifier) })
184+
185+
// CRITICAL SECURITY FIX: If filter result is empty, DO NOT send to anyone
186+
// Sending nil to CoreBluetooth broadcasts to ALL subscribers (privacy leak!)
187+
if filtered.isEmpty {
188+
BleuLogger.peripheral.error("No matching centrals found for UUIDs \(requestedCentralIDs) - refusing to broadcast")
189+
throw BleuError.peripheralNotFound(requestedCentralIDs.first ?? UUID())
190+
}
191+
192+
centralsToUpdate = filtered
176193
} else {
177-
centralsToUpdate = subscribedCentrals[characteristicUUID].map { Array($0) }
194+
// No filter specified - send to all subscribers
195+
centralsToUpdate = Array(subscribedCentrals[characteristicUUID] ?? [])
196+
}
197+
198+
// CRITICAL: Retry on failure to prevent dropped RPC responses
199+
// When updateValue returns false, the queue is full and the notification was NOT sent
200+
let maxRetries = 3
201+
for attempt in 0..<maxRetries {
202+
let success = peripheralManager.updateValue(
203+
data,
204+
for: characteristic,
205+
onSubscribedCentrals: centralsToUpdate.isEmpty ? nil : centralsToUpdate
206+
)
207+
208+
if success {
209+
return true
210+
}
211+
212+
// Queue full - wait and retry
213+
if attempt < maxRetries - 1 {
214+
BleuLogger.peripheral.warning("updateValue failed (queue full), retrying (\(attempt + 1)/\(maxRetries))...")
215+
try await Task.sleep(nanoseconds: 10_000_000) // 10ms
216+
}
178217
}
179218

180-
return peripheralManager?.updateValue(
181-
data,
182-
for: characteristic,
183-
onSubscribedCentrals: centralsToUpdate?.isEmpty == false ? centralsToUpdate : nil
184-
) ?? false
219+
// All retries exhausted - this is a critical error for RPC
220+
BleuLogger.peripheral.error("updateValue failed after \(maxRetries) retries - notification dropped!")
221+
throw BleuError.rpcFailed("Failed to send notification after \(maxRetries) retries")
185222
}
186223

187224
public func subscribedCentrals(for characteristicUUID: UUID) async -> [UUID] {
188-
// Note: CoreBluetooth doesn't provide central UUIDs in peripheral role
189-
// We can only track CBCentral instances, not their identifiers
190-
// Return empty array as we cannot provide UUIDs
191-
return []
225+
return Array(subscribedCentralIDs[characteristicUUID] ?? [])
192226
}
193227
}
194228

@@ -202,13 +236,13 @@ extension CoreBluetoothPeripheralManager {
202236
}
203237

204238
/// Track a read request for logging/monitoring
205-
func trackReadRequest(characteristicUUID: String, serviceUUID: String?) async {
239+
func trackReadRequest(centralID: UUID, characteristicUUID: String, serviceUUID: String?) async {
206240
guard let charUUID = UUID(uuidString: characteristicUUID) else { return }
207241
let svcUUID = serviceUUID.flatMap(UUID.init(uuidString:)) ?? UUID()
208242

209243
// Send event for tracking
210244
await eventChannel.send(.readRequestReceived(
211-
UUID(), // We don't have central ID here
245+
centralID, // Real central identifier from CBCentral
212246
svcUUID,
213247
charUUID
214248
))
@@ -229,8 +263,20 @@ extension CoreBluetoothPeripheralManager {
229263
}
230264

231265
func handleServiceAdded(_ service: CBService, error: Error?) async {
232-
if let error = error {
233-
BleuLogger.peripheral.error("Error adding service: \(error.localizedDescription)")
266+
if let continuation = serviceAddContinuation {
267+
if let error = error {
268+
BleuLogger.peripheral.error("Error adding service: \(error.localizedDescription)")
269+
continuation.resume(throwing: error)
270+
} else {
271+
BleuLogger.peripheral.debug("Successfully added service: \(service.uuid)")
272+
continuation.resume()
273+
}
274+
serviceAddContinuation = nil
275+
} else {
276+
// If no continuation is waiting, just log (shouldn't happen in normal flow)
277+
if let error = error {
278+
BleuLogger.peripheral.error("Error adding service (no continuation): \(error.localizedDescription)")
279+
}
234280
}
235281
}
236282

@@ -246,10 +292,10 @@ extension CoreBluetoothPeripheralManager {
246292
}
247293

248294
func handleWriteRequests(
249-
_ extractedRequests: [(serviceUUID: String?, characteristicUUID: String, value: Data?, offset: Int)]
295+
_ extractedRequests: [(centralID: UUID, serviceUUID: String?, characteristicUUID: String, value: Data?, offset: Int)]
250296
) async {
251297
// Process the extracted requests
252-
for (serviceUUID, characteristicUUID, value, _) in extractedRequests {
298+
for (centralID, serviceUUID, characteristicUUID, value, _) in extractedRequests {
253299
guard let charUUID = UUID(uuidString: characteristicUUID) else { continue }
254300
let svcUUID = serviceUUID.flatMap(UUID.init(uuidString:)) ?? UUID()
255301

@@ -261,13 +307,13 @@ extension CoreBluetoothPeripheralManager {
261307
let transport = BLETransport.shared
262308
if let completeData = await transport.receive(value) {
263309
// We have a complete message, process it
264-
await handleRPCInvocation(data: completeData, characteristicUUID: charUUID)
310+
await handleRPCInvocation(data: completeData, characteristicUUID: charUUID, centralID: centralID)
265311
}
266312
// If nil, packet is part of a larger message, wait for more
267313
} else {
268314
// Regular characteristic write
269315
await eventChannel.send(.writeRequestReceived(
270-
UUID(), // We don't have central ID here
316+
centralID, // Real central identifier from CBCentral
271317
svcUUID,
272318
charUUID,
273319
value
@@ -277,15 +323,15 @@ extension CoreBluetoothPeripheralManager {
277323
}
278324
}
279325

280-
private func handleRPCInvocation(data: Data, characteristicUUID: UUID) async {
326+
private func handleRPCInvocation(data: Data, characteristicUUID: UUID, centralID: UUID) async {
281327
// Emit write event to EventBridge for RPC processing
282328
// EventBridge has the correct BLEActorSystem instance registered via setRPCRequestHandler()
283329
// This maintains proper separation of concerns and instance isolation
284330
await eventChannel.send(.writeRequestReceived(
285-
UUID(), // central ID (CoreBluetooth limitation - unavailable in peripheral role)
286-
UUID(), // service UUID (would need to be tracked separately)
331+
centralID, // Real central identifier from CBCentral
332+
UUID(), // service UUID (would need to be tracked separately)
287333
characteristicUUID,
288-
data // Complete RPC data (already reassembled from fragments by BLETransport)
334+
data // Complete RPC data (already reassembled from fragments by BLETransport)
289335
))
290336
}
291337

@@ -300,22 +346,40 @@ extension CoreBluetoothPeripheralManager {
300346
}
301347

302348
let svcUUID = serviceUUID.flatMap(UUID.init(uuidString:)) ?? UUID()
349+
let centralID = central.identifier // Extract real central identifier
303350

304351
if subscribed {
305352
var centrals = subscribedCentrals[charUUID] ?? []
306353
centrals.insert(central)
307354
subscribedCentrals[charUUID] = centrals
308355

356+
var centralIDs = subscribedCentralIDs[charUUID] ?? []
357+
centralIDs.insert(centralID)
358+
subscribedCentralIDs[charUUID] = centralIDs
359+
360+
// CRITICAL: Update MTU for this central in BLETransport
361+
// This enables optimal packet fragmentation for responses to this central
362+
let maxUpdateValueLength = central.maximumUpdateValueLength
363+
let transport = BLETransport.shared
364+
await transport.updateMaxPayloadSize(for: centralID, maxWriteLength: maxUpdateValueLength)
365+
366+
BleuLogger.peripheral.debug("Updated MTU for central \(centralID): \(maxUpdateValueLength) bytes")
367+
309368
await eventChannel.send(.centralSubscribed(
310-
UUID(), // We don't have central ID - this is a limitation of CoreBluetooth
369+
centralID, // Real central identifier from CBCentral
311370
svcUUID,
312371
charUUID
313372
))
314373
} else {
315374
subscribedCentrals[charUUID]?.remove(central)
375+
subscribedCentralIDs[charUUID]?.remove(centralID)
376+
377+
// Clean up MTU entry when central disconnects
378+
let transport = BLETransport.shared
379+
await transport.removeMTU(for: centralID)
316380

317381
await eventChannel.send(.centralUnsubscribed(
318-
UUID(), // We don't have central ID - this is a limitation of CoreBluetooth
382+
centralID, // Real central identifier from CBCentral
319383
svcUUID,
320384
charUUID
321385
))
@@ -357,6 +421,7 @@ fileprivate final class CoreBluetoothPeripheralManagerDelegateProxy: NSObject, C
357421
public func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) {
358422
// Extract necessary data before Task
359423
let characteristicUUID = request.characteristic.uuid
424+
let centralID = request.central.identifier // Extract real central identifier
360425

361426
// Handle the response in the delegate to avoid passing non-Sendable objects
362427
Task { [weak actor] in
@@ -379,6 +444,7 @@ fileprivate final class CoreBluetoothPeripheralManagerDelegateProxy: NSObject, C
379444
let charUUIDString = characteristicUUID.uuidString
380445
let serviceUUIDString = request.characteristic.service?.uuid.uuidString
381446
await actor?.trackReadRequest(
447+
centralID: centralID,
382448
characteristicUUID: charUUIDString,
383449
serviceUUID: serviceUUIDString
384450
)
@@ -387,9 +453,11 @@ fileprivate final class CoreBluetoothPeripheralManagerDelegateProxy: NSObject, C
387453

388454
public func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
389455
// Extract data from requests to avoid Sendable issues
390-
let extractedRequests: [(serviceUUID: String?, characteristicUUID: String, value: Data?, offset: Int)] =
456+
// CRITICAL: Extract central.identifier for proper multi-client RPC support
457+
let extractedRequests: [(centralID: UUID, serviceUUID: String?, characteristicUUID: String, value: Data?, offset: Int)] =
391458
requests.map { request in
392459
(
460+
centralID: request.central.identifier,
393461
serviceUUID: request.characteristic.service?.uuid.uuidString,
394462
characteristicUUID: request.characteristic.uuid.uuidString,
395463
value: request.value,

Sources/Bleu/LocalActors/LocalCentralActor.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,12 +363,14 @@ public actor LocalCentralActor {
363363
UUID()
364364
}
365365
let charUUID = UUID(uuidString: characteristicUUID) ?? UUID.deterministic(from: characteristicUUID)
366-
366+
367+
// Propagate error to BLEActorSystem for immediate failure
367368
await messageChannel.send(.characteristicValueUpdated(
368369
peripheralID,
369370
svcUUID,
370371
charUUID,
371-
value
372+
value,
373+
error
372374
))
373375
}
374376
}

0 commit comments

Comments
 (0)