diff --git a/.changeset/quiet-planets-fix.md b/.changeset/quiet-planets-fix.md new file mode 100644 index 0000000000..4d025c31f9 --- /dev/null +++ b/.changeset/quiet-planets-fix.md @@ -0,0 +1,5 @@ +--- +"livekit-client": patch +--- + +fix: recover broken publish paths — act on local `ConnectionQuality.Lost`, recreate the peer connection when an ICE restart has no remote description, bound how long a transport may stay connecting, and reconnect (instead of disconnecting) on a detected connection state mismatch diff --git a/src/room/PCTransport.ts b/src/room/PCTransport.ts index e0aae6558d..5223c32329 100644 --- a/src/room/PCTransport.ts +++ b/src/room/PCTransport.ts @@ -425,9 +425,15 @@ export default class PCTransport extends (EventEmitter as new () => TypedEmitter // the only exception to this is when ICE restart is needed const currentSD = this._pc.remoteDescription; if (options?.iceRestart && currentSD) { - // TODO: handle when ICE restart is needed but we don't have a remote description - // the best thing to do is to recreate the peerconnection + // roll the remote description back in so createOffer produces a valid + // ICE-restart offer on top of the already-negotiated state await this._pc.setRemoteDescription(currentSD); + } else if (options?.iceRestart) { + // ICE restart with no remote description to restart on: `renegotiate` would stall + // (the pending offer is never answered), so throw for the caller to recreate the PC. + throw new NegotiationError( + 'ICE restart requested without a remote description, peer connection must be recreated', + ); } else { this.renegotiate = true; this.log.debug('requesting renegotiation'); diff --git a/src/room/RTCEngine.test.ts b/src/room/RTCEngine.test.ts index bcc00f4323..1dc28a2cf6 100644 --- a/src/room/RTCEngine.test.ts +++ b/src/room/RTCEngine.test.ts @@ -1,6 +1,12 @@ -import { DataPacket, DataPacket_Kind, UserPacket } from '@livekit/protocol'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DataPacket, + DataPacket_Kind, + ConnectionQuality as ProtoConnectionQuality, + UserPacket, +} from '@livekit/protocol'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { DataPacketBuffer } from '../utils/dataPacketBuffer'; +import { PCTransportState } from './PCTransportManager'; import RTCEngine, { DataChannelKind } from './RTCEngine'; import { roomOptionDefaults } from './defaults'; import { PublishDataError, UnexpectedConnectionState } from './errors'; @@ -582,4 +588,335 @@ describe('RTCEngine', () => { expect(error).not.toHaveBeenCalled(); }); }); + + describe('local connection quality Lost handling', () => { + // The engine reacts to the server's own verdict: a sustained local `LOST` while + // connected and publishing means our media isn't reaching the server, so it forces + // a full reconnect. (A genuine `LOST` can't be produced from a browser page — any + // live sender keeps RTCP flowing — so the behavior is unit tested here rather than + // in the e2e suite.) `connectionQualityLostTimeout` in RTCEngine.ts is 5s. + const LOST_TIMEOUT_MS = 10_000; + const LOCAL_SID = 'PA_local'; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + /** An engine primed to satisfy the reconnect guard: connected, publishing, not closed. */ + function primeEngine(overrides: { activeSenders?: boolean; pcState?: number } = {}) { + const engine = new RTCEngine(roomOptionDefaults); + const internals = engine as unknown as { + _isClosed: boolean; + participantSid: string; + // PCState is a private enum; Connected is 1, Reconnecting is 3. + pcState: number; + attemptingReconnect: boolean; + pcManager: unknown; + handleDisconnect: (connection: string, reason?: number) => void; + handleLocalConnectionQuality: (update: unknown) => void; + }; + internals._isClosed = false; + internals.participantSid = LOCAL_SID; + internals.pcState = overrides.pcState ?? 1; // PCState.Connected + internals.attemptingReconnect = false; + internals.pcManager = { + publisher: { + getSenders: () => + overrides.activeSenders === false ? [] : [{ track: { readyState: 'live' } }], + }, + }; + const handleDisconnect = vi.fn(); + internals.handleDisconnect = handleDisconnect; + return { engine, internals, handleDisconnect }; + } + + function qualityUpdate(sid: string, quality: ProtoConnectionQuality) { + return { updates: [{ participantSid: sid, quality }] }; + } + + it('forces a full reconnect after a sustained local Lost while publishing', () => { + const { engine, internals, handleDisconnect } = primeEngine(); + + internals.handleLocalConnectionQuality(qualityUpdate(LOCAL_SID, ProtoConnectionQuality.LOST)); + + // still pending — the reconnect only fires once the timeout elapses + expect(engine.fullReconnectOnNext).toBe(false); + expect(handleDisconnect).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(LOST_TIMEOUT_MS); + + expect(engine.fullReconnectOnNext).toBe(true); + expect(handleDisconnect).toHaveBeenCalledTimes(1); + }); + + it('cancels the pending reconnect when quality recovers before the timeout', () => { + const { engine, internals, handleDisconnect } = primeEngine(); + + internals.handleLocalConnectionQuality(qualityUpdate(LOCAL_SID, ProtoConnectionQuality.LOST)); + vi.advanceTimersByTime(LOST_TIMEOUT_MS / 2); + internals.handleLocalConnectionQuality( + qualityUpdate(LOCAL_SID, ProtoConnectionQuality.EXCELLENT), + ); + vi.advanceTimersByTime(LOST_TIMEOUT_MS); + + expect(engine.fullReconnectOnNext).toBe(false); + expect(handleDisconnect).not.toHaveBeenCalled(); + }); + + it('does not reconnect on Lost when there are no active publisher senders', () => { + const { engine, internals, handleDisconnect } = primeEngine({ activeSenders: false }); + + internals.handleLocalConnectionQuality(qualityUpdate(LOCAL_SID, ProtoConnectionQuality.LOST)); + vi.advanceTimersByTime(LOST_TIMEOUT_MS); + + expect(engine.fullReconnectOnNext).toBe(false); + expect(handleDisconnect).not.toHaveBeenCalled(); + }); + + it('does not reconnect on Lost when the pc is not connected', () => { + const { engine, internals, handleDisconnect } = primeEngine({ pcState: 3 }); // Reconnecting + + internals.handleLocalConnectionQuality(qualityUpdate(LOCAL_SID, ProtoConnectionQuality.LOST)); + vi.advanceTimersByTime(LOST_TIMEOUT_MS); + + expect(engine.fullReconnectOnNext).toBe(false); + expect(handleDisconnect).not.toHaveBeenCalled(); + }); + + it('ignores Lost quality reported for other participants', () => { + const { engine, internals, handleDisconnect } = primeEngine(); + + internals.handleLocalConnectionQuality( + qualityUpdate('PA_other', ProtoConnectionQuality.LOST), + ); + vi.advanceTimersByTime(LOST_TIMEOUT_MS); + + expect(engine.fullReconnectOnNext).toBe(false); + expect(handleDisconnect).not.toHaveBeenCalled(); + }); + }); + + describe('reconnect requested mid-attempt', () => { + // A full reconnect requested while a resume is already in flight (e.g. a server + // RECONNECT leave racing the resume) sets `fullReconnectOnNext` mid-attempt. A + // successful resume must not swallow it: it survives and is dispatched afterwards. + interface ReconnectInternals { + _isClosed: boolean; + attemptingReconnect: boolean; + clientConfiguration: unknown; + pcManager: unknown; + resumeConnection: (reason?: number) => Promise; + restartConnection: (regionUrl?: string) => Promise; + clearPendingReconnect: () => void; + handleDisconnect: (connection: string, reason?: number) => void; + attemptReconnect: (reason?: number) => Promise; + } + + function primeEngine() { + const engine = new RTCEngine(roomOptionDefaults); + const internals = engine as unknown as ReconnectInternals; + internals._isClosed = false; + internals.attemptingReconnect = false; + // avoid the "resume disabled / pcManager is NEW -> force full reconnect" escalation + internals.clientConfiguration = undefined; + internals.pcManager = { currentState: PCTransportState.CONNECTED }; + internals.clearPendingReconnect = vi.fn(); + const handleDisconnect = vi.fn(); + internals.handleDisconnect = handleDisconnect; + const restartConnection = vi.fn(async () => {}); + internals.restartConnection = restartConnection; + return { engine, internals, handleDisconnect, restartConnection }; + } + + it('dispatches a full reconnect when a resume succeeds but one was requested mid-attempt', async () => { + const { engine, internals, handleDisconnect, restartConnection } = primeEngine(); + engine.fullReconnectOnNext = false; + // the resume succeeds, but a RECONNECT leave arrives while it is in flight + internals.resumeConnection = vi.fn(async () => { + engine.fullReconnectOnNext = true; + }); + + await internals.attemptReconnect(); + + expect(internals.resumeConnection).toHaveBeenCalledTimes(1); + expect(restartConnection).not.toHaveBeenCalled(); + // the mid-attempt request survived the successful resume and was dispatched + expect(engine.fullReconnectOnNext).toBe(true); + expect(handleDisconnect).toHaveBeenCalledTimes(1); + expect(handleDisconnect).toHaveBeenCalledWith('reconnect'); + }); + + it('does not dispatch a follow-up after an ordinary successful resume', async () => { + const { engine, internals, handleDisconnect, restartConnection } = primeEngine(); + engine.fullReconnectOnNext = false; + internals.resumeConnection = vi.fn(async () => {}); + + await internals.attemptReconnect(); + + expect(internals.resumeConnection).toHaveBeenCalledTimes(1); + expect(restartConnection).not.toHaveBeenCalled(); + expect(engine.fullReconnectOnNext).toBe(false); + expect(handleDisconnect).not.toHaveBeenCalled(); + }); + + it('clears the flag and does not re-dispatch after a successful full reconnect', async () => { + const { engine, internals, handleDisconnect, restartConnection } = primeEngine(); + engine.fullReconnectOnNext = true; // enters as a full reconnect + internals.resumeConnection = vi.fn(async () => {}); + + await internals.attemptReconnect(); + + expect(restartConnection).toHaveBeenCalledTimes(1); + expect(internals.resumeConnection).not.toHaveBeenCalled(); + expect(engine.fullReconnectOnNext).toBe(false); + expect(handleDisconnect).not.toHaveBeenCalled(); + }); + + it('dispatches a follow-up when a full reconnect succeeds but one was requested mid-attempt', async () => { + const { engine, internals, handleDisconnect, restartConnection } = primeEngine(); + engine.fullReconnectOnNext = true; // enters as a full reconnect + // a new RECONNECT request arrives while restartConnection is running + restartConnection.mockImplementationOnce(async () => { + engine.fullReconnectOnNext = true; + }); + + await internals.attemptReconnect(); + + expect(restartConnection).toHaveBeenCalledTimes(1); + // the mid-restart request survived the successful full reconnect and was dispatched + expect(engine.fullReconnectOnNext).toBe(true); + expect(handleDisconnect).toHaveBeenCalledTimes(1); + expect(handleDisconnect).toHaveBeenCalledWith('reconnect'); + }); + + it('does not add a dispatch on top of the failure path retry', async () => { + const { engine, internals, handleDisconnect } = primeEngine(); + engine.fullReconnectOnNext = false; + // resume fails after a mid-attempt request; the catch path schedules the retry + internals.resumeConnection = vi.fn(async () => { + engine.fullReconnectOnNext = true; + throw new Error('resume failed'); + }); + + await internals.attemptReconnect(); + + // exactly one dispatch (from the catch), not a second one from the finally + expect(handleDisconnect).toHaveBeenCalledTimes(1); + }); + }); + + describe('verifyTransport stuck-connecting bound', () => { + interface VerifyInternals { + pcManager: unknown; + client: unknown; + transportConnectingSince?: number; + } + + function primeEngine(currentState: PCTransportState) { + const engine = new RTCEngine(roomOptionDefaults); + const internals = engine as unknown as VerifyInternals; + internals.pcManager = { currentState }; + internals.client = { ws: { readyState: WebSocket.OPEN } }; + return { engine, internals }; + } + + it('reports the transport stuck when connecting longer than peerConnectionTimeout', () => { + const { engine, internals } = primeEngine(PCTransportState.CONNECTING); + internals.transportConnectingSince = Date.now() - (engine.peerConnectionTimeout + 1_000); + + expect(engine.verifyTransport()).toBe(false); + }); + + it('tolerates a transport still within the connecting window', () => { + const { engine, internals } = primeEngine(PCTransportState.CONNECTING); + internals.transportConnectingSince = Date.now(); + + expect(engine.verifyTransport()).toBe(true); + }); + + it('fails open (and does not record a timestamp) when connecting is untracked', () => { + // verifyTransport is a pure read now: an unrecorded CONNECTING must not be treated as + // stuck, and the method must not seed a timestamp that could later leak across teardown. + const { engine, internals } = primeEngine(PCTransportState.CONNECTING); + internals.transportConnectingSince = undefined; + + expect(engine.verifyTransport()).toBe(true); + expect(internals.transportConnectingSince).toBeUndefined(); + }); + + it('does not measure a stale connecting timestamp while connected', () => { + const { engine, internals } = primeEngine(PCTransportState.CONNECTED); + // a leftover timestamp must not affect the CONNECTED verdict, and stays for the + // state-change handler to clear rather than being mutated here + internals.transportConnectingSince = Date.now() - 10 * engine.peerConnectionTimeout; + + expect(engine.verifyTransport()).toBe(true); + }); + }); + + describe('Lost-quality countdown across reconnects', () => { + // A Lost-quality countdown armed by the previous session must not survive a reconnect + // and fire against the new session before the server has re-evaluated it. + const LOST_TIMEOUT_MS = 5_000; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('cancels a pending Lost countdown when a reconnect attempt begins', async () => { + const engine = new RTCEngine(roomOptionDefaults); + const internals = engine as unknown as { + _isClosed: boolean; + participantSid: string; + pcState: number; + attemptingReconnect: boolean; + clientConfiguration: unknown; + pcManager: unknown; + lostQualityTimeout?: ReturnType; + resumeConnection: (reason?: number) => Promise; + restartConnection: () => Promise; + clearPendingReconnect: () => void; + handleDisconnect: (connection: string, reason?: number) => void; + handleLocalConnectionQuality: (update: unknown) => void; + attemptReconnect: (reason?: number) => Promise; + }; + internals._isClosed = false; + internals.participantSid = 'PA_local'; + internals.pcState = 1; // PCState.Connected — the guards the countdown checks would pass + internals.attemptingReconnect = false; + internals.clientConfiguration = undefined; + internals.pcManager = { + currentState: PCTransportState.CONNECTED, + publisher: { getSenders: () => [{ track: { readyState: 'live' } }] }, + }; + internals.clearPendingReconnect = vi.fn(); + const handleDisconnect = vi.fn(); + internals.handleDisconnect = handleDisconnect; + internals.resumeConnection = vi.fn(async () => {}); + internals.restartConnection = vi.fn(async () => {}); + + // a LOST verdict from the (soon-to-be-previous) session arms the countdown + internals.handleLocalConnectionQuality({ + updates: [{ participantSid: 'PA_local', quality: ProtoConnectionQuality.LOST }], + }); + expect(internals.lostQualityTimeout).toBeDefined(); + + // a reconnect begins and completes (resume) before the countdown elapses + engine.fullReconnectOnNext = false; + await internals.attemptReconnect(); + + // the stale countdown was cancelled and cannot fire against the reconnected session + expect(internals.lostQualityTimeout).toBeUndefined(); + vi.advanceTimersByTime(LOST_TIMEOUT_MS); + expect(handleDisconnect).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/room/RTCEngine.ts b/src/room/RTCEngine.ts index ab0a7bb4ae..f571ae09a6 100644 --- a/src/room/RTCEngine.ts +++ b/src/room/RTCEngine.ts @@ -18,6 +18,7 @@ import { LeaveRequest_Action, MediaSectionsRequirement, ParticipantInfo, + ConnectionQuality as ProtoConnectionQuality, PublishDataTrackResponse, ReconnectReason, type ReconnectResponse, @@ -108,6 +109,12 @@ import { const minReconnectWait = 2 * 1000; const leaveReconnect = 'leave-reconnect'; + +/** + * How long local connection quality must stay `LOST` while connected and publishing before we + * force a full reconnect — `LOST` is the server's verdict that it isn't receiving our media. + */ +const connectionQualityLostTimeout = 10 * 1000; const reliabeReceiveStateTTL = 30_000; const initialMediaSectionsAudio = 3; @@ -246,6 +253,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit /** used to indicate whether the browser is currently waiting to reconnect */ private isWaitingForNetworkReconnect: boolean = false; + /** set while the local participant's connection quality is `LOST`; forces a full reconnect on timeout */ + private lostQualityTimeout?: ReturnType; + + /** timestamp (ms) the primary transport entered `CONNECTING`, used to bound how long we tolerate it */ + private transportConnectingSince?: number; + constructor(private options: InternalRoomOptions) { super(); this.log = getLogger(options.loggerName ?? LoggerNames.Engine, () => this.logContext); @@ -271,8 +284,10 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.client.onParticipantUpdate = (updates) => this.emit(EngineEvent.ParticipantUpdate, updates); - this.client.onConnectionQuality = (update) => + this.client.onConnectionQuality = (update) => { + this.handleLocalConnectionQuality(update); this.emit(EngineEvent.ConnectionQualityUpdate, update); + }; this.client.onRoomUpdate = (update) => this.emit(EngineEvent.RoomUpdate, update); this.client.onSubscriptionError = (resp) => this.emit(EngineEvent.SubscriptionError, resp); this.client.onSubscriptionPermissionUpdate = (update) => @@ -429,6 +444,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.removeAllListeners(); this.deregisterOnLineListener(); this.clearPendingReconnect(); + this.clearLostQualityTimeout(); this.cleanupLossyDataStats(); await this.cleanupPeerConnections(); await this.cleanupClient(); @@ -442,6 +458,8 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit await this.pcManager?.close(); this.pcManager = undefined; + // the connecting timestamp belongs to the transports we just tore down + this.transportConnectingSince = undefined; this.reliableReceivedState.clear(); } @@ -468,7 +486,7 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit throw new TrackInvalidError('a track with the same ID has already been published'); } return new Promise((resolve, reject) => { - const publicationTimeout = setTimeout(() => { + const publicationTimeout = CriticalTimers.setTimeout(() => { delete this.pendingTrackResolvers[req.cid]; reject( ConnectionError.timeout('publication of local track timed out, no response from server'), @@ -476,11 +494,11 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit }, 10_000); this.pendingTrackResolvers[req.cid] = { resolve: (info: TrackInfo) => { - clearTimeout(publicationTimeout); + CriticalTimers.clearTimeout(publicationTimeout); resolve(info); }, reject: () => { - clearTimeout(publicationTimeout); + CriticalTimers.clearTimeout(publicationTimeout); reject(new Error('Cancelled publication by calling unpublish')); }, }; @@ -565,6 +583,16 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.pcManager.onStateChange = async (connectionState, publisherState, subscriberState) => { this.log.debug(`primary PC state changed ${connectionState}`); + // Record when the primary transport actually entered CONNECTING so + // verifyTransport() can bound how long we tolerate it. Deriving it from the + // real transition (this handler only fires on state changes) rather than from + // observation time keeps it from going stale across peer-connection rebuilds. + if (connectionState === PCTransportState.CONNECTING) { + this.transportConnectingSince = Date.now(); + } else { + this.transportConnectingSince = undefined; + } + if (['closed', 'disconnected', 'failed'].includes(publisherState)) { // reset publisher connection promise this.publisherConnectionPromise = undefined; @@ -1157,6 +1185,73 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit ); }; + /** + * A sustained local `LOST` while connected and publishing means the server isn't receiving + * our media, so force a full reconnect; any non-`LOST` value cancels a pending trigger. + */ + private handleLocalConnectionQuality(update: ConnectionQualityUpdate) { + if (!this.participantSid) { + return; + } + const localUpdate = update.updates.find((u) => u.participantSid === this.participantSid); + if (!localUpdate) { + return; + } + if (localUpdate.quality === ProtoConnectionQuality.LOST) { + this.scheduleLostQualityReconnect(); + } else { + this.clearLostQualityTimeout(); + } + } + + private scheduleLostQualityReconnect() { + if (this.lostQualityTimeout) { + // already counting down towards a reconnect + return; + } + this.lostQualityTimeout = CriticalTimers.setTimeout(() => { + this.lostQualityTimeout = undefined; + if (this._isClosed || this.pcState !== PCState.Connected || this.attemptingReconnect) { + return; + } + if (!this.hasActivePublisherSenders()) { + return; + } + this.log.warn( + 'local connection quality lost while publishing, triggering full reconnect', + this.logContext, + ); + this.fullReconnectOnNext = true; + this.handleDisconnect('connection quality lost', ReconnectReason.RR_PUBLISHER_FAILED); + }, connectionQualityLostTimeout); + } + + private clearLostQualityTimeout() { + if (this.lostQualityTimeout) { + CriticalTimers.clearTimeout(this.lostQualityTimeout); + this.lostQualityTimeout = undefined; + } + } + + /** Whether the publisher currently has any sender with a live track. */ + private hasActivePublisherSenders(): boolean { + return ( + this.pcManager?.publisher + .getSenders() + .some((sender) => !!sender.track && sender.track.readyState === 'live') ?? false + ); + } + + /** + * Forces a full reconnect while keeping the engine (and its saved credentials) alive. Used by + * Room's connection-reconcile safety net when the transport silently died but we looked connected. + * @internal + */ + reconnect(reason: ReconnectReason = ReconnectReason.RR_UNKNOWN) { + this.fullReconnectOnNext = true; + this.handleDisconnect('reconcile', reason); + } + private async attemptReconnect(reason?: ReconnectReason) { if (this._isClosed) { return; @@ -1166,6 +1261,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.log.warn('already attempting reconnect, returning early'); return; } + + // A pending Lost-quality countdown belongs to the session we're now leaving; cancel it so + // it can't fire against the reconnected session before the server has evaluated it. (A resume + // keeps the peer connections, so cleanupPeerConnections wouldn't cover this path.) + this.clearLostQualityTimeout(); + if ( this.clientConfiguration?.resumeConnection === ClientConfigSetting.DISABLED || // signaling state could change to closed due to hardware sleep @@ -1175,15 +1276,23 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.fullReconnectOnNext = true; } + // Consume the flag up front: capture whether this attempt is a full reconnect, then reset + // it. From here on a `true` value unambiguously represents a *new* full-reconnect request + // that arrived while this attempt was running (e.g. a server RECONNECT leave), which the + // finally block dispatches — for both the resume and full-reconnect paths. + const fullReconnect = this.fullReconnectOnNext; + this.fullReconnectOnNext = false; + + let succeeded = false; try { this.attemptingReconnect = true; - if (this.fullReconnectOnNext) { + if (fullReconnect) { await this.restartConnection(); } else { await this.resumeConnection(reason); } this.clearPendingReconnect(); - this.fullReconnectOnNext = false; + succeeded = true; } catch (e) { this.reconnectAttempts += 1; let recoverable = true; @@ -1191,8 +1300,9 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit this.log.debug('received unrecoverable error', { error: e }); // unrecoverable recoverable = false; - } else if (!(e instanceof SignalReconnectError)) { - // cannot resume + } else if (fullReconnect || !(e instanceof SignalReconnectError)) { + // a failed full reconnect stays a full reconnect; a failed resume can only be + // resumed again for a signal-level error, otherwise it escalates this.fullReconnectOnNext = true; } @@ -1209,6 +1319,14 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit } } finally { this.attemptingReconnect = false; + + // A full reconnect requested while this attempt was running (e.g. a `RECONNECT` leave + // during a resume or a restart) that a successful attempt didn't act on; dispatch it now + // (the failure path already retries). + if (succeeded && this.fullReconnectOnNext && !this._isClosed) { + this.log.debug('full reconnect requested during in-progress attempt, dispatching'); + this.handleDisconnect('reconnect'); + } } } @@ -1591,11 +1709,12 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (!this.pcManager) { return false; } + const state = this.pcManager.currentState; const allowedConnectionStates: PCTransportState[] = [ PCTransportState.CONNECTING, PCTransportState.CONNECTED, ]; - if (!allowedConnectionStates.includes(this.pcManager.currentState)) { + if (!allowedConnectionStates.includes(state)) { return false; } @@ -1603,6 +1722,20 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit if (!this.client.ws || this.client.ws.readyState === WebSocket.CLOSED) { return false; } + + // A transport stuck in CONNECTING never reaches CONNECTED nor reports FAILED, so it would + // otherwise look healthy forever; bound how long we tolerate it. The entry time is recorded + // in the pcManager state-change handler (see configure()), so this is a pure read — an + // unrecorded CONNECTING fails open rather than measuring against a stale timestamp. + if ( + state === PCTransportState.CONNECTING && + this.transportConnectingSince !== undefined && + Date.now() - this.transportConnectingSince > this.peerConnectionTimeout + ) { + this.log.warn('transport stuck in connecting state', this.logContext); + return false; + } + return true; } diff --git a/src/room/Room.ts b/src/room/Room.ts index 47b3087777..bcc0b5ae6a 100644 --- a/src/room/Room.ts +++ b/src/room/Room.ts @@ -2604,11 +2604,20 @@ class Room extends (EventEmitter as new () => TypedEmitter) : undefined, }); if (consecutiveFailures >= 3) { - this.recreateEngine(); - this.handleDisconnect( - this.options.stopLocalTrackOnUnpublish, - DisconnectReason.STATE_MISMATCH, - ); + this.clearConnectionReconcile(); + if (this.engine && !this.engine.isClosed) { + // The transport silently died while we still looked connected. Try a full reconnect + // (keeps the room alive; the engine falls back to Disconnected if it ultimately fails). + this.log.warn('detected connection state mismatch, attempting full reconnect'); + this.engine.reconnect(); + } else { + // No usable engine to reconnect with; tear down. + this.recreateEngine(); + this.handleDisconnect( + this.options.stopLocalTrackOnUnpublish, + DisconnectReason.STATE_MISMATCH, + ); + } } } else { consecutiveFailures = 0;