Skip to content

NegotiationError from createAndSendOffer escapes to zone error handler (debounced negotiate discards inner Future) #1093

@therileyboxtoy-source

Description

@therileyboxtoy-source

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:

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions