mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-28 08:56:13 +00:00
* fix(sandbox): prevent truncated tenant ID reuse * fix(sandbox): handle late same-tenant box registration
993 lines
44 KiB
Python
993 lines
44 KiB
Python
"""Tests for AioSandboxProvider mount helpers."""
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import hashlib
|
|
import importlib
|
|
import stat
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from deerflow.config.paths import Paths, join_host_path
|
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
|
|
|
_LEGACY_COLLIDING_IDENTITIES = (
|
|
("user-9721", "thread-9721"),
|
|
("user-94361", "thread-94361"),
|
|
)
|
|
|
|
# ── ensure_thread_dirs ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_ensure_thread_dirs_creates_acp_workspace(tmp_path):
|
|
"""ACP workspace directory must be created alongside user-data dirs."""
|
|
paths = Paths(base_dir=tmp_path)
|
|
paths.ensure_thread_dirs("thread-1")
|
|
|
|
assert (tmp_path / "threads" / "thread-1" / "user-data" / "workspace").exists()
|
|
assert (tmp_path / "threads" / "thread-1" / "user-data" / "uploads").exists()
|
|
assert (tmp_path / "threads" / "thread-1" / "user-data" / "outputs").exists()
|
|
assert (tmp_path / "threads" / "thread-1" / "acp-workspace").exists()
|
|
|
|
|
|
def test_ensure_thread_dirs_acp_workspace_is_world_writable(tmp_path):
|
|
"""ACP workspace must be chmod 0o777 so the ACP subprocess can write into it."""
|
|
paths = Paths(base_dir=tmp_path)
|
|
paths.ensure_thread_dirs("thread-2")
|
|
|
|
acp_dir = tmp_path / "threads" / "thread-2" / "acp-workspace"
|
|
mode = oct(acp_dir.stat().st_mode & 0o777)
|
|
assert mode == oct(0o777)
|
|
|
|
|
|
def test_host_thread_dir_rejects_invalid_thread_id(tmp_path):
|
|
paths = Paths(base_dir=tmp_path)
|
|
|
|
with pytest.raises(ValueError, match="Invalid thread_id"):
|
|
paths.host_thread_dir("../escape")
|
|
|
|
|
|
# ── _get_thread_mounts ───────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_provider(tmp_path):
|
|
"""Build a minimal AioSandboxProvider instance without starting the idle checker.
|
|
|
|
``tmp_path`` is accepted and ignored: ownership no longer lives on disk. Each
|
|
provider gets its own in-process ownership store, so it owns every sandbox it
|
|
tracks — cross-instance behaviour is covered in
|
|
``test_sandbox_orphan_reconciliation.py`` (shared store) and
|
|
``test_sandbox_ownership_store.py`` (store contract).
|
|
"""
|
|
from deerflow.community.aio_sandbox.ownership.memory import MemoryOwnershipStore
|
|
from deerflow.config.sandbox_config import SandboxOwnershipConfig
|
|
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
with patch.object(aio_mod.AioSandboxProvider, "_start_idle_checker"):
|
|
provider = aio_mod.AioSandboxProvider.__new__(aio_mod.AioSandboxProvider)
|
|
provider._config = {"idle_timeout": 600, "replicas": 3}
|
|
provider._sandboxes = {}
|
|
provider._active_sandbox_identity = {}
|
|
provider._warm_pool_identity = {}
|
|
provider._local_teardown = set()
|
|
provider._acquire_epoch = {}
|
|
provider._acquire_epoch_counter = 0
|
|
provider._acquire_inflight = {}
|
|
provider._lock = MagicMock()
|
|
provider._idle_checker_stop = MagicMock()
|
|
provider._renewal_stop = MagicMock()
|
|
provider._renewal_thread = None
|
|
provider._owner_id = "test-worker"
|
|
provider._ownership_config = SandboxOwnershipConfig()
|
|
provider._ownership = MemoryOwnershipStore(owner_id="test-worker", ttl_seconds=600)
|
|
return provider
|
|
|
|
|
|
def test_get_thread_mounts_includes_acp_workspace(tmp_path, monkeypatch):
|
|
"""_get_thread_mounts must include /mnt/acp-workspace (read-only) for docker sandbox."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: None)
|
|
|
|
mounts = aio_mod.AioSandboxProvider._get_thread_mounts("thread-3")
|
|
|
|
container_paths = {m[1]: (m[0], m[2]) for m in mounts}
|
|
|
|
assert "/mnt/acp-workspace" in container_paths, "ACP workspace mount is missing"
|
|
expected_host = str(tmp_path / "threads" / "thread-3" / "acp-workspace")
|
|
actual_host, read_only = container_paths["/mnt/acp-workspace"]
|
|
assert actual_host == expected_host
|
|
assert read_only is True, "ACP workspace should be read-only inside the sandbox"
|
|
|
|
|
|
def test_get_thread_mounts_includes_user_data_dirs(tmp_path, monkeypatch):
|
|
"""Baseline: user-data mounts must still be present after the ACP workspace change."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
|
|
|
|
mounts = aio_mod.AioSandboxProvider._get_thread_mounts("thread-4")
|
|
container_paths = {m[1] for m in mounts}
|
|
|
|
assert "/mnt/user-data/workspace" in container_paths
|
|
assert "/mnt/user-data/uploads" in container_paths
|
|
assert "/mnt/user-data/outputs" in container_paths
|
|
|
|
|
|
def test_get_thread_mounts_uses_explicit_user_id(tmp_path, monkeypatch):
|
|
"""Channel runs must mount the same user bucket used for artifact delivery."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: "default")
|
|
|
|
mounts = aio_mod.AioSandboxProvider._get_thread_mounts("thread-4", user_id="ou-user")
|
|
container_paths = {container_path: host_path for host_path, container_path, _ in mounts}
|
|
|
|
assert container_paths["/mnt/user-data/workspace"] == str(tmp_path / "users" / "ou-user" / "threads" / "thread-4" / "user-data" / "workspace")
|
|
assert container_paths["/mnt/user-data/uploads"] == str(tmp_path / "users" / "ou-user" / "threads" / "thread-4" / "user-data" / "uploads")
|
|
assert container_paths["/mnt/user-data/outputs"] == str(tmp_path / "users" / "ou-user" / "threads" / "thread-4" / "user-data" / "outputs")
|
|
|
|
|
|
def test_get_lark_cli_runtime_mounts_uses_user_auth_dirs(tmp_path, monkeypatch):
|
|
"""Sandbox lark-cli commands must read the same auth dirs as Settings."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
lark_cli = importlib.import_module("deerflow.integrations.lark_cli")
|
|
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: "default")
|
|
runtime_dir = tmp_path / "integrations" / "lark-cli" / "sandbox-cli"
|
|
runtime_dir.mkdir(parents=True)
|
|
|
|
mounts = aio_mod.AioSandboxProvider._get_lark_cli_runtime_mounts(user_id="alice")
|
|
container_paths = {container_path: (host_path, read_only) for host_path, container_path, read_only in mounts}
|
|
|
|
assert container_paths[lark_cli.LARK_CLI_SANDBOX_CONFIG_DIR] == (
|
|
str(tmp_path / "users" / "alice" / "integrations" / "lark-cli" / "config"),
|
|
True,
|
|
)
|
|
assert container_paths[lark_cli.LARK_CLI_SANDBOX_DATA_DIR] == (
|
|
str(tmp_path / "users" / "alice" / "integrations" / "lark-cli" / "data"),
|
|
False,
|
|
)
|
|
assert stat.S_IMODE((tmp_path / "users" / "alice" / "integrations" / "lark-cli" / "config").stat().st_mode) == 0o700
|
|
assert stat.S_IMODE((tmp_path / "users" / "alice" / "integrations" / "lark-cli" / "data").stat().st_mode) == 0o700
|
|
assert container_paths["/mnt/integrations/lark-cli/runtime"] == (
|
|
str(runtime_dir),
|
|
True,
|
|
)
|
|
|
|
|
|
def test_get_user_skill_mounts_mounts_only_global_integrations(tmp_path, monkeypatch):
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
skills_root = tmp_path / "skills"
|
|
(skills_root / "public").mkdir(parents=True)
|
|
config = SimpleNamespace(
|
|
skills=SimpleNamespace(
|
|
get_skills_path=lambda: skills_root,
|
|
container_path="/mnt/skills",
|
|
)
|
|
)
|
|
monkeypatch.setattr(aio_mod, "get_app_config", lambda: config)
|
|
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path / "home"))
|
|
|
|
alice = {container: host for host, container, _read_only in aio_mod.AioSandboxProvider._get_user_skill_mounts(user_id="alice")}
|
|
bob = {container: host for host, container, _read_only in aio_mod.AioSandboxProvider._get_user_skill_mounts(user_id="bob")}
|
|
|
|
assert set(alice) == {"/mnt/skills/integrations"}
|
|
assert set(bob) == {"/mnt/skills/integrations"}
|
|
assert alice["/mnt/skills/integrations"] == bob["/mnt/skills/integrations"]
|
|
assert alice["/mnt/skills/integrations"] == str(tmp_path / "home" / "integrations" / "skills")
|
|
|
|
|
|
def test_get_extra_mounts_provisioner_payload_has_unique_container_paths(tmp_path, monkeypatch, provisioner_module):
|
|
"""Full AIO mount composition must not send duplicate paths to provisioner."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
lark_cli = importlib.import_module("deerflow.integrations.lark_cli")
|
|
remote_backend = importlib.import_module("deerflow.community.aio_sandbox.remote_backend")
|
|
skills_root = tmp_path / "skills"
|
|
(skills_root / "public").mkdir(parents=True)
|
|
home = tmp_path / "home"
|
|
config = SimpleNamespace(
|
|
skills=SimpleNamespace(
|
|
get_skills_path=lambda: skills_root,
|
|
container_path="/mnt/skills",
|
|
)
|
|
)
|
|
runtime_dir = home / "integrations" / "lark-cli" / "sandbox-cli"
|
|
runtime_dir.mkdir(parents=True)
|
|
|
|
monkeypatch.setattr(aio_mod, "get_app_config", lambda: config)
|
|
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=home))
|
|
monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: "default")
|
|
monkeypatch.setattr(aio_mod, "user_should_see_legacy_skills", lambda *_args, **_kwargs: False)
|
|
|
|
provider = _make_provider(tmp_path)
|
|
mounts = provider._get_extra_mounts("thread-1", user_id="alice")
|
|
container_paths = [container for _host, container, _read_only in mounts]
|
|
|
|
assert len(container_paths) == len(set(container_paths))
|
|
assert "/mnt/skills/custom" in container_paths
|
|
assert "/mnt/skills/integrations" in container_paths
|
|
assert lark_cli.LARK_CLI_SANDBOX_CONFIG_DIR in container_paths
|
|
assert lark_cli.LARK_CLI_SANDBOX_DATA_DIR in container_paths
|
|
assert lark_cli.LARK_CLI_SANDBOX_RUNTIME_DIR in container_paths
|
|
|
|
payload = remote_backend._provisioner_extra_mounts_payload(mounts)
|
|
payload_paths = [str(item["container_path"]) for item in payload]
|
|
assert len(payload_paths) == len(set(payload_paths))
|
|
|
|
provisioner_module.DEER_FLOW_HOST_BASE_DIR = str(home)
|
|
validated = provisioner_module._validated_extra_mounts([provisioner_module.ExtraMount(**item) for item in payload])
|
|
validated_paths = [mount.container_path for mount in validated]
|
|
|
|
assert len(validated_paths) == len(set(validated_paths))
|
|
assert set(validated_paths) == {
|
|
"/mnt/acp-workspace",
|
|
"/mnt/skills/custom",
|
|
"/mnt/skills/integrations",
|
|
lark_cli.LARK_CLI_SANDBOX_CONFIG_DIR,
|
|
lark_cli.LARK_CLI_SANDBOX_DATA_DIR,
|
|
lark_cli.LARK_CLI_SANDBOX_RUNTIME_DIR,
|
|
}
|
|
|
|
|
|
def test_join_host_path_preserves_windows_drive_letter_style():
|
|
base = r"C:\Users\demo\deer-flow\backend\.deer-flow"
|
|
|
|
joined = join_host_path(base, "threads", "thread-9", "user-data", "outputs")
|
|
|
|
assert joined == r"C:\Users\demo\deer-flow\backend\.deer-flow\threads\thread-9\user-data\outputs"
|
|
|
|
|
|
def test_get_thread_mounts_preserves_windows_host_path_style(tmp_path, monkeypatch):
|
|
"""Docker bind mount sources must keep Windows-style paths intact."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
monkeypatch.setenv("DEER_FLOW_HOST_BASE_DIR", r"C:\Users\demo\deer-flow\backend\.deer-flow")
|
|
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: None)
|
|
|
|
mounts = aio_mod.AioSandboxProvider._get_thread_mounts("thread-10")
|
|
|
|
container_paths = {container_path: host_path for host_path, container_path, _ in mounts}
|
|
|
|
assert container_paths["/mnt/user-data/workspace"] == r"C:\Users\demo\deer-flow\backend\.deer-flow\threads\thread-10\user-data\workspace"
|
|
assert container_paths["/mnt/user-data/uploads"] == r"C:\Users\demo\deer-flow\backend\.deer-flow\threads\thread-10\user-data\uploads"
|
|
assert container_paths["/mnt/user-data/outputs"] == r"C:\Users\demo\deer-flow\backend\.deer-flow\threads\thread-10\user-data\outputs"
|
|
assert container_paths["/mnt/acp-workspace"] == r"C:\Users\demo\deer-flow\backend\.deer-flow\threads\thread-10\acp-workspace"
|
|
|
|
|
|
def test_discover_or_create_only_unlocks_when_lock_succeeds(tmp_path, monkeypatch):
|
|
"""Unlock should not run if exclusive locking itself fails."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(tmp_path)
|
|
provider._discover_or_create_with_lock = aio_mod.AioSandboxProvider._discover_or_create_with_lock.__get__(
|
|
provider,
|
|
aio_mod.AioSandboxProvider,
|
|
)
|
|
|
|
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
aio_mod,
|
|
"_lock_file_exclusive",
|
|
lambda _lock_file: (_ for _ in ()).throw(RuntimeError("lock failed")),
|
|
)
|
|
|
|
unlock_calls: list[object] = []
|
|
monkeypatch.setattr(
|
|
aio_mod,
|
|
"_unlock_file",
|
|
lambda lock_file: unlock_calls.append(lock_file),
|
|
)
|
|
|
|
with patch.object(provider, "_create_sandbox", return_value="sandbox-id"):
|
|
with pytest.raises(RuntimeError, match="lock failed"):
|
|
provider._discover_or_create_with_lock("thread-5", "sandbox-5")
|
|
|
|
assert unlock_calls == []
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_acquire_async_uses_async_readiness_polling(monkeypatch):
|
|
"""AioSandboxProvider async creation must not use sync readiness polling."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(None)
|
|
provider._config = {"replicas": 3}
|
|
provider._thread_locks = {}
|
|
provider._warm_pool = {}
|
|
provider._sandbox_infos = {}
|
|
provider._thread_sandboxes = {}
|
|
provider._last_activity = {}
|
|
provider._lock = aio_mod.threading.Lock()
|
|
provider._backend = SimpleNamespace(
|
|
create=MagicMock(return_value=aio_mod.SandboxInfo(sandbox_id="sandbox-async", sandbox_url="http://sandbox")),
|
|
destroy=MagicMock(),
|
|
discover=MagicMock(return_value=None),
|
|
)
|
|
|
|
async_readiness_calls: list[tuple[str, int]] = []
|
|
|
|
async def fake_wait_for_sandbox_ready_async(sandbox_url: str, timeout: int = 30, poll_interval: float = 1.0) -> bool:
|
|
async_readiness_calls.append((sandbox_url, timeout))
|
|
return True
|
|
|
|
monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready_async", fake_wait_for_sandbox_ready_async)
|
|
monkeypatch.setattr(
|
|
aio_mod,
|
|
"wait_for_sandbox_ready",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("sync readiness should not be used")),
|
|
)
|
|
|
|
sandbox_id = await provider._create_sandbox_async("thread-async", "sandbox-async", user_id="user-async")
|
|
|
|
assert sandbox_id == "sandbox-async"
|
|
assert async_readiness_calls == [("http://sandbox", 60)]
|
|
assert provider._backend.destroy.call_count == 0
|
|
assert provider._thread_sandboxes[("user-async", "thread-async")] == "sandbox-async"
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_discover_or_create_with_lock_async_offloads_lock_file_open_and_close(tmp_path, monkeypatch):
|
|
"""Async lock path must not open or close lock files on the event loop."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(tmp_path)
|
|
provider._discover_or_create_with_lock_async = aio_mod.AioSandboxProvider._discover_or_create_with_lock_async.__get__(
|
|
provider,
|
|
aio_mod.AioSandboxProvider,
|
|
)
|
|
provider._thread_locks = {}
|
|
provider._warm_pool = {}
|
|
provider._sandbox_infos = {}
|
|
provider._thread_sandboxes = {("default", "thread-async-lock"): "sandbox-async-lock"}
|
|
provider._sandboxes = {"sandbox-async-lock": aio_mod.AioSandbox(id="sandbox-async-lock", base_url="http://sandbox")}
|
|
provider._last_activity = {}
|
|
provider._lock = aio_mod.threading.Lock()
|
|
provider._backend = SimpleNamespace(discover=MagicMock(return_value=None))
|
|
|
|
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
|
|
|
|
to_thread_calls: list[object] = []
|
|
|
|
async def fake_to_thread(func, /, *args, **kwargs):
|
|
to_thread_calls.append(func)
|
|
return func(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(aio_mod.asyncio, "to_thread", fake_to_thread)
|
|
|
|
sandbox_id = await provider._discover_or_create_with_lock_async("thread-async-lock", "sandbox-async-lock", user_id="default")
|
|
|
|
assert sandbox_id == "sandbox-async-lock"
|
|
assert aio_mod._open_lock_file in to_thread_calls
|
|
assert any(getattr(func, "__name__", "") == "close" for func in to_thread_calls)
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_acquire_thread_lock_async_uses_dedicated_executor(monkeypatch):
|
|
"""Per-thread lock waits should not consume the default asyncio.to_thread pool."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
lock = aio_mod.threading.Lock()
|
|
|
|
async def fail_to_thread(*_args, **_kwargs):
|
|
raise AssertionError("thread-lock acquisition must not use asyncio.to_thread")
|
|
|
|
monkeypatch.setattr(aio_mod.asyncio, "to_thread", fail_to_thread)
|
|
|
|
await aio_mod._acquire_thread_lock_async(lock)
|
|
try:
|
|
assert not lock.acquire(blocking=False)
|
|
finally:
|
|
lock.release()
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_acquire_async_cancellation_does_not_leak_thread_lock(tmp_path):
|
|
"""Cancelled async lock waiters must not leave the per-thread lock held."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(tmp_path)
|
|
provider._thread_locks = {}
|
|
provider._warm_pool = {}
|
|
provider._sandbox_infos = {}
|
|
provider._thread_sandboxes = {}
|
|
provider._last_activity = {}
|
|
provider._lock = aio_mod.threading.Lock()
|
|
|
|
thread_id = "thread-cancel-lock"
|
|
thread_lock = provider._get_thread_lock(thread_id, "default")
|
|
thread_lock.acquire()
|
|
|
|
task = asyncio.create_task(provider.acquire_async(thread_id, user_id="default"))
|
|
await asyncio.sleep(0.05)
|
|
task.cancel()
|
|
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
thread_lock.release()
|
|
deadline = asyncio.get_running_loop().time() + 1
|
|
while asyncio.get_running_loop().time() < deadline:
|
|
acquired = thread_lock.acquire(blocking=False)
|
|
if acquired:
|
|
thread_lock.release()
|
|
return
|
|
await asyncio.sleep(0.01)
|
|
|
|
pytest.fail("provider thread lock was leaked after cancelling acquire_async")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_acquire_async_cancelled_waiter_does_not_block_successor(tmp_path, monkeypatch):
|
|
"""A cancelled waiter must not prevent the next live waiter from acquiring."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(tmp_path)
|
|
provider._thread_locks = {}
|
|
provider._warm_pool = {}
|
|
provider._sandbox_infos = {}
|
|
provider._thread_sandboxes = {}
|
|
provider._last_activity = {}
|
|
provider._lock = aio_mod.threading.Lock()
|
|
|
|
async def fake_acquire_internal_async(thread_id: str | None, *, user_id: str) -> str:
|
|
assert thread_id == "thread-successor-lock"
|
|
assert user_id == "default"
|
|
await asyncio.sleep(0)
|
|
return "sandbox-successor"
|
|
|
|
monkeypatch.setattr(provider, "_acquire_internal_async", fake_acquire_internal_async)
|
|
|
|
thread_id = "thread-successor-lock"
|
|
thread_lock = provider._get_thread_lock(thread_id, "default")
|
|
thread_lock.acquire()
|
|
|
|
cancelled_waiter = asyncio.create_task(provider.acquire_async(thread_id, user_id="default"))
|
|
await asyncio.sleep(0.05)
|
|
cancelled_waiter.cancel()
|
|
try:
|
|
await cancelled_waiter
|
|
except asyncio.CancelledError:
|
|
pass
|
|
|
|
live_waiter = asyncio.create_task(provider.acquire_async(thread_id, user_id="default"))
|
|
thread_lock.release()
|
|
|
|
assert await asyncio.wait_for(live_waiter, timeout=1) == "sandbox-successor"
|
|
|
|
deadline = asyncio.get_running_loop().time() + 1
|
|
while asyncio.get_running_loop().time() < deadline:
|
|
acquired = thread_lock.acquire(blocking=False)
|
|
if acquired:
|
|
thread_lock.release()
|
|
return
|
|
await asyncio.sleep(0.01)
|
|
|
|
pytest.fail("provider thread lock was not released after successor acquire_async")
|
|
|
|
|
|
@pytest.mark.anyio
|
|
async def test_acquire_internal_async_offloads_cached_reuse_health_check(tmp_path, monkeypatch):
|
|
"""Async cached reuse must keep backend health checks off the event loop."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider, _sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-cached-async")
|
|
provider._thread_sandboxes = {("default", "thread-cached-async"): "sandbox-cached-async"}
|
|
provider._backend.is_alive = MagicMock(return_value=True)
|
|
|
|
to_thread_calls: list[tuple[object, tuple[object, ...]]] = []
|
|
|
|
async def fake_to_thread(func, /, *args, **kwargs):
|
|
to_thread_calls.append((func, args))
|
|
return func(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(aio_mod.asyncio, "to_thread", fake_to_thread)
|
|
|
|
sandbox_id = await provider._acquire_internal_async("thread-cached-async", user_id="default")
|
|
|
|
assert sandbox_id == "sandbox-cached-async"
|
|
assert to_thread_calls == [(provider._reuse_in_process_sandbox, ("thread-cached-async",))]
|
|
|
|
|
|
def test_remote_backend_create_forwards_effective_user_id(monkeypatch):
|
|
"""Provisioner mode must receive user_id so PVC subPath matches user isolation."""
|
|
remote_mod = importlib.import_module("deerflow.community.aio_sandbox.remote_backend")
|
|
backend = remote_mod.RemoteSandboxBackend("http://provisioner:8002")
|
|
token = set_current_user(SimpleNamespace(id="user-7"))
|
|
posted: dict = {}
|
|
|
|
class _Response:
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return {"sandbox_url": "http://sandbox.local"}
|
|
|
|
def _post(url, json, timeout, headers=None): # noqa: A002 - mirrors requests.post kwarg
|
|
posted.update({"url": url, "json": json, "timeout": timeout})
|
|
return _Response()
|
|
|
|
monkeypatch.setattr(remote_mod.requests, "post", _post)
|
|
monkeypatch.setattr(remote_mod, "user_should_see_legacy_skills", lambda user_id: True)
|
|
|
|
try:
|
|
backend.create("thread-42", "sandbox-42")
|
|
finally:
|
|
reset_current_user(token)
|
|
|
|
assert posted["url"] == "http://provisioner:8002/api/sandboxes"
|
|
assert posted["json"] == {
|
|
"sandbox_id": "sandbox-42",
|
|
"thread_id": "thread-42",
|
|
"user_id": "user-7",
|
|
"include_legacy_skills": True,
|
|
"provision_lark_cli_runtime": False,
|
|
}
|
|
|
|
|
|
def test_remote_backend_create_prefers_explicit_user_id(monkeypatch):
|
|
"""Provisioner mode must not fall back to the ambient default for channel runs."""
|
|
remote_mod = importlib.import_module("deerflow.community.aio_sandbox.remote_backend")
|
|
backend = remote_mod.RemoteSandboxBackend("http://provisioner:8002")
|
|
posted: dict = {}
|
|
|
|
class _Response:
|
|
def raise_for_status(self):
|
|
return None
|
|
|
|
def json(self):
|
|
return {"sandbox_url": "http://sandbox.local"}
|
|
|
|
def _post(url, json, timeout, headers=None): # noqa: A002 - mirrors requests.post kwarg
|
|
posted.update({"url": url, "json": json, "timeout": timeout})
|
|
return _Response()
|
|
|
|
monkeypatch.setattr(remote_mod.requests, "post", _post)
|
|
monkeypatch.setattr(remote_mod, "get_effective_user_id", lambda: "default")
|
|
monkeypatch.setattr(remote_mod, "user_should_see_legacy_skills", lambda user_id: False)
|
|
|
|
backend.create("thread-42", "sandbox-42", user_id="ou-user")
|
|
|
|
assert posted["json"]["user_id"] == "ou-user"
|
|
assert posted["json"]["include_legacy_skills"] is False
|
|
|
|
|
|
def test_create_sandbox_requests_runtime_when_lark_installed(tmp_path, monkeypatch):
|
|
"""The provider must request lark-cli runtime provisioning when Lark is installed."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(tmp_path)
|
|
provider._config = {"replicas": 3}
|
|
provider._thread_locks = {}
|
|
provider._warm_pool = {}
|
|
provider._sandbox_infos = {}
|
|
provider._thread_sandboxes = {}
|
|
provider._last_activity = {}
|
|
provider._lock = aio_mod.threading.Lock()
|
|
|
|
captured: dict = {}
|
|
|
|
def _create(thread_id, sandbox_id, *, extra_mounts=None, user_id=None, provision_lark_cli_runtime=False):
|
|
captured["provision_lark_cli_runtime"] = provision_lark_cli_runtime
|
|
return aio_mod.SandboxInfo(sandbox_id=sandbox_id, sandbox_url="http://sandbox")
|
|
|
|
provider._backend = SimpleNamespace(create=_create, destroy=MagicMock(), discover=MagicMock(return_value=None))
|
|
monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready", lambda *_a, **_k: True)
|
|
monkeypatch.setattr(provider, "_get_extra_mounts", lambda *_a, **_k: [])
|
|
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_lark_integration_active", staticmethod(lambda user_id=None: True))
|
|
monkeypatch.setattr(provider, "_register_created_sandbox", lambda *a, **k: "sandbox-lark")
|
|
|
|
provider._create_sandbox("thread-lark", "sandbox-lark", user_id="alice")
|
|
assert captured["provision_lark_cli_runtime"] is True
|
|
|
|
|
|
def test_create_sandbox_skips_runtime_when_lark_absent(tmp_path, monkeypatch):
|
|
"""No runtime provisioning request when the Lark skill pack is not installed."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(tmp_path)
|
|
provider._config = {"replicas": 3}
|
|
provider._thread_locks = {}
|
|
provider._warm_pool = {}
|
|
provider._sandbox_infos = {}
|
|
provider._thread_sandboxes = {}
|
|
provider._last_activity = {}
|
|
provider._lock = aio_mod.threading.Lock()
|
|
|
|
captured: dict = {}
|
|
|
|
def _create(thread_id, sandbox_id, *, extra_mounts=None, user_id=None, provision_lark_cli_runtime=False):
|
|
captured["provision_lark_cli_runtime"] = provision_lark_cli_runtime
|
|
return aio_mod.SandboxInfo(sandbox_id=sandbox_id, sandbox_url="http://sandbox")
|
|
|
|
provider._backend = SimpleNamespace(create=_create, destroy=MagicMock(), discover=MagicMock(return_value=None))
|
|
monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready", lambda *_a, **_k: True)
|
|
monkeypatch.setattr(provider, "_get_extra_mounts", lambda *_a, **_k: [])
|
|
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_lark_integration_active", staticmethod(lambda user_id=None: False))
|
|
monkeypatch.setattr(provider, "_register_created_sandbox", lambda *a, **k: "sandbox-nolark")
|
|
|
|
provider._create_sandbox("thread-nolark", "sandbox-nolark", user_id="alice")
|
|
assert captured["provision_lark_cli_runtime"] is False
|
|
|
|
|
|
# ── Sandbox client teardown (#2872) ──────────────────────────────────────────
|
|
|
|
|
|
def _make_provider_with_active_sandbox(tmp_path, sandbox_id: str):
|
|
"""Build a provider with one active sandbox suitable for release/destroy/shutdown tests."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(tmp_path)
|
|
provider._lock = aio_mod.threading.Lock()
|
|
provider._warm_pool = {}
|
|
provider._sandbox_infos = {
|
|
sandbox_id: aio_mod.SandboxInfo(sandbox_id=sandbox_id, sandbox_url="http://sandbox-host"),
|
|
}
|
|
provider._thread_sandboxes = {}
|
|
provider._last_activity = {sandbox_id: 0.0}
|
|
provider._local_teardown = set()
|
|
provider._acquire_epoch = {}
|
|
provider._acquire_epoch_counter = 0
|
|
provider._acquire_inflight = {}
|
|
provider._shutdown_called = False
|
|
provider._idle_checker_thread = None
|
|
provider._backend = SimpleNamespace(destroy=MagicMock())
|
|
|
|
sandbox = MagicMock()
|
|
sandbox.id = sandbox_id
|
|
sandbox.close = MagicMock()
|
|
provider._sandboxes = {sandbox_id: sandbox}
|
|
return provider, sandbox, aio_mod
|
|
|
|
|
|
def test_release_closes_cached_sandbox_client(tmp_path):
|
|
"""release() must close the host-side client owned by the cached AioSandbox (#2872)."""
|
|
provider, sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-rel")
|
|
|
|
provider.release("sandbox-rel")
|
|
|
|
sandbox.close.assert_called_once_with()
|
|
# And the sandbox is parked in the warm pool (container still running).
|
|
assert "sandbox-rel" in provider._warm_pool
|
|
assert "sandbox-rel" not in provider._sandboxes
|
|
|
|
|
|
def test_destroy_closes_cached_sandbox_client(tmp_path):
|
|
"""destroy() must close the host-side client before backend container teardown (#2872)."""
|
|
provider, sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-destroy")
|
|
backend_destroy = provider._backend.destroy
|
|
|
|
provider.destroy("sandbox-destroy")
|
|
|
|
sandbox.close.assert_called_once_with()
|
|
backend_destroy.assert_called_once()
|
|
assert "sandbox-destroy" not in provider._sandboxes
|
|
assert "sandbox-destroy" not in provider._sandbox_infos
|
|
|
|
|
|
def test_shutdown_closes_all_active_sandbox_clients(tmp_path):
|
|
"""shutdown() must close every cached AioSandbox client during teardown (#2872)."""
|
|
provider, sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-shut")
|
|
|
|
provider.shutdown()
|
|
|
|
sandbox.close.assert_called_once_with()
|
|
provider._backend.destroy.assert_called_once()
|
|
assert provider._sandboxes == {}
|
|
|
|
|
|
def test_release_swallows_close_errors(tmp_path, caplog):
|
|
"""A failure inside sandbox.close() must not break provider release()."""
|
|
provider, sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-rel-err")
|
|
sandbox.close.side_effect = RuntimeError("boom")
|
|
|
|
with caplog.at_level("WARNING"):
|
|
provider.release("sandbox-rel-err")
|
|
|
|
assert "Error closing sandbox sandbox-rel-err during release" in caplog.text
|
|
# Still moved to warm pool: client teardown failure must not block lifecycle.
|
|
assert "sandbox-rel-err" in provider._warm_pool
|
|
|
|
|
|
def test_get_uses_in_memory_registry_only(tmp_path):
|
|
"""get() must stay event-loop safe by avoiding backend health checks."""
|
|
provider, sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-dead")
|
|
provider._backend.is_alive = MagicMock(side_effect=AssertionError("get must not call backend health checks"))
|
|
|
|
assert provider.get("sandbox-dead") is sandbox
|
|
|
|
|
|
def test_acquire_drops_dead_cached_sandbox(tmp_path, monkeypatch):
|
|
"""acquire() must replace a stale active cache entry after its container dies."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider, sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-dead")
|
|
provider._thread_locks = {}
|
|
provider._thread_sandboxes = {("default", "thread-dead"): "sandbox-dead"}
|
|
provider._config = {"replicas": 3}
|
|
provider._backend.is_alive = MagicMock(return_value=False)
|
|
provider._backend.discover = MagicMock(return_value=None)
|
|
provider._backend.create = MagicMock(
|
|
return_value=aio_mod.SandboxInfo(
|
|
sandbox_id="sandbox-dead",
|
|
sandbox_url="http://fresh-sandbox",
|
|
container_name="deer-flow-sandbox-sandbox-dead",
|
|
)
|
|
)
|
|
|
|
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_sandbox_id_for_thread", lambda _self, _thread_id, _user_id: "sandbox-dead")
|
|
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_get_extra_mounts", lambda _self, _thread_id, *, user_id=None: [])
|
|
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: None)
|
|
monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready", lambda _url, timeout=60: True)
|
|
|
|
sandbox_id = provider.acquire("thread-dead", user_id="default")
|
|
|
|
assert sandbox_id == "sandbox-dead"
|
|
sandbox.close.assert_called_once_with()
|
|
provider._backend.destroy.assert_called_once()
|
|
provider._backend.create.assert_called_once()
|
|
assert provider._thread_sandboxes[("default", "thread-dead")] == "sandbox-dead"
|
|
assert provider._sandboxes["sandbox-dead"].base_url == "http://fresh-sandbox"
|
|
|
|
|
|
def test_acquire_keeps_cached_sandbox_when_health_check_errors(tmp_path):
|
|
"""Transient backend health-check errors must not destroy a tracked sandbox."""
|
|
provider, sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-transient")
|
|
provider._thread_locks = {}
|
|
provider._thread_sandboxes = {("default", "thread-transient"): "sandbox-transient"}
|
|
provider._backend.is_alive = MagicMock(side_effect=OSError("docker daemon busy"))
|
|
|
|
sandbox_id = provider.acquire("thread-transient", user_id="default")
|
|
|
|
assert sandbox_id == "sandbox-transient"
|
|
sandbox.close.assert_not_called()
|
|
provider._backend.destroy.assert_not_called()
|
|
assert provider._sandboxes["sandbox-transient"] is sandbox
|
|
|
|
|
|
def test_drop_unhealthy_sandbox_skips_recreated_entry(tmp_path):
|
|
"""A stale health-check result must not delete a newly registered sandbox."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(tmp_path)
|
|
provider._lock = aio_mod.threading.Lock()
|
|
provider._warm_pool = {}
|
|
provider._last_activity = {"sandbox-toctou": 1.0}
|
|
provider._thread_sandboxes = {("default", "thread-toctou"): "sandbox-toctou"}
|
|
old_info = aio_mod.SandboxInfo(sandbox_id="sandbox-toctou", sandbox_url="http://old-sandbox")
|
|
new_info = aio_mod.SandboxInfo(sandbox_id="sandbox-toctou", sandbox_url="http://new-sandbox")
|
|
new_sandbox = MagicMock()
|
|
provider._sandbox_infos = {"sandbox-toctou": new_info}
|
|
provider._sandboxes = {"sandbox-toctou": new_sandbox}
|
|
provider._backend = SimpleNamespace(destroy=MagicMock())
|
|
|
|
provider._drop_unhealthy_sandbox("sandbox-toctou", "stale health check", expected_info=old_info)
|
|
|
|
new_sandbox.close.assert_not_called()
|
|
provider._backend.destroy.assert_not_called()
|
|
assert provider._sandbox_infos["sandbox-toctou"] is new_info
|
|
assert provider._sandboxes["sandbox-toctou"] is new_sandbox
|
|
assert provider._thread_sandboxes == {("default", "thread-toctou"): "sandbox-toctou"}
|
|
|
|
|
|
def test_acquire_skips_dead_warm_pool_sandbox(tmp_path, monkeypatch):
|
|
"""acquire() must create a fresh sandbox when the warm-pool entry died."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(tmp_path)
|
|
provider._lock = aio_mod.threading.Lock()
|
|
provider._thread_locks = {}
|
|
provider._sandboxes = {}
|
|
provider._sandbox_infos = {}
|
|
provider._thread_sandboxes = {}
|
|
provider._last_activity = {}
|
|
provider._warm_pool = {
|
|
"sandbox-warm-dead": (
|
|
aio_mod.SandboxInfo(
|
|
sandbox_id="sandbox-warm-dead",
|
|
sandbox_url="http://stale-sandbox",
|
|
container_name="deer-flow-sandbox-sandbox-warm-dead",
|
|
),
|
|
0.0,
|
|
)
|
|
}
|
|
provider._config = {"replicas": 3}
|
|
provider._backend = SimpleNamespace(
|
|
is_alive=MagicMock(return_value=False),
|
|
destroy=MagicMock(),
|
|
discover=MagicMock(return_value=None),
|
|
create=MagicMock(
|
|
return_value=aio_mod.SandboxInfo(
|
|
sandbox_id="sandbox-warm-dead",
|
|
sandbox_url="http://fresh-sandbox",
|
|
container_name="deer-flow-sandbox-sandbox-warm-dead",
|
|
)
|
|
),
|
|
)
|
|
|
|
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_sandbox_id_for_thread", lambda _self, _thread_id, _user_id: "sandbox-warm-dead")
|
|
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_get_extra_mounts", lambda _self, _thread_id, *, user_id=None: [])
|
|
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(aio_mod, "get_effective_user_id", lambda: None)
|
|
monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready", lambda _url, timeout=60: True)
|
|
|
|
sandbox_id = provider.acquire("thread-warm-dead", user_id="default")
|
|
|
|
assert sandbox_id == "sandbox-warm-dead"
|
|
provider._backend.destroy.assert_called_once()
|
|
provider._backend.create.assert_called_once()
|
|
assert provider._warm_pool == {}
|
|
assert provider._thread_sandboxes[("default", "thread-warm-dead")] == "sandbox-warm-dead"
|
|
assert provider._sandboxes["sandbox-warm-dead"].base_url == "http://fresh-sandbox"
|
|
|
|
|
|
def test_destroy_swallows_close_errors_and_still_destroys_backend(tmp_path, caplog):
|
|
"""A failure in sandbox.close() must not skip backend container destruction."""
|
|
provider, sandbox, _ = _make_provider_with_active_sandbox(tmp_path, "sandbox-dest-err")
|
|
sandbox.close.side_effect = RuntimeError("boom")
|
|
|
|
with caplog.at_level("WARNING"):
|
|
provider.destroy("sandbox-dest-err")
|
|
|
|
assert "Error closing sandbox sandbox-dest-err during destroy" in caplog.text
|
|
provider._backend.destroy.assert_called_once()
|
|
|
|
|
|
def test_cleanup_idle_sandboxes_keeps_active_cleanup_and_delegates_warm_expiry(tmp_path):
|
|
"""AIO active-idle cleanup must remain local while warm expiry uses the shared lifecycle."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(tmp_path)
|
|
provider._lock = aio_mod.threading.Lock()
|
|
provider._sandboxes = {"active-old": MagicMock()}
|
|
provider._sandbox_infos = {
|
|
"active-old": aio_mod.SandboxInfo(sandbox_id="active-old", sandbox_url="http://active-old"),
|
|
}
|
|
provider._thread_sandboxes = {("default", "thread-old"): "active-old"}
|
|
provider._last_activity = {"active-old": 0.0}
|
|
provider._warm_pool = {
|
|
"warm-old": (
|
|
aio_mod.SandboxInfo(sandbox_id="warm-old", sandbox_url="http://warm-old"),
|
|
0.0,
|
|
)
|
|
}
|
|
|
|
calls = []
|
|
# The idle path destroys through `_destroy_tracked`, not `destroy()`: its
|
|
# "still idle?" re-check has to run in the same critical section that
|
|
# reserves the teardown, so it is passed down as a predicate. Asserting on
|
|
# `destroy` here would pass vacuously — it is no longer on this path.
|
|
provider._destroy_tracked = MagicMock(side_effect=lambda _sandbox_id, **_kw: calls.append("active"))
|
|
provider._reap_expired_warm = MagicMock(side_effect=lambda _idle_timeout: calls.append("warm"))
|
|
|
|
provider._cleanup_idle_sandboxes(1.0)
|
|
|
|
assert provider._destroy_tracked.call_count == 1
|
|
assert provider._destroy_tracked.call_args.args == ("active-old",)
|
|
# The gate must actually be a live predicate, not a constant-true placeholder.
|
|
assert provider._destroy_tracked.call_args.kwargs["still_reapable"]() is True
|
|
provider._reap_expired_warm.assert_called_once_with(1.0)
|
|
assert calls == ["active", "warm"]
|
|
|
|
|
|
def test_create_sandbox_evicts_oldest_warm_replica_via_shared_lifecycle(tmp_path, monkeypatch):
|
|
"""Replica enforcement must destroy the oldest warm SandboxInfo before creating another."""
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(tmp_path)
|
|
provider._lock = aio_mod.threading.Lock()
|
|
provider._config = {"replicas": 2}
|
|
provider._sandboxes = {}
|
|
provider._sandbox_infos = {}
|
|
provider._thread_sandboxes = {}
|
|
provider._last_activity = {}
|
|
|
|
oldest_info = aio_mod.SandboxInfo(sandbox_id="warm-oldest", sandbox_url="http://warm-oldest")
|
|
newest_info = aio_mod.SandboxInfo(sandbox_id="warm-newest", sandbox_url="http://warm-newest")
|
|
created_info = aio_mod.SandboxInfo(sandbox_id="created", sandbox_url="http://created")
|
|
provider._warm_pool = {
|
|
"warm-newest": (newest_info, 20.0),
|
|
"warm-oldest": (oldest_info, 10.0),
|
|
}
|
|
provider._backend = SimpleNamespace(
|
|
create=MagicMock(return_value=created_info),
|
|
destroy=MagicMock(),
|
|
)
|
|
monkeypatch.setattr(aio_mod.AioSandboxProvider, "_get_extra_mounts", lambda _self, _thread_id, *, user_id=None: [])
|
|
monkeypatch.setattr(aio_mod, "wait_for_sandbox_ready", lambda _url, *, timeout=60: True)
|
|
|
|
sandbox_id = provider._create_sandbox(None, "created", user_id="default")
|
|
|
|
assert sandbox_id == "created"
|
|
provider._backend.destroy.assert_called_once_with(oldest_info)
|
|
assert "warm-oldest" not in provider._warm_pool
|
|
assert provider._warm_pool == {"warm-newest": (newest_info, 20.0)}
|
|
assert provider._sandbox_infos["created"] is created_info
|
|
|
|
|
|
def _make_tenant_isolation_provider(tmp_path, monkeypatch):
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
provider = _make_provider(tmp_path)
|
|
provider._lock = aio_mod.threading.Lock()
|
|
provider._sandboxes = {}
|
|
provider._sandbox_infos = {}
|
|
provider._thread_sandboxes = {}
|
|
provider._thread_locks = {}
|
|
provider._last_activity = {}
|
|
provider._warm_pool = {}
|
|
provider._active_sandbox_identity = {}
|
|
provider._warm_pool_identity = {}
|
|
provider._shutdown_called = False
|
|
provider._config = {"replicas": 3, "idle_timeout": 0}
|
|
|
|
create_calls = []
|
|
|
|
def _create(thread_id, sandbox_id, **kwargs):
|
|
create_calls.append((thread_id, sandbox_id, kwargs.get("user_id")))
|
|
return aio_mod.SandboxInfo(
|
|
sandbox_id=sandbox_id,
|
|
sandbox_url=f"http://sandbox-{len(create_calls)}.local",
|
|
container_name=f"deer-flow-sandbox-{sandbox_id}",
|
|
)
|
|
|
|
provider._backend = SimpleNamespace(
|
|
create=MagicMock(side_effect=_create),
|
|
destroy=MagicMock(),
|
|
discover=MagicMock(return_value=None),
|
|
is_alive=MagicMock(return_value=True),
|
|
list_running=MagicMock(return_value=[]),
|
|
)
|
|
provider._claim_ownership = MagicMock(return_value=True)
|
|
provider._held_teardown_lease = lambda _sandbox_id: contextlib.nullcontext()
|
|
|
|
monkeypatch.setattr(aio_mod, "get_paths", lambda: Paths(base_dir=tmp_path))
|
|
monkeypatch.setattr(
|
|
aio_mod.AioSandboxProvider,
|
|
"_get_extra_mounts",
|
|
lambda self, thread_id, *, user_id=None: [],
|
|
)
|
|
monkeypatch.setattr(
|
|
aio_mod,
|
|
"wait_for_sandbox_ready",
|
|
lambda _url, timeout=60: True,
|
|
)
|
|
return provider, create_calls, aio_mod
|
|
|
|
|
|
def test_aio_wider_id_separates_known_legacy_collision():
|
|
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
|
|
identity_a, identity_b = _LEGACY_COLLIDING_IDENTITIES
|
|
user_a, thread_a = identity_a
|
|
user_b, thread_b = identity_b
|
|
|
|
old_a = hashlib.sha256(f"{user_a}:{thread_a}".encode()).hexdigest()[:8]
|
|
old_b = hashlib.sha256(f"{user_b}:{thread_b}".encode()).hexdigest()[:8]
|
|
|
|
assert old_a == old_b
|
|
assert aio_mod.AioSandboxProvider._deterministic_sandbox_id(
|
|
thread_a,
|
|
user_a,
|
|
) != aio_mod.AioSandboxProvider._deterministic_sandbox_id(
|
|
thread_b,
|
|
user_b,
|
|
)
|
|
|
|
|
|
def test_aio_forced_collision_never_overwrites_active_tenant(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
provider, create_calls, aio_mod = _make_tenant_isolation_provider(
|
|
tmp_path,
|
|
monkeypatch,
|
|
)
|
|
monkeypatch.setattr(
|
|
aio_mod.AioSandboxProvider,
|
|
"_deterministic_sandbox_id",
|
|
staticmethod(lambda thread_id, user_id: "deadbeefdeadbeef"),
|
|
)
|
|
|
|
sandbox_id = provider.acquire("thread-a", user_id="user-a")
|
|
info_a = provider._sandbox_infos[sandbox_id]
|
|
provider.release(sandbox_id)
|
|
|
|
assert sandbox_id in provider._warm_pool
|
|
|
|
with pytest.raises(aio_mod.SandboxIdentityCollisionError):
|
|
provider.acquire("thread-b", user_id="user-b")
|
|
|
|
assert provider._warm_pool[sandbox_id][0] is info_a
|
|
provider._backend.destroy.assert_not_called()
|
|
assert len(create_calls) == 1
|
|
assert provider.acquire("thread-a", user_id="user-a") == sandbox_id
|
|
assert provider._sandbox_infos[sandbox_id] is info_a
|