Skip to content

refactor(signaling): explicit signal lifecycle state machine - #1402

Open
lukasIO wants to merge 6 commits into
mainfrom
lukas/signal-state-machine
Open

refactor(signaling): explicit signal lifecycle state machine#1402
lukasIO wants to merge 6 commits into
mainfrom
lukas/signal-state-machine

Conversation

@lukasIO

@lukasIO lukasIO commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

SignalInner tracked its lifecycle in two fields, a stream slot and a reconnecting AtomicBool, that had to be kept in step by hand across restart, set_reconnected, close and send. It now holds one SignalState that owns the transport: Connected, Reconnecting, Offline, Disconnecting, Closed.

Every change goes through SignalState::transition, one match that is the whole table and also says which transport each move releases. An input a state does not accept is logged and refused; a resume from a state that cannot accept it fails with SignalError::InvalidState. The resume gate stays a state until the engine confirms, a stale transport cannot report in because SignalClient::restart awaits the old task first, and ReconnectFailed always lands in Offline.

The held-signal queue moves to a sync parking_lot Mutex that is never held across an await. The old async queue lock was taken in both orders relative to the stream lock, which with tokio's fair RwLock could deadlock against a pending restart writer. A send that fails with any transport error is now held like a SendError was.

SignalInner tracked its lifecycle in two fields, a stream slot and a
`reconnecting` AtomicBool, that had to be kept in step by hand across
restart, set_reconnected, close and send. It now holds one SignalState
that owns the transport: Connected, Reconnecting, Offline,
Disconnecting, Closed.

Every change goes through SignalState::transition, one match that is
the whole table and also says which transport each move releases. An
input a state does not accept is logged and refused; a resume from a
state that cannot accept it fails with SignalError::InvalidState. The
resume gate stays a state until the engine confirms, a stale transport
cannot report in because SignalClient::restart awaits the old task
first, and ReconnectFailed always lands in Offline.

The held-signal queue moves to a sync parking_lot Mutex that is never
held across an await. The old async queue lock was taken in both orders
relative to the stream lock, which with tokio's fair RwLock could
deadlock against a pending restart writer. A send that fails with any
transport error is now held like a SendError was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Changeset ✓

This PR includes a changeset covering all affected packages:

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

@lukasIO
lukasIO marked this pull request as ready for review September 9, 2026 09:16
@lukasIO
lukasIO requested a review from ladvoc as a code owner September 9, 2026 09:16
@lukasIO
lukasIO requested a review from 1egoman September 9, 2026 09:16

@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 3 potential issues.

2 flags not posted on this PR by your GitHub settings — view them in Devin Review. (Configure)

Devin Review

Comment on lines +753 to +756
let queued = std::mem::take(&mut *self.queue.lock());
for signal in queued {
if let Err(err) = stream.send(signal).await {
log::error!("failed to send queued signal: {}", err); // Lost message

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.

🔴 Queued sends vanish after failure

When a queued send fails, flush_queue discards that request and every remaining drained request. A transient transport failure loses all held session changes.

Prompt for agents
In livekit-signaling/src/lib.rs, make SignalInner::flush_queue preserve unsent messages when SignalStream::send returns an error. The queue is removed wholesale with mem::take, so the failed item and the untouched suffix currently disappear. Restore the failed item and remaining suffix in original order, ahead of messages concurrently added through hold_or_drop. Stop sending once the transport fails. Add a test with multiple queued signals and a stream that fails partway through, then verify a later successful flush sends every unsent signal exactly once and in FIFO order.
Devin Review

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

Comment on lines 665 to 668
let (new_stream, mut events) =
SignalStream::connect(lk_url, &token, self.options.connect_timeout).await?;
let reconnect_response = get_reconnect_response(&mut events).await?;
SignalResult::Ok((new_stream, reconnect_response, events))

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.

🔴 Reconnect failures leak live transports

If get_reconnect_response fails, new_stream drops without closing its transport. The read task keeps the writer and socket alive across retries.

Prompt for agents
In SignalInner::restart in livekit-signaling/src/lib.rs, ensure every successfully created SignalStream becomes owned by lifecycle state immediately or is explicitly closed on every failure and cancellation path. Currently a timeout, closed response channel, parse error, or LeaveRequest from get_reconnect_response drops new_stream without calling SignalStream::close. SignalStream's read task retains an internal_tx clone, so dropping the struct alone does not terminate the write task or transport. Restructure the reconnect attempt with an ownership guard or an intermediate state that guarantees cleanup, and add a test where the server keeps the reconnect WebSocket open but never returns ReconnectResponse.
Devin Review

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

Comment on lines +643 to +646
let mut state = self.state.write().await;
let old_stream = state.transition(SignalInput::Reconnect).map_err(|_| {
SignalError::InvalidState(format!("resume the signal session from {state:?}"))
})?;

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 reconnects block every retry

If restart is cancelled after Reconnect, the state remains Reconnecting(None). Every later retry returns InvalidState, leaving the client unable to reconnect.

Prompt for agents
Make SignalClient::restart and SignalInner::restart cancellation-safe. Once the state transitions to Reconnecting(None), dropping the future must restore a retryable state and clean up any old or newly opened transport. The close phase has the same issue after entering Disconnecting. Consider an RAII transition guard or redesigning the lifecycle operation as an owned task whose cleanup completes independently of the caller future. Add tests that poll restart into the close and connect phases, cancel it, and verify a subsequent restart can proceed.
Devin Review

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

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

Generally makes sense to me. I focused more on the infrastructure and general approach, and less on the exact states and all the exact transitions between them.

One thing missing which I was expecting to see: some sort of mechanism that a visualizer could subscribe to state changes and use them to build a visualization. Was this expected to be part of a follow up change or did I miss it in here somewhere?

use SignalInput as In;
use SignalState::*;
// `Closed` is a placeholder while the old state is moved out
let (next_state, released_stream) = match (std::mem::replace(self, Closed), input) {

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.

nitpick: I brought this up in our 1:1 but it might be slightly cleaner if SignalState::Closed was the Default value for SignalState and then this could become std::mem::take(self) given the replaced value is bogus anyway.

Suggested change
let (next_state, released_stream) = match (std::mem::replace(self, Closed), input) {
let (next_state, released_stream) = match (std::mem::take(self), input) {

Comment on lines 634 to +636
///
/// Leaves `reconnecting=true` on success — the engine is expected to call
/// [`Self::set_reconnected`] once the full resume has succeeded. On failure
/// resets `reconnecting=false` so subsequent retries can re-enter cleanly.
/// The stream slot is held under a write lock for the entire close + new
/// connect, so concurrent senders block on the read side until the new
/// stream is in place.
/// The write lock is held for the entire close + connect, so concurrent senders wait on
/// the read side and land on the new transport instead of in a transport-less gap.

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.

thought: This is a pretty clever way to prioritize locks during the close and connect phases. That being said I also think it is potentially somewhat unintuitive. Maybe it makes sense to move this comment to the AsyncRwLock field definition in the struct?

Comment on lines 696 to +706
pub async fn close(&self, notify_close: bool) {
if let Some(stream) = self.stream.write().await.take() {
// Already closing or closed: whoever owns that close finishes it.
let Ok(stream) = self.state.write().await.transition(SignalInput::Close) else {
return;
};
if let Some(stream) = stream {
stream.close(notify_close).await;
}
let _ = self.state.write().await.transition(SignalInput::CloseComplete);
}

@1egoman 1egoman Sep 9, 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.

thought: I'm not sure about this idea or not, but I wonder if there would be benefit to driving the state machine side effects by subscribing to a stream of state changes from the state machine, not just running the side effects alongside at every .transition(...) call sizte. What this would look like is some handler consuming the stream of state changes and that handler containing a big match not unlike how .transition(...) works today.

I will say one nice thing about that is it means that places which all issue the same .transition(...) call would be guaranteed to run the same side effects, which could be nice.

Comment on lines +1 to +9
---
livekit-signaling: patch
---

# Model the signal connection lifecycle as an explicit state machine

`SignalInner` tracked its lifecycle in two fields, a stream slot and a `reconnecting` flag,
that had to be kept in step by hand. It now holds one `SignalState` that owns the transport
(`Connected`, `Reconnecting`, `Offline`, `Disconnecting`, `Closed`). Every change goes through one

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.

nitpick: this file and .changeset/refactor_signaling_explicit_signal_lifecycle_state_machine.md look to be semantically identical?

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