Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
bff8324
refactor into reusable sandboxes
syphar May 4, 2026
99019d4
fix(sandbox): delete both cached containers on cleanup
syphar May 4, 2026
69ebae6
refactor(sandbox): cache one reusable container
syphar May 4, 2026
e5096bd
refactor(sandbox): configure mount mode on builder
syphar May 4, 2026
c72650c
refactor(sandbox): require source and target dirs
syphar May 4, 2026
2390668
fix(sandbox): normalize reusable sandbox paths
syphar May 4, 2026
eda02c3
fix(sandbox): reset reused containers after oom
syphar May 4, 2026
bafbdd1
docs(sandbox): clarify reused container state
syphar May 4, 2026
9c4fa8e
fix(sandbox): delete stopped reused containers
syphar May 4, 2026
6b5f596
fix(cmd): clarify reentrant sandbox panic
syphar May 4, 2026
2f0fd01
refactor(build): move memory stats to build output
syphar May 4, 2026
df2bef7
refactor(cmd): remove empty process statistics
syphar May 4, 2026
e053e86
refactor(build): restore statistics merge helpers
syphar May 4, 2026
1796a3e
refactor(sandbox): deduplicate peak updates
syphar May 4, 2026
58e3c7b
docs(build): clarify build-level memory stats
syphar May 4, 2026
ef7455c
fix(sandbox): clean up failed container starts
syphar May 4, 2026
ff0ee15
refactor(build): rename build stats to sandbox stats
syphar May 4, 2026
70df402
refactor(sandbox): move stats to sandbox API
syphar May 4, 2026
dcdde5c
refactor(build): drop peak accessors from build API
syphar May 4, 2026
7c88b51
Merge branch 'main' into reuse-container
syphar May 5, 2026
28299b7
fix(sandbox): reuse the live container for sandboxed commands
syphar May 5, 2026
7d50093
fix(sandbox): log leaked containers on cleanup failure
syphar May 5, 2026
58db997
fix(sandbox): start reusable containers eagerly
syphar May 5, 2026
feb5508
docs(sandbox): clarify sandbox lifecycle and restart behavior
syphar May 5, 2026
7a1aa10
fix(cmd): default sandbox commands to source dir workdir
syphar May 6, 2026
50080d8
docs(changelog): document sandbox reuse API changes
syphar May 6, 2026
8406ae7
test(container_cleanup): verify recreation after container kill
syphar May 6, 2026
6880010
refactor(sandbox): rely on Drop for container cleanup
syphar May 6, 2026
f437b7f
fix(sandbox): make container cleanup idempotent and fallible
syphar May 6, 2026
5dc1887
fix(sandbox): make container deletion idempotent
syphar May 6, 2026
ec7f895
docs(sandbox): clarify when reusable container is recreated
syphar May 6, 2026
8f7682c
fix(sandbox): log container deletion failure as a single error
syphar May 6, 2026
f4b72d4
feat(build): expose sandbox statistics during a build
syphar May 7, 2026
6fb318e
style(sandbox): remove unused Error import
syphar May 7, 2026
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
86 changes: 86 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,92 @@ 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,
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.

* 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.

* **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();
```

`BuildResult<T>` also derefs to `&T` for ergonomics.

* **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 `cd(...)` path must point inside the source
directory or it will panic at runtime.

See https://github.com/rust-lang/rustwide/pull/127

## [0.23.1] - 2026-04-19

* extend `ProcessStatistics` struct with some traits & helper functions
Expand Down
1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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
96 changes: 77 additions & 19 deletions src/build.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
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, container_dirs},
prepare::Prepare,
};
use std::ops::{Deref, DerefMut};
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 +46,38 @@ 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<T> Deref for BuildResult<T> {
type Target = T;

fn deref(&self) -> &Self::Target {
&self.output
}
}

impl<T> DerefMut for BuildResult<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.output
}
}

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 +147,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 +163,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 @@ -187,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 @@ -203,14 +246,23 @@ impl BuildDirectory {
})?;

std::fs::create_dir_all(self.target_dir())?;
let sandbox = Rc::new(RefCell::new(sandbox.start(
&self.workspace,
source_dir.clone(),
self.target_dir(),
)?));
let res = f(&Build {
dir: self,
toolchain,
sandbox,
sandbox: sandbox.clone(),
})?;
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 @@ -241,7 +293,7 @@ impl BuildDirectory {
pub struct Build<'ws> {
dir: &'ws BuildDirectory,
toolchain: &'ws Toolchain,
sandbox: SandboxBuilder,
sandbox: Rc<RefCell<Sandbox<'ws>>>,
}

impl<'ws> Build<'ws> {
Expand All @@ -251,6 +303,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 panic at runtime.
///
/// # Example
///
/// ```no_run
Expand All @@ -270,17 +327,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,
)
.cd(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 @@ -310,6 +360,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.sandbox.borrow().statistics()
}

/// 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