Skip to content

Bugfix: Server-initiated room disconnect shutdown fix#205

Open
alan-george-lk wants to merge 10 commits into
mainfrom
alan/bugfix-room-shutdown
Open

Bugfix: Server-initiated room disconnect shutdown fix#205
alan-george-lk wants to merge 10 commits into
mainfrom
alan/bugfix-room-shutdown

Conversation

@alan-george-lk

@alan-george-lk alan-george-lk commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 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

Before fix, when server deleted the room:

[2026-07-13 10:02:31.515] [livekit] [error] FfiClient listener threw: mutex lock failed: Invalid argument

Above observed on Mac, but crashed on Linux (user-reported). No longer after the fix.

Testing

  • Original issue reproduced via local binary (not committed), then fixed and confirmed no longer occurred
  • Add regression coverage for synthetic FFI server disconnects and local-SFU room deletion via lk --dev room delete
  • Consolidates emitFfiEvent into standalone header helper ffi_utils.h for use across two tests now

@alan-george-lk alan-george-lk changed the title Bugfix: server-initiated room disconnect teardown fix Bugfix: Server-initiated room disconnect teardown fix Jul 13, 2026
@alan-george-lk alan-george-lk marked this pull request as ready for review July 13, 2026 22:36

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 1 additional finding.

Open in Devin Review

@xianshijing-lk xianshijing-lk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, assuming you address those comments.

Comment thread include/livekit/room.h Outdated
Comment thread src/room.cpp
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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we return true if nothing to do or it is being torndown ?

@alan-george-lk alan-george-lk Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm open to changing this, right now our public API for disconnect() states:

@returns true if the graceful disconnect succeeds; false if 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is fair. We can keep the current behavior to avoid breaking changes.

Comment thread src/tests/unit/test_room.cpp Outdated
alan-george-lk and others added 2 commits July 13, 2026 21:53
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>
@alan-george-lk alan-george-lk changed the title Bugfix: Server-initiated room disconnect teardown fix Bugfix: Server-initiated room disconnect shutdown fix Jul 14, 2026

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment thread src/room.cpp
if (connection_state_ != ConnectionState::Disconnected) {
throw std::runtime_error("already connected");
}
shutdown_started_ = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Room becomes permanently stuck after concurrent disconnect during connect, leaking FFI handles

The shutdown latch is reset only at the start of the connection attempt (shutdown_started_ = false at src/room.cpp:105) but is never re-checked or re-cleared when the connection succeeds and state is published atomically at line 174–181, so a concurrent disconnect() that sets the latch mid-connect leaves the room in an unrecoverable state where it appears connected but can never be torn down.

Impact: FFI resource handles (room, participants) are permanently leaked because the destructor's disconnect() call is also a no-op.

Race window and mechanism

The race window is between line 107 (lock release after setting Reconnecting) and line 174 (lock acquire to publish final state). During this window, connect() performs network I/O (connectAsync().get()) which can take hundreds of milliseconds.

If another thread calls disconnect() during this window:

  1. shutdown() acquires lock_, sees shutdown_started_ == false and connection_state_ == Reconnecting (has_room_state is true)
  2. Sets shutdown_started_ = true, captures listener_to_remove, sets connection_state_ = Disconnected
  3. Removes the listener via FfiClient::instance().removeListener()
  4. Returns true

Then connect() continues:
5. FFI connect succeeds
6. At line 174, acquires lock and publishes connection_state_ = Connected, room_handle_, local_participant_, etc.
7. Does NOT reset shutdown_started_ (still true)
8. Calls readyForRoomEvent() — but listener is gone, so events are lost

Now the room is stuck:

  • connection_state_ == Connected, room_handle_ is set, local_participant_ is set
  • shutdown_started_ == true
  • No listener registered
  • Any call to disconnect()shutdown() sees shutdown_started_ == true and returns false immediately
  • ~Room() calls disconnect() which also returns false
  • FFI handles are never released

In the old code (before this PR), disconnect() checked connection_state_ == Disconnected to bail out. After connect() overwrote it with Connected, a subsequent disconnect() would see Connected and proceed normally, cleaning up handles. The introduction of shutdown_started_ as a one-way latch creates this new failure mode.

Prompt for agents
The bug is that shutdown_started_ is reset only at line 105 (beginning of connect()) but not when connect() successfully publishes state at lines 174-181. If disconnect()/shutdown() runs between these two points and sets shutdown_started_ = true, connect() overwrites connection_state_ with Connected but leaves shutdown_started_ = true, making the room permanently stuck.

Possible fixes:
1. In the atomic state publish block at line 174-181, also reset shutdown_started_ = false. This ensures that a successful connect always leaves the room in a clean state.
2. Alternatively, check shutdown_started_ at line 174 before publishing state, and if it's true, abort the connect (treat it as a failure, clean up the new state, return false).
3. Option 2 is arguably better because if disconnect() was called during connect(), the user's intent was to disconnect, so connect() should fail rather than silently succeed in a broken state.

The relevant code is in Room::connect() in src/room.cpp. The state publish block is around lines 174-181 (the 'Publish all state atomically under lock' section).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@xianshijing-lk

Copy link
Copy Markdown
Collaborator

lgtm, thanks

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment thread src/room.cpp
Comment on lines 1197 to 1200
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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:

  1. Moves out all room state (src/room.cpp:239–248)
  2. Removes the FFI listener via FfiClient::instance().removeListener() (src/room.cpp:290)
  3. 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: kDisconnectedonDisconnectedkEos → cleanup + onRoomEos.
The new sequence is: kDisconnected → cleanup + onDisconnected → (kEos silently dropped).

Prompt for agents
The kDisconnected handler at src/room.cpp:1197-1200 calls shutdown() which removes the FFI listener. This prevents the subsequent kEos event from Rust from being dispatched to this Room, so onRoomEos is never called after a server-initiated disconnect.

Possible approaches:
1. Do not remove the FFI listener during shutdown when called from kDisconnected. Instead, let kEos arrive and do the listener removal there (similar to the old code's split between kDisconnected and kEos).
2. Keep the current shutdown() behavior but have the kDisconnected handler explicitly fire onRoomEos after shutdown() returns, since the delegate_snapshot is still valid.
3. Split shutdown into two phases: one that does state cleanup without removing the listener (for kDisconnected), and one that also removes the listener (for kEos and explicit disconnect).

The key constraint is that the Rust FFI sends kDisconnected followed by kEos, and both should result in their respective delegate callbacks being fired to maintain backward compatibility with the public RoomDelegate API.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +287 to +288
const std::string delete_command = std::string("lk --dev room delete --yes ") + room_name;
ASSERT_EQ(std::system(delete_command.c_str()), 0) << "failed to delete local test room";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Unsanitized environment variable concatenated into shell command

The integration test constructs a shell command by directly concatenating the LIVEKIT_ROOM environment variable into a std::system() call (src/tests/integration/test_room.cpp:287-288). If the environment variable contained shell metacharacters (e.g., ; rm -rf /), arbitrary commands could execute. In practice, this variable is set by the test helper script to a hardcoded safe value, and this code only runs in test/CI environments.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants