Skip to content

Commit faa78b4

Browse files
mhotanclaude
andcommitted
proxy: bind loopback on both IPv4 and IPv6 so localhost isn't shadowed
The proxy bound only 127.0.0.1 (IPv4). On macOS `localhost` resolves to ::1 (IPv6) first, so if another process holds a wildcard IPv6 listener on the same port (e.g. OrbStack/Docker on *:PORT with SO_REUSEPORT), the IPv4-only bind still succeeds but never receives `localhost` traffic — it lands on the other listener, which resets it. The proxy looked healthy ("Ctrl-C to stop") while every request failed with "connection reset by peer". Bind loopback on both 127.0.0.1 and ::1 (single site for an explicit non-loopback --address). Binding the specific loopback addresses wins over a wildcard listener, so localhost reaches the proxy; a genuinely conflicting specific bind now surfaces as a loud EADDRINUSE instead of a silent half-bind. IPv6-unavailable hosts fall back to IPv4 with a note. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c425c88 commit faa78b4

1 file changed

Lines changed: 42 additions & 6 deletions

File tree

src/flyte/cli/_proxy.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import errno
23
import json
34
import sys
45

@@ -169,15 +170,50 @@ async def send(refresh: bool):
169170
server.router.add_route("*", "/{tail:.*}", handle)
170171
runner = web.AppRunner(server)
171172
await runner.setup()
172-
site = web.TCPSite(runner, address, port)
173-
await site.start()
173+
# Bind loopback on BOTH IPv4 and IPv6 so the proxy owns `localhost` fully.
174+
# macOS resolves `localhost` to ::1 (IPv6) first, and a wildcard listener in
175+
# another process (e.g. OrbStack/Docker on *:PORT) otherwise shadows an
176+
# IPv4-only 127.0.0.1 bind: bind() still succeeds, so the proxy looks healthy
177+
# yet never receives the request (it lands on the other listener, which
178+
# resets it). Binding ::1 too turns that silent half-bind into a loud
179+
# EADDRINUSE. A non-loopback --address is bound as-is (single family).
180+
loopback = address in ("127.0.0.1", "localhost", "::1", "loopback")
181+
hosts = ["127.0.0.1", "::1"] if loopback else [address]
174182

175183
actual = port
176-
if port == 0:
177-
actual = runner.addresses[0][1]
178-
local = f"http://{address}:{actual}"
184+
bound: list[str] = []
185+
for host in hosts:
186+
try:
187+
await web.TCPSite(runner, host, actual).start()
188+
except OSError as e:
189+
if e.errno == errno.EADDRINUSE:
190+
await runner.cleanup()
191+
raise click.ClickException(
192+
f"Port {actual} is already in use (binding {host} failed). Another "
193+
f"process — often a wildcard binder like OrbStack or Docker — holds it "
194+
f"and would silently shadow this proxy. Re-run with --port <free-port>."
195+
)
196+
if host == "::1" and e.errno in (errno.EADDRNOTAVAIL, errno.EAFNOSUPPORT):
197+
click.secho(
198+
f"note: IPv6 loopback unavailable, binding IPv4 only ({e.strerror}).",
199+
fg="yellow",
200+
err=True,
201+
)
202+
continue
203+
await runner.cleanup()
204+
raise
205+
bound.append(host)
206+
if port == 0 and actual == 0:
207+
# First site picked a free port; pin the other family to the same one.
208+
actual = runner.addresses[0][1]
209+
if not bound:
210+
await runner.cleanup()
211+
raise click.ClickException("Failed to bind any loopback address.")
212+
213+
display_host = "127.0.0.1" if loopback else address
214+
local = f"http://{display_host}:{actual}"
179215

180-
if address not in ("127.0.0.1", "localhost", "::1"):
216+
if not loopback:
181217
click.secho(
182218
f"WARNING: binding {address} exposes your Union identity to anything that can reach it; prefer 127.0.0.1.",
183219
fg="yellow",

0 commit comments

Comments
 (0)