Skip to content

Buffer ICE candidates until setRemoteDescription completes (fix CONNECTING hang) - #14

Merged
queueeee merged 1 commit into
mainfrom
claude/review-claude-md-iOuUt
May 1, 2026
Merged

queueeee merged 1 commit into
mainfrom
claude/review-claude-md-iOuUt

Conversation

@queueeee

@queueeee queueeee commented May 1, 2026

Copy link
Copy Markdown
Owner

Why "CONNECTING" hängt

Live: TS6-UI bleibt bei CONNECTING stehen, ICE läuft nie auf connected. Die ganze Zeit haben wir diese Warning in den Logs ignoriert:

RTCPeerConnection addIceCandidate called without remote description
RTCPeerConnection addIceCandidate called without remote description
RTCPeerConnection addIceCandidate called without remote description
RTCPeerConnection addIceCandidate called without remote description
RTCPeerConnection addIceCandidate called without remote description

Die ist nicht harmlos. aiortc REJECTED die addIceCandidate-Calls wenn setRemoteDescription noch nicht durch ist — das Candidate ist weg. Wir haben in jedem Versuch alle 5 ersten ICE-Candidates verloren.

Race-Condition: _on_signaling_message spawnt sowohl die Answer-Task (_apply_answer → setRemoteDescription) als auch jede ICE-Candidate-Task (_apply_ice_candidate → addIceCandidate) als unabhängige asyncio.create_tasks. Die ICE-Tasks laufen typisch VOR der Answer-Task durch.

Fix

Per-Viewer asyncio.Event (remote_set). Wird gesetzt wenn setRemoteDescription durch ist. _apply_ice_candidate awaitet das Event (10 s Cap als Safety) bevor es addIceCandidate ruft. Damit kommen ICE-Candidates byte-genau an, egal in welcher Reihenfolge sie reinkommen.

Bonus

  • STUN_URL / TURN_URL / TURN_USERNAME / TURN_PASSWORD als ENV-Variablen. Default-STUN bleibt stun.l.google.com:19302. Wenn das echte ICE-Problem trotz dieses Fixes weiterläuft (symmetric NAT / CGNAT), kann der Operator einen freien TURN-Server eintragen ohne Code-Änderung:
TURN_URL=turn:openrelay.metered.ca:80
TURN_USERNAME=openrelayproject
TURN_PASSWORD=openrelayproject

Tests

  • 11 publisher tests grün, inkl. neue Regression die ICE-Candidate VOR Answer schickt und prüft dass addIceCandidate nicht aufgerufen wird bis Answer da ist.
  • ruff + mypy strict + 219 tests overall.

Operator commands

cd /opt/ts6-stream-bot
git pull
docker compose down
docker compose build --no-cache
docker compose up -d
docker compose logs -f

Dann im TS6-Client den Bot-Stream beitreten. Erwartet:

viewer_offer_sent clid=…
viewer_pc_state state=connecting
viewer_ice_state ice=checking
viewer_ice_state ice=completed       ← KEINE "addIceCandidate without remote description" Warnings mehr
viewer_pc_state state=connected
parec_audio.started

UI wechselt auf CONNECTED und du siehst Bot's Chromium (initial leerer Screen — POST /play <url> machen für Inhalt).

https://claude.ai/code/session_016DuCjRJK995Tj9aDhhB9at


Generated by Claude Code

The "stuck on CONNECTING" symptom: client UI never transitions to
CONNECTED even with STUN configured. Root cause was hiding in a
warning we'd been ignoring -

    RTCPeerConnection addIceCandidate called without remote description

aiortc's addIceCandidate REJECTS the call when no remote description
is set yet, just emits that warning, and the candidate is gone. The
publisher was spawning the answer task and per-candidate tasks as
independent asyncio tasks; the candidate tasks raced ahead of the
answer task's setRemoteDescription and got dropped on the floor.

Every successful ICE we've seen so far was a NAT-punch lottery on the
remaining candidates that happened to arrive after the answer settled.

Fix: per-Viewer asyncio.Event (`remote_set`) flips when
setRemoteDescription completes. _apply_ice_candidate awaits it (10 s
cap, in case the answer never lands) before calling addIceCandidate.
Candidates now flow correctly regardless of arrival order.

Also done while we're in this neighborhood:

- STUN_URL / TURN_URL / TURN_USERNAME / TURN_PASSWORD env-overridable.
  Default STUN stays at stun.l.google.com:19302; TURN empty. If a
  symmetric NAT or CGNAT prevents direct punching, the operator can
  point at a free public TURN (openrelay.metered.ca) without code
  changes.

Tests: 11 publisher cases pass including a new regression that emits
ICE BEFORE the answer and asserts addIceCandidate is held until the
answer arrives. ruff + mypy strict + 219 tests overall.

https://claude.ai/code/session_016DuCjRJK995Tj9aDhhB9at
@queueeee
queueeee merged commit 9d2bc04 into main May 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants