//! Native build and WASI libtest integration checks under PROTOCOL.md section 11. use super::common::CACHE; use sausage::{Host, world::World}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use tokio::sync::OnceCell; struct TestWorld(PathBuf); impl TestWorld { /// Creates a fresh native build world. fn new() -> Self { let path = std::env::temp_dir().join(format!("sausage-cargo-{}", uuid::Uuid::now_v7())); World::create(&path).unwrap(); Self(path) } /// Short-deadline tests must not queue on another test's Cargo target lock. async fn timeout_host(&self, name: &str) -> Host { Host::start_with_cache(&self.0, &Path::new(CACHE).join("timeouts").join(name)) .await .unwrap() } /// Creates a dependency-free plugin fixture. fn simple(&self, name: &str, text: &str) { let root = self.0.join(format!("plugins/{name}/source")); 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"), text).unwrap(); } /// Copies a fixture into its plugin source folder. fn source(&self, name: &str, source: &Path) { copy(source, &self.0.join(format!("plugins/{name}/source"))); } } impl Drop for TestWorld { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } /// Copies fixtures without their build outputs or git metadata. fn copy(source: &Path, destination: &Path) { std::fs::create_dir_all(destination).unwrap(); for entry in std::fs::read_dir(source).unwrap() { let entry = entry.unwrap(); if matches!(entry.file_name().to_str(), Some("target" | ".git")) { continue; } let destination = destination.join(entry.file_name()); if entry.file_type().unwrap().is_dir() { copy(&entry.path(), &destination); } else { std::fs::copy(entry.path(), destination).unwrap(); } } } /// Finds a checked-in build fixture. fn fixture(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/crates") .join(name) } /// Builds through the real registered native plugin. async fn build(world: &TestWorld, name: &str) -> Result { let host = Host::start_with_cache(&world.0, Path::new(CACHE)) .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, Path::new(CACHE)) .unwrap() .register(&host); let result = host.call("cargo.build", json!({"plugin":name})).await; host.stop().await; let logs = match &result { Ok(output) => &output["logs"], Err(error) => &error["data"]["logs"], }; check_logs(world, logs); result } /// RPC paths point at full raw streams, outside pruned build folders. fn check_logs(world: &TestWorld, logs: &Value) { let mut total = 0; for stream in ["stdout", "stderr"] { let path = logs[stream]["path"].as_str().expect("log path"); assert!(path.starts_with("/logs/cargo/")); assert!(path.ends_with(&format!(".{stream}.log"))); let bytes = std::fs::metadata(world.0.join(path.trim_start_matches('/'))) .unwrap() .len(); assert_eq!(logs[stream]["bytes"], bytes); total += bytes; } assert_eq!(logs["total_bytes"], total); } struct Valid { world: TestWorld, result: Value, test: PathBuf, } /// Shares one successful SDK build while runner invocations retain isolated worlds. async fn valid() -> &'static Valid { static VALID: OnceCell = OnceCell::const_new(); VALID .get_or_init(|| async { let world = TestWorld::new(); world.source("valid", &fixture("cargo-valid")); std::fs::create_dir_all(world.0.join("plugins/valid/source/target")).unwrap(); std::fs::write( world.0.join("plugins/valid/source/target/excluded"), "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}")); // Integration tests also build a normal binary. Cargo identifies the libtest artifact. let artifacts = tokio::process::Command::new("cargo") .args([ "test", "--no-run", "--release", "--message-format=json", "--bin", "cargo-valid", "--target", "wasm32-wasip2", "--target-dir", ]) .arg(Path::new(CACHE).join("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 = String::from_utf8_lossy(&artifacts.stdout) .lines() .filter_map(|line| serde_json::from_str::(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, test, } }) .await } /// Invokes the same runner subcommand that cargo uses. async fn runner(valid: &Valid, args: &[&str]) -> std::process::Output { 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")) .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 logs = &valid.result["logs"]; let raw = |stream: &str| { std::fs::read_to_string( valid.world.0.join( logs[stream]["path"] .as_str() .unwrap() .trim_start_matches('/'), ), ) .unwrap() }; assert!(raw("stdout").contains("\"reason\":\"compiler-artifact\"")); assert!(raw("stderr").contains("Finished ")); 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")); let document: toml_edit::DocumentMut = manifest.parse().unwrap(); let sdk = Path::new(document["target"]["cfg(target_arch = \"wasm32\")"]["dependencies"]["sausage_plugin"]["path"].as_str().unwrap()); assert_eq!(sdk, valid.world.0.join("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_with_cache(&world.0, Path::new(CACHE)) .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); check_logs(&world, &error["data"]["logs"]); assert!( error["data"]["output"] .as_str() .unwrap() .contains("plugins/missing/source") ); host.stop().await; } /// Rework 3: an unavailable cache prevents builds, not host startup or SDK installation. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cache_failure_is_deferred_until_build() { let world = TestWorld::new(); world.source("simple", &fixture("cargo-test-fail")); // A file blocks directory creation even when the test runs as root. let cache = world.0.join("blocked-cache"); std::fs::write(&cache, "not a directory").unwrap(); let host = Host::start_with_cache(&world.0, &cache).await.unwrap(); assert!(world.0.join("plugins/cargo/sdk/src/lib.rs").is_file()); let error = host .call("cargo.build", json!({"plugin":"simple"})) .await .unwrap_err(); assert_eq!(error["code"], -32004); let output = error["data"]["output"].as_str().unwrap(); assert!(output.contains(cache.to_str().unwrap()), "{output}"); assert!(output.contains("create"), "{output}"); host.stop().await; let standalone = TestWorld::new(); standalone.source("simple", &fixture("cargo-test-fail")); let builder = sausage::cargo::Cargo::with_runner( World::open(&standalone.0).unwrap(), PathBuf::from(env!("CARGO_BIN_EXE_sausage")), &cache, ) .unwrap(); assert!(standalone.0.join("plugins/cargo/sdk/src/lib.rs").is_file()); let error = builder .build(&standalone.0.join("plugins/simple/source")) .await .unwrap_err(); assert!(error.output.contains(cache.to_str().unwrap()), "{error}"); assert!(cache.is_file()); } /// 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(test))] 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")), Path::new(CACHE), ) .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() { let valid = valid().await; 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"], 12); assert_eq!(valid.result["tests"]["failed"], 0); assert!(!valid.world.0.join("plugins/valid/plugin.wasm").exists()); assert!(!valid.world.0.join("state/cargo/target").exists()); assert!( Path::new(CACHE) .join("cargo-target/wasm32-wasip2/release") .is_dir() ); } /// PROTOCOL 11: tests and compilation share one source copy without target metadata. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn source_copy() { let valid = valid().await; assert!( !valid .world .0 .join("state/cargo/work/target/excluded") .exists() ); assert!( std::fs::read_to_string(valid.world.0.join("state/cargo/work/Cargo.toml")) .unwrap() .contains(valid.world.0.join("plugins/cargo/sdk").to_str().unwrap()) ); } /// PROTOCOL 11: compiler failures return compiler diagnostics. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn compile_error() { let world = TestWorld::new(); world.source("bad", &fixture("cargo-fail")); let error = build(&world, "bad").await.unwrap_err(); assert!( error["data"]["tests"]["output"] .as_str() .unwrap() .contains("error") ); } /// PROTOCOL 11: a failing test rejects the build with its test output. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn failing_test() { let world = TestWorld::new(); world.source("bad", &fixture("cargo-test-fail")); let error = build(&world, "bad").await.unwrap_err(); assert_eq!(error["data"]["tests"]["failed"], 1); assert!( error["data"]["tests"]["output"] .as_str() .unwrap() .contains("deliberate WASI panic") ); } /// PROTOCOL 11: one panic does not prevent another test instance from passing. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn panic_is_isolated() { let world = TestWorld::new(); world.source("bad", &fixture("cargo-test-fail")); let error = build(&world, "bad").await.unwrap_err(); assert_eq!(error["data"]["tests"]["passed"], 1); } /// PROTOCOL 11: build scripts can compile and link WASI C code. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn c_build() { let world = TestWorld::new(); world.source("c", &fixture("cargo-c")); let result = build(&world, "c") .await .unwrap_or_else(|error| panic!("{error}")); assert_eq!(result["tests"]["passed"], 1); } /// PROTOCOL 11: libtest name filters select only matching tests. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn name_filter() { let output = runner(valid().await, &["arithmetic"]).await; assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed")); } /// PROTOCOL 11: --ignored selects ignored tests instead of normal tests. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn ignored_filter() { let output = runner(valid().await, &["--ignored"]).await; assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert!(String::from_utf8_lossy(&output.stdout).contains("test tests::ignored_case ... ok")); } /// PROTOCOL 11: CARGO_MANIFEST_DIR fixture reads work at the package's absolute host path. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn manifest_fixture() { let output = runner(valid().await, &["fixture_at_manifest_path"]).await; assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); } /// PROTOCOL 11: WASI Preview 2 keeps cwd at / despite PWD. Absolute manifest paths reach fixtures. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn current_folder() { let valid = valid().await; let output = runner(valid, &["current_directory"]).await; let text = String::from_utf8_lossy(&output.stdout); assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); assert!( text.lines() .any(|line| line.ends_with("WASI_CURRENT_DIR=/")), "{text}" ); } /// PROTOCOL 11: the SDK can acquire and release locks through the runner's core transport. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sdk_lock() { let output = runner(valid().await, &["sdk_lock"]).await; assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); } /// PROTOCOL 11: calls to other plugins fail with a clear runner error. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unavailable_plugin() { let output = runner(valid().await, &["other_plugin_fails"]).await; assert!( output.status.success(), "{}", String::from_utf8_lossy(&output.stderr) ); } /// PROTOCOL 11: the notes example tests run in isolated worlds under the same runner. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn notes_example() { let world = TestWorld::new(); world.source( "notes", &Path::new(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .join("examples/notes"), ); let result = build(&world, "notes") .await .unwrap_or_else(|error| panic!("{error}")); assert!(result["tests"]["passed"].as_u64().unwrap() > 0); } /// PROTOCOL 11: SDK installation leaves unchanged files and manifests untouched. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn unchanged_sdk() { let world = TestWorld::new(); let setup = || { sausage::cargo::Cargo::with_runner( World::open(&world.0).unwrap(), PathBuf::from(env!("CARGO_BIN_EXE_sausage")), Path::new(CACHE), ) .unwrap() }; setup(); let manifest = world.0.join("plugins/cargo/sdk/Cargo.toml"); let modified = std::fs::metadata(&manifest).unwrap().modified().unwrap(); setup(); assert_eq!( std::fs::metadata(manifest).unwrap().modified().unwrap(), modified ); } /// PROTOCOL 11: an SDK edit is used by the next build on the same running host. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn world_sdk_edits_apply_without_restart() { tokio::time::timeout(std::time::Duration::from_secs(180), async { let world = TestWorld::new(); world.simple("sdk-probe", "fn main() {}\n#[test]\nfn world_sdk_probe() { assert_eq!(sausage_plugin::world_sdk_probe(), 1); }\n"); let manifest = world.0.join("plugins/sdk-probe/source/Cargo.toml"); let mut text = std::fs::read_to_string(&manifest).unwrap(); text.push_str("\n[dependencies]\nsausage-plugin = '0.1'\n"); std::fs::write(manifest, text).unwrap(); let host = Host::start_with_cache(&world.0, Path::new(CACHE)).await.unwrap(); let library = world.0.join("plugins/cargo/sdk/src/lib.rs"); let original = std::fs::read_to_string(&library).unwrap(); std::fs::write(&library, format!("{original}\npub fn world_sdk_probe() -> u8 {{ 1 }}\n")).unwrap(); let params = json!({"plugin":"sdk-probe", "timeout_secs":120, "test_timeout_secs":10}); let first = host.call("cargo.build", params.clone()).await.unwrap(); assert_eq!(first["tests"]["passed"], 1); // Only the SDK changes: Cargo must not reuse its old compiled version. std::fs::write(&library, format!("{original}\npub fn world_sdk_probe() -> u8 {{ 2 }}\n")).unwrap(); let second = host.call("cargo.build", params).await.unwrap_err(); assert_eq!(second["code"], -32004); assert_eq!(second["data"]["tests"]["failed"], 1); assert!(second["data"]["tests"]["output"].as_str().unwrap().contains("world_sdk_probe")); host.stop().await; }).await.expect("SDK edit regression timed out"); } /// PROTOCOL 11: startup seeds a missing SDK, but never repairs or overwrites an existing one. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn world_sdk_survives_restart_and_missing_sdk_is_reseeded() { tokio::time::timeout(std::time::Duration::from_secs(30), async { let world = TestWorld::new(); let sdk = world.0.join("plugins/cargo/sdk"); let host = Host::start_with_cache(&world.0, Path::new(CACHE)) .await .unwrap(); let library = sdk.join("src/lib.rs"); let original = std::fs::read(&library).unwrap(); let manifest = sdk.join("Cargo.toml"); let edited = b"// world SDK edit must survive restart\n"; std::fs::write(&library, edited).unwrap(); std::fs::write(&manifest, "# edited manifest\n").unwrap(); let removed = sdk.join("macros/src/lib.rs"); std::fs::remove_file(&removed).unwrap(); host.stop().await; let host = Host::start_with_cache(&world.0, Path::new(CACHE)) .await .unwrap(); assert_eq!(std::fs::read(&library).unwrap(), edited); assert_eq!( std::fs::read_to_string(&manifest).unwrap(), "# edited manifest\n" ); assert!(!removed.exists(), "existing SDK must not be repaired"); host.stop().await; std::fs::remove_dir_all(&sdk).unwrap(); let host = Host::start_with_cache(&world.0, Path::new(CACHE)) .await .unwrap(); assert_eq!(std::fs::read(&library).unwrap(), original); assert!(removed.is_file()); assert!(sdk.join("interface/src/lib.rs").is_file()); host.stop().await; }) .await .expect("SDK restart regression timed out"); } /// PROTOCOL 11: removing the SDK while the host is running re-seeds it on the next build. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn world_sdk_missing_at_build_is_reseeded() { tokio::time::timeout(std::time::Duration::from_secs(180), async { let world = TestWorld::new(); world.simple("reseed-probe", "fn main() {}\n"); let host = Host::start_with_cache(&world.0, Path::new(CACHE)) .await .unwrap(); let sdk = world.0.join("plugins/cargo/sdk"); let original = std::fs::read(sdk.join("src/lib.rs")).unwrap(); std::fs::remove_dir_all(&sdk).unwrap(); host.call( "cargo.build", json!({"plugin":"reseed-probe", "timeout_secs":120, "test_timeout_secs":10}), ) .await .unwrap(); assert_eq!(std::fs::read(sdk.join("src/lib.rs")).unwrap(), original); host.stop().await; }) .await .expect("SDK re-seed build regression timed out"); } /// PROTOCOL 11: init can build a plugin without a running host. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn standalone_builder() { let world = TestWorld::new(); world.source("simple", &fixture("cargo-test-fail")); let builder = sausage::cargo::Cargo::with_runner( World::open(&world.0).unwrap(), PathBuf::from(env!("CARGO_BIN_EXE_sausage")), Path::new(CACHE), ) .unwrap(); assert_eq!( builder .build(&world.0.join("plugins/simple/source")) .await .unwrap_err() .tests .failed, 1 ); } /// SAU-126: a named CPU-bound WASI test times out, while other instances still run. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn named_test_timeout_and_mutex_release() { let world = TestWorld::new(); world.simple("test-timeout", "fn main() {} #[test] fn a_hung() { loop { std::hint::black_box(1); } } #[test] fn z_remaining() { eprintln!(\"REMAINING_TEST_RAN\"); }"); world.simple("next", "fn main() {} #[test] fn passes() {}"); let integration = world.0.join("plugins/test-timeout/source/tests"); std::fs::create_dir_all(&integration).unwrap(); std::fs::write( integration.join("remaining.rs"), "#[test] fn integration_remaining() { eprintln!(\"REMAINING_BINARY_RAN\"); }", ) .unwrap(); let host = world.timeout_host("named").await; let error = tokio::time::timeout( std::time::Duration::from_secs(60), host.call( "cargo.build", json!({"plugin":"test-timeout", "test_timeout_secs":1, "timeout_secs":45}), ), ) .await .unwrap() .unwrap_err(); assert_eq!(error["code"], -32004); assert_eq!(error["message"], "WASI tests failed"); assert_eq!(error["data"]["tests"]["failed"], 1); assert_eq!(error["data"]["tests"]["passed"], 2); let output = error["data"]["output"].as_str().unwrap(); assert!(output.contains("test a_hung ... FAILED"), "{output}"); assert!(output.contains("timed out after 1 s"), "{output}"); assert!(output.contains("REMAINING_TEST_RAN"), "{output}"); assert!(output.contains("REMAINING_BINARY_RAN"), "{output}"); let next = tokio::time::timeout( std::time::Duration::from_secs(30), host.call("cargo.build", json!({"plugin":"next"})), ) .await .unwrap() .unwrap(); assert_eq!(next["tests"]["passed"], 1); host.stop().await; } /// Wait for a build-script child without using timing sleeps. async fn script_child(world: &TestWorld) -> u32 { tokio::time::timeout(std::time::Duration::from_secs(60), async { loop { if let Ok(text) = std::fs::read_to_string(world.0.join("state/cargo/work/child.pid")) && let Ok(pid) = text.trim().parse() { break pid; } tokio::task::yield_now().await; } }) .await .expect("build script never started") } /// A killed descendant may briefly remain as a zombie, but cannot keep executing. async fn child_ended(child: u32) { tokio::time::timeout(std::time::Duration::from_secs(10), async { loop { match std::fs::read_to_string(format!("/proc/{child}/stat")) { Ok(status) if status.split_whitespace().nth(2) == Some("Z") => break, Err(error) if error.kind() == std::io::ErrorKind::NotFound => break, _ => tokio::task::yield_now().await, } } }) .await .expect("cargo descendant survived cancellation/timeout"); } /// SAU-126: an explicit small total budget kills build.rs and unblocks a queued build. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn build_timeout_and_queued_build() { let world = TestWorld::new(); world.source("hang", &fixture("cargo-hang")); world.simple("next", "fn main() {} #[test] fn passes() {}"); let host = world.timeout_host("queued").await; let building = host.clone(); let task = tokio::spawn(async move { building .call("cargo.build", json!({"plugin":"hang", "timeout_secs":5})) .await }); let child = script_child(&world).await; let queued_host = host.clone(); let queued = tokio::spawn(async move { queued_host .call("cargo.build", json!({"plugin":"next", "timeout_secs":3})) .await }); let error = task.await.unwrap().unwrap_err(); assert_eq!(error["code"], -32004); assert_eq!(error["message"], "timeout"); assert_eq!(error["data"]["reason"], "timeout"); check_logs(&world, &error["data"]["logs"]); assert_eq!(error["data"]["step"], "tests"); assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 5.0); assert!(!error["data"]["output"].as_str().unwrap().is_empty()); child_ended(child).await; let next = tokio::time::timeout(std::time::Duration::from_secs(20), queued) .await .unwrap() .unwrap() .unwrap(); assert_eq!(next["tests"]["passed"], 1); host.stop().await; } /// SAU-126: the same total deadline covers release compilation and retains test results. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn release_build_timeout_retains_tests() { let world = TestWorld::new(); world.simple( "release-timeout", r#"fn main() {} #[test] fn prepare_release() { std::fs::write(concat!(env!("CARGO_MANIFEST_DIR"), "/release-marker"), "ready").unwrap(); }"#, ); std::fs::write( world.0.join("plugins/release-timeout/source/build.rs"), r#" fn main() { println!("cargo:rerun-if-changed=release-marker"); if std::path::Path::new("release-marker").exists() { std::process::Command::new("sh") .args(["-c", "echo $$ > child.pid; exec sleep 1000"]) .status().unwrap(); } }"#, ) .unwrap(); let host = world.timeout_host("release").await; let building = host.clone(); let task = tokio::spawn(async move { building .call( "cargo.build", json!({"plugin":"release-timeout", "timeout_secs":5}), ) .await }); let child = script_child(&world).await; let error = tokio::time::timeout(std::time::Duration::from_secs(10), task) .await .unwrap() .unwrap() .unwrap_err(); assert_eq!(error["code"], -32004); assert_eq!(error["data"]["reason"], "timeout"); check_logs(&world, &error["data"]["logs"]); assert_eq!(error["data"]["step"], "release build"); assert_eq!(error["data"]["tests"]["passed"], 1); assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 5.0); child_ended(child).await; host.stop().await; } /// SAU-126: omission selects the real 900-second deadline, without a 15-minute test. #[tokio::test] async fn omitted_build_timeout_uses_default() { let world = TestWorld::new(); world.source("hang", &fixture("cargo-hang")); let host = world.timeout_host("default").await; let building = host.clone(); let task = tokio::spawn(async move { building.call("cargo.build", json!({"plugin":"hang"})).await }); let child = script_child(&world).await; tokio::time::pause(); tokio::time::advance(std::time::Duration::from_secs( sausage::cargo::DEFAULT_TIMEOUT_SECS, )) .await; // Let the awakened deadline observe the advanced clock before resuming it. tokio::task::yield_now().await; let error = task.await.unwrap().unwrap_err(); tokio::time::resume(); assert_eq!(error["data"]["reason"], "timeout"); assert!(error["data"]["elapsed_secs"].as_f64().unwrap() >= 900.0); child_ended(child).await; host.stop().await; } /// SAU-126: a caller ending stops its attached native build and releases the mutex. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn ended_caller_releases_build_mutex() { let world = TestWorld::new(); world.source("hang", &fixture("cargo-hang")); world.simple("next", "fn main() {} #[test] fn passes() {}"); let host = world.timeout_host("caller").await; let (finish, finished) = tokio::sync::watch::channel(false); host.register_native( "caller", std::sync::Arc::new(move |context, _, _| { let mut finished = finished.clone(); Box::pin(async move { tokio::spawn( async move { context.call("cargo.build", json!({"plugin":"hang"})).await }, ); let _ = finished.wait_for(|done| *done).await; Ok(json!({})) }) }), ); let calling = host.clone(); let caller = tokio::spawn(async move { calling.call("caller.run", json!({})).await }); let child = script_child(&world).await; let queued_host = host.clone(); let queued = tokio::spawn(async move { queued_host .call("cargo.build", json!({"plugin":"next"})) .await }); finish.send_replace(true); caller.await.unwrap().unwrap(); let next = tokio::time::timeout(std::time::Duration::from_secs(12), queued) .await .expect("ended caller retained mutex") .unwrap() .unwrap(); assert_eq!(next["tests"]["passed"], 1); child_ended(child).await; host.stop().await; } /// SAU-126: discovery exposes non-null defaults; invalid budgets cannot disable limits. #[tokio::test] async fn timeout_params_discovery_and_validation() { let world = TestWorld::new(); let host = Host::start_with_cache(&world.0, Path::new(CACHE)) .await .unwrap(); let doc = host.call("cargo.rpc.discover", json!({})).await.unwrap(); let params = doc["methods"][0]["params"].as_array().unwrap(); for (name, default) in [("timeout_secs", 900), ("test_timeout_secs", 120)] { let param = params.iter().find(|param| param["name"] == name).unwrap(); assert_eq!(param["schema"]["default"], default); assert_eq!(param["schema"]["minimum"], 1); assert!(!param["description"].as_str().unwrap().is_empty()); for value in [json!(0), json!(-1), json!(1.5), json!("1"), json!(null)] { let mut args = json!({"plugin":"missing"}); args[name] = value; let error = host.call("cargo.build", args).await.unwrap_err(); assert_eq!(error["code"], -32602, "{error}"); } } host.stop().await; } /// 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 = world.timeout_host("kill").await; let building = host.clone(); let task = tokio::spawn(async move { building.call("cargo.build", json!({"plugin":"hang"})).await }); let child = tokio::time::timeout(std::time::Duration::from_secs(120), async { loop { if let Ok(text) = std::fs::read_to_string(world.0.join("state/cargo/work/child.pid")) && let Ok(pid) = text.trim().parse::() { break pid; } tokio::task::yield_now().await; } }) .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() ); // Queue a build on the SAME builder before cancelling the lock holder. world.simple("next", "fn main() {} #[test] fn passes() {}"); let queued_host = host.clone(); let queued = tokio::spawn(async move { queued_host .call("cargo.build", json!({"plugin":"next"})) .await }); host.call("core.kill", json!({"pid":pid})).await.unwrap(); assert!(task.await.unwrap().is_err()); child_ended(ancestor).await; let next = tokio::time::timeout(std::time::Duration::from_secs(10), queued) .await .expect("cancelled build retained mutex") .unwrap() .unwrap(); assert_eq!(next["tests"]["passed"], 1); tokio::time::timeout(std::time::Duration::from_secs(10), async { loop { let status = std::fs::read_to_string(format!("/proc/{child}/stat")); 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; } }) .await .unwrap(); host.stop().await; }