cargo.build, the WASI test runner, the console, and core.notify ordering
- cargo.build (native): one source copy, tests under WASI, release build to /state/cargo/builds/<n>/, process groups killed on cancel. The host writes the SDK copy to /plugins/cargo/sdk/ at start. - sausage test-runner: one instance per test, in parallel, with its own mount table (empty world, package folder at its path); answers core.lock, core.unlock, core.notify, core.log. - The console: rustyline, notifications while typing, ls/cat through the process view, methods, ps, log, activate, revert, stop, kill. - core.notify on stdout is handled in order, before the response.
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
//! Cargo's WASI test runner: one compiled component, isolated instances, and a small core transport.
|
||||
use anyhow::{Result, bail};
|
||||
use sausage::{
|
||||
Host,
|
||||
runtime::{Exit, InstanceConfig, Runtime},
|
||||
world::World,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use wasmtime::component::Component;
|
||||
|
||||
/// A temporary world remains alive through the end of its WASI store.
|
||||
struct TestWorld(PathBuf);
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
struct Run {
|
||||
exit: Exit,
|
||||
output: String,
|
||||
}
|
||||
/// Runs one instance and separates JSON-RPC requests from ordinary test output.
|
||||
async fn instance(
|
||||
runtime: Arc<Runtime>,
|
||||
component: Component,
|
||||
args: Vec<String>,
|
||||
package: PathBuf,
|
||||
) -> Result<Run> {
|
||||
let path = std::env::temp_dir().join(format!("sausage-test-{}", uuid::Uuid::now_v7()));
|
||||
World::create(&path)?;
|
||||
let world = TestWorld(path);
|
||||
let host = Host::start(&world.0).await?;
|
||||
let tracker = sausage_vfs::Process::default();
|
||||
let mut filesystem = host.filesystem().await?;
|
||||
filesystem.mount(
|
||||
package.to_str().unwrap(),
|
||||
Arc::new(sausage_vfs::Host::new(&package, tracker.clone())?),
|
||||
false,
|
||||
);
|
||||
let mut env: Vec<_> = std::env::vars().filter(|(key, _)| key != "PWD").collect();
|
||||
env.extend([
|
||||
("PWD".into(), package.display().to_string()),
|
||||
("SAUSAGE_PID".into(), "test".into()),
|
||||
("SAUSAGE_PLUGIN".into(), "test".into()),
|
||||
("SAUSAGE_PARENT".into(), "core".into()),
|
||||
]);
|
||||
let mut guest = runtime
|
||||
.start_with_args(
|
||||
&component,
|
||||
InstanceConfig {
|
||||
filesystem,
|
||||
store_ended: tokio::sync::watch::channel(true).0,
|
||||
network: true,
|
||||
env,
|
||||
},
|
||||
&args,
|
||||
)
|
||||
.await?;
|
||||
let mut stdin = guest.stdin.take().unwrap();
|
||||
let mut stdout = std::mem::replace(&mut guest.stdout, tokio::sync::mpsc::channel(1).1);
|
||||
let mut stderr = std::mem::replace(&mut guest.stderr, tokio::sync::mpsc::channel(1).1);
|
||||
let mut exit = None;
|
||||
let mut output = String::new();
|
||||
let wait = guest.wait();
|
||||
tokio::pin!(wait);
|
||||
let mut stdout_open = true;
|
||||
let mut stderr_open = true;
|
||||
let mut handlers = tokio::task::JoinSet::new();
|
||||
while exit.is_none() || stdout_open || stderr_open {
|
||||
tokio::select! {
|
||||
result=&mut wait, if exit.is_none()=>exit=Some(result),
|
||||
line=stdout.recv(), if stdout_open=>match line {
|
||||
Some(line)=> {
|
||||
// Libtest can prefix its status line before SDK output. The SDK starts JSON on a fresh line.
|
||||
if let Ok(request)=serde_json::from_str::<Value>(&line) && request["jsonrpc"]=="2.0" && request["method"].is_string() {
|
||||
let host = host.clone();
|
||||
handlers.spawn(async move {
|
||||
let method=request["method"].as_str().unwrap();
|
||||
let params=request.get("params").cloned().unwrap_or(json!({}));
|
||||
let result=match method {
|
||||
"core.lock"|"core.unlock"|"core.log"=>host.call(method,params).await,
|
||||
"core.notify"=>Ok(Value::Null),
|
||||
_=>Err(json!({"code":-32601,"message":"a test has no other plugins"})),
|
||||
};
|
||||
request.get("id").map(|id|match result {Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}),Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error})})
|
||||
});
|
||||
} else {output.push_str(&line);output.push('\n');}
|
||||
},None=>stdout_open=false,
|
||||
},
|
||||
line=stderr.recv(), if stderr_open=>match line {Some(line)=>{output.push_str(&line);output.push('\n');},None=>stderr_open=false},
|
||||
response=handlers.join_next(), if !handlers.is_empty()=> {
|
||||
if let Some(response) = response.transpose()?.flatten() {
|
||||
stdin.write_all(format!("{response}\n").as_bytes()).await?;
|
||||
stdin.flush().await?;
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
handlers.abort_all();
|
||||
tracker.idle().await;
|
||||
host.stop().await;
|
||||
Ok(Run {
|
||||
exit: exit.unwrap(),
|
||||
output,
|
||||
})
|
||||
}
|
||||
/// Extracts libtest names from its stable list format.
|
||||
fn names(output: &str) -> Vec<String> {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| line.strip_suffix(": test").map(str::to_owned))
|
||||
.collect()
|
||||
}
|
||||
/// Lists tests, selects libtest filters, and runs each selected test on the core pool.
|
||||
pub async fn run(wasm: &Path, args: Vec<String>) -> Result<bool> {
|
||||
let package = std::env::current_dir()?;
|
||||
let cache = std::env::var_os("XDG_CACHE_HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache")
|
||||
})
|
||||
.join("sausage");
|
||||
let compile_path = wasm.to_path_buf();
|
||||
let (runtime, component) = tokio::task::spawn_blocking(move || -> Result<_> {
|
||||
let runtime = Runtime::new(&cache)?;
|
||||
let component = runtime.compile(&std::fs::read(compile_path)?)?;
|
||||
Ok((runtime, component))
|
||||
})
|
||||
.await??;
|
||||
let mut listing_args = vec![wasm.display().to_string(), "--list".into()];
|
||||
listing_args.extend(args.iter().filter(|arg| arg.as_str() != "--list").cloned());
|
||||
let listing = instance(
|
||||
runtime.clone(),
|
||||
component.clone(),
|
||||
listing_args.clone(),
|
||||
package.clone(),
|
||||
)
|
||||
.await?;
|
||||
if !matches!(listing.exit, Exit::Clean) {
|
||||
bail!(
|
||||
"cannot list WASI tests: {}\n{}",
|
||||
listing.exit,
|
||||
listing.output
|
||||
);
|
||||
}
|
||||
if args.iter().any(|arg| arg == "--list") {
|
||||
print!("{}", listing.output);
|
||||
return Ok(true);
|
||||
}
|
||||
let selected = names(&listing.output);
|
||||
listing_args.push("--ignored".into());
|
||||
let ignored = if args
|
||||
.iter()
|
||||
.any(|arg| matches!(arg.as_str(), "--ignored" | "--include-ignored"))
|
||||
{
|
||||
Vec::new()
|
||||
} else {
|
||||
names(
|
||||
&instance(
|
||||
runtime.clone(),
|
||||
component.clone(),
|
||||
listing_args,
|
||||
package.clone(),
|
||||
)
|
||||
.await?
|
||||
.output,
|
||||
)
|
||||
};
|
||||
println!("running {} tests", selected.len());
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(usize::from)
|
||||
.unwrap_or(1);
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(cores));
|
||||
let mut tasks = tokio::task::JoinSet::new();
|
||||
let mut skipped = 0;
|
||||
for name in selected {
|
||||
if ignored.contains(&name) {
|
||||
println!("test {name} ... ignored");
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let runtime = runtime.clone();
|
||||
let component = component.clone();
|
||||
let package = package.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
let arguments = vec![
|
||||
wasm.display().to_string(),
|
||||
"--exact".into(),
|
||||
name.clone(),
|
||||
"--nocapture".into(),
|
||||
"--test-threads=1".into(),
|
||||
"--include-ignored".into(),
|
||||
];
|
||||
tasks.spawn(async move {
|
||||
let _permit = semaphore.acquire_owned().await?;
|
||||
let result = instance(runtime, component, arguments, package).await;
|
||||
Ok::<_, anyhow::Error>((name, result))
|
||||
});
|
||||
}
|
||||
let mut passed = 0;
|
||||
let mut failed = 0;
|
||||
while let Some(result) = tasks.join_next().await {
|
||||
let (name, result) = result??;
|
||||
let success = result
|
||||
.as_ref()
|
||||
.is_ok_and(|run| matches!(run.exit, Exit::Clean));
|
||||
if success {
|
||||
passed += 1;
|
||||
} else {
|
||||
failed += 1;
|
||||
}
|
||||
println!("test {name} ... {}", if success { "ok" } else { "FAILED" });
|
||||
match result {
|
||||
Ok(run) => {
|
||||
for line in run
|
||||
.output
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with("test result:"))
|
||||
{
|
||||
// Indentation keeps ordinary test output separate from our libtest status and totals.
|
||||
println!(" {line}");
|
||||
}
|
||||
}
|
||||
Err(error) => println!("{error:#}"),
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"\ntest result: {}. {passed} passed; {failed} failed; {skipped} ignored; 0 measured; 0 filtered out",
|
||||
if failed == 0 { "ok" } else { "FAILED" }
|
||||
);
|
||||
Ok(failed == 0)
|
||||
}
|
||||
Reference in New Issue
Block a user