From f74b16dea9f4ef5a9921c49636c42d03bb552b26 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 10:36:25 +0000 Subject: [PATCH 1/8] builder: add runtime tools and preserve the host Cargo configuration --- flake.lock | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++- flake.nix | 14 ++++++----- 2 files changed, 75 insertions(+), 7 deletions(-) diff --git a/flake.lock b/flake.lock index b6986fb..2a693b1 100644 --- a/flake.lock +++ b/flake.lock @@ -33,6 +33,29 @@ "type": "github" } }, + "kache": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ], + "rust-overlay": "rust-overlay", + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1792081631, + "narHash": "sha256-thzKe50UE6CKYYfKNrVyNdgQdAqr0bLRYOZzvWwlxW8=", + "owner": "kunobi-ninja", + "repo": "kache", + "rev": "96052b4842239bb216f6c4812054203c21550788", + "type": "github" + }, + "original": { + "owner": "kunobi-ninja", + "ref": "stable", + "repo": "kache", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1791637130, @@ -53,11 +76,33 @@ "inputs": { "crane": "crane", "flake-utils": "flake-utils", + "kache": "kache", "nixpkgs": "nixpkgs", - "rust-overlay": "rust-overlay" + "rust-overlay": "rust-overlay_2" } }, "rust-overlay": { + "inputs": { + "nixpkgs": [ + "kache", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1791101754, + "narHash": "sha256-y/GF+9B0t0TZ0nQiSRMqDXpr76yT7sdDbS/3GNLhzkE=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "dbc715a4b7c0ace63b9769a032d1dd34cd89e5bd", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "rust-overlay_2": { "inputs": { "nixpkgs": [ "nixpkgs" @@ -91,6 +136,27 @@ "repo": "default", "type": "github" } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "kache", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1784369104, + "narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "df3c0640565d04a0261253cdd89fce78ec50168a", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index e61ce59..1ebff48 100644 --- a/flake.nix +++ b/flake.nix @@ -7,9 +7,13 @@ rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; crane.url = "github:ipetkov/crane"; + kache = { + url = "github:kunobi-ninja/kache/stable"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = { nixpkgs, flake-utils, rust-overlay, crane, ... }: + outputs = { nixpkgs, flake-utils, rust-overlay, crane, kache, ... }: flake-utils.lib.eachSystem flake-utils.lib.allSystems (system: let pkgs = import nixpkgs { @@ -35,7 +39,8 @@ moldEnv = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { ${moldVar} = "-C link-arg=-fuse-ld=mold"; }; - runtimePath = pkgs.lib.makeBinPath ([ rust pkgs.stdenv.cc ] ++ mold); + builderTools = [ kache.packages.${system}.default pkgs.bubblewrap pkgs.nushell pkgs.wasm-tools ]; + runtimePath = pkgs.lib.makeBinPath ([ rust pkgs.stdenv.cc ] ++ builderTools ++ mold); # A WASI clang with wasi-libc, so that build scripts (the `cc` crate) # can compile C for wasm32-wasip2. Its headers are WASI 0.1: C code # that needs sockets does not build. @@ -204,10 +209,7 @@ apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; }; devShells.default = pkgs.mkShell (wasiCcEnv // moldEnv // { - packages = [ rust pkgs.wasmtime pkgs.wasm-tools ] ++ mold; - shellHook = '' - export CARGO_HOME="$PWD/.cargo-home" - ''; + packages = [ rust pkgs.wasmtime ] ++ builderTools ++ mold; }); }); } From c96e59417a145f26b12d799d2b9e0fa2c605408c Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 11:59:28 +0000 Subject: [PATCH 2/8] builder: add native Cargo service and world CLI --- Cargo.lock | 1 + flake.nix | 2 +- host/Cargo.toml | 1 + host/src/api/cargo.rs | 60 +++ host/src/api/mod.rs | 4 + host/src/builder/README.md | 44 ++ host/src/builder/files.rs | 127 ++++++ host/src/builder/mod.rs | 410 ++++++++++++++++++ host/src/builder/tests.rs | 85 ++++ host/src/lib.rs | 1 + host/src/process/http.rs | 11 +- host/src/process/mod.rs | 4 + host/tests/builder-fixture/Cargo.toml | 9 + host/tests/builder-fixture/build.rs | 39 ++ host/tests/builder-fixture/src/add.c | 1 + host/tests/builder-fixture/src/main.rs | 22 + .../builder-fixture/tests/integration.rs | 5 + host/tests/builder.rs | 262 +++++++++++ host/tests/hook_git.rs | 10 +- host/tests/hook_guest.rs | 5 +- host/tests/process.rs | 44 +- userland/Cargo.lock | 10 + userland/Cargo.toml | 2 +- userland/cargo/Cargo.toml | 14 + userland/cargo/src/lib.rs | 131 ++++++ userland/cargo/src/main.rs | 10 + 26 files changed, 1278 insertions(+), 36 deletions(-) create mode 100644 host/src/api/cargo.rs create mode 100644 host/src/builder/README.md create mode 100644 host/src/builder/files.rs create mode 100644 host/src/builder/mod.rs create mode 100644 host/src/builder/tests.rs create mode 100644 host/tests/builder-fixture/Cargo.toml create mode 100644 host/tests/builder-fixture/build.rs create mode 100644 host/tests/builder-fixture/src/add.c create mode 100644 host/tests/builder-fixture/src/main.rs create mode 100644 host/tests/builder-fixture/tests/integration.rs create mode 100644 host/tests/builder.rs create mode 100644 userland/cargo/Cargo.toml create mode 100644 userland/cargo/src/lib.rs create mode 100644 userland/cargo/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 7359c40..9c7e02a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5035,6 +5035,7 @@ dependencies = [ "hyper", "hyper-util", "if-addrs", + "libc", "openssl", "p256", "rsa", diff --git a/flake.nix b/flake.nix index 1ebff48..355d88d 100644 --- a/flake.nix +++ b/flake.nix @@ -39,7 +39,7 @@ moldEnv = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { ${moldVar} = "-C link-arg=-fuse-ld=mold"; }; - builderTools = [ kache.packages.${system}.default pkgs.bubblewrap pkgs.nushell pkgs.wasm-tools ]; + builderTools = [ kache.packages.${system}.default pkgs.bubblewrap pkgs.nushell pkgs.wasm-tools pkgs.bash pkgs.coreutils ]; runtimePath = pkgs.lib.makeBinPath ([ rust pkgs.stdenv.cc ] ++ builderTools ++ mold); # A WASI clang with wasi-libc, so that build scripts (the `cc` crate) # can compile C for wasm32-wasip2. Its headers are WASI 0.1: C code diff --git a/host/Cargo.toml b/host/Cargo.toml index f1e3a08..6625717 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -62,6 +62,7 @@ http-body-util = "0.1.5" hyper-util = { version = "0.1.21", features = ["tokio"] } hyper = { version = "1.12.0", features = ["client", "http1", "server"] } tiny_http = "0.12" +libc = "0.2.190" [dev-dependencies] tempfile = "3" diff --git a/host/src/api/cargo.rs b/host/src/api/cargo.rs new file mode 100644 index 0000000..81f009e --- /dev/null +++ b/host/src/api/cargo.rs @@ -0,0 +1,60 @@ +//! A streaming Cargo response. Dropping the body cancels the builder's native process group. +use crate::builder::Event; +use bytes::Bytes; +use http::{Request, Response}; +use http_body_util::BodyExt; +use std::{ + pin::Pin, + task::{Context, Poll}, +}; +struct Body(tokio::sync::mpsc::Receiver); +impl http_body::Body for Body { + type Data = Bytes; + type Error = wasmtime_wasi_http::Error; + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + self.0.poll_recv(cx).map(|event| { + event.map(|event| { + let mut bytes = serde_json::to_vec(&event).expect("Cargo event"); + bytes.push(b'\n'); + Ok(http_body::Frame::data(Bytes::from(bytes))) + }) + }) + } +} +pub(crate) fn stream( + api: &super::Api, + request: Request, +) -> Response { + let (tx, rx) = tokio::sync::mpsc::channel(16); + let builder = api.builder.get().cloned(); + let request = serde_json::from_slice::(request.body()); + tokio::spawn(async move { + let result = async { + builder + .ok_or_else(|| anyhow::anyhow!("builder unavailable"))? + .execute(request?, &tx) + .await + } + .await; + if let Err(error) = result { + let _ = tx + .send(Event::Stderr { + bytes: format!("cargo: {error:#}\n").into_bytes(), + }) + .await; + let _ = tx + .send(Event::Exit { + code: 1, + executables: Vec::new(), + }) + .await; + } + }); + Response::builder() + .header("content-type", "application/x-ndjson") + .body(Body(rx).boxed_unsync()) + .unwrap() +} diff --git a/host/src/api/mod.rs b/host/src/api/mod.rs index 310ee52..daa97fd 100644 --- a/host/src/api/mod.rs +++ b/host/src/api/mod.rs @@ -1,6 +1,8 @@ //! JSON routes for host.sausage. The transport supplies the trusted caller. //! Host-owned pages use passkeys and typed proofs for every authorization ceremony. mod auth; +mod cargo; +pub(crate) use cargo::stream; mod pages; mod server; mod ssh; @@ -19,6 +21,7 @@ use std::{ pub struct Api { pub cred: Arc, + pub(crate) builder: std::sync::OnceLock>, script: cred::quickjs::QuickJs, pub passkeys: Mutex>, pub entry: EntryKey, @@ -47,6 +50,7 @@ impl Api { ) -> Self { Self { cred: cred.into(), + builder: Default::default(), script: cred::quickjs::QuickJs::default(), passkeys: Mutex::new(passkeys), entry, diff --git a/host/src/builder/README.md b/host/src/builder/README.md new file mode 100644 index 0000000..64ff8a0 --- /dev/null +++ b/host/src/builder/README.md @@ -0,0 +1,44 @@ +# Rust builder + +The world `cargo` sends `POST http://host.sausage/cargo` with `argv`, `cwd`, and 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. + +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. +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. +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. +It publishes WASI executables into the world's workspace target directory. +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. + +The Nix wrapper supplies these tools on PATH: + +- Rust and the native compiler +- The WASI C tools +- Bubblewrap +- Nushell +- wasm-tools +- Kache +- Bash and Coreutils + +Kache comes from the upstream flake at revision `96052b4842239bb216f6c4812054203c21550788`. +The development shell supplies the same builder tools. +The builder resolves the wrapper and interpreter paths to their canonical Nix store paths. +The embedded cwd-init sources produce a startup object and a linker script inside each workspace's tool folder. +The linker script uses the canonical Nushell interpreter, so it needs no host `/usr/bin/env`. +The sandbox exposes Bash at `/bin/sh` for native build scripts. +It does not depend on a user-profile path or a host HOME file. +Development commands retain the global Cargo configuration and its kache wrapper. +The sandbox uses a private Cargo home and selects kache through `--config build.rustc-wrapper`. + +For `cargo add`, the network phase performs a dry run to populate the registry index. +The offline command makes the actual manifest change inside the namespaces. +The disk cap applies after each completed command, including compiler failures. diff --git a/host/src/builder/files.rs b/host/src/builder/files.rs new file mode 100644 index 0000000..20b6ee2 --- /dev/null +++ b/host/src/builder/files.rs @@ -0,0 +1,127 @@ +//! Workspace discovery and source/artifact copies through the world's shared VFS. +use anyhow::{Context, Result}; +use sausage_vfs::{Layer, Open}; +use std::path::Path; + +pub async fn read(root: &dyn Layer, path: &str) -> Result> { + let file = root + .open( + path, + Open { + follow: true, + ..Default::default() + }, + ) + .await?; + let size = file.stat().await?.size; + Ok(file.read(0, usize::try_from(size)?).await?) +} +pub async fn workspace(root: &dyn Layer, cwd: &str) -> Result { + let mut path = sausage_vfs::resolve("/", cwd); + let mut nearest = None; + loop { + let manifest = sausage_vfs::resolve(&path, "Cargo.toml"); + if let Ok(bytes) = read(root, &manifest).await { + let value: toml::Value = toml::from_str(std::str::from_utf8(&bytes)?)?; + if value.get("workspace").is_some() { + return Ok(path); + } + if nearest.is_none() { + nearest = Some(path.clone()); + } + } + if path == "/" { + break; + } + path = sausage_vfs::resolve(&path, ".."); + } + nearest.context("no Cargo.toml above cwd") +} +pub async fn copy_out(root: &dyn Layer, source: &str, destination: &Path) -> Result<()> { + std::fs::create_dir_all(destination)?; + for entry in root.list(source).await? { + if matches!(entry.name.as_str(), "target" | ".git") { + continue; + } + let from = sausage_vfs::resolve(source, &entry.name); + let to = destination.join(&entry.name); + if entry.directory { + Box::pin(copy_out(root, &from, &to)).await?; + } else { + std::fs::write(to, read(root, &from).await?)?; + } + } + Ok(()) +} +pub async fn mkdir(root: &dyn Layer, path: &str) -> Result<()> { + let mut current = String::new(); + for part in path.split('/').filter(|p| !p.is_empty()) { + current.push('/'); + current.push_str(part); + if let Err(error) = root.mkdir(¤t).await + && error.kind() != std::io::ErrorKind::AlreadyExists + { + return Err(error.into()); + } + } + Ok(()) +} +pub async fn write(root: &dyn Layer, path: &str, bytes: Vec) -> Result<()> { + if read(root, path).await.ok().as_deref() == Some(bytes.as_slice()) { + return Ok(()); + } + mkdir(root, path.rsplit_once('/').unwrap().0).await?; + let file = root + .open( + path, + Open { + follow: true, + write: true, + create: true, + truncate: true, + ..Default::default() + }, + ) + .await?; + let mut offset = 0; + while offset < bytes.len() { + let written = file.write(offset as u64, bytes[offset..].to_vec()).await?; + anyhow::ensure!(written != 0, "source write made no progress"); + offset += written; + } + Ok(()) +} +pub async fn copy_back( + root: &dyn Layer, + source: &Path, + destination: &str, + artifacts: bool, +) -> Result<()> { + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let name = entry.file_name().to_string_lossy().into_owned(); + if matches!(name.as_str(), "target" | ".git") { + continue; + } + let to = sausage_vfs::resolve(destination, &name); + if entry.file_type()?.is_dir() { + Box::pin(copy_back(root, &entry.path(), &to, artifacts)).await?; + } else if !artifacts || entry.path().extension().is_some_and(|e| e == "wasm") { + write(root, &to, std::fs::read(entry.path())?).await?; + } + } + Ok(()) +} + +pub fn size(path: &Path) -> Result { + let mut total = 0; + for entry in std::fs::read_dir(path)? { + let entry = entry?; + total += if entry.file_type()?.is_dir() { + size(&entry.path())? + } else { + entry.metadata()?.len() + }; + } + Ok(total) +} diff --git a/host/src/builder/mod.rs b/host/src/builder/mod.rs new file mode 100644 index 0000000..7fd7bef --- /dev/null +++ b/host/src/builder/mod.rs @@ -0,0 +1,410 @@ +//! Native Cargo execution, isolated workspace copies, streamed output, and bounded disk use. +mod files; +#[cfg(test)] +mod tests; +use anyhow::{Context, Result, ensure}; +use sausage_vfs::Layer; +use serde::{Deserialize, Serialize}; +use std::{ + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::Duration, +}; +use tokio::{ + io::AsyncReadExt, + process::Command, + sync::{Mutex, mpsc}, + time::Instant, +}; + +#[derive(Deserialize)] +pub struct Request { + pub argv: Vec, + pub cwd: String, + pub timeout_secs: Option, +} +#[derive(Serialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum Event { + Stdout { bytes: Vec }, + Stderr { bytes: Vec }, + Exit { code: i32, executables: Vec }, +} +pub struct Builder { + root: Arc, + directory: PathBuf, + lock: Mutex<()>, +} +impl Builder { + pub fn new(root: Arc, host: &Path) -> Result { + Ok(Self { + root, + directory: std::fs::canonicalize(host)?.join("builds"), + lock: Mutex::new(()), + }) + } + pub async fn execute(&self, request: Request, tx: &mpsc::Sender) -> Result<()> { + let _guard = self.lock.lock().await; + let action = request + .argv + .first() + .context("missing Cargo command")? + .as_str(); + ensure!( + [ + "build", "check", "clippy", "test", "run", "fmt", "fetch", "add", "remove", + "update", "tree", "metadata", "clean" + ] + .contains(&action), + "unsupported Cargo command" + ); + let seconds = request.timeout_secs.unwrap_or(1800); + ensure!(seconds > 0, "timeout must be positive"); + let deadline = Instant::now() + .checked_add(Duration::from_secs(seconds)) + .context("timeout too large")?; + let world_cwd = sausage_vfs::resolve("/", &request.cwd); + let workspace = files::workspace(self.root.as_ref(), &world_cwd).await?; + use std::hash::{Hash, Hasher}; + let mut hash = std::collections::hash_map::DefaultHasher::new(); + workspace.hash(&mut hash); + let id = format!("{:016x}", hash.finish()); + let base = self.directory.join(id); + let source = base.join("source"); + // Cargo clean must remove its directory, not a namespace mount point. + let target_mount = base.join("target"); + let target = target_mount.join("cargo"); + let registry = base.join("cargo"); + let cache = base.join("kache"); + if source.exists() { + std::fs::remove_dir_all(&source)?; + } + files::copy_out(self.root.as_ref(), &workspace, &source).await?; + for path in [&target, ®istry, &cache, &target.join("tmp")] { + std::fs::create_dir_all(path)?; + } + std::fs::write( + target.join("CACHEDIR.TAG"), + b"Signature: 8a477f597d28d172789f06886806bc55\n", + )?; + std::fs::write(base.join("last-used"), [])?; + let cwd = source.join( + world_cwd + .strip_prefix(&workspace) + .expect("workspace is an ancestor of cwd") + .trim_start_matches('/'), + ); + let mut fetch = Command::new("cargo"); + if action == "add" { + fetch.args(&request.argv).arg("--dry-run"); + } else { + fetch.arg("fetch"); + } + fetch.current_dir(&cwd).env("CARGO_HOME", ®istry); + let mut code = if matches!(action, "clean" | "fmt" | "remove") { + 0 + } else { + run(fetch, tx, deadline).await?.0 + }; + let mut executables = Vec::new(); + if code == 0 { + let tools = base.join("tools"); + std::fs::create_dir_all(&tools)?; + for (name, bytes) in [ + ( + "init.rs", + include_bytes!("../../../userland/cwd-init/init.rs").as_slice(), + ), + ( + "exit.wit", + include_bytes!("../../../userland/cwd-init/exit.wit").as_slice(), + ), + ( + "build.nu", + include_bytes!("../../../userland/cwd-init/build.nu").as_slice(), + ), + ( + "link.nu", + include_bytes!("../../../userland/cwd-init/link.nu").as_slice(), + ), + ] { + std::fs::write(tools.join(name), bytes)?; + } + let linker = std::fs::read_to_string(tools.join("link.nu"))?.replacen( + "#!/usr/bin/env nu", + &format!("#!{}", executable("nu")?.display()), + 1, + ); + std::fs::write(tools.join("link.nu"), linker)?; + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + tools.join("link.nu"), + std::fs::Permissions::from_mode(0o755), + )?; + let mut startup = Command::new("nu"); + startup.arg(tools.join("build.nu")); + let (startup_code, _) = run(startup, tx, deadline).await?; + if startup_code != 0 { + files::copy_back(self.root.as_ref(), &source, &workspace, false).await?; + self.evict(20 * 1024 * 1024 * 1024)?; + tx.send(Event::Exit { + code: startup_code, + executables, + }) + .await?; + return Ok(()); + } + let kache = executable("kache")?; + let mut cmd = Command::new("bwrap"); + cmd.args([ + "--unshare-user", + "--unshare-pid", + "--unshare-net", + "--die-with-parent", + "--ro-bind", + "/nix/store", + "/nix/store", + ]) + .arg("--bind") + .arg(target.join("tmp")) + .arg("/tmp") + .arg("--ro-bind") + .arg(executable("sh")?) + .arg("/bin/sh"); + for path in [&source, &target_mount, &cache] { + cmd.arg("--bind").arg(path).arg(path); + } + for path in [®istry, &tools] { + cmd.arg("--ro-bind").arg(path).arg(path); + } + cmd.args([ + "--proc", + "/proc", + "--dev", + "/dev", + "--remount-ro", + "/", + "--chdir", + ]) + .arg(&cwd) + .args(["--clearenv", "--setenv", "PATH"]) + .arg(std::env::var("PATH")?) + .args(["--setenv", "HOME", "/nonexistent", "--setenv", "CARGO_HOME"]) + .arg(®istry) + .args(["--setenv", "CARGO_TARGET_DIR"]) + .arg(&target) + .args(["--setenv", "KACHE_CACHE_DIR"]) + .arg(&cache) + .args(["--setenv", "SAUSAGE_CWD_INIT"]) + .arg(tools.join("init.o")) + .args(["--setenv", "CARGO_BUILD_JOBS", "2"]); + for name in [ + "CC_wasm32_wasip2", + "AR_wasm32_wasip2", + "CFLAGS_wasm32_wasip2", + ] { + if let Ok(value) = std::env::var(name) { + cmd.args(["--setenv", name]).arg(value); + } + } + cmd.args(["cargo", "--offline", "--config"]) + .arg(format!("build.rustc-wrapper={:?}", kache)) + .arg("--config") + .arg(format!( + "target.wasm32-wasip2.linker={:?}", + tools.join("link.nu") + )) + .args([ + "--config", + "target.wasm32-wasip2.rustflags=['-C','link-arg=sausage-cwd-init']", + ]); + let mut args = request.argv.clone(); + if action == "run" { + args[0] = "build".into(); + } + if let Some(index) = args.iter().position(|a| a == "--") { + args.truncate(index); + } + if action == "test" { + // Cargo's positional test filter belongs to the world runner, not the compiler. + args = test_build_args(&args); + if !args.iter().any(|a| a == "--no-run") { + args.push("--no-run".into()); + } + } + cmd.args(args); + if ["build", "check", "clippy", "test", "run"].contains(&action) { + cmd.args([ + "--target", + "wasm32-wasip2", + "--message-format=json-render-diagnostics", + ]); + } + let (status, stdout) = run(cmd, tx, deadline).await?; + code = status; + let artifacts = target.join("wasm32-wasip2"); + if artifacts.exists() { + files::copy_back( + self.root.as_ref(), + &artifacts, + &format!("{workspace}/target/wasm32-wasip2"), + true, + ) + .await?; + } + if code == 0 + && matches!(action, "test" | "run") + && !request.argv.iter().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) + && let Some(path) = value["executable"].as_str() + && let Ok(relative) = Path::new(path).strip_prefix(&target) + { + executables.push(format!("{workspace}/target/{}", relative.display())); + } + } + executables.sort(); + executables.dedup(); + } + } + files::copy_back(self.root.as_ref(), &source, &workspace, false).await?; + self.evict(20 * 1024 * 1024 * 1024)?; + tx.send(Event::Exit { code, executables }).await?; + Ok(()) + } + fn evict(&self, limit: u64) -> Result<()> { + let mut entries = Vec::new(); + let mut total = 0; + for entry in std::fs::read_dir(&self.directory)? { + let path = entry?.path(); + let size = files::size(&path)?; + total += size; + entries.push(( + std::fs::metadata(path.join("last-used"))?.modified()?, + path, + size, + )); + } + entries.sort_by_key(|e| e.0); + for (_, path, size) in entries { + if total <= limit { + break; + } + std::fs::remove_dir_all(path)?; + total -= size; + } + Ok(()) + } +} +fn executable(name: &str) -> Result { + let path = std::env::split_paths(&std::env::var_os("PATH").context("PATH missing")?) + .map(|p| p.join(name)) + .find(|p| p.is_file()) + .context(format!("missing {name}"))?; + Ok(std::fs::canonicalize(path)?) +} +fn test_build_args(args: &[String]) -> Vec { + let mut out = vec!["test".into()]; + let mut value = false; + for arg in &args[1..] { + if value || arg.starts_with('-') { + out.push(arg.clone()); + } + value = [ + "-p", + "--package", + "--test", + "--bin", + "--example", + "--features", + "--profile", + "--manifest-path", + "--exclude", + "-j", + "--jobs", + "-F", + "--config", + "--target", + "--target-dir", + "--message-format", + "--color", + "--lockfile-path", + "-Z", + ] + .contains(&arg.as_str()); + } + out +} +// The guard kills native descendants when Cargo exits or the caller cancels the command. +struct ProcessGroup(u32); +impl Drop for ProcessGroup { + fn drop(&mut self) { + unsafe { + libc::kill(-(self.0 as i32), libc::SIGKILL); + } + } +} +async fn pump( + mut reader: impl tokio::io::AsyncRead + Unpin, + tx: &mpsc::Sender, + stderr: bool, +) -> Result> { + let mut all = Vec::new(); + let mut bytes = vec![0; 8192]; + loop { + let n = reader.read(&mut bytes).await?; + if n == 0 { + return Ok(all); + } + if !stderr { + all.extend_from_slice(&bytes[..n]); + } + tx.send(if stderr { + Event::Stderr { + bytes: bytes[..n].to_vec(), + } + } else { + Event::Stdout { + bytes: bytes[..n].to_vec(), + } + }) + .await?; + } +} +async fn run( + mut cmd: Command, + tx: &mpsc::Sender, + deadline: Instant, +) -> Result<(i32, Vec)> { + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .process_group(0) + .kill_on_drop(true); + let mut child = cmd.spawn()?; + let group = ProcessGroup(child.id().unwrap()); + let out = child.stdout.take().unwrap(); + let err = child.stderr.take().unwrap(); + let collect = async { + let (status, out, _) = tokio::try_join!( + async { Ok::<_, anyhow::Error>(child.wait().await?) }, + pump(out, tx, false), + pump(err, tx, true) + )?; + Ok::<_, anyhow::Error>((status.code().unwrap_or(1), out)) + }; + let result = tokio::select! { + result = collect => result, + _ = tx.closed() => anyhow::bail!("caller exited"), + _ = tokio::time::sleep_until(deadline) => { + tx.send(Event::Stderr { bytes: b"cargo: command timed out\n".to_vec() }).await?; + Ok((124, Vec::new())) + } + }; + drop(group); + result +} diff --git a/host/src/builder/tests.rs b/host/src/builder/tests.rs new file mode 100644 index 0000000..4c14774 --- /dev/null +++ b/host/src/builder/tests.rs @@ -0,0 +1,85 @@ +//! Focused tests for source discovery, argument forwarding, eviction, and process cancellation. +use super::*; +#[tokio::test] +async fn discovers_workspace_and_skips_target() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + files::write( + world.root.as_ref(), + "/project/Cargo.toml", + b"[workspace]\nmembers=['member']".to_vec(), + ) + .await?; + files::write( + world.root.as_ref(), + "/project/member/Cargo.toml", + b"[package]\nname='member'\nversion='0.1.0'".to_vec(), + ) + .await?; + files::write( + world.root.as_ref(), + "/project/target/junk", + b"junk".to_vec(), + ) + .await?; + assert_eq!( + files::workspace(world.root.as_ref(), "/project/member/src").await?, + "/project" + ); + let copy = temp.path().join("copy"); + files::copy_out(world.root.as_ref(), "/project", ©).await?; + assert!(copy.join("member/Cargo.toml").exists()); + assert!(!copy.join("target").exists()); + Ok(()) +} +#[test] +fn relative_host_directory_uses_absolute_namespace_paths() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + let builder = Builder::new(world.root, Path::new("."))?; + assert!(builder.directory.is_absolute()); + Ok(()) +} +#[test] +fn test_filter_is_not_a_build_argument() { + let args = ["test", "-p", "example", "filter", "--release"].map(str::to_string); + assert_eq!( + test_build_args(&args), + ["test", "-p", "example", "--release"] + ); +} +#[test] +fn disk_cap_removes_workspaces() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + let builder = Builder::new(world.root, &world.host_dir)?; + for name in ["a", "b"] { + let path = builder.directory.join(name); + std::fs::create_dir_all(&path)?; + std::fs::write(path.join("last-used"), [])?; + std::fs::write(path.join("data"), [0; 32])?; + } + std::fs::File::open(builder.directory.join("a/last-used"))? + .set_times(std::fs::FileTimes::new().set_modified(std::time::UNIX_EPOCH))?; + builder.evict(32)?; + assert!(!builder.directory.join("a").exists()); + assert!(builder.directory.join("b").exists()); + assert_eq!(files::size(&builder.directory)?, 32); + Ok(()) +} +#[tokio::test] +async fn closed_receiver_cancels_command() -> Result<()> { + let (tx, rx) = mpsc::channel(1); + drop(rx); + 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)) + ) + .await? + .is_err() + ); + Ok(()) +} diff --git a/host/src/lib.rs b/host/src/lib.rs index 048b719..9ad732b 100644 --- a/host/src/lib.rs +++ b/host/src/lib.rs @@ -2,6 +2,7 @@ //! and the durable agent harness. See `~/research/sausage-v3/PLAN.md` until the docs move here. pub mod api; +pub mod builder; pub mod cred; pub mod exec; pub mod harness; diff --git a/host/src/process/http.rs b/host/src/process/http.rs index 7db1772..747f5ae 100644 --- a/host/src/process/http.rs +++ b/host/src/process/http.rs @@ -45,9 +45,14 @@ impl WasiHttpHooks for Hooks { .is_some_and(|h| h.eq_ignore_ascii_case("host.sausage")) { let bytes = body.collect().await?.to_bytes(); - return Ok(response( - crate::api::handle(&router, caller, Request::from_parts(parts, bytes)).await, - )); + let request = Request::from_parts(parts, bytes); + if request.method() == http::Method::POST && request.uri().path() == "/cargo" { + return Ok(( + crate::api::stream(&router, request), + Box::new(async { Ok(()) }), + )); + } + return Ok(response(crate::api::handle(&router, caller, request).await)); } if !matches!(parts.uri.scheme_str(), Some("http" | "https")) { while let Some(frame) = body.frame().await { diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs index 5397a81..48a5d2e 100644 --- a/host/src/process/mod.rs +++ b/host/src/process/mod.rs @@ -191,6 +191,10 @@ impl Runtime { "http://localhost:8081", )?), }; + let _ = router.builder.set(Arc::new(crate::builder::Builder::new( + world.root.clone(), + &world.host_dir, + )?)); let network = Arc::new(net::NetPolicy::open(&world.host_dir)?); let engine = compiler.engine.clone(); let alive = Arc::new(AtomicBool::new(true)); diff --git a/host/tests/builder-fixture/Cargo.toml b/host/tests/builder-fixture/Cargo.toml new file mode 100644 index 0000000..98f63a4 --- /dev/null +++ b/host/tests/builder-fixture/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "builder-fixture" +version = "0.1.0" +edition = "2024" + +[workspace] + +[dependencies] +serde = "1" diff --git a/host/tests/builder-fixture/build.rs b/host/tests/builder-fixture/build.rs new file mode 100644 index 0000000..1d1f519 --- /dev/null +++ b/host/tests/builder-fixture/build.rs @@ -0,0 +1,39 @@ +//! Assert the native build script's namespace boundaries. +fn main() { + assert!(!std::path::Path::new("/home/surma/.ssh").exists()); + assert!( + !std::path::Path::new(&std::env::var("HOME").unwrap()) + .join(".ssh") + .exists() + ); + assert!(std::fs::write("/outside-probe", b"no").is_err()); + assert!( + std::net::TcpStream::connect_timeout( + &"1.1.1.1:443".parse().unwrap(), + std::time::Duration::from_millis(100) + ) + .is_err() + ); + let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); + let object = out.join("add.o"); + assert!( + std::process::Command::new(std::env::var("CC_wasm32_wasip2").unwrap()) + .args(["-c", "src/add.c", "-o"]) + .arg(&object) + .status() + .unwrap() + .success() + ); + assert!( + std::process::Command::new(std::env::var("AR_wasm32_wasip2").unwrap()) + .arg("crs") + .arg(out.join("libadd.a")) + .arg(&object) + .status() + .unwrap() + .success() + ); + println!("cargo:rustc-link-search=native={}", out.display()); + println!("cargo:rustc-link-lib=static=add"); + println!("cargo:warning=namespace assertions passed"); +} diff --git a/host/tests/builder-fixture/src/add.c b/host/tests/builder-fixture/src/add.c new file mode 100644 index 0000000..f83c3dc --- /dev/null +++ b/host/tests/builder-fixture/src/add.c @@ -0,0 +1 @@ +int c_add(int a, int b) { return a + b; } diff --git a/host/tests/builder-fixture/src/main.rs b/host/tests/builder-fixture/src/main.rs new file mode 100644 index 0000000..891f1b6 --- /dev/null +++ b/host/tests/builder-fixture/src/main.rs @@ -0,0 +1,22 @@ +//! An executable and tests that run only inside the world. +unsafe extern "C" { + fn c_add(a: i32, b: i32) -> i32; +} +fn main() { + if std::env::args().any(|arg| arg == "--exit") { + std::process::exit(23); + } + assert_eq!(unsafe { c_add(20, 22) }, 42); + println!( + "builder fixture cwd: {}", + std::env::current_dir().unwrap().display() + ); +} +#[test] +fn pass() { + assert_eq!(std::env::current_dir().unwrap().to_str(), Some("/project")); +} +#[test] +fn fail() { + panic!("intentional fixture failure"); +} diff --git a/host/tests/builder-fixture/tests/integration.rs b/host/tests/builder-fixture/tests/integration.rs new file mode 100644 index 0000000..dcfa869 --- /dev/null +++ b/host/tests/builder-fixture/tests/integration.rs @@ -0,0 +1,5 @@ +//! Cargo executes each integration test component through the world process interface. +#[test] +fn pass_integration() { + println!("INTEGRATION_CASE"); +} diff --git a/host/tests/builder.rs b/host/tests/builder.rs new file mode 100644 index 0000000..6fb7bf1 --- /dev/null +++ b/host/tests/builder.rs @@ -0,0 +1,262 @@ +//! Real-world Cargo acceptance: native namespaces, source changes, cache, and WASI execution. +use sausage::{ + exec::{Command, Exec, Options}, + process::Runtime, + world::World, +}; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, OnceLock}, + time::Duration, +}; +fn programs() -> PathBuf { + static PATH: OnceLock = OnceLock::new(); + PATH.get_or_init(|| { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap(); + assert!( + std::process::Command::new("nu") + .arg(root.join("userland/cwd-init/build.nu")) + .status() + .unwrap() + .success() + ); + assert!( + std::process::Command::new("cargo") + .args(["build", "--release", "-p", "world-cargo"]) + .current_dir(root.join("userland")) + .status() + .unwrap() + .success() + ); + assert!( + std::process::Command::new("nu") + .arg(root.join("userland/brush/build.nu")) + .status() + .unwrap() + .success() + ); + PathBuf::from(std::env::var_os("CARGO_TARGET_DIR").expect("executor target required")) + .join("wasm32-wasip2/release") + }) + .clone() +} +async fn cargo(runtime: &Arc, args: &[&str]) -> anyhow::Result<(i32, String)> { + let mut bytes = Vec::new(); + let code = runtime + .exec( + Command::Argv( + std::iter::once("/bin/cargo.wasm".into()) + .chain(args.iter().map(|a| a.to_string())) + .collect(), + ), + Options { + cwd: "/project".into(), + timeout: Some(Duration::from_secs(120)), + ..Default::default() + }, + &mut |_, b| bytes.extend_from_slice(b), + ) + .await?; + Ok((code, String::from_utf8_lossy(&bytes).into_owned())) +} +fn cache_hits(base: &Path) -> anyhow::Result { + let stats = std::process::Command::new("kache") + .arg("stats") + .env("KACHE_CACHE_DIR", base.join("kache")) + .output()?; + assert!(stats.status.success()); + 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); + assert!( + std::process::Command::new("kache") + .args(["daemon", "stop"]) + .env("KACHE_CACHE_DIR", base.join("kache")) + .status()? + .success() + ); + Ok(hits) +} +#[tokio::test(flavor = "multi_thread")] +async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> { + let binaries = programs(); + let temp = tempfile::tempdir()?; + let world = World::open(temp.path())?; + let bin = temp.path().join("world/bin"); + std::fs::create_dir_all(&bin)?; + std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?; + std::fs::copy(binaries.join("brush.wasm"), bin.join("sh.wasm"))?; + let source = temp.path().join("world/project"); + std::fs::create_dir_all(source.join("src"))?; + std::fs::create_dir_all(source.join("tests"))?; + for (path, bytes) in [ + ( + "Cargo.toml", + include_bytes!("builder-fixture/Cargo.toml").as_slice(), + ), + ( + "build.rs", + include_bytes!("builder-fixture/build.rs").as_slice(), + ), + ( + "src/main.rs", + include_bytes!("builder-fixture/src/main.rs").as_slice(), + ), + ( + "tests/integration.rs", + include_bytes!("builder-fixture/tests/integration.rs").as_slice(), + ), + ( + "src/add.c", + include_bytes!("builder-fixture/src/add.c").as_slice(), + ), + ] { + std::fs::write(source.join(path), bytes)?; + } + let runtime = Runtime::new(world)?; + let (code, out) = cargo(&runtime, &["build"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("namespace assertions passed"), "{out}"); + assert!(source.join("Cargo.lock").exists()); + let mut shell_output = Vec::new(); + let shell_code = runtime + .exec( + Command::Argv(vec![ + "/bin/sh.wasm".into(), + "-c".into(), + "./target/wasm32-wasip2/debug/builder-fixture.wasm".into(), + ]), + Options { + cwd: "/project".into(), + ..Default::default() + }, + &mut |_, b| shell_output.extend_from_slice(b), + ) + .await?; + assert_eq!(shell_code, 0, "{}", String::from_utf8_lossy(&shell_output)); + assert!(String::from_utf8_lossy(&shell_output).contains("builder fixture cwd: /project")); + 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", "--", "--exit"]).await?; + assert_eq!(code, 23, "{out}"); + let (code, out) = cargo(&runtime, &["test", "pass", "--", "--nocapture"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(out.contains("1 passed"), "{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}"); + for action in ["check", "clippy", "fetch", "tree", "metadata", "update"] { + let (code, out) = cargo(&runtime, &[action]).await?; + assert_eq!(code, 0, "{action}: {out}"); + } + let base = std::fs::read_dir(temp.path().join("host/builds"))? + .next() + .unwrap()? + .path(); + let before = cache_hits(&base)?; + let (code, out) = cargo(&runtime, &["clean"]).await?; + assert_eq!(code, 0, "{out}"); + let (code, out) = cargo(&runtime, &["build"]).await?; + assert_eq!(code, 0, "{out}"); + let after = cache_hits(&base)?; + println!("CACHE AFTER CLEAN: {before} -> {after} hits"); + assert!( + after > before, + "the second build did not restore cached actions" + ); + std::fs::write( + source.join("src/main.rs"), + "fn main(){println!(\"format me\");}\n", + )?; + 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() {")); + let (code, out) = cargo(&runtime, &["remove", "serde"]).await?; + assert_eq!(code, 0, "{out}"); + let (code, out) = cargo(&runtime, &["add", "serde"]).await?; + assert_eq!(code, 0, "{out}"); + assert!(std::fs::read_to_string(source.join("Cargo.toml"))?.contains("serde")); + std::fs::write( + source.join("src/main.rs"), + "fn main() { undefined_function(); }", + )?; + let (code, out) = cargo(&runtime, &["build"]).await?; + assert_ne!(code, 0, "{out}"); + assert!(out.contains("undefined_function"), "{out}"); + Ok(()) +} +#[tokio::test] +async fn timeout_and_cancellation() -> anyhow::Result<()> { + use sausage::builder::{Builder, Event, Request}; + let temp = tempfile::tempdir()?; + let world = World::open(temp.path())?; + let source = temp.path().join("world/project"); + std::fs::create_dir_all(source.join("src"))?; + std::fs::write( + source.join("Cargo.toml"), + "[package]\nname='hang'\nversion='0.1.0'\n[workspace]\n", + )?; + std::fs::write(source.join("src/main.rs"), "fn main() {}")?; + std::fs::write( + source.join("build.rs"), + "fn main() { std::process::Command::new(\"sh\").args([\"-c\", \"echo $$ > child.pid; exec sleep 1000\"]).status().unwrap(); }", + )?; + let builder = Arc::new(Builder::new(world.root.clone(), &world.host_dir)?); + let (tx, mut rx) = tokio::sync::mpsc::channel(16); + let task = tokio::spawn(async move { + builder + .execute( + Request { + argv: vec!["build".into()], + cwd: "/project".into(), + timeout_secs: Some(3), + }, + &tx, + ) + .await + }); + let mut timed_out = false; + while let Some(event) = rx.recv().await { + if let Event::Exit { code, .. } = event { + timed_out = code == 124; + } + } + task.await??; + assert!(timed_out); + let binaries = programs(); + let bin = temp.path().join("world/bin"); + std::fs::create_dir_all(&bin)?; + std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?; + let runtime = Runtime::new(world)?; + let code = runtime + .exec( + Command::Argv(vec!["/bin/cargo.wasm".into(), "build".into()]), + Options { + cwd: "/project".into(), + timeout: Some(Duration::from_secs(1)), + ..Default::default() + }, + &mut |_, _| {}, + ) + .await?; + assert_ne!(code, 0); + let (code, out) = + tokio::time::timeout(Duration::from_secs(5), cargo(&runtime, &["metadata"])).await??; + assert_eq!( + code, 0, + "the canceled build still held the builder lock: {out}" + ); + Ok(()) +} diff --git a/host/tests/hook_git.rs b/host/tests/hook_git.rs index 463cce1..8e4139c 100644 --- a/host/tests/hook_git.rs +++ b/host/tests/hook_git.rs @@ -24,7 +24,14 @@ fn git(cwd: &Path, args: &[&str]) { async fn several_mb_git_push() { static BUILT: OnceLock = OnceLock::new(); let component = BUILT.get_or_init(|| { - let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hook-git"); + let target = std::env::var_os("CARGO_TARGET_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_TARGET_TMPDIR")) + .parent() + .unwrap() + .to_owned() + }); let userland = Path::new(env!("CARGO_MANIFEST_DIR")).join("../userland"); if !userland.join("cwd-init/init.o").is_file() { assert!( @@ -71,6 +78,7 @@ async fn several_mb_git_push() { "-c", "user.email=test@example.com", "commit", + "--no-gpg-sign", "-m", "large commit", ], diff --git a/host/tests/hook_guest.rs b/host/tests/hook_guest.rs index d14adb8..ab8b22b 100644 --- a/host/tests/hook_guest.rs +++ b/host/tests/hook_guest.rs @@ -12,6 +12,7 @@ fn write(path: PathBuf, text: &str) -> std::io::Result<()> { pub fn component() -> &'static Path { static PATH: OnceLock = OnceLock::new(); PATH.get_or_init(|| { + let target = std::env::var_os("CARGO_TARGET_DIR").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_TARGET_TMPDIR")).parent().unwrap().to_owned()); let directory = Path::new(env!("CARGO_TARGET_TMPDIR")).join("hook-guest"); std::fs::create_dir_all(directory.join("src")).unwrap(); std::fs::create_dir_all(directory.join("wit/deps")).unwrap(); @@ -67,7 +68,7 @@ async fn main() -> anyhow::Result<()> { Ok(()) } "#).unwrap(); - assert!(std::process::Command::new("cargo").args(["build", "--manifest-path"]).arg(directory.join("Cargo.toml")).args(["--target", "wasm32-wasip2", "--release", "-j", "2"]).env("CARGO_TARGET_DIR", directory.join("target")).status().unwrap().success(), "test guest build failed"); - directory.join("target/wasm32-wasip2/release/hook-guest.wasm") + assert!(std::process::Command::new("cargo").args(["build", "--manifest-path"]).arg(directory.join("Cargo.toml")).args(["--target", "wasm32-wasip2", "--release", "-j", "2"]).env("CARGO_TARGET_DIR", &target).status().unwrap().success(), "test guest build failed"); + target.join("wasm32-wasip2/release/hook-guest.wasm") }) } diff --git a/host/tests/process.rs b/host/tests/process.rs index 517de26..27fc711 100644 --- a/host/tests/process.rs +++ b/host/tests/process.rs @@ -350,6 +350,16 @@ fn repo() -> PathBuf { .unwrap() .to_owned() } +fn target() -> PathBuf { + std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| { + Path::new(env!("CARGO_TARGET_TMPDIR")) + .parent() + .unwrap() + .to_owned() + }) +} fn build() { BUILD.call_once(|| { let root = repo(); @@ -357,8 +367,7 @@ fn build() { assert!( std::process::Command::new(args[0]) .args(&args[1..]) - .env_remove("CARGO_TARGET_DIR") - .env("CARGO_HOME", cwd.join(".cargo-home")) + .env("CARGO_TARGET_DIR", target()) .current_dir(cwd) .status() .unwrap() @@ -403,16 +412,7 @@ fn build() { &root.join("userland"), ); run( - &[ - "cargo", - "build", - "--release", - "-p", - "git", - "--target-dir", - "../target/process-userland", - "--locked", - ], + &["cargo", "build", "--release", "-p", "git", "--locked"], &root.join("userland"), ); }); @@ -442,7 +442,7 @@ fn world() -> (tempfile::TempDir, Arc, sausage::process::Processes) { let processes = world.processes.clone(); fixture(&temp.path().join("world")); - let artifacts = repo().join("userland/target/wasm32-wasip2/release"); + let artifacts = target().join("wasm32-wasip2/release"); let bin = temp.path().join("world/bin"); for tool in ["cat", "tr", "ls", "wc", "yes", "head", "sleep"] { std::fs::copy( @@ -455,21 +455,9 @@ fn world() -> (tempfile::TempDir, Arc, sausage::process::Processes) { std::fs::copy(artifacts.join("sausage-procps.wasm"), bin.join(tool)).unwrap(); } std::fs::copy(artifacts.join("sausage-cwd-probe.wasm"), bin.join("probe")).unwrap(); - std::fs::copy( - repo().join("userland/brush/source/target/wasm32-wasip2/release/brush.wasm"), - bin.join("sh"), - ) - .unwrap(); - std::fs::copy( - repo().join("userland/brush/probe/target/wasm32-wasip2/release/process-probe.wasm"), - bin.join("launcher"), - ) - .unwrap(); - std::fs::copy( - repo().join("target/process-userland/wasm32-wasip2/release/git.wasm"), - bin.join("git"), - ) - .unwrap(); + std::fs::copy(artifacts.join("brush.wasm"), bin.join("sh")).unwrap(); + std::fs::copy(artifacts.join("process-probe.wasm"), bin.join("launcher")).unwrap(); + std::fs::copy(artifacts.join("git.wasm"), bin.join("git")).unwrap(); ( temp, Runtime::new_with_cache(world, &cache).unwrap(), diff --git a/userland/Cargo.lock b/userland/Cargo.lock index 70d4431..2f052bb 100644 --- a/userland/Cargo.lock +++ b/userland/Cargo.lock @@ -6742,6 +6742,16 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "world-cargo" +version = "0.1.0" +dependencies = [ + "sausage-spawn", + "serde", + "serde_json", + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "write16" version = "1.0.0" diff --git a/userland/Cargo.toml b/userland/Cargo.toml index cca634f..463e507 100644 --- a/userland/Cargo.toml +++ b/userland/Cargo.toml @@ -1,7 +1,7 @@ # The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's. [workspace] resolver = "3" -members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init"] +members = ["git", "coreutils", "procps", "cwd-init/probe", "ripgrep", "diffutils", "jaq", "sed", "grep", "fd", "spawn", "findutils", "exec-tools", "ssh-client", "ssh", "curl", "init", "cargo"] # WASI compatibility patches: git/vendor/PATCHES.md. [patch.crates-io] diff --git a/userland/cargo/Cargo.toml b/userland/cargo/Cargo.toml new file mode 100644 index 0000000..4bc28dd --- /dev/null +++ b/userland/cargo/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "world-cargo" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "cargo" +path = "src/main.rs" + +[dependencies] +wasi = "0.14" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +spawn = { package = "sausage-spawn", path = "../spawn" } diff --git a/userland/cargo/src/lib.rs b/userland/cargo/src/lib.rs new file mode 100644 index 0000000..0c90a90 --- /dev/null +++ b/userland/cargo/src/lib.rs @@ -0,0 +1,131 @@ +//! Stream native compiler events and run the resulting components through the process interface. +use std::io::{self, Write}; +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 }, +} +fn error(value: impl std::fmt::Debug) -> io::Error { + io::Error::other(format!("{value:?}")) +} +pub fn run() -> io::Result { + let argv: Vec = std::env::args().skip(1).collect(); + let data = serde_json::to_vec( + &serde_json::json!({"argv": argv, "cwd": std::env::current_dir()?.to_string_lossy()}), + )?; + let fields = Fields::new(); + fields + .set("content-length", &[data.len().to_string().into_bytes()]) + .map_err(error)?; + fields + .set("content-type", &[b"application/json".to_vec()]) + .map_err(error)?; + let request = OutgoingRequest::new(fields); + request.set_method(&Method::Post).map_err(error)?; + request.set_scheme(Some(&Scheme::Http)).map_err(error)?; + request.set_authority(Some("host.sausage")).map_err(error)?; + request.set_path_with_query(Some("/cargo")).map_err(error)?; + let outgoing = request.body().map_err(error)?; + let options = RequestOptions::new(); + options + .set_between_bytes_timeout(Some(1860 * 1_000_000_000)) + .map_err(error)?; + let future = outgoing_handler::handle(request, Some(options)).map_err(error)?; + let stream = outgoing.write().map_err(error)?; + let mut remaining = data.as_slice(); + while !remaining.is_empty() { + stream.subscribe().block(); + let n = (stream.check_write().map_err(error)? as usize).min(remaining.len()); + stream.write(&remaining[..n]).map_err(error)?; + remaining = &remaining[n..]; + } + drop(stream); + OutgoingBody::finish(outgoing, None).map_err(error)?; + future.subscribe().block(); + let response = future + .get() + .ok_or_else(|| error("missing response"))? + .map_err(error)? + .map_err(error)?; + let incoming = response.consume().map_err(error)?; + let stream = incoming.stream().map_err(error)?; + let mut pending = Vec::new(); + let mut result = None; + loop { + stream.subscribe().block(); + match stream.read(65536) { + Ok(bytes) => pending.extend(bytes), + Err(wasi::io::streams::StreamError::Closed) => break, + Err(e) => return Err(error(e)), + } + while let Some(end) = pending.iter().position(|b| *b == b'\n') { + let line: Vec<_> = pending.drain(..=end).collect(); + match serde_json::from_slice::(&line)? { + Event::Stdout { bytes } => { + io::stdout().write_all(&bytes)?; + io::stdout().flush()?; + } + Event::Stderr { bytes } => { + io::stderr().write_all(&bytes)?; + io::stderr().flush()?; + } + Event::Exit { code, executables } => result = Some((code, executables)), + } + } + } + drop(stream); + drop(IncomingBody::finish(incoming)); + drop(response); + let (code, executables) = result.ok_or_else(|| error("missing Cargo exit event"))?; + if code != 0 { + return Ok(code); + } + 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()?; + if code != 0 { + return Ok(if action == "test" { 101 } else { code }); + } + } + Ok(0) +} +fn execution_args(argv: &[String]) -> Vec { + let split = argv.iter().position(|a| a == "--").unwrap_or(argv.len()); + let mut args = argv.get(split + 1..).unwrap_or_default().to_vec(); + if argv.first().is_some_and(|a| a == "test") { + let mut value = false; + for arg in &argv[1..split] { + if !value && !arg.starts_with('-') { + args.insert(0, arg.clone()); + break; + } + value = [ + "-p", + "--package", + "--test", + "--bin", + "--example", + "--features", + "--profile", + "--manifest-path", + "--exclude", + "-j", + "--jobs", + "-F", + "--config", + "--target", + "--target-dir", + "--message-format", + "--color", + "--lockfile-path", + "-Z", + ] + .contains(&arg.as_str()); + } + } + args +} diff --git a/userland/cargo/src/main.rs b/userland/cargo/src/main.rs new file mode 100644 index 0000000..b5f26d7 --- /dev/null +++ b/userland/cargo/src/main.rs @@ -0,0 +1,10 @@ +//! Cargo's world entry point. Compile through HTTP, then execute tests and programs in the world. +fn main() { + match world_cargo::run() { + Ok(code) => std::process::exit(code), + Err(error) => { + eprintln!("cargo: {error}"); + std::process::exit(1); + } + } +} From a27d579264b45832239e4b2ab748bbbac659607b Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 12:14:17 +0000 Subject: [PATCH 3/8] 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() {")); From 2f18f0fa4cce107c8fe3521186b86885a9a6d7c9 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 12:32:45 +0000 Subject: [PATCH 4/8] builder: enforce target, source sync, and timeout contracts --- host/src/builder/README.md | 15 ++++- host/src/builder/files.rs | 28 ++++++--- host/src/builder/mod.rs | 45 ++++++++++---- host/src/builder/tests.rs | 117 +++++++++++++++++++++++++++++++++++++ 4 files changed, 182 insertions(+), 23 deletions(-) diff --git a/host/src/builder/README.md b/host/src/builder/README.md index bf3595e..2c460ce 100644 --- a/host/src/builder/README.md +++ b/host/src/builder/README.md @@ -8,18 +8,27 @@ For `test` and `run`, arguments after `--` belong to the executable, not the hos 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. +It excludes `target` and `.git`. + +Each workspace owns a source copy, target directory, 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. 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. +It preserves other source files, including concurrent edits in the world. 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. +Every target build uses `wasm32-wasip2` and links the shared cwd-init object, including its numeric exit-code interface. +The host rejects other build targets and `--target-dir` overrides. + +The command deadline defaults to 30 minutes. +The host kills timed-out commands before it sends their timeout message. +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. The Nix wrapper supplies these tools on PATH: diff --git a/host/src/builder/files.rs b/host/src/builder/files.rs index be61326..7e3a2ef 100644 --- a/host/src/builder/files.rs +++ b/host/src/builder/files.rs @@ -1,7 +1,10 @@ //! Workspace discovery and source/artifact copies through the world's shared VFS. use anyhow::{Context, Result}; use sausage_vfs::{Layer, Open}; -use std::path::Path; +use std::{collections::HashMap, path::Path}; + +/// Source hashes before the native command changes its copy. +pub type Snapshot = HashMap; pub async fn read(root: &dyn Layer, path: &str) -> Result> { let file = root @@ -37,8 +40,9 @@ pub async fn workspace(root: &dyn Layer, cwd: &str) -> Result { } nearest.context("no Cargo.toml above cwd") } -pub async fn copy_out(root: &dyn Layer, source: &str, destination: &Path) -> Result<()> { +pub async fn copy_out(root: &dyn Layer, source: &str, destination: &Path) -> Result { std::fs::create_dir_all(destination)?; + let mut before = Snapshot::new(); for entry in root.list(source).await? { if matches!(entry.name.as_str(), "target" | ".git") { continue; @@ -46,12 +50,14 @@ pub async fn copy_out(root: &dyn Layer, source: &str, destination: &Path) -> Res let from = sausage_vfs::resolve(source, &entry.name); let to = destination.join(&entry.name); if entry.directory { - Box::pin(copy_out(root, &from, &to)).await?; + before.extend(Box::pin(copy_out(root, &from, &to)).await?); } else { - std::fs::write(to, read(root, &from).await?)?; + let bytes = read(root, &from).await?; + before.insert(from, openssl::sha::sha256(&bytes)); + std::fs::write(to, bytes)?; } } - Ok(()) + Ok(before) } pub async fn mkdir(root: &dyn Layer, path: &str) -> Result<()> { let mut current = String::new(); @@ -91,11 +97,12 @@ pub async fn write(root: &dyn Layer, path: &str, bytes: Vec) -> Result<()> { } Ok(()) } +/// A snapshot selects changed sources. Without a snapshot, publish only WASM artifacts. pub async fn copy_back( root: &dyn Layer, source: &Path, destination: &str, - artifacts: bool, + before: Option<&Snapshot>, ) -> Result<()> { for entry in std::fs::read_dir(source)? { let entry = entry?; @@ -105,9 +112,12 @@ pub async fn copy_back( } let to = sausage_vfs::resolve(destination, &name); if entry.file_type()?.is_dir() { - Box::pin(copy_back(root, &entry.path(), &to, artifacts)).await?; - } else if !artifacts || entry.path().extension().is_some_and(|e| e == "wasm") { - write(root, &to, std::fs::read(entry.path())?).await?; + Box::pin(copy_back(root, &entry.path(), &to, before)).await?; + } else if before.is_some() || entry.path().extension().is_some_and(|e| e == "wasm") { + let bytes = std::fs::read(entry.path())?; + if before.is_none_or(|before| before.get(&to) != Some(&openssl::sha::sha256(&bytes))) { + write(root, &to, bytes).await?; + } } } Ok(()) diff --git a/host/src/builder/mod.rs b/host/src/builder/mod.rs index bb017aa..a241b49 100644 --- a/host/src/builder/mod.rs +++ b/host/src/builder/mod.rs @@ -59,6 +59,25 @@ impl Builder { .contains(&action), "unsupported Cargo command" ); + let compiles = ["build", "check", "clippy", "test", "run"].contains(&action); + let mut args = request.argv.iter().take_while(|arg| arg.as_str() != "--"); + while let Some(arg) = args.next() { + ensure!( + arg != "--target-dir" && !arg.starts_with("--target-dir="), + "the builder controls the target directory" + ); + let target = if arg == "--target" { + args.next().map(String::as_str) + } else { + arg.strip_prefix("--target=") + }; + if compiles && let Some(target) = target { + ensure!( + target == "wasm32-wasip2", + "the build target is always wasm32-wasip2" + ); + } + } let seconds = request.timeout_secs.unwrap_or(1800); ensure!(seconds > 0, "timeout must be positive"); let deadline = Instant::now() @@ -81,7 +100,7 @@ impl Builder { if source.exists() { std::fs::remove_dir_all(&source)?; } - files::copy_out(self.root.as_ref(), &workspace, &source).await?; + let before = files::copy_out(self.root.as_ref(), &workspace, &source).await?; for path in [&target, ®istry, &cache, &target.join("tmp")] { std::fs::create_dir_all(path)?; } @@ -147,7 +166,7 @@ impl Builder { startup.arg(tools.join("build.nu")); let (startup_code, _) = run(startup, tx, deadline).await?; if startup_code != 0 { - files::copy_back(self.root.as_ref(), &source, &workspace, false).await?; + files::copy_back(self.root.as_ref(), &source, &workspace, Some(&before)).await?; self.evict(20 * 1024 * 1024 * 1024)?; tx.send(Event::Exit { code: startup_code, @@ -238,7 +257,7 @@ impl Builder { } let split = args.iter().position(|a| a == "--").unwrap_or(args.len()); cmd.args(&args[..split]); - if ["build", "check", "clippy", "test", "run"].contains(&action) { + if compiles { cmd.args([ "--target", "wasm32-wasip2", @@ -257,7 +276,7 @@ impl Builder { self.root.as_ref(), &artifacts, &format!("{world_target}/wasm32-wasip2"), - true, + None, ) .await?; } @@ -284,7 +303,7 @@ impl Builder { executables.dedup(); } } - files::copy_back(self.root.as_ref(), &source, &workspace, false).await?; + files::copy_back(self.root.as_ref(), &source, &workspace, Some(&before)).await?; self.evict(20 * 1024 * 1024 * 1024)?; tx.send(Event::Exit { code, executables }).await?; Ok(()) @@ -410,14 +429,18 @@ async fn run( )?; Ok::<_, anyhow::Error>((status.code().unwrap_or(1), out)) }; - let result = tokio::select! { - result = collect => result, + let (result, timed_out) = tokio::select! { + result = collect => (result, false), _ = tx.closed() => anyhow::bail!("caller exited"), - _ = tokio::time::sleep_until(deadline) => { - tx.send(Event::Stderr { bytes: b"cargo: command timed out\n".to_vec() }).await?; - Ok((124, Vec::new())) - } + _ = tokio::time::sleep_until(deadline) => (Ok((124, Vec::new())), true), }; + // Kill the command before a full output channel can delay its timeout message. drop(group); + if timed_out { + tx.send(Event::Stderr { + bytes: b"cargo: command timed out\n".to_vec(), + }) + .await?; + } result } diff --git a/host/src/builder/tests.rs b/host/src/builder/tests.rs index c0a18f7..cc27d26 100644 --- a/host/src/builder/tests.rs +++ b/host/src/builder/tests.rs @@ -33,6 +33,41 @@ async fn discovers_workspace_and_skips_target() -> Result<()> { Ok(()) } #[tokio::test] +async fn copies_only_changed_and_new_sources() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + for name in ["unchanged.rs", "changed.rs"] { + files::write( + world.root.as_ref(), + &format!("/project/{name}"), + b"initial".to_vec(), + ) + .await?; + } + let copy = temp.path().join("copy"); + let before = files::copy_out(world.root.as_ref(), "/project", ©).await?; + files::write( + world.root.as_ref(), + "/project/unchanged.rs", + b"concurrent edit".to_vec(), + ) + .await?; + std::fs::write(copy.join("changed.rs"), b"changed by command")?; + std::fs::write(copy.join("Cargo.lock"), b"created by command")?; + files::copy_back(world.root.as_ref(), ©, "/project", Some(&before)).await?; + for (name, expected) in [ + ("unchanged.rs", "concurrent edit"), + ("changed.rs", "changed by command"), + ("Cargo.lock", "created by command"), + ] { + assert_eq!( + files::read(world.root.as_ref(), &format!("/project/{name}")).await?, + expected.as_bytes() + ); + } + Ok(()) +} +#[tokio::test] async fn clean_removes_only_deleted_artifacts() -> Result<()> { let temp = tempfile::tempdir()?; let world = crate::world::World::open(temp.path())?; @@ -69,6 +104,58 @@ fn relative_host_directory_uses_absolute_namespace_paths() -> Result<()> { assert!(builder.directory.is_absolute()); Ok(()) } +#[tokio::test] +async fn target_options_preserve_the_platform_target_and_cache() -> Result<()> { + let temp = tempfile::tempdir()?; + let world = crate::world::World::open(temp.path())?; + let builder = Builder::new(world.root, &world.host_dir)?; + let (tx, _rx) = mpsc::channel(1); + for options in [ + vec!["--target", "aarch64-unknown-linux-gnu"], + vec!["--target=aarch64-unknown-linux-gnu"], + vec!["--target-dir", "/other"], + vec!["--target-dir=/other"], + ] { + let error = builder + .execute( + Request { + argv: std::iter::once("build") + .chain(options) + .map(str::to_string) + .collect(), + cwd: "/".into(), + timeout_secs: None, + }, + &tx, + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("target"), "{error}"); + } + for (action, options) in [ + ("run", vec!["--target", "wasm32-wasip2"]), + ("run", vec!["--target=wasm32-wasip2"]), + ("run", vec!["--", "--target-dir", "/argument"]), + ("tree", vec!["--target", "all"]), + ] { + let error = builder + .execute( + Request { + argv: std::iter::once(action) + .chain(options) + .map(str::to_string) + .collect(), + cwd: "/".into(), + timeout_secs: None, + }, + &tx, + ) + .await + .unwrap_err(); + assert_eq!(error.to_string(), "no Cargo.toml above cwd"); + } + Ok(()) +} #[test] fn test_filter_is_not_a_build_argument() { let args = ["test", "-p", "example", "filter", "--release"].map(str::to_string); @@ -96,6 +183,36 @@ fn disk_cap_removes_workspaces() -> Result<()> { assert_eq!(files::size(&builder.directory)?, 32); Ok(()) } +#[cfg(target_os = "linux")] +#[tokio::test] +async fn timeout_kills_before_output_can_drain() -> Result<()> { + use std::os::fd::{FromRawFd, OwnedFd}; + let (tx, mut rx) = mpsc::channel(1); + let output = tx.clone(); + let mut cmd = Command::new("sh"); + cmd.args(["-c", "echo $$; exec sleep 1000"]); + let task = + tokio::spawn( + async move { run(cmd, &output, Instant::now() + Duration::from_secs(1)).await }, + ); + let Some(Event::Stdout { bytes }) = + tokio::time::timeout(Duration::from_secs(2), rx.recv()).await? + else { + anyhow::bail!("missing native PID"); + }; + let pid: u32 = std::str::from_utf8(&bytes)?.trim().parse()?; + let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; + ensure!(fd >= 0, "pidfd_open: {}", std::io::Error::last_os_error()); + let exited = tokio::io::unix::AsyncFd::new(unsafe { OwnedFd::from_raw_fd(fd as i32) })?; + // Fill the output channel, so the timeout message cannot enter it. + tx.send(Event::Stdout { bytes: Vec::new() }).await?; + let _ready = tokio::time::timeout(Duration::from_secs(2), exited.readable()).await??; + assert!(matches!(rx.recv().await, Some(Event::Stdout { .. }))); + assert!(matches!(rx.recv().await, Some(Event::Stderr { bytes }) + if bytes.as_slice() == b"cargo: command timed out\n")); + assert_eq!(task.await??.0, 124); + Ok(()) +} #[tokio::test] async fn closed_receiver_cancels_command() -> Result<()> { let (tx, mut rx) = mpsc::channel(1); From 0be19f3f9c3ed8e861b6e8b0c22abf8b7f17a30e Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 12:55:28 +0000 Subject: [PATCH 5/8] builder: exclude namespace mounts from Cargo clean --- host/src/builder/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/host/src/builder/mod.rs b/host/src/builder/mod.rs index a241b49..4894657 100644 --- a/host/src/builder/mod.rs +++ b/host/src/builder/mod.rs @@ -95,13 +95,15 @@ impl Builder { // Cargo clean must remove its directory, not a namespace mount point. let target_mount = base.join("target"); let target = target_mount.join("cargo"); + // Keep /tmp's namespace mountpoints outside the directory Cargo clean removes. + let temporary = target_mount.join("tmp"); let registry = base.join("cargo"); let cache = base.join("kache"); if source.exists() { std::fs::remove_dir_all(&source)?; } let before = files::copy_out(self.root.as_ref(), &workspace, &source).await?; - for path in [&target, ®istry, &cache, &target.join("tmp")] { + for path in [&target, ®istry, &cache, &temporary] { std::fs::create_dir_all(path)?; } std::fs::write( @@ -187,7 +189,7 @@ impl Builder { "/nix/store", ]) .arg("--bind") - .arg(target.join("tmp")) + .arg(&temporary) .arg("/tmp") .arg("--ro-bind") .arg(executable("sh")?) From e53211eff1c0c03fb1d7e2a4b30a8fe6cb58ca04 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 13:24:53 +0000 Subject: [PATCH 6/8] 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 }); } From 7ee40594145e7ae85650aeace65eeaeb7e6eb0c8 Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 14:20:37 +0000 Subject: [PATCH 7/8] builder: retain Cargo config for run and correct acceptance checks --- host/src/builder/mod.rs | 18 +++++++++++------- host/src/process/http.rs | 2 +- host/tests/builder.rs | 12 +++++++----- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/host/src/builder/mod.rs b/host/src/builder/mod.rs index bf54efb..b1c17e2 100644 --- a/host/src/builder/mod.rs +++ b/host/src/builder/mod.rs @@ -123,12 +123,15 @@ impl Builder { b"Signature: 8a477f597d28d172789f06886806bc55\n", )?; std::fs::write(base.join("last-used"), [])?; - let cwd = source.join( - world_cwd - .strip_prefix(&workspace) - .expect("workspace is an ancestor of cwd") - .trim_start_matches('/'), - ); + let relative = world_cwd + .strip_prefix(&workspace) + .expect("workspace is an ancestor of cwd") + .trim_start_matches('/'); + let cwd = if relative.is_empty() { + source.clone() + } else { + source.join(relative) + }; let mut fetch = Command::new("cargo"); if action == "add" { fetch.args(&request.argv).arg("--dry-run"); @@ -274,7 +277,6 @@ 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=[{:?},{:?}]", @@ -282,6 +284,8 @@ impl Builder { tools.join("run.nu") )); } + // Keep all global config flags before the subcommand so Cargo retains them. + cmd.args(&args[..split]); if compiles { cmd.args([ "--target", diff --git a/host/src/process/http.rs b/host/src/process/http.rs index 747f5ae..8772a84 100644 --- a/host/src/process/http.rs +++ b/host/src/process/http.rs @@ -49,7 +49,7 @@ impl WasiHttpHooks for Hooks { if request.method() == http::Method::POST && request.uri().path() == "/cargo" { return Ok(( crate::api::stream(&router, request), - Box::new(async { Ok(()) }), + Box::new(async { Ok(()) }) as Io, )); } return Ok(response(crate::api::handle(&router, caller, request).await)); diff --git a/host/tests/builder.rs b/host/tests/builder.rs index ef46819..019823a 100644 --- a/host/tests/builder.rs +++ b/host/tests/builder.rs @@ -171,7 +171,8 @@ async fn cargo_build_test_and_source_changes() -> anyhow::Result<()> { .await?; assert_eq!(code, 0, "{out}"); assert!(out.contains("INTEGRATION_CASE"), "{out}"); - let (code, out) = cargo(&runtime, &["test", "fail"]).await?; + // WASI aborts on panic, so disable capture to expose the panic message. + let (code, out) = cargo(&runtime, &["test", "fail", "--", "--nocapture"]).await?; assert_eq!(code, 101, "{out}"); assert!(out.contains("intentional fixture failure"), "{out}"); let (code, out) = cargo(&runtime, &["test", "--no-run"]).await?; @@ -291,7 +292,7 @@ async fn timeout_and_cancellation() -> anyhow::Result<()> { std::fs::create_dir_all(&bin)?; std::fs::copy(binaries.join("cargo.wasm"), bin.join("cargo.wasm"))?; let runtime = Runtime::new(world)?; - let code = runtime + let error = runtime .exec( Command::Argv(vec!["/bin/cargo.wasm".into(), "build".into()]), Options { @@ -301,10 +302,11 @@ async fn timeout_and_cancellation() -> anyhow::Result<()> { }, &mut |_, _| {}, ) - .await?; - assert_ne!(code, 0); + .await + .unwrap_err(); + assert!(error.to_string().contains("timed out"), "{error}"); let (code, out) = - tokio::time::timeout(Duration::from_secs(5), cargo(&runtime, &["metadata"])).await??; + tokio::time::timeout(Duration::from_secs(20), cargo(&runtime, &["metadata"])).await??; assert_eq!( code, 0, "the canceled build still held the builder lock: {out}" From d63482f02e9011ee9df76720bbb76985543f7a7f Mon Sep 17 00:00:00 2001 From: Surma Date: Sat, 7 Nov 2026 14:46:04 +0000 Subject: [PATCH 8/8] builder: register the owned pidfd with the current Tokio API --- host/src/builder/tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/host/src/builder/tests.rs b/host/src/builder/tests.rs index cc27d26..c373593 100644 --- a/host/src/builder/tests.rs +++ b/host/src/builder/tests.rs @@ -203,7 +203,8 @@ async fn timeout_kills_before_output_can_drain() -> Result<()> { let pid: u32 = std::str::from_utf8(&bytes)?.trim().parse()?; let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; ensure!(fd >= 0, "pidfd_open: {}", std::io::Error::last_os_error()); - let exited = tokio::io::unix::AsyncFd::new(unsafe { OwnedFd::from_raw_fd(fd as i32) })?; + // OwnedFd keeps the PID descriptor valid and unchanged until AsyncFd drops it. + let exited = unsafe { tokio::io::unix::AsyncFd::register(OwnedFd::from_raw_fd(fd as i32)) }?; // Fill the output channel, so the timeout message cannot enter it. tx.send(Event::Stdout { bytes: Vec::new() }).await?; let _ready = tokio::time::timeout(Duration::from_secs(2), exited.readable()).await??;