Skip to content

Detach publisher senders before closing the PC so a server-ended room releases its local tracks - #1444

Open
sgu-bithuman wants to merge 3 commits into
livekit:mainfrom
sgu-bithuman:fix/close-detach-senders-before-pc-close
Open

sgu-bithuman wants to merge 3 commits into
livekit:mainfrom
sgu-bithuman:fix/close-detach-senders-before-pc-close

Conversation

@sgu-bithuman

@sgu-bithuman sgu-bithuman commented Sep 19, 2026

Copy link
Copy Markdown

Fixes #1443.

On a server-initiated disconnect the engine closes the publisher PeerConnection (SessionInner::close) before RoomSession::close's unpublish loop runs. libwebrtc refuses RemoveTrack/SetTrack on a closed PC (INVALID_STATE "PeerConnection is closed.") and RtpSenderBase::Stop() never releases track_, so every sender keeps its MediaStreamTrack for the life of the process; unpublish_track's remove_track(sender)? then aborts before set_transceiver(None) / publication.set_track(None), so the publication keeps the track as well. For a local audio track that strands one AudioSourceCapture thread per server-ended room. The client-initiated path (unpublish loop first, then engine close) does not leak.

Path (main @ c944510)

  1. livekit/src/rtc_engine/rtc_session.rs:2044 on_session_disconnected (signal Leave{Disconnect}) → :2057 emits SessionEvent::CloseEngineInner::closeSessionInner::close (:2066)
  2. livekit/src/rtc_engine/rtc_session.rs:2074 self.publisher_pc.close()tracks still attached
  3. EngineEvent::Disconnectedlivekit/src/room/mod.rs:1807 RoomSession::handle_disconnectedRoomSession::close:1187 unpublish_track(sid) per publication
  4. livekit/src/room/participant/local_participant.rs:670 self.inner.rtc_engine.remove_track(sender)? — libwebrtc answers INVALID_STATE "PeerConnection is closed.", the ? returns before track.set_transceiver(None) / publication.set_track(None) / local_track_unpublished, and RtpSenderBase::Stop() has left track_ set, so the sender and the publication both keep the MediaStreamTrack.

Changes

  • livekit/src/rtc_engine/rtc_session.rs:2074 SessionInner::close: remove every sender from the publisher PC while it is still open, before publisher_pc.close(), so the tracks are released.

  • livekit/src/room/participant/local_participant.rs:670 unpublish_track: log and continue when rtc_engine.remove_track fails instead of ?, so the transceiver/publication bookkeeping and the local_track_unpublished callback always run.

  • webrtc-sys/src/rtp_sender.cpp:156 RtpSender::track(): a sender legitimately has no track after RemoveTrack; return null instead of handing a null scoped_refptr to get_or_create_media_stream_track (which dereferences it — without this, SessionInner::remove_track's sender.track() crashes once the first change detaches the sender).

  • .changeset/detach_senders_before_pc_close.md: patch bumps for livekit, webrtc-sys and their dependents (libwebrtc, livekit-capture, livekit-ffi), as the changeset check requires.

Measured

Python SDK livekit 1.1.19 with this liblivekit_ffi.so (0.12.79 + this patch) dropped into the wheel, Linux x86_64; AudioSourceCapture threads counted in /proc/self/task after all wrappers and FFI handles are dropped and a 10 s settle:

build server-ended room client-ended room (control)
stock 0.12.79 1 → 1 (leaked) 1 → 0
this patch 1 → 0 1 → 0

The remaining thread set after the patched server-ended run is asyncio_0, livekit-audio, python, tokio-rt-worker only (stock still shows AudioSourceCapt, network_thread, signaling_threa, worker_thread 0, rtc-low-prio).

🤖 Generated with Claude Code

https://claude.ai/code/session_01BgyJFvVUi9ZjMPkqg2mFqA

… releases its local tracks

On a server-initiated disconnect (signal `Leave{Disconnect}`, room deleted,
participant removed, duplicate identity) the engine closes the publisher
PeerConnection before the room's unpublish loop runs:

  rtc_session.rs on_signal_event(Leave) -> on_session_disconnected
  -> SessionEvent::Close -> EngineInner::close -> SessionInner::close
  -> publisher_pc.close()                      (tracks still attached)
  -> EngineEvent::Disconnected -> RoomSession::handle_disconnected
  -> RoomSession::close -> unpublish_track(sid) for each publication
  -> rtc_engine.remove_track(sender)?          (INVALID_STATE, aborts)

libwebrtc refuses RemoveTrack/SetTrack on a closed PeerConnection and
RtpSenderBase::Stop() never releases `track_`, so every RtpSender of the
closed PC keeps its MediaStreamTrack for the life of the process. The `?`
then skips `set_transceiver(None)`, the `local_track_unpublished` callback
and `publication.set_track(None)`, so the publication keeps the track too.
For an audio track this strands one native AudioSourceCapture thread
(~96 MB) per server-ended room; the client-initiated path (unpublish loop
first, then close) does not leak.

* SessionInner::close: remove every sender from the publisher PC while it
  is still open, so the tracks are released before close().
* LocalParticipant::unpublish_track: do not abort the bookkeeping when the
  engine's remove_track fails (the PC is already closed on this path).
* webrtc-sys RtpSender::track(): a sender legitimately has no track after
  RemoveTrack; map the null to None instead of dereferencing it.

Measured with the python SDK (livekit 1.1.19, liblivekit_ffi 0.12.79),
counting AudioSourceCapture threads in /proc/self/task after a
server-deleted room, all wrappers dropped, 10 s settle:
stock 1 -> 1 (leaked); patched 1 -> 0. Client-ended control 1 -> 0 both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgyJFvVUi9ZjMPkqg2mFqA
@CLAassistant

CLAassistant commented Sep 19, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

devin-ai-integration[bot]

This comment was marked as resolved.

sgu-bithuman and others added 2 commits September 19, 2026 09:27
Patch bumps for the changed crates (livekit, webrtc-sys) and their transitive
dependents (libwebrtc, livekit-capture, livekit-ffi), as the changeset check
requires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgyJFvVUi9ZjMPkqg2mFqA
…y the closed-PC case

Review (Devin): swallowing every remove_track error made unpublish_track report
success while an open publisher PeerConnection could still be sending on the
attached sender.

libwebrtc's RemoveTrackOrError reports exactly one condition as INVALID_STATE,
"PeerConnection is closed" (api/peer_connection_interface.h); a sender it does
not know is not an error under Unified Plan. So unpublish_track now matches
EngineError::Rtc(RtcError { error_type: RtcErrorType::InvalidState, .. }) as
the expected outcome of a server-ended room (the engine already detached the
sender in SessionInner::close), and returns every other error to the caller —
after the local bookkeeping (set_transceiver(None), the unpublished callback,
publication.set_track(None)) has run, so the publication still drops its
reference to the local track.

The sender-detach loop in SessionInner::close keeps logging instead of
returning: close() has no error channel and aborting on one sender would
strand the rest.

Adds a unit test for the predicate and adjusts the changeset summary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BgyJFvVUi9ZjMPkqg2mFqA
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.

Server-ended room leaks every local track (publisher PC closed before the unpublish loop; AudioSourceCapture thread stranded per room)

2 participants