Skip to content

[@elevenlabs/client][@elevenlabs/react] Pending WebRTC start cannot be cancelled and can reject after teardown #1013

Open

Description

@Enalmada

Summary

There are two related lifecycle gaps during WebRTC startup.

  1. A publication rejection can remain unobserved. WebRTCConnection.create() begins microphone publication from RoomEvent.SignalConnected, but does not observe the resulting setMicrophoneEnabled() promise (micEnabled) until after room.connect() and the connected-state wait finish. If publication rejects first—for example, when LiveKit's AddTrack request times out—the rejection can surface as a browser unhandledrejection while startup remains pending.
  2. A pending start cannot be cancelled. At the React layer, endSession() and provider unmount mark an in-flight Conversation.startSession() as ended and attach cleanup to its outer promise. The old LiveKit Room and microphone can therefore remain alive until that promise settles, while a replacement provider can start. A later stale failure may be omitted from application onError even if an earlier inner rejection has already escaped.

Source inspection

The ordering is present on main at effdd8abae4589cf717e11854d12c90fcea09961:

The current React tests named “cancels session if endSession is called during connection” and “allows new connection after cancelled session” resolve the pending start before asserting teardown or replacement. They cover deferred cleanup after successful resolution, not cancellation while startup remains pending.

Suggested tests to demonstrate current behavior

For the client, use a mocked LiveKit Room with independent deferred promises:

  1. Call WebRTCConnection.create() and emit SignalConnected.
  2. Leave room.connect() pending.
  3. Reject setMicrophoneEnabled() with new Error("publication of local track timed out, no response from server").
  4. Observe that the start promise remains pending and the rejection can reach unhandledrejection before room.connect() settles.

For React, leave Conversation.startSession() pending, call provider endSession() or unmount, and observe that the in-flight start is not cancelled and cleanup does not run until the outer promise settles. A replacement provider can begin while the old Room or microphone work is still pending. There is currently no public handle to cancel that start or await teardown completion before replacement.

Requested behavioral contract

  • Observe rejections from both room.connect() and microphone publication from their creation through cancellation and late settlement, so neither can become an unhandledrejection regardless of settlement order.
  • Provide a public way to cancel an in-flight start and await teardown completion, through React endSession() or an equivalent API. Provider unmount must begin cancellation synchronously even though an effect cleanup cannot await it.
  • On active startup failure, disconnect and clean up the Room; stop every microphone track acquired for that start, including acquisition that resolves after cancellation; allow a later start without waiting for the cancelled outer promise; and deliver the original Error to the application exactly once.
  • On intentional teardown of a stale start, perform the same cleanup without calling application onError.
  • Make late callbacks and settlements from the cancelled Room inert so they cannot publish or mutate replacement-session state.
  • When cancellation and active failure race, deliver exactly one documented outcome and no duplicate failure signal.
  • Resolve teardown only after the cancelled start's Room cannot publish and all microphone acquisition is either cancelled or guarded so a later-acquired track is stopped before use. Calling room.disconnect() alone should not be assumed to abort every pending LiveKit operation.

Observing both startup promises concurrently closes the unhandled-rejection window but is not sufficient on its own: without external cancellation, the old Room and microphone can still outlive React teardown until the LiveKit failure arrives.

Suggested regression and contract coverage

Client

  1. Microphone-first failure: micEnabled rejects while room.connect() is pending. Startup rejects with the same Error without waiting for room.connect() to settle; Room cleanup runs; no unhandledrejection fires.
  2. Connect-first failure: room.connect() rejects while microphone setup is pending. Microphone and Room cleanup run; the connect error is reported once; a later microphone rejection remains observed.
  3. Explicit cancellation: cancel while both operations are pending. Cleanup starts without waiting for either operation; teardown does not resolve before owned Room and track resources are safe; later rejections remain observed.
  4. Late settlement and acquisition: settle either operation, or resolve pending media acquisition, after cancellation. Every late-acquired track stops; no publication, stale callback, stale state mutation, unhandledrejection, or second failure signal occurs.

React

  1. endSession() during start: cancellation is invoked immediately; after teardown resolves, a later start is allowed even if the old outer promise remains pending; application onError is not called.
  2. Unmount during start: cancellation begins synchronously and late settlements remain observed even though React cannot await effect cleanup.
  3. Replacement ordering: after awaiting teardown, a second start sees no live old track or Room capable of publishing; stale callbacks cannot overwrite replacement state.
  4. Active failure and tie-breaking: a non-cancelled publication failure reaches provider onError once with the original message and leaves the provider ready for a later start; cancellation/failure races follow one documented outcome without duplicate signaling.

Versions

Observed by source inspection in @elevenlabs/client@1.17.0 and @elevenlabs/react@1.12.0; still present in published @elevenlabs/client@1.25.0, published @elevenlabs/react@1.15.2, and upstream main at effdd8abae4589cf717e11854d12c90fcea09961.

Activity

  1. sailod commented on Sep 30, 2026

    @sailod

    Confirming both lifecycle gaps from #1013 with a production incident we
    diagnosed last week — the timeline matches the "stale failure omitted from
    onError" + replacement-start gating behavior described here, and it's
    reproducible from our server-side records alone.

    Environment: @elevenlabs/react 1.13.0 / @elevenlabs/client 1.21.0,
    WebRTC mode with webRtc: { iceTransportPolicy: "relay" } (EL TURN relay),
    mobile Chrome on Android.

    Incident timeline (from the EL conversation record + our post-call webhook
    server logs — client telemetry for this window was unfortunately lost, so
    every timestamp below is server-side):

    • 09:20 — Session 1 connects normally, ~255s of practice, disconnects
      (client network drop, Client disconnected: 1000). Billed normally.
    • 09:25:50 — User taps our app's Reconnect. Access check mints a fresh
      conversation token (we had recently fixed an unrelated stale-token caching
      bug, so the token was minted milliseconds before the attempt — not the
      variable here).
    • 09:25:51 — EL creates the conversation; the agent starts its greeting
      (room alive, waiting).
    • 09:26:22 — 30 seconds after room creation, EL closes the
      conversation (Client disconnected: 1000). Our webhook receives the
      post-call event and finds no matching local record — proof the client
      never finished joining and never delivered the conversation id up-stack.
    • Client side (reconstructed): UI remained on "connecting…" indefinitely —
      no onError fired, and a subsequent startSession() call was a silent
      no-op until the page was reloaded.

    Why we believe this maps to #1013 rather than a plain ICE failure:
    LiveKit's own peerConnectionTimeout (15s) should have rejected the join
    long before EL's 30s server close. The only ways the app ends up in
    "pending forever with no onError" are the ordering gaps this issue
    describes — the server-close path firing the provider's disconnect/status
    handling while the startup rejection is still in flight, the stale/shouldEnd
    gating classifying that rejection as superseded, and lockRef never
    clearing as a result (if (lockRef.current) return; on the next
    startSession()).

    Operational impact for us: in addition to the stuck UI, we're billed
    platform credits for the ~30s of agent greeting audio on each occurrence
    (the room did play TTS to nobody). We're currently shipping extra
    client/server instrumentation to capture the next occurrence with
    client-side evidence, and we are watching PR #1014 with interest — the
    per-start AbortSignal + 10-second replacement-gating release + preserved
    failed-start retry look like they address exactly this failure mode.

    Happy to share traces from the next instrumented repro if useful. Thanks
    for the detailed source inspection in this issue — it matched our
    reconstruction of the incident precisely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions