Skip to content

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

Merged
lukasIO merged 13 commits into
mainfrom
lukas/signal-state-machine
Sep 24, 2026
Merged

lukasIO merged 13 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: Connecting, 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 lock is now always taken under the state lock, which fixes the lock order: the old code took the queue and stream locks in both orders, and with tokio's fair RwLock that could deadlock against a pending restart writer. The lock stays held while a batch drains, so a concurrent Connected send waits behind older held signals instead of interleaving with them; a test drives two senders against a held batch and asserts the batch reaches the wire first. 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-capture 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[bot]

This comment was marked as resolved.

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

Comment thread livekit-signaling/src/lib.rs Outdated
Comment thread livekit-signaling/src/lib.rs
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, agree, I would like to move into that direction eventually.
I think it should be rather easy to transition (pun intended) to that design later on once we also represent the PC connection as more of a state machine pattern?

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.

Makes sense, cool, let's revisit it later then.

Comment thread .changeset/signal_state_enum.md Outdated
@lukasIO

lukasIO commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Was this expected to be part of a follow up change or did I miss it in here somewhere?

yeah, that's intended as follow up work

lukasIO and others added 2 commits September 11, 2026 08:50
The machine now starts in Connecting, which is also its Default, so the
transition table can use mem::take instead of a placeholder. The
constructor drives the first transition before there is a client to
hold the state: ConnectComplete(stream) lands in Connected, and any
failure, transport or missing JoinResponse, lands in Closed and is
dropped with the error.

The v1/v0 transport selection moves out of connect into open_transport
so the constructor reads as: open, join, transition, build. Behaviour
is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

lukasIO and others added 3 commits September 11, 2026 08:58
flush_queue took the whole queue out under a sync lock and then sent
each signal with the lock released. The state read lock is shared, so
a second Connected sender could see an empty queue and put a newer
signal on the wire between two held ones: Mute(true), Mute(false) held,
then a concurrent Mute(true) landed before the held Mute(false), and
the server kept the older unmute.

The queue is an async mutex again, always taken under the state lock
so the order is fixed, and held for the whole drain so a concurrent
send waits behind the batch. This restores the gate main had before
the state machine change dropped it.

SignalStream::spawn splits the task setup out of connect so a test can
drive a connection that stays alive. The new test spawns two senders
against a held batch and asserts the batch reaches the wire first; on
the old drain it fails with ["held-1", "b", "held-2", "a"].

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@lukasIO
lukasIO merged commit d66d86f into main Sep 24, 2026
33 checks passed
@lukasIO
lukasIO deleted the lukas/signal-state-machine branch September 24, 2026 13:05
@knope-bot knope-bot Bot mentioned this pull request Sep 24, 2026
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