Repository navigation
feat(gradium): multiplex TTS replies over one shared websocket (2/2) - #7818
hhugo-gradium wants to merge 7 commits into
Conversation
908083e to
ecd966e
Compare
29cc44d to
4cddec0
Compare
6a77346 to
f4a5534
Compare
f3ab5a7 to
a0b5b42
Compare
| async with self._conn_lock: | ||
| if self._conn is not None and not self._conn.closed: | ||
| return self._conn, 0.0, True | ||
| if self._conn is not None: | ||
| await self._conn.aclose() | ||
| self._conn = None | ||
| t0 = time.perf_counter() | ||
| self._conn = _Connection(await self._connect_ws(timeout)) |
There was a problem hiding this comment.
🟡 Prewarm stalls short-timeout replies
When prewarm() holds _conn_lock during its handshake, a reply waits there beyond its configured timeout. _connect_ws only applies the reply's timeout after the lock becomes available.
Learn more
The lock serializes socket creation, and prewarm uses the default connection timeout of 10 seconds. A reply's shorter timeout is passed to _connect_ws only if that reply reaches the new handshake; waiting for the lock and closing a stale connection do not count toward that limit. Thus an already-started prewarm can hold a time-sensitive reply for much longer than its connection timeout.
Example: prewarm() starts a handshake that takes 8 seconds. A reply using APIConnectOptions(timeout=0.2) starts immediately afterward. It waits almost 8 seconds for _conn_lock, then uses the prewarmed socket, rather than failing after 0.2 seconds.
Recommended fix: Bound the entire _connection acquisition with the caller's deadline, including lock wait and stale-connection cleanup. Ensure that timing out a waiting reply does not cancel another caller's prewarm or connection attempt.
Was this helpful? React with 👍 or 👎 to provide feedback.
| while True: | ||
| item = await (queue.get() if ready else asyncio.wait_for(queue.get(), ready_timeout)) |
There was a problem hiding this comment.
🟡 Pre-ready frames prevent setup timeout
If non-ready frames keep arriving, _consume_session restarts ready_timeout on every frame. A reply can wait indefinitely for ready on an otherwise live socket.
Learn more
A session starts a countdown to the server's ready frame. The current code creates a new wait_for on every queue read until ready arrives. Receiving any other recognized or unknown frame resets the countdown. Because the websocket remains live, the heartbeat cannot end this wait.
Example: With timeout=0.2, a server sends need_more_text frames every 0.1 seconds but never sends ready. The reply continues waiting past 0.2 seconds instead of raising APITimeoutError.
Recommended fix: Calculate a monotonic deadline when beginning the ready wait, and pass only its remaining duration into subsequent queue reads. Keep the unbounded wait only after ready is received.
Was this helpful? React with 👍 or 👎 to provide feedback.
| logger.warning( | ||
| "Gradium error for an earlier session on this socket", | ||
| extra={"client_req_id": client_req_id, "error": data.get("message")}, |
There was a problem hiding this comment.
a0b5b42 to
d26ea0f
Compare
There was a problem hiding this comment.
🔴 Partial speech replays after socket failure
When Gradium emits audio before a retryable socket failure, ChunkedStream retries the entire text. Voice generation forwards already emitted frames without checking the new request ID, so listeners hear repeated speech.
Learn more
The chunked TTS base class creates a fresh emitter for each retry, but it does not remove audio already read from the stream. The removed guard let an error retry only when the first attempt had pushed no audio. After the guard is gone, audio from the failed attempt can reach the listener before the retry emits the same words again. StreamAdapterWrapper and voice generation forward frames without using the changed request ID to discard the stale attempt.
Example: A provider sends 0.5 seconds of "Hello world," pauses, and closes with code 1011. The caller hears the first 0.5 seconds, then hears the start again when the full synthesis retries.
Recommended fix: Either prevent one-shot retries after audio has been made available to consumers, or implement attempt-aware replacement through the entire playback path before allowing partial-audio retries. Test with a pause between the first audio frame and the failure so the first attempt's frames actually reach the caller.
Was this helpful? React with 👍 or 👎 to provide feedback.
d26ea0f to
eb104ed
Compare
cba0831 to
8f250ec
Compare
| error = APIConnectionError("Gradium websocket send failed") | ||
| error.__cause__ = e | ||
| self._fail(error) |
There was a problem hiding this comment.
🟡 Send failure masks terminal server close
When a 1003 close races a send, send can fail before the reader records the close code. _fail then gives every session a retryable error, so terminal rejections are retried.
Learn more
A shared connection routes server frames to active synthesis sessions. aiohttp can reject a write after it begins closing the transport, before the reader has recorded the CLOSE frame and its code. Here _fail stores a generic retryable error in every session queue. The later reader error cannot replace it because _fail returns when _error is set. This loses non-retryable close codes for sessions that have not yet consumed their queues, including siblings of the failed send.
Example: The server rejects an invalid model with code 1003 while another reply sends text. Its write raises ClientConnectionResetError before the reader processes CLOSE. Both replies receive a retryable connection error instead of the terminal 1003 status, so their retry loops can reconnect with the same invalid model.
Recommended fix: Preserve the pending reader's close status when a failed send overlaps a server close, as _Connection.aclose does for a closed websocket. Keep immediate failure for a genuine dead transport, but avoid committing a generic error over an available terminal close code; add a test with a pending 1003 CLOSE and a concurrent failed send.
Was this helpful? React with 👍 or 👎 to provide feedback.
| setup_sent = False | ||
| try: | ||
| await self.send(setup, client_req_id) | ||
| setup_sent = True | ||
| yield queue | ||
| except BaseException: | ||
| if setup_sent: | ||
| await self._cancel(client_req_id) |
There was a problem hiding this comment.
🔴 Interrupted setup leaves server workers occupied
If interrupted while send_str drains a written setup frame, setup_sent stays false and skips cancellation. The server keeps synthesizing the abandoned session and holds its concurrency slot until timeout.
Learn more
An aiohttp WebSocket send writes the frame to its transport before it awaits flow-control drain. Cancellation during that drain leaves the send unfinished even though the setup can reach the server. The context manager then skips cancel because it marks setup as sent only after the await returns. The abandoned server session holds a worker and concurrency slot until the server's idle timeout. This affects both ChunkedStream and SynthesizeStream.
Example: A streaming reply begins setup on a backpressured socket. aiohttp writes the setup frame, then waits for the transport to drain. The agent interrupts that reply during the wait. No cancel is sent, although the server has started the session.
Recommended fix: Make setup delivery and cancellation atomic with respect to task interruption, for example by allowing the send to finish under shielding before deciding whether to cancel. If delivery cannot be determined, retire the socket so a potentially delivered setup cannot retain a session on the shared connection. Preserve the no-cancel behavior when setup definitely was not sent.
Was this helpful? React with 👍 or 👎 to provide feedback.
…kedStream arguments Both streams built the same setup dict inline; `_setup_msg` builds it from the options and the model name. The one-shot stream also received the API key, endpoint and model name but only read the model name, which its TTS already carries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The one-shot and the streaming path read the socket with the same loop; `_consume_session` consumes one session's frames until its end_of_stream, handing the audio to a callback, and the callers keep their flush / end_segment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fake socket now blocks while nothing is queued instead of reporting itself closed, takes one script of frames per session (tagged with the session's client_req_id once there is one), can queue frames the server sends on its own and records whether it was closed. `_drain` returns the audio duration and bounds the run, and every TTS a test creates is closed after it. No plugin change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each reply gets a client_req_id, used as the emitter's request or segment id and sent on its setup, text and end_of_stream messages. The server echoes it on its replies; nothing reads it yet. `_run_ws` becomes `_run_session`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…socket instead The receive timeout applied between any two server messages. After `ready` the server is silent until it has enough text, so an LLM that paused longer than `conn_options.timeout` mid reply failed the stream with APITimeoutError, and with audio already out the base class did not retry: the reply was cut. Now only the wait for `ready` is timed; after it the session waits as long as the socket lives, and a client heartbeat (`WS_HEARTBEAT_S`) is how a dead socket is noticed. Replaces livekit#7817. The heartbeat is 10s: aiohttp closes the socket when a pong takes longer than half the interval, so a stall of the event loop (end-of-turn inference, GC) gets 5s before a socket that was fine is lost, and a dead transport is still found within 15s, well inside the server's 120s idle limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ba0d8f5 to
7034697
Compare
Every reply opened its own websocket: TLS and websocket handshake, API-key resolution, then the session. The Gradium API runs several sessions on one socket (`close_ws_on_eos: false`, one `client_req_id` per session, every message and reply tagged with it), so a TTS instance now keeps one socket and runs every reply on it as a session, in parallel when asked. A reader task (`_Connection`) owns the socket and routes each frame to the session it is tagged with; frames for a session nobody listens to any more are dropped, an error frame among them is logged, a malformed frame is skipped. When the socket dies every open session gets the error and the next reply opens a new socket; a send that fails with a connection error fails the connection too, so a retry does not pick up a socket the reader has not yet seen die. Only the wait for `ready` is bounded by `conn_options.timeout`; after that a session waits as long as the socket lives, and the client heartbeat is the liveness check, so there is no per-message receive timeout left. Each reply runs its session through `_Connection.session()`, a context manager that registers the session, sends its setup and stops listening on exit; both streams only send text and consume the queue it yields. `prewarm()` opens the socket when the agent activity starts, so the first reply skips the handshake too. `aclose()` fails the in-flight sessions, closes the streams and the socket, under the connection lock so a connect still in flight cannot store a socket after it. aiohttp marks a socket closed before it hands the CLOSE frame to the reader, so `aclose()` on an already-closed socket gives the reader a moment to record the close: the sessions then get the server's close code (a final 1002/1003 stays final) rather than a generic retryable error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A session left before its last frame arrived (the agent interrupted it, it timed out waiting for the server, or it failed) is cancelled with the API's `cancel` message: the server stops synthesizing it, releases the worker and answers `cancelled` as the session's last frame, so a retry on the same socket does not run next to an orphan holding a worker and a concurrency slot. The socket and the other sessions on it are unaffected. Same contract as ElevenLabs' `close_context` / `isFinal` and Inworld's `close_context` / `contextClosed`. Ships with gradium-serve livekit#4604 (api 0.97.1). The cancel is sent from `_Connection.session()` on an early exit, and only once the setup went out: the server answers a message for an id it has never seen with a 1002 protocol error and closes the socket under every other session. A cancel landing after the session ended on its own changes nothing on the server, so nothing tracks which sessions ended. A `cancelled` frame nobody asked for ends the session instead of leaving it waiting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7034697 to
0a6e815
Compare
Summary
Every Gradium TTS reply opened its own websocket: TLS and websocket handshake, API-key resolution, then the session. The Gradium API supports several sessions on one socket (
close_ws_on_eos: false, oneclient_req_idper session, every message and reply tagged with it), so the plugin now keeps one socket perTTSinstance and runs every reply on it as a session, in parallel when the agent asks for parallel synthesis. This is the shape the ElevenLabs and Inworld plugins use for their multi-context connections.prewarm()opens the socket when the agent activity starts, so the first reply skips the handshake too. A socket the server closed, after a failed session for instance, is reopened by the next reply.cancelmessage: the server stops synthesizing it, releases the worker and answerscancelledas the session's last frame, so a retry on the same socket does not run next to an orphan holding a worker and a concurrency slot. The socket and the other sessions on it are unaffected, and whatever still arrives for the cancelled session is dropped. This is the same contract as ElevenLabs'close_context/isFinaland Inworld'sclose_context/contextClosed. Each session runs as a context manager on the connection (_Connection.session()) that sends its setup and, on an early exit, the cancel. The cancel goes out only once the setup did: the server answers a message for an id it has never seen with a 1002 protocol error and closes the socket under every other session. A cancel landing after the session ended on its own changes nothing on the server, so no bookkeeping tracks which sessions ended.readyis bounded byconn_options.timeout. After that a session waits as long as the socket lives: the server is silent while it waits for text, and a dead socket is noticed through a client heartbeat on the shared socket, a fixed 10 s interval (aiohttp allows half of it for the pong, so an event loop stalled by inference or GC has 5 s before every session on the socket is lost); the server's pongs keep the socket alive while the text source stalls, and there is no per-message receive timeout left for the interval to stay ahead of. This folds in what fix(gradium): keep the TTS socket alive while the text source stalls (3/4) #7817 did on the single-socket design; that PR is closed in favour of this one.ready(ElevenLabs re-arms a timer on every audio frame): the heartbeat and the server's 120 s idle limit are the bounds, since a timeout between frames is exactly what cut replies behind a slow LLM.aclose()fails the in-flight sessions, closes the streams and the socket. The setup message is built once for both streams, andChunkedStreamno longer carries arguments it does not use.Server dependency
Two server changes have to be live before this PR is released:
cancelmessage, gradium-serve export session state as db and compute delta for sync #4604, merged and in api 0.97.1. Against an older server acancelfails message validation and the server closes the socket with 1011, which the plugin reports as a retryable connection error on the live sessions; the next reply reconnects.max_concurrent_websocketsreplies and every latersetupis refused with "Concurrency limit exceeded". That happens on prod whenever no local worker is free.Both verified live on staging on 2026-10-20 with this branch: one socket, three replies, an interrupted reply acknowledged with
cancelled, a reply after the interruption and one after 30 s idle, on a satellite serving locally and on one forwarding to a peer. Everything else here works against the current API.Testing
tests/test_plugin_gradium_tts.py: the fake socket carries any number of sessions, one script persetupit receives, tagging frames with that session's id, acknowledging acancelwithcancelled, and blocking while idle like a live socket. Fifteen tests for the shared connection: sequential and parallel replies share one socket with distinct ids; frames and error frames of unknown sessions are dropped or logged; an interrupted reply, streaming or one-shot, sendscancel, its later frames are dropped without a warning and its sibling completes on the same socket; a failed session raises for that session and the next reply reconnects after the server closes the socket; a connection loss reaches every open session;prewarm()connects ahead of the first reply; a server close or reader failure while idle reconnects without a retry; the wait forreadytimes out and silence afterreadydoes not;aclose()closes the socket and ends an in-flight one-shot instead of hanging it; a malformed frame is skipped; a session that timed out is cancelled server-side and a completed one is not. A review pass on the shared connection added four more, folded into the commits they cover: a session that already ended with an error frame is not sent acancel(the server would not acknowledge it) and leaves no bookkeeping behind; acancelledframe nobody asked for ends the session with a retryable error instead of hanging it; a close code survives the window in which aiohttp has marked the socket closed but the reader has not recorded the CLOSE frame yet, so a 1003 stays final; an error frame with noclient_req_idfails every open session and the next reply reconnects. A second review pass after the rebase added three more:aclose()closes a socket whose connect was still in flight; a send that fails with a connection error drops the socket so the retry opens a new one, for both streams. The restructure into_Connection.session()added one more, for both streams: a reply interrupted while its setup is still on its way out sends no cancel and leaves the socket usable. 68 tests across the Gradium modules at this head, all passing; every test closes its TTS so no task leaks.ruff,ruff format --checkandmypy --strictclean on the plugin.api.gradium.ai, everything except the cancel path, which needs export session state as db and compute delta for sync #4604:TTSRe-run on 2026-10-20 against
api.gradium.aionce every prod satellite was on api 0.97.1, which carries #4604:cancelacknowledged withcancelledwithin 0.5 s; first audio 55 to 118 ms, one outlier at 533 msThe same sequence also passed on staging through a satellite that forwards every session to a peer.
Agent-shaped run on 2026-10-22 against
api.gradium.ai, at this head: a real LLM (LiveKit Inference,gpt-4.1-mini) streams each reply token by token intotts.stream(), asAgentSessiondoes. Prewarm, two turns, a barge-in at 1.5 s of audio, the next turn right away, 30 s idle, a turn, then a filler and a reply synthesized together. Four runs each on this branch and on main's plugin, alternating.cancelacknowledged withcancelledwithin 20 ms, no error framesWith an LLM in front, main's handshake is hidden behind the first token, so streamed turns do not get faster; replies that do not wait on an LLM (fillers,
say()) skip the handshake and start about 40 ms earlier.Commits, in review order
refactor(gradium): build the TTS setup message once, drop unused ChunkedStream arguments:_setup_msgreplaces the two inline setup dicts; the one-shot stream stops taking the API key, endpoint and model name it did not use.refactor(gradium): share the receive loop of both TTS streams: the two identical receive loops become one_consume_session(ws, on_audio=...); the callers keep theirflush()/end_segment().test(gradium): a TTS test harness that behaves like a live socket: test-only. The fake blocks while idle, takes one script per session and can queue unsolicited frames;_drainreturns the audio duration; every TTS is closed after its test.refactor(gradium): tag every TTS session message with a client_req_id: one id per reply, on setup, text and end_of_stream, also used as the emitter's request or segment id;_run_wsbecomes_run_session. Nothing reads the id yet.fix(gradium): time only the wait for ready on TTS sessions, ping the socket instead: what fix(gradium): keep the TTS socket alive while the text source stalls (3/4) #7817 fixed, the way this PR needs it. Only the wait forreadyis bounded byconn_options.timeout; after it a 10 s client heartbeat is the liveness check, so a stalled LLM no longer cuts the reply.feat(gradium): share one websocket per TTS instance across replies: the core._Connectionwith the reader task and per-session routing,close_ws_on_eos: false,_Connection.session()as the context manager each reply runs through (registers the session, sends its setup, stops listening on exit),_consume_sessionreading from the session's queue,_connection()under a lock,prewarm(),TTS.aclose()under the same lock, a failed send failing the connection, and anaclose()that keeps the server's close code when aiohttp has already marked the socket closed; the shared-connection tests.feat(gradium): cancel interrupted TTS sessions server-side: the cancel on an early exit ofsession(), sent only once the setup went out; an unrequestedcancelledends the session; the cancel tests, including the one for a reply interrupted while its setup is still on its way out.Every commit passes the Gradium tests on its own. The branch was squashed from fourteen commits on 2026-10-22, with the tree unchanged: the review fix-ups now sit in the commits they correct.
The agent-shaped live run above was repeated at this head on 2026-10-22: one socket per conversation, the barge-in acknowledged with
cancelledwithin 10 ms, first audio after the first token median 128 ms, one-shot median 122 ms.History
#7672 (1/2) has merged and this branch is rebased on main: the diff is only this PR's commits. #7815 has merged and #7817 is closed, its heartbeat lives here.
🤖 Generated with Claude Code