host: review fixes for cargo.build, the test runner, the console, and main; tests for every PROTOCOL.md rule

cargo.build and the runner (review T22R): builds use the network again;
core.unlock and core.notify in order under the runner; quiet test
output; manifest preparation keeps [workspace], target tables, renamed
and optional entries; cargo.rpc.discover; -32004 with data.output;
retention of 10 builds; light, empty test worlds with an in-memory
journal; only CARGO_/RUST_/SAUSAGE_ variables; stdin is null; the runner
through --config. A compile cache that cannot be created is not fatal.
Activation replaces plugin.wasm without a gap.

Console and main: init ships the workspace Cargo.lock and creates the
world atomically; each console command runs in its own task, so ps,
stop, and kill work during a slow call; SAUSAGE_HEADLESS; the terminal
mode is restored; signals during boot; chat text is not taken as a
command.

Tests: 20 more tests for PROTOCOL.md sections 2 to 10; the cargo tests
choose the libtest binary from cargo's JSON output and time out.
This commit is contained in:
2026-10-23 07:47:39 +01:00
parent 61d5d5f817
commit a090e1fd57
24 changed files with 1889 additions and 242 deletions
+64 -25
View File
@@ -3,14 +3,12 @@ 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.
@@ -32,19 +30,36 @@ async fn instance(
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 host = Host::test_world(&world.0, runtime.clone()).await?;
let tracker = sausage_vfs::Process::default();
let mut filesystem = host.filesystem().await?;
// Virtual ancestors keep directory entries of the absolute package mount traversable.
for ancestor in package
.ancestors()
.skip(1)
.filter(|path| *path != Path::new("/"))
{
filesystem.mount(
ancestor.to_str().unwrap(),
Arc::new(sausage_vfs::Memory::default()),
false,
);
}
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();
let mut env: Vec<_> = std::env::vars_os()
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
.filter(|(key, _)| {
["CARGO_", "RUST_", "SAUSAGE_"]
.iter()
.any(|prefix| key.starts_with(prefix))
})
.collect();
env.extend([
("PWD".into(), package.display().to_string()),
("SAUSAGE_PID".into(), "test".into()),
("SAUSAGE_PLUGIN".into(), "test".into()),
("SAUSAGE_PARENT".into(), "core".into()),
@@ -61,23 +76,38 @@ async fn instance(
&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 pending = std::collections::VecDeque::new();
let mut stderr = std::mem::replace(&mut guest.stderr, tokio::sync::mpsc::channel(1).1);
let mut exit = None;
let (errors, mut stderr_lines) = tokio::sync::mpsc::unbounded_channel();
let stderr_task = tokio::spawn(async move {
while let Some(line) = stderr.recv().await {
let _ = errors.send(line);
}
});
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 {
while stdout_open || stderr_open || !pending.is_empty() {
tokio::select! {
result=&mut wait, if exit.is_none()=>exit=Some(result),
line=stdout.recv(), if stdout_open=>match line {
line=async { if let Some(line) = pending.pop_front() { Some(line) } else { guest.stdout.recv().await } }, if stdout_open || !pending.is_empty()=>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 method = request["method"].as_str().unwrap();
if !matches!(method, "core.lock" | "core.log") {
let params = request.get("params").cloned().unwrap_or(json!({}));
let result = match method {
"core.unlock" => host.call(method, params).await,
"core.notify" => Ok(Value::Null),
_ => Err(json!({"code":-32601,"message":"a test has no other plugins"})),
};
if let Some(id) = request.get("id") {
let response = match result { Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}), Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error}) };
if guest.send_line(&response.to_string(), |line| pending.push_back(line)).await.is_err() { guest.stdin.take(); }
}
continue;
}
let host = host.clone();
handlers.spawn(async move {
let method=request["method"].as_str().unwrap();
@@ -89,25 +119,22 @@ async fn instance(
};
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');}
} else {output.push_str(line.trim_end_matches(['\r', '\n']));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},
line=stderr_lines.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?;
}
if let Some(response) = response.transpose()?.flatten()
&& guest.send_line(&response.to_string(), |line| pending.push_back(line)).await.is_err() { guest.stdin.take(); }
},
}
}
handlers.abort_all();
let exit = guest.wait().await;
let _ = stderr_task.await;
tracker.idle().await;
host.stop().await;
Ok(Run {
exit: exit.unwrap(),
output,
})
Ok(Run { exit, output })
}
/// Extracts libtest names from its stable list format.
fn names(output: &str) -> Vec<String> {
@@ -220,7 +247,19 @@ pub async fn run(wasm: &Path, args: Vec<String>) -> Result<bool> {
for line in run
.output
.lines()
.filter(|line| !line.starts_with("test result:"))
.filter(|line| {
!line.trim().is_empty()
&& !line.starts_with("test result:")
&& !matches!(*line, "running 1 test" | "running 0 tests")
&& !matches!(*line, "ok" | "FAILED")
})
.map(|line| {
line.strip_prefix(&format!("test {name} ... "))
.unwrap_or(line)
})
.filter(|line| {
!matches!(*line, "ok" | "FAILED" | "ignored") && !line.is_empty()
})
{
// Indentation keeps ordinary test output separate from our libtest status and totals.
println!(" {line}");