Skip to content

fix(rtc): avoid Room.sid hang when disconnected or cancelled (#651) - #875

Open
NabiBukhsh-AI wants to merge 7 commits into
livekit:mainfrom
NabiBukhsh-AI:fix/651-room-sid-hang
Open

NabiBukhsh-AI wants to merge 7 commits into
livekit:mainfrom
NabiBukhsh-AI:fix/651-room-sid-hang

Conversation

@NabiBukhsh-AI

Copy link
Copy Markdown
Contributor

Description

This PR resolves two issues in livekit-rtc:

  1. Fixes Room.sid hangs indefinitely when room_sid_changed FFI event is not delivered #651: Room.sid hanging indefinitely when the room disconnects before receiving a SID, when connect fails, or when accessed on a disconnected room.
  2. Fixes Blank "active_speakers_changed" events are fired even when there are no changes #148: Blank or duplicate active_speakers_changed events being emitted when there are no changes to the active speakers list.

Root Cause Analysis & Key Changes

1. Issue #651 (Room.sid indefinite hang)

  • Problem: Room.sid awaited self._first_sid_future. If a room disconnected before receiving a SID (e.g. SIP ringback hangup before server sends SID), or if connect() threw a ConnectError, or if sid was queried on a disconnected room, _first_sid_future remained pending indefinitely, causing caller tasks to suspend forever.
  • Fix:
    • sid property: if connection_state == ConnectionState.CONN_DISCONNECTED and not currently connecting, immediately raises RuntimeError("cannot get sid of a disconnected room").
    • Added _fail_first_sid(exc): if _first_sid_future is pending, sets the exception (and marks it retrieved to prevent unretrieved future warnings if unawaited).
    • On room disconnect(), disconnected FFI event, or _listen_task termination (EOS/error): marks _first_sid_future failed with RuntimeError("room disconnected before receiving sid").
    • On connect() failure: marks _first_sid_future failed with the connect exception.
    • Added async def get_sid(self, timeout: float | None = None) -> str for callers to easily bound the wait time for a SID.
    • Defensively guard Room.__repr__ against unhandled exceptions on _first_sid_future.

2. Issue #148 (Blank and redundant active_speakers_changed events)

  • Problem: When audio level updates arrive from the SFU, _on_room_event emitted active_speakers_changed unconditionally, even when the active speaker list remained identical or empty.
  • Fix:
    • Tracked self._active_speakers: list[Participant] = [] and exposed property Room.active_speakers.
    • Only emit active_speakers_changed when the identities list differs from self._active_speakers.
    • On participant disconnect or room disconnect: cleanly update/reset self._active_speakers and emit an updated list.

3. Test Runner FFI Isolation

  • In livekit-rtc/tests/test_ffi_handle_lifecycle.py, test_live_handles_marked_consumed_and_no_assert_after_dispose is isolated in a subprocess to prevent FfiClient._dispose() from tearing down the C/Rust FFI runtime in the main pytest process.

Verification

  • uv run pytest tests/ livekit-rtc/tests/: 203 passed, 74 skipped, 0 failures.
  • uv run mypy livekit-protocol livekit-api livekit-rtc: Success (no issues found in 105 files).
  • uv run ruff check & uv run ruff format --check: Passed cleanly.

…peakers

- Fix Room.sid indefinitely hanging when room disconnects before receiving SID or when called on a disconnected room (fixes livekit#651)
- Add Room.get_sid(timeout=...) to allow bounding the wait for room SID
- Fail _first_sid_future cleanly with RuntimeError/ConnectError when disconnect or connect error occurs
- Safely retrieve participant identity in _retrieve_participant without requiring local_participant before connecting
- Safely read sid in Room.__repr__ without raising if _first_sid_future has an exception
- Deduplicate active_speakers_changed events to avoid emitting duplicate or blank events when active speakers have not changed (fixes livekit#148)
- Expose Room.active_speakers property to inspect currently active speakers
- Clear active_speakers and emit empty list when room disconnects or speaking participant disconnects
- Isolate FfiClient._dispose in subprocess in test_ffi_handle_lifecycle to avoid invalidating the global FFI client during test runs
devin-ai-integration[bot]

This comment was marked as resolved.

- Catch BaseException in connect to properly fail pending SID future and unsubscribe FFI queue on cancellation
- Emit defensive list(speakers) copy to prevent listener mutation from corrupting cached speakers
- Add regression tests for connect cancellation and speaker list immutability

@lukasIO lukasIO left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi, thanks for the contribution.
Please split PRs up into singular concerns.

For 1):

  • we don't need to introduce a new get_sid method. Fixing the existing async property should be enough

for 2):

@NabiBukhsh-AI NabiBukhsh-AI changed the title fix(rtc): avoid Room.sid hang on disconnect and deduplicate active speakers fix(rtc): avoid Room.sid hang when disconnected or cancelled (#651) Oct 20, 2026
@NabiBukhsh-AI

Copy link
Copy Markdown
Contributor Author

Hi @lukasIO, thank you for the review!

I have refactored this PR to address a single concern per your feedback:

  1. Removed the get_sid method completely. The fix now purely focuses on the existing async property Room.sid — it raises RuntimeError("cannot get sid of a disconnected room") if disconnected, resolves cleanly when received from connect or update events, and propagates cancellation/disconnection errors rather than hanging indefinitely (Fixes Room.sid hangs indefinitely when room_sid_changed FFI event is not delivered #651).
  2. Removed all active speakers deduplication logic and properties from the Python SDK so that behavior can live in the Rust core under livekit/rust-sdks.

All tests (pytest) and type checks (mypy, ruff) are passing cleanly.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 2 new potential issues.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Disconnected speakers remain in listener state

When a speaking participant or room disconnects, active_speakers_changed no longer reports the reduced or empty list. Listeners retain departed speakers until another speaker update arrives.

(Refers to this code)

Learn more

Speaker listeners receive a complete speaker list, not incremental removal events. On a participant disconnect, the room removes the participant and emits participant_disconnected, but no longer tells speaker listeners that their list changed. The same omission occurs on both room disconnect paths in disconnect and the disconnected event.

Example: The last speaker is Alice. Alice disconnects, and a UI that updates speakers only from active_speakers_changed continues to show Alice until another speaker update arrives.

Recommended fix: Maintain current speakers and emit a reduced list when a speaking participant disconnects; emit an empty list on room disconnect when any speakers remain.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

if participant is not None:
speakers.append(participant)

self.emit("active_speakers_changed", speakers)

@devin-ai-integration devin-ai-integration Bot Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Unchanged speakers trigger duplicate notifications

When speaker updates repeat, active_speakers_changed fires for every update, including repeated empty lists. Listeners receive change notifications without any speaker change.

Learn more

The room event carries a complete list of active speaker identities. Consecutive FFI events can contain the same list, but the event handler emits each one. Consumers then receive redundant notifications, including repeated notifications for an empty room.

Example: Two successive updates both contain ['alice']. A listener receives two change events, although Alice remained the only speaker.

Recommended fix: Keep the previous speaker identities in Room and emit only when the new identities differ. Retain a copy rather than letting listeners mutate the stored list.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Duplicate departures produce false error logs

When a departed participant appears in another disconnect event, pop raises KeyError. The listener logs a callback failure instead of ignoring the duplicate.

(Refers to this code)

Learn more

The room tracks remote participants by identity and removes an entry on a disconnect event. A duplicate or late disconnect event has no corresponding entry. The bare pop raises KeyError, which _listen_task catches and logs as an error running a user callback. No user callback caused that failure.

Example: Alice disconnects and the first event removes Alice from the map. A second disconnect event for Alice raises KeyError and produces an error log instead of being ignored.

Recommended fix: Use pop(identity, None) and emit only when a participant was found, preserving the existing behavior for unknown identities.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

@NabiBukhsh-AI

Copy link
Copy Markdown
Contributor Author

Hi @lukasIO,

Following up with the latest updates:

  1. Focused Scope: Per your feedback, this PR is now strictly scoped to the Room.sid fix:
    • Removed the get_sid() method completely.
    • Removed all active speakers tracking and deduplication logic from python-sdks so that concern remains in livekit/rust-sdks.
  2. Room.sid Async Property Fix (Room.sid hangs indefinitely when room_sid_changed FFI event is not delivered #651):
    • Raises RuntimeError("cannot get sid of a disconnected room") immediately if disconnected.
    • Shields the SID future during connect so individual caller cancellations do not cancel it for other waiters.
    • Cleans up and fails pending SID waiters immediately on disconnect, error, or connection cancellation rather than hanging indefinitely.
  3. CI Status:
    • Resolved the cyclic-garbage SIGABRT on Python 3.9/3.10 Unix runners in test_ffi_client.py.
    • All 36 CI matrix checks (Python 3.9–3.14 across Ubuntu, macOS, and Windows) and Devin Review are passing 100% green.

Ready for final review whenever you have a moment!

Comment on lines +582 to +629
queue = FfiClient.instance.queue.subscribe()
try:
resp = FfiClient.instance.request(req)
cb: proto_ffi.FfiEvent = await queue.wait_for(
lambda e: e.connect.async_id == resp.connect.async_id
)
finally:
FfiClient.instance.queue.unsubscribe(queue)

self._e2ee_manager = E2EEManager(
self._ffi_handle.handle, options.encryption or options.e2ee
)
if cb.connect.error:
FfiClient.instance.queue.unsubscribe(self._ffi_queue)
raise ConnectError(cb.connect.error)

self._info = cb.connect.result.room.info
self._resolve_first_sid(self._info.sid)
self._connection_state = ConnectionState.CONN_CONNECTED
self._ffi_handle = FfiHandle(cb.connect.result.room.handle.id)

self._local_participant = LocalParticipant(
self._room_queue, cb.connect.result.local_participant
)
self._e2ee_manager = E2EEManager(
self._ffi_handle.handle, options.encryption or options.e2ee
)

for pt in cb.connect.result.participants:
rp = self._create_remote_participant(pt.participant)
self._info = cb.connect.result.room.info
self._resolve_first_sid(self._info.sid)
self._connection_state = ConnectionState.CONN_CONNECTED

# add the initial remote participant tracks
for owned_publication_info in pt.publications:
publication = RemoteTrackPublication(owned_publication_info)
rp._track_publications[publication.sid] = publication
self._local_participant = LocalParticipant(
self._room_queue, cb.connect.result.local_participant
)

# start listening to room events
self._task = self._loop.create_task(self._listen_task())
for pt in cb.connect.result.participants:
rp = self._create_remote_participant(pt.participant)

# add the initial remote participant tracks
for owned_publication_info in pt.publications:
publication = RemoteTrackPublication(owned_publication_info)
rp._track_publications[publication.sid] = publication

# start listening to room events
self._task = self._loop.create_task(self._listen_task())
except BaseException as e:
if self._ffi_handle is None and hasattr(self, "_ffi_queue"):
FfiClient.instance.queue.unsubscribe(self._ffi_queue)
self._fail_first_sid(
RuntimeError("room connection was cancelled")
if isinstance(e, asyncio.CancelledError)
else e
)
raise
finally:
self._is_connecting = False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you clarify hoew these changes are connected to avoiding the sid hang?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @lukasIO,

The diff in connect() looks larger than it is because wrapping the setup logic in try ... except BaseException ... finally: caused Git to re-indent ~45 lines (viewing the diff with whitespace hidden ?w=1 shows the exact additions).

Here is how these lines directly prevent Room.sid from hanging:

  1. Unblocking concurrent await room.sid waiters on connection failure/cancellation:
    When an agent or application connects, it is common to await room.sid concurrently while room.connect() is in progress. In that state, Room.sid suspends awaiting self._first_sid_future.
    If connect() fails (e.g. ConnectError) or is cancelled (e.g. via asyncio.wait_for(room.connect(), ...)), connect() aborts before reaching _resolve_first_sid(). Without the except BaseException: handler calling self._fail_first_sid(), self._first_sid_future was left pending forever, stranding any coroutines awaiting room.sid. Calling _fail_first_sid unblocks them immediately with a RuntimeError rather than hanging indefinitely.

  2. State cleanup via self._is_connecting = False in finally::
    Room.sid checks:

    if self._connection_state == ConnectionState.CONN_DISCONNECTED and not self._is_connecting:
        raise RuntimeError("cannot get sid of a disconnected room")

    Resetting self._is_connecting = False in finally: guarantees that once connect() finishes (successfully, with an error, or cancelled), any subsequent caller awaiting room.sid immediately receives a RuntimeError instead of creating or awaiting a future on a room that is no longer connecting.

This exact failure case is validated in the new test test_connect_cancellation_fails_sid_future_and_cleans_up in tests/rtc/test_room_sid.py.

If you prefer to avoid the indentation diff in connect(), I can extract the try/except block to keep the diff minimal.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants