Repository navigation
Conversation
🦋 Changeset detectedLatest commit: 69266e1 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
A backup codec sends its own clone of the track. Only restartTrack() renewed that clone, so after replaceTrack() (or an unmute of an SDK managed camera, or a processor change) resumeUpstream() put the stale clone back on the backup sender: backup codec subscribers saw the old track, or nothing once it had ended. The clones were also never stopped on replace, on mute or on unpublish, which kept the camera on. Renew the clones whenever the media stream track changes, stop the clones they replace, and stop them on mute and unpublish too.
60174fd to
266b0e3
Compare
…cessful swap A disconnect closes the engine before unpublishing, so the clones were neither stopped nor dropped when the track was kept: they kept the camera on, and the leftover entry made addSimulcastTrack() skip the backup codec when the track was published again. A rejected replaceTrack() on a backup sender also skipped stopping the previous clone.
… new one A rejected replaceTrack() leaves the sender on the previous clone, so stopping it unconditionally cut the backup codec video. Drop the new clone instead and keep the previous one owned, so it is still stopped on the next refresh, on stop or on unpublish.
lukasIO
left a comment
There was a problem hiding this comment.
thanks for the PR!
The issue is real and the fix is mostly there already.
However the failure path should not keep the previous clone alive. If the backup sender rejects camera B, it should stop sending camera A:
await sc.sender.replaceTrack(null);
previousTrack.stop();
newTrack.stop();The backup entry should then be marked as needing refresh. A later replaceTrack(B) must bypass the same-track early return and retry the backup sender with a fresh clone of B.
Otherwise, camera A can continue being published after the application tried to replace it, and retrying with B can resolve without updating the backup sender.
Could we add a regression test covering this sequence?
- Replacing A with B fails on the backup sender.
- The backup sender receives
replaceTrack(null). - A’s clone is stopped.
- Retrying with the same B track sends a fresh B clone successfully.
The application replaced the previous track, so the backup sender must not keep publishing it: send nothing, stop both clones, and mark the codec for a refresh. A retried replaceTrack() with the same track skips setMediaStreamTrack(), so the refresh is retried from onSenderTrackSwapped() instead.
After a rejected swap the backup codec holds a stopped clone, which resumeUpstream() handed back to the sender: a pause and resume left backup subscribers without video. Resuming now refreshes the codecs marked needsRefresh, and only those are refreshed when a swap retries.
setMediaStreamTrack() resumes upstream before it refreshes every backup codec, so retrying a rejected codec on that resume cloned the new track twice and swapped the sender twice. Retry on resume only when no track change holds the lock.
resumeUpstream() left a rejected backup codec to the track change that held the lock, but that change can fail before refreshing the codecs (a processor init(), getUserMedia() in restart(), applyConstraints() in stopProcessor()), leaving the backup disconnected. Wait for the change to end instead, and retry the codec if it is still pending and the track is neither stopped nor unpublished.
| .then(async (unlock) => { | ||
| unlock(); | ||
| // still pending, and not dropped by an unpublish or a stop in the meantime | ||
| if (sc.needsRefresh && !this.isStopped && this.simulcastCodecs.get(sc.codec) === sc) { | ||
| await this.refreshSimulcastTrack(sc); |
There was a problem hiding this comment.
🔴 Late retry blanks the backup codec
When another camera replacement starts during retryAfterTrackChange, the retry can finish after the newer backup swap. refreshSimulcastTrack stops the older clone, so the late retry leaves the backup sender publishing an ended track.
Learn more
A rejected backup sender sets needsRefresh and waits for a new clone. The queued retry acquires the track-change lock but releases it before awaiting refreshSimulcastTrack. A later replaceTrack can refresh that same sender while the retry's replaceTrack is pending. If the older sender operation finishes last, it attaches a clone the later refresh has stopped. The retry cleared needsRefresh before awaiting, so nothing requests another swap.
Example: Camera B's backup swap fails. The retry starts swapping a fresh B clone, then camera C replaces B and successfully swaps a C clone. If the B swap completes last, the backup sender ends up on B's stopped clone instead of C.
Recommended fix: Keep backup refreshes serialized with track changes until the sender swap settles. Also serialize or recheck against upstream pause and stop operations before committing an asynchronous retry.
Was this helpful? React with 👍 or 👎 to provide feedback.
refreshSimulcastTrack() also runs outside the track change lock (the deferred retry, the same-track retry, setProcessor, a public resume). When its swap was rejected after a newer refresh had taken the codec over, it sent null after the newer swap and stopped the newer clone, blanking a working backup codec. Only take the codec off the sender when no newer refresh replaced the clone in the meantime.
The bug
With a backup codec (e.g. VP9 primary, VP8 backup through
backupCodec/backupCodecPolicy: SIMULCAST),addSimulcastTrack()gives each backup codec its ownthis.mediaStreamTrack.clone(), kept inSimulcastTrackInfo.mediaStreamTrack. OnlyrestartTrack()renewed that clone.replaceTrack()goes throughLocalTrack.setMediaStreamTrack(), which swaps the primary sender and then callsresumeUpstream(), andLocalVideoTrack.resumeUpstream()putssc.mediaStreamTrack, the clone taken at publish time, back on every backup sender.As a result:
replaceTrack(newTrack), with or without apauseUpstream()before it, backup codec subscribers keep receiving the old track. Once the old track stops producing frames, they get no video at all, while primary codec subscribers are fine.Found by reading the code, not reproduced:
mute()on an SDK managed camera stops_mediaStreamTrackto release the camera, but leaves the backup clones running, so the camera is never released while muted. On unmute,restart()goes throughsetMediaStreamTrack()too and the backup goes back to the old clone, so asetDeviceId()done while muted never reaches backup codec subscribers.setProcessor()putsprocessedTrackon the backup senders, butsc.mediaStreamTrackis still the unprocessed clone, so the nextresumeUpstream()(e.g. after amuteevent on the track) sends the unprocessed track on the backup codec.unpublishTrack()clearssimulcastCodecswithout stopping the clones. WithstopOnUnpublish = false, whichrepublishAllTracks()uses after a full reconnect, the clones leak.We hit this in production in WorkAdventure, which publishes VP9 with a VP8 backup and replaces its own camera track when the camera is turned back on. A subscriber that could not decode VP9 lost a publisher's camera after the publisher toggled it, while the other subscribers were fine. We currently work around it in workadventure/workadventure#6789 by doing what
restartTrack()does after eachreplaceTrack().The fix
LocalVideoTrack.onMediaStreamTrackChanged(), the hooksetMediaStreamTrack()already runs onreplaceTrack(),restart()(so unmute andrestartTrack()) andstopProcessor(), now gives each backup codec a fresh clone of the current track, sends it unless upstream is paused, and stops the clone it replaces.restartTrack()no longer needs its own loop.setProcessor()uses the same helper, sosc.mediaStreamTrackstays the track the backup sender actually sends.mute()on an SDK managed camera also stops the backup clones, so the camera is released.unpublishTrack()stops the clones before dropping them, which also coversrepublishAllTracks().Testing
LocalVideoTrack.test.ts, both failing onmain: pause +replaceTrack()sends a clone of the new track on the backup sender and stops the old clone; muting an SDK managed camera stops the backup clone.pnpm test(977 passed),tsc --noEmit,eslintandprettier --checkare clean.mainat b6b9210 (2.23.0) and with this branch. ThesetProcessor()path has no dedicated test;unpublishTrack()has one inLocalParticipant.test.ts, including after a disconnect.mainliveendedSame numbers with a
pauseUpstream()before thereplaceTrack().Repro (no server needed)
It publishes a
LocalVideoTrackover a loopbackRTCPeerConnection, with the backup sender wired up the waypublishAdditionalCodecForTrack()does it. Each "camera" is a canvas that stops drawing when it is turned off.