Skip to content

fix: handle race between LocalTrackSubscribed signal and publishTrack completion#1872

Open
pabloFuente wants to merge 1 commit intolivekit:mainfrom
pabloFuente:patch-1
Open

fix: handle race between LocalTrackSubscribed signal and publishTrack completion#1872
pabloFuente wants to merge 1 commit intolivekit:mainfrom
pabloFuente:patch-1

Conversation

@pabloFuente
Copy link
Copy Markdown
Contributor

Problem

When a local participant publishes a track, there is a race condition between two asynchronous flows:

  1. Signaling path: The SFU confirms the subscription and the engine emits EngineEvent.LocalTrackSubscribed with the track SID.
  2. SDK path: LocalParticipant.publishTrack() finishes its async work and calls addTrackPublication() to register the LocalTrackPublication.

If the signaling confirmation arrives before addTrackPublication() completes, the LocalTrackSubscribed handler in Room.ts calls getTrackPublications().find(...), finds nothing, logs a warning ("could not find local track subscription for subscribed event"), and silently drops the event. Neither ParticipantEvent.LocalTrackSubscribed nor RoomEvent.LocalTrackSubscribed is ever emitted for that track because the warning log statement will just return without defering the event:

.on(EngineEvent.LocalTrackSubscribed, (subscribedSid) => {
  const trackPublication = this.localParticipant
    .getTrackPublications()
    .find(({ trackSid }) => trackSid === subscribedSid);
  if (!trackPublication) {
    this.log.warn('could not find local track subscription for subscribed event', ...);
    return; // event dropped!!
  }
  // emit events...
})

I have been able to replicate this behavior consistently in a high concurrency scenario, where many users connect, publish and subscribe multiple tracks to the same Room at the same time.

Fix

This PR replaces the inline handler with handleLocalTrackSubscribed(), which uses a deferred-emit pattern. That is consistent with the existing onTrackAdded defer logic to already present in Room.ts, that helps handling it when the participant is in Connecting or Reconnecting state (see here).

My proposal for the defering logic of the LocalTrackSubscribed event follows this path:

  1. Fast path: look up the publication immediately. If found, emit events and return.
  2. Deferred path: if the publication is not yet registered, listen for ParticipantEvent.LocalTrackPublished on the local participant. When the matching trackSid appears, emit the LocalTrackSubscribed events.
  3. Timeout safety net (10 s): if the publication never arrives (e.g., publish failed silently), perform one final lookup attempt, then clean up listeners. This prevents leaking event listeners indefinitely.
  4. Disconnect cleanup: all listeners are removed if the room disconnects before resolution.

A small emitLocalTrackSubscribed() helper is extracted to deduplicate the two-event emission (ParticipantEvent + RoomEvent).

Handle race between `LocalTrackSubscribed` signal and `publishTrack` completion.
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 6, 2026

⚠️ No Changeset found

Latest commit: eae7187

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pabloFuente
Copy link
Copy Markdown
Contributor Author

pabloFuente commented Apr 8, 2026

Just in case you need proof of the bug.

  1. Run latest livekit-server:

    docker run --rm --network=host livekit/livekit-server:latest --dev
  2. Serve this minimal index.html sample file (which uses the latest official livekit-client@2.18.1):

    <html>
    <head>
      <meta charset="UTF-8" />
      <title>LiveKit PR #1872 Repro</title>
      <script src="https://cdn.jsdelivr.net/npm/livekit-client@2.18.1/dist/livekit-client.umd.min.js"></script>
      <script type="module">
        import { AccessToken } from 'https://esm.sh/livekit-server-sdk';
        window.makeToken = async (apiKey, apiSecret, identity, room) => {
          const at = new AccessToken(apiKey, apiSecret, { identity });
          at.addGrant({ room, roomJoin: true, canPublish: true, canSubscribe: true });
          return await at.toJwt();
        };
      </script>
      <style>
        body { background: #111; color: #eee; font-family: monospace; padding: 12px; }
        #grid { display: grid; gap: 3px; margin-top: 10px; }
        video { width: 177px; height: 100px; background: #000; display: block; }
        button { margin: 4px; padding: 6px 14px; cursor: pointer; }
        #log { height: 100px; overflow-y: auto; font-size: 11px; background: #1a1a1a; padding: 6px; margin-top: 8px; white-space: pre-wrap; }
      </style>
    </head>
    <body>
    
    <label>URL: <input id="urlInput" value="ws://localhost:7880" style="width:200px"></label>
    <label>Key: <input id="keyInput" value="devkey" style="width:70px"></label>
    <label>Secret: <input id="secInput" value="secret" style="width:100px"></label>
    <label>Participants: <input id="nInput" type="number" value="10" min="1" max="50" style="width:50px"></label>
    <button onclick="startAll()">Connect</button>
    <button onclick="stopAll()">Disconnect</button>
    <span id="status" style="font-size:12px;margin-left:10px"></span>
    
    <div id="log"></div>
    <div id="grid"></div>
    
    <script>
    const ROOM   = 'repro-1872';
    const rooms  = [];
    const videos = {};
    let   subCount = 0, N = 10;
    
    function buildGrid() {
      const grid = document.getElementById('grid');
      grid.innerHTML = '';
      grid.style.gridTemplateColumns = `repeat(${N}, 177px)`;
      Object.keys(videos).forEach(k => delete videos[k]);
      for (let r = 0; r < N; r++)
        for (let c = 0; c < N; c++) {
          const v = document.createElement('video');
          v.autoplay = v.muted = v.playsInline = true;
          grid.appendChild(v);
          videos[`${r}_${c}`] = v;
        }
    }
    
    function log(msg) {
      const el = document.getElementById('log');
      el.textContent += msg + '\n';
      el.scrollTop = el.scrollHeight;
    }
    
    async function connectOne(idx, server, apiKey, apiSecret) {
      const identity = `p${idx}`;
      const token    = await window.makeToken(apiKey, apiSecret, identity, ROOM);
      const room     = new LivekitClient.Room({ adaptiveStream: false, dynacast: false });
      rooms.push(room);
    
      room.on(LivekitClient.RoomEvent.LocalTrackSubscribed, (pub) => {
        subCount++;
        document.getElementById('status').textContent = `LocalTrackSubscribed: ${subCount}/${N * 2}`;
        log(`✓ ${identity} LocalTrackSubscribed ${pub.kind} ${pub.trackSid}`);
      });
    
      room.on(LivekitClient.RoomEvent.TrackSubscribed, (track, _pub, participant) => {
        const remIdx = parseInt(participant.identity.slice(1));
        if (track.kind === 'video' && !isNaN(remIdx))
          videos[`${idx}_${remIdx}`].srcObject = new MediaStream([track.mediaStreamTrack]);
      });
    
      await room.connect(server, token, { autoSubscribe: true });
    
      const [videoTrack, audioTrack] = await Promise.all([
        LivekitClient.createLocalVideoTrack({ resolution: { width: 177, height: 100, frameRate: 3 } }),
        LivekitClient.createLocalAudioTrack(),
      ]);
    
      videos[`${idx}_${idx}`].srcObject = new MediaStream([videoTrack.mediaStreamTrack]);
    
      await Promise.all([
        room.localParticipant.publishTrack(videoTrack, { simulcast: false }),
        room.localParticipant.publishTrack(audioTrack),
      ]);
    }
    
    async function startAll() {
      const server   = document.getElementById('urlInput').value.trim();
      const apiKey   = document.getElementById('keyInput').value.trim();
      const apiSecret = document.getElementById('secInput').value.trim();
      N = parseInt(document.getElementById('nInput').value) || 10;
    
      subCount = 0;
      buildGrid();
      document.getElementById('status').textContent = `LocalTrackSubscribed: 0/${N * 2}`;
      log(`Connecting ${N} participants to ${server}…`);
      await Promise.allSettled(Array.from({ length: N }, (_, i) => connectOne(i, server, apiKey, apiSecret)));
    }
    
    async function stopAll() {
      for (const r of rooms) await r.disconnect().catch(() => {});
      rooms.length = 0;
      subCount = 0;
      document.getElementById('status').textContent = '—';
    }
    </script>
    </body>
    </html>

This sample app simply allows connecting multiple participants to the same room simultaneously, each publishing an audio and video track. You can see that not all LocalTrackSubscribed events will be received due to the race condition.

For example, this screenshot of a 3-by-3 Room shows how only 2 LocalTrackSubscribed events were received of the 6 expected:

2026-04-08_12-08

The browser's console will display multiple warn messages:

Screenshot From 2026-04-08 12-03-29

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant