@@ -212,7 +212,10 @@ bool Room::connect(const std::string& url, const std::string& token, const RoomO
212212
213213bool Room::disconnect (DisconnectReason reason) {
214214 TRACE_EVENT0 (" livekit" , " Room::disconnect" );
215+ return shutdown (true , reason, true );
216+ }
215217
218+ bool Room::shutdown (bool disconnect_ffi, DisconnectReason reason, bool notify_delegate) {
216219 std::shared_ptr<FfiHandle> handle;
217220 RoomDelegate* delegate_snapshot = nullptr ;
218221 std::shared_ptr<LocalParticipant> local_participant_to_cleanup;
@@ -221,61 +224,90 @@ bool Room::disconnect(DisconnectReason reason) {
221224 std::unordered_map<std::string, std::shared_ptr<TextStreamReader>> text_stream_readers_to_clear;
222225 std::unordered_map<std::string, std::shared_ptr<ByteStreamReader>> byte_stream_readers_to_clear;
223226 int listener_to_remove = 0 ;
227+ bool claimed_disconnect = false ;
224228
225229 {
226230 const std::scoped_lock<std::mutex> g (lock_);
227- if (connection_state_ == ConnectionState::Disconnected) {
228- // Already torn down (or never connected). Nothing to do.
231+ const bool has_room_state = connection_state_ != ConnectionState::Disconnected || listener_id_ != 0 ||
232+ room_handle_ || local_participant_ || !remote_participants_.empty ();
233+ // Return false for a no-op so callers can tell whether this call claimed the
234+ // room state and performed cleanup. Matches disconnect()'s documented contract.
235+ if (!has_room_state) {
229236 return false ;
230237 }
231- handle = room_handle_;
238+ // The state transition determines which racing path owns the FFI request
239+ // and delegate notification. Remaining room state is still claimed here so
240+ // EOS or destruction can finish local cleanup after a server disconnect.
241+ claimed_disconnect = connection_state_ != ConnectionState::Disconnected;
242+ handle = std::move (room_handle_);
232243 delegate_snapshot = delegate_;
233- // Take ownership of everything under the lock so the kEos handler (which
234- // also tries to move it out) loses any race here — only one teardown
235- // path operates on this state.
236244 local_participant_to_cleanup = std::move (local_participant_);
237245 remote_participants_to_clear = std::move (remote_participants_);
238246 e2ee_manager_to_clear = std::move (e2ee_manager_);
239247 text_stream_readers_to_clear = std::move (text_stream_readers_);
240248 byte_stream_readers_to_clear = std::move (byte_stream_readers_);
241249 listener_to_remove = listener_id_;
242250 listener_id_ = 0 ;
243- room_handle_.reset ();
244- // Flip state immediately so the in-flight Disconnected room-event we'll
245- // get back doesn't double-fire onDisconnected. Mirrors Python's
246- // Room.disconnect()
247251 connection_state_ = ConnectionState::Disconnected;
248252 }
249253
250- // Drain in-flight RPC handlers BEFORE telling Rust to tear down the room.
251- // Mirrors client-sdk-python's Room.disconnect() ordering
254+ bool shutdown_ok = true ;
252255 if (local_participant_to_cleanup) {
253- local_participant_to_cleanup->shutdown ();
256+ try {
257+ local_participant_to_cleanup->shutdown ();
258+ } catch (const std::exception& e) {
259+ LK_LOG_ERROR (" Room shutdown: local participant shutdown failed: {}" , e.what ());
260+ shutdown_ok = false ;
261+ } catch (...) {
262+ LK_LOG_ERROR (" Room shutdown: local participant shutdown failed: unknown exception" );
263+ shutdown_ok = false ;
264+ }
254265 }
255266
256- // Tell the FFI to close the room and wait for the callback. If this fails
257- // we still complete local-side teardown below
258- bool ffi_ok = true ;
259- if (handle) {
267+ if (disconnect_ffi && claimed_disconnect && handle && handle->valid ()) {
260268 try {
261269 FfiClient::instance ().disconnectAsync (handle->get (), reason).get ();
262270 } catch (const std::exception& e) {
263- LK_LOG_ERROR (" Room::disconnect: FFI disconnect failed (continuing local teardown): {}" , e.what ());
264- ffi_ok = false ;
271+ LK_LOG_ERROR (" Room shutdown: FFI disconnect failed (continuing local shutdown): {}" , e.what ());
272+ shutdown_ok = false ;
273+ } catch (...) {
274+ LK_LOG_ERROR (" Room shutdown: FFI disconnect failed (continuing local shutdown): unknown exception" );
275+ shutdown_ok = false ;
265276 }
266277 }
267278
268- // Stop dispatcher so no track callbacks fire mid-teardown.
269279 if (subscription_thread_dispatcher_) {
270- subscription_thread_dispatcher_->stopAll ();
280+ try {
281+ subscription_thread_dispatcher_->stopAll ();
282+ } catch (const std::exception& e) {
283+ LK_LOG_ERROR (" Room shutdown: subscription shutdown failed: {}" , e.what ());
284+ shutdown_ok = false ;
285+ } catch (...) {
286+ LK_LOG_ERROR (" Room shutdown: subscription shutdown failed: unknown exception" );
287+ shutdown_ok = false ;
288+ }
271289 }
272290
273291 if (listener_to_remove != 0 ) {
274- FfiClient::instance ().removeListener (listener_to_remove);
292+ try {
293+ FfiClient::instance ().removeListener (listener_to_remove);
294+ } catch (const std::exception& e) {
295+ LK_LOG_ERROR (" Room shutdown: listener removal failed: {}" , e.what ());
296+ shutdown_ok = false ;
297+ } catch (...) {
298+ LK_LOG_ERROR (" Room shutdown: listener removal failed: unknown exception" );
299+ shutdown_ok = false ;
300+ }
275301 }
276302
277- // Fire onDisconnected exactly once, with the reason the caller passed.
278- if (delegate_snapshot) {
303+ local_participant_to_cleanup.reset ();
304+ remote_participants_to_clear.clear ();
305+ e2ee_manager_to_clear.reset ();
306+ text_stream_readers_to_clear.clear ();
307+ byte_stream_readers_to_clear.clear ();
308+ handle.reset ();
309+
310+ if (notify_delegate && claimed_disconnect && delegate_snapshot) {
279311 DisconnectedEvent ev;
280312 ev.reason = reason;
281313 try {
@@ -287,9 +319,7 @@ bool Room::disconnect(DisconnectReason reason) {
287319 }
288320 }
289321
290- // Moved-out state (local participant, remote participants, e2ee manager,
291- // stream readers) destructs here, releasing FFI handles.
292- return ffi_ok;
322+ return claimed_disconnect && shutdown_ok;
293323}
294324
295325RoomInfoData Room::roomInfo () const {
@@ -1168,20 +1198,18 @@ void Room::onEvent(const FfiEvent& event) {
11681198 break ;
11691199 }
11701200 case proto::RoomEvent::kDisconnected : {
1171- // If disconnect() was driven from our side, it already flipped state
1172- // to Disconnected and fired the delegate; skip the duplicate here.
1173- bool already_disconnected = false ;
1201+ bool should_notify = false ;
11741202 {
11751203 const std::scoped_lock<std::mutex> guard (lock_);
1176- already_disconnected = (connection_state_ == ConnectionState::Disconnected);
1204+ // Local shutdown marks the state before awaiting the FFI response
1205+ // and notifies the delegate itself. Suppress that duplicate while
1206+ // passing server-initiated disconnects through unchanged.
1207+ should_notify = connection_state_ != ConnectionState::Disconnected;
11771208 connection_state_ = ConnectionState::Disconnected;
11781209 }
1179- if (already_disconnected) {
1180- break ;
1181- }
1182- DisconnectedEvent ev;
1183- ev.reason = toDisconnectReason (re.disconnected ().reason ());
1184- if (delegate_snapshot) {
1210+ if (should_notify && delegate_snapshot) {
1211+ DisconnectedEvent ev;
1212+ ev.reason = toDisconnectReason (re.disconnected ().reason ());
11851213 delegate_snapshot->onDisconnected (*this , ev);
11861214 }
11871215 break ;
@@ -1208,56 +1236,7 @@ void Room::onEvent(const FfiEvent& event) {
12081236 break ;
12091237 }
12101238 case proto::RoomEvent::kEos : {
1211- if (subscription_thread_dispatcher_) {
1212- subscription_thread_dispatcher_->stopAll ();
1213- }
1214-
1215- int listener_to_remove = 0 ;
1216-
1217- // Move state out of lock scope before destroying to avoid holding lock
1218- // during potentially long destructors
1219- std::shared_ptr<LocalParticipant> old_local_participant;
1220- std::unordered_map<std::string, std::shared_ptr<RemoteParticipant>> old_remote_participants;
1221- std::shared_ptr<FfiHandle> old_room_handle;
1222- std::shared_ptr<E2EEManager> old_e2ee_manager;
1223- std::unordered_map<std::string, std::shared_ptr<TextStreamReader>> old_text_readers;
1224- std::unordered_map<std::string, std::shared_ptr<ByteStreamReader>> old_byte_readers;
1225-
1226- {
1227- const std::scoped_lock<std::mutex> guard (lock_);
1228- listener_to_remove = listener_id_;
1229- listener_id_ = 0 ;
1230-
1231- // Reset connection state
1232- connection_state_ = ConnectionState::Disconnected;
1233-
1234- // Move state out for cleanup outside lock
1235- old_local_participant = std::move (local_participant_);
1236- old_remote_participants = std::move (remote_participants_);
1237- old_room_handle = std::move (room_handle_);
1238- old_e2ee_manager = std::move (e2ee_manager_);
1239- old_text_readers = std::move (text_stream_readers_);
1240- old_byte_readers = std::move (byte_stream_readers_);
1241- }
1242-
1243- // Drain in-flight RPC invocations before destroying the local
1244- // participant's FFI handle. Mirrors the ordering in disconnect();
1245- // without this, a listener-thread RPC handler can race with handle
1246- // disposal and send to a dead handle → INVALID_HANDLE → terminate.
1247- if (old_local_participant) {
1248- old_local_participant->shutdown ();
1249- }
1250-
1251- // Remove listener outside lock
1252- if (listener_to_remove != 0 ) {
1253- FfiClient::instance ().removeListener (listener_to_remove);
1254- }
1255-
1256- if (old_local_participant) {
1257- old_local_participant->shutdown ();
1258- }
1259-
1260- // old_* state is destroyed here when going out of scope
1239+ (void )shutdown (false , DisconnectReason::Unknown, false );
12611240
12621241 const RoomEosEvent ev;
12631242 if (delegate_snapshot) {
0 commit comments