Skip to content

fix: lower timeout for validating connection errors and guard against locking - #2148

Merged
lukasIO merged 6 commits into
mainfrom
lukas/fix-signal-lock
Oct 14, 2026
Merged

lukasIO merged 6 commits into
mainfrom
lukas/fix-signal-lock

Conversation

@lukasIO

@lukasIO lukasIO commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

SignalClient.connect() uses connectionLock to run one connect attempt at a time. An attempt releases the lock when its executor finishes, not when the caller gets the result. When the websocket fails to open, the attempt asks the validate endpoint why, and it waits for that fetch with no deadline. A timed-out attempt already reports its failure to the engine, but it keeps the lock until the fetch settles. On a network that drops packets without a reset, the fetch can hang for minutes. A pooled connection that went stale during a short Wi-Fi outage is one example. The next reconnect attempt waits on the lock with no deadline. The engine still counts a reconnect as in progress, so it ignores other triggers and never asks the reconnect policy again. The room stays in SignalReconnecting and never emits Disconnected.

A second path leaks the lock for good. connect() takes the lock and then builds the join request and the URLs outside the try/finally that releases it. A malformed URL makes new URL() throw, so every later join() or reconnect() hangs.

This change gives the validate fetch a 3 s abort signal. A timeout becomes the existing ServerUnreachable error, so RTCEngine.join retries it like any other unreachable server. The setup code before the executor releases the lock if it throws, then re-throws the error.

The failure case is a validate endpoint that answers after more than 3 s. The attempt cannot classify the websocket error, so a 401 or a missing room is reported as ServerUnreachable. The engine retries instead of stopping. This affects only the path where the websocket fails on its own. When the connect timeout fires, the caller already has a Timeout error and the validate result is discarded.

Impact

A user reported a room stuck in SignalReconnecting for more than 7 minutes after a short Wi-Fi outage. The network came back after about 1 minute. The room never retried and never emitted Disconnected, and only room.disconnect() ended it. With this change, a failed attempt holds the lock for at most 3 s after its websocket closes, and the next attempt then runs under the normal connect timeout. The new test does not let a timed-out attempt block the next one reproduces the report: on main the second attempt never opens a websocket. A second test covers the malformed-URL leak.

@changeset-bot

changeset-bot Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ee591cf

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
livekit-client Patch

Not sure what this means? Click here to learn what changesets are.

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

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

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

🔍 Devin Review: 1 flag

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

Devin Review

@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
dist/livekit-client.esm.mjs 113.02 KB (+0.16% 🔺)
dist/livekit-client.umd.js 122.1 KB (+0.05% 🔺)

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

it looks good to me regarding the deadlock issue.
two questions

Comment thread src/api/SignalClient.ts
@@ -434,15 +435,23 @@ export class SignalClient {
): Promise<JoinResponse | ReconnectResponse | undefined> {
const unlock = await this.connectionLock.lock();

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.

just found out Connect() and close() are using different locks, why is it ?

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.

They guard different things, and one shared lock would deadlock. connectionLock makes connect attempts run one at a time. closingLock makes teardowns run one at a time, and it lets a new join()/reconnect() wait for a close that is still running (settleInFlightClose).

connect() calls close() while it holds connectionLock. Examples are the abort handler (abort signal or connect timeout) and the missing-first-message path. In the timeout case, the attempt is waiting on ws.opened. Only close() makes opened settle, because it closes the socket. If close() needed connectionLock, it would wait for the attempt to finish, and the attempt would wait for close(). A shared lock would also make room.disconnect() wait out a stalled connect instead of cancelling it.

Comment thread src/api/SignalClient.ts Outdated

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.

question

from what I am seeing, close take two parans, reason is the second one:
async close(updateState: boolean = true, reason = 'Close method called on signal client') {
// when the lifecycle is already shutting down (or another close owns it), only the transport
// teardown below still applies — it is idempotent, so callers can always await a close
const drivesLifecycle = updateState && this.sendLifecycleInput({ type: 'close', reason });
const unlock = await this.closingLock.lock();
try {
await this.teardownTransport(reason);
} finally {
if (drivesLifecycle) {
this.sendLifecycleInput({ type: 'closeComplete' });
}
unlock();
}
}

Or I miss something here ?

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.

Good catch, this is a bug. this.close(reason) passes the reason as updateState. The string is truthy, so the lifecycle still closes, but reason falls back to the default and the abort reason is lost. TypeScript does not catch it: getAbortReasonAsString returns signal.reason, which is any, so the inferred return type is any. The bug predates this PR. I will fix it here with this.close(true, reason), pass the reason in the two other close() calls in the abort handler too, and give the helper an explicit : string return type so the compiler flags calls like this.

close(reason) passed the reason as updateState, so the abort reason was
dropped. getAbortReasonAsString inferred `any` from signal.reason, which
hid the mistake from the compiler; it now declares a string return type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 new potential issue.

1 flag not posted on this PR by your GitHub settings — view it in Devin Review. (Configure)

Devin Review

Comment thread src/api/SignalClient.ts
});
} else {
this.close();
this.close(true, reason);

@devin-ai-integration devin-ai-integration Bot Oct 14, 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.

🔴 Long abort reasons leave sockets open

When join receives an abort reason exceeding 123 UTF-8 bytes, close passes it to WebSocket.close, which throws. teardownTransport catches that error before clearing the socket, leaving the aborted transport open.

Learn more

An AbortSignal supplied to join can carry an arbitrary string reason. getAbortReasonAsString preserves that string, and teardownTransport passes it directly to the browser's WebSocket.close. WebSocket close reasons have a 123-byte UTF-8 limit. An oversized reason throws before teardownTransport clears ws, and the catch swallows the error without closing the socket. The same oversized reason can reach the send-leave success and failure branches.

Example: A caller aborts a pending join with 'x'.repeat(124). The join rejects as cancelled, but closing the socket throws, so the pending transport remains attached to the client.

Recommended fix: Keep the full abort reason for diagnostics and the rejected error, but pass a bounded WebSocket close reason to close in every abort branch. Truncate by UTF-8 byte length, or use a fixed short close reason.

Devin Review


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

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.

addressed in 7a92a8a

WebSocket.close throws a SyntaxError for a reason over 123 UTF-8 bytes.
The connect abort handler passes the caller's abort reason, so a long one
made teardownTransport throw before it released the socket, leaving the
aborted transport open. WebSocketStream.close truncates the reason on code
point boundaries, and teardownTransport releases the transport before it
closes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@lukasIO
lukasIO merged commit fb8e5b3 into main Oct 14, 2026
6 checks passed
@lukasIO
lukasIO deleted the lukas/fix-signal-lock branch October 14, 2026 12:27
@github-actions github-actions Bot mentioned this pull request Oct 14, 2026
1egoman added a commit to horvatz/client-sdk-js that referenced this pull request Oct 14, 2026
Since an abandoned connect skips the validate request, the hanging-
validate regression test from livekit#2148 no longer consumes its queued
fetch mocks, which leaked into the handleConnectionError tests. Assert
that no validation happens and reset the mocks afterwards.

Co-Authored-By: Claude Opus 5.5 (1M context) <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