deer-flow/backend/tests/blocking_io/test_opensandbox_acquire.py
Zeren Wang bb75f8d736
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.
2026-08-30 10:30:34 +08:00

57 lines
2.4 KiB
Python

"""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()