@mentra/bluetooth-sdk 3.4.0-dev.978 → 3.4.0-dev.980

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,384 @@
1
+ import Foundation
2
+ import CoreBluetooth
3
+
4
+ /// One process-local authority shared by the typed Expo bridge and the Mac
5
+ /// fixture socket. Neither accepts packets, connection addresses or file paths.
6
+ @MainActor
7
+ public enum NimoResourceFixtureBridge {
8
+ public static func execute(_ json: String) async throws -> String {
9
+ let bytes = Data(json.utf8)
10
+ return String(decoding: try await NimoResourceFixture.shared.execute(bytes), as: UTF8.self)
11
+ }
12
+ }
13
+
14
+ @MainActor
15
+ final class NimoResourceFixture {
16
+ static let shared = NimoResourceFixture()
17
+ let process = NimoResourceProcess(pid: ProcessInfo.processInfo.processIdentifier, instanceId: UUID().uuidString.lowercased())
18
+ private(set) var session: NimoResourceSession?
19
+ private(set) var owner: Nimo?
20
+ private var acceptedPair: NimoResourceSelection?
21
+ private var captureNonce: String?
22
+ private var armedElapsedMs: UInt64?
23
+ private var discovery: NimoResourceConnectedDiscovery?
24
+ private var discoveryRequestNonce: String?
25
+ private var discoveryReleaseNonce: String?
26
+ private var discoveryReleaseStartedElapsedMs: UInt64?
27
+ private var discoveryCancelled = false
28
+ private var completed: [String: (Data, Data)] = [:]
29
+ private var pending: [String: (Data, Task<Data, Error>)] = [:]
30
+ private var transport: NimoMacResourceTransport?
31
+ private var startupError: Error?
32
+ private var originalClosed = false
33
+ private let managerEntries = NimoResourceManagerEntries()
34
+ var pendingManagerEntries: Int { managerEntries.all }
35
+ var pendingOriginalManagerEntries: Int { managerEntries.originalNimo }
36
+ func beginManagerEntry(_ wearable: String) -> NimoResourceManagerEntries.Ticket { managerEntries.begin(wearable) }
37
+ func endManagerEntry(_ ticket: NimoResourceManagerEntries.Ticket) { managerEntries.end(ticket) }
38
+ #if os(macOS)
39
+ private let requested = ProcessInfo.processInfo.environment["MENTRA_NIMO_RESOURCE_BOOTSTRAP"] != nil
40
+ #elseif os(iOS)
41
+ private let requested = ProcessInfo.processInfo.isiOSAppOnMac && ProcessInfo.processInfo.environment["MENTRA_NIMO_RESOURCE_BOOTSTRAP"] != nil
42
+ #else
43
+ private let requested = false
44
+ #endif
45
+ #if SWIFT_PACKAGE
46
+ private var packageEnabled = false
47
+ #endif
48
+ var enabled: Bool {
49
+ #if SWIFT_PACKAGE
50
+ return requested || packageEnabled
51
+ #else
52
+ return requested
53
+ #endif
54
+ }
55
+
56
+ func start() {
57
+ guard enabled, transport == nil, startupError == nil else { return }
58
+ do { transport = try NimoMacResourceTransport(process: process) }
59
+ catch { startupError = error }
60
+ }
61
+ func allowsManager(_ wearable: String, name: String? = nil) -> Bool {
62
+ guard enabled else { return true }
63
+ // A different manager can clean up the captured Nimo object. Permit it
64
+ // only after the original resource has actually completed teardown.
65
+ guard wearable.contains("NIMO") else { return NimoResourceManagerState.allowsReplacement(fixtureEnabled: enabled, originalClosed: originalClosed && (session == nil || session?.errors.isEmpty == true && session?.connected == false)) }
66
+ guard let session, !session.cancelled, session.releaseNonce == nil, session.original == nil,
67
+ name == nil || name == session.pair.name else { return false }
68
+ return true
69
+ }
70
+ func hasVerifiedOriginalClosure(_ candidate: Nimo) -> Bool {
71
+ originalClosed && owner === candidate && session?.isOwner(candidate) == true
72
+ && session?.errors.isEmpty == true && session?.connected == false
73
+ }
74
+ func admit(_ candidate: Nimo, name: String?) -> Bool {
75
+ guard enabled else { return true }
76
+ guard (DeviceManager.shared.sgc as AnyObject?) === candidate, let session, let transport else { return false }
77
+ let admitted = session.admit(candidate, instanceId: candidate.resourceInstanceId, name: name) { proposed in
78
+ self.persistAdmission(session, instanceId: candidate.resourceInstanceId, elapsed: proposed, transportStart: false, transport: transport)
79
+ }
80
+ if session.isOwner(candidate), owner == nil { owner = candidate }
81
+ return admitted && owner === candidate
82
+ }
83
+ private func persistAdmission(_ session: NimoResourceSession, instanceId: String, elapsed: UInt64, transportStart: Bool, transport: NimoMacResourceTransport) -> Bool {
84
+ let value: [String: Any] = ["protocol": "nimo-macos-resource-v1", "captureNonce": session.captureNonce,
85
+ "process": ["pid": process.pid, "instanceId": process.instanceId],
86
+ "planSha256": transport.configuration.planSha256, "grantSha256": transport.configuration.grantSha256,
87
+ "instanceId": instanceId, "peripheralUuid": session.pair.peripheralUuid, "sessionGeneration": transportStart ? 1 : 0,
88
+ "armedElapsedMs": session.armedElapsedMs, "admittedElapsedMs": transportStart ? nullable(session.admittedElapsedMs) : elapsed,
89
+ "startedElapsedMs": transportStart ? elapsed : NSNull() as Any]
90
+ do {
91
+ let bytes = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys])
92
+ try transport.persist(bytes, nonce: session.captureNonce, kind: transportStart ? "transport" : "admission")
93
+ return true
94
+ } catch { return false }
95
+ }
96
+ func preserveScan(_ name: String) -> Bool {
97
+ guard enabled, let owner, let session, session.isOwner(owner),
98
+ session.original == nil, !session.cancelled, name == session.pair.name else { return false }
99
+ return (DeviceManager.shared.sgc as AnyObject?) === owner
100
+ }
101
+ func beginTransport(_ candidate: Nimo, peripheral: AnyObject, uuid: String) -> Bool {
102
+ guard enabled else { return true }
103
+ guard (DeviceManager.shared.sgc as AnyObject?) === candidate, let session, let transport else { return false }
104
+ return session.beginTransport(candidate, peripheral: peripheral, uuid: uuid) { proposed in
105
+ self.persistAdmission(session, instanceId: candidate.resourceInstanceId, elapsed: proposed, transportStart: true, transport: transport)
106
+ }
107
+ }
108
+ func allowsProducer(_ candidate: Nimo) -> Bool {
109
+ guard enabled else { return true }
110
+ return session?.isOwner(candidate) == true && session?.releaseNonce == nil && session?.cancelled == false && (DeviceManager.shared.sgc as AnyObject?) === candidate
111
+ }
112
+ func captured(_ candidate: Nimo) -> NimoResourceSession? {
113
+ guard enabled, session?.isOwner(candidate) == true else { return nil }; return session
114
+ }
115
+
116
+ func execute(_ bytes: Data) async throws -> Data {
117
+ start()
118
+ guard bytes.count <= 16_384, enabled, let transport, startupError == nil else { throw failure("fixture-unavailable") }
119
+ let request = try decode(bytes)
120
+ guard request.version == 1, request.expectedProcess == process,
121
+ request.planSha256 == transport.configuration.planSha256,
122
+ request.grantSha256 == transport.configuration.grantSha256 else { throw failure("fixture-authority-mismatch") }
123
+ if let (original, receipt) = completed[request.requestNonce] {
124
+ guard original == bytes else { throw failure("request-nonce-reused") }; return receipt
125
+ }
126
+ if let (original, task) = pending[request.requestNonce] {
127
+ guard original == bytes else { throw failure("request-nonce-reused") }; return try await task.value
128
+ }
129
+ if request.operation != .arm && request.operation != .discover {
130
+ guard captureNonce == request.captureNonce, acceptedPair == request.expectedPair else { throw failure("capture-mismatch") }
131
+ let session = self.session
132
+ let observed = session?.original ?? session?.instanceId.map { NimoResourceOriginal(instanceId: $0, peripheralUuid: session!.pair.peripheralUuid, sessionGeneration: 0) }
133
+ if let expected = request.original { guard expected == observed else { throw failure("original-session-mismatch") } }
134
+ if request.operation == .release, session?.hasOwner == true, request.original == nil { throw failure("release-requires-original") }
135
+ if request.operation == .release, let release = session?.releaseNonce ?? discoveryReleaseNonce, release != request.requestNonce { throw failure("original-release-nonce-required") }
136
+ }
137
+ let recovery = request.operation == .release || request.operation == .cancel || session?.releaseNonce != nil || discoveryReleaseNonce != nil
138
+ guard completed.count + pending.count < (recovery ? 256 : 192) else { throw failure("request-budget-exhausted") }
139
+ // Intent is persisted before ANY capture or release mutation. No caller
140
+ // path is used; replay compares the exact original request bytes.
141
+ try transport.persist(bytes, nonce: request.requestNonce, kind: "intent")
142
+ let received = NimoResourceClock.now()
143
+ let task = Task { @MainActor in
144
+ let result = try await self.perform(request, received: received)
145
+ let encoded = try JSONSerialization.data(withJSONObject: result, options: [.sortedKeys])
146
+ guard encoded.count <= 32_768 else { throw self.failure("receipt-budget-exhausted") }
147
+ try transport.persist(encoded, nonce: request.requestNonce, kind: "receipt")
148
+ return encoded
149
+ }
150
+ pending[request.requestNonce] = (bytes, task)
151
+ do {
152
+ let receipt = try await task.value
153
+ completed[request.requestNonce] = (bytes, receipt); pending.removeValue(forKey: request.requestNonce)
154
+ return receipt
155
+ } catch {
156
+ // Keep the original failed intent fenced; do not reexecute effects.
157
+ throw error
158
+ }
159
+ }
160
+
161
+ private func perform(_ request: NimoResourceRequest, received: UInt64) async throws -> [String: Any] {
162
+ if request.operation == .discover {
163
+ guard request.expectedPair.peripheralSelection == NimoResourceSelection.connectedService,
164
+ captureNonce == nil, request.original == nil, DeviceManager.shared.sgc == nil, pendingManagerEntries == 0 else { throw failure("discover-requires-unowned-manager") }
165
+ acceptedPair = request.expectedPair; captureNonce = request.captureNonce; armedElapsedMs = NimoResourceClock.now()
166
+ let observer = NimoResourceConnectedDiscovery(name: request.expectedPair.name, startedElapsedMs: armedElapsedMs!)
167
+ discovery = observer; discoveryRequestNonce = request.requestNonce
168
+ await observer.discoverConnected()
169
+ }
170
+ if request.operation == .arm {
171
+ guard session == nil, request.original == nil, DeviceManager.shared.sgc == nil, pendingManagerEntries == 0 else { throw failure("arm-requires-unowned-manager") }
172
+ let uuid: String
173
+ if let observed = request.expectedPair.peripheralUuid {
174
+ guard captureNonce == nil else { throw failure("capture-already-exists") }
175
+ acceptedPair = request.expectedPair; captureNonce = request.captureNonce
176
+ uuid = observed
177
+ } else {
178
+ guard captureNonce == request.captureNonce, acceptedPair == request.expectedPair,
179
+ !discoveryCancelled, discoveryReleaseNonce == nil,
180
+ let discoveryRequestNonce, completed[discoveryRequestNonce] != nil,
181
+ let discovery, discovery.ledger.settledElapsedMs != nil, discovery.callbacksPending == 0,
182
+ discovery.ledger.errors.isEmpty, let selected = discovery.ledger.selectedPeripheralUuid else { throw failure("discovery-not-admissible") }
183
+ uuid = selected
184
+ }
185
+ if armedElapsedMs == nil { armedElapsedMs = NimoResourceClock.now() }
186
+ session = NimoResourceSession(captureNonce: request.captureNonce, pair: NimoResourcePair(name: request.expectedPair.name, peripheralUuid: uuid), armedElapsedMs: armedElapsedMs!)
187
+ }
188
+ guard captureNonce == request.captureNonce, acceptedPair == request.expectedPair else { throw failure("capture-mismatch") }
189
+ if session == nil {
190
+ guard request.original == nil else { throw failure("original-session-mismatch") }
191
+ if request.operation == .release || request.operation == .cancel {
192
+ if let original = discoveryReleaseNonce, request.operation == .release, original != request.requestNonce { throw failure("original-release-nonce-required") }
193
+ if discoveryReleaseNonce == nil {
194
+ discoveryReleaseNonce = request.requestNonce; discoveryReleaseStartedElapsedMs = NimoResourceClock.now()
195
+ discoveryCancelled = request.operation == .cancel
196
+ await discovery?.stopDiscovery(cancel: true)
197
+ }
198
+ }
199
+ return discoveryOnlyReceipt(request, received: received)
200
+ }
201
+ guard let session, session.captureNonce == request.captureNonce, acceptedPair == request.expectedPair else { throw failure("capture-mismatch") }
202
+ if let expected = request.original {
203
+ let observed = session.original ?? session.instanceId.map { NimoResourceOriginal(instanceId: $0, peripheralUuid: session.pair.peripheralUuid, sessionGeneration: 0) }
204
+ guard expected == observed else { throw failure("original-session-mismatch") }
205
+ } else if request.operation == .release, session.hasOwner { throw failure("release-requires-original") }
206
+ if request.operation == .release || request.operation == .cancel {
207
+ if session.releaseNonce == nil {
208
+ let mic = owner?.resourceMicStopRequired ?? false, canvas = owner?.resourceCanvasRequired ?? false
209
+ guard session.beginRelease(nonce: request.requestNonce, cancel: request.operation == .cancel, mic: mic, canvas: canvas) else { throw failure("release-mismatch") }
210
+ if let owner { await owner.releaseResource(session) }
211
+ else {
212
+ let deadline = NimoResourceClock.now() + 1_000
213
+ while pendingOriginalManagerEntries != 0, NimoResourceClock.now() < deadline { try? await Task.sleep(nanoseconds: 10_000_000) }
214
+ if pendingOriginalManagerEntries != 0 { session.recordError("pending-manager-entry-not-settled") }
215
+ session.proveNoTransport()
216
+ }
217
+ } else if request.operation == .release, session.releaseNonce != request.requestNonce {
218
+ throw failure("original-release-nonce-required")
219
+ }
220
+ }
221
+ var producers = NimoResourceProducers()
222
+ producers.postedCallbacks = pendingOriginalManagerEntries
223
+ if let owner { producers = await owner.resourceProducers() }
224
+ originalClosed = session.settled(producers: producers)
225
+ let manager = DeviceManager.shared.sgc
226
+ let managerId = (manager as? Nimo)?.resourceInstanceId
227
+ let current: [String: Any] = ["type": nullable(manager?.type), "instanceId": nullable(managerId)]
228
+ var result: [String: Any] = ["version": 1, "protocol": "nimo-macos-resource-v1", "operation": request.operation.rawValue,
229
+ "requestNonce": request.requestNonce, "captureNonce": request.captureNonce,
230
+ "planSha256": request.planSha256, "grantSha256": request.grantSha256,
231
+ "process": ["pid": process.pid, "instanceId": process.instanceId],
232
+ "clock": ["kind": "darwin-continuous-time-v1", "receivedElapsedMs": received, "observedElapsedMs": NimoResourceClock.now()],
233
+ "capture": ["armedElapsedMs": session.armedElapsedMs, "admittedElapsedMs": nullable(session.admittedElapsedMs), "cancelled": session.cancelled,
234
+ "expectedPair": request.expectedPair.dictionary],
235
+ "currentManager": current, "session": nullable(session.sessionDictionary()),
236
+ "release": session.releaseDictionary(producers: producers), "producers": producers.dictionary, "errors": session.errors]
237
+ if let discovery { result["discovery"] = discovery.ledger.dictionary(callbacksPending: discovery.callbacksPending) }
238
+ return result
239
+ }
240
+
241
+ private func discoveryOnlyReceipt(_ request: NimoResourceRequest, received: UInt64) -> [String: Any] {
242
+ let settled = discovery?.ledger.settledElapsedMs != nil && discovery?.callbacksPending == 0
243
+ let manager = DeviceManager.shared.sgc
244
+ let managerSafe = manager == nil || originalClosed && !(manager?.type.contains("NIMO") ?? false)
245
+ let closed = discoveryReleaseNonce != nil && settled && pendingOriginalManagerEntries == 0 && managerSafe
246
+ originalClosed = closed
247
+ var producers = NimoResourceProducers()
248
+ producers.postedCallbacks = pendingOriginalManagerEntries + (discovery?.callbacksPending ?? 0)
249
+ producers.timers = settled ? 0 : 1
250
+ let release: [String: Any] = ["nonce": nullable(discoveryReleaseNonce), "startedElapsedMs": nullable(discoveryReleaseStartedElapsedMs),
251
+ "micStopRequired": false, "displayExitRequired": false, "micStopStartedElapsedMs": NSNull(), "micStopWrittenElapsedMs": NSNull(), "micStopMarkerElapsedMs": NSNull(),
252
+ "exitStatusCode": NSNull(), "exitAckElapsedMs": NSNull(), "disconnectRequestedElapsedMs": NSNull(), "disconnectCallbackElapsedMs": NSNull(), "disconnectError": NSNull(),
253
+ "connectionFailedElapsedMs": NSNull(), "noTransport": closed, "settled": closed]
254
+ return ["version": 1, "protocol": "nimo-macos-resource-v1", "operation": request.operation.rawValue,
255
+ "requestNonce": request.requestNonce, "captureNonce": request.captureNonce, "planSha256": request.planSha256, "grantSha256": request.grantSha256,
256
+ "process": ["pid": process.pid, "instanceId": process.instanceId],
257
+ "clock": ["kind": "darwin-continuous-time-v1", "receivedElapsedMs": received, "observedElapsedMs": NimoResourceClock.now()],
258
+ "capture": ["armedElapsedMs": armedElapsedMs!, "admittedElapsedMs": NSNull(), "cancelled": discoveryCancelled, "expectedPair": request.expectedPair.dictionary],
259
+ "currentManager": ["type": nullable(DeviceManager.shared.sgc?.type), "instanceId": nullable((DeviceManager.shared.sgc as? Nimo)?.resourceInstanceId)],
260
+ "session": NSNull(), "release": release, "producers": producers.dictionary, "errors": [],
261
+ "discovery": discovery!.ledger.dictionary(callbacksPending: discovery!.callbacksPending)]
262
+ }
263
+
264
+ #if SWIFT_PACKAGE
265
+ /// Test-only original discovery seed; no CoreBluetooth object is created.
266
+ /// Tests still execute the real perform/status/cancel/arm authority branches.
267
+ func packageSeedDiscovery(name: String, capture: String, discoveryOperation: String, candidates: [(String, String?)], persisted: Bool) {
268
+ packageEnabled = true
269
+ acceptedPair = NimoResourceSelection(name: name, peripheralSelection: NimoResourceSelection.connectedService)
270
+ captureNonce = capture; armedElapsedMs = NimoResourceClock.now()
271
+ let observer = NimoResourceConnectedDiscovery(name: name, startedElapsedMs: armedElapsedMs!)
272
+ discovery = observer; discoveryRequestNonce = discoveryOperation
273
+ for (uuid, candidateName) in candidates { observer.ledger.observe(uuid: uuid, name: candidateName) }
274
+ observer.ledger.finish(at: NimoResourceClock.now(), callbacksPending: 0)
275
+ if persisted { completed[discoveryOperation] = (Data(), Data()) }
276
+ }
277
+ func packagePerform(_ bytes: Data) async throws -> [String: Any] {
278
+ try await perform(try decode(bytes), received: NimoResourceClock.now())
279
+ }
280
+ #endif
281
+
282
+ private func decode(_ bytes: Data) throws -> NimoResourceRequest { try NimoResourceRequest.decode(bytes) }
283
+ private func failure(_ code: String) -> Error { NSError(domain: "NimoResourceFixture", code: 1, userInfo: [NSLocalizedDescriptionKey: code]) }
284
+ }
285
+
286
+
287
+ /// Retrieval only: no scan, connection, peripheral delegate, service discovery or
288
+ /// writes. The serial state-delegate queue drains before candidate commitment.
289
+ @MainActor
290
+ final class NimoResourceConnectedDiscovery: NSObject, CBCentralManagerDelegate {
291
+ let ledger: NimoResourceDiscovery
292
+ private let queue = DispatchQueue(label: "com.mentra.nimo.resource.connected-discovery")
293
+ private var central: CBCentralManager?
294
+ private var stopped = false
295
+ private var running = false
296
+ private var now: () -> UInt64 = NimoResourceClock.now
297
+ private var pause: () async throws -> Void = { try await Task.sleep(nanoseconds: 50_000_000) }
298
+ #if SWIFT_PACKAGE
299
+ private var packageSample: (() -> (CBManagerState, [(String, String?)]))?
300
+ func packageConfigureObserver(now: @escaping () -> UInt64, pause: @escaping () async throws -> Void,
301
+ sample: @escaping () -> (CBManagerState, [(String, String?)])) {
302
+ precondition(!running && central == nil)
303
+ self.now = now; self.pause = pause; packageSample = sample
304
+ }
305
+ #endif
306
+ nonisolated private let callbacks = NimoResourceCallbackCounter()
307
+ var callbacksPending: Int { callbacks.value }
308
+ init(name: String, startedElapsedMs: UInt64) {
309
+ ledger = NimoResourceDiscovery(name: name, startedElapsedMs: startedElapsedMs); super.init()
310
+ }
311
+ nonisolated func centralManagerDidUpdateState(_ central: CBCentralManager) {
312
+ callbacks.add(1)
313
+ defer { callbacks.add(-1) }
314
+ // No deferred work. State is sampled by the owned bounded Main loop.
315
+ }
316
+ @inline(never)
317
+ private func retrieveConnected(_ central: CBCentralManager) -> [CBPeripheral] {
318
+ central.retrieveConnectedPeripherals(withServices: [CBUUID(string: "00007033-0000-1000-8000-00805F9B34FB")])
319
+ }
320
+ @inline(never)
321
+ func discoverConnected() async {
322
+ guard !running, !stopped else { return }
323
+ running = true
324
+ #if SWIFT_PACKAGE
325
+ if packageSample == nil {
326
+ central = CBCentralManager(delegate: self, queue: queue, options: [CBCentralManagerOptionShowPowerAlertKey: false])
327
+ }
328
+ #else
329
+ central = CBCentralManager(delegate: self, queue: queue, options: [CBCentralManagerOptionShowPowerAlertKey: false])
330
+ #endif
331
+ let deadline = ledger.startedElapsedMs + 5_000
332
+ while !stopped {
333
+ // A post-sleep final powered-on retrieval replaces earlier facts.
334
+ let finalSample = now() >= deadline
335
+ var state = central?.state ?? .unknown
336
+ #if SWIFT_PACKAGE
337
+ let simulated = packageSample?()
338
+ if let simulated { state = simulated.0 }
339
+ #endif
340
+ switch state {
341
+ case .poweredOn:
342
+ #if SWIFT_PACKAGE
343
+ if let simulated { ledger.observeConnectedSnapshot(simulated.1) }
344
+ else if let central { ledger.observeConnectedSnapshot(retrieveConnected(central).map { ($0.identifier.uuidString.lowercased(), $0.name) }) }
345
+ else { ledger.observeConnectedSnapshot([]) }
346
+ #else
347
+ if let central { ledger.observeConnectedSnapshot(retrieveConnected(central).map { ($0.identifier.uuidString.lowercased(), $0.name) }) }
348
+ else { ledger.observeConnectedSnapshot([]) }
349
+ #endif
350
+ case .unsupported, .unauthorized:
351
+ ledger.observeConnectedSnapshot([])
352
+ ledger.recordError("connected-service-observer-unavailable"); finishDiscovery(); return
353
+ default:
354
+ // Radio loss invalidates the current snapshot, not history.
355
+ ledger.observeConnectedSnapshot([])
356
+ }
357
+ if finalSample { break }
358
+ do { try await pause() }
359
+ catch { ledger.recordError("discovery-task-cancelled"); ledger.cancel(); finishDiscovery(); return }
360
+ }
361
+ finishDiscovery()
362
+ }
363
+ @inline(never)
364
+ func stopDiscovery(cancel: Bool) async {
365
+ if cancel { ledger.cancel() }
366
+ stopped = true
367
+ let deadline = NimoResourceClock.now() + 1_000
368
+ while running, NimoResourceClock.now() < deadline {
369
+ do { try await Task.sleep(nanoseconds: 1_000_000) } catch { await Task.yield() }
370
+ }
371
+ guard !running else { ledger.recordError("discovery-loop-not-settled"); return }
372
+ finishDiscovery()
373
+ }
374
+ @inline(never)
375
+ private func finishDiscovery() {
376
+ stopped = true
377
+ central?.delegate = nil
378
+ // Callback contains no Main work; the queue barrier cannot deadlock.
379
+ queue.sync {}
380
+ central = nil
381
+ ledger.finish(at: now(), callbacksPending: callbacksPending)
382
+ running = false
383
+ }
384
+ }