Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
9 changes: 9 additions & 0 deletions src/kilntainers/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
61 changes: 49 additions & 12 deletions src/kilntainers/backends/modal.py
Original file line number Diff line number Diff line change
@@ -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."""

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -143,33 +176,35 @@ 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"
)

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:
Expand All @@ -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,
Expand All @@ -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."
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/kilntainers/backends/test_docker_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
58 changes: 58 additions & 0 deletions src/kilntainers/backends/test_modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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."""

Expand Down
3 changes: 2 additions & 1 deletion src/kilntainers/backends/test_modal_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
ModalBackend,
ModalBackendConfig,
ModalSandbox,
_get_modal_sdk,
)
from kilntainers.errors import BackendError, SandboxDiedError

Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/kilntainers/backends/test_wasm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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."""

Expand Down
33 changes: 33 additions & 0 deletions src/kilntainers/backends/wasm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/kilntainers/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading