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:
+56
-18
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user