console: offer a relay of this page's own, for a consumer with no IPv4 - #280
Merged
Merged
Conversation
§6 argued that only the robot needs to offer a relay candidate: a connection needs one and not two, and `aiortc`'s TURN client does not work, so a Python consumer can never be that side. Both halves are true. The conclusion assumed something it did not check — that the two ends can address each other at all. An iPhone on a mobile network cannot. It has no IPv4 socket: it reaches a hostname through DNS64/NAT64, the STUN server reports an IPv4 reflexive address back, and the phone gathers a candidate saying so. But an ICE candidate is a bare literal, and the robot's relay candidate is a bare IPv4 literal on a board with no global IPv6 at all, which that phone cannot send a packet to. Measured on olducky over 4G: six sessions, `offering relay candidates relays=5` every one, `Ice connection state ... failed` every one, eight seconds apart. The same symptom a missing relay gives, and a different cause — which is why the robot-side endpoint fix did not move it. So the page mints credentials for itself. Only its own allocation bridges this: `turn.cloudflare.com` is a name, so it resolves over IPv6, and the relayed address Cloudflare hands back is IPv4, which the robot can reach. Confirmed from the phone before this was written — those same credentials in a Trickle ICE page gathered a `relay` candidate with an IPv4 address over 4G, where the robot's own candidates had paired with nothing. With the visitor's token rather than the robot's, which is the right way round twice: a robot's allowance should go on being watched rather than on watching, and a browser signed in with `hf_oauth` already holds a token of its own. `refreshRelays` runs on the connect path, which is already asynchronous and already says "connecting…". `newPeerConnection` reads what it stored and never fetches — the same rule the robot follows in `consumer-added`, and for the same reason: that handler cannot wait without delaying every session, LAN sessions included, which will never use a relay. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage71.76% lines on this branch, against a floor of 70%. Per-file |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #258 — base is
live-turn-endpoint, because this uses the endpoint that PRestablished and amends the §6 text it rewrote. Review after it, or say the word and I'll squash
the two together.
Problem
#258 fixed the robot's relay endpoint and proved it on hardware:
offering relay candidates relays=5, every session. A phone on 4G still could not connect.Six sessions on olducky between 09:43 and 09:45, all identical:
The
.localfailures are the browser's mDNS host candidates, unusable off-LAN and expected. Therelay candidate is the one that was supposed to work, and did not.
Cause
The iPhone has no IPv4 socket, and an ICE candidate is a bare literal.
On a mobile network it reaches a hostname through DNS64/NAT64. The STUN server sees the
carrier's translator and reports an IPv4 reflexive address back, so the phone gathers a candidate
that looks like IPv4 —
92.184.109.200in this case. But there is no synthesis for a bareliteral, and the robot's relay candidate is exactly that:
141.101.90.1, on a board with noglobal IPv6 at all.
So there was no pair either end could actually use, and the session negotiated perfectly and
carried nothing — the same symptom a missing relay gives, from a different cause, which is why
#258 did not move it.
Fix
The page mints relay credentials for itself. Only the consumer's own allocation bridges this:
turn.cloudflare.comis a name, so the phone resolves it over IPv6, and the relayed addressCloudflare hands back is IPv4 — which the robot can reach.
With the visitor's HF token, not the robot's. The console Space already signs the visitor in
with
hf_oauth, so the token is there; and a robot's metered allowance should go on beingwatched rather than on watching. reachy_mini#1408's own doc note recommends the same.
refreshRelaysruns on the connect path, which is alreadyasyncand already says"connecting…".
newPeerConnectionreads what it stored and never fetches — the same rule therobot follows in its
consumer-addedhandler, because that handler cannot wait without delayingevery session, including the LAN ones that will never use a relay. A LAN session asks for no
credentials at all.
Verification
Confirmed from the phone before this was written. Robot credentials pasted into
Trickle ICE in mobile
Safari on 4G gathered a
relaycandidate with an IPv4 address — where the phone's own candidateshad paired with nothing across six attempts. That is the mechanism this change automates.
Script syntax checked with
node --check.Verified end to end (2026-09-14). Published to
pollen-robotics/microduck-console— livebuild
9aed222/f02fb229 2026-09-14T10:08Z— and an iPhone on 4G, WiFi off, connects to olduckythrough the console and carries video. The same phone, the same robot and the same rendezvous
failed six times in a row before this, with the robot offering
relays=5every time.Note the Space now serves this branch rather than
mainuntil this merges.What this changes in the design
§6's "only the robot offers one" is amended rather than deleted. The claim holds for a Python
consumer, which is what it was written about, and fails for a browser whose address family does
not overlap the robot's. One relay candidate is still enough; it just is not always the robot's.
Also still open from #258 and unaddressed here: nothing in CI would notice either endpoint dying,
and
*.hf.spaceis{owner}-{space}with no alias left to repoint.🤖 Generated with Claude Code