Skip to content

fix(room): release remote participants when the room is closed - #1435

Open
LautaroPetaccio wants to merge 1 commit into
livekit:mainfrom
LautaroPetaccio:fix/close-releases-remote-participants
Open

LautaroPetaccio wants to merge 1 commit into
livekit:mainfrom
LautaroPetaccio:fix/close-releases-remote-participants

Conversation

@LautaroPetaccio

@LautaroPetaccio LautaroPetaccio commented Sep 18, 2026

Copy link
Copy Markdown

The problem

RoomSession::close unpublished the local participant's tracks and then let the remote participant map drop. Dropping the map does not release the participants, because every remote publication registers callbacks that hold the participant which owns them:

  • on_subscribed / on_unsubscribed capture self.clone() (remote_participant.rs:384-402), stored in RemoteTrackPublication.remote.events.
  • on_muted / on_unmuted capture the participant (participant/mod.rs:450-500), stored in TrackPublicationInner.events.

Both publications live in the participant's own track_publications map, so each one is a cycle: Arc<ParticipantInner> → map → publication → closure → Arc<ParticipantInner>.

ParticipantInner holds Arc<RtcEngine>, so a single leaked remote participant pins the entire engine, its peer connections and the WebRTC runtime — the same graph commit 16530d2 set out to release. Every connect/close cycle leaked another one.

handle_participant_disconnect already unregisters these when a peer actually leaves, but close() never walked the remote participants: grepping every use of remote_participants shows inserts at mod.rs:2273 and a single remove at :2285, with everything else read-only.

The fix

Tear the remote participants down in close(), unregistering the callbacks that form the cycles.

This deliberately does not reuse handle_participant_disconnect, which also emits ParticipantDisconnected and TrackUnpublished. Those peers have not left — the room is closing at the caller's request — so emitting departures for them would be a behaviour change for anyone still draining the event stream. The teardown here unregisters directly and leaves the event stream untouched.

Verification

test_close_releases_remote_participants connects two participants, publishes and subscribes a track (the cycles only exist once a publication exists), takes a drop probe on the remote participant, closes the room, and asserts the internals are released.

  • Before: fails — "remote participant retained after the room was closed".
  • After: passes.

Full e2e suite is green; the only delta from the baseline is the added test.

Worth noting why this was not already caught: test_close_releases_room_session runs with test_rooms(1) — one participant, no remote participants, no tracks — so neither cycle is ever formed.

devin-ai-integration[bot]

This comment was marked as resolved.

@LautaroPetaccio
LautaroPetaccio force-pushed the fix/close-releases-remote-participants branch from 44d8d9a to 6788a0d Compare September 18, 2026 14:23

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

Copy link
Copy Markdown
Contributor

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.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment thread livekit/src/room/mod.rs Outdated
Comment on lines +1221 to +1227
let remote_participants = std::mem::take(&mut *self.remote_participants.write());
for participant in remote_participants.into_values() {
for (sid, publication) in participant.track_publications() {
participant.remove_publication(&sid);
publication.set_track(None);
}
}

@devin-ai-integration devin-ai-integration Bot Sep 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Cancelled close leaks racing participants

When room_task inserts a participant after release_remote_participants, cancelling close skips the second drain. Publication callbacks then retain the participant and engine indefinitely.

Learn more

The first drain is not mutually exclusive with participant creation. room_task remains active until close_tx is signalled later, and create_participant can therefore repopulate the map while close awaits local unpublishing or engine shutdown. Cancellation drops the remaining future, so the post-join drain never runs and the new participant's callback cycle survives.

Example: A participant update starts while close performs its first drain. The update inserts Alice, then the caller's timeout cancels close during local track unpublishing. Alice remains in remote_participants, and her publication callbacks retain both Alice and RtcEngine.

Recommended fix: Make shutdown and participant insertion mutually exclusive before the cancellation-safe drain. A closing state checked under the same synchronization as insertion can reject or immediately unlink late participants. Add a lifecycle test that blocks an in-flight participant update between the first drain and cancellation, then verifies both participant and room-session destruction.

Devin Review


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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed. The teardown is now an idempotent helper called twice: once before close can suspend, and again after the task joins.

Running it only after the joins was wrong for exactly the reason you give. close takes the handle first, so a dropped future cannot retry and the cleanup would be skipped for good. Running it up front covers cancellation; running it again afterwards still catches anything room_task inserted in the meantime.

Added test_cancelled_close_releases_remote_participants, which cancels the close at its first suspension point with a subscribed remote track. Dropping the early call makes it fail, so it does guard this.

`RoomSession::close` unpublished the local participant's tracks and then dropped
the remote participant map. Dropping the map does not release the participants:
every remote publication registers callbacks that hold the participant which owns
them — `on_subscribed`/`on_unsubscribed` capture the `RemoteParticipant`, and
`on_muted`/`on_unmuted` capture it through the publication map — so each remote
participant is kept alive by a cycle through its own publications.

A leaked participant holds `Arc<RtcEngine>`, so it pins the whole engine, its peer
connections and the WebRTC runtime: exactly what commit 16530d2 set out to
release. Each connect/close cycle leaked another one.

`handle_participant_disconnect` already unregisters these when a peer actually
leaves, but it also emits ParticipantDisconnected and TrackUnpublished. The peers
have not left here — the room is closing — so unregister directly instead, leaving
the event stream unchanged.

Two ordering constraints make this correct:

- The teardown runs after `room_handle` has been joined. `room_task` is what
  inserts remote participants, so draining the map any earlier would let an engine
  event queued behind the close repopulate it, and those participants would never
  be visited.
- Each publication is unregistered before its track is detached.
  `RemoteTrackPublication::set_track(None)` invokes the unsubscribe handler *before*
  clearing it, and that handler dispatches TrackUnsubscribed carrying strong clones
  of the participant and track. Clearing the handlers first keeps teardown silent,
  so a receiver the application has stopped draining cannot pin the participant
  through a queued event.

`test_close_releases_room_session` did not catch any of this because it runs with a
single participant and no tracks, so neither cycle is ever formed. The new test uses
two participants with a published, subscribed track, keeps its event receiver alive
and undrained across the close, and asserts via a drop probe that the remote
participant's internals are released.

The teardown is idempotent and runs twice: once before `close` can suspend, and again
after the task joins. `close` takes the room handle first, so a caller that bounds it
with a timeout and drops the future cannot retry, and a teardown placed only after the
joins would be skipped for good. Running it up front keeps a cancelled close correct,
and running it again afterwards catches anything `room_task` inserted in between.
@LautaroPetaccio
LautaroPetaccio force-pushed the fix/close-releases-remote-participants branch from 6dd253b to 1357589 Compare September 18, 2026 16:19
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.

1 participant