Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jobs:
cargo_flags:
- ""
- "--no-default-features"
- "--features tracing"
- "--all-features"
include:
# Integration tests are disabled on Windows as they take *way* too
Expand Down
97 changes: 97 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,103 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

* Sandbox containers are now reused across commands within a single build,
Comment thread
syphar marked this conversation as resolved.
avoiding per-command `docker create`/`docker rm` overhead. Every `Command`
spawned inside a `BuildBuilder::run` closure runs in the same container,
and the container is recreated transparently if a previous command's OOM
kill brought it down or if the container was otherwise stopped.

* Timed-out sandboxed commands now tear down the reused container before the
next command runs. This prevents abandoned processes left behind by a killed
host-side `docker exec` from racing later commands or leaking filesystem
changes into them.

* Added the public `Sandbox`, `SandboxStatistics`, and `BuildResult` types,
plus `SandboxBuilder::start` for direct sandbox construction. The
underlying container is created and started before `start` returns, so
docker errors surface there rather than on the first command.

* Added `Build::statistics()` for taking a snapshot of sandbox statistics
while a build is still running. This can be used for per-step reporting
from inside the build closure, including from `process_lines` callbacks.

* Added `Command::arg` and relaxed `Command::args`,
`Command::env`, and `Command::current_directory` to accept owned values
directly (`Into<OsString>` / `Into<PathBuf>`) instead of requiring borrowed
slices or references.

* **BREAKING**: `BuildBuilder::run` now returns `BuildResult<R>` instead of
`R`. The result wraps the closure's return value together with
`SandboxStatistics` gathered over the whole build:

```rust
let result = build_dir.build(&toolchain, &krate, sandbox).run(|build| {
build.cargo().args(&["test"]).run()?;
Ok(())
})?;
let peak = result.statistics().memory_peak_bytes();
let value = result.into_inner();
```

* **BREAKING**: `Command::run` returns `()` instead of `ProcessStatistics`.
Peak memory is no longer tracked per command; the cumulative maximum
across all commands in the build is exposed via the `BuildResult`
returned from `BuildBuilder::run`:

```rust
let result = build_dir.build(&toolchain, &krate, sandbox).run(|build| {
build.cargo().args(&["test"]).run()?;
build.cargo().args(&["doc"]).run()?;
Ok(())
})?;
let peak = result.statistics().memory_peak_bytes();
```

* **BREAKING**: `ProcessOutput::memory_peak_bytes` and the
`ProcessStatistics` type were removed. Use
`BuildResult::statistics().memory_peak_bytes()` (or
`Sandbox::statistics()` when using the sandbox API directly).

* **BREAKING**: `Command::source_dir_mount_kind` moved to
`SandboxBuilder::source_dir_mount_kind`. The mount kind now applies to
every command spawned in the build, since they share one container:

```rust
let sandbox = SandboxBuilder::new()
.source_dir_mount_kind(MountKind::ReadWrite);
build_dir.build(&toolchain, &krate, sandbox).run(|build| {
build.cargo().args(&["test"]).run()?;
Ok(())
})?;
```

With a writable source mount, mutations from an earlier command
persist into all later commands in the same build (and across reuse
of the source directory by later builds) — only opt in if you trust
every step to leave the source in a sensible state.

* **BREAKING**: `Command::new_sandboxed` was renamed to
`Command::new_in_sandbox` and now takes an `Rc<RefCell<Sandbox>>`
produced by `SandboxBuilder::start`, instead of a `SandboxBuilder`.
Most callers should use `BuildBuilder::run` instead; the lower-level
form is:

```rust
use std::{cell::RefCell, rc::Rc};

let sandbox = Rc::new(RefCell::new(
SandboxBuilder::new().start(&workspace, source_dir, target_dir)?,
));
Command::new_in_sandbox(&workspace, sandbox, "cargo")
.args(&["test"])
.run()?;
```

By default the command's working directory is the sandbox's source
directory; an explicit `current_directory(...)` path must point inside
the source directory or it will panic at runtime.


## [0.24.0] - 2026-05-12

* make alternate registry support optional to reduce dependencies
Expand Down
1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ tokio = { version = "1.0", features = ["process", "time", "io-util", "rt", "rt-m
tokio-stream = { version = "0.1", features = ["io-util"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
scopeguard = "1.0.0"
tempfile = "3.0.0"
attohttpc = "0.30.1"
flate2 = "1"
Expand Down
2 changes: 1 addition & 1 deletion examples/docs-builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ fn main() -> Result<(), Box<dyn Error>> {

let mut build_dir = workspace.build_dir("docs");
build_dir.build(&toolchain, &krate, sandbox).run(|build| {
build.cargo().args(&["doc", "--no-deps"]).run()?;
build.cargo().arg("doc").arg("--no-deps").run()?;
Ok(())
})?;

Expand Down
98 changes: 78 additions & 20 deletions src/build.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
use crate::cmd::{Command, MountKind, Runnable, SandboxBuilder};
use crate::prepare::Prepare;
use crate::{Crate, PrepareError, Toolchain, Workspace};
use crate::{
Crate, PrepareError, Toolchain, Workspace,
cmd::{
Command, Runnable, Sandbox, SandboxBuilder, SandboxStatistics, SandboxStatisticsState,
container_dirs,
},
prepare::Prepare,
};
use std::path::PathBuf;
use std::vec::Vec;
use std::{cell::RefCell, rc::Rc};

#[derive(Clone)]
pub(crate) enum CratePatch {
Expand Down Expand Up @@ -42,6 +48,24 @@ pub struct BuildBuilder<'a> {
patches: Vec<CratePatch>,
}

/// Output of a completed build together with build-level statistics.
pub struct BuildResult<T> {
output: T,
statistics: SandboxStatistics,
}

impl<T> BuildResult<T> {
/// Return the wrapped build output.
pub fn into_inner(self) -> T {
self.output
}

/// Borrow the build-level statistics.
pub fn statistics(&self) -> &SandboxStatistics {
&self.statistics
}
}

impl BuildBuilder<'_> {
/// Add a git-based patch to this build.
/// Patches get added to the crate's Cargo.toml in the `patch.crates-io` table.
Expand Down Expand Up @@ -111,6 +135,9 @@ impl BuildBuilder<'_> {
/// be provided an instance of [`Build`](struct.Build.html) that allows spawning new processes
/// inside the sandbox.
///
/// Returns a [`BuildResult`] containing both the closure's return value and build-level
/// statistics gathered across the sandbox lifetime.
///
/// All the state will be kept on disk as long as the closure doesn't exit: after that things
/// might be removed.
/// # Example
Expand All @@ -124,13 +151,17 @@ impl BuildBuilder<'_> {
/// # let krate = Crate::local("".as_ref());
/// # let sandbox = SandboxBuilder::new();
/// let mut build_dir = workspace.build_dir("foo");
/// build_dir.build(&toolchain, &krate, sandbox).run(|build| {
/// let result = build_dir.build(&toolchain, &krate, sandbox).run(|build| {
/// build.cargo().args(&["test", "--all"]).run()?;
/// Ok(())
/// })?;
/// let _peak = result.statistics().memory_peak_bytes();
/// # Ok(())
/// # }
pub fn run<R, F: FnOnce(&Build) -> anyhow::Result<R>>(self, f: F) -> anyhow::Result<R> {
pub fn run<R, F: FnOnce(&Build) -> anyhow::Result<R>>(
self,
f: F,
) -> anyhow::Result<BuildResult<R>> {
self.build_dir
.run(self.toolchain, self.krate, self.sandbox, self.patches, f)
}
Expand Down Expand Up @@ -199,7 +230,7 @@ impl BuildDirectory {
sandbox: SandboxBuilder,
patches: Vec<CratePatch>,
f: F,
) -> anyhow::Result<R> {
) -> anyhow::Result<BuildResult<R>> {
let source_dir = self.source_dir();
if source_dir.exists() {
crate::utils::remove_dir_all(&source_dir)?;
Expand All @@ -215,18 +246,38 @@ impl BuildDirectory {
})?;

std::fs::create_dir_all(self.target_dir())?;
let statistics = Rc::new(SandboxStatisticsState::default());
let sandbox = Rc::new(RefCell::new(sandbox.start_with_statistics(
&self.workspace,
source_dir.clone(),
self.target_dir(),
statistics.clone(),
)?));

let res = {
#[cfg(feature = "tracing")]
let _entered = tracing::info_span!("build.user_callback").entered();
let _entered = tracing::info_span!(
"build.user_callback",
build_dir = %self.name,
krate = %krate,
toolchain = %toolchain,
)
.entered();

f(&Build {
dir: self,
toolchain,
sandbox,
sandbox: sandbox.clone(),
statistics,
})
}?;
let statistics = sandbox.borrow_mut().cleanup()?;

crate::utils::remove_dir_all(&source_dir)?;
Ok(res)
Ok(BuildResult {
output: res,
statistics,
})
}

/// Remove all the contents of the build directory, freeing disk space.
Expand Down Expand Up @@ -257,7 +308,8 @@ impl BuildDirectory {
pub struct Build<'ws> {
dir: &'ws BuildDirectory,
toolchain: &'ws Toolchain,
sandbox: SandboxBuilder,
sandbox: Rc<RefCell<Sandbox<'ws>>>,
statistics: Rc<SandboxStatisticsState>,
}

impl<'ws> Build<'ws> {
Expand All @@ -267,6 +319,11 @@ impl<'ws> Build<'ws> {
/// outside the sandbox. The crate's source directory will be the working directory for the
/// command.
///
/// All commands spawned through the same [`Build`] share a single underlying container, so
/// running a sandboxed command from inside another sandboxed command's
/// [`process_lines`](struct.Command.html#method.process_lines) callback is not supported and
/// will return [`CommandError::ReentrantSandbox`](../cmd/enum.CommandError.html#variant.ReentrantSandbox).
///
/// # Example
///
/// ```no_run
Expand All @@ -286,17 +343,10 @@ impl<'ws> Build<'ws> {
/// # }
/// ```
pub fn cmd<'pl, R: Runnable>(&self, bin: R) -> Command<'ws, 'pl> {
let container_dir = &*crate::cmd::container_dirs::TARGET_DIR;
let container_dir = &*container_dirs::TARGET_DIR;

Command::new_sandboxed(
&self.dir.workspace,
self.sandbox
.clone()
.mount(&self.dir.target_dir(), container_dir, MountKind::ReadWrite),
bin,
)
.current_directory(self.dir.source_dir())
.env("CARGO_TARGET_DIR", container_dir)
Command::new_in_sandbox(&self.dir.workspace, self.sandbox.clone(), bin)
.env("CARGO_TARGET_DIR", container_dir)
}

/// Run `cargo` inside the sandbox, using the toolchain chosen for the build.
Expand Down Expand Up @@ -326,6 +376,14 @@ impl<'ws> Build<'ws> {
self.cmd(self.toolchain.cargo())
}

/// Snapshot the sandbox statistics (e.g. peak memory) gathered so far in
/// this build. The same data is available on the [`BuildResult`] returned
/// from [`BuildBuilder::run`]; this method exposes it mid-build, e.g. for
/// per-step reporting from inside the closure.
pub fn statistics(&self) -> SandboxStatistics {
self.statistics.snapshot()
}

/// Get the path to the source code on the host machine (outside the sandbox).
pub fn host_source_dir(&self) -> PathBuf {
self.dir.source_dir()
Expand Down
Loading
Loading