Skip to content

Fix v1 endpoint port pre-probe race (bind 0.0.0.0:0 + readback)#1513

Merged
snimu merged 2 commits into
mainfrom
sebastian/fix-ports-2026-06-01
Jun 1, 2026
Merged

Fix v1 endpoint port pre-probe race (bind 0.0.0.0:0 + readback)#1513
snimu merged 2 commits into
mainfrom
sebastian/fix-ports-2026-06-01

Conversation

@snimu
Copy link
Copy Markdown
Contributor

@snimu snimu commented Jun 1, 2026

Description

Endpoint pre-allocated its interception-server port at construction via get_free_port(), which binds a throwaway socket to 127.0.0.1:0, reads the number, and closes it. InterceptionServer then bound that cached port on 0.0.0.0 at the first rollout. That left two defects on the shared v1 path:

  • the port was unreserved between Endpoint construction and the first rollout (a TOCTOU race — another port consumer can take it), and
  • it was validated on 127.0.0.1 but bound on 0.0.0.0, so a port free on loopback could already be taken on another interface and the real bind could collide.

Fix: stop pre-probing. Hand InterceptionServer port 0 so it binds 0.0.0.0:0 and adopts the OS-assigned port via the getsockname() readback that already existed in InterceptionServer.start() (previously dead code on the v1 path). Endpoint.port is now a property over server.port, so it always reflects the live socket and is 0 only in the construct->start() window, which the rollout path never observes. Probe and bind become the same held operation on the same interface.

Explicit Endpoint(port=...) construction is unchanged. This matches the bind-and-readback pattern already used by cli_agent_env and rlm_env.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Test improvement

Testing

  • All existing tests pass when running uv run pytest locally.
  • New tests have been added to cover the changes

Checklist

  • My code follows the style guidelines of this project as outlined in AGENTS.md
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

Note

Low Risk
Narrow change to how the interception server picks and exposes its listen port; explicit port construction is unchanged and rollout paths already start the server before URLs are built.

Overview
Fixes a port allocation race on the v1 Endpoint path by stopping the upfront get_free_port() probe and letting InterceptionServer bind 0.0.0.0:0 (or an explicit port) and adopt the OS-assigned port via the existing getsockname() readback in start().

Local URLs and tunnels now use self.server.port after the server is listening, so the advertised port matches the live socket instead of a number that was only reserved briefly on loopback.

Reviewed by Cursor Bugbot for commit 994e4bb. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Fix pre-probe port race in v1 Endpoint by binding to 0.0.0.0:0 and reading back the assigned port

Previously, Endpoint.__init__ called get_free_port() to select a port before binding, creating a race window where another process could claim that port. Now, InterceptionServer is started with port 0 so the OS assigns an ephemeral port, and url_base and get_tunnel_url read the actual bound port from self.server.port. The self.port attribute and get_free_port import are removed from endpoint_utils.py.

Macroscope summarized 994e4bb.

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Jun 1, 2026
@macroscopeapp
Copy link
Copy Markdown

macroscopeapp Bot commented Jun 1, 2026

Approvability

Verdict: Approved

Straightforward bug fix for a port binding race condition. The change replaces a get-then-bind pattern with bind-to-zero-and-readback, which is a well-known correct approach for avoiding TOCTOU races.

You can customize Macroscope's approvability policy. Learn more.

Endpoint pre-allocated its interception-server port at construction via
get_free_port(), which binds a throwaway socket to 127.0.0.1:0, reads the
number, and closes it. InterceptionServer then bound that cached port on
0.0.0.0 at the first rollout. That left two defects on the shared v1 path:

- the port was unreserved between Endpoint construction and the first
  rollout (a TOCTOU race — another port consumer can take it), and
- it was validated on 127.0.0.1 but bound on 0.0.0.0, so a port free on
  loopback could already be taken on another interface and the real bind
  could collide.

Fix: stop pre-probing. Hand InterceptionServer port 0 so it binds
0.0.0.0:0 and adopts the OS-assigned port via the getsockname() readback
that already existed in InterceptionServer.start() (previously dead code
on the v1 path). Endpoint now reads server.port directly; it is 0 only in
the construct->start() window, which the rollout path never observes
(register_rollout calls start() before building any URL). Probe and bind
become the same held operation on the same interface.

Explicit Endpoint(port=...) construction is unchanged. This matches the
bind-and-readback pattern already used by cli_agent_env and rlm_env.

Verified: ruff clean; test_v1_endpoint_protocols.py and
test_interception_utils.py (33 tests) pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Jun 1, 2026
@snimu snimu merged commit 6085c1f into main Jun 1, 2026
15 checks passed
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.

2 participants