312 lines
12 KiB
Python
312 lines
12 KiB
Python
"""Yuxi AIO 风格沙盒 provider。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import hashlib
|
|
import os
|
|
import signal
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from yuxi import config as sys_config
|
|
from yuxi.services.skill_service import get_skills_root_dir
|
|
from yuxi.utils.logging_config import logger
|
|
|
|
from .sandbox_config import (
|
|
IDLE_CHECK_INTERVAL,
|
|
LARGE_TOOL_RESULTS_DIR,
|
|
OUTPUTS_DIR,
|
|
SKILLS_PATH,
|
|
THREADS_DIR,
|
|
UPLOADS_DIR,
|
|
USER_DATA_PATH,
|
|
WORKSPACE_DIR,
|
|
get_container_prefix,
|
|
get_idle_timeout,
|
|
get_max_replicas,
|
|
get_sandbox_base_port,
|
|
get_sandbox_image,
|
|
get_sandbox_provisioner_url,
|
|
)
|
|
from .sandbox_executor import YuxiSandboxBackend
|
|
from .sandbox_info import SandboxInfo
|
|
from .sandbox_local_container import LocalContainerBackend
|
|
from .sandbox_provisioner_base import SandboxBackend
|
|
from .sandbox_remote import RemoteSandboxBackend
|
|
|
|
|
|
class YuxiSandboxProvider:
|
|
def __init__(self):
|
|
self._lock = threading.Lock()
|
|
self._sandboxes: dict[str, YuxiSandboxBackend] = {}
|
|
self._sandbox_infos: dict[str, SandboxInfo] = {}
|
|
self._thread_sandboxes: dict[str, str] = {}
|
|
self._thread_locks: dict[str, threading.Lock] = {}
|
|
self._last_activity: dict[str, float] = {}
|
|
self._warm_pool: dict[str, tuple[SandboxInfo, float]] = {}
|
|
self._shutdown_called = False
|
|
self._idle_checker_stop = threading.Event()
|
|
self._idle_checker_thread: threading.Thread | None = None
|
|
|
|
self._idle_timeout = get_idle_timeout()
|
|
self._max_replicas = get_max_replicas()
|
|
self._backend: SandboxBackend = self._create_backend()
|
|
|
|
atexit.register(self.shutdown)
|
|
self._register_signal_handlers()
|
|
if self._idle_timeout > 0:
|
|
self._start_idle_checker()
|
|
|
|
def _create_backend(self) -> SandboxBackend:
|
|
provisioner_url = get_sandbox_provisioner_url()
|
|
if provisioner_url:
|
|
logger.info(f"Using remote sandbox backend: {provisioner_url}")
|
|
return RemoteSandboxBackend(provisioner_url)
|
|
|
|
return LocalContainerBackend(
|
|
image=get_sandbox_image(),
|
|
base_port=get_sandbox_base_port(),
|
|
container_prefix=get_container_prefix(),
|
|
)
|
|
|
|
def _register_signal_handlers(self):
|
|
try:
|
|
self._original_sigterm = signal.getsignal(signal.SIGTERM)
|
|
self._original_sigint = signal.getsignal(signal.SIGINT)
|
|
|
|
def signal_handler(signum, frame):
|
|
self.shutdown()
|
|
original = self._original_sigterm if signum == signal.SIGTERM else self._original_sigint
|
|
if callable(original):
|
|
original(signum, frame)
|
|
elif original == signal.SIG_DFL:
|
|
signal.signal(signum, signal.SIG_DFL)
|
|
signal.raise_signal(signum)
|
|
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
except ValueError:
|
|
logger.debug("Could not register signal handlers (not main thread)")
|
|
|
|
def _start_idle_checker(self):
|
|
self._idle_checker_thread = threading.Thread(
|
|
target=self._idle_checker_loop,
|
|
name="sandbox-idle-checker",
|
|
daemon=True,
|
|
)
|
|
self._idle_checker_thread.start()
|
|
|
|
def _idle_checker_loop(self):
|
|
while not self._idle_checker_stop.wait(timeout=IDLE_CHECK_INTERVAL):
|
|
try:
|
|
self._cleanup_idle_sandboxes()
|
|
except Exception as exc:
|
|
logger.error(f"Error in idle checker loop: {exc}")
|
|
|
|
def _cleanup_idle_sandboxes(self):
|
|
current_time = time.time()
|
|
active_to_destroy: list[str] = []
|
|
warm_to_destroy: list[tuple[str, SandboxInfo]] = []
|
|
|
|
with self._lock:
|
|
for sandbox_id, last_activity in self._last_activity.items():
|
|
if current_time - last_activity > self._idle_timeout:
|
|
active_to_destroy.append(sandbox_id)
|
|
for sandbox_id, (info, released_at) in list(self._warm_pool.items()):
|
|
if current_time - released_at > self._idle_timeout:
|
|
warm_to_destroy.append((sandbox_id, info))
|
|
del self._warm_pool[sandbox_id]
|
|
|
|
for sandbox_id in active_to_destroy:
|
|
self.destroy_by_key(sandbox_id)
|
|
|
|
for sandbox_id, info in warm_to_destroy:
|
|
try:
|
|
self._backend.destroy(info)
|
|
logger.info(f"Destroyed idle warm sandbox {sandbox_id}")
|
|
except Exception as exc:
|
|
logger.warning(f"Failed to destroy idle warm sandbox {sandbox_id}: {exc}")
|
|
|
|
def _get_thread_lock(self, thread_id: str) -> threading.Lock:
|
|
with self._lock:
|
|
return self._thread_locks.setdefault(thread_id, threading.Lock())
|
|
|
|
@staticmethod
|
|
def _deterministic_sandbox_id(thread_id: str) -> str:
|
|
return hashlib.sha256(thread_id.encode()).hexdigest()[:8]
|
|
|
|
def _get_thread_user_data_dir(self, thread_id: str) -> Path:
|
|
root = (Path(sys_config.save_dir) / THREADS_DIR / thread_id / "user-data").resolve()
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
return root
|
|
|
|
@staticmethod
|
|
def _resolve_mount_source(path: Path) -> str:
|
|
resolved_path = path.resolve()
|
|
host_project_dir = os.getenv("YUXI_HOST_PROJECT_DIR", "").strip()
|
|
app_root = Path("/app")
|
|
if host_project_dir and (resolved_path == app_root or resolved_path.is_relative_to(app_root)):
|
|
host_root = Path(host_project_dir).resolve()
|
|
return str(host_root / resolved_path.relative_to(app_root))
|
|
return str(resolved_path)
|
|
|
|
def ensure_thread_dirs(self, thread_id: str) -> Path:
|
|
user_data_dir = self._get_thread_user_data_dir(thread_id)
|
|
for name in (WORKSPACE_DIR, OUTPUTS_DIR, UPLOADS_DIR, LARGE_TOOL_RESULTS_DIR):
|
|
(user_data_dir / name).mkdir(parents=True, exist_ok=True)
|
|
(user_data_dir / UPLOADS_DIR / "attachments").mkdir(parents=True, exist_ok=True)
|
|
return user_data_dir
|
|
|
|
def get_thread_user_data_dir(self, thread_id: str) -> Path:
|
|
return self.ensure_thread_dirs(thread_id)
|
|
|
|
def _get_extra_mounts(self, thread_id: str) -> list[tuple[str, str, bool]]:
|
|
mounts = [(self._resolve_mount_source(self.ensure_thread_dirs(thread_id)), USER_DATA_PATH, False)]
|
|
try:
|
|
skills_path = get_skills_root_dir()
|
|
if skills_path.exists():
|
|
mounts.append((self._resolve_mount_source(skills_path), SKILLS_PATH, True))
|
|
except Exception as exc:
|
|
logger.warning(f"Could not setup skills mount: {exc}")
|
|
return mounts
|
|
|
|
def acquire(self, thread_id: str) -> YuxiSandboxBackend:
|
|
thread_lock = self._get_thread_lock(thread_id)
|
|
with thread_lock:
|
|
return self._acquire_internal(thread_id)
|
|
|
|
def _acquire_internal(self, thread_id: str) -> YuxiSandboxBackend:
|
|
sandbox_id = self._deterministic_sandbox_id(thread_id)
|
|
|
|
with self._lock:
|
|
existing_id = self._thread_sandboxes.get(thread_id)
|
|
if existing_id and existing_id in self._sandboxes:
|
|
self._last_activity[existing_id] = time.time()
|
|
return self._sandboxes[existing_id]
|
|
|
|
if sandbox_id in self._warm_pool:
|
|
info, _ = self._warm_pool.pop(sandbox_id)
|
|
return self._register_sandbox(thread_id, sandbox_id, info)
|
|
|
|
info = self._backend.discover(sandbox_id)
|
|
if info is not None:
|
|
return self._register_sandbox(thread_id, sandbox_id, info)
|
|
|
|
with self._lock:
|
|
total = len(self._sandboxes) + len(self._warm_pool)
|
|
if total >= self._max_replicas:
|
|
self._evict_oldest_warm()
|
|
|
|
info = self._backend.create(
|
|
thread_id=thread_id,
|
|
sandbox_id=sandbox_id,
|
|
extra_mounts=self._get_extra_mounts(thread_id),
|
|
)
|
|
return self._register_sandbox(thread_id, sandbox_id, info)
|
|
|
|
def _register_sandbox(self, thread_id: str, sandbox_id: str, info: SandboxInfo) -> YuxiSandboxBackend:
|
|
backend = YuxiSandboxBackend(
|
|
sandbox_key=sandbox_id,
|
|
container_name=info.container_name,
|
|
thread_id=thread_id,
|
|
host_user_data_dir=self.ensure_thread_dirs(thread_id),
|
|
skills_host_path=get_skills_root_dir(),
|
|
)
|
|
with self._lock:
|
|
self._sandboxes[sandbox_id] = backend
|
|
self._sandbox_infos[sandbox_id] = info
|
|
self._thread_sandboxes[thread_id] = sandbox_id
|
|
self._last_activity[sandbox_id] = time.time()
|
|
return backend
|
|
|
|
def get(self, thread_id: str) -> YuxiSandboxBackend | None:
|
|
with self._lock:
|
|
sandbox_id = self._thread_sandboxes.get(thread_id)
|
|
return self._sandboxes.get(sandbox_id) if sandbox_id else None
|
|
|
|
def release(self, thread_id: str) -> None:
|
|
with self._lock:
|
|
sandbox_id = self._thread_sandboxes.pop(thread_id, None)
|
|
if not sandbox_id:
|
|
return
|
|
info = self._sandbox_infos.pop(sandbox_id, None)
|
|
self._sandboxes.pop(sandbox_id, None)
|
|
self._last_activity.pop(sandbox_id, None)
|
|
if info is not None:
|
|
self._warm_pool[sandbox_id] = (info, time.time())
|
|
|
|
def destroy(self, thread_id: str) -> None:
|
|
sandbox_id = None
|
|
info = None
|
|
with self._lock:
|
|
sandbox_id = self._thread_sandboxes.pop(thread_id, None) or self._deterministic_sandbox_id(thread_id)
|
|
self._sandboxes.pop(sandbox_id, None)
|
|
info = self._sandbox_infos.pop(sandbox_id, None)
|
|
self._last_activity.pop(sandbox_id, None)
|
|
warm_info, _ = self._warm_pool.pop(sandbox_id, (None, None))
|
|
if info is None:
|
|
info = warm_info
|
|
if info is not None:
|
|
self._backend.destroy(info)
|
|
|
|
def destroy_by_key(self, sandbox_id: str) -> None:
|
|
info = None
|
|
with self._lock:
|
|
self._sandboxes.pop(sandbox_id, None)
|
|
info = self._sandbox_infos.pop(sandbox_id, None)
|
|
self._last_activity.pop(sandbox_id, None)
|
|
self._warm_pool.pop(sandbox_id, None)
|
|
thread_ids = [
|
|
thread_id for thread_id, mapped_id in self._thread_sandboxes.items() if mapped_id == sandbox_id
|
|
]
|
|
for thread_id in thread_ids:
|
|
del self._thread_sandboxes[thread_id]
|
|
if info is not None:
|
|
self._backend.destroy(info)
|
|
|
|
def _evict_oldest_warm(self) -> str | None:
|
|
with self._lock:
|
|
if not self._warm_pool:
|
|
return None
|
|
sandbox_id = min(self._warm_pool, key=lambda key: self._warm_pool[key][1])
|
|
info, _ = self._warm_pool.pop(sandbox_id)
|
|
self._backend.destroy(info)
|
|
return sandbox_id
|
|
|
|
def shutdown(self) -> None:
|
|
with self._lock:
|
|
if self._shutdown_called:
|
|
return
|
|
self._shutdown_called = True
|
|
active_ids = list(self._sandboxes.keys())
|
|
warm_items = list(self._warm_pool.items())
|
|
self._warm_pool.clear()
|
|
|
|
self._idle_checker_stop.set()
|
|
if self._idle_checker_thread and self._idle_checker_thread.is_alive():
|
|
self._idle_checker_thread.join(timeout=5)
|
|
|
|
for sandbox_id in active_ids:
|
|
try:
|
|
self.destroy_by_key(sandbox_id)
|
|
except Exception as exc:
|
|
logger.warning(f"Failed to destroy sandbox {sandbox_id} during shutdown: {exc}")
|
|
|
|
for sandbox_id, (info, _) in warm_items:
|
|
try:
|
|
self._backend.destroy(info)
|
|
except Exception as exc:
|
|
logger.warning(f"Failed to destroy warm sandbox {sandbox_id} during shutdown: {exc}")
|
|
|
|
|
|
_sandbox_provider: YuxiSandboxProvider | None = None
|
|
|
|
|
|
def get_sandbox_provider() -> YuxiSandboxProvider:
|
|
global _sandbox_provider
|
|
if _sandbox_provider is None:
|
|
_sandbox_provider = YuxiSandboxProvider()
|
|
return _sandbox_provider
|