host: review fixes for cargo.build, the test runner, the console, and main; tests for every PROTOCOL.md rule
cargo.build and the runner (review T22R): builds use the network again; core.unlock and core.notify in order under the runner; quiet test output; manifest preparation keeps [workspace], target tables, renamed and optional entries; cargo.rpc.discover; -32004 with data.output; retention of 10 builds; light, empty test worlds with an in-memory journal; only CARGO_/RUST_/SAUSAGE_ variables; stdin is null; the runner through --config. A compile cache that cannot be created is not fatal. Activation replaces plugin.wasm without a gap. Console and main: init ships the workspace Cargo.lock and creates the world atomically; each console command runs in its own task, so ps, stop, and kill work during a slow call; SAUSAGE_HEADLESS; the terminal mode is restored; signals during boot; chat text is not taken as a command. Tests: 20 more tests for PROTOCOL.md sections 2 to 10; the cargo tests choose the libtest binary from cargo's JSON output and time out.
This commit is contained in:
+315
-20
@@ -46,6 +46,11 @@ fn fixture(name: &str) -> PathBuf {
|
||||
/// Builds through the real registered native plugin.
|
||||
async fn build(world: &TestWorld, name: &str) -> Result<Value, Value> {
|
||||
let host = Host::start(&world.0).await.unwrap();
|
||||
let runner = world.0.join("state/runner with spaces");
|
||||
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_sausage"), &runner).unwrap();
|
||||
sausage::cargo::Cargo::with_runner(World::open(&world.0).unwrap(), runner)
|
||||
.unwrap()
|
||||
.register(&host);
|
||||
let result = host.call("cargo.build", json!({"plugin":name})).await;
|
||||
host.stop().await;
|
||||
result
|
||||
@@ -68,22 +73,62 @@ async fn valid() -> &'static Valid {
|
||||
"exclude",
|
||||
)
|
||||
.unwrap();
|
||||
let nested = world.0.join("plugins/valid/source/src/target");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(nested.join("data"), "nested target").unwrap();
|
||||
let git = world.0.join("plugins/valid/source/src/.git");
|
||||
std::fs::create_dir_all(&git).unwrap();
|
||||
std::fs::write(git.join("data"), "nested git").unwrap();
|
||||
for number in 1..=20 {
|
||||
std::fs::create_dir_all(world.0.join(format!("state/cargo/builds/{number}")))
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(world.0.join("state/cargo/tmp/stale")).unwrap();
|
||||
std::fs::write(world.0.join("state/cargo/tmp/stale/file"), "stale").unwrap();
|
||||
let result = build(&world, "valid")
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("build failed: {error}"));
|
||||
let test =
|
||||
std::fs::read_dir(world.0.join("state/cargo/target/wasm32-wasip2/debug/deps"))
|
||||
.unwrap()
|
||||
.filter_map(|entry| {
|
||||
let path = entry.ok()?.path();
|
||||
(path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "wasm")
|
||||
&& path.file_name()?.to_str()?.starts_with("cargo_valid-"))
|
||||
.then_some(path)
|
||||
})
|
||||
.next()
|
||||
.unwrap();
|
||||
// Integration tests also build a normal binary. Cargo identifies the libtest artifact.
|
||||
let artifacts = tokio::process::Command::new("cargo")
|
||||
.args([
|
||||
"test",
|
||||
"--no-run",
|
||||
"--message-format=json",
|
||||
"--bin",
|
||||
"cargo-valid",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(world.0.join("state/cargo/target"))
|
||||
.current_dir(world.0.join("state/cargo/work"))
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
artifacts.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&artifacts.stderr)
|
||||
);
|
||||
let mut tests: Vec<PathBuf> = String::from_utf8_lossy(&artifacts.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
|
||||
.filter(|message| {
|
||||
message["reason"] == "compiler-artifact"
|
||||
&& message["profile"]["test"] == true
|
||||
&& message["target"]["kind"]
|
||||
.as_array()
|
||||
.is_some_and(|kinds| kinds.iter().any(|kind| kind == "bin"))
|
||||
})
|
||||
.filter_map(|message| message["executable"].as_str().map(PathBuf::from))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
tests.len(),
|
||||
1,
|
||||
"expected exactly one libtest binary: {tests:?}"
|
||||
);
|
||||
let test = tests.pop().unwrap();
|
||||
Valid {
|
||||
world,
|
||||
result,
|
||||
@@ -94,16 +139,215 @@ async fn valid() -> &'static Valid {
|
||||
}
|
||||
/// Invokes the same runner subcommand that cargo uses.
|
||||
async fn runner(valid: &Valid, args: &[&str]) -> std::process::Output {
|
||||
tokio::process::Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
let mut command = tokio::process::Command::new(env!("CARGO_BIN_EXE_sausage"));
|
||||
command
|
||||
.arg("test-runner")
|
||||
.arg(&valid.test)
|
||||
.args(args)
|
||||
.current_dir(valid.world.0.join("state/cargo/work"))
|
||||
.output()
|
||||
.kill_on_drop(true);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(120), command.output())
|
||||
.await
|
||||
.expect("test runner exceeded 120 seconds")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// T23: unlocks stay ordered before the next lock over 300 acquisitions.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unlock_order() {
|
||||
assert!(
|
||||
runner(valid().await, &["unlock_order"])
|
||||
.await
|
||||
.status
|
||||
.success()
|
||||
);
|
||||
}
|
||||
/// T23: each runner world is empty and has independent proc, self, and run mounts.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn empty_and_isolated_worlds() {
|
||||
for name in ["empty_world", "isolation_", "environment", "late_response"] {
|
||||
let output = runner(valid().await, &[name]).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{name}: {} {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
/// T23: successful builds preserve warnings and integration output without cargo progress noise.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn build_output_and_warnings() {
|
||||
let valid = valid().await;
|
||||
assert!(
|
||||
valid.result["warnings"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("fixture build warning")
|
||||
);
|
||||
let output = valid.result["tests"]["output"].as_str().unwrap();
|
||||
assert!(output.contains("INTEGRATION_CASE"));
|
||||
assert!(!output.contains("Compiling "));
|
||||
assert!(!output.contains(" running 1 test"));
|
||||
assert!(
|
||||
!output
|
||||
.lines()
|
||||
.any(|line| line.starts_with(" ") && line.trim().is_empty())
|
||||
);
|
||||
}
|
||||
/// T23: publication retains ten builds and the next build removes abandoned test worlds.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn build_retention_and_temporary_cleanup() {
|
||||
let valid = valid().await;
|
||||
assert_eq!(
|
||||
std::fs::read_dir(valid.world.0.join("state/cargo/builds"))
|
||||
.unwrap()
|
||||
.count(),
|
||||
10
|
||||
);
|
||||
assert!(!valid.world.0.join("state/cargo/tmp/stale").exists());
|
||||
assert_eq!(
|
||||
std::fs::read_dir(valid.world.0.join("state/cargo/tmp"))
|
||||
.unwrap()
|
||||
.count(),
|
||||
0
|
||||
);
|
||||
}
|
||||
/// T23: manifest preparation preserves workspaces, renamed target dependencies, and optional flags.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn manifest_and_nested_sources() {
|
||||
let valid = valid().await;
|
||||
let root = valid.world.0.join("state/cargo/work");
|
||||
let manifest = std::fs::read_to_string(root.join("Cargo.toml")).unwrap();
|
||||
assert!(manifest.contains("workspace.package"));
|
||||
assert!(manifest.contains("optional = true"));
|
||||
assert!(manifest.contains("/plugins/cargo/sdk"));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("src/target/data")).unwrap(),
|
||||
"nested target"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("src/.git/data")).unwrap(),
|
||||
"nested git"
|
||||
);
|
||||
}
|
||||
/// T23: discovery describes the native build method and preparation errors name their paths.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn native_discovery_and_error_paths() {
|
||||
let world = TestWorld::new();
|
||||
let host = Host::start(&world.0).await.unwrap();
|
||||
assert_eq!(
|
||||
host.call("cargo.rpc.discover", json!({})).await.unwrap()["methods"][0]["name"],
|
||||
"build"
|
||||
);
|
||||
let error = host
|
||||
.call("cargo.build", json!({"plugin":"missing"}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error["code"], -32004);
|
||||
assert!(
|
||||
error["data"]["output"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("plugins/missing/source")
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// T23: release-only failures use data.output and retain successful WASI totals.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn release_only_failure() {
|
||||
let world = TestWorld::new();
|
||||
let root = world.0.join("plugins/release/source/src");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(
|
||||
root.parent().unwrap().join("Cargo.toml"),
|
||||
"[package]\nname='release-only'\nversion='0.1.0'\nedition='2024'\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("main.rs"), "#[cfg(not(debug_assertions))] compile_error!(\"release-only error\"); fn main() {} #[test] fn passes() {}\n").unwrap();
|
||||
let error = build(&world, "release").await.unwrap_err();
|
||||
assert!(
|
||||
error["data"]["output"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("release-only error")
|
||||
);
|
||||
assert_eq!(error["data"]["tests"]["passed"], 1);
|
||||
}
|
||||
/// T23: cancellation retains serialization until a blocking source copy completes.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cancelled_preparation_is_serialized() {
|
||||
let world = TestWorld::new();
|
||||
let first = world.0.join("first");
|
||||
let second = world.0.join("second");
|
||||
for (root, name) in [(&first, "first"), (&second, "second")] {
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("Cargo.toml"),
|
||||
format!("[package]\nname='{name}'\nversion='0.1.0'\nedition='2024'\n"),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
}
|
||||
for number in 0..10000 {
|
||||
std::fs::write(first.join(format!("old-{number}")), "copy").unwrap();
|
||||
}
|
||||
let builder = std::sync::Arc::new(
|
||||
sausage::cargo::Cargo::with_runner(
|
||||
World::open(&world.0).unwrap(),
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_sausage")),
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let copying = builder.clone();
|
||||
let task = tokio::spawn(async move { copying.build(&first).await });
|
||||
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
||||
loop {
|
||||
if std::fs::read_dir(world.0.join("state/cargo/work"))
|
||||
.is_ok_and(|entries| entries.count() > 10)
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
builder.build(&second).await.unwrap();
|
||||
assert!(
|
||||
!std::fs::read_dir(world.0.join("state/cargo/work"))
|
||||
.unwrap()
|
||||
.any(|entry| entry
|
||||
.unwrap()
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("old-"))
|
||||
);
|
||||
}
|
||||
/// T23: multiple binary artifacts fail clearly instead of publishing an arbitrary binary.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn multiple_binaries() {
|
||||
let world = TestWorld::new();
|
||||
let root = world.0.join("plugins/multi/source/src/bin");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(
|
||||
root.parent().unwrap().parent().unwrap().join("Cargo.toml"),
|
||||
"[package]\nname='multiple'\nversion='0.1.0'\nedition='2024'\n",
|
||||
)
|
||||
.unwrap();
|
||||
for name in ["one", "two"] {
|
||||
std::fs::write(root.join(format!("{name}.rs")), "fn main() {}\n").unwrap();
|
||||
}
|
||||
let error = build(&world, "multi").await.unwrap_err();
|
||||
assert!(
|
||||
error["data"]["output"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("exactly one binary")
|
||||
);
|
||||
}
|
||||
/// PROTOCOL 11: a valid SDK plugin builds and publishes a release component without activation.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn valid_build() {
|
||||
@@ -111,7 +355,7 @@ async fn valid_build() {
|
||||
let wasm = valid.result["wasm"].as_str().unwrap();
|
||||
assert!(wasm.starts_with("/state/cargo/builds/"));
|
||||
assert!(valid.world.0.join(wasm.trim_start_matches('/')).is_file());
|
||||
assert_eq!(valid.result["tests"]["passed"], 5);
|
||||
assert_eq!(valid.result["tests"]["passed"], 12);
|
||||
assert_eq!(valid.result["tests"]["failed"], 0);
|
||||
assert!(!valid.world.0.join("plugins/valid/plugin.wasm").exists());
|
||||
}
|
||||
@@ -305,6 +549,10 @@ async fn standalone_builder() {
|
||||
/// PROTOCOL 11: killing a native build also kills its build-script descendants.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kill_process_group() {
|
||||
assert!(
|
||||
Path::new("/proc/self/stat").is_file(),
|
||||
"process-group checks require /proc"
|
||||
);
|
||||
let world = TestWorld::new();
|
||||
world.source("hang", &fixture("cargo-hang"));
|
||||
let host = Host::start(&world.0).await.unwrap();
|
||||
@@ -323,21 +571,68 @@ async fn kill_process_group() {
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut ancestor = child;
|
||||
loop {
|
||||
let command = std::fs::read(format!("/proc/{ancestor}/cmdline")).unwrap();
|
||||
let arguments: Vec<_> = command
|
||||
.split(|byte| *byte == 0)
|
||||
.map(|part| String::from_utf8_lossy(part).to_string())
|
||||
.collect();
|
||||
if arguments[0].ends_with("/cargo") || arguments[0] == "cargo" {
|
||||
assert!(!arguments.iter().any(|arg| arg == "--offline"));
|
||||
assert!(
|
||||
arguments
|
||||
.iter()
|
||||
.any(|arg| arg.starts_with("target.wasm32-wasip2.runner=["))
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_link(format!("/proc/{ancestor}/fd/0")).unwrap(),
|
||||
PathBuf::from("/dev/null")
|
||||
);
|
||||
break;
|
||||
}
|
||||
let status = std::fs::read_to_string(format!("/proc/{ancestor}/status")).unwrap();
|
||||
ancestor = status
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("PPid:"))
|
||||
.unwrap()
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert!(ancestor > 1, "cargo ancestor not found");
|
||||
}
|
||||
let pid = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(_, process)| process.plugin == "cargo")
|
||||
.unwrap()
|
||||
.0;
|
||||
let fs = host.filesystem().await.unwrap();
|
||||
use sausage_vfs::Layer;
|
||||
assert!(
|
||||
fs.stat(&format!("/proc/{pid}/info.json"), true)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
!host
|
||||
.call("core.log", json!({"pid":pid,"grep":"start build"}))
|
||||
.await
|
||||
.unwrap()
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
host.call("core.kill", json!({"pid":pid})).await.unwrap();
|
||||
assert!(task.await.unwrap().is_err());
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
loop {
|
||||
let status = std::fs::read_to_string(format!("/proc/{child}/stat"));
|
||||
if status
|
||||
.as_ref()
|
||||
.map_or(true, |status| status.split_whitespace().nth(2) == Some("Z"))
|
||||
{
|
||||
if match status {
|
||||
Ok(status) => status.split_whitespace().nth(2) == Some("Z"),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
|
||||
Err(error) => panic!("read /proc/{child}/stat: {error}"),
|
||||
} {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
Reference in New Issue
Block a user