@annadata/capacitor-mqtt-quic 0.1.8 → 0.2.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 (81) hide show
  1. package/README.md +90 -9
  2. package/android/build-wolfssl.sh +8 -2
  3. package/android/install/nghttp3-android/arm64-v8a/lib/libnghttp3.a +0 -0
  4. package/android/install/nghttp3-android/arm64-v8a/lib/libnghttp3.so +0 -0
  5. package/android/install/nghttp3-android/arm64-v8a/lib/pkgconfig/libnghttp3.pc +4 -4
  6. package/android/install/nghttp3-android/armeabi-v7a/lib/libnghttp3.a +0 -0
  7. package/android/install/nghttp3-android/armeabi-v7a/lib/libnghttp3.so +0 -0
  8. package/android/install/nghttp3-android/armeabi-v7a/lib/pkgconfig/libnghttp3.pc +4 -4
  9. package/android/install/nghttp3-android/x86_64/lib/libnghttp3.a +0 -0
  10. package/android/install/nghttp3-android/x86_64/lib/libnghttp3.so +0 -0
  11. package/android/install/nghttp3-android/x86_64/lib/pkgconfig/libnghttp3.pc +4 -4
  12. package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2.a +0 -0
  13. package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2.so +0 -0
  14. package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2_crypto_wolfssl.a +0 -0
  15. package/android/install/ngtcp2-android/arm64-v8a/lib/libngtcp2_crypto_wolfssl.so +0 -0
  16. package/android/install/ngtcp2-android/arm64-v8a/lib/pkgconfig/libngtcp2.pc +4 -4
  17. package/android/install/ngtcp2-android/arm64-v8a/lib/pkgconfig/libngtcp2_crypto_wolfssl.pc +4 -4
  18. package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2.a +0 -0
  19. package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2.so +0 -0
  20. package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2_crypto_wolfssl.a +0 -0
  21. package/android/install/ngtcp2-android/armeabi-v7a/lib/libngtcp2_crypto_wolfssl.so +0 -0
  22. package/android/install/ngtcp2-android/armeabi-v7a/lib/pkgconfig/libngtcp2.pc +4 -4
  23. package/android/install/ngtcp2-android/armeabi-v7a/lib/pkgconfig/libngtcp2_crypto_wolfssl.pc +4 -4
  24. package/android/install/ngtcp2-android/x86_64/lib/libngtcp2.a +0 -0
  25. package/android/install/ngtcp2-android/x86_64/lib/libngtcp2.so +0 -0
  26. package/android/install/ngtcp2-android/x86_64/lib/libngtcp2_crypto_wolfssl.a +0 -0
  27. package/android/install/ngtcp2-android/x86_64/lib/libngtcp2_crypto_wolfssl.so +0 -0
  28. package/android/install/ngtcp2-android/x86_64/lib/pkgconfig/libngtcp2.pc +4 -4
  29. package/android/install/ngtcp2-android/x86_64/lib/pkgconfig/libngtcp2_crypto_wolfssl.pc +4 -4
  30. package/android/install/wolfssl-android/arm64-v8a/bin/wolfssl-config +1 -1
  31. package/android/install/wolfssl-android/arm64-v8a/lib/libwolfssl.a +0 -0
  32. package/android/install/wolfssl-android/arm64-v8a/lib/libwolfssl.la +1 -1
  33. package/android/install/wolfssl-android/arm64-v8a/lib/pkgconfig/wolfssl.pc +1 -1
  34. package/android/install/wolfssl-android/armeabi-v7a/bin/wolfssl-config +1 -1
  35. package/android/install/wolfssl-android/armeabi-v7a/lib/libwolfssl.a +0 -0
  36. package/android/install/wolfssl-android/armeabi-v7a/lib/libwolfssl.la +1 -1
  37. package/android/install/wolfssl-android/armeabi-v7a/lib/pkgconfig/wolfssl.pc +1 -1
  38. package/android/install/wolfssl-android/x86_64/bin/wolfssl-config +1 -1
  39. package/android/install/wolfssl-android/x86_64/lib/libwolfssl.a +0 -0
  40. package/android/install/wolfssl-android/x86_64/lib/libwolfssl.la +1 -1
  41. package/android/install/wolfssl-android/x86_64/lib/pkgconfig/wolfssl.pc +1 -1
  42. package/android/src/main/cpp/ngtcp2_jni.cpp +94 -19
  43. package/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt +75 -3
  44. package/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt +270 -80
  45. package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt +33 -2
  46. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt +25 -15
  47. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt +1 -1
  48. package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt +1 -1
  49. package/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt +80 -5
  50. package/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt +4 -0
  51. package/dist/esm/definitions.d.ts +8 -0
  52. package/dist/esm/definitions.d.ts.map +1 -1
  53. package/dist/esm/web.d.ts +5 -1
  54. package/dist/esm/web.d.ts.map +1 -1
  55. package/dist/esm/web.js +6 -0
  56. package/dist/esm/web.js.map +1 -1
  57. package/dist/plugin.cjs.js +6 -0
  58. package/dist/plugin.cjs.js.map +1 -1
  59. package/dist/plugin.js +6 -0
  60. package/dist/plugin.js.map +1 -1
  61. package/docs/diff-node_modules-vs-standalone-android-src.patch +1031 -0
  62. package/ios/Sources/MqttQuicPlugin/Client/MQTTClient.swift +4 -0
  63. package/ios/Sources/MqttQuicPlugin/MqttQuicPlugin.swift +14 -8
  64. package/ios/Sources/MqttQuicPlugin/Transport/QUICStreamAdapter.swift +2 -7
  65. package/ios/build-wolfssl.sh +8 -3
  66. package/ios/libs/libnghttp3.a +0 -0
  67. package/ios/libs/libngtcp2.a +0 -0
  68. package/ios/libs/libngtcp2_crypto_wolfssl.a +0 -0
  69. package/ios/libs/libwolfssl.a +0 -0
  70. package/ios/libs-simulator/libnghttp3.a +0 -0
  71. package/ios/libs-simulator/libngtcp2.a +0 -0
  72. package/ios/libs-simulator/libngtcp2_crypto_wolfssl.a +0 -0
  73. package/ios/libs-simulator/libwolfssl.a +0 -0
  74. package/ios/libs-simulator-x86_64/libnghttp3.a +0 -0
  75. package/ios/libs-simulator-x86_64/libngtcp2.a +0 -0
  76. package/ios/libs-simulator-x86_64/libngtcp2_crypto_wolfssl.a +0 -0
  77. package/ios/libs-simulator-x86_64/libwolfssl.a +0 -0
  78. package/package.json +1 -1
  79. package/ios/libs/MqttQuicLibs.xcframework/Info.plist +0 -44
  80. package/ios/libs/MqttQuicLibs.xcframework/ios-arm64/libmqttquic_native_device.a +0 -0
  81. package/ios/libs/MqttQuicLibs.xcframework/ios-arm64_x86_64-simulator/libmqttquic_native_simulator.a +0 -0
@@ -1,5 +1,6 @@
1
1
  package ai.annadata.mqttquic.client
2
2
 
3
+ import android.util.Log
3
4
  import ai.annadata.mqttquic.mqtt.MQTT5PropertyType
4
5
  import ai.annadata.mqttquic.mqtt.MQTTConnAckCode
5
6
  import ai.annadata.mqttquic.mqtt.MQTTMessageType
@@ -15,6 +16,7 @@ import ai.annadata.mqttquic.transport.MQTTStreamReader
15
16
  import ai.annadata.mqttquic.transport.MQTTStreamWriter
16
17
  import ai.annadata.mqttquic.transport.QUICStreamReader
17
18
  import ai.annadata.mqttquic.transport.QUICStreamWriter
19
+ import kotlinx.coroutines.CompletableDeferred
18
20
  import kotlinx.coroutines.CoroutineScope
19
21
  import kotlinx.coroutines.Dispatchers
20
22
  import kotlinx.coroutines.delay
@@ -24,6 +26,9 @@ import kotlinx.coroutines.runBlocking
24
26
  import kotlinx.coroutines.sync.Mutex
25
27
  import kotlinx.coroutines.sync.withLock
26
28
  import kotlinx.coroutines.SupervisorJob
29
+ import kotlinx.coroutines.CancellationException
30
+ import kotlinx.coroutines.TimeoutCancellationException
31
+ import kotlinx.coroutines.withTimeout
27
32
 
28
33
  /**
29
34
  * High-level MQTT client: connect, publish, subscribe, disconnect.
@@ -62,6 +67,13 @@ class MQTTClient {
62
67
  var onPublish: ((String, ByteArray) -> Unit)? = null
63
68
  /** Per-connection Topic Alias map (alias -> topic name) for MQTT 5.0 incoming PUBLISH. */
64
69
  private val topicAliasMap = mutableMapOf<Int, String>()
70
+ /** Pending SUBACK by packet ID. Message loop completes with (fullPacket, hdrLen). Single reader: only message loop reads stream. */
71
+ private val pendingSubacks = mutableMapOf<Int, CompletableDeferred<Pair<ByteArray, Int>>>()
72
+ /** Pending UNSUBACK by packet ID. Message loop completes when UNSUBACK is read. */
73
+ private val pendingUnsubacks = mutableMapOf<Int, CompletableDeferred<Unit>>()
74
+ /** Pending PINGRESP for sendMqttPing(). Message loop completes when PINGRESP is read. */
75
+ @Volatile
76
+ private var pendingPingresp: CompletableDeferred<Unit>? = null
65
77
  private val lock = Mutex()
66
78
  private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
67
79
 
@@ -77,6 +89,29 @@ class MQTTClient {
77
89
  /** Assigned Client Identifier from CONNACK when client sent empty ClientID; null otherwise. */
78
90
  fun getAssignedClientIdentifier(): String? = runBlocking { lock.withLock { assignedClientIdentifier } }
79
91
 
92
+ /** Resolved IP used for the current/last QUIC connection (from native getaddrinfo). Used by plugin to cache for reconnect when Java DNS fails. */
93
+ fun getLastResolvedAddress(): String? = (quicClient as? NGTCP2Client)?.getLastResolvedAddress()
94
+
95
+ /** Read full MQTT fixed header (1 byte type + 1–4 bytes remaining length per MQTT v5.0 §2.1.4). Returns (msgType, remLen, fixedHeaderBytes). */
96
+ private suspend fun readFixedHeader(r: MQTTStreamReader): Triple<Byte, Int, ByteArray> {
97
+ Log.i("MQTTClient", "readFixedHeader: requesting first byte")
98
+ var fixed = r.readexactly(1).toMutableList()
99
+ val firstByte = fixed[0].toInt() and 0xFF
100
+ Log.i("MQTTClient", "readFixedHeader: got type byte 0x${Integer.toHexString(firstByte)} ($firstByte)")
101
+ repeat(5) { // 1 type + up to 4 remaining-length bytes (Variable Byte Integer)
102
+ try {
103
+ val (rem, _) = MQTTProtocol.decodeRemainingLength(fixed.toByteArray(), 1)
104
+ Log.i("MQTTClient", "readFixedHeader: decoded remLen=$rem fixedSize=${fixed.size}")
105
+ return Triple(fixed[0], rem, fixed.toByteArray())
106
+ } catch (_: IllegalArgumentException) {
107
+ if (fixed.size > 5) throw IllegalArgumentException("Invalid remaining length")
108
+ fixed.addAll(r.readexactly(1).toList())
109
+ Log.i("MQTTClient", "readFixedHeader: added byte, fixedSize=${fixed.size}")
110
+ }
111
+ }
112
+ throw IllegalArgumentException("Invalid remaining length")
113
+ }
114
+
80
115
  suspend fun connect(
81
116
  host: String,
82
117
  port: Int,
@@ -85,7 +120,8 @@ class MQTTClient {
85
120
  password: String?,
86
121
  cleanSession: Boolean,
87
122
  keepalive: Int,
88
- sessionExpiryInterval: Int? = null
123
+ sessionExpiryInterval: Int? = null,
124
+ connectAddress: String? = null
89
125
  ) {
90
126
  lock.withLock {
91
127
  if (state == State.CONNECTING) {
@@ -109,7 +145,7 @@ class MQTTClient {
109
145
  } else {
110
146
  QuicClientStub(connack.toList())
111
147
  }
112
- quic.connect(host, port)
148
+ quic.connect(host, port, connectAddress)
113
149
  val s = quic.openStream()
114
150
  val r = QUICStreamReader(s)
115
151
  val w = QUICStreamWriter(s)
@@ -146,50 +182,118 @@ class MQTTClient {
146
182
  w.write(connectData)
147
183
  w.drain()
148
184
 
149
- // MQTT 5.0: server may send AUTH before CONNACK; loop until CONNACK
185
+ // MQTT 5.0: server may send AUTH before CONNACK; loop until CONNACK. Timeout so we don't block 30s until ERR_IDLE_CLOSE.
186
+ // Efficient path: drain stream (read until empty), then parse first complete packet; repeat until CONNACK or timeout.
150
187
  var full: ByteArray
151
188
  var hdrLen: Int
152
- if (activeProtocolVersion == MQTTProtocolLevel.V5) {
153
- while (true) {
154
- val fixed = r.readexactly(2)
155
- val (msgType, remLen, hLen) = MQTTProtocol.parseFixedHeader(fixed)
156
- val rest = r.readexactly(remLen)
157
- full = fixed + rest
158
- hdrLen = hLen
159
- when (msgType) {
160
- MQTTMessageType.CONNACK -> break
161
- MQTTMessageType.AUTH -> {
162
- lock.withLock { state = State.ERROR }
163
- try {
164
- w.write(MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.BAD_AUTHENTICATION_METHOD_DISC))
165
- w.drain()
166
- w.close()
167
- } catch (_: Exception) { /* ignore */ }
168
- throw IllegalArgumentException("Enhanced authentication not supported")
169
- }
170
- MQTTMessageType.DISCONNECT -> {
171
- lock.withLock { state = State.ERROR }
172
- throw IllegalArgumentException("Server sent DISCONNECT before CONNACK")
189
+ val connackTimeoutMs = 15_000L
190
+ try {
191
+ if (activeProtocolVersion == MQTTProtocolLevel.V5) {
192
+ Log.i("MQTTClient", "reading CONNACK (MQTT5) timeout=${connackTimeoutMs}ms (drain then parse)")
193
+ withTimeout(connackTimeoutMs) {
194
+ while (true) {
195
+ if (r is QUICStreamReader) {
196
+ r.drain()
197
+ val avail = r.available()
198
+ Log.i("MQTTClient", "CONNACK loop: after drain available=$avail")
199
+ val packet = r.tryConsumeNextPacket()
200
+ if (packet != null) {
201
+ val (msgType, _, fixedLen) = MQTTProtocol.parseFixedHeader(packet.copyOf(minOf(5, packet.size)))
202
+ val typeByte = msgType.toInt() and 0xFF
203
+ Log.i("MQTTClient", "CONNACK loop: packet type=0x${Integer.toHexString(typeByte)} len=${packet.size} hdrLen=$fixedLen")
204
+ when (typeByte) {
205
+ 0x20 -> {
206
+ full = packet
207
+ hdrLen = fixedLen
208
+ return@withTimeout
209
+ }
210
+ 0xF0 -> {
211
+ lock.withLock { state = State.ERROR }
212
+ try {
213
+ w.write(MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.BAD_AUTHENTICATION_METHOD_DISC))
214
+ w.drain()
215
+ w.close()
216
+ } catch (_: Exception) { /* ignore */ }
217
+ throw IllegalArgumentException("Enhanced authentication not supported")
218
+ }
219
+ 0xE0 -> {
220
+ lock.withLock { state = State.ERROR }
221
+ throw IllegalArgumentException("Server sent DISCONNECT before CONNACK")
222
+ }
223
+ else -> Log.i("MQTTClient", "CONNACK loop: skipping non-CONNACK packet type=0x${Integer.toHexString(typeByte)}")
224
+ }
225
+ } else {
226
+ // Only delay when no data; if we have data but no packet, retry soon (avoid suspend then timeout before next iteration)
227
+ if (avail == 0) delay(50) else delay(10)
228
+ }
229
+ } else {
230
+ // Fallback for non-QUIC reader (e.g. mock)
231
+ val (msgType, remLen, fixed) = readFixedHeader(r)
232
+ val typeByte = fixed[0].toInt() and 0xFF
233
+ val rest = r.readexactly(remLen)
234
+ full = fixed + rest
235
+ hdrLen = fixed.size
236
+ when (typeByte) {
237
+ 0x20 -> return@withTimeout
238
+ 0xF0 -> {
239
+ lock.withLock { state = State.ERROR }
240
+ try {
241
+ w.write(MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.BAD_AUTHENTICATION_METHOD_DISC))
242
+ w.drain()
243
+ w.close()
244
+ } catch (_: Exception) { /* ignore */ }
245
+ throw IllegalArgumentException("Enhanced authentication not supported")
246
+ }
247
+ 0xE0 -> {
248
+ lock.withLock { state = State.ERROR }
249
+ throw IllegalArgumentException("Server sent DISCONNECT before CONNACK")
250
+ }
251
+ else -> { /* skip; continue */ }
252
+ }
253
+ }
173
254
  }
174
- else -> {
175
- lock.withLock { state = State.ERROR }
176
- throw IllegalArgumentException("Expected CONNACK or AUTH, got $msgType")
255
+ }
256
+ Log.i("MQTTClient", "got CONNACK (MQTT5) (fullLen=${full.size} hdrLen=$hdrLen)")
257
+ } else {
258
+ Log.i("MQTTClient", "reading CONNACK (3.1.1) timeout=${connackTimeoutMs}ms")
259
+ withTimeout(connackTimeoutMs) {
260
+ if (r is QUICStreamReader) {
261
+ while (true) {
262
+ r.drain()
263
+ val packet = r.tryConsumeNextPacket()
264
+ if (packet != null) {
265
+ val (msgType, _, fixedLen) = MQTTProtocol.parseFixedHeader(packet.copyOf(minOf(5, packet.size)))
266
+ if (msgType == MQTTMessageType.CONNACK) {
267
+ full = packet
268
+ hdrLen = fixedLen
269
+ break
270
+ }
271
+ lock.withLock { state = State.ERROR }
272
+ throw IllegalArgumentException("expected CONNACK, got $msgType")
273
+ }
274
+ delay(50)
275
+ }
276
+ } else {
277
+ val (msgType, remLen, fixed) = readFixedHeader(r)
278
+ val rest = r.readexactly(remLen)
279
+ full = fixed + rest
280
+ hdrLen = fixed.size
281
+ if (msgType != MQTTMessageType.CONNACK) {
282
+ lock.withLock { state = State.ERROR }
283
+ throw IllegalArgumentException("expected CONNACK, got $msgType")
284
+ }
177
285
  }
286
+ Log.i("MQTTClient", "got CONNACK (3.1.1)")
178
287
  }
179
288
  }
180
- } else {
181
- val fixed = r.readexactly(2)
182
- val (msgType, remLen, hLen) = MQTTProtocol.parseFixedHeader(fixed)
183
- val rest = r.readexactly(remLen)
184
- full = fixed + rest
185
- hdrLen = hLen
186
- if (msgType != MQTTMessageType.CONNACK) {
187
- lock.withLock { state = State.ERROR }
188
- throw IllegalArgumentException("expected CONNACK, got $msgType")
189
- }
289
+ } catch (e: TimeoutCancellationException) {
290
+ lock.withLock { state = State.ERROR }
291
+ Log.w("MQTTClient", "CONNACK read timed out after ${connackTimeoutMs}ms", e)
292
+ throw IllegalStateException("CONNACK read timed out. Drain+parse did not receive a complete CONNACK in time. Ask server to send full CONNACK (loop writev_stream until all bytes sent); or check network.", e)
190
293
  }
191
294
 
192
295
  if (activeProtocolVersion == MQTTProtocolLevel.V5) {
296
+ Log.i("MQTTClient", "parsing CONNACK v5 (offset=$hdrLen)")
193
297
  val (_, reasonCode, props) = MQTT5Protocol.parseConnackV5(full, hdrLen)
194
298
  if (reasonCode != MQTT5ReasonCode.SUCCESS) {
195
299
  lock.withLock { state = State.ERROR }
@@ -210,21 +314,37 @@ class MQTTClient {
210
314
  }
211
315
 
212
316
  lock.withLock { state = State.CONNECTED }
317
+ Log.i("MQTTClient", "state=CONNECTED, starting message and keepalive loops")
213
318
  startMessageLoop()
214
319
  startKeepaliveLoop()
215
320
  } catch (e: Exception) {
216
- val wr = lock.withLock {
321
+ val (wr, quic) = lock.withLock {
217
322
  val w = writer
323
+ val q = quicClient
218
324
  quicClient = null
219
325
  stream = null
220
326
  reader = null
221
327
  writer = null
222
328
  state = State.ERROR
223
- w
329
+ Pair(w, q)
224
330
  }
225
331
  try {
226
332
  wr?.close()
227
333
  } catch (_: Exception) { /* ignore */ }
334
+ // Skip quic.close() on timeout/cancellation: server may have already sent idle close, and native close() can crash. Prefer leak over crash.
335
+ val isTimeoutOrCancel = e is TimeoutCancellationException ||
336
+ e is CancellationException ||
337
+ e.cause is TimeoutCancellationException ||
338
+ (e.message?.contains("Timed out", ignoreCase = true) == true)
339
+ if (!isTimeoutOrCancel) {
340
+ try {
341
+ quic?.close()
342
+ } catch (ex: Exception) {
343
+ Log.w("MQTTClient", "Error closing QUIC connection on connect failure", ex)
344
+ }
345
+ } else {
346
+ Log.i("MQTTClient", "Skipping quic.close() on timeout/cancellation to avoid native crash")
347
+ }
228
348
  throw e
229
349
  }
230
350
  }
@@ -260,59 +380,66 @@ class MQTTClient {
260
380
 
261
381
  suspend fun subscribe(topic: String, qos: Int, subscriptionIdentifier: Int? = null) {
262
382
  if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
263
- val (r, w, version) = lock.withLock { Triple(reader, writer, activeProtocolVersion) }
264
- if (r == null || w == null) throw IllegalStateException("no reader/writer")
383
+ val (w, version) = lock.withLock { writer to activeProtocolVersion }
384
+ if (w == null) throw IllegalStateException("no writer")
265
385
 
266
386
  val pid = nextPacketIdUsed()
267
- val data: ByteArray
268
- if (version == MQTTProtocolLevel.V5) {
269
- data = MQTT5Protocol.buildSubscribeV5(pid, topic, qos, subscriptionIdentifier)
270
- } else {
271
- data = MQTTProtocol.buildSubscribe(pid, topic, qos)
272
- }
273
- w.write(data)
274
- w.drain()
275
-
276
- val fixed = r.readexactly(2)
277
- val (_, remLen, hdrLen) = MQTTProtocol.parseFixedHeader(fixed)
278
- val rest = r.readexactly(remLen)
279
- val full = fixed + rest
280
-
281
- if (version == MQTTProtocolLevel.V5) {
282
- val (_, reasonCodes, _) = MQTT5Protocol.parseSubackV5(full, hdrLen)
283
- if (reasonCodes.isNotEmpty()) {
284
- val firstRC = reasonCodes[0]
285
- if (firstRC != MQTT5ReasonCode.GRANTED_QOS_0 && firstRC != MQTT5ReasonCode.GRANTED_QOS_1 && firstRC != MQTT5ReasonCode.GRANTED_QOS_2) {
286
- throw IllegalArgumentException("SUBACK error $firstRC")
387
+ val deferred = CompletableDeferred<Pair<ByteArray, Int>>()
388
+ lock.withLock { pendingSubacks[pid] = deferred }
389
+ try {
390
+ val data: ByteArray
391
+ if (version == MQTTProtocolLevel.V5) {
392
+ data = MQTT5Protocol.buildSubscribeV5(pid, topic, qos, subscriptionIdentifier)
393
+ } else {
394
+ data = MQTTProtocol.buildSubscribe(pid, topic, qos)
395
+ }
396
+ w.write(data)
397
+ w.drain()
398
+
399
+ val (full, hdrLen) = withTimeout(15_000L) { deferred.await() }
400
+
401
+ if (version == MQTTProtocolLevel.V5) {
402
+ val (_, reasonCodes, _) = MQTT5Protocol.parseSubackV5(full, hdrLen)
403
+ if (reasonCodes.isNotEmpty()) {
404
+ val firstRC = reasonCodes[0]
405
+ if (firstRC != MQTT5ReasonCode.GRANTED_QOS_0 && firstRC != MQTT5ReasonCode.GRANTED_QOS_1 && firstRC != MQTT5ReasonCode.GRANTED_QOS_2) {
406
+ throw IllegalArgumentException("SUBACK error $firstRC")
407
+ }
287
408
  }
409
+ } else {
410
+ val (_, rc, _) = MQTTProtocol.parseSuback(full, hdrLen)
411
+ if (rc > 0x02) throw IllegalArgumentException("SUBACK error $rc")
288
412
  }
289
- } else {
290
- val (_, rc, _) = MQTTProtocol.parseSuback(full, hdrLen)
291
- if (rc > 0x02) throw IllegalArgumentException("SUBACK error $rc")
413
+ } finally {
414
+ lock.withLock { pendingSubacks.remove(pid) }
292
415
  }
293
416
  }
294
417
 
295
418
  suspend fun unsubscribe(topic: String) {
296
419
  if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
297
- val (r, w, version) = lock.withLock {
420
+ val (w, version) = lock.withLock {
298
421
  subscribedTopics.remove(topic)
299
- Triple(reader, writer, activeProtocolVersion)
422
+ writer to activeProtocolVersion
300
423
  }
301
- if (r == null || w == null) throw IllegalStateException("no reader/writer")
424
+ if (w == null) throw IllegalStateException("no writer")
302
425
 
303
426
  val pid = nextPacketIdUsed()
304
- val data: ByteArray
305
- if (version == MQTTProtocolLevel.V5) {
306
- data = MQTT5Protocol.buildUnsubscribeV5(pid, listOf(topic))
307
- } else {
308
- data = MQTTProtocol.buildUnsubscribe(pid, listOf(topic))
309
- }
310
- w.write(data)
311
- w.drain()
427
+ val deferred = CompletableDeferred<Unit>()
428
+ lock.withLock { pendingUnsubacks[pid] = deferred }
429
+ try {
430
+ val data: ByteArray
431
+ if (version == MQTTProtocolLevel.V5) {
432
+ data = MQTT5Protocol.buildUnsubscribeV5(pid, listOf(topic))
433
+ } else {
434
+ data = MQTTProtocol.buildUnsubscribe(pid, listOf(topic))
435
+ }
436
+ w.write(data)
437
+ w.drain()
312
438
 
313
- val fixed = r.readexactly(2)
314
- val (_, remLen, _) = MQTTProtocol.parseFixedHeader(fixed)
315
- r.readexactly(remLen)
439
+ withTimeout(15_000L) { deferred.await() }
440
+ } finally {
441
+ lock.withLock { pendingUnsubacks.remove(pid) }
442
+ }
316
443
  }
317
444
 
318
445
  suspend fun disconnect() {
@@ -323,6 +450,8 @@ class MQTTClient {
323
450
  job?.cancel()
324
451
  job?.join()
325
452
 
453
+ failPendingSubacksUnsubacks(IllegalStateException("Disconnected"))
454
+
326
455
  val (w, version) = lock.withLock {
327
456
  val wr = writer
328
457
  val v = activeProtocolVersion
@@ -369,6 +498,46 @@ class MQTTClient {
369
498
  pid
370
499
  }
371
500
 
501
+ private suspend fun failPendingSubacksUnsubacks(cause: Throwable) {
502
+ lock.withLock {
503
+ pendingSubacks.values.forEach { it.completeExceptionally(cause) }
504
+ pendingSubacks.clear()
505
+ pendingUnsubacks.values.forEach { it.completeExceptionally(cause) }
506
+ pendingUnsubacks.clear()
507
+ pendingPingresp?.completeExceptionally(cause)
508
+ pendingPingresp = null
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Send MQTT PINGREQ and wait for PINGRESP. Resets server's idle timer.
514
+ * Returns true if PINGRESP received within timeout, false on timeout or error.
515
+ */
516
+ suspend fun sendMqttPing(timeoutMs: Long = 5000): Boolean {
517
+ if (getState() != State.CONNECTED) return false
518
+ val (w, defToAwait) = lock.withLock {
519
+ val existing = pendingPingresp
520
+ if (existing != null) return@withLock (null as MQTTStreamWriter?) to existing
521
+ val deferred = CompletableDeferred<Unit>()
522
+ pendingPingresp = deferred
523
+ writer to deferred
524
+ }
525
+ return try {
526
+ if (w != null) {
527
+ w.write(MQTTProtocol.buildPingreq())
528
+ w.drain()
529
+ }
530
+ if (defToAwait != null) withTimeout(timeoutMs) { defToAwait.await() }
531
+ true
532
+ } catch (e: Exception) {
533
+ lock.withLock {
534
+ pendingPingresp?.completeExceptionally(e)
535
+ pendingPingresp = null
536
+ }
537
+ false
538
+ }
539
+ }
540
+
372
541
  /** Send PINGREQ at effectiveKeepalive interval so server sees activity and does not close (idle/keepalive). [MQTT-3.1.2-20] */
373
542
  private fun startKeepaliveLoop() {
374
543
  keepaliveJob?.cancel()
@@ -394,11 +563,24 @@ class MQTTClient {
394
563
  while (isActive) {
395
564
  val r = lock.withLock { reader } ?: break
396
565
  try {
397
- val fixed = r.readexactly(2)
398
- val (msgType, remLen, _) = MQTTProtocol.parseFixedHeader(fixed)
566
+ val (msgType, remLen, fixed) = readFixedHeader(r)
399
567
  val rest = r.readexactly(remLen)
400
568
  val type = (msgType.toInt() and 0xF0).toByte()
401
569
  when (type) {
570
+ MQTTMessageType.SUBACK -> {
571
+ if (rest.size >= 2) {
572
+ val pid = ((rest[0].toInt() and 0xFF) shl 8) or (rest[1].toInt() and 0xFF)
573
+ val full = fixed + rest
574
+ val hdrLen = fixed.size
575
+ lock.withLock { pendingSubacks.remove(pid)?.complete(Pair(full, hdrLen)) }
576
+ }
577
+ }
578
+ MQTTMessageType.UNSUBACK -> {
579
+ if (rest.size >= 2) {
580
+ val pid = ((rest[0].toInt() and 0xFF) shl 8) or (rest[1].toInt() and 0xFF)
581
+ lock.withLock { pendingUnsubacks.remove(pid)?.complete(Unit) }
582
+ }
583
+ }
402
584
  MQTTMessageType.DISCONNECT -> {
403
585
  val reasonCode = if (rest.isNotEmpty()) rest[0].toInt() and 0xFF else 0x00
404
586
  lock.withLock {
@@ -412,6 +594,7 @@ class MQTTClient {
412
594
  topicAliasMap.clear()
413
595
  state = if (reasonCode >= 0x80) State.ERROR else State.DISCONNECTED
414
596
  }
597
+ failPendingSubacksUnsubacks(IllegalStateException("Server sent DISCONNECT"))
415
598
  break
416
599
  }
417
600
  MQTTMessageType.PINGREQ -> {
@@ -422,6 +605,12 @@ class MQTTClient {
422
605
  it.drain()
423
606
  }
424
607
  }
608
+ MQTTMessageType.PINGRESP -> {
609
+ lock.withLock {
610
+ pendingPingresp?.complete(Unit)
611
+ pendingPingresp = null
612
+ }
613
+ }
425
614
  MQTTMessageType.PUBLISH -> {
426
615
  val qos = (msgType.toInt() shr 1) and 0x03
427
616
  val (topic, packetId, payload) = lock.withLock {
@@ -473,6 +662,7 @@ class MQTTClient {
473
662
  topicAliasMap.clear()
474
663
  state = State.DISCONNECTED
475
664
  }
665
+ failPendingSubacksUnsubacks(e)
476
666
  }
477
667
  break
478
668
  }
@@ -40,10 +40,10 @@ object MQTTProtocol {
40
40
  val b = data[i].toInt() and 0xFF
41
41
  len += (b and 0x7F) * mul
42
42
  i++
43
- if ((b and 0x80) == 0) return@repeat
43
+ if ((b and 0x80) == 0) return len to (i - offset)
44
44
  mul *= 128
45
45
  }
46
- return len to (i - offset)
46
+ throw IllegalArgumentException("Invalid remaining length (max 4 bytes)")
47
47
  }
48
48
 
49
49
  fun encodeString(s: String): ByteArray {
@@ -75,6 +75,37 @@ object MQTTProtocol {
75
75
  return Triple(msgType, rem, 1 + consumed)
76
76
  }
77
77
 
78
+ /**
79
+ * Returns total MQTT packet length (fixed header + payload) if buffer has at least a decodable fixed header, else null.
80
+ * Use when draining stream: accumulate bytes, then call this; when buffer.size >= length, you have a complete packet.
81
+ */
82
+ fun getNextPacketLength(buffer: ByteArray): Int? {
83
+ if (buffer.size < 2) return null
84
+ // CONNACK (0x20): often single-byte remaining length (e.g. 0x20 = 32 → total 34). Handle that first.
85
+ if (buffer[0].toInt() and 0xFF == 0x20) {
86
+ val b1 = buffer[1].toInt() and 0xFF
87
+ if ((b1 and 0x80) == 0) {
88
+ val rem = b1
89
+ val total = 1 + 1 + rem
90
+ if (total <= buffer.size) return total
91
+ } else {
92
+ try {
93
+ val (rem, consumed) = decodeRemainingLength(buffer, 1)
94
+ val total = 1 + consumed + rem
95
+ if (total <= buffer.size) return total
96
+ } catch (_: IllegalArgumentException) { /* fall through */ }
97
+ }
98
+ }
99
+ for (len in minOf(5, buffer.size) downTo 2) {
100
+ try {
101
+ val (_, rem, fixedLen) = parseFixedHeader(buffer.copyOf(len))
102
+ val total = fixedLen + rem
103
+ if (total in 1..buffer.size) return total
104
+ } catch (_: IllegalArgumentException) { /* need more bytes for remaining length */ }
105
+ }
106
+ return null
107
+ }
108
+
78
109
  fun buildConnect(
79
110
  clientId: String,
80
111
  username: String? = null,
@@ -40,22 +40,28 @@ class NGTCP2Client : QuicClient {
40
40
 
41
41
  // Native methods (implemented in ngtcp2_jni.cpp)
42
42
  private external fun nativeCreateConnection(host: String, port: Int): Long
43
+ private external fun nativeCreateConnectionWithAddress(hostnameForTls: String, connectAddress: String, port: Int): Long
43
44
  private external fun nativeConnect(connHandle: Long): Int
44
45
  private external fun nativeOpenStream(connHandle: Long): Long
45
46
  private external fun nativeWriteStream(connHandle: Long, streamId: Long, data: ByteArray): Int
46
47
  private external fun nativeReadStream(connHandle: Long, streamId: Long): ByteArray?
47
48
  private external fun nativeClose(connHandle: Long)
48
49
  private external fun nativeIsConnected(connHandle: Long): Boolean
49
- internal external fun nativeCloseStream(connHandle: Long, streamId: Long): Int
50
+ // Public to avoid Kotlin internal-name mangling (nativeCloseStream$module) which breaks JNI lookup
51
+ external fun nativeCloseStream(connHandle: Long, streamId: Long): Int
50
52
  @JvmName("nativeGetLastError")
51
- internal external fun nativeGetLastError(connHandle: Long): String
52
-
53
+ external fun nativeGetLastError(connHandle: Long): String
54
+ private external fun nativeGetLastResolvedAddress(connHandle: Long): String?
55
+
56
+ /** Resolved IP used for this connection (set after init_socket in native). Use for reconnect cache when Java DNS fails. */
57
+ fun getLastResolvedAddress(): String? = if (connHandle != 0L) nativeGetLastResolvedAddress(connHandle) else null
58
+
53
59
  // Connection state
54
60
  private var connHandle: Long = 0
55
61
  private var isConnected: Boolean = false
56
62
  private val streams = mutableMapOf<Long, NGTCP2Stream>()
57
63
 
58
- override suspend fun connect(host: String, port: Int) {
64
+ override suspend fun connect(host: String, port: Int, connectAddress: String?) {
59
65
  if (!isAvailable()) {
60
66
  throw IllegalStateException("ngtcp2 native library is not loaded")
61
67
  }
@@ -63,8 +69,12 @@ class NGTCP2Client : QuicClient {
63
69
  throw IllegalStateException("Already connected")
64
70
  }
65
71
 
66
- // Create native connection
67
- connHandle = nativeCreateConnection(host, port)
72
+ // Create native connection (use pre-resolved IP when given to avoid "No address" on reconnect)
73
+ connHandle = if (!connectAddress.isNullOrBlank()) {
74
+ nativeCreateConnectionWithAddress(host, connectAddress, port)
75
+ } else {
76
+ nativeCreateConnection(host, port)
77
+ }
68
78
  if (connHandle == 0L) {
69
79
  throw IllegalStateException("Failed to create QUIC connection")
70
80
  }
@@ -147,19 +157,19 @@ internal class NGTCP2Stream(
147
157
 
148
158
  private var isClosed: Boolean = false
149
159
 
160
+ /**
161
+ * Read up to maxBytes from the stream. Returns immediately with whatever is
162
+ * currently available (possibly empty). This allows drain()-then-parse logic
163
+ * to complete: first read returns CONNACK bytes, second read returns empty
164
+ * so drain() breaks and tryConsumeNextPacket() can run. Never blocks waiting
165
+ * for more data (native recv_buf is already populated by the event loop).
166
+ */
150
167
  override suspend fun read(maxBytes: Int): ByteArray {
151
168
  if (isClosed) {
152
169
  throw IllegalStateException("Stream is closed")
153
170
  }
154
-
155
- while (!isClosed) {
156
- val data = client.readStreamData(streamId)
157
- if (data != null && data.isNotEmpty()) {
158
- return data
159
- }
160
- delay(5)
161
- }
162
- return ByteArray(0)
171
+ val data = client.readStreamData(streamId) ?: return ByteArray(0)
172
+ return data
163
173
  }
164
174
 
165
175
  override suspend fun write(data: ByteArray) {
@@ -36,7 +36,7 @@ class QuicClientStub(private val initialReadData: List<Byte> = emptyList()) : Qu
36
36
  private var buffer: MockStreamBuffer? = null
37
37
  private var streamId = 0L
38
38
 
39
- override suspend fun connect(host: String, port: Int) {
39
+ override suspend fun connect(host: String, port: Int, connectAddress: String?) {
40
40
  buffer = MockStreamBuffer(initialReadData.toByteArray())
41
41
  streamId = 0L
42
42
  }
@@ -15,7 +15,7 @@ interface QuicStream {
15
15
  * QUIC client: connect, TLS handshake, open one bidirectional stream.
16
16
  */
17
17
  interface QuicClient {
18
- suspend fun connect(host: String, port: Int)
18
+ suspend fun connect(host: String, port: Int, connectAddress: String? = null)
19
19
  suspend fun openStream(): QuicStream
20
20
  suspend fun close()
21
21
  }