mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
SandboxOptions was an intermediate between the environment's settings and the driver's SandboxSpec that mirrored the spec field for field: image and Dockerfile for the source, cpu and byte sizes for the resources, auto-stop for the timers, plus the two clone fields. Every provider overlay then read the options a second time to fill the spec. The environment now maps onto the driver spec once, in sandbox_spec_for_environment, and the overlays read the spec: Docker takes its image from the source and clears the timers it cannot honor, Daytona takes its snapshot inputs from the source and resources and its auto-stop from the timers. A plugin gets the spec trimmed to the network and timer capabilities it declares. The snapshot carries the resources a Daytona sandbox is sized by, so the overlay clears them from the spec the sandbox is created with; the driver refuses them there, which the options path never reached in a live run. The clone selectors, depth, and skip flag travel as one CloneRequest beside the spec instead of five loose parameters and two option fields, so provider_sandbox takes six arguments instead of nine. The two helpers that read environment settings for a local run, its working directory and its unresolved variables, become methods on RunEnvironmentSettings in fabro-types, where the settings live. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
406 lines
13 KiB
Rust
406 lines
13 KiB
Rust
//! Phase 2 of the sandbox-driver adoption: measure agent tool-call latency
|
|
//! through the driver against fabro's current providers before any cutover.
|
|
//!
|
|
//! Three comparisons, each over the same medium repository (fabro's own
|
|
//! `lib/` tree, about 1,100 Rust files):
|
|
//!
|
|
//! - Docker file reads and content search: fabro's driver-backed Docker sandbox
|
|
//! (its path resolution and result shaping) against the bare driver
|
|
//! `DockerProvider` in-process.
|
|
//! - Host tool calls: fabro's local sandbox against the driver `HostProvider`
|
|
//! in-process, to confirm no regression on the path every local run takes.
|
|
//! - The wire: the driver Host and Docker providers served over the JSON-RPC
|
|
//! protocol on an in-process duplex pipe, to size the budget for running a
|
|
//! provider out of process later (the plan allows 100 ms per tool call).
|
|
//!
|
|
//! Ignored: it needs a Docker daemon with `buildpack-deps:noble` present and
|
|
//! takes a minute. Run with
|
|
//! `cargo nextest run -p fabro-sandbox --test driver_bench --run-ignored only
|
|
//! --no-capture`.
|
|
|
|
#![allow(
|
|
clippy::print_stderr,
|
|
clippy::cast_precision_loss,
|
|
clippy::cast_possible_truncation,
|
|
clippy::cast_sign_loss,
|
|
reason = "a benchmark reports through stderr and rounds durations for display"
|
|
)]
|
|
#![expect(
|
|
clippy::disallowed_methods,
|
|
reason = "the fixture is packed and enumerated synchronously before the timed section starts"
|
|
)]
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use fabro_sandbox::{
|
|
CloneRequest, ProviderAccess, RunSandbox, SandboxProviderKind, local_sandbox, provider_sandbox,
|
|
};
|
|
use sandbox_driver::{
|
|
ExecSpec, GrepOptions, Sandbox as DriverHandle, SandboxProvider, SandboxSource, SandboxSpec,
|
|
Search,
|
|
};
|
|
use sandbox_driver_docker::DockerProvider;
|
|
use sandbox_driver_host::HostProvider;
|
|
use sandbox_driver_protocol::{PluginProvider, serve};
|
|
use tokio::io::{duplex, split};
|
|
|
|
const IMAGE: &str = "buildpack-deps:noble";
|
|
const READS: usize = 200;
|
|
const GREPS: usize = 20;
|
|
const GREP_PATTERN: &str = "async fn ";
|
|
|
|
/// The medium repository: fabro's `lib/` tree, packed once per run.
|
|
struct Repository {
|
|
tarball: PathBuf,
|
|
/// Repository-relative paths of the files the read benchmark samples.
|
|
files: Vec<String>,
|
|
_dir: tempfile::TempDir,
|
|
}
|
|
|
|
impl Repository {
|
|
fn pack() -> Self {
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../..")
|
|
.canonicalize()
|
|
.expect("workspace lib dir");
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let tarball = dir.path().join("repo.tar");
|
|
let status = Command::new("tar")
|
|
.args(["-cf"])
|
|
.arg(&tarball)
|
|
.args(["--exclude", "target", "--exclude", "node_modules", "-C"])
|
|
.arg(&root)
|
|
.arg(".")
|
|
.status()
|
|
.expect("tar available");
|
|
assert!(status.success(), "packing the repository failed");
|
|
let mut files: Vec<String> = walkdir(&root)
|
|
.into_iter()
|
|
.filter(|path| path.extension().is_some_and(|ext| ext == "rs"))
|
|
.filter_map(|path| {
|
|
path.strip_prefix(&root)
|
|
.ok()
|
|
.map(|rel| rel.to_string_lossy().into_owned())
|
|
})
|
|
.collect();
|
|
files.sort();
|
|
// A fixed stride samples the tree evenly and identically for every
|
|
// provider under test.
|
|
let stride = (files.len() / READS).max(1);
|
|
let files = files.into_iter().step_by(stride).take(READS).collect();
|
|
Self {
|
|
tarball,
|
|
files,
|
|
_dir: dir,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn walkdir(root: &Path) -> Vec<PathBuf> {
|
|
let mut out = Vec::new();
|
|
let mut stack = vec![root.to_path_buf()];
|
|
while let Some(dir) = stack.pop() {
|
|
let Ok(entries) = std::fs::read_dir(&dir) else {
|
|
continue;
|
|
};
|
|
for entry in entries.flatten() {
|
|
let path = entry.path();
|
|
if path.is_dir() {
|
|
if path.file_name().is_some_and(|name| name == "target") {
|
|
continue;
|
|
}
|
|
stack.push(path);
|
|
} else {
|
|
out.push(path);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Samples(Vec<Duration>);
|
|
|
|
impl Samples {
|
|
fn record(&mut self, duration: Duration) {
|
|
self.0.push(duration);
|
|
}
|
|
|
|
fn percentile(&self, pct: f64) -> Duration {
|
|
let mut sorted = self.0.clone();
|
|
sorted.sort();
|
|
if sorted.is_empty() {
|
|
return Duration::ZERO;
|
|
}
|
|
let index = ((sorted.len() - 1) as f64 * pct).round() as usize;
|
|
sorted[index]
|
|
}
|
|
|
|
fn mean(&self) -> Duration {
|
|
if self.0.is_empty() {
|
|
return Duration::ZERO;
|
|
}
|
|
self.0.iter().sum::<Duration>() / self.0.len() as u32
|
|
}
|
|
}
|
|
|
|
struct Row {
|
|
label: &'static str,
|
|
op: &'static str,
|
|
n: usize,
|
|
stats: Samples,
|
|
}
|
|
|
|
fn report(rows: &[Row]) {
|
|
eprintln!();
|
|
eprintln!(
|
|
"{:<34} {:<8} {:>5} {:>9} {:>9} {:>9}",
|
|
"provider", "op", "n", "p50 ms", "p95 ms", "mean ms"
|
|
);
|
|
for row in rows {
|
|
eprintln!(
|
|
"{:<34} {:<8} {:>5} {:>9.2} {:>9.2} {:>9.2}",
|
|
row.label,
|
|
row.op,
|
|
row.n,
|
|
row.stats.percentile(0.5).as_secs_f64() * 1000.0,
|
|
row.stats.percentile(0.95).as_secs_f64() * 1000.0,
|
|
row.stats.mean().as_secs_f64() * 1000.0,
|
|
);
|
|
}
|
|
eprintln!();
|
|
}
|
|
|
|
/// The two operations an agent issues most: a file read and a content
|
|
/// search, expressed against fabro's current trait.
|
|
async fn bench_fabro(label: &'static str, sandbox: &RunSandbox, repo: &Repository) -> Vec<Row> {
|
|
let mut reads = Samples::default();
|
|
for file in &repo.files {
|
|
let started = Instant::now();
|
|
let bytes = sandbox
|
|
.read_file_bytes(&format!("repo/{file}"))
|
|
.await
|
|
.expect("read");
|
|
assert!(!bytes.is_empty());
|
|
reads.record(started.elapsed());
|
|
}
|
|
let mut greps = Samples::default();
|
|
let mut options = GrepOptions::default();
|
|
options.include = Some("*.rs".to_owned());
|
|
options.max_matches = Some(50);
|
|
for _ in 0..GREPS {
|
|
let started = Instant::now();
|
|
let matches = sandbox
|
|
.grep(GREP_PATTERN, "repo", &options)
|
|
.await
|
|
.expect("grep");
|
|
assert!(!matches.is_empty());
|
|
greps.record(started.elapsed());
|
|
}
|
|
vec![
|
|
Row {
|
|
label,
|
|
op: "read",
|
|
n: repo.files.len(),
|
|
stats: reads,
|
|
},
|
|
Row {
|
|
label,
|
|
op: "grep",
|
|
n: GREPS,
|
|
stats: greps,
|
|
},
|
|
]
|
|
}
|
|
|
|
/// The same two operations against the driver's facets.
|
|
async fn bench_driver(
|
|
label: &'static str,
|
|
sandbox: &dyn DriverHandle,
|
|
repo: &Repository,
|
|
) -> Vec<Row> {
|
|
let mut reads = Samples::default();
|
|
for file in &repo.files {
|
|
let started = Instant::now();
|
|
let bytes = sandbox
|
|
.fs()
|
|
.read(&format!("repo/{file}"))
|
|
.await
|
|
.expect("read");
|
|
assert!(!bytes.is_empty());
|
|
reads.record(started.elapsed());
|
|
}
|
|
let search = sandbox.search().expect("search facet");
|
|
let mut options = GrepOptions::default();
|
|
options.include = Some("*.rs".to_owned());
|
|
options.max_matches = Some(50);
|
|
let mut greps = Samples::default();
|
|
for _ in 0..GREPS {
|
|
let started = Instant::now();
|
|
let matches = search
|
|
.grep(GREP_PATTERN, "repo", &options)
|
|
.await
|
|
.expect("grep");
|
|
assert!(!matches.is_empty());
|
|
greps.record(started.elapsed());
|
|
}
|
|
vec![
|
|
Row {
|
|
label,
|
|
op: "read",
|
|
n: repo.files.len(),
|
|
stats: reads,
|
|
},
|
|
Row {
|
|
label,
|
|
op: "grep",
|
|
n: GREPS,
|
|
stats: greps,
|
|
},
|
|
]
|
|
}
|
|
|
|
async fn unpack_fabro(sandbox: &RunSandbox, repo: &Repository) {
|
|
sandbox
|
|
.upload_file_from_local(&repo.tarball, "/tmp/repo.tar")
|
|
.await
|
|
.expect("upload");
|
|
let result = sandbox
|
|
.exec_command(
|
|
"mkdir -p repo && tar -xf /tmp/repo.tar -C repo",
|
|
120_000,
|
|
None,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("unpack exec");
|
|
assert!(result.success(), "unpack failed: {}", result.stderr_lossy());
|
|
}
|
|
|
|
async fn unpack_driver(sandbox: &dyn DriverHandle, repo: &Repository) {
|
|
sandbox
|
|
.fs()
|
|
.upload(&repo.tarball, "/tmp/repo.tar")
|
|
.await
|
|
.expect("upload");
|
|
let result = sandbox
|
|
.exec()
|
|
.run(
|
|
&ExecSpec::bash("mkdir -p repo && tar -xf /tmp/repo.tar -C repo")
|
|
.timeout(Duration::from_mins(2)),
|
|
)
|
|
.await
|
|
.expect("unpack exec");
|
|
assert!(result.success(), "unpack failed: {}", result.stderr_lossy());
|
|
}
|
|
|
|
fn docker_spec() -> SandboxSpec {
|
|
SandboxSpec::new(SandboxSource::Image {
|
|
reference: IMAGE.to_owned(),
|
|
})
|
|
.working_directory("/workspace")
|
|
}
|
|
|
|
async fn serve_over_duplex(provider: Arc<dyn SandboxProvider>) -> PluginProvider {
|
|
let (host_side, plugin_side) = duplex(1024 * 1024);
|
|
let (host_read, host_write) = split(host_side);
|
|
let (plugin_read, plugin_write) = split(plugin_side);
|
|
tokio::spawn(serve(provider, plugin_read, plugin_write));
|
|
PluginProvider::connect(host_read, host_write)
|
|
.await
|
|
.expect("handshake")
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
#[ignore = "benchmark: needs a Docker daemon with buildpack-deps:noble and takes about a minute"]
|
|
async fn agent_tool_call_latency_through_the_driver() {
|
|
let image_check = Command::new("docker")
|
|
.args(["image", "inspect", IMAGE])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.status();
|
|
if !image_check.is_ok_and(|status| status.success()) {
|
|
eprintln!("no Docker daemon or {IMAGE} is not present locally; skipping");
|
|
return;
|
|
}
|
|
let repo = Repository::pack();
|
|
let mut rows = Vec::new();
|
|
|
|
// -- Host, in-process: fabro local sandbox vs driver HostProvider.
|
|
let host_dir = tempfile::tempdir().expect("tempdir");
|
|
let local = local_sandbox(host_dir.path().to_path_buf())
|
|
.await
|
|
.expect("local sandbox should be created");
|
|
local.initialize().await.expect("local init");
|
|
unpack_fabro(&local, &repo).await;
|
|
rows.extend(bench_fabro("fabro local sandbox", &local, &repo).await);
|
|
|
|
let host_provider = Arc::new(HostProvider::new());
|
|
let host = host_provider
|
|
.create(
|
|
&SandboxSpec::new(SandboxSource::HostDirectory)
|
|
.working_directory(host_dir.path().to_string_lossy().into_owned()),
|
|
None,
|
|
)
|
|
.await
|
|
.expect("host create");
|
|
rows.extend(bench_driver("driver Host (in-process)", host.as_ref(), &repo).await);
|
|
|
|
// -- Host over the wire (duplex pipe, no process boundary).
|
|
let remote_host = serve_over_duplex(host_provider.clone()).await;
|
|
let wire_host = remote_host.attach(host.id(), None).await.expect("attach");
|
|
rows.extend(bench_driver("driver Host (JSON-RPC, duplex)", wire_host.as_ref(), &repo).await);
|
|
drop(wire_host);
|
|
remote_host.shutdown().await.expect("shutdown");
|
|
host.delete().await.expect("host delete");
|
|
|
|
// -- Docker, in-process: fabro's driver-backed sandbox vs the bare driver.
|
|
let fabro_docker = provider_sandbox(
|
|
SandboxProviderKind::DOCKER,
|
|
&ProviderAccess::default(),
|
|
SandboxSpec::new(SandboxSource::Image {
|
|
reference: IMAGE.to_owned(),
|
|
}),
|
|
&CloneRequest::none(),
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("fabro docker sandbox");
|
|
fabro_docker.initialize().await.expect("fabro docker init");
|
|
unpack_fabro(&fabro_docker, &repo).await;
|
|
rows.extend(bench_fabro("fabro Docker (driver-backed)", &fabro_docker, &repo).await);
|
|
fabro_docker.cleanup().await.expect("fabro docker cleanup");
|
|
|
|
let docker_provider = Arc::new(DockerProvider::connect().await.expect("docker connect"));
|
|
let container = docker_provider
|
|
.create(&docker_spec(), None)
|
|
.await
|
|
.expect("driver docker create");
|
|
unpack_driver(container.as_ref(), &repo).await;
|
|
rows.extend(bench_driver("driver Docker (in-process)", container.as_ref(), &repo).await);
|
|
|
|
// -- Docker over the wire (duplex pipe, no process boundary).
|
|
let remote_docker = serve_over_duplex(docker_provider.clone()).await;
|
|
let wire_docker = remote_docker
|
|
.attach(container.id(), None)
|
|
.await
|
|
.expect("attach");
|
|
rows.extend(
|
|
bench_driver(
|
|
"driver Docker (JSON-RPC, duplex)",
|
|
wire_docker.as_ref(),
|
|
&repo,
|
|
)
|
|
.await,
|
|
);
|
|
drop(wire_docker);
|
|
remote_docker.shutdown().await.expect("shutdown");
|
|
container.delete().await.expect("driver docker delete");
|
|
|
|
report(&rows);
|
|
}
|