Skip to content

fix: dropping an xAI streaming transcription closes the socket - #311

Merged
eric8810 merged 7 commits into
masterfrom
fix/xai-stt-closes-the-socket-when-the-stream-is-dropped
Oct 25, 2026
Merged

eric8810 merged 7 commits into
masterfrom
fix/xai-stt-closes-the-socket-when-the-stream-is-dropped

Conversation

@cunninghamcard-bit

@cunninghamcard-bit cunninghamcard-bit commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes the 2 behaviour differences in xAI found while porting the upstream tests. Each difference is its own commit (listed below) and adds a test that fails on master and a CHANGELOG Fixed entry. They were separate PRs (#310 and this one). To keep one fix PR per vendor, the others are closed and this PR carries all of them. The xAI test PR (#312) stacks on this branch. Part of the #276 list.

# Commit Difference Was
1 1852ed5d, e6084f08 xAI streaming transcription connect failure errors the stream #310
2 c2c70271, 47045379 dropping an xAI streaming transcription closes the socket #311

1. xAI streaming transcription connect failure errors the stream

Before: xAI streaming transcription opened its WebSocket in do_stream and returned a connect failure (a refused connection, a failing custom connector) as the Err of do_stream itself.

After: as in @ai-sdk/xai 5.0.12, do_stream still opens the socket before it returns (upstream's ReadableStream start runs while doStream builds the stream), but a connect failure no longer fails do_stream: the returned stream yields it as its only item and ends, which drops the caller's audio stream (upstream cancels it).

Found while porting the upstream xAI tests (case doStream › should cancel the audio stream when the WebSocket constructor throws).

How it works today

XaiTranscriptionModel::do_stream                      aimux-providers/src/xai/transcription.rs:210   // upstream: XaiTranscriptionModel.doStream  xai-transcription-model.ts
└─ stream::do_stream                                  transcription.rs:295
   ├─ Opts::parse, multichannel check, warnings       :296-334
   ├─ ws_connect(&WebSocketRequest::for_transcription(..)).await?
   │                                                  :338   ← connect failure returns Err from do_stream
   │                                                         // upstream: connectToWebSocket runs in the ReadableStream's start (:276-358);
   │                                                         //   a failure goes to finishWithError → cleanup cancels audio → controller.error
   ├─ async_stream::stream! { audio / ws.next() loop } :355
   └─ Ok(TranscriptionStreamResult { stream, .. })    :489
aimux-provider-utils/src/ws.rs:477  ws_connect → connector.connect or tungstenite

What this PR changes

stream::do_stream                                     transcription.rs:295
├─ let connected = ws_connect(..).await               :340   no `?`: the Result is handed to the stream
└─ async_stream::stream! {                            :357
     let mut ws = match connected {                   :358
         Ok(ws) => ws,
         Err(e) => { yield Err(e); return; }          ← the stream's error; ending the stream drops `audio`
     };
     … the existing loop, unchanged
   }

The socket is still opened before do_stream returns, so a stream dropped unread has connected, as upstream's has (its case should strip undefined header values before the WebSocket constructor reads the handshake after cancelling the unread stream).

Tests (tests/xai_transcription_test.rs): a_connect_failure_errors_the_stream points the model at a closed port and expects do_stream to resolve and the stream to hold one error; it fails on master with do_stream resolves; the connect failure arrives on the stream: ApiCall(... "websocket connect failed: ... Connection refused"). The upstream case is ported in the xAI test PR.

Docs: CHANGELOG Fixed.

Note: Only xAI changes here. ElevenLabs streaming transcription also returns a connect failure from do_stream (elevenlabs.rs:941); it is not touched, since its upstream package is ported separately.

2. dropping an xAI streaming transcription closes the socket

Before: xAI streaming transcription sent the WebSocket close frame only after transcript.done or an error event. A stream dropped earlier (the caller cancels, or an audio send fails) released the transport without a close frame.

After: as in @ai-sdk/xai 5.0.12, where cancelling the stream runs cleanup and calls connection.close(), dropping the stream before it closed the socket sends the close frame. The audio stream is dropped with it, as before.

Found while porting the upstream xAI tests (case doStream › should close the WebSocket and stop reading audio when the stream is cancelled).

How it works today

stream::do_stream                                     aimux-providers/src/xai/transcription.rs:295   // upstream: XaiTranscriptionModel.doStream
├─ let connected = ws_connect(..).await               :340   (#310)
└─ async_stream::stream! {                            :357
     let mut ws: WsConnection = match connected { .. } :358
     loop { audio send / ws.next() }
     ├─ "transcript.done" (all channels) → ws.close().await; break     :479
     ├─ "error"                          → ws.close().await; break     :488
     └─ send error / peer error / drop    → ws dropped, no close frame  ← differs
   }
   // upstream: cleanup (:290-298) calls connection.close() on every end: finish, error,
   //   and ReadableStream cancel (:459-462)
aimux-provider-utils/src/ws.rs:770  WsConnection::close  sends Close(1000, "finished"), bounded to 5 s

What this PR changes

stream::Socket(Option<WsConnection>)                  transcription.rs:243   ← new
├─ Socket::close(&mut self)                           :246   takes the connection and closes it
├─ Deref / DerefMut → WsConnection                    :253   send_text / send_binary / next unchanged
└─ impl Drop for Socket                               :267   still open → runtime.spawn(ws.close())
stream::do_stream
├─ ws_connect(..).await.map(|ws| Socket(Some(ws)))    :392   the guard is built here, before the stream exists
└─ async_stream::stream! { match connected { Ok(ws) => ws, … } }   :403   the guard moves in; the two existing
                                                       ws.close().await calls (:525, :534) now go through Socket::close

The guard is built outside the async_stream::stream! body because that body runs only on the first poll: a guard built inside it (the first version of this commit) left a stream that was connected but never polled to drop the bare WsConnection without a close frame. 47045379 moves it out, as eric pointed out in review.

Tests (tests/xai_transcription_test.rs): dropping_the_stream_closes_the_socket runs a local WebSocket server, reads the stream start, drops the stream and expects the server to see a close frame; it fails on master with no close frame was sent when the stream was dropped. dropping_an_unpolled_stream_closes_the_socket drops the stream without calling next(); it fails with the guard built inside the stream body (no close frame was sent when the unpolled stream was dropped). The upstream case is ported in the xAI test PR.

Docs: CHANGELOG Fixed.

Note: The close runs on a spawned task because Drop cannot await; with no tokio runtime (dropped outside one) the transport is released as before. Only xAI changes here.

Type of change

  • Bug fix (non-breaking)
  • New feature (non-breaking)
  • Breaking change
  • New provider
  • New / updated binding
  • Docs / RFC

Checklist

  • cargo +1.99 fmt -p aimux-providers -- --check passes
  • cargo +1.99 clippy -p aimux-providers --lib --tests -- -D warnings passes
  • cargo +1.99 test -p aimux-providers --test xai_transcription_test passes (21)
  • scripts/check_provider_boundaries.sh passes
  • Updated relevant docs (CHANGELOG)
  • Commit messages follow Conventional Commits

Notes for reviewers


Generated by Claude Code

cunninghamcard-bit and others added 2 commits October 20, 2026 08:12
@ai-sdk/xai 5.0.12 opens the STT WebSocket inside the returned stream
(xai-transcription-model.ts:276-358), so doStream resolves and a connect
failure errors the stream, cancelling the caller's audio. aimux connected
before returning and failed do_stream itself. The connect now runs first
inside the stream; on failure the stream yields the error and ends, which
drops the audio.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5
In @ai-sdk/xai 5.0.12, cancelling the transcription stream runs cleanup,
which calls connection.close() (xai-transcription-model.ts:290-298,
:459-462). aimux only sent a close frame after transcript.done or an error
event; a stream dropped earlier released the transport without one. The
socket now lives in a guard that, when dropped before it was closed, sends
the close frame on the runtime.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5
cunninghamcard-bit and others added 2 commits October 20, 2026 08:24
Upstream's ReadableStream start runs when doStream builds the stream, so
the WebSocket is opened before doStream resolves even if the stream is
never read. Connecting on the stream's first poll would skip the
connection for a stream dropped unread; connect in do_stream instead and
hand the outcome to the stream, which still yields a failure as its
error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5
…/xai-stt-closes-the-socket-when-the-stream-is-dropped

# Conflicts:
#	aimux-providers/src/xai/transcription.rs
…opped

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5
@cunninghamcard-bit
cunninghamcard-bit marked this pull request as ready for review October 24, 2026 10:12

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

drop 后关闭 socket 的主要路径成立,但 Socket(Some(ws)) 在 async_stream::stream! 的第一条语句里,宏体首次 poll 才执行;已连接但从未 poll 的流 drop 时仍不会发 close frame。建议在宏外包装 connected,并加一个“drop 前不调用 next()”的测试。CHANGELOG 里“在 stream 内打开”也不准确:ws_connect 仍在 do_stream 里 await。

cunninghamcard-bit and others added 2 commits October 25, 2026 08:11
…the-socket-when-the-stream-is-dropped

# Conflicts:
#	CHANGELOG.md
…en dropped

The socket guard was built in the first statement of the
async_stream::stream! body, which runs only on the first poll. A stream
that do_stream returned with an open socket but that was dropped before
next() released the transport without a close frame. The guard is now
built in do_stream, right after the connect, and moved into the stream.

The CHANGELOG said the socket is opened inside the stream; do_stream still
awaits ws_connect. The entry now says what changed: a connect failure
arrives as the stream's error.

Test: dropping_an_unpolled_stream_closes_the_socket
(tests/xai_transcription_test.rs) fails without the change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoxcKU4zBD64Lri3e4Lrq5

Copy link
Copy Markdown
Contributor Author
eric 的意见(原话) 处理
「Socket(Some(ws)) 在 async_stream::stream! 的第一条语句里,宏体首次 poll 才执行;已连接但从未 poll 的流 drop 时仍不会发 close frame。建议在宏外包装 connected」 4704537:do_stream 里 ws_connect(..).await.map(|ws| Socket(Some(ws))),守卫在宏外建好再移进 stream,宏里只剩 Ok(ws) => ws
「并加一个"drop 前不调用 next()"的测试」 同一提交:dropping_an_unpolled_stream_closes_the_socket,do_stream 返回后直接 drop,断言服务端收到 close frame;把守卫放回宏内时它失败(no close frame was sent when the unpolled stream was dropped),改后通过
「CHANGELOG 里"在 stream 内打开"也不准确:ws_connect 仍在 do_stream 里 await」 同一提交:第一条改为「连接失败作为 stream 的错误到达,do_stream 仍 await 连接但不再因它失败」;第二条补上「未 poll 就 drop 也会发 close frame」

PR 描述的调用栈和测试段已按新提交更新。分支此前已合入最新 master(只有 CHANGELOG 冲突)。本地检查:fmt、边界脚本、clippy、lib + xAI 测试通过。


Generated by Claude Code


Generated by Claude Code

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

drop-before-poll 已修复,Socket 在 stream 外构造,未 poll 的 drop 也会发 close;测试覆盖未 poll/polled 两种路径。Approve。

@eric8810
eric8810 merged commit 1c6e90d into master Oct 25, 2026
16 checks passed
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