Merge 'card/sau-126'
This commit is contained in:
@@ -84,6 +84,16 @@ a plugin, or a change that the agent makes to itself.
|
||||
that occur only in WASI, for example a missing WASI function or a panic at
|
||||
start. The test runner uses the same Wasmtime and the same mounts as the
|
||||
core.
|
||||
- **Tests never run without a timeout.** `cargo.build` serializes its work
|
||||
folder, so one hung test or build script must not block all later builds.
|
||||
`timeout_secs` defaults to 900 seconds after mutex acquisition, excluding
|
||||
queue time; `test_timeout_secs` defaults to 120 seconds per WASI instance.
|
||||
Both are optional positive integers, never null/unlimited. Test timeouts name
|
||||
the failing test and let other instances finish. Build deadlines and
|
||||
cancellation kill cargo's process group before dropping the mutex; timeout
|
||||
errors retain the phase, elapsed time, and recent output. Source preparation
|
||||
and publication retain serialization while their blocking filesystem work
|
||||
finishes, even if cancelled, to avoid overlapping writes to the work folder.
|
||||
- **The SDK is not published.** `cargo` points `sausage-plugin = "0.1"` at
|
||||
its own copy, which it also shows in `/plugins/cargo/sdk/`.
|
||||
- **Compiled code is a cache, not state.** It lives outside the world, in
|
||||
|
||||
+14
@@ -451,6 +451,20 @@ builds `/plugins/notes/source/`:
|
||||
{"passed", "failed", "output"}, "warnings"}`. If a step fails, it returns
|
||||
an error with the compiler or test output.
|
||||
|
||||
- Optional `timeout_secs` is an integer of at least 1, default **900** (not
|
||||
null or unlimited). The wall-clock budget starts when the build acquires its
|
||||
mutex, excluding queue time, and covers tests and release compilation together.
|
||||
On expiry, cargo and its descendant process group are killed and the mutex is
|
||||
released. Error -32004 has reason/message `timeout`, with `data.reason`,
|
||||
`data.elapsed_secs`, `data.step` (`tests` or `release build`), `data.tests`,
|
||||
and `data.output` containing the last 50 output lines.
|
||||
- Optional `test_timeout_secs` is an integer of at least 1, default **120**.
|
||||
Each WASI test instance has this wall-clock budget. On expiry, the instance
|
||||
is killed, its named test fails with `timed out after N s`, and remaining
|
||||
tests continue. The overall build budget still applies.
|
||||
- Cancellation kills cargo's process group, including build scripts and test
|
||||
runners, before releasing the build mutex. An attached build also stops when
|
||||
its caller ends (under the normal process stopping deadline).
|
||||
- A call to `cargo.build` gets a process ID, a folder in `/proc`, and journal
|
||||
entries, like other calls.
|
||||
- `/plugins/cargo/sdk/` shows the source of the SDK. The dependency
|
||||
|
||||
@@ -36,6 +36,7 @@ chrono = { version = "0.4.42", default-features = false, features = ["clock"] }
|
||||
tempfile = "3.27.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1", features = ["test-util"] }
|
||||
hyper = { version = "1.12.0", features = ["server", "http1"] }
|
||||
hyper-util = { version = "0.1.21", features = ["tokio"] }
|
||||
sausage-interface = { path = "../interface" }
|
||||
|
||||
+205
-31
@@ -4,11 +4,44 @@ use anyhow::{Context, Result, bail};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::process::Command;
|
||||
use tokio::{io::AsyncReadExt, process::Command, time::Instant};
|
||||
|
||||
/// Default wall-clock budget after acquiring the build mutex (not queue time).
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 900;
|
||||
/// Default wall-clock budget for each WASI test instance.
|
||||
pub const DEFAULT_TEST_TIMEOUT_SECS: u64 = 120;
|
||||
|
||||
/// Wall-clock budgets for a build and its individual WASI tests.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct BuildOptions {
|
||||
pub timeout_secs: u64,
|
||||
pub test_timeout_secs: u64,
|
||||
}
|
||||
impl Default for BuildOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeout_secs: DEFAULT_TIMEOUT_SECS,
|
||||
test_timeout_secs: DEFAULT_TEST_TIMEOUT_SECS,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Optional RPC seconds are positive integers; null does not disable a deadline.
|
||||
fn seconds(params: &Value, name: &str, default: u64) -> Result<u64, Value> {
|
||||
match params.get(name) {
|
||||
None => Ok(default),
|
||||
Some(value) => value
|
||||
.as_u64()
|
||||
.filter(|seconds| *seconds >= 1)
|
||||
.ok_or_else(|| {
|
||||
crate::error(-32602, &format!("{name} must be an integer of at least 1"))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The native build plugin.
|
||||
pub const ID: &str = "cargo";
|
||||
@@ -38,6 +71,8 @@ pub struct BuildFailure {
|
||||
pub reason: String,
|
||||
pub tests: TestResults,
|
||||
pub output: String,
|
||||
pub elapsed_secs: Option<f64>,
|
||||
pub step: Option<&'static str>,
|
||||
}
|
||||
impl std::fmt::Display for BuildFailure {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -51,6 +86,8 @@ impl From<anyhow::Error> for BuildFailure {
|
||||
reason: "build preparation failed".into(),
|
||||
output: format!("{error:#}"),
|
||||
tests: TestResults::default(),
|
||||
elapsed_secs: None,
|
||||
step: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,19 +179,39 @@ impl Cargo {
|
||||
host.register_native(ID,Arc::new(move |_context,method,params| {
|
||||
let builder=builder.clone();
|
||||
Box::pin(async move {
|
||||
if method=="rpc.discover" { return Ok(json!({"openrpc":"1.2.6","info":{"title":"cargo","version":"native"},"methods":[{"name":"build","paramStructure":"by-name","params":[{"name":"plugin","required":true,"schema":{"type":"string"}}],"result":{"name":"build","schema":{"type":"object","required":["wasm","tests","warnings"],"properties":{"wasm":{"type":"string"},"warnings":{"type":"string"},"tests":{"type":"object","required":["passed","failed","output"],"properties":{"passed":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"output":{"type":"string"}}}}}},"errors":[{"code":-32004,"message":"Build failed"}]}]})); }
|
||||
if method=="rpc.discover" { return Ok(json!({"openrpc":"1.2.6","info":{"title":"cargo","version":"native"},"methods":[{"name":"build","paramStructure":"by-name","description":"Copies source, runs bounded WASI tests, then builds a release component. Deadlines kill cargo and its descendants; cancellation releases the build mutex.","params":[{"name":"plugin","required":true,"schema":{"type":"string"}},{"name":"timeout_secs","required":false,"description":"Total wall-clock limit in seconds for tests and release build after acquiring the build mutex (queue time excluded). Default 900; minimum 1. Cannot be disabled.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TIMEOUT_SECS}},{"name":"test_timeout_secs","required":false,"description":"Wall-clock limit in seconds for each WASI test instance. Default 120; minimum 1. A timed-out test is named and fails; remaining tests continue.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TEST_TIMEOUT_SECS}}],"result":{"name":"build","schema":{"type":"object","required":["wasm","tests","warnings"],"properties":{"wasm":{"type":"string"},"warnings":{"type":"string"},"tests":{"type":"object","required":["passed","failed","output"],"properties":{"passed":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"output":{"type":"string"}}}}}},"errors":[{"code":-32004,"message":"Build failed"}]}]})); }
|
||||
if method!="build" { return Err(crate::error(-32601,"cargo method not found")); }
|
||||
let plugin=params["plugin"].as_str().ok_or_else(||crate::error(-32602,"plugin must be a string"))?;
|
||||
match builder.build(&builder.world.plugin_dir(plugin).join("source")).await {
|
||||
let options = BuildOptions {
|
||||
timeout_secs: seconds(¶ms, "timeout_secs", DEFAULT_TIMEOUT_SECS)?,
|
||||
test_timeout_secs: seconds(¶ms, "test_timeout_secs", DEFAULT_TEST_TIMEOUT_SECS)?,
|
||||
};
|
||||
match builder.build_with_options(&builder.world.plugin_dir(plugin).join("source"), options).await {
|
||||
Ok(output)=>Ok(json!({"wasm":format!("/{}",output.wasm.strip_prefix(&builder.world.root).unwrap().display()),"tests":output.tests,"warnings":output.warnings})),
|
||||
Err(failure)=>Err(json!({"code":-32004,"message":failure.reason,"data":{"tests":failure.tests,"output":failure.output}})),
|
||||
Err(failure)=>Err(json!({"code":-32004,"message":failure.reason,"data":{"reason":failure.reason,"tests":failure.tests,"output":failure.output,"elapsed_secs":failure.elapsed_secs,"step":failure.step}})),
|
||||
}
|
||||
})
|
||||
}));
|
||||
}
|
||||
/// Copies source once, runs WASI tests, then publishes a release component under state/cargo/builds.
|
||||
pub async fn build(&self, source: &Path) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||
self.build_with_options(source, BuildOptions::default())
|
||||
.await
|
||||
}
|
||||
/// Like build, with explicit positive wall-clock budgets. Waiting for the mutex is unbounded.
|
||||
pub async fn build_with_options(
|
||||
&self,
|
||||
source: &Path,
|
||||
options: BuildOptions,
|
||||
) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||
if options.timeout_secs == 0 || options.test_timeout_secs == 0 {
|
||||
return Err(anyhow::anyhow!("timeouts must be at least 1 second").into());
|
||||
}
|
||||
let build_guard = self.builds.clone().lock_owned().await;
|
||||
let started = Instant::now();
|
||||
let deadline = started
|
||||
.checked_add(Duration::from_secs(options.timeout_secs))
|
||||
.ok_or_else(|| anyhow::anyhow!("timeout_secs is too large"))?;
|
||||
let work = self.world.state_dir(ID).join("work");
|
||||
let target = self.target.clone();
|
||||
let temporary = self.world.state_dir(ID).join("tmp");
|
||||
@@ -208,16 +265,27 @@ impl Cargo {
|
||||
.await
|
||||
.map_err(anyhow::Error::from)??;
|
||||
let mut test = Command::new("cargo");
|
||||
test.args(["test", "--release", "--target", TARGET, "--target-dir"])
|
||||
.arg(&target)
|
||||
.current_dir(&work)
|
||||
.env("TMPDIR", &temporary)
|
||||
.arg("--config")
|
||||
.arg(format!(
|
||||
"target.wasm32-wasip2.runner={}",
|
||||
json!([self.runner, "test-runner"])
|
||||
));
|
||||
let output = run_command(test).await?;
|
||||
test.args([
|
||||
"test",
|
||||
"--no-fail-fast",
|
||||
"--release",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target)
|
||||
.current_dir(&work)
|
||||
.env("TMPDIR", &temporary)
|
||||
.env(
|
||||
"SAUSAGE_TEST_TIMEOUT_SECS",
|
||||
options.test_timeout_secs.to_string(),
|
||||
)
|
||||
.arg("--config")
|
||||
.arg(format!(
|
||||
"target.wasm32-wasip2.runner={}",
|
||||
json!([self.runner, "test-runner"])
|
||||
));
|
||||
let output = run_command(test, started, deadline, "tests", TestResults::default()).await?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let tests = TestResults {
|
||||
@@ -234,22 +302,24 @@ impl Cargo {
|
||||
reason: "WASI tests failed".into(),
|
||||
output: tests.output.clone(),
|
||||
tests,
|
||||
elapsed_secs: None,
|
||||
step: None,
|
||||
});
|
||||
}
|
||||
let mut build = Command::new("cargo");
|
||||
build
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--message-format=json-render-diagnostics",
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target)
|
||||
.current_dir(&work);
|
||||
let output = run_command(build, started, deadline, "release build", tests.clone()).await?;
|
||||
let result = async {
|
||||
let mut build = Command::new("cargo");
|
||||
build
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--target",
|
||||
TARGET,
|
||||
"--message-format=json-render-diagnostics",
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target)
|
||||
.current_dir(&work);
|
||||
let output = run_command(build).await?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let diagnostics = stderr.to_string();
|
||||
@@ -292,6 +362,8 @@ impl Cargo {
|
||||
reason: "release build failed".into(),
|
||||
output: format!("{error:#}"),
|
||||
tests,
|
||||
elapsed_secs: None,
|
||||
step: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -306,16 +378,81 @@ impl Drop for ProcessGroup {
|
||||
}
|
||||
}
|
||||
/// Starts one process group and retains its guard across all output collection.
|
||||
async fn run_command(mut command: Command) -> Result<std::process::Output> {
|
||||
async fn run_command(
|
||||
mut command: Command,
|
||||
started: Instant,
|
||||
deadline: Instant,
|
||||
step: &'static str,
|
||||
mut tests: TestResults,
|
||||
) -> std::result::Result<std::process::Output, BuildFailure> {
|
||||
command
|
||||
.process_group(0)
|
||||
.kill_on_drop(true)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
let child = command.spawn().context("start cargo")?;
|
||||
let _group = ProcessGroup(child.id().unwrap());
|
||||
Ok(child.wait_with_output().await?)
|
||||
let mut child = command.spawn().context("start cargo")?;
|
||||
let group = ProcessGroup(child.id().unwrap());
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let mut stderr = child.stderr.take().unwrap();
|
||||
let mut out = Vec::new();
|
||||
let mut err = Vec::new();
|
||||
let (status, timed_out) = {
|
||||
let collect = async {
|
||||
let (status, _, _) = tokio::try_join!(
|
||||
child.wait(),
|
||||
stdout.read_to_end(&mut out),
|
||||
stderr.read_to_end(&mut err)
|
||||
)?;
|
||||
Ok::<_, std::io::Error>(status)
|
||||
};
|
||||
tokio::pin!(collect);
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = tokio::time::sleep_until(deadline) => {
|
||||
// Kill the entire tree before waiting for EOF/reaping cargo. The guard also
|
||||
// covers cancellation while collecting, and runs before the mutex drops.
|
||||
let _ = nix::sys::signal::killpg(nix::unistd::Pid::from_raw(group.0 as i32), nix::sys::signal::Signal::SIGKILL);
|
||||
(collect.await, true)
|
||||
}
|
||||
status = &mut collect => (status, false),
|
||||
}
|
||||
};
|
||||
let status = status.map_err(anyhow::Error::from)?;
|
||||
if timed_out {
|
||||
let output = format!(
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&out),
|
||||
String::from_utf8_lossy(&err)
|
||||
);
|
||||
let output = output
|
||||
.trim_end()
|
||||
.lines()
|
||||
.rev()
|
||||
.take(50)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if step == "tests" {
|
||||
tests.passed = test_count(&String::from_utf8_lossy(&out), "passed");
|
||||
tests.failed = test_count(&String::from_utf8_lossy(&out), "failed");
|
||||
tests.output = output.clone();
|
||||
}
|
||||
return Err(BuildFailure {
|
||||
reason: "timeout".into(),
|
||||
tests,
|
||||
output,
|
||||
elapsed_secs: Some(started.elapsed().as_secs_f64()),
|
||||
step: Some(step),
|
||||
});
|
||||
}
|
||||
Ok(std::process::Output {
|
||||
status,
|
||||
stdout: out,
|
||||
stderr: err,
|
||||
})
|
||||
}
|
||||
/// Copies only source, excluding build outputs and git internals at every level.
|
||||
fn copy_source(source: &Path, destination: &Path, root: bool) -> Result<()> {
|
||||
@@ -457,3 +594,40 @@ fn strip_progress(output: &str) -> String {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod timeout_tests {
|
||||
use super::*;
|
||||
|
||||
/// Deadline errors retain the shared start time, phase, test totals, and output tail.
|
||||
#[tokio::test]
|
||||
async fn timeout_diagnostics() {
|
||||
let mut command = Command::new("sh");
|
||||
command.args([
|
||||
"-c",
|
||||
"i=0; while [ $i -lt 100 ]; do echo diagnostic-$i; i=$((i+1)); done; exec sleep 1000",
|
||||
]);
|
||||
let now = Instant::now();
|
||||
let failure = run_command(
|
||||
command,
|
||||
now - Duration::from_secs(10),
|
||||
now + Duration::from_secs(1),
|
||||
"release build",
|
||||
TestResults {
|
||||
passed: 7,
|
||||
failed: 0,
|
||||
output: "successful tests".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(failure.reason, "timeout");
|
||||
assert_eq!(failure.step, Some("release build"));
|
||||
assert!(failure.elapsed_secs.unwrap() >= 11.0);
|
||||
assert_eq!(failure.tests.passed, 7);
|
||||
assert_eq!(failure.tests.output, "successful tests");
|
||||
assert_eq!(failure.output.lines().count(), 50);
|
||||
assert!(failure.output.contains("diagnostic-99"));
|
||||
assert!(!failure.output.contains("diagnostic-0\n"));
|
||||
}
|
||||
}
|
||||
|
||||
+19
-2
@@ -64,12 +64,21 @@ async fn instance(
|
||||
("SAUSAGE_PLUGIN".into(), "test".into()),
|
||||
("SAUSAGE_PARENT".into(), "core".into()),
|
||||
]);
|
||||
let timeout_secs = std::env::var("SAUSAGE_TEST_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value >= 1)
|
||||
.unwrap_or(sausage::cargo::DEFAULT_TEST_TIMEOUT_SECS);
|
||||
let deadline = tokio::time::Instant::now()
|
||||
.checked_add(std::time::Duration::from_secs(timeout_secs))
|
||||
.ok_or_else(|| anyhow::anyhow!("test_timeout_secs is too large"))?;
|
||||
let (ended, mut ended_rx) = tokio::sync::watch::channel(true);
|
||||
let mut guest = runtime
|
||||
.start_with_args(
|
||||
&component,
|
||||
InstanceConfig {
|
||||
filesystem,
|
||||
store_ended: tokio::sync::watch::channel(true).0,
|
||||
store_ended: ended,
|
||||
network: true,
|
||||
env,
|
||||
},
|
||||
@@ -88,6 +97,7 @@ async fn instance(
|
||||
let mut stdout_open = true;
|
||||
let mut stderr_open = true;
|
||||
let mut handlers = tokio::task::JoinSet::new();
|
||||
let result = tokio::time::timeout_at(deadline, async {
|
||||
while stdout_open || stderr_open || !pending.is_empty() {
|
||||
tokio::select! {
|
||||
line=async { if let Some(line) = pending.pop_front() { Some(line) } else { guest.stdout.recv().await } }, if stdout_open || !pending.is_empty()=>match line {
|
||||
@@ -129,11 +139,18 @@ async fn instance(
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok::<_, anyhow::Error>(guest.wait().await)
|
||||
}).await;
|
||||
handlers.abort_all();
|
||||
let exit = guest.wait().await;
|
||||
// Dropping the instance aborts even a CPU-bound WASI loop. Wait for the
|
||||
// store and outstanding filesystem work before removing its test world.
|
||||
drop(guest);
|
||||
let _ = ended_rx.wait_for(|ended| *ended).await;
|
||||
let _ = stderr_task.await;
|
||||
tracker.idle().await;
|
||||
host.stop().await;
|
||||
let exit =
|
||||
result.map_err(|_| anyhow::anyhow!("timed out after {timeout_secs} s\n{output}"))??;
|
||||
Ok(Run { exit, output })
|
||||
}
|
||||
/// Extracts libtest names from its stable list format.
|
||||
|
||||
+284
-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")));
|
||||
@@ -606,6 +623,257 @@ 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");
|
||||
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");
|
||||
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 +883,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 +951,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