From 2dbb047422f99b26f61c46ff1a714f9a8f7ef65e Mon Sep 17 00:00:00 2001 From: Karthikeyan Marudhachalam Date: Mon, 1 Jun 2026 13:20:16 +0530 Subject: [PATCH 1/3] fix(rtc): avoid KeyError in local_track_unpublished handler during teardown Room._on_room_event did an unchecked `self.local_participant.track_publications[sid]` lookup on the `local_track_unpublished` branch. LocalParticipant.unpublish_track removes the publication from `_track_publications` when its FFI async response is processed, and that response races the `local_track_unpublished` room event. When the response is handled first, the SID is already gone and the handler raises KeyError, which _listen_task logs as a spurious ERROR with a full traceback on every affected disconnect cleanup. Pop the publication defensively in the handler and only emit when it is still tracked, mirroring the remote `track_unpublished` and `local_track_republished` handlers. This also folds removal into the handler, fixing a latent leak where a server-forced unpublish (no unpublish_track call) left the publication in the dict. Because the handler now owns removal, the common (non-racing) ordering is that the room event is processed before unpublish_track's pop. Make unpublish_track's pop tolerant (`pop(track_sid, None)` + guarded `_track = None`) so it does not raise a new KeyError once the handler has already removed the publication. Fixes #681 Co-Authored-By: Claude Opus 4.8 (1M context) --- livekit-rtc/livekit/rtc/participant.py | 8 ++++++-- livekit-rtc/livekit/rtc/room.py | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/livekit-rtc/livekit/rtc/participant.py b/livekit-rtc/livekit/rtc/participant.py index fd259ac1..28c265e8 100644 --- a/livekit-rtc/livekit/rtc/participant.py +++ b/livekit-rtc/livekit/rtc/participant.py @@ -799,8 +799,12 @@ 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 + # The local_track_unpublished room event may have already removed + # this publication from the dict (the FFI event and this async + # response race during teardown), so pop defensively. + publication = 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..08bb522c 100644 --- a/livekit-rtc/livekit/rtc/room.py +++ b/livekit-rtc/livekit/rtc/room.py @@ -735,9 +735,21 @@ 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 + # (or by a previously processed event), so the SID can be gone by + # the time this event is dispatched. Pop defensively and skip the + # emit when it is no longer tracked, mirroring the remote + # track_unpublished and local_track_republished 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) + lpublication = self.local_participant._track_publications.pop(sid, None) + if lpublication is not None: + self.emit("local_track_unpublished", lpublication) + 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 From 5e6bddc1ad971d2dc05ed781b2270aa947e9b8ac Mon Sep 17 00:00:00 2001 From: Karthikeyan Marudhachalam Date: Mon, 1 Jun 2026 13:37:22 +0530 Subject: [PATCH 2/3] fix(rtc): satisfy mypy strict and preserve track cleanup on unpublish race Address two issues from review of the local_track_unpublished fix: 1. mypy strict (CI gate) flagged room.py: `pop(sid, None)` was assigned to `lpublication`, which is also bound to `track_publications[sid]` (non-optional LocalTrackPublication) in sibling branches of the same method. mypy fixes the variable's type from that first binding and pushes it as the expected return type into `pop`, selecting the `pop(key, default: _VT) -> _VT` overload and rejecting None. Use a fresh variable with `.get()` + conditional `del`, mirroring the existing local_track_republished handler, which already passes the gate. 2. With the handler now removing the publication first in the common ordering, unpublish_track's `pop(track_sid, None)` returned None and skipped the `_track = None` cleanup, leaving a LocalTrackPublication reference held by application code reporting a stale non-None track after `await unpublish_track(...)` completed. Capture the publication reference before the FFI round-trip so the track is cleared once unpublish completes regardless of which path removes it from the dict. Co-Authored-By: Claude Opus 4.8 (1M context) --- livekit-rtc/livekit/rtc/participant.py | 14 ++++++++++---- livekit-rtc/livekit/rtc/room.py | 21 ++++++++++----------- 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/livekit-rtc/livekit/rtc/participant.py b/livekit-rtc/livekit/rtc/participant.py index 28c265e8..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,10 +806,9 @@ async def unpublish_track(self, track_sid: str) -> None: if cb.unpublish_track.error: raise UnpublishTrackError(cb.unpublish_track.error) - # The local_track_unpublished room event may have already removed - # this publication from the dict (the FFI event and this async - # response race during teardown), so pop defensively. - publication = self._track_publications.pop(track_sid, 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() diff --git a/livekit-rtc/livekit/rtc/room.py b/livekit-rtc/livekit/rtc/room.py index 08bb522c..6d5bc11d 100644 --- a/livekit-rtc/livekit/rtc/room.py +++ b/livekit-rtc/livekit/rtc/room.py @@ -737,19 +737,18 @@ def _on_room_event(self, event: proto_room.RoomEvent) -> None: elif which == "local_track_unpublished": # During teardown the publication may already have been removed # from the participant's dict by LocalParticipant.unpublish_track - # (or by a previously processed event), so the SID can be gone by - # the time this event is dispatched. Pop defensively and skip the - # emit when it is no longer tracked, mirroring the remote - # track_unpublished and local_track_republished handlers, instead - # of raising a KeyError that _listen_task logs as an error. + # (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.pop(sid, None) - if lpublication is not None: - 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 - ) + 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 From db33421eed8e420f0a237d711c2efc0e8deacac8 Mon Sep 17 00:00:00 2001 From: Karthikeyan Marudhachalam Date: Mon, 1 Jun 2026 13:44:30 +0530 Subject: [PATCH 3/3] chore: re-trigger CI (transient quay.io 502 on manylinux image pull) Co-Authored-By: Claude Opus 4.8 (1M context)