Skip to content
5 changes: 5 additions & 0 deletions .changeset/fix-connectws-timeout-socket-leak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents': patch
---

Fix orphaned WebSocket leak in `connectWs`: when the connection timeout fires, the socket is now terminated so it cannot connect and linger without an owner. Also fixes a hang where a normal (code 1000) close during the handshake left the promise unsettled — it now rejects on any close before the socket opens. Uses `APITimeoutError` instead of `APIConnectionError` for clearer retry semantics.
12 changes: 8 additions & 4 deletions agents/src/inference/utils.ts

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.

🚩 Pattern alignment with ws_transport.ts

This change aligns connectWs with the established pattern in agents/src/inference/interruption/ws_transport.ts, which already calls ws.terminate() before rejecting with APITimeoutError on connection timeout. The ws_transport.ts version also terminates on error events (not just timeout), which connectWs does not do — the onError and onClose handlers here don't call terminate(). This is a pre-existing inconsistency that could leave sockets lingering after error/close events if the caller doesn't clean up, though in practice the ConnectionPool manages the socket lifecycle.

(Refers to lines 102-129)

Open in Devin Review

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

During the handshake, ws self-cleans on error and close. All the connection-phase error paths (lines 729, 886, 912) funnel through emitErrorAndClose (line 1039):

  function emitErrorAndClose(websocket, err) {
    websocket._readyState = WebSocket.CLOSING;
    websocket._errorEmitted = true;
    websocket.emit('error', err);   // <-- our onError runs here
    websocket.emitClose();
  }

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { ThrowsPromise } from '@livekit/throws-transformer/throws';
import { AccessToken } from 'livekit-server-sdk';
import { WebSocket } from 'ws';
import { APIConnectionError, APIStatusError } from '../_exceptions.js';
import { APIConnectionError, APIStatusError, APITimeoutError } from '../_exceptions.js';
import { getJobContext } from '../job.js';
import { version } from '../version.js';

Expand Down Expand Up @@ -90,12 +90,16 @@ export async function connectWs(
return new ThrowsPromise<WebSocket, APIConnectionError | APIStatusError>((resolve, reject) => {
const socket = new WebSocket(url, { headers: { ...buildMetadataHeaders(), ...headers } });

let opened = false;

const timeout = setTimeout(() => {
reject(new APIConnectionError({ message: 'Timeout connecting to LiveKit WebSocket' }));
socket.terminate();
reject(new APITimeoutError({ message: 'Timeout connecting to LiveKit WebSocket' }));
}, timeoutMs);

const onOpen = () => {
clearTimeout(timeout);
opened = true;
resolve(socket);
};

Expand All @@ -113,9 +117,9 @@ export async function connectWs(
}
};

const onClose = (code: number) => {
const onClose = () => {
clearTimeout(timeout);
if (code !== 1000) {
if (!opened) {
reject(
new APIConnectionError({
message: 'Connection closed unexpectedly',
Expand Down
Loading