cargo.build: a persistent build cache shared by all worlds

- The cargo target folder is <cache>/cargo-target, next to Wasmtime's
  cache, which moves to <cache>/wasmtime: Wasmtime's cleanup worker
  deletes unknown entries in its folder, also Cargo's artifacts.
- Builds use the SDK at <cache>/sdk/<content hash>/, so cargo reuses
  the compiled SDK in every world. /plugins/cargo/sdk/ stays for agents.
- Tests run in the release profile and share compiled crates with the
  release build.
- The cargo and cli tests use a persistent cache in CARGO_TARGET_TMPDIR:
  warm, the cargo suite takes 11 s (was 235 s) and the cli suite 6 s
  (was 149 s).
- nix flake check runs ignored stress tests. AGENTS.md: rules for fast
  tests.
This commit is contained in:
2026-10-23 08:05:15 +01:00
parent a090e1fd57
commit 93f092e02b
11 changed files with 155 additions and 48 deletions
+56 -18
View File
@@ -3,6 +3,7 @@ use crate::{Host, world::World};
use anyhow::{Context, Result, bail};
use include_dir::{Dir, include_dir};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
path::{Path, PathBuf},
sync::Arc,
@@ -58,10 +59,12 @@ pub struct Cargo {
world: World,
builds: Arc<tokio::sync::Mutex<()>>,
runner: PathBuf,
target: PathBuf,
sdk: PathBuf,
}
impl Cargo {
/// Installs changed SDK files and selects the current sausage executable as runner.
pub fn new(world: World) -> Result<Self> {
pub fn new(world: World, cache: &Path) -> Result<Self> {
let mut runner = std::env::current_exe()?;
if runner
.parent()
@@ -69,28 +72,29 @@ impl Cargo {
{
runner = runner.parent().unwrap().parent().unwrap().join("sausage");
}
Self::with_runner(world, runner)
Self::with_runner(world, runner, cache)
}
/// Installs the SDK and accepts an explicit sausage binary, including for init integrations.
pub fn with_runner(world: World, runner: PathBuf) -> Result<Self> {
pub fn with_runner(world: World, runner: PathBuf, cache: &Path) -> Result<Self> {
let sdk = world.plugin_dir(ID).join("sdk");
write_dir(&SDK, &sdk.join("src"))?;
write_dir(&MACROS, &sdk.join("macros/src"))?;
write_dir(&INTERFACE, &sdk.join("interface/src"))?;
let mut files = std::collections::BTreeMap::new();
collect_files(&SDK, Path::new("src"), &mut files);
collect_files(&MACROS, Path::new("macros/src"), &mut files);
collect_files(&INTERFACE, Path::new("interface/src"), &mut files);
for (manifest, destination, interface_path) in [
(
include_str!("../../sdk/Cargo.toml"),
sdk.join("Cargo.toml"),
PathBuf::from("Cargo.toml"),
Some("interface"),
),
(
include_str!("../../sdk/macros/Cargo.toml"),
sdk.join("macros/Cargo.toml"),
PathBuf::from("macros/Cargo.toml"),
Some("../interface"),
),
(
include_str!("../../interface/Cargo.toml"),
sdk.join("interface/Cargo.toml"),
PathBuf::from("interface/Cargo.toml"),
None,
),
] {
@@ -108,9 +112,40 @@ impl Cargo {
}
}
}
write_changed(&destination, document.to_string().as_bytes())?;
files.insert(destination, document.to_string().into_bytes());
}
let mut hash = Sha256::new();
for (path, bytes) in &files {
let name = path.to_str().unwrap().as_bytes();
hash.update((name.len() as u64).to_le_bytes());
hash.update(name);
hash.update((bytes.len() as u64).to_le_bytes());
hash.update(bytes);
write_changed(&sdk.join(path), bytes)?;
}
let cached = std::path::absolute(cache)?
.join("sdk")
.join(format!("{:x}", hash.finalize()));
if !cached.is_dir() {
let parent = cached.parent().unwrap();
std::fs::create_dir_all(parent)
.with_context(|| format!("create {}", parent.display()))?;
let staging = tempfile::Builder::new()
.prefix("install-")
.tempdir_in(parent)?;
for (path, bytes) in &files {
write_changed(&staging.path().join(path), bytes)?;
}
// Another builder can publish the same complete, content-addressed SDK first.
if let Err(error) = std::fs::rename(staging.path(), &cached)
&& !cached.is_dir()
{
return Err(error).with_context(|| format!("install {}", cached.display()));
}
}
Ok(Self {
sdk: cached,
target: std::path::absolute(cache)?.join("cargo-target"),
world,
runner,
builds: Arc::new(tokio::sync::Mutex::new(())),
@@ -136,12 +171,12 @@ impl Cargo {
pub async fn build(&self, source: &Path) -> std::result::Result<BuildOutput, BuildFailure> {
let build_guard = self.builds.clone().lock_owned().await;
let work = self.world.state_dir(ID).join("work");
let target = self.world.state_dir(ID).join("target");
let target = self.target.clone();
let temporary = self.world.state_dir(ID).join("tmp");
let cleanup = temporary.clone();
let source = source.to_path_buf();
let copy = work.clone();
let sdk = self.world.plugin_dir(ID).join("sdk");
let sdk = self.sdk.clone();
let build_guard = tokio::task::spawn_blocking(move || -> Result<_> {
if cleanup.exists() {
std::fs::remove_dir_all(&cleanup)
@@ -168,7 +203,7 @@ impl Cargo {
.await
.map_err(anyhow::Error::from)??;
let mut test = Command::new("cargo");
test.args(["test", "--target", TARGET, "--target-dir"])
test.args(["test", "--release", "--target", TARGET, "--target-dir"])
.arg(&target)
.current_dir(&work)
.env("TMPDIR", &temporary)
@@ -372,15 +407,18 @@ fn write_changed(path: &Path, bytes: &[u8]) -> Result<()> {
}
Ok(())
}
/// Installs one embedded source tree without touching unchanged files.
fn write_dir(directory: &Dir<'_>, destination: &Path) -> Result<()> {
/// Collects embedded source files for deterministic hashing and installation.
fn collect_files(
directory: &Dir<'_>,
destination: &Path,
files: &mut std::collections::BTreeMap<PathBuf, Vec<u8>>,
) {
for file in directory.files() {
write_changed(&destination.join(file.path()), file.contents())?;
files.insert(destination.join(file.path()), file.contents().to_vec());
}
for directory in directory.dirs() {
write_dir(directory, destination)?;
collect_files(directory, destination, files);
}
Ok(())
}
/// Counts only final libtest summaries, never individual status lines.
fn test_count(output: &str, field: &str) -> u64 {
+5 -2
View File
@@ -58,6 +58,7 @@ impl Host {
/// Start a world with an explicit compile cache folder.
pub async fn start_with_cache(path: &Path, cache: &Path) -> anyhow::Result<Self> {
let path = path.to_path_buf();
let cargo_cache = cache.to_path_buf();
let cache = cache.to_path_buf();
let (world, runtime) = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
Ok((World::open(&path)?, Runtime::new(&cache)?))
@@ -67,8 +68,10 @@ impl Host {
journal::Journal::open(world.state_dir("core").join("journal.sqlite")).await?;
let host = Self::with_parts(world, runtime, journal);
let cargo_world = host.0.world.clone();
let cargo =
tokio::task::spawn_blocking(move || crate::cargo::Cargo::new(cargo_world)).await??;
let cargo = tokio::task::spawn_blocking(move || {
crate::cargo::Cargo::new(cargo_world, &cargo_cache)
})
.await??;
cargo.register(&host);
host.boot().await?;
Ok(host)
+12 -4
View File
@@ -79,7 +79,7 @@ fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
}
}
/// Creates a configured world and builds its embedded agent through the standalone builder.
async fn init(dir: &Path) -> Result<()> {
async fn init(dir: &Path, cache: Option<&Path>) -> Result<()> {
let api = setting("SAUSAGE_API", "API shape", Some("openai-responses"))?;
if api != "openai-responses" && api != "anthropic-messages" {
bail!("unknown API shape {api:?}");
@@ -105,7 +105,15 @@ async fn init(dir: &Path) -> Result<()> {
&json!({"api":api,"base_url":base_url.trim_end_matches('/'),"model":model,"token":token}),
)?,
)?;
let builder = sausage::cargo::Cargo::new(world.clone())?;
let cache = cache.map(Path::to_path_buf).unwrap_or_else(|| {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache")
});
base.join("sausage")
});
let builder = sausage::cargo::Cargo::new(world.clone(), &cache)?;
let source = world.plugin_dir("agent").join("source");
std::fs::create_dir_all(source.join("src"))?;
AGENT.extract(source.join("src"))?;
@@ -133,7 +141,7 @@ async fn main() -> Result<std::process::ExitCode> {
None => {
let dir = PathBuf::from("world");
if !dir.join("config/boot.json").is_file() {
init(&dir).await?;
init(&dir, cli.cache_dir.as_deref()).await?;
}
Cmd::Run { dir }
}
@@ -144,7 +152,7 @@ async fn main() -> Result<std::process::ExitCode> {
return Ok(std::process::ExitCode::FAILURE);
}
}
Cmd::Init { dir } => init(&dir).await?,
Cmd::Init { dir } => init(&dir, cli.cache_dir.as_deref()).await?,
Cmd::Run { dir } => {
let mut term =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
+2 -1
View File
@@ -222,7 +222,8 @@ impl Runtime {
let mut config = Config::new();
config.epoch_interruption(true);
let disk_cache = (|| -> Result<_> {
std::fs::create_dir_all(cache)?;
let cache = cache.join("wasmtime");
std::fs::create_dir_all(&cache)?;
let mut cache_config = wasmtime::CacheConfig::new();
cache_config.with_directory(cache.canonicalize()?);
Ok(wasmtime::Cache::new(cache_config)?)
+34 -8
View File
@@ -3,6 +3,7 @@ use sausage::{Host, world::World};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use tokio::sync::OnceCell;
const CACHE: &str = concat!(env!("CARGO_TARGET_TMPDIR"), "/cache");
struct TestWorld(PathBuf);
impl TestWorld {
/// Creates a fresh native build world.
@@ -45,10 +46,12 @@ fn fixture(name: &str) -> PathBuf {
}
/// Builds through the real registered native plugin.
async fn build(world: &TestWorld, name: &str) -> Result<Value, Value> {
let host = Host::start(&world.0).await.unwrap();
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
.await
.unwrap();
let runner = world.0.join("state/runner with spaces");
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_sausage"), &runner).unwrap();
sausage::cargo::Cargo::with_runner(World::open(&world.0).unwrap(), runner)
sausage::cargo::Cargo::with_runner(World::open(&world.0).unwrap(), runner, Path::new(CACHE))
.unwrap()
.register(&host);
let result = host.call("cargo.build", json!({"plugin":name})).await;
@@ -93,6 +96,7 @@ async fn valid() -> &'static Valid {
.args([
"test",
"--no-run",
"--release",
"--message-format=json",
"--bin",
"cargo-valid",
@@ -100,7 +104,7 @@ async fn valid() -> &'static Valid {
"wasm32-wasip2",
"--target-dir",
])
.arg(world.0.join("state/cargo/target"))
.arg(Path::new(CACHE).join("cargo-target"))
.current_dir(world.0.join("state/cargo/work"))
.stdin(std::process::Stdio::null())
.output()
@@ -221,7 +225,16 @@ async fn manifest_and_nested_sources() {
let manifest = std::fs::read_to_string(root.join("Cargo.toml")).unwrap();
assert!(manifest.contains("workspace.package"));
assert!(manifest.contains("optional = true"));
assert!(manifest.contains("/plugins/cargo/sdk"));
let document: toml_edit::DocumentMut = manifest.parse().unwrap();
let sdk = Path::new(document["target"]["cfg(target_arch = \"wasm32\")"]["dependencies"]["sausage_plugin"]["path"].as_str().unwrap());
assert_eq!(sdk.parent().unwrap(), Path::new(CACHE).join("sdk"));
let hash = sdk.file_name().unwrap().to_str().unwrap();
assert_eq!(hash.len(), 64);
assert!(hash.bytes().all(|byte| byte.is_ascii_hexdigit()));
assert_eq!(
std::fs::read(sdk.join("src/lib.rs")).unwrap(),
std::fs::read(valid.world.0.join("plugins/cargo/sdk/src/lib.rs")).unwrap()
);
assert_eq!(
std::fs::read_to_string(root.join("src/target/data")).unwrap(),
"nested target"
@@ -235,7 +248,9 @@ async fn manifest_and_nested_sources() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn native_discovery_and_error_paths() {
let world = TestWorld::new();
let host = Host::start(&world.0).await.unwrap();
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
.await
.unwrap();
assert_eq!(
host.call("cargo.rpc.discover", json!({})).await.unwrap()["methods"][0]["name"],
"build"
@@ -264,7 +279,7 @@ async fn release_only_failure() {
"[package]\nname='release-only'\nversion='0.1.0'\nedition='2024'\n",
)
.unwrap();
std::fs::write(root.join("main.rs"), "#[cfg(not(debug_assertions))] compile_error!(\"release-only error\"); fn main() {} #[test] fn passes() {}\n").unwrap();
std::fs::write(root.join("main.rs"), "#[cfg(not(test))] compile_error!(\"release-only error\"); fn main() {} #[test] fn passes() {}\n").unwrap();
let error = build(&world, "release").await.unwrap_err();
assert!(
error["data"]["output"]
@@ -296,6 +311,7 @@ async fn cancelled_preparation_is_serialized() {
sausage::cargo::Cargo::with_runner(
World::open(&world.0).unwrap(),
PathBuf::from(env!("CARGO_BIN_EXE_sausage")),
Path::new(CACHE),
)
.unwrap(),
);
@@ -358,6 +374,12 @@ async fn valid_build() {
assert_eq!(valid.result["tests"]["passed"], 12);
assert_eq!(valid.result["tests"]["failed"], 0);
assert!(!valid.world.0.join("plugins/valid/plugin.wasm").exists());
assert!(!valid.world.0.join("state/cargo/target").exists());
assert!(
Path::new(CACHE)
.join("cargo-target/wasm32-wasip2/release")
.is_dir()
);
}
/// PROTOCOL 11: tests and compilation share one source copy without target metadata.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -373,7 +395,7 @@ async fn source_copy() {
assert!(
std::fs::read_to_string(valid.world.0.join("state/cargo/work/Cargo.toml"))
.unwrap()
.contains("/plugins/cargo/sdk")
.contains(Path::new(CACHE).join("sdk").to_str().unwrap())
);
}
/// PROTOCOL 11: compiler failures return compiler diagnostics.
@@ -514,6 +536,7 @@ async fn unchanged_sdk() {
sausage::cargo::Cargo::with_runner(
World::open(&world.0).unwrap(),
PathBuf::from(env!("CARGO_BIN_EXE_sausage")),
Path::new(CACHE),
)
.unwrap()
};
@@ -534,6 +557,7 @@ async fn standalone_builder() {
let builder = sausage::cargo::Cargo::with_runner(
World::open(&world.0).unwrap(),
PathBuf::from(env!("CARGO_BIN_EXE_sausage")),
Path::new(CACHE),
)
.unwrap();
assert_eq!(
@@ -555,7 +579,9 @@ async fn kill_process_group() {
);
let world = TestWorld::new();
world.source("hang", &fixture("cargo-hang"));
let host = Host::start(&world.0).await.unwrap();
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
.await
.unwrap();
let building = host.clone();
let task =
tokio::spawn(async move { building.call("cargo.build", json!({"plugin":"hang"})).await });
+16 -7
View File
@@ -12,9 +12,15 @@ use tokio::{
process::{Child, Command},
};
const CACHE: &str = concat!(env!("CARGO_TARGET_TMPDIR"), "/cache");
fn binary() -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_sausage"));
command.env_remove("SAUSAGE_HEADLESS").kill_on_drop(true);
command
.arg("--cache-dir")
.arg(CACHE)
.env_remove("SAUSAGE_HEADLESS")
.kill_on_drop(true);
command
}
fn world() -> (tempfile::TempDir, PathBuf) {
@@ -133,12 +139,15 @@ async fn init_builds_agent() {
std::fs::read(world.join("plugins/agent/source/Cargo.lock")).unwrap(),
include_bytes!("../../Cargo.lock")
);
let host = Host::start_with_cache(
&world,
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/core/cli-cache"),
)
.await
.unwrap();
assert!(!world.join("state/cargo/target").exists());
assert!(
Path::new(CACHE)
.join("cargo-target/wasm32-wasip2/release")
.is_dir()
);
let host = Host::start_with_cache(&world, Path::new(CACHE))
.await
.unwrap();
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
assert_eq!(doc["x-sausage-grace"], 600);
assert!(