Repository navigation
fix(providers): draw local sandbox host ports below the ephemeral range, in turn - #161
Merged
Merged
Conversation
…ge, in turn A local sandbox fixed each published port by binding port 0 and releasing it, and Docker bound it seconds to minutes later, after the image pull or load. Meanwhile an outgoing connection could take it, since port 0 draws from the range those use, and so could another deploy that drew the same number. On Docker Desktop and Rancher Desktop the probe also runs outside the VM whose sockets Docker binds among, so it can't see what holds a port there. The deploy then failed with "failed to bind host port ...: address already in use". Ports now come from 20000-29999, below every default ephemeral range and Kubernetes' node ports, handed out in turn through a cursor file under the sandbox root that every process using it shares under flock. A port in use is skipped, and none is handed out again until the whole range has gone round. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… other bind errors raise Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not the sandbox root A run leaves the sandbox root empty once its work folders are removed; the cursor is per-user state, so it goes with the other lock files under the state root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A draw waits on the cursor's flock, which another process may hold, so create_vm now draws in a thread instead of blocking every task on the loop. The comments now say what the cursor is shared by: processes sharing a state root, not every process. The ephemeral ranges the host ports stay below are the defaults. Tests: one binds real sockets (Linux only), so a probe that checked loopback alone would fail it. A missing or unreadable cursor starts at a random port, replacing a test that couldn't fail. The stand-in allocator draws from the range. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A local sandbox fixed each host port it publishes by binding port 0, closing the socket and keeping the number. Docker bound the port later: after the image pull for an agent, or after every image load for a gateway, so seconds to minutes later. In between, two things could take it:
The deploy then failed before anything ran:
In the PortSim v5 sweeps (12 models, on that Mac), 66 attempts failed this way, and 7 weeks lost all three of their attempts to it. Every failed port lay in 49532–60994, where the Mac's ephemeral range overlaps the VM's.
The change
_free_host_port()now does two things:locks/host-portsbeside agent-env's other lock files, is held underflockwhile each port is drawn. Every agent-env process that shares the state root (the sameXDG_STATE_HOME, or home directory) shares the cursor. It isn't kept in the sandbox root, which a run leaves empty. The bind probe still skips a port in use (EADDRINUSE), and any other bind error raises. No port is handed out again until the whole range has gone round, 10,000 draws later.create_vmmakes its draws in a thread, since a draw waits for any other process's draw to finish. Nothing else changes:create_vmstill fixes every port up front, sotunnel_urls,.port-map.jsonand every publish site are as before.Why not let Docker pick the port (
-p 127.0.0.1::8000, thendocker port)create_vmhas to returntunnel_urls.deploy_sandboxrecords them in the task context before any container publishes, forrun_docker_containerandinstall_agentto use later.install_agentbinds the port itself, with no Docker involved.host.docker.internal:<port>. Two ephemeral bindings needn't get the same number.No retry on a failed bind
Both causes are gone, so a retry would only be defensive code for a case nothing produces now. The chaos tests below check that nothing is left for it to catch.
Limits
XDG_STATE_HOMEshare the cursor.net.ipv4.ip_local_port_rangereaches below 30000 lets outgoing connections take these ports again. Concurrent deploys still never draw the same one.Testing
local_sandbox_host_ports_test.py:flockremoved.EADDRINUSE), and raises on any other bind error;unitjob passes.Chaos test 1: deploys back to back
It ran on c6i.2xlarge EC2 instances (Ubuntu 24.04, Docker 29.9.0, ephemeral range 32768–60999), 4 minutes a phase. In each phase:
LocalSandboxProvider.create_containerback to back for a small HTTP image, waits until it serves, holds it 0–3 s, then terminates it.On the split-namespace box, the deploying processes run in containers that drive the host's Docker through its socket. Their probe therefore can't see the host's sockets, as on Docker Desktop and Rancher Desktop.
aae072eaae072ebeefd98, state root sharedAt
aae072ethe cursor lived in the sandbox root, which every process shared. On the native box every process also shares one home, so the cursor's later move to the state root doesn't change what those rows test. The split-namespace containers each have their own home, so thebeefd98run mounts one state root into all of them.There were no other failures in any phase: every deploy that bound its port served. Median deploy time was unchanged: 2.42 s → 2.56 s native, 2.80 s → 2.37 s split (2.33 s at
beefd98).Chaos test 2: PortSim v5 weeks
The workload that hit the race: the
portsim-linesweeks of agentenv-portsim-plugin v0.5.1. Each episode deploys a gateway env (gateway, pgweb and db-mcp published), a planner agent and 7–9 line agents: 13–15 containers and 11–13 host ports. A stand-in model answers each planner turn with the week'sadvance, so every week plays to its end at no cost.It ran on two c6i.8xlarge instances (32 vCPU, 64 GB, Ubuntu 24.04, Docker 29.9.0), one per build, at the same time, for 20 minutes each:
agent-env portsim sweep run --lines --tasks all --k 60 --parallel 24: 24 attempts at a time, each its ownagent-env runprocess. The sweep retries a failed attempt up to twice.beefd98done. The branch's median week took 128 s under that load; alone, a week takes 48 s.Follow-up
After this ships, bump the PortSim plugin's framework pin and run its remaining weeks at full parallelism.
🤖 Generated with Claude Code


Confidence Score: 5/5
The PR appears safe to merge; no new blocking issue remains.Summary
Local sandboxes now draw host ports from 20000–29999 using a shared, locked cursor.
create_vmruns those draws off the event loop.Diagram
%%{init: {'theme': 'neutral'}}%% flowchart TD A[create_vm] --> B[Draw ports in a worker thread] B --> C[Lock the shared cursor] C --> D[Probe the next port] D -->|Busy| E[Try the next port] E --> D D -->|Free| F[Save cursor and close lock] F --> G[Create sandbox and save port map]Reviews (4) · Last reviewed commit: "fix(providers): draw local sandbox host ..." · Reviewed by Greptile