Skip to content

Commit 6085c1f

Browse files
snimuclaude
andauthored
Fix v1 endpoint port pre-probe race (bind 0.0.0.0:0 + readback) (#1513)
* Fix v1 endpoint port pre-probe race (bind 0.0.0.0:0 + readback) 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> * remove unnecessary comment --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fdbfe49 commit 6085c1f

1 file changed

Lines changed: 4 additions & 5 deletions

File tree

verifiers/v1/utils/endpoint_utils.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
synthesize_stream,
3131
)
3232
from verifiers.utils.message_utils import normalize_messages
33-
from verifiers.utils.serve_utils import get_free_port
3433

3534
from ..runtime import ModelRequestContext, Runtime, TrajectoryVisibility
3635
from ..state import State
@@ -136,11 +135,11 @@ def __init__(
136135
use_tunnel: bool = False,
137136
logger: logging.Logger | None = None,
138137
):
139-
self.port = get_free_port() if port is None else port
140138
self.use_tunnel = use_tunnel
141139
self.logger = logger or logging.getLogger(__name__)
142140
self.server = InterceptionServer(
143-
self.port, secret=secret or os.environ.get("ENDPOINT_SECRET")
141+
port if port is not None else 0,
142+
secret=secret or os.environ.get("ENDPOINT_SECRET"),
144143
)
145144
self.secret = self.server.secret
146145
self._tunnel: TunnelHandle | None = None
@@ -261,7 +260,7 @@ def trajectory_visibility(self, headers: dict[str, str]) -> TrajectoryVisibility
261260
async def url_base(self) -> str:
262261
if self.use_tunnel:
263262
return await self.get_tunnel_url()
264-
return f"http://127.0.0.1:{self.port}"
263+
return f"http://127.0.0.1:{self.server.port}"
265264

266265
async def get_tunnel_url(self) -> str:
267266
from prime_tunnel import Tunnel
@@ -282,7 +281,7 @@ async def get_tunnel_url(self) -> str:
282281
self._tunnel = None
283282

284283
if self._tunnel is None:
285-
tunnel = cast(TunnelHandle, Tunnel(local_port=self.port))
284+
tunnel = cast(TunnelHandle, Tunnel(local_port=self.server.port))
286285
url = await tunnel.start()
287286
self._tunnel = tunnel
288287
self._tunnel_last_checked = time.time()

0 commit comments

Comments
 (0)