Skip to content

Commit d83b0f0

Browse files
fix(rtc): avoid Room.sid hang when disconnected or cancelled (#651) (#875)
1 parent aae61be commit d83b0f0

2 files changed

Lines changed: 174 additions & 35 deletions

File tree

‎livekit-rtc/livekit/rtc/room.py‎

Lines changed: 78 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ def __init__(
189189
self._connection_state = ConnectionState.CONN_DISCONNECTED
190190
self._first_sid_future = asyncio.Future[str]()
191191
self._local_participant: LocalParticipant | None = None
192+
self._is_connecting: bool = False
192193

193194
# active incoming stream readers, keyed by FFI reader handle id
194195
self._text_stream_readers: Dict[int, TextStreamReader] = {}
@@ -209,10 +210,16 @@ async def sid(self) -> str:
209210
210211
Returns:
211212
str: The session ID of the room.
213+
214+
Raises:
215+
RuntimeError: If the room is disconnected and no SID was received.
212216
"""
213217
if self._info.sid:
214218
return self._info.sid
215219

220+
if self._connection_state == ConnectionState.CONN_DISCONNECTED and not self._is_connecting:
221+
raise RuntimeError("cannot get sid of a disconnected room")
222+
216223
# This future is shared state for the Room. Caller-side timeouts, e.g.
217224
# asyncio.wait_for(room.sid, ...), should not cancel it for future waiters.
218225
return await asyncio.shield(self._first_sid_future)
@@ -221,8 +228,16 @@ def _resolve_first_sid(self, sid: str) -> None:
221228
# The room SID can arrive through the initial connect result or later
222229
# room update events. Resolve the first-SID waiter from any source that
223230
# carries it so waiters do not depend on event ordering.
224-
if sid and not self._first_sid_future.done():
225-
self._first_sid_future.set_result(sid)
231+
if sid:
232+
self._info.sid = sid
233+
if not self._first_sid_future.done():
234+
self._first_sid_future.set_result(sid)
235+
236+
def _fail_first_sid(self, exc: BaseException) -> None:
237+
if not self._first_sid_future.done():
238+
self._first_sid_future.set_exception(exc)
239+
# Retrieve the exception to suppress unretrieved future warnings if unawaited
240+
self._first_sid_future.exception()
226241

227242
@property
228243
def local_participant(self) -> LocalParticipant:
@@ -469,6 +484,9 @@ def on_participant_connected(participant):
469484
"""
470485
self._server_url = url
471486
self._token = token
487+
self._is_connecting = True
488+
if self._first_sid_future.done() and not self._info.sid:
489+
self._first_sid_future = asyncio.Future[str]()
472490
req = proto_ffi.FfiRequest()
473491
req.connect.url = url
474492
req.connect.token = token
@@ -560,43 +578,55 @@ def on_participant_connected(participant):
560578
),
561579
)
562580

563-
queue = FfiClient.instance.queue.subscribe()
564581
try:
565-
resp = FfiClient.instance.request(req)
566-
cb: proto_ffi.FfiEvent = await queue.wait_for(
567-
lambda e: e.connect.async_id == resp.connect.async_id
568-
)
569-
finally:
570-
FfiClient.instance.queue.unsubscribe(queue)
571-
572-
if cb.connect.error:
573-
FfiClient.instance.queue.unsubscribe(self._ffi_queue)
574-
raise ConnectError(cb.connect.error)
575-
576-
self._ffi_handle = FfiHandle(cb.connect.result.room.handle.id)
582+
queue = FfiClient.instance.queue.subscribe()
583+
try:
584+
resp = FfiClient.instance.request(req)
585+
cb: proto_ffi.FfiEvent = await queue.wait_for(
586+
lambda e: e.connect.async_id == resp.connect.async_id
587+
)
588+
finally:
589+
FfiClient.instance.queue.unsubscribe(queue)
577590

578-
self._e2ee_manager = E2EEManager(
579-
self._ffi_handle.handle, options.encryption or options.e2ee
580-
)
591+
if cb.connect.error:
592+
FfiClient.instance.queue.unsubscribe(self._ffi_queue)
593+
raise ConnectError(cb.connect.error)
581594

582-
self._info = cb.connect.result.room.info
583-
self._resolve_first_sid(self._info.sid)
584-
self._connection_state = ConnectionState.CONN_CONNECTED
595+
self._ffi_handle = FfiHandle(cb.connect.result.room.handle.id)
585596

586-
self._local_participant = LocalParticipant(
587-
self._room_queue, cb.connect.result.local_participant
588-
)
597+
self._e2ee_manager = E2EEManager(
598+
self._ffi_handle.handle, options.encryption or options.e2ee
599+
)
589600

590-
for pt in cb.connect.result.participants:
591-
rp = self._create_remote_participant(pt.participant)
601+
self._info = cb.connect.result.room.info
602+
self._resolve_first_sid(self._info.sid)
603+
self._connection_state = ConnectionState.CONN_CONNECTED
592604

593-
# add the initial remote participant tracks
594-
for owned_publication_info in pt.publications:
595-
publication = RemoteTrackPublication(owned_publication_info)
596-
rp._track_publications[publication.sid] = publication
605+
self._local_participant = LocalParticipant(
606+
self._room_queue, cb.connect.result.local_participant
607+
)
597608

598-
# start listening to room events
599-
self._task = self._loop.create_task(self._listen_task())
609+
for pt in cb.connect.result.participants:
610+
rp = self._create_remote_participant(pt.participant)
611+
612+
# add the initial remote participant tracks
613+
for owned_publication_info in pt.publications:
614+
publication = RemoteTrackPublication(owned_publication_info)
615+
rp._track_publications[publication.sid] = publication
616+
617+
# start listening to room events
618+
self._task = self._loop.create_task(self._listen_task())
619+
except BaseException as e:
620+
if self._ffi_handle is None and hasattr(self, "_ffi_queue"):
621+
FfiClient.instance.queue.unsubscribe(self._ffi_queue)
622+
self._fail_first_sid(
623+
RuntimeError("room connection was cancelled")
624+
if isinstance(e, asyncio.CancelledError)
625+
else e
626+
)
627+
raise
628+
finally:
629+
self._is_connecting = False
600630

601631
# unsubscribe from the FFI queue once the listen task ends.
602632
# disconnect() unsubscribes too, but it early-returns when the
@@ -713,6 +743,7 @@ async def disconnect(
713743
if self._connection_state != ConnectionState.CONN_DISCONNECTED:
714744
self.local_participant._info.disconnect_reason = reason
715745
self._connection_state = ConnectionState.CONN_DISCONNECTED
746+
self._fail_first_sid(RuntimeError("room disconnected before receiving sid"))
716747
self.emit("connection_state_changed", self._connection_state)
717748
self.emit("disconnected", reason)
718749

@@ -744,6 +775,7 @@ async def _listen_task(self) -> None:
744775
await self._drain_rpc_invocation_tasks()
745776
self._error_stream_readers()
746777
self._dispose_open_stream_writers()
778+
self._fail_first_sid(RuntimeError("room disconnected before receiving sid"))
747779

748780
def _on_rpc_method_invocation(self, rpc_invocation: RpcMethodInvocationEvent) -> None:
749781
if self._local_participant is None:
@@ -1126,6 +1158,7 @@ def _on_room_event(self, event: proto_room.RoomEvent) -> None:
11261158
self._connection_state = connection_state
11271159
self.emit("connection_state_changed", connection_state)
11281160
elif which == "disconnected":
1161+
self._fail_first_sid(RuntimeError("room disconnected before receiving sid"))
11291162
self.emit("disconnected", event.disconnected.reason)
11301163
elif which == "reconnecting":
11311164
self.emit("reconnecting")
@@ -1243,8 +1276,12 @@ def _retrieve_remote_participant(self, identity: str) -> Optional[RemoteParticip
12431276

12441277
def _retrieve_participant(self, identity: str) -> Optional[Participant]:
12451278
"""Retrieve a local or remote participant by identity"""
1246-
if identity and identity == self.local_participant.identity:
1247-
return self.local_participant
1279+
if (
1280+
identity
1281+
and self._local_participant is not None
1282+
and identity == self._local_participant.identity
1283+
):
1284+
return self._local_participant
12481285

12491286
return self._retrieve_remote_participant(identity)
12501287

@@ -1260,7 +1297,13 @@ def _create_remote_participant(
12601297

12611298
def __repr__(self) -> str:
12621299
sid = "unknown"
1263-
if self._first_sid_future.done():
1300+
if self._info.sid:
1301+
sid = self._info.sid
1302+
elif (
1303+
self._first_sid_future.done()
1304+
and not self._first_sid_future.cancelled()
1305+
and self._first_sid_future.exception() is None
1306+
):
12641307
sid = self._first_sid_future.result()
12651308

12661309
return f"rtc.Room(sid={sid}, name={self.name}, metadata={self.metadata}, connection_state={ConnectionState.Name(self._connection_state)})"

‎tests/rtc/test_room_sid.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
import asyncio
16+
17+
import pytest
18+
19+
from livekit import rtc
20+
from livekit.rtc import ConnectionState
21+
from livekit.rtc._proto import ffi_pb2 as proto_ffi
22+
23+
24+
@pytest.mark.asyncio
25+
async def test_room_sid_disconnected_raises_runtime_error() -> None:
26+
room = rtc.Room()
27+
with pytest.raises(RuntimeError, match="cannot get sid of a disconnected room"):
28+
await room.sid
29+
30+
31+
@pytest.mark.asyncio
32+
async def test_room_sid_resolution() -> None:
33+
room = rtc.Room()
34+
room._connection_state = ConnectionState.CONN_CONNECTED
35+
36+
async def resolve_soon() -> None:
37+
await asyncio.sleep(0.01)
38+
room._resolve_first_sid("RM_test123")
39+
40+
task = asyncio.create_task(resolve_soon())
41+
sid = await room.sid
42+
await task
43+
assert sid == "RM_test123"
44+
assert "RM_test123" in repr(room)
45+
46+
47+
@pytest.mark.asyncio
48+
async def test_room_sid_failed_on_disconnect() -> None:
49+
room = rtc.Room()
50+
room._connection_state = ConnectionState.CONN_CONNECTED
51+
52+
async def wait_sid() -> None:
53+
await room.sid
54+
55+
wait_task = asyncio.create_task(wait_sid())
56+
await asyncio.sleep(0.01)
57+
58+
# Room disconnects before receiving SID
59+
room._fail_first_sid(RuntimeError("room disconnected before receiving sid"))
60+
61+
with pytest.raises(RuntimeError, match="room disconnected before receiving sid"):
62+
await wait_task
63+
64+
65+
@pytest.mark.asyncio
66+
async def test_connect_cancellation_fails_sid_future_and_cleans_up(
67+
monkeypatch: pytest.MonkeyPatch,
68+
) -> None:
69+
room = rtc.Room()
70+
71+
# Mock FfiClient request to never answer connect response
72+
def mock_request(req: proto_ffi.FfiRequest) -> proto_ffi.FfiResponse:
73+
resp = proto_ffi.FfiResponse()
74+
resp.connect.async_id = 9999
75+
return resp
76+
77+
monkeypatch.setattr(rtc._ffi_client.FfiClient.instance, "request", mock_request)
78+
79+
connect_task = asyncio.create_task(room.connect("ws://dummy", "dummy_token"))
80+
await asyncio.sleep(0.01)
81+
82+
assert room._is_connecting
83+
84+
async def wait_sid() -> None:
85+
await room.sid
86+
87+
sid_task = asyncio.create_task(wait_sid())
88+
await asyncio.sleep(0.01)
89+
90+
connect_task.cancel()
91+
with pytest.raises(asyncio.CancelledError):
92+
await connect_task
93+
94+
assert not room._is_connecting
95+
with pytest.raises(RuntimeError, match="room connection was cancelled"):
96+
await sid_task

0 commit comments

Comments
 (0)