Skip to content

fix(streaming): stop uncaught error when tearing down a connecting socket - #173

Merged
ccampbell-aai merged 2 commits into
mainfrom
ccampbell/fix-connecting-socket-teardown-crash
Aug 27, 2026
Merged

fix(streaming): stop uncaught error when tearing down a connecting socket#173
ccampbell-aai merged 2 commits into
mainfrom
ccampbell/fix-connecting-socket-teardown-crash

Conversation

@ccampbell-aai

Copy link
Copy Markdown
Contributor

Fixes #170

Problem

Closing a WebSocket that is still in the CONNECTING state makes ws abort the handshake and emit error on the next tick (abortHandshakeprocess.nextTick(emitErrorAndClose, …)). The SDK's teardown paths call socket.removeAllListeners() immediately before socket.close(), so that deferred emit finds zero error listeners — and Node's EventEmitter escalates it to an uncaughtException that kills the process. It lands outside the teardown's own try/catch and outside any caller's try/catch around await transcriber.connect().

Two triggers, both reproduced against assemblyai@4.36.4 / ws@8.21.1:

  • Connect timeoutdiscardPendingSocket(): the timeout fires precisely because the socket is still CONNECTING. Worse than a second unhandleable error: failAttempt() discards the socket before rejecting, and the nextTick queue drains ahead of the promise microtask queue — so the process dies before the caller's catch ever runs, and the timeout rejection is never even observable.
  • close() racing connect(): StreamingTranscriber.close() (and the deprecated RealtimeTranscriber.close()) run the identical removeAllListeners() + close() sequence with no guard at all.

Fix

After removeAllListeners(), re-attach a no-op error sink (socket.onerror = () => {}) before calling close(), in all three teardown sites. On ws, assigning onerror registers a real listener, so the deferred emit lands on the sink instead of crashing the process; the failure itself is still reported through the rejected connect() promise. Browser builds are unaffected — the sink assignment is harmless, and a browser close() on a CONNECTING socket is a no-op anyway.

Tests

New tests/unit/streaming-connecting-teardown.test.ts covers all three paths and is red without the fix, green with it. The existing unit-test ws mock could never catch this class of bug — its removeAllListeners() replaces handlers with no-ops instead of removing them — so the tests inject an EventEmitter-backed fake with real ws semantics (error emit with zero listeners throws; onerror assignment registers a listener).

Also verified end-to-end: the issue's repro (1ms connectTimeout, and close() immediately after an un-awaited connect()) crashes the process with the reported stack on the published package, and survives cleanly (exit 0, timeout rejection now reaching the caller's catch) against this branch's build.

🤖 Generated with Claude Code

…cket

When a socket still in the CONNECTING state is closed, ws aborts the
handshake and emits `error` on the next tick. The teardown paths in
discardPendingSocket() and close() call removeAllListeners() first, so
that deferred emit found zero listeners and Node escalated it to an
uncaughtException that killed the process — outside any caller's
try/catch, and before the connect() rejection could even reach the
caller. Keep a no-op error sink attached after removeAllListeners();
the failure itself is still reported through the rejected connect()
promise. Applies to StreamingTranscriber and the deprecated
RealtimeTranscriber alike.

Fixes #170

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ccampbell-aai

Copy link
Copy Markdown
Contributor Author

Repro verification

Verified this end-to-end with a standalone repro script (Node v26.4.0) covering both triggers from #170: the timeout scenario uses the issue's exact setup (connectTimeout: 1, maxConnectionRetries: 0, placeholder API key — no valid key needed, the socket is still CONNECTING before auth), and the close scenario calls transcriber.close() immediately after an un-awaited connect(). Each run keeps the process alive for 3s afterward and prints SURVIVED if it's still running.

Before (published assemblyai@4.36.4, ws@8.21.1) — both scenarios kill the process with exit code 1:

scenario: timeout
node:events:487
      throw er; // Unhandled 'error' event
      ^
Error: WebSocket was closed before the connection was established
    at WebSocket.close (node_modules/ws/lib/websocket.js:306:7)
    at StreamingTranscriber.discardPendingSocket (node_modules/assemblyai/dist/node.cjs:1759:25)
    at failAttempt (node_modules/assemblyai/dist/node.cjs:1618:22)
    at Timeout.<anonymous> (node_modules/assemblyai/dist/node.cjs:1633:21)
Emitted 'error' event on WebSocket instance at:
    at emitErrorAndClose (node_modules/ws/lib/websocket.js:1060:13)
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21)

Note caught from connect(): ... never prints — the nextTick crash lands before the promise rejection reaches the caller's catch, so the timeout isn't just accompanied by an unhandleable error, it's never observable at all. The close scenario dies identically via StreamingTranscriber.close.

After (this branch's built dist/node.mjs) — both scenarios exit 0:

scenario: timeout
caught from connect(): Streaming connection timed out after 1ms
SURVIVED: no crash within 3s
exit code: 0
scenario: close
connect() rejected: Unauthorized Connection: Invalid API key
SURVIVED: no crash within 3s
exit code: 0

The timeout rejection now reaches the caller's catch as expected, and no deferred error emit escapes.

The unit suite couldn't have caught this: the test ws mock's removeAllListeners() swaps handlers for no-ops instead of removing them, and mock-socket never does ws's deferred handshake-abort emit. The new tests/unit/streaming-connecting-teardown.test.ts therefore injects an EventEmitter-backed fake with real ws semantics (zero-listener error emit throws; onerror = registers a listener) — all three tests fail on the unfixed source and pass with the fix.

@ccampbell-aai
ccampbell-aai marked this pull request as ready for review August 27, 2026 21:12

@bgotthold-aai bgotthold-aai left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does the version need to get bumped?

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ccampbell-aai
ccampbell-aai merged commit 2d171da into main Aug 27, 2026
3 checks passed
@ccampbell-aai
ccampbell-aai deleted the ccampbell/fix-connecting-socket-teardown-crash branch August 27, 2026 21:31
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.

error: WebSocket was closed before the connection was established

3 participants