From e53211eff1c0c03fb1d7e2a4b30a8fe6cb58ca04 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 13:24:53 +0000 Subject: [PATCH] builder: delegate run selection and preserve test working directories --- host/src/builder/README.md | 26 +++++-- host/src/builder/mod.rs | 70 +++++++++++++++---- host/src/builder/run.nu | 4 ++ .../builder-fixture/tests/integration.rs | 5 ++ host/tests/builder.rs | 36 +++++++++- userland/cargo/src/lib.rs | 23 ++++-- 6 files changed, 137 insertions(+), 27 deletions(-) create mode 100644 host/src/builder/run.nu diff --git a/host/src/builder/README.md b/host/src/builder/README.md index 2c460ce..0056cec 100644 --- a/host/src/builder/README.md +++ b/host/src/builder/README.md @@ -1,21 +1,33 @@ # Rust builder -The world `cargo` sends `POST http://host.sausage/cargo` with `argv`, `cwd`, and optional `timeout_secs`. +The world `cargo` calls `POST http://host.sausage/cargo` with `argv` and `cwd`. +The request also accepts optional `timeout_secs`. 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. +`stdout` and `stderr` events contain byte arrays. +The final `exit` event contains a code and component locations. +Each location contains an executable path and working directory. + +The CLI forwards the bytes immediately. +For `test` and `run`, it executes the artifacts through the process interface. +Cargo selects run binaries through a capture runner, so `--bin` and `default-run` retain their normal behavior. +The test runner uses each package's root directory, even when the caller runs Cargo from a subdirectory. 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. +Each workspace owns a source copy and target directory. +It also owns a registry and kache cache. These directories live under `/host/builds/`. -Cargo fetches dependencies with network access. The command then runs offline inside bubblewrap namespaces. -Only the source copy, target, and private kache cache permit writes. -The registry and Nix store remain read-only. HOME and the host network are absent. +Cargo fetches dependencies with network access. +The command then runs offline inside bubblewrap namespaces. + +The source copy and target directory permit writes. +The private kache cache also permits writes. +The registry and Nix store remain read-only. +Host home directories and the host network are absent. The PID namespace removes compiler descendants and the private kache daemon after Cargo exits. The builder copies changed and new source files back through the VFS. diff --git a/host/src/builder/mod.rs b/host/src/builder/mod.rs index 4894657..bf54efb 100644 --- a/host/src/builder/mod.rs +++ b/host/src/builder/mod.rs @@ -24,12 +24,24 @@ pub struct Request { pub cwd: String, pub timeout_secs: Option, } +#[derive(Serialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct Executable { + pub path: String, + pub cwd: String, +} #[derive(Serialize)] #[serde(tag = "event", rename_all = "snake_case")] pub enum Event { - Stdout { bytes: Vec }, - Stderr { bytes: Vec }, - Exit { code: i32, executables: Vec }, + Stdout { + bytes: Vec, + }, + Stderr { + bytes: Vec, + }, + Exit { + code: i32, + executables: Vec, + }, } pub struct Builder { root: Arc, @@ -150,6 +162,7 @@ impl Builder { "link.nu", include_bytes!("../../../userland/cwd-init/link.nu").as_slice(), ), + ("run.nu", include_bytes!("run.nu").as_slice()), ] { std::fs::write(tools.join(name), bytes)?; } @@ -178,6 +191,10 @@ impl Builder { return Ok(()); } let kache = executable("kache")?; + let run_path = target_mount.join("run-path"); + if action == "run" && run_path.exists() { + std::fs::remove_file(&run_path)?; + } let mut cmd = Command::new("bwrap"); cmd.args([ "--unshare-user", @@ -220,6 +237,8 @@ impl Builder { .arg(&cache) .args(["--setenv", "SAUSAGE_CWD_INIT"]) .arg(tools.join("init.o")) + .args(["--setenv", "SAUSAGE_RUN_PATH"]) + .arg(&run_path) .args(["--setenv", "CARGO_BUILD_JOBS", "2"]); for name in [ "CC_wasm32_wasip2", @@ -242,9 +261,6 @@ impl Builder { "target.wasm32-wasip2.rustflags=['-C','link-arg=sausage-cwd-init']", ]); let mut args = request.argv.clone(); - if action == "run" { - args[0] = "build".into(); - } if matches!(action, "run" | "test") && let Some(index) = args.iter().position(|a| a == "--") { @@ -259,6 +275,13 @@ impl Builder { } let split = args.iter().position(|a| a == "--").unwrap_or(args.len()); cmd.args(&args[..split]); + if action == "run" { + cmd.arg("--config").arg(format!( + "target.wasm32-wasip2.runner=[{:?},{:?}]", + executable("nu")?, + tools.join("run.nu") + )); + } if compiles { cmd.args([ "--target", @@ -282,23 +305,40 @@ impl Builder { ) .await?; } + if code == 0 && action == "run" && run_path.exists() { + let path = std::fs::read_to_string(&run_path)?; + let relative = Path::new(&path).strip_prefix(&target)?; + executables.push(Executable { + path: format!("{world_target}/{}", relative.display()), + cwd: world_cwd.clone(), + }); + } if code == 0 - && matches!(action, "test" | "run") - && !(action == "test" - && request - .argv - .iter() - .take_while(|a| a.as_str() != "--") - .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) && value["reason"] == "compiler-artifact" - && (action == "run" || value["profile"]["test"] == true) + && value["profile"]["test"] == true && let Some(path) = value["executable"].as_str() && let Ok(relative) = Path::new(path).strip_prefix(&target) { - executables.push(format!("{world_target}/{}", relative.display())); + let manifest = Path::new( + value["manifest_path"] + .as_str() + .context("missing test manifest")?, + ); + let package = manifest.parent().unwrap().strip_prefix(&source)?; + let cwd = sausage_vfs::resolve(&workspace, &package.to_string_lossy()); + executables.push(Executable { + path: format!("{world_target}/{}", relative.display()), + cwd, + }); } } executables.sort(); diff --git a/host/src/builder/run.nu b/host/src/builder/run.nu new file mode 100644 index 0000000..2eebfa4 --- /dev/null +++ b/host/src/builder/run.nu @@ -0,0 +1,4 @@ +# Record Cargo's selected component without executing it on the host. +def main [executable: string] { + $executable | save --raw --force $env.SAUSAGE_RUN_PATH +} diff --git a/host/tests/builder-fixture/tests/integration.rs b/host/tests/builder-fixture/tests/integration.rs index dcfa869..6a880cb 100644 --- a/host/tests/builder-fixture/tests/integration.rs +++ b/host/tests/builder-fixture/tests/integration.rs @@ -1,5 +1,10 @@ //! Cargo executes each integration test component through the world process interface. #[test] fn pass_integration() { + assert!( + std::fs::read_to_string("Cargo.toml") + .unwrap() + .contains("builder-fixture") + ); println!("INTEGRATION_CASE"); } diff --git a/host/tests/builder.rs b/host/tests/builder.rs index 87e43c8..ef46819 100644 --- a/host/tests/builder.rs +++ b/host/tests/builder.rs @@ -41,6 +41,13 @@ fn programs() -> PathBuf { .clone() } async fn cargo(runtime: &Arc, args: &[&str]) -> anyhow::Result<(i32, String)> { + cargo_at(runtime, args, "/project").await +} +async fn cargo_at( + runtime: &Arc, + args: &[&str], + cwd: &str, +) -> anyhow::Result<(i32, String)> { let mut bytes = Vec::new(); let code = runtime .exec( @@ -50,7 +57,7 @@ async fn cargo(runtime: &Arc, args: &[&str]) -> anyhow::Result<(i32, St .collect(), ), Options { - cwd: "/project".into(), + cwd: cwd.into(), timeout: Some(Duration::from_secs(120)), ..Default::default() }, @@ -156,12 +163,39 @@ async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> { assert_eq!(code, 0, "{out}"); assert!(out.contains("1 passed"), "{out}"); assert!(out.contains("INTEGRATION_CASE"), "{out}"); + let (code, out) = cargo_at( + &runtime, + &["test", "pass", "--", "--nocapture"], + "/project/src", + ) + .await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("INTEGRATION_CASE"), "{out}"); let (code, out) = cargo(&runtime, &["test", "fail"]).await?; assert_eq!(code, 101, "{out}"); assert!(out.contains("intentional fixture failure"), "{out}"); let (code, out) = cargo(&runtime, &["test", "--no-run"]).await?; assert_eq!(code, 0, "{out}"); assert!(!out.contains("intentional fixture failure"), "{out}"); + std::fs::create_dir_all(source.join("src/bin"))?; + std::fs::write( + source.join("src/bin/other.rs"), + "fn main() { println!(\"OTHER_BIN\"); }\n", + )?; + let (code, out) = cargo(&runtime, &["run"]).await?; + assert_eq!(code, 101, "{out}"); + assert!(out.contains("--bin"), "{out}"); + let manifest = std::fs::read_to_string(source.join("Cargo.toml"))?; + std::fs::write( + source.join("Cargo.toml"), + manifest.replace("[package]\n", "[package]\ndefault-run = \"other\"\n"), + )?; + let (code, out) = cargo(&runtime, &["run"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("OTHER_BIN"), "{out}"); + let (code, out) = cargo(&runtime, &["run", "--bin", "builder-fixture"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("builder fixture cwd: /project"), "{out}"); for action in ["check", "clippy", "fetch", "tree", "metadata", "update"] { let (code, out) = cargo(&runtime, &[action]).await?; assert_eq!(code, 0, "{action}: {out}"); diff --git a/userland/cargo/src/lib.rs b/userland/cargo/src/lib.rs index 0c90a90..7eaa064 100644 --- a/userland/cargo/src/lib.rs +++ b/userland/cargo/src/lib.rs @@ -4,9 +4,21 @@ use wasi::http::{outgoing_handler, types::*}; #[derive(serde::Deserialize)] #[serde(tag = "event", rename_all = "snake_case")] enum Event { - Stdout { bytes: Vec }, - Stderr { bytes: Vec }, - Exit { code: i32, executables: Vec }, + Stdout { + bytes: Vec, + }, + Stderr { + bytes: Vec, + }, + Exit { + code: i32, + executables: Vec, + }, +} +#[derive(serde::Deserialize)] +struct Executable { + path: String, + cwd: String, } fn error(value: impl std::fmt::Debug) -> io::Error { io::Error::other(format!("{value:?}")) @@ -86,7 +98,10 @@ pub fn run() -> io::Result { let action = argv.first().map(String::as_str).unwrap_or(""); let args = execution_args(&argv); for executable in executables { - let code = spawn::Command::new(executable).args(&args).status()?; + let code = spawn::Command::new(executable.path) + .current_dir(executable.cwd) + .args(&args) + .status()?; if code != 0 { return Ok(if action == "test" { 101 } else { code }); }