Skip to content

Close peer connections before awaiting signal teardown - #1335

Open
sebitokazu wants to merge 1 commit into
livekit:mainfrom
sebitokazu:fix/close-peer-connections-before-signal-teardown
Open

Close peer connections before awaiting signal teardown#1335
sebitokazu wants to merge 1 commit into
livekit:mainfrom
sebitokazu:fix/close-peer-connections-before-signal-teardown

Conversation

@sebitokazu

Copy link
Copy Markdown

Before you submit your PR

Make sure the following is true before submitting your PR:

  • I have read the contributing guidelines and validated that this PR will be accepted.
  • I have read and followed the principles regarding breaking changes, testing, and code quality.

PR description

Fixes #1334

SessionInner::close closed the peer connections after two awaits that can block indefinitely:

self.signal_client.send(Leave { .. }).await;
self.signal_client.close().await;
self.publisher_pc.close();

PeerConnection::close is the only thing that releases a room's ICE UDP sockets, so a caller bounding close() with a timeout — a natural response to an unbounded API — dropped the future before it ran, and the sockets stayed bound for the process's lifetime. Nothing else releases them: engine_task and room_task own Arcs of the inner state and exit only on close_rx, so an un-closed room keeps the graph alive and the PeerConnection destructor never runs.

Both awaits really can park. SignalStream::send waits on the writer task, which does conn.send(data).await with no timeout, so a half-open socket blocks it until TCP gives up. SignalStream::close then joins that same task, and the InternalMessage::Close it queues sits behind the stuck message in a capacity-8 channel. Every timeout in signal_client is on the connect path.

This moves the peer-connection close above both awaits. PeerTransport::close is synchronous, so it has already run by the time the future first suspends. The signalling socket is unaffected, so the Leave still goes out.

Measured on aarch64 with a bare connect/close loop, counting /proc/self/fd socket inodes against /proc/self/net/udp{,6}:

teardown before after
close() cancelled ~13 UDP/cycle 0
close() awaited 0 0
Room dropped, never closed ~13 UDP/cycle ~12 (unchanged)

Room-dropped is deliberately untouched: that needs the spawned tasks stopped so the graph can drop, which is a larger change.

client-sdk-js already orders it this way — RTCEngine.close() closes the peer connections before the signal client.

Breaking changes

None. Statement reorder inside a private async fn; no public API or behaviour change.

MSRV

Unchanged.

Testing

Adds test_cancelled_close_still_closes_peer_connections to livekit/tests/room_test.rs, using the existing common::test_rooms harness.

It cancels close() at its earliest suspension point (timeout(Duration::ZERO, ..) — tokio polls the inner future once before the elapsed timer fires) and asserts the publisher transport is Closed. That only holds if the transports were closed before the future suspended, so moving the close back below an await fails the test. It also asserts the publisher was Connected first, so it cannot pass vacuously.

Reading transport state needed a test-only accessor, following the existing #[cfg(feature = "__lk-e2e-test")] pattern on Room. Room-level state was not usable — a room can report Disconnected while its transport is still open, which is the bug.

Reverting only the reorder, keeping the test:

test test_cancelled_close_still_closes_peer_connections ... FAILED
  left: Connected
 right: Closed

With it restored, all four room_test cases pass. Run with cargo test -p livekit --features __lk-e2e-test,rustls-tls-webpki-roots --test room_test; a TLS feature is needed only because this ran against a wss:// endpoint rather than a local --dev server.

I also ran the rest of the suite, each file serially, with and without the change. Nothing regressed: dynacast_test 3/0 both, rpc_test 6/0 both, data_track_test 11 passed/4 failed both, reconnection_test 4/1 both (same test), peer_connection_signaling_test 20/7 before vs 23/4 after, plus the 76 lib unit tests green. The pre-existing failures look environmental rather than real — test_published_state asserts a 20 ms tolerance that a cloud RTT cannot meet, and the node_failure / resume_* cases want a server they can fault-inject. I did not have a local livekit-server --dev to confirm that, so I am reporting the comparison rather than claiming a green suite.

Async

No new runtime dependencies; the production change introduces no .await. The test uses tokio::time::timeout directly and contains no artificial delay — it polls once and asserts.

`SessionInner::close` released the peer connections only after two awaits
that can block indefinitely, so cancelling `close()` -- for example by
wrapping it in a timeout -- dropped the future before the transports were
ever closed, leaving their ICE UDP sockets bound for the lifetime of the
process. Nothing else releases them, so long-lived clients eventually
exhausted their file descriptors and could not recover.

`PeerTransport::close` is synchronous, so closing before the first await
means a cancelled teardown can no longer leak. The signalling socket is
unaffected and the Leave still goes out.
@sebitokazu
sebitokazu marked this pull request as ready for review August 17, 2026 03:39
@sebitokazu
sebitokazu requested a review from ladvoc as a code owner August 17, 2026 03:39

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

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +1991 to +1998
// Both awaits below are unbounded, and `PeerTransport::close` is synchronous, so
// closing here — before the future can suspend — is what stops a cancelled
// `close()` leaving the ICE sockets bound for the process's lifetime. The
// signalling socket is unaffected, so the Leave below still goes out.
self.publisher_pc.close();
if let Some(ref sub_pc) = self.subscriber_pc {
sub_pc.close();
}

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.

🔴 Cancelling a room shutdown still leaves the media connections open

The media transports are still shut down (self.publisher_pc.close() at livekit/src/rtc_engine/rtc_session.rs:1995) only after the shutdown has already waited on four background workers, so abandoning a shutdown early still leaves them open.
Impact: A caller who bounds room teardown with a short timeout still leaks the room's network sockets for the lifetime of the process, and the new test that asserts otherwise fails.

Why the reorder does not reach the first suspension point

Room::closeRoomSession::close (livekit/src/room/mod.rs:1175-1183) → RtcEngine::closeEngineInner::close (livekit/src/rtc_engine/mod.rs:1052-1065) → RtcSession::close (livekit/src/rtc_engine/rtc_session.rs:797-811). RtcSession::close awaits handle.rtc_task, handle.signal_task, handle.dc_task and handle.dt_sender_task before calling self.inner.close(reason).await, which is where the new publisher_pc.close() lives. Those JoinHandle awaits return Pending on the first poll (the tasks have only just been signalled via close_tx, and on the default current-thread test runtime they cannot even run while the close future is being polled), and the workers themselves contain unbounded awaits.

Consequently the future's first suspension happens at handle.rtc_task.await, still with both peer connections open. livekit/tests/room_test.rs:114-122 cancels at exactly that first suspension (timeout(Duration::ZERO, room.close())) and then asserts PeerConnectionState::Closed, which cannot hold under this call chain.

To make cancellation actually safe, the transport close needs to happen before the task joins in RtcSession::close (or at the very top of the teardown chain), not merely before the signal-client awaits in SessionInner::close.

Prompt for agents
The PR moves publisher/subscriber PeerConnection close to the top of SessionInner::close so that a cancelled close() (e.g. wrapped in a timeout) still releases ICE sockets. However, SessionInner::close is only reached after RtcSession::close (livekit/src/rtc_engine/rtc_session.rs, around lines 797-811) awaits four JoinHandles (rtc_task, signal_task, dc_task, dt_sender_task), and EngineInner::close/RoomSession::close sit above that. Those joins are the real first suspension points of the teardown future, so a cancelled close still leaves the transports open — and the new test test_cancelled_close_still_closes_peer_connections in livekit/tests/room_test.rs, which cancels at the first suspension, should fail. Consider closing the transports synchronously before the task joins (e.g. at the start of RtcSession::close, keeping SessionInner::close idempotent) or otherwise ensuring the synchronous PeerTransport::close runs before any await in the whole teardown chain, and re-verify the test.
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.

Room::close() is not cancellation-safe — cancelling it leaks the room's ICE UDP sockets

1 participant