Skip to content

fix(engine): ignore the mid mapping of a stale publisher answer - #2196

Merged
lukasIO merged 2 commits into
livekit:mainfrom
ninglinLiu:fix/stale-answer-mid-mapping
Oct 27, 2026
Merged

lukasIO merged 2 commits into
livekit:mainfrom
ninglinLiu:fix/stale-answer-mid-mapping

Conversation

@ninglinLiu

@ninglinLiu ninglinLiu commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2195

Problem

In single peer connection mode (publisher-only), client.onAnswer replaced midToTrackId with the answer's mapping before calling pcManager.setPublisherAnswer. PCTransport.setRemoteDescription then ignores an answer whose offer id is older than latestOfferId ("ignoring answer for old offer"), but by then the stale mapping had already replaced the one from the current answer. A track event that reaches the SDK after that (react-native-webrtc dispatches them late) can't resolve its mid in getTrackIdForReceiver, so Room.onTrackAdded logs "Tried to add a track whose 'sid' could not be found" and the track is dropped.

Change

  • PCTransport.isStaleAnswer(offerId) holds the existing "answer to an older offer" check, and setRemoteDescription uses it, so behaviour there is unchanged.
  • onAnswer returns early for a stale answer (same "ignoring answer for old offer" warning), so neither its mapping nor setPublisherAnswer/RequestSubscribedCodecRefresh are processed (review feedback).
  • A current answer only replaces the mapping when it is non-empty, matching the Rust SDK guard: protobuf decodes an absent map as {}, which would wipe the mapping the same way (review feedback). The mapping is still installed before setRemoteDescription, because track fires inside that call.

Dual peer connection mode is unaffected.

Tests

New publisher answer mid mapping tests in RTCEngine.test.ts:

  • the answer to the latest offer installs its mapping
  • an answer to an older offer arriving afterwards keeps the current mapping (fails on main)
  • an answer to an older offer is not applied at all (no setPublisherAnswer, no codec refresh)
  • a current answer with an empty mapping keeps the existing one

vitest run src/room/RTCEngine.test.ts src/room/PCTransport* (121 passed), tsc --noEmit, eslint and prettier pass. Includes a patch changeset.

This PR was prepared with AI assistance and reviewed/tested by the author.

@changeset-bot

changeset-bot Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 3e13dc4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
livekit-client Patch

Not sure what this means? Click here to learn what changesets are.

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

Comment thread src/room/RTCEngine.ts Outdated
Comment on lines 729 to 736
// transport has no sending tracks) and must not clobber the subscriber offer mapping
if (this.pcManager.mode === 'publisher-only') {
// an answer to an older offer is ignored by the publisher transport, so its mapping
// is stale too and must not replace the one installed by the current answer
if (
this.pcManager.mode === 'publisher-only' &&
!this.pcManager.publisher.isStaleAnswer(offerId)
) {
this.midToTrackId = midToTrackId;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Is there a reason that the stale answer check couldn't be an if with an early return at the top of onAnswer? I'd think this would be cleaner as I don't think there's a case where a late answer should be processed normally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call, no: a stale answer is dropped by PCTransport.setRemoteDescription anyway, so nothing in it should be processed. In 3e13dc4 onAnswer now checks publisher.isStaleAnswer(offerId) first and returns early (with the same "ignoring answer for old offer" warning), so it no longer reaches setPublisherAnswer or emits RequestSubscribedCodecRefresh either. Added a test for that.

@xianshijing-lk xianshijing-lk left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one question, in general, it looks good to me, but I will defer it to @1egoman or @lukasIO

Comment thread src/room/RTCEngine.ts Outdated
// is stale too and must not replace the one installed by the current answer
if (
this.pcManager.mode === 'publisher-only' &&
!this.pcManager.publisher.isStaleAnswer(offerId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curiously on your thought on the following problem ?

A current, non-stale answer carrying an empty mapping does this.midToTrackId = {} and destroys the map just as a stale one does. SignalClient.ts:981 passes midToTrackId unconditionally and protobuf-es gives {} for an empty map, so the assignment is a real wipe, and getTrackIdForReceiver has no fallback.

rust guards this explicitly (!answer.mid_to_track_id.is_empty(), rtc_session.rs:1354); Swift merges, so empty is a no-op. JS has neither, before or after this PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, that is the same wipe through another path. In 3e13dc4 a current answer only replaces the mapping when it is non-empty, matching the Rust guard (!answer.mid_to_track_id.is_empty()). Keeping the old entries is harmless: a mid is only looked up for a receiver the SFU described, and any answer that adds a receiver carries a non-empty mapping that replaces them. Added a test where an answer to the latest offer with {} keeps the existing mapping. I kept it to replace-when-non-empty rather than the Swift merge so the change stays small; happy to switch to a merge if you prefer.

@lukasIO lukasIO left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch!

@lukasIO
lukasIO merged commit d9beed7 into livekit:main Oct 27, 2026
6 checks passed
@github-actions github-actions Bot mentioned this pull request Oct 27, 2026
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.

Single peer connection: a stale publisher answer replaces midToTrackId, so a remote track whose track event arrives afterwards is silently dropped

4 participants