builder: delegate run selection and preserve test working directories

This commit is contained in:
2026-11-07 13:24:53 +00:00
parent 0be19f3f9c
commit e53211eff1
6 changed files with 137 additions and 27 deletions
+19 -7
View File
@@ -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 `<dir>/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.
+55 -15
View File
@@ -24,12 +24,24 @@ pub struct Request {
pub cwd: String,
pub timeout_secs: Option<u64>,
}
#[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<u8> },
Stderr { bytes: Vec<u8> },
Exit { code: i32, executables: Vec<String> },
Stdout {
bytes: Vec<u8>,
},
Stderr {
bytes: Vec<u8>,
},
Exit {
code: i32,
executables: Vec<Executable>,
},
}
pub struct Builder {
root: Arc<dyn Layer>,
@@ -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::<serde_json::Value>(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();
+4
View File
@@ -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
}
@@ -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");
}
+35 -1
View File
@@ -41,6 +41,13 @@ fn programs() -> PathBuf {
.clone()
}
async fn cargo(runtime: &Arc<Runtime>, args: &[&str]) -> anyhow::Result<(i32, String)> {
cargo_at(runtime, args, "/project").await
}
async fn cargo_at(
runtime: &Arc<Runtime>,
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<Runtime>, 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}");
+19 -4
View File
@@ -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<u8> },
Stderr { bytes: Vec<u8> },
Exit { code: i32, executables: Vec<String> },
Stdout {
bytes: Vec<u8>,
},
Stderr {
bytes: Vec<u8>,
},
Exit {
code: i32,
executables: Vec<Executable>,
},
}
#[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<i32> {
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 });
}