diff --git a/livekit-rtc/livekit/rtc/participant.py b/livekit-rtc/livekit/rtc/participant.py index fd259ac1..eb8a3a82 100644 --- a/livekit-rtc/livekit/rtc/participant.py +++ b/livekit-rtc/livekit/rtc/participant.py @@ -784,6 +784,13 @@ async def unpublish_track(self, track_sid: str) -> None: Raises: UnpublishTrackError: If there is an error in unpublishing the track. """ + # Capture the publication before the FFI round-trip. The + # local_track_unpublished room event races this async response and may + # remove it from _track_publications first; holding our own reference + # guarantees the track is cleared once unpublish completes, regardless + # of which path removes the publication from the dict. + publication = self._track_publications.get(track_sid) + req = proto_ffi.FfiRequest() req.unpublish_track.local_participant_handle = self._ffi_handle.handle req.unpublish_track.track_sid = track_sid @@ -799,8 +806,11 @@ async def unpublish_track(self, track_sid: str) -> None: if cb.unpublish_track.error: raise UnpublishTrackError(cb.unpublish_track.error) - publication = self._track_publications.pop(track_sid) - publication._track = None + # Remove defensively: the room-event handler may already have done + # so when it processed local_track_unpublished first. + self._track_publications.pop(track_sid, None) + if publication is not None: + publication._track = None queue.task_done() finally: self._room_queue.unsubscribe(queue) diff --git a/livekit-rtc/livekit/rtc/room.py b/livekit-rtc/livekit/rtc/room.py index e3619d9c..6d5bc11d 100644 --- a/livekit-rtc/livekit/rtc/room.py +++ b/livekit-rtc/livekit/rtc/room.py @@ -735,9 +735,20 @@ def _on_room_event(self, event: proto_room.RoomEvent) -> None: ltrack = lpublication.track self.emit("local_track_published", lpublication, ltrack) elif which == "local_track_unpublished": + # During teardown the publication may already have been removed + # from the participant's dict by LocalParticipant.unpublish_track + # (the FFI event races that async response), so the SID can be gone + # by the time this event is dispatched. Look it up defensively and + # skip the emit when it is no longer tracked, mirroring the + # local_track_republished and remote track_unpublished handlers, + # instead of raising a KeyError that _listen_task logs as an error. sid = event.local_track_unpublished.publication_sid - lpublication = self.local_participant.track_publications[sid] - self.emit("local_track_unpublished", lpublication) + unpublished = self.local_participant._track_publications.get(sid) + if unpublished is not None: + del self.local_participant._track_publications[sid] + self.emit("local_track_unpublished", unpublished) + else: + logging.debug("local_track_unpublished for untracked publication sid %s", sid) elif which == "local_track_republished": # The SDK auto-republished a local track during a full # reconnect: the underlying Track (and its bound source) is