Skip to content

Commit c362267

Browse files
Room disconnect issue fix (#146)
Fixes room disconnect behavior, ~Room now gracefully disconnects from the server. Also implements Room::disconnect() API call mirroring Python SDK implementation
1 parent 01ebc32 commit c362267

7 files changed

Lines changed: 286 additions & 28 deletions

File tree

include/livekit/room.h

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,22 @@ class LIVEKIT_API Room {
141141
bool Connect(const std::string& url, const std::string& token, const RoomOptions& options);
142142
// NOLINTEND(readability-identifier-naming)
143143

144+
/// Disconnect from the room.
145+
///
146+
/// This method attempts a best-effort graceful disconnect of the room. If the room was connected prior, after @ref
147+
/// disconnect() is called the room object is considered in a terminal state and should no longer be used. If @ref
148+
/// disconnect() was called before @ref connect(), no operations are performed and the room object is still valid.
149+
///
150+
/// @note `~Room()` invokes `disconnect()` automatically if the room is
151+
/// still connected, so explicit calls are optional.
152+
/// @warning Safe to call from any thread, but **must not** be called from inside a
153+
/// `RoomDelegate` callback — doing so will deadlock the event listener.
154+
///
155+
/// @param reason Reason reported to the server (default: ClientInitiated).
156+
/// @returns true if the graceful disconnect succeeds; false if the
157+
/// room was already disconnected (no-op) or the graceful disconnect fails.
158+
bool disconnect(DisconnectReason reason = DisconnectReason::ClientInitiated);
159+
144160
// Accessors
145161

146162
/// Retrieve static metadata about the room.

src/ffi_client.cpp

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,37 @@ std::future<proto::ConnectCallback> FfiClient::connectAsync(const std::string& u
380380
return fut;
381381
}
382382

383+
std::future<void> FfiClient::disconnectAsync(uintptr_t room_handle, DisconnectReason reason) {
384+
const AsyncId async_id = generateAsyncId();
385+
386+
auto fut = registerAsync<void>(
387+
async_id,
388+
// match: this DisconnectCallback's async_id
389+
[async_id](const proto::FfiEvent& event) {
390+
return event.has_disconnect() && event.disconnect().async_id() == async_id;
391+
},
392+
// handler: nothing to extract; the callback is signal-only
393+
[](const proto::FfiEvent& /*event*/, std::promise<void>& pr) { pr.set_value(); });
394+
395+
proto::FfiRequest req;
396+
auto* disconnect = req.mutable_disconnect();
397+
disconnect->set_room_handle(room_handle);
398+
disconnect->set_request_async_id(async_id);
399+
disconnect->set_reason(toProto(reason));
400+
401+
try {
402+
const proto::FfiResponse resp = sendRequest(req);
403+
if (!resp.has_disconnect()) {
404+
logAndThrow("FfiResponse missing disconnect");
405+
}
406+
} catch (...) {
407+
cancelPendingByAsyncId(async_id);
408+
throw;
409+
}
410+
411+
return fut;
412+
}
413+
383414
// Track APIs Implementation
384415
std::future<std::vector<RtcStats>> FfiClient::getTrackStatsAsync(uintptr_t track_handle) {
385416
// Generate client-side async_id first

src/ffi_client.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
#include "data_track.pb.h"
3131
#include "livekit/data_track_error.h"
3232
#include "livekit/result.h"
33+
#include "livekit/room_event_types.h"
3334
#include "livekit/stats.h"
3435
#include "livekit/visibility.h"
3536
#include "lk_log.h"
@@ -94,6 +95,8 @@ class LIVEKIT_INTERNAL_API FfiClient {
9495
std::future<proto::ConnectCallback> connectAsync(const std::string& url, const std::string& token,
9596
const RoomOptions& options);
9697

98+
std::future<void> disconnectAsync(uintptr_t room_handle, DisconnectReason reason);
99+
97100
// Track APIs
98101
std::future<std::vector<RtcStats>> getTrackStatsAsync(uintptr_t track_handle);
99102

src/room.cpp

Lines changed: 108 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -76,32 +76,14 @@ void readyForRoomEvent(std::uint64_t room_handle) {
7676
Room::Room() : subscription_thread_dispatcher_(std::make_unique<SubscriptionThreadDispatcher>()) {}
7777

7878
Room::~Room() {
79-
if (subscription_thread_dispatcher_) {
80-
subscription_thread_dispatcher_->stopAll();
81-
}
82-
83-
int listener_to_remove = 0;
84-
std::unique_ptr<LocalParticipant> local_participant_to_cleanup;
85-
{
86-
const std::scoped_lock<std::mutex> g(lock_);
87-
listener_to_remove = listener_id_;
88-
listener_id_ = 0;
89-
// Move local participant out for cleanup outside the lock
90-
local_participant_to_cleanup = std::move(local_participant_);
91-
}
92-
93-
// Shutdown local participant (unregisters RPC handlers, etc.) before
94-
// removing the listener. This prevents in-flight RPC responses from
95-
// trying to use destroyed handles.
96-
if (local_participant_to_cleanup) {
97-
local_participant_to_cleanup->shutdown();
98-
}
99-
100-
if (listener_to_remove != 0) {
101-
FfiClient::instance().removeListener(listener_to_remove);
79+
// disconnect() handles all destruction/graceful teardown functionality, simply call it here
80+
try {
81+
(void)disconnect(); // Don't need return value
82+
} catch (const std::exception& e) {
83+
LK_LOG_ERROR("Room::~Room: graceful disconnect failed: {}", e.what());
84+
} catch (...) {
85+
LK_LOG_ERROR("Room::~Room: graceful disconnect failed: unknown exception");
10286
}
103-
104-
// local_participant_to_cleanup is destroyed here after listener is removed
10587
}
10688

10789
void Room::setDelegate(RoomDelegate* delegate) {
@@ -234,6 +216,88 @@ bool Room::Connect(const std::string& url, const std::string& token, const RoomO
234216
return connect(url, token, options);
235217
}
236218

219+
bool Room::disconnect(DisconnectReason reason) {
220+
TRACE_EVENT0("livekit", "Room::disconnect");
221+
222+
std::shared_ptr<FfiHandle> handle;
223+
RoomDelegate* delegate_snapshot = nullptr;
224+
std::unique_ptr<LocalParticipant> local_participant_to_cleanup;
225+
std::unordered_map<std::string, std::shared_ptr<RemoteParticipant>> remote_participants_to_clear;
226+
std::unique_ptr<E2EEManager> e2ee_manager_to_clear;
227+
std::unordered_map<std::string, std::shared_ptr<TextStreamReader>> text_stream_readers_to_clear;
228+
std::unordered_map<std::string, std::shared_ptr<ByteStreamReader>> byte_stream_readers_to_clear;
229+
int listener_to_remove = 0;
230+
231+
{
232+
const std::scoped_lock<std::mutex> g(lock_);
233+
if (connection_state_ == ConnectionState::Disconnected) {
234+
// Already torn down (or never connected). Nothing to do.
235+
return false;
236+
}
237+
handle = room_handle_;
238+
delegate_snapshot = delegate_;
239+
// Take ownership of everything under the lock so the kEos handler (which
240+
// also tries to move it out) loses any race here — only one teardown
241+
// path operates on this state.
242+
local_participant_to_cleanup = std::move(local_participant_);
243+
remote_participants_to_clear = std::move(remote_participants_);
244+
e2ee_manager_to_clear = std::move(e2ee_manager_);
245+
text_stream_readers_to_clear = std::move(text_stream_readers_);
246+
byte_stream_readers_to_clear = std::move(byte_stream_readers_);
247+
listener_to_remove = listener_id_;
248+
listener_id_ = 0;
249+
room_handle_.reset();
250+
// Flip state immediately so the in-flight Disconnected room-event we'll
251+
// get back doesn't double-fire onDisconnected. Mirrors Python's
252+
// Room.disconnect()
253+
connection_state_ = ConnectionState::Disconnected;
254+
}
255+
256+
// Drain in-flight RPC handlers BEFORE telling Rust to tear down the room.
257+
// Mirrors client-sdk-python's Room.disconnect() ordering
258+
if (local_participant_to_cleanup) {
259+
local_participant_to_cleanup->shutdown();
260+
}
261+
262+
// Tell the FFI to close the room and wait for the callback. If this fails
263+
// we still complete local-side teardown below
264+
bool ffi_ok = true;
265+
if (handle) {
266+
try {
267+
FfiClient::instance().disconnectAsync(handle->get(), reason).get();
268+
} catch (const std::exception& e) {
269+
LK_LOG_ERROR("Room::disconnect: FFI disconnect failed (continuing local teardown): {}", e.what());
270+
ffi_ok = false;
271+
}
272+
}
273+
274+
// Stop dispatcher so no track callbacks fire mid-teardown.
275+
if (subscription_thread_dispatcher_) {
276+
subscription_thread_dispatcher_->stopAll();
277+
}
278+
279+
if (listener_to_remove != 0) {
280+
FfiClient::instance().removeListener(listener_to_remove);
281+
}
282+
283+
// Fire onDisconnected exactly once, with the reason the caller passed.
284+
if (delegate_snapshot) {
285+
DisconnectedEvent ev;
286+
ev.reason = reason;
287+
try {
288+
delegate_snapshot->onDisconnected(*this, ev);
289+
} catch (const std::exception& e) {
290+
LK_LOG_ERROR("Room::disconnect: onDisconnected threw: {}", e.what());
291+
} catch (...) {
292+
LK_LOG_ERROR("Room::disconnect: onDisconnected threw: unknown exception");
293+
}
294+
}
295+
296+
// Moved-out state (local participant, remote participants, e2ee manager,
297+
// stream readers) destructs here, releasing FFI handles.
298+
return ffi_ok;
299+
}
300+
237301
RoomInfoData Room::roomInfo() const {
238302
const std::scoped_lock<std::mutex> g(lock_);
239303
return room_info_;
@@ -1139,6 +1203,17 @@ void Room::onEvent(const FfiEvent& event) {
11391203
break;
11401204
}
11411205
case proto::RoomEvent::kDisconnected: {
1206+
// If disconnect() was driven from our side, it already flipped state
1207+
// to Disconnected and fired the delegate; skip the duplicate here.
1208+
bool already_disconnected = false;
1209+
{
1210+
const std::scoped_lock<std::mutex> guard(lock_);
1211+
already_disconnected = (connection_state_ == ConnectionState::Disconnected);
1212+
connection_state_ = ConnectionState::Disconnected;
1213+
}
1214+
if (already_disconnected) {
1215+
break;
1216+
}
11421217
DisconnectedEvent ev;
11431218
ev.reason = toDisconnectReason(re.disconnected().reason());
11441219
if (delegate_snapshot) {
@@ -1193,6 +1268,14 @@ void Room::onEvent(const FfiEvent& event) {
11931268
old_byte_readers = std::move(byte_stream_readers_);
11941269
}
11951270

1271+
// Drain in-flight RPC invocations before destroying the local
1272+
// participant's FFI handle. Mirrors the ordering in disconnect();
1273+
// without this, a listener-thread RPC handler can race with handle
1274+
// disposal and send to a dead handle → INVALID_HANDLE → terminate.
1275+
if (old_local_participant) {
1276+
old_local_participant->shutdown();
1277+
}
1278+
11961279
// Remove listener outside lock
11971280
if (listener_to_remove != 0) {
11981281
FfiClient::instance().removeListener(listener_to_remove);

src/room_proto_converter.cpp

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,80 @@ DataPacketKind toDataPacketKind(proto::DataPacketKind in) {
9999
}
100100
}
101101

102-
DisconnectReason toDisconnectReason(proto::DisconnectReason /*in*/) {
103-
// TODO: map each proto::DisconnectReason to your DisconnectReason enum
104-
return DisconnectReason::Unknown;
102+
DisconnectReason toDisconnectReason(proto::DisconnectReason in) {
103+
switch (in) {
104+
case proto::CLIENT_INITIATED:
105+
return DisconnectReason::ClientInitiated;
106+
case proto::DUPLICATE_IDENTITY:
107+
return DisconnectReason::DuplicateIdentity;
108+
case proto::SERVER_SHUTDOWN:
109+
return DisconnectReason::ServerShutdown;
110+
case proto::PARTICIPANT_REMOVED:
111+
return DisconnectReason::ParticipantRemoved;
112+
case proto::ROOM_DELETED:
113+
return DisconnectReason::RoomDeleted;
114+
case proto::STATE_MISMATCH:
115+
return DisconnectReason::StateMismatch;
116+
case proto::JOIN_FAILURE:
117+
return DisconnectReason::JoinFailure;
118+
case proto::MIGRATION:
119+
return DisconnectReason::Migration;
120+
case proto::SIGNAL_CLOSE:
121+
return DisconnectReason::SignalClose;
122+
case proto::ROOM_CLOSED:
123+
return DisconnectReason::RoomClosed;
124+
case proto::USER_UNAVAILABLE:
125+
return DisconnectReason::UserUnavailable;
126+
case proto::USER_REJECTED:
127+
return DisconnectReason::UserRejected;
128+
case proto::SIP_TRUNK_FAILURE:
129+
return DisconnectReason::SipTrunkFailure;
130+
case proto::CONNECTION_TIMEOUT:
131+
return DisconnectReason::ConnectionTimeout;
132+
case proto::MEDIA_FAILURE:
133+
return DisconnectReason::MediaFailure;
134+
case proto::UNKNOWN_REASON:
135+
default:
136+
return DisconnectReason::Unknown;
137+
}
138+
}
139+
140+
proto::DisconnectReason toProto(DisconnectReason in) {
141+
switch (in) {
142+
case DisconnectReason::ClientInitiated:
143+
return proto::CLIENT_INITIATED;
144+
case DisconnectReason::DuplicateIdentity:
145+
return proto::DUPLICATE_IDENTITY;
146+
case DisconnectReason::ServerShutdown:
147+
return proto::SERVER_SHUTDOWN;
148+
case DisconnectReason::ParticipantRemoved:
149+
return proto::PARTICIPANT_REMOVED;
150+
case DisconnectReason::RoomDeleted:
151+
return proto::ROOM_DELETED;
152+
case DisconnectReason::StateMismatch:
153+
return proto::STATE_MISMATCH;
154+
case DisconnectReason::JoinFailure:
155+
return proto::JOIN_FAILURE;
156+
case DisconnectReason::Migration:
157+
return proto::MIGRATION;
158+
case DisconnectReason::SignalClose:
159+
return proto::SIGNAL_CLOSE;
160+
case DisconnectReason::RoomClosed:
161+
return proto::ROOM_CLOSED;
162+
case DisconnectReason::UserUnavailable:
163+
return proto::USER_UNAVAILABLE;
164+
case DisconnectReason::UserRejected:
165+
return proto::USER_REJECTED;
166+
case DisconnectReason::SipTrunkFailure:
167+
return proto::SIP_TRUNK_FAILURE;
168+
case DisconnectReason::ConnectionTimeout:
169+
return proto::CONNECTION_TIMEOUT;
170+
case DisconnectReason::MediaFailure:
171+
return proto::MEDIA_FAILURE;
172+
case DisconnectReason::Unknown:
173+
default:
174+
return proto::UNKNOWN_REASON;
175+
}
105176
}
106177

107178
// --------- basic helper conversions ---------

src/room_proto_converter.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ LIVEKIT_INTERNAL_API ConnectionQuality toConnectionQuality(proto::ConnectionQual
3737
LIVEKIT_INTERNAL_API ConnectionState toConnectionState(proto::ConnectionState in);
3838
LIVEKIT_INTERNAL_API DataPacketKind toDataPacketKind(proto::DataPacketKind in);
3939
LIVEKIT_INTERNAL_API DisconnectReason toDisconnectReason(proto::DisconnectReason in);
40+
LIVEKIT_INTERNAL_API proto::DisconnectReason toProto(DisconnectReason in);
4041

4142
LIVEKIT_INTERNAL_API UserPacketData fromProto(const proto::UserPacket& in);
4243
LIVEKIT_INTERNAL_API SipDtmfData fromProto(const proto::SipDTMF& in);

src/tests/integration/test_room.cpp

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,57 @@ TEST_F(RoomTest, ConnectWithInvalidUrl) {
7070
EXPECT_FALSE(connected) << "Should fail to connect to invalid URL";
7171
}
7272

73+
namespace {
74+
75+
class DisconnectTrackingDelegate : public RoomDelegate {
76+
public:
77+
void onDisconnected(Room&, const DisconnectedEvent& ev) override {
78+
++count;
79+
last_reason = ev.reason;
80+
}
81+
82+
std::atomic<int> count{0};
83+
DisconnectReason last_reason = DisconnectReason::Unknown;
84+
};
85+
86+
} // namespace
87+
88+
// Case: User calls disconnect()
89+
TEST_F(RoomTest, UserDisconnect) {
90+
Room room;
91+
DisconnectTrackingDelegate delegate;
92+
room.setDelegate(&delegate);
93+
94+
RoomOptions options;
95+
ASSERT_TRUE(room.connect(server_url_, token_, options)) << "connect failed";
96+
ASSERT_EQ(room.connectionState(), ConnectionState::Connected);
97+
ASSERT_NE(room.localParticipant(), nullptr);
98+
99+
EXPECT_NO_THROW(room.disconnect()) << "disconnect should not throw on a connected room";
100+
EXPECT_EQ(room.connectionState(), ConnectionState::Disconnected);
101+
EXPECT_EQ(room.localParticipant(), nullptr) << "local participant should be cleared after disconnect";
102+
EXPECT_EQ(delegate.count.load(), 1) << "onDisconnected should fire exactly once";
103+
EXPECT_EQ(delegate.last_reason, DisconnectReason::ClientInitiated);
104+
105+
// Calling again on an already-disconnected room is a no-op
106+
EXPECT_NO_THROW(room.disconnect()) << "second disconnect should not throw on an already-disconnected room";
107+
EXPECT_EQ(delegate.count.load(), 1) << "delegate must not double-fire";
108+
}
109+
110+
// Case: Room goes out of scope while still connected
111+
TEST_F(RoomTest, DestructorDisconnect) {
112+
std::unique_ptr<Room> room = std::make_unique<Room>();
113+
114+
DisconnectTrackingDelegate delegate;
115+
room->setDelegate(&delegate);
116+
RoomOptions options;
117+
ASSERT_TRUE(room->connect(server_url_, token_, options));
118+
ASSERT_EQ(room->connectionState(), ConnectionState::Connected);
119+
120+
room.reset(); // invokes destructor which calls disconnect()
121+
122+
EXPECT_EQ(delegate.count.load(), 1) << "destructor should fire onDisconnected exactly once";
123+
EXPECT_EQ(delegate.last_reason, DisconnectReason::ClientInitiated);
124+
}
125+
73126
} // namespace livekit::test

0 commit comments

Comments
 (0)