@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.
@@ -514,4 +514,172 @@ describeE2E('livekit-rtc e2e', () => {
514
514
  },
515
515
  testTimeoutMs * 2,
516
516
  );
517
+
518
+ it(
519
+ 'cleans up stream controllers when disconnecting during an active stream',
520
+ async () => {
521
+ const { rooms } = await connectTestRooms(2);
522
+ const [receivingRoom, sendingRoom] = rooms;
523
+ const topic = 'cleanup-stream-topic';
524
+
525
+ // Register a handler on the receiving side that will intentionally
526
+ // NOT fully consume the stream — simulating an abandoned transfer.
527
+ let readerReceived = false;
528
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
529
+ receivingRoom!.registerTextStreamHandler(topic, async (_reader, _sender) => {
530
+ readerReceived = true;
531
+ // Deliberately do not call reader.readAll() so the stream stays open
532
+ });
533
+
534
+ // Start sending a text stream but don't close it
535
+ const writer = await sendingRoom!.localParticipant!.streamText({ topic });
536
+ await writer.write('partial data');
537
+
538
+ // Wait for the receiving side to get the stream header
539
+ await waitFor(() => readerReceived, {
540
+ timeoutMs: 5000,
541
+ debugName: 'text stream header received',
542
+ });
543
+
544
+ // Disconnect the receiving room while the stream is still open.
545
+ // This should close the stream controller without throwing.
546
+ await receivingRoom!.disconnect();
547
+
548
+ // Also close the writer and disconnect the sender
549
+ await writer.close();
550
+ await sendingRoom!.disconnect();
551
+
552
+ // If we got here without hanging or throwing, the stream controller
553
+ // was properly cleaned up on disconnect.
554
+ },
555
+ testTimeoutMs,
556
+ );
557
+
558
+ it(
559
+ 'cleans up track publications when a remote participant disconnects',
560
+ async () => {
561
+ const { rooms } = await connectTestRooms(2);
562
+ const [stayingRoom, leavingRoom] = rooms;
563
+
564
+ // Publish a track from the leaving participant so its track publication
565
+ // will need to be cleaned up on disconnect.
566
+ const source = new AudioSource(48_000, 1);
567
+ const track = LocalAudioTrack.createAudioTrack('cleanup-test', source);
568
+ const options = new TrackPublishOptions();
569
+ options.source = TrackSource.SOURCE_MICROPHONE;
570
+ await leavingRoom!.localParticipant!.publishTrack(track, options);
571
+
572
+ // Wait for the staying room to see the track subscription
573
+ await waitFor(
574
+ () => {
575
+ const remote = stayingRoom!.remoteParticipants.get(
576
+ leavingRoom!.localParticipant!.identity,
577
+ );
578
+ return remote !== undefined && remote.trackPublications.size > 0;
579
+ },
580
+ { timeoutMs: 5000, debugName: 'track publication visible' },
581
+ );
582
+
583
+ // Capture a reference to the remote participant before disconnect
584
+ const remoteParticipant = stayingRoom!.remoteParticipants.get(
585
+ leavingRoom!.localParticipant!.identity,
586
+ )!;
587
+ expect(remoteParticipant.trackPublications.size).toBeGreaterThan(0);
588
+
589
+ // Listen for the disconnect event
590
+ const disconnected = waitForRoomEvent(
591
+ stayingRoom!,
592
+ RoomEvent.ParticipantDisconnected,
593
+ testTimeoutMs,
594
+ (p: { identity: string }) => p.identity,
595
+ );
596
+
597
+ await leavingRoom!.disconnect();
598
+ await disconnected;
599
+
600
+ // trackUnpublished events fire before participantDisconnected, so
601
+ // by this point all publications should already be removed and disposed.
602
+ expect(remoteParticipant.trackPublications.size).toBe(0);
603
+ expect(stayingRoom!.remoteParticipants.has(remoteParticipant.identity)).toBe(false);
604
+
605
+ await source.close();
606
+ await stayingRoom!.disconnect();
607
+ },
608
+ testTimeoutMs,
609
+ );
610
+
611
+ it(
612
+ 'cleans up resources when multiple participants disconnect simultaneously',
613
+ async () => {
614
+ // Connect 4 participants to stress-test concurrent disconnection cleanup
615
+ const { rooms } = await connectTestRooms(4);
616
+
617
+ // Publish a track from each participant to create track publications
618
+ const sources: AudioSource[] = [];
619
+ for (const room of rooms) {
620
+ const source = new AudioSource(48_000, 1);
621
+ sources.push(source);
622
+ const track = LocalAudioTrack.createAudioTrack('multi-cleanup', source);
623
+ const options = new TrackPublishOptions();
624
+ options.source = TrackSource.SOURCE_MICROPHONE;
625
+ await room.localParticipant!.publishTrack(track, options);
626
+ }
627
+
628
+ // Wait for all participants to see each other's tracks
629
+ await waitFor(
630
+ () =>
631
+ rooms.every(
632
+ (r) =>
633
+ r.remoteParticipants.size === 3 &&
634
+ [...r.remoteParticipants.values()].every((p) => p.trackPublications.size > 0),
635
+ ),
636
+ { timeoutMs: 5000, debugName: 'all tracks visible' },
637
+ );
638
+
639
+ // Register listeners before disconnecting so we can verify both
640
+ // RoomEvent.Disconnected and RoomEvent.ConnectionStateChanged fire
641
+ // for every room, even when disconnects race.
642
+ const disconnectedEvents = rooms.map((r) =>
643
+ waitForRoomEvent(r, RoomEvent.Disconnected, 3_000, (reason) => reason),
644
+ );
645
+ const connectionStateEvents = rooms.map((r) =>
646
+ waitForRoomEvent(r, RoomEvent.ConnectionStateChanged, 3_000, (state) => state),
647
+ );
648
+
649
+ // Disconnect all participants simultaneously
650
+ await Promise.all([...rooms.map((r) => r.disconnect()), ...sources.map((s) => s.close())]);
651
+
652
+ await Promise.all(disconnectedEvents);
653
+ const observedStates = await Promise.all(connectionStateEvents);
654
+
655
+ // Verify all rooms are disconnected and remote participant maps are empty
656
+ for (const room of rooms) {
657
+ expect(room.isConnected).toBe(false);
658
+ }
659
+ for (const state of observedStates) {
660
+ expect(state).toBe(ConnectionState.CONN_DISCONNECTED);
661
+ }
662
+ },
663
+ testTimeoutMs * 2,
664
+ );
665
+
666
+ it(
667
+ 'concurrent getSid() calls share a single listener and resolve consistently',
668
+ async () => {
669
+ const { rooms } = await connectTestRooms(1);
670
+ const room = rooms[0]!;
671
+
672
+ // Fire multiple concurrent getSid() calls — they should all resolve
673
+ // to the same SID without leaking event listeners.
674
+ const results = await Promise.all([room.getSid(), room.getSid(), room.getSid()]);
675
+
676
+ // All calls should return the same non-empty SID
677
+ expect(results[0]).toBeTruthy();
678
+ expect(results[1]).toBe(results[0]);
679
+ expect(results[2]).toBe(results[0]);
680
+
681
+ await room.disconnect();
682
+ },
683
+ testTimeoutMs,
684
+ );
517
685
  });
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = "0.13.25";
1
+ export const SDK_VERSION = "0.13.26";