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:
2026-10-23 01:42:19 +01:00
parent 0516158883
commit 8d058666dc
23 changed files with 1786 additions and 296 deletions
+237
View File
@@ -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)
}