@livekit/rtc-node 0.13.25 → 0.13.26

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.
@@ -311,7 +311,7 @@ export class AudioMixer {
311
311
  // Accumulate data until we have at least chunkSize samples
312
312
  while (buf.length < this.chunkSize * this.numChannels && !exhausted && !this.closed) {
313
313
  try {
314
- const result = await Promise.race([iterator.next(), this.timeout(this.streamTimeoutMs)]);
314
+ const result = await this.timeoutRace(iterator.next(), this.streamTimeoutMs);
315
315
 
316
316
  if (result === 'timeout') {
317
317
  console.warn(`AudioMixer: stream timeout after ${this.streamTimeoutMs}ms`);
@@ -412,7 +412,13 @@ export class AudioMixer {
412
412
  return new Promise((resolve) => setTimeout(resolve, ms));
413
413
  }
414
414
 
415
- private timeout(ms: number): Promise<'timeout'> {
416
- return new Promise((resolve) => setTimeout(() => resolve('timeout'), ms));
415
+ /** Race a promise against a timeout. The losing setTimeout is automatically
416
+ * cleared via `.finally()` so callers don't need to manage cleanup. */
417
+ private timeoutRace<T>(promise: Promise<T>, ms: number): Promise<T | 'timeout'> {
418
+ let timer: ReturnType<typeof setTimeout>;
419
+ const timeoutPromise = new Promise<'timeout'>((resolve) => {
420
+ timer = setTimeout(() => resolve('timeout'), ms);
421
+ });
422
+ return Promise.race([promise.finally(() => clearTimeout(timer)), timeoutPromise]);
417
423
  }
418
424
  }
package/src/ffi_client.ts CHANGED
@@ -70,14 +70,37 @@ export class FfiClient extends (EventEmitter as new () => TypedEmitter<FfiClient
70
70
  return livekitRetrievePtr(data);
71
71
  }
72
72
 
73
- async waitFor<T>(predicate: (ev: FfiEvent) => boolean): Promise<T> {
74
- return new Promise<T>((resolve) => {
73
+ async waitFor<T>(
74
+ predicate: (ev: FfiEvent) => boolean,
75
+ options?: { signal?: AbortSignal },
76
+ ): Promise<T> {
77
+ return new Promise<T>((resolve, reject) => {
75
78
  const listener = (ev: FfiEvent) => {
76
79
  if (predicate(ev)) {
77
- this.off(FfiClientEvent.FfiEvent, listener);
80
+ cleanup();
78
81
  resolve(ev.message.value as T);
79
82
  }
80
83
  };
84
+
85
+ const cleanup = () => {
86
+ this.off(FfiClientEvent.FfiEvent, listener);
87
+ options?.signal?.removeEventListener('abort', onAbort);
88
+ };
89
+
90
+ // If an AbortSignal is provided, remove the listener when the signal
91
+ // fires so that pending waitFor() calls don't leak listeners after
92
+ // the room disconnects or the operation is cancelled.
93
+ const onAbort = () => {
94
+ cleanup();
95
+ reject(options?.signal?.reason ?? new Error('waitFor aborted'));
96
+ };
97
+
98
+ if (options?.signal?.aborted) {
99
+ reject(options.signal.reason ?? new Error('waitFor aborted'));
100
+ return;
101
+ }
102
+
103
+ options?.signal?.addEventListener('abort', onAbort);
81
104
  this.on(FfiClientEvent.FfiEvent, listener);
82
105
  });
83
106
  }
@@ -157,11 +157,16 @@ export class LocalParticipant extends Participant {
157
157
 
158
158
  private ffiEventLock: Mutex;
159
159
 
160
+ // Signal that fires when the owning Room disconnects, used to cancel
161
+ // pending FfiClient.waitFor() listeners so they don't leak.
162
+ private disconnectSignal: AbortSignal;
163
+
160
164
  trackPublications: Map<string, LocalTrackPublication> = new Map();
161
165
 
162
- constructor(info: OwnedParticipant, ffiEventLock: Mutex) {
166
+ constructor(info: OwnedParticipant, ffiEventLock: Mutex, disconnectSignal: AbortSignal) {
163
167
  super(info);
164
168
  this.ffiEventLock = ffiEventLock;
169
+ this.disconnectSignal = disconnectSignal;
165
170
  }
166
171
 
167
172
  async publishData(data: Uint8Array, options: DataPublishOptions) {
@@ -178,9 +183,10 @@ export class LocalParticipant extends Participant {
178
183
  message: { case: 'publishData', value: req },
179
184
  });
180
185
 
181
- const cb = await FfiClient.instance.waitFor<PublishDataCallback>((ev) => {
182
- return ev.message.case == 'publishData' && ev.message.value.asyncId == res.asyncId;
183
- });
186
+ const cb = await FfiClient.instance.waitFor<PublishDataCallback>(
187
+ (ev) => ev.message.case == 'publishData' && ev.message.value.asyncId == res.asyncId,
188
+ { signal: this.disconnectSignal },
189
+ );
184
190
 
185
191
  if (cb.error) {
186
192
  throw new Error(cb.error);
@@ -198,9 +204,10 @@ export class LocalParticipant extends Participant {
198
204
  message: { case: 'publishSipDtmf', value: req },
199
205
  });
200
206
 
201
- const cb = await FfiClient.instance.waitFor<PublishSipDtmfCallback>((ev) => {
202
- return ev.message.case == 'publishSipDtmf' && ev.message.value.asyncId == res.asyncId;
203
- });
207
+ const cb = await FfiClient.instance.waitFor<PublishSipDtmfCallback>(
208
+ (ev) => ev.message.case == 'publishSipDtmf' && ev.message.value.asyncId == res.asyncId,
209
+ { signal: this.disconnectSignal },
210
+ );
204
211
 
205
212
  if (cb.error) {
206
213
  throw new Error(cb.error);
@@ -229,9 +236,10 @@ export class LocalParticipant extends Participant {
229
236
  message: { case: 'publishTranscription', value: req },
230
237
  });
231
238
 
232
- const cb = await FfiClient.instance.waitFor<PublishTranscriptionCallback>((ev) => {
233
- return ev.message.case == 'publishTranscription' && ev.message.value.asyncId == res.asyncId;
234
- });
239
+ const cb = await FfiClient.instance.waitFor<PublishTranscriptionCallback>(
240
+ (ev) => ev.message.case == 'publishTranscription' && ev.message.value.asyncId == res.asyncId,
241
+ { signal: this.disconnectSignal },
242
+ );
235
243
 
236
244
  if (cb.error) {
237
245
  throw new Error(cb.error);
@@ -248,9 +256,10 @@ export class LocalParticipant extends Participant {
248
256
  message: { case: 'setLocalMetadata', value: req },
249
257
  });
250
258
 
251
- await FfiClient.instance.waitFor<SetLocalMetadataCallback>((ev) => {
252
- return ev.message.case == 'setLocalMetadata' && ev.message.value.asyncId == res.asyncId;
253
- });
259
+ await FfiClient.instance.waitFor<SetLocalMetadataCallback>(
260
+ (ev) => ev.message.case == 'setLocalMetadata' && ev.message.value.asyncId == res.asyncId,
261
+ { signal: this.disconnectSignal },
262
+ );
254
263
  }
255
264
 
256
265
  /**
@@ -335,8 +344,24 @@ export class LocalParticipant extends Participant {
335
344
  });
336
345
  await sendTrailer(trailerReq);
337
346
  },
338
- abort(err) {
347
+ // Send a trailer with the error reason so the remote side's stream
348
+ // controller is closed instead of waiting for data that won't arrive.
349
+ async abort(err) {
339
350
  log.error(err, 'Sink Error');
351
+ try {
352
+ const trailerReq = new SendStreamTrailerRequest({
353
+ senderIdentity,
354
+ localParticipantHandle: localHandle,
355
+ destinationIdentities,
356
+ trailer: new DataStream_Trailer({
357
+ streamId,
358
+ reason: err instanceof Error ? err.message : String(err ?? ''),
359
+ }),
360
+ });
361
+ await sendTrailer(trailerReq);
362
+ } catch {
363
+ // Best-effort: the connection may already be gone.
364
+ }
340
365
  },
341
366
  });
342
367
 
@@ -450,8 +475,24 @@ export class LocalParticipant extends Participant {
450
475
  });
451
476
  await sendTrailer(trailerReq);
452
477
  },
453
- abort(err) {
478
+ // Send a trailer with the error reason so the remote side's stream
479
+ // controller is closed instead of waiting for data that won't arrive.
480
+ async abort(err) {
454
481
  log.error(err, 'Sink error');
482
+ try {
483
+ const trailerReq = new SendStreamTrailerRequest({
484
+ senderIdentity,
485
+ localParticipantHandle: localHandle,
486
+ destinationIdentities,
487
+ trailer: new DataStream_Trailer({
488
+ streamId,
489
+ reason: err instanceof Error ? err.message : String(err ?? ''),
490
+ }),
491
+ });
492
+ await sendTrailer(trailerReq);
493
+ } catch {
494
+ // Best-effort: the connection may already be gone.
495
+ }
455
496
  },
456
497
  });
457
498
 
@@ -494,44 +535,47 @@ export class LocalParticipant extends Participant {
494
535
  message: { case: type, value: req },
495
536
  });
496
537
 
497
- const cb = await FfiClient.instance.waitFor<SendStreamHeaderCallback>((ev) => {
498
- return ev.message.case == type && ev.message.value.asyncId == res.asyncId;
499
- });
538
+ const cb = await FfiClient.instance.waitFor<SendStreamHeaderCallback>(
539
+ (ev) => ev.message.case == type && ev.message.value.asyncId == res.asyncId,
540
+ { signal: this.disconnectSignal },
541
+ );
500
542
 
501
543
  if (cb.error) {
502
544
  throw new Error(cb.error);
503
545
  }
504
546
  }
505
547
 
506
- private async sendStreamChunk(req: SendStreamChunkRequest) {
548
+ private sendStreamChunk = async (req: SendStreamChunkRequest) => {
507
549
  const type = 'sendStreamChunk';
508
550
  const res = FfiClient.instance.request<SendStreamChunkResponse>({
509
551
  message: { case: type, value: req },
510
552
  });
511
553
 
512
- const cb = await FfiClient.instance.waitFor<SendStreamChunkCallback>((ev) => {
513
- return ev.message.case == type && ev.message.value.asyncId == res.asyncId;
514
- });
554
+ const cb = await FfiClient.instance.waitFor<SendStreamChunkCallback>(
555
+ (ev) => ev.message.case == type && ev.message.value.asyncId == res.asyncId,
556
+ { signal: this.disconnectSignal },
557
+ );
515
558
 
516
559
  if (cb.error) {
517
560
  throw new Error(cb.error);
518
561
  }
519
- }
562
+ };
520
563
 
521
- private async sendStreamTrailer(req: SendStreamTrailerRequest) {
564
+ private sendStreamTrailer = async (req: SendStreamTrailerRequest) => {
522
565
  const type = 'sendStreamTrailer';
523
566
  const res = FfiClient.instance.request<SendStreamTrailerResponse>({
524
567
  message: { case: type, value: req },
525
568
  });
526
569
 
527
- const cb = await FfiClient.instance.waitFor<SendStreamTrailerCallback>((ev) => {
528
- return ev.message.case == type && ev.message.value.asyncId == res.asyncId;
529
- });
570
+ const cb = await FfiClient.instance.waitFor<SendStreamTrailerCallback>(
571
+ (ev) => ev.message.case == type && ev.message.value.asyncId == res.asyncId,
572
+ { signal: this.disconnectSignal },
573
+ );
530
574
 
531
575
  if (cb.error) {
532
576
  throw new Error(cb.error);
533
577
  }
534
- }
578
+ };
535
579
 
536
580
  /**
537
581
  * Sends a chat message to participants in the room
@@ -557,9 +601,10 @@ export class LocalParticipant extends Participant {
557
601
  message: { case: 'sendChatMessage', value: req },
558
602
  });
559
603
 
560
- const cb = await FfiClient.instance.waitFor<SendChatMessageCallback>((ev) => {
561
- return ev.message.case == 'chatMessage' && ev.message.value.asyncId == res.asyncId;
562
- });
604
+ const cb = await FfiClient.instance.waitFor<SendChatMessageCallback>(
605
+ (ev) => ev.message.case == 'chatMessage' && ev.message.value.asyncId == res.asyncId,
606
+ { signal: this.disconnectSignal },
607
+ );
563
608
 
564
609
  switch (cb.message.case) {
565
610
  case 'chatMessage':
@@ -603,9 +648,10 @@ export class LocalParticipant extends Participant {
603
648
  message: { case: 'editChatMessage', value: req },
604
649
  });
605
650
 
606
- const cb = await FfiClient.instance.waitFor<SendChatMessageCallback>((ev) => {
607
- return ev.message.case == 'chatMessage' && ev.message.value.asyncId == res.asyncId;
608
- });
651
+ const cb = await FfiClient.instance.waitFor<SendChatMessageCallback>(
652
+ (ev) => ev.message.case == 'chatMessage' && ev.message.value.asyncId == res.asyncId,
653
+ { signal: this.disconnectSignal },
654
+ );
609
655
 
610
656
  switch (cb.message.case) {
611
657
  case 'chatMessage':
@@ -632,9 +678,10 @@ export class LocalParticipant extends Participant {
632
678
  message: { case: 'setLocalName', value: req },
633
679
  });
634
680
 
635
- await FfiClient.instance.waitFor<SetLocalNameCallback>((ev) => {
636
- return ev.message.case == 'setLocalName' && ev.message.value.asyncId == res.asyncId;
637
- });
681
+ await FfiClient.instance.waitFor<SetLocalNameCallback>(
682
+ (ev) => ev.message.case == 'setLocalName' && ev.message.value.asyncId == res.asyncId,
683
+ { signal: this.disconnectSignal },
684
+ );
638
685
  }
639
686
 
640
687
  async setAttributes(attributes: Record<string, string>) {
@@ -647,9 +694,10 @@ export class LocalParticipant extends Participant {
647
694
  message: { case: 'setLocalAttributes', value: req },
648
695
  });
649
696
 
650
- await FfiClient.instance.waitFor<SetLocalAttributesCallback>((ev) => {
651
- return ev.message.case == 'setLocalAttributes' && ev.message.value.asyncId == res.asyncId;
652
- });
697
+ await FfiClient.instance.waitFor<SetLocalAttributesCallback>(
698
+ (ev) => ev.message.case == 'setLocalAttributes' && ev.message.value.asyncId == res.asyncId,
699
+ { signal: this.disconnectSignal },
700
+ );
653
701
  }
654
702
 
655
703
  async publishTrack(
@@ -669,9 +717,10 @@ export class LocalParticipant extends Participant {
669
717
  });
670
718
 
671
719
  try {
672
- const cb = await FfiClient.instance.waitFor<PublishTrackCallback>((ev) => {
673
- return ev.message.case == 'publishTrack' && ev.message.value.asyncId == res.asyncId;
674
- });
720
+ const cb = await FfiClient.instance.waitFor<PublishTrackCallback>(
721
+ (ev) => ev.message.case == 'publishTrack' && ev.message.value.asyncId == res.asyncId,
722
+ { signal: this.disconnectSignal },
723
+ );
675
724
 
676
725
  switch (cb.message.case) {
677
726
  case 'publication':
@@ -702,9 +751,10 @@ export class LocalParticipant extends Participant {
702
751
  message: { case: 'unpublishTrack', value: req },
703
752
  });
704
753
 
705
- const cb = await FfiClient.instance.waitFor<UnpublishTrackCallback>((ev) => {
706
- return ev.message.case == 'unpublishTrack' && ev.message.value.asyncId == res.asyncId;
707
- });
754
+ const cb = await FfiClient.instance.waitFor<UnpublishTrackCallback>(
755
+ (ev) => ev.message.case == 'unpublishTrack' && ev.message.value.asyncId == res.asyncId,
756
+ { signal: this.disconnectSignal },
757
+ );
708
758
 
709
759
  if (cb.error) {
710
760
  throw new Error(cb.error);
@@ -744,9 +794,10 @@ export class LocalParticipant extends Participant {
744
794
  message: { case: 'performRpc', value: req },
745
795
  });
746
796
 
747
- const cb = await FfiClient.instance.waitFor<PerformRpcCallback>((ev) => {
748
- return ev.message.case === 'performRpc' && ev.message.value.asyncId === res.asyncId;
749
- });
797
+ const cb = await FfiClient.instance.waitFor<PerformRpcCallback>(
798
+ (ev) => ev.message.case === 'performRpc' && ev.message.value.asyncId === res.asyncId,
799
+ { signal: this.disconnectSignal },
800
+ );
750
801
 
751
802
  if (cb.error) {
752
803
  throw RpcError.fromProto(cb.error);
package/src/room.ts CHANGED
@@ -4,7 +4,7 @@
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,
@@ -96,6 +96,15 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
96
96
 
97
97
  private preConnectEvents: FfiEvent[] = [];
98
98
 
99
+ // Aborted on disconnect to cancel any pending FfiClient.waitFor() listeners,
100
+ // preventing them from leaking when the room goes away.
101
+ private disconnectController = new AbortController();
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
+
99
108
  private _token?: string;
100
109
  private _serverUrl?: string;
101
110
 
@@ -131,6 +140,11 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
131
140
  return this._serverUrl;
132
141
  }
133
142
 
143
+ // Shared promise for concurrent getSid() callers. Without this, each call
144
+ // registers its own RoomSidChanged + Disconnected listeners, and if many
145
+ // calls race only one of each pair is cleaned up — leaking the rest.
146
+ private sidPromise?: Promise<string>;
147
+
134
148
  /**
135
149
  * Gets the room's server ID. This ID is assigned by the LiveKit server
136
150
  * and is unique for each room session.
@@ -144,19 +158,26 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
144
158
  if (this.info?.sid && this.info.sid !== '') {
145
159
  return this.info.sid;
146
160
  }
147
- return new Promise((resolve, reject) => {
148
- const handleRoomUpdate = (sid: string) => {
149
- if (sid !== '') {
161
+ if (!this.sidPromise) {
162
+ this.sidPromise = new Promise<string>((resolve, reject) => {
163
+ const handleDisconnect = () => {
150
164
  this.off(RoomEvent.RoomSidChanged, handleRoomUpdate);
151
- resolve(sid);
152
- }
153
- };
154
- this.on(RoomEvent.RoomSidChanged, handleRoomUpdate);
155
- this.once(RoomEvent.Disconnected, () => {
156
- this.off(RoomEvent.RoomSidChanged, handleRoomUpdate);
157
- reject('Room disconnected before room server id was available');
165
+ this.sidPromise = undefined;
166
+ reject('Room disconnected before room server id was available');
167
+ };
168
+ const handleRoomUpdate = (sid: string) => {
169
+ if (sid !== '') {
170
+ this.off(RoomEvent.RoomSidChanged, handleRoomUpdate);
171
+ this.off(RoomEvent.Disconnected as any, handleDisconnect);
172
+ this.sidPromise = undefined;
173
+ resolve(sid);
174
+ }
175
+ };
176
+ this.on(RoomEvent.RoomSidChanged, handleRoomUpdate);
177
+ this.once(RoomEvent.Disconnected, handleDisconnect);
158
178
  });
159
- });
179
+ }
180
+ return this.sidPromise;
160
181
  }
161
182
 
162
183
  get numParticipants(): number {
@@ -219,45 +240,56 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
219
240
 
220
241
  FfiClient.instance.on(FfiClientEvent.FfiEvent, this.onFfiEvent);
221
242
 
222
- const res = FfiClient.instance.request<ConnectResponse>({
223
- message: {
224
- case: 'connect',
225
- value: req,
226
- },
227
- });
228
-
229
- const cb = await FfiClient.instance.waitFor<ConnectCallback>((ev: FfiEvent) => {
230
- return ev.message.case == 'connect' && ev.message.value.asyncId == res.asyncId;
231
- });
232
-
233
- log.debug('Connect callback received');
243
+ try {
244
+ const res = FfiClient.instance.request<ConnectResponse>({
245
+ message: {
246
+ case: 'connect',
247
+ value: req,
248
+ },
249
+ });
234
250
 
235
- switch (cb.message.case) {
236
- case 'result':
237
- this.ffiHandle = new FfiHandle(cb.message.value.room!.handle!.id!);
238
- this.e2eeManager = e2eeEnabled && new E2EEManager(this.ffiHandle.handle, e2eeOptions);
251
+ const cb = await FfiClient.instance.waitFor<ConnectCallback>((ev: FfiEvent) => {
252
+ return ev.message.case == 'connect' && ev.message.value.asyncId == res.asyncId;
253
+ });
239
254
 
240
- this._token = token;
241
- this._serverUrl = url;
242
- this.info = cb.message.value.room!.info;
243
- this.connectionState = ConnectionState.CONN_CONNECTED;
244
- this.localParticipant = new LocalParticipant(
245
- cb.message.value.localParticipant!,
246
- this.ffiEventLock,
247
- );
255
+ log.debug('Connect callback received');
256
+
257
+ switch (cb.message.case) {
258
+ case 'result':
259
+ this.ffiHandle = new FfiHandle(cb.message.value.room!.handle!.id!);
260
+ this.e2eeManager = e2eeEnabled && new E2EEManager(this.ffiHandle.handle, e2eeOptions);
261
+
262
+ this._token = token;
263
+ this._serverUrl = url;
264
+ this.info = cb.message.value.room!.info;
265
+ this.connectionState = ConnectionState.CONN_CONNECTED;
266
+ // Reset the abort controller for this connection session so that
267
+ // a previous disconnect doesn't immediately cancel new operations.
268
+ this.disconnectController = new AbortController();
269
+ this.hasCleanedUp = false;
270
+ this.localParticipant = new LocalParticipant(
271
+ cb.message.value.localParticipant!,
272
+ this.ffiEventLock,
273
+ this.disconnectController.signal,
274
+ );
248
275
 
249
- for (const pt of cb.message.value.participants) {
250
- const rp = this.createRemoteParticipant(pt.participant!);
276
+ for (const pt of cb.message.value.participants) {
277
+ const rp = this.createRemoteParticipant(pt.participant!);
251
278
 
252
- for (const pub of pt.publications) {
253
- const publication = new RemoteTrackPublication(pub);
254
- rp.trackPublications.set(publication.sid!, publication);
279
+ for (const pub of pt.publications) {
280
+ const publication = new RemoteTrackPublication(pub);
281
+ rp.trackPublications.set(publication.sid!, publication);
282
+ }
255
283
  }
256
- }
257
- break;
258
- case 'error':
259
- default:
260
- throw new ConnectError(cb.message.value || '');
284
+ break;
285
+ case 'error':
286
+ default:
287
+ throw new ConnectError(cb.message.value || '');
288
+ }
289
+ } catch (e) {
290
+ FfiClient.instance.off(FfiClientEvent.FfiEvent, this.onFfiEvent);
291
+ this.preConnectEvents = [];
292
+ throw e;
261
293
  }
262
294
  }
263
295
 
@@ -283,10 +315,59 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
283
315
  return ev.message.case == 'disconnect' && ev.message.value.asyncId == res.asyncId;
284
316
  });
285
317
 
318
+ this.cleanupOnDisconnect(DisconnectReason.CLIENT_INITIATED);
286
319
  FfiClient.instance.removeListener(FfiClientEvent.FfiEvent, this.onFfiEvent);
320
+
287
321
  this.removeAllListeners();
288
322
  }
289
323
 
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
+
331
+ // Error all in-progress stream controllers to prevent FD leaks.
332
+ // Streams that were receiving data but never got a trailer (e.g. the sender
333
+ // disconnected mid-transfer) would otherwise keep their ReadableStream open
334
+ // indefinitely, leaking the underlying controller and any buffered chunks.
335
+ // Using error() instead of close() signals an abnormal termination to consumers.
336
+ for (const [, streamController] of this.byteStreamControllers) {
337
+ try {
338
+ streamController.controller.error(new Error('Disconnected while receiving'));
339
+ } catch {
340
+ // controller may already be closed or errored
341
+ }
342
+ }
343
+ this.byteStreamControllers.clear();
344
+
345
+ for (const [, streamController] of this.textStreamControllers) {
346
+ try {
347
+ streamController.controller.error(new Error('Disconnected while receiving'));
348
+ } catch {
349
+ // controller may already be closed or errored
350
+ }
351
+ }
352
+ this.textStreamControllers.clear();
353
+
354
+ // Clear sidPromise before removing listeners so that a reconnect
355
+ // doesn't return a stale, permanently-pending promise.
356
+ this.sidPromise = undefined;
357
+ // Abort all pending FfiClient.waitFor() listeners so they don't leak.
358
+ // This causes any in-flight operations (publishData, publishTrack, etc.)
359
+ // to reject and clean up their event listeners.
360
+ this.disconnectController.abort();
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);
369
+ }
370
+
290
371
  /**
291
372
  * Registers a handler for incoming text data streams on a specific topic.
292
373
  * Text streams are used for receiving structured text data from other participants.
@@ -418,6 +499,9 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
418
499
  participant.trackPublications.delete(ev.value.publicationSid!);
419
500
  if (publication) {
420
501
  this.emit(RoomEvent.TrackUnpublished, publication, participant);
502
+ // Dispose eagerly so handles don't accumulate when a participant
503
+ // publishes and unpublishes many tracks during a long-lived session.
504
+ publication.ffiHandle.dispose();
421
505
  } else {
422
506
  log.warn(`RoomEvent.TrackUnpublished: Could not find publication`);
423
507
  }
@@ -594,12 +678,20 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
594
678
  this.emit(RoomEvent.EncryptionError, new Error('internal server error'));
595
679
  }
596
680
  } else if (ev.case == 'connectionStateChanged') {
597
- 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;
598
688
  this.emit(RoomEvent.ConnectionStateChanged, this.connectionState);
599
689
  /*} else if (ev.case == 'connected') {
600
690
  this.emit(RoomEvent.Connected);*/
601
691
  } else if (ev.case == 'disconnected') {
602
- 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!);
603
695
  } else if (ev.case == 'reconnecting') {
604
696
  this.emit(RoomEvent.Reconnecting);
605
697
  } else if (ev.case == 'reconnected') {