livekit-client 2.22.3 → 2.23.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 (272) hide show
  1. package/README.md +35 -0
  2. package/dist/livekit-client.e2ee.worker.js +1 -1
  3. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  4. package/dist/livekit-client.e2ee.worker.mjs +71 -8
  5. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  6. package/dist/livekit-client.esm.mjs +1746 -547
  7. package/dist/livekit-client.esm.mjs.map +1 -1
  8. package/dist/livekit-client.fm.worker.js +1 -1
  9. package/dist/livekit-client.fm.worker.js.map +1 -1
  10. package/dist/livekit-client.fm.worker.mjs +2 -1
  11. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  12. package/dist/livekit-client.umd.js +1 -1
  13. package/dist/livekit-client.umd.js.map +1 -1
  14. package/dist/src/api/SignalClient.d.ts +4 -2
  15. package/dist/src/api/SignalClient.d.ts.map +1 -1
  16. package/dist/src/api/WebSocketStream.d.ts +1 -0
  17. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  18. package/dist/src/api/utils.d.ts +1 -1
  19. package/dist/src/api/utils.d.ts.map +1 -1
  20. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  21. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  22. package/dist/src/e2ee/worker/naluUtils.d.ts +12 -1
  23. package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
  24. package/dist/src/index.d.ts +2 -2
  25. package/dist/src/index.d.ts.map +1 -1
  26. package/dist/src/options.d.ts +14 -0
  27. package/dist/src/options.d.ts.map +1 -1
  28. package/dist/src/room/DefaultReconnectPolicy.d.ts.map +1 -1
  29. package/dist/src/room/PCTransport.d.ts +100 -7
  30. package/dist/src/room/PCTransport.d.ts.map +1 -1
  31. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  32. package/dist/src/room/RTCEngine.d.ts +16 -0
  33. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  34. package/dist/src/room/Room.d.ts +54 -1
  35. package/dist/src/room/Room.d.ts.map +1 -1
  36. package/dist/src/room/data-channel/DataChannelManager.d.ts +9 -8
  37. package/dist/src/room/data-channel/DataChannelManager.d.ts.map +1 -1
  38. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts +25 -12
  39. package/dist/src/room/data-channel/FlowControlledDataChannel.d.ts.map +1 -1
  40. package/dist/src/room/data-channel/LossyDataChannel.d.ts.map +1 -1
  41. package/dist/src/room/data-channel/ReliableDataChannel.d.ts.map +1 -1
  42. package/dist/src/room/data-stream/incoming/IncomingDataStreamManager.d.ts.map +1 -1
  43. package/dist/src/room/defaults.d.ts.map +1 -1
  44. package/dist/src/room/errors.d.ts +18 -0
  45. package/dist/src/room/errors.d.ts.map +1 -1
  46. package/dist/src/room/events.d.ts +3 -2
  47. package/dist/src/room/events.d.ts.map +1 -1
  48. package/dist/src/room/participant/LocalParticipant.d.ts +4 -0
  49. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  50. package/dist/src/room/participant/Participant.d.ts +1 -1
  51. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  52. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  53. package/dist/src/room/rpc/client/RpcClientManager.d.ts.map +1 -1
  54. package/dist/src/room/rpc/utils.d.ts.map +1 -1
  55. package/dist/src/room/track/LocalAudioTrack.d.ts +24 -1
  56. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  57. package/dist/src/room/track/LocalTrack.d.ts +18 -0
  58. package/dist/src/room/track/LocalTrack.d.ts.map +1 -1
  59. package/dist/src/room/track/LocalVideoTrack.d.ts +9 -0
  60. package/dist/src/room/track/LocalVideoTrack.d.ts.map +1 -1
  61. package/dist/src/room/track/RemoteAudioTrack.d.ts +6 -0
  62. package/dist/src/room/track/RemoteAudioTrack.d.ts.map +1 -1
  63. package/dist/src/room/track/processor/types.d.ts +11 -0
  64. package/dist/src/room/track/processor/types.d.ts.map +1 -1
  65. package/dist/src/room/track/utils.d.ts +13 -0
  66. package/dist/src/room/track/utils.d.ts.map +1 -1
  67. package/dist/src/room/utils.d.ts +60 -1
  68. package/dist/src/room/utils.d.ts.map +1 -1
  69. package/dist/src/test/compressionStreamStubs.d.ts +12 -0
  70. package/dist/src/test/compressionStreamStubs.d.ts.map +1 -0
  71. package/dist/ts4.2/api/SignalClient.d.ts +187 -185
  72. package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -85
  73. package/dist/ts4.2/api/WebSocketStream.d.ts +34 -33
  74. package/dist/ts4.2/api/utils.d.ts +9 -9
  75. package/dist/ts4.2/connectionHelper/ConnectionCheck.d.ts +30 -30
  76. package/dist/ts4.2/connectionHelper/checks/Checker.d.ts +62 -62
  77. package/dist/ts4.2/connectionHelper/checks/cloudRegion.d.ts +18 -18
  78. package/dist/ts4.2/connectionHelper/checks/connectionProtocol.d.ts +20 -20
  79. package/dist/ts4.2/connectionHelper/checks/publishAudio.d.ts +6 -6
  80. package/dist/ts4.2/connectionHelper/checks/publishVideo.d.ts +7 -7
  81. package/dist/ts4.2/connectionHelper/checks/reconnect.d.ts +6 -6
  82. package/dist/ts4.2/connectionHelper/checks/turn.d.ts +6 -6
  83. package/dist/ts4.2/connectionHelper/checks/webrtc.d.ts +6 -6
  84. package/dist/ts4.2/connectionHelper/checks/websocket.d.ts +6 -6
  85. package/dist/ts4.2/e2ee/E2eeManager.d.ts +111 -111
  86. package/dist/ts4.2/e2ee/KeyProvider.d.ts +54 -54
  87. package/dist/ts4.2/e2ee/constants.d.ts +21 -21
  88. package/dist/ts4.2/e2ee/errors.d.ts +12 -12
  89. package/dist/ts4.2/e2ee/events.d.ts +39 -39
  90. package/dist/ts4.2/e2ee/index.d.ts +6 -6
  91. package/dist/ts4.2/e2ee/types.d.ts +231 -231
  92. package/dist/ts4.2/e2ee/utils.d.ts +32 -32
  93. package/dist/ts4.2/e2ee/worker/DataCryptor.d.ts +16 -16
  94. package/dist/ts4.2/e2ee/worker/ErrorRateLimiter.d.ts +21 -21
  95. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +209 -209
  96. package/dist/ts4.2/e2ee/worker/ParticipantKeyHandler.d.ts +81 -84
  97. package/dist/ts4.2/e2ee/worker/e2ee.worker.d.ts +2 -2
  98. package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +39 -28
  99. package/dist/ts4.2/e2ee/worker/sifPayload.d.ts +23 -23
  100. package/dist/ts4.2/frameMetadata/FrameMetadataManager.d.ts +53 -53
  101. package/dist/ts4.2/frameMetadata/frameMetadata.d.ts +33 -33
  102. package/dist/ts4.2/frameMetadata/types.d.ts +62 -62
  103. package/dist/ts4.2/frameMetadata/utils.d.ts +9 -9
  104. package/dist/ts4.2/frameMetadata/worker/frameMetadata.worker.d.ts +2 -2
  105. package/dist/ts4.2/index.d.ts +76 -76
  106. package/dist/ts4.2/logger.d.ts +66 -66
  107. package/dist/ts4.2/options.d.ts +163 -149
  108. package/dist/ts4.2/room/BackOffStrategy.d.ts +27 -27
  109. package/dist/ts4.2/room/DefaultReconnectPolicy.d.ts +8 -8
  110. package/dist/ts4.2/room/DeviceManager.d.ts +12 -12
  111. package/dist/ts4.2/room/PCTransport.d.ts +268 -175
  112. package/dist/ts4.2/room/PCTransportManager.d.ts +77 -77
  113. package/dist/ts4.2/room/RTCEngine.d.ts +277 -262
  114. package/dist/ts4.2/room/ReconnectPolicy.d.ts +24 -24
  115. package/dist/ts4.2/room/RegionUrlProvider.d.ts +36 -36
  116. package/dist/ts4.2/room/Room.d.ts +407 -365
  117. package/dist/ts4.2/room/attribute-typings.d.ts +35 -35
  118. package/dist/ts4.2/room/data-channel/DataChannelManager.d.ts +63 -62
  119. package/dist/ts4.2/room/data-channel/FlowControlledDataChannel.d.ts +107 -95
  120. package/dist/ts4.2/room/data-channel/LossyDataChannel.d.ts +41 -41
  121. package/dist/ts4.2/room/data-channel/ReliableDataChannel.d.ts +57 -57
  122. package/dist/ts4.2/room/data-channel/types.d.ts +12 -12
  123. package/dist/ts4.2/room/data-stream/compression.d.ts +49 -49
  124. package/dist/ts4.2/room/data-stream/constants.d.ts +18 -18
  125. package/dist/ts4.2/room/data-stream/incoming/IncomingDataStreamManager.d.ts +29 -29
  126. package/dist/ts4.2/room/data-stream/incoming/StreamReader.d.ts +80 -80
  127. package/dist/ts4.2/room/data-stream/outgoing/OutgoingDataStreamManager.d.ts +73 -73
  128. package/dist/ts4.2/room/data-stream/outgoing/StreamWriter.d.ts +16 -16
  129. package/dist/ts4.2/room/data-stream/outgoing/header-utils.d.ts +18 -18
  130. package/dist/ts4.2/room/data-track/LocalDataTrack.d.ts +79 -80
  131. package/dist/ts4.2/room/data-track/RemoteDataTrack.d.ts +50 -50
  132. package/dist/ts4.2/room/data-track/depacketizer.d.ts +59 -59
  133. package/dist/ts4.2/room/data-track/frame.d.ts +22 -22
  134. package/dist/ts4.2/room/data-track/handle.d.ts +27 -27
  135. package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +124 -124
  136. package/dist/ts4.2/room/data-track/incoming/errors.d.ts +24 -24
  137. package/dist/ts4.2/room/data-track/incoming/pipeline.d.ts +41 -41
  138. package/dist/ts4.2/room/data-track/incoming/types.d.ts +20 -20
  139. package/dist/ts4.2/room/data-track/outgoing/OutgoingDataTrackManager.d.ts +126 -126
  140. package/dist/ts4.2/room/data-track/outgoing/errors.d.ts +74 -74
  141. package/dist/ts4.2/room/data-track/outgoing/pipeline.d.ts +23 -23
  142. package/dist/ts4.2/room/data-track/outgoing/types.d.ts +48 -48
  143. package/dist/ts4.2/room/data-track/packet/constants.d.ts +20 -20
  144. package/dist/ts4.2/room/data-track/packet/errors.d.ts +40 -40
  145. package/dist/ts4.2/room/data-track/packet/extensions.d.ts +69 -69
  146. package/dist/ts4.2/room/data-track/packet/index.d.ts +98 -98
  147. package/dist/ts4.2/room/data-track/packet/serializable.d.ts +13 -13
  148. package/dist/ts4.2/room/data-track/packetizer.d.ts +43 -43
  149. package/dist/ts4.2/room/data-track/track-interfaces.d.ts +23 -23
  150. package/dist/ts4.2/room/data-track/types.d.ts +22 -22
  151. package/dist/ts4.2/room/data-track/utils.d.ts +60 -60
  152. package/dist/ts4.2/room/debounce.d.ts +11 -11
  153. package/dist/ts4.2/room/defaults.d.ts +9 -9
  154. package/dist/ts4.2/room/errors.d.ts +180 -162
  155. package/dist/ts4.2/room/events.d.ts +638 -637
  156. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +291 -287
  157. package/dist/ts4.2/room/participant/Participant.d.ts +142 -144
  158. package/dist/ts4.2/room/participant/ParticipantTrackPermission.d.ts +26 -26
  159. package/dist/ts4.2/room/participant/RemoteParticipant.d.ts +85 -86
  160. package/dist/ts4.2/room/participant/publishUtils.d.ts +53 -53
  161. package/dist/ts4.2/room/rpc/client/RpcClientManager.d.ts +43 -43
  162. package/dist/ts4.2/room/rpc/client/events.d.ts +8 -8
  163. package/dist/ts4.2/room/rpc/index.d.ts +7 -7
  164. package/dist/ts4.2/room/rpc/server/RpcServerManager.d.ts +44 -44
  165. package/dist/ts4.2/room/rpc/server/events.d.ts +8 -8
  166. package/dist/ts4.2/room/rpc/utils.d.ts +131 -131
  167. package/dist/ts4.2/room/stats.d.ts +71 -71
  168. package/dist/ts4.2/room/statsSummary.d.ts +13 -13
  169. package/dist/ts4.2/room/timers.d.ts +12 -12
  170. package/dist/ts4.2/room/token-source/TokenSource.d.ts +82 -82
  171. package/dist/ts4.2/room/token-source/types.d.ts +70 -70
  172. package/dist/ts4.2/room/token-source/utils.d.ts +8 -8
  173. package/dist/ts4.2/room/track/FrameMetadataExtractor.d.ts +19 -19
  174. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +62 -42
  175. package/dist/ts4.2/room/track/LocalTrack.d.ts +143 -125
  176. package/dist/ts4.2/room/track/LocalTrackPublication.d.ts +42 -42
  177. package/dist/ts4.2/room/track/LocalVideoTrack.d.ts +109 -100
  178. package/dist/ts4.2/room/track/RemoteAudioTrack.d.ts +61 -55
  179. package/dist/ts4.2/room/track/RemoteTrack.d.ts +38 -38
  180. package/dist/ts4.2/room/track/RemoteTrackPublication.d.ts +72 -75
  181. package/dist/ts4.2/room/track/RemoteVideoTrack.d.ts +73 -76
  182. package/dist/ts4.2/room/track/Track.d.ts +156 -166
  183. package/dist/ts4.2/room/track/TrackPublication.d.ts +75 -81
  184. package/dist/ts4.2/room/track/create.d.ts +25 -25
  185. package/dist/ts4.2/room/track/facingMode.d.ts +41 -41
  186. package/dist/ts4.2/room/track/options.d.ts +403 -403
  187. package/dist/ts4.2/room/track/processor/types.d.ts +42 -31
  188. package/dist/ts4.2/room/track/record.d.ts +13 -13
  189. package/dist/ts4.2/room/track/types.d.ts +28 -28
  190. package/dist/ts4.2/room/track/utils.d.ts +60 -47
  191. package/dist/ts4.2/room/types.d.ts +127 -127
  192. package/dist/ts4.2/room/utils.d.ts +273 -214
  193. package/dist/ts4.2/test/MockMediaStreamTrack.d.ts +26 -26
  194. package/dist/ts4.2/test/compressionStreamStubs.d.ts +12 -0
  195. package/dist/ts4.2/test/mocks.d.ts +12 -12
  196. package/dist/ts4.2/test/promiseState.d.ts +12 -12
  197. package/dist/ts4.2/test/signalToken.d.ts +26 -26
  198. package/dist/ts4.2/type-polyfills/non-shared-typed-arrays.d.ts +2 -2
  199. package/dist/ts4.2/utils/AsyncQueue.d.ts +23 -23
  200. package/dist/ts4.2/utils/TypedPromise.d.ts +17 -17
  201. package/dist/ts4.2/utils/abort-signal-polyfill.d.ts +13 -13
  202. package/dist/ts4.2/utils/browserParser.d.ts +13 -13
  203. package/dist/ts4.2/utils/camelToSnakeCase.d.ts +8 -8
  204. package/dist/ts4.2/utils/cloneDeep.d.ts +2 -2
  205. package/dist/ts4.2/utils/dataPacketBuffer.d.ts +34 -34
  206. package/dist/ts4.2/utils/deferrable-map.d.ts +32 -32
  207. package/dist/ts4.2/utils/machineInspector.d.ts +54 -54
  208. package/dist/ts4.2/utils/serializer.d.ts +48 -48
  209. package/dist/ts4.2/utils/subscribeToEvents.d.ts +15 -15
  210. package/dist/ts4.2/utils/ttlmap.d.ts +20 -20
  211. package/dist/ts4.2/version.d.ts +15 -15
  212. package/package.json +5 -4
  213. package/src/api/SignalClient.e2e.test.ts +1 -3
  214. package/src/api/SignalClient.test.ts +394 -10
  215. package/src/api/SignalClient.ts +132 -76
  216. package/src/api/WebSocketStream.test.ts +11 -0
  217. package/src/api/WebSocketStream.ts +22 -1
  218. package/src/api/utils.ts +1 -1
  219. package/src/e2ee/E2eeManager.test.ts +43 -0
  220. package/src/e2ee/E2eeManager.ts +6 -2
  221. package/src/e2ee/worker/FrameCryptor.test.ts +212 -90
  222. package/src/e2ee/worker/FrameCryptor.ts +21 -4
  223. package/src/e2ee/worker/naluUtils.test.ts +123 -0
  224. package/src/e2ee/worker/naluUtils.ts +57 -2
  225. package/src/index.ts +2 -0
  226. package/src/options.ts +16 -0
  227. package/src/room/DefaultReconnectPolicy.test.ts +58 -0
  228. package/src/room/DefaultReconnectPolicy.ts +14 -2
  229. package/src/room/PCTransport.test.ts +327 -9
  230. package/src/room/PCTransport.ts +339 -36
  231. package/src/room/PCTransportManager.ts +3 -1
  232. package/src/room/RTCEngine.test.ts +318 -2
  233. package/src/room/RTCEngine.ts +126 -12
  234. package/src/room/Room.test.ts +148 -6
  235. package/src/room/Room.ts +312 -80
  236. package/src/room/data-channel/DataChannelManager.test.ts +24 -7
  237. package/src/room/data-channel/DataChannelManager.ts +43 -38
  238. package/src/room/data-channel/FlowControlledDataChannel.test.ts +60 -18
  239. package/src/room/data-channel/FlowControlledDataChannel.ts +40 -22
  240. package/src/room/data-channel/LossyDataChannel.test.ts +38 -0
  241. package/src/room/data-channel/LossyDataChannel.ts +10 -1
  242. package/src/room/data-channel/ReliableDataChannel.test.ts +72 -0
  243. package/src/room/data-channel/ReliableDataChannel.ts +16 -5
  244. package/src/room/data-stream/incoming/IncomingDataStreamManager.test.ts +47 -20
  245. package/src/room/data-stream/incoming/IncomingDataStreamManager.ts +16 -5
  246. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.test.ts +60 -39
  247. package/src/room/data-stream/outgoing/OutgoingDataStreamManager.ts +4 -4
  248. package/src/room/defaults.ts +1 -0
  249. package/src/room/errors.test.ts +36 -0
  250. package/src/room/errors.ts +27 -0
  251. package/src/room/events.ts +2 -1
  252. package/src/room/participant/LocalParticipant.test.ts +136 -1
  253. package/src/room/participant/LocalParticipant.ts +66 -17
  254. package/src/room/participant/Participant.ts +6 -3
  255. package/src/room/participant/publishUtils.ts +0 -19
  256. package/src/room/participant/restartOnDefaultAudioDevice.test.ts +177 -0
  257. package/src/room/rpc/client/RpcClientManager.test.ts +51 -0
  258. package/src/room/rpc/client/RpcClientManager.ts +32 -15
  259. package/src/room/rpc/utils.test.ts +32 -0
  260. package/src/room/rpc/utils.ts +7 -0
  261. package/src/room/track/LocalAudioTrack.ts +101 -10
  262. package/src/room/track/LocalTrack.ts +87 -19
  263. package/src/room/track/LocalVideoTrack.ts +17 -3
  264. package/src/room/track/RemoteAudioTrack.ts +59 -14
  265. package/src/room/track/RemoteVideoTrack.test.ts +210 -0
  266. package/src/room/track/RemoteVideoTrack.ts +29 -3
  267. package/src/room/track/processor/types.ts +11 -0
  268. package/src/room/track/utils.test.ts +206 -2
  269. package/src/room/track/utils.ts +117 -5
  270. package/src/room/utils.test.ts +250 -0
  271. package/src/room/utils.ts +152 -19
  272. package/src/test/compressionStreamStubs.ts +53 -0
@@ -24,13 +24,24 @@ export interface DataChannelManagerOptions {
24
24
  onBufferStatusChanged: (kind: DataChannelKind, isLow: boolean) => void;
25
25
  }
26
26
 
27
+ /** Unhooks every handler the manager may have wired onto `dc`, ahead of closing it. */
28
+ function clearHandlers(dc: RTCDataChannel) {
29
+ dc.onbufferedamountlow = null;
30
+ dc.onclose = null;
31
+ dc.onclosing = null;
32
+ dc.onerror = null;
33
+ dc.onmessage = null;
34
+ dc.onopen = null;
35
+ }
36
+
27
37
  /**
28
38
  * Owns the engine's data channels: the three flow-controlled publisher channel wrappers (which
29
39
  * live for the engine's lifetime and have RTCDataChannel handles attached/detached as peer
30
40
  * connections come and go) plus the subscriber-side receive handles adopted by label.
31
41
  *
32
- * Handle turnover goes through {@link FlowControlledDataChannel.attach}/`detach`, which reject
33
- * parked headroom waiters as a built-in — there is no separate invalidation step to forget.
42
+ * Handle turnover goes through {@link FlowControlledDataChannel.attach}/`detach`, which close the
43
+ * released handle and reject parked headroom waiters as a built-in — there is no separate
44
+ * invalidation step to forget.
34
45
  */
35
46
  export class DataChannelManager {
36
47
  readonly reliable: ReliableDataChannel;
@@ -110,17 +121,16 @@ export class DataChannelManager {
110
121
 
111
122
  /**
112
123
  * Creates the three publisher data channels on the given transport, wires their handlers, and
113
- * attaches them to the wrappers — attaching rejects any waiters still parked on replaced
114
- * channel objects.
124
+ * attaches them to the wrappers — attaching closes any replaced channel objects and rejects the
125
+ * waiters still parked on them.
115
126
  */
116
127
  createPublisherChannels(pcManager: PCTransportManager) {
117
- // clear old data channel callbacks if recreate
128
+ // Recreating (the Safari null-id path): attaching the new handles below closes the old ones,
129
+ // so unhook them first — the old channel's close must not report an unexpected close.
118
130
  for (const channel of [this.lossy, this.reliable, this.dataTrack]) {
119
131
  const old = channel.channelHandle;
120
132
  if (old) {
121
- old.onmessage = null;
122
- old.onerror = null;
123
- old.onclose = null;
133
+ clearHandlers(old);
124
134
  }
125
135
  }
126
136
 
@@ -192,41 +202,36 @@ export class DataChannelManager {
192
202
  }
193
203
 
194
204
  /**
195
- * Tears down all channels for a peer-connection cleanup: rejects parked waiters (detach — the
196
- * spec allows `pc.close()` to transition channels to 'closed' without firing events, so waiting
197
- * for browser close events is not an option), strips handlers, closes the handles, and resets
198
- * the reliable session state.
205
+ * Tears down all channels for a peer-connection cleanup: strips handlers, closes the handles,
206
+ * and resets the reliable session state. Detaching the publisher handles closes them and
207
+ * rejects parked waiters (the spec allows `pc.close()` to transition channels to 'closed'
208
+ * without firing events, so waiting for browser close events is not an option).
199
209
  */
200
210
  teardown() {
201
- const dcCleanup = (dc: RTCDataChannel | undefined) => {
202
- if (!dc) {
203
- return;
204
- }
205
-
206
- // Detach the data channel handlers before closing anything. Closing a peer connection tears
207
- // down the SCTP transport, which can dispatch `error`/`close` events on the still-open data
208
- // channels; if our handlers are still attached at that point, handleDataError logs a spurious
209
- // "Unknown DataChannel error" during an otherwise graceful disconnect. Removing the handlers
210
- // before dc.close()/pcManager.close() makes this deterministic regardless of how/when the
211
- // browser dispatches those teardown events. See livekit/client-sdk-js#1953.
212
- dc.onbufferedamountlow = null;
213
- dc.onclose = null;
214
- dc.onclosing = null;
215
- dc.onerror = null;
216
- dc.onmessage = null;
217
- dc.onopen = null;
218
-
219
- dc.close();
220
- };
221
-
222
- for (const channel of [this.lossy, this.reliable, this.dataTrack]) {
211
+ // Detach the data channel handlers before closing anything. Closing a peer connection tears
212
+ // down the SCTP transport, which can dispatch `error`/`close` events on the still-open data
213
+ // channels; if our handlers are still attached at that point, handleDataError logs a spurious
214
+ // "Unknown DataChannel error" during an otherwise graceful disconnect. Removing the handlers
215
+ // before dc.close()/pcManager.close() makes this deterministic regardless of how/when the
216
+ // browser dispatches those teardown events. See livekit/client-sdk-js#1953.
217
+ const publisherDataChannels = [this.lossy, this.reliable, this.dataTrack];
218
+ const subscriberDataChannels = [this.lossySub, this.reliableSub, this.dataTrackSub];
219
+
220
+ // Publisher handles are closed by detach, which also rejects parked waiters.
221
+ for (const channel of publisherDataChannels) {
223
222
  const dc = channel.channelHandle;
223
+ if (dc) {
224
+ clearHandlers(dc);
225
+ }
224
226
  channel.detach('peer connections cleaned up');
225
- dcCleanup(dc);
226
227
  }
227
- dcCleanup(this.lossySub);
228
- dcCleanup(this.reliableSub);
229
- dcCleanup(this.dataTrackSub);
228
+ // Subscriber handles have no wrapper, so close them directly.
229
+ for (const dc of subscriberDataChannels) {
230
+ if (dc) {
231
+ clearHandlers(dc);
232
+ dc.close();
233
+ }
234
+ }
230
235
  this.lossySub = undefined;
231
236
  this.reliableSub = undefined;
232
237
  this.dataTrackSub = undefined;
@@ -7,8 +7,17 @@ class FakeDataChannel extends EventTarget {
7
7
  bufferedAmount = 0;
8
8
 
9
9
  bufferedAmountLowThreshold = 64;
10
+
11
+ readyState: RTCDataChannelState = 'open';
12
+
13
+ // Like the real thing, close() moves to 'closing' synchronously.
14
+ close = vi.fn(() => {
15
+ this.readyState = 'closing';
16
+ });
10
17
  }
11
18
 
19
+ const handle = (dc: FakeDataChannel) => dc as unknown as RTCDataChannel;
20
+
12
21
  const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
13
22
 
14
23
  // Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`)
@@ -62,37 +71,54 @@ describe('FlowControlledDataChannel', () => {
62
71
 
63
72
  it('reports watermark status against the given channel', () => {
64
73
  const { channel, dc } = makeChannel();
65
- const handle = dc as unknown as RTCDataChannel;
66
74
  dc.bufferedAmount = 0;
67
- expect(channel.isBelowHighWaterMark(handle)).toBe(true);
68
- expect(channel.isBelowLowWaterMark(handle)).toBe(true);
75
+ expect(channel.isBelowHighWaterMark(handle(dc))).toBe(true);
76
+ expect(channel.isBelowLowWaterMark(handle(dc))).toBe(true);
69
77
 
70
78
  dc.bufferedAmount = 512; // between low (64) and high (1024)
71
- expect(channel.isBelowHighWaterMark(handle)).toBe(true);
72
- expect(channel.isBelowLowWaterMark(handle)).toBe(false);
79
+ expect(channel.isBelowHighWaterMark(handle(dc))).toBe(true);
80
+ expect(channel.isBelowLowWaterMark(handle(dc))).toBe(false);
73
81
 
74
82
  dc.bufferedAmount = 2048;
75
- expect(channel.isBelowHighWaterMark(handle)).toBe(false);
83
+ expect(channel.isBelowHighWaterMark(handle(dc))).toBe(false);
76
84
  });
77
85
 
78
- it('waiting for headroom without a handle rejects with a connection error', async () => {
79
- const { channel } = makeChannel({ dc: null });
80
- await expect(channel.waitForHeadroomWithLock()).rejects.toBeInstanceOf(
86
+ it('rejects a wait on a handle that is not open', async () => {
87
+ const { channel, dc } = makeChannel();
88
+ dc.readyState = 'connecting';
89
+ await expect(channel.waitForHeadroomWithLock(handle(dc))).rejects.toBeInstanceOf(
81
90
  UnexpectedConnectionState,
82
91
  );
83
92
  });
84
93
 
94
+ it('rejects a wait queued on the lock once its handle is replaced', async () => {
95
+ const { channel, dc: oldDc } = makeChannel();
96
+ oldDc.bufferedAmount = 2048;
97
+ const parked = channel.waitForHeadroomWithLock(handle(oldDc));
98
+ parked.catch(() => {});
99
+ // Queued behind the parked waiter, so the abort on replacement doesn't reach it — the closed
100
+ // handle does, once it gets the lock.
101
+ const queued = channel.waitForHeadroomWithLock(handle(oldDc));
102
+ queued.catch(() => {});
103
+ await tick();
104
+
105
+ channel.attach(handle(new FakeDataChannel()));
106
+
107
+ await expect(parked).rejects.toBeInstanceOf(UnexpectedConnectionState);
108
+ await expect(queued).rejects.toBeInstanceOf(UnexpectedConnectionState);
109
+ });
110
+
85
111
  it('resolves immediately while below the high-water mark', async () => {
86
112
  const { channel, dc } = makeChannel();
87
113
  dc.bufferedAmount = 1024;
88
- await expect(channel.waitForHeadroomWithLock()).resolves.toBeUndefined();
114
+ await expect(channel.waitForHeadroomWithLock(handle(dc))).resolves.toBeUndefined();
89
115
  });
90
116
 
91
117
  it('parks above the high-water mark and resumes on bufferedamountlow', async () => {
92
118
  const { channel, dc } = makeChannel();
93
119
  dc.bufferedAmount = 2048;
94
120
  const resolved = vi.fn();
95
- const wait = channel.waitForHeadroomWithLock().then(resolved);
121
+ const wait = channel.waitForHeadroomWithLock(handle(dc)).then(resolved);
96
122
  await tick();
97
123
  expect(resolved).not.toHaveBeenCalled();
98
124
 
@@ -105,7 +131,7 @@ describe('FlowControlledDataChannel', () => {
105
131
  it('rejects a parked waiter when the channel closes', async () => {
106
132
  const { channel, dc } = makeChannel();
107
133
  dc.bufferedAmount = 2048;
108
- const wait = channel.waitForHeadroomWithLock();
134
+ const wait = channel.waitForHeadroomWithLock(handle(dc));
109
135
  wait.catch(() => {});
110
136
  await tick();
111
137
 
@@ -116,7 +142,7 @@ describe('FlowControlledDataChannel', () => {
116
142
  it('rejects a parked waiter on invalidateWaiters and recovers with a fresh controller', async () => {
117
143
  const { channel, dc } = makeChannel();
118
144
  dc.bufferedAmount = 2048;
119
- const wait = channel.waitForHeadroomWithLock();
145
+ const wait = channel.waitForHeadroomWithLock(handle(dc));
120
146
  wait.catch(() => {});
121
147
  await tick();
122
148
 
@@ -125,13 +151,29 @@ describe('FlowControlledDataChannel', () => {
125
151
 
126
152
  // Fresh controller: the gate is usable again and the lock was released.
127
153
  dc.bufferedAmount = 0;
128
- await expect(channel.waitForHeadroomWithLock()).resolves.toBeUndefined();
154
+ await expect(channel.waitForHeadroomWithLock(handle(dc))).resolves.toBeUndefined();
155
+ });
156
+
157
+ it('closes the handle it lets go of, on replacement and on detach', () => {
158
+ const { channel, dc: first } = makeChannel();
159
+ const second = new FakeDataChannel();
160
+
161
+ channel.attach(first as unknown as RTCDataChannel); // re-attaching the same handle is a no-op
162
+ expect(first.close).not.toHaveBeenCalled();
163
+
164
+ channel.attach(second as unknown as RTCDataChannel);
165
+ expect(first.close).toHaveBeenCalledTimes(1);
166
+ expect(second.close).not.toHaveBeenCalled();
167
+
168
+ channel.detach();
169
+ expect(second.close).toHaveBeenCalledTimes(1);
170
+ expect(channel.channelHandle).toBeUndefined();
129
171
  });
130
172
 
131
173
  it('rejects immediately when the engine is closed', async () => {
132
- const { channel, state } = makeChannel();
174
+ const { channel, dc, state } = makeChannel();
133
175
  state.engineClosed = true;
134
- await expect(channel.waitForHeadroomWithLock()).rejects.toBeInstanceOf(
176
+ await expect(channel.waitForHeadroomWithLock(handle(dc))).rejects.toBeInstanceOf(
135
177
  UnexpectedConnectionState,
136
178
  );
137
179
  });
@@ -140,8 +182,8 @@ describe('FlowControlledDataChannel', () => {
140
182
  const { channel, dc } = makeChannel();
141
183
  dc.bufferedAmount = 2048;
142
184
  const order: number[] = [];
143
- const first = channel.waitForHeadroomWithLock().then(() => order.push(1));
144
- const second = channel.waitForHeadroomWithLock().then(() => order.push(2));
185
+ const first = channel.waitForHeadroomWithLock(handle(dc)).then(() => order.push(1));
186
+ const second = channel.waitForHeadroomWithLock(handle(dc)).then(() => order.push(2));
145
187
  await tick();
146
188
 
147
189
  // One drain event wakes the head waiter; the second re-checks under the lock and, with the
@@ -30,7 +30,8 @@ export interface FlowControlledDataChannelOptions {
30
30
  * Waiters are parked on the channel object captured at wait entry. If that object stops being
31
31
  * current — replaced or torn down — its events may never fire again, so the owner must call
32
32
  * {@link invalidateWaiters}, which aborts parked waiters (releasing the gate); the next waiter
33
- * gets a fresh controller.
33
+ * gets a fresh controller. {@link attach} and {@link detach} do this, and close the released
34
+ * handle, as part of handle turnover.
34
35
  */
35
36
  export class FlowControlledDataChannel {
36
37
  readonly kind: DataChannelKind;
@@ -67,25 +68,39 @@ export class FlowControlledDataChannel {
67
68
  }
68
69
 
69
70
  /**
70
- * Attaches the channel handle this wrapper controls. Replacing an existing handle rejects
71
- * parked waiters — their events would never fire again on the abandoned object — and installs a
72
- * fresh controller, so queued senders re-check against the new channel. Wrappers outlive their
73
- * handles: this is the one place handle turnover happens, which is what makes stranding a
74
- * waiter structurally impossible.
71
+ * Attaches the channel handle this wrapper controls. Replacing an existing handle closes it and
72
+ * rejects parked waiters — their events would never fire again on the abandoned object — and
73
+ * installs a fresh controller. Wrappers outlive their handles: this is the one place handle
74
+ * turnover happens, so a handle the wrapper lets go of is never left open, and stranding a
75
+ * waiter is structurally impossible.
75
76
  */
76
77
  attach(dc: RTCDataChannel) {
77
- if (this.handle && this.handle !== dc) {
78
- this.invalidateWaiters('data channel replaced');
79
- }
78
+ const old = this.handle;
80
79
  this.handle = dc;
80
+ if (old && old !== dc) {
81
+ this.release(old, 'data channel replaced');
82
+ }
81
83
  }
82
84
 
83
- /** Detaches the handle on teardown, rejecting parked waiters. */
85
+ /** Detaches and closes the handle on teardown, rejecting parked waiters. */
84
86
  detach(reason: string = 'data channel torn down') {
85
- if (this.handle) {
86
- this.invalidateWaiters(reason);
87
- }
87
+ const old = this.handle;
88
88
  this.handle = undefined;
89
+ if (old) {
90
+ this.release(old, reason);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Closes a handle the wrapper no longer controls. `close()` moves it to `closing` synchronously,
96
+ * so senders still holding it (queued on the headroom lock) fail the open check once they get
97
+ * the lock. Parked waiters are aborted directly instead of relying on the `close` event, which
98
+ * fires asynchronously, if at all (the spec lets `pc.close()` skip it). Strip any handlers that
99
+ * must not observe the close before calling this.
100
+ */
101
+ private release(dc: RTCDataChannel, reason: string) {
102
+ this.invalidateWaiters(reason);
103
+ dc.close();
89
104
  }
90
105
 
91
106
  protected getChannel(): RTCDataChannel | undefined {
@@ -121,30 +136,33 @@ export class FlowControlledDataChannel {
121
136
  }
122
137
 
123
138
  /**
124
- * Resolves once the caller may send on this channel: immediately while the send buffer is at or
125
- * below its high-water mark, otherwise once the buffer has drained to the low-water mark (the
139
+ * Resolves once the caller may send on `dc`: immediately while its send buffer is at or below
140
+ * the high-water mark, otherwise once the buffer has drained to the low-water mark (the
126
141
  * `bufferedamountlow` event). Callers are serialized through the headroom lock so that, when
127
142
  * the buffer drains, they refill it one at a time (up to the high-water mark) rather than all
128
143
  * sending at once and overflowing the SCTP send buffer (see livekit/client-sdk-js#1995). The
129
- * closed/buffer checks run inside the lock so queued callers proceed in FIFO order.
144
+ * checks run inside the lock so queued callers proceed in FIFO order.
145
+ *
146
+ * The caller passes the handle it resolved before queueing, and the wait is against that one
147
+ * handle throughout: if it was replaced meanwhile, it has been closed, so the wait rejects
148
+ * rather than letting the caller send on the replacement out of turn.
130
149
  */
131
- async waitForHeadroomWithLock() {
150
+ async waitForHeadroomWithLock(dc: RTCDataChannel) {
132
151
  const unlock = await this.lockHeadroom();
133
152
  try {
134
- await this.waitForHeadroomWithoutLock();
153
+ await this.waitForHeadroomWithoutLock(dc);
135
154
  } finally {
136
155
  unlock();
137
156
  }
138
157
  }
139
158
 
140
159
  /** Core wait of {@link waitForHeadroomWithLock}. The caller must hold the headroom lock. */
141
- async waitForHeadroomWithoutLock() {
160
+ async waitForHeadroomWithoutLock(dc: RTCDataChannel) {
142
161
  if (this.isEngineClosed()) {
143
162
  throw new UnexpectedConnectionState('engine closed');
144
163
  }
145
- const dc = this.getChannel();
146
- if (!dc) {
147
- throw new UnexpectedConnectionState(`DataChannel not found, kind: ${this.kind}`);
164
+ if (dc.readyState !== 'open') {
165
+ throw new UnexpectedConnectionState(`DataChannel ${this.kind} is ${dc.readyState}`);
148
166
  }
149
167
  if (this.isBelowHighWaterMark(dc)) {
150
168
  return;
@@ -8,6 +8,13 @@ class FakeDataChannel extends EventTarget {
8
8
  bufferedAmountLowThreshold = 64;
9
9
 
10
10
  send = vi.fn();
11
+
12
+ readyState: RTCDataChannelState = 'open';
13
+
14
+ // Like the real thing, close() moves to 'closing' synchronously.
15
+ close = vi.fn(() => {
16
+ this.readyState = 'closing';
17
+ });
11
18
  }
12
19
 
13
20
  // Pass `dc: null` for a handle-less channel; omit it to get a fresh one. `null` (not `undefined`)
@@ -76,6 +83,37 @@ describe('LossyDataChannel', () => {
76
83
  expect(dc.send).toHaveBeenCalledTimes(1);
77
84
  });
78
85
 
86
+ it('wait policy: drops waiting sends when the channel is replaced', async () => {
87
+ const { channel, dc: oldDc } = makeChannel('wait');
88
+ oldDc.bufferedAmount = 2048;
89
+ const parked = channel.send(new Uint8Array([1]));
90
+ const queued = channel.send(new Uint8Array([2]));
91
+ await vi.advanceTimersByTimeAsync(0);
92
+
93
+ const newDc = new FakeDataChannel();
94
+ channel.attach(newDc as unknown as RTCDataChannel);
95
+
96
+ // Both resolve: a dropped frame mustn't reject (the room doesn't handle frame send failures).
97
+ await expect(parked).resolves.toBeUndefined();
98
+ await expect(queued).resolves.toBeUndefined();
99
+ // Not on the abandoned channel, and not on the replacement, which may still be connecting.
100
+ expect(oldDc.send).not.toHaveBeenCalled();
101
+ expect(newDc.send).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('wait policy: rejects a waiting send when the engine closes', async () => {
105
+ const { channel, dc, state } = makeChannel('wait');
106
+ dc.bufferedAmount = 2048;
107
+ const send = channel.send(new Uint8Array([1]));
108
+ send.catch(() => {});
109
+ await vi.advanceTimersByTimeAsync(0);
110
+
111
+ state.engineClosed = true;
112
+ channel.detach();
113
+
114
+ await expect(send).rejects.toThrow();
115
+ });
116
+
79
117
  it('skips sends while a reconnect is underway', async () => {
80
118
  const { channel, dc, state } = makeChannel('drop');
81
119
  state.skipSends = true;
@@ -55,7 +55,16 @@ export class LossyDataChannel extends FlowControlledDataChannel {
55
55
  switch (this.bufferFullBehavior) {
56
56
  case 'wait':
57
57
  if (!this.isBelowHighWaterMark(dc)) {
58
- await this.waitForHeadroomWithLock();
58
+ try {
59
+ await this.waitForHeadroomWithLock(dc);
60
+ } catch (error) {
61
+ if (this.isEngineClosed()) {
62
+ throw error;
63
+ }
64
+ // The channel closed or was replaced while this send waited. The replacement may
65
+ // still be connecting, so drop the packet, as sends during a reconnect are.
66
+ return;
67
+ }
59
68
  }
60
69
  break;
61
70
  case 'drop':
@@ -9,6 +9,13 @@ class FakeDataChannel extends EventTarget {
9
9
  bufferedAmountLowThreshold = 64;
10
10
 
11
11
  send = vi.fn();
12
+
13
+ readyState: RTCDataChannelState = 'open';
14
+
15
+ // Like the real thing, close() moves to 'closing' synchronously.
16
+ close = vi.fn(() => {
17
+ this.readyState = 'closing';
18
+ });
12
19
  }
13
20
 
14
21
  const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
@@ -77,6 +84,33 @@ describe('ReliableDataChannel', () => {
77
84
  expect(buffer.getAll()[0].sent).toBe(false);
78
85
  });
79
86
 
87
+ it('defers a send queued on the lock to the replay when the channel is replaced, preserving order', async () => {
88
+ const { channel, dc: oldDc, buffer } = makeChannel();
89
+ oldDc.bufferedAmount = 2048; // above high mark → the first send parks
90
+ const parked = channel.send(new Uint8Array([1]), channel.nextSequence());
91
+ // Queues behind the parked send on the headroom lock; replacing the handle rejects only the
92
+ // parked waiter, so this one gets the lock afterwards.
93
+ const queued = channel.send(new Uint8Array([2]), channel.nextSequence());
94
+ await tick();
95
+
96
+ const newDc = new FakeDataChannel();
97
+ channel.attach(newDc as unknown as RTCDataChannel);
98
+
99
+ await expect(parked).resolves.toBeUndefined();
100
+ await expect(queued).resolves.toBeUndefined();
101
+ // Neither goes out ahead of the replay: sending seq 2 now would make receivers discard seq 1
102
+ // once the replay delivers it.
103
+ expect(oldDc.send).not.toHaveBeenCalled();
104
+ expect(newDc.send).not.toHaveBeenCalled();
105
+ expect(buffer.getAll().map(({ sequence, sent }) => ({ sequence, sent }))).toEqual([
106
+ { sequence: 1, sent: false },
107
+ { sequence: 2, sent: false },
108
+ ]);
109
+
110
+ await channel.replay(0);
111
+ expect(newDc.send.mock.calls.map(([data]) => data[0])).toEqual([1, 2]);
112
+ });
113
+
80
114
  it('rejects when the engine is closed while waiting', async () => {
81
115
  const { channel, dc, state } = makeChannel();
82
116
  dc.bufferedAmount = 2048;
@@ -135,6 +169,44 @@ describe('ReliableDataChannel', () => {
135
169
  expect(buffer.getAll().filter((i) => !i.sent)).toHaveLength(0);
136
170
  });
137
171
 
172
+ it('replays on the replacement handle when the channel is replaced while replay waits for the lock', async () => {
173
+ const { channel, dc: oldDc } = makeChannel();
174
+ await channel.send(new Uint8Array([1]), channel.nextSequence());
175
+ oldDc.send.mockClear();
176
+
177
+ const unlock = await channel.lockHeadroom();
178
+ const replay = channel.replay(0);
179
+ await tick();
180
+
181
+ const newDc = new FakeDataChannel();
182
+ channel.attach(newDc as unknown as RTCDataChannel);
183
+ unlock();
184
+ await replay;
185
+
186
+ expect(oldDc.send).not.toHaveBeenCalled();
187
+ expect(newDc.send).toHaveBeenCalledTimes(1);
188
+ });
189
+
190
+ it('stops the replay, keeping the rest unsent, when the channel is replaced mid-replay', async () => {
191
+ const { channel, dc: oldDc, state, buffer } = makeChannel();
192
+ state.deferring = true;
193
+ await channel.send(new Uint8Array([1]), channel.nextSequence());
194
+ await channel.send(new Uint8Array([2]), channel.nextSequence());
195
+ state.deferring = false;
196
+
197
+ oldDc.bufferedAmount = 2048; // the replay parks before its first send
198
+ const replay = channel.replay(0);
199
+ replay.catch(() => {});
200
+ await tick();
201
+
202
+ // A second replacement belongs to another resume, whose replay delivers the backlog.
203
+ channel.attach(new FakeDataChannel() as unknown as RTCDataChannel);
204
+
205
+ await expect(replay).rejects.toBeInstanceOf(UnexpectedConnectionState);
206
+ expect(oldDc.send).not.toHaveBeenCalled();
207
+ expect(buffer.getAll().every((item) => !item.sent)).toBe(true);
208
+ });
209
+
138
210
  it('holds the headroom lock across the whole replay so new sends cannot interleave', async () => {
139
211
  const { channel, dc } = makeChannel();
140
212
  (channel as unknown as { isDeferringSends: () => boolean }).isDeferringSends = () => true;
@@ -1,5 +1,6 @@
1
1
  import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays';
2
2
  import { DataPacketBuffer } from '../../utils/dataPacketBuffer';
3
+ import { UnexpectedConnectionState } from '../errors';
3
4
  import {
4
5
  FlowControlledDataChannel,
5
6
  type FlowControlledDataChannelOptions,
@@ -64,7 +65,7 @@ export class ReliableDataChannel extends FlowControlledDataChannel {
64
65
  }
65
66
 
66
67
  try {
67
- await this.waitForHeadroomWithLock();
68
+ await this.waitForHeadroomWithLock(dc);
68
69
  } catch (error) {
69
70
  if (this.isEngineClosed()) {
70
71
  // No replay is coming after an engine close — surface the failure.
@@ -72,7 +73,9 @@ export class ReliableDataChannel extends FlowControlledDataChannel {
72
73
  }
73
74
  // Transient teardown (the channel closed or was replaced while we waited): the reliable
74
75
  // channel promises delivery across resume, so queue the packet for the replay instead of
75
- // rejecting a send the app can't meaningfully retry.
76
+ // rejecting a send the app can't meaningfully retry. This also keeps the order: a send
77
+ // queued behind one aborted by a replacement fails the same way (the old handle is closed),
78
+ // so it can't reach the replacement ahead of the lower sequence the replay will deliver.
76
79
  this.messageBuffer.push({ data: msg, sequence, sent: false });
77
80
  return;
78
81
  }
@@ -96,13 +99,18 @@ export class ReliableDataChannel extends FlowControlledDataChannel {
96
99
  * receivers would then discard the remaining lower-sequence resent messages as duplicates.
97
100
  */
98
101
  async replay(lastMessageSeq: number) {
99
- const dc = this.getChannel();
100
- if (!dc) {
102
+ if (!this.getChannel()) {
101
103
  return;
102
104
  }
103
105
  this.messageBuffer.popToSequence(lastMessageSeq);
104
106
  const unlock = await this.lockHeadroom();
105
107
  try {
108
+ // Resolved only once the lock is held: the handle can be replaced while the replay waits
109
+ // for it.
110
+ const dc = this.getChannel();
111
+ if (!dc) {
112
+ throw new UnexpectedConnectionState(`DataChannel not found, kind: ${this.kind}`);
113
+ }
106
114
  // Everything left after the ack cutoff must be re-handed to the current channel.
107
115
  this.messageBuffer.markAllUnsent();
108
116
  // Drain in passes, re-scanning the live buffer each time: a send that arrives (deferred,
@@ -118,7 +126,10 @@ export class ReliableDataChannel extends FlowControlledDataChannel {
118
126
  ) {
119
127
  for (const item of batch) {
120
128
  // Respect flow control on resume too, so a large resend doesn't overflow the buffer.
121
- await this.waitForHeadroomWithoutLock();
129
+ // Against the handle resolved under the lock: if it is replaced mid-replay (another
130
+ // resume), it has been closed, so this throws and that resume's replay picks up the
131
+ // entries still flagged unsent.
132
+ await this.waitForHeadroomWithoutLock(dc);
122
133
  dc.send(item.data);
123
134
  this.messageBuffer.markSent(item);
124
135
  }