@livekit/rtc-node 0.13.26 → 0.13.28

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.
package/src/room.ts CHANGED
@@ -5,7 +5,11 @@ 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
7
  import { DisconnectReason, type OwnedParticipant } from '@livekit/rtc-ffi-bindings';
8
- import type { DataStream_Trailer, DisconnectCallback } from '@livekit/rtc-ffi-bindings';
8
+ import type {
9
+ DataStream_Trailer,
10
+ DisconnectCallback,
11
+ TrackPublicationInfo,
12
+ } from '@livekit/rtc-ffi-bindings';
9
13
  import {
10
14
  type ConnectCallback,
11
15
  ConnectRequest,
@@ -21,6 +25,9 @@ import {
21
25
  type IceServer,
22
26
  IceTransportType,
23
27
  type RoomInfo,
28
+ type SimulateScenarioCallback,
29
+ type SimulateScenarioKind,
30
+ type SimulateScenarioResponse,
24
31
  } from '@livekit/rtc-ffi-bindings';
25
32
  import { TrackKind } from '@livekit/rtc-ffi-bindings';
26
33
  import type { TypedEventEmitter as TypedEmitter } from '@livekit/typed-emitter';
@@ -107,9 +114,9 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
107
114
 
108
115
  private _token?: string;
109
116
  private _serverUrl?: string;
117
+ private _connectionState: ConnectionState = ConnectionState.CONN_DISCONNECTED;
110
118
 
111
119
  e2eeManager?: E2EEManager;
112
- connectionState: ConnectionState = ConnectionState.CONN_DISCONNECTED;
113
120
 
114
121
  remoteParticipants: Map<string, RemoteParticipant> = new Map();
115
122
  localParticipant?: LocalParticipant;
@@ -118,6 +125,10 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
118
125
  super();
119
126
  }
120
127
 
128
+ get connectionState() {
129
+ return this._connectionState;
130
+ }
131
+
121
132
  get name(): string | undefined {
122
133
  return this.info?.name;
123
134
  }
@@ -262,7 +273,6 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
262
273
  this._token = token;
263
274
  this._serverUrl = url;
264
275
  this.info = cb.message.value.room!.info;
265
- this.connectionState = ConnectionState.CONN_CONNECTED;
266
276
  // Reset the abort controller for this connection session so that
267
277
  // a previous disconnect doesn't immediately cancel new operations.
268
278
  this.disconnectController = new AbortController();
@@ -281,6 +291,7 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
281
291
  rp.trackPublications.set(publication.sid!, publication);
282
292
  }
283
293
  }
294
+ this.updateConnectionState(ConnectionState.CONN_CONNECTED);
284
295
  break;
285
296
  case 'error':
286
297
  default:
@@ -321,6 +332,44 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
321
332
  this.removeAllListeners();
322
333
  }
323
334
 
335
+ /**
336
+ * Trigger a reconnection / chaos scenario for testing. Most useful in
337
+ * tests to deterministically force a Resume (signal-only reconnect that
338
+ * preserves the PeerConnection and existing publications) or a full
339
+ * reconnect (the SDK rebuilds the RtcSession and re-publishes existing
340
+ * local tracks; `RoomEvent.Reconnected` fires).
341
+ */
342
+ async simulateScenario(scenario: SimulateScenarioKind): Promise<void> {
343
+ if (!this.isConnected || !this.ffiHandle) {
344
+ throw new Error('simulateScenario requires a connected room');
345
+ }
346
+ const res = FfiClient.instance.request<SimulateScenarioResponse>({
347
+ message: {
348
+ case: 'simulateScenario',
349
+ value: {
350
+ roomHandle: this.ffiHandle.handle,
351
+ scenario,
352
+ },
353
+ },
354
+ });
355
+ const cb = await FfiClient.instance.waitFor<SimulateScenarioCallback>(
356
+ (ev: FfiEvent) =>
357
+ ev.message.case === 'simulateScenario' && ev.message.value.asyncId === res.asyncId,
358
+ { signal: this.disconnectController.signal },
359
+ );
360
+ if (cb.error) {
361
+ throw new Error(`simulateScenario failed: ${cb.error}`);
362
+ }
363
+ }
364
+
365
+ private updateConnectionState(newState: ConnectionState) {
366
+ if (this._connectionState === newState) {
367
+ return;
368
+ }
369
+ this._connectionState = newState;
370
+ this.emit(RoomEvent.ConnectionStateChanged, this._connectionState);
371
+ }
372
+
324
373
  // Runs at most once per connection session. The FFI layer and explicit
325
374
  // disconnect() both race to get here — whichever wins emits the events,
326
375
  // the other is a no-op. A reconnect via connect() clears hasCleanedUp.
@@ -359,12 +408,7 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
359
408
  // to reject and clean up their event listeners.
360
409
  this.disconnectController.abort();
361
410
 
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
- }
411
+ this.updateConnectionState(ConnectionState.CONN_DISCONNECTED);
368
412
  this.emit(RoomEvent.Disconnected, reason);
369
413
  }
370
414
 
@@ -474,6 +518,19 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
474
518
  const publication = this.localParticipant.trackPublications.get(ev.value.publicationSid!);
475
519
  this.localParticipant.trackPublications.delete(ev.value.publicationSid!);
476
520
  this.emit(RoomEvent.LocalTrackUnpublished, publication!, this.localParticipant!);
521
+ } else if ((ev.case as string) == 'localTrackRepublished') {
522
+ const value = (ev as any).value;
523
+ const previousSid: string = value.previousSid!;
524
+ const newInfo: TrackPublicationInfo = value.info!;
525
+ const publication = this.localParticipant.trackPublications.get(previousSid);
526
+ if (publication) {
527
+ publication.updateInfo(newInfo);
528
+ this.localParticipant.trackPublications.delete(previousSid);
529
+ this.localParticipant.trackPublications.set(publication.sid!, publication);
530
+ this.emit(RoomEvent.LocalTrackRepublished, publication, previousSid, this.localParticipant);
531
+ } else {
532
+ log.warn(`RoomEvent.LocalTrackRepublished: previous publication not found: ${previousSid}`);
533
+ }
477
534
  } else if (ev.case == 'localTrackSubscribed') {
478
535
  const publication = this.localParticipant.trackPublications.get(ev.value.trackSid!);
479
536
  if (publication) {
@@ -678,14 +735,7 @@ export class Room extends (EventEmitter as new () => TypedEmitter<RoomCallbacks>
678
735
  this.emit(RoomEvent.EncryptionError, new Error('internal server error'));
679
736
  }
680
737
  } else if (ev.case == 'connectionStateChanged') {
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;
688
- this.emit(RoomEvent.ConnectionStateChanged, this.connectionState);
738
+ this.updateConnectionState(ev.value.state!);
689
739
  /*} else if (ev.case == 'connected') {
690
740
  this.emit(RoomEvent.Connected);*/
691
741
  } else if (ev.case == 'disconnected') {
@@ -903,6 +953,18 @@ export type RoomCallbacks = {
903
953
  publication: LocalTrackPublication,
904
954
  participant: LocalParticipant,
905
955
  ) => void;
956
+ /**
957
+ * Fired when the SDK auto-republished a local track during a full
958
+ * reconnect. The publication object's identity is preserved (the same
959
+ * instance is updated in place with the new server-assigned SIDs);
960
+ * `previousSid` is provided for callers that key external state on the
961
+ * old SID and need to reconcile.
962
+ */
963
+ localTrackRepublished: (
964
+ publication: LocalTrackPublication,
965
+ previousSid: string,
966
+ participant: LocalParticipant,
967
+ ) => void;
906
968
  localTrackSubscribed: (track: LocalTrack) => void;
907
969
  trackPublished: (publication: RemoteTrackPublication, participant: RemoteParticipant) => void;
908
970
  trackUnpublished: (publication: RemoteTrackPublication, participant: RemoteParticipant) => void;
@@ -960,6 +1022,7 @@ export enum RoomEvent {
960
1022
  ParticipantDisconnected = 'participantDisconnected',
961
1023
  LocalTrackPublished = 'localTrackPublished',
962
1024
  LocalTrackUnpublished = 'localTrackUnpublished',
1025
+ LocalTrackRepublished = 'localTrackRepublished',
963
1026
  LocalTrackSubscribed = 'localTrackSubscribed',
964
1027
  TrackPublished = 'trackPublished',
965
1028
  TrackUnpublished = 'trackUnpublished',
@@ -15,6 +15,8 @@ import {
15
15
  Room,
16
16
  RoomEvent,
17
17
  RpcError,
18
+ SimulateScenarioKind,
19
+ TrackKind,
18
20
  TrackPublishOptions,
19
21
  TrackSource,
20
22
  dispose,
@@ -682,4 +684,159 @@ describeE2E('livekit-rtc e2e', () => {
682
684
  },
683
685
  testTimeoutMs,
684
686
  );
687
+
688
+ // -- Reconnect scenarios --
689
+ //
690
+ // Both tests verify the user-visible behavior: after the scenario fires,
691
+ // the subscriber continues to receive the publisher's tone. The full
692
+ // reconnect test additionally asserts there is exactly one audio
693
+ // publication on each side (regression: duplicate-publish bug).
694
+
695
+ const runReconnectScenario = async (scenario: SimulateScenarioKind) => {
696
+ const { rooms } = await connectTestRooms(2);
697
+ const [subRoom, pubRoom] = rooms;
698
+
699
+ const pubRateHz = 48_000;
700
+ const source = new AudioSource(pubRateHz, 1);
701
+ const track = LocalAudioTrack.createAudioTrack('reconnect_tone', source);
702
+ const opts = new TrackPublishOptions();
703
+ opts.source = TrackSource.SOURCE_MICROPHONE;
704
+ await pubRoom!.localParticipant!.publishTrack(track, opts);
705
+
706
+ let tonePhase = 0;
707
+ const samplesPer10ms = Math.floor(pubRateHz / 100);
708
+ const amplitude = 0.8 * 32767;
709
+ const sineHz = 60;
710
+ let toneRunning = true;
711
+ const toneTask = (async () => {
712
+ while (toneRunning) {
713
+ const frame = AudioFrame.create(pubRateHz, 1, samplesPer10ms);
714
+ for (let s = 0; s < samplesPer10ms; s++) {
715
+ frame.data[s] = Math.round(
716
+ amplitude * Math.sin((2 * Math.PI * sineHz * tonePhase) / pubRateHz),
717
+ );
718
+ tonePhase++;
719
+ }
720
+ await source.captureFrame(frame);
721
+ }
722
+ })();
723
+
724
+ // Subscriber-side: re-attach an AudioStream every time TrackSubscribed
725
+ // fires (a full reconnect may issue TrackUnsubscribed → TrackSubscribed
726
+ // with a fresh remote track).
727
+ const sub = {
728
+ lastFrameAt: 0,
729
+ collectFromMs: Number.POSITIVE_INFINITY,
730
+ collected: [] as Int16Array[],
731
+ readers: [] as ReturnType<AudioStream['getReader']>[],
732
+ };
733
+ const attach = (remoteTrack: unknown) => {
734
+ const stream = new AudioStream(remoteTrack as any, {
735
+ sampleRate: pubRateHz,
736
+ numChannels: 1,
737
+ });
738
+ const reader = stream.getReader();
739
+ sub.readers.push(reader);
740
+ (async () => {
741
+ try {
742
+ while (true) {
743
+ const { done, value } = await reader.read();
744
+ if (done) break;
745
+ sub.lastFrameAt = Date.now();
746
+ if (sub.lastFrameAt >= sub.collectFromMs) {
747
+ sub.collected.push(channelSamples(value, 0));
748
+ }
749
+ }
750
+ } catch {
751
+ // reader released
752
+ }
753
+ })();
754
+ };
755
+ subRoom!.on(RoomEvent.TrackSubscribed, (t) => attach(t));
756
+
757
+ try {
758
+ await waitFor(() => sub.lastFrameAt > 0 && Date.now() - sub.lastFrameAt < 500, {
759
+ timeoutMs: 10_000,
760
+ debugName: 'initial audio flow',
761
+ });
762
+
763
+ const simulateAt = Date.now();
764
+ await pubRoom!.simulateScenario(scenario);
765
+
766
+ // Wait for audio to actually flow again post-simulate: a frame
767
+ // received well after the simulate AND a fresh latest-frame timestamp.
768
+ await waitFor(
769
+ () => sub.lastFrameAt >= simulateAt + 500 && Date.now() - sub.lastFrameAt < 300,
770
+ { timeoutMs: 30_000, debugName: 'audio re-established after simulate' },
771
+ );
772
+ // Drain post-recovery buffer/jitter, then collect a 2s window of
773
+ // steady-state samples for tone detection.
774
+ await delay(1_500);
775
+ sub.collected.length = 0;
776
+ sub.collectFromMs = Date.now();
777
+ await waitFor(() => sub.collected.reduce((a, s) => a + s.length, 0) >= pubRateHz * 2, {
778
+ timeoutMs: 15_000,
779
+ debugName: 'post-simulate audio sampling',
780
+ });
781
+
782
+ const totalLen = sub.collected.reduce((a, s) => a + s.length, 0);
783
+ const concat = new Int16Array(totalLen);
784
+ let off = 0;
785
+ for (const s of sub.collected) {
786
+ concat.set(s, off);
787
+ off += s.length;
788
+ }
789
+ const detected = estimateFreqHz(concat, pubRateHz);
790
+ // Wider tolerance than the clean-path sine test: post-reconnect
791
+ // audio has brief discontinuities, and the autocorrelation is
792
+ // integer-lag (next neighbors to 60Hz are exactly 80Hz/40Hz), so
793
+ // ±20Hz lands right on the failure boundary under CI load.
794
+ expect(Math.abs(detected - sineHz)).toBeLessThan(25);
795
+
796
+ return { rooms, subRoom: subRoom!, pubRoom: pubRoom! };
797
+ } finally {
798
+ toneRunning = false;
799
+ await toneTask;
800
+ for (const r of sub.readers) {
801
+ try {
802
+ r.releaseLock();
803
+ } catch {
804
+ // ignore
805
+ }
806
+ }
807
+ await track.close();
808
+ }
809
+ };
810
+
811
+ itRaw(
812
+ 'resume keeps audio flowing on the subscriber side',
813
+ async () => {
814
+ const { rooms } = await runReconnectScenario(SimulateScenarioKind.SIMULATE_SIGNAL_RECONNECT);
815
+ await Promise.all(rooms.map((r) => r.disconnect()));
816
+ },
817
+ testTimeoutMs * 4,
818
+ );
819
+
820
+ itRaw(
821
+ 'full reconnect keeps audio flowing and ends with one publication on the subscriber',
822
+ async () => {
823
+ const { rooms, subRoom, pubRoom } = await runReconnectScenario(
824
+ SimulateScenarioKind.SIMULATE_FULL_RECONNECT,
825
+ );
826
+
827
+ try {
828
+ // Regression: subscriber must see exactly ONE audio publication after
829
+ // recovery — not duplicates from the auto-republish path.
830
+ const subscriberAudioPubs = Array.from(
831
+ subRoom.remoteParticipants
832
+ .get(pubRoom.localParticipant!.identity)!
833
+ .trackPublications.values(),
834
+ ).filter((p) => p.kind === TrackKind.KIND_AUDIO);
835
+ expect(subscriberAudioPubs.length).toBe(1);
836
+ } finally {
837
+ await Promise.all(rooms.map((r) => r.disconnect()));
838
+ }
839
+ },
840
+ testTimeoutMs * 4,
841
+ );
685
842
  });
@@ -66,6 +66,18 @@ export abstract class TrackPublication {
66
66
  get encryptionType(): EncryptionType | undefined {
67
67
  return this.info?.encryptionType;
68
68
  }
69
+
70
+ /**
71
+ * Update the publication's info in place. Used by the SDK when the
72
+ * server re-issues IDs / metadata for an existing publication (e.g.
73
+ * after a full reconnect). Application code holding a cached
74
+ * publication reference continues to read fresh values via the
75
+ * unchanged object identity.
76
+ * @internal
77
+ */
78
+ updateInfo(info: TrackPublicationInfo): void {
79
+ this.info = info;
80
+ }
69
81
  }
70
82
 
71
83
  export class LocalTrackPublication extends TrackPublication {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = "0.13.26";
1
+ export const SDK_VERSION = "0.13.28";