From 2ffa90df8e994f62d7c6638f834566baeaf27a84 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 20 Oct 2026 17:36:57 +0200 Subject: [PATCH 1/3] fix: report one recovery with the connected region's changes after a region failover When a full reconnect joined a region whose peer connection then failed, that region's handleSignalRestarted kept waiting and resolved on the next region's Restarted, so Reconnected fired twice. The failed region's join also overwrote roomInfo, and the next region cleared the held-back RoomMetadataChanged event, so the change was never reported. Each full reconnect attempt now starts from the room info from before the outage with no held-back events, and only the latest attempt reports the recovery. Co-Authored-By: Claude Opus 5.5 --- .changeset/restart-region-failover.md | 5 ++++ src/room/CoreRoom.ts | 22 ++++++++++++++++ src/room/Room.test.ts | 37 +++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 .changeset/restart-region-failover.md 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..e2ed66ecb6 100644 --- a/src/room/CoreRoom.ts +++ b/src/room/CoreRoom.ts @@ -251,6 +251,15 @@ 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; + + /** counts full reconnect attempts, so that only the latest one reports the recovery */ + private restartAttempt = 0; + private isResuming: boolean = false; private pendingTrackAddedCallbacks = new Map void>>(); @@ -1718,6 +1727,12 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter TypedEmitter TypedEmitter TypedEmitter result.onDisconnect?.()); if (this.state === ConnectionState.Disconnected) { diff --git a/src/room/Room.test.ts b/src/room/Room.test.ts index b2a0cdee99..755539dbb9 100644 --- a/src/room/Room.test.ts +++ b/src/room/Room.test.ts @@ -555,3 +555,40 @@ 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(); + }); +}); From a295d1dda803853b84000797024752193ec1abce Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 20 Oct 2026 18:24:02 +0200 Subject: [PATCH 2/3] refactor: let the engine end a restart wait when the next region starts waitForRestarted rejects on the next Restarting, so the handler of a region that failed returns without emitting Reconnected. This replaces the restartAttempt counter in CoreRoom. Co-Authored-By: Claude Opus 5.5 --- src/room/CoreRoom.ts | 15 +++++---------- src/room/RTCEngine.ts | 15 +++++++++++---- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/room/CoreRoom.ts b/src/room/CoreRoom.ts index e2ed66ecb6..8bd4d3e7f9 100644 --- a/src/room/CoreRoom.ts +++ b/src/room/CoreRoom.ts @@ -257,9 +257,6 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter void>>(); @@ -1728,7 +1725,6 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter TypedEmitter TypedEmitter TypedEventEmit 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); }); }; From 2c5ae313e68f1eed7e02259d439fcd997a77fbb8 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Tue, 20 Oct 2026 18:27:00 +0200 Subject: [PATCH 3/3] fix: start the restart wait before republishing tracks handleSignalRestarted waited for Restarted only after the republish. A failed region whose republish outlasted its failure started the wait after the next Restarting, and the short-circuit on a pcState left over from before the outage could resolve it at once. The wait now starts when SignalRestarted fires, and waitForRestarted always waits for the attempt's Restarted. Co-Authored-By: Claude Opus 5.5 --- src/room/CoreRoom.ts | 14 +++++++------- src/room/RTCEngine.ts | 4 +--- src/room/Room.test.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/room/CoreRoom.ts b/src/room/CoreRoom.ts index 8bd4d3e7f9..2dab0ccd87 100644 --- a/src/room/CoreRoom.ts +++ b/src/room/CoreRoom.ts @@ -1744,6 +1744,11 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter true, + () => false, + ); this.applyJoinResponse(joinResponse); @@ -1754,17 +1759,12 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter 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); diff --git a/src/room/Room.test.ts b/src/room/Room.test.ts index 755539dbb9..0db896e508 100644 --- a/src/room/Room.test.ts +++ b/src/room/Room.test.ts @@ -591,4 +591,35 @@ describe('full reconnect failing over to a second region', () => { 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(); + }); });