From 641b35d193c1030ebd1e5194edff897c81682979 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 1 May 2026 19:52:56 +0000 Subject: [PATCH] ICE: surface bot's local candidates + offer host-network compose mode Live trace shows ICE failing because the bot only ever advertised one local candidate - its docker bridge IP `172.18.0.5`. No srflx, no relay. Without an externally-reachable candidate the viewer's TS6 client has nowhere to NAT-punch back to: Connection(0) Check CandidatePair(('172.18.0.5', 49510) -> ('192.168.178.29', 52891)) FAILED Connection(0) Check CandidatePair(('172.18.0.5', 49510) -> ('87.79.86.36', 52891)) FAILED STUN/TURN responses are getting eaten by the docker bridge NAT. Two changes: 1. New `stream_publisher.local_ice_candidates` log right before sending the offer. Lists what aiortc actually produced after gathering completed (kind = host / srflx / relay, ip+port). Makes "STUN didn't help" diagnosable in one line instead of inferring it from absence-of-evidence in the connection-check trace. 2. docker-compose: split into two profiles. - "bridge" (default, profile="" too so existing `docker compose up` still works unchanged): original ts6-net bridge networking. - "host": same image, but `network_mode: host`. Bot sees the host's real LAN/WAN interface, STUN actually returns a usable srflx, viewers can reach back. Operator picks which one fits their deployment via `docker compose --profile host up -d`. ruff + mypy strict + 219 tests pass. https://claude.ai/code/session_016DuCjRJK995Tj9aDhhB9at --- docker-compose.yml | 46 +++++++++++++++++-- .../pipeline/stream_publisher.py | 35 ++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 95609c0..d1672b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,27 @@ # inside Xvfb, captures the frames + audio, and (post phase 1) speaks the TS3 # voice protocol directly to push the result into a TS6 channel via the # server's built-in stream feature. No HLS, no second nginx container. +# +# Two networking modes are available via compose profiles: +# +# * default (profile "bridge"): bot runs in the standard ts6-net bridge. +# Simple, isolated, but WebRTC ICE candidate gathering can struggle - +# STUN responses to the docker bridge IP often don't establish a +# usable srflx candidate, so peers can't NAT-punch back to the bot. +# +# * profile "host": bot runs in the host network namespace. The host's +# real LAN/WAN IPs become the ICE candidates, STUN works normally, +# and viewers can reach the bot. Trade-off: shares the host's port +# space (8080 must be free on the host), needs the TS6 server +# reachable via 127.0.0.1 / a public hostname. +# +# Use one or the other, not both: +# +# docker compose --profile bridge up -d # original behaviour +# docker compose --profile host up -d # works around ICE failures +# +# Without --profile only the bridge service runs (matching prior behaviour +# so existing setups keep working unchanged). services: bot: @@ -11,16 +32,14 @@ services: context: . dockerfile: docker/Dockerfile container_name: ts6-stream-bot + profiles: ["", "bridge"] restart: unless-stopped env_file: .env - # We need ipc=host or a large /dev/shm so Chromium doesn't crash with - # "out of shared memory" on big frames. shm_size: "1gb" volumes: - # Mount sources read-only so a code edit doesn't require rebuild during dev - ./src:/app/src:ro ports: - - "127.0.0.1:8080:8080" # control API - keep local; expose via reverse proxy if needed + - "127.0.0.1:8080:8080" networks: - ts6-net healthcheck: @@ -30,6 +49,25 @@ services: retries: 3 start_period: 20s + bot-host: + build: + context: . + dockerfile: docker/Dockerfile + container_name: ts6-stream-bot + profiles: ["host"] + restart: unless-stopped + env_file: .env + shm_size: "1gb" + network_mode: host + volumes: + - ./src:/app/src:ro + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + networks: ts6-net: external: true diff --git a/src/ts6_stream_bot/pipeline/stream_publisher.py b/src/ts6_stream_bot/pipeline/stream_publisher.py index 3590aa0..558b2bf 100644 --- a/src/ts6_stream_bot/pipeline/stream_publisher.py +++ b/src/ts6_stream_bot/pipeline/stream_publisher.py @@ -334,6 +334,19 @@ async def _on_ice_state_change() -> None: offer_sdp = pc.localDescription.sdp + # Surface the local ICE candidate set so we can tell at a glance + # whether STUN/TURN actually contributed anything. The trace we + # need to debug "ICE failed" almost always boils down to "the bot + # only has its docker-bridge host candidate, no srflx, no relay". + local_candidates = _parse_local_candidates(offer_sdp) + log.info( + "stream_publisher.local_ice_candidates", + clid=viewer_clid, + count=len(local_candidates), + kinds=sorted({c["typ"] for c in local_candidates}), + candidates=local_candidates, + ) + async with self._lock: self._viewers[viewer_clid] = _Viewer( clid=viewer_clid, pc=pc, joined_at=asyncio.get_event_loop().time() @@ -456,6 +469,28 @@ async def _wait_for_ice_gathering( await asyncio.sleep(poll) +def _parse_local_candidates(sdp: str) -> list[dict[str, str]]: + """Pull the c=...candidate lines out of an SDP into a structured form + so the diagnostic log shows "what did STUN actually give us".""" + out: list[dict[str, str]] = [] + for line in sdp.splitlines(): + if not line.startswith("a=candidate:"): + continue + parts = line[len("a=candidate:") :].split() + # Format: + # typ [raddr rport ] [tcptype ...] ... + if len(parts) < 7: + continue + entry: dict[str, str] = { + "protocol": parts[2].lower(), + "ip": parts[4], + "port": parts[5], + "typ": parts[7] if len(parts) > 7 and parts[6] == "typ" else "?", + } + out.append(entry) + return out + + __all__ = [ "StreamPublisher", "StreamPublisherStatus",