From 2c2ae4f3a2d6fd9ab50d008251a75a174d0fc2ec Mon Sep 17 00:00:00 2001 From: Yunnglin Date: Mon, 25 May 2026 15:21:18 +0800 Subject: [PATCH 1/3] fix(local_manager): tolerate cross-loop shutdown of cleanup task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalSandboxManager._cleanup_task is bound to whatever asyncio loop first called start() — typically a per-worker thread loop. When that worker thread tears down its loop before SandboxService.shutdown_all runs at process exit, manager.stop() on the main loop hits: RuntimeError: Event loop is closed (when awaiting the cancellation) and asyncio then prints: Task was destroyed but it is pending! on GC of the orphaned cleanup task. Detect a closed owner loop and: - suppress the asyncio pending-task warning via _log_destroy_pending - skip the cancel/await dance entirely (the task's loop is gone, so the cancellation cannot be driven to completion anyway) Otherwise behave exactly as before. Tolerate RuntimeError in the live-loop branch too as belt-and-suspenders for any other ordering race. --- ms_enclave/sandbox/manager/local_manager.py | 22 ++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/ms_enclave/sandbox/manager/local_manager.py b/ms_enclave/sandbox/manager/local_manager.py index 5b60a4f..957e2da 100644 --- a/ms_enclave/sandbox/manager/local_manager.py +++ b/ms_enclave/sandbox/manager/local_manager.py @@ -55,13 +55,25 @@ async def stop(self) -> None: self._running = False - # Cancel cleanup task + # Cancel cleanup task. The task may be bound to an asyncio loop that + # has already been closed (e.g. when the worker thread that first + # called start() has exited and torn down its per-thread loop before + # atexit-driven shutdown reaches us). In that case we can't drive the + # cancellation to completion, so suppress asyncio's "Task was + # destroyed but it is pending" noise via _log_destroy_pending. if self._cleanup_task: - self._cleanup_task.cancel() try: - await self._cleanup_task - except asyncio.CancelledError: - pass + task_loop = self._cleanup_task.get_loop() + except RuntimeError: + task_loop = None + if task_loop is None or task_loop.is_closed(): + self._cleanup_task._log_destroy_pending = False + else: + try: + self._cleanup_task.cancel() + await self._cleanup_task + except (asyncio.CancelledError, RuntimeError): + pass # Stop and cleanup all sandboxes await self.cleanup_all_sandboxes() From 81adb749c960b94b9c70415631658e4b5bba56d3 Mon Sep 17 00:00:00 2001 From: Yunnglin Date: Mon, 25 May 2026 15:21:26 +0800 Subject: [PATCH 2/3] feat(docker_sandbox): expose extra_hosts on DockerSandboxConfig Adds a Dict[str, str] field forwarded verbatim to docker-py's extra_hosts kwarg, so containers can be launched with custom /etc/hosts entries (e.g. {"host.docker.internal": "host-gateway"} to reach host services from inside the container on Linux, where that alias is not auto-provided). --- ms_enclave/sandbox/boxes/docker_sandbox.py | 5 +++++ ms_enclave/sandbox/model/config.py | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/ms_enclave/sandbox/boxes/docker_sandbox.py b/ms_enclave/sandbox/boxes/docker_sandbox.py index 3d9e6ce..a271475 100644 --- a/ms_enclave/sandbox/boxes/docker_sandbox.py +++ b/ms_enclave/sandbox/boxes/docker_sandbox.py @@ -475,6 +475,11 @@ async def _create_container(self) -> None: elif self.config.network: container_config['network'] = self.config.network + # Extra /etc/hosts entries (e.g. host.docker.internal -> host-gateway + # on Linux so containerised agents can reach services on the host). + if self.config.extra_hosts: + container_config['extra_hosts'] = dict(self.config.extra_hosts) + # Privileged mode container_config['privileged'] = self.config.privileged diff --git a/ms_enclave/sandbox/model/config.py b/ms_enclave/sandbox/model/config.py index 955e7f5..fe7d1a0 100644 --- a/ms_enclave/sandbox/model/config.py +++ b/ms_enclave/sandbox/model/config.py @@ -65,6 +65,16 @@ class DockerSandboxConfig(SandboxConfig): ) ports: Dict[str, Union[int, str, Tuple[str, int]]] = Field(default_factory=dict, description='Port mappings') network: Optional[str] = Field('bridge', description='Network name') + extra_hosts: Dict[str, str] = Field( + default_factory=dict, + description=( + 'Additional ``hostname -> ip`` entries written to the container\'s /etc/hosts. ' + 'Use the special value ``host-gateway`` to resolve a hostname to the Docker host ' + '(e.g. ``{"host.docker.internal": "host-gateway"}`` so processes inside the ' + 'container can reach a service on the host on Linux, where the alias is not ' + 'provided automatically). Maps directly onto the docker-py ``extra_hosts`` kwarg.' + ), + ) platform: Optional[str] = Field( default=None, description='Docker platform (e.g. "linux/amd64"). Required when the image arch differs from the host ' From bfd2a31384ac8d489c18e9140ddfd64f1f0964a4 Mon Sep 17 00:00:00 2001 From: Yunnglin Date: Mon, 25 May 2026 15:59:27 +0800 Subject: [PATCH 3/3] fix(local_manager): skip done cleanup task and clear reference after stop Address review feedback: short-circuit when the cleanup task is already done, suppress destroy-pending warning on cross-loop RuntimeError, and drop the task reference so a restarted manager starts clean. --- ms_enclave/sandbox/manager/local_manager.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ms_enclave/sandbox/manager/local_manager.py b/ms_enclave/sandbox/manager/local_manager.py index 957e2da..b7057da 100644 --- a/ms_enclave/sandbox/manager/local_manager.py +++ b/ms_enclave/sandbox/manager/local_manager.py @@ -61,7 +61,7 @@ async def stop(self) -> None: # atexit-driven shutdown reaches us). In that case we can't drive the # cancellation to completion, so suppress asyncio's "Task was # destroyed but it is pending" noise via _log_destroy_pending. - if self._cleanup_task: + if self._cleanup_task and not self._cleanup_task.done(): try: task_loop = self._cleanup_task.get_loop() except RuntimeError: @@ -72,8 +72,11 @@ async def stop(self) -> None: try: self._cleanup_task.cancel() await self._cleanup_task - except (asyncio.CancelledError, RuntimeError): + except asyncio.CancelledError: pass + except RuntimeError: + self._cleanup_task._log_destroy_pending = False + self._cleanup_task = None # Stop and cleanup all sandboxes await self.cleanup_all_sandboxes()