kages/aiohttp/client_ws.py\", line 245, in send_st...
# ask-ai
c
kages/aiohttp/client_ws.py\", line 245, in send_str\n await self._writer.send_frame(\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/aiohttp/_websocket/writer.py\", line 83, in send_frame\n self._write_websocket_frame(message, opcode, 0)\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/aiohttp/_websocket/writer.py\", line 151, in _write_websocket_frame\n raise ClientConnectionResetError(\"Cannot write to closing transport\")\naiohttp.client_exceptions.ClientConnectionResetError: Cannot write to closing transport\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/livekit/plugins/sarvam/tts.py\", line 786, in _run_ws\n await asyncio.gather(*tasks)\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/livekit/plugins/sarvam/tts.py\", line 736, in send_task\n raise APIConnectionError(f\"Send task failed: {e}\") from e\nlivekit.agents._exceptions.APIConnectionError: Send task failed: Cannot write to closing transport", "session_id": 138374040272048, "connection_state": "connected", "model": "bulbul:v2", "speaker": "anushka", "client_request_id": "12840c7fe812", "server_request_id": null, "room": "ivr-saurabh-1771946728", "pid": 157960, "job_id": "AJ_xCeYW4NuwBUt", "room_id": "RM_Dr8ZtnQZi7Ww", "timestamp": "2026-02-24T152643.301748+00:00"} {"message": "Unexpected error in WebSocket session: Send task failed: Cannot write to closing transport\nTraceback (most recent call last):\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/livekit/plugins/sarvam/tts.py\", line 714, in send_task\n await ws.send_str(json.dumps(config_msg))\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/aiohttp/client_ws.py\", line 245, in send_str\n await self._writer.send_frame(\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/aiohttp/_websocket/writer.py\", line 83, in send_frame\n self._write_websocket_frame(message, opcode, 0)\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/aiohttp/_websocket/writer.py\", line 151, in _write_websocket_frame\n raise ClientConnectionResetError(\"Cannot write to closing transport\")\naiohttp.client_exceptions.ClientConnectionResetError: Cannot write to closing transport\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/livekit/plugins/sarvam/tts.py\", line 786, in _run_ws\n await asyncio.gather(*tasks)\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/livekit/plugins/sarvam/tts.py\", line 736, in send_task\n raise APIConnectionError(f\"Send task failed: {e}\") from e\nlivekit.agents._exceptions.APIConnectionError: Send task failed: Cannot write to closing transport", "level": "ERROR", "name": "livekit.plugins.sarvam.log", "exc_info": "Traceback (most recent call last):\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/livekit/plugins/sarvam/tts.py\", line 714, in send_task\n await ws.send_str(json.dumps(config_msg))\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/aiohttp/client_ws.py\", line 245, in send_str\n await self._writer.send_frame(\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/aiohttp/_websocket/writer.py\", line 83, in send_frame\n self._write_websocket_frame(message, opcode, 0)\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/aiohttp/_websocket/writer.py\", line 151, in _write_websocket_frame\n raise ClientConnectionResetError(\"Cannot write to closing transport\")\naiohttp.client_exceptions.ClientConnectionResetError: Cannot write to closing transport\n\nThe above exception was the direct cause of the following exception:\n\nTraceback (most recent call last):\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/livekit/plugins/sarvam/tts.py\", line 786, in _run_ws\n await asyncio.gather(*tasks)\n File \"/home/ubuntu/algotalks/DynamicContextAgent/.venv/lib/python3.12/site-packages/livekit/plugins/sarvam/tts.py\", line 736, in send_task\n raise APIConnectionError(f\"Send task failed: {e}\") from e\nlivekit.agents._exceptions.APIConnectionError: Send task failed: Cannot write to closing transport", "session_id": 138374040272048, "connection_state": "failed", "model": "bulbul:v2", "speaker": "anushka", "client_request_id": "12840c7fe812", "server_request_id": null, "room": "ivr-saurabh-1771946728", "pid": 157960, "job_id": "AJ_xCeYW4NuwBUt", "room_id": "RM_Dr8ZtnQZi7Ww", "timestamp": "2026-02-24T152643.302968+00:00"} {"message": "failed to synthesize speech: Connection error., retrying in 0.1s", "level": "WARNING", "name": "livekit.agents", "tts": "livekit.plugins.sarvam.tts.TTS", "attempt": 1, "streamed": true, "room": "ivr-saurabh-1771946728", "pid": 157960, "job_id": "AJ_xCeYW4NuwBUt", "room_id": "RM_Dr8ZtnQZi7Ww", "timestamp": "2026-02-24T152643.307817+00:00"} {"message": "Starting TTS WebSocket session", "level": "INFO", "name": "livekit.plugins.sarvam.log", "session_id": 138374040272048, "connection_state": "disconnected", "model": "bulbul:v2", "speaker": "anushka", "client_request_id": "407335089cf3", "server_request_id": null, "room": "ivr-saurabh-1771946728", "pid": 157960, "job_id": "AJ_xCeYW4NuwBUt", "room_id": "RM_Dr8ZtnQZi7Ww", "timestamp": "2026-02-24T152643.408803+00:00"} getting this error when the agent is called concurrently
t
Thanks for reaching out, Algoflow. I'm an AI assistant and I'm here to help. Give me a moment while I research this topic.
Thanks — I can see you're hitting repeated WebSocket errors from the Sarvam TTS plugin when the agent is called concurrently. I’ll help diagnose the likely cause and give concrete steps to reduce or eliminate the "Cannot write to closing transport" errors. Acknowledging what you sent • The error is raised inside
livekit/plugins/sarvam/tts.py
when
await ws.send_str(...)
runs and aiohttp raises
ClientConnectionResetError: Cannot write to closing transport
. • The failures appear during concurrent agent sessions and the plugin retries, e.g. "failed to synthesize speech: Connection error., retrying in 0.1s". • This looks like a race condition or lifecycle/connection-management issue where a WebSocket is being used while it is closing. What you can try next (ordered by easiest first) 1. Add defensive checks and catch the specific websocket/send error ◦ Before sending, check connection state if available and catch
aiohttp.client_exceptions.ClientConnectionResetError
and
ConnectionResetError
to retry or recreate the WS. ◦ Example pattern: i. Try send ii. On
ClientConnectionResetError
/
ConnectionError
, close and recreate the websocket and retry with backoff ◦ This prevents one send failure from crashing the task and provides a graceful reconnect. 1. Ensure each concurrent agent session uses an isolated TTS websocket instance ◦ Confirm your code does not share a single
ws
or TTS client instance between sessions; each session should create/own its TTS websocket. ◦ Shared instances cause one session’s close to affect others, producing exactly this symptom. 1. Improve retry/backoff with jitter ◦ Instead of immediate repeated retries, use short exponential backoff with jitter (e.g., 0.1s -> 0.3s -> 1s) so bursts of concurrent retries don’t overload the Sarvam endpoint. ◦ Limit retries to a small number and surface clear errors after failure. 1. Delay cleanup until audio sending is confirmed complete ◦ Make sure session shutdown/cleanup waits until all TTS sends and playback are finished. If cleanup triggers while sends are in-flight, you will see "closing transport" errors. 1. Add lifecycle logging around the websocket ◦ Log open, close, send attempts, exceptions, and the stack trace to identify whether closures come from your side, Sarvam, or network problems. ◦ This helps tell whether connections are being closed intentionally (cleanup) or unexpectedly (remote close or network). 1. Confirm Sarvam concurrent connection limits and health ◦ Check whether the Sarvam service imposes per-account/per-IP connection limits or aggressively closes connections under load. If it does, reduce concurrent websocket count or pool requests appropriately. 1. Verify dependency versions ◦ Ensure you are on modern versions of
aiohttp
,
livekit
, and the Sarvam plugin; connection handling bugs have been fixed in some aiohttp versions. Targeted code ideas • Wrap sends in a helper that checks and recreates the websocket on failure, e.g.:
Copy code
async def safe_send(ws, payload, recreate_ws_fn):
    try:
        await ws.send_str(payload)
    except (aiohttp.client_exceptions.ClientConnectionResetError, ConnectionError) as e:
        # close current if needed, recreate, then retry once
        try:
            await ws.close()
        except Exception:
            pass
        ws = await recreate_ws_fn()
        await ws.send_str(payload)
    return ws
• Use small exponential backoff with jitter for retries. Diagnostics I’d ask you to run (so I can help further) • Do concurrent calls each create their own TTS websocket, or do they share one? • How many concurrent TTS sessions are typical when you see the failure? • Which versions of
aiohttp
,
livekit
, and the Sarvam plugin are you running? • Do logs show the websocket was closed remotely (a close frame) or do they show local cleanup immediately before the send? • Can you reproduce reliably with a small test script that opens N concurrent sessions and performs TTS? If so, what N? If you share short answers to the diagnostics above and a minimal snippet showing how you create/manage the TTS websocket per session, I can suggest a concrete patch to
livekit/plugins/sarvam/tts.py
or a safe wrapper you can add to your agent to eliminate the race. If you prefer, I can also provide a more specific retry/backoff implementation or a suggested lifecycle sequence (open → send config → stream → finalize → close) you can adopt. Which would you like next? Sources: Sarvam TTS plugin guide | LiveKit Documentation | How to build an agent with speech-to-text input and text-only output | Live... | Server options | LiveKit Documentation
c
Thanks for the detailed response. I want to clarify a few things and provide more context to help narrow down the issue. Ruling out concurrency: The TTS is initialized per session, not shared across concurrent calls: python
Copy code
@server.rtc_session(agent_name="AlgoflowSurvey")
async def my_agent(ctx: JobContext):
    tts = create_sarvam_tts()  # fresh instance per session
    session = AgentSession(
        stt=stt,
        llm=llm,
        tts=tts,
        vad=vad,
    )
So the shared instance theory does not apply here. This happens within a single call: This is not a concurrency issue. The error occurs mid-conversation in a single active session, between two consecutive TTS synthesis requests (between agent turns). Key observations from logs: 1.
server_request_id: null
on the failed session — meaning Sarvam's server closed the WebSocket before acknowledging the request. The close is originating from the Sarvam side, not ours. 2. Same
session_id: 138374040272048
across all failed log lines — confirming this is within a single session, not across concurrent ones. 3. Both
send_task
and
recv_task
fail simultaneously — the transport was already closing when the plugin attempted to write. The plugin has no prior warning. 4. The retry succeeds in ~0.5s with a fresh connection (
client_request_id
changes from
12840c7fe812
to
407335089cf3
) — but during that 0.5s window the agent goes completely silent, causing a bad user experience. 5. The error sequence is:
ClientConnectionResetError: Cannot write to closing transport
failed to synthesize speech: Connection error., retrying in 0.1s
Fresh connection →
WebSocket session completed successfully
Does Sarvam's WebSocket server have an *idle timeout* between messages? If so, what is it? This would explain why the connection is closing between agent turns.
Can the plugin implement a
*proactive connection health check* — detecting remote close frames before attempting to send — so it reconnects before the next synthesis request rather than after it fails?
Can the
*retry delay be reduced or made configurable* below 0.1s to minimize the silence gap when this does happen?
Is there a
*keep-alive or ping mechanism* available in the plugin to prevent Sarvam's server from closing idle connections between turns?
1. Is this a
*known issue* in the current plugin version, and is there a fix planned or a workaround recommended for production voice agents?
Environment:
Plugin: livekit-plugins-sarvam (latest)
Model: bulbul:v2
•
Speaker: anushka
•
Use case: IVR/voice survey agent with multiple turns per call
t
Thanks — this is very helpful context. You’ve confirmed this happens inside a single session, the close is coming from Sarvam (server_request_id: null), retries create a fresh session, and the user-visible problem is the ~0.5s silence while the plugin reconnects. I’ll summarize what’s confirmed, what is and isn’t available in the current plugin, and practical, safe steps you can take now to minimize the gap. What’s confirmed from your logs • The close originates from the Sarvam side (no server_request_id recorded for the failed send). • The same
session_id
throughout shows this is mid-session, not cross-session interference. • The plugin detects the closed transport only when attempting to write (both send_task and recv_task fail together). • A fresh connection succeeds quickly, but the reconnect causes noticeable silence for callers. What the Sarvam TTS plugin currently does (and does not do) • The plugin uses a WebSocket streaming connection for TTS. • The plugin detects connection failure reactively (on send/receive failure) and then reconnects; your logs show the retry path that opens a new
client_request_id
. • There is no documented or exposed keep-alive/ping/heartbeat in the plugin to prevent idle timeouts. • There is no documented configurable pre-send health check or configurable retry delay exposed by the plugin (the 0.1s retry appears internal and not user-configurable). • Because the plugin lacks proactive health checks or keep-alive, the client only knows the transport is closed when it tries to write — which creates the silent gap. Likely root cause • Sarvam is very likely closing idle WebSocket connections (an idle timeout). That explains a remote close between turns, the null
server_request_id
, and why a fresh connection fixes it immediately. Practical mitigations you can apply now 1) Pre-warm the connection immediately before each turn • Send a very small/no-op or silent synthesis before the real TTS so you detect/recreate a closed WS and avoid the visible gap: ◦ Example pattern: try a tiny synth (a short silence or punctuation), then the real text. • This trades a negligible extra request for consistent low latency. 2) Implement a lightweight “idle-aware” wrapper around the plugin • Track last-used timestamp; if idle > threshold (e.g., 5–8s), proactively reinitialize the TTS connection before sending the next utterance. • This prevents the client from trying to write to a transport that Sarvam already closed. 3) Add pre-send safe-send logic with immediate reconnect • Attempt send; if it raises
aiohttp.client_exceptions.ClientConnectionResetError
(or
ConnectionError
), close and recreate the websocket and retry once immediately (no long backoff). • Keep this retry quick (retry once immediately, then a short backoff) so you minimize the silence window. 4) Backoff/jitter tuning (if you modify plugin) • If you control the retry code, use a two-step retry: immediate quick retry (reconnect) then a short backoff with jitter for subsequent retries. This reduces user-visible silence while avoiding thundering reconnect bursts. 5) Mask reconnection with fallback audio • If you can’t completely avoid the gap, play a short neutral prompt or tone while reconnecting so the caller doesn’t experience awkward silence. 6) Measure the idle timeout • Run a small test that opens a WS session, waits N seconds, then attempts a send. Increase N to find the idle timeout threshold. This helps tune your pre-warm/idle threshold. Longer-term fixes (recommended) • Add keep-alive/ping logic to the Sarvam plugin (or fork and add it): ◦ Periodic WebSocket pings or lightweight heartbeats during inter-turn silence would prevent Sarvam from closing the connection. • Expose configurable retry/backoff and an idle timeout guard in the plugin: ◦ Make the retry delay configurable and allow pre-send health checks. • Confirm with Sarvam what their idle timeout policy is and whether they recommend a recommended keep-alive interval. Suggested next steps I can help with • Provide a small safe-send wrapper you can drop into your agent to detect closed transports, reconnect, and retry with minimal delay. • Help draft a short test script to measure Sarvam’s idle timeout in your environment. • Help craft an issue / PR for the plugin to add keep-alive and configurable retry/idle parameters if you’d like to contribute a fix. Which would you like first: (A) a compact safe-send wrapper you can integrate now, or (B) a small idle-timeout measurement script to determine the timeout value? Sources: Sarvam TTS plugin guide | LiveKit Documentation | Agents Framework | livekit/livekit_composite | DeepWiki | Agents (Python) | livekit/livekit_composite | DeepWiki | examples/voice_agents | livekit/livekit_composite | DeepWiki
c
what is the production safe solution that I can go with for this timeout issue? this should be handled inside livekit plugin
t
Thanks — I understand you need a production-safe fix implemented inside the LiveKit Sarvam TTS plugin so callers no longer hear the reconnect silence when Sarvam closes idle WebSocket connections. Below is a clear, prioritized, production-ready design you can implement inside the plugin (or request as a PR). It focuses on reliability, minimal latency, and safe resource usage. Summary of the production goal • Prevent or eliminate audible gaps when Sarvam closes an idle WS between agent turns by making the plugin detect/avoid remote closes and reconnect with minimal delay. • Do this inside the plugin (no app-level hacks) and make behavior configurable for production tuning. Recommended production-safe solution (overview) 1. Connection liveness + keep-alive (primary) 2. Proactive health checks before send (fast fail/soft-reconnect) 3. Immediate reconnect + bounded retry strategy (minimize silence) 4. Configurable parameters and metrics/logging (production control) 5. Graceful shutdown and resource limits (safety) Detailed implementation plan (recommended order) 1) Keep-alive / heartbeat • Add periodic WebSocket ping/pong or lightweight heartbeat frames while the session is idle. • Implementation: ◦ After opening the WS, start a background task that sends
ping
(or a small JSON heartbeat if server requires application-level) every configurable interval (e.g., 5s). ◦ Reset last-pong/last-activity timestamp on any pong/recv. ◦ If pong not received after N intervals (configurable), mark the connection unhealthy and initiate reconnect logic. • Rationale: Prevents the server from classifying the socket as idle and closing it between turns. 2) Pre-send health check (fast path) • Before each synth send: ◦ Check a small internal
is_alive
flag derived from the last pong/recv timestamp and WS state. ◦ If
is_alive
is false (or WS.closed), proactively recreate the WS synchronously before sending. ◦ If
is_alive
is true, proceed to send. • Rationale: Avoid writing to a transport that is already closing—this prevents the ClientConnectionResetError. 3) Safe-send with immediate reconnect and bounded retries • Implement a
safe_send
wrapper used for all outgoing messages: ◦ Try sending once. ◦ On
aiohttp.client_exceptions.ClientConnectionResetError
,
ConnectionResetError
,
BrokenPipeError
, or `ConnectionError`: i. Immediately cancel pending send task(s). ii. Close WS gracefully (ignore errors). iii. Recreate WS synchronously (one immediate reconnect attempt). iv. Retry send once. ◦ If still failing, perform 1–2 additional retries with a short exponential backoff + jitter (configurable; e.g., 50–200ms). ◦ Surface clear errors to the agent after exhausted retries. • Rationale: Immediate reconnect minimizes user-perceived silence; bounded retries and backoff avoid thundering reconnects under load. 4) Configurable parameters (expose to integrators) • Expose plugin config options with safe defaults: ◦ heartbeat_interval (default 5s) ◦ heartbeat_timeout / max_missed_heartbeats (default 2) ◦ pre_send_idle_threshold (e.g., 1.5x heartbeat_interval) ◦ reconnect_retries (default 3) ◦ reconnect_backoff_base_ms (default 50) ◦ reconnect_backoff_max_ms (default 300) • Rationale: Different deployments (IVR vs. chatty agents) can tune for cost vs. latency. 5) Connection lifecycle and resource safety • Pooling: keep per-session WS (as you already do). Do not share across sessions. • Graceful shutdown: ensure background heartbeat and health tasks are cancelled on session close; cleanly close WS. • Rate limiting: prevent excessive reconnect loops by capping reconnects per minute per session and emitting a metric/error when exceeded. • Rationale: Avoid runaway resource use or DDOSing Sarvam under persistent failures. 6) Observability, metrics, and logging • Emit structured logs / metrics for: ◦ heartbeats sent, pongs received, missed heartbeats ◦ proactive reconnects vs. reactive reconnects ◦ reconnect latency and retry counts ◦ final failures (so operators can detect systemic issues) • Rationale: Allows ops to tune heartbeat interval and detect server-side timeouts or network instability. 7) Optional: Application-level “keepalive” if server needs it • If Sarvam does not honor WS-level pings or drops pings, use a light application-level keepalive message that Sarvam will acknowledge. • This requires agreement with Sarvam (or testing to ensure such messages are accepted). Safety & correctness considerations • Default heartbeat must be conservative (e.g., 5s) to minimize overhead and cost. • Keep reconnect attempts bounded and add exponential backoff + jitter. • Ensure all background tasks are cancelled on session end to avoid leaks. • Add unit/integration tests simulating server close between turns and verifying no audible gap beyond target (e.g., <150ms for reconnect where possible). Suggested minimal change set (concrete) 1. Add a Heartbeat task in Sarvam TTS client class: ◦ start: after ws open -> spawn heartbeat coroutine ◦ heartbeat coroutine: ▪︎ loop: await sleep(heartbeat_interval); send ping or heartbeat frame; wait for pong or timeout; if timeout increment missed; if missed >= limit -> mark unhealthy and call reconnect 1. Add safe_send wrapper used by send_task to pre-check
is_alive
and to handle immediate reconnect on write error. 2. Add plugin config options and environment-based defaults. 3. Add metrics/logging and unit tests. 4. Release patch, and document config options. Why this is production-safe • Proactive heartbeats keep connections alive across normal inter-turn silence. • Pre-send checks prevent writing to a transport that is closing. • Immediate reconnect minimizes user-perceived silence; bounded retries and backoff protect server and client resources. • Tuning parameters let you find balance for IVR latency vs. connection cost. If you’d like, I can: • Provide a concrete patch outline (code sketch) for the Sarvam plugin showing: ◦ heartbeat implementation (aiohttp ping or app-level keepalive), ◦ safe_send wrapper, ◦ config plumbing, and ◦ test scenarios. • Draft a PR description you can use when contributing this change to the plugin repo. Which would you prefer next — a focused code sketch of the heartbeat + safe_send implementation, or a ready-to-submit PR description and test plan?