luo jiyin 3b77a7401b
fix(sandbox): enforce E2B replica capacity limits (#4391)
* fix(sandbox): enforce E2B replica capacity limits (in-process)

Add SandboxCapacityExceededError with diagnostic fields.  Add
overflow_policy (wait/reject/burst), acquire_timeout, and burst_limit
config options.

Implement atomic capacity reservation with a four-slot model:
reserved / active / warm / transitioning.  Transitioning slots close
the window where active-to-warm or warm-to-active transitions appear
to have zero occupied slots, which would let concurrent acquires
exceed the configured replica ceiling.

Re-route release, reclaim, and evict through transitioning counters.
Add shutdown guard: reject waiters, kill VMs created during shutdown.

Add 14 tests: policy enforcement, release+acquire race, warm-reclaim
race, shutdown-waiter interaction, shutdown-during-create, and
concurrent different-thread capacity assertion.

Related: #4339

* fix: harden e2b sandbox capacity lifecycle

* fix: retain e2b capacity during uncertain eviction

* fix: serialize e2b tombstone eviction

* fix: retain capacity after uncertain e2b cleanup

* fix: track e2b remote operations during shutdown

* fix(sandbox): validate E2B capacity config

* fix(sandbox): classify capacity errors

* fix(sandbox): harden E2B capacity lifecycle

* test(sandbox): cover E2B review findings

* docs(changelog): note E2B capacity behavior

* docs(readme): explain E2B overflow handling

* docs(backend): record E2B lifecycle rules

* docs(sandbox): clarify destructive E2B reset

* fix(sandbox): close E2B capacity race gaps

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-07-25 10:54:14 +08:00

176 lines
6.8 KiB
Python

import asyncio
import threading
from abc import ABC, abstractmethod
from deerflow.config import get_app_config
from deerflow.reflection import resolve_class
from deerflow.sandbox.sandbox import Sandbox
class SandboxProvider(ABC):
"""Abstract base class for sandbox providers"""
uses_thread_data_mounts: bool = False
needs_upload_permission_adjustment: bool = True
@abstractmethod
def acquire(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
"""Acquire a sandbox environment and return its ID.
Returns:
The ID of the acquired sandbox environment.
"""
pass
async def acquire_async(self, thread_id: str | None = None, *, user_id: str | None = None) -> str:
"""Acquire a sandbox without blocking the event loop.
Most sandbox providers expose a synchronous lifecycle API because local
Docker/provisioner operations are blocking. Async runtimes should call
this method so those blocking operations run in a worker thread instead
of stalling the event loop.
"""
return await asyncio.to_thread(self.acquire, thread_id, user_id=user_id)
@abstractmethod
def get(self, sandbox_id: str) -> Sandbox | None:
"""Get a sandbox environment by ID.
Args:
sandbox_id: The ID of the sandbox environment to retain.
"""
pass
@abstractmethod
def release(self, sandbox_id: str) -> None:
"""Release a sandbox environment.
Args:
sandbox_id: The ID of the sandbox environment to destroy.
"""
pass
def reset(self) -> None:
"""Clear cached state that survives provider instance replacement.
Provider overrides can release resources and make the instance unusable.
"""
pass
_default_sandbox_provider: SandboxProvider | None = None
# Guards every read and write of `_default_sandbox_provider`. The singleton is
# reachable from more than one OS thread (e.g. the main event loop and the Feishu
# channel thread, which runs its own loop), so a bare check-then-create can double
# initialize the provider, and an unsynchronized reset/shutdown racing a get can
# hand a caller `None` or a torn instance. Every access to the global below takes
# this lock, including the read+return in `get_sandbox_provider()`.
#
# The lock guards only the reference swap. Provider callbacks (`__init__`,
# `reset()`, `shutdown()`) and the dynamic import in `resolve_class()` run
# *outside* the lock: they are plugin-supplied (`config.sandbox.use` resolves to
# an arbitrary class) and may be slow or, worse, re-enter these lifecycle
# functions. Holding a non-reentrant `threading.Lock` across them would
# self-deadlock such a provider and would block every concurrent `get()` during a
# slow teardown. Keeping callbacks off the lock avoids both.
_provider_lock = threading.Lock()
def get_sandbox_provider(**kwargs) -> SandboxProvider:
"""Get the sandbox provider singleton.
Returns a cached singleton instance. Use `reset_sandbox_provider()` to clear
the cache, or `shutdown_sandbox_provider()` to properly shutdown and clear.
Returns:
A sandbox provider instance.
"""
global _default_sandbox_provider
# Fast path: a single locked read so a concurrent reset/shutdown can't null
# the global between the check and the return.
with _provider_lock:
if _default_sandbox_provider is not None:
return _default_sandbox_provider
# Cold start. Resolve + construct outside the lock: the import and the
# provider constructor are plugin code and must not run under a non-reentrant
# lock. The construction may race another caller; we reconcile under the lock.
config = get_app_config()
cls = resolve_class(config.sandbox.use, SandboxProvider)
provider = cls(**kwargs)
with _provider_lock:
if _default_sandbox_provider is None:
_default_sandbox_provider = provider
return provider
# We lost the install race: another thread got there first. `winner` is
# read under the same lock, so it is always a live instance, never None.
winner = _default_sandbox_provider
# Discard the instance we just built (outside the lock). For providers with
# side-effectful constructors (e.g. AioSandboxProvider starts an idle-checker
# thread), this tears down the orphan so it does not leak — issue #3721.
if hasattr(provider, "shutdown"):
provider.shutdown()
return winner
def reset_sandbox_provider() -> None:
"""Reset the sandbox provider singleton.
This clears the cached instance without calling shutdown directly.
The next call to `get_sandbox_provider()` will create a new instance.
Useful for testing or when switching configurations.
Providers can override `reset()` to clear any module-level state they keep
alive across instances (for example, `LocalSandboxProvider`'s cached
`LocalSandbox` singleton). Without it, config/mount changes would not take
effect on the next acquire().
A provider override can release active sandboxes during reset.
Otherwise, active sandboxes become orphaned.
Do not reuse the detached provider after reset.
Use `shutdown_sandbox_provider()` for proper cleanup.
"""
global _default_sandbox_provider
# Detach the reference under the lock, then run the provider's `reset()`
# callback outside it (see the `_provider_lock` note).
with _provider_lock:
provider = _default_sandbox_provider
_default_sandbox_provider = None
if provider is not None:
provider.reset()
def shutdown_sandbox_provider() -> None:
"""Shutdown and reset the sandbox provider.
This properly shuts down the provider (releasing all sandboxes)
before clearing the singleton. Call this when the application
is shutting down or when you need to completely reset the sandbox system.
"""
global _default_sandbox_provider
# Detach the reference under the lock, then run the (potentially slow)
# `shutdown()` callback outside it (see the `_provider_lock` note).
with _provider_lock:
provider = _default_sandbox_provider
_default_sandbox_provider = None
if provider is not None and hasattr(provider, "shutdown"):
provider.shutdown()
def set_sandbox_provider(provider: SandboxProvider) -> None:
"""Set a custom sandbox provider instance.
This allows injecting a custom or mock provider for testing purposes.
Note: any previously installed provider is replaced but not shut down; the
caller owns the lifecycle of the instance it is overwriting.
Args:
provider: The SandboxProvider instance to use.
"""
global _default_sandbox_provider
with _provider_lock:
_default_sandbox_provider = provider