-
Notifications
You must be signed in to change notification settings - Fork 276
fix: recover broken publish paths and stuck reconnects #2030
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "livekit-client": patch | ||
| --- | ||
|
|
||
| fix: recover broken publish paths — act on local `ConnectionQuality.Lost`, add outbound-RTP liveness to the connection reconcile, recreate the peer connection when an ICE restart has no remote description, and reconnect (instead of disconnecting) on a detected connection state mismatch |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 = 5 * 1000; | ||
| const reliabeReceiveStateTTL = 30_000; | ||
|
|
||
| const initialMediaSectionsAudio = 3; | ||
|
|
@@ -246,6 +253,15 @@ 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<typeof setTimeout>; | ||
|
|
||
| /** timestamp (ms) the primary transport entered `CONNECTING`, used to bound how long we tolerate it */ | ||
| private transportConnectingSince?: number; | ||
|
|
||
| /** last observed publisher outbound `bytesSent`, used to detect a stalled publish path in {@link verifyTransport} */ | ||
| private lastPublisherBytesSent?: number; | ||
|
|
||
| constructor(private options: InternalRoomOptions) { | ||
| super(); | ||
| this.log = getLogger(options.loggerName ?? LoggerNames.Engine, () => this.logContext); | ||
|
|
@@ -271,8 +287,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 +447,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(); | ||
|
|
@@ -1157,6 +1176,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) { | ||
| 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; | ||
|
|
@@ -1175,15 +1261,23 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit | |
| this.fullReconnectOnNext = true; | ||
| } | ||
|
|
||
| let succeeded = false; | ||
| let performedFullReconnect = false; | ||
| try { | ||
| this.attemptingReconnect = true; | ||
| if (this.fullReconnectOnNext) { | ||
| performedFullReconnect = true; | ||
| await this.restartConnection(); | ||
| } else { | ||
| await this.resumeConnection(reason); | ||
| } | ||
| this.clearPendingReconnect(); | ||
| this.fullReconnectOnNext = false; | ||
| // Only clear the flag if we actually did a full reconnect, so a full reconnect requested | ||
| // mid-attempt (e.g. a server leave during a resume) survives a successful resume. | ||
| if (performedFullReconnect) { | ||
| this.fullReconnectOnNext = false; | ||
| } | ||
| succeeded = true; | ||
| } catch (e) { | ||
| this.reconnectAttempts += 1; | ||
| let recoverable = true; | ||
|
|
@@ -1209,6 +1303,13 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit | |
| } | ||
| } finally { | ||
| this.attemptingReconnect = false; | ||
|
|
||
| // A full reconnect requested mid-attempt (e.g. a `RECONNECT` leave during a resume) 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'); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -1587,25 +1688,85 @@ export default class RTCEngine extends (EventEmitter as new () => TypedEventEmit | |
| } | ||
|
|
||
| /* @internal */ | ||
| verifyTransport(): boolean { | ||
| async verifyTransport(): Promise<boolean> { | ||
| 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)) { | ||
| this.transportConnectingSince = undefined; | ||
| this.lastPublisherBytesSent = undefined; | ||
| return false; | ||
| } | ||
|
|
||
| // ensure signal is connected | ||
| 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. | ||
| if (state === PCTransportState.CONNECTING) { | ||
| const now = Date.now(); | ||
| if (this.transportConnectingSince === undefined) { | ||
| this.transportConnectingSince = now; | ||
| } else if (now - this.transportConnectingSince > this.peerConnectionTimeout) { | ||
| this.log.warn('transport stuck in connecting state', this.logContext); | ||
| return false; | ||
| } | ||
| // can't assert media liveness until connected | ||
| this.lastPublisherBytesSent = undefined; | ||
| return true; | ||
| } | ||
| this.transportConnectingSince = undefined; | ||
|
|
||
| // Outbound-RTP liveness: with active senders `bytesSent` must keep advancing between checks; | ||
| // if it stalls while connected the publish path is broken even though the PC looks connected. | ||
| if (this.hasActivePublisherSenders()) { | ||
| const bytesSent = await this.getPublisherBytesSent(); | ||
| if (bytesSent !== undefined) { | ||
| const advanced = | ||
| this.lastPublisherBytesSent === undefined || bytesSent > this.lastPublisherBytesSent; | ||
| this.lastPublisherBytesSent = bytesSent; | ||
| if (!advanced) { | ||
| this.log.warn('publisher outbound bytes not advancing while senders active', { | ||
| ...this.logContext, | ||
| bytesSent, | ||
| }); | ||
| return false; | ||
| } | ||
| } | ||
| } else { | ||
| this.lastPublisherBytesSent = undefined; | ||
| } | ||
|
Comment on lines
+1729
to
+1745
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 Sessions that legitimately send no video for a few seconds get forcibly reconnected A connected session is judged broken ( Why a healthy publisher can flatline outbound bytes
The reconcile in A tolerance (e.g. require several consecutive stalled samples only when a track is unmuted/expected to send, or compare against Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is also an interesting question too 🤔 |
||
|
|
||
| return true; | ||
| } | ||
|
|
||
| /** Sum of `bytesSent` across the publisher's outbound-rtp stats, or undefined if unavailable. */ | ||
| private async getPublisherBytesSent(): Promise<number | undefined> { | ||
| try { | ||
| const stats = await this.pcManager?.publisher.getStats(); | ||
| if (!stats) { | ||
| return undefined; | ||
| } | ||
| let bytesSent = 0; | ||
| stats.forEach((report) => { | ||
| if (report.type === 'outbound-rtp') { | ||
| bytesSent += report.bytesSent ?? 0; | ||
| } | ||
| }); | ||
| return bytesSent; | ||
| } catch (e) { | ||
| this.log.debug('could not read publisher stats', { ...this.logContext, error: e }); | ||
| return undefined; | ||
| } | ||
| } | ||
|
|
||
| /** @internal */ | ||
| async negotiate(): Promise<void> { | ||
| // observe signal state | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Pending connection-quality reconnect timer may not be cancellable on some platforms
The countdown that forces a reconnect is cancelled with the global timer-clearing call (
clearTimeout(this.lostQualityTimeout)atsrc/room/RTCEngine.ts:1222) instead of the platform-overridable one used to start it, so on platforms that swap in their own timers the countdown can keep running and reconnect anyway.Impact: On platforms with custom timers (e.g. React Native background-safe timers), a connection-quality alarm that should have been cancelled can still fire and needlessly restart the connection.
Timer API mismatch
The timeout is created with
CriticalTimers.setTimeout(src/room/RTCEngine.ts:1203), whose implementation can be replaced at runtime (src/room/timers.ts) with a platform-specific one whose handle is not a plainsetTimeoutid. Every other site in the codebase pairs it withCriticalTimers.clearTimeout(e.g.clearReconnectTimeoutatsrc/room/RTCEngine.ts:1962-1966,clearPingTimeoutinsrc/api/SignalClient.ts). Using the globalclearTimeouthere can silently fail to cancel the handle.Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
^ I was also wondering this, I think this change as is is today would work on web, but would fail to clean itself up properly on react native.