@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.
- package/android/src/main/java/com/mentra/bluetoothsdk/GeneratedReleaseMetadata.kt +5 -5
- package/build/_private/BluetoothSdkModule.d.ts +3 -0
- package/build/_private/BluetoothSdkModule.d.ts.map +1 -1
- package/build/_private/BluetoothSdkModule.js.map +1 -1
- package/build/generated/releaseMetadata.js +5 -5
- package/build/generated/releaseMetadata.js.map +1 -1
- package/ios/BluetoothSdkModule.swift +6 -0
- package/ios/Source/BluetoothSdkDefaults.swift +1 -1
- package/ios/Source/DeviceManager.swift +10 -1
- package/ios/Source/GeneratedReleaseMetadata.swift +5 -5
- package/ios/Source/MentraBluetoothSDK.swift +15 -2
- package/ios/Source/sgcs/Nimo.swift +278 -38
- package/ios/Source/sgcs/NimoCanvasCoordinator.swift +77 -18
- package/ios/Source/sgcs/NimoCanvasSession.swift +1 -0
- package/ios/Source/sgcs/NimoMacResourceTransport.swift +217 -0
- package/ios/Source/sgcs/NimoResourceFixture.swift +384 -0
- package/ios/Source/sgcs/NimoResourceSession.swift +406 -0
- package/ios/Tests/DeviceManagerReconnectTests.swift +75 -2
- package/ios/Tests/NimoCanvasTransportTests.swift +147 -0
- package/ios/Tests/NimoResourceSessionTests.swift +476 -0
- package/package.json +2 -2
- package/src/_private/BluetoothSdkModule.ts +3 -0
- package/src/generated/releaseMetadata.ts +5 -5
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Darwin
|
|
3
|
+
|
|
4
|
+
/// All mutation is serialized on Main by the native fixture/SDK boundary.
|
|
5
|
+
/// Epoch clocks are deliberately absent: host freshness has a separate authority.
|
|
6
|
+
enum NimoResourceClock {
|
|
7
|
+
static func now() -> UInt64 {
|
|
8
|
+
var info = mach_timebase_info_data_t()
|
|
9
|
+
mach_timebase_info(&info)
|
|
10
|
+
let ticks = mach_continuous_time()
|
|
11
|
+
let divisor = UInt64(info.denom) * 1_000_000
|
|
12
|
+
return (ticks / divisor) * UInt64(info.numer) + (ticks % divisor) * UInt64(info.numer) / divisor
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// Shared product state belongs to the current manager, independently of the
|
|
17
|
+
/// retained original object's cleanup and closure evidence.
|
|
18
|
+
enum NimoResourceManagerState {
|
|
19
|
+
static func allowsReplacement(fixtureEnabled: Bool, originalClosed: Bool) -> Bool {
|
|
20
|
+
!fixtureEnabled || originalClosed
|
|
21
|
+
}
|
|
22
|
+
static func publish(owner: AnyObject, current: AnyObject?, _ update: () -> Void) {
|
|
23
|
+
guard current === owner else { return }
|
|
24
|
+
update()
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/// Queue tickets freeze dispatch scope before DeviceManager's asynchronous work.
|
|
29
|
+
/// All entries fence initial arm; only original NIMO entries belong to its drain.
|
|
30
|
+
final class NimoResourceManagerEntries {
|
|
31
|
+
final class Ticket {
|
|
32
|
+
fileprivate weak var owner: NimoResourceManagerEntries?
|
|
33
|
+
fileprivate let nimo: Bool
|
|
34
|
+
fileprivate var ended = false
|
|
35
|
+
fileprivate init(owner: NimoResourceManagerEntries, nimo: Bool) { self.owner = owner; self.nimo = nimo }
|
|
36
|
+
}
|
|
37
|
+
private(set) var all = 0
|
|
38
|
+
private(set) var originalNimo = 0
|
|
39
|
+
func begin(_ wearable: String) -> Ticket {
|
|
40
|
+
let nimo = wearable.contains("NIMO")
|
|
41
|
+
all += 1
|
|
42
|
+
if nimo { originalNimo += 1 }
|
|
43
|
+
return Ticket(owner: self, nimo: nimo)
|
|
44
|
+
}
|
|
45
|
+
func end(_ ticket: Ticket) {
|
|
46
|
+
guard ticket.owner === self, !ticket.ended else { return }
|
|
47
|
+
ticket.ended = true; all -= 1
|
|
48
|
+
if ticket.nimo { originalNimo -= 1 }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
struct NimoResourceOriginal: Codable, Equatable {
|
|
53
|
+
let instanceId: String
|
|
54
|
+
let peripheralUuid: String
|
|
55
|
+
let sessionGeneration: UInt64
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
struct NimoResourceProcess: Codable, Equatable {
|
|
59
|
+
let pid: Int32
|
|
60
|
+
let instanceId: String
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
struct NimoResourcePair: Codable, Equatable {
|
|
64
|
+
let name: String
|
|
65
|
+
let peripheralUuid: String
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Accepted selection remains immutable even after connected-service discovery.
|
|
69
|
+
struct NimoResourceSelection: Codable, Equatable {
|
|
70
|
+
static let connectedService = "connected-service-exact-name-v1"
|
|
71
|
+
let name: String
|
|
72
|
+
let peripheralUuid: String?
|
|
73
|
+
let peripheralSelection: String?
|
|
74
|
+
init(name: String, peripheralUuid: String? = nil, peripheralSelection: String? = nil) { self.name = name; self.peripheralUuid = peripheralUuid; self.peripheralSelection = peripheralSelection }
|
|
75
|
+
init(pair: NimoResourcePair) { name = pair.name; peripheralUuid = pair.peripheralUuid; peripheralSelection = nil }
|
|
76
|
+
var dictionary: [String: Any] {
|
|
77
|
+
if let peripheralUuid { return ["name": name, "peripheralUuid": peripheralUuid] }
|
|
78
|
+
return ["name": name, "peripheralSelection": peripheralSelection ?? ""]
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/// Pure candidate ledger. Selection is committed only once, after the bounded
|
|
83
|
+
/// observer has stopped; a later callback cannot replace its physical identity.
|
|
84
|
+
final class NimoResourceDiscovery {
|
|
85
|
+
static let serviceUuid = "00007033-0000-1000-8000-00805f9b34fb"
|
|
86
|
+
let name: String
|
|
87
|
+
let startedElapsedMs: UInt64
|
|
88
|
+
private(set) var settledElapsedMs: UInt64?
|
|
89
|
+
private(set) var selectedPeripheralUuid: String?
|
|
90
|
+
private(set) var candidates: [String: String] = [:]
|
|
91
|
+
private var currentlyConnected: Set<String> = []
|
|
92
|
+
private(set) var errors: [String] = []
|
|
93
|
+
private(set) var cancelled = false
|
|
94
|
+
init(name: String, startedElapsedMs: UInt64) { self.name = name; self.startedElapsedMs = startedElapsedMs }
|
|
95
|
+
func observe(uuid: String, name: String?) {
|
|
96
|
+
guard settledElapsedMs == nil, !cancelled, name == self.name else { return }
|
|
97
|
+
guard UUID(uuidString: uuid)?.uuidString.lowercased() == uuid else { recordError("invalid-discovery-uuid"); return }
|
|
98
|
+
if candidates.count >= 16, candidates[uuid] == nil { recordError("discovery-candidate-budget"); return }
|
|
99
|
+
candidates[uuid] = name
|
|
100
|
+
currentlyConnected.insert(uuid)
|
|
101
|
+
if candidates.count > 1 { recordError("ambiguous-connected-service-peer") }
|
|
102
|
+
}
|
|
103
|
+
func observeConnectedSnapshot(_ peers: [(String, String?)]) {
|
|
104
|
+
guard settledElapsedMs == nil, !cancelled else { return }
|
|
105
|
+
currentlyConnected.removeAll()
|
|
106
|
+
for (uuid, name) in peers { observe(uuid: uuid, name: name) }
|
|
107
|
+
}
|
|
108
|
+
func recordError(_ error: String) { guard settledElapsedMs == nil else { return }; if errors.count < 16, !errors.contains(error) { errors.append(error) } }
|
|
109
|
+
func cancel() { guard settledElapsedMs == nil else { return }; cancelled = true }
|
|
110
|
+
func finish(at: UInt64, callbacksPending: Int) {
|
|
111
|
+
guard settledElapsedMs == nil, callbacksPending == 0, at >= startedElapsedMs else { return }
|
|
112
|
+
if !cancelled, errors.isEmpty, candidates.count == 1, currentlyConnected.count == 1 { selectedPeripheralUuid = candidates.keys.first }
|
|
113
|
+
else if !cancelled, currentlyConnected.isEmpty { recordError("no-connected-service-peer") }
|
|
114
|
+
settledElapsedMs = at
|
|
115
|
+
}
|
|
116
|
+
func dictionary(callbacksPending: Int) -> [String: Any] {
|
|
117
|
+
["kind": NimoResourceSelection.connectedService, "selectedPeripheralUuid": nullable(selectedPeripheralUuid),
|
|
118
|
+
"candidates": candidates.keys.sorted().map { ["peripheralUuid": $0, "name": candidates[$0]!, "source": "connected-service", "serviceUuid": Self.serviceUuid] },
|
|
119
|
+
"startedElapsedMs": startedElapsedMs, "settledElapsedMs": nullable(settledElapsedMs),
|
|
120
|
+
"settled": settledElapsedMs != nil && callbacksPending == 0, "callbacksPending": callbacksPending, "errors": errors]
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
struct NimoResourceRequest: Codable, Equatable {
|
|
125
|
+
enum Operation: String, Codable { case discover, arm, status, release, cancel }
|
|
126
|
+
let version: Int
|
|
127
|
+
let operation: Operation
|
|
128
|
+
let requestNonce: String
|
|
129
|
+
let captureNonce: String
|
|
130
|
+
let planSha256: String
|
|
131
|
+
let grantSha256: String
|
|
132
|
+
let expectedProcess: NimoResourceProcess
|
|
133
|
+
let expectedPair: NimoResourceSelection
|
|
134
|
+
let original: NimoResourceOriginal?
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
struct NimoResourceProducers {
|
|
138
|
+
var writerActive = 0, writerQueued = 0, encoderWorker = 0, encoderDelivery = 0
|
|
139
|
+
var postedCallbacks = 0, canvasCommands = 0, pendingAcks = 0, audioWorkers = 0
|
|
140
|
+
var scan = 0, timers = 0, reconnectTask = 0
|
|
141
|
+
var dictionary: [String: Int] {
|
|
142
|
+
["writerActive": writerActive, "writerQueued": writerQueued, "encoderWorker": encoderWorker,
|
|
143
|
+
"encoderDelivery": encoderDelivery, "postedCallbacks": postedCallbacks, "canvasCommands": canvasCommands,
|
|
144
|
+
"pendingAcks": pendingAcks, "audioWorkers": audioWorkers, "scan": scan, "timers": timers, "reconnectTask": reconnectTask]
|
|
145
|
+
}
|
|
146
|
+
var settled: Bool { dictionary.values.allSatisfy { $0 == 0 } }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/// Retains the ORIGINAL object identities even after cancellation or timeout.
|
|
150
|
+
/// A later object, generation, peripheral or release nonce cannot be adopted.
|
|
151
|
+
final class NimoResourceSession {
|
|
152
|
+
let captureNonce: String
|
|
153
|
+
let pair: NimoResourcePair
|
|
154
|
+
let armedElapsedMs: UInt64
|
|
155
|
+
private(set) var admittedElapsedMs: UInt64?
|
|
156
|
+
private(set) var instanceId: String?
|
|
157
|
+
private(set) var original: NimoResourceOriginal?
|
|
158
|
+
private(set) var cancelled = false
|
|
159
|
+
private(set) var connected = false
|
|
160
|
+
private var everConnected = false
|
|
161
|
+
private(set) var connectionFailedElapsedMs: UInt64?
|
|
162
|
+
private(set) var startedElapsedMs: UInt64?
|
|
163
|
+
private(set) var twsConnected = false
|
|
164
|
+
private(set) var twsObservedElapsedMs: UInt64?
|
|
165
|
+
private(set) var handshakeTwsElapsedMs: UInt64?
|
|
166
|
+
private(set) var timeCommandId: String?
|
|
167
|
+
private(set) var timeWriteStartedElapsedMs: UInt64?
|
|
168
|
+
private(set) var timeWriteCompletedElapsedMs: UInt64?
|
|
169
|
+
private(set) var timeAckElapsedMs: UInt64?
|
|
170
|
+
private(set) var timeAckStatusCode: Int?
|
|
171
|
+
private(set) var releaseNonce: String?
|
|
172
|
+
private(set) var releaseStartedElapsedMs: UInt64?
|
|
173
|
+
private(set) var micStopRequired = false
|
|
174
|
+
private(set) var displayExitRequired = false
|
|
175
|
+
private(set) var micStopStartedElapsedMs: UInt64?
|
|
176
|
+
private(set) var micStopWrittenElapsedMs: UInt64?
|
|
177
|
+
private(set) var micStopMarkerElapsedMs: UInt64?
|
|
178
|
+
private(set) var exitStatusCode: Int?
|
|
179
|
+
private(set) var exitAckElapsedMs: UInt64?
|
|
180
|
+
private(set) var disconnectRequestedElapsedMs: UInt64?
|
|
181
|
+
private(set) var disconnectCallbackElapsedMs: UInt64?
|
|
182
|
+
private(set) var disconnectError: String?
|
|
183
|
+
private(set) var noTransport = false
|
|
184
|
+
private(set) var errors: [String] = []
|
|
185
|
+
private var owner: AnyObject?
|
|
186
|
+
private var peripheral: AnyObject?
|
|
187
|
+
private let now: () -> UInt64
|
|
188
|
+
|
|
189
|
+
init(captureNonce: String, pair: NimoResourcePair, now: @escaping () -> UInt64 = NimoResourceClock.now, armedElapsedMs: UInt64? = nil) {
|
|
190
|
+
self.captureNonce = captureNonce; self.pair = pair; self.now = now
|
|
191
|
+
self.armedElapsedMs = armedElapsedMs ?? now()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
func isOwner(_ candidate: AnyObject) -> Bool { owner === candidate }
|
|
195
|
+
var hasOwner: Bool { owner != nil }
|
|
196
|
+
func recordError(_ value: String) { let bounded = String(value.prefix(160)); if errors.count < 16, !errors.contains(bounded) { errors.append(bounded) } }
|
|
197
|
+
|
|
198
|
+
/// Ordinary manager disposal may follow an already accepted original close.
|
|
199
|
+
/// It must not rewrite that physical history or touch a replacement device.
|
|
200
|
+
/// An active or contradicted capture retains the original diagnostic error.
|
|
201
|
+
func shouldRunOrdinaryDisconnect(_ candidate: AnyObject, originalClosed: Bool) -> Bool {
|
|
202
|
+
guard isOwner(candidate) else { return false }
|
|
203
|
+
if originalClosed && settled(producers: NimoResourceProducers()) { return false }
|
|
204
|
+
recordError("ordinary-disconnect-during-capture")
|
|
205
|
+
if releaseNonce != nil { disconnectRequested(candidate) }
|
|
206
|
+
return true
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/// Called before scanning/connecting, not from a status response.
|
|
210
|
+
func admit(_ candidate: AnyObject, instanceId: String, name: String?, persist: (UInt64) -> Bool = { _ in true }) -> Bool {
|
|
211
|
+
guard !cancelled, releaseNonce == nil, original == nil, name == nil || name == pair.name,
|
|
212
|
+
owner == nil || owner === candidate else { return false }
|
|
213
|
+
if owner == nil {
|
|
214
|
+
let proposed = now()
|
|
215
|
+
owner = candidate; self.instanceId = instanceId
|
|
216
|
+
guard persist(proposed) else { cancelled = true; recordError("admission-persist-failed"); return false }
|
|
217
|
+
admittedElapsedMs = proposed
|
|
218
|
+
}
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func allowsEntry(_ candidate: AnyObject) -> Bool {
|
|
223
|
+
owner === candidate && !cancelled && releaseNonce == nil && original == nil
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// The plan pins a CoreBluetooth UUID; it is never constructed from a MAC.
|
|
227
|
+
func beginTransport(_ candidate: AnyObject, peripheral: AnyObject, uuid: String, persist: (UInt64) -> Bool = { _ in true }) -> Bool {
|
|
228
|
+
guard allowsEntry(candidate), uuid.lowercased() == pair.peripheralUuid.lowercased(), let instanceId else { return false }
|
|
229
|
+
let proposed = now()
|
|
230
|
+
guard persist(proposed) else { cancelled = true; recordError("transport-persist-failed"); return false }
|
|
231
|
+
self.peripheral = peripheral
|
|
232
|
+
original = NimoResourceOriginal(instanceId: instanceId, peripheralUuid: uuid.lowercased(), sessionGeneration: 1)
|
|
233
|
+
startedElapsedMs = proposed
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func matches(_ candidate: AnyObject, _ peer: AnyObject) -> Bool { owner === candidate && peripheral === peer }
|
|
238
|
+
func mayReceive(_ candidate: AnyObject, _ peer: AnyObject) -> Bool { matches(candidate, peer) && disconnectCallbackElapsedMs == nil && connectionFailedElapsedMs == nil }
|
|
239
|
+
func transportConnected(_ candidate: AnyObject, _ peer: AnyObject) {
|
|
240
|
+
guard matches(candidate, peer) else { return }
|
|
241
|
+
everConnected = true; connected = true
|
|
242
|
+
if connectionFailedElapsedMs != nil { recordError("connect-after-failed-attempt"); return }
|
|
243
|
+
}
|
|
244
|
+
/// The original failed-attempt callback proves this never-connected transport ended.
|
|
245
|
+
func connectionFailed(_ candidate: AnyObject, _ peer: AnyObject, receivedElapsedMs: UInt64) {
|
|
246
|
+
guard matches(candidate, peer), !everConnected, let startedElapsedMs, receivedElapsedMs >= startedElapsedMs else { return }
|
|
247
|
+
if connectionFailedElapsedMs == nil { connectionFailedElapsedMs = receivedElapsedMs }
|
|
248
|
+
connected = false
|
|
249
|
+
}
|
|
250
|
+
func observeTws(_ candidate: AnyObject, connected: Bool, receivedElapsedMs: UInt64? = nil) {
|
|
251
|
+
guard owner === candidate, releaseNonce == nil, self.connected else { return }
|
|
252
|
+
let received = receivedElapsedMs ?? now()
|
|
253
|
+
guard let startedElapsedMs, received >= startedElapsedMs else { return }
|
|
254
|
+
twsConnected = connected; twsObservedElapsedMs = received
|
|
255
|
+
}
|
|
256
|
+
/// A setup timeout after the release fence is an abandoned handshake, not
|
|
257
|
+
/// a new failure or authority to disconnect/retry the original transport.
|
|
258
|
+
func recordHandshakeFailure(_ candidate: AnyObject) -> Bool {
|
|
259
|
+
guard owner === candidate, releaseNonce == nil else { return false }
|
|
260
|
+
recordError("handshake-failed")
|
|
261
|
+
return true
|
|
262
|
+
}
|
|
263
|
+
func beginTimeCommand(_ candidate: AnyObject) -> String? {
|
|
264
|
+
guard owner === candidate, releaseNonce == nil, connected, twsConnected, let twsObservedElapsedMs else { return nil }
|
|
265
|
+
if handshakeTwsElapsedMs == nil { handshakeTwsElapsedMs = twsObservedElapsedMs }
|
|
266
|
+
let id = UUID().uuidString.lowercased(); timeCommandId = id
|
|
267
|
+
timeWriteStartedElapsedMs = nil; timeWriteCompletedElapsedMs = nil; timeAckElapsedMs = nil; timeAckStatusCode = nil
|
|
268
|
+
return id
|
|
269
|
+
}
|
|
270
|
+
func timeWriteStarted(_ candidate: AnyObject, command: String) {
|
|
271
|
+
guard owner === candidate, timeCommandId == command, releaseNonce == nil, timeWriteStartedElapsedMs == nil else { return }
|
|
272
|
+
timeWriteStartedElapsedMs = now()
|
|
273
|
+
}
|
|
274
|
+
func timeWriteCompleted(_ candidate: AnyObject, command: String) {
|
|
275
|
+
guard owner === candidate, timeCommandId == command, releaseNonce == nil, timeWriteStartedElapsedMs != nil, timeWriteCompletedElapsedMs == nil else { return }
|
|
276
|
+
timeWriteCompletedElapsedMs = now()
|
|
277
|
+
}
|
|
278
|
+
func timeAck(_ candidate: AnyObject, command: String, status: Int, receivedElapsedMs: UInt64? = nil) {
|
|
279
|
+
guard owner === candidate, timeCommandId == command, releaseNonce == nil, timeWriteStartedElapsedMs != nil, timeAckElapsedMs == nil else { return }
|
|
280
|
+
let received = receivedElapsedMs ?? now()
|
|
281
|
+
guard let started = timeWriteStartedElapsedMs, received >= started else { return }
|
|
282
|
+
timeAckElapsedMs = received; timeAckStatusCode = status
|
|
283
|
+
}
|
|
284
|
+
var ready: Bool {
|
|
285
|
+
guard !cancelled, releaseNonce == nil, connected, twsConnected,
|
|
286
|
+
let start = startedElapsedMs, let tws = handshakeTwsElapsedMs,
|
|
287
|
+
let written = timeWriteStartedElapsedMs, let completed = timeWriteCompletedElapsedMs,
|
|
288
|
+
let ack = timeAckElapsedMs, timeAckStatusCode == 0 else { return false }
|
|
289
|
+
return start <= tws && tws <= written && written <= completed && written <= ack
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Preserve the genuine release-admission boundary for delivered compiler proof.
|
|
293
|
+
@inline(never)
|
|
294
|
+
func beginRelease(nonce: String, cancel: Bool, mic: Bool, canvas: Bool) -> Bool {
|
|
295
|
+
if let releaseNonce { return releaseNonce == nonce }
|
|
296
|
+
releaseNonce = nonce; releaseStartedElapsedMs = now(); cancelled = true
|
|
297
|
+
micStopRequired = mic; displayExitRequired = canvas
|
|
298
|
+
if cancel { cancelled = true }
|
|
299
|
+
return true
|
|
300
|
+
}
|
|
301
|
+
func micStopStarted(_ candidate: AnyObject) {
|
|
302
|
+
guard owner === candidate, releaseNonce != nil, micStopRequired else { return }; micStopStartedElapsedMs = now()
|
|
303
|
+
}
|
|
304
|
+
func micStopWritten(_ candidate: AnyObject) {
|
|
305
|
+
guard owner === candidate, micStopStartedElapsedMs != nil, micStopWrittenElapsedMs == nil else { return }; micStopWrittenElapsedMs = now()
|
|
306
|
+
}
|
|
307
|
+
func micStopMarker(_ candidate: AnyObject, receivedElapsedMs: UInt64? = nil) {
|
|
308
|
+
guard owner === candidate, let started = micStopStartedElapsedMs, micStopMarkerElapsedMs == nil else { return }
|
|
309
|
+
let received = receivedElapsedMs ?? now()
|
|
310
|
+
guard received >= started else { return }; micStopMarkerElapsedMs = received
|
|
311
|
+
}
|
|
312
|
+
func exitAcknowledged(_ candidate: AnyObject, status: Int, receivedElapsedMs: UInt64? = nil) {
|
|
313
|
+
guard owner === candidate, releaseNonce != nil, displayExitRequired else { return }
|
|
314
|
+
let received = receivedElapsedMs ?? now()
|
|
315
|
+
guard let started = releaseStartedElapsedMs, received >= started else { return }
|
|
316
|
+
exitStatusCode = status; exitAckElapsedMs = received
|
|
317
|
+
}
|
|
318
|
+
var commandsSettled: Bool {
|
|
319
|
+
(!micStopRequired || micStopStartedElapsedMs != nil && micStopWrittenElapsedMs != nil && micStopMarkerElapsedMs != nil) &&
|
|
320
|
+
(!displayExitRequired || exitStatusCode == 0 && exitAckElapsedMs != nil)
|
|
321
|
+
}
|
|
322
|
+
func disconnectRequested(_ candidate: AnyObject) {
|
|
323
|
+
guard owner === candidate, releaseNonce != nil, disconnectRequestedElapsedMs == nil else { return }
|
|
324
|
+
disconnectRequestedElapsedMs = now()
|
|
325
|
+
}
|
|
326
|
+
/// A nil app reference or cancelPeripheralConnection return is NOT closure.
|
|
327
|
+
func disconnected(_ candidate: AnyObject, _ peer: AnyObject, error: String?, receivedElapsedMs: UInt64? = nil) {
|
|
328
|
+
guard matches(candidate, peer) else { return }
|
|
329
|
+
connected = false
|
|
330
|
+
guard disconnectRequestedElapsedMs != nil else { recordError("unexpected-disconnect"); return }
|
|
331
|
+
let received = receivedElapsedMs ?? now()
|
|
332
|
+
guard let requested = disconnectRequestedElapsedMs, received >= requested else { recordError("pre-request-disconnect"); return }
|
|
333
|
+
if disconnectCallbackElapsedMs == nil { disconnectCallbackElapsedMs = received; disconnectError = error }
|
|
334
|
+
}
|
|
335
|
+
func proveNoTransport() {
|
|
336
|
+
guard releaseNonce != nil, original == nil, peripheral == nil else { return }; noTransport = true
|
|
337
|
+
}
|
|
338
|
+
func settled(producers: NimoResourceProducers) -> Bool {
|
|
339
|
+
releaseNonce != nil && commandsSettled && (noTransport || connectionFailedElapsedMs != nil || disconnectCallbackElapsedMs != nil && disconnectError == nil) && producers.settled && errors.isEmpty
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
func sessionDictionary() -> [String: Any]? {
|
|
343
|
+
guard let instanceId, admittedElapsedMs != nil else { return nil }
|
|
344
|
+
let original = original ?? NimoResourceOriginal(instanceId: instanceId, peripheralUuid: pair.peripheralUuid.lowercased(), sessionGeneration: 0)
|
|
345
|
+
return ["instanceId": original.instanceId, "peripheralUuid": original.peripheralUuid,
|
|
346
|
+
"sessionGeneration": original.sessionGeneration, "startedElapsedMs": nullable(startedElapsedMs),
|
|
347
|
+
"connected": connected, "initializationPending": !ready && releaseNonce == nil,
|
|
348
|
+
"twsConnected": twsConnected, "twsObservedElapsedMs": nullable(twsObservedElapsedMs),
|
|
349
|
+
"handshakeTwsElapsedMs": nullable(handshakeTwsElapsedMs), "timeCommandId": nullable(timeCommandId),
|
|
350
|
+
"timeWriteStartedElapsedMs": nullable(timeWriteStartedElapsedMs), "timeWriteCompletedElapsedMs": nullable(timeWriteCompletedElapsedMs),
|
|
351
|
+
"timeAckElapsedMs": nullable(timeAckElapsedMs), "timeAckStatusCode": nullable(timeAckStatusCode)]
|
|
352
|
+
}
|
|
353
|
+
func releaseDictionary(producers: NimoResourceProducers) -> [String: Any] {
|
|
354
|
+
["nonce": nullable(releaseNonce), "startedElapsedMs": nullable(releaseStartedElapsedMs),
|
|
355
|
+
"micStopRequired": micStopRequired, "displayExitRequired": displayExitRequired,
|
|
356
|
+
"micStopStartedElapsedMs": nullable(micStopStartedElapsedMs), "micStopWrittenElapsedMs": nullable(micStopWrittenElapsedMs),
|
|
357
|
+
"micStopMarkerElapsedMs": nullable(micStopMarkerElapsedMs), "exitStatusCode": nullable(exitStatusCode), "exitAckElapsedMs": nullable(exitAckElapsedMs),
|
|
358
|
+
"disconnectRequestedElapsedMs": nullable(disconnectRequestedElapsedMs), "disconnectCallbackElapsedMs": nullable(disconnectCallbackElapsedMs),
|
|
359
|
+
"disconnectError": nullable(disconnectError), "connectionFailedElapsedMs": nullable(connectionFailedElapsedMs), "noTransport": noTransport, "settled": settled(producers: producers)]
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
func nullable<T>(_ value: T?) -> Any { value.map { $0 as Any } ?? NSNull() }
|
|
364
|
+
|
|
365
|
+
/// Cross-queue roster; queued Main callbacks cannot disappear on logical reset.
|
|
366
|
+
final class NimoResourceCallbackCounter: @unchecked Sendable {
|
|
367
|
+
private let lock = NSLock()
|
|
368
|
+
private var count = 0
|
|
369
|
+
func add(_ amount: Int) { lock.lock(); count += amount; lock.unlock() }
|
|
370
|
+
var value: Int { lock.lock(); defer { lock.unlock() }; return count }
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
extension NimoResourceRequest {
|
|
374
|
+
static func decode(_ bytes: Data) throws -> NimoResourceRequest {
|
|
375
|
+
// JSONDecoder ignores unknown keys; reject them before typed decoding.
|
|
376
|
+
guard bytes.count <= 16_384, let value = try JSONSerialization.jsonObject(with: bytes) as? [String: Any],
|
|
377
|
+
Set(value.keys) == Set(["version","operation","requestNonce","captureNonce","planSha256","grantSha256","expectedProcess","expectedPair","original"]),
|
|
378
|
+
let process = value["expectedProcess"] as? [String: Any], Set(process.keys) == Set(["pid","instanceId"]),
|
|
379
|
+
let pair = value["expectedPair"] as? [String: Any],
|
|
380
|
+
Set(pair.keys) == Set(["name","peripheralUuid"]) || Set(pair.keys) == Set(["name","peripheralSelection"]) else { throw failure("invalid-request-schema") }
|
|
381
|
+
if let original = value["original"] as? [String: Any], Set(original.keys) != Set(["instanceId","peripheralUuid","sessionGeneration"]) { throw failure("invalid-original-schema") }
|
|
382
|
+
let result = try JSONDecoder().decode(NimoResourceRequest.self, from: bytes)
|
|
383
|
+
let ids = [result.requestNonce, result.captureNonce, result.expectedProcess.instanceId] + (result.expectedPair.peripheralUuid.map { [$0] } ?? []) +
|
|
384
|
+
(result.original.map { [$0.instanceId, $0.peripheralUuid] } ?? [])
|
|
385
|
+
for id in ids {
|
|
386
|
+
guard UUID(uuidString: id)?.uuidString.lowercased() == id else { throw failure("invalid-uuid") }
|
|
387
|
+
}
|
|
388
|
+
if result.operation == .discover {
|
|
389
|
+
guard result.expectedPair.peripheralSelection == NimoResourceSelection.connectedService,
|
|
390
|
+
result.requestNonce == result.captureNonce, result.original == nil else { throw failure("invalid-discovery-identity") }
|
|
391
|
+
}
|
|
392
|
+
if result.operation == .arm {
|
|
393
|
+
guard result.original == nil,
|
|
394
|
+
(result.expectedPair.peripheralUuid != nil ? result.requestNonce == result.captureNonce : result.requestNonce != result.captureNonce) else { throw failure("invalid-arm-identity") }
|
|
395
|
+
}
|
|
396
|
+
if let original = result.original {
|
|
397
|
+
guard original.sessionGeneration <= 1, (result.expectedPair.peripheralUuid == nil || original.peripheralUuid == result.expectedPair.peripheralUuid) else { throw failure("invalid-original-identity") }
|
|
398
|
+
}
|
|
399
|
+
guard (result.expectedPair.peripheralUuid != nil && result.expectedPair.peripheralSelection == nil || result.expectedPair.peripheralUuid == nil && result.expectedPair.peripheralSelection == NimoResourceSelection.connectedService),
|
|
400
|
+
result.expectedProcess.pid > 1, result.version == 1, !result.expectedPair.name.isEmpty, result.expectedPair.name.utf8.count <= 128,
|
|
401
|
+
result.planSha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil,
|
|
402
|
+
result.grantSha256.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { throw failure("invalid-request-identity") }
|
|
403
|
+
return result
|
|
404
|
+
}
|
|
405
|
+
private static func failure(_ code: String) -> Error { NSError(domain: "NimoResourceFixture", code: 1, userInfo: [NSLocalizedDescriptionKey: code]) }
|
|
406
|
+
}
|
|
@@ -5,6 +5,77 @@ import XCTest
|
|
|
5
5
|
final class DeviceManagerReconnectTests: XCTestCase {
|
|
6
6
|
private let requests = ["should_send_lc3", "should_send_pcm", "should_send_transcript", "local_stt_fallback_active"]
|
|
7
7
|
|
|
8
|
+
func testActualManagerHandoffCleanupDoesNotInvalidateClosedOriginalStatus() {
|
|
9
|
+
withRecordingDevice { manager, _ in
|
|
10
|
+
let original = manager.sgc as! ReconnectRecordingDevice
|
|
11
|
+
original.type = DeviceTypes.NIMO
|
|
12
|
+
let uuid = "00000000-0000-4000-8000-000000000001"
|
|
13
|
+
let model = NimoResourceSession(captureNonce: uuid, pair: NimoResourcePair(name: "Nimo-Test", peripheralUuid: uuid))
|
|
14
|
+
XCTAssertTrue(model.admit(original, instanceId: uuid, name: "Nimo-Test"))
|
|
15
|
+
XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false))
|
|
16
|
+
model.proveNoTransport()
|
|
17
|
+
let verifiedClosed = model.settled(producers: NimoResourceProducers())
|
|
18
|
+
var cleanupCalls = 0, disconnectEffects = 0
|
|
19
|
+
original.onCleanup = {
|
|
20
|
+
cleanupCalls += 1
|
|
21
|
+
// Same original-session guard used by actual Nimo.disconnect.
|
|
22
|
+
if model.shouldRunOrdinaryDisconnect(original, originalClosed: verifiedClosed) { disconnectEffects += 1 }
|
|
23
|
+
}
|
|
24
|
+
manager.initSGC(DeviceTypes.SIMULATED) // Actual manager replacement; no Bluetooth device.
|
|
25
|
+
XCTAssertEqual(cleanupCalls, 1)
|
|
26
|
+
XCTAssertEqual(disconnectEffects, 0)
|
|
27
|
+
XCTAssertTrue(model.errors.isEmpty)
|
|
28
|
+
XCTAssertTrue(model.settled(producers: NimoResourceProducers())) // Fresh status after cleanup.
|
|
29
|
+
XCTAssertTrue(NimoResourceManagerState.allowsReplacement(fixtureEnabled: true, originalClosed: model.settled(producers: NimoResourceProducers())))
|
|
30
|
+
XCTAssertEqual(manager.sgc?.type, DeviceTypes.SIMULATED)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func testActualQueuedReplacementDoesNotJoinOriginalNimoProducerRoster() {
|
|
35
|
+
withRecordingDevice { manager, store in
|
|
36
|
+
let original = manager.sgc as! ReconnectRecordingDevice
|
|
37
|
+
original.type = DeviceTypes.NIMO
|
|
38
|
+
let uuid = "00000000-0000-4000-8000-000000000001"
|
|
39
|
+
let model = NimoResourceSession(captureNonce: uuid, pair: NimoResourcePair(name: "Nimo-Test", peripheralUuid: uuid))
|
|
40
|
+
XCTAssertTrue(model.admit(original, instanceId: uuid, name: "Nimo-Test"))
|
|
41
|
+
XCTAssertTrue(model.beginRelease(nonce: uuid, cancel: true, mic: false, canvas: false)); model.proveNoTransport()
|
|
42
|
+
let stopped = expectation(description: "Original disposal inside real queued manager handoff")
|
|
43
|
+
original.onDisconnect = {
|
|
44
|
+
XCTAssertFalse(model.shouldRunOrdinaryDisconnect(original, originalClosed: true))
|
|
45
|
+
stopped.fulfill()
|
|
46
|
+
}
|
|
47
|
+
// Same asynchronous connectByName path as G2; simulated replacement
|
|
48
|
+
// avoids any Bluetooth transport in this source regression.
|
|
49
|
+
store.set("bluetooth", "pending_wearable", DeviceTypes.SIMULATED)
|
|
50
|
+
manager.connectByName("Simulated")
|
|
51
|
+
XCTAssertEqual(NimoResourceFixture.shared.pendingManagerEntries, 1)
|
|
52
|
+
XCTAssertEqual(NimoResourceFixture.shared.pendingOriginalManagerEntries, 0)
|
|
53
|
+
wait(for: [stopped], timeout: 1)
|
|
54
|
+
var producers = NimoResourceProducers()
|
|
55
|
+
producers.postedCallbacks = NimoResourceFixture.shared.pendingOriginalManagerEntries
|
|
56
|
+
XCTAssertTrue(model.settled(producers: producers)) // Fresh status during replacement wait.
|
|
57
|
+
XCTAssertTrue(model.errors.isEmpty)
|
|
58
|
+
let completed = expectation(description: "Replacement queued work has actually settled")
|
|
59
|
+
Task { @MainActor in
|
|
60
|
+
let deadline = ProcessInfo.processInfo.systemUptime + 2
|
|
61
|
+
while ProcessInfo.processInfo.systemUptime < deadline {
|
|
62
|
+
if NimoResourceFixture.shared.pendingManagerEntries == 0,
|
|
63
|
+
manager.sgc?.type == DeviceTypes.SIMULATED {
|
|
64
|
+
completed.fulfill()
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
try? await Task.sleep(nanoseconds: 5_000_000)
|
|
68
|
+
}
|
|
69
|
+
// Leave the expectation unmet on timeout; elapsed delay alone
|
|
70
|
+
// cannot stand in for the queued manager's actual completion.
|
|
71
|
+
}
|
|
72
|
+
wait(for: [completed], timeout: 2.5)
|
|
73
|
+
XCTAssertEqual(NimoResourceFixture.shared.pendingManagerEntries, 0)
|
|
74
|
+
XCTAssertTrue(model.settled(producers: NimoResourceProducers()))
|
|
75
|
+
XCTAssertEqual(manager.sgc?.type, DeviceTypes.SIMULATED)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
8
79
|
func testDelayedReconnectReplayPreservesNimoDepthEndpoints() {
|
|
9
80
|
for depth in [0, 10] {
|
|
10
81
|
withRecordingDevice { manager, store in
|
|
@@ -280,6 +351,8 @@ final class DeviceManagerReconnectTests: XCTestCase {
|
|
|
280
351
|
|
|
281
352
|
@MainActor
|
|
282
353
|
private final class ReconnectRecordingDevice: SGCManager {
|
|
354
|
+
var onCleanup: (() -> Void)?
|
|
355
|
+
var onDisconnect: (() -> Void)?
|
|
283
356
|
var type = "Reconnect test glasses"
|
|
284
357
|
let hasMic = true
|
|
285
358
|
let showConnectionConfirmation = false
|
|
@@ -333,7 +406,7 @@ private final class ReconnectRecordingDevice: SGCManager {
|
|
|
333
406
|
func sendShutdown() {}
|
|
334
407
|
func sendReboot() {}
|
|
335
408
|
func sendRgbLedControl(requestId _: String, packageName _: String?, action _: String, color _: String?, onDurationMs _: Int, offDurationMs _: Int, count _: Int) {}
|
|
336
|
-
func disconnect() {}
|
|
409
|
+
func disconnect() { onDisconnect?() }
|
|
337
410
|
func forget() {}
|
|
338
411
|
func findCompatibleDevices() {}
|
|
339
412
|
func stopScan() {}
|
|
@@ -342,7 +415,7 @@ private final class ReconnectRecordingDevice: SGCManager {
|
|
|
342
415
|
nil
|
|
343
416
|
}
|
|
344
417
|
|
|
345
|
-
func cleanup() {}
|
|
418
|
+
func cleanup() { onCleanup?() }
|
|
346
419
|
func ping() {}
|
|
347
420
|
func dbg1() {}
|
|
348
421
|
func dbg2() {}
|
|
@@ -183,4 +183,151 @@ final class NimoCanvasTransportTests: XCTestCase {
|
|
|
183
183
|
XCTAssertEqual(written.count, expected.count + 1)
|
|
184
184
|
canvas.disconnected(); queue.reset()
|
|
185
185
|
}
|
|
186
|
+
|
|
187
|
+
func testEncoderInvalidationRetainsRunningWorkerAndPendingDelivery() {
|
|
188
|
+
let main = NimoTestClock(), worker = NimoTestClock()
|
|
189
|
+
var delivered = false
|
|
190
|
+
var encoder: NimoCanvasEncoder!
|
|
191
|
+
encoder = NimoCanvasEncoder(main: main.schedule, worker: worker.schedule, failure: { XCTFail("\($0)") })
|
|
192
|
+
encoder.submit(encode: {
|
|
193
|
+
XCTAssertEqual(encoder.workerCount, 1)
|
|
194
|
+
encoder.invalidate()
|
|
195
|
+
XCTAssertEqual(encoder.workerCount, 1) // Running encode is not cancelled by dropping its result.
|
|
196
|
+
return Data([1])
|
|
197
|
+
}, deliver: { _ in delivered = true })
|
|
198
|
+
worker.advance()
|
|
199
|
+
XCTAssertEqual(encoder.workerCount, 0)
|
|
200
|
+
XCTAssertEqual(encoder.deliveryCount, 1)
|
|
201
|
+
main.advance()
|
|
202
|
+
XCTAssertEqual(encoder.deliveryCount, 0)
|
|
203
|
+
XCTAssertFalse(delivered)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func testWriterResetCannotEraseRealInFlightOrPostedCompletion() {
|
|
207
|
+
let clock = NimoTestClock(), original = NSObject(), replacement = NSObject(), characteristic = NSObject()
|
|
208
|
+
let queue = NimoWriteQueue<NSObject>(schedule: clock.schedule, write: { _, _ in true }, failure: { XCTFail($0) })
|
|
209
|
+
queue.connected(original)
|
|
210
|
+
queue.enqueue(original, characteristic: characteristic, frames: [Data([1])])
|
|
211
|
+
XCTAssertEqual(queue.activeCount, 1)
|
|
212
|
+
queue.reset()
|
|
213
|
+
XCTAssertEqual(queue.activeCount, 1)
|
|
214
|
+
queue.written(replacement, characteristic: characteristic, success: true)
|
|
215
|
+
XCTAssertEqual(queue.activeCount, 1)
|
|
216
|
+
queue.connectionClosed(replacement)
|
|
217
|
+
XCTAssertEqual(queue.activeCount, 1)
|
|
218
|
+
queue.connectionClosed(original)
|
|
219
|
+
XCTAssertEqual(queue.activeCount, 0)
|
|
220
|
+
queue.connected(original)
|
|
221
|
+
queue.enqueue(original, characteristic: characteristic, frames: [Data([1])])
|
|
222
|
+
queue.written(original, characteristic: characteristic, success: true)
|
|
223
|
+
queue.reset()
|
|
224
|
+
XCTAssertEqual(queue.activeCount, 1) // Real scheduled completion still must run its fence.
|
|
225
|
+
clock.advance(0.005)
|
|
226
|
+
XCTAssertEqual(queue.activeCount, 0)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func testCanvasExitEvidenceWaitsForWriteCompletionAndExactBusinessAck() {
|
|
230
|
+
let clock = NimoTestClock()
|
|
231
|
+
var started: (() -> Void)?, completed: (() -> Void)?, results: [Int] = []
|
|
232
|
+
var observed: [UInt64?] = []
|
|
233
|
+
let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { _, start, complete in
|
|
234
|
+
started = start; completed = complete; return true
|
|
235
|
+
}, reconnect: { XCTFail($0) }, rejected: { _ in }, result: { key, status in
|
|
236
|
+
if key == 3 { results.append(status) }
|
|
237
|
+
}, observedResult: { key, _, received in
|
|
238
|
+
if key == 3 { observed.append(received) }
|
|
239
|
+
}, continuousNow: { 120 })
|
|
240
|
+
canvas.activate(); canvas.readiness(true)
|
|
241
|
+
started?(); completed?(); canvas.response(key: 1, payload: Data([0, 0xFD]))
|
|
242
|
+
started?(); completed?(); canvas.response(key: 4, payload: Data([0, 0xFD, 0, 0, 0]))
|
|
243
|
+
XCTAssertTrue(canvas.mayHaveEntered)
|
|
244
|
+
canvas.exit(); started?()
|
|
245
|
+
canvas.response(key: 3, payload: Data([0, 0xFD]), receivedElapsedMs: 119)
|
|
246
|
+
XCTAssertTrue(results.isEmpty) // Queued prewrite ACK cannot settle Exit.
|
|
247
|
+
canvas.response(key: 3, payload: Data([0, 0xFD]), receivedElapsedMs: 123)
|
|
248
|
+
XCTAssertTrue(results.isEmpty)
|
|
249
|
+
completed?()
|
|
250
|
+
XCTAssertEqual(results, [0])
|
|
251
|
+
XCTAssertEqual(observed, [123]) // Never retimestamp buffered ACK at write completion.
|
|
252
|
+
XCTAssertFalse(canvas.mayHaveEntered)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
func testCancelledQueuedEncoderRemainsInRosterUntilBothRealFencesExecute() {
|
|
257
|
+
let main = NimoTestClock(), worker = NimoTestClock()
|
|
258
|
+
var encoded = false
|
|
259
|
+
let encoder = NimoCanvasEncoder(main: main.schedule, worker: worker.schedule, failure: { XCTFail("\($0)") })
|
|
260
|
+
encoder.submit(encode: { encoded = true; return Data([1]) }, deliver: { _ in XCTFail("Cancelled delivery") })
|
|
261
|
+
encoder.invalidate()
|
|
262
|
+
XCTAssertEqual(encoder.workerCount, 1)
|
|
263
|
+
worker.advance()
|
|
264
|
+
XCTAssertFalse(encoded)
|
|
265
|
+
XCTAssertEqual(encoder.workerCount, 0)
|
|
266
|
+
XCTAssertEqual(encoder.deliveryCount, 1)
|
|
267
|
+
main.advance()
|
|
268
|
+
XCTAssertEqual(encoder.deliveryCount, 0)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func testReleaseDrainsOriginalLaunchAndUpdateBeforeExit() {
|
|
272
|
+
for releaseKey in [1, 4] {
|
|
273
|
+
let clock = NimoTestClock()
|
|
274
|
+
var now: UInt64 = 100
|
|
275
|
+
var started: (() -> Void)?, completed: (() -> Void)?
|
|
276
|
+
var keys: [Int] = [], observed: [(Int, UInt64?)] = []
|
|
277
|
+
let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { frames, start, complete in
|
|
278
|
+
keys.append(Int(frames[0][9])); started = start; completed = complete; return true
|
|
279
|
+
}, reconnect: { XCTFail($0) }, rejected: { _ in }, observedResult: { key, _, received in observed.append((key, received)) }, continuousNow: { now })
|
|
280
|
+
canvas.activate(); canvas.readiness(true)
|
|
281
|
+
if releaseKey == 4 {
|
|
282
|
+
started?(); completed?(); canvas.response(key: 1, payload: Data([0, 0xFD]), receivedElapsedMs: 100)
|
|
283
|
+
now = 110
|
|
284
|
+
}
|
|
285
|
+
started?(); let originalCompletion = completed
|
|
286
|
+
canvas.drainForRelease()
|
|
287
|
+
canvas.offer(Data([9]), scope: "new", force: true)
|
|
288
|
+
canvas.activate(); canvas.readiness(true); canvas.nativeApp(99, entered: true)
|
|
289
|
+
let payload = releaseKey == 4 ? Data([0, 0xFD, 0, 0, 0]) : Data([0, 0xFD])
|
|
290
|
+
func deliver(_ key: Int, _ data: Data, _ received: UInt64) {
|
|
291
|
+
if canvas.acceptsReleaseResponse(key: key, payload: data) { canvas.response(key: key, payload: data, receivedElapsedMs: received) }
|
|
292
|
+
}
|
|
293
|
+
XCTAssertFalse(canvas.acceptsReleaseResponse(key: 3, payload: Data([0, 0xFD])))
|
|
294
|
+
XCTAssertFalse(canvas.acceptsReleaseResponse(key: releaseKey, payload: Data([0, 1])))
|
|
295
|
+
XCTAssertTrue(canvas.acceptsReleaseResponse(key: releaseKey, payload: payload))
|
|
296
|
+
deliver(releaseKey, payload, now - 1) // Callback received before actual final write.
|
|
297
|
+
XCTAssertEqual(keys.last, releaseKey)
|
|
298
|
+
deliver(releaseKey, payload, now + 2) // Early ACK buffered until real didWrite.
|
|
299
|
+
XCTAssertEqual(keys.last, releaseKey)
|
|
300
|
+
originalCompletion?()
|
|
301
|
+
XCTAssertEqual(keys.last, 3)
|
|
302
|
+
XCTAssertEqual(observed.last?.0, releaseKey); XCTAssertEqual(observed.last?.1, now + 2)
|
|
303
|
+
now += 10; started?(); completed?()
|
|
304
|
+
deliver(releaseKey, payload, now)
|
|
305
|
+
XCTAssertEqual(canvas.pendingCount, 1)
|
|
306
|
+
deliver(3, Data([0, 0xFD]), now + 1)
|
|
307
|
+
XCTAssertEqual(canvas.pendingCount, 0); XCTAssertFalse(canvas.mayHaveEntered)
|
|
308
|
+
XCTAssertEqual(keys, releaseKey == 1 ? [1, 3] : [1, 4, 3])
|
|
309
|
+
canvas.offer(Data([8]), scope: "replacement"); clock.advance(2)
|
|
310
|
+
XCTAssertEqual(keys.last, 3)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
func testReleaseMissingOrRejectedOriginalAckCannotInventExitOrRetry() {
|
|
315
|
+
for status: UInt8? in [nil, 7, 8, 2] {
|
|
316
|
+
let clock = NimoTestClock()
|
|
317
|
+
var keys: [Int] = [], reconnects: [String] = [], results: [Int] = []
|
|
318
|
+
let canvas = NimoCanvasCoordinator(schedule: clock.schedule, writeCapacity: { 512 }, enqueue: { frames, start, complete in
|
|
319
|
+
keys.append(Int(frames[0][9])); start(); complete(); return true
|
|
320
|
+
}, reconnect: { reconnects.append($0) }, rejected: { _ in }, result: { key, _ in results.append(key) })
|
|
321
|
+
canvas.activate(); canvas.readiness(true); canvas.drainForRelease()
|
|
322
|
+
if let status {
|
|
323
|
+
let payload = Data([status])
|
|
324
|
+
XCTAssertTrue(canvas.acceptsReleaseResponse(key: 1, payload: payload))
|
|
325
|
+
canvas.response(key: 1, payload: payload)
|
|
326
|
+
}
|
|
327
|
+
clock.advance(46)
|
|
328
|
+
XCTAssertEqual(keys, [1]); XCTAssertFalse(results.contains(3))
|
|
329
|
+
if status == nil || status == 2 { XCTAssertEqual(reconnects.count, 1) }
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
186
333
|
}
|