Skip to content

fix(server): retry egress sidecar with new ports on Docker Desktop/Windows port conflicts - #1706

Open
EffNine wants to merge 1 commit into
opensandbox-group:mainfrom
EffNine:fix/egress-sidecar-port-retry-1702
Open

fix(server): retry egress sidecar with new ports on Docker Desktop/Windows port conflicts#1706
EffNine wants to merge 1 commit into
opensandbox-group:mainfrom
EffNine:fix/egress-sidecar-port-retry-1702

Conversation

@EffNine

@EffNine EffNine commented Sep 2, 2026

Copy link
Copy Markdown

Summary

When creating sandboxes with an egress sidecar on Docker Desktop for Windows (or other environments with OS-reserved excluded port ranges), docker start can fail with `"ports are not available: ... bind: ... forbidden"` even though the container-side port probe succeeded. The server currently raises a generic HTTP 500 without retrying — this PR adds a one-shot retry with freshly-allocated ports.

Root Cause

allocate_host_port() in port_allocator.py probes availability with socket.bind() inside the server container (Linux network stack). On Docker Desktop/Windows the actual bind happens on the Windows host, where OS-reserved excluded port ranges (e.g. Hyper-V/WinNAT) make the bind fail with WSAEACCES — the container-side probe cannot see this.

_start_egress_sidecar() in networking.py only retried on the IPv6-sysctl rejection; any other start() failure (including "ports are not available") cleaned up and raised HTTP 500 without re-allocating a new host port.

Fix

  • Added _is_port_publish_error() helper that detects "ports are not available" and "bind:" + "forbidden" patterns in Docker errors.
  • When start() fails with a port-publish error and a port_allocator is wired in, re-allocate fresh ports, clean up the failed container, and retry create+start once.
  • When the retry also fails with a port error, surface the real Docker error in the 500 detail instead of the generic message.
  • Fixed a shallow-copy bug: build_sidecar_host_config() does dict(base_sidecar_host_config_kwargs), which preserves the old port_bindings dict when sidecar_port_bindings is reassigned. The fix rebuilds base_sidecar_host_config_kwargs from scratch before the retry.

Testing

  • Added test_egress_sidecar_retries_on_port_publish_error: verifies that on a port-publish failure the sidecar is recreated with different ports and starts successfully.
  • Added test_egress_sidecar_raises_on_second_port_failure: verifies that when both attempts fail, an HTTPException is raised with the real Docker error.
  • All 146 tests in test_docker_service.py pass.
  • ruff check passes on all changed files.

Files Changed

  • server/opensandbox_server/services/docker/networking.py — retry logic + _is_port_publish_error
  • server/opensandbox_server/services/docker/docker_service.py — wire port_allocator at the call site
  • server/tests/test_docker_service.py — two regression tests

Closes #1702

…ndows port conflicts

When docker start fails with "ports are not available" or "bind: forbidden"
(e.g. on Docker Desktop for Windows where OS-reserved port ranges are invisible
from the container-side probe), retry once with freshly-allocated ports instead
of failing immediately with a generic 500.

Also surfaces the original Docker error in the failure message when the retry
also fails.

A shallow-copy bug in build_sidecar_host_config meant the retry used stale
port_bindings from base_sidecar_host_config_kwargs; the fix rebuilds that dict
before the retry create call.

Closes opensandbox-group#1702
@github-actions github-actions Bot added component/server size/L Denotes a PR that changes 100-499 lines, ignoring generated files. labels Sep 2, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9bb4d510b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +651 to +654
retry_api_host_port = next(
(b[1] for b in sidecar_port_bindings.values()),
egress_api_host_port,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the retried egress API binding for readiness

When the retry path is reached from create_sandbox, sidecar_port_bindings is ordered as 44772, 8080, then 18080, so this next() always selects the new execd host port rather than the egress API port. The readiness probe then requests /healthz from execd instead of the egress server on container port 18080, causing every otherwise-successful port-conflict retry to wait until the readiness timeout and fail sandbox creation. Select sidecar_port_bindings["18080"][1] instead.

Useful? React with 👍 / 👎.

Comment on lines +571 to +576
retry_bindings = port_allocator(
list(sidecar_port_bindings.keys()),
min_port=self.app_config.docker.port_range_min,
max_port=self.app_config.docker.port_range_max,
)
sidecar_port_bindings = retry_bindings

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return the retried ports to sandbox creation

A successful retry only replaces this method's local sidecar_port_bindings; the caller still writes its original host_execd_port and host_http_port into the main container labels in docker_service.py:840-841. Endpoint resolution reads those labels, so after a successful retry clients are directed to the rejected old ports rather than the sidecar's newly published execd and HTTP ports. Propagate the refreshed bindings (or refreshed two host ports) back to the caller before it creates the main container.

Useful? React with 👍 / 👎.

Comment on lines +555 to +560
if (
last_port_error is not None
and port_allocator is not None
and not _is_port_publish_error(last_port_error)
):
raise

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve cleanup for non-port start failures

The production caller now always supplies port_allocator, so any sidecar start() failure that is not recognized as a port-publish error takes this bare raise. That exits the outer handler before its cleanup at lines 662-680; the caller has not received a sidecar_container return value either, so it cannot remove the already-created sidecar. This regresses ordinary Docker start failures into orphaned sidecars and uncaught DockerExceptions instead of the prior cleanup and normalized HTTP error.

Useful? React with 👍 / 👎.

Comment on lines +637 to +647
if _is_port_publish_error(retry_exc):
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={
"code": SandboxErrorCodes.CONTAINER_START_FAILED,
"message": (
f"Egress sidecar container failed to start: "
f"{retry_exc}"
),
},
) from retry_exc

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the second sidecar after a retry failure

If the second start() also gets a port-publish error, this immediately raises an HTTPException from inside the outer exception handler, bypassing the normal cleanup below. The first failed sidecar was removed before retrying, but the newly created second container remains orphaned because create_sandbox never receives a returned sidecar to clean up. Route this failure through cleanup before surfacing the HTTP error.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component/server size/L Denotes a PR that changes 100-499 lines, ignoring generated files.

Projects

None yet

1 participant