diff --git a/.changeset/restart-region-failover.md b/.changeset/restart-region-failover.md new file mode 100644 index 0000000000..3101ef9a5f --- /dev/null +++ b/.changeset/restart-region-failover.md @@ -0,0 +1,5 @@ +--- +'livekit-client': patch +--- + +Emit `Reconnected` once and keep room metadata and recording changes when a full reconnect fails over to another region diff --git a/src/room/CoreRoom.ts b/src/room/CoreRoom.ts index 6843908a6c..2dab0ccd87 100644 --- a/src/room/CoreRoom.ts +++ b/src/room/CoreRoom.ts @@ -251,6 +251,12 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter = []; + /** + * the room as it was before the current full reconnect. Every region the full reconnect tries + * starts from it, so the region it connects to reports every change since the outage + */ + private roomInfoBeforeRestart?: RoomModel; + private isResuming: boolean = false; private pendingTrackAddedCallbacks = new Map void>>(); @@ -1718,6 +1724,11 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter TypedEmitter true, + () => false, + ); this.applyJoinResponse(joinResponse); @@ -1743,13 +1759,13 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter TypedEmitter result.onDisconnect?.()); if (this.state === ConnectionState.Disconnected) { diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index 8fd42ad9f6..10adac1932 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -1667,21 +1667,26 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } } + /** waits for the current restart attempt's `Restarted`; call it on `SignalRestarted` */ waitForRestarted = () => { return new Promise((resolve, reject) => { - if (this.pcState === PCState.Connected) { - resolve(); - } + const cleanup = () => { + this.off(EngineEvent.Restarted, onRestarted); + this.off(EngineEvent.Disconnected, onFailed); + this.off(EngineEvent.Restarting, onFailed); + }; const onRestarted = () => { - this.off(EngineEvent.Disconnected, onDisconnected); + cleanup(); resolve(); }; - const onDisconnected = () => { - this.off(EngineEvent.Restarted, onRestarted); + // a new Restarting means this attempt's region failed and the next attempt takes over + const onFailed = () => { + cleanup(); reject(); }; this.once(EngineEvent.Restarted, onRestarted); - this.once(EngineEvent.Disconnected, onDisconnected); + this.once(EngineEvent.Disconnected, onFailed); + this.once(EngineEvent.Restarting, onFailed); }); }; diff --git a/src/room/Room.test.ts b/src/room/Room.test.ts index b2a0cdee99..0db896e508 100644 --- a/src/room/Room.test.ts +++ b/src/room/Room.test.ts @@ -555,3 +555,71 @@ describe('connection attempt racing disconnect (#2150)', () => { expect(disconnected).not.toHaveBeenCalled(); }); }); + +describe('full reconnect failing over to a second region', () => { + const join = (metadata: string) => + new JoinResponse({ + room: { sid: 'RM_test', name: 'test', metadata }, + participant: { sid: 'PA_local', identity: 'local' }, + }); + const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + + it('reports the recovery once, with the changes the connected region reports', async () => { + const room = new Room(); + room.state = ConnectionState.Connected; + room.engine.emit(EngineEvent.RoomUpdate, join('old').room!); + vi.spyOn(room.localParticipant, 'republishAllTracks').mockImplementation(async () => {}); + const reconnected = vi.fn(); + const metadataChanged = vi.fn(); + room.on(RoomEvent.Reconnected, reconnected); + room.on(RoomEvent.RoomMetadataChanged, metadataChanged); + + // the first region joins, then its peer connection fails + room.engine.emit(EngineEvent.Restarting); + room.engine.emit(EngineEvent.SignalRestarted, join('new')); + await flush(); + // the second region joins and connects + room.engine.emit(EngineEvent.Restarting); + room.engine.emit(EngineEvent.SignalRestarted, join('new')); + await flush(); + room.engine.emit(EngineEvent.Restarted); + await flush(); + + expect(room.state).toBe(ConnectionState.Connected); + expect(reconnected).toHaveBeenCalledTimes(1); + expect(metadataChanged).toHaveBeenCalledTimes(1); + expect(metadataChanged).toHaveBeenCalledWith('new'); + await room.disconnect(); + }); + + it('reports the recovery once when the failed region republishes past its failure', async () => { + const room = new Room(); + room.state = ConnectionState.Connected; + // the peer connection state from before the outage; only the restart's Restarted counts + (room.engine as unknown as { pcState: number }).pcState = 1; // PCState.Connected + let finishFirstRepublish!: () => void; + vi.spyOn(room.localParticipant, 'republishAllTracks') + .mockImplementationOnce( + () => new Promise((resolve) => (finishFirstRepublish = resolve)), + ) + .mockImplementation(async () => {}); + const reconnected = vi.fn(); + room.on(RoomEvent.Reconnected, reconnected); + + room.engine.emit(EngineEvent.Restarting); + room.engine.emit(EngineEvent.SignalRestarted, join('new')); + await flush(); + room.engine.emit(EngineEvent.Restarting); + finishFirstRepublish(); + await flush(); + room.engine.emit(EngineEvent.SignalRestarted, join('new')); + await flush(); + expect(reconnected).not.toHaveBeenCalled(); + + room.engine.emit(EngineEvent.Restarted); + await flush(); + + expect(reconnected).toHaveBeenCalledTimes(1); + await room.disconnect(); + }); +});