deer-flow/backend/tests/test_aio_sandbox.py
Aari 9e0fbd60fa
fix(sandbox): isolate concurrent subagent shell sessions (#5134)
* fix(sandbox): isolate concurrent subagent shell sessions

* fix(sandbox): make execution acquire idempotent

* fix(sandbox): close execution lifecycle gaps

* fix(sandbox): serialize retained client lifecycle

* fix(sandbox): close remaining client lifecycle gaps

* fix(sandbox): unwind failed client lookup

* fix(sandbox): protect internal lease identities

* fix(sandbox): make cancellation reconciliation durable

* fix(sandbox): fence cancelled workers and IM uploads
2026-09-02 21:05:23 +08:00

875 lines
35 KiB
Python

"""Tests for AioSandbox concurrent command serialization (#1433)."""
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
class _TeardownFirstScopeLock:
"""Let teardown clean a scope before one already-admitted waiter runs."""
def __init__(self) -> None:
self._lock = threading.Lock()
self.command_waiting = threading.Event()
self.allow_command = threading.Event()
self.command_done = threading.Event()
def __enter__(self):
if threading.current_thread().name == "queued-command":
self.command_waiting.set()
self.allow_command.wait(timeout=2)
self._lock.acquire()
return self
def __exit__(self, exc_type, exc, tb) -> None:
self._lock.release()
if threading.current_thread().name == "scope-teardown":
self.allow_command.set()
self.command_done.wait(timeout=2)
def test_local_sandbox_client_bypasses_environment_proxy():
"""Local sandbox API calls must not inherit HTTP_PROXY (#3441)."""
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
sentinel_httpx = MagicMock()
with (
patch("deerflow.community.aio_sandbox.aio_sandbox.httpx.Client", return_value=sentinel_httpx) as client_cls,
patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient") as sdk_cls,
):
AioSandbox(id="test-sandbox", base_url="http://host.docker.internal:8080")
client_cls.assert_called_once_with(timeout=600, follow_redirects=True, trust_env=False)
sdk_cls.assert_called_once_with(
base_url="http://host.docker.internal:8080",
timeout=600,
httpx_client=sentinel_httpx,
)
@pytest.mark.parametrize(
"base_url",
[
"https://sandbox.example.com",
"http://8.8.8.8:8080",
"http://[2606:4700:4700::1111]:8080",
],
)
def test_external_sandbox_client_keeps_environment_proxy_support(base_url: str):
"""Externally hosted sandbox URLs retain the SDK's default proxy behavior."""
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
with (
patch("deerflow.community.aio_sandbox.aio_sandbox.httpx.Client") as client_cls,
patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient") as sdk_cls,
):
AioSandbox(id="test-sandbox", base_url=base_url)
client_cls.assert_not_called()
sdk_cls.assert_called_once_with(base_url=base_url, timeout=600)
@pytest.fixture()
def sandbox():
"""Create an AioSandbox with a mocked client."""
with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"):
from deerflow.community.aio_sandbox.aio_sandbox import AioSandbox
sb = AioSandbox(id="test-sandbox", base_url="http://localhost:8080")
return sb
def test_exec_command_appends_exit_marker_when_failure_has_output(sandbox):
"""The legacy exec path must propagate the structured exit_code into the
output text (LocalSandbox parity) instead of discarding it."""
sandbox._client.shell.exec_command = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="5 passed, 1 error\n", exit_code=1)))
assert sandbox.execute_command("make test") == "5 passed, 1 error\n\nExit Code: 1"
def test_bash_exec_appends_exit_marker_when_failure_has_output(sandbox):
"""The bash.exec (env-bearing) path must propagate exit_code the same way."""
sandbox._client.bash.exec = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(stdout="5 passed, 1 error\n", stderr="", exit_code=1)))
assert sandbox.execute_command("make test", env={"A": "1"}) == "5 passed, 1 error\n\nExit Code: 1"
class TestExecuteCommandSerialization:
"""Verify that concurrent exec_command calls are serialized."""
def test_lock_prevents_concurrent_execution(self, sandbox):
"""Concurrent threads should not overlap inside execute_command."""
call_log = []
barrier = threading.Barrier(3)
def slow_exec(command, **kwargs):
call_log.append(("enter", command))
import time
time.sleep(0.05)
call_log.append(("exit", command))
return SimpleNamespace(data=SimpleNamespace(output=f"ok: {command}"))
sandbox._client.shell.exec_command = slow_exec
def worker(cmd):
barrier.wait() # ensure all threads contend for the lock simultaneously
sandbox.execute_command(cmd)
threads = []
for i in range(3):
t = threading.Thread(target=worker, args=(f"cmd-{i}",))
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
# Verify serialization: each "enter" should be followed by its own
# "exit" before the next "enter" (no interleaving).
enters = [i for i, (action, _) in enumerate(call_log) if action == "enter"]
exits = [i for i, (action, _) in enumerate(call_log) if action == "exit"]
assert len(enters) == 3
assert len(exits) == 3
for e_idx, x_idx in zip(enters, exits):
assert x_idx == e_idx + 1, f"Interleaved execution detected: {call_log}"
class TestErrorObservationRetry:
"""Verify ErrorObservation detection and fresh-session retry."""
def test_retry_on_error_observation(self, sandbox):
"""When output contains ErrorObservation, retry with a fresh session."""
call_count = 0
def mock_exec(command, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return SimpleNamespace(data=SimpleNamespace(output="'ErrorObservation' object has no attribute 'exit_code'"))
return SimpleNamespace(data=SimpleNamespace(output="success"))
sandbox._client.shell.exec_command = mock_exec
result = sandbox.execute_command("echo hello")
assert result == "success"
assert call_count == 2
def test_retry_creates_fresh_session_before_targeting_it(self, sandbox):
"""Recovery must explicitly create a session, then exec against that id.
The sandbox image only auto-creates a session when exec_command is
called with *no* id; an exec carrying an unknown id returns HTTP 404
"Session not found". So the retry must obtain a real, distinct session
via create_session() first and target that id, rather than fabricating
an id and handing it straight to exec_command (the regression that
404'd every recovery and looped runs to the recursion limit).
"""
exec_calls = []
created_ids = []
cleaned_ids = []
def mock_exec(command, **kwargs):
exec_calls.append(kwargs)
if len(exec_calls) == 1:
return SimpleNamespace(data=SimpleNamespace(output="'ErrorObservation' object has no attribute 'exit_code'"))
return SimpleNamespace(data=SimpleNamespace(output="ok"))
def mock_create_session(id, **kwargs):
created_ids.append(id)
return SimpleNamespace(data=SimpleNamespace(session_id=id))
def mock_cleanup_session(session_id, **kwargs):
cleaned_ids.append(session_id)
sandbox._client.shell.exec_command = mock_exec
sandbox._client.shell.create_session = mock_create_session
sandbox._client.shell.cleanup_session = mock_cleanup_session
result = sandbox.execute_command("test")
assert result == "ok"
assert len(exec_calls) == 2
# First attempt runs on the default session (no id).
assert "id" not in exec_calls[0]
# A fresh session was explicitly created...
assert len(created_ids) == 1
assert len(created_ids[0]) == 36 # UUID format
# ...and the retry targets exactly that created session, never an
# uncreated/fabricated id (which would 404).
assert exec_calls[1].get("id") == created_ids[0]
# The recovered session is promoted: future commands must not return
# to the corrupted implicit default session.
assert cleaned_ids == []
assert sandbox.execute_command("again") == "ok"
assert exec_calls[-1].get("id") == created_ids[0]
sandbox.close()
assert cleaned_ids == [created_ids[0]]
def test_cleanup_failure_does_not_mask_successful_retry(self, sandbox):
"""A failure releasing the recovery session must not lose the retry output."""
def mock_exec(command, **kwargs):
if "id" not in kwargs:
return SimpleNamespace(data=SimpleNamespace(output="'ErrorObservation' object has no attribute 'exit_code'"))
return SimpleNamespace(data=SimpleNamespace(output="recovered"))
def mock_cleanup_session(session_id, **kwargs):
raise RuntimeError("cleanup boom")
sandbox._client.shell.exec_command = mock_exec
sandbox._client.shell.create_session = lambda id, **kwargs: SimpleNamespace(data=SimpleNamespace(session_id=id))
sandbox._client.shell.cleanup_session = mock_cleanup_session
# The retry succeeded; the swallowed cleanup error must not turn this
# into an "Error: ..." result.
assert sandbox.execute_command("test") == "recovered"
def test_failed_replacement_never_falls_back_to_corrupt_default(self, sandbox):
created_ids: list[str] = []
exec_ids: list[str | None] = []
def mock_create_session(id, **kwargs):
created_ids.append(id)
return SimpleNamespace(data=SimpleNamespace(session_id=id))
def mock_exec(command, **kwargs):
session_id = kwargs.get("id")
exec_ids.append(session_id)
if len(exec_ids) <= 2:
return SimpleNamespace(data=SimpleNamespace(output="'ErrorObservation' object has no attribute 'exit_code'"))
return SimpleNamespace(data=SimpleNamespace(output="healthy"))
sandbox._client.shell.create_session = mock_create_session
sandbox._client.shell.exec_command = mock_exec
assert "ErrorObservation" in sandbox.execute_command("first")
assert sandbox.execute_command("second") == "healthy"
assert exec_ids[0] is None
assert exec_ids[1] == created_ids[0]
# The next call creates another explicit session instead of touching
# the already-proven-corrupt implicit default again.
assert exec_ids[2] == created_ids[1]
assert all(session_id is not None for session_id in exec_ids[1:])
def test_no_retry_on_clean_output(self, sandbox):
"""Normal output should not trigger a retry."""
call_count = 0
def mock_exec(command, **kwargs):
nonlocal call_count
call_count += 1
return SimpleNamespace(data=SimpleNamespace(output="all good"))
sandbox._client.shell.exec_command = mock_exec
result = sandbox.execute_command("echo hello")
assert result == "all good"
assert call_count == 1
class TestScopedShellSessions:
"""Concurrent subagents use independent persistent shell sessions (#5128)."""
def test_different_scopes_execute_concurrently(self, sandbox):
active = 0
max_active = 0
active_lock = threading.Lock()
start_barrier = threading.Barrier(2)
session_ids: list[str] = []
def create_session(id, **kwargs):
session_ids.append(id)
return SimpleNamespace(data=SimpleNamespace(session_id=id))
def overlapping_exec(command, **kwargs):
nonlocal active, max_active
with active_lock:
active += 1
max_active = max(max_active, active)
start_barrier.wait(timeout=1)
with active_lock:
active -= 1
return SimpleNamespace(data=SimpleNamespace(output=command, exit_code=0))
sandbox._client.shell.create_session = create_session
sandbox._client.shell.exec_command = overlapping_exec
outputs: list[str] = []
def worker(scope_id: str):
outputs.append(
sandbox.execute_command_in_scope(
scope_id,
scope_id=scope_id,
)
)
threads = [
threading.Thread(target=worker, args=("subagent-a",)),
threading.Thread(target=worker, args=("subagent-b",)),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert sorted(outputs) == ["subagent-a", "subagent-b"]
assert max_active == 2
assert len(set(session_ids)) == 2
def test_same_scope_remains_serialized(self, sandbox):
call_log: list[tuple[str, str]] = []
start_barrier = threading.Barrier(3)
sandbox._client.shell.create_session = lambda id, **kwargs: SimpleNamespace(data=SimpleNamespace(session_id=id))
def slow_exec(command, **kwargs):
call_log.append(("enter", command))
import time
time.sleep(0.03)
call_log.append(("exit", command))
return SimpleNamespace(data=SimpleNamespace(output=command, exit_code=0))
sandbox._client.shell.exec_command = slow_exec
def worker(command: str):
start_barrier.wait()
sandbox.execute_command_in_scope(command, scope_id="one-subagent")
threads = [threading.Thread(target=worker, args=(f"cmd-{index}",)) for index in range(3)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
for index in range(0, len(call_log), 2):
assert call_log[index][0] == "enter"
assert call_log[index + 1] == ("exit", call_log[index][1])
def test_corrupt_scoped_session_is_replaced_and_reused(self, sandbox):
created_ids: list[str] = []
cleaned_ids: list[str] = []
exec_ids: list[str] = []
def create_session(id, **kwargs):
created_ids.append(id)
return SimpleNamespace(data=SimpleNamespace(session_id=id))
def exec_command(command, **kwargs):
exec_ids.append(kwargs["id"])
if len(exec_ids) == 1:
return SimpleNamespace(
data=SimpleNamespace(
output="'ErrorObservation' object has no attribute 'exit_code'",
exit_code=None,
)
)
return SimpleNamespace(data=SimpleNamespace(output="ok", exit_code=0))
sandbox._client.shell.create_session = create_session
sandbox._client.shell.exec_command = exec_command
sandbox._client.shell.cleanup_session = lambda session_id, **kwargs: cleaned_ids.append(session_id)
assert sandbox.execute_command_in_scope("first", scope_id="subagent-a") == "ok"
assert len(created_ids) == 2
assert cleaned_ids == [created_ids[0]]
assert exec_ids == [created_ids[0], created_ids[1]]
assert sandbox.execute_command_in_scope("second", scope_id="subagent-a") == "ok"
assert exec_ids[-1] == created_ids[1]
sandbox.release_command_scope("subagent-a")
assert cleaned_ids == created_ids
def test_queued_command_cannot_restart_session_after_scope_release(self, sandbox):
created_ids: list[str] = []
executed_commands: list[str] = []
cleaned_ids: list[str] = []
sandbox._client.shell.create_session = lambda id, **kwargs: created_ids.append(id)
sandbox._client.shell.exec_command = lambda command, **kwargs: executed_commands.append(command) or SimpleNamespace(data=SimpleNamespace(output="ok", exit_code=0))
sandbox._client.shell.cleanup_session = lambda session_id, **kwargs: cleaned_ids.append(session_id)
assert sandbox.execute_command_in_scope("initial", scope_id="subagent-a") == "ok"
scoped = sandbox._scoped_shell_sessions["subagent-a"]
controlled_lock = _TeardownFirstScopeLock()
scoped.lock = controlled_lock
queued_results: list[str] = []
def queued_command() -> None:
try:
queued_results.append(sandbox.execute_command_in_scope("late", scope_id="subagent-a"))
finally:
controlled_lock.command_done.set()
command_thread = threading.Thread(target=queued_command, name="queued-command")
command_thread.start()
assert controlled_lock.command_waiting.wait(timeout=1)
teardown_thread = threading.Thread(
target=sandbox.release_command_scope,
args=("subagent-a",),
name="scope-teardown",
)
teardown_thread.start()
command_thread.join(timeout=2)
teardown_thread.join(timeout=2)
assert not command_thread.is_alive()
assert not teardown_thread.is_alive()
assert queued_results == ["Error: sandbox command scope is no longer active"]
assert len(created_ids) == 1
assert executed_commands == ["initial"]
assert cleaned_ids == created_ids
def test_queued_command_cannot_restart_session_while_sandbox_closes(self, sandbox):
created_ids: list[str] = []
executed_commands: list[str] = []
cleaned_ids: list[str] = []
sandbox._client.shell.create_session = lambda id, **kwargs: created_ids.append(id)
sandbox._client.shell.exec_command = lambda command, **kwargs: executed_commands.append(command) or SimpleNamespace(data=SimpleNamespace(output="ok", exit_code=0))
sandbox._client.shell.cleanup_session = lambda session_id, **kwargs: cleaned_ids.append(session_id)
assert sandbox.execute_command_in_scope("initial", scope_id="subagent-a") == "ok"
scoped = sandbox._scoped_shell_sessions["subagent-a"]
controlled_lock = _TeardownFirstScopeLock()
scoped.lock = controlled_lock
queued_results: list[str] = []
def queued_command() -> None:
try:
queued_results.append(sandbox.execute_command_in_scope("late", scope_id="subagent-a"))
finally:
controlled_lock.command_done.set()
command_thread = threading.Thread(target=queued_command, name="queued-command")
command_thread.start()
assert controlled_lock.command_waiting.wait(timeout=1)
teardown_thread = threading.Thread(target=sandbox.close, name="scope-teardown")
teardown_thread.start()
command_thread.join(timeout=2)
teardown_thread.join(timeout=2)
assert not command_thread.is_alive()
assert not teardown_thread.is_alive()
assert queued_results == ["Error: sandbox command scope is no longer active"]
assert len(created_ids) == 1
assert executed_commands == ["initial"]
assert cleaned_ids == created_ids
def test_env_command_keeps_fresh_bash_exec_semantics(self, sandbox):
sandbox._client.bash.exec = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(stdout="ok", stderr="", exit_code=0)))
assert (
sandbox.execute_command_in_scope(
"echo $TOKEN",
env={"TOKEN": "secret"},
scope_id="subagent-a",
)
== "ok"
)
sandbox._client.bash.exec.assert_called_once()
sandbox._client.shell.create_session.assert_not_called()
assert sandbox._scoped_shell_sessions == {}
def test_closed_sandbox_rejects_new_scope_without_leaking_session(self, sandbox):
client = sandbox._client
sandbox.close()
assert sandbox.execute_command_in_scope("echo late", scope_id="subagent-late") == "Error: sandbox client is closed"
assert sandbox._scoped_shell_sessions == {}
client.shell.create_session.assert_not_called()
class TestBashExecUnsupportedFailFast:
"""Regression tests for #3921: sandbox images older than all-in-one-sandbox
1.9.x have no ``/v1/bash/exec`` route, so every env-bearing command (skills
declaring ``required-secrets``) hit a bare nginx 404 that the model kept
retrying. The sandbox must fail fast with an actionable, operator-facing
error instead."""
def _api_error_404(self):
from agent_sandbox.core.api_error import ApiError
return ApiError(
headers={"server": "nginx/1.18.0 (Ubuntu)"},
status_code=404,
body={"success": False, "message": "Not Found", "data": None},
)
def test_bash_exec_404_returns_actionable_error(self, sandbox):
"""A 404 from bash.exec must explain the image capability gap and the
remediation (upgrade image), not surface the raw nginx error."""
sandbox._client.bash.exec = MagicMock(side_effect=self._api_error_404())
out = sandbox.execute_command("echo $TOK", env={"TOK": "secret-v"})
assert out.startswith("Error:")
# Actionable: names the missing capability and the minimum image version.
assert "/v1/bash/exec" in out
assert "1.9.3" in out
assert "required-secrets" in out
# Not the raw upstream 404 body the model can't act on.
assert "nginx" not in out
def test_bash_exec_404_is_cached_and_stops_retry_storm(self, sandbox):
"""After one 404 the capability gap is remembered on the instance:
follow-up env-bearing calls return the same actionable error without
another HTTP round-trip (the original bug produced 4 consecutive 404s
as the model retried variants of the command)."""
sandbox._client.bash.exec = MagicMock(side_effect=self._api_error_404())
first = sandbox.execute_command("cmd-1", env={"TOK": "v"})
second = sandbox.execute_command("cmd-2", env={"TOK": "v"})
assert sandbox._client.bash.exec.call_count == 1
assert first == second
assert "1.9.3" in second
def test_bash_exec_non_404_error_is_not_cached(self, sandbox):
"""Transient failures (e.g. 500) must not permanently disable the env
path — the next env-bearing call should try bash.exec again."""
from agent_sandbox.core.api_error import ApiError
sandbox._client.bash.exec = MagicMock(side_effect=ApiError(status_code=500, body="boom"))
first = sandbox.execute_command("cmd-1", env={"TOK": "v"})
second = sandbox.execute_command("cmd-2", env={"TOK": "v"})
assert sandbox._client.bash.exec.call_count == 2
assert first.startswith("Error:")
assert "1.9.3" not in first
assert second.startswith("Error:")
def test_env_less_path_unaffected_after_404(self, sandbox):
"""The legacy persistent-shell path must keep working on an image
without bash.exec — only env injection is unavailable there."""
sandbox._client.bash.exec = MagicMock(side_effect=self._api_error_404())
sandbox._client.shell.exec_command = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="plain ok")))
sandbox.execute_command("cmd", env={"TOK": "v"})
out = sandbox.execute_command("echo plain")
assert out == "plain ok"
sandbox._client.shell.exec_command.assert_called_once()
def test_bash_exec_success_does_not_mark_unsupported(self, sandbox):
"""A healthy bash.exec keeps the env path fully enabled."""
sandbox._client.bash.exec = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(stdout="ok", stderr=None)))
first = sandbox.execute_command("cmd-1", env={"TOK": "v"})
second = sandbox.execute_command("cmd-2", env={"TOK": "v"})
assert first == "ok"
assert second == "ok"
assert sandbox._client.bash.exec.call_count == 2
class TestListDirSerialization:
"""Verify that list_dir also acquires the lock."""
def test_list_dir_uses_lock(self, sandbox):
"""list_dir should hold the lock during execution."""
lock_was_held = []
original_exec = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="/a\n/b")))
def tracking_exec(command, **kwargs):
lock_was_held.append(sandbox._lock.locked())
return original_exec(command, **kwargs)
sandbox._client.shell.exec_command = tracking_exec
result = sandbox.list_dir("/test")
assert result == ["/a", "/b"]
assert lock_was_held == [True], "list_dir must hold the lock during exec_command"
class TestNoChangeTimeout:
"""Verify that no_change_timeout is forwarded to every exec_command call."""
def test_execute_command_passes_no_change_timeout(self, sandbox):
"""execute_command should pass no_change_timeout to exec_command."""
calls = []
def mock_exec(command, **kwargs):
calls.append(kwargs)
return SimpleNamespace(data=SimpleNamespace(output="ok"))
sandbox._client.shell.exec_command = mock_exec
sandbox.execute_command("echo hello")
assert len(calls) == 1
assert calls[0].get("no_change_timeout") == sandbox._DEFAULT_NO_CHANGE_TIMEOUT
def test_retry_passes_no_change_timeout(self, sandbox):
"""The ErrorObservation retry path should also pass no_change_timeout."""
calls = []
def mock_exec(command, **kwargs):
calls.append(kwargs)
if len(calls) == 1:
return SimpleNamespace(data=SimpleNamespace(output="'ErrorObservation' object has no attribute 'exit_code'"))
return SimpleNamespace(data=SimpleNamespace(output="ok"))
sandbox._client.shell.exec_command = mock_exec
sandbox.execute_command("echo hello")
assert len(calls) == 2
assert calls[0].get("no_change_timeout") == sandbox._DEFAULT_NO_CHANGE_TIMEOUT
assert calls[1].get("no_change_timeout") == sandbox._DEFAULT_NO_CHANGE_TIMEOUT
def test_list_dir_passes_no_change_timeout(self, sandbox):
"""list_dir should pass no_change_timeout to exec_command."""
calls = []
def mock_exec(command, **kwargs):
calls.append(kwargs)
return SimpleNamespace(data=SimpleNamespace(output="/a\n/b"))
sandbox._client.shell.exec_command = mock_exec
sandbox.list_dir("/test")
assert len(calls) == 1
assert calls[0].get("no_change_timeout") == sandbox._DEFAULT_NO_CHANGE_TIMEOUT
class TestReadFile:
def test_read_file_forwards_requested_line_range(self, sandbox):
sandbox._client.file.read_file = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(content="line 1\nline 2")))
result = sandbox.read_file("/mnt/user-data/workspace/huge.log", start_line=1, end_line=10)
assert result == "line 1\nline 2"
sandbox._client.file.read_file.assert_called_once_with(
file="/mnt/user-data/workspace/huge.log",
start_line=0,
end_line=10,
)
class TestConcurrentFileWrites:
"""Verify file write paths do not lose concurrent updates."""
def test_append_should_preserve_both_parallel_writes(self, sandbox):
storage = {"content": "seed\n"}
active_reads = 0
state_lock = threading.Lock()
overlap_detected = threading.Event()
def overlapping_read_file(path):
nonlocal active_reads
with state_lock:
active_reads += 1
snapshot = storage["content"]
if active_reads == 2:
overlap_detected.set()
overlap_detected.wait(0.05)
with state_lock:
active_reads -= 1
return snapshot
def write_back(*, file, content, **kwargs):
storage["content"] = content
return SimpleNamespace(data=SimpleNamespace())
sandbox.read_file = overlapping_read_file
sandbox._client.file.write_file = write_back
barrier = threading.Barrier(2)
def writer(payload: str):
barrier.wait()
sandbox.write_file("/tmp/shared.log", payload, append=True)
threads = [
threading.Thread(target=writer, args=("A\n",)),
threading.Thread(target=writer, args=("B\n",)),
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert storage["content"] in {"seed\nA\nB\n", "seed\nB\nA\n"}
class TestDownloadFile:
"""Tests for AioSandbox.download_file."""
def test_returns_concatenated_bytes(self, sandbox):
"""download_file should join chunks from the client iterator into bytes."""
sandbox._client.file.download_file = MagicMock(return_value=[b"hel", b"lo"])
result = sandbox.download_file("/mnt/user-data/outputs/file.bin")
assert result == b"hello"
sandbox._client.file.download_file.assert_called_once_with(path="/mnt/user-data/outputs/file.bin")
def test_returns_empty_bytes_for_empty_file(self, sandbox):
"""download_file should return b'' when the iterator yields nothing."""
sandbox._client.file.download_file = MagicMock(return_value=iter([]))
result = sandbox.download_file("/mnt/user-data/outputs/empty.bin")
assert result == b""
def test_uses_lock_during_download(self, sandbox):
"""download_file should hold the lock while calling the client."""
lock_was_held = []
def tracking_download(path):
lock_was_held.append(sandbox._lock.locked())
return iter([b"data"])
sandbox._client.file.download_file = tracking_download
sandbox.download_file("/mnt/user-data/outputs/file.bin")
assert lock_was_held == [True], "download_file must hold the lock during client call"
def test_raises_oserror_on_client_error(self, sandbox):
"""download_file should wrap client exceptions as OSError."""
sandbox._client.file.download_file = MagicMock(side_effect=RuntimeError("network error"))
with pytest.raises(OSError, match="network error"):
sandbox.download_file("/mnt/user-data/outputs/file.bin")
def test_preserves_oserror_from_client(self, sandbox):
"""OSError raised by the client should propagate without re-wrapping."""
sandbox._client.file.download_file = MagicMock(side_effect=OSError("disk error"))
with pytest.raises(OSError, match="disk error"):
sandbox.download_file("/mnt/user-data/outputs/file.bin")
def test_rejects_path_outside_virtual_prefix_and_logs_error(self, sandbox, caplog):
"""download_file must reject downloads outside /mnt/user-data and log the reason."""
sandbox._client.file.download_file = MagicMock()
with caplog.at_level("ERROR"):
with pytest.raises(PermissionError, match="must be under"):
sandbox.download_file("/etc/passwd")
assert "outside allowed directory" in caplog.text
sandbox._client.file.download_file.assert_not_called()
@pytest.mark.parametrize(
"path",
[
"/mnt/workspace/../../etc/passwd",
"../secret",
"/a/b/../../../etc/shadow",
],
)
def test_rejects_path_traversal(self, sandbox, path):
"""download_file must reject paths containing '..' before calling the client."""
sandbox._client.file.download_file = MagicMock()
with pytest.raises(PermissionError, match="path traversal"):
sandbox.download_file(path)
sandbox._client.file.download_file.assert_not_called()
def test_single_chunk(self, sandbox):
"""download_file should work correctly with a single-chunk response."""
sandbox._client.file.download_file = MagicMock(return_value=[b"single-chunk"])
result = sandbox.download_file("/mnt/user-data/outputs/single.bin")
assert result == b"single-chunk"
class TestClose:
"""Verify AioSandbox.close() tears down the host-side HTTP client (#2872)."""
def test_close_calls_real_nested_httpx_client(self, sandbox):
"""close() must close the real httpx.Client at the bottom of the chain.
Mirrors the actual Fern structure:
Sandbox._client_wrapper.httpx_client -> Fern HttpClient (no close())
.httpx_client -> httpx.Client (the real owner)
The intermediate HttpClient deliberately exposes NO close(), so a naive
one-level lookup (the original bug) would silently close nothing.
"""
real_httpx = MagicMock(spec=["close"])
fern_http = SimpleNamespace(httpx_client=real_httpx) # no close on this layer
sandbox._client._client_wrapper = SimpleNamespace(httpx_client=fern_http)
sandbox.close()
real_httpx.close.assert_called_once_with()
def test_close_clears_client_reference(self, sandbox):
"""After close(), the client reference must be dropped (use-after-close safety)."""
real_httpx = MagicMock(spec=["close"])
fern_http = SimpleNamespace(httpx_client=real_httpx)
sandbox._client._client_wrapper = SimpleNamespace(httpx_client=fern_http)
sandbox.close()
assert sandbox._client is None
assert sandbox._closed is True
def test_close_is_idempotent(self, sandbox):
"""Calling close() multiple times must close the underlying client at most once."""
real_httpx = MagicMock(spec=["close"])
fern_http = SimpleNamespace(httpx_client=real_httpx)
sandbox._client._client_wrapper = SimpleNamespace(httpx_client=fern_http)
sandbox.close()
sandbox.close()
sandbox.close()
assert real_httpx.close.call_count == 1
def test_close_swallows_exceptions(self, sandbox, caplog):
"""close() must be best-effort: client errors are logged but never raised."""
real_httpx = MagicMock(spec=["close"])
real_httpx.close.side_effect = RuntimeError("teardown boom")
fern_http = SimpleNamespace(httpx_client=real_httpx)
sandbox._client._client_wrapper = SimpleNamespace(httpx_client=fern_http)
with caplog.at_level("WARNING"):
sandbox.close()
assert "Error closing AioSandbox client" in caplog.text
def test_close_falls_back_to_client_close(self, sandbox):
"""If no nested httpx.Client is reachable, close() degrades to the client's own close()."""
# Replace the mocked client with a stub that exposes only top-level close()
client = MagicMock(spec=["close"])
sandbox._client = client
sandbox.close()
client.close.assert_called_once_with()
def test_close_when_no_close_attr_does_not_raise(self, sandbox):
"""A client without any close attribute must not crash close()."""
sandbox._client = SimpleNamespace() # no close, no _client_wrapper
sandbox.close() # must not raise
assert sandbox._client is None
def test_list_dir_preserves_trailing_space_in_filename(sandbox):
""" "notes.txt " (trailing space) is a legal Linux filename; find prints it
verbatim, one entry per line, so a per-line strip() corrupts the name."""
sandbox._client.shell.exec_command = MagicMock(return_value=SimpleNamespace(data=SimpleNamespace(output="/test/notes.txt \n/test/sub\n")))
assert sandbox.list_dir("/test") == ["/test/notes.txt ", "/test/sub"]