@cnhongwei/react-native-mqtt 0.0.0-stage → 0.1.0

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.
Files changed (58) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +169 -2
  3. package/README.zh-CN.md +100 -0
  4. package/ReactNativeMqtt.podspec +28 -0
  5. package/THIRD_PARTY_NOTICES.md +17 -0
  6. package/android/build.gradle +70 -0
  7. package/android/consumer-rules.pro +10 -0
  8. package/android/src/main/AndroidManifest.xml +2 -0
  9. package/android/src/main/java/com/cnhongwei/reactnativemqtt/MqttOptionsParser.kt +176 -0
  10. package/android/src/main/java/com/cnhongwei/reactnativemqtt/MqttSession.kt +1006 -0
  11. package/android/src/main/java/com/cnhongwei/reactnativemqtt/PahoLogger.java +44 -0
  12. package/android/src/main/java/com/cnhongwei/reactnativemqtt/PahoMqttTransport.kt +399 -0
  13. package/android/src/main/java/com/cnhongwei/reactnativemqtt/ReactNativeMqttModule.kt +619 -0
  14. package/android/src/main/java/com/cnhongwei/reactnativemqtt/ReactNativeMqttPackage.kt +31 -0
  15. package/ios/ReactNativeMqtt.h +15 -0
  16. package/ios/ReactNativeMqtt.mm +1184 -0
  17. package/ios/ReactNativeMqttCocoaTransport.swift +782 -0
  18. package/ios/ReactNativeMqttConnAckProbe.swift +87 -0
  19. package/ios/ReactNativeMqttSession.swift +420 -0
  20. package/lib/module/NativeReactNativeMqtt.js +5 -0
  21. package/lib/module/NativeReactNativeMqtt.js.map +1 -0
  22. package/lib/module/client.js +555 -0
  23. package/lib/module/client.js.map +1 -0
  24. package/lib/module/errors.js +20 -0
  25. package/lib/module/errors.js.map +1 -0
  26. package/lib/module/helpers.js +116 -0
  27. package/lib/module/helpers.js.map +1 -0
  28. package/lib/module/index.js +8 -0
  29. package/lib/module/index.js.map +1 -0
  30. package/lib/module/package.json +1 -0
  31. package/lib/module/types.js +2 -0
  32. package/lib/module/types.js.map +1 -0
  33. package/lib/module/validation.js +306 -0
  34. package/lib/module/validation.js.map +1 -0
  35. package/lib/typescript/package.json +1 -0
  36. package/lib/typescript/src/NativeReactNativeMqtt.d.ts +154 -0
  37. package/lib/typescript/src/NativeReactNativeMqtt.d.ts.map +1 -0
  38. package/lib/typescript/src/client.d.ts +3 -0
  39. package/lib/typescript/src/client.d.ts.map +1 -0
  40. package/lib/typescript/src/errors.d.ts +18 -0
  41. package/lib/typescript/src/errors.d.ts.map +1 -0
  42. package/lib/typescript/src/helpers.d.ts +5 -0
  43. package/lib/typescript/src/helpers.d.ts.map +1 -0
  44. package/lib/typescript/src/index.d.ts +9 -0
  45. package/lib/typescript/src/index.d.ts.map +1 -0
  46. package/lib/typescript/src/types.d.ts +161 -0
  47. package/lib/typescript/src/types.d.ts.map +1 -0
  48. package/lib/typescript/src/validation.d.ts +13 -0
  49. package/lib/typescript/src/validation.d.ts.map +1 -0
  50. package/package.json +188 -4
  51. package/react-native.config.js +1 -0
  52. package/src/NativeReactNativeMqtt.ts +173 -0
  53. package/src/client.ts +859 -0
  54. package/src/errors.ts +51 -0
  55. package/src/helpers.ts +151 -0
  56. package/src/index.ts +30 -0
  57. package/src/types.ts +184 -0
  58. package/src/validation.ts +735 -0
@@ -0,0 +1,1006 @@
1
+ package com.cnhongwei.reactnativemqtt
2
+
3
+ import java.util.concurrent.CompletableFuture
4
+ import java.util.concurrent.ConcurrentHashMap
5
+ import java.util.concurrent.ExecutorService
6
+ import java.util.concurrent.Executors
7
+ import java.util.concurrent.RejectedExecutionException
8
+ import java.util.concurrent.ScheduledExecutorService
9
+ import java.util.concurrent.TimeUnit
10
+ import java.util.concurrent.atomic.AtomicBoolean
11
+ import java.util.concurrent.ThreadLocalRandom
12
+
13
+ internal interface MqttTimer {
14
+ fun cancel()
15
+ fun isDone(): Boolean
16
+ }
17
+
18
+ internal interface MqttScheduler {
19
+ fun schedule(delayMs: Long, task: () -> Unit): MqttTimer
20
+ fun shutdown()
21
+ }
22
+
23
+ private class ExecutorMqttScheduler : MqttScheduler {
24
+ private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { runnable ->
25
+ Thread(runnable, "react-native-mqtt-deadlines").apply { isDaemon = true }
26
+ }
27
+
28
+ override fun schedule(delayMs: Long, task: () -> Unit): MqttTimer {
29
+ val future = executor.schedule(task, delayMs, TimeUnit.MILLISECONDS)
30
+ return object : MqttTimer {
31
+ override fun cancel() { future.cancel(false) }
32
+ override fun isDone(): Boolean = future.isDone
33
+ }
34
+ }
35
+
36
+ override fun shutdown() { executor.shutdownNow() }
37
+ }
38
+
39
+ internal class MqttSession(
40
+ private val instanceId: String,
41
+ private val options: ParsedMqttOptions,
42
+ private val onMessage: (Long, MqttMessageData) -> Unit = { _, _ -> },
43
+ private val transportFactory: ((ParsedMqttOptions, (MqttMessageData) -> Unit) -> MqttTransport)? = null,
44
+ private val onTransportLost: (Long, MqttDisconnect?) -> Unit = { _, _ -> },
45
+ ) {
46
+ private data class SubscriptionIntent(
47
+ val qos: Int,
48
+ val noLocal: Boolean,
49
+ val retainAsPublished: Boolean,
50
+ val retainHandling: Int,
51
+ val revision: Long,
52
+ var grantedQos: Int? = null,
53
+ )
54
+ private data class SubscriptionWaiter(
55
+ val generation: Long,
56
+ val desiredRevision: Long,
57
+ val future: CompletableFuture<Map<String, Any?>>,
58
+ )
59
+ private data class OperationLedgerEntry(
60
+ val operationId: Long,
61
+ val generation: Long,
62
+ val operation: String,
63
+ val topic: String?,
64
+ var settled: Boolean = false,
65
+ )
66
+
67
+ private var revision = 0L
68
+ private var generation = 0L
69
+ private var state = "idle"
70
+ private var lastConnAck: Map<String, Any?>? = null
71
+ private var subscriptionsRevision = 0L
72
+ private var subscriptionsStatus = "ready"
73
+ private var transport: MqttTransport? = null
74
+ private var connecting: CompletableFuture<MqttConnAck>? = null
75
+ private var attemptId = 0L
76
+ private var stopRequested = false
77
+ private val desiredSubscriptions = LinkedHashMap<String, SubscriptionIntent>()
78
+ private val confirmedSubscriptions = LinkedHashMap<String, SubscriptionIntent>()
79
+ private val tombstones = LinkedHashMap<String, Long>()
80
+ private val subscriptionTails = mutableMapOf<String, CompletableFuture<Unit>>()
81
+ private var pendingSubscriptionOperations = 0
82
+ private val subscriptionWaiters = mutableListOf<SubscriptionWaiter>()
83
+ private val reconnectWaiters = mutableListOf<CompletableFuture<Map<String, Any?>>>()
84
+ private val pendingOperationFutures = mutableSetOf<CompletableFuture<*>>()
85
+ private val operationLedger = LinkedHashMap<Long, OperationLedgerEntry>()
86
+ private var nextOperationId = 0L
87
+
88
+ fun snapshot(): Map<String, Any?> = mapOf(
89
+ "instanceId" to instanceId,
90
+ "revision" to revision,
91
+ "generation" to generation,
92
+ "state" to state,
93
+ "lastConnAck" to lastConnAck,
94
+ "subscriptionsRevision" to subscriptionsRevision,
95
+ "subscriptionsStatus" to subscriptionsStatus,
96
+ "deliveryStatus" to "active",
97
+ )
98
+
99
+ fun connect(serialExecutor: ExecutorService, forceAttempt: Boolean = false): CompletableFuture<Map<String, Any?>> {
100
+ if (state == "closed") return failed(MqttSessionException("ERR_DISPOSED", "Session is closed"))
101
+ if (state == "connected" && lastConnAck != null) return CompletableFuture.completedFuture(mapOf("snapshot" to snapshot(), "ack" to lastConnAck))
102
+ val existing = connecting
103
+ if (existing != null) return existing.thenApplyAsync({ mapOf("snapshot" to snapshot(), "ack" to lastConnAck) }, serialExecutor)
104
+ if (state == "reconnecting" && !forceAttempt) {
105
+ return CompletableFuture<Map<String, Any?>>().also { reconnectWaiters += it }
106
+ }
107
+ val reuseTransport = state == "reconnecting" && transport?.supportsInPlaceReconnect() == true
108
+ stopRequested = false
109
+ val currentAttempt = ++attemptId
110
+ state = "connecting"
111
+ revision += 1
112
+ val callbackGeneration = generation + 1
113
+ val currentTransport = if (reuseTransport) {
114
+ transport!!
115
+ } else {
116
+ if (transportFactory == null) {
117
+ // Paho owns the client callback across an in-place reconnect. Resolve
118
+ // the generation at delivery time so messages after reconnect are
119
+ // tagged with the current generation rather than the first one.
120
+ PahoMqttTransport(options) { message ->
121
+ if (state == "connected") onMessage(generation, message)
122
+ }
123
+ } else {
124
+ transportFactory.invoke(options) { message ->
125
+ if (state == "connected" && generation == callbackGeneration) onMessage(callbackGeneration, message)
126
+ }
127
+ }.also { transport = it }
128
+ }
129
+ currentTransport.setDisconnectedListener { reason ->
130
+ try {
131
+ serialExecutor.execute {
132
+ if (state == "connected" && generation == callbackGeneration) {
133
+ state = "reconnecting"
134
+ revision += 1
135
+ onTransportLost(callbackGeneration, reason)
136
+ }
137
+ }
138
+ } catch (_: RejectedExecutionException) {
139
+ // Registry teardown owns the terminal state.
140
+ }
141
+ }
142
+ val current = currentTransport.connect()
143
+ connecting = current
144
+ return current.handleAsync({ ack, error ->
145
+ if (error != null) {
146
+ val existing = findMqttSessionException(error)
147
+ if (existing != null) throw existing
148
+ throw MqttSessionException(
149
+ "ERR_CONNECTION_REFUSED",
150
+ error.message ?: "Connection failed",
151
+ error,
152
+ operation = "connect",
153
+ generation = callbackGeneration,
154
+ outcome = "unknown",
155
+ )
156
+ }
157
+ if (ack == null) throw MqttSessionException(
158
+ "ERR_CONNECTION_REFUSED",
159
+ "Connection completed without CONNACK",
160
+ operation = "connect",
161
+ generation = callbackGeneration,
162
+ outcome = "unknown",
163
+ )
164
+ if (stopRequested || currentAttempt != attemptId) throw MqttSessionException("ERR_CONNECTION_LOST", "Connection attempt was superseded")
165
+ state = "connected"
166
+ generation += 1
167
+ revision += 1
168
+ reconnectAttempts = 0
169
+ lastConnAck = mapOf("sessionPresent" to ack.sessionPresent, "reasonCode" to ack.reasonCode, "reasonString" to ack.reasonString, "assignedClientId" to ack.assignedClientId, "userProperties" to ack.userProperties).filterValues { it != null }
170
+ connecting = null
171
+ subscriptionsStatus = if (desiredSubscriptions.isEmpty()) "ready" else "pending"
172
+ if (!ack.sessionPresent) {
173
+ tombstones.clear()
174
+ if (desiredSubscriptions.isNotEmpty()) restoreSubscriptions(serialExecutor)
175
+ } else if (tombstones.isNotEmpty()) {
176
+ restoreTombstones(serialExecutor)
177
+ }
178
+ settleSubscriptionWaiters()
179
+ mapOf<String, Any?>("snapshot" to snapshot(), "ack" to lastConnAck).also { result ->
180
+ reconnectWaiters.removeAll { waiter -> waiter.complete(result); true }
181
+ }
182
+ }, serialExecutor)
183
+ .whenCompleteAsync({ _, _ -> if (currentAttempt == attemptId && state == "connecting") { state = "disconnected"; connecting = null } }, serialExecutor)
184
+ }
185
+
186
+ fun disconnect(serialExecutor: ExecutorService): CompletableFuture<Map<String, Any?>> {
187
+ if (state == "idle" || state == "disconnected") return CompletableFuture.completedFuture(snapshot())
188
+ stopRequested = true
189
+ attemptId += 1
190
+ rejectReconnectWaiters(MqttSessionException("ERR_CONNECTION_LOST", "Connection was stopped"))
191
+ connecting?.cancel(true)
192
+ connecting = null
193
+ state = "disconnecting"
194
+ revision += 1
195
+ cancelPendingOperations(MqttSessionException("ERR_CONNECTION_LOST", "Operation was cancelled by disconnect"))
196
+ settleSubscriptionWaiters()
197
+ return (transport?.disconnect() ?: CompletableFuture.completedFuture(null)).thenApplyAsync({
198
+ state = "disconnected"
199
+ revision += 1
200
+ settleSubscriptionWaiters()
201
+ snapshot()
202
+ }, serialExecutor)
203
+ }
204
+
205
+ fun timeoutConnect() {
206
+ if (state != "connecting") return
207
+ state = "disconnected"
208
+ revision += 1
209
+ stopRequested = true
210
+ attemptId += 1
211
+ settleSubscriptionWaiters()
212
+ connecting?.cancel(true)
213
+ connecting = null
214
+ rejectReconnectWaiters(MqttSessionException("ERR_CONNECT_TIMEOUT", "Connection timed out"))
215
+ transport?.disconnect()
216
+ }
217
+
218
+ fun setSubscription(
219
+ filter: String,
220
+ qos: Int,
221
+ noLocal: Boolean,
222
+ retainAsPublished: Boolean,
223
+ retainHandling: Int,
224
+ intentRevision: Long,
225
+ serialExecutor: ExecutorService,
226
+ ): CompletableFuture<Map<String, Any?>> {
227
+ if (state != "connected") return failed(MqttSessionException("ERR_NOT_CONNECTED", "Session is not connected"))
228
+ subscriptionsStatus = "pending"
229
+ subscriptionsRevision = maxOf(subscriptionsRevision, intentRevision)
230
+ tombstones.remove(filter)
231
+ desiredSubscriptions[filter] = SubscriptionIntent(qos, noLocal, retainAsPublished, retainHandling, intentRevision)
232
+ val currentTransport = transport
233
+ if (currentTransport == null) {
234
+ desiredSubscriptions.remove(filter)
235
+ return failed(MqttSessionException("ERR_NOT_CONNECTED", "Session transport is not connected"))
236
+ }
237
+ val operationGeneration = generation
238
+ pendingSubscriptionOperations += 1
239
+ val operation = enqueueFilterOperation(filter, serialExecutor) {
240
+ currentTransport.subscribe(filter, qos, noLocal, retainAsPublished, retainHandling)
241
+ }.exceptionally { error -> throw mapProtocolOperationError(error, "subscribe", operationGeneration) }
242
+ val result = operation.thenApplyAsync({ result ->
243
+ if (state != "connected" || generation != operationGeneration) {
244
+ throw MqttSessionException("ERR_CONNECTION_LOST", "Subscription generation changed")
245
+ }
246
+ val current = desiredSubscriptions[filter]
247
+ if (current == null || current.revision != intentRevision) {
248
+ throw MqttSessionException("ERR_OPERATION_SUPERSEDED", "Subscription intent was superseded")
249
+ }
250
+ if (result.grantedQos < 0) {
251
+ desiredSubscriptions.remove(filter)
252
+ subscriptionsStatus = "failed"
253
+ throw MqttSessionException(
254
+ "ERR_SUBSCRIBE_FAILED",
255
+ "Broker rejected subscription",
256
+ reasonCode = result.reasonCode,
257
+ outcome = "rejected",
258
+ )
259
+ }
260
+ current.grantedQos = result.grantedQos
261
+ confirmedSubscriptions[filter] = current.copy()
262
+ revision += 1
263
+ mapOf<String, Any?>("generation" to generation, "intentRevision" to intentRevision, "grantedQos" to result.grantedQos, "reasonCode" to result.reasonCode)
264
+ }, serialExecutor).whenCompleteAsync({ _, error ->
265
+ pendingSubscriptionOperations = (pendingSubscriptionOperations - 1).coerceAtLeast(0)
266
+ if (error != null) subscriptionsStatus = "failed"
267
+ else if (pendingSubscriptionOperations == 0) subscriptionsStatus = "ready"
268
+ settleSubscriptionWaiters()
269
+ }, serialExecutor)
270
+ return trackOperation(result, serialExecutor, "subscribe", filter, operationGeneration)
271
+ }
272
+
273
+ fun removeSubscription(filter: String, intentRevision: Long, serialExecutor: ExecutorService): CompletableFuture<Map<String, Any?>> {
274
+ if (state == "closed") return failed(MqttSessionException("ERR_DISPOSED", "Session is closed"))
275
+ subscriptionsRevision = maxOf(subscriptionsRevision, intentRevision)
276
+ val previous = desiredSubscriptions.remove(filter)
277
+ tombstones[filter] = intentRevision
278
+ if (state != "connected") {
279
+ subscriptionsStatus = "pending"
280
+ return CompletableFuture.completedFuture(mapOf("intentRevision" to intentRevision))
281
+ }
282
+ subscriptionsStatus = "pending"
283
+ val currentTransport = transport
284
+ if (currentTransport == null) {
285
+ if (previous != null) desiredSubscriptions[filter] = previous
286
+ return CompletableFuture.completedFuture(mapOf("intentRevision" to intentRevision))
287
+ }
288
+ val operationGeneration = generation
289
+ pendingSubscriptionOperations += 1
290
+ val operation = enqueueFilterOperation(filter, serialExecutor) {
291
+ currentTransport.unsubscribe(filter)
292
+ }.exceptionally { error -> throw mapProtocolOperationError(error, "unsubscribe", operationGeneration) }
293
+ val result = operation.thenApplyAsync({ reasonCode ->
294
+ if (state != "connected" || generation != operationGeneration) {
295
+ throw MqttSessionException("ERR_CONNECTION_LOST", "Unsubscription generation changed")
296
+ }
297
+ val current = desiredSubscriptions[filter]
298
+ if (current != null && current.revision > intentRevision) {
299
+ throw MqttSessionException("ERR_OPERATION_SUPERSEDED", "Unsubscription intent was superseded")
300
+ }
301
+ if (reasonCode >= 128) {
302
+ if (previous != null) desiredSubscriptions[filter] = previous
303
+ tombstones.remove(filter)
304
+ subscriptionsStatus = "failed"
305
+ throw MqttSessionException(
306
+ "ERR_UNSUBSCRIBE_FAILED",
307
+ "Broker rejected unsubscription",
308
+ reasonCode = reasonCode,
309
+ outcome = "rejected",
310
+ )
311
+ }
312
+ tombstones.remove(filter)
313
+ confirmedSubscriptions.remove(filter)
314
+ revision += 1
315
+ mapOf<String, Any?>("intentRevision" to intentRevision)
316
+ }, serialExecutor).whenCompleteAsync({ _, error ->
317
+ pendingSubscriptionOperations = (pendingSubscriptionOperations - 1).coerceAtLeast(0)
318
+ if (error != null) subscriptionsStatus = "failed"
319
+ else if (pendingSubscriptionOperations == 0) subscriptionsStatus = "ready"
320
+ settleSubscriptionWaiters()
321
+ }, serialExecutor)
322
+ return trackOperation(result, serialExecutor, "unsubscribe", filter, operationGeneration)
323
+ }
324
+
325
+ private fun restoreSubscriptions(serialExecutor: ExecutorService) {
326
+ val currentTransport = transport ?: return
327
+ val intents = desiredSubscriptions.toMap().entries.toList()
328
+ val batches = mutableListOf<List<Map.Entry<String, SubscriptionIntent>>>()
329
+ var currentBatch = mutableListOf<Map.Entry<String, SubscriptionIntent>>()
330
+ var currentBytes = 0
331
+ var restoreFailed = false
332
+ intents.forEach { entry ->
333
+ val estimatedBytes = entry.key.toByteArray(Charsets.UTF_8).size + 8
334
+ if (estimatedBytes > RESTORE_MAX_PACKET_BYTES) {
335
+ restoreFailed = true
336
+ return@forEach
337
+ }
338
+ if (currentBatch.isNotEmpty() &&
339
+ (currentBatch.size >= RESTORE_MAX_FILTERS || currentBytes + estimatedBytes > RESTORE_MAX_PACKET_BYTES)
340
+ ) {
341
+ batches += currentBatch
342
+ currentBatch = mutableListOf()
343
+ currentBytes = 0
344
+ }
345
+ currentBatch += entry
346
+ currentBytes += estimatedBytes
347
+ }
348
+ if (currentBatch.isNotEmpty()) batches += currentBatch
349
+
350
+ var chain = CompletableFuture.completedFuture(Unit)
351
+ if (batches.isEmpty()) {
352
+ subscriptionsStatus = "failed"
353
+ revision += 1
354
+ settleSubscriptionWaiters()
355
+ return
356
+ }
357
+ batches.chunked(RESTORE_MAX_IN_FLIGHT).forEach { wave ->
358
+ chain = chain.handleAsync({ _, error ->
359
+ if (error != null) restoreFailed = true
360
+ Unit
361
+ }, serialExecutor).thenComposeAsync({
362
+ val operations = wave.map { restoreSubscriptionBatch(it, currentTransport, serialExecutor) }
363
+ CompletableFuture.allOf(*operations.toTypedArray()).thenApply { Unit }
364
+ }, serialExecutor)
365
+ }
366
+ chain.handleAsync({ _, error ->
367
+ if (error != null) restoreFailed = true
368
+ if (!restoreFailed && pendingSubscriptionOperations == 0) subscriptionsStatus = "ready"
369
+ else subscriptionsStatus = "failed"
370
+ revision += 1
371
+ settleSubscriptionWaiters()
372
+ Unit
373
+ }, serialExecutor)
374
+ }
375
+
376
+ private fun restoreTombstones(serialExecutor: ExecutorService) {
377
+ val currentTransport = transport ?: return
378
+ val removals = tombstones.toMap().entries.toList()
379
+ subscriptionsStatus = "pending"
380
+ pendingSubscriptionOperations += removals.size
381
+ removals.forEach { (filter, intentRevision) ->
382
+ val operationGeneration = generation
383
+ enqueueFilterOperation(filter, serialExecutor) { currentTransport.unsubscribe(filter) }
384
+ .thenApplyAsync({ reasonCode ->
385
+ if (state != "connected" || generation != operationGeneration) {
386
+ throw MqttSessionException("ERR_CONNECTION_LOST", "Tombstone restoration was interrupted")
387
+ }
388
+ if (reasonCode >= 128) {
389
+ throw MqttSessionException("ERR_UNSUBSCRIBE_FAILED", "Broker rejected tombstone", reasonCode = reasonCode, outcome = "rejected")
390
+ }
391
+ if (tombstones[filter] == intentRevision) {
392
+ tombstones.remove(filter)
393
+ confirmedSubscriptions.remove(filter)
394
+ }
395
+ Unit
396
+ }, serialExecutor)
397
+ .whenCompleteAsync({ _, error ->
398
+ pendingSubscriptionOperations = (pendingSubscriptionOperations - 1).coerceAtLeast(0)
399
+ if (error != null) subscriptionsStatus = "failed"
400
+ else if (pendingSubscriptionOperations == 0) subscriptionsStatus = "ready"
401
+ settleSubscriptionWaiters()
402
+ }, serialExecutor)
403
+ }
404
+ }
405
+
406
+ /** Serialize one filter's protocol operations so late ACKs cannot cross requests. */
407
+ private fun <T> enqueueFilterOperation(
408
+ filter: String,
409
+ serialExecutor: ExecutorService,
410
+ operation: () -> CompletableFuture<T>,
411
+ ): CompletableFuture<T> {
412
+ val previous = subscriptionTails[filter] ?: CompletableFuture.completedFuture(Unit)
413
+ val next = previous
414
+ .handleAsync({ _, _ -> Unit }, serialExecutor)
415
+ .thenComposeAsync({ operation() }, serialExecutor)
416
+ val tail = next.handleAsync({ _, _ -> Unit }, serialExecutor)
417
+ subscriptionTails[filter] = tail
418
+ tail.whenCompleteAsync({ _, _ ->
419
+ if (subscriptionTails[filter] === tail) subscriptionTails.remove(filter)
420
+ }, serialExecutor)
421
+ return next
422
+ }
423
+
424
+ private fun restoreSubscriptionBatch(
425
+ batch: List<Map.Entry<String, SubscriptionIntent>>,
426
+ currentTransport: MqttTransport,
427
+ serialExecutor: ExecutorService,
428
+ ): CompletableFuture<Unit> {
429
+ pendingSubscriptionOperations += batch.size
430
+ val requests = batch.map { (filter, intent) ->
431
+ MqttSubscriptionRequest(filter, intent.qos, intent.noLocal, intent.retainAsPublished, intent.retainHandling)
432
+ }
433
+ return currentTransport.subscribeBatch(requests)
434
+ .thenApplyAsync({ results ->
435
+ if (state != "connected") throw MqttSessionException("ERR_CONNECTION_LOST", "Subscription restore was interrupted")
436
+ var failed = results.size != batch.size
437
+ results.take(batch.size).forEachIndexed { index, result ->
438
+ val (filter, intent) = batch[index]
439
+ val current = desiredSubscriptions[filter]
440
+ if (current == null || current.revision != intent.revision) return@forEachIndexed
441
+ if (result.grantedQos < 0) {
442
+ failed = true
443
+ } else {
444
+ current.grantedQos = result.grantedQos
445
+ confirmedSubscriptions[filter] = current.copy()
446
+ }
447
+ }
448
+ if (failed) throw MqttSessionException("ERR_SUBSCRIBE_FAILED", "Broker returned a partial or rejected SUBACK")
449
+ Unit
450
+ }, serialExecutor)
451
+ .whenCompleteAsync({ _, error ->
452
+ pendingSubscriptionOperations = (pendingSubscriptionOperations - batch.size).coerceAtLeast(0)
453
+ if (error != null) subscriptionsStatus = "failed"
454
+ settleSubscriptionWaiters()
455
+ }, serialExecutor)
456
+ }
457
+
458
+ private companion object {
459
+ const val RESTORE_MAX_FILTERS = 32
460
+ const val RESTORE_MAX_PACKET_BYTES = 16 * 1024
461
+ const val RESTORE_MAX_IN_FLIGHT = 4
462
+ }
463
+
464
+ fun awaitSubscriptions(generation: Long, desiredRevision: Long, serialExecutor: ExecutorService): CompletableFuture<Map<String, Any?>> {
465
+ if (state != "connected") return failed(MqttSessionException("ERR_NOT_CONNECTED", "Session is not connected"))
466
+ if (this.generation != generation) return failed(MqttSessionException("ERR_CONNECTION_LOST", "Connection generation changed"))
467
+ if (subscriptionsStatus == "failed") return failed(MqttSessionException("ERR_SUBSCRIBE_FAILED", "Subscription synchronization failed"))
468
+ if (subscriptionsRevision >= desiredRevision && subscriptionsStatus == "ready") return CompletableFuture.completedFuture(snapshot())
469
+ val waiter = CompletableFuture<Map<String, Any?>>().also {
470
+ subscriptionWaiters += SubscriptionWaiter(generation, desiredRevision, it)
471
+ }
472
+ return trackOperation(waiter, serialExecutor, "ready", null, generation)
473
+ }
474
+
475
+ private fun settleSubscriptionWaiters() {
476
+ val iterator = subscriptionWaiters.iterator()
477
+ while (iterator.hasNext()) {
478
+ val waiter = iterator.next()
479
+ when {
480
+ state != "connected" || generation != waiter.generation -> {
481
+ waiter.future.completeExceptionally(MqttSessionException("ERR_CONNECTION_LOST", "Connection generation changed"))
482
+ iterator.remove()
483
+ }
484
+ subscriptionsStatus == "failed" -> {
485
+ waiter.future.completeExceptionally(MqttSessionException("ERR_SUBSCRIBE_FAILED", "Subscription synchronization failed"))
486
+ iterator.remove()
487
+ }
488
+ subscriptionsStatus == "ready" && subscriptionsRevision >= waiter.desiredRevision -> {
489
+ waiter.future.complete(snapshot())
490
+ iterator.remove()
491
+ }
492
+ }
493
+ }
494
+ }
495
+
496
+ fun publish(topic: String, payload: ByteArray, qos: Int, retained: Boolean, properties: MqttPublishProperties = MqttPublishProperties(), serialExecutor: ExecutorService): CompletableFuture<Void> {
497
+ if (state != "connected") return failed(MqttSessionException("ERR_NOT_CONNECTED", "Session is not connected"))
498
+ val currentTransport = transport
499
+ ?: return failed(MqttSessionException("ERR_NOT_CONNECTED", "Session transport is not connected"))
500
+ val operationGeneration = generation
501
+ val result: CompletableFuture<Void> = currentTransport.publish(topic, payload, qos, retained, properties)
502
+ .handle<Void> { _, error ->
503
+ if (error != null) {
504
+ throw mapProtocolOperationError(error, "publish", operationGeneration)
505
+ }
506
+ null
507
+ }
508
+ return trackOperation(result, serialExecutor, "publish", topic, operationGeneration)
509
+ }
510
+
511
+ fun close(): Map<String, Any?> {
512
+ if (state != "closed") {
513
+ stopRequested = true
514
+ attemptId += 1
515
+ connecting?.cancel(true)
516
+ connecting = null
517
+ rejectReconnectWaiters(MqttSessionException("ERR_DISPOSED", "Session was closed"))
518
+ cancelPendingOperations(MqttSessionException("ERR_DISPOSED", "Session was closed"))
519
+ transport?.disconnect()
520
+ state = "closed"
521
+ revision += 1
522
+ settleSubscriptionWaiters()
523
+ }
524
+ return snapshot()
525
+ }
526
+
527
+ fun close(serialExecutor: ExecutorService): CompletableFuture<Map<String, Any?>> {
528
+ if (state == "closed") return CompletableFuture.completedFuture(snapshot())
529
+ stopRequested = true
530
+ attemptId += 1
531
+ connecting?.cancel(true)
532
+ connecting = null
533
+ state = "disconnecting"
534
+ revision += 1
535
+ cancelPendingOperations(MqttSessionException("ERR_DISPOSED", "Session was closed"))
536
+ settleSubscriptionWaiters()
537
+ val disconnect = transport?.disconnect() ?: CompletableFuture.completedFuture(null)
538
+ return disconnect.thenApplyAsync({
539
+ state = "closed"
540
+ revision += 1
541
+ settleSubscriptionWaiters()
542
+ snapshot()
543
+ }, serialExecutor)
544
+ }
545
+
546
+ private fun <T> failed(error: Throwable): CompletableFuture<T> = CompletableFuture<T>().also { it.completeExceptionally(error) }
547
+
548
+ private fun mapProtocolOperationError(error: Throwable, operation: String, operationGeneration: Long): MqttSessionException {
549
+ val cause = findMqttSessionException(error) ?: (error.cause ?: error)
550
+ val protocolFailure = when (cause) {
551
+ is org.eclipse.paho.mqttv5.common.MqttException -> Triple("ERR_${operation.uppercase()}_FAILED", cause.reasonCode, null)
552
+ is org.eclipse.paho.client.mqttv3.MqttException -> Triple("ERR_${operation.uppercase()}_FAILED", cause.reasonCode, null)
553
+ else -> null
554
+ }
555
+ if (cause is MqttSessionException) return cause
556
+ return MqttSessionException(
557
+ protocolFailure?.first ?: when (operation) {
558
+ "subscribe" -> "ERR_SUBSCRIBE_FAILED"
559
+ "unsubscribe" -> "ERR_UNSUBSCRIBE_FAILED"
560
+ else -> "ERR_PUBLISH_FAILED"
561
+ },
562
+ cause.message ?: "MQTT $operation failed",
563
+ cause = cause,
564
+ operation = operation,
565
+ generation = operationGeneration,
566
+ reasonCode = protocolFailure?.second,
567
+ reasonString = protocolFailure?.third,
568
+ outcome = if (protocolFailure != null) "rejected" else "unknown",
569
+ )
570
+ }
571
+
572
+ @Synchronized
573
+ private fun <T> trackOperation(
574
+ operation: CompletableFuture<T>,
575
+ serialExecutor: ExecutorService,
576
+ operationType: String,
577
+ topic: String?,
578
+ operationGeneration: Long,
579
+ ): CompletableFuture<T> {
580
+ val operationId = ++nextOperationId
581
+ val entry = OperationLedgerEntry(operationId, operationGeneration, operationType, topic)
582
+ operationLedger[operationId] = entry
583
+ pendingOperationFutures += operation
584
+ operation.whenCompleteAsync({ _, _ ->
585
+ synchronized(this) {
586
+ // Completion, timeout, disconnect and dispose all converge here. The
587
+ // settled bit makes the ledger's removal explicitly idempotent even
588
+ // when a source future invokes its callback more than once.
589
+ if (!entry.settled) {
590
+ entry.settled = true
591
+ operationLedger.remove(operationId)
592
+ }
593
+ pendingOperationFutures.remove(operation)
594
+ }
595
+ }, serialExecutor)
596
+ return operation
597
+ }
598
+
599
+ @Synchronized
600
+ private fun cancelPendingOperations(error: Throwable) {
601
+ pendingOperationFutures.toList().forEach { it.completeExceptionally(error) }
602
+ }
603
+
604
+ fun sameOptions(other: ParsedMqttOptions): Boolean = options == other
605
+
606
+ fun connectTimeoutMs(): Long = options.connectTimeoutMs
607
+
608
+ fun operationTimeoutMs(): Long = options.operationTimeoutMs
609
+
610
+ fun disconnectTimeoutMs(): Long = options.disconnectTimeoutMs
611
+
612
+ fun reconnectEnabled(): Boolean = options.reconnectEnabled
613
+
614
+ @Synchronized
615
+ fun pendingOperations(): Int = pendingOperationFutures.size
616
+
617
+ @Synchronized
618
+ fun operationLedgerSnapshot(): List<Map<String, Any?>> = operationLedger.values.map { entry ->
619
+ mapOf(
620
+ "operationId" to entry.operationId,
621
+ "generation" to entry.generation,
622
+ "operation" to entry.operation,
623
+ "topic" to entry.topic,
624
+ "settled" to entry.settled,
625
+ )
626
+ }
627
+
628
+ fun reconnectAttemptsCount(): Long = reconnectAttempts
629
+
630
+ fun reconnectDelayMs(): Long {
631
+ reconnectAttempts += 1
632
+ val exponent = (reconnectAttempts - 1).coerceAtMost(30).toInt()
633
+ val base = (options.reconnectInitialDelayMs * (1L shl exponent)).coerceAtMost(options.reconnectMaxDelayMs)
634
+ val spread = (base.toDouble() * options.reconnectJitterRatio).toLong()
635
+ return if (spread == 0L) base else (base + ThreadLocalRandom.current().nextLong(-spread, spread + 1)).coerceAtLeast(1L)
636
+ }
637
+
638
+ fun stopReconnecting() {
639
+ if (state == "reconnecting") {
640
+ state = "disconnected"
641
+ revision += 1
642
+ settleSubscriptionWaiters()
643
+ }
644
+ rejectReconnectWaiters(MqttSessionException("ERR_CONNECTION_LOST", "Automatic reconnect is disabled"))
645
+ reconnectAttempts = 0
646
+ }
647
+
648
+ fun cancelReconnectWaiter(waiter: CompletableFuture<Map<String, Any?>>, error: Throwable) {
649
+ if (reconnectWaiters.remove(waiter)) waiter.completeExceptionally(error)
650
+ }
651
+
652
+ fun forceDisconnect() {
653
+ if (state != "closed" && state != "disconnected") {
654
+ state = "disconnected"
655
+ revision += 1
656
+ settleSubscriptionWaiters()
657
+ }
658
+ stopRequested = true
659
+ attemptId += 1
660
+ connecting?.cancel(true)
661
+ connecting = null
662
+ rejectReconnectWaiters(MqttSessionException("ERR_CONNECTION_LOST", "Connection was stopped"))
663
+ }
664
+
665
+ fun forceClose() {
666
+ stopRequested = true
667
+ attemptId += 1
668
+ connecting?.cancel(true)
669
+ connecting = null
670
+ rejectReconnectWaiters(MqttSessionException("ERR_DISPOSED", "Session was closed"))
671
+ if (state != "closed") {
672
+ state = "closed"
673
+ revision += 1
674
+ settleSubscriptionWaiters()
675
+ }
676
+ }
677
+
678
+ private fun rejectReconnectWaiters(error: Throwable) {
679
+ reconnectWaiters.forEach { it.completeExceptionally(error) }
680
+ reconnectWaiters.clear()
681
+ }
682
+
683
+ fun isReconnecting(): Boolean = state == "reconnecting"
684
+
685
+ private var reconnectAttempts = 0L
686
+ }
687
+
688
+ internal class MqttSessionRegistry {
689
+ constructor(
690
+ onMessage: (String, Long, MqttMessageData) -> Unit = { _, _, _ -> },
691
+ transportFactory: ((ParsedMqttOptions, (MqttMessageData) -> Unit) -> MqttTransport)? = null,
692
+ onTransportLost: (String, Long, MqttDisconnect?) -> Unit = { _, _, _ -> },
693
+ onSessionSnapshot: (Map<String, Any?>) -> Unit = {},
694
+ scheduler: MqttScheduler = ExecutorMqttScheduler(),
695
+ ) {
696
+ this.onMessage = onMessage
697
+ this.transportFactory = transportFactory
698
+ this.onTransportLost = onTransportLost
699
+ this.onSessionSnapshot = onSessionSnapshot
700
+ this.scheduler = scheduler
701
+ }
702
+
703
+ private val onMessage: (String, Long, MqttMessageData) -> Unit
704
+ private val transportFactory: ((ParsedMqttOptions, (MqttMessageData) -> Unit) -> MqttTransport)?
705
+ private val onTransportLost: (String, Long, MqttDisconnect?) -> Unit
706
+ private val onSessionSnapshot: (Map<String, Any?>) -> Unit
707
+ private val executor: ExecutorService = Executors.newSingleThreadExecutor { runnable ->
708
+ Thread(runnable, "react-native-mqtt-registry").apply { isDaemon = true }
709
+ }
710
+ private val scheduler: MqttScheduler
711
+ private val sessions = ConcurrentHashMap<String, MqttSession>()
712
+ private val reconnectTasks = ConcurrentHashMap<String, MqttTimer>()
713
+ private val invalidated = AtomicBoolean(false)
714
+
715
+ fun open(instanceId: String, options: ParsedMqttOptions): CompletableFuture<Map<String, Any?>> {
716
+ val result = CompletableFuture<Map<String, Any?>>()
717
+ try {
718
+ executor.execute {
719
+ if (invalidated.get()) {
720
+ result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated"))
721
+ } else {
722
+ val existing = sessions[instanceId]
723
+ if (existing != null && !existing.sameOptions(options)) {
724
+ result.completeExceptionally(MqttSessionException("ERR_OPERATION_IN_PROGRESS", "Session options cannot change"))
725
+ } else {
726
+ val session = existing ?: MqttSession(instanceId, options, onMessage = { generation, message ->
727
+ try {
728
+ executor.execute { if (!invalidated.get() && sessions.containsKey(instanceId)) onMessage(instanceId, generation, message) }
729
+ } catch (_: RejectedExecutionException) {
730
+ // Invalidation owns teardown; late SDK callbacks are ignored.
731
+ }
732
+ }, transportFactory = transportFactory, onTransportLost = { generation, reason ->
733
+ onTransportLost(instanceId, generation, reason)
734
+ try {
735
+ executor.execute { scheduleReconnect(instanceId) }
736
+ } catch (_: RejectedExecutionException) {
737
+ // Registry teardown owns the terminal state.
738
+ }
739
+ }).also { sessions[instanceId] = it }
740
+ result.complete(session.snapshot())
741
+ }
742
+ }
743
+ }
744
+ } catch (error: RejectedExecutionException) {
745
+ result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated", error))
746
+ }
747
+ return result
748
+ }
749
+
750
+ fun snapshot(instanceId: String): CompletableFuture<Map<String, Any?>> = submit {
751
+ sessions[instanceId]?.snapshot() ?: closedSnapshot(instanceId)
752
+ }
753
+
754
+ fun pendingOperations(instanceId: String): Int = sessions[instanceId]?.pendingOperations() ?: 0
755
+
756
+ fun operationLedger(instanceId: String): List<Map<String, Any?>> = sessions[instanceId]?.operationLedgerSnapshot() ?: emptyList()
757
+
758
+ fun reconnectAttempts(instanceId: String): Long = sessions[instanceId]?.reconnectAttemptsCount() ?: 0
759
+
760
+ fun close(instanceId: String): CompletableFuture<Map<String, Any?>> = submitFuture {
761
+ val session = sessions[instanceId] ?: return@submitFuture CompletableFuture.completedFuture(closedSnapshot(instanceId))
762
+ reconnectTasks.remove(instanceId)?.cancel()
763
+ val result = withDeadline(
764
+ session.close(executor),
765
+ session.disconnectTimeoutMs(),
766
+ "ERR_OPERATION_TIMEOUT",
767
+ "Close timed out",
768
+ onTimeout = { session.forceClose() },
769
+ )
770
+ result.whenComplete { _, _ ->
771
+ sessions.remove(instanceId, session)
772
+ reconnectTasks.remove(instanceId)?.cancel()
773
+ }
774
+ result
775
+ }
776
+
777
+ fun connect(instanceId: String): CompletableFuture<Map<String, Any?>> {
778
+ val result = CompletableFuture<Map<String, Any?>>()
779
+ try {
780
+ executor.execute {
781
+ if (invalidated.get()) {
782
+ result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated"))
783
+ return@execute
784
+ }
785
+ val session = sessions[instanceId]
786
+ if (session == null) {
787
+ result.completeExceptionally(MqttSessionException("ERR_NOT_CONNECTED", "Session is not open"))
788
+ return@execute
789
+ }
790
+ if (!session.isReconnecting()) reconnectTasks.remove(instanceId)?.cancel()
791
+ var timeout: MqttTimer? = null
792
+ try {
793
+ val connection = session.connect(executor)
794
+ timeout = scheduler.schedule(session.connectTimeoutMs()) {
795
+ executor.execute {
796
+ if (!result.isDone) {
797
+ val timeoutError = MqttSessionException("ERR_CONNECT_TIMEOUT", "Connection timed out")
798
+ session.timeoutConnect()
799
+ session.cancelReconnectWaiter(connection, timeoutError)
800
+ result.completeExceptionally(timeoutError)
801
+ }
802
+ }
803
+ }
804
+ connection.whenComplete { value, error ->
805
+ executor.execute {
806
+ timeout?.cancel()
807
+ if (error == null) {
808
+ result.complete(value)
809
+ } else {
810
+ onSessionSnapshot(session.snapshot())
811
+ result.completeExceptionally(error)
812
+ }
813
+ }
814
+ }
815
+ } catch (error: Throwable) {
816
+ timeout?.cancel()
817
+ onSessionSnapshot(session.snapshot())
818
+ result.completeExceptionally(error)
819
+ }
820
+ }
821
+ } catch (error: RejectedExecutionException) {
822
+ result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated", error))
823
+ }
824
+ return result
825
+ }
826
+
827
+ fun disconnect(instanceId: String): CompletableFuture<Map<String, Any?>> = submitFuture {
828
+ reconnectTasks.remove(instanceId)?.cancel()
829
+ val session = sessions[instanceId]
830
+ if (session == null) CompletableFuture.completedFuture(closedSnapshot(instanceId))
831
+ else withDeadline(
832
+ session.disconnect(executor),
833
+ session.disconnectTimeoutMs(),
834
+ "ERR_OPERATION_TIMEOUT",
835
+ "Disconnect timed out",
836
+ onTimeout = { session.forceDisconnect() },
837
+ )
838
+ }
839
+
840
+ fun setSubscription(instanceId: String, filter: String, qos: Int, noLocal: Boolean, retainAsPublished: Boolean, retainHandling: Int, intentRevision: Long): CompletableFuture<Map<String, Any?>> =
841
+ submitFuture {
842
+ val session = sessions[instanceId]
843
+ if (session == null) failedFuture(MqttSessionException("ERR_NOT_CONNECTED", "Session is not open"))
844
+ else withDeadline(
845
+ session.setSubscription(filter, qos, noLocal, retainAsPublished, retainHandling, intentRevision, executor),
846
+ session.operationTimeoutMs(),
847
+ "ERR_OPERATION_TIMEOUT",
848
+ "Subscription operation timed out",
849
+ )
850
+ }
851
+
852
+ fun removeSubscription(instanceId: String, filter: String, intentRevision: Long): CompletableFuture<Map<String, Any?>> =
853
+ submitFuture {
854
+ val session = sessions[instanceId]
855
+ if (session == null) failedFuture(MqttSessionException("ERR_NOT_CONNECTED", "Session is not open"))
856
+ else withDeadline(
857
+ session.removeSubscription(filter, intentRevision, executor),
858
+ session.operationTimeoutMs(),
859
+ "ERR_OPERATION_TIMEOUT",
860
+ "Unsubscription operation timed out",
861
+ )
862
+ }
863
+
864
+ fun awaitSubscriptions(instanceId: String, generation: Long, desiredRevision: Long): CompletableFuture<Map<String, Any?>> =
865
+ submitFuture {
866
+ val session = sessions[instanceId]
867
+ if (session == null) failedFuture(MqttSessionException("ERR_NOT_CONNECTED", "Session is not open"))
868
+ else withDeadline(
869
+ session.awaitSubscriptions(generation, desiredRevision, executor),
870
+ session.operationTimeoutMs(),
871
+ "ERR_OPERATION_TIMEOUT",
872
+ "Subscription readiness timed out",
873
+ )
874
+ }
875
+
876
+ fun publish(instanceId: String, topic: String, payload: ByteArray, qos: Int, retained: Boolean, properties: MqttPublishProperties = MqttPublishProperties()): CompletableFuture<Void> {
877
+ val result = CompletableFuture<Void>()
878
+ try {
879
+ executor.execute {
880
+ if (invalidated.get()) result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated"))
881
+ else {
882
+ val session = sessions[instanceId]
883
+ if (session == null) result.completeExceptionally(MqttSessionException("ERR_NOT_CONNECTED", "Session is not open"))
884
+ else withDeadline(
885
+ session.publish(topic, payload, qos, retained, properties, executor),
886
+ session.operationTimeoutMs(),
887
+ "ERR_OPERATION_TIMEOUT",
888
+ "Publish operation timed out",
889
+ ).whenComplete { value, error ->
890
+ executor.execute { if (error == null) result.complete(value) else result.completeExceptionally(error) }
891
+ }
892
+ }
893
+ }
894
+ } catch (error: RejectedExecutionException) {
895
+ result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated", error))
896
+ }
897
+ return result
898
+ }
899
+
900
+ fun invalidate() {
901
+ if (!invalidated.compareAndSet(false, true)) return
902
+ executor.execute {
903
+ reconnectTasks.values.forEach { it.cancel() }
904
+ reconnectTasks.clear()
905
+ sessions.values.forEach { it.close() }
906
+ sessions.clear()
907
+ executor.shutdown()
908
+ scheduler.shutdown()
909
+ }
910
+ }
911
+
912
+ private fun scheduleReconnect(instanceId: String) {
913
+ val session = sessions[instanceId] ?: return
914
+ if (!session.reconnectEnabled()) {
915
+ session.stopReconnecting()
916
+ return
917
+ }
918
+ val existing = reconnectTasks[instanceId]
919
+ if (existing != null && !existing.isDone()) return
920
+ val delay = session.reconnectDelayMs()
921
+ reconnectTasks[instanceId] = scheduler.schedule(delay) {
922
+ try {
923
+ executor.execute {
924
+ reconnectTasks.remove(instanceId)
925
+ val current = sessions[instanceId] ?: return@execute
926
+ current.connect(executor, forceAttempt = true).whenComplete { _, error ->
927
+ executor.execute {
928
+ if (error != null && sessions[instanceId] === current && current.reconnectEnabled()) scheduleReconnect(instanceId)
929
+ }
930
+ }
931
+ }
932
+ } catch (_: RejectedExecutionException) {
933
+ // Registry teardown owns the terminal state.
934
+ }
935
+ }
936
+ }
937
+
938
+ private fun submit(block: () -> Map<String, Any?>): CompletableFuture<Map<String, Any?>> {
939
+ val result = CompletableFuture<Map<String, Any?>>()
940
+ try {
941
+ executor.execute {
942
+ if (invalidated.get()) result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated"))
943
+ else runCatching(block).onSuccess(result::complete).onFailure(result::completeExceptionally)
944
+ }
945
+ } catch (error: RejectedExecutionException) {
946
+ result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated", error))
947
+ }
948
+ return result
949
+ }
950
+
951
+ private fun submitFuture(block: () -> CompletableFuture<Map<String, Any?>>): CompletableFuture<Map<String, Any?>> {
952
+ val result = CompletableFuture<Map<String, Any?>>()
953
+ try {
954
+ executor.execute {
955
+ if (invalidated.get()) result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated"))
956
+ else try { block().whenComplete { value, error -> if (error == null) result.complete(value) else result.completeExceptionally(error) } } catch (error: Throwable) { result.completeExceptionally(error) }
957
+ }
958
+ } catch (error: RejectedExecutionException) { result.completeExceptionally(MqttSessionException("ERR_DISPOSED", "Native module is invalidated", error)) }
959
+ return result
960
+ }
961
+
962
+ private fun <T> withDeadline(
963
+ source: CompletableFuture<T>,
964
+ timeoutMs: Long,
965
+ errorCode: String,
966
+ message: String,
967
+ onTimeout: () -> Unit = {},
968
+ ): CompletableFuture<T> {
969
+ val result = CompletableFuture<T>()
970
+ val timeout = scheduler.schedule(timeoutMs) {
971
+ if (result.completeExceptionally(MqttSessionException(errorCode, message))) {
972
+ onTimeout()
973
+ source.cancel(true)
974
+ }
975
+ }
976
+ source.whenComplete { value, error ->
977
+ timeout.cancel()
978
+ if (error == null) result.complete(value) else result.completeExceptionally(error)
979
+ }
980
+ return result
981
+ }
982
+
983
+ private fun closedSnapshot(instanceId: String): Map<String, Any?> = mapOf(
984
+ "instanceId" to instanceId,
985
+ "revision" to 0L,
986
+ "generation" to 0L,
987
+ "state" to "closed",
988
+ "lastConnAck" to null,
989
+ "subscriptionsRevision" to 0L,
990
+ "subscriptionsStatus" to "ready",
991
+ "deliveryStatus" to "closed",
992
+ )
993
+
994
+ private fun <T> failedFuture(error: Throwable): CompletableFuture<T> = CompletableFuture<T>().also { it.completeExceptionally(error) }
995
+ }
996
+
997
+ internal class MqttSessionException(
998
+ val errorCode: String,
999
+ message: String,
1000
+ cause: Throwable? = null,
1001
+ val operation: String? = null,
1002
+ val generation: Long? = null,
1003
+ val reasonCode: Int? = null,
1004
+ val reasonString: String? = null,
1005
+ val outcome: String? = null,
1006
+ ) : RuntimeException(message, cause)