Bugfix: Server-initiated room disconnect shutdown fix#205
Bugfix: Server-initiated room disconnect shutdown fix#205alan-george-lk wants to merge 13 commits into
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
xianshijing-lk
left a comment
There was a problem hiding this comment.
lgtm, assuming you address those comments.
| const bool has_room_state = connection_state_ != ConnectionState::Disconnected || listener_id_ != 0 || | ||
| room_handle_ || local_participant_ || !remote_participants_.empty(); | ||
| if (teardown_started_ || !has_room_state) { | ||
| return false; |
There was a problem hiding this comment.
should we return true if nothing to do or it is being torndown ?
There was a problem hiding this comment.
I'm open to changing this, right now our public API for disconnect() states:
@returns trueif the graceful disconnect succeeds;falseif the room was already disconnected (no-op) or the graceful disconnect fails.
Which this matches, false == noop. I get how that could be confusing, but I think the idea is true is when the disconnect actually happened
There was a problem hiding this comment.
That is fair. We can keep the current behavior to avoid breaking changes.
There was a problem hiding this comment.
FWIW we have a public connectionState so apps can check the state before trying to disconnect
Align the shared cleanup helper with LocalParticipant/FfiClient naming, keep disconnect() returning false for no-ops per its documented contract, and cover Room reuse after server-initiated shutdown. Co-authored-by: Cursor <cursoragent@cursor.com>
|
lgtm, thanks |
| case proto::RoomEvent::kDisconnected: { | ||
| // If disconnect() was driven from our side, it already flipped state | ||
| // to Disconnected and fired the delegate; skip the duplicate here. | ||
| bool already_disconnected = false; | ||
| { | ||
| const std::scoped_lock<std::mutex> guard(lock_); | ||
| already_disconnected = (connection_state_ == ConnectionState::Disconnected); | ||
| connection_state_ = ConnectionState::Disconnected; | ||
| } | ||
| if (already_disconnected) { | ||
| break; | ||
| } | ||
| DisconnectedEvent ev; | ||
| ev.reason = toDisconnectReason(re.disconnected().reason()); | ||
| if (delegate_snapshot) { | ||
| delegate_snapshot->onDisconnected(*this, ev); | ||
| } | ||
| const auto reason = toDisconnectReason(re.disconnected().reason()); | ||
| (void)shutdown(false, reason, true); | ||
| break; |
There was a problem hiding this comment.
🔴 Server-initiated disconnect silently suppresses the end-of-stream callback to the application
The listener is removed during server-disconnect cleanup (shutdown(false, reason, true) at src/room.cpp:1199) before the Rust FFI sends the follow-up end-of-stream event, so the onRoomEos delegate callback is never delivered.
Impact: Applications relying on the end-of-stream callback for final cleanup after a server-initiated disconnect will silently miss it.
Mechanism: kDisconnected removes the listener before kEos arrives
In the old code, kDisconnected (at the old src/room.cpp:1170–1186) only set connection_state_ to Disconnected and fired onDisconnected. The FFI listener remained registered, so the subsequent kEos event from Rust would still be dispatched to Room::onEvent(), which performed full state cleanup and called delegate_snapshot->onRoomEos().
In the new code, kDisconnected calls shutdown(false, reason, true) which:
- Moves out all room state (
src/room.cpp:239–248) - Removes the FFI listener via
FfiClient::instance().removeListener()(src/room.cpp:290) - Fires
onDisconnected(src/room.cpp:311)
Because the listener is now removed, when Rust later sends the kEos event, FfiClient::pushEvent finds no matching listener slot for this Room. The kEos case at src/room.cpp:1223–1230 is never reached, and onRoomEos is never called.
The old event sequence for server disconnect was: kDisconnected → onDisconnected → kEos → cleanup + onRoomEos.
The new sequence is: kDisconnected → cleanup + onDisconnected → (kEos silently dropped).
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
hard to image apps would be using kEos rather than kDisconnected, i guess something to consider
| } | ||
|
|
||
| // old_* state is destroyed here when going out of scope | ||
| (void)shutdown(false, DisconnectReason::Unknown, false); |
There was a problem hiding this comment.
is EOS not a DisconnectReason?
There was a problem hiding this comment.
TODO: Confirm the flow is this on server disconnect:
- Server disconnect event (do shutdown)
- EOS (don't need shutdown)
Verify that the EOS is double-calling shutdown right now as written (after server close)
3cbddb6 to
a2e6a9e
Compare
| case proto::RoomEvent::kEos: { | ||
| if (subscription_thread_dispatcher_) { | ||
| subscription_thread_dispatcher_->stopAll(); | ||
| } | ||
|
|
||
| int listener_to_remove = 0; | ||
|
|
||
| // Move state out of lock scope before destroying to avoid holding lock | ||
| // during potentially long destructors | ||
| std::shared_ptr<LocalParticipant> old_local_participant; | ||
| std::unordered_map<std::string, std::shared_ptr<RemoteParticipant>> old_remote_participants; | ||
| std::shared_ptr<FfiHandle> old_room_handle; | ||
| std::shared_ptr<E2EEManager> old_e2ee_manager; | ||
| std::unordered_map<std::string, std::shared_ptr<TextStreamReader>> old_text_readers; | ||
| std::unordered_map<std::string, std::shared_ptr<ByteStreamReader>> old_byte_readers; | ||
|
|
||
| { | ||
| const std::scoped_lock<std::mutex> guard(lock_); | ||
| listener_to_remove = listener_id_; | ||
| listener_id_ = 0; | ||
|
|
||
| // Reset connection state | ||
| connection_state_ = ConnectionState::Disconnected; | ||
|
|
||
| // Move state out for cleanup outside lock | ||
| old_local_participant = std::move(local_participant_); | ||
| old_remote_participants = std::move(remote_participants_); | ||
| old_room_handle = std::move(room_handle_); | ||
| old_e2ee_manager = std::move(e2ee_manager_); | ||
| old_text_readers = std::move(text_stream_readers_); | ||
| old_byte_readers = std::move(byte_stream_readers_); | ||
| } | ||
|
|
||
| // Drain in-flight RPC invocations before destroying the local | ||
| // participant's FFI handle. Mirrors the ordering in disconnect(); | ||
| // without this, a listener-thread RPC handler can race with handle | ||
| // disposal and send to a dead handle → INVALID_HANDLE → terminate. | ||
| if (old_local_participant) { | ||
| old_local_participant->shutdown(); | ||
| } | ||
|
|
||
| // Remove listener outside lock | ||
| if (listener_to_remove != 0) { | ||
| FfiClient::instance().removeListener(listener_to_remove); | ||
| } | ||
|
|
||
| if (old_local_participant) { | ||
| old_local_participant->shutdown(); | ||
| } | ||
|
|
||
| // old_* state is destroyed here when going out of scope | ||
|
|
||
| const RoomEosEvent ev; | ||
| if (delegate_snapshot) { | ||
| delegate_snapshot->onRoomEos(*this, ev); |
There was a problem hiding this comment.
🔴 End-of-stream event no longer cleans up room resources, causing leaks when no prior disconnect event arrives
All teardown logic was removed from the end-of-stream handler (kEos at src/room.cpp:1221), so if the stream ends without a preceding disconnect event, the room's internal state (FFI handles, participants, listener registration) is never released.
Impact: The room leaks FFI resources and its event listener, and becomes permanently stuck in a stale state that cannot be recovered or reconnected.
Mechanism: kEos teardown was removed but not replaced with a shutdown() call
The old code at kEos performed full teardown: it moved out local_participant_, remote_participants_, room_handle_, e2ee_manager_, stream readers, called local_participant->shutdown(), and removed the FFI listener via removeListener(). The new code at src/room.cpp:1221-1226 only fires the delegate callback and does nothing else.
The new shutdown() method's own comment at include/livekit/room.h:359 says it is the "Shared shutdown path for explicit disconnect, server disconnect, EOS, and destruction," confirming the intent was for EOS to use it. But the kEos case never calls shutdown().
The kDisconnected handler at src/room.cpp:1195-1198 does call shutdown(), but if kEos arrives without a preceding kDisconnected (e.g., abnormal server termination, or a protocol edge case), the room handle, local participant, remote participants, E2EE manager, stream readers, and the FFI listener all leak.
(Refers to lines 1221-1226)
Was this helpful? React with 👍 or 👎 to provide feedback.
Summary
shutdown()helper, which by proxy cleans up event codeBefore fix, when server deleted the room:
Above observed on Mac, but crashed on Linux (user-reported). No longer after the fix.
Testing
lk --dev room deleteemitFfiEventinto standalone header helperffi_utils.hfor use across two tests nowTicket
BOT-464