From a27d579264b45832239e4b2ab748bbbac659607b Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 12:14:17 +0000 Subject: [PATCH] builder: preserve forwarded arguments and clean world artifacts --- host/src/builder/README.md | 3 +++ host/src/builder/files.rs | 19 ++++++++++++++ host/src/builder/mod.rs | 23 +++++++++++++---- host/src/builder/tests.rs | 51 +++++++++++++++++++++++++++++++------- host/tests/builder.rs | 37 +++++++++++++++++++-------- 5 files changed, 109 insertions(+), 24 deletions(-) diff --git a/host/src/builder/README.md b/host/src/builder/README.md index 64ff8a0..bf3595e 100644 --- a/host/src/builder/README.md +++ b/host/src/builder/README.md @@ -4,6 +4,8 @@ The world `cargo` sends `POST http://host.sausage/cargo` with `argv`, `cwd`, and The response contains newline-delimited JSON events. `stdout` and `stderr` events contain byte arrays. The final `exit` event contains a code and executable paths. The CLI forwards the bytes immediately. For `test` and `run`, it executes the artifacts through the process interface. +For `test` and `run`, arguments after `--` belong to the executable, not the host build. +Other commands retain their `--` arguments, such as `fmt -- --check` and Clippy lint options. The host finds the Cargo workspace above the requested directory and copies its sources through the shared VFS. It excludes `target` and `.git`. Each workspace owns a source copy, target directory, registry, and kache cache. @@ -15,6 +17,7 @@ The PID namespace removes compiler descendants and the private kache daemon afte The builder copies changed and new source files back through the VFS. It publishes WASI executables into the world's workspace target directory. +After `cargo clean`, it removes the published artifacts that Cargo removed from the native target. Every target build links the shared cwd-init object, including its numeric exit-code interface. The command deadline defaults to 30 minutes. A closed response cancels the native command and its process group. The host retains at most 20 GiB of build directories and evicts the oldest workspace first. diff --git a/host/src/builder/files.rs b/host/src/builder/files.rs index 20b6ee2..be61326 100644 --- a/host/src/builder/files.rs +++ b/host/src/builder/files.rs @@ -113,6 +113,25 @@ pub async fn copy_back( Ok(()) } +/// Remove published artifacts that Cargo clean removed from the native target. +pub async fn clean_artifacts(root: &dyn Layer, native: &Path, world: &str) -> Result<()> { + let entries = match root.list(world).await { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + for entry in entries { + let path = sausage_vfs::resolve(world, &entry.name); + let native = native.join(&entry.name); + if entry.directory { + Box::pin(clean_artifacts(root, &native, &path)).await?; + } else if entry.name.ends_with(".wasm") && !native.exists() { + root.remove_file(&path).await?; + } + } + Ok(()) +} + pub fn size(path: &Path) -> Result { let mut total = 0; for entry in std::fs::read_dir(path)? { diff --git a/host/src/builder/mod.rs b/host/src/builder/mod.rs index 7fd7bef..bb017aa 100644 --- a/host/src/builder/mod.rs +++ b/host/src/builder/mod.rs @@ -66,6 +66,7 @@ impl Builder { .context("timeout too large")?; let world_cwd = sausage_vfs::resolve("/", &request.cwd); let workspace = files::workspace(self.root.as_ref(), &world_cwd).await?; + let world_target = sausage_vfs::resolve(&workspace, "target"); use std::hash::{Hash, Hasher}; let mut hash = std::collections::hash_map::DefaultHasher::new(); workspace.hash(&mut hash); @@ -223,7 +224,9 @@ impl Builder { if action == "run" { args[0] = "build".into(); } - if let Some(index) = args.iter().position(|a| a == "--") { + if matches!(action, "run" | "test") + && let Some(index) = args.iter().position(|a| a == "--") + { args.truncate(index); } if action == "test" { @@ -233,7 +236,8 @@ impl Builder { args.push("--no-run".into()); } } - cmd.args(args); + let split = args.iter().position(|a| a == "--").unwrap_or(args.len()); + cmd.args(&args[..split]); if ["build", "check", "clippy", "test", "run"].contains(&action) { cmd.args([ "--target", @@ -241,21 +245,30 @@ impl Builder { "--message-format=json-render-diagnostics", ]); } + cmd.args(&args[split..]); let (status, stdout) = run(cmd, tx, deadline).await?; code = status; + if action == "clean" && code == 0 { + files::clean_artifacts(self.root.as_ref(), &target, &world_target).await?; + } let artifacts = target.join("wasm32-wasip2"); if artifacts.exists() { files::copy_back( self.root.as_ref(), &artifacts, - &format!("{workspace}/target/wasm32-wasip2"), + &format!("{world_target}/wasm32-wasip2"), true, ) .await?; } if code == 0 && matches!(action, "test" | "run") - && !request.argv.iter().any(|a| a == "--no-run") + && !(action == "test" + && request + .argv + .iter() + .take_while(|a| a.as_str() != "--") + .any(|a| a == "--no-run")) { for line in stdout.split(|b| *b == b'\n') { if let Ok(value) = serde_json::from_slice::(line) @@ -264,7 +277,7 @@ impl Builder { && let Some(path) = value["executable"].as_str() && let Ok(relative) = Path::new(path).strip_prefix(&target) { - executables.push(format!("{workspace}/target/{}", relative.display())); + executables.push(format!("{world_target}/{}", relative.display())); } } executables.sort(); diff --git a/host/src/builder/tests.rs b/host/src/builder/tests.rs index 4c14774..c0a18f7 100644 --- a/host/src/builder/tests.rs +++ b/host/src/builder/tests.rs @@ -32,6 +32,35 @@ async fn discovers_workspace_and_skips_target() -> Result<()> { assert!(!copy.join("target").exists()); Ok(()) } +#[tokio::test] +async fn clean_removes_only_deleted_artifacts() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + let native = temp.path().join("native"); + std::fs::create_dir_all(&native)?; + std::fs::write(native.join("keep.wasm"), b"keep")?; + for name in ["keep.wasm", "remove.wasm", "note.txt"] { + files::write( + world.root.as_ref(), + &format!("/target/{name}"), + b"data".to_vec(), + ) + .await?; + } + files::clean_artifacts(world.root.as_ref(), &native, "/target").await?; + assert!( + files::read(world.root.as_ref(), "/target/remove.wasm") + .await + .is_err() + ); + for name in ["keep.wasm", "note.txt"] { + assert_eq!( + files::read(world.root.as_ref(), &format!("/target/{name}")).await?, + b"data" + ); + } + Ok(()) +} #[test] fn relative_host_directory_uses_absolute_namespace_paths() -> Result<()> { let temp = tempfile::tempdir()?; @@ -69,17 +98,21 @@ fn disk_cap_removes_workspaces() -> Result<()> { } #[tokio::test] async fn closed_receiver_cancels_command() -> Result<()> { - let (tx, rx) = mpsc::channel(1); - drop(rx); + let (tx, mut rx) = mpsc::channel(1); let mut cmd = Command::new("sh"); - cmd.args(["-c", "exec sleep 1000"]); - assert!( - tokio::time::timeout( - Duration::from_secs(2), - run(cmd, &tx, Instant::now() + Duration::from_secs(60)) - ) + cmd.args(["-c", "printf ready; exec sleep 1000"]); + let task = + tokio::spawn(async move { run(cmd, &tx, Instant::now() + Duration::from_secs(60)).await }); + // Output must arrive before the command exits, not after collection completes. + let event = tokio::time::timeout(Duration::from_secs(2), rx.recv()) .await? - .is_err() + .context("missing streamed output")?; + assert!(matches!(event, Event::Stdout { bytes } if bytes == b"ready")); + drop(rx); + assert!( + tokio::time::timeout(Duration::from_secs(2), task) + .await?? + .is_err() ); Ok(()) } diff --git a/host/tests/builder.rs b/host/tests/builder.rs index 6fb7bf1..87e43c8 100644 --- a/host/tests/builder.rs +++ b/host/tests/builder.rs @@ -64,17 +64,19 @@ fn cache_hits(base: &Path) -> anyhow::Result { .arg("stats") .env("KACHE_CACHE_DIR", base.join("kache")) .output()?; - assert!(stats.status.success()); + assert!( + stats.status.success(), + "{}", + String::from_utf8_lossy(&stats.stderr) + ); let text = String::from_utf8_lossy(&stats.stdout); println!("PRIVATE CACHE: {text}"); - let hits = text - .lines() - .find(|line| line.contains("hits")) - .and_then(|line| { - line.split_whitespace() - .find_map(|word| word.replace(',', "").parse::().ok()) - }) - .unwrap_or(0); + let words: Vec<_> = text.split_whitespace().collect(); + let hits = words.windows(2).find_map(|pair| { + (pair[1] == "hits") + .then(|| pair[0].replace(',', "").parse::().ok()) + .flatten() + }); assert!( std::process::Command::new("kache") .args(["daemon", "stop"]) @@ -82,7 +84,7 @@ fn cache_hits(base: &Path) -> anyhow::Result { .status()? .success() ); - Ok(hits) + Ok(hits.expect("kache stats must report its lifetime hits")) } #[tokio::test(flavor = "multi_thread")] async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> { @@ -145,6 +147,9 @@ async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> { let (code, out) = cargo(&runtime, &["run"]).await?; assert_eq!(code, 0, "{out}"); assert!(out.contains("builder fixture cwd: /project"), "{out}"); + let (code, out) = cargo(&runtime, &["run", "--", "--no-run"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("builder fixture cwd: /project"), "{out}"); let (code, out) = cargo(&runtime, &["run", "--", "--exit"]).await?; assert_eq!(code, 23, "{out}"); let (code, out) = cargo(&runtime, &["test", "pass", "--", "--nocapture"]).await?; @@ -161,6 +166,9 @@ async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> { let (code, out) = cargo(&runtime, &[action]).await?; assert_eq!(code, 0, "{action}: {out}"); } + let (code, out) = cargo(&runtime, &["clippy", "--", "-D", "clippy::print_stdout"]).await?; + assert_ne!(code, 0, "{out}"); + assert!(out.contains("stdout"), "{out}"); let base = std::fs::read_dir(temp.path().join("host/builds"))? .next() .unwrap()? @@ -168,6 +176,11 @@ async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> { let before = cache_hits(&base)?; let (code, out) = cargo(&runtime, &["clean"]).await?; assert_eq!(code, 0, "{out}"); + assert!( + !source + .join("target/wasm32-wasip2/debug/builder-fixture.wasm") + .exists() + ); let (code, out) = cargo(&runtime, &["build"]).await?; assert_eq!(code, 0, "{out}"); let after = cache_hits(&base)?; @@ -180,6 +193,10 @@ async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> { source.join("src/main.rs"), "fn main(){println!(\"format me\");}\n", )?; + let unformatted = std::fs::read(source.join("src/main.rs"))?; + let (code, out) = cargo(&runtime, &["fmt", "--", "--check"]).await?; + assert_ne!(code, 0, "{out}"); + assert_eq!(std::fs::read(source.join("src/main.rs"))?, unformatted); let (code, out) = cargo(&runtime, &["fmt"]).await?; assert_eq!(code, 0, "{out}"); assert!(std::fs::read_to_string(source.join("src/main.rs"))?.contains("fn main() {"));