Skip to content

Commit adc16d3

Browse files
committed
fix: ensure unpublished event is backwards compatible
Set _track to None AFTER the unpublished event is fired to be 100% backwards compatible
1 parent 4fe5018 commit adc16d3

2 files changed

Lines changed: 48 additions & 8 deletions

File tree

livekit-rtc/livekit/rtc/room.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -759,15 +759,21 @@ def _on_room_event(self, event: proto_room.RoomEvent) -> None:
759759
unpublished = self.local_participant._track_publications.get(sid)
760760
if unpublished is not None:
761761
del self.local_participant._track_publications[sid]
762-
if unpublished.track is not None:
763-
unpublished.track._set_room(None)
764-
# Mirror track_unsubscribed: drop the publication's track
765-
# reference. This also makes unpublish_track's own
766-
# _set_room(None) a no-op when it loses the race (its
767-
# `publication._track is not None` guard short-circuits),
768-
# avoiding a redundant clear.
769-
unpublished._track = None
762+
track = unpublished.track
763+
if track is not None:
764+
track._set_room(None)
765+
# Emit while `publication.track` is still set, preserving the
766+
# pre-existing payload for callbacks. This handler is synchronous
767+
# and emit() invokes listeners synchronously, so nulling the track
768+
# right after still completes before any other coroutine (e.g.
769+
# unpublish_track) can interleave.
770770
self.emit("local_track_unpublished", unpublished)
771+
# Mirror track_unsubscribed: drop the publication's track
772+
# reference. This also makes unpublish_track's own _set_room(None)
773+
# a no-op when it loses the race (its `publication._track is not
774+
# None` guard short-circuits), avoiding a redundant clear.
775+
if track is not None:
776+
unpublished._track = None
771777
else:
772778
logging.debug("local_track_unpublished for untracked publication sid %s", sid)
773779
elif which == "local_track_republished":

livekit-rtc/tests/test_audio_stream_room_lifecycle.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,40 @@ def test_local_track_unpublished_event_nulls_publication_track() -> None:
649649
assert publication.track is None
650650

651651

652+
def test_local_track_unpublished_callback_still_sees_track() -> None:
653+
"""Backwards-compat: `publication._track` is nulled AFTER the
654+
`local_track_unpublished` event is emitted, so a callback that reads
655+
`publication.track` during the event still observes the track (matching
656+
pre-PR behavior), while the reference is dropped once the handler returns.
657+
"""
658+
room = _make_room(name="room-1", token="tok-1", url="wss://r")
659+
local = _make_local_participant("agent")
660+
room._local_participant = local
661+
662+
track = _make_track(sid="TR_1")
663+
publication = _make_local_publication(sid="TR_1")
664+
publication._track = track
665+
local._track_publications["TR_1"] = publication
666+
track._set_room(room)
667+
668+
seen_track: list[Optional[rtc.Track]] = []
669+
670+
@room.on("local_track_unpublished")
671+
def _on_unpublished(pub: rtc.LocalTrackPublication) -> None:
672+
seen_track.append(pub.track)
673+
674+
room._on_room_event(
675+
proto_room.RoomEvent(
676+
local_track_unpublished=proto_room.LocalTrackUnpublished(publication_sid="TR_1"),
677+
)
678+
)
679+
680+
# the callback saw the track (backwards-compatible payload) ...
681+
assert seen_track == [track]
682+
# ... and the reference is dropped once the handler returns
683+
assert publication.track is None
684+
685+
652686
def test_token_refresh_listener_only_removed_by_set_room_none() -> None:
653687
"""The `token_refreshed` listener a Track registers on its
654688
Room is only ever removed by `_set_room(None)`. `Room.disconnect()` does not

0 commit comments

Comments
 (0)