Skip to content

fix(room): finish local unpublish cleanup when the transport is gone - #1436

Open
LautaroPetaccio wants to merge 2 commits into
livekit:mainfrom
LautaroPetaccio:fix/unpublish-track-cleanup-on-error
Open

LautaroPetaccio wants to merge 2 commits into
livekit:mainfrom
LautaroPetaccio:fix/unpublish-track-cleanup-on-error

Conversation

@LautaroPetaccio

@LautaroPetaccio LautaroPetaccio commented Sep 18, 2026

Copy link
Copy Markdown

The problem

LocalParticipant::unpublish_track removed the publication from the participant and then propagated a transport failure with ?:

let publication = self.remove_publication(sid);
...
self.inner.rtc_engine.remove_track(sender)?;   // <-- early return
track.set_transceiver(None);                    // skipped
...
publication.set_track(None);                    // skipped

set_track(None) is the only thing that unregisters the track's mute callbacks. Those callbacks capture the publication (publication/mod.rs:181-216) while the publication holds the track in info.track — a cycle, so the pair keeps itself alive, and with it the RtpTransceiver and the PeerConnection it references.

Removing the sender fails on two routine paths, not exotic ones:

  • Abnormal disconnect. EngineEvent::Disconnected is emitted at rtc_engine/mod.rs:790, after session.close() has closed the publisher, so the room's teardown runs against an already-closed transport and libwebrtc rejects the removal.
  • Full reconnect. try_restart_connection installs the new session before Restarted is emitted, so handle_restarted's unpublish targets a sender bound to the previous transport. The existing TODO at rtc_engine/mod.rs:387-389 says this error "is safe to ignore" — it is not, because the caller's cleanup sits behind the ?.

close() calls this as let _ = ... .await, so the error surfaces nowhere.

The fix

Run the local cleanup unconditionally and report the failure once local state is consistent. The return value is unchanged.

The local_track_unpublished callback and the renegotiation stay on the success path, exactly as before. That callback dispatches RoomEvent::LocalTrackUnpublished, and firing it on the reconnect path would emit an unpublish immediately before LocalTrackRepublished — which room.proto explicitly tells bindings to treat as a preserved publication handle. Keeping it where it was means this change has no observable behavioural delta.

Known follow-up: the E2EE FrameCryptor cleanup (e2ee/manager.rs:214-220) also hangs off that callback, so a republished track still leaves a stale cryptor keyed on the old sid. Fixing that means accepting the event-stream change above, so I left it out of this PR rather than bundle the decision.

Verification

test_unpublish_cleans_up_when_transport_is_gone publishes a track, deletes the room server-side to force an abnormal disconnect, then drops every reference held outside the SDK — the publication handle, the track helper, and the event receiver — so anything still alive is held only by the cycle.

  • Before: fails — "local publication retained after an unpublish that could not reach the transport".
  • After: passes.

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

`unpublish_track` removed the publication from the participant, then propagated a
failure from `rtc_engine.remove_track` with `?`. Everything after that call was
skipped, including `publication.set_track(None)` — the only thing that unregisters
the track's mute callbacks. The publication holds its track and those callbacks
hold the publication back, so the pair kept itself alive, and with it the
transceiver and the peer connection the transceiver references.

Removing the sender fails on two routine paths, not on exotic ones:

- An abnormal disconnect. `EngineEvent::Disconnected` is emitted after the session
  has closed its transports, so the room's teardown runs against a publisher that
  is already closed and libwebrtc rejects the removal.
- A full reconnect. The new session installs a new publisher before `Restarted` is
  emitted, so the sender being removed belongs to the previous transport.

`close()` discards the result, so neither surfaced anywhere.

The local cleanup now always runs and the error is reported afterwards, leaving the
return value unchanged. The `local_track_unpublished` callback and the renegotiation
stay on the success path exactly as before: that callback dispatches
LocalTrackUnpublished, and firing it during the reconnect republish would emit an
unpublish immediately before LocalTrackRepublished, which bindings are told to treat
as a preserved publication handle. Note this leaves the stale E2EE FrameCryptor for
a republished track in place, since that cleanup hangs off the same callback.

@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 potential issue.

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

Devin Review

Comment on lines +681 to 687
if removed.is_ok() {
if let Some(local_track_unpublished) =
self.local.events.local_track_unpublished.lock().as_ref()
{
local_track_unpublished(self.clone(), publication.clone());
}
}

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.

🔴 Full reconnects retain stale frame cryptors

When remove_track fails during a full reconnect, local_track_unpublished is skipped and the old frame cryptor remains registered. on_local_track_unpublished is the only per-track path removing the previous SID's cryptor. Each full reconnect retains another native cryptor and its old RTP sender until room shutdown.

Learn more

A full reconnect replaces the RTC session, then republishes every local track. Removing an old sender through the new session fails, so this guard suppresses the callback. That callback invokes on_local_track_unpublished, which removes the frame cryptor stored under the old track SID. The subsequent publish creates a new frame cryptor under the new SID, leaving the old native cryptor registered. The stale cryptor holds the old RTP sender and related native transport resources until the room-wide E2EE cleanup runs at shutdown.

Example: An encrypted track starts under SID TR_old. A full reconnect makes sender removal fail, then republishes it as TR_new. The manager contains cryptors for both TR_old and TR_new; repeated reconnects add another stale entry each time.

Recommended fix: Separate internal E2EE teardown from the externally visible LocalTrackUnpublished event. Always remove the cryptor for the old SID after local unpublish cleanup, while preserving the reconnect event contract for bindings. Add a full-reconnect lifecycle test that verifies the old SID disappears and the cryptor count returns to one.

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.

I implemented this and could not reproduce the leak, so I reverted it.

On unpatched code the cryptor map after a full reconnect holds one entry under the new sid, with the old one already gone:

before: [(ParticipantIdentity("p0"), TrackSid("TR_V5kQJepNxmJEu"))]
after:  [(ParticipantIdentity("p0"), TrackSid("TR_VPqmeKjnwF8ub"))]

So remove_track is not failing on the reconnect path the way it does on an abnormal disconnect, and the unpublished callback removes the old sid normally. The other path is covered too: RoomSession::close calls e2ee_manager.cleanup(), which clears every cryptor.

The test I wrote for it passed with and without the change, which makes it worthless as a guard. Measured against a local dev server, so if you have a trace showing two cryptors after a reconnect, send it and I will put the change back.

@LautaroPetaccio

LautaroPetaccio commented Sep 18, 2026

Copy link
Copy Markdown
Author

The red macOS job is a SIGSEGV in reconnection_test::test_full_reconnect_recovers, which does not exercise this change: handle_restarted only calls unpublish_track inside its loop over published tracks, and that test publishes none.

It passed on the previous run here, and 8/8 locally on the same target. The macOS failure has also moved around between runs, hitting a different test on #1434 earlier while Linux and Windows stayed green both times.

I cannot re-run it from a fork. The segfault itself looks worth a separate look, since it points at something unsound in the native reconnect path rather than at any of these PRs.

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