Skip to content
Open
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/quiet-planets-fix.md
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
10 changes: 8 additions & 2 deletions src/room/PCTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
169 changes: 165 additions & 4 deletions src/room/RTCEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
LeaveRequest_Action,
MediaSectionsRequirement,
ParticipantInfo,
ConnectionQuality as ProtoConnectionQuality,
PublishDataTrackResponse,
ReconnectReason,
type ReconnectResponse,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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) =>
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
}
}
Comment on lines +1220 to +1225

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.

🟡 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) at src/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 plain setTimeout id. Every other site in the codebase pairs it with CriticalTimers.clearTimeout (e.g. clearReconnectTimeout at src/room/RTCEngine.ts:1962-1966, clearPingTimeout in src/api/SignalClient.ts). Using the global clearTimeout here can silently fail to cancel the handle.

Suggested change
private clearLostQualityTimeout() {
if (this.lostQualityTimeout) {
clearTimeout(this.lostQualityTimeout);
this.lostQualityTimeout = undefined;
}
}
private clearLostQualityTimeout() {
if (this.lostQualityTimeout) {
CriticalTimers.clearTimeout(this.lostQualityTimeout);
this.lostQualityTimeout = undefined;
}
}
Open in Devin Review

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

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.

^ 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.


/** 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;
Expand All @@ -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;
Expand All @@ -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');
}
}
}

Expand Down Expand Up @@ -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

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.

🔴 Sessions that legitimately send no video for a few seconds get forcibly reconnected

A connected session is judged broken (bytesSent > this.lastPublisherBytesSent at src/room/RTCEngine.ts:1729-1741) purely because outbound bytes didn't grow between health checks, so a perfectly healthy session that momentarily has nothing to send is torn down and re-established.
Impact: Users sharing a static screen or holding a paused/muted (but still attached) camera can be kicked into a full reconnect, briefly interrupting their published media.

Why a healthy publisher can flatline outbound bytes

hasActivePublisherSenders() (src/room/RTCEngine.ts:1228-1234) only requires a sender whose MediaStreamTrack.readyState === 'live'; it does not consider whether the track is currently producing frames. Two common cases keep a sender "live" while producing no RTP:

  • A screen share of unchanging content: the capturer delivers no new frames, so the encoder emits nothing and bytesSent stays flat.
  • A user-provided or screen-share track muted via enabled = false (LiveKit only calls stop() for non-user-provided camera/mic tracks — see LocalVideoTrack.mute() and LocalAudioTrack.mute()), which stays live but can produce (near-)zero traffic.

The reconcile in src/room/Room.ts:2587-2628 runs every 4s and escalates after 3 consecutive failures, so ~12s of a static/paused publisher (with no other advancing sender, e.g. mic stopped on mute) is enough to trigger engine.reconnect().

A tolerance (e.g. require several consecutive stalled samples only when a track is unmuted/expected to send, or compare against framesEncoded/packetsSent plus an allowance for idle encoders) would avoid the false positive.

Prompt for agents
verifyTransport() in src/room/RTCEngine.ts treats a non-advancing summed outbound-rtp bytesSent as a broken publish path whenever hasActivePublisherSenders() is true. hasActivePublisherSenders() only checks that sender.track.readyState === 'live', which is also true for tracks that legitimately produce no media: a screen share of static content (capturer emits no new frames), or a muted user-provided/screen-share track (LiveKit only stops camera/mic tracks that are not user-provided, see LocalVideoTrack.mute / LocalAudioTrack.mute). Combined with Room's reconcile (4s interval, 3 consecutive failures) this can force a full reconnect on a perfectly healthy session after ~12 seconds of idleness. Consider restricting the liveness assertion to senders whose track is enabled/unmuted and whose source is expected to emit, or requiring corroborating evidence (e.g. ICE/DTLS state, RTCP round trip, or a much longer stall window) before declaring the transport unhealthy.
Open in Devin Review

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

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.

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
Expand Down
28 changes: 20 additions & 8 deletions src/room/Room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2584,31 +2584,43 @@ class Room extends (EventEmitter as new () => TypedEmitter<RoomEventCallbacks>)
private registerConnectionReconcile() {
this.clearConnectionReconcile();
let consecutiveFailures = 0;
this.connectionReconcileInterval = CriticalTimers.setInterval(() => {
this.connectionReconcileInterval = CriticalTimers.setInterval(async () => {
// `verifyTransport` is async (it samples outbound-rtp stats), so resolve it once
// and reuse the result for both the decision and the diagnostic log.
const transportHealthy = this.engine ? await this.engine.verifyTransport() : false;
if (
// ensure we didn't tear it down
!this.engine ||
// engine detected close, but Room missed it
this.engine.isClosed ||
// transports failed without notifying engine
!this.engine.verifyTransport()
!transportHealthy
) {
consecutiveFailures++;
this.log.warn('detected connection state mismatch', {
numFailures: consecutiveFailures,
engine: this.engine
? {
closed: this.engine.isClosed,
transportsConnectedOrConnecting: this.engine.verifyTransport(),
transportsConnectedOrConnecting: transportHealthy,
}
: 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;
Expand Down
Loading