SUBSCRIBER transport SCTP negotiation never completes for ESP32 client (server never replies to SCTP INIT), while PUBLISHER transport succeeds every time
LiveKit server: SUBSCRIBER transport SCTP negotiation never completes for ESP32 client (server never replies to SCTP INIT), while PUBLISHER transport succeeds every time
Summary
Using livekit/client-sdk-esp32 (v0.3.10) as a pure listener (subscribe-only,
never publishes), the client's SUBSCRIBER peer connection's SCTP
handshake never completes: the client sends repeated SCTP INIT chunks
(DTLS-wrapped, ~162ms apart) but the server never replies on that transport's
UDP port pair, until the server's own negotiationFailedTimeout (15s) fires
and the participant is disconnected with reason: "negotiation timed out",
explicitly tagged "transport": "SUBSCRIBER" in the server's own log.
In the same connection attempt, seconds earlier, the client's
PUBLISHER transport (created unconditionally by the SDK even though this
device never publishes anything) completes its own SCTP handshake and opens
its _reliable/_lossy data channels with no issues at all - every single
time, across 9+ repeated attempts observed.
This asymmetry - identical client, identical server, identical room, one
transport works and the other never gets a reply - points to something
server-side treating the two transports differently, or the SUBSCRIBER-role
SCTP client behavior of esp_peer's precompiled implementation sending
something the server never recognizes as a valid INIT on that specific
transport.
Net effect: Room state reports Connected (since that only reflects
PUBLISHER success), but zero real RTP audio ever reaches the client -
confirmed via added diagnostic logging on every audio frame callback, across
multiple full connection cycles, including while real audio was actively
being sent by another participant the whole time.
Environment
- Server: LiveKit server v1.13.1 (self-hosted, Docker), config is close
to default (
room.empty_timeout: 300,room.departure_timeout: 20, no custom RTC/negotiation timeout overrides) - Client:
livekit/client-sdk-esp32v0.3.10, on ESP32-S3, ESP-IDF v5.4 - Client role: subscribe-only (
room_options.subscribe.kind = LIVEKIT_MEDIA_TYPE_AUDIO,room_options.publishleft zeroed /LIVEKIT_MEDIA_TYPE_NONE) - Two unrelated client-side SDK issues were found and worked around in our
own patched copy of
managed_components/livekit__livekit/core/(not believed to be related to this issue, but noting for completeness since they touch the same negotiation path):esp_peer's audio-codec reporting always returnsESP_PEER_AUDIO_CODEC_NONEtoon_peer_sub_audio_info()even when a real codec (PCMA/8000) was negotiated - worked around by assuming PCMA/8000 when NONE is reported.esp_peer's SDP-answer generation writesa=inactiveinstead ofa=recvonlyfor a RECV_ONLY-only audio stream, even though the direction flag going in is correct - worked around with an in-place string substitution on the outgoing SDP text. Both are still present with these client-side workarounds applied when the SUBSCRIBER-transport issue below was captured - i.e. the SDP the server receives already saysa=recvonlycorrectly, and the codec is correctly PCMA/8000 on both sides' SDP, ruling those two out as the cause of this separate issue.
Repro steps
- Connect a subscribe-only
client-sdk-esp32client to a room with another participant actively publishing audio. - Observe: PUBLISHER transport (client-side, always created even for a subscribe-only room) completes DTLS+SCTP and opens its data channels within ~100-200ms of ICE connectivity.
- Observe: SUBSCRIBER transport's own DTLS handshake also completes fine
(
DTLS: Client handshake success), but its SCTP handshake never receives a reply - client sendsSCTP INITrepeatedly with no response until the server's connection-level 15s negotiation timeout fires and force-disconnects the participant. Room statebriefly reportsConnectedin between (reflecting only the PUBLISHER transport's success), then goesFailed~15-24s later withFailure reason: State Mismatch.- This repeats on every reconnect attempt - not a one-off race, confirmed across 9+ consecutive cycles in a single session.
Evidence
1. Server's own log (exact matching event)
2026-08-31T08:30:57.879Z INFO livekit.transport rtc/transport.go:3010 negotiation failed
{"transport": "SUBSCRIBER", "reason": "negotiation timed out",
"localCurrent": {sdp includes "a=candidate:... udp 2130706431 <server-ip> 50142 typ host ..."},
...}The server itself tags this failure as the SUBSCRIBER transport, and the
SDP in the log confirms the local (server-side) candidate for that
transport is UDP port 50142.
2. Client's own log (same connection attempt, matching timestamps)
I (...) livekit_peer.sub: State changed: 0 -> 1
I (...) AGENT: 1 0 Send binding request (cand:0) local0:<client-lan-ip>:54396 remote0:<server-ip>:50142 ...
...
I (...) PEER_DEF: Sctp role as 1
I (...) SCTP: 1 state 1
I (...) SCTP: Send INIT chunk <- repeats every ~162ms, ~90 times, no reply ever logged
I (...) SCTP: Send INIT chunk
... (continues until server's 15s timeout)Compare to the PUBLISHER transport in the same connection attempt, which succeeds within ~150ms:
I (...) PEER_DEF: Sctp role as 0
I (...) SCTP: 0 Receive chunk 1 SCTP_INIT
I (...) SCTP: 0 state 2
I (...) SCTP: Send INIT_ACK chunk
I (...) SCTP: 0 Receive chunk 10 SCTP_COOKIE_ECHO
I (...) SCTP: Send ECHO_ACK chunk
I (...) SCTP: 0 state 5
I (...) livekit_peer.pub: Channel open: label=_reliable, stream_id=1
I (...) livekit_peer.pub: Channel open: label=_lossy, stream_id=33. Wire-level packet capture (server host, tcpdump, filtered to exactly port 50142)
<client-pub-ip>.54396 > <server-ip>.50142: UDP, length 77 [repeats every ~162ms]
<server-ip>.50142 > <client-pub-ip>.54396: UDP, length 100 [STUN keepalive, ~every 3s]
<server-ip>.50142 > <client-pub-ip>.54396: UDP, length 64 [STUN, occasional]
... (no other server->client packets for the entire ~15s window - specifically,
never a reply-sized packet consistent with an SCTP INIT_ACK)
<server-ip>.50142 > <client-pub-ip>.54396: UDP, length 39 [at the very end - likely the close/reset]The 77-byte packets sent every ~162ms are the DTLS-wrapped SCTP INIT chunks logged by the client. The server never sends anything on this port that looks like an SCTP response (only STUN keepalives at a much slower, unrelated cadence) for the entire negotiation window, confirming this is genuine server-side silence on the wire - not a client-side misinterpretation of a real reply.
Expected behavior
The SUBSCRIBER transport's SCTP handshake should complete the same way the PUBLISHER transport's does in the same connection attempt (INIT_ACK -> COOKIE_ECHO -> ECHO_ACK -> data channel open), allowing the room to reach a genuinely fully-connected state and RTP media to flow.
Actual behavior
The SUBSCRIBER transport's SCTP INIT is never answered by the server. The
room reports Connected (misleadingly, since that only reflects the
PUBLISHER transport), no RTP audio is ever received, and the whole
connection is force-reset by the server's own 15s negotiation timeout,
repeating indefinitely on reconnect.
Additional notes
- This is not a one-off: observed across 9+ consecutive reconnect attempts in one session, always with the identical signature (SUBSCRIBER SCTP never gets a reply, PUBLISHER SCTP always succeeds).
- Confirmed via a dedicated diagnostic (counting every
on_audio_framecallback invocation) that literal zero RTP audio frames are ever received by the client across this entire test session, including while another participant was actively publishing real audio (an interview recording) for large portions of the test. - Happy to provide the full raw logs (client + server) and pcap file for this session if useful - trimmed above for readability, IPs partially redacted from this write-up but available in the raw capture.
Source: livekit/livekit