Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/restart-region-failover.md
Original file line number Diff line number Diff line change
@@ -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
27 changes: 22 additions & 5 deletions src/room/CoreRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter<RoomEventC

private bufferedEvents: Array<any> = [];

/**
* 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<Track.SID, Set<() => void>>();
Expand Down Expand Up @@ -1718,6 +1724,11 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter<RoomEventC
// in case we went from resuming to full-reconnect, make sure to reflect it on the isResuming flag
this.isResuming = false;

// a region that failed after its join leaves its room state and held back events behind
this.roomInfoBeforeRestart ??= this.roomInfo;
this.roomInfo = this.roomInfoBeforeRestart;
this.bufferedEvents = [];

// also unwind existing participants & existing subscriptions
for (const p of this.remoteParticipants.values()) {
this.handleParticipantDisconnected(p.identity, p);
Expand All @@ -1733,6 +1744,11 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter<RoomEventC
region: joinResponse.serverRegion,
});
this.bufferedEvents = [];
// wait from here, so that this region failing during the republish ends the wait
const restarted = this.engine.waitForRestarted().then(
() => true,
() => false,
);

this.applyJoinResponse(joinResponse);

Expand All @@ -1743,13 +1759,13 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter<RoomEventC
this.log.error('error trying to re-publish tracks after reconnection', { error });
}

try {
await this.engine.waitForRestarted();
this.log.debug(`fully reconnected to server`, { region: joinResponse.serverRegion });
} catch {
// reconnection failed, handleDisconnect is being invoked already, just return here
if (!(await restarted)) {
// this region failed and the next attempt reports the recovery, or reconnection failed
// and handleDisconnect is being invoked already
return;
Comment on lines 1747 to 1765

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: is it worth keeping around the fully reconnected to server log line?

}
this.log.debug(`fully reconnected to server`, { region: joinResponse.serverRegion });
this.roomInfoBeforeRestart = undefined;
this.setAndEmitConnectionState(ConnectionState.Connected);
Comment on lines +1762 to 1769

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Late republish falsely completes another reconnect

When republishAllTracks outlasts a successful restart and another restart begins, the older handler still sees restarted as true. It marks the room connected during the new restart and clears its metadata baseline, losing changes reported by later regions.

Learn more

A full reconnect emits Restarted when its peer connection is ready, without waiting for the room's track republishing. The room waits for track republishing before announcing recovery. If another full reconnect begins in that interval, the first attempt's restarted promise is already true, so its eventual continuation clears the saved room state and changes the room back to Connected while the second attempt is in progress. restartConnection emits Restarting again for that second attempt; handleRestarting had kept the original room state for comparison. Clearing it early makes a later failed-region metadata update the baseline for a subsequent attempt.

Example: Region A emits Restarted but camera republishing remains pending. Region B starts a new full restart. The camera republish finishes, and A announces Reconnected during B's restart and discards the saved metadata from before A. If B fails and region C joins with B's metadata, the metadata change can go unreported.

Recommended fix: Associate each handleSignalRestarted run with its restart generation and check that it remains current after republishing and before clearing roomInfoBeforeRestart or announcing recovery. Increment the generation on handleRestarting, and invalidate it on disconnect.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

this.emit(RoomEvent.Reconnected);
this.registerConnectionReconcile();
Expand All @@ -1760,6 +1776,7 @@ export class CoreRoom extends (EventEmitter as new () => TypedEmitter<RoomEventC
this.clearConnectionReconcile();
this.isResuming = false;
this.bufferedEvents = [];
this.roomInfoBeforeRestart = undefined;
this.transcriptionReceivedTimes.clear();
this.extensionResults.forEach((result) => result.onDisconnect?.());
if (this.state === ConnectionState.Disconnected) {
Expand Down
19 changes: 12 additions & 7 deletions src/room/RTCEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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);
Comment on lines 1687 to +1689

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Engine closure strands pending restart waits

If the user disconnects after SignalRestarted, waitForRestarted never settles. close emits Closing and removes listeners without emitting any event the wait observes.

Learn more

A restart waiter listens for success (Restarted) or failure (Disconnected or another Restarting). A user-initiated disconnect instead calls close, which emits Closing and removes all engine listeners. Once the close removes these listeners, a pending restart promise cannot resolve or reject; handleSignalRestarted remains suspended at that wait after track republishing finishes.

Example: The engine emits SignalRestarted for a new region. Before its peer connection reconnects, the application calls room.disconnect(). The room disconnects, but its pending handleSignalRestarted never finishes.

Recommended fix: Subscribe to EngineEvent.Closing as a failure in waitForRestarted, and remove that listener in cleanup alongside the other listeners.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

});
};

Expand Down
68 changes: 68 additions & 0 deletions src/room/Room.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +567 to +575

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: You might consider using subscribeToEvents (link) here, as it cleans up a lot of this room.on(RoomEvent.Reconnected, fi.fn()); type mocking.


// 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<void>((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();
});
});
Loading