Skip to content

Commit 7f1804c

Browse files
committed
Fix unpublish_track deadlock on FFI error
task_done() ran after the error raise, so a failed unpublish left the room queue's join() waiting forever and stalled the event loop.
1 parent 16ccac5 commit 7f1804c

2 files changed

Lines changed: 75 additions & 1 deletion

File tree

livekit-rtc/livekit/rtc/participant.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -861,6 +861,10 @@ async def unpublish_track(self, track_sid: str) -> None:
861861
cb: proto_ffi.FfiEvent = await queue.wait_for(
862862
lambda e: e.unpublish_track.async_id == resp.unpublish_track.async_id
863863
)
864+
# wait_for hands back one event the caller owns; mark it done before the
865+
# error check, otherwise raising here leaves the room queue's join()
866+
# waiting on it forever and the room event loop stalls.
867+
queue.task_done()
864868

865869
if cb.unpublish_track.error:
866870
raise UnpublishTrackError(cb.unpublish_track.error)
@@ -879,7 +883,6 @@ async def unpublish_track(self, track_sid: str) -> None:
879883
if publication._track is not None:
880884
publication._track._set_room(None)
881885
publication._track = None
882-
queue.task_done()
883886
finally:
884887
self._room_queue.unsubscribe(queue)
885888

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Copyright 2026 LiveKit, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Regression test for unpublish_track deadlocking the room event loop.
16+
17+
When the FFI reports an unpublish error, unpublish_track raises. If it raises
18+
before marking its room-queue event done, Room._listen_task's join() blocks on
19+
that unconsumed event forever and the room stops processing events.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import asyncio
25+
from unittest.mock import MagicMock, patch
26+
27+
import pytest
28+
29+
from livekit import rtc
30+
from livekit.rtc._ffi_client import FfiClient
31+
from livekit.rtc._proto import ffi_pb2 as proto_ffi
32+
from livekit.rtc._utils import BroadcastQueue
33+
from livekit.rtc.participant import UnpublishTrackError
34+
35+
_ASYNC_ID = 4242
36+
37+
38+
async def test_unpublish_track_error_does_not_deadlock_room_queue() -> None:
39+
room_queue: BroadcastQueue[proto_ffi.FfiEvent] = BroadcastQueue()
40+
41+
participant = rtc.LocalParticipant.__new__(rtc.LocalParticipant)
42+
participant._room_queue = room_queue
43+
participant._ffi_handle = MagicMock(handle=1)
44+
participant._track_publications = {}
45+
46+
resp = proto_ffi.FfiResponse()
47+
resp.unpublish_track.async_id = _ASYNC_ID
48+
49+
error_event = proto_ffi.FfiEvent()
50+
error_event.unpublish_track.async_id = _ASYNC_ID
51+
error_event.unpublish_track.error = "unpublish failed"
52+
53+
async def deliver_event_like_listen_task() -> None:
54+
# Mirror Room._listen_task: hand the event to subscribers, then wait for
55+
# them to finish before moving on to the next event.
56+
room_queue.put_nowait(error_event)
57+
await room_queue.join()
58+
59+
with patch.object(FfiClient.instance, "request", return_value=resp):
60+
unpublish = asyncio.ensure_future(participant.unpublish_track("TR_test"))
61+
await asyncio.sleep(0) # let unpublish subscribe before the event is delivered
62+
listen = asyncio.ensure_future(deliver_event_like_listen_task())
63+
64+
with pytest.raises(UnpublishTrackError):
65+
await unpublish
66+
67+
try:
68+
await asyncio.wait_for(listen, timeout=5)
69+
except asyncio.TimeoutError:
70+
listen.cancel()
71+
pytest.fail("room queue join() deadlocked after unpublish_track error")

0 commit comments

Comments
 (0)