diff --git a/libs/code/deepagents_code/server_graph.py b/libs/code/deepagents_code/server_graph.py index 7fce782cdb..a6d80bfe3d 100644 --- a/libs/code/deepagents_code/server_graph.py +++ b/libs/code/deepagents_code/server_graph.py @@ -67,6 +67,32 @@ _server_tracing_initialized = False +def _close_sandbox(context: AbstractContextManager[Any]) -> None: + context.__exit__(None, None, None) + + +async def _open_sandbox( + create: Callable[[], AbstractContextManager[Any]], +) -> tuple[AbstractContextManager[Any], Any]: + def _enter() -> tuple[AbstractContextManager[Any], Any]: + context = create() + return context, context.__enter__() # noqa: PLC2801 + + task = asyncio.create_task(asyncio.to_thread(_enter)) + try: + return await asyncio.shield(task) + except asyncio.CancelledError: + try: + context, _ = await asyncio.shield(task) + except BaseException: # Preserve the caller's cancellation + logger.debug( + "Sandbox startup did not complete after cancellation", exc_info=True + ) + else: + await asyncio.to_thread(_close_sandbox, context) + raise + + def _configure_server_tracing(environ: Mapping[str, str], *, redact: bool) -> None: """Pin tracing for the server lifetime before any runtime can execute. @@ -442,24 +468,22 @@ def _resolve_project_context_and_settings() -> tuple[ # invocation. global _sandbox_cm, _sandbox_backend # noqa: PLW0603 sandbox_backend = None - if config.sandbox_type: + if sandbox_type := config.sandbox_type: from deepagents_code.integrations.sandbox_factory import create_sandbox try: - _sandbox_cm = create_sandbox( - config.sandbox_type, - sandbox_id=config.sandbox_id, - snapshot_name=config.sandbox_snapshot_name, - setup_script_path=config.sandbox_setup, + context, backend = await _open_sandbox( + lambda: create_sandbox( + sandbox_type, + sandbox_id=config.sandbox_id, + snapshot_name=config.sandbox_snapshot_name, + setup_script_path=config.sandbox_setup, + ) ) - _sandbox_backend = _sandbox_cm.__enter__() # noqa: PLC2801 # Context manager kept open for server process lifetime - sandbox_backend = _sandbox_backend - - def _cleanup_sandbox() -> None: - if _sandbox_cm is not None: - _sandbox_cm.__exit__(None, None, None) - - atexit.register(_cleanup_sandbox) + _sandbox_cm = context + _sandbox_backend = backend + sandbox_backend = backend + atexit.register(_close_sandbox, context) except ImportError: logger.exception( "Sandbox provider '%s' is not installed", config.sandbox_type diff --git a/libs/code/tests/unit_tests/test_server_graph.py b/libs/code/tests/unit_tests/test_server_graph.py index 0701fdfa02..4e175e9404 100644 --- a/libs/code/tests/unit_tests/test_server_graph.py +++ b/libs/code/tests/unit_tests/test_server_graph.py @@ -2,20 +2,24 @@ from __future__ import annotations +import asyncio import dataclasses import importlib import os import subprocess import sys +import threading +import time from types import ModuleType, SimpleNamespace from typing import TYPE_CHECKING, Any from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -from blockbuster import blockbuster_ctx +from blockbuster import BlockBuster, blockbuster_ctx from deepagents_code._env_vars import SERVER_ENV_PREFIX from deepagents_code._server_config import ServerConfig +from deepagents_code.integrations import sandbox_factory if TYPE_CHECKING: from pathlib import Path @@ -434,6 +438,159 @@ def create_cli_agent_side_effect(**kwargs: object) -> tuple[object, object]: "auto_classifier_model": "openai:gpt-5.6-luna", } + async def test_sandbox_creation_does_not_trip_blockbuster_guard( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + """Sandbox creation must not run sync blocking I/O on the event loop. + + `langgraph dev` arms the blockbuster guard + (`langgraph_runtime_inmem/queue.py` -> `_enable_blockbuster`), which + raises `BlockingError` when a patched blocking call runs on the + asyncio loop. `_make_graphs` creates the sandbox synchronously, so + `dcode --sandbox ` fails the server readiness check with + "Blocking call to socket.socket.connect" (reproduced on langsmith, + agentcore, and daytona). This test pins the desired behavior: the + provider's sync `get_or_create` must not run directly on the loop. + """ + graph_obj = object() + model_obj = object() + + def create_cli_agent_side_effect(**_kwargs: object) -> tuple[object, object]: + return graph_obj, _backend_with_offload(object()) + + # The sync sandbox SDKs (langsmith `SandboxClient`, daytona, ...) do + # real blocking I/O such as socket connects. Model that with a sleep: + # blockbuster flags it identically on the event loop, and it stays + # deterministic under pytest-socket's `--disable-socket`. + def blocking_get_or_create(**_kwargs: object) -> object: + time.sleep(0.001) + return MagicMock() + + provider = MagicMock() + provider.get_or_create.side_effect = blocking_get_or_create + registry = MagicMock() + registry.get_metadata.return_value = None + registry.get_params.return_value = {} + + settings_obj = SimpleNamespace(has_tavily=False, tavily_api_key=None) + environment = dict(os.environ) + config_module = _module_with_attrs( + "deepagents_code.config", + Credentials=SimpleNamespace( + snapshot_from_environment=MagicMock(return_value=settings_obj) + ), + _ensure_bootstrap=MagicMock(), + _preview_dotenv_environ=MagicMock(return_value=environment), + active_environment=MagicMock(return_value=environment), + use_environment=__import__("contextlib").nullcontext, + _tracing_environment_values=MagicMock(return_value={}), + is_langsmith_redaction_enabled=MagicMock(return_value=True), + configure_langsmith_secret_redaction=MagicMock(), + reconcile_tracing_environment=MagicMock(), + create_model=MagicMock( + return_value=SimpleNamespace( + model=model_obj, + provider="openai", + apply_to_runtime_state=MagicMock(), + model_retries=5, + cli_max_retries=None, + ), + ), + is_memory_auto_save_enabled=MagicMock(return_value=False), + resolve_auto_classifier_model_for_provider=MagicMock(return_value=None), + credentials=settings_obj, + ) + agent_module = _module_with_attrs( + "deepagents_code.agent", + create_cli_agent=MagicMock(side_effect=create_cli_agent_side_effect), + load_async_subagents=MagicMock(return_value=None), + ) + tools_module = _module_with_attrs( + "deepagents_code.tools", + create_web_search_tool=Mock(), + fetch_url=object(), + get_current_thread_id=object(), + web_search=object(), + ) + config = ServerConfig(no_mcp=True, sandbox_type="langsmith") + env_overrides = { + f"{SERVER_ENV_PREFIX}{suffix}": value + for suffix, value in config.to_env().items() + if value is not None + } + + with ( + patch.dict(os.environ, env_overrides, clear=False), + patch.dict( + sys.modules, + { + "deepagents_code.agent": agent_module, + "deepagents_code.config": config_module, + "deepagents_code.tools": tools_module, + }, + ), + patch( + "deepagents_code.project_utils.get_server_project_context", + return_value=None, + ), + patch.object( + sandbox_factory, + "_get_provider", + return_value=provider, + ), + patch.object( + sandbox_factory, + "_get_registry", + return_value=registry, + ), + ): + module = _import_fresh_server_graph() + bb = BlockBuster() + bb.activate() + try: + try: + result = await module.make_graph() + except SystemExit as exc: + captured = capsys.readouterr() + pytest.fail( + "sandbox creation tripped the blockbuster " + f"blocking-I/O guard: SystemExit({exc.code}) -- " + f"startup error: {captured.err}" + ) + finally: + # Explicit activate/deactivate rather than `blockbuster_ctx`: + # blockbuster <1.5.27 lacks the try/finally in that helper, so + # the guard leaks into later tests when the body raises. + bb.deactivate() + + assert result is graph_obj + + async def test_cancelled_sandbox_creation_cleans_up_after_entry(self) -> None: + module = _import_fresh_server_graph() + entered = threading.Event() + release = threading.Event() + closed = threading.Event() + backend = object() + + class Context: + def __enter__(self) -> object: + entered.set() + release.wait() + return backend + + def __exit__(self, *_args: object) -> None: + closed.set() + + task = asyncio.create_task(module._open_sandbox(Context)) + await asyncio.to_thread(entered.wait) + task.cancel() + release.set() + + with pytest.raises(asyncio.CancelledError): + await task + + assert closed.is_set() + class TestWorkspaceEnvironmentBinding: """The workspace snapshot must actually be bound around construction.