feat(benchmark): add OpenSandbox provider coverage (#7059)

This commit is contained in:
ShiYuan Xiong 2026-10-19 07:45:59 +08:00 committed by GitHub
parent 81daa578f7
commit 2254ec6ed5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 440 additions and 1 deletions

View File

@ -868,6 +868,11 @@ Outputs with case- or Unicode-equivalent file or directory names are skipped
before copying, and aliases of existing local paths are rejected, preserving
previous complete copies.
The [sandbox benchmark](backend/scripts/benchmark/sandbox/README.md#opensandbox)
also supports OpenSandbox cold acquisition, warm reuse and concurrent scopes,
using the configured service connection and reporting unconfirmed cleanup as a
failed run. Live runs require a disposable OpenSandbox service and runtime home.
BoxLite shutdown rejects late VM registration and keeps its SDK loop open while
in-flight acquisitions finish. If they cannot drain within five seconds,
shutdown fails with resources still owned and can be retried.

View File

@ -74,6 +74,10 @@ vars; `summarize` and pytest are offline. See its README for the protocol.
Benchmarks in `scripts/benchmark/` must be standalone and reproducible. Import
production functions; never duplicate them or introduce an alternative runtime.
OpenSandbox benchmarks reuse the production lifecycle with scoped config overrides.
Export only non-secret dimensions; final cleanup failures must fail both the runner
and JSONL summary. See the sandbox benchmark README for supported options and tests.
- Pin every external dataset by immutable revision and SHA-256. Callers provide
the local dataset path; evaluation commands must not silently download data.
- Never commit upstream dataset text, credentials, complete provider requests,

View File

@ -49,6 +49,63 @@ explicit destruction. `warm_hit` comes from the actual reclaim path. Keep differ
images, pool settings, replica limits, user counts and concurrency levels separate;
the summary's default grouping does so. Percentiles use inclusive interpolation.
## OpenSandbox
`--provider opensandbox` uses the production OpenSandbox provider and reads its
connection, timeouts and environment from the selected DeerFlow configuration.
Point `DEER_FLOW_CONFIG_PATH` at a disposable benchmark configuration with the
[OpenSandbox settings](../../../packages/harness/deerflow/community/opensandbox/README.md)
and set `DEER_FLOW_HOME` to a disposable local directory. Use a test OpenSandbox
service; these commands create and destroy real remote resources. Install the
optional `deerflow-harness[opensandbox]` dependency before running.
```sh
DEER_FLOW_CONFIG_PATH=/absolute/path/opensandbox-benchmark.yaml \
DEER_FLOW_HOME=/absolute/path/disposable-home \
uv run --no-sync python scripts/benchmark/sandbox/bench_provider.py \
--provider opensandbox --image <pinned-image> \
--scenario warm_same_thread --workload state_reuse --iterations 10 \
--output opensandbox-warm.jsonl
```
All five scenarios are supported. For a cold baseline, use
`--scenario cold_unique_thread --no-warmpool`; for simultaneous scopes, use
`--scenario replica_pressure --users 4 --concurrency 4`. Use an image with the
Python and POSIX utilities required by the selected workload and output sync.
The normal warm-up creates and destroys a separate remote before measurement.
CLI `--replicas` and `--idle-timeout` override their YAML values, including their
CLI defaults (3 and 600). `--image` overrides YAML only when supplied. Connection
settings, including HTTPS and `use_server_proxy`, are retained. The exported
configuration dimensions contain only image, replicas and idle timeout; credentials,
endpoint and environment are not copied into result rows. Record non-secret SDK,
server and deployment versions separately for reproducibility.
`release_ms` includes production best-effort output synchronization. Successful
command/release timing does not prove that every artifact was mirrored. Warm hits
come from the actual provider reclaim path, including its health probe. The replica
limit is a soft cap, not global admission control. `--fault-inject` and nonzero
`--health-check-skip-seconds` are rejected before provisioning: OpenSandbox does not
implement the existing BoxLite/AIO fault and health-skip adapters.
The benchmark retains production lifecycle behavior but records reported cleanup
failures instead of accepting them as success. A final cleanup failure exits nonzero
and appends a failed `shutdown` / `cleanup` row, so `summarize.py` also exits nonzero.
These rows are cleanup diagnostics, not command latency samples. Inspect the remote
IDs in the error and clean up unconfirmed resources through the test service.
The benchmark cannot establish physical deletion beyond the provider's destroy
contract. The Docker-prefix resource sampler below does not measure remote
OpenSandbox resources; collect server-side metrics separately.
Offline regressions use the production provider with an SDK transport double:
```sh
uv run --no-sync pytest tests/test_bench_opensandbox.py tests/test_bench_sandbox_provider.py -q
```
They check lifecycle and reporting contracts, not live SDK/server compatibility or
performance. Keep real-service runs opt-in and report their evidence separately.
## Resource samples
Run the sampler beside an explicit benchmark run ID:

View File

@ -25,6 +25,7 @@ Providers
---------
``boxlite`` BoxLite micro-VM sandbox (requires ``pip install boxlite``).
``aio-docker`` AIO Docker sandbox (requires Docker daemon + ``deerflow-harness`` extras).
``opensandbox`` OpenSandbox service (requires ``deerflow-harness[opensandbox]`` and sandbox config).
Scenarios
---------
@ -256,9 +257,64 @@ def _make_aio_provider(config: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
return provider, {**sandbox_attrs, "image": provider._config["image"]}
def _make_opensandbox_provider(config: dict[str, Any]) -> tuple[Any, dict[str, Any]]:
"""Use the configured connection and the production lifecycle, with strict cleanup accounting."""
from deerflow.community.opensandbox.provider import OpenSandboxProvider
from deerflow.config.app_config import get_app_config, pop_current_app_config, push_current_app_config
class BenchmarkOpenSandboxProvider(OpenSandboxProvider):
# Production cleanup logs failures and continues. A benchmark must still
# attempt every cleanup, but cannot report a successful run afterwards.
def __init__(self):
self._cleanup_failures: set[str] = set()
super().__init__()
def _destroy_quietly(self, sandbox, *, context: str) -> None:
try:
sandbox.destroy()
except Exception:
with self._lock:
self._cleanup_failures.add(sandbox.remote_id)
def _check_cleanup(self) -> None:
with self._lock:
failed = sorted(self._cleanup_failures)
if failed:
# Report resource identities for manual cleanup, never arbitrary
# SDK exception text that might contain connection credentials.
raise RuntimeError(f"OpenSandbox benchmark cleanup unconfirmed for remote IDs: {', '.join(failed)}")
def destroy(self, sandbox_id: str) -> None:
# The runner calls this after release. Reuse the provider's owner of
# identity removal instead of separately editing its three maps.
with self._acquire_serializer.hold(sandbox_id):
self._invalidate_sandbox(sandbox_id, reason="benchmark_no_warmpool")
self._check_cleanup()
def shutdown(self) -> None:
if self._shutdown_called:
return
super().shutdown()
self._check_cleanup()
app_config = get_app_config()
overrides = {key: config[key] for key in ("replicas", "idle_timeout", "image") if config.get(key) is not None}
sandbox_config = app_config.sandbox.model_copy(update=overrides)
# Preserve resolved connection/environment settings without mutating either
# the caller's config or a module-global getter used by another provider.
push_current_app_config(app_config.model_copy(update={"sandbox": sandbox_config}))
try:
provider = BenchmarkOpenSandboxProvider()
finally:
pop_current_app_config()
# Only non-secret benchmark dimensions leave the factory.
return provider, {key: provider._config[key] for key in ("image", "replicas", "idle_timeout")}
PROVIDER_FACTORIES: dict[str, Callable] = {
"boxlite": _make_boxlite_provider,
"aio-docker": _make_aio_provider,
"opensandbox": _make_opensandbox_provider,
}
@ -695,6 +751,8 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv)
if args.provider == "opensandbox" and (args.fault_inject is not None or args.health_check_skip_seconds != 0):
raise SystemExit("opensandbox benchmarks do not support --fault-inject or nonzero --health-check-skip-seconds")
if min(args.concurrency, args.users, args.replicas) < 1 or min(args.iterations, args.warmup_iterations) < 0:
raise SystemExit("concurrency, users and replicas must be positive; iteration counts must be nonnegative")
if args.fault_inject is not None and (args.concurrency != 1 or args.no_warmpool or args.scenario != "warm_same_thread"):
@ -786,7 +844,35 @@ def main(argv: list[str] | None = None) -> int:
return int(any(not result.success for result in results))
finally:
provider.shutdown()
cleanup_started = time.perf_counter()
try:
provider.shutdown()
except Exception as exc:
if args.provider == "opensandbox":
# Preserve failed final cleanup in the existing provider-row
# format so summarize.py cannot turn a failed run green.
elapsed = (time.perf_counter() - cleanup_started) * 1000
failure = BenchResult(
provider=args.provider,
scenario="shutdown",
workload="cleanup",
iteration=-1,
concurrency=args.concurrency,
thread_id="",
user_id="",
acquire_ms=0,
run_ms=0,
release_ms=0,
total_ms=elapsed,
cleanup_ms=elapsed,
success=False,
error=str(exc),
run_id=args.run_id,
users=args.users,
no_warmpool=args.no_warmpool,
)
_write_results([failure], output_path, config_used)
raise
def _run_idle_timeout_scenario(

View File

@ -0,0 +1,287 @@
"""Exercise the benchmark through the real provider with an offline SDK transport."""
from __future__ import annotations
import json
import threading
from datetime import timedelta
from types import SimpleNamespace
import pytest
from deerflow.community.opensandbox import provider as provider_module
from deerflow.config.app_config import AppConfig, get_app_config, pop_current_app_config, push_current_app_config
from deerflow.config.sandbox_config import SandboxConfig
from scripts.benchmark.sandbox import bench_provider as bench
from scripts.benchmark.sandbox import summarize
class Remote:
def __init__(self, number):
self.id = f"offline-remote-{number}"
self.destroy_calls = 0
self.destroy_error = False
self.command_error = False
self.commands = SimpleNamespace(run=self.run)
self.state = ""
def run(self, command, **kwargs):
if self.command_error:
raise RuntimeError("offline command failure")
if "__DF_OUTPUT_SYNC__" in command:
output = "\n__DF_OUTPUT_SYNC__:0:0:0"
elif 'write_text("benchmark-state-42")' in command:
self.state = "benchmark-state-42"
output = "written"
elif 'Path("/tmp/warm_state.txt").read_text()' in command:
output = self.state
else:
output = ""
return SimpleNamespace(exit_code=0, logs=SimpleNamespace(stdout=[SimpleNamespace(text=output)], stderr=[]), result=[])
def renew(self, timeout):
pass
def destroy(self):
self.destroy_calls += 1
if self.destroy_error:
raise RuntimeError("sentinel-private-sdk-error")
@pytest.fixture
def transport(monkeypatch, tmp_path):
remotes = []
requests = []
lock = threading.Lock()
def create(image, **kwargs):
with lock:
remote = Remote(len(remotes) + 1)
remotes.append(remote)
requests.append({"image": image, **kwargs})
return remote
monkeypatch.setattr(provider_module, "_import_sdk", lambda: (SimpleNamespace(create=create), SimpleNamespace, SimpleNamespace))
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path / "home"))
config = AppConfig(
sandbox=SandboxConfig(
use="deerflow.community.opensandbox:OpenSandboxProvider",
image="test-image@sha256:fixture",
api_key="sentinel-api-key",
domain="sandbox.invalid:443",
protocol="https",
request_timeout=17,
ready_timeout=19,
use_server_proxy=True,
environment={"BENCH_SECRET": "sentinel-environment"},
)
)
push_current_app_config(config)
try:
yield SimpleNamespace(remotes=remotes, requests=requests, config=config)
finally:
pop_current_app_config()
def test_factory_preserves_connection_config_without_exporting_secrets(transport):
provider, snapshot = bench._make_opensandbox_provider({"replicas": 2, "idle_timeout": 0, "image": None})
try:
assert get_app_config() is transport.config
assert transport.config.sandbox.image == "test-image@sha256:fixture"
sid = provider.acquire("thread", user_id="owner")
request = transport.requests[0]
assert request["image"] == "test-image@sha256:fixture"
assert request["env"] == {"BENCH_SECRET": "sentinel-environment"}
assert vars(request["connection_config"]) == {
"api_key": "sentinel-api-key",
"domain": "sandbox.invalid:443",
"protocol": "https",
"request_timeout": timedelta(seconds=17),
"use_server_proxy": True,
}
assert request["ready_timeout"].total_seconds() == 19
assert snapshot == {"image": request["image"], "replicas": 2, "idle_timeout": 0}
assert "sentinel" not in json.dumps(snapshot)
provider.release(sid)
finally:
provider.shutdown()
@pytest.mark.parametrize("cold", [False, True])
def test_cli_measures_real_reclaim_and_destroys_all_created_remotes(transport, tmp_path, cold):
output = tmp_path / "results.jsonl"
args = ["--provider", "opensandbox", "--iterations", "2", "--idle-timeout", "0", "--output", str(output)]
if cold:
args.append("--no-warmpool")
assert bench.main(args) == 0
rows = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()]
assert [row["warm_hit"] for row in rows] == [False, not cold]
assert all(row["success"] and row["provider"] == "opensandbox" for row in rows)
assert all(row["health_check_skip_seconds"] is None for row in rows)
# One separate warm-up remote, then two cold remotes or one reused remote.
assert len(transport.remotes) == (3 if cold else 2)
assert all(remote.destroy_calls == 1 for remote in transport.remotes)
assert "sentinel" not in output.read_text(encoding="utf-8")
def test_state_reuse_preserves_remote_files(transport, tmp_path):
output = tmp_path / "state.jsonl"
assert bench.main(["--provider", "opensandbox", "--workload", "state_reuse", "--iterations", "2", "--idle-timeout", "0", "--output", str(output)]) == 0
assert transport.remotes[-1].state == "benchmark-state-42"
def test_replica_pressure_keeps_concurrent_scopes_separate(transport, tmp_path):
output = tmp_path / "pressure.jsonl"
assert (
bench.main(
[
"--provider",
"opensandbox",
"--scenario",
"replica_pressure",
"--iterations",
"4",
"--concurrency",
"2",
"--users",
"2",
"--replicas",
"1",
"--idle-timeout",
"0",
"--output",
str(output),
]
)
== 0
)
rows = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()]
assert all(row["active_at_acquire"] == 2 and row["success"] for row in rows)
assert len({row["sandbox_id"] for row in rows}) == 2
assert all(remote.destroy_calls == 1 for remote in transport.remotes)
def test_failed_command_still_releases_and_destroys_remote(transport):
provider, _ = bench._make_opensandbox_provider({"idle_timeout": 0})
sid = provider.acquire("thread", user_id="owner")
transport.remotes[0].command_error = True
try:
row = bench._run_one_turn(provider, "opensandbox", "warm_same_thread", "noop", "true", 0, 1, "owner", "thread", True)
assert not row.success
assert row.sandbox_id == sid
assert transport.remotes[0].destroy_calls == 1
assert not provider._warm_pool and not provider._sandboxes
finally:
provider.shutdown()
def test_failed_destroy_invalidates_measurement_and_fails_shutdown(transport):
provider, _ = bench._make_opensandbox_provider({"idle_timeout": 0})
provider.acquire("thread", user_id="owner")
transport.remotes[0].destroy_error = True
try:
row = bench._run_one_turn(provider, "opensandbox", "warm_same_thread", "noop", "true", 0, 1, "owner", "thread", True)
assert not row.success
assert "offline-remote-1" in row.error
assert "sentinel-private-sdk-error" not in row.error
finally:
with pytest.raises(RuntimeError, match="cleanup"):
provider.shutdown()
# atexit must not repeat the error or pretend that a closed SDK client retried it.
provider.shutdown()
assert transport.remotes[0].destroy_calls == 1
def test_shutdown_attempts_every_remote_before_reporting_failure(transport):
provider, _ = bench._make_opensandbox_provider({"idle_timeout": 0})
provider.acquire("first", user_id="owner")
provider.acquire("second", user_id="owner")
transport.remotes[0].destroy_error = True
with pytest.raises(RuntimeError, match="cleanup"):
provider.shutdown()
assert [remote.destroy_calls for remote in transport.remotes] == [1, 1]
def test_shutdown_failure_is_also_visible_in_jsonl(transport, monkeypatch, tmp_path):
original_factory = bench.PROVIDER_FACTORIES["opensandbox"]
def factory(config):
provider, snapshot = original_factory(config)
original_create = provider._create_sandbox
def create(*args, **kwargs):
sandbox = original_create(*args, **kwargs)
transport.remotes[-1].destroy_error = True
return sandbox
provider._create_sandbox = create
return provider, snapshot
monkeypatch.setitem(bench.PROVIDER_FACTORIES, "opensandbox", factory)
output = tmp_path / "cleanup.jsonl"
with pytest.raises(RuntimeError, match="cleanup"):
bench.main(["--provider", "opensandbox", "--iterations", "1", "--warmup-iterations", "0", "--idle-timeout", "0", "--output", str(output)])
rows = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()]
assert rows[0]["success"]
assert rows[-1]["scenario"] == "shutdown"
assert rows[-1]["success"] is False
assert "sentinel-private-sdk-error" not in output.read_text(encoding="utf-8")
assert summarize.main([str(output), "--json"]) == 1
def test_image_override_and_failed_construction_restore_config(transport):
provider, snapshot = bench._make_opensandbox_provider({"image": "override-image", "idle_timeout": 0})
try:
assert snapshot["image"] == "override-image"
assert get_app_config() is transport.config
assert transport.config.sandbox.image == "test-image@sha256:fixture"
finally:
provider.shutdown()
bad = transport.config.model_copy(update={"sandbox": transport.config.sandbox.model_copy(update={"request_timeout": -1})})
push_current_app_config(bad)
try:
with pytest.raises(ValueError, match="request_timeout"):
bench._make_opensandbox_provider({})
assert get_app_config() is bad
finally:
pop_current_app_config()
def test_idle_reaping_forces_a_new_remote(transport, monkeypatch, tmp_path):
from deerflow.community import warm_pool_lifecycle
clock = [100.0]
monkeypatch.setattr(warm_pool_lifecycle.time, "time", lambda: clock[0])
monkeypatch.setattr(bench.time, "sleep", lambda seconds: clock.__setitem__(0, clock[0] + seconds))
output = tmp_path / "idle.jsonl"
assert (
bench.main(
[
"--provider",
"opensandbox",
"--scenario",
"idle_timeout",
"--iterations",
"1",
"--warmup-iterations",
"0",
"--idle-timeout",
"1",
"--output",
str(output),
]
)
== 0
)
rows = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()]
assert [row["warm_hit"] for row in rows] == [False, False]
assert all(row["success"] for row in rows)
assert len(transport.remotes) == 2
assert all(remote.destroy_calls == 1 for remote in transport.remotes)
@pytest.mark.parametrize("extra", [["--fault-inject", "0"], ["--health-check-skip-seconds", "1"]])
def test_unsupported_options_fail_before_creating_resources(transport, extra):
with pytest.raises(SystemExit, match="opensandbox"):
bench.main(["--provider", "opensandbox", *extra])
assert not transport.remotes