Bug
In livekit_client 2.7.0, NegotiationError from Transport.createAndSendOffer escapes the try/catch in Engine.negotiate and bubbles to the zone error handler, even though the SDK's reconnect path is already engaged. Apps that install PlatformDispatcher.onError see this as an unhandled error and surface it to the user — but the call itself self-heals on the next reconnect.
The error surfaces with this text:
LiveKit Exception: [NegotiationError] Unable to RTCPeerConnection::setLocalDescription:
peerConnectionSetLocalDescription(): WEBRTC_SET_LOCAL_DESCRIPTION_ERROR:
Failed to set local offer sdp: The order of m-lines in subsequent offer doesn't match
order from previous offer/answer.
Root cause
Transport.negotiate at lib/src/core/transport.dart:111-115 is declared as a debounced void-returning function:
late final negotiate = Utils.createDebounceFunc(
(void _) => createAndSendOffer(),
cancelFunc: (f) => _cancelDebounce = f,
wait: connectOptions.timeouts.debounce,
);
createAndSendOffer() returns Future<void> but the inner callback returns its Future to the debouncer, which discards it (the debouncer signature is void Function(void)). Any rejection inside createAndSendOffer — most prominently a setLocalDescription error like the one above — becomes an unhandled future rejection.
Engine.negotiate at lib/src/core/engine.dart:319-336 wraps publisher!.negotiate(null) in a try/catch:
@internal
Future<void> negotiate({bool? iceRestart}) async {
if (publisher == null) {
return;
}
_hasPublished = true;
try {
publisher!.negotiate(null); // ← line 326: NOT awaited
} catch (error) {
if (error is NegotiationError) {
fullReconnectOnNext = true;
}
await handleReconnect(/* ... */);
}
}
But the catch can never fire for the async rejection, because the debounced negotiate(null) returns immediately and the actual setLocalDescription rejection happens microtasks later, outside the synchronous try frame.
The SDK author appears to be aware of an adjacent variant of this — transport.dart:138 has a // await or un-awaited ? TODO comment on the createAndSendOffer() call inside setRemoteDescription's if (renegotiate) branch.
Why it's user-visible
For Flutter apps that install a global error handler (Sentry, our own banner, anything via PlatformDispatcher.onError), the unhandled rejection from the discarded debouncer future surfaces to that handler. The user sees an error banner / Sentry event for a state the SDK silently recovers from on the next handleReconnect() cycle.
The triggering condition we see in production is a rapid call cycle — a Room is being torn down while a publish-side negotiate is in flight — which races m-line ordering between the previous and current offer/answer.
Reproducer
Wire a participant join → publish → leave → re-join cycle faster than connectOptions.timeouts.debounce. Under WebRTC m-line ordering races, the second negotiate's setLocalDescription rejects with the m-line-order error, and the rejection escapes.
Suggested fix
The debounce shape needs to either:
-
await the inner createAndSendOffer() inside the debouncer so the rejection has a handler in scope — and make the debouncer return Future<void> so callers (like Engine.negotiate) can await it. This makes the engine.dart:326 try/catch work for the async rejection.
-
Or keep the debounce shape but ensure rejections route into a controlled channel — e.g., set fullReconnectOnNext = true + call handleReconnect from inside createAndSendOffer's catch instead of Engine.negotiate's catch.
(1) is simpler and matches the apparent intent of the Engine.negotiate try/catch.
Our workaround
Until this lands upstream we filter NegotiationError in our PlatformDispatcher.onError so it doesn't surface as an error banner. Sentry + log still capture it. We drop the filter on SDK bump if the underlying rejection is contained.
Environment
livekit_client: ^2.7.0
- Flutter 3.x (Android, iOS — the bug is in the Dart layer)
- Server: LiveKit Cloud
Bug
In
livekit_client2.7.0,NegotiationErrorfromTransport.createAndSendOfferescapes the try/catch inEngine.negotiateand bubbles to the zone error handler, even though the SDK's reconnect path is already engaged. Apps that installPlatformDispatcher.onErrorsee this as an unhandled error and surface it to the user — but the call itself self-heals on the next reconnect.The error surfaces with this text:
Root cause
Transport.negotiateatlib/src/core/transport.dart:111-115is declared as a debouncedvoid-returning function:createAndSendOffer()returnsFuture<void>but the inner callback returns itsFutureto the debouncer, which discards it (the debouncer signature isvoid Function(void)). Any rejection insidecreateAndSendOffer— most prominently asetLocalDescriptionerror like the one above — becomes an unhandled future rejection.Engine.negotiateatlib/src/core/engine.dart:319-336wrapspublisher!.negotiate(null)in a try/catch:But the catch can never fire for the async rejection, because the debounced
negotiate(null)returns immediately and the actualsetLocalDescriptionrejection happens microtasks later, outside the synchronous try frame.The SDK author appears to be aware of an adjacent variant of this —
transport.dart:138has a// await or un-awaited ?TODO comment on thecreateAndSendOffer()call insidesetRemoteDescription'sif (renegotiate)branch.Why it's user-visible
For Flutter apps that install a global error handler (Sentry, our own banner, anything via
PlatformDispatcher.onError), the unhandled rejection from the discarded debouncer future surfaces to that handler. The user sees an error banner / Sentry event for a state the SDK silently recovers from on the nexthandleReconnect()cycle.The triggering condition we see in production is a rapid call cycle — a Room is being torn down while a publish-side
negotiateis in flight — which races m-line ordering between the previous and current offer/answer.Reproducer
Wire a participant join → publish → leave → re-join cycle faster than
connectOptions.timeouts.debounce. Under WebRTC m-line ordering races, the second negotiate'ssetLocalDescriptionrejects with the m-line-order error, and the rejection escapes.Suggested fix
The debounce shape needs to either:
awaitthe innercreateAndSendOffer()inside the debouncer so the rejection has a handler in scope — and make the debouncer returnFuture<void>so callers (likeEngine.negotiate) can await it. This makes theengine.dart:326try/catch work for the async rejection.Or keep the debounce shape but ensure rejections route into a controlled channel — e.g., set
fullReconnectOnNext = true+ callhandleReconnectfrom insidecreateAndSendOffer's catch instead ofEngine.negotiate's catch.(1) is simpler and matches the apparent intent of the
Engine.negotiatetry/catch.Our workaround
Until this lands upstream we filter
NegotiationErrorin ourPlatformDispatcher.onErrorso it doesn't surface as an error banner. Sentry + log still capture it. We drop the filter on SDK bump if the underlying rejection is contained.Environment
livekit_client: ^2.7.0