Skip to content

chore: initial state machine plumbing - #2046

Open
lukasIO wants to merge 6 commits into
mainfrom
lukas/signal-client-sm
Open

chore: initial state machine plumbing#2046
lukasIO wants to merge 6 commits into
mainfrom
lukas/signal-client-sm

Conversation

@lukasIO

@lukasIO lukasIO commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 8173a54

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
dist/livekit-client.esm.mjs 109.08 KB (+3.38% 🔺)
dist/livekit-client.umd.js 118.27 KB (+3.08% 🔺)

@lukasIO
lukasIO marked this pull request as ready for review August 18, 2026 16:28

@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 4 additional findings in Devin Review.

Open in Devin Review

Comment thread src/api/SignalClient.ts
Comment on lines +1114 to +1118
this.sendLifecycleInput(
this.lifecycleState === 'reconnecting'
? { type: 'reconnectComplete' }
: { type: 'connectComplete' },
);

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.

🟡 A signal connection that finishes after the client gave up still starts a heartbeat timer that never stops

The result of moving the connection lifecycle forward is discarded (sendLifecycleInput(...) at src/api/SignalClient.ts:1114-1118) before the heartbeat timer and message reader are started, so a connection that completes after the client already gave up leaves a repeating timer running forever.
Impact: A cancelled or failed connect can leave a permanently repeating background timer and a stray message reader behind for the lifetime of the page.

Ignored lifecycle transition still starts pings and the read loop

handleSignalConnected picks reconnectComplete vs connectComplete from the current lifecycle state, but never checks whether the machine accepted the input. Both inputs are only handled in connecting/reconnecting (src/api/SignalClientStateMachine.ts:80-118), so if the lifecycle has already left those states the transition is a no-op while lines 1119-1122 still run.

Reachable path: Room.disconnect() aborts a pending connect (src/room/Room.ts:1157), the abort handler inside connect() rejects the promise (src/api/SignalClient.ts:434) while the body is still awaiting the first message read, and join()'s catch drives the machine to closed via connectFailed (src/api/SignalClient.ts:348). If the first message then arrives, handleSignalConnected runs with lifecycleState === 'closed': connectComplete is dropped, yet startPingInterval() arms a CriticalTimers.setInterval that is only ever cleared by teardownTransport()/close() — which have already run — and startReadingLoop begins dispatching signal messages (participant updates, leave, etc.) into the callbacks of an abandoned session.

Guarding on the boolean that sendLifecycleInput already returns keeps the transport work tied to an accepted transition.

Suggested change
this.sendLifecycleInput(
this.lifecycleState === 'reconnecting'
? { type: 'reconnectComplete' }
: { type: 'connectComplete' },
);
const accepted = this.sendLifecycleInput(
this.lifecycleState === 'reconnecting'
? { type: 'reconnectComplete' }
: { type: 'connectComplete' },
);
if (!accepted) {
// the lifecycle has moved on (closed or replaced by a newer attempt): don't attach pings
// or a reading loop to a connection nobody owns anymore
this.log.debug('signal connected after the attempt was abandoned, ignoring');
clearTimeout(timeoutHandle);
return;
}
Open in 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.

This generally looks like a great start to me!

One thing I would find really helpful would be some sort of way to visualize the state machine. If there isn't a tool already which can read the machina state machine and generate a mermaid / graphviz / etc type visualization from it, I suspect this would be pretty easy to add. I'm thinking some sort of small script which could import in the SignalClient (and in the future, other state machines all combined together), convert to mermaid or graphviz, then generate a big svg. This is also something a LLM could use to generate diagrams for describing potential changes.

The dream / probably a stretch goal would be some way for the example app to be able to show the state machine live so as you test out scenarios you can actually watch the "active" state propagate through the state machine interactively. At a past job I did this for a large distributed state machine which was not too dissimilar from livekit's domain and it proved invaluable both for engineering understanding and explaining failure modes to less technical folks.

Comment thread src/api/SignalClient.ts
* Public projection of the lifecycle machine's states. `new`, `offline` and `closed` are all
* reported as `DISCONNECTED`: they differ in what may happen next
*/
const lifecycleToConnectionState: Record<SignalLifecycleState, SignalConnectionState> = {

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

nitpick: should this constant be in SCREAMING_SNAKE_CASE? There's a few other constants as well like signalStates which I think this also applies to.

Suggested change
const lifecycleToConnectionState: Record<SignalLifecycleState, SignalConnectionState> = {
const LIFECYCLE_TO_CONNECTION_STATE: Record<SignalLifecycleState, SignalConnectionState> = {

Comment on lines +73 to +83

const signalStates = {
new: {
connect: startConnect,
reconnect: startReconnect,
close: requestClose,
},
connecting: {
connect: startConnect,
reconnect: startReconnect,
connectComplete: 'connected',

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: you may consider breaking out some of these repeated states instead into some more "component level" objects which can be spread in rather than breaking out hte functions individually. As is I find myself going back and forth a lot between function and state transition map when reasoning about this.

Suggested change
const signalStates = {
new: {
connect: startConnect,
reconnect: startReconnect,
close: requestClose,
},
connecting: {
connect: startConnect,
reconnect: startReconnect,
connectComplete: 'connected',
const common = {
connect: on<'connect'>(({ ctx }) => {
ctx.attemptId += 1;
ctx.lastError = undefined;
return 'connecting';
}),
reconnect: on<'reconnect'>(({ ctx }) => {
ctx.attemptId += 1;
ctx.lastError = undefined;
return 'reconnecting';
}),
close: on<'close'>(({ ctx }, event) => {
ctx.closeReason = event.reason;
return 'disconnecting';
}),
};
const signalStates = {
new: common,
connecting: {
...common, // etc
connectComplete: 'connected',

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