Skip to content

livekit-signaling: fix teardown gaps on requested close and response timeout - #1568

Merged
lukasIO merged 4 commits into
mainfrom
lukas/signaling-teardown-gaps
Nov 2, 2026
Merged

lukasIO merged 4 commits into
mainfrom
lukas/signaling-teardown-gaps

Conversation

@lukasIO

@lukasIO lukasIO commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

livekit-signaling has two teardown gaps that block its use from the Swift SDK over UniFFI (#1521). First, a requested close emits a spurious SignalEvent::Close("stream closed"). SignalClient::restart calls close(), which aborts the stream, so the old signal task reports the end of the stream as a lost link. A host that reads Close as "start a resume" then reconnects inside its own reconnect. Second, a join or reconnect response that fails or times out drops the fresh SignalStream without closing it. SignalStream has no Drop, so its read and write tasks keep the WebSocket open. Devin raised the second gap on #1521, and it was left out of scope there.

Change

  • If the lifecycle accepts the TransportFailed transition, the signal task emits Close. Otherwise it emits nothing. The lifecycle already refuses that transition after a requested close or restart, so a requested close stays silent. A remote close, a dropped link, and a ping timeout still emit Close, before the old stream closes.
  • If get_join_response or get_reconnect_response fails, a new helper, close_on_err, closes the stream. SignalInner::connect and SignalInner::restart both use it.
  • Two tests cover the fixes. If its fix is reverted, each test fails. an_unanswered_transport_is_released_on_timeout uses a mock connection that stays open and never answers, and checks that the join and reconnect timeouts both release it. close_is_reported_for_a_lost_transport_only checks that close and restart emit no event and that a dropped transport emits Close.
  • The livekit-signaling tokio dev-dependency enables test-util, so the 5 s timeout test runs on a paused clock.

The livekit engine already filters a Close that arrives after it closes the session (rtc_session.rs, SignalEvent::Close handler). It still gets Close on a real link loss, and no longer gets the spurious one during its own restart.

๐Ÿค– Generated with Claude Code

โ€ฆtimeout

A requested close (`close` or `restart`) aborted the stream, and the old signal
task then emitted `SignalEvent::Close("stream closed")`. A host that treats
Close as "start a resume" reconnected inside its own reconnect. The signal task
now emits Close only when the `TransportFailed` transition is accepted, which
the lifecycle already refuses after a requested close.

A join or reconnect response that failed or timed out dropped the fresh
`SignalStream` without closing it. `SignalStream` has no `Drop`, so its read
and write tasks kept the socket open. Both paths now close the stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lukasIO
lukasIO requested a review from ladvoc as a code owner October 23, 2026 07:54
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Changeset โœ“

This PR includes a changeset covering all affected packages:

Package Bump
livekit patch
livekit-api patch
livekit-capture patch
livekit-ffi patch
livekit-signaling patch

@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: 1 flag

Not posted on this PR by your GitHub settings โ€” view it in Devin Review. (Configure)

Devin Review

lukasIO and others added 2 commits October 23, 2026 12:16
Fixes issues with teardown gaps during requested close and response timeout in livekit-signaling.
`SimulateScenario::SignalReconnect` and `DisconnectSignalOnResume` closed the
signal client and relied on the resulting `SignalEvent::Close` to start the
resume. A requested close no longer emits that event, so the simulated loss
reports itself through `on_session_disconnected`, with the same arguments the
`Close` handler uses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lukasIO
lukasIO merged commit f445ce8 into main Nov 2, 2026
33 checks passed
@lukasIO
lukasIO deleted the lukas/signaling-teardown-gaps branch November 2, 2026 10:08
lukasIO added a commit that referenced this pull request Nov 2, 2026
#1589)

`SignalClient::close` cannot end a session for good. A closed client
still accepts `restart`, because `restart` and the engine's simulated
signal loss both close first and then resume. So a close that races a
resume loses: the resume reopens the session, and nobody holds the new
transport. The UniFFI wrapper from #1521 works around this with its own
token, checked before `send` and around `restart`. 1egoman suggested on
#1521 that the token belongs in `SignalClient`, as in the web SDK.
Stacked on #1568, which merges first.

## Change

`SignalOptions` has a new field, `cancellation_token`, owned by the
caller. Cancelling it ends the session for good:

- A connect in flight, including the region fallback, stops with the new
`SignalError::Cancelled`. A connect is not retried on other regions
after a cancel.
- `restart` refuses a cancelled token before it dials. A resume in
flight stops, and closes the transport it opened.
- The signal task closes the transport without emitting
`SignalEvent::Close`.
- `send` drops the request instead of holding it for a flush that never
runs. A send that the transport never completes gives up, because it
holds the lock that the close needs.

`close` does not change and still leaves the session resumable. The
default token is never cancelled, so callers that do not set it see no
change. Both the field and the variant are additions to
`#[non_exhaustive]` types.

The wrapper drops its checks around `restart` and its `CloseOnCancel`
guard. A cancelled `connect_signal_session` call now cancels the token
through a drop guard. Releasing a `SignalSession` only cancels the
token, and the signal task does the close. The wrapper keeps its check
in `send`, because `SignalClient::send` returns `()` and the wrapper
reports `SignalRequestError::Closed`.

The engine gives each `RtcSession` a fresh token, and cancels it in
`close` after the `Leave` goes out. The fresh token replaces any token
in `EngineOptions`, because a cancel from outside would end signalling
without the engine noticing. The engine ends with `close` instead.

## Failure case

A close lands while a resume is waiting on the server. Today the resume
completes and installs a transport that nobody closes. With this change
the resume stops with `Cancelled` and closes the transport it opened. If
the cancel lands just after the resume installed its transport,
`restart` still returns `Cancelled`, and the new signal task closes the
transport.

## Verified

- `cargo test -p livekit-signaling`: 51 passed. Three new tests:
- `cancel_interrupts_an_attempt_waiting_on_the_server` cancels a connect
and a resume that wait on a server that never answers. Both return
`Cancelled`, no connection stays open, and the connect makes no region
request.
- `cancel_closes_a_connected_session_for_good` cancels a connected
client. The signal task ends with the state `Closed` and emits no
`Close`. A later `restart` returns `Cancelled`, a later `send` is not
held, and the client is freed once dropped.
- `cancel_abandons_a_write_the_transport_never_completes` stalls a send
on a transport that never completes writes, then cancels. The send
returns and the signal task closes the transport. Without the fix, the
test fails after 5 s.
- `cargo test -p livekit-ffi --no-default-features --features
core-modules --lib signaling`: 9 passed.
`dropping_mid_restart_stops_the_resume` now asserts that the resume
never dials after the session is released.
- `cargo test -p livekit --features "__lk-e2e-test,native-tls" --test
reconnection_test --test engine_lifecycle_test` against `livekit-server
--dev`: 9 passed, including `test_signal_reconnect_resumes` and
`test_resume_failure_escalates_to_full_reconnect`.
- `cargo make android-package` from `livekit-ffi/`: the Kotlin bindings
compile.

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@knope-bot knope-bot Bot mentioned this pull request Nov 2, 2026
stephen-derosa pushed a commit that referenced this pull request Nov 4, 2026
> [!IMPORTANT]
> Merging this pull request will create these releases

# livekit-api 0.10.1 (2026-11-04)
## Fixes

- livekit-signaling: end a session for good through a cancellation token
- #1589 (@lukasIO)
- livekit-signaling: fix teardown gaps on requested close and response
timeout - #1568 (@lukasIO)
# livekit-signaling 0.1.9 (2026-11-04)
## Fixes

- Add `SignalOptions::cancellation_token`, which ends a signal session
for good: a connect or resume in flight stops with
`SignalError::Cancelled`, and the signal task closes the transport.
- A requested `close` or `restart` no longer emits `SignalEvent::Close`.
A join or reconnect response timeout now closes the WebSocket instead of
leaving it open.
# livekit 0.10.1 (2026-11-04)
## Fixes

- Pre-encoded video publishing no longer requires a local encoder for
its codec, so pre-encoded H265 works on hosts without an H265 encoder, a
camera track requesting a codec no local encoder produces now publishes
VP8 instead of a codec it cannot encode, and
`PeerConnectionFactory::video_encoder_codecs` reports codec support
using a non-exhaustive `libwebrtc::video_codec::VideoCodec` enum
supporting `FromStr`, with `Auto` excluding the pre-encoded pass-through
backend.
- Preserve distinct frame metadata when queued video frames are captured
within the same millisecond, including pre-encoded frames that share an
RTP timestamp.
- livekit-signaling: fix teardown gaps on requested close and response
timeout - #1568 (@lukasIO)
- `OfferOptions::offer_to_receive_audio` and `offer_to_receive_video`
are now `Option<bool>`. `None` leaves libwebrtc's option unset, so
receive-only transceivers stay receive-only. The default is still
`Some(false)`. Single peer connection offers leave both unset and no
longer rewrite `a=inactive` to `a=recvonly`.
- Space reconnect attempts on the same schedule as livekit-client's
`DefaultReconnectPolicy` (0.3 s, 1.2 s, 2.7 s, 4.8 s, then 7 s, plus up
to 1 s of jitter) instead of full jitter, which could spend all ten
attempts within a few seconds of outage. The jitter now comes from a
randomly keyed hash rather than the clock's sub-second digits, which are
all zero on macOS. No new reconnect attempt starts after
`RECONNECT_MAX_DURATION` (60 s), so slow failing attempts cannot keep a
room in `Reconnecting` for minutes. `RECONNECT_BACKOFF_MULTIPLIER` is
deprecated: the schedule is quadratic in the attempt number.
- Add `SignalOptions::cancellation_token`, which ends a signal session
for good: a connect or resume in flight stops with
`SignalError::Cancelled`, and the signal task closes the transport.
- A requested `close` or `restart` no longer emits `SignalEvent::Close`.
A join or reconnect response timeout now closes the WebSocket instead of
leaving it open.
- A received transcription with an invalid track sid is now dropped with
a warning. Before, it made the client panic, which aborts the process in
release builds.
# livekit-ffi 0.13.4 (2026-11-04)
## Fixes

- Pre-encoded video publishing no longer requires a local encoder for
its codec, so pre-encoded H265 works on hosts without an H265 encoder, a
camera track requesting a codec no local encoder produces now publishes
VP8 instead of a codec it cannot encode, and
`PeerConnectionFactory::video_encoder_codecs` reports codec support
using a non-exhaustive `libwebrtc::video_codec::VideoCodec` enum
supporting `FromStr`, with `Auto` excluding the pre-encoded pass-through
backend.
- Re-baseline the iOS and Android binary size budgets for the signaling
client, which blocked publishing the 0.13.3 Swift and Android packages.
- A failed FFI request no longer writes the full request to the error
log or to the Node error message, so E2EE keys in `SetSharedKey` and
`SetKey` requests stay out of logs.
- Preserve distinct frame metadata when queued video frames are captured
within the same millisecond, including pre-encoded frames that share an
RTP timestamp.
- Build the shipped staticlib and cdylib with LTO, shrinking the
core-modules library by ~17%
- livekit-signaling: fix teardown gaps on requested close and response
timeout - #1568 (@lukasIO)
- `OfferOptions::offer_to_receive_audio` and `offer_to_receive_video`
are now `Option<bool>`. `None` leaves libwebrtc's option unset, so
receive-only transceivers stay receive-only. The default is still
`Some(false)`. Single peer connection offers leave both unset and no
longer rewrite `a=inactive` to `a=recvonly`.
- Space reconnect attempts on the same schedule as livekit-client's
`DefaultReconnectPolicy` (0.3 s, 1.2 s, 2.7 s, 4.8 s, then 7 s, plus up
to 1 s of jitter) instead of full jitter, which could spend all ten
attempts within a few seconds of outage. The jitter now comes from a
randomly keyed hash rather than the clock's sub-second digits, which are
all zero on macOS. No new reconnect attempt starts after
`RECONNECT_MAX_DURATION` (60 s), so slow failing attempts cannot keep a
room in `Reconnecting` for minutes. `RECONNECT_BACKOFF_MULTIPLIER` is
deprecated: the schedule is quadratic in the attempt number.
- Add `SignalOptions::cancellation_token`, which ends a signal session
for good: a connect or resume in flight stops with
`SignalError::Cancelled`, and the signal task closes the transport.
- A received transcription with an invalid track sid is now dropped with
a warning. Before, it made the client panic, which aborts the process in
release builds.
# livekit-capture 0.1.9 (2026-11-04)
## Fixes

- Pre-encoded video publishing no longer requires a local encoder for
its codec, so pre-encoded H265 works on hosts without an H265 encoder, a
camera track requesting a codec no local encoder produces now publishes
VP8 instead of a codec it cannot encode, and
`PeerConnectionFactory::video_encoder_codecs` reports codec support
using a non-exhaustive `libwebrtc::video_codec::VideoCodec` enum
supporting `FromStr`, with `Auto` excluding the pre-encoded pass-through
backend.
- Drop a transcription with an invalid track sid instead of panicking -
#1600 (@lukasIO)
- Preserve distinct frame metadata when queued video frames are captured
within the same millisecond, including pre-encoded frames that share an
RTP timestamp.
- livekit-signaling: end a session for good through a cancellation token
- #1589 (@lukasIO)
- livekit-signaling: fix teardown gaps on requested close and response
timeout - #1568 (@lukasIO)
- `OfferOptions::offer_to_receive_audio` and `offer_to_receive_video`
are now `Option<bool>`. `None` leaves libwebrtc's option unset, so
receive-only transceivers stay receive-only. The default is still
`Some(false)`. Single peer connection offers leave both unset and no
longer rewrite `a=inactive` to `a=recvonly`.
- Space reconnect attempts on the same schedule as livekit-client's
`DefaultReconnectPolicy` (0.3 s, 1.2 s, 2.7 s, 4.8 s, then 7 s, plus up
to 1 s of jitter) instead of full jitter, which could spend all ten
attempts within a few seconds of outage. The jitter now comes from a
randomly keyed hash rather than the clock's sub-second digits, which are
all zero on macOS. No new reconnect attempt starts after
`RECONNECT_MAX_DURATION` (60 s), so slow failing attempts cannot keep a
room in `Reconnecting` for minutes. `RECONNECT_BACKOFF_MULTIPLIER` is
deprecated: the schedule is quadratic in the attempt number.
# webrtc-sys 0.3.53 (2026-11-04)
## Fixes

- Pre-encoded video publishing no longer requires a local encoder for
its codec, so pre-encoded H265 works on hosts without an H265 encoder, a
camera track requesting a codec no local encoder produces now publishes
VP8 instead of a codec it cannot encode, and
`PeerConnectionFactory::video_encoder_codecs` reports codec support
using a non-exhaustive `libwebrtc::video_codec::VideoCodec` enum
supporting `FromStr`, with `Auto` excluding the pre-encoded pass-through
backend.
- Preserve distinct frame metadata when queued video frames are captured
within the same millisecond, including pre-encoded frames that share an
RTP timestamp.
# libwebrtc 0.4.0 (2026-11-04)
## Breaking Changes

- `OfferOptions::offer_to_receive_audio` and `offer_to_receive_video`
are now `Option<bool>`. `None` leaves libwebrtc's option unset, so
receive-only transceivers stay receive-only. The default is still
`Some(false)`. Single peer connection offers leave both unset and no
longer rewrite `a=inactive` to `a=recvonly`.

## Features

- Pre-encoded video publishing no longer requires a local encoder for
its codec, so pre-encoded H265 works on hosts without an H265 encoder, a
camera track requesting a codec no local encoder produces now publishes
VP8 instead of a codec it cannot encode, and
`PeerConnectionFactory::video_encoder_codecs` reports codec support
using a non-exhaustive `libwebrtc::video_codec::VideoCodec` enum
supporting `FromStr`, with `Auto` excluding the pre-encoded pass-through
backend.

## Fixes

- Preserve distinct frame metadata when queued video frames are captured
within the same millisecond, including pre-encoded frames that share an
RTP timestamp.

Co-authored-by: knope-bot[bot] <152252888+knope-bot[bot]@users.noreply.github.com>
JesseDietrichson added a commit to RootPlatform/livekit-rust-sdks that referenced this pull request Nov 6, 2026
Upstream since livekit-ffi/v0.13.1: 35 commits, through livekit-ffi v0.13.4
(ab3711a). Among them: libwebrtc webrtc-89d790b -> webrtc-a545de4 without the
roots behind the compromised eMudhra intermediates, and the first prebuilt
built with the fixed external_audio_source.patch (livekit#1562); livekit-client's
reconnect backoff, at least ~44 s with a 60 s cap (livekit#1457); signaling teardown
without a spurious Close and without WebSockets left open after a failed join
or resume, plus a per-session cancellation token (livekit#1568, livekit#1589); publisher
senders detached before the PeerConnection closes (livekit#1570); a transcription
with an invalid track sid dropped instead of aborting the process (livekit#1600);
protocol 18 with negotiated data channels in single PC mode (livekit#1527); single PC
offers without offer_to_receive and without the inactive->recvonly munge
(livekit#1610); NVENC/Jetson delivered frames no longer reported as encode errors
(livekit#1551); per-frame VAAPI parameter buffers destroyed (livekit#1524); pass-through
codecs advertised and publish codec selection (livekit#1451); presentation
timestamps kept by the encoder wrapper (livekit#1607); capture JoinCapture (livekit#1547)
and use_legacy_data_streams removed (livekit#1575); rustls 0.23.46 and zip 9.0.5.

Conflicts, all textual:

- livekit/src/room/options.rs: the test module imports both sides' helpers.
- peer_transport.rs: create_initial_offer's comment takes upstream's first
  sentence; its x-google-start-bitrate paragraph stays out, since the fork
  never took that munging.
- peer_connection_factory.h: both sides' forward declarations.
- video_encoder_factory.cpp: both sides' includes.

Reviewed where both sides meet without a conflict: create_sender keeps the
fork's stereo and external-audio hooks next to livekit#1451's producible-codec
filter (the H.264 publishes are unchanged: OpenH264 is in every prebuilt);
close() detaches senders through the fork's remove_track; the encoder factory
keeps the MF backend and the per-codec de-dup after livekit#1451's pass-through
formats; livekit#1607's wrapper fits the MF encoder, which delivers on the encoder
queue and whose Release() is idempotent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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