diff --git a/backend/src/community/aio_sandbox/local_backend.py b/backend/src/community/aio_sandbox/local_backend.py index fbddfa5d..e85e644d 100644 --- a/backend/src/community/aio_sandbox/local_backend.py +++ b/backend/src/community/aio_sandbox/local_backend.py @@ -105,7 +105,10 @@ class LocalContainerBackend(SandboxBackend): RuntimeError: If the container fails to start. """ container_name = f"{self._container_prefix}-{sandbox_id}" - port = get_free_port(start_port=self._base_port) + # Check host-side Docker published ports as well, because this code may + # run inside gateway/langgraph containers while creating sibling sandbox + # containers via host Docker socket. + port = get_free_port(start_port=self._base_port, check_docker_host_ports=True) try: container_id = self._start_container(container_name, port, extra_mounts) self._ensure_user_data_permissions(container_name) diff --git a/backend/src/utils/network.py b/backend/src/utils/network.py index 4802dbef..5991bc26 100644 --- a/backend/src/utils/network.py +++ b/backend/src/utils/network.py @@ -1,6 +1,8 @@ """Thread-safe network utilities.""" +import re import socket +import subprocess import threading from contextlib import contextmanager @@ -32,7 +34,33 @@ class PortAllocator: self._lock = threading.Lock() self._reserved_ports: set[int] = set() - def _is_port_available(self, port: int) -> bool: + @staticmethod + def _is_docker_host_port_in_use(port: int) -> bool: + """Check whether a host port is already published by Docker containers. + + This is useful when running inside a container (e.g. gateway/langgraph) + while creating sibling containers through the host Docker daemon. + """ + try: + result = subprocess.run( + ["docker", "ps", "--format", "{{.Ports}}"], + capture_output=True, + text=True, + check=True, + timeout=5, + ) + except Exception: + # Fail-open to preserve previous behavior when docker CLI is unavailable. + return False + + pattern = re.compile(r":(\d+)->") + for line in result.stdout.splitlines(): + for match in pattern.finditer(line): + if int(match.group(1)) == port: + return True + return False + + def _is_port_available(self, port: int, *, check_docker_host_ports: bool = False) -> bool: """Check if a port is available for binding. Args: @@ -44,6 +72,9 @@ class PortAllocator: if port in self._reserved_ports: return False + if check_docker_host_ports and self._is_docker_host_port_in_use(port): + return False + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: try: s.bind(("localhost", port)) @@ -51,7 +82,7 @@ class PortAllocator: except OSError: return False - def allocate(self, start_port: int = 8080, max_range: int = 100) -> int: + def allocate(self, start_port: int = 8080, max_range: int = 100, *, check_docker_host_ports: bool = False) -> int: """Allocate an available port in a thread-safe manner. This method is thread-safe. It finds an available port, marks it as reserved, @@ -69,7 +100,7 @@ class PortAllocator: """ with self._lock: for port in range(start_port, start_port + max_range): - if self._is_port_available(port): + if self._is_port_available(port, check_docker_host_ports=check_docker_host_ports): self._reserved_ports.add(port) return port @@ -85,7 +116,7 @@ class PortAllocator: self._reserved_ports.discard(port) @contextmanager - def allocate_context(self, start_port: int = 8080, max_range: int = 100): + def allocate_context(self, start_port: int = 8080, max_range: int = 100, *, check_docker_host_ports: bool = False): """Context manager for port allocation with automatic release. Args: @@ -95,7 +126,7 @@ class PortAllocator: Yields: An available port number. """ - port = self.allocate(start_port, max_range) + port = self.allocate(start_port, max_range, check_docker_host_ports=check_docker_host_ports) try: yield port finally: @@ -106,7 +137,7 @@ class PortAllocator: _global_port_allocator = PortAllocator() -def get_free_port(start_port: int = 8080, max_range: int = 100) -> int: +def get_free_port(start_port: int = 8080, max_range: int = 100, *, check_docker_host_ports: bool = False) -> int: """Get a free port in a thread-safe manner. This function uses a global port allocator to ensure that concurrent calls @@ -123,7 +154,11 @@ def get_free_port(start_port: int = 8080, max_range: int = 100) -> int: Raises: RuntimeError: If no available port is found in the specified range. """ - return _global_port_allocator.allocate(start_port, max_range) + return _global_port_allocator.allocate( + start_port, + max_range, + check_docker_host_ports=check_docker_host_ports, + ) def release_port(port: int) -> None: