@@ -76,6 +76,21 @@ void readyForRoomEvent(std::uint64_t room_handle) {
7676Room::Room () : subscription_thread_dispatcher_(std::make_unique<SubscriptionThreadDispatcher>()) {}
7777
7878Room::~Room () {
79+ // Issue a graceful disconnect so the server sees us leave instead of
80+ // timing out (RAII expectation; see issue #118). disconnect() does the
81+ // full teardown including subscription threads, listener, and local
82+ // participant, so the destructor only needs to handle the
83+ // already-disconnected path.
84+ try {
85+ disconnect ();
86+ } catch (const std::exception& e) {
87+ LK_LOG_ERROR (" Room::~Room: graceful disconnect failed: {}" , e.what ());
88+ } catch (...) {
89+ LK_LOG_ERROR (" Room::~Room: graceful disconnect failed: unknown exception" );
90+ }
91+
92+ // Defensive: if disconnect() bailed early (e.g. never connected), still
93+ // tear down any state that may have leaked.
7994 if (subscription_thread_dispatcher_) {
8095 subscription_thread_dispatcher_->stopAll ();
8196 }
@@ -86,22 +101,16 @@ Room::~Room() {
86101 const std::scoped_lock<std::mutex> g (lock_);
87102 listener_to_remove = listener_id_;
88103 listener_id_ = 0 ;
89- // Move local participant out for cleanup outside the lock
90104 local_participant_to_cleanup = std::move (local_participant_);
91105 }
92106
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.
96107 if (local_participant_to_cleanup) {
97108 local_participant_to_cleanup->shutdown ();
98109 }
99110
100111 if (listener_to_remove != 0 ) {
101112 FfiClient::instance ().removeListener (listener_to_remove);
102113 }
103-
104- // local_participant_to_cleanup is destroyed here after listener is removed
105114}
106115
107116void Room::setDelegate (RoomDelegate* delegate) {
@@ -234,6 +243,83 @@ bool Room::Connect(const std::string& url, const std::string& token, const RoomO
234243 return connect (url, token, options);
235244}
236245
246+ bool Room::disconnect (DisconnectReason reason) {
247+ TRACE_EVENT0 (" livekit" , " Room::disconnect" );
248+
249+ std::shared_ptr<FfiHandle> handle;
250+ RoomDelegate* delegate_snapshot = nullptr ;
251+ {
252+ const std::scoped_lock<std::mutex> g (lock_);
253+ if (connection_state_ == ConnectionState::Disconnected) {
254+ return false ;
255+ }
256+ handle = room_handle_;
257+ delegate_snapshot = delegate_;
258+ // Flip state immediately so the in-flight Disconnected room-event we'll
259+ // get back doesn't double-fire onDisconnected. Mirrors Python's
260+ // Room.disconnect(), which also flips state before sending the request.
261+ connection_state_ = ConnectionState::Disconnected;
262+ }
263+
264+ // Tell the FFI to close the room and wait for the callback. Catch the
265+ // exception so we still run teardown below; the caller learns about the
266+ // failure via the returned bool / logs.
267+ bool ffi_ok = true ;
268+ if (handle) {
269+ try {
270+ FfiClient::instance ().disconnectAsync (handle->get (), reason).get ();
271+ } catch (const std::exception& e) {
272+ LK_LOG_ERROR (" Room::disconnect: FFI disconnect failed: {}" , e.what ());
273+ ffi_ok = false ;
274+ }
275+ }
276+
277+ // Stop dispatcher first so no track callbacks fire mid-teardown.
278+ if (subscription_thread_dispatcher_) {
279+ subscription_thread_dispatcher_->stopAll ();
280+ }
281+
282+ int listener_to_remove = 0 ;
283+ std::unique_ptr<LocalParticipant> local_participant_to_cleanup;
284+ {
285+ const std::scoped_lock<std::mutex> g (lock_);
286+ listener_to_remove = listener_id_;
287+ listener_id_ = 0 ;
288+ local_participant_to_cleanup = std::move (local_participant_);
289+ remote_participants_.clear ();
290+ room_handle_.reset ();
291+ e2ee_manager_.reset ();
292+ text_stream_readers_.clear ();
293+ byte_stream_readers_.clear ();
294+ }
295+
296+ // Shut down local participant (unregisters RPC handlers, etc.) before
297+ // removing the listener, so in-flight RPC responses don't reach a
298+ // destroyed handle.
299+ if (local_participant_to_cleanup) {
300+ local_participant_to_cleanup->shutdown ();
301+ }
302+
303+ if (listener_to_remove != 0 ) {
304+ FfiClient::instance ().removeListener (listener_to_remove);
305+ }
306+
307+ // Fire onDisconnected exactly once, with the reason the caller passed.
308+ if (delegate_snapshot) {
309+ DisconnectedEvent ev;
310+ ev.reason = reason;
311+ try {
312+ delegate_snapshot->onDisconnected (*this , ev);
313+ } catch (const std::exception& e) {
314+ LK_LOG_ERROR (" Room::disconnect: onDisconnected threw: {}" , e.what ());
315+ } catch (...) {
316+ LK_LOG_ERROR (" Room::disconnect: onDisconnected threw: unknown exception" );
317+ }
318+ }
319+
320+ return ffi_ok;
321+ }
322+
237323RoomInfoData Room::roomInfo () const {
238324 const std::scoped_lock<std::mutex> g (lock_);
239325 return room_info_;
@@ -1139,6 +1225,17 @@ void Room::onEvent(const FfiEvent& event) {
11391225 break ;
11401226 }
11411227 case proto::RoomEvent::kDisconnected : {
1228+ // If disconnect() was driven from our side, it already flipped state
1229+ // to Disconnected and fired the delegate; skip the duplicate here.
1230+ bool already_disconnected = false ;
1231+ {
1232+ const std::scoped_lock<std::mutex> guard (lock_);
1233+ already_disconnected = (connection_state_ == ConnectionState::Disconnected);
1234+ connection_state_ = ConnectionState::Disconnected;
1235+ }
1236+ if (already_disconnected) {
1237+ break ;
1238+ }
11421239 DisconnectedEvent ev;
11431240 ev.reason = toDisconnectReason (re.disconnected ().reason ());
11441241 if (delegate_snapshot) {
0 commit comments