feat(sandbox): share sandbox identity derivation and acquire serialization (#4741) (#5089)

* feat(sandbox): share sandbox identity derivation and acquire serialization (#4741)

Remote providers (AIO, E2B, BoxLite, Tenki, OpenSandbox) each inlined the
same sha256(user:thread)[:16] sandbox-id expression and kept per-scope lock
dicts that grew unboundedly until shutdown. This extracts both mechanisms
into shared components without changing provider lifecycle, ids, capacity
semantics, or public tool behavior:

- sandbox/identity.py: keyword-only derive_sandbox_scope_token (byte-pinned
  compatibility contract) + is_sandbox_scope_token; per-provider golden
  vectors pin current behavior including BoxLite's raw-None quirk and each
  provider's private user_id resolution.
- sandbox/acquire_serialization.py: AcquireSerializer — per-key lock table
  with holder/waiter refcount reclamation, bounded dedicated executor
  (async waits off both the event loop and the default executor),
  worker-owned cancellation cleanup (no event-loop callback dependency), idempotent close().
- Each provider adopts both components; AIO/E2B key by (user_id, thread_id)
  with acquire and (E2B) release serialized; BoxLite/Tenki/OpenSandbox key
  by derived sandbox id and offload the whole sync acquire to the
  serializer's executor so a cancelled awaiter cannot overlap a retried
  same-scope body (leaked-remote-VM regression caught in review).
- thread_id=None acquires stay unserialized; provider shutdown()/reset()
  close the serializer; E2B capacity/ledger/reconciliation and AIO
  ownership/flock machinery untouched.
- blocking-IO anchor proves contended OpenSandbox acquire_async stays off
  the event loop (teeth verified red/green); AGENTS.md documents the
  shared components.

* refactor(sandbox): address review on acquire serialization (#5089)

- Replace unreachable checkin branch with an assertion: run() returns
  False only after abandon(), which the except handler always re-raises;
  the old _checkin would have double-decremented the refcount.
- Document the task.cancelling() == 0 assumption in hold_async.
- Drop unused thread_id/user_id kwargs from BoxLite and Tenki
  _acquire_scope_locked (OpenSandbox still forwards them).

* fix(sandbox): preserve request ContextVars in acquire executor bridge (#5089)

loop.run_in_executor() does not copy contextvars, unlike the inherited
SandboxProvider.acquire_async() which used asyncio.to_thread(). The
BoxLite/OpenSandbox/Tenki acquire_async bridges introduced in this PR
therefore dropped the request trace id (logged as trace_id=-).

Add AcquireSerializer.run_on_executor(), which copies the calling
context and runs the callable through ctx.run, and route all three
providers through it. Add regression tests binding request_trace_context
and verifying the worker thread observes it.
This commit is contained in:
Zeren Wang 2026-08-30 04:30:34 +02:00 committed by GitHub
parent bf740ffa90
commit bb75f8d736
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1012 additions and 235 deletions

View File

@ -13,7 +13,6 @@ The provider itself handles:
import asyncio
import atexit
import contextlib
import hashlib
import logging
import os
import signal
@ -21,7 +20,6 @@ import threading
import time
import uuid
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
try:
import fcntl
@ -42,6 +40,8 @@ from deerflow.config.paths import VIRTUAL_PATH_PREFIX, get_paths, join_host_path
from deerflow.integrations.lark_cli import INTEGRATION_ID as LARK_CLI_INTEGRATION_ID
from deerflow.integrations.lark_cli import LARK_CLI_SANDBOX_CONFIG_DIR, LARK_CLI_SANDBOX_DATA_DIR, LARK_CLI_SANDBOX_LOCKS_DIR, LARK_CLI_SANDBOX_RUNTIME_DIR, ensure_lark_cli_credential_tree, lark_skills_installed
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.acquire_serialization import AcquireSerializer
from deerflow.sandbox.identity import derive_sandbox_scope_token
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import SandboxProvider
@ -67,9 +67,6 @@ DEFAULT_IMAGE = "enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in
DEFAULT_PORT = 8080
DEFAULT_CONTAINER_PREFIX = "deer-flow-sandbox"
IDLE_CHECK_INTERVAL = _SHARED_IDLE_CHECK_INTERVAL
THREAD_LOCK_EXECUTOR_WORKERS = min(32, (os.cpu_count() or 1) + 4)
_THREAD_LOCK_EXECUTOR = ThreadPoolExecutor(max_workers=THREAD_LOCK_EXECUTOR_WORKERS, thread_name_prefix="sandbox-lock-wait")
atexit.register(_THREAD_LOCK_EXECUTOR.shutdown, wait=False, cancel_futures=True)
class SandboxBeingDestroyedError(RuntimeError):
@ -121,36 +118,6 @@ def _open_lock_file(lock_path):
return open(lock_path, "a", encoding="utf-8")
async def _acquire_thread_lock_async(lock: threading.Lock) -> None:
"""Acquire a threading.Lock without polling or using the default executor."""
loop = asyncio.get_running_loop()
acquire_future = loop.run_in_executor(_THREAD_LOCK_EXECUTOR, lock.acquire, True)
try:
acquired = await asyncio.shield(acquire_future)
except asyncio.CancelledError:
acquire_future.add_done_callback(lambda task: _release_cancelled_lock_acquire(lock, task))
raise
if not acquired:
raise RuntimeError("Failed to acquire sandbox thread lock")
def _release_cancelled_lock_acquire(lock: threading.Lock, task: asyncio.Future[bool]) -> None:
"""Release a lock acquired after its awaiting coroutine was cancelled."""
if task.cancelled():
return
try:
acquired = task.result()
except Exception as e:
logger.warning(f"Cancelled sandbox lock acquisition finished with error: {e}")
return
if acquired:
lock.release()
class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
"""Sandbox provider that manages containers running the AIO sandbox.
@ -189,7 +156,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
self._sandboxes: dict[str, AioSandbox] = {} # sandbox_id -> AioSandbox instance
self._sandbox_infos: dict[str, SandboxInfo] = {} # sandbox_id -> SandboxInfo (for destroy)
self._thread_sandboxes: dict[tuple[str, str], str] = {} # (user_id, thread_id) -> sandbox_id
self._thread_locks: dict[tuple[str, str], threading.Lock] = {} # (user_id, thread_id) -> in-process lock
self._acquire_serializer: AcquireSerializer[tuple[str, str]] = AcquireSerializer(thread_name_prefix="aio-sandbox-lock-wait")
self._last_activity: dict[str, float] = {} # sandbox_id -> last activity timestamp
# Warm pool: released sandboxes whose containers are still running.
# Maps sandbox_id -> (SandboxInfo, release_timestamp).
@ -754,7 +721,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
reused under the new 16-character identity. They remain eligible for
normal orphan cleanup while the first new-version acquire cold-starts.
"""
return hashlib.sha256(f"{user_id}:{thread_id}".encode()).hexdigest()[:16]
return derive_sandbox_scope_token(user_id=user_id, thread_id=thread_id)
def _assert_active_identity_available_locked(
self,
@ -1306,14 +1273,6 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
# ── Thread locking (in-process) ──────────────────────────────────────
def _get_thread_lock(self, thread_id: str, user_id: str) -> threading.Lock:
"""Get or create an in-process lock for a specific user/thread scope."""
key = self._thread_key(thread_id, user_id)
with self._lock:
if key not in self._thread_locks:
self._thread_locks[key] = threading.Lock()
return self._thread_locks[key]
def _sandbox_id_for_thread(self, thread_id: str | None, user_id: str | None) -> str:
"""Return deterministic IDs for thread sandboxes and random IDs otherwise."""
return self._deterministic_sandbox_id(thread_id, self._effective_acquire_user_id(user_id)) if thread_id else str(uuid.uuid4())[:8]
@ -1803,11 +1762,9 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
"""
effective_user_id = self._effective_acquire_user_id(user_id)
if thread_id:
thread_lock = self._get_thread_lock(thread_id, effective_user_id)
with thread_lock:
with self._acquire_serializer.hold(self._thread_key(thread_id, effective_user_id)):
return self._acquire_internal(thread_id, user_id=effective_user_id)
else:
return self._acquire_internal(thread_id, user_id=effective_user_id)
return self._acquire_internal(thread_id, user_id=effective_user_id)
async def acquire_async(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
"""Acquire a sandbox environment without blocking the event loop.
@ -1818,13 +1775,8 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
"""
effective_user_id = self._effective_acquire_user_id(user_id)
if thread_id:
thread_lock = self._get_thread_lock(thread_id, effective_user_id)
await _acquire_thread_lock_async(thread_lock)
try:
async with self._acquire_serializer.hold_async(self._thread_key(thread_id, effective_user_id)):
return await self._acquire_internal_async(thread_id, user_id=effective_user_id)
finally:
thread_lock.release()
return await self._acquire_internal_async(thread_id, user_id=effective_user_id)
def _acquire_internal(self, thread_id: str | None, *, user_id: str) -> str:
@ -2234,6 +2186,10 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
# lease stuck in `del:`.
self._release_ownership(sandbox_id)
def reset(self) -> None:
"""Release process-local acquire workers when this instance is detached."""
self._acquire_serializer.close()
def shutdown(self) -> None:
"""Shutdown all sandboxes. Thread-safe and idempotent."""
with self._lock:
@ -2271,3 +2227,5 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
self._ownership.close()
except Exception as e:
logger.warning(f"Error closing sandbox ownership store during shutdown: {e}")
self._acquire_serializer.close()

View File

@ -15,17 +15,19 @@ from __future__ import annotations
import asyncio
import atexit
import hashlib
import logging
import threading
import time
import uuid
from collections.abc import Awaitable
from functools import partial
from typing import TYPE_CHECKING, Any, TypeVar
from deerflow.config import get_app_config
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
from deerflow.sandbox.acquire_serialization import AcquireSerializer
from deerflow.sandbox.identity import derive_sandbox_scope_token
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import SandboxProvider
@ -185,7 +187,7 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
until the normal orphan cleanup removes them; they are never reused
under a new 16-character identity.
"""
return hashlib.sha256(f"{user_id}:{thread_id}".encode()).hexdigest()[:16]
return derive_sandbox_scope_token(user_id=user_id, thread_id=thread_id)
# ── Provider ────────────────────────────────────────────────────────
@ -197,7 +199,7 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
self._active_box_identity: dict[str, tuple[str, str] | None] = {}
self._warm_pool_identity: dict[str, tuple[str, str] | None] = {}
self._skip_health_check_warm_ids: set[str] = set()
self._acquire_locks: dict[str, threading.Lock] = {}
self._acquire_serializer: AcquireSerializer[str] = AcquireSerializer(thread_name_prefix="boxlite-acquire-wait")
self._idle_checker_stop = threading.Event()
self._idle_checker_thread: threading.Thread | None = None
self._shutdown_called = False
@ -243,15 +245,6 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
sandbox_id = name[len(_BOX_NAME_PREFIX) :]
return sandbox_id or None
def _lock_for_sandbox(self, sandbox_id: str) -> threading.Lock:
"""Return the per-sandbox acquire lock for a deterministic sandbox id."""
with self._lock:
lock = self._acquire_locks.get(sandbox_id)
if lock is None:
lock = threading.Lock()
self._acquire_locks[sandbox_id] = lock
return lock
def _start_idle_checker(self) -> None:
"""Start idle cleanup when enabled; idle_timeout=0 keeps it disabled."""
if self._config["idle_timeout"] <= 0:
@ -378,49 +371,63 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
key = self._thread_key(thread_id, user_id)
sandbox_id = self._sandbox_id(thread_id, user_id)
acquire_lock = self._lock_for_sandbox(sandbox_id)
with acquire_lock:
with self._lock:
existing = self._thread_boxes.get(key)
if existing is not None and existing in self._boxes:
return existing
if sandbox_id in self._boxes:
owner = self._active_box_identity.get(sandbox_id)
if owner != key:
raise SandboxIdentityCollisionError(
sandbox_id,
owner,
key,
)
self._thread_boxes[key] = sandbox_id
return sandbox_id
with self._acquire_serializer.hold(sandbox_id):
return self._acquire_scope_locked(key, sandbox_id)
reclaimed = self._reclaim_warm_pool(sandbox_id, key)
if reclaimed is not None:
with self._lock:
self._thread_boxes[key] = reclaimed
return reclaimed
async def acquire_async(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
"""Acquire without blocking the event loop.
box = self._create_box(sandbox_id)
conflict: tuple[str, str] | None | object = _NO_ACTIVE_IDENTITY
with self._lock:
if box.id in self._boxes:
conflict = self._active_box_identity.get(box.id)
if conflict == key:
self._thread_boxes[key] = box.id
else:
self._boxes[box.id] = box
self._active_box_identity[box.id] = key
self._thread_boxes[key] = box.id
if conflict is not _NO_ACTIVE_IDENTITY:
box.close()
if conflict != key:
The entire synchronous acquire (serializer wait + body) runs on the
serializer's dedicated executor — never the default executor. A
cancelled awaiter abandons the worker thread, which runs to
completion and releases the hold itself, so a retry serializes
behind it instead of overlapping the abandoned body.
"""
acquire = partial(self.acquire, thread_id, user_id=user_id)
return await self._acquire_serializer.run_on_executor(acquire)
def _acquire_scope_locked(self, key: tuple[str, str], sandbox_id: str) -> str:
with self._lock:
existing = self._thread_boxes.get(key)
if existing is not None and existing in self._boxes:
return existing
if sandbox_id in self._boxes:
owner = self._active_box_identity.get(sandbox_id)
if owner != key:
raise SandboxIdentityCollisionError(
sandbox_id,
conflict,
owner,
key,
)
return box.id
self._thread_boxes[key] = sandbox_id
return sandbox_id
reclaimed = self._reclaim_warm_pool(sandbox_id, key)
if reclaimed is not None:
with self._lock:
self._thread_boxes[key] = reclaimed
return reclaimed
box = self._create_box(sandbox_id)
conflict: tuple[str, str] | None | object = _NO_ACTIVE_IDENTITY
with self._lock:
if box.id in self._boxes:
conflict = self._active_box_identity.get(box.id)
if conflict == key:
self._thread_boxes[key] = box.id
else:
self._boxes[box.id] = box
self._active_box_identity[box.id] = key
self._thread_boxes[key] = box.id
if conflict is not _NO_ACTIVE_IDENTITY:
box.close()
if conflict != key:
raise SandboxIdentityCollisionError(
sandbox_id,
conflict,
key,
)
return box.id
def _create_box(self, sandbox_id: str) -> BoxliteBox:
# Enforce replica limit: evict oldest warm-pool box if active + warm boxes are at capacity.
@ -627,7 +634,7 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
self._boxes.clear()
self._active_box_identity.clear()
self._thread_boxes.clear()
self._acquire_locks.clear()
self._acquire_serializer.close()
def shutdown(self) -> None:
with self._lock:
@ -645,7 +652,7 @@ class BoxliteProvider(WarmPoolLifecycleMixin[BoxliteBox], SandboxProvider):
self._active_box_identity.clear()
self._warm_pool_identity.clear()
self._thread_boxes.clear()
self._acquire_locks.clear()
self._acquire_serializer.close()
self._skip_health_check_warm_ids.clear()
for box in active + warm:

View File

@ -36,7 +36,6 @@ from __future__ import annotations
import asyncio
import atexit
import hashlib
import json
import logging
import os
@ -58,7 +57,9 @@ from e2b_code_interpreter import Sandbox as E2BClientSandbox
from deerflow.config import get_app_config
from deerflow.runtime.user_context import get_effective_user_id
from deerflow.sandbox.acquire_serialization import AcquireSerializer
from deerflow.sandbox.exceptions import SandboxCapacityExceededError
from deerflow.sandbox.identity import derive_sandbox_scope_token
from deerflow.sandbox.sandbox import Sandbox
from deerflow.sandbox.sandbox_provider import SandboxProvider
@ -191,9 +192,9 @@ class E2BSandboxProvider(SandboxProvider):
self._sandboxes: dict[str, E2BSandbox] = {}
# (user_id, thread_id) -> sandbox id for fast in-process lookup.
self._thread_sandboxes: dict[tuple[str, str], str] = {}
# Per-(user,thread) lock to serialise acquire() and release() state
# Per-(user,thread) serializer for acquire() and release() state
# transitions without holding the provider-wide lock across remote IO.
self._thread_locks: dict[tuple[str, str], threading.Lock] = {}
self._acquire_serializer: AcquireSerializer[tuple[str, str]] = AcquireSerializer(thread_name_prefix="e2b-sandbox-lock-wait")
# Warm pool: released sandboxes whose remote micro-VM is still alive.
# ``OrderedDict`` maintains insertion / move_to_end order for LRU.
self._warm_pool: OrderedDict[str, tuple[str, float]] = OrderedDict()
@ -356,7 +357,12 @@ class E2BSandboxProvider(SandboxProvider):
@staticmethod
def _stable_seed(thread_id: str, user_id: str) -> str:
return hashlib.sha256(f"{user_id}:{thread_id}".encode()).hexdigest()[:16]
"""Warm-pool lookup seed derived from user/thread scope.
For E2B this value is the warm-pool lookup seed, not the
provider-issued remote id (RFC #4741 §6).
"""
return derive_sandbox_scope_token(user_id=user_id, thread_id=thread_id)
def _metadata_matches_capacity_ledger(
self,
@ -412,19 +418,10 @@ class E2BSandboxProvider(SandboxProvider):
sig_name,
)
def _get_thread_lock(self, thread_id: str, user_id: str) -> threading.Lock:
key = self._thread_key(thread_id, user_id)
with self._lock:
lock = self._thread_locks.get(key)
if lock is None:
lock = threading.Lock()
self._thread_locks[key] = lock
return lock
def acquire(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
effective_user_id = self._effective_acquire_user_id(user_id)
if thread_id:
with self._get_thread_lock(thread_id, effective_user_id):
with self._acquire_serializer.hold(self._thread_key(thread_id, effective_user_id)):
return self._acquire_internal(thread_id, user_id=effective_user_id)
return self._acquire_internal(thread_id, user_id=effective_user_id)
@ -2343,7 +2340,7 @@ class E2BSandboxProvider(SandboxProvider):
return
user_id, thread_id = thread_key
with self._get_thread_lock(thread_id, user_id):
with self._acquire_serializer.hold(self._thread_key(thread_id, user_id)):
self._release_internal(sandbox_id)
def _release_internal(self, sandbox_id: str) -> None:
@ -2505,7 +2502,7 @@ class E2BSandboxProvider(SandboxProvider):
self._remote_ops_in_progress.clear()
self._unowned_remote_ops_in_progress.clear()
self._thread_sandboxes.clear()
self._thread_locks.clear()
self._acquire_serializer.close()
self._owned_sandbox_ids.clear()
self._acquire_inflight.clear()
self._orphan_first_seen.clear()

View File

@ -3,7 +3,6 @@
from __future__ import annotations
import atexit
import hashlib
import ipaddress
import logging
import os
@ -11,10 +10,13 @@ import threading
import time
import uuid
from datetime import timedelta
from functools import partial
from typing import TYPE_CHECKING, Any
from urllib.parse import urlsplit
from deerflow.config import get_app_config
from deerflow.sandbox.acquire_serialization import AcquireSerializer
from deerflow.sandbox.identity import derive_sandbox_scope_token
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.sandbox_provider import SandboxProvider
@ -78,7 +80,7 @@ class OpenSandboxProvider(WarmPoolLifecycleMixin[OpenSandboxSandbox], SandboxPro
self._sandboxes: dict[str, OpenSandboxSandbox] = {}
self._thread_sandboxes: dict[tuple[str, str], str] = {}
self._warm_pool: dict[str, tuple[OpenSandboxSandbox, float]] = {}
self._acquire_locks: dict[str, threading.Lock] = {}
self._acquire_serializer: AcquireSerializer[str] = AcquireSerializer(thread_name_prefix="opensandbox-acquire-wait")
self._idle_checker_stop = threading.Event()
self._idle_checker_thread: threading.Thread | None = None
self._shutdown_called = False
@ -167,20 +169,12 @@ class OpenSandboxProvider(WarmPoolLifecycleMixin[OpenSandboxSandbox], SandboxPro
@staticmethod
def _sandbox_id(thread_id: str, user_id: str) -> str:
return hashlib.sha256(f"{user_id}:{thread_id}".encode()).hexdigest()[:16]
return derive_sandbox_scope_token(user_id=user_id, thread_id=thread_id)
@staticmethod
def _thread_key(thread_id: str, user_id: str | None) -> tuple[str, str]:
return (user_id or "", thread_id)
def _lock_for_sandbox(self, sandbox_id: str) -> threading.Lock:
with self._lock:
lock = self._acquire_locks.get(sandbox_id)
if lock is None:
lock = threading.Lock()
self._acquire_locks[sandbox_id] = lock
return lock
def _start_idle_checker(self) -> None:
if self._config["idle_timeout"] <= 0:
return
@ -231,45 +225,60 @@ class OpenSandboxProvider(WarmPoolLifecycleMixin[OpenSandboxSandbox], SandboxPro
key = self._thread_key(thread_id, user_id)
sandbox_id = self._sandbox_id(thread_id, user_id or "")
with self._lock_for_sandbox(sandbox_id):
with self._lock:
existing = self._thread_sandboxes.get(key)
active = self._sandboxes.get(existing) if existing is not None else None
if existing is not None and active is not None:
try:
active.renew()
return existing
except Exception:
# A terminal renewal failure invokes _invalidate_sandbox,
# which removes and closes this exact client. Rebuild in the
# same acquire; transient errors leave the registry intact
# and must remain visible to the caller.
with self._lock:
invalidated = self._sandboxes.get(existing) is not active and existing not in self._warm_pool
shutting_down = self._shutdown_called
if not invalidated or not active.is_closed or shutting_down:
raise
logger.info("Rebuilding terminal OpenSandbox %s during acquire", existing)
reclaimed = self._reclaim_warm_pool(sandbox_id)
if reclaimed is not None:
with self._acquire_serializer.hold(sandbox_id):
return self._acquire_scope_locked(key, sandbox_id, thread_id=thread_id, user_id=user_id)
async def acquire_async(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
"""Acquire without blocking the event loop.
The entire synchronous acquire (serializer wait + body) runs on the
serializer's dedicated executor — never the default executor. A
cancelled awaiter abandons the worker thread, which runs to
completion and releases the hold itself, so a retry serializes
behind it instead of overlapping the abandoned body.
"""
acquire = partial(self.acquire, thread_id, user_id=user_id)
return await self._acquire_serializer.run_on_executor(acquire)
def _acquire_scope_locked(self, key: tuple[str, str], sandbox_id: str, *, thread_id: str, user_id: str | None) -> str:
with self._lock:
existing = self._thread_sandboxes.get(key)
active = self._sandboxes.get(existing) if existing is not None else None
if existing is not None and active is not None:
try:
active.renew()
return existing
except Exception:
# A terminal renewal failure invokes _invalidate_sandbox,
# which removes and closes this exact client. Rebuild in the
# same acquire; transient errors leave the registry intact
# and must remain visible to the caller.
with self._lock:
if self._shutdown_called:
raise RuntimeError("OpenSandboxProvider shut down during acquire")
if reclaimed in self._sandboxes:
self._thread_sandboxes[key] = reclaimed
return reclaimed
sandbox = self._create_sandbox(sandbox_id, thread_id=thread_id, user_id=user_id)
invalidated = self._sandboxes.get(existing) is not active and existing not in self._warm_pool
shutting_down = self._shutdown_called
if not invalidated or not active.is_closed or shutting_down:
raise
logger.info("Rebuilding terminal OpenSandbox %s during acquire", existing)
reclaimed = self._reclaim_warm_pool(sandbox_id)
if reclaimed is not None:
with self._lock:
if self._shutdown_called:
destroy_after_unlock = True
else:
self._sandboxes[sandbox_id] = sandbox
self._thread_sandboxes[key] = sandbox_id
destroy_after_unlock = False
if destroy_after_unlock:
self._destroy_quietly(sandbox, context="created during shutdown")
raise RuntimeError("OpenSandboxProvider shut down during acquire")
return sandbox_id
raise RuntimeError("OpenSandboxProvider shut down during acquire")
if reclaimed in self._sandboxes:
self._thread_sandboxes[key] = reclaimed
return reclaimed
sandbox = self._create_sandbox(sandbox_id, thread_id=thread_id, user_id=user_id)
with self._lock:
if self._shutdown_called:
destroy_after_unlock = True
else:
self._sandboxes[sandbox_id] = sandbox
self._thread_sandboxes[key] = sandbox_id
destroy_after_unlock = False
if destroy_after_unlock:
self._destroy_quietly(sandbox, context="created during shutdown")
raise RuntimeError("OpenSandboxProvider shut down during acquire")
return sandbox_id
def _create_sandbox(self, sandbox_id: str, *, thread_id: str | None, user_id: str | None) -> OpenSandboxSandbox:
replicas, total = self._replica_count()
@ -368,7 +377,7 @@ class OpenSandboxProvider(WarmPoolLifecycleMixin[OpenSandboxSandbox], SandboxPro
self._warm_pool.setdefault(sandbox_id, (sandbox, now))
self._sandboxes.clear()
self._thread_sandboxes.clear()
self._acquire_locks.clear()
self._acquire_serializer.close()
def shutdown(self) -> None:
with self._lock:
@ -381,7 +390,7 @@ class OpenSandboxProvider(WarmPoolLifecycleMixin[OpenSandboxSandbox], SandboxPro
self._sandboxes.clear()
self._warm_pool.clear()
self._thread_sandboxes.clear()
self._acquire_locks.clear()
self._acquire_serializer.close()
for sandbox in sandboxes:
self._destroy_quietly(sandbox, context="shutdown")

View File

@ -18,15 +18,17 @@ needed once this provider is selected.
from __future__ import annotations
import atexit
import hashlib
import logging
import shlex
import threading
import time
import uuid
from functools import partial
from typing import TYPE_CHECKING, Any
from deerflow.config import get_app_config
from deerflow.sandbox.acquire_serialization import AcquireSerializer
from deerflow.sandbox.identity import derive_sandbox_scope_token
from deerflow.sandbox.sandbox import Sandbox, _validate_extra_env
from deerflow.sandbox.sandbox_provider import SandboxProvider
@ -111,7 +113,7 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider
gateway a hash collision would let one user reclaim another's parked
sandbox. 64 bits, like community/e2b_sandbox, keeps that negligible.
"""
return hashlib.sha256(f"{user_id}:{thread_id}".encode()).hexdigest()[:16]
return derive_sandbox_scope_token(user_id=user_id, thread_id=thread_id)
# ── Provider lifecycle ───────────────────────────────────────────────
@ -120,7 +122,7 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider
self._sandboxes: dict[str, TenkiSandbox] = {}
self._thread_sandboxes: dict[tuple[str, str], str] = {}
self._warm_pool: dict[str, tuple[TenkiSandbox, float]] = {}
self._acquire_locks: dict[str, threading.Lock] = {}
self._acquire_serializer: AcquireSerializer[str] = AcquireSerializer(thread_name_prefix="tenki-acquire-wait")
self._idle_checker_stop = threading.Event()
self._idle_checker_thread: threading.Thread | None = None
self._shutdown_called = False
@ -209,14 +211,6 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider
def _sandbox_name(cls, sandbox_id: str) -> str:
return f"{_SANDBOX_NAME_PREFIX}{sandbox_id}"
def _lock_for_sandbox(self, sandbox_id: str) -> threading.Lock:
with self._lock:
lock = self._acquire_locks.get(sandbox_id)
if lock is None:
lock = threading.Lock()
self._acquire_locks[sandbox_id] = lock
return lock
def _start_idle_checker(self) -> None:
"""Start idle cleanup when enabled; idle_timeout=0 keeps it disabled."""
if self._config["idle_timeout"] <= 0:
@ -257,24 +251,38 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider
key = self._thread_key(thread_id, user_id)
sandbox_id = self._sandbox_id(thread_id, user_id or "")
acquire_lock = self._lock_for_sandbox(sandbox_id)
with acquire_lock:
with self._lock:
existing = self._thread_sandboxes.get(key)
if existing is not None and existing in self._sandboxes:
return existing
with self._acquire_serializer.hold(sandbox_id):
return self._acquire_scope_locked(key, sandbox_id)
reclaimed = self._reclaim_warm_pool(sandbox_id)
if reclaimed is not None:
with self._lock:
self._thread_sandboxes[key] = reclaimed
return reclaimed
async def acquire_async(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
"""Acquire without blocking the event loop.
sandbox = self._create_sandbox(sandbox_id)
The entire synchronous acquire (serializer wait + body) runs on the
serializer's dedicated executor — never the default executor. A
cancelled awaiter abandons the worker thread, which runs to
completion and releases the hold itself, so a retry serializes
behind it instead of overlapping the abandoned body.
"""
acquire = partial(self.acquire, thread_id, user_id=user_id)
return await self._acquire_serializer.run_on_executor(acquire)
def _acquire_scope_locked(self, key: tuple[str, str], sandbox_id: str) -> str:
with self._lock:
existing = self._thread_sandboxes.get(key)
if existing is not None and existing in self._sandboxes:
return existing
reclaimed = self._reclaim_warm_pool(sandbox_id)
if reclaimed is not None:
with self._lock:
self._sandboxes[sandbox.id] = sandbox
self._thread_sandboxes[key] = sandbox.id
return sandbox.id
self._thread_sandboxes[key] = reclaimed
return reclaimed
sandbox = self._create_sandbox(sandbox_id)
with self._lock:
self._sandboxes[sandbox.id] = sandbox
self._thread_sandboxes[key] = sandbox.id
return sandbox.id
def _create_sandbox(self, sandbox_id: str) -> TenkiSandbox:
# Enforce replica soft cap: evict the oldest warm sandbox if active + warm
@ -428,7 +436,7 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider
self._warm_pool.setdefault(sandbox_id, (sandbox, now))
self._sandboxes.clear()
self._thread_sandboxes.clear()
self._acquire_locks.clear()
self._acquire_serializer.close()
def shutdown(self) -> None:
with self._lock:
@ -444,7 +452,7 @@ class TenkiSandboxProvider(WarmPoolLifecycleMixin[TenkiSandbox], SandboxProvider
self._sandboxes.clear()
self._warm_pool.clear()
self._thread_sandboxes.clear()
self._acquire_locks.clear()
self._acquire_serializer.close()
for sandbox in active + warm:
self._close_quietly(sandbox, context="shutdown")

View File

@ -2,16 +2,17 @@
**Interface**: Abstract `Sandbox` with `execute_command(command, env=None)`, `read_file`, `write_file`, `list_dir`, `glob`, and `grep`. `grep` accepts either one text file or a directory tree. The optional `env` injects per-call environment variables (request-scoped secrets — see Request-Scoped Secrets below); `LocalSandbox` merges it via `subprocess.run(env=...)` and `AioSandbox` routes env-bearing commands through the `bash.exec(env=...)` API on a fresh session.
**Provider Pattern**: `SandboxProvider` with `acquire`, `acquire_async`, `get`, `release` lifecycle. Async agent/tool paths call async sandbox lifecycle hooks so Docker sandbox creation, discovery, cross-process locking, readiness polling, and release stay off the event loop.
**Shared components** (RFC #4741): remote providers derive their deterministic sandbox id through `derive_sandbox_scope_token` (`sandbox/identity.py`, keyword-only; the sha256/16-hex derivation is a compatibility contract — changing it orphans existing containers), and serialize provider-selected acquire/release transitions through `AcquireSerializer` (`sandbox/acquire_serialization.py`): per-key `threading.Lock` table with holder/waiter refcount reclamation (no unbounded per-thread lock growth), a bounded dedicated executor so async waits never touch the event loop or the default executor, worker-owned cancellation cleanup that does not depend on a cancelled event loop task resuming, and idempotent `close()` called from provider `shutdown()`/`reset()`. AIO/E2B key by `(user_id, thread_id)`; BoxLite/Tenki/OpenSandbox key by the derived sandbox id. `thread_id=None` acquires (random uuid ids) never enter the serializer.
**Authorization gate** (`sandbox:execute`, RFC #4063 Phase 3): every sandbox acquisition passes through `authorize_sandbox_execution` (`deerflow/authz/sandbox_authz.py`) - a binary `authorize(principal, "sandbox", "execute", target="*")` check before `provider.acquire`. The gate lives at the single acquisition entry point (`ensure_sandbox_initialized` / `_acquire_sandbox_async` in `tools.py`, and `SandboxMiddleware.before_agent` / `abefore_agent`), so it cannot be bypassed regardless of which sandbox-dependent tool triggers it; the reuse path (sandbox already in state) skips the re-check. Deny raises `SandboxAuthorizationError` (`sandbox/exceptions.py`), which propagates out of tool execution as a friendly error `ToolMessage` ("sandbox execution is not permitted for your role") - the eager path catches it and skips acquisition instead, deferring the deny to the first sandbox-touching tool call so both paths share the same semantics. Provider errors (both `authorize()` and provider resolution) follow `authorization.fail_closed` / `fail_open`; no readable `config.yaml` or `authorization.enabled: false` makes the gate a no-op (`safe_app_config` tolerates missing config). Gateway auxiliary sync paths (uploads/artifacts routers) call `try_acquire_sandbox_for_request` (`app/gateway/authz.py`), which gates via `authorize_sandbox_for_request` and skips the sync on deny - the upload/artifact edit itself still succeeds. Tests: `tests/test_sandbox_authorization.py`.
**Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved.
**Implementations**:
- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-thread `LocalSandbox` (id `local:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`. Public, custom, legacy, and managed integration skill mappings point at stable enabled-only projection roots rather than raw skill directories. On Windows, Git Bash/MSYS argument-conversion exclusions are limited to safe non-root virtual path prefixes; do not restore a blanket conversion disable, because host-native CLI launchers need normal MSYS path conversion for their own installation paths.
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire. `get()` remains an in-memory lookup for event-loop-safe tool paths — it never touches the ownership store (that would be blocking IO on the event loop); ownership is published on acquire/reclaim and refreshed off the event loop by the dedicated renewal thread (`_renew_owned_leases`). `uses_thread_data_mounts` defaults to backend detection (`LocalContainerBackend=True`, remote/provisioner backends=False), while the optional `sandbox.thread_data_mounts` boolean takes precedence for deployments that guarantee the Gateway and sandbox share the same thread user-data directories. Setting it `true` skips upload-time sandbox acquire/sync; a false positive leaves uploads unavailable to the sandbox. Local-container and hostPath-provisioner mounts use the same stable skill projection roots; PVC-backed skills remain governed by the operator-supplied PVC layout until PVC materialization is implemented. Readiness probes and `agent_sandbox` clients classify loopback/private IPs, single-label cluster hosts, and Docker/Podman internal hostnames as direct control-plane destinations and set `trust_env=False`; external FQDNs and public IPs retain environment proxy support.
- `AioSandboxProvider` (`packages/harness/deerflow/community/`) - Docker-based isolation. Active-cache and warm-pool entries are checked with the backend during acquire/reuse; definitively dead containers are dropped from all in-process maps so the thread can discover or create a fresh sandbox instead of reusing a stale client. Backend health-check failures are treated as unknown, not dead; local discovery likewise treats an unverifiable container as not adoptable and falls through to create rather than failing acquire. `get()` remains an in-memory lookup for event-loop-safe tool paths — it never touches the ownership store (that would be blocking IO on the event loop); ownership is published on acquire/reclaim and refreshed off the event loop by the dedicated renewal thread (`_renew_owned_leases`). `reset()` closes the per-instance acquire serializer so replacing the singleton cannot retain its executor workers; full remote sandbox teardown remains `shutdown()`. `uses_thread_data_mounts` defaults to backend detection (`LocalContainerBackend=True`, remote/provisioner backends=False), while the optional `sandbox.thread_data_mounts` boolean takes precedence for deployments that guarantee the Gateway and sandbox share the same thread user-data directories. Setting it `true` skips upload-time sandbox acquire/sync; a false positive leaves uploads unavailable to the sandbox. Local-container and hostPath-provisioner mounts use the same stable skill projection roots; PVC-backed skills remain governed by the operator-supplied PVC layout until PVC materialization is implemented. Readiness probes and `agent_sandbox` clients classify loopback/private IPs, single-label cluster hosts, and Docker/Podman internal hostnames as direct control-plane destinations and set `trust_env=False`; external FQDNs and public IPs retain environment proxy support.
- `E2BSandboxProvider` (`packages/harness/deerflow/community/e2b_sandbox/`) provides E2B remote isolation.
New sandboxes receive a one-shot upload from the enabled-only public, custom,
legacy, and managed integration projections. Existing E2B VMs keep their
creation-time snapshot because E2B has no shared host mount.
Acquire and release share a per-user and thread lock. The provider lock does
Acquire and release share an `AcquireSerializer` hold keyed by `(user_id, thread_id)`. The serializer does
not cover remote IO. `burst_limit` adds capacity only for the `burst` policy.
The `wait` policy fails the turn after `acquire_timeout`. The runtime does not
retry the turn automatically. E2B acquisition uses a bounded executor.

View File

@ -0,0 +1,219 @@
"""Per-key acquire serialization with bounded lock-table growth (RFC #4741).
Shared component for sandbox providers. Each provider selects the key that
preserves its current collision and lifecycle semantics (``(user_id,
thread_id)`` for AIO/E2B, the derived sandbox id for BoxLite/Tenki/
OpenSandbox); the serializer never interprets it.
The async path runs the blocking ``threading.Lock.acquire`` on a bounded
dedicated executor (never the default executor, never the event loop). A small
handoff state makes the acquire worker release an abandoned lock itself, so
cleanup does not depend on the cancelling event loop running a done callback.
"""
from __future__ import annotations
import asyncio
import contextvars
import os
import threading
from collections.abc import AsyncIterator, Callable, Hashable, Iterator
from concurrent.futures import Future, ThreadPoolExecutor
from contextlib import asynccontextmanager, contextmanager
from dataclasses import dataclass, field
from functools import partial
DEFAULT_MAX_WORKERS = min(32, (os.cpu_count() or 1) + 4)
@dataclass
class _Entry:
lock: threading.Lock = field(default_factory=threading.Lock)
refs: int = 0 # holders + waiters
class _AsyncAcquire:
"""Race-safe ownership handoff between an event loop and acquire worker."""
def __init__(self, entry: _Entry, cleanup: Callable[[], None]) -> None:
self._entry = entry
self._cleanup = cleanup
self._state_lock = threading.Lock()
self._abandoned = False
self._acquired = False
self._cleaned = False
def run(self) -> bool:
self._entry.lock.acquire()
with self._state_lock:
if self._abandoned:
cleanup = self._mark_cleaned()
else:
self._acquired = True
cleanup = False
if cleanup:
self._entry.lock.release()
self._cleanup()
return not cleanup
def abandon(self) -> None:
with self._state_lock:
self._abandoned = True
cleanup = self._acquired and self._mark_cleaned()
if cleanup:
self._acquired = False
if cleanup:
self._entry.lock.release()
self._cleanup()
def cancel_queued(self) -> None:
"""Clean up an executor job cancelled before its worker started."""
with self._state_lock:
cleanup = self._mark_cleaned()
if cleanup:
self._cleanup()
def worker_done(self, future: Future[bool]) -> None:
"""Reclaim a checkout if executor shutdown cancelled queued work."""
if future.cancelled():
self.cancel_queued()
def release(self) -> None:
with self._state_lock:
cleanup = self._acquired and self._mark_cleaned()
if cleanup:
self._acquired = False
if not cleanup: # pragma: no cover - indicates an internal ownership bug
raise RuntimeError("Serializer lock is not held")
self._entry.lock.release()
self._cleanup()
def _mark_cleaned(self) -> bool:
if self._cleaned:
return False
self._cleaned = True
return True
class AcquireSerializer[KeyT: Hashable]:
"""Serialize provider-selected lifecycle transitions per key."""
def __init__(
self,
*,
max_workers: int = DEFAULT_MAX_WORKERS,
thread_name_prefix: str = "sandbox-acquire-wait",
) -> None:
self._table: dict[KeyT, _Entry] = {}
self._table_lock = threading.Lock()
self._closed = False
self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix=thread_name_prefix)
@property
def executor(self) -> ThreadPoolExecutor:
"""The dedicated bounded executor backing async waits.
Providers may offload an entire synchronous acquire (with hold()
inside) here so a cancelled awaiter abandons the worker, not the
lock: the hold then follows the body to completion, matching the
pre-serializer to_thread bridge semantics.
"""
return self._executor
async def run_on_executor[**P, T](self, func: Callable[P, T], /, *args: P.args, **kwargs: P.kwargs) -> T:
"""Run a blocking callable on the serializer's dedicated executor.
``asyncio.to_thread`` copies ``ContextVar`` values automatically; raw
``loop.run_in_executor`` does not. The providers whose ``acquire_async``
offloads the whole synchronous acquire here replaced the inherited
``to_thread`` bridge, so copy the calling context explicitly: without
it, request-scoped ContextVars (e.g. the trace id bound by
``request_trace_context``) read as unset inside the worker thread.
"""
loop = asyncio.get_running_loop()
ctx = contextvars.copy_context()
call = partial(func, *args, **kwargs)
return await loop.run_in_executor(self._executor, ctx.run, call)
@contextmanager
def hold(self, key: KeyT) -> Iterator[None]:
entry = self._checkout(key)
entry.lock.acquire()
try:
yield
finally:
entry.lock.release()
self._checkin(key, entry)
@asynccontextmanager
async def hold_async(self, key: KeyT) -> AsyncIterator[None]:
entry = self._checkout(key)
loop = asyncio.get_running_loop()
acquire = _AsyncAcquire(entry, lambda: self._checkin(key, entry))
try:
worker_future = self._executor.submit(acquire.run)
except RuntimeError: # executor shut down between checkout and scheduling
self._checkin(key, entry)
raise RuntimeError("AcquireSerializer is closed") from None
# concurrent.futures callbacks run in the cancelling/worker thread, so
# executor-shutdown cleanup does not depend on this loop progressing.
worker_future.add_done_callback(acquire.worker_done)
acquire_future = asyncio.wrap_future(worker_future, loop=loop)
try:
acquired = await asyncio.shield(acquire_future)
except asyncio.CancelledError:
task = asyncio.current_task()
if acquire_future.cancelled() and task is not None and task.cancelling() == 0:
# close() cancelled the queued wait via executor shutdown, not
# the caller: check in exactly once (no done callback — the
# cancelled future is already done) and report the documented
# closure error instead of a spurious CancelledError. The
# `cancelling() == 0` guard assumes this await is the task's
# only pending cancellation (no stale count left by an outer
# swallowed CancelledError).
acquire.cancel_queued()
raise RuntimeError("AcquireSerializer is closed") from None
if acquire_future.cancelled():
acquire.cancel_queued()
else:
acquire.abandon()
raise
# run() returns False only when the worker observed _abandoned, and
# abandon() is called solely from the except handler above, which
# always re-raises — so a normal completion implies the lock is held.
# If this were reachable, the worker would already have checked in;
# an extra _checkin here would underflow the refcount, so assert.
assert acquired, "acquire worker returned without holding the lock"
try:
yield
finally:
acquire.release()
def close(self) -> None:
"""Reject new holders and release executor resources. Idempotent.
A currently executing critical section is not invalidated: its exit
still releases its lock and reclaims its table entry.
"""
with self._table_lock:
if self._closed:
return
self._closed = True
self._executor.shutdown(wait=False, cancel_futures=True)
def _checkout(self, key: KeyT) -> _Entry:
with self._table_lock:
if self._closed:
raise RuntimeError("AcquireSerializer is closed")
entry = self._table.get(key)
if entry is None:
entry = _Entry()
self._table[key] = entry
entry.refs += 1
return entry
def _checkin(self, key: KeyT, entry: _Entry) -> None:
with self._table_lock:
entry.refs -= 1
if entry.refs == 0 and not entry.lock.locked():
self._table.pop(key, None)

View File

@ -0,0 +1,43 @@
"""Shared sandbox scope-token derivation (RFC #4741).
COMPATIBILITY CONTRACT: this derivation is a durable identity boundary. AIO,
E2B, BoxLite, Tenki, and OpenSandbox locate existing containers and VMs
through this token. Changing any property — separator, encoding, digest,
casing, or truncation length — is a breaking migration: existing remote
resources would no longer be found and would be cold-started.
Providers resolve ``user_id`` before calling this function, and their
resolutions differ (effective-user lookup, ``""`` substitution, or raw
pass-through). That resolution stays provider-private; this module only pins
what happens to the resolved strings.
"""
from __future__ import annotations
import hashlib
import re
SANDBOX_ID_VERSION = 1
_TOKEN_HEX_LEN = 16
_TOKEN_RE = re.compile(r"[0-9a-f]{16}")
def derive_sandbox_scope_token(*, user_id: str, thread_id: str) -> str:
"""Return the durable 16-lowercase-hex sandbox scope token.
Keyword-only: every provider helper this replaces takes
``(thread_id, user_id)`` positionally — the reverse order — and both are
plain ``str``; keyword-only call sites eliminate silent argument-order
mistakes during and after the migration.
WARNING: changing the separator, encoding, digest, casing, or truncation
length is a breaking migration — existing containers and VMs would no
longer be found. See module docstring.
"""
return hashlib.sha256(f"{user_id}:{thread_id}".encode()).hexdigest()[:_TOKEN_HEX_LEN]
def is_sandbox_scope_token(value: object) -> bool:
"""Validate token shape only; a truncated hash is not reversible."""
return isinstance(value, str) and _TOKEN_RE.fullmatch(value) is not None

View File

@ -73,13 +73,14 @@ def _make_provider(tmp_path: Path):
"""Build an ``AioSandboxProvider`` without ``__init__`` (no Docker, no threads)."""
from deerflow.community.aio_sandbox.aio_sandbox_provider import AioSandboxProvider
from deerflow.config.sandbox_config import SandboxOwnershipConfig
from deerflow.sandbox.acquire_serialization import AcquireSerializer
provider = AioSandboxProvider.__new__(AioSandboxProvider)
provider._lock = threading.Lock()
provider._sandboxes = {}
provider._sandbox_infos = {}
provider._thread_sandboxes = {}
provider._thread_locks = {}
provider._acquire_serializer = AcquireSerializer(thread_name_prefix="aio-sandbox-lock-wait")
provider._last_activity = {}
provider._warm_pool = {}
provider._active_sandbox_identity = {}

View File

@ -0,0 +1,56 @@
"""Anchor: contended OpenSandbox acquire_async must not block the event loop.
Two concurrent acquire_async calls for the same scope serialize on the
AcquireSerializer; the loser's wait and both callers' creation path must stay
off the loop. The fake SDK boundary below performs REAL file IO inside
create(), so any regression that moves creation back onto the loop trips the
Blockbuster gate (FILE_IO rules give this anchor teeth).
Blockbuster's default rule set is blind to ``threading.Lock.acquire`` (verified
empirically under ``detect_blocking_io_strict``), so the serializer lock wait
itself cannot be pinned this way — the lock-wait placement is covered by Task
7's ExplodingExecutor contract test. What this anchor pins instead is the
provider creation path: under contention, no blocking IO may run on the loop.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
import pytest
from blockbuster import BlockingError
from test_opensandbox_provider import _FakeRemote, _FakeSandboxClass, _install
pytestmark = pytest.mark.asyncio
class _FileIOProbingSandboxClass(_FakeSandboxClass):
"""Fake SDK whose create() performs real blocking file IO at the boundary."""
def __init__(self, probe_dir: Path) -> None:
super().__init__()
self._probe_dir = probe_dir
def create(self, image: str, **kwargs) -> _FakeRemote:
probe = self._probe_dir / f"create-{len(self.create_calls) + 1}.probe"
probe.write_text("x" * 4096) # real blocking file IO at the SDK boundary
return super().create(image, **kwargs)
async def test_concurrent_acquire_async_stays_off_event_loop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
provider, _fake_sdk = _install(monkeypatch, sdk=_FileIOProbingSandboxClass(tmp_path))
first, second = await asyncio.gather(
provider.acquire_async("thread-anchor", user_id="u-anchor"),
provider.acquire_async("thread-anchor", user_id="u-anchor"),
)
assert first == second # same scope serialized, second caller reuses
provider.shutdown()
async def test_sync_acquire_on_loop_trips_the_gate(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Meta-check (teeth): a synchronous acquire on the loop MUST be caught."""
provider, _fake_sdk = _install(monkeypatch, sdk=_FileIOProbingSandboxClass(tmp_path))
with pytest.raises(BlockingError):
provider.acquire("thread-anchor", user_id="u-anchor")
provider.shutdown()

View File

@ -71,6 +71,7 @@ def _make_provider_with_active_sandbox(tmp_path: Path, sandbox_id: str):
from deerflow.community.aio_sandbox.aio_sandbox_provider import AioSandboxProvider
from deerflow.community.aio_sandbox.sandbox_info import SandboxInfo
from deerflow.config.sandbox_config import SandboxOwnershipConfig
from deerflow.sandbox.acquire_serialization import AcquireSerializer
provider = AioSandboxProvider.__new__(AioSandboxProvider)
provider._lock = threading.Lock()
@ -85,7 +86,7 @@ def _make_provider_with_active_sandbox(tmp_path: Path, sandbox_id: str):
)
}
provider._thread_sandboxes = {}
provider._thread_locks = {}
provider._acquire_serializer = AcquireSerializer(thread_name_prefix="aio-sandbox-lock-wait")
provider._last_activity = {sandbox_id: 1.0}
provider._warm_pool = {}
provider._warm_pool_identity = {}

View File

@ -0,0 +1,270 @@
"""Contract tests for AcquireSerializer (RFC #4741 §5)."""
from __future__ import annotations
import asyncio
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import pytest
from deerflow.sandbox.acquire_serialization import AcquireSerializer
from deerflow.trace_context import get_current_trace_id, request_trace_context
class TestSyncMutualExclusion:
def test_same_key_never_overlaps(self):
serializer = AcquireSerializer()
active = 0
max_active = 0
counter_lock = threading.Lock()
def worker():
nonlocal active, max_active
with serializer.hold("k"):
with counter_lock:
active += 1
max_active = max(max_active, active)
time.sleep(0.02)
with counter_lock:
active -= 1
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert max_active == 1
def test_different_keys_proceed_concurrently(self):
serializer = AcquireSerializer()
barrier = threading.Barrier(2, timeout=2)
def worker(key):
with serializer.hold(key):
barrier.wait() # raises BrokenBarrierError if serialized
t1 = threading.Thread(target=worker, args=("a",))
t2 = threading.Thread(target=worker, args=("b",))
t1.start()
t2.start()
t1.join(timeout=5)
t2.join(timeout=5)
assert not t1.is_alive() and not t2.is_alive()
def test_lock_table_reclaims_entries(self):
serializer = AcquireSerializer()
for i in range(50):
with serializer.hold(f"scope-{i}"):
pass
assert len(serializer._table) == 0
class TestAsyncContract:
@pytest.mark.asyncio
async def test_cancellation_before_acquire_leaks_nothing(self):
serializer = AcquireSerializer()
with serializer.hold("k"): # uncontended acquire, safe on the loop
waiter = asyncio.create_task(_hold_once(serializer, "k"))
await asyncio.sleep(0.05) # let the waiter start blocking in the worker
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
# The worker acquires the now-free lock in the background; its done
# callback must release it and reclaim the entry.
async with serializer.hold_async("k"):
pass
await _wait_for_table_empty(serializer)
@pytest.mark.asyncio
async def test_cancelled_wait_releases_without_event_loop_callback(self):
serializer = AcquireSerializer()
holder_ready = threading.Event()
release_holder = threading.Event()
def hold_in_thread():
with serializer.hold("k"):
holder_ready.set()
release_holder.wait(2)
holder = threading.Thread(target=hold_in_thread)
holder.start()
assert holder_ready.wait(2)
waiter = asyncio.create_task(_hold_once(serializer, "k"))
await asyncio.sleep(0.05)
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
# Once cancellation has been observed, cleanup must be completed by
# the acquire worker itself. Blocking this loop keeps asyncio Future
# callbacks from hiding a lock leak here.
release_holder.set()
deadline = time.monotonic() + 2
while serializer._table and time.monotonic() < deadline:
time.sleep(0.01)
holder.join(timeout=2)
assert not holder.is_alive()
assert len(serializer._table) == 0
@pytest.mark.asyncio
async def test_cancellation_after_acquire_releases(self):
serializer = AcquireSerializer()
async def run():
async with serializer.hold_async("k"):
await asyncio.sleep(10)
task = asyncio.create_task(run())
await asyncio.sleep(0.05)
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
async with serializer.hold_async("k"): # must not hang
pass
assert len(serializer._table) == 0
@pytest.mark.asyncio
async def test_async_wait_keeps_loop_alive_and_off_default_executor(self):
serializer = AcquireSerializer()
loop = asyncio.get_running_loop()
class ExplodingExecutor(ThreadPoolExecutor):
def submit(self, *args, **kwargs):
raise AssertionError("serializer wait used the default executor")
previous = loop._default_executor
loop.set_default_executor(ExplodingExecutor())
try:
entered = asyncio.Event()
async def holder():
async with serializer.hold_async("k"):
entered.set()
await asyncio.sleep(0.2)
h = asyncio.create_task(holder())
await entered.wait()
w = asyncio.create_task(_hold_once(serializer, "k"))
for _ in range(5): # loop stays responsive while w waits
await asyncio.sleep(0.02)
assert not w.done()
await h
await asyncio.wait_for(w, 2)
finally:
if previous is not None: # fresh loops have no default executor yet
loop.set_default_executor(previous)
@pytest.mark.asyncio
async def test_concurrent_async_waiters_serialize(self):
serializer = AcquireSerializer()
active = 0
max_active = 0
async def worker():
nonlocal active, max_active
async with serializer.hold_async("k"):
active += 1
max_active = max(max_active, active)
await asyncio.sleep(0.01)
active -= 1
await asyncio.gather(*[worker() for _ in range(8)])
assert max_active == 1
@pytest.mark.asyncio
async def test_run_on_executor_preserves_request_trace_context(self):
"""run_on_executor must carry request ContextVars into the worker.
Regression test (#5089): raw ``loop.run_in_executor`` does not copy
contextvars, so without an explicit ``copy_context`` the worker thread
reads the trace id bound by ``request_trace_context()`` as unset.
"""
serializer = AcquireSerializer()
try:
with request_trace_context("trace-5089"):
seen = await serializer.run_on_executor(get_current_trace_id)
assert seen == "trace-5089"
finally:
serializer.close()
async def _hold_once(serializer, key):
async with serializer.hold_async(key):
pass
async def _wait_for_table_empty(serializer, timeout=2.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if len(serializer._table) == 0:
return
await asyncio.sleep(0.01)
raise AssertionError("serializer lock table did not drain")
class TestClose:
def test_close_rejects_new_holders_and_is_idempotent(self):
serializer = AcquireSerializer()
serializer.close()
serializer.close()
with pytest.raises(RuntimeError, match="closed"):
with serializer.hold("k"):
pass
@pytest.mark.asyncio
async def test_close_does_not_invalidate_active_critical_section(self):
serializer = AcquireSerializer()
async with serializer.hold_async("k"):
serializer.close() # must not raise, must not break exit
# New holders rejected afterwards:
with pytest.raises(RuntimeError, match="closed"):
async with serializer.hold_async("k"):
pass
@pytest.mark.asyncio
async def test_executor_shutdown_between_checkout_and_schedule_reclaims_entry(self):
serializer = AcquireSerializer()
serializer._executor.shutdown(wait=False, cancel_futures=True) # simulates close() landing mid-window
with pytest.raises(RuntimeError, match="closed"):
async with serializer.hold_async("k"):
pass
assert len(serializer._table) == 0
@pytest.mark.asyncio
async def test_close_reports_closed_to_queued_waiter(self):
"""A waiter whose queued acquire is cancelled by close() must see
RuntimeError("closed"), not a spurious CancelledError (#4741)."""
serializer = AcquireSerializer(max_workers=1)
blocker = threading.Event()
with serializer.hold("k"): # uncontended acquire, safe on the loop
# Occupy the single worker so the waiter's acquire future stays
# queued (pending) and is cancelled by close()'s cancel_futures.
occupied = asyncio.get_running_loop().run_in_executor(serializer.executor, blocker.wait, 5)
waiter = asyncio.create_task(_hold_once(serializer, "k"))
await asyncio.sleep(0.1) # let the waiter check out and queue its acquire
serializer.close()
with pytest.raises(RuntimeError, match="closed"):
await waiter
blocker.set()
await occupied
assert len(serializer._table) == 0
@pytest.mark.asyncio
async def test_close_reclaims_caller_cancelled_queued_waiter(self):
serializer = AcquireSerializer(max_workers=1)
blocker = threading.Event()
occupied = asyncio.get_running_loop().run_in_executor(serializer.executor, blocker.wait, 5)
waiter = asyncio.create_task(_hold_once(serializer, "k"))
await asyncio.sleep(0.05)
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
# The acquire was abandoned by its caller but has not started. close()
# must not strand its checkout when cancelling the executor queue.
serializer.close()
blocker.set()
await occupied
await _wait_for_table_empty(serializer)

View File

@ -14,6 +14,7 @@ import pytest
from deerflow.config.paths import Paths, join_host_path
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.runtime.user_context import reset_current_user, set_current_user
from deerflow.sandbox.acquire_serialization import AcquireSerializer
_LEGACY_COLLIDING_IDENTITIES = (
("user-9721", "thread-9721"),
@ -119,6 +120,7 @@ def _make_provider(tmp_path):
provider._acquire_epoch = {}
provider._acquire_epoch_counter = 0
provider._acquire_inflight = {}
provider._acquire_serializer = AcquireSerializer(thread_name_prefix="aio-sandbox-lock-wait")
provider._lock = MagicMock()
provider._idle_checker_stop = MagicMock()
provider._renewal_stop = MagicMock()
@ -347,7 +349,6 @@ async def test_acquire_async_uses_async_readiness_polling(monkeypatch):
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 = {}
@ -389,7 +390,6 @@ async def test_discover_or_create_with_lock_async_offloads_lock_file_open_and_cl
provider,
aio_mod.AioSandboxProvider,
)
provider._thread_locks = {}
provider._warm_pool = {}
provider._sandbox_infos = {}
provider._thread_sandboxes = {("default", "thread-async-lock"): "sandbox-async-lock"}
@ -416,21 +416,33 @@ async def test_discover_or_create_with_lock_async_offloads_lock_file_open_and_cl
@pytest.mark.anyio
async def test_acquire_thread_lock_async_uses_dedicated_executor(monkeypatch):
async def test_acquire_async_lock_wait_uses_dedicated_executor(tmp_path, 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()
provider = _make_provider(tmp_path)
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)
async def fake_acquire_internal_async(thread_id: str | None, *, user_id: str) -> str:
await asyncio.sleep(0)
return "sandbox-lock-wait"
monkeypatch.setattr(provider, "_acquire_internal_async", fake_acquire_internal_async)
thread_id = "thread-lock-wait"
hold = provider._acquire_serializer.hold(provider._thread_key(thread_id, "default"))
hold.__enter__()
try:
assert not lock.acquire(blocking=False)
waiter = asyncio.create_task(provider.acquire_async(thread_id, user_id="default"))
await asyncio.sleep(0.05)
assert not waiter.done()
finally:
lock.release()
hold.__exit__(None, None, None)
assert await asyncio.wait_for(waiter, timeout=1) == "sandbox-lock-wait"
@pytest.mark.anyio
@ -438,7 +450,6 @@ 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 = {}
@ -446,8 +457,9 @@ async def test_acquire_async_cancellation_does_not_leak_thread_lock(tmp_path):
provider._lock = aio_mod.threading.Lock()
thread_id = "thread-cancel-lock"
thread_lock = provider._get_thread_lock(thread_id, "default")
thread_lock.acquire()
key = provider._thread_key(thread_id, "default")
hold = provider._acquire_serializer.hold(key)
hold.__enter__()
task = asyncio.create_task(provider.acquire_async(thread_id, user_id="default"))
await asyncio.sleep(0.05)
@ -458,12 +470,10 @@ async def test_acquire_async_cancellation_does_not_leak_thread_lock(tmp_path):
except asyncio.CancelledError:
pass
thread_lock.release()
hold.__exit__(None, None, None)
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()
if key not in provider._acquire_serializer._table:
return
await asyncio.sleep(0.01)
@ -475,7 +485,6 @@ async def test_acquire_async_cancelled_waiter_does_not_block_successor(tmp_path,
"""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 = {}
@ -491,8 +500,9 @@ async def test_acquire_async_cancelled_waiter_does_not_block_successor(tmp_path,
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()
key = provider._thread_key(thread_id, "default")
hold = provider._acquire_serializer.hold(key)
hold.__enter__()
cancelled_waiter = asyncio.create_task(provider.acquire_async(thread_id, user_id="default"))
await asyncio.sleep(0.05)
@ -503,15 +513,13 @@ async def test_acquire_async_cancelled_waiter_does_not_block_successor(tmp_path,
pass
live_waiter = asyncio.create_task(provider.acquire_async(thread_id, user_id="default"))
thread_lock.release()
hold.__exit__(None, None, None)
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()
if key not in provider._acquire_serializer._table:
return
await asyncio.sleep(0.01)
@ -612,7 +620,6 @@ def test_create_sandbox_requests_runtime_when_lark_installed(tmp_path, monkeypat
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 = {}
@ -643,7 +650,6 @@ def test_create_sandbox_requests_broker_when_active(tmp_path, monkeypatch):
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 = {}
@ -674,7 +680,6 @@ def test_create_sandbox_skips_runtime_when_lark_absent(tmp_path, monkeypatch):
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 = {}
@ -765,6 +770,24 @@ def test_shutdown_closes_all_active_sandbox_clients(tmp_path):
assert provider._sandboxes == {}
@pytest.mark.asyncio
async def test_reset_closes_acquire_serializer_executor(tmp_path):
provider = _make_provider(tmp_path)
async with provider._acquire_serializer.hold_async(("alice", "thread-reset")):
pass
worker_threads = tuple(provider._acquire_serializer.executor._threads)
assert worker_threads
provider.reset()
for thread in worker_threads:
thread.join(timeout=2)
assert all(not thread.is_alive() for thread in worker_threads)
with pytest.raises(RuntimeError, match="closed"):
async with provider._acquire_serializer.hold_async(("alice", "thread-after-reset")):
pass
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")
@ -790,7 +813,6 @@ 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)
@ -822,7 +844,6 @@ def test_acquire_drops_dead_cached_sandbox(tmp_path, monkeypatch):
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"))
@ -863,7 +884,6 @@ def test_acquire_skips_dead_warm_pool_sandbox(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._thread_locks = {}
provider._sandboxes = {}
provider._sandbox_infos = {}
provider._thread_sandboxes = {}
@ -997,7 +1017,6 @@ def _make_tenant_isolation_provider(tmp_path, monkeypatch):
provider._sandboxes = {}
provider._sandbox_infos = {}
provider._thread_sandboxes = {}
provider._thread_locks = {}
provider._last_activity = {}
provider._warm_pool = {}
provider._active_sandbox_identity = {}
@ -1101,7 +1120,6 @@ def _make_unready_destroy_provider(tmp_path, *, sandbox_id, base_url, monkeypatc
provider = _make_provider(tmp_path)
provider._lock = aio_mod.threading.Lock()
provider._config = {"replicas": 3}
provider._thread_locks = {}
provider._warm_pool = {}
provider._sandbox_infos = {}
provider._thread_sandboxes = {}
@ -1305,3 +1323,10 @@ def test_reconcile_adopts_unready_container_when_no_teardown_is_in_flight(tmp_pa
provider._reconcile_orphans()
assert "adoptable" in provider._warm_pool, "reconcile must still adopt a genuinely unowned container"
def test_deterministic_sandbox_id_matches_shared_identity():
from deerflow.sandbox.identity import derive_sandbox_scope_token
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
assert aio_mod.AioSandboxProvider._deterministic_sandbox_id("t-1", "u-1") == derive_sandbox_scope_token(user_id="u-1", thread_id="t-1")

View File

@ -18,6 +18,7 @@ import pytest
from deerflow.community.boxlite.box import BoxliteBox
from deerflow.community.boxlite.provider import BoxliteProvider, _import_simplebox
from deerflow.trace_context import get_current_trace_id, request_trace_context
_LEGACY_COLLIDING_IDENTITIES = (
("user-9721", "thread-9721"),
@ -476,6 +477,48 @@ def test_acquire_reclaims_from_warm_pool(monkeypatch):
assert sid2 not in provider._warm_pool
@pytest.mark.asyncio
async def test_acquire_async_propagates_request_trace_context(monkeypatch):
"""acquire_async must carry request ContextVars into the worker thread.
Regression test (#5089): the dedicated-executor bridge replaced the
inherited ``to_thread`` acquire (which copies contextvars). Without an
explicit ``copy_context`` the worker thread reads the trace id bound by
``request_trace_context()`` as unset and logs ``trace_id=-``.
"""
monkeypatch.setattr(
"deerflow.community.boxlite.provider.get_app_config",
lambda: _stub_config(),
)
monkeypatch.setattr(
"deerflow.community.boxlite.provider._import_simplebox",
lambda: _FakeBox,
)
provider = BoxliteProvider()
provider._loop.run = _fake_run
seen: list[str | None] = []
original = BoxliteProvider._acquire_scope_locked
def spy(self, key, sandbox_id):
# Runs inside the executor worker thread, under the copied context.
seen.append(get_current_trace_id())
return original(self, key, sandbox_id)
monkeypatch.setattr(BoxliteProvider, "_acquire_scope_locked", spy)
try:
with request_trace_context("trace-boxlite-5089"):
sid = await provider.acquire_async("thread-1", user_id="u1")
assert sid
assert seen == ["trace-boxlite-5089"]
finally:
# _fake_run uses asyncio.run, which cannot run inside this test's event
# loop thread; shut down on a worker thread so box close() completes.
await asyncio.to_thread(provider.shutdown)
def test_explicit_recent_reclaim_skip_avoids_health_check(monkeypatch):
"""A configured skip window can reclaim recently released boxes without a ping."""
monkeypatch.setattr(
@ -873,7 +916,9 @@ def test_reset_parks_running_resources_for_later_cleanup(monkeypatch):
assert provider._warm_pool[sid_active][0] is active_box
assert provider._warm_pool[sid_warm][0] is warm_box
assert provider._thread_boxes == {}
assert provider._acquire_locks == {}
with pytest.raises(RuntimeError, match="closed"):
with provider._acquire_serializer.hold("k"):
pass
assert not active_box._closed
assert not warm_box._closed
assert not provider._shutdown_called
@ -1226,3 +1271,21 @@ def test_failed_health_check_does_not_remove_swapped_warm_entry(monkeypatch):
)
assert not replacement.is_closed
provider.shutdown()
def test_sandbox_id_matches_shared_identity():
from deerflow.sandbox.identity import derive_sandbox_scope_token
assert BoxliteProvider._sandbox_id("t-1", "u-1") == derive_sandbox_scope_token(user_id="u-1", thread_id="t-1")
def test_sandbox_id_none_user_quirk_pinned():
"""BoxLite passes user_id through raw; None renders as the literal "None".
Quirk pinned per RFC #4741 §2.2 (its _thread_key uses "" instead, so the
two disagree). NOT fixed here — unifying the resolution is a separate
behavior-changing decision with its own follow-up issue.
"""
from deerflow.sandbox.identity import derive_sandbox_scope_token
assert BoxliteProvider._sandbox_id("t-1", None) == derive_sandbox_scope_token(user_id="None", thread_id="t-1")

View File

@ -24,6 +24,7 @@ from deerflow.community.e2b_sandbox.capacity import (
)
from deerflow.config.paths import Paths
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.sandbox.acquire_serialization import AcquireSerializer
from deerflow.sandbox.exceptions import SandboxCapacityExceededError
# ──────────────────────────────────────────────────────────────────────────────
@ -250,7 +251,7 @@ def _make_provider(*, replicas: int = 3, idle_timeout: int = 1800, overflow_poli
provider._lock = threading.Lock()
provider._sandboxes = {}
provider._thread_sandboxes = {}
provider._thread_locks = {}
provider._acquire_serializer = AcquireSerializer(thread_name_prefix="e2b-sandbox-lock-wait")
provider._warm_pool = OrderedDict()
provider._eviction_tombstones = set()
provider._evictions_in_progress = set()
@ -4112,3 +4113,10 @@ def test_shutdown_during_discovery_does_not_kill_unowned_vm(monkeypatch):
assert client.closed
assert p._sandboxes == {}
assert p._reserved_slots == 0
def test_stable_seed_matches_shared_identity():
from deerflow.sandbox.identity import derive_sandbox_scope_token
mod = importlib.import_module("deerflow.community.e2b_sandbox.e2b_sandbox_provider")
assert mod.E2BSandboxProvider._stable_seed("t-1", "u-1") == derive_sandbox_scope_token(user_id="u-1", thread_id="t-1")

View File

@ -8,6 +8,7 @@ transport, search parsing, path guards, and terminal-session eviction.
from __future__ import annotations
import asyncio
import errno
import logging
import re
@ -711,3 +712,46 @@ def test_concurrent_same_scope_acquire_creates_once(monkeypatch: pytest.MonkeyPa
assert len(results) == 2 and results[0] == results[1]
assert len(sdk.create_calls) == 1
provider.shutdown()
@pytest.mark.asyncio
async def test_cancelled_acquire_async_serializes_retry_behind_abandoned_body(monkeypatch: pytest.MonkeyPatch) -> None:
"""A cancelled acquire_async abandons the body thread, not the lock.
Regression test (#4741): the serializer hold must follow the abandoned
body to completion, so a retry for the same scope serializes behind it
instead of overlapping it and creating a duplicate, untracked remote.
"""
provider, sdk = _install(monkeypatch)
started = threading.Event()
release = threading.Event()
original_create = sdk.create
def blocking_create(image: str, **kwargs: Any) -> _FakeRemote:
started.set()
assert release.wait(timeout=10)
return original_create(image, **kwargs)
sdk.create = blocking_create # type: ignore[method-assign]
first = asyncio.create_task(provider.acquire_async("thread", user_id="user"))
assert await asyncio.to_thread(started.wait, 10) # body is blocked inside create()
first.cancel()
with pytest.raises(asyncio.CancelledError):
await first
release.set() # abandoned body runs to completion and registers
second = await provider.acquire_async("thread", user_id="user")
expected_id = provider._sandbox_id("thread", "user")
assert len(sdk.create_calls) == 1 # no duplicate remote sandbox
assert second == expected_id
assert provider._thread_sandboxes[provider._thread_key("thread", "user")] == expected_id
provider.shutdown()
def test_sandbox_id_matches_shared_identity():
from deerflow.sandbox.identity import derive_sandbox_scope_token
assert OpenSandboxProvider._sandbox_id("t-1", "u-1") == derive_sandbox_scope_token(user_id="u-1", thread_id="t-1")
assert OpenSandboxProvider._sandbox_id("t-1", "") == derive_sandbox_scope_token(user_id="", thread_id="t-1")

View File

@ -0,0 +1,59 @@
"""Golden-vector tests for the shared sandbox scope token (RFC #4741).
The expected strings below are LITERALS computed from the five providers'
current inline expressions as of 2026-08-30. They pin the compatibility
contract byte-for-byte; never recompute them from the implementation.
"""
from deerflow.sandbox.identity import (
SANDBOX_ID_VERSION,
derive_sandbox_scope_token,
is_sandbox_scope_token,
)
class TestGoldenVectors:
def test_ascii(self):
assert derive_sandbox_scope_token(user_id="alice", thread_id="thread-1") == "dc977b840cb8638e"
def test_empty_user(self):
# Tenki/OpenSandbox resolution path: user_id or ""
assert derive_sandbox_scope_token(user_id="", thread_id="t") == "983743815e8fe2ac"
def test_empty_thread(self):
assert derive_sandbox_scope_token(user_id="u", thread_id="") == "27e14d2b41b03178"
def test_non_ascii(self):
assert derive_sandbox_scope_token(user_id="用户", thread_id="线程") == "cfa8a5bf41ddff8d"
def test_literal_none_string(self):
# BoxLite quirk: user_id=None renders as the literal "None" (RFC #4741
# §2.2; pinned, NOT fixed — unifying is a separate behavior change).
assert derive_sandbox_scope_token(user_id="None", thread_id="abc") == "c0b80df1b97d187e"
def test_version_constant(self):
assert SANDBOX_ID_VERSION == 1
def test_keyword_only(self):
import inspect
sig = inspect.signature(derive_sandbox_scope_token)
for param in sig.parameters.values():
assert param.kind is inspect.Parameter.KEYWORD_ONLY
class TestShapeValidation:
def test_accepts_token(self):
token = derive_sandbox_scope_token(user_id="alice", thread_id="thread-1")
assert is_sandbox_scope_token(token) is True
def test_rejects_wrong_shape(self):
assert is_sandbox_scope_token("") is False
assert is_sandbox_scope_token("dc977b840cb8638") is False # 15 chars
assert is_sandbox_scope_token("dc977b840cb8638ee") is False # 17 chars
assert is_sandbox_scope_token("DC977B840CB8638E") is False # uppercase
assert is_sandbox_scope_token("local:alice:thread-1") is False
def test_rejects_non_str(self):
assert is_sandbox_scope_token(None) is False
assert is_sandbox_scope_token(123) is False

View File

@ -421,6 +421,7 @@ def _make_provider_for_reconciliation(tmp_path=None, *, worker_id: str = "worker
``tmp_path`` is accepted and ignored: ownership no longer lives on disk.
"""
from deerflow.config.sandbox_config import SandboxOwnershipConfig
from deerflow.sandbox.acquire_serialization import AcquireSerializer
aio_mod = importlib.import_module("deerflow.community.aio_sandbox.aio_sandbox_provider")
provider = aio_mod.AioSandboxProvider.__new__(aio_mod.AioSandboxProvider)
@ -428,7 +429,7 @@ def _make_provider_for_reconciliation(tmp_path=None, *, worker_id: str = "worker
provider._sandboxes = {}
provider._sandbox_infos = {}
provider._thread_sandboxes = {}
provider._thread_locks = {}
provider._acquire_serializer = AcquireSerializer(thread_name_prefix="aio-sandbox-lock-wait")
provider._last_activity = {}
provider._warm_pool = {}
provider._active_sandbox_identity = {}

View File

@ -941,3 +941,10 @@ def test_integration_real_sandbox(monkeypatch):
assert box.read_file("/mnt/user-data/workspace/it.txt") == "tenki-e2e"
finally:
provider.shutdown()
def test_sandbox_id_matches_shared_identity():
from deerflow.sandbox.identity import derive_sandbox_scope_token
assert TenkiSandboxProvider._sandbox_id("t-1", "u-1") == derive_sandbox_scope_token(user_id="u-1", thread_id="t-1")
assert TenkiSandboxProvider._sandbox_id("t-1", "") == derive_sandbox_scope_token(user_id="", thread_id="t-1")