Skip to content

fix(engine): allocate distributed init ports outside the ephemeral range - #518

Open
CAICAIIs wants to merge 5 commits into
inclusionAI:mainfrom
CAICAIIs:perf/fix-find-free-port
Open

fix(engine): allocate distributed init ports outside the ephemeral range#518
CAICAIIs wants to merge 5 commits into
inclusionAI:mainfrom
CAICAIIs:perf/fix-find-free-port

Conversation

@CAICAIIs

@CAICAIIs CAICAIIs commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #517: distributed worker startup could fail with EADDRINUSE on busy
hosts. find_free_port() probed a port with bind(0) and immediately closed
the reservation socket; the just-freed ephemeral port could be re-taken as
the source port of an outbound connection (leaving it in TIME-WAIT, so the
later TCPStore bind failed), and consecutive calls could hand the same port
to the train and rollout clusters.

New approach (per maintainer review): the coordinator creates and retains
the rendezvous store.
Instead of probing a port, the coordinator now creates
a TCPStore(port=0) and keeps it open for the cluster lifetime, so the
resolved port is genuinely reserved before any worker starts. Workers join as
TCPStore clients and initialize the process group with store=, so no
worker ever binds the rendezvous port — the bind-close-bind TOCTOU race is
eliminated, including across processes.

Changes:

  • areno/engine/protocol.py: new _create_rendezvous_store() binds port=0
    and is retained on the cluster; Cluster.start and
    start_partitioned_clusters resolve the store port into the shared
    world_spec before spawning workers. find_free_port is removed.
  • areno/engine/parallel/context.py: init_process_group creates a client
    TCPStore and passes it to dist.init_process_group(store=...).
  • areno/api/backend/cuda/backend.py: the rollout world_spec uses a
    placeholder port that start_partitioned_clusters fills in.
  • Tests: the gloo broadcast test now mirrors production with a coordinator
    store; a new test asserts the resolved port is genuinely held (re-binding
    the same port fails while the store is alive) and serves client stores.

No public API or CLI change.

Repro evidence

On a shared 8×A100 node, 4 consecutive areno train startups failed with
DistNetworkError: ... EADDRINUSE on freshly "free" ports (39625 / 34127 /
58051 / 49989); ss at failure time showed the chosen port in TIME-WAIT as
the source of an outbound connection. With the coordinator-held store,
startups on the same busy host succeed reliably.

Test commands (CPU)

pytest tests/test_parallel_partition_cpu.py -v

The gloo broadcast test spawns 4 worker processes that join through the
coordinator-held store (client stores + init_process_group(store=...)),
exercising the production rendezvous path end to end.

Type of change

  • 🐛 Bug fix
  • ✨ New feature
  • 💥 Breaking change
  • 📝 Documentation update
  • ♻️ Refactoring
  • ⚡ Performance improvement
  • ✅ Test coverage improvement

Checklist

  • The PR title summarizes the contribution.
  • Linked the related issue in the description (if any).
  • Existing tests pass (pytest tests/ -k cpu).
  • New behavior is covered by tests.
  • Described the test commands run and any hardware limitations.
  • Public API / CLI changes are additive and backward-compatible (see CONTRIBUTING.md).

@xsuler

xsuler commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Hi @CAICAIIs , your change mitigates the observed collision but does not eliminate the bind-close-bind TOCTOU race, especially across processes. A stronger approach is to let the coordinator create and retain a TCPStore with port=0, then pass its resolved port to worker-side client stores. Also, the exclusion check/add should currently be under the same lock, and the fallback reintroduces the original ephemeral-port issue.

CAICAIIs added a commit to CAICAIIs/AReno that referenced this pull request Aug 27, 2026
Addresses maintainer review (inclusionAI#518): instead of probing a port with
bind-close-bind (which raced with outbound traffic and could hand the same
port to the train and rollout clusters), the coordinator now creates and
retains a TCPStore with port=0 before spawning workers, and every worker
joins the group as a TCPStore client on the resolved port.

- protocol.py: _create_rendezvous_store() binds port=0 and is retained on
  the cluster for its lifetime; Cluster.start and start_partitioned_clusters
  resolve the store port into the shared world_spec before spawning
- context.py: init_process_group joins via a client store and
  init_process_group(store=...), so no worker ever binds the rendezvous port
- backend.py: rollout world_spec uses a placeholder port that
  start_partitioned_clusters fills in
- tests: the gloo broadcast test mirrors production with a coordinator
  store; a new test asserts the resolved port is genuinely held (re-bind
  fails while the store is alive) and serves client stores

find_free_port is removed; no public API change.
find_free_port() probed bind(0) and closed the reservation immediately; the
just-freed ephemeral port could be re-taken as the source port of an outbound
connection, leaving it in TIME-WAIT so the later TCPStore server bind failed
with EADDRINUSE on busy hosts, and consecutive calls could return the same
port for the train and rollout clusters. Ports are now probed with a real
bind from a fixed non-ephemeral range (which also excludes listeners and
TIME-WAIT sockets), kept distinct per process, with a bind(0) fallback.

Fixes inclusionAI#517
Self-review fixes:
- randint could return the inclusive upper bound 30000 while the test
  asserts port < 30000; make the range half-open [20000, 30000)
- guard the process-local exclusion set with a lock so concurrent callers
  cannot hand out the same port
- document the residual caveat for hosts that widen ip_local_port_range
Addresses maintainer review (inclusionAI#518): instead of probing a port with
bind-close-bind (which raced with outbound traffic and could hand the same
port to the train and rollout clusters), the coordinator now creates and
retains a TCPStore with port=0 before spawning workers, and every worker
joins the group as a TCPStore client on the resolved port.

- protocol.py: _create_rendezvous_store() binds port=0 and is retained on
  the cluster for its lifetime; Cluster.start and start_partitioned_clusters
  resolve the store port into the shared world_spec before spawning
- context.py: init_process_group joins via a client store and
  init_process_group(store=...), so no worker ever binds the rendezvous port
- backend.py: rollout world_spec uses a placeholder port that
  start_partitioned_clusters fills in
- tests: the gloo broadcast test mirrors production with a coordinator
  store; a new test asserts the resolved port is genuinely held (re-bind
  fails while the store is alive) and serves client stores

find_free_port is removed; no public API change.
CI caught that test_policy_tensor_sync_cpu.py still imported the removed
find_free_port; both real-gloo reshuffle tests now create a coordinator-held
rendezvous store and pass its resolved port to the spawned workers, mirroring
the production rendezvous path.
The store-based rendezvous now creates a real client TCPStore before calling
init_process_group; the two mocked tests patched only init_process_group, so
the client store tried to connect to the placeholder port 12345 (no server)
and blocked for the connection timeout. Patch TCPStore alongside
init_process_group so the tests keep exercising group construction only.
@CAICAIIs
CAICAIIs force-pushed the perf/fix-find-free-port branch from 6f70417 to 9ce7fed Compare August 27, 2026 06:30
@CAICAIIs

Copy link
Copy Markdown
Contributor Author

Thanks @xsuler — reworked per your suggestion: the coordinator now creates and retains the TCPStore(port=0) and workers join as client stores (find_free_port removed, fallback gone); the PR is rebased onto the latest main and the conflict is resolved.

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.

find_free_port can return ports that collide with outbound traffic on busy hosts (EADDRINUSE at TCPStore bind)

2 participants