WebRTC receive-task wedge — incident research / runbook
One-line: On an affected pod, every new WebRTC endpoint silently fails to establish media — signaling succeeds, the endpoint's UDP socket is bound, but nothing ever reads it, so ICE never completes, str0m never replies, and the call dies with endpoint media timeout. Control plane, /health, /metrics stay perfectly healthy. A pod restart fixes it; it recurs.
This is the failure the commit "Add telemetry for WebRTC accept_answer wedge investigation" (11e41e8) was chasing. First fully diagnosed 2026-06-02. Root-cause theories re-ranked 2026-06-09 from retained logs/metrics — see §3; the datapath branch is now the front-runner, and the §4 supervision counters are expected to stay silent during a recurrence (§4 expectation note).
Current status (2026-07-06)
Status: not root-cause closed, but no recurrence observed in recent production Grafana data.
Production Mimir/Loki review on 2026-07-06:
2026-06-22T21:33Z→2026-07-06T21:33Z: nortpbridge_webrtc_recv_task_start_timeout_total,rtpbridge_webrtc_recv_task_dead_total, orrtpbridge_webrtc_recv_overflow_totalincreases onrtpbridge-0/1/2.rtpbridge_webrtc_recv_task_started_totalcontinued climbing normally on all pods (~30k per pod), and packet routing remained healthy.- Same window had one
WebRTC negotiation stuck past watchdog thresholdlog onrtpbridge-0at2026-07-03T14:10:20Z. Zoom-in showed a single-session failure, not this pod-wide wedge: the WebRTC endpoint reachedConnectedtwice, that session routed packets around 100 pps, the pod returned to zero active sessions, and the same pod created new WebRTC endpoints and routed ~40k packets in the following hour. 2026-06-08T21:33Z→2026-06-22T21:33Z: no recv-task supervision counter increases, no recv overflow, nowebrtc_connecting_stuckincrements, and no matching watchdog or recv-task liveness logs. Media timeouts existed but were distributed across pods and did not match the pod-wide blackhole signature.- Exact 14 days before the July 3 warning (
2026-06-19T14:10:20Z→2026-07-03T14:10:20Z) also showed zero recv-task supervision counter increases, zero recv overflow, zerowebrtc_connecting_stuckincreases, and no matching Loki warning logs.
Interpretation: keep this as an active incident-research/runbook document. The recent evidence supports "quiet since the supervision work," not "fixed." A true closure still needs either a captured recurrence that pins the trigger or a follow-up that gives the readiness probe a direct media-path/self-probe signal.
1. How it presents
- Users on calls routed to one pod: "dropped calls", "no audio then it hangs up". Intermittent across the fleet because
rtpbridge-lbspreads calls over all pods — only the calls that land on the wedged pod fail (≈1/N). - Client (softphone) logs: ICE →
failed, "no active candidate pair found", slow/relayed-only candidate gathering,cRequestIceRestart, then teardown. - Bridge logs: SIP + SDP complete (INVITE→200→ACK, answer relayed, DTLS role stored), then
endpoint.media_timeout, zero packets. - rtpbridge (the wedged pod):
WebRTC negotiation stuck past watchdog threshold,endpoint media timeout duration_ms≈5000, endpoints stuckConnecting. - No panic, no ERROR, no restart in the pod's logs. The wedge is silent and develops mid-life (not at startup).
Blast radius that confirms it's this
- Multiple devices on different networks fail → server-side, not client/network.
- Isolated to one pod:
rtpbridge_packets_routed_totalis ~flat for the bad pod while it still holdsrtpbridge_sessions_active> 0; peers route media normally. - coturn on the same pod/IP answers STUN + TURN Allocate fine → not the node, not the network, not coturn.
2. Confirm it fast (≈2 min)
Supervision metrics (after the supervision fix):
# Any non-zero value means a live endpoint lost its WebRTC UDP reader.
increase(rtpbridge_webrtc_recv_task_start_timeout_total[15m])
or
increase(rtpbridge_webrtc_recv_task_dead_total[15m])For the never-started variant specifically:
increase(rtpbridge_webrtc_recv_task_start_timeout_total[15m])Also useful:
rtpbridge_webrtc_recv_task_started_totalflattening while calls keep arriving means new recv tasks are not reaching their receive loop. If it continues climbing, the task-start path is alive and the failure is further downstream (socket readiness or packet delivery).rtpbridge_webrtc_connecting_stuck_totalclimbs;..._packets_routed_totalflat;rtpbridge_sessions_active{pod=...}> 0 with no packets.- Grep the pod's logs for
recv task never started within the grace window/recv task is gone but endpoint is still active.
Live probe (reproduces it on demand, A/B against a healthy pod):
# Control WS is cluster-internal; port-forward the SUSPECT and a HEALTHY pod.
kubectl -n zynotalk-cluster port-forward pod/rtpbridge-<bad> 9111:9100 &
kubectl -n zynotalk-cluster port-forward pod/rtpbridge-<good> 9112:9100 &
cargo run --example media_probe -- --ws 127.0.0.1:9111 --label bad --secs 9
cargo run --example media_probe -- --ws 127.0.0.1:9112 --label good --secs 9examples/media_probe.rs connects to the control WS, creates a WebRTC endpoint, acts as a full-ICE str0m peer, and sends real SRTP. On a healthy pod you see ICE: CONNECTED and rtpbridge's inbound.packets climbing. On a wedged pod you see ICE: NOT CONNECTED — the endpoint's media socket never answered.
3. Root cause
The WebRTC UDP receive path is per-endpoint and fire-and-forget:
- Each endpoint binds its own UDP socket (
new_with_socket,src/session/endpoint_webrtc.rs) and spawns a task that loops onsocket.recv_from()→ forwardsInboundPacketto the session'spacket_tx(start_recv_task). The session driver drainspacket_rx(run_media_session,src/session/media_session.rs) and feeds str0m viaInput::Receive. - str0m is downstream of this. If the recv task never reads the socket, str0m never sees the inbound STUN, never validates a candidate pair, never replies, never emits
Connected. The call then trips the media-timeout reaper.
Decisive evidence for the blackhole (strace of the pod's single tokio worker while a probe sent STUN to the endpoint's socket): the socket is bind+getsockname'd, then zero recvfrom/sendto on it, ever, while the same worker concurrently and healthily services the control WS, /health, /metrics. So the process never services that endpoint socket, while the rest of the process runs fine. By itself, that trace does not distinguish "task never polled the socket" from "socket readiness/packet delivery never arrived" (note the fd is registered at bind time regardless — see §5 step 4); §5 has the captures that separate those cases.
Because creation only tokio::spawns the task and returns the SDP without confirming the task ever started, this was completely silent — the endpoint looked created, but its media leg was dead on arrival.
What is not the cause (ruled out)
- Not coturn (live STUN + Allocate both succeed on the same pod/IP). NOTE this only vouches for coturn's own ports — coturn shares the pod's netns, so it proves UDP reaches the pod on its well-known port, nothing about the arbitrary high ports rtpbridge endpoints bind. It does NOT clear the node datapath.
- Not a deadlock or spin (the worker sits idle in
epoll_wait;/procthread state identical to a healthy pod). - Not a panic (none logged in the pod's entire life; the recv task also
catch_unwinds and would logrecv task panicked). - Not str0m credential divergence (that would still read the socket then reject; here the socket is never read). The unrelated
pending_offer/ ICE-restart-generation work is a different, per-call bug — see theice_restartguard +offer_generation— not this process-wide wedge. - Not a tokio 1.52 regression: the investigation telemetry (
11e41e8, 2026-05-13) predates the 1.50.0 → 1.52.3 bump (2026-05-15), so the wedge existed on both versions.
Still open: the exact trigger — re-ranked 2026-06-09
We proved the symptom (socket never read by the process) and the defect (unsupervised fire-and-forget recv loop), but not the trigger. A 2026-06-09 re-analysis of the June 2 incident from retained Loki/Mimir data (plus an adversarial second review) re-ranked the theories. Key evidence:
- Idle onset. The wedged pod (up since Jun 1 ~17:00Z) routed media normally until Jun 2 ~00:40Z, then sat idle. Synthetic prober cycles completed cleanly at 01:15, 02:24, 02:25 (7–13 s each); the 06:21 cycle took 30 s (prober timeout?); the 10:13:13 probe got its SDP but its session was never destroyed. Onset is bounded to 02:25–10:13Z (likely ≤06:21) with ZERO log lines at the transition and zero traffic — overload/backpressure triggers are out.
- The runtime kept scheduling new tasks and serving new TCP fds during the wedge. At 10:13 and ~12:24, brand-new control-WS TCP connections (and the freshly spawned session tasks behind them) worked end to end. So the scheduler and reactor were not globally dead. Caveat (from the adversarial review): TCP and UDP share the driver but not identical wait states (per-fd
ScheduledIo, edge-triggered readiness), so a UDP-side per-fd divergence is not fully excluded by this. - Registration is NOT the recv task's job. Verified against tokio 1.52.3 / mio 1.2.0 sources:
UdpSocket::bind→PollEvented::newregisters the fd (EPOLL_CTL_ADD) at construction, on the session task, insidecreate_offer/from_offer— before the recv task is spawned. Theory 1's "never registers the fd" framing conflated two things. - Port spread. Failed-endpoint ports (40833, 33081, 51836, RTP 23032) interleave with healthy-period ports (34108, 35726, 57228): if it's datapath, it is a generalized UDP delivery failure to the pod, not a static port-range DNAT steal.
- "Restart fixes it" does not isolate it in-process: the Jun 2 mitigation was a pod delete; pod churn is exactly what makes svclb/kube-proxy reprogram node NAT/conntrack state. There was also node-level churn inside the onset window (kube-state-metrics moved instances ~08:00Z), and the May 15 burst hit two pods simultaneously (caveat: contaminated by the watchdog false-positives fixed in
fd76dbdthe same day). - The first Jun 2 casualty was a plain RTP endpoint, so the wedge is probably not WebRTC-specific — weak corroboration only, since RTP recv tasks still use blocking
send().awaitand could park on a full channel instead.
Theories, re-ranked (was 1 > 2 > 3):
- Packets never reach the socket (node/datapath: NAT, conntrack, svclb / kube-proxy reprogramming, kernel drop) — now MOST probable. Fits the strace exactly (idle
epoll_wait, zerorecvfrom= nothing to deliver), survives the coturn and restart counter-arguments per above. - Recv task polls, but readiness/waker delivery is broken for UDP fds — still viable; no known tokio/mio bug of this shape in 1.50–1.52, but the new-TCP evidence doesn't fully exclude a per-fd UDP divergence.
- Recv task never polled (scheduler loses the spawn) — least likely: during the wedge every other freshly spawned task ran, and the idle worker cuts against starvation. Not formally dead (the June 2 incident predates the
recv_startedtelemetry, so "the task was polled" is inference, not data).
The supervision metrics now discriminate 3 from 1/2 automatically on the next recurrence: theory 3 ⇒ start_timeout fires; theories 1/2 ⇒ counters stay silent while calls fail (see §4 note). The 1-vs-2 split needs §5's captures on a still-wedged pod.
4. The supervision fix (what changed)
Goal: turn a silent, multi-hour, hard-to-attribute blackhole into a loud, observable, attributable failure — and prevent one variant outright. (src/session/endpoint_webrtc.rs, src/session/media_session.rs, src/metrics.rs.)
- Liveness flag + sweep (off the hot path). The recv task flips
recv_startedthe instant it reaches its loop, andstart_recv_taskarms arecv_start_deadline(grace =RECV_TASK_START_GRACE, 2s). The session's reliable 1 Hz maintenance pass (the elapsed-gated block that also runs the connecting-watchdog — NOT the starvablesleepselect arm) callssupervise_recv()per WebRTC endpoint, which — once per endpoint — flags and counts two failure modes: a task that never reached its receive loop (flag still false past the deadline → the never-started variant) and a task that started then died (JoinHandle::is_finished()while the endpoint is still active). This coverscreate_offer,from_offer, AND the transfer-restart path uniformly, and never blocks creation or the session task. (An earlier draft awaited the start signal insidecreate_offer; on a wedged endpoint that 2s await would stall co-session endpoints' media/commands — the session is one task — so it was moved to the sweep.) - Non-blocking forward.
packet_tx.send(..).await→try_send. A full session channel can no longer park the reader (which stops it servicing the socket and blackholes the endpoint — the very failure mode). Packets are dropped under backpressure and counted. This drops STUN/DTLS as well as RTP/SRTP: a full 256-deep channel is itself an overload signal, and dropping a setup packet beats wedging the socket — but under sustained cross-endpoint backpressure it can slow ICE/DTLS setup. Class-aware priority for STUN/DTLS is a follow-up (§6). - Metrics (all
rtpbridge_…_total):webrtc_recv_task_started— recv tasks that reached their receive loop (task-start heartbeat; flattening while calls arrive means the never-started variant).webrtc_recv_task_exited— recv loops that exited cooperatively (cancellation / session-close / UDP error). NOTDrop-aborts, so it sits below_startedrather than mirroring it; a spike = abnormal exits.webrtc_recv_task_dead— liveness sweep found a finished recv task while the endpoint was still active. Non-zero ⇒ live endpoint has no UDP reader.webrtc_recv_task_start_timeout— endpoints whose recv task never started within the grace window. Non-zero ⇒ the never-started variant.webrtc_recv_overflow— packets dropped on a full session channel.
Detection for the supervised variants lands within ~grace + one sweep (≈2–3s), before the call's endpoint media timeout (5s). It makes those failures visible and attributable; it does not prove or auto-recover a readiness/datapath failure where the recv task started and stayed alive. See §5/§6.
Expectation (2026-06-09 re-analysis): under §3's now-leading theories the recv task starts fine and parks forever in recv_from, so on the next wedge start_timeout/dead likely stay at ZERO while recv_task_started keeps climbing and calls fail. Counters at zero since the Jun 3 deploy means "no recurrence yet" (connecting_stuck is also zero), NOT "fixed". Don't let the silent supervision counters talk you out of the diagnosis — confirm with the §2 probe.
5. Diagnosing a recurrence — capture BEFORE you restart
The pod restart is the mitigation, but it erases the evidence. If you can spare the wedged pod for ~5 min, grab these (they are what's still missing to close §3's open trigger). All read-only.
- Pin the pod & a live endpoint, and note the NODE. Run the probe (§2) against the bad pod; note the
rtpbridge media candidate = <ip>:<port>it prints, and recordkubectl get pod -o wide(node identity matters if the datapath theory is right — check whether wedges correlate with a node or with svclb/kube-proxy/pod churn on it in the onset window). - THE decisive first split — Recv-Q + drops on that endpoint socket, while the probe sends continuously:bash
# In the rtpbridge container (or a debug sidecar sharing its netns), # sample a few times while the probe is actively sending STUN: # rx_queue grows => kernel has packets, app isn't reading => task/readiness branch (stop chasing datapath) # rx_queue 0, drops 0 => packets never arrive => datapath branch # rx_queue 0, drops grow => kernel is dropping at the socket => buffer/filter branch grep <hex(port)> /proc/<pid>/net/udp # rx_queue and drops columns - Datapath branch: packet-capture at each hop until the packets vanish — host NIC → (pod veth, if not hostNetwork) → inside the pod netns (
tcpdump -ni any udp port <port>from the debug container) — plusconntrack -L | grep <port>,iptables-save/nft list rulesetdiffed against a healthy node. Rules inspection alone can miss the drop point; the capture shows it. - Task/readiness branch: strace the worker (needs
CAP_SYS_PTRACE; the container has none, so use an ephemeral debug container):bashInterpretation (corrected 2026-06-09): the fd iskubectl -n zynotalk-cluster debug <pod> --image=nicolaka/netshoot \ --target=rtpbridge --profile=sysadmin -c dbg --attach=false -- sleep 3600 # then, while a probe drives STUN at the endpoint socket: kubectl -n zynotalk-cluster exec <pod> -c dbg -- \ timeout 15 strace -f -p 1 -e trace=%network,epoll_ctl,epoll_wait,recvfrom,sendto -yy -ttEPOLL_CTL_ADD-ed atUdpSocket::bindtime on the session task, BEFORE the recv task spawns — verified against tokio 1.52.3/mio 1.2.0 sources. So the ADD being present says nothing about whether the recv task ran (the old "no ADD ⇒ task never ran" inference was wrong; an absent ADD would instead mean bind-time registration failed, whichcreate_offerwould have surfaced). What this capture CAN show:epoll_waitreturning events for the endpoint fd (-yydecodes fds) with no subsequentrecvfrom⇒ readiness arrives but the waker/poll path is broken (§3 theory 2); no events for that fd at all while Recv-Q grows would be kernel-internal and warrants a tokio/mio bug report. Combine with the supervision counters:start_timeoutfired ⇒ never-polled variant (§3 theory 3). - Thread state (
/proc/1/task/*/{stat,wchan,syscall,schedstat}) — confirm the worker is idle inep_polland not spinning/futex-blocked (rules deadlock/spin in or out vs a healthy pod). - After capture, restart (§6).
When this trigger is finally pinned, update §3 and link the fix.
6. Mitigation
- Restart the wedged pod:
kubectl -n zynotalk-cluster delete pod <pod>. Confirm recovery with the probe on the fresh pod. (2026-06-09 caveat: "it's in-process, same node is fine" is no longer safe to assume — pod delete also reprograms svclb/kube-proxy/conntrack node state, which under §3's leading theory may be the actual fix. If the replacement lands on the same node and the probe still fails, that's itself decisive data: capture §5 step 3 there.) - The supervision alerts (
rtpbridge_webrtc_recv_task_start_timeout_total > 0,rtpbridge_webrtc_recv_task_dead_total > 0) catch the never-polled and died-young variants — but per §4's expectation note they likely stay SILENT for this wedge. Also alert on the symptom directly, e.g. endpoints created but zeroConnectedtransitions /rtpbridge_packets_routed_totalflat over 10m whilertpbridge_sessions_active > 0on a pod. - Recommended follow-ups (not yet implemented):
- Drive the readiness probe unhealthy when
webrtc_recv_task_start_timeoutorwebrtc_recv_task_deadfires so k8s drains/restarts the pod automatically (the real auto-recovery). - In-process UDP self-probe in the 1 Hz sweep: a scratch socket sends a datagram to a live endpoint's local port and the sweep verifies the recv task saw it (count before str0m/routing). Limits: loopback bypasses the external datapath, so self-probe OK + external probe failing ⇒ datapath; self-probe failing ⇒ genuinely in-process. Either way it converts the next recurrence into an instant branch verdict and gives the readiness probe a real signal.
- Give the bridge-side prober a timeout — on Jun 2 it hung forever on the 10:13 probe (session never destroyed, WS never closed), so the wedge showed up as silence instead of probe failures.
- Extend
try_send+ supervision to RTP/RTCP recv tasks (endpoint_rtp.rsstill uses blockingsend().await— the original parking hazard the WebRTC path was cured of). - Attempt a bounded recv-task restart in the liveness sweep before giving up.
- Class-aware backpressure: demux inbound by RFC 5764 packet class (STUN / DTLS / SRTP) so STUN/DTLS are not dropped under RTP overload, while keeping RTP/SRTP lossy (see the
try_sendnote in §4).
- Drive the readiness probe unhealthy when
7. Correlation keys & references
- Client
callId== SIPCall-ID. Bridge logrtpbridge session createdjoinscallId⇄ rtpbridgesession_id. rtpbridge endpoint logs carryendpoint_id+local_addr(the media socket). - Cross-stack methodology:
zynotalk-mobile/docs/BACKEND_INCIDENT_RESEARCH.md. - Code:
src/session/endpoint_webrtc.rs(start_recv_task,await_recv_task_started,supervise_recv),src/session/media_session.rs(run_media_session1 Hz sweep,create_offer/from_offercall sites),src/metrics.rs. Repro tool:examples/media_probe.rs.