Compare commits

...
Author SHA1 Message Date
Sausage Agent 6923ca3e60 preserve the packaged runtime Rust toolchain
ci / check (push) Successful in 6s
2026-10-17 16:36:08 +00:00
Sausage Agent c504532513 drive packaged host runtime check over pipes
ci / check (push) Failing after 1m16s
2026-10-17 16:33:31 +00:00
Sausage Agent eb35a79698 trace the packaged host runtime check
ci / check (push) Failing after 1m47s
2026-10-17 16:29:02 +00:00
Sausage Agent bfcd71e2f4 test packaged host cargo runtime with an empty PATH
ci / check (push) Failing after 1m33s
2026-10-17 16:25:14 +00:00
agent-pool-12 172a776eec cargo: annotate test summary count parsing
ci / check (push) Successful in 5s
2026-10-17 16:00:54 +00:00
agent-pool-12 6a9d35ceb8 cargo: preserve build failures in result wrapper
ci / check (push) Failing after 6m15s
2026-10-17 15:52:37 +00:00
agent-pool-12 22590c608d cargo: run host unit tests before builds
ci / check (push) Failing after 8m51s
2026-10-17 15:50:09 +00:00
8 changed files with 318 additions and 66 deletions
+13 -3
View File
@@ -231,9 +231,19 @@ folder again, as at start.
{"type": "build", "source": "/actor/agent/public/drafts/notes"} {"type": "build", "source": "/actor/agent/public/drafts/notes"}
``` ```
The reply is `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "built_by": "cargo", ...}` The build request may include `"skip_tests": true` to skip Rust unit tests.
or `{"type": "build_failed", "errors": "..."}`. Pass `wasm`, `source`, and By default, `cargo` first runs `cargo test` for the host target (not WASI),
`built_by` on to `spawn` or `upgrade`. so pure-logic unit tests in a `wasm32-wasip2` actor crate can run without a
WASI test runner. If tests fail or cannot compile, the build fails before the
WASI build. The reply carries `tests: {"passed": <count>, "failed": <count>,
"output": "<cargo test stdout and stderr>"}`; counts sum Cargo's test-result
summaries, and a test command/compilation failure without a summary counts as
one failure. With `skip_tests: true`, counts are zero and output says tests
were skipped.
The reply is `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "built_by": "cargo", "tests": {...}, ...}`
or `{"type": "build_failed", "errors": "...", "tests": {...}}`. Pass
`wasm`, `source`, and `built_by` on to `spawn` or `upgrade`.
A Rust actor depends on the SDK with `sausage-actor = "0.1"`. `cargo` points A Rust actor depends on the SDK with `sausage-actor = "0.1"`. `cargo` points
that dependency at the SDK of the host, which it publishes at that dependency at the SDK of the host, which it publishes at
+137 -11
View File
@@ -1,8 +1,9 @@
//! The native `cargo` actor: builds Rust source folders into `.wasm` files. //! The native `cargo` actor: builds Rust source folders into `.wasm` files.
//! //!
//! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}` //! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}`
//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", ...}` //! optional `"skip_tests": true` skips native `cargo test` (tests run by default).
//! `{"type": "build_failed", "errors": "..."}` //! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "tests": {"passed": 1, "failed": 0, "output": "..."}, ...}`
//! `{"type": "build_failed", "errors": "...", "tests": {"passed": 0, "failed": 1, "output": "..."}}`
//! //!
//! The broker knows nothing about Rust. This actor is one way to get a //! The broker knows nothing about Rust. This actor is one way to get a
//! `.wasm` file. Other actors can add other languages. //! `.wasm` file. Other actors can add other languages.
@@ -32,11 +33,49 @@ pub const TARGET: &str = "wasm32-wasip2";
/// The source of the `sausage-actor` SDK. /// The source of the `sausage-actor` SDK.
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk"); static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk");
#[derive(Clone, Default, Debug)]
pub struct TestResults {
pub passed: u64,
pub failed: u64,
pub output: String,
}
impl TestResults {
fn value(&self) -> Value {
json!({"passed": self.passed, "failed": self.failed, "output": self.output})
}
}
pub struct BuildOutput { pub struct BuildOutput {
/// Host path of the published `.wasm` file. /// Host path of the published `.wasm` file.
pub wasm: PathBuf, pub wasm: PathBuf,
pub seconds: f64, pub seconds: f64,
pub warnings: String, pub warnings: String,
pub tests: TestResults,
}
#[derive(Debug)]
pub struct BuildFailure {
pub reason: String,
pub tests: TestResults,
}
impl std::fmt::Display for BuildFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.tests.failed > 0 && !self.tests.output.is_empty() {
write!(f, "{}:\n{}", self.reason, self.tests.output)
} else {
f.write_str(&self.reason)
}
}
}
impl std::error::Error for BuildFailure {}
impl From<anyhow::Error> for BuildFailure {
fn from(error: anyhow::Error) -> Self {
Self { reason: format!("{error:#}"), tests: TestResults::default() }
}
} }
/// All builds share one target folder, so they run one at a time. /// All builds share one target folder, so they run one at a time.
@@ -51,19 +90,44 @@ impl Cargo {
Ok(Self { world }) Ok(Self { world })
} }
/// Build the crate in `source` (a host path) and publish the `.wasm` file /// Run native `cargo test` unless opted out, then build and publish the
/// under `/actor/cargo/public/builds/<n>/`. /// crate's `wasm32-wasip2` binary under `/actor/cargo/public/builds/<n>/`.
pub async fn build(&self, source: &Path) -> Result<BuildOutput> { pub async fn build(&self, source: &Path, skip_tests: bool) -> std::result::Result<BuildOutput, BuildFailure> {
if !source.join("Cargo.toml").is_file() { if !source.join("Cargo.toml").is_file() {
bail!("{} has no Cargo.toml", source.display()); return Err(BuildFailure {
reason: format!("{} has no Cargo.toml", source.display()),
tests: TestResults::default(),
});
} }
let started = Instant::now(); let started = Instant::now();
let data = self.world.data_dir(ID); let data = self.world.data_dir(ID);
let build_dir = data.join("work"); let build_dir = data.join("work");
world::replace_source(source, &build_dir)?; world::replace_source(source, &build_dir)?;
let manifest = build_dir.join("Cargo.toml"); let manifest = build_dir.join("Cargo.toml");
std::fs::write(&manifest, prepare_manifest(&std::fs::read_to_string(&manifest)?, Some(&self.world.public_dir(ID).join("sdk")))?)?; let original = std::fs::read_to_string(&manifest).context("read build manifest")?;
let prepared = prepare_manifest(&original, Some(&self.world.public_dir(ID).join("sdk")))?;
std::fs::write(&manifest, prepared).context("write prepared manifest")?;
let target_dir = data.join("target");
let tests = if skip_tests {
TestResults { output: "skipped (skip_tests=true)".into(), ..TestResults::default() }
} else {
match run_tests(&build_dir, &target_dir).await {
Ok(tests) => tests,
Err(error) => {
let output = format!("cannot run cargo tests: {error:#}");
return Err(BuildFailure {
reason: output.clone(),
tests: TestResults { failed: 1, output, ..TestResults::default() },
});
}
}
};
if tests.failed > 0 {
return Err(BuildFailure { reason: "unit tests failed".into(), tests });
}
let build_result: Result<BuildOutput> = async {
let mut cmd = Command::new("cargo"); let mut cmd = Command::new("cargo");
cmd.arg("build") cmd.arg("build")
.arg("--release") .arg("--release")
@@ -71,7 +135,7 @@ impl Cargo {
.arg(TARGET) .arg(TARGET)
.arg("--message-format=json-render-diagnostics") .arg("--message-format=json-render-diagnostics")
.arg("--target-dir") .arg("--target-dir")
.arg(data.join("target")) .arg(&target_dir)
.current_dir(&build_dir) .current_dir(&build_dir)
.kill_on_drop(true); .kill_on_drop(true);
let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?; let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
@@ -95,7 +159,15 @@ impl Cargo {
let name = artifact.file_name().context("artifact has no file name")?; let name = artifact.file_name().context("artifact has no file name")?;
let dest = dest_dir.join(name); let dest = dest_dir.join(name);
world::write_atomic(&dest, &std::fs::read(&artifact)?)?; world::write_atomic(&dest, &std::fs::read(&artifact)?)?;
Ok(BuildOutput { wasm: dest, seconds: started.elapsed().as_secs_f64(), warnings: strip_progress(&stderr) }) Ok(BuildOutput {
wasm: dest,
seconds: started.elapsed().as_secs_f64(),
warnings: strip_progress(&stderr),
tests: tests.clone(),
})
}
.await;
build_result.map_err(|error| BuildFailure { reason: format!("{error:#}"), tests })
} }
/// The native `cargo` actor. Builds run one at a time, in order. /// The native `cargo` actor. Builds run one at a time, in order.
@@ -106,7 +178,7 @@ impl Cargo {
continue; continue;
} }
let body = self.handle(&broker, &env).await.unwrap_or_else(|e| { let body = self.handle(&broker, &env).await.unwrap_or_else(|e| {
json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}")}) json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}"), "tests": TestResults::default().value()})
}); });
broker.send(ID, Some(&env.from), None, Some(&env.id), body); broker.send(ID, Some(&env.from), None, Some(&env.id), body);
} }
@@ -117,7 +189,9 @@ impl Cargo {
Some("build") => { Some("build") => {
let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?; let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?;
let source = broker.world.resolve(&env.from, source_s)?; let source = broker.world.resolve(&env.from, source_s)?;
let out = self.build(&source).await?; let skip_tests = env.body.get("skip_tests").and_then(Value::as_bool).unwrap_or(false);
match self.build(&source, skip_tests).await {
Ok(out) => {
let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string()); let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string());
Ok(json!({ Ok(json!({
"type": "built", "type": "built",
@@ -126,13 +200,65 @@ impl Cargo {
"built_by": ID, "built_by": ID,
"seconds": (out.seconds * 10.0).round() / 10.0, "seconds": (out.seconds * 10.0).round() / 10.0,
"warnings": out.warnings, "warnings": out.warnings,
"tests": out.tests.value(),
})) }))
} }
Err(failure) => Ok(json!({
"type": "build_failed",
"source": source_s,
"errors": failure.reason,
"tests": failure.tests.value(),
})),
}
}
other => bail!("unknown request {other:?}. Known: build"), other => bail!("unknown request {other:?}. Known: build"),
} }
} }
} }
/// Run tests for the host triple: WASI crates' pure-logic tests can use the
/// normal Rust test harness without needing a WASI test runner.
async fn run_tests(build_dir: &Path, target_dir: &Path) -> Result<TestResults> {
let mut cmd = Command::new("cargo");
cmd.arg("test")
.arg("--target-dir")
.arg(target_dir)
.current_dir(build_dir)
.kill_on_drop(true);
let out = cmd.output().await.context("cannot run cargo test")?;
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
let combined = match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout,
(true, false) => stderr,
(false, false) => format!("{stdout}\n{stderr}"),
};
let output = strip_progress(&combined);
let passed = test_count(&output, "passed");
let mut failed = test_count(&output, "failed");
// Compilation failures and runner errors have no `test result` summary,
// but still make the default test phase fail.
if !out.status.success() && failed == 0 {
failed = 1;
}
Ok(TestResults { passed, failed, output })
}
/// Count the `passed` or `failed` totals in cargo's per-target test summaries.
fn test_count(output: &str, field: &str) -> u64 {
output
.lines()
.filter_map(|line| line.split_once("test result:").map(|(_, rest)| rest))
.flat_map(|summary| summary.split(';'))
.filter_map(|part| {
let words: Vec<_> = part.split_whitespace().collect();
let index = words.iter().position(|word| *word == field)?;
words.get(index.checked_sub(1)?)?.parse::<u64>().ok()
})
.sum()
}
/// Point a `sausage-actor` dependency at `sdk` (keeping its features), and /// Point a `sausage-actor` dependency at `sdk` (keeping its features), and
/// add an empty `[workspace]`, so the crate never joins a parent workspace. /// add an empty `[workspace]`, so the crate never joins a parent workspace.
fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> { fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> {
+1 -1
View File
@@ -85,7 +85,7 @@ async fn init(dir: PathBuf) -> Result<()> {
std::fs::create_dir_all(&source)?; std::fs::create_dir_all(&source)?;
AGENT_SOURCE.extract(&source).context("extract agent source")?; AGENT_SOURCE.extract(&source).context("extract agent source")?;
println!("Building the agent actor (the first build takes a few minutes) …"); println!("Building the agent actor (the first build takes a few minutes) …");
let out = cargo_actor::Cargo::new(world.clone())?.build(&source).await.context("build agent")?; let out = cargo_actor::Cargo::new(world.clone())?.build(&source, false).await.context("build agent")?;
std::fs::copy(&out.wasm, world.wasm_path("agent"))?; std::fs::copy(&out.wasm, world.wasm_path("agent"))?;
println!("Built in {:.1} s. Start the world with: sausage run {}", out.seconds, dir.display()); println!("Built in {:.1} s. Start the world with: sausage run {}", out.seconds, dir.display());
Ok(()) Ok(())
@@ -0,0 +1,6 @@
[package]
name = "cargo-test-fail"
version = "0.1.0"
edition = "2024"
[workspace]
@@ -0,0 +1,9 @@
fn main() {}
#[cfg(test)]
mod tests {
#[test]
fn deliberate_unit_test_failure() {
assert_eq!(2 + 2, 5);
}
}
+8
View File
@@ -1 +1,9 @@
fn main() {} fn main() {}
#[cfg(test)]
mod tests {
#[test]
fn unit_tests_run_on_the_host_target() {
assert_eq!(2 + 2, 4);
}
}
+38 -11
View File
@@ -619,9 +619,10 @@ async fn unknown_broker_request_lists_the_known_ones() {
} }
#[tokio::test] #[tokio::test]
async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() { async fn cargo_actor_runs_unit_tests_by_default_and_can_skip_them() {
let world = create_world("cargo-actor"); let world = create_world("cargo-actor");
let (host, mut mailbox) = start(&world).await; let (host, mut mailbox) = start(&world).await;
let valid_source = cargo_source("cargo-valid"); let valid_source = cargo_source("cargo-valid");
let valid_id = host.broker.send( let valid_id = host.broker.send(
"console", "console",
@@ -630,12 +631,42 @@ async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() {
None, None,
json!({"type": "build", "source": valid_source.display().to_string()}), json!({"type": "build", "source": valid_source.display().to_string()}),
); );
let built = receive_reply(&mut mailbox, &valid_id, Duration::from_secs(120)).await; let built = receive_reply(&mut mailbox, &valid_id, Duration::from_secs(180)).await;
assert_eq!(built.body["type"], "built"); assert_eq!(built.body["type"], "built", "{built:?}");
assert_eq!(built.body["tests"]["passed"], 1);
assert_eq!(built.body["tests"]["failed"], 0);
assert!(built.body["tests"]["output"].as_str().unwrap().contains("unit_tests_run_on_the_host_target"));
let wasm = built.body["wasm"].as_str().unwrap(); let wasm = built.body["wasm"].as_str().unwrap();
assert!(wasm.ends_with(".wasm")); assert!(wasm.ends_with(".wasm"));
assert!(host.world.resolve("console", wasm).unwrap().is_file()); assert!(host.world.resolve("console", wasm).unwrap().is_file());
let failing_test_source = cargo_source("cargo-test-fail");
let failed_test_id = host.broker.send(
"console",
Some("cargo"),
None,
None,
json!({"type": "build", "source": failing_test_source.display().to_string()}),
);
let failed_test = receive_reply(&mut mailbox, &failed_test_id, Duration::from_secs(120)).await;
assert_eq!(failed_test.body["type"], "build_failed", "{failed_test:?}");
assert_eq!(failed_test.body["tests"]["passed"], 0);
assert_eq!(failed_test.body["tests"]["failed"], 1);
assert!(failed_test.body["tests"]["output"].as_str().unwrap().contains("deliberate_unit_test_failure"));
let skipped_id = host.broker.send(
"console",
Some("cargo"),
None,
None,
json!({"type": "build", "source": failing_test_source.display().to_string(), "skip_tests": true}),
);
let skipped = receive_reply(&mut mailbox, &skipped_id, Duration::from_secs(120)).await;
assert_eq!(skipped.body["type"], "built", "{skipped:?}");
assert_eq!(skipped.body["tests"]["passed"], 0);
assert_eq!(skipped.body["tests"]["failed"], 0);
assert_eq!(skipped.body["tests"]["output"], "skipped (skip_tests=true)");
let invalid_source = cargo_source("cargo-fail"); let invalid_source = cargo_source("cargo-fail");
let invalid_id = host.broker.send( let invalid_id = host.broker.send(
"console", "console",
@@ -644,14 +675,10 @@ async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() {
None, None,
json!({"type": "build", "source": invalid_source.display().to_string()}), json!({"type": "build", "source": invalid_source.display().to_string()}),
); );
let failed = receive_reply(&mut mailbox, &invalid_id, Duration::from_secs(120)).await; let invalid = receive_reply(&mut mailbox, &invalid_id, Duration::from_secs(120)).await;
assert_eq!(failed.body["type"], "build_failed"); assert_eq!(invalid.body["type"], "build_failed", "{invalid:?}");
assert!( assert_eq!(invalid.body["tests"]["failed"], 1);
failed.body["errors"] assert!(invalid.body["tests"]["output"].as_str().unwrap().contains("missing_function"));
.as_str()
.unwrap()
.contains("missing_function")
);
host.stop().await; host.stop().await;
} }
+70 -4
View File
@@ -21,9 +21,11 @@
targets = [ "wasm32-wasip2" ]; targets = [ "wasm32-wasip2" ];
extensions = [ "rust-src" ]; extensions = [ "rust-src" ];
}; };
# Crane builds the dependencies once (`cargoArtifacts`, keyed only on # The runtime cargo actor needs Cargo, rustc (with wasm32-wasip2), and
# Cargo.toml/Cargo.lock), so a change to our own crates rebuilds # the native linker, even when the host itself is started with a bare
# just them. # PATH. Keep this same runtime path in the packaged host and exercise
# the installed binary below with PATH deliberately empty.
runtimePath = pkgs.lib.makeBinPath [ rust pkgs.stdenv.cc ];
craneLib = (crane.mkLib pkgs).overrideToolchain rust; craneLib = (crane.mkLib pkgs).overrideToolchain rust;
common = { common = {
pname = "sausage"; pname = "sausage";
@@ -42,12 +44,13 @@
}); });
sausage = craneLib.buildPackage (common // { sausage = craneLib.buildPackage (common // {
inherit cargoArtifacts; inherit cargoArtifacts;
doNotRemoveReferencesToRustToolchain = true;
cargoExtraArgs = "--locked -p sausage"; cargoExtraArgs = "--locked -p sausage";
doCheck = false; doCheck = false;
nativeBuildInputs = [ pkgs.makeWrapper ]; nativeBuildInputs = [ pkgs.makeWrapper ];
# The `cargo` actor runs cargo at runtime. Build scripts need a C linker. # The `cargo` actor runs cargo at runtime. Build scripts need a C linker.
postInstall = '' postInstall = ''
wrapProgram $out/bin/sausage --prefix PATH : ${pkgs.lib.makeBinPath [ rust pkgs.stdenv.cc ]} wrapProgram $out/bin/sausage --prefix PATH : ${runtimePath}
''; '';
meta.mainProgram = "sausage"; meta.mainProgram = "sausage";
}); });
@@ -91,6 +94,69 @@
rm -f cli/tests/actors/toolbox/Cargo.lock rm -f cli/tests/actors/toolbox/Cargo.lock
''; '';
}); });
# Exercise the installed host exactly as the loop launches it: the
# caller has no Cargo on PATH, so only the packaged runtime toolchain
# can run the native tests and the wasm32-wasip2 build.
checks.runtime-toolchain = pkgs.runCommand "sausage-runtime-toolchain" { } ''
set -eu
root="$TMPDIR/runtime-toolchain"
world="$root/world"
emptyPath="$root/empty-bin"
mkdir -p "$emptyPath" "$root/home" "$root/cargo-home"
for actor in broker console cargo fs; do
mkdir -p "$world/actor/$actor/public" "$world/actor/$actor/data"
done
source="$world/actor/console/public/probe"
mkdir -p "$source/src" "$source/tests"
cat > "$source/Cargo.toml" <<'TOML'
[package]
name = "runtime-probe"
version = "0.1.0"
edition = "2024"
TOML
printf 'fn main() {}\n' > "$source/src/main.rs"
cat > "$source/tests/probe.rs" <<'RUST'
#[test]
fn native_test_harness_runs() {
assert_eq!(2 + 2, 4);
}
RUST
echo "runtime-check: run packaged host with an empty PATH"
coproc host {
${pkgs.coreutils}/bin/env -i PATH="$emptyPath" HOME="$root/home" TMPDIR="$root" \
CARGO_HOME="$root/cargo-home" ${sausage}/bin/sausage run "$world"
}
hostInput=''${host[1]}
hostOutput=''${host[0]}
hostPid=$host_PID
trap 'kill "$hostPid" 2>/dev/null || true' EXIT
printf '%s\n' '@cargo {"type":"build","source":"/actor/console/public/probe"}' >&"$hostInput"
built=0
while IFS= read -r -t 300 line <&"$hostOutput"; do
echo "$line"
case "$line" in
*'"type":"build_failed"'*)
echo "packaged cargo actor build failed with an empty caller PATH" >&2
exit 1
;;
*'"type":"built"'*)
case "$line" in
*'"passed":1'*) built=1 ;;
*) echo "native test did not pass" >&2; exit 1 ;;
esac
break
;;
esac
done
if [ "$built" -ne 1 ]; then
echo "packaged host did not build the probe" >&2
exit 1
fi
printf '%s\n' '/quit' >&"$hostInput"
wait "$hostPid"
mkdir -p "$out"
touch "$out/passed"
'';
apps.default = { type = "app"; program = "${sausage}/bin/sausage"; }; apps.default = { type = "app"; program = "${sausage}/bin/sausage"; };
apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; }; apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; };