This commit is contained in:
+321
-3
@@ -12,6 +12,23 @@ impl TestWorld {
|
||||
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")));
|
||||
@@ -56,8 +73,28 @@ async fn build(world: &TestWorld, name: &str) -> Result<Value, Value> {
|
||||
.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,
|
||||
@@ -189,6 +226,20 @@ async fn build_output_and_warnings() {
|
||||
.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 "));
|
||||
@@ -260,6 +311,7 @@ async fn native_discovery_and_error_paths() {
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(error["code"], -32004);
|
||||
check_logs(&world, &error["data"]["logs"]);
|
||||
assert!(
|
||||
error["data"]["output"]
|
||||
.as_str()
|
||||
@@ -606,6 +658,259 @@ async fn standalone_builder() {
|
||||
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() {
|
||||
@@ -615,9 +920,7 @@ async fn kill_process_group() {
|
||||
);
|
||||
let world = TestWorld::new();
|
||||
world.source("hang", &fixture("cargo-hang"));
|
||||
let host = Host::start_with_cache(&world.0, Path::new(CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
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 });
|
||||
@@ -685,8 +988,23 @@ async fn kill_process_group() {
|
||||
.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"));
|
||||
|
||||
Reference in New Issue
Block a user