Fix tracks not being resumed in case of a startPublishing vs track

creating race.
This commit is contained in:
Timo K.
2026-07-07 14:02:37 +02:00
parent ad76969ce2
commit 2e1a8bb039
2 changed files with 65 additions and 3 deletions

View File

@@ -286,6 +286,53 @@ describe("Publisher", () => {
expect(track!.isUpstreamPaused).toBe(true);
});
it("Resumes upstream for tracks published after startPublishing was called (slow camera)", async () => {
videoEnabled$.next(true);
// Simulate that the upstream ended up paused by the time the track gets
// published (e.g. paused while it was still unpublished).
const originalPublishTrack = localParticipant.publishTrack;
vi.mocked(localParticipant).publishTrack = vi
.fn()
.mockImplementation(async (track: LocalTrack) => {
await track.pauseUpstream();
return originalPublishTrack(track);
});
const resolvers = Promise.withResolvers<void>();
createTrackLock = resolvers.promise;
// Track creation is slow (e.g. camera hardware takes time to open)
await publisher.createAndSetupTracks();
// startPublishing runs before the camera track exists, so its
// resumeUpstreams call finds nothing to resume
await publisher.startPublishing();
expect(
localParticipant.getTrackPublication(Track.Source.Camera),
).toBeUndefined();
// The camera opens and the track gets published
resolvers.resolve();
await flushPromises();
const track = localParticipant.getTrackPublication(
Track.Source.Camera,
)?.track;
expect(track).toBeDefined();
expect(track!.resumeUpstream).toHaveBeenCalled();
expect(track!.isUpstreamPaused).toBe(false);
});
it("Does not pause tracks published after the publisher was destroyed", async () => {
await publisher.destroy();
const track = createMockLocalTrack(Track.Source.Camera);
await localParticipant.publishTrack(track);
await flushPromises();
expect(track.pauseUpstream).not.toHaveBeenCalled();
});
it("Ensure resume upstream when published is called", async () => {
videoEnabled$.next(true);
await publisher.createAndSetupTracks();

View File

@@ -80,11 +80,15 @@ export class Publisher {
this.connection.livekitRoom.localParticipant.on(
ParticipantEvent.LocalTrackPublished,
this.onLocalTrackPublished.bind(this),
this.onLocalTrackPublished,
);
}
public async destroy(): Promise<void> {
this.connection.livekitRoom.localParticipant.off(
ParticipantEvent.LocalTrackPublished,
this.onLocalTrackPublished,
);
this.scope.end();
this.logger.info("Scope ended -> unset handler");
this.muteStates.audio.unsetHandler();
@@ -106,9 +110,9 @@ export class Publisher {
// So for that we use pauseUpStream(): Stops sending media to the server by replacing
// the sender track with null, but keeps the local MediaStreamTrack active.
// The user can still see/hear themselves locally, but remote participants see nothing.
private onLocalTrackPublished(
private onLocalTrackPublished = (
localTrackPublication: LocalTrackPublication,
): void {
): void => {
this.logger.info("Local track published", localTrackPublication);
const lkRoom = this.connection.livekitRoom;
if (!this.shouldPublish) {
@@ -116,6 +120,17 @@ export class Publisher {
this.pauseUpstreams(lkRoom, [localTrackPublication.source]).catch((e) => {
this.logger.error(`Failed to pause upstreams`, e);
});
} else {
// If startPublishing() ran before this track existed (track creation is
// not awaited and e.g. camera hardware can take a while to open), its
// resumeUpstreams() call found no track and did nothing. Resume here so
// that a track published after startPublishing() actually sends media.
// This is a no-op if the upstream is not paused.
this.resumeUpstreams(lkRoom, [localTrackPublication.source]).catch(
(e) => {
this.logger.error(`Failed to resume upstreams`, e);
},
);
}
if (localTrackPublication.source === Track.Source.Microphone) {
const muteState = this.muteStates.audio;