Skip to content

[bug] Track published via SDP renegotiation never forwarded to subscribers — server OnTrack doesn't fire, publish times out at 30s (self-hosted) #1104

Open

Description

@ianagallardo7

Versions

  • livekit_client (Dart): 2.7.0
  • flutter_webrtc: 1.4.0 (libwebrtc m144)
  • livekit-server (self-hosted): 1.11.0 (also reproduced on 1.10.0)
  • Flutter: 3.41.4
  • iOS: 18.2 and 26.0 (both repro)
  • Devices: iPhone 13, iPhone 15 Pro, iPad mini 6
  • Topology: multi-PC (default, not single-PC mode)

What happens

  1. Host connects to Room, publishes camera + mic (initial offer). Works.

  2. Host taps "Share screen" → our native bridge presents RPSystemBroadcastPickerView → user taps Start Broadcast.

  3. iOS Broadcast Upload Extension starts pushing CMSampleBuffers via the Unix socket in the App Group container.

  4. SDK's BroadcastManager fires isBroadcasting=true. Our Dart code does NOT call setScreenShareEnabled(true) — we rely on SDK auto-publish (after removing our manual call, a race condition was resolved in a prior diagnostic loop).

    Room is configured with:

   RoomOptions(
     defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(
       useiOSBroadcastExtension: true,
     ),
   )
  1. Client logs LocalTrackPublishedEvent for screenShareVideo — SDK side believes publish succeeded.
  2. Server side: receives SDP renegotiation (offer/answer pair completes cleanly, NO unhandled RTP, NO ICE errors, mid=3 video VP8 with simulcast layers q;h).
  3. Exactly 30s later: server fires publish time out from the supervisor (SetMaxSubscribedQuality + supervisor state machine). Track removed server-side. Viewer NEVER receives TrackSubscribed for screenShareVideo.

Confirmed NOT the cause

  • Network: UDP 50000-60000 open server-side; camera/mic over the same path work flawlessly.
  • ICE: candidates exchanged, transport stays connected.
  • SDP renegotiation: completes successfully on both sides.
  • Codec: VP8 (also tried H.264 via Room options) — same outcome.
  • Simulcast: reproduces with 1 layer too (manually reduced via defaultVideoPublishOptions).
  • Race condition with our bridge: a prior loop removed our manual setScreenShareEnabled call; SDK auto-publishes via its own BroadcastManager listener. Bug persists.
  • Identity / token permissions: subscriber token has wildcard subscribe permission; server logs show no permission denial.
  • Server version: reproduces identically on 1.10.0 and 1.11.0.

Suspected pattern

This matches webrtc-rs PR #750 ("Handle receiver reuse during renegotiation") in behavior — active receivers (camera) appear to cause the new transceiver for screen share to be marked NOT HANDLED. Is there an equivalent fix in pion-go currently shipping with livekit-server? Or in the SDK's add-track flow?

Questions

  1. Is this a known issue with publishing a 2nd video track via renegotiation while another video track is active in the same Room?
  2. Is singlePeerConnection mode planned for client-sdk-flutter? The Swift SDK exposes it (2.13.0+); we suspect single-PC mode would bypass this entirely.
  3. Is there a recommended workaround (e.g. pre-allocating transceivers at connect time, publish-order tricks, dynacast off, dummy track pre-publish)?
  4. Is there a way to disable the supervisor's hardcoded 30s publish timeout server-side while we diagnose?

Forensic data available on request

  • Full SDP offer + answer of the failing renegotiation
  • Timestamped client logs (both host and viewer side)
  • Server logs at debug level showing the 30s supervisor timeout
  • Confirmation of zero unhandled RTP packets during the broadcast window

Happy to share via secure channel or attach to this issue.

Impact

This blocks shipping screen share in our production live streaming application. We are not in a position to migrate to LiveKit Cloud or rewrite the host to Swift SDK. Looking for a workaround or fix timeline guidance.

Thanks!

Activity

  1. ianagallardo7 commented on Jun 4, 2026

    @ianagallardo7
    Author

    Update — Server-side root cause confirmed (with forensic logs)

    We dug deeper with a self-hosted server-side investigation and have a more precise diagnosis. Sharing it here since it may help others and the maintainers.

    What we tried (and ruled out)

    We attempted a dual-connection workaround: the host opens a second, dedicated LiveKit Room (separate participant identity, <id>_screen) that publishes only the screen share track — no camera, no mic on that connection. The theory was that the bug required an already-active camera track competing on the same PeerConnection.

    Result: the bug still reproduces. Even when the screen share is the only track on a fresh connection, with no camera/mic competing, the publish still times out at exactly 30s. This rules out "camera already active" as the trigger.

    Root cause (server logs)

    On our self-hosted server we traced it to the publisher's OnTrack callback (pion peer connection) never firing for a track added via SDP renegotiation post-connect. Consequences:

    • The track is never marked published → supervisor fires publish time out at exactly 30s (pkg/rtc/supervisor/publication_monitor.go, publishWaitDuration = 30 * time.Second, hardcoded).
    • More importantly, AddPublishedTrack (the actual forwarding path to subscribers) is never reached — so even if the supervisor timeout were disabled, the viewer would still never receive the track.

    Server-side log excerpt (identities/room anonymized):

    T+0.000 participant _screen joined (UseSinglePeerConnection: false)
    T+0.5 participant active — UDP/ICE OK, SDK Flutter 2.7.0, iOS
    T+0.5 CanPublish: true, CanSubscribe: false
    T+~0.7 client: LocalTrackPublished source=screenShareVideo (client believes publish succeeded)
    T+30.0 ERROR supervisor: "publish time out" (trackID ...) ← exactly 30s
    track removed server-side; subscriber never receives it

    Key detail: the client SDK reports LocalTrackPublished and stays "sharing" with no error for the full duration, while the server silently times out and never forwards. The host has no signal that publishing actually failed.

    Environment

    • Reproduced on self-hosted server v1.10.0, v1.11.0, and v1.12.0 (the supervisor module — publication_monitor.go — is byte-identical across these; unchanged for ~18 months).
    • UseSinglePeerConnection: false (legacy connect path). The Flutter SDK 2.7.0 does not send WrappedJoinRequest, so single-PC mode is never engaged.

    Questions for maintainers

    1. Is the publisher OnTrack not firing for renegotiation-added tracks a known issue at the pion/transceiver level?
    2. Is WrappedJoinRequest / single-PC mode planned for the Flutter SDK? It appears to be the path that would avoid this entirely.
    3. Is there any supported way to publish a track post-connect on the Flutter SDK that doesn't rely on the renegotiation flow that hits this?

    Happy to share full (un-anonymized) server logs privately if useful. This reproduces 100% on our setup.

  2. changed the title [-][bug] Screen share publish via renegotiation times out at exactly 30s when camera is already active (iOS Broadcast Extension, self-hosted server)[/-] [+][bug] Track published via SDP renegotiation never forwarded to subscribers — server OnTrack doesn't fire, publish times out at 30s (self-hosted)[/+] on Jun 4, 2026
  3. ianagallardo7 commented on Jun 4, 2026

    @ianagallardo7
    Author

    Update — controlled isolation tests confirm the bug is in the

    server's handling of the iOS Broadcast Extension feed (not client-side)

    Following the initial report, I ran 4 controlled tests over the same
    self-hosted server (v1.11.0, pion/webrtc v4.2.11) to isolate which
    variable triggers the supervisor publish time out. In every test the
    server never logs mediaTrack published for the screen-share-side
    track, suggesting OnTrack never fires for that specific feed pipeline.
    The supervisor error on publication: "publish time out" always lands
    at exactly +30s after the publisher is active.

    Test matrix

    # Setup Result
    1 (baseline) Real camera, setCameraEnabled(true) post-connect ✅ mediaTrack published fires
    for q, h, f layers within ~3s. Subscribers get TrackSubscribed instantly.
    2 Broadcast Extension feed, default flow (setScreenShareEnabled(true) →
    TrackSource.screenShareVideo, simulcast q;h, scalability L1T3), published on the host's primary
    participant ❌ supervisor error: publish time out at +30s. mediaTrack published never appears.
    Subscribers receive nothing.
    3 Same as test 2 but published on a separate participant in the same room (identity
    <hostId>_screen, token with canPublish: true, canSubscribe: false) ❌ Identical failure mode.
    Different trackID, same supervisor timeout, same absence of mediaTrack published.
    4 Broadcast Extension feed wrapped as a LocalVideoTrack(TrackSource.camera, ...) (no
    setScreenShareEnabled, no screen-share specific encoding), published on the secondary participant
    (<hostId>_screen, publish-only token) ❌ Same failure mode. Server treats the participant join +
    ICE perfectly, supervisor times out, no mediaTrack published log.
    5 Same wrap as test 4, but published on the primary participant (full host token, `canPublish:
    true, canSubscribe: true`) — additive 2nd camera track, no other changes ❌ Same. `supervisor error:
    publish time out, no mediaTrack published` log.

    What this rules out

    After the matrix above, the following are confirmed to be NOT the
    trigger:

    • TrackSource.SCREEN_SHARE_VIDEO field (test 4 used CAMERA and failed)
    • Screen-share encoding presets / simulcast layers q;h / scalability
      mode L1T3 (test 4 used camera defaults q;h;f + no SVC mode and
      failed)
    • The renegotiation flow per se (real camera/mic also publish via
      post-connect renegotiation and work — test 1)
    • The secondary-participant identity suffix _screen (test 5 published
      on the primary participant identity and failed identically)
    • Token grants (canSubscribe: false removed in test 5 — same failure)
    • Single vs separate Room instance (tests 4 and 5 differ only in
      which Room.localParticipant.publishVideoTrack(track) is called on)

    The remaining variable

    The only common factor across the 4 failing tests is the source of
    the MediaStream
    : all four obtained the stream via the iOS Broadcast
    Upload Extension pipeline (useiOSBroadcastExtension: true,
    FlutterBroadcastScreenCapturer, sample buffers crossing the App Group
    Unix socket). The real-camera baseline obtains its stream via
    getUserMedia (FlutterRPCameraCapturer).

    This strongly points to the bug living at the boundary where the server
    (pion) tries to associate incoming RTP packets from the broadcast feed
    with the declared transceiver. The signal-level publish flow completes
    (server allocates a TrackInfo and a trackID), but the media-track
    registration that would trigger mediaTrack published and the
    forwarding pipeline never happens.

    Server-side log signature

    For every failing test the server log shows this exact pattern:

    INFO service/roommanager.go:415 starting RTC session participant=
    INFO rtc/room.go:1262 participant active participant= connectionType=udp
    (ICE/UDP/DTLS all fine, no transport errors)
    ERROR supervisor/participant_supervisor.go:156 supervisor error on publication
    trackID=TR_xxxxxxxxxxxx error="publish time out"
    ⤷ +30s after "participant active"

    There is no rtc/participant.go:2274 mediaTrack published log for
    that trackID, while the same log line appears reliably (1-3 seconds
    after participant active) for the camera/microphone tracks in the
    same room.

    There are also no unhandled RTP ssrc errors for the failing tracks,
    which suggests the RTP packets aren't even arriving at the per-track
    receiver path — or they arrive but don't match the transceiver mid /
    rid binding expected for that publication.

    Client-side reproduction (for completeness)

    // All four failing tests use a variant of this. Tested on:
    // livekit_client 2.7.0 + flutter_webrtc 1.4.0 (libwebrtc m144)
    // Flutter 3.41.4, iOS 18.2 / 26.5, iPhone 13/14/15 Pro
    // self-hosted livekit-server v1.11.0 (pion/webrtc v4.2.11), multi-PC.
    // Default LiveKit Room (no `RoomOptions.singlePeerConnection`).
    
    await room.connect(serverUrl, token);
    // then either:
    await room.localParticipant.setScreenShareEnabled(true);          // Test 2/3
    // or (Test 4/5):
    const opts = ScreenShareCaptureOptions(useiOSBroadcastExtension: true);
    final stream = await LocalTrack.createStream(opts);
    final track = LocalVideoTrack(
      TrackSource.camera,    // ⚡ note: NOT screenShareVideo
      stream,
      stream.getVideoTracks().first,
      opts,
    );
    await room.localParticipant.publishVideoTrack(track);
    
    Questions for maintainers
    
    1. Is there an internal difference in how pion's OnTrack callback is
    wired for tracks whose RTP packets originate from
    FlutterBroadcastScreenCapturer (App Group socket) vs the regular
    getUserMedia capturer? Anything codec/SDP/timing-related we can
    look at on the SDK side that might be specific to this pipeline?
    2. Is UseSinglePeerConnection: true (or any equivalent server-side
    knob) known to bypass this? The Dart SDK doesn't appear to negotiate
    singlePeerConnection: true over a legacy connect URL (it stays
    false in our server logs for every test). The Swift SDK 2.13+
    exposes it via RoomOptions. Is there a roadmap for adding this to
    client-sdk-flutter?
    3. Is there any way (config or otherwise, on either side) to make the
    server log emit more detail about why OnTrack isn't firing for
    the failing publication? Anything more verbose than log_level: debug
    that would surface the per-SSRC RTP receipt path?
    4. We have full forensic data available (trackIDs, participantIDs,
    SDP exchanges, packet captures if helpful). Happy to share off-issue
    or in a private channel if that's preferable.
    
    Environment recap
    
    - Server: livekit-server v1.11.0 (statically linked Go binary),
    Ubuntu 24.04 LTS, x86_64, single-node, no Redis-backed routing,
    logging.level: info
    - Client: livekit_client 2.7.0 (Flutter), iOS 18.2 / 26.5
    - Codec: VP8 in all failing tests (also reproduced once with H.264,
    same failure)
    - Network: UDP 50000-60000 open, ICE/DTLS established cleanly in all
    tests
    - Both publisher and subscriber on the same self-hosted node
    
    Thanks for any pointers — happy to keep this thread updated if we
    identify anything further.
  4. ianagallardo7 commented on Jun 4, 2026

    @ianagallardo7
    Author

    Update — Twilio and OpenTok succeed with the same Broadcast Extension feed; the difference appears to be publish-at-initial-offer vs post-connect renegotiation

    Looking into how other WebRTC SDKs handle the iOS Broadcast Extension, both Twilio Video and OpenTok/Vonage ship working screen-share-from-broadcast-extension examples (public repos). Comparing their approach to the Flutter SDK's surfaced what may be the relevant structural difference.

    What they do differently

    Aspect Twilio / OpenTok Flutter SDK (our setup)
    Where the SDK runs Inside the broadcast extension process Host app; extension feed crosses an App Group socket
    Track in SDP Included in the initial offer (Twilio: ConnectOptions.videoTracks; OpenTok: atomic session publish) Added post-connect via renegotiation
    Frame delivery Custom VideoSource / VideoCapture consuming CMSampleBuffer in-process Cross-process via App Group socket

    The common factor in the working implementations: the screen track is part of the initial SDP negotiation, not added later via renegotiation. This lines up with what we observed — the publisher's OnTrack never fires for the track we add via post-connect renegotiation, while camera/mic (also added post-connect in our flow) work.

    Why this matters for the Flutter SDK

    FastConnectOptions.screen exists, but internally it still calls publishVideoTrack after EngineConnectedEvent → engine.addTrack → negotiate() → post-connect renegotiation. So it doesn't replicate the "track in initial offer" pattern that Twilio/OpenTok rely on.

    Questions

    1. Is there a supported way in client-sdk-flutter to include a video track in the initial SDP offer (i.e. before the first negotiation completes), similar to Twilio's ConnectOptions.videoTracks?
    2. Does singlePeerConnection mode (available in the Swift SDK 2.13+) change the publish path such that this renegotiation issue is avoided? Is there a roadmap for exposing it in the Flutter SDK?
    3. Given Twilio/OpenTok demonstrate the broadcast extension feed itself works fine over WebRTC, would you consider this a Flutter-SDK-specific gap rather than a server/pion issue?

    Happy to provide more detail. Thanks again.

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