Skip to content

Commit ef35d9c

Browse files
Bugfix: Server-initiated room disconnect shutdown fix (#205)
* Fixes server-initiated room disconnect shutdown by routing explicit disconnect, destructor cleanup, server disconnect events, and EOS through a shared shutdown() helper, which by proxy cleans up event code * Ensure server Disconnected events remove the C++ FFI listener, stop subscriptions, clear room-owned state, and fire onDisconnected exactly once without re-sending an FFI disconnect
1 parent edc6471 commit ef35d9c

7 files changed

Lines changed: 371 additions & 100 deletions

File tree

client-sdk-rust

include/livekit/room.h

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,7 @@ class LIVEKIT_API Room {
330330
void removeOnDataFrameCallback(DataFrameCallbackId id);
331331

332332
private:
333-
friend class RoomCallbackTest;
333+
friend struct RoomTestAccess;
334334

335335
mutable std::mutex lock_;
336336
ConnectionState connection_state_ = ConnectionState::Disconnected;
@@ -355,5 +355,8 @@ class LIVEKIT_API Room {
355355
int listener_id_{0};
356356

357357
void onEvent(const proto::FfiEvent& event);
358+
359+
// Shared shutdown path for explicit disconnect, server disconnect, EOS, and destruction.
360+
bool shutdown(bool disconnect_ffi, DisconnectReason reason, bool notify_delegate);
358361
};
359362
} // namespace livekit

src/room.cpp

Lines changed: 66 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,10 @@ bool Room::connect(const std::string& url, const std::string& token, const RoomO
212212

213213
bool 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

295325
RoomInfoData 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) {

src/tests/common/ffi_utils.h

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2026 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
#pragma once
18+
19+
#include <gtest/gtest.h>
20+
21+
#include <cstdint>
22+
#include <string>
23+
24+
#include "ffi.pb.h"
25+
#include "ffi_client.h"
26+
27+
namespace livekit::test {
28+
29+
/// Serializes and dispatches a synthetic FFI event through the real callback entry point.
30+
/// Defined in this header for use across different tests.
31+
inline void emitFfiEvent(const proto::FfiEvent& event) {
32+
std::string bytes;
33+
ASSERT_TRUE(event.SerializeToString(&bytes));
34+
ffiEventCallback(reinterpret_cast<const std::uint8_t*>(bytes.data()), bytes.size());
35+
}
36+
37+
} // namespace livekit::test

0 commit comments

Comments
 (0)