Skip to content

Implement negotiation tracking based on offerId#1927

Open
lukasIO wants to merge 6 commits intomainfrom
lukas/negotiaton-loo
Open

Implement negotiation tracking based on offerId#1927
lukasIO wants to merge 6 commits intomainfrom
lukas/negotiaton-loo

Conversation

@lukasIO
Copy link
Copy Markdown
Contributor

@lukasIO lukasIO commented Apr 29, 2026

Fix publisher negotiation hang under stacked offer cycles

Under specific conditions the Promise returned by setCameraEnabled(true) could neither resolve nor reject, even though the track was successfully published from the server's perspective. Sessions hitting this also showed MaxListenersExceededWarning for negotiationComplete/negotiationStarted on the publisher within seconds of connecting.

Root cause

PCTransportManager.negotiate() resolved on the next NegotiationComplete event from the publisher. That event only fires when an answer is processed and renegotiate === false. If a new publisher.negotiate() lands while an offer is in flight, the next answer recurses into another offer instead of emitting NegotiationComplete (PCTransport.ts:239-243). With enough cycle starts (data-channel sends, server-driven MediaSectionsRequirement, concurrent track publishes) the cycle never converges, the resettable timeout from #1813 keeps firing, and the Promise wedges. cleanup() also never offed the once(NegotiationComplete) listener, which is why the warning surfaced.

Change

Switch from an event-cycle-based wait to an offerId checkpoint:

  • Capture checkpoint = publisher.latestOfferId at the moment negotiate() is called.
  • Resolve when any offer with offerId > checkpoint has had its answer applied — that offer necessarily contains the changes that motivated the call.
  • New PCEvents.OfferAnswered fires for every successful answer, including ones that recurse via renegotiate=true (which the existing NegotiationComplete deliberately suppresses).
  • Single non-resettable deadline as a backstop. No NegotiationStarted reset path.
  • Single listener type, idempotent cleanup() — no leaks across abort/timeout/error paths.
  • Concurrent callers each get their own checkpoint and resolve independently when their changes are negotiated.
  • NegotiationComplete and RTPVideoPayloadTypes are kept intact for existing consumers.

Tests

PCTransportManager.test.ts covers:

  • Resolution on offer past checkpoint, including via the renegotiate-recursion path.
  • Non-resolution on answers at or before the checkpoint.
  • Concurrent callers resolving independently.
  • Race where an answer past the checkpoint already arrived at entry.
  • Abort, deadline, and publisher.negotiate error paths.
  • Listener cleanup across all paths plus a 12-iteration leak guard.
  • Regression test for the field hang: spurious cycle noise + non-matching answers no longer wedge the promise.

Risk

Behavior change is contained to the publisher negotiation handshake. latestOfferId is exposed publicly (was private) and latestAcknowledgedOfferId is added — both internal SDK fields. No public API surface changes.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 29, 2026

🦋 Changeset detected

Latest commit: 106574e

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

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

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 29, 2026

size-limit report 📦

Path Size
dist/livekit-client.esm.mjs 96.99 KB (+0.06% 🔺)
dist/livekit-client.umd.js 105.9 KB (+0.01% 🔺)

Copy link
Copy Markdown
Contributor

@1egoman 1egoman left a comment

Choose a reason for hiding this comment

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

Generally the whole approach makes sense to me!

Comment on lines +128 to +132
setTimeout(() => pub.answer(1), 10); // does not satisfy checkpoint=1
setTimeout(() => {
const id = pub.startOffer(); // 2
pub.answer(id);
}, 30);
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.

thought: Two things:

  • I wonder if there's a way to do a lot of these same testing scenarios without explicit sleeps. Are the lengths of these delays meaningful here, or is the goal just to have a series of events all occur in a defined order all on their own event loop macrotasks?
  • I think linearizing these would make the tests a bit easier to follow, so you could scan the test top to bottom and have to jump between different functions which get scheduled to run at different times. ie, something like:
// Some sort of explanation here
// does not satisfy checkpoint=1
pub.answer(1);

await sleep(20); // Or ideally if the delay isn't important, `sleep(0)` / `setImmediate`.

// Some sort of explanation here
const id = pub.startOffer(); // 2
pub.answer(id);

Comment on lines +242 to +246
// Race: an answer past our checkpoint already arrived before we had a
// chance to subscribe.
if (this.publisher.latestAcknowledgedOfferId > checkpoint) {
resolve();
return;
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.

suggestion: Maybe it's worth logging here so that it would be evident that there's a lot of spurious renegotiation occurring in the context of other issues? Not sure.

Comment on lines +59 to +63

describe('PCTransportManager.negotiate', () => {
let originalRTCPeerConnection: unknown;

beforeEach(() => {
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.

Really glad to see some tests here for this! Looking forward to seeing this grow moving forward.

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.

2 participants