From b96735fdefadd85040833b3cb3b7811826fbb30c Mon Sep 17 00:00:00 2001 From: mandreschak <196235471+mario-andreschak@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:57:59 -0500 Subject: [PATCH 1/2] fix: restrict MCP dependency to 1.x --- pyproject.toml | 2 +- uv.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b7c904c..2cb6e0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "e2b>=2.13.2", - "mcp>=1.26.0", + "mcp>=1.26.0,<2", "modal>=1.3.2", ] diff --git a/uv.lock b/uv.lock index dbdb1b6..dcec4c2 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.13" resolution-markers = [ "python_full_version >= '3.14'", @@ -683,7 +683,7 @@ dev = [ requires-dist = [ { name = "e2b", specifier = ">=2.13.2" }, { name = "kilntainers", extras = ["wasm"], marker = "extra == 'all'" }, - { name = "mcp", specifier = ">=1.26.0" }, + { name = "mcp", specifier = ">=1.26.0,<2" }, { name = "modal", specifier = ">=1.3.2" }, { name = "wasmtime", marker = "extra == 'wasm'", specifier = ">=41.0.0" }, ] From 98e5201f091f21c49593beb08bc4edba6c49fe0e Mon Sep 17 00:00:00 2001 From: mandreschak <196235471+mario-andreschak@users.noreply.github.com> Date: Tue, 11 Aug 2026 07:11:06 -0500 Subject: [PATCH 2/2] fix: isolate Windows backend runtime state --- src/kilntainers/backends/base.py | 9 ++ src/kilntainers/backends/modal.py | 61 +++++++++--- .../backends/test_docker_integration.py | 2 +- src/kilntainers/backends/test_modal.py | 58 ++++++++++++ .../backends/test_modal_integration.py | 3 +- src/kilntainers/backends/test_wasm.py | 19 ++++ src/kilntainers/backends/wasm.py | 33 +++++++ src/kilntainers/cli.py | 3 + src/kilntainers/test_cli.py | 24 ++++- src/kilntainers/test_http_lifecycle.py | 94 +++++++++++++++++-- src/kilntainers/test_lifecycle_integration.py | 6 +- 11 files changed, 285 insertions(+), 27 deletions(-) diff --git a/src/kilntainers/backends/base.py b/src/kilntainers/backends/base.py index 05c9812..9198808 100644 --- a/src/kilntainers/backends/base.py +++ b/src/kilntainers/backends/base.py @@ -160,6 +160,15 @@ def __init__(self, config: "BackendConfig") -> None: self._validated: bool = False self._config = config + @classmethod + def prepare_runtime(cls) -> None: + """Prepare process-wide state before the server event loop is created. + + Most backends need no preparation. Backends with SDKs that configure + global runtime state can override this hook so those changes apply only + when that backend is selected. + """ + @classmethod @abstractmethod def add_cli_arguments(cls, group: argparse._ArgumentGroup) -> None: diff --git a/src/kilntainers/backends/modal.py b/src/kilntainers/backends/modal.py index dee6279..4e2b62c 100644 --- a/src/kilntainers/backends/modal.py +++ b/src/kilntainers/backends/modal.py @@ -1,18 +1,46 @@ """Modal backend implementation.""" +from __future__ import annotations + import argparse import asyncio +import importlib import os import time from dataclasses import dataclass +from typing import TYPE_CHECKING, Any -import modal +if TYPE_CHECKING: + import modal +else: + modal = None from kilntainers.backends.base import Backend, ExecRequest, ExecResult, Sandbox from kilntainers.config import BackendConfig from kilntainers.errors import BackendError, SandboxDiedError +def _get_modal_sdk(*, preserve_event_loop_policy: bool = True) -> Any: + """Import Modal only when the Modal backend is actually used. + + Importing Modal changes the global asyncio event-loop policy on Windows. + Imports from an already-running process preserve its policy so Modal cannot + contaminate later event loops used by other backends. ModalBackend's startup + hook deliberately allows the change when Modal is the selected backend. + """ + global modal + if modal is None: + previous_policy = ( + asyncio.get_event_loop_policy() if preserve_event_loop_policy else None + ) + try: + modal = importlib.import_module("modal") + finally: + if previous_policy is not None: + asyncio.set_event_loop_policy(previous_policy) + return modal + + class _OutputLimitExceeded(Exception): """Internal signal: combined output exceeded the configured limit.""" @@ -56,6 +84,11 @@ class ModalBackend(Backend): through the Modal Python SDK. """ + @classmethod + def prepare_runtime(cls) -> None: + """Load Modal before loop creation so its Windows policy takes effect.""" + _get_modal_sdk(preserve_event_loop_policy=False) + @classmethod def add_cli_arguments(cls, group: argparse._ArgumentGroup) -> None: """Register Modal-specific CLI arguments.""" @@ -143,20 +176,21 @@ async def _validate(self) -> None: """ # Configure auth from CLI args (if provided) self._configure_auth() + modal_sdk = _get_modal_sdk() try: - self._app = await modal.App.lookup.aio( + self._app = await modal_sdk.App.lookup.aio( self._config.app_name, create_if_missing=True, ) - except modal.exception.AuthError: + except modal_sdk.exception.AuthError: raise BackendError( "Modal authentication failed. Either:\n" " - Set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET environment variables\n" " - Run 'modal token set' to configure credentials\n" " - Pass --modal-token-id and --modal-token-secret" ) - except modal.exception.ConnectionError: + except modal_sdk.exception.ConnectionError: raise BackendError( "Cannot connect to Modal. Check your network connection " "and Modal service status at https://status.modal.com" @@ -164,12 +198,13 @@ async def _validate(self) -> None: def _build_image(self) -> modal.Image: """Construct the Modal Image from configuration.""" + modal_sdk = _get_modal_sdk() if self._config.image is None: - return modal.Image.debian_slim() + return modal_sdk.Image.debian_slim() else: - return modal.Image.from_registry(self._config.image) + return modal_sdk.Image.from_registry(self._config.image) - async def _create_sandbox(self) -> "ModalSandbox": + async def _create_sandbox(self) -> ModalSandbox: """Create a Modal sandbox. Performs the full startup sequence: @@ -181,13 +216,14 @@ async def _create_sandbox(self) -> "ModalSandbox": """ # 1. Build image image = self._build_image() + modal_sdk = _get_modal_sdk() # 2. Build GPU config (if specified) gpu_config = self._config.gpu # 3. Create sandbox try: - sb = await modal.Sandbox.create.aio( + sb = await modal_sdk.Sandbox.create.aio( app=self._app, image=image, timeout=self._config.sandbox_timeout, @@ -197,9 +233,9 @@ async def _create_sandbox(self) -> "ModalSandbox": region=self._config.region, block_network=not self._config.network_enabled, ) - except modal.exception.InvalidError as e: + except modal_sdk.exception.InvalidError as e: raise BackendError(f"Failed to create Modal sandbox: {e}") - except modal.exception.NotFoundError: + except modal_sdk.exception.NotFoundError: raise BackendError( f"Modal image not found: '{self._config.image}'. " f"Check that the image name is correct and the registry is accessible." @@ -365,6 +401,7 @@ async def _do_exec(self, request: ExecRequest) -> ExecResult: """Core exec implementation.""" exec_args = self._build_exec_args(request) exec_kwargs = self._build_exec_kwargs(request) + modal_sdk = _get_modal_sdk() start_time = time.monotonic() @@ -428,14 +465,14 @@ async def _do_exec(self, request: ExecRequest) -> ExecResult: exec_duration_ms=elapsed_ms, ) - except modal.exception.SandboxTerminatedError: + except modal_sdk.exception.SandboxTerminatedError: if not self._stop_requested: raise SandboxDiedError( f"Sandbox {self.sandbox_id} died during command execution" ) raise SandboxDiedError("Sandbox has been stopped") - except modal.exception.SandboxTimeoutError: + except modal_sdk.exception.SandboxTimeoutError: # Sandbox lifetime timeout expired (not exec timeout) raise SandboxDiedError( f"Sandbox {self.sandbox_id} lifetime timeout expired" diff --git a/src/kilntainers/backends/test_docker_integration.py b/src/kilntainers/backends/test_docker_integration.py index 661ca76..d90e357 100644 --- a/src/kilntainers/backends/test_docker_integration.py +++ b/src/kilntainers/backends/test_docker_integration.py @@ -117,7 +117,7 @@ async def test_bad_host_fails_validation(self, engine): backend = DockerBackend(config) with pytest.raises(BackendError) as exc_info: - await asyncio.wait_for(backend.validate(), timeout=2) + await asyncio.wait_for(backend.validate(), timeout=15) assert "Cannot connect to" in str(exc_info.value) diff --git a/src/kilntainers/backends/test_modal.py b/src/kilntainers/backends/test_modal.py index fa00ca0..b1f1317 100644 --- a/src/kilntainers/backends/test_modal.py +++ b/src/kilntainers/backends/test_modal.py @@ -8,6 +8,7 @@ import pytest +import kilntainers.backends.modal as modal_backend_module from kilntainers.backends.base import ExecRequest from kilntainers.backends.modal import ( ModalBackend, @@ -219,6 +220,63 @@ def default_config(): # --- ModalBackend tests --- +class TestModalRuntimePreparation: + """Tests for containing Modal's process-wide import side effects.""" + + def test_lazy_import_restores_event_loop_policy(self, monkeypatch): + original_policy = object() + imported_sdk = object() + restored_policies = [] + + monkeypatch.setattr(modal_backend_module, "modal", None) + monkeypatch.setattr( + modal_backend_module.asyncio, + "get_event_loop_policy", + lambda: original_policy, + ) + monkeypatch.setattr( + modal_backend_module.asyncio, + "set_event_loop_policy", + restored_policies.append, + ) + monkeypatch.setattr( + modal_backend_module.importlib, + "import_module", + lambda name: imported_sdk, + ) + + result = modal_backend_module._get_modal_sdk() + + assert result is imported_sdk + assert restored_policies == [original_policy] + + def test_selected_modal_backend_keeps_sdk_policy_change(self, monkeypatch): + imported_sdk = object() + + monkeypatch.setattr(modal_backend_module, "modal", None) + monkeypatch.setattr( + modal_backend_module.asyncio, + "get_event_loop_policy", + lambda: pytest.fail("selected Modal startup must not preserve the policy"), + ) + monkeypatch.setattr( + modal_backend_module.asyncio, + "set_event_loop_policy", + lambda policy: pytest.fail( + "selected Modal startup must not restore policy" + ), + ) + monkeypatch.setattr( + modal_backend_module.importlib, + "import_module", + lambda name: imported_sdk, + ) + + ModalBackend.prepare_runtime() + + assert modal_backend_module.modal is imported_sdk + + class TestModalBackendConfig: """Tests for ModalBackendConfig dataclass.""" diff --git a/src/kilntainers/backends/test_modal_integration.py b/src/kilntainers/backends/test_modal_integration.py index 004af2f..26f0e79 100644 --- a/src/kilntainers/backends/test_modal_integration.py +++ b/src/kilntainers/backends/test_modal_integration.py @@ -18,6 +18,7 @@ ModalBackend, ModalBackendConfig, ModalSandbox, + _get_modal_sdk, ) from kilntainers.errors import BackendError, SandboxDiedError @@ -30,7 +31,7 @@ def _modal_auth_available() -> bool: # Try to validate with Modal client (may fail if no auth) try: - import modal + modal = _get_modal_sdk() # Check if there's a default profile or active token # modal.config.Config() will load the configuration diff --git a/src/kilntainers/backends/test_wasm.py b/src/kilntainers/backends/test_wasm.py index 59f9e91..5e9714c 100644 --- a/src/kilntainers/backends/test_wasm.py +++ b/src/kilntainers/backends/test_wasm.py @@ -10,6 +10,7 @@ import pytest +import kilntainers.backends.wasm as wasm_backend_module from kilntainers.backends.base import ExecRequest from kilntainers.backends.wasm import ( GoBusyBoxBackend, @@ -24,6 +25,24 @@ # --- Mock wasmtime utilities --- +def test_windows_architecture_fallback_uses_python_platform(monkeypatch): + """Wasmtime gets a stable architecture when Windows environment data is absent.""" + monkeypatch.setattr(wasm_backend_module.sys, "platform", "win32") + monkeypatch.setattr( + wasm_backend_module.sysconfig, + "get_platform", + lambda: "win-amd64", + ) + monkeypatch.delenv("PROCESSOR_ARCHITEW6432", raising=False) + monkeypatch.delenv("PROCESSOR_ARCHITECTURE", raising=False) + monkeypatch.setattr(wasm_backend_module.platform, "_uname_cache", object()) + + wasm_backend_module._ensure_windows_processor_architecture() + + assert os.environ["PROCESSOR_ARCHITECTURE"] == "AMD64" + assert wasm_backend_module.platform._uname_cache is None + + class MockWasiConfig: """Mock wasmtime.WasiConfig.""" diff --git a/src/kilntainers/backends/wasm.py b/src/kilntainers/backends/wasm.py index 9a0440b..8deb2da 100644 --- a/src/kilntainers/backends/wasm.py +++ b/src/kilntainers/backends/wasm.py @@ -3,13 +3,46 @@ import argparse import asyncio import os +import platform import shutil +import sys +import sysconfig import tempfile import threading import time from dataclasses import dataclass from typing import TYPE_CHECKING + +def _ensure_windows_processor_architecture() -> None: + """Provide Wasmtime's fallback architecture when Windows omits it. + + Wasmtime first queries WMI and falls back to PROCESSOR_ARCHITECTURE. + Parallel test workers can overwhelm WMI, while some environments omit the + fallback variable entirely. Python's build platform is deterministic and + identifies the same architecture without another system query. + """ + if sys.platform != "win32": + return + if os.environ.get("PROCESSOR_ARCHITEW6432") or os.environ.get( + "PROCESSOR_ARCHITECTURE" + ): + return + + python_platform = sysconfig.get_platform().lower() + architecture = { + "win-amd64": "AMD64", + "win-arm64": "ARM64", + }.get(python_platform) + if architecture is not None: + os.environ["PROCESSOR_ARCHITECTURE"] = architecture + # A failed WMI query may already have cached an empty machine value. + # Clear it so Wasmtime's platform.machine() call uses the fallback. + platform._uname_cache = None # ty: ignore[unresolved-attribute] + + +_ensure_windows_processor_architecture() + if TYPE_CHECKING: import wasmtime # type: ignore[import-not-found] else: diff --git a/src/kilntainers/cli.py b/src/kilntainers/cli.py index 3d49a42..2a5f837 100644 --- a/src/kilntainers/cli.py +++ b/src/kilntainers/cli.py @@ -285,6 +285,7 @@ def main() -> None: # Create backend (validation happens lazily on first sandbox_exec) backend_name = args.backend backend_class = get_backend_class(backend_name) + backend_class.prepare_runtime() backend = backend_class(backend_config) # Create the MCP server (assembles tool description, registers tool) @@ -315,6 +316,8 @@ def _handle_sigterm(signum: int, frame: object) -> None: os.kill(os.getpid(), signal.SIGINT) signal.signal(signal.SIGTERM, _handle_sigterm) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _handle_sigterm) try: mcp.run(transport=transport) diff --git a/src/kilntainers/test_cli.py b/src/kilntainers/test_cli.py index d174516..46b6c29 100644 --- a/src/kilntainers/test_cli.py +++ b/src/kilntainers/test_cli.py @@ -1,5 +1,7 @@ """Tests for CLI argument parsing, config construction, and validation.""" +import subprocess +import sys from typing import cast from unittest.mock import MagicMock, patch @@ -555,12 +557,15 @@ def test_main_keyboard_interrupt(): mock_args = MagicMock() mock_parser.return_value.parse_args.return_value = mock_args mock_build_configs.return_value = (ServerConfig(), DockerBackendConfig()) - mock_get_backend.return_value = lambda _: mock_backend + mock_backend_class = MagicMock(return_value=mock_backend) + mock_get_backend.return_value = mock_backend_class mock_create_server.return_value = mock_mcp # Should not raise main() + mock_backend_class.prepare_runtime.assert_called_once_with() + # ================ # Integration Test: Help Output @@ -584,3 +589,20 @@ def test_help_output_structure(): assert "--engine" in help_text assert "--image" in help_text assert "--docker-run-flag" in help_text + + +def test_loading_modal_backend_preserves_event_loop_policy_in_fresh_process(): + """Loading Modal's backend class must not import its policy-changing SDK.""" + script = """ +import asyncio +from kilntainers.backends import get_backend_class + +policy_before = type(asyncio.get_event_loop_policy()) +get_backend_class("modal") +policy_after = type(asyncio.get_event_loop_policy()) +raise SystemExit(0 if policy_after is policy_before else 1) +""" + + result = subprocess.run([sys.executable, "-c", script], check=False) + + assert result.returncode == 0 diff --git a/src/kilntainers/test_http_lifecycle.py b/src/kilntainers/test_http_lifecycle.py index 98c851b..53b955d 100644 --- a/src/kilntainers/test_http_lifecycle.py +++ b/src/kilntainers/test_http_lifecycle.py @@ -140,6 +140,11 @@ async def test_separate_http_connections_get_separate_vms(self): """ import asyncio import json + import signal + import socket + import subprocess + import sys + import uuid from mcp.client.session import ClientSession from mcp.client.streamable_http import streamable_http_client @@ -147,11 +152,17 @@ async def test_separate_http_connections_get_separate_vms(self): # Skip if docker is not available validate_engine_available("docker") - # Start HTTP server as subprocess (use random port to avoid conflicts) + # Reserve a dynamic port to avoid conflicts between parallel workers. + with socket.socket() as port_socket: + port_socket.bind(("127.0.0.1", 0)) + server_port = port_socket.getsockname()[1] + test_container_label = f"kilntainers-http-test={uuid.uuid4().hex}" + + # Use the current interpreter directly so terminating this process also + # terminates the server, rather than leaving a child behind an `uv run` + # wrapper. server_proc = await asyncio.create_subprocess_exec( - "uv", - "run", - "python", + sys.executable, "-m", "kilntainers", "--transport", @@ -159,13 +170,33 @@ async def test_separate_http_connections_get_separate_vms(self): "--host", "127.0.0.1", "--port", - "18435", + str(server_port), + f"--docker-run-flag=--label={test_container_label}", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + creationflags=( + subprocess.CREATE_NEW_PROCESS_GROUP if sys.platform == "win32" else 0 + ), ) - # Wait for server to start - await asyncio.sleep(3) + # Wait until the server accepts connections, with a bounded deadline. + startup_deadline = asyncio.get_running_loop().time() + 15 + server_ready = False + while True: + if server_proc.returncode is not None: + break + try: + _reader, writer = await asyncio.open_connection( + "127.0.0.1", server_port + ) + writer.close() + await writer.wait_closed() + server_ready = True + break + except OSError: + if asyncio.get_running_loop().time() >= startup_deadline: + break + await asyncio.sleep(0.1) # Check if server started successfully if server_proc.returncode is not None: @@ -176,8 +207,16 @@ async def test_separate_http_connections_get_separate_vms(self): raise RuntimeError( f"Server failed to start. Return code: {server_proc.returncode}. Stderr: {stderr_str}" ) + if not server_ready: + server_proc.terminate() + try: + await asyncio.wait_for(server_proc.wait(), timeout=15) + except TimeoutError: + server_proc.kill() + await server_proc.wait() + raise RuntimeError("Server did not accept connections within 15 seconds") - server_url = "http://127.0.0.1:18435/mcp" + server_url = f"http://127.0.0.1:{server_port}/mcp" # Events to coordinate execution order (no sleep/timing dependencies) client1_ready = asyncio.Event() @@ -269,7 +308,42 @@ async def client2_session(): finally: # Clean up server if server_proc.returncode is None: - server_proc.terminate() - await server_proc.wait() + if sys.platform == "win32": + server_proc.send_signal(signal.CTRL_BREAK_EVENT) + else: + server_proc.terminate() + try: + await asyncio.wait_for(server_proc.wait(), timeout=15) + except TimeoutError: + server_proc.kill() + await server_proc.wait() else: await server_proc.wait() + + # Session shutdown is best-effort if the server is interrupted. + # Remove only containers uniquely labeled by this test run. + container_query = await asyncio.create_subprocess_exec( + "docker", + "ps", + "-aq", + "--filter", + f"label={test_container_label}", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + query_stdout, _query_stderr = await asyncio.wait_for( + container_query.communicate(), timeout=10 + ) + container_ids = query_stdout.decode().split() + if container_ids: + container_stop = await asyncio.create_subprocess_exec( + "docker", + "stop", + *container_ids, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + await asyncio.wait_for( + container_stop.communicate(), + timeout=20, + ) diff --git a/src/kilntainers/test_lifecycle_integration.py b/src/kilntainers/test_lifecycle_integration.py index 442b098..e5c2073 100644 --- a/src/kilntainers/test_lifecycle_integration.py +++ b/src/kilntainers/test_lifecycle_integration.py @@ -254,10 +254,12 @@ async def test_death_propagation_stdio(self, backend, engine): """Sandbox death triggers death callback in stdio mode.""" # Use a list to capture death notifications without sending SIGTERM death_notifications: list[None] = [] + death_event = asyncio.Event() def death_callback() -> None: """Capture death notification instead of sending SIGTERM.""" death_notifications.append(None) + death_event.set() lifespan_fn = create_lifespan(backend, "stdio", death_callback=death_callback) mock_server = MagicMock() @@ -273,8 +275,8 @@ def death_callback() -> None: capture_output=True, ) - # Give death task time to process - await asyncio.sleep(0.2) + # Wait for the death monitor instead of relying on scheduling speed. + await asyncio.wait_for(death_event.wait(), timeout=10) # Verify death callback was invoked assert len(death_notifications) >= 1