From 88f072bec764af977eb34aba0b3d1fce8ab7902e Mon Sep 17 00:00:00 2001 From: shobhitagnihotri69 Date: Fri, 18 Sep 2026 14:43:52 +0530 Subject: [PATCH 1/3] fix(realtime): reject connect() promise when WebSocket errors before SessionBegins Fixes #136. RealtimeTranscriber.connect() returned a Promise that only captured resolve, not reject. When the underlying WebSocket emitted an onerror event before the server sent a SessionBegins message (e.g. connection refused, auth failure, network drop), the error was forwarded to any registered error listener but the connect() promise was left in the pending state forever. Callers awaiting connect() would hang indefinitely. Changes: - Add a private field to hold the rejection handler from the connect() Promise. - In , call if it is set (i.e. SessionBegins has not yet been received), then clear the field. - In the SessionBegins handler, clear so that any subsequent socket errors after a successful handshake only propagate to the error listener, not to the already-resolved connect() promise. Tests: - rejects connect() when WebSocket errors before SessionBegins - does not reject an already-resolved connect() on subsequent errors All 26 tests pass. --- src/services/realtime/service.ts | 22 +++++++++++++++++++--- tests/unit/realtime.test.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/services/realtime/service.ts b/src/services/realtime/service.ts index 8be0de1..b372b9f 100644 --- a/src/services/realtime/service.ts +++ b/src/services/realtime/service.ts @@ -65,6 +65,7 @@ export class RealtimeTranscriber { private socket?: PolyfillWebSocket; private listeners: RealtimeListeners = {}; private sessionTerminatedResolve?: () => void; + private connectReject?: (reason: Error) => void; /** * Create a new RealtimeTranscriber. @@ -187,11 +188,13 @@ export class RealtimeTranscriber { * @returns A promise that resolves when the connection is established and the session begins. */ connect() { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { if (this.socket) { throw new Error("Already connected"); } + this.connectReject = reject; + const url = this.connectionUrl(); if (this.token) { @@ -235,8 +238,18 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c }; this.socket!.onerror = (event: ErrorEvent) => { - if (event.error) this.listeners.error?.(event.error as Error); - else this.listeners.error?.(new Error(event.message)); + const error = event.error + ? (event.error as Error) + : new Error(event.message); + // Reject the connect() promise if SessionBegins has not yet resolved it. + // This prevents the promise from hanging when the socket fails during + // the handshake (fixes #136). + const rejectConnect = this.connectReject; + if (rejectConnect) { + this.connectReject = undefined; + rejectConnect(error); + } + this.listeners.error?.(error); }; this.socket!.onmessage = ({ data }: MessageEvent) => { @@ -251,6 +264,9 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c sessionId: message.session_id, expiresAt: new Date(message.expires_at), }; + // Session started successfully — clear the rejection handler so + // subsequent onerror calls (after handshake) only fire listeners. + this.connectReject = undefined; resolve(openObject); this.listeners.open?.(openObject); break; diff --git a/tests/unit/realtime.test.ts b/tests/unit/realtime.test.ts index 9bb28a0..db63230 100644 --- a/tests/unit/realtime.test.ts +++ b/tests/unit/realtime.test.ts @@ -84,6 +84,35 @@ describe("realtime", () => { ); }); + // Regression: Issue #136 — connect() must reject when a WebSocket error + // fires before SessionBegins instead of hanging indefinitely. + it("rejects connect() when WebSocket errors before SessionBegins", async () => { + const errorUrl = "wss://localhost:9991"; + const errorServer = new WS(errorUrl); + const rtNew = new RealtimeTranscriber({ realtimeUrl: errorUrl, apiKey }); + + const connectPromise = rtNew.connect(); + await errorServer.connected; + + // Simulate a network error before the server sends SessionBegins. + errorServer.error({ code: 0, reason: "Connection refused", wasClean: false }); + + await expect(connectPromise).rejects.toThrow(); + WS.clean(); + }); + + // After SessionBegins, onerror must only fire the error listener — the + // already-resolved connect() promise must not be double-rejected. + it("does not reject an already-resolved connect() on subsequent errors", async () => { + // `rt` is already connected by beforeEach; further errors fire listener only. + const onError = jest.fn(); + rt.on("error", onError); + + server.error({ code: 0, reason: "Post-handshake error", wasClean: false }); + + expect(onError).toHaveBeenCalledWith(expect.any(Error)); + }); + it("fails to send audio with closed websocket", async () => { await close(rt, server); expect(() => rt.sendAudio(new ArrayBuffer(8))).toThrow( From 0ad56bc46ddff3d44f7a77a6d22808638d8b4cea Mon Sep 17 00:00:00 2001 From: shobhitagnihotri69 Date: Sun, 20 Sep 2026 06:30:20 +0530 Subject: [PATCH 2/3] fix(realtime): tear down socket on connection failure and remove issue 136 reference - Implement discardPendingSocket() on RealtimeTranscriber and invoke it when pre-session connection fails (in onerror and onclose) so that callers can safely retry connect() without encountering 'Already connected'. - Remove references claiming to fix #136 in comments and tests. - Add unit test verifying reconnecting after a failed connect() attempt. - Add unit test verifying discardPendingSocket() attaches an error sink. --- src/services/realtime/service.ts | 33 ++++++++++++++++++- tests/unit/realtime.test.ts | 32 ++++++++++++++++-- .../streaming-connecting-teardown.test.ts | 15 +++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/src/services/realtime/service.ts b/src/services/realtime/service.ts index b372b9f..fff8273 100644 --- a/src/services/realtime/service.ts +++ b/src/services/realtime/service.ts @@ -234,6 +234,16 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c // The socket is gone, so no `SessionTerminated` message is coming. // Release a `close()` that is waiting for one. this.resolveSessionTermination(); + const rejectConnect = this.connectReject; + if (rejectConnect) { + this.connectReject = undefined; + this.discardPendingSocket(); + rejectConnect( + new Error( + `Realtime connection closed before session started: ${code} ${reason ?? ""}`.trim(), + ), + ); + } this.listeners.close?.(code, reason); }; @@ -243,10 +253,11 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c : new Error(event.message); // Reject the connect() promise if SessionBegins has not yet resolved it. // This prevents the promise from hanging when the socket fails during - // the handshake (fixes #136). + // the handshake. const rejectConnect = this.connectReject; if (rejectConnect) { this.connectReject = undefined; + this.discardPendingSocket(); rejectConnect(error); } this.listeners.error?.(error); @@ -354,6 +365,26 @@ Learn more at https://github.com/AssemblyAI/assemblyai-node-sdk/blob/main/docs/c resolve?.(); } + /** Tear down a half-open socket from a failed connection attempt. */ + private discardPendingSocket(): void { + if (!this.socket) return; + try { + if (this.socket.removeAllListeners) { + this.socket.removeAllListeners(); + // `ws` aborts a still-CONNECTING handshake by emitting `error` on the + // next tick, and an `error` emit with no listener crashes the process + // as an uncaughtException — outside this try/catch and any caller's. + // Keep a sink attached; the failure is already reported through the + // rejected connect() promise. + this.socket.onerror = () => {}; + } + this.socket.close(); + } catch { + // Best-effort cleanup; a half-open socket may throw on close. + } + this.socket = undefined; + } + /** Awaits the session-termination message, bounded by `timeoutMs`. */ private async waitForTermination( sessionTerminated: Promise, diff --git a/tests/unit/realtime.test.ts b/tests/unit/realtime.test.ts index db63230..b4c4215 100644 --- a/tests/unit/realtime.test.ts +++ b/tests/unit/realtime.test.ts @@ -84,7 +84,7 @@ describe("realtime", () => { ); }); - // Regression: Issue #136 — connect() must reject when a WebSocket error + // Regression: connect() must reject when a WebSocket error // fires before SessionBegins instead of hanging indefinitely. it("rejects connect() when WebSocket errors before SessionBegins", async () => { const errorUrl = "wss://localhost:9991"; @@ -95,12 +95,40 @@ describe("realtime", () => { await errorServer.connected; // Simulate a network error before the server sends SessionBegins. - errorServer.error({ code: 0, reason: "Connection refused", wasClean: false }); + errorServer.error({ + code: 0, + reason: "Connection refused", + wasClean: false, + }); await expect(connectPromise).rejects.toThrow(); WS.clean(); }); + // After a failed connect(), this.socket must be dropped so retrying does + // not throw "Already connected". + it("allows reconnecting after connect() fails", async () => { + const errorUrl = "wss://localhost:9992"; + const errorServer = new WS(errorUrl); + const rtNew = new RealtimeTranscriber({ realtimeUrl: errorUrl, apiKey }); + + const firstConnect = rtNew.connect(); + await errorServer.connected; + errorServer.error({ + code: 0, + reason: "Connection refused", + wasClean: false, + }); + await expect(firstConnect).rejects.toThrow(); + WS.clean(); + + // Reconnecting should not throw "Already connected" + const retryServer = new WS(errorUrl); + await connect(rtNew, retryServer); + await close(rtNew, retryServer); + WS.clean(); + }); + // After SessionBegins, onerror must only fire the error listener — the // already-resolved connect() promise must not be double-rejected. it("does not reject an already-resolved connect() on subsequent errors", async () => { diff --git a/tests/unit/streaming-connecting-teardown.test.ts b/tests/unit/streaming-connecting-teardown.test.ts index 1af6b9a..d85cd0c 100644 --- a/tests/unit/streaming-connecting-teardown.test.ts +++ b/tests/unit/streaming-connecting-teardown.test.ts @@ -95,6 +95,21 @@ describe("tearing down a CONNECTING socket", () => { expect(() => socket.emitDeferredHandshakeAbort()).not.toThrow(); }); + it("RealtimeTranscriber.discardPendingSocket() leaves an error sink attached", () => { + const rt = new RealtimeTranscriber({ + apiKey: "123", + sampleRate: 16_000, + }); + const socket = new FakeConnectingWsSocket(); + injectSocket(rt, socket); + + (rt as unknown as { discardPendingSocket(): void }).discardPendingSocket(); + + expect(socket.closeCalled).toBe(true); + expect(socket.listenerCount("error")).toBeGreaterThan(0); + expect(() => socket.emitDeferredHandshakeAbort()).not.toThrow(); + }); + it("RealtimeTranscriber.close() leaves an error sink attached", async () => { const rt = new RealtimeTranscriber({ apiKey: "123", From 3236c00ff1ab1d492f60587171d6b7d132b9f102 Mon Sep 17 00:00:00 2001 From: shobhitagnihotri69 Date: Sun, 20 Sep 2026 06:31:24 +0530 Subject: [PATCH 3/3] chore: bump version to 4.41.4 and update changelog --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 141449a..de78dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## [4.41.4] + +- Fix `RealtimeTranscriber.connect()` promise hanging indefinitely when a socket error or unexpected close occurs before `SessionBegins`. The promise now rejects, and the socket reference is discarded so that connections can be safely retried. + ## [4.41.3] - Export the helper types (`LiteralUnion`) from the package root, so a downstream TypeScript package can export a value typed by `Transcript.language_code`, `TranscriptParams.language_code` or `SyncSpeechModel` without its declaration emit failing with TS2742 (the internal path `assemblyai/dist/types/helpers` is not in the `exports` map). No runtime change diff --git a/package.json b/package.json index dc2df75..cff4429 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "assemblyai", - "version": "4.41.3", + "version": "4.41.4", "description": "The AssemblyAI JavaScript SDK provides an easy-to-use interface for interacting with the AssemblyAI API, which supports async and real-time transcription.", "engines": { "node": ">=18"