I’m running a Python LiveKit Agent (v1.5.7) on LiveKit Cloud and handling SIP calls.
Occasionally, I see the following warning: livekit_api::signal_client:530:livekit_api::signal_client - dropping pass-through signal — no stream available
```
{
“message”: “livekit_api::signal_client:530:livekit_api::signal_client - dropping pass-through signal — no stream available”,
“level”: “WARNING”,
“job_id”: “AJ_d8hntNqCScKD”,
“room_id”: “RM_gdUKfrou73q4”,
“timestamp”: “2026-06-23T18:10:20.719385+00:00”
}
```
Around the same time, I sometimes observe that a room appears to have been closed/disconnected, but my agent does not receive the expected disconnect-related events. As a result, cleanup logic is not triggered and the session can remain in an inconsistent state.
Happy to provide additional logs if helpful.
Hey Sahil, welcome to the community ![]()
Short version: that warning is a symptom of the room being torn down under your agent, not the cause — and the reason your cleanup doesn’t fire is that the close you receive is a ROOM_CLOSED (empty-room timeout), which the framework’s auto-close doesn’t treat as a participant hangup.
What the warning means
dropping pass-through signal — no stream availablecomes from the Rust signal client (livekit-api/src/signal_client/mod.rs). When the agent tries to send a signal while the signal WebSocket isn’t currently up, the SDK either queues the message or — for “pass-through” messages that are only valid on a live connection — drops it and logs this. So it’s emitted while the connection is already going away. Benign on its own.
Why cleanup doesn’t run
The agents framework only auto-closes the session when the linked participant disconnects with one of:
CLIENT_INITIATEDROOM_DELETEDUSER_REJECTED
(see DEFAULT_CLOSE_ON_DISCONNECT_REASONSin voice/room_io/types.py).
A SIP caller hanging up (BYE) normally surfaces as CLIENT_INITIATED, which would close the session immediately. But in the failure case, that caller-disconnect event apparently never reaches the agent. The room then sits idle until the server’s empty/departure timeout closes it and evicts the agent with ROOM_CLOSED — which is not in the list above, and isn’t a per-participant disconnect, so neither the framework’s auto-close nor any handler you keyed on participant_disconnectedruns. That’s the inconsistent state you’re seeing, and the dropped pass-through signal is the same unhealthy-signal-link condition showing up on the send side.
What to do
- Don’t rely solely on
participant_disconnected— that’s exactly the event that goes missing here. Add reason-agnostic teardown so cleanup runs no matter how the call ends:ctx.add_shutdown_callback(...)- and/or
room.on("disconnected")
- Look for a blocked event loop. “Events not delivered + pass-through dropped” usually means the rtc/asyncio loop the SDK feeds is stalled — most often a long synchronous call inside an event callback. Move blocking work off the loop (
run_in_executor/ make it async). - If you’re also doing cleanup via webhooks, verify they’re actually being delivered — if the agent path and the webhook path both fail, you lose both safety nets.
You could consider something like this:
from livekit import rtc
from livekit.agents import JobContext
async def entrypoint(ctx: JobContext):
await ctx.connect()
# 1. Reason-agnostic cleanup — fires on ANY job shutdown, including the
# ROOM_CLOSED empty-timeout case where participant_disconnected never arrives.
async def cleanup(reason: str):
# release DB rows, external sessions, telephony state, etc.
# MUST be idempotent — may also be reached via the handlers below.
...
ctx.add_shutdown_callback(cleanup) # callback may take an optional `reason: str`
# 2. Belt-and-suspenders: catch the room going away directly.
@ctx.room.on("disconnected")
def _on_room_disconnected(*args):
# sync handler — schedule async work, never block the event loop
... # e.g. asyncio.create_task(cleanup("room disconnected"))
# 3. If you specifically want "caller hung up" handling, watch the SIP
# participant and don't rely only on the framework's auto-close.
@ctx.room.on("participant_disconnected")
def _on_participant_disconnected(p: rtc.RemoteParticipant):
if p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP:
reason = rtc.DisconnectReason.Name(
p.disconnect_reason or rtc.DisconnectReason.UNKNOWN_REASON
)
# log it, then trigger cleanup / end the session
...
session = AgentSession(...)
await session.start(agent=..., room=ctx.room)
# 4. AgentSession also emits a close event with a structured reason.
@session.on("close")
def _on_close(ev): # ev.reason is a CloseReason
...
Thanks for detailed explanation.
We have been facing this issue quite frequently since last week.
We noticed that the execution was getting stuck on await ctx.room.sid, which was causing the entrypoint task to be cancelled.
To mitigate this, we implemented the following fix:
- Wait for
ctx.room.sidwith a timeout. - If it times out, fall back to
ctx.job.room.sid. - Backfill the room SID by updating
room._info.sidand resolvingroom._first_sid_future.
After applying this fix, the issue is no longer blocking our flow, and we are successfully receiving all room hook events.
We have also observed that this issue has been occurring very frequently since last week. We’re trying to understand the root cause and whether there have be.
@sahil.tulani, The root cause is a known race, already fixed: await room.sid could hang even when the SID was already in room._info.sid [ livekit/python-sdks#713, merged 2026-06-16 ], which is exactly why backfilling room._info.sid cleared it for you. The fix shipped in the livekit (rtc) package v1.1.11 (2026-06-23), so bump livekit to >=1.1.11 and you can drop the private-field part of your workaround.
Your wait_for + ctx.job.room.sid fallback are fine to keep. Current room.sid wraps the wait in asyncio.shield so a caller-side asyncio.wait_for(ctx.room.sid, timeout) is safe and won’t cancel the shared future for other waiters [ livekit/python-sdks rtc/room.py ], which means the whole thing collapses to:
# after bumping `livekit` >= 1.1.11
try:
sid = await asyncio.wait_for(ctx.room.sid, timeout=5)
except asyncio.TimeoutError:
sid = ctx.job.room.sid # job-assigned room info
# no more room._info.sid / room._first_sid_future backfill
The hang explains the rest: the entrypoint blocked on room.sid and got cancelled, so your room hooks never registered. The pass-through-drop warning is the same dying-signal-link condition on the send side, as noted above.