From 5aae9c1bb1db3f5f7309b87db8dd2e7ae41efb59 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 29 Apr 2026 10:50:47 +0200 Subject: [PATCH 1/5] add unit tests for negotiation tracking --- src/room/PCTransportManager.test.ts | 181 ++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 src/room/PCTransportManager.test.ts diff --git a/src/room/PCTransportManager.test.ts b/src/room/PCTransportManager.test.ts new file mode 100644 index 0000000000..b3d7f3389b --- /dev/null +++ b/src/room/PCTransportManager.test.ts @@ -0,0 +1,181 @@ +import { EventEmitter } from 'events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PCEvents } from './PCTransport'; +import { PCTransportManager } from './PCTransportManager'; + +class StubPC { + iceConnectionState: RTCIceConnectionState = 'new'; + + signalingState: RTCSignalingState = 'stable'; + + connectionState: RTCPeerConnectionState = 'new'; + + onicecandidate: ((ev: RTCPeerConnectionIceEvent) => void) | null = null; + + onicecandidateerror: ((ev: Event) => void) | null = null; + + oniceconnectionstatechange: (() => void) | null = null; + + onsignalingstatechange: (() => void) | null = null; + + onconnectionstatechange: (() => void) | null = null; + + ondatachannel: ((ev: RTCDataChannelEvent) => void) | null = null; + + ontrack: ((ev: RTCTrackEvent) => void) | null = null; + + getTransceivers() { + return []; + } + + getSenders() { + return []; + } + + close() {} + + setConfiguration() {} +} + +class FakePublisher extends EventEmitter { + negotiate = vi.fn(async (_onError?: (e: Error) => void) => {}); +} + +describe('PCTransportManager.negotiate', () => { + let originalRTCPeerConnection: unknown; + + beforeEach(() => { + originalRTCPeerConnection = (globalThis as unknown as { RTCPeerConnection?: unknown }) + .RTCPeerConnection; + (globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection = StubPC; + }); + + afterEach(() => { + (globalThis as unknown as { RTCPeerConnection: unknown }).RTCPeerConnection = + originalRTCPeerConnection; + }); + + function makeManager() { + const manager = new PCTransportManager('publisher-only', {}); + const fake = new FakePublisher(); + // swap in the fake publisher so we control the event surface and avoid + // exercising any real PeerConnection plumbing + (manager as unknown as { publisher: FakePublisher }).publisher = fake; + manager.peerConnectionTimeout = 200; + return { manager, pub: fake }; + } + + it('resolves when NegotiationComplete fires', async () => { + const { manager, pub } = makeManager(); + const ac = new AbortController(); + const p = manager.negotiate(ac); + setTimeout(() => pub.emit(PCEvents.NegotiationComplete), 10); + await expect(p).resolves.toBeUndefined(); + }); + + it('rejects when the initial timeout elapses', async () => { + const { manager } = makeManager(); + await expect(manager.negotiate(new AbortController())).rejects.toThrow(/timed out/); + }); + + it('rejects when the abort signal fires', async () => { + const { manager } = makeManager(); + const ac = new AbortController(); + setTimeout(() => ac.abort(), 10); + await expect(manager.negotiate(ac)).rejects.toThrow(/aborted/); + }); + + it('rejects when publisher.negotiate invokes its error callback', async () => { + const { manager, pub } = makeManager(); + pub.negotiate.mockImplementationOnce(async (onError?: (e: Error) => void) => { + onError?.(new Error('publisher boom')); + }); + await expect(manager.negotiate(new AbortController())).rejects.toThrow(/publisher boom/); + }); + + it('removes all listeners after NegotiationComplete resolves the promise', async () => { + const { manager, pub } = makeManager(); + const p = manager.negotiate(new AbortController()); + pub.emit(PCEvents.NegotiationComplete); + await p; + expect(pub.listenerCount(PCEvents.NegotiationStarted)).toBe(0); + expect(pub.listenerCount(PCEvents.NegotiationComplete)).toBe(0); + }); + + // BUG: the initial setTimeout at PCTransportManager.ts:232-234 rejects without + // calling cleanup(), so the NegotiationStarted handler, the abort handler, + // and the once(NegotiationComplete) handler all leak after the first timeout. + it('removes all listeners after the initial timeout rejects', async () => { + const { manager, pub } = makeManager(); + await expect(manager.negotiate(new AbortController())).rejects.toThrow(/timed out/); + expect(pub.listenerCount(PCEvents.NegotiationStarted)).toBe(0); + expect(pub.listenerCount(PCEvents.NegotiationComplete)).toBe(0); + }); + + // BUG: cleanup() at PCTransportManager.ts:236-240 never offs the + // once(NegotiationComplete) handler. After abort/cycle-reset timeout/ + // publisher error, that listener accumulates over repeated negotiate() calls + // (matching the "11 negotiationComplete listeners added" warning seen in + // production sessions that hit this hang). + it('removes the NegotiationComplete listener after the cycle-reset timeout rejects', async () => { + const { manager, pub } = makeManager(); + const p = manager.negotiate(new AbortController()); + // switch onto the resettable timer path + pub.emit(PCEvents.NegotiationStarted); + await expect(p).rejects.toThrow(/timed out/); + expect(pub.listenerCount(PCEvents.NegotiationComplete)).toBe(0); + }); + + it('removes the NegotiationComplete listener after abort rejects', async () => { + const { manager, pub } = makeManager(); + const ac = new AbortController(); + const p = manager.negotiate(ac); + setTimeout(() => ac.abort(), 10); + await expect(p).rejects.toThrow(/aborted/); + expect(pub.listenerCount(PCEvents.NegotiationComplete)).toBe(0); + }); + + it('removes the NegotiationComplete listener after publisher.negotiate errors', async () => { + const { manager, pub } = makeManager(); + pub.negotiate.mockImplementationOnce(async (onError?: (e: Error) => void) => { + onError?.(new Error('publisher boom')); + }); + await expect(manager.negotiate(new AbortController())).rejects.toThrow(/publisher boom/); + expect(pub.listenerCount(PCEvents.NegotiationComplete)).toBe(0); + }); + + // ROOT-CAUSE TEST: reproduces the field hang reported on Windows 11 with a + // slow Camera Frame Server. Every NegotiationStarted resets the timer + // (PCTransportManager.ts:252-261, introduced in PR #1813). If something keeps + // emitting NegotiationStarted faster than peerConnectionTimeout — e.g. + // ensureDataTransportConnected calling publisher.negotiate() while ICE isn't + // up, or repeated server-driven MediaSectionsRequirement updates — the + // outer Promise neither resolves nor rejects. publishTrack's + // Promise.all([addTrackPromise, negotiate()]) is then wedged forever even + // though the underlying PC has already finished offer/answer. The fix is to + // bound the resettable cycle with a non-resettable max deadline. + it('does not hang when NegotiationStarted keeps resetting the timer', async () => { + const { manager, pub } = makeManager(); + manager.peerConnectionTimeout = 100; + const ac = new AbortController(); + const p = manager.negotiate(ac); + // swallow whichever way it eventually settles (or doesn't) + p.catch(() => {}); + + const interval = setInterval(() => pub.emit(PCEvents.NegotiationStarted), 30); + + try { + const settled = await Promise.race([ + p.then( + () => 'resolved' as const, + () => 'rejected' as const, + ), + new Promise<'hung'>((res) => setTimeout(() => res('hung'), 1500)), + ]); + expect(settled, 'negotiate() never settled — timer keeps resetting').not.toBe('hung'); + } finally { + clearInterval(interval); + ac.abort(); + } + }); +}); From 160723bc1050466225f97939eb262fddbb5ff5b0 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 29 Apr 2026 11:05:10 +0200 Subject: [PATCH 2/5] implement negotiation tracking based on offerId --- src/room/PCTransport.ts | 17 +- src/room/PCTransportManager.test.ts | 253 +++++++++++++++++++--------- src/room/PCTransportManager.ts | 67 ++++---- 3 files changed, 230 insertions(+), 107 deletions(-) diff --git a/src/room/PCTransport.ts b/src/room/PCTransport.ts index c78e92c741..057b121239 100644 --- a/src/room/PCTransport.ts +++ b/src/room/PCTransport.ts @@ -29,6 +29,11 @@ const debounceInterval = 20; export const PCEvents = { NegotiationStarted: 'negotiationStarted', NegotiationComplete: 'negotiationComplete', + // Fired with the offerId for every successful publisher answer application, + // including answers that immediately recurse into another offer via + // `renegotiate`. Use this rather than NegotiationComplete to know that a + // specific offer has been negotiated end-to-end. + OfferAnswered: 'offerAnswered', RTPVideoPayloadTypes: 'rtpVideoPayloadTypes', } as const; @@ -51,7 +56,9 @@ export default class PCTransport extends EventEmitter { private ddExtID = 0; - private latestOfferId: number = 0; + latestOfferId: number = 0; + + latestAcknowledgedOfferId: number = 0; private offerLock: Mutex; @@ -236,6 +243,14 @@ export default class PCTransport extends EventEmitter { this.pendingCandidates = []; this.restartingIce = false; + // Fire OfferAnswered for every successfully applied answer, including the + // ones that recurse into another offer via `renegotiate`. Callers waiting + // on a specific offerId can resolve as soon as their offer's answer is in. + if (sd.type === 'answer') { + this.latestAcknowledgedOfferId = offerId; + this.emit(PCEvents.OfferAnswered, offerId); + } + if (this.renegotiate) { this.renegotiate = false; await this.createAndSendOffer(); diff --git a/src/room/PCTransportManager.test.ts b/src/room/PCTransportManager.test.ts index b3d7f3389b..f4f4cff33a 100644 --- a/src/room/PCTransportManager.test.ts +++ b/src/room/PCTransportManager.test.ts @@ -38,7 +38,23 @@ class StubPC { } class FakePublisher extends EventEmitter { + latestOfferId = 0; + + latestAcknowledgedOfferId = 0; + negotiate = vi.fn(async (_onError?: (e: Error) => void) => {}); + + /** Simulate a publisher offer cycle: bump latestOfferId. */ + startOffer() { + this.latestOfferId += 1; + return this.latestOfferId; + } + + /** Simulate a successful answer for the given offerId. */ + answer(offerId: number) { + this.latestAcknowledgedOfferId = offerId; + this.emit(PCEvents.OfferAnswered, offerId); + } } describe('PCTransportManager.negotiate', () => { @@ -58,22 +74,106 @@ describe('PCTransportManager.negotiate', () => { function makeManager() { const manager = new PCTransportManager('publisher-only', {}); const fake = new FakePublisher(); - // swap in the fake publisher so we control the event surface and avoid - // exercising any real PeerConnection plumbing (manager as unknown as { publisher: FakePublisher }).publisher = fake; manager.peerConnectionTimeout = 200; return { manager, pub: fake }; } - it('resolves when NegotiationComplete fires', async () => { + it('resolves when an offer past the checkpoint is answered', async () => { + const { manager, pub } = makeManager(); + const p = manager.negotiate(new AbortController()); + setTimeout(() => { + const id = pub.startOffer(); + pub.answer(id); + }, 10); + await expect(p).resolves.toBeUndefined(); + }); + + it('does not resolve on answers for offers at or before the checkpoint', async () => { const { manager, pub } = makeManager(); + // Some prior cycle is in flight with id=5 at the moment we capture our + // checkpoint. Its answer must NOT satisfy our request — our changes + // weren't in offer 5. + pub.latestOfferId = 5; const ac = new AbortController(); const p = manager.negotiate(ac); - setTimeout(() => pub.emit(PCEvents.NegotiationComplete), 10); + + let settled = false; + p.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + + pub.answer(5); + await new Promise((r) => setTimeout(r, 50)); + expect(settled).toBe(false); + + ac.abort(); + await expect(p).rejects.toThrow(/aborted/); + }); + + it('resolves through the renegotiate-recursion path', async () => { + // Reproduces the field shape: we capture checkpoint=N while an offer N is + // in flight. The answer for N arrives (renegotiate=true on the publisher, + // so it doesn't satisfy us), then a follow-up offer N+1 is created and + // answered. We resolve on the second answer. + const { manager, pub } = makeManager(); + pub.latestOfferId = 1; + const p = manager.negotiate(new AbortController()); + + setTimeout(() => pub.answer(1), 10); // does not satisfy checkpoint=1 + setTimeout(() => { + const id = pub.startOffer(); // 2 + pub.answer(id); + }, 30); + await expect(p).resolves.toBeUndefined(); }); - it('rejects when the initial timeout elapses', async () => { + it('resolves immediately when an answer past the checkpoint already arrived', async () => { + const { manager, pub } = makeManager(); + pub.latestOfferId = 3; + pub.latestAcknowledgedOfferId = 4; + await expect(manager.negotiate(new AbortController())).resolves.toBeUndefined(); + }); + + it('resolves concurrent callers independently at their own checkpoints', async () => { + const { manager, pub } = makeManager(); + + // A captures checkpoint=0 + const a = manager.negotiate(new AbortController()); + let aResolved = false; + a.then(() => { + aResolved = true; + }); + + // First cycle starts and answers — A is satisfied (1 > 0) + const id1 = pub.startOffer(); + + // B captures checkpoint=1 (an offer is now in flight) + const b = manager.negotiate(new AbortController()); + let bResolved = false; + b.then(() => { + bResolved = true; + }); + + pub.answer(id1); + await new Promise((r) => setTimeout(r, 0)); + expect(aResolved).toBe(true); + expect(bResolved).toBe(false); + + // B should resolve only on the next cycle + const id2 = pub.startOffer(); + pub.answer(id2); + await b; + expect(bResolved).toBe(true); + }); + + it('rejects when the deadline elapses', async () => { const { manager } = makeManager(); await expect(manager.negotiate(new AbortController())).rejects.toThrow(/timed out/); }); @@ -93,89 +193,90 @@ describe('PCTransportManager.negotiate', () => { await expect(manager.negotiate(new AbortController())).rejects.toThrow(/publisher boom/); }); - it('removes all listeners after NegotiationComplete resolves the promise', async () => { - const { manager, pub } = makeManager(); - const p = manager.negotiate(new AbortController()); - pub.emit(PCEvents.NegotiationComplete); - await p; - expect(pub.listenerCount(PCEvents.NegotiationStarted)).toBe(0); - expect(pub.listenerCount(PCEvents.NegotiationComplete)).toBe(0); - }); + describe('listener cleanup', () => { + it('after success', async () => { + const { manager, pub } = makeManager(); + const p = manager.negotiate(new AbortController()); + const id = pub.startOffer(); + pub.answer(id); + await p; + expect(pub.listenerCount(PCEvents.OfferAnswered)).toBe(0); + }); - // BUG: the initial setTimeout at PCTransportManager.ts:232-234 rejects without - // calling cleanup(), so the NegotiationStarted handler, the abort handler, - // and the once(NegotiationComplete) handler all leak after the first timeout. - it('removes all listeners after the initial timeout rejects', async () => { - const { manager, pub } = makeManager(); - await expect(manager.negotiate(new AbortController())).rejects.toThrow(/timed out/); - expect(pub.listenerCount(PCEvents.NegotiationStarted)).toBe(0); - expect(pub.listenerCount(PCEvents.NegotiationComplete)).toBe(0); - }); + it('after non-matching answer (still pending), then abort', async () => { + const { manager, pub } = makeManager(); + pub.latestOfferId = 5; + const ac = new AbortController(); + const p = manager.negotiate(ac); + pub.answer(5); // does not satisfy checkpoint=5 + expect(pub.listenerCount(PCEvents.OfferAnswered)).toBe(1); + ac.abort(); + await expect(p).rejects.toThrow(/aborted/); + expect(pub.listenerCount(PCEvents.OfferAnswered)).toBe(0); + }); - // BUG: cleanup() at PCTransportManager.ts:236-240 never offs the - // once(NegotiationComplete) handler. After abort/cycle-reset timeout/ - // publisher error, that listener accumulates over repeated negotiate() calls - // (matching the "11 negotiationComplete listeners added" warning seen in - // production sessions that hit this hang). - it('removes the NegotiationComplete listener after the cycle-reset timeout rejects', async () => { - const { manager, pub } = makeManager(); - const p = manager.negotiate(new AbortController()); - // switch onto the resettable timer path - pub.emit(PCEvents.NegotiationStarted); - await expect(p).rejects.toThrow(/timed out/); - expect(pub.listenerCount(PCEvents.NegotiationComplete)).toBe(0); - }); + it('after deadline', async () => { + const { manager, pub } = makeManager(); + await expect(manager.negotiate(new AbortController())).rejects.toThrow(/timed out/); + expect(pub.listenerCount(PCEvents.OfferAnswered)).toBe(0); + }); - it('removes the NegotiationComplete listener after abort rejects', async () => { - const { manager, pub } = makeManager(); - const ac = new AbortController(); - const p = manager.negotiate(ac); - setTimeout(() => ac.abort(), 10); - await expect(p).rejects.toThrow(/aborted/); - expect(pub.listenerCount(PCEvents.NegotiationComplete)).toBe(0); - }); + it('after abort', async () => { + const { manager, pub } = makeManager(); + const ac = new AbortController(); + const p = manager.negotiate(ac); + setTimeout(() => ac.abort(), 10); + await expect(p).rejects.toThrow(/aborted/); + expect(pub.listenerCount(PCEvents.OfferAnswered)).toBe(0); + }); - it('removes the NegotiationComplete listener after publisher.negotiate errors', async () => { - const { manager, pub } = makeManager(); - pub.negotiate.mockImplementationOnce(async (onError?: (e: Error) => void) => { - onError?.(new Error('publisher boom')); + it('after publisher.negotiate errors', async () => { + const { manager, pub } = makeManager(); + pub.negotiate.mockImplementationOnce(async (onError?: (e: Error) => void) => { + onError?.(new Error('publisher boom')); + }); + await expect(manager.negotiate(new AbortController())).rejects.toThrow(/publisher boom/); + expect(pub.listenerCount(PCEvents.OfferAnswered)).toBe(0); + }); + + it('does not leak across many sequential negotiate() calls', async () => { + const { manager, pub } = makeManager(); + for (let i = 0; i < 12; i += 1) { + const p = manager.negotiate(new AbortController()); + const id = pub.startOffer(); + pub.answer(id); + await p; + } + expect(pub.listenerCount(PCEvents.OfferAnswered)).toBe(0); }); - await expect(manager.negotiate(new AbortController())).rejects.toThrow(/publisher boom/); - expect(pub.listenerCount(PCEvents.NegotiationComplete)).toBe(0); }); - // ROOT-CAUSE TEST: reproduces the field hang reported on Windows 11 with a - // slow Camera Frame Server. Every NegotiationStarted resets the timer - // (PCTransportManager.ts:252-261, introduced in PR #1813). If something keeps - // emitting NegotiationStarted faster than peerConnectionTimeout — e.g. - // ensureDataTransportConnected calling publisher.negotiate() while ICE isn't - // up, or repeated server-driven MediaSectionsRequirement updates — the - // outer Promise neither resolves nor rejects. publishTrack's - // Promise.all([addTrackPromise, negotiate()]) is then wedged forever even - // though the underlying PC has already finished offer/answer. The fix is to - // bound the resettable cycle with a non-resettable max deadline. - it('does not hang when NegotiationStarted keeps resetting the timer', async () => { + // Regression test for the field hang on Windows 11 with slow Camera Frame + // Server. With the old design, NegotiationStarted firing faster than + // peerConnectionTimeout kept resetting the timer indefinitely while + // NegotiationComplete was suppressed by an unconverging `renegotiate` cycle, + // wedging the publishTrack Promise. The offerId-checkpoint design resolves + // on the first answer past the checkpoint, regardless of how many cycles + // start in between. + it('does not hang when many spurious cycles start without converging on the checkpoint', async () => { const { manager, pub } = makeManager(); - manager.peerConnectionTimeout = 100; - const ac = new AbortController(); - const p = manager.negotiate(ac); - // swallow whichever way it eventually settles (or doesn't) - p.catch(() => {}); + manager.peerConnectionTimeout = 1500; + pub.latestOfferId = 1; // an unrelated cycle is in flight + const p = manager.negotiate(new AbortController()); - const interval = setInterval(() => pub.emit(PCEvents.NegotiationStarted), 30); + // Lots of NegotiationStarted noise (not listened to anymore) and a few + // answers that don't satisfy the checkpoint. + const noise = setInterval(() => pub.emit(PCEvents.NegotiationStarted), 30); + setTimeout(() => pub.answer(1), 50); // doesn't satisfy + setTimeout(() => { + const id = pub.startOffer(); // 2 + pub.answer(id); + }, 200); try { - const settled = await Promise.race([ - p.then( - () => 'resolved' as const, - () => 'rejected' as const, - ), - new Promise<'hung'>((res) => setTimeout(() => res('hung'), 1500)), - ]); - expect(settled, 'negotiate() never settled — timer keeps resetting').not.toBe('hung'); + await expect(p).resolves.toBeUndefined(); } finally { - clearInterval(interval); - ac.abort(); + clearInterval(noise); } }); }); diff --git a/src/room/PCTransportManager.ts b/src/room/PCTransportManager.ts index f95a5faa64..bc66ae4e9a 100644 --- a/src/room/PCTransportManager.ts +++ b/src/room/PCTransportManager.ts @@ -228,46 +228,53 @@ export class PCTransportManager { } async negotiate(abortController: AbortController) { - return new TypedPromise(async (resolve, reject) => { - let negotiationTimeout = setTimeout(() => { - reject(new NegotiationError('negotiation timed out')); - }, this.peerConnectionTimeout); + return new TypedPromise((resolve, reject) => { + // Capture the publisher's latest offer id at request time. We are done + // when an offer with a higher id has had its answer successfully + // applied — that offer is the one that includes any transceiver/SDP + // changes that motivated this negotiate call. Concurrent callers each + // get their own checkpoint and resolve independently. + const checkpoint = this.publisher.latestOfferId; + + // Race: an answer past our checkpoint already arrived before we had a + // chance to subscribe. + if (this.publisher.latestAcknowledgedOfferId > checkpoint) { + resolve(); + return; + } + let cleanedUp = false; const cleanup = () => { - clearTimeout(negotiationTimeout); - this.publisher.off(PCEvents.NegotiationStarted, onNegotiationStarted); - abortController.signal.removeEventListener('abort', abortHandler); + if (cleanedUp) return; + cleanedUp = true; + clearTimeout(deadlineTimer); + this.publisher.off(PCEvents.OfferAnswered, onAnswered); + abortController.signal.removeEventListener('abort', onAbort); }; - const abortHandler = () => { - cleanup(); - reject(new NegotiationError('negotiation aborted')); + const onAnswered = (offerId: number) => { + if (offerId > checkpoint) { + cleanup(); + resolve(); + } }; - // Reset the timeout each time a renegotiation cycle starts. This - // prevents premature timeouts when the negotiation machinery is - // actively renegotiating (offers going out, answers coming back) but - // NegotiationComplete hasn't fired yet because new requirements keep - // arriving between offer/answer round-trips. - const onNegotiationStarted = () => { - if (abortController.signal.aborted) { - return; - } - clearTimeout(negotiationTimeout); - negotiationTimeout = setTimeout(() => { - cleanup(); - reject(new NegotiationError('negotiation timed out')); - }, this.peerConnectionTimeout); + const onAbort = () => { + cleanup(); + reject(new NegotiationError('negotiation aborted')); }; - abortController.signal.addEventListener('abort', abortHandler); - this.publisher.on(PCEvents.NegotiationStarted, onNegotiationStarted); - this.publisher.once(PCEvents.NegotiationComplete, () => { + // Single hard deadline as a backstop. Not reset on cycle progress — + // progress is tracked via OfferAnswered, not NegotiationStarted. + const deadlineTimer = setTimeout(() => { cleanup(); - resolve(); - }); + reject(new NegotiationError('negotiation timed out')); + }, this.peerConnectionTimeout); + + abortController.signal.addEventListener('abort', onAbort); + this.publisher.on(PCEvents.OfferAnswered, onAnswered); - await this.publisher.negotiate((e) => { + this.publisher.negotiate((e) => { cleanup(); if (e instanceof Error) { reject(e); From 6baddb6c7c8df5b721703f65d7ffafe7e9607366 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 29 Apr 2026 11:44:42 +0200 Subject: [PATCH 3/5] Create eight-eggs-obey.md --- .changeset/eight-eggs-obey.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/eight-eggs-obey.md diff --git a/.changeset/eight-eggs-obey.md b/.changeset/eight-eggs-obey.md new file mode 100644 index 0000000000..309aa629b4 --- /dev/null +++ b/.changeset/eight-eggs-obey.md @@ -0,0 +1,5 @@ +--- +"livekit-client": patch +--- + +Implement negotiation tracking based on offerId From a349cfa84f2d774c5d9b63c5ad7608ec91fd5bc6 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 29 Apr 2026 12:04:57 +0200 Subject: [PATCH 4/5] comments --- src/room/PCTransportManager.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/room/PCTransportManager.test.ts b/src/room/PCTransportManager.test.ts index f4f4cff33a..5702ce8f7b 100644 --- a/src/room/PCTransportManager.test.ts +++ b/src/room/PCTransportManager.test.ts @@ -251,8 +251,8 @@ describe('PCTransportManager.negotiate', () => { }); }); - // Regression test for the field hang on Windows 11 with slow Camera Frame - // Server. With the old design, NegotiationStarted firing faster than + // Regression test for publishing call getting stuck + // With the old design, NegotiationStarted firing faster than // peerConnectionTimeout kept resetting the timer indefinitely while // NegotiationComplete was suppressed by an unconverging `renegotiate` cycle, // wedging the publishTrack Promise. The offerId-checkpoint design resolves From 106574eec09effe4dc6fa5f0d02def0069915fd2 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 29 Apr 2026 12:14:33 +0200 Subject: [PATCH 5/5] tighten event payload --- src/room/PCTransport.ts | 12 ++++++++++-- src/room/PCTransportManager.ts | 5 ++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/room/PCTransport.ts b/src/room/PCTransport.ts index 057b121239..4fbbd75d18 100644 --- a/src/room/PCTransport.ts +++ b/src/room/PCTransport.ts @@ -1,7 +1,8 @@ import { Mutex } from '@livekit/mutex'; import { EventEmitter } from 'events'; import { parse, write } from 'sdp-transform'; -import type { MediaDescription, SessionDescription } from 'sdp-transform'; +import type { MediaAttributes, MediaDescription, SessionDescription } from 'sdp-transform'; +import type TypedEmitter from 'typed-emitter'; import log, { LoggerNames, getLogger } from '../logger'; import { debounce } from './debounce'; import { NegotiationError, UnexpectedConnectionState } from './errors'; @@ -38,7 +39,7 @@ export const PCEvents = { } as const; /** @internal */ -export default class PCTransport extends EventEmitter { +export default class PCTransport extends (EventEmitter as new () => TypedEmitter) { private _pc: RTCPeerConnection | null; private get pc() { @@ -752,3 +753,10 @@ function ensureIPAddrMatchVersion(media: MediaDescription) { function getMidString(mid: string | number) { return typeof mid === 'number' ? mid.toFixed(0) : mid; } + +type PCTransportEventCallbacks = { + negotiationStarted: () => void; + negotiationComplete: () => void; + offerAnswered: (offerId: number) => void; + rtpVideoPayloadTypes: (attributes: MediaAttributes['rtp']) => void; +}; diff --git a/src/room/PCTransportManager.ts b/src/room/PCTransportManager.ts index bc66ae4e9a..cc18811d33 100644 --- a/src/room/PCTransportManager.ts +++ b/src/room/PCTransportManager.ts @@ -1,5 +1,6 @@ import { Mutex } from '@livekit/mutex'; import { SignalTarget } from '@livekit/protocol'; +import type { Throws } from '@livekit/throws-transformer/throws'; import log, { LoggerNames, getLogger } from '../logger'; import TypedPromise from '../utils/TypedPromise'; import PCTransport, { PCEvents } from './PCTransport'; @@ -227,7 +228,9 @@ export class PCTransportManager { } } - async negotiate(abortController: AbortController) { + async negotiate( + abortController: AbortController, + ): Promise> { return new TypedPromise((resolve, reject) => { // Capture the publisher's latest offer id at request time. We are done // when an offer with a higher id has had its answer successfully