Skip to content

fix(tunnel): bind userspace tunnel listener on an ephemeral port to avoid collisions - #833

Open
danielpaulus wants to merge 2 commits into
mainfrom
fix/tunnel-ephemeral-userspace-port
Open

fix(tunnel): bind userspace tunnel listener on an ephemeral port to avoid collisions#833
danielpaulus wants to merge 2 commits into
mainfrom
fix/tunnel-ephemeral-userspace-port

Conversation

@danielpaulus

Copy link
Copy Markdown
Owner

Root cause

The userspace tunnel's local listener port collides when two per-device tunnel
agents run on one host. This started failing in real-device CI after a 2nd
device (iOS 26.5) was added to the office01 macOS runner.

  • TunnelManager pre-computed the userspace TUN listener port as
    d.UserspaceTUNPort = m.basePort + m.portOffset (portOffset starts at 1), and
    connectToUserspaceTunnelLockdown bound it via
    net.Listen("tcp", "localhost:<port>").

  • Only the agent's tunnel-info HTTP port is guaranteed free (each per-device
    agent is started with a free --tunnel-info-port). The derived userspace
    TUN port had no free-port guarantee, so with two concurrent per-device
    agents on one host it collided → the second agent could never bind and its
    tunnel never came up:

    WARN failed to start tunnel ... error="could not setup listener. listen tcp 127.0.0.1:57472: bind: address already in use"
    

    TestTunnelAgent/userspace/<udid> on the second device then timed out after 90s.

The fix

Bind the userspace TUN listener on an OS-assigned ephemeral port and
advertise the actual chosen port:

  • connectToUserspaceTunnelLockdown passes ifacePort through to
    net.Listen; with ifacePort == 0 the OS picks a free port. The actual bound
    port is read back via listener.Addr().(*net.TCPAddr).Port and set on the
    returned Tunnel.UserspaceTUNPort (with UserspaceTUN = true), so the
    tunnel-info API (/tunnel/{udid}, /tunnels) serves a real, reachable port.
    An explicit non-zero ifacePort is still honored for backward compatibility.
  • TunnelManager no longer pre-assigns basePort + portOffset; the now-dead
    basePort/portOffset derivation is removed. The clobbering overwrite in
    manualPairingTunnelStart.StartTunnel
    (tun.UserspaceTUNPort = device.UserspaceTUNPort) is removed so it can't
    reset the real port back to a stale value.

The tunnel-info HTTP API port is untouched — only the userspace TUN listener
port becomes ephemeral. The two ports were previously entangled via basePort
(which defaulted to ios.HttpApiPort()); they are now fully independent.

Options considered

  • Ephemeral bind (chosen). Let the OS assign a free port and advertise the
    actual one. Zero collision surface, no retry loop, no shared coordination
    state, and mirrors the existing REST API ephemeral-port approach. The
    advertised port is always correct because it's read from the bound listener.
  • Test-only free-port range. Only papers over the symptom in CI; production
    multi-agent hosts would still collide. Rejected.
  • Retry-on-EADDRINUSE. Retrying a derived port on bind failure adds a retry
    loop and still races two agents onto the same next candidate; strictly worse
    than letting the OS pick. Rejected.
  • Per-agent port namespacing. Deriving disjoint ranges per agent requires
    coordinating a base offset across independently-launched processes on one
    host — exactly the coordination the ephemeral bind avoids. Rejected.

Verification

  • go build ./..., go vet ./..., gofmt -l clean; go test ./... passes.

🤖 Generated with Claude Code

danielpaulus and others added 2 commits August 12, 2026 09:08
…void collisions

The userspace TUN local listener port was pre-computed as basePort+portOffset
in the TunnelManager and only the tunnel-info HTTP port was guaranteed free. The
derived TUN listener port had no free-port guarantee, so two per-device tunnel
agents on one host could pick the same port and the second agent could never
bind ("bind: address already in use"), leaving its tunnel permanently down.

Bind the userspace TUN listener on an OS-assigned ephemeral port (localhost:0),
read the actual bound port back via listener.Addr(), and advertise that real
port on the returned Tunnel (UserspaceTUNPort) so the tunnel-info API serves a
reachable port. An explicit non-zero ifacePort is still honored for backward
compatibility. The now-dead basePort/portOffset derivation is removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…port

TestTunnelAgentMixed asserted the old contract that each agent's userspace
listener port was derived from its tunnel-info port (perPort+1). Now the
listener binds an OS-assigned ephemeral port and advertises the actual bound
port, so assert that each advertised port is a valid port distinct from the
agent's tunnel-info port, and keep the cross-agent no-collision check. The
test's real intent (two agents coexist, isolated, each with its own working
tunnel) is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant