From 5b441248b85cb47f3e525767c331b6d2af64ef15 Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Fri, 12 Jun 2026 15:13:11 +0100 Subject: [PATCH 1/8] fix: terminate WebSocket and remove listeners on connectWs timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the connection timeout fired, the socket was never terminated — if it later connected, resolve() was called on an already-settled promise, leaving an orphaned WebSocket with no owner. The fix calls socket.terminate() and strips the pending listeners in the timeout handler via a shared cleanup() helper (also used by onOpen/onError/ onClose to prevent cross-path double-firing). Uses APITimeoutError instead of the generic APIConnectionError for clearer retry semantics. Co-Authored-By: Claude Sonnet 4.6 --- agents/src/inference/utils.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/agents/src/inference/utils.ts b/agents/src/inference/utils.ts index 8a9b8319e..695211f3a 100644 --- a/agents/src/inference/utils.ts +++ b/agents/src/inference/utils.ts @@ -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'; @@ -90,17 +90,26 @@ export async function connectWs( return new ThrowsPromise((resolve, reject) => { const socket = new WebSocket(url, { headers: { ...buildMetadataHeaders(), ...headers } }); + const cleanup = () => { + clearTimeout(timeout); + socket.off('open', onOpen); + socket.off('error', onError); + socket.off('close', onClose); + }; + const timeout = setTimeout(() => { - reject(new APIConnectionError({ message: 'Timeout connecting to LiveKit WebSocket' })); + cleanup(); + socket.terminate(); + reject(new APITimeoutError({ message: 'Timeout connecting to LiveKit WebSocket' })); }, timeoutMs); const onOpen = () => { - clearTimeout(timeout); + cleanup(); resolve(socket); }; const onError = (err: unknown) => { - clearTimeout(timeout); + cleanup(); if (err && typeof err === 'object' && 'code' in err && (err as any).code === 429) { reject( new APIStatusError({ @@ -114,7 +123,7 @@ export async function connectWs( }; const onClose = (code: number) => { - clearTimeout(timeout); + cleanup(); if (code !== 1000) { reject( new APIConnectionError({ From 1fffdb7ead24d9312cdd73709cd9253f1badb384 Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Fri, 12 Jun 2026 15:13:23 +0100 Subject: [PATCH 2/8] chore: add changeset for connectWs socket leak fix Co-Authored-By: Claude Sonnet 4.6 --- .changeset/fix-connectws-timeout-socket-leak.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-connectws-timeout-socket-leak.md diff --git a/.changeset/fix-connectws-timeout-socket-leak.md b/.changeset/fix-connectws-timeout-socket-leak.md new file mode 100644 index 000000000..19c1327b5 --- /dev/null +++ b/.changeset/fix-connectws-timeout-socket-leak.md @@ -0,0 +1,5 @@ +--- +'@livekit/agents': patch +--- + +Fix orphaned WebSocket leak in `connectWs` when the connection timeout fires before the socket opens. The socket is now terminated and all pending listeners removed on timeout. Also uses `APITimeoutError` instead of the generic `APIConnectionError` for clearer retry semantics. From a300cb1498ba530ede297e068c0255630c5e1e13 Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Fri, 12 Jun 2026 15:16:28 +0100 Subject: [PATCH 3/8] fix: also terminate socket on connection error, not just timeout Co-Authored-By: Claude Sonnet 4.6 --- agents/src/inference/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/agents/src/inference/utils.ts b/agents/src/inference/utils.ts index 695211f3a..019476cfc 100644 --- a/agents/src/inference/utils.ts +++ b/agents/src/inference/utils.ts @@ -110,6 +110,7 @@ export async function connectWs( const onError = (err: unknown) => { cleanup(); + socket.terminate(); if (err && typeof err === 'object' && 'code' in err && (err as any).code === 429) { reject( new APIStatusError({ From 4c84ad011e668eea5293141a0f5784403119a440 Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Fri, 12 Jun 2026 15:25:19 +0100 Subject: [PATCH 4/8] fix: remove redundant socket.terminate() from onError ws closes the socket automatically after a connection error (always emits close after error), so terminate() there was redundant. The only path that needs it is the timeout handler, where no automatic cleanup occurs. Removing it from onError also untangles the circular dependency where cleanup() in onOpen was only needed to prevent the onError handler from calling terminate() on the caller's open socket. Co-Authored-By: Claude Sonnet 4.6 --- agents/src/inference/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/agents/src/inference/utils.ts b/agents/src/inference/utils.ts index 019476cfc..695211f3a 100644 --- a/agents/src/inference/utils.ts +++ b/agents/src/inference/utils.ts @@ -110,7 +110,6 @@ export async function connectWs( const onError = (err: unknown) => { cleanup(); - socket.terminate(); if (err && typeof err === 'object' && 'code' in err && (err as any).code === 429) { reject( new APIStatusError({ From 10201209e8ad5ce47650f166df4d44540322b7fc Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Fri, 12 Jun 2026 15:27:29 +0100 Subject: [PATCH 5/8] fix: only clear timeout in onOpen, keep error/close listeners live Calling cleanup() in onOpen removed the error and close once-listeners right as the socket was handed to the caller, leaving a gap with no error handler. Since onError no longer calls socket.terminate(), the stale once-listeners are harmless (they'd only do a no-op reject on an already-settled promise), so there's no reason to strip them. Just clear the timeout and let the listeners expire naturally. Co-Authored-By: Claude Sonnet 4.6 --- agents/src/inference/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agents/src/inference/utils.ts b/agents/src/inference/utils.ts index 695211f3a..5e62a86e8 100644 --- a/agents/src/inference/utils.ts +++ b/agents/src/inference/utils.ts @@ -104,7 +104,7 @@ export async function connectWs( }, timeoutMs); const onOpen = () => { - cleanup(); + clearTimeout(timeout); resolve(socket); }; From f496d82a104f3ce9cd596983d0a5317177af7c9e Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Fri, 12 Jun 2026 15:30:15 +0100 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20simplify=20connectWs=20timeout=20?= =?UTF-8?q?=E2=80=94=20terminate=20socket=20and=20use=20APITimeoutError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The only real change from the original: terminate the socket when the timeout fires (preventing an orphaned WebSocket) and use APITimeoutError for clearer semantics. The cleanup() helper and listener unregistration were unnecessary — onError/onClose don't need them since the socket is closing anyway and the once-listeners expire naturally. Co-Authored-By: Claude Sonnet 4.6 --- agents/src/inference/utils.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/agents/src/inference/utils.ts b/agents/src/inference/utils.ts index 5e62a86e8..4d78cfe95 100644 --- a/agents/src/inference/utils.ts +++ b/agents/src/inference/utils.ts @@ -90,15 +90,7 @@ export async function connectWs( return new ThrowsPromise((resolve, reject) => { const socket = new WebSocket(url, { headers: { ...buildMetadataHeaders(), ...headers } }); - const cleanup = () => { - clearTimeout(timeout); - socket.off('open', onOpen); - socket.off('error', onError); - socket.off('close', onClose); - }; - const timeout = setTimeout(() => { - cleanup(); socket.terminate(); reject(new APITimeoutError({ message: 'Timeout connecting to LiveKit WebSocket' })); }, timeoutMs); @@ -109,7 +101,7 @@ export async function connectWs( }; const onError = (err: unknown) => { - cleanup(); + clearTimeout(timeout); if (err && typeof err === 'object' && 'code' in err && (err as any).code === 429) { reject( new APIStatusError({ @@ -123,7 +115,7 @@ export async function connectWs( }; const onClose = (code: number) => { - cleanup(); + clearTimeout(timeout); if (code !== 1000) { reject( new APIConnectionError({ From 8ffdee6a718f47f31bda2ccad0e80801cad983ac Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Fri, 12 Jun 2026 15:33:34 +0100 Subject: [PATCH 7/8] chore: update changeset description Co-Authored-By: Claude Sonnet 4.6 --- .changeset/fix-connectws-timeout-socket-leak.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-connectws-timeout-socket-leak.md b/.changeset/fix-connectws-timeout-socket-leak.md index 19c1327b5..a69d644ed 100644 --- a/.changeset/fix-connectws-timeout-socket-leak.md +++ b/.changeset/fix-connectws-timeout-socket-leak.md @@ -2,4 +2,4 @@ '@livekit/agents': patch --- -Fix orphaned WebSocket leak in `connectWs` when the connection timeout fires before the socket opens. The socket is now terminated and all pending listeners removed on timeout. Also uses `APITimeoutError` instead of the generic `APIConnectionError` for clearer retry semantics. +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 uses `APITimeoutError` instead of `APIConnectionError` for clearer retry semantics. From 64678f21c84c17b09313bda1973e54566267717a Mon Sep 17 00:00:00 2001 From: Chenghao Mou Date: Fri, 12 Jun 2026 16:24:56 +0100 Subject: [PATCH 8/8] fix: reject connectWs promise on any close before open A normal (code 1000) close during the handshake previously left the ThrowsPromise unsettled forever. Track whether the socket opened and reject on any close that happens before open, regardless of code. After open, the lingering close listener is a no-op on the settled promise. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/fix-connectws-timeout-socket-leak.md | 2 +- agents/src/inference/utils.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/fix-connectws-timeout-socket-leak.md b/.changeset/fix-connectws-timeout-socket-leak.md index a69d644ed..1e9abbdf3 100644 --- a/.changeset/fix-connectws-timeout-socket-leak.md +++ b/.changeset/fix-connectws-timeout-socket-leak.md @@ -2,4 +2,4 @@ '@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 uses `APITimeoutError` instead of `APIConnectionError` for clearer retry semantics. +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. diff --git a/agents/src/inference/utils.ts b/agents/src/inference/utils.ts index 4d78cfe95..334b9e3ec 100644 --- a/agents/src/inference/utils.ts +++ b/agents/src/inference/utils.ts @@ -90,6 +90,8 @@ export async function connectWs( return new ThrowsPromise((resolve, reject) => { const socket = new WebSocket(url, { headers: { ...buildMetadataHeaders(), ...headers } }); + let opened = false; + const timeout = setTimeout(() => { socket.terminate(); reject(new APITimeoutError({ message: 'Timeout connecting to LiveKit WebSocket' })); @@ -97,6 +99,7 @@ export async function connectWs( const onOpen = () => { clearTimeout(timeout); + opened = true; resolve(socket); }; @@ -114,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',