|
4 | 4 | import { Mutex } from '@livekit/mutex'; |
5 | 5 | import { EncryptionState, type EncryptionType } from '@livekit/rtc-ffi-bindings'; |
6 | 6 | import type { FfiEvent } from '@livekit/rtc-ffi-bindings'; |
7 | | -import type { DisconnectReason, OwnedParticipant } from '@livekit/rtc-ffi-bindings'; |
| 7 | +import { DisconnectReason, type OwnedParticipant } from '@livekit/rtc-ffi-bindings'; |
8 | 8 | import type { DataStream_Trailer, DisconnectCallback } from '@livekit/rtc-ffi-bindings'; |
9 | 9 | import { |
10 | 10 | type ConnectCallback, |
@@ -100,6 +100,11 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks> |
100 | 100 | // preventing them from leaking when the room goes away. |
101 | 101 | private disconnectController = new AbortController(); |
102 | 102 |
|
| 103 | + // Guards cleanupOnDisconnect so the ConnectionStateChanged/Disconnected |
| 104 | + // events fire exactly once, no matter which path (explicit disconnect() |
| 105 | + // vs. FFI 'disconnected' event) wins the race. |
| 106 | + private hasCleanedUp = false; |
| 107 | + |
103 | 108 | private _token?: string; |
104 | 109 | private _serverUrl?: string; |
105 | 110 |
|
@@ -261,6 +266,7 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks> |
261 | 266 | // Reset the abort controller for this connection session so that |
262 | 267 | // a previous disconnect doesn't immediately cancel new operations. |
263 | 268 | this.disconnectController = new AbortController(); |
| 269 | + this.hasCleanedUp = false; |
264 | 270 | this.localParticipant = new LocalParticipant( |
265 | 271 | cb.message.value.localParticipant!, |
266 | 272 | this.ffiEventLock, |
@@ -309,13 +315,19 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks> |
309 | 315 | return ev.message.case == 'disconnect' && ev.message.value.asyncId == res.asyncId; |
310 | 316 | }); |
311 | 317 |
|
312 | | - this.cleanupOnDisconnect(); |
313 | | - |
| 318 | + this.cleanupOnDisconnect(DisconnectReason.CLIENT_INITIATED); |
314 | 319 | FfiClient.instance.removeListener(FfiClientEvent.FfiEvent, this.onFfiEvent); |
| 320 | + |
315 | 321 | this.removeAllListeners(); |
316 | 322 | } |
317 | 323 |
|
318 | | - private cleanupOnDisconnect() { |
| 324 | + // Runs at most once per connection session. The FFI layer and explicit |
| 325 | + // disconnect() both race to get here — whichever wins emits the events, |
| 326 | + // the other is a no-op. A reconnect via connect() clears hasCleanedUp. |
| 327 | + private cleanupOnDisconnect(reason: DisconnectReason = DisconnectReason.CLIENT_INITIATED) { |
| 328 | + if (this.hasCleanedUp) return; |
| 329 | + this.hasCleanedUp = true; |
| 330 | + |
319 | 331 | // Error all in-progress stream controllers to prevent FD leaks. |
320 | 332 | // Streams that were receiving data but never got a trailer (e.g. the sender |
321 | 333 | // disconnected mid-transfer) would otherwise keep their ReadableStream open |
@@ -346,10 +358,14 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks> |
346 | 358 | // This causes any in-flight operations (publishData, publishTrack, etc.) |
347 | 359 | // to reject and clean up their event listeners. |
348 | 360 | this.disconnectController.abort(); |
349 | | - // Flip state synchronously so isConnected reflects reality immediately. |
350 | | - // The connectionStateChanged FFI event may arrive after we've removed |
351 | | - // our listener (see disconnect()), so relying on it alone is racy. |
352 | | - this.connectionState = ConnectionState.CONN_DISCONNECTED; |
| 361 | + |
| 362 | + // Only emit ConnectionStateChanged if the FFI 'connectionStateChanged' |
| 363 | + // path didn't already flip us to DISCONNECTED. |
| 364 | + if (this.connectionState !== ConnectionState.CONN_DISCONNECTED) { |
| 365 | + this.connectionState = ConnectionState.CONN_DISCONNECTED; |
| 366 | + this.emit(RoomEvent.ConnectionStateChanged, this.connectionState); |
| 367 | + } |
| 368 | + this.emit(RoomEvent.Disconnected, reason); |
353 | 369 | } |
354 | 370 |
|
355 | 371 | /** |
@@ -662,13 +678,20 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks> |
662 | 678 | this.emit(RoomEvent.EncryptionError, new Error('internal server error')); |
663 | 679 | } |
664 | 680 | } else if (ev.case == 'connectionStateChanged') { |
665 | | - this.connectionState = ev.value.state!; |
| 681 | + const newState = ev.value.state!; |
| 682 | + // Skip redundant transitions — cleanupOnDisconnect may have already |
| 683 | + // flipped us to DISCONNECTED, and we don't want to emit the event twice. |
| 684 | + if (this.connectionState === newState) { |
| 685 | + return; |
| 686 | + } |
| 687 | + this.connectionState = newState; |
666 | 688 | this.emit(RoomEvent.ConnectionStateChanged, this.connectionState); |
667 | 689 | /*} else if (ev.case == 'connected') { |
668 | 690 | this.emit(RoomEvent.Connected);*/ |
669 | 691 | } else if (ev.case == 'disconnected') { |
670 | | - this.cleanupOnDisconnect(); |
671 | | - this.emit(RoomEvent.Disconnected, ev.value.reason!); |
| 692 | + // cleanupOnDisconnect emits RoomEvent.Disconnected itself (guarded by |
| 693 | + // hasCleanedUp so it fires exactly once across both disconnect paths). |
| 694 | + this.cleanupOnDisconnect(ev.value.reason!); |
672 | 695 | } else if (ev.case == 'reconnecting') { |
673 | 696 | this.emit(RoomEvent.Reconnecting); |
674 | 697 | } else if (ev.case == 'reconnected') { |
|
0 commit comments