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:
@@ -29,6 +29,10 @@ rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
sausage-vfs = { version = "0.1.0", path = "../vfs" }
|
||||
sausage-interface = { version = "0.1.0", path = "../interface" }
|
||||
notify = "8.2.0"
|
||||
rustyline = "19"
|
||||
nix = { version = "0.31.3", features = ["signal", "process"] }
|
||||
|
||||
[dev-dependencies]
|
||||
hyper = { version = "1.12.0", features = ["server", "http1"] }
|
||||
hyper-util = { version = "0.1.21", features = ["tokio"] }
|
||||
sausage-interface = { path = "../interface" }
|
||||
|
||||
+274
-250
@@ -1,69 +1,48 @@
|
||||
//! Rust build helpers for the native cargo plugin.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde_json::Value;
|
||||
//! Native cargo builds, one immutable source copy, SDK installation, and process-group cancellation.
|
||||
use crate::{Host, world::World};
|
||||
use anyhow::{Context, Result, bail};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::process::Command;
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
|
||||
use crate::world::{self, World};
|
||||
|
||||
/// The name reserved for the native Rust build plugin.
|
||||
/// The native build plugin.
|
||||
pub const ID: &str = "cargo";
|
||||
/// The WASI component target used for Rust plugin builds.
|
||||
/// The target used for builds and tests.
|
||||
pub const TARGET: &str = "wasm32-wasip2";
|
||||
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/src");
|
||||
static MACROS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/macros/src");
|
||||
static INTERFACE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../interface/src");
|
||||
|
||||
/// The source of the `sausage-actor` SDK.
|
||||
static SDK_SRC: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/src");
|
||||
static SDK_MACROS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/macros/src");
|
||||
|
||||
#[derive(Clone, Default, Debug)]
|
||||
/// The totals and output from a source tree's test run.
|
||||
#[derive(Clone, Default, Debug, serde::Serialize)]
|
||||
/// Test totals and captured output.
|
||||
pub struct TestResults {
|
||||
/// The number of tests that passed.
|
||||
pub passed: u64,
|
||||
/// The number of tests that failed.
|
||||
pub failed: u64,
|
||||
/// The test output retained for the caller.
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
/// The component artifact and diagnostics from a successful build.
|
||||
pub struct BuildOutput {
|
||||
/// Host path of the published `.wasm` file.
|
||||
pub wasm: PathBuf,
|
||||
/// The elapsed build time in seconds.
|
||||
pub seconds: f64,
|
||||
/// The compiler diagnostics without progress lines.
|
||||
pub warnings: String,
|
||||
/// The totals and output of the test phase.
|
||||
pub tests: TestResults,
|
||||
}
|
||||
|
||||
/// A successful build, usable without a running host.
|
||||
#[derive(Debug)]
|
||||
/// The failed build or test phase and its diagnostics.
|
||||
pub struct BuildOutput {
|
||||
pub wasm: PathBuf,
|
||||
pub warnings: String,
|
||||
pub tests: TestResults,
|
||||
}
|
||||
/// A failed compiler or test phase, with its complete diagnostics.
|
||||
#[derive(Debug)]
|
||||
pub struct BuildFailure {
|
||||
/// The reason that the build could not complete.
|
||||
pub reason: String,
|
||||
/// The totals and output of the test phase.
|
||||
pub tests: TestResults,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for BuildFailure {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
if self.tests.failed > 0 && !self.tests.output.is_empty() {
|
||||
write!(f, "{}:\n{}", self.reason, self.tests.output)
|
||||
} else {
|
||||
f.write_str(&self.reason)
|
||||
}
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(formatter, "{}\n{}", self.reason, self.tests.output)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for BuildFailure {}
|
||||
|
||||
impl From<anyhow::Error> for BuildFailure {
|
||||
fn from(error: anyhow::Error) -> Self {
|
||||
Self {
|
||||
@@ -72,170 +51,271 @@ impl From<anyhow::Error> for BuildFailure {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// All builds share one target folder, so they run one at a time.
|
||||
/// A host-independent builder. Each builder serializes access to its work and target folders.
|
||||
pub struct Cargo {
|
||||
world: World,
|
||||
builds: tokio::sync::Mutex<()>,
|
||||
runner: PathBuf,
|
||||
}
|
||||
|
||||
impl Cargo {
|
||||
/// Create the plugin and install the SDK of this host into `public/sdk/`.
|
||||
/// Installs changed SDK files and selects the current sausage executable as runner.
|
||||
pub fn new(world: World) -> Result<Self> {
|
||||
let sdk = world.plugin_dir(ID).join("sdk");
|
||||
write_dir(&SDK_SRC, &sdk.join("src"))?;
|
||||
write_dir(&SDK_MACROS, &sdk.join("macros/src"))?;
|
||||
world::write_atomic(
|
||||
&sdk.join("Cargo.toml"),
|
||||
include_bytes!("../../sdk/Cargo.toml"),
|
||||
)?;
|
||||
world::write_atomic(
|
||||
&sdk.join("macros/Cargo.toml"),
|
||||
include_bytes!("../../sdk/macros/Cargo.toml"),
|
||||
)?;
|
||||
Ok(Self { world })
|
||||
}
|
||||
|
||||
/// Run native `cargo test` unless opted out, then build and publish the
|
||||
/// crate's `wasm32-wasip2` binary under `/plugin/cargo/public/builds/<n>/`.
|
||||
pub async fn build(
|
||||
&self,
|
||||
source: &Path,
|
||||
skip_tests: bool,
|
||||
) -> std::result::Result<BuildOutput, BuildFailure> {
|
||||
if !source.join("Cargo.toml").is_file() {
|
||||
return Err(BuildFailure {
|
||||
reason: format!("{} has no Cargo.toml", source.display()),
|
||||
tests: TestResults::default(),
|
||||
});
|
||||
let mut runner = std::env::current_exe()?;
|
||||
if runner
|
||||
.parent()
|
||||
.is_some_and(|parent| parent.file_name().is_some_and(|name| name == "deps"))
|
||||
{
|
||||
runner = runner.parent().unwrap().parent().unwrap().join("sausage");
|
||||
}
|
||||
let started = Instant::now();
|
||||
let data = self.world.state_dir(ID);
|
||||
let build_dir = data.join("work");
|
||||
world::replace_source(source, &build_dir)?;
|
||||
let manifest = build_dir.join("Cargo.toml");
|
||||
let original = std::fs::read_to_string(&manifest).context("read build manifest")?;
|
||||
let prepared = prepare_manifest(&original, Some(&self.world.plugin_dir(ID).join("sdk")))?;
|
||||
std::fs::write(&manifest, prepared).context("write prepared manifest")?;
|
||||
|
||||
let target_dir = data.join("target");
|
||||
let tests = if skip_tests {
|
||||
TestResults {
|
||||
output: "skipped (skip_tests=true)".into(),
|
||||
..TestResults::default()
|
||||
}
|
||||
} else {
|
||||
match run_tests(&build_dir, &target_dir).await {
|
||||
Ok(tests) => tests,
|
||||
Err(error) => {
|
||||
let output = format!("cannot run cargo tests: {error:#}");
|
||||
return Err(BuildFailure {
|
||||
reason: output.clone(),
|
||||
tests: TestResults {
|
||||
failed: 1,
|
||||
output,
|
||||
..TestResults::default()
|
||||
},
|
||||
});
|
||||
Self::with_runner(world, runner)
|
||||
}
|
||||
/// Installs the SDK and accepts an explicit sausage binary, including for init integrations.
|
||||
pub fn with_runner(world: World, runner: PathBuf) -> Result<Self> {
|
||||
let sdk = world.plugin_dir(ID).join("sdk");
|
||||
write_dir(&SDK, &sdk.join("src"))?;
|
||||
write_dir(&MACROS, &sdk.join("macros/src"))?;
|
||||
write_dir(&INTERFACE, &sdk.join("interface/src"))?;
|
||||
for (manifest, destination, interface_path) in [
|
||||
(
|
||||
include_str!("../../sdk/Cargo.toml"),
|
||||
sdk.join("Cargo.toml"),
|
||||
Some("interface"),
|
||||
),
|
||||
(
|
||||
include_str!("../../sdk/macros/Cargo.toml"),
|
||||
sdk.join("macros/Cargo.toml"),
|
||||
Some("../interface"),
|
||||
),
|
||||
(
|
||||
include_str!("../../interface/Cargo.toml"),
|
||||
sdk.join("interface/Cargo.toml"),
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let mut document: toml_edit::DocumentMut = manifest.parse()?;
|
||||
document["workspace"] = toml_edit::table();
|
||||
if let Some(path) = interface_path {
|
||||
for section in ["dependencies", "dev-dependencies"] {
|
||||
if let Some(dependency) = document
|
||||
.get_mut(section)
|
||||
.and_then(|table| table.get_mut("sausage-interface"))
|
||||
{
|
||||
dependency["path"] = toml_edit::value(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
write_changed(&destination, document.to_string().as_bytes())?;
|
||||
}
|
||||
Ok(Self {
|
||||
world,
|
||||
runner,
|
||||
builds: tokio::sync::Mutex::new(()),
|
||||
})
|
||||
}
|
||||
/// Registers cargo.build as a cancellable native process.
|
||||
pub fn register(self, host: &Host) {
|
||||
let builder = Arc::new(self);
|
||||
host.register_native(ID,Arc::new(move |_context,method,params| {
|
||||
let builder=builder.clone();
|
||||
Box::pin(async move {
|
||||
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 {
|
||||
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}})),
|
||||
}
|
||||
})
|
||||
}));
|
||||
}
|
||||
/// 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> {
|
||||
let _build = self.builds.lock().await;
|
||||
let work = self.world.state_dir(ID).join("work");
|
||||
let target = self.world.state_dir(ID).join("target");
|
||||
let source = source.to_path_buf();
|
||||
let copy = work.clone();
|
||||
let sdk = self.world.plugin_dir(ID).join("sdk");
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
if copy.exists() {
|
||||
std::fs::remove_dir_all(©)?;
|
||||
}
|
||||
copy_source(&source, ©)?;
|
||||
let manifest = copy.join("Cargo.toml");
|
||||
let text = std::fs::read_to_string(&manifest)?;
|
||||
std::fs::write(manifest, prepare_manifest(&text, &sdk)?)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(anyhow::Error::from)??;
|
||||
let mut test = Command::new("cargo");
|
||||
test.args(["test", "--offline", "--target", TARGET, "--target-dir"])
|
||||
.arg(&target)
|
||||
.current_dir(&work)
|
||||
.env(
|
||||
"CARGO_TARGET_WASM32_WASIP2_RUNNER",
|
||||
format!("{} test-runner", self.runner.display()),
|
||||
);
|
||||
let output = run_command(test).await?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let tests = TestResults {
|
||||
passed: test_count(&stdout, "passed"),
|
||||
failed: if output.status.success() {
|
||||
0
|
||||
} else {
|
||||
test_count(&stdout, "failed").max(1)
|
||||
},
|
||||
output: format!("{stdout}\n{stderr}"),
|
||||
};
|
||||
if tests.failed > 0 {
|
||||
if !output.status.success() {
|
||||
return Err(BuildFailure {
|
||||
reason: "unit tests failed".into(),
|
||||
reason: "WASI tests failed".into(),
|
||||
tests,
|
||||
});
|
||||
}
|
||||
|
||||
let build_result: Result<BuildOutput> = async {
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.arg("build")
|
||||
.arg("--release")
|
||||
.arg("--target")
|
||||
.arg(TARGET)
|
||||
.arg("--message-format=json-render-diagnostics")
|
||||
.arg("--target-dir")
|
||||
.arg(&target_dir)
|
||||
.current_dir(&build_dir)
|
||||
.kill_on_drop(true);
|
||||
let out = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
if !out.status.success() {
|
||||
bail!("{}", strip_progress(&stderr));
|
||||
}
|
||||
let result = async {
|
||||
let mut build = Command::new("cargo");
|
||||
build
|
||||
.args([
|
||||
"build",
|
||||
"--offline",
|
||||
"--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 mut diagnostics = stderr.to_string();
|
||||
let mut artifact = None;
|
||||
for line in String::from_utf8_lossy(&out.stdout).lines() {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(line)
|
||||
&& v["reason"] == "compiler-artifact"
|
||||
&& let Some(exe) = v["executable"].as_str()
|
||||
{
|
||||
artifact = Some(PathBuf::from(exe));
|
||||
for line in stdout.lines() {
|
||||
if let Ok(message) = serde_json::from_str::<Value>(line) {
|
||||
if message["reason"] == "compiler-artifact"
|
||||
&& let Some(executable) = message["executable"].as_str()
|
||||
{
|
||||
artifact = Some(PathBuf::from(executable));
|
||||
}
|
||||
if let Some(rendered) = message["message"]["rendered"].as_str() {
|
||||
diagnostics.push_str(rendered);
|
||||
}
|
||||
}
|
||||
}
|
||||
let artifact = artifact.ok_or_else(|| {
|
||||
anyhow!("cargo built no binary. Does the crate have a src/main.rs?")
|
||||
})?;
|
||||
|
||||
let n = next_build_number(&self.world.plugin_dir(ID).join("builds"))?;
|
||||
let dest_dir = self.world.plugin_dir(ID).join("builds").join(n.to_string());
|
||||
let name = artifact.file_name().context("artifact has no file name")?;
|
||||
let dest = dest_dir.join(name);
|
||||
world::write_atomic(&dest, &std::fs::read(&artifact)?)?;
|
||||
if !output.status.success() {
|
||||
bail!("{diagnostics}");
|
||||
}
|
||||
let artifact = artifact.context("cargo built no binary")?;
|
||||
let builds = self.world.state_dir(ID).join("builds");
|
||||
std::fs::create_dir_all(&builds)?;
|
||||
let number = std::fs::read_dir(&builds)?
|
||||
.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse::<u64>().ok())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
+ 1;
|
||||
let destination = builds
|
||||
.join(number.to_string())
|
||||
.join(artifact.file_name().unwrap());
|
||||
crate::world::write_atomic(&destination, &tokio::fs::read(artifact).await?)?;
|
||||
Ok(BuildOutput {
|
||||
wasm: dest,
|
||||
seconds: started.elapsed().as_secs_f64(),
|
||||
warnings: strip_progress(&stderr),
|
||||
wasm: destination,
|
||||
warnings: strip_progress(&diagnostics),
|
||||
tests: tests.clone(),
|
||||
})
|
||||
}
|
||||
.await;
|
||||
build_result.map_err(|error| BuildFailure {
|
||||
result.map_err(|error: anyhow::Error| BuildFailure {
|
||||
reason: format!("{error:#}"),
|
||||
tests,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Run tests for the host triple: WASI crates' pure-logic tests can use the
|
||||
/// normal Rust test harness without needing a WASI test runner.
|
||||
async fn run_tests(build_dir: &Path, target_dir: &Path) -> Result<TestResults> {
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.arg("test")
|
||||
.arg("--target-dir")
|
||||
.arg(target_dir)
|
||||
.current_dir(build_dir)
|
||||
.kill_on_drop(true);
|
||||
let out = cmd.output().await.context("cannot run cargo test")?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
||||
let combined = match (stdout.is_empty(), stderr.is_empty()) {
|
||||
(true, true) => String::new(),
|
||||
(false, true) => stdout,
|
||||
(true, false) => stderr,
|
||||
(false, false) => format!("{stdout}\n{stderr}"),
|
||||
};
|
||||
let output = strip_progress(&combined);
|
||||
let passed = test_count(&output, "passed");
|
||||
let mut failed = test_count(&output, "failed");
|
||||
// Compilation failures and runner errors have no `test result` summary,
|
||||
// but still make the default test phase fail.
|
||||
if !out.status.success() && failed == 0 {
|
||||
failed = 1;
|
||||
/// Kills cargo and every descendant, including tests and build scripts, on cancellation.
|
||||
struct ProcessGroup(u32);
|
||||
impl Drop for ProcessGroup {
|
||||
fn drop(&mut self) {
|
||||
let _ = nix::sys::signal::killpg(
|
||||
nix::unistd::Pid::from_raw(self.0 as i32),
|
||||
nix::sys::signal::Signal::SIGKILL,
|
||||
);
|
||||
}
|
||||
Ok(TestResults {
|
||||
passed,
|
||||
failed,
|
||||
output,
|
||||
})
|
||||
}
|
||||
|
||||
/// Count the `passed` or `failed` totals in cargo's per-target test summaries.
|
||||
/// Starts one process group and retains its guard across all output collection.
|
||||
async fn run_command(mut command: Command) -> Result<std::process::Output> {
|
||||
command
|
||||
.process_group(0)
|
||||
.kill_on_drop(true)
|
||||
.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?)
|
||||
}
|
||||
/// Copies only source, excluding build outputs and git internals at every level.
|
||||
fn copy_source(source: &Path, destination: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(destination)?;
|
||||
for entry in std::fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
if matches!(entry.file_name().to_str(), Some("target" | ".git")) {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_source(&entry.path(), &destination.join(entry.file_name()))?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), destination.join(entry.file_name()))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Repoints SDK dependencies without discarding feature flags and detaches the copied crate's workspace.
|
||||
fn prepare_manifest(text: &str, sdk: &Path) -> Result<String> {
|
||||
let mut document: toml_edit::DocumentMut = text.parse()?;
|
||||
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
|
||||
if let Some(dependency) = document
|
||||
.get_mut(section)
|
||||
.and_then(|table| table.get_mut("sausage-plugin"))
|
||||
{
|
||||
let features = dependency.get("features").cloned();
|
||||
let defaults = dependency.get("default-features").cloned();
|
||||
let mut replacement = toml_edit::InlineTable::new();
|
||||
replacement.insert("path", sdk.display().to_string().into());
|
||||
if let Some(features) = features.and_then(|value| value.as_value().cloned()) {
|
||||
replacement.insert("features", features);
|
||||
}
|
||||
if let Some(defaults) = defaults.and_then(|value| value.as_value().cloned()) {
|
||||
replacement.insert("default-features", defaults);
|
||||
}
|
||||
*dependency = toml_edit::value(replacement);
|
||||
}
|
||||
}
|
||||
document["workspace"] = toml_edit::table();
|
||||
Ok(document.to_string())
|
||||
}
|
||||
/// Writes embedded sources only when their bytes changed.
|
||||
fn write_changed(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
if std::fs::read(path).ok().as_deref() != Some(bytes) {
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
std::fs::write(path, bytes)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Installs one embedded source tree without touching unchanged files.
|
||||
fn write_dir(directory: &Dir<'_>, destination: &Path) -> Result<()> {
|
||||
for file in directory.files() {
|
||||
write_changed(&destination.join(file.path()), file.contents())?;
|
||||
}
|
||||
for directory in directory.dirs() {
|
||||
write_dir(directory, destination)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Counts only final libtest summaries, never individual status lines.
|
||||
fn test_count(output: &str, field: &str) -> u64 {
|
||||
output
|
||||
.lines()
|
||||
.filter_map(|line| line.split_once("test result:").map(|(_, rest)| rest))
|
||||
.filter_map(|line| line.strip_prefix("test result:"))
|
||||
.flat_map(|summary| summary.split(';'))
|
||||
.filter_map(|part| {
|
||||
let words: Vec<_> = part.split_whitespace().collect();
|
||||
@@ -244,77 +324,21 @@ fn test_count(output: &str, field: &str) -> u64 {
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Point a `sausage-actor` dependency at `sdk` (keeping its features), and
|
||||
/// add an empty `[workspace]`, so the crate never joins a parent workspace.
|
||||
fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> {
|
||||
let mut doc: toml_edit::DocumentMut = text.parse()?;
|
||||
if let Some(sdk) = sdk {
|
||||
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
|
||||
let Some(dep) = doc
|
||||
.get_mut(section)
|
||||
.and_then(|t| t.get_mut("sausage-actor"))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let mut table = dep.as_inline_table().cloned().unwrap_or_default();
|
||||
for key in ["version", "path", "git", "branch", "tag", "rev", "registry"] {
|
||||
table.remove(key);
|
||||
}
|
||||
table.insert("path", sdk.display().to_string().into());
|
||||
*dep = toml_edit::value(table);
|
||||
}
|
||||
}
|
||||
if doc.get("workspace").is_none() {
|
||||
doc["workspace"] = toml_edit::table();
|
||||
}
|
||||
Ok(doc.to_string())
|
||||
}
|
||||
|
||||
/// Write the files of `dir` below `to`. Skip files that did not change, so
|
||||
/// that cargo does not rebuild the SDK at every start.
|
||||
fn write_dir(dir: &Dir<'_>, to: &Path) -> Result<()> {
|
||||
for file in dir.files() {
|
||||
let dest = to.join(file.path());
|
||||
let contents = match file.path().file_name() {
|
||||
Some(n) if n == "Cargo.toml" => {
|
||||
prepare_manifest(file.contents_utf8().context("Cargo.toml")?, None)?.into_bytes()
|
||||
}
|
||||
_ => file.contents().to_vec(),
|
||||
};
|
||||
if std::fs::read(&dest).ok() != Some(contents.clone()) {
|
||||
std::fs::create_dir_all(dest.parent().context("no parent")?)?;
|
||||
std::fs::write(&dest, contents)?;
|
||||
}
|
||||
}
|
||||
for sub in dir.dirs() {
|
||||
write_dir(sub, to)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next_build_number(dir: &Path) -> Result<u64> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let max = std::fs::read_dir(dir)?
|
||||
.filter_map(|e| e.ok()?.file_name().to_str()?.parse::<u64>().ok())
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
Ok(max + 1)
|
||||
}
|
||||
|
||||
/// Remove cargo's "Compiling ..." progress lines. Keep diagnostics.
|
||||
fn strip_progress(stderr: &str) -> String {
|
||||
stderr
|
||||
/// Removes progress lines while retaining compiler diagnostics.
|
||||
fn strip_progress(output: &str) -> String {
|
||||
output
|
||||
.lines()
|
||||
.filter(|l| {
|
||||
let t = l.trim_start();
|
||||
!(t.starts_with("Compiling ")
|
||||
|| t.starts_with("Downloaded ")
|
||||
|| t.starts_with("Downloading ")
|
||||
|| t.starts_with("Updating ")
|
||||
|| t.starts_with("Locking ")
|
||||
|| t.starts_with("Adding ")
|
||||
|| t.starts_with("Blocking "))
|
||||
.filter(|line| {
|
||||
![
|
||||
"Compiling ",
|
||||
"Finished ",
|
||||
"Blocking ",
|
||||
"Updating ",
|
||||
"Locking ",
|
||||
"Adding ",
|
||||
]
|
||||
.iter()
|
||||
.any(|prefix| line.trim_start().starts_with(prefix))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
|
||||
+320
-19
@@ -1,24 +1,325 @@
|
||||
//! The minimal console reads explicit JSON-RPC calls and the quit command.
|
||||
//! Console commands, readable results, and line editing with live notifications.
|
||||
use crate::Host;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use anyhow::{Result, bail};
|
||||
use rustyline::{DefaultEditor, ExternalPrinter, error::ReadlineError};
|
||||
use sausage_vfs::{Layer, Open};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Read console lines until EOF or quit, and print each call's result.
|
||||
pub async fn run_input(host: Host) -> anyhow::Result<()> {
|
||||
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
if line == "quit" {
|
||||
break;
|
||||
}
|
||||
let (method, params) = line.split_once(' ').unwrap_or((&line, "{}"));
|
||||
match serde_json::from_str(params) {
|
||||
Ok(params) => {
|
||||
let value = match host.call(method, params).await {
|
||||
Ok(value) | Err(value) => value,
|
||||
};
|
||||
println!("{value}");
|
||||
}
|
||||
Err(error) => eprintln!("{error}"),
|
||||
const HELP: &str = "text chat with the agent
|
||||
plugin.method {json} call a plugin
|
||||
ls [path] list a folder (default /)
|
||||
cat <path> read a file
|
||||
methods <plugin> show methods, params, types, and docs
|
||||
ps show pid, plugin, method, parent, state, age
|
||||
log [plugin or pid] [-n N] read the journal
|
||||
activate <plugin> <wasm> activate a version
|
||||
revert <plugin> restore the previous version
|
||||
stop <pid> request a graceful stop
|
||||
kill <pid> kill a process
|
||||
help show this help
|
||||
quit leave the console";
|
||||
|
||||
async fn read(host: &Host, path: &str) -> Result<Vec<u8>> {
|
||||
let fs = host.filesystem().await?;
|
||||
let file = fs
|
||||
.open(
|
||||
path,
|
||||
Open {
|
||||
follow: true,
|
||||
..Open::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let chunk = file.read(bytes.len() as u64, 65536).await?;
|
||||
if chunk.is_empty() {
|
||||
return Ok(bytes);
|
||||
}
|
||||
bytes.extend(chunk);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn pretty(value: &Value) -> Result<String> {
|
||||
Ok(serde_json::to_string_pretty(value)?)
|
||||
}
|
||||
async fn call(host: &Host, method: &str, params: Value, chat: bool) -> Result<String> {
|
||||
match host.call(method, params).await {
|
||||
Ok(value) if chat => Ok(value["text"]
|
||||
.as_str()
|
||||
.or_else(|| value.as_str())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or(pretty(&value)?)),
|
||||
Ok(value) => pretty(&value),
|
||||
Err(error) => Ok(format!("error: {}", pretty(&error)?)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute one input line without terminal IO. The caller handles quit after printing.
|
||||
pub async fn execute_line(host: &Host, line: &str) -> Result<String> {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let (command, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
|
||||
let rest = rest.trim();
|
||||
let words: Vec<_> = rest.split_whitespace().collect();
|
||||
match command {
|
||||
"help" if words.is_empty() => Ok(HELP.into()),
|
||||
"quit" if words.is_empty() => Ok("Goodbye.".into()),
|
||||
"ls" if words.len() <= 1 => {
|
||||
let fs = host.filesystem().await?;
|
||||
let mut entries = fs.list(words.first().copied().unwrap_or("/")).await?;
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
Ok(entries
|
||||
.into_iter()
|
||||
.map(|e| format!("{}{}", e.name, if e.directory { "/" } else { "" }))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"))
|
||||
}
|
||||
"cat" if !rest.is_empty() => Ok(String::from_utf8(read(host, rest).await?)?),
|
||||
"methods" if words.len() == 1 => {
|
||||
host.refresh_graph()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
let document: Value = serde_json::from_slice(
|
||||
&read(
|
||||
host,
|
||||
&format!("/proc/core/plugins/{}/discover.json", words[0]),
|
||||
)
|
||||
.await?,
|
||||
)?;
|
||||
let mut lines = Vec::new();
|
||||
for method in document["methods"]
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow::anyhow!("discovery document has no methods"))?
|
||||
{
|
||||
let params = method["params"]
|
||||
.as_array()
|
||||
.map(|params| {
|
||||
params
|
||||
.iter()
|
||||
.map(|p| {
|
||||
format!(
|
||||
"{}: {}",
|
||||
p["name"].as_str().unwrap_or("?"),
|
||||
p["schema"]["type"].as_str().unwrap_or("any")
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
lines.push(format!(
|
||||
"{}.{}({}) -> {}",
|
||||
words[0],
|
||||
method["name"].as_str().unwrap_or("?"),
|
||||
params,
|
||||
method["result"]["schema"]["type"].as_str().unwrap_or("any")
|
||||
));
|
||||
for field in ["summary", "description"] {
|
||||
if let Some(doc) = method[field].as_str() {
|
||||
lines.push(format!(" {doc}"));
|
||||
}
|
||||
}
|
||||
if let Some(params) = method["params"].as_array() {
|
||||
for p in params {
|
||||
if let Some(doc) = p["description"].as_str() {
|
||||
lines.push(format!(" {}: {doc}", p["name"].as_str().unwrap_or("?")));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
"ps" if words.is_empty() => {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
|
||||
let mut processes: Vec<_> = host.processes().into_iter().collect();
|
||||
processes.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut lines = vec!["PID PLUGIN METHOD PARENT STATE AGE".into()];
|
||||
lines.extend(processes.into_iter().map(|(pid, p)| {
|
||||
format!(
|
||||
"{} {} {} {} {} {}s",
|
||||
pid,
|
||||
p.plugin,
|
||||
p.method,
|
||||
p.parent.as_deref().unwrap_or("-"),
|
||||
p.state,
|
||||
now.saturating_sub(p.start_time) / 1000
|
||||
)
|
||||
}));
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
"log" => {
|
||||
let mut params = json!({});
|
||||
let mut i = 0;
|
||||
while i < words.len() {
|
||||
if words[i] == "-n" {
|
||||
i += 1;
|
||||
let count: u64 = words
|
||||
.get(i)
|
||||
.ok_or_else(|| anyhow::anyhow!("log -n requires a count"))?
|
||||
.parse()?;
|
||||
params["limit"] = json!(count);
|
||||
} else if params.get("pid").is_none() && params.get("plugin").is_none() {
|
||||
let key = if words[i].len() > 36
|
||||
&& words[i]
|
||||
.get(words[i].len() - 36..)
|
||||
.is_some_and(|suffix| uuid::Uuid::try_parse(suffix).is_ok())
|
||||
{
|
||||
"pid"
|
||||
} else {
|
||||
"plugin"
|
||||
};
|
||||
params[key] = json!(words[i]);
|
||||
} else {
|
||||
bail!("usage: log [plugin or pid] [-n N]");
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
call(host, "core.log", params, false).await
|
||||
}
|
||||
"activate" if words.len() == 2 => {
|
||||
call(
|
||||
host,
|
||||
"core.activate",
|
||||
json!({"plugin":words[0],"wasm":words[1]}),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"revert" if words.len() == 1 => {
|
||||
call(host, "core.revert", json!({"plugin":words[0]}), false).await
|
||||
}
|
||||
"stop" | "kill" if words.len() == 1 => {
|
||||
call(
|
||||
host,
|
||||
&format!("core.{command}"),
|
||||
json!({"pid":words[0]}),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
"help" | "quit" | "ls" | "methods" | "ps" | "activate" | "revert" | "stop" | "kill"
|
||||
| "cat" => bail!("invalid arguments for {command}; use help"),
|
||||
_ if command
|
||||
.split_once('.')
|
||||
.is_some_and(|(plugin, method)| !plugin.is_empty() && !method.is_empty()) =>
|
||||
{
|
||||
call(
|
||||
host,
|
||||
command,
|
||||
if rest.is_empty() {
|
||||
json!({})
|
||||
} else {
|
||||
serde_json::from_str(rest)?
|
||||
},
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => call(host, "agent.chat", json!({"text":line}), true).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Format text notifications dimly, while preserving other notification payloads.
|
||||
pub fn notification_text(notification: &Value) -> String {
|
||||
let params = ¬ification["params"];
|
||||
if params["name"] == "text" {
|
||||
let payload = ¶ms["params"];
|
||||
let text = payload["text"]
|
||||
.as_str()
|
||||
.or_else(|| payload.as_str())
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| payload.to_string());
|
||||
format!("\x1b[2m{text}\x1b[0m")
|
||||
} else {
|
||||
notification.to_string()
|
||||
}
|
||||
}
|
||||
fn indent(text: &str) -> String {
|
||||
text.lines()
|
||||
.map(|line| format!(" {line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Run the line editor on a dedicated thread, with notifications through its external printer.
|
||||
pub async fn run_input(host: Host) -> Result<()> {
|
||||
let history = host.0.world.state_dir("core").join("history");
|
||||
let (ready, started) = tokio::sync::oneshot::channel();
|
||||
let (sender, mut lines) = tokio::sync::mpsc::unbounded_channel::<
|
||||
Result<(String, tokio::sync::oneshot::Sender<bool>)>,
|
||||
>();
|
||||
// A detached terminal thread does not hold Tokio shutdown open at a blocked readline.
|
||||
std::thread::spawn(move || {
|
||||
let setup = (|| -> Result<_> {
|
||||
let mut editor = DefaultEditor::new()?;
|
||||
let _ = editor.load_history(&history);
|
||||
let printer = editor.create_external_printer()?;
|
||||
Ok((editor, printer))
|
||||
})();
|
||||
let (mut editor, printer) = match setup {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
let _ = ready.send(Err(error));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if ready.send(Ok(printer)).is_err() {
|
||||
return;
|
||||
}
|
||||
let result = (|| -> Result<()> {
|
||||
loop {
|
||||
match editor.readline("> ") {
|
||||
Ok(line) => {
|
||||
editor.add_history_entry(&line)?;
|
||||
editor.save_history(&history)?;
|
||||
let (done, wait) = tokio::sync::oneshot::channel();
|
||||
if sender.send(Ok((line, done))).is_err()
|
||||
|| !wait.blocking_recv().unwrap_or(false)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(ReadlineError::Interrupted) => break,
|
||||
Err(ReadlineError::Eof) => break,
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
let _ = sender.send(Err(error));
|
||||
}
|
||||
});
|
||||
let mut printer = started.await??;
|
||||
let mut notifications = host.subscribe();
|
||||
let print_notifications = async move {
|
||||
loop {
|
||||
match notifications.recv().await {
|
||||
Ok(value) => printer.print(format!("{}\n", indent(¬ification_text(&value))))?,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return Ok(()),
|
||||
}
|
||||
}
|
||||
};
|
||||
let commands = async {
|
||||
while let Some(line) = lines.recv().await {
|
||||
let (line, done) = line?;
|
||||
let text = execute_line(&host, &line)
|
||||
.await
|
||||
.unwrap_or_else(|error| format!("error: {error}"));
|
||||
if !text.is_empty() {
|
||||
println!("{}", indent(&text));
|
||||
}
|
||||
let quit = line.trim() == "quit";
|
||||
let _ = done.send(!quit);
|
||||
if quit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
tokio::select! {result=commands=>result,result=print_notifications=>result}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ impl Host {
|
||||
watcher: Mutex::new(None),
|
||||
retirements: Mutex::new(std::collections::HashSet::new()),
|
||||
}));
|
||||
let cargo_world = host.0.world.clone();
|
||||
let cargo =
|
||||
tokio::task::spawn_blocking(move || crate::cargo::Cargo::new(cargo_world)).await??;
|
||||
cargo.register(&host);
|
||||
host.boot().await?;
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
+27
-7
@@ -4,6 +4,7 @@ use clap::{Parser, Subcommand};
|
||||
use sausage::{Host, world::World};
|
||||
use serde_json::json;
|
||||
use std::{io::Write, path::PathBuf};
|
||||
mod test_runner;
|
||||
#[derive(Parser)]
|
||||
#[command(version)]
|
||||
struct Cli {
|
||||
@@ -16,8 +17,18 @@ struct Cli {
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
Init { dir: PathBuf },
|
||||
Run { dir: PathBuf },
|
||||
Init {
|
||||
dir: PathBuf,
|
||||
},
|
||||
Run {
|
||||
dir: PathBuf,
|
||||
},
|
||||
/// Run a WASI libtest component in isolated instances.
|
||||
TestRunner {
|
||||
wasm: PathBuf,
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
args: Vec<String>,
|
||||
},
|
||||
}
|
||||
fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
|
||||
if let Ok(v) = std::env::var(var)
|
||||
@@ -41,6 +52,11 @@ fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
Cmd::TestRunner { wasm, args } => {
|
||||
if !test_runner::run(&wasm, args).await? {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Cmd::Init { dir } => {
|
||||
let api = setting("SAUSAGE_API", "API shape", Some("openai-responses"))?;
|
||||
if api != "openai-responses" && api != "anthropic-messages" {
|
||||
@@ -65,10 +81,12 @@ async fn main() -> Result<()> {
|
||||
None => Host::start(&dir).await?,
|
||||
};
|
||||
let mut notifications = host.subscribe();
|
||||
let notification_task = tokio::spawn(async move {
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
println!("{notification}");
|
||||
}
|
||||
let notification_task = cli.headless.then(|| {
|
||||
tokio::spawn(async move {
|
||||
while let Ok(notification) = notifications.recv().await {
|
||||
println!("{notification}");
|
||||
}
|
||||
})
|
||||
});
|
||||
let console = sausage::console::run_input(host.clone());
|
||||
let exit = tokio::select! {
|
||||
@@ -82,7 +100,9 @@ async fn main() -> Result<()> {
|
||||
};
|
||||
let exit = exit || host.restart_requested();
|
||||
host.stop().await;
|
||||
notification_task.abort();
|
||||
if let Some(task) = notification_task {
|
||||
task.abort();
|
||||
}
|
||||
if exit {
|
||||
std::process::exit(75);
|
||||
}
|
||||
|
||||
@@ -220,6 +220,30 @@ impl Host {
|
||||
return;
|
||||
}
|
||||
if let Some(method) = v["method"].as_str() {
|
||||
if method == "core.notify" {
|
||||
// Deliver progress before a following response removes its destination.
|
||||
let params = v.get("params").cloned().unwrap_or(json!({}));
|
||||
let result = if !params.is_object() {
|
||||
Err(error(-32602, "params must be an object"))
|
||||
} else if let Some(name) = params["name"].as_str() {
|
||||
self.notify(
|
||||
pid,
|
||||
name,
|
||||
params.get("params").cloned().unwrap_or(json!({})),
|
||||
);
|
||||
Ok(Value::Null)
|
||||
} else {
|
||||
Err(error(-32602, "name must be a string"))
|
||||
};
|
||||
if let Some(id) = v.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}),
|
||||
};
|
||||
self.send(pid, response);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let h = self.clone();
|
||||
let caller = pid.to_string();
|
||||
let method = method.to_string();
|
||||
|
||||
@@ -243,12 +243,23 @@ impl Runtime {
|
||||
self: &Arc<Self>,
|
||||
component: &Component,
|
||||
cfg: InstanceConfig,
|
||||
) -> Result<Instance> {
|
||||
self.start_with_args(component, cfg, &[]).await
|
||||
}
|
||||
|
||||
/// Start a WASI task with explicit CLI arguments, including its program name.
|
||||
pub async fn start_with_args(
|
||||
self: &Arc<Self>,
|
||||
component: &Component,
|
||||
cfg: InstanceConfig,
|
||||
args: &[String],
|
||||
) -> Result<Instance> {
|
||||
let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
|
||||
let (guest_out, host_out) = tokio::io::duplex(64 * 1024);
|
||||
|
||||
let (guest_err, host_err) = tokio::io::duplex(64 * 1024);
|
||||
let mut b = WasiCtx::builder();
|
||||
b.args(args);
|
||||
if cfg.network {
|
||||
b.inherit_network()
|
||||
.allow_tcp(true)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
//! Native build and WASI libtest integration checks under PROTOCOL.md section 11.
|
||||
use sausage::{Host, world::World};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::sync::OnceCell;
|
||||
struct TestWorld(PathBuf);
|
||||
impl TestWorld {
|
||||
/// Creates a fresh native build world.
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("sausage-cargo-{}", uuid::Uuid::now_v7()));
|
||||
World::create(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
/// Copies a fixture into its plugin source folder.
|
||||
fn source(&self, name: &str, source: &Path) {
|
||||
copy(source, &self.0.join(format!("plugins/{name}/source")));
|
||||
}
|
||||
}
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
/// Copies fixtures without their build outputs or git metadata.
|
||||
fn copy(source: &Path, destination: &Path) {
|
||||
std::fs::create_dir_all(destination).unwrap();
|
||||
for entry in std::fs::read_dir(source).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
if matches!(entry.file_name().to_str(), Some("target" | ".git")) {
|
||||
continue;
|
||||
}
|
||||
let destination = destination.join(entry.file_name());
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy(&entry.path(), &destination);
|
||||
} else {
|
||||
std::fs::copy(entry.path(), destination).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Finds a checked-in build fixture.
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/crates")
|
||||
.join(name)
|
||||
}
|
||||
/// Builds through the real registered native plugin.
|
||||
async fn build(world: &TestWorld, name: &str) -> Result<Value, Value> {
|
||||
let host = Host::start(&world.0).await.unwrap();
|
||||
let result = host.call("cargo.build", json!({"plugin":name})).await;
|
||||
host.stop().await;
|
||||
result
|
||||
}
|
||||
struct Valid {
|
||||
world: TestWorld,
|
||||
result: Value,
|
||||
test: PathBuf,
|
||||
}
|
||||
/// Shares one successful SDK build while runner invocations retain isolated worlds.
|
||||
async fn valid() -> &'static Valid {
|
||||
static VALID: OnceCell<Valid> = OnceCell::const_new();
|
||||
VALID
|
||||
.get_or_init(|| async {
|
||||
let world = TestWorld::new();
|
||||
world.source("valid", &fixture("cargo-valid"));
|
||||
std::fs::create_dir_all(world.0.join("plugins/valid/source/target")).unwrap();
|
||||
std::fs::write(
|
||||
world.0.join("plugins/valid/source/target/excluded"),
|
||||
"exclude",
|
||||
)
|
||||
.unwrap();
|
||||
let result = build(&world, "valid")
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("build failed: {error}"));
|
||||
let test =
|
||||
std::fs::read_dir(world.0.join("state/cargo/target/wasm32-wasip2/debug/deps"))
|
||||
.unwrap()
|
||||
.filter_map(|entry| {
|
||||
let path = entry.ok()?.path();
|
||||
(path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "wasm")
|
||||
&& path.file_name()?.to_str()?.starts_with("cargo_valid-"))
|
||||
.then_some(path)
|
||||
})
|
||||
.next()
|
||||
.unwrap();
|
||||
Valid {
|
||||
world,
|
||||
result,
|
||||
test,
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
/// Invokes the same runner subcommand that cargo uses.
|
||||
async fn runner(valid: &Valid, args: &[&str]) -> std::process::Output {
|
||||
tokio::process::Command::new(env!("CARGO_BIN_EXE_sausage"))
|
||||
.arg("test-runner")
|
||||
.arg(&valid.test)
|
||||
.args(args)
|
||||
.current_dir(valid.world.0.join("state/cargo/work"))
|
||||
.output()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// PROTOCOL 11: a valid SDK plugin builds and publishes a release component without activation.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn valid_build() {
|
||||
let valid = valid().await;
|
||||
let wasm = valid.result["wasm"].as_str().unwrap();
|
||||
assert!(wasm.starts_with("/state/cargo/builds/"));
|
||||
assert!(valid.world.0.join(wasm.trim_start_matches('/')).is_file());
|
||||
assert_eq!(valid.result["tests"]["passed"], 5);
|
||||
assert_eq!(valid.result["tests"]["failed"], 0);
|
||||
assert!(!valid.world.0.join("plugins/valid/plugin.wasm").exists());
|
||||
}
|
||||
/// PROTOCOL 11: tests and compilation share one source copy without target metadata.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn source_copy() {
|
||||
let valid = valid().await;
|
||||
assert!(
|
||||
!valid
|
||||
.world
|
||||
.0
|
||||
.join("state/cargo/work/target/excluded")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
std::fs::read_to_string(valid.world.0.join("state/cargo/work/Cargo.toml"))
|
||||
.unwrap()
|
||||
.contains("/plugins/cargo/sdk")
|
||||
);
|
||||
}
|
||||
/// PROTOCOL 11: compiler failures return compiler diagnostics.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn compile_error() {
|
||||
let world = TestWorld::new();
|
||||
world.source("bad", &fixture("cargo-fail"));
|
||||
let error = build(&world, "bad").await.unwrap_err();
|
||||
assert!(
|
||||
error["data"]["tests"]["output"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("error")
|
||||
);
|
||||
}
|
||||
/// PROTOCOL 11: a failing test rejects the build with its test output.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn failing_test() {
|
||||
let world = TestWorld::new();
|
||||
world.source("bad", &fixture("cargo-test-fail"));
|
||||
let error = build(&world, "bad").await.unwrap_err();
|
||||
assert_eq!(error["data"]["tests"]["failed"], 1);
|
||||
assert!(
|
||||
error["data"]["tests"]["output"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("deliberate WASI panic")
|
||||
);
|
||||
}
|
||||
/// PROTOCOL 11: one panic does not prevent another test instance from passing.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn panic_is_isolated() {
|
||||
let world = TestWorld::new();
|
||||
world.source("bad", &fixture("cargo-test-fail"));
|
||||
let error = build(&world, "bad").await.unwrap_err();
|
||||
assert_eq!(error["data"]["tests"]["passed"], 1);
|
||||
}
|
||||
/// PROTOCOL 11: build scripts can compile and link WASI C code.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn c_build() {
|
||||
let world = TestWorld::new();
|
||||
world.source("c", &fixture("cargo-c"));
|
||||
let result = build(&world, "c")
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
assert_eq!(result["tests"]["passed"], 1);
|
||||
}
|
||||
/// PROTOCOL 11: libtest name filters select only matching tests.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn name_filter() {
|
||||
let output = runner(valid().await, &["arithmetic"]).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed; 0 failed"));
|
||||
}
|
||||
/// PROTOCOL 11: --ignored selects ignored tests instead of normal tests.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ignored_filter() {
|
||||
let output = runner(valid().await, &["--ignored"]).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).contains("test tests::ignored_case ... ok"));
|
||||
}
|
||||
/// PROTOCOL 11: CARGO_MANIFEST_DIR fixture reads work at the package's absolute host path.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn manifest_fixture() {
|
||||
let output = runner(valid().await, &["fixture_at_manifest_path"]).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
/// PROTOCOL 11: WASI Preview 2 keeps cwd at / despite PWD. Absolute manifest paths reach fixtures.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn current_folder() {
|
||||
let valid = valid().await;
|
||||
let output = runner(valid, &["current_directory"]).await;
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
text.lines()
|
||||
.any(|line| line.ends_with("WASI_CURRENT_DIR=/")),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
/// PROTOCOL 11: the SDK can acquire and release locks through the runner's core transport.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn sdk_lock() {
|
||||
let output = runner(valid().await, &["sdk_lock"]).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
/// PROTOCOL 11: calls to other plugins fail with a clear runner error.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unavailable_plugin() {
|
||||
let output = runner(valid().await, &["other_plugin_fails"]).await;
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
/// PROTOCOL 11: the notes example tests run in isolated worlds under the same runner.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn notes_example() {
|
||||
let world = TestWorld::new();
|
||||
world.source(
|
||||
"notes",
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("examples/notes"),
|
||||
);
|
||||
let result = build(&world, "notes")
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("{error}"));
|
||||
assert!(result["tests"]["passed"].as_u64().unwrap() > 0);
|
||||
}
|
||||
/// PROTOCOL 11: SDK installation leaves unchanged files and manifests untouched.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unchanged_sdk() {
|
||||
let world = TestWorld::new();
|
||||
let setup = || {
|
||||
sausage::cargo::Cargo::with_runner(
|
||||
World::open(&world.0).unwrap(),
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_sausage")),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
setup();
|
||||
let manifest = world.0.join("plugins/cargo/sdk/Cargo.toml");
|
||||
let modified = std::fs::metadata(&manifest).unwrap().modified().unwrap();
|
||||
setup();
|
||||
assert_eq!(
|
||||
std::fs::metadata(manifest).unwrap().modified().unwrap(),
|
||||
modified
|
||||
);
|
||||
}
|
||||
/// PROTOCOL 11: init can build a plugin without a running host.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn standalone_builder() {
|
||||
let world = TestWorld::new();
|
||||
world.source("simple", &fixture("cargo-test-fail"));
|
||||
let builder = sausage::cargo::Cargo::with_runner(
|
||||
World::open(&world.0).unwrap(),
|
||||
PathBuf::from(env!("CARGO_BIN_EXE_sausage")),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
builder
|
||||
.build(&world.0.join("plugins/simple/source"))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.tests
|
||||
.failed,
|
||||
1
|
||||
);
|
||||
}
|
||||
/// 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() {
|
||||
let world = TestWorld::new();
|
||||
world.source("hang", &fixture("cargo-hang"));
|
||||
let host = Host::start(&world.0).await.unwrap();
|
||||
let building = host.clone();
|
||||
let task =
|
||||
tokio::spawn(async move { building.call("cargo.build", json!({"plugin":"hang"})).await });
|
||||
let child = tokio::time::timeout(std::time::Duration::from_secs(120), 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::<u32>()
|
||||
{
|
||||
break pid;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let pid = host
|
||||
.processes()
|
||||
.into_iter()
|
||||
.find(|(_, process)| process.plugin == "cargo")
|
||||
.unwrap()
|
||||
.0;
|
||||
host.call("core.kill", json!({"pid":pid})).await.unwrap();
|
||||
assert!(task.await.unwrap().is_err());
|
||||
tokio::time::timeout(std::time::Duration::from_secs(10), async {
|
||||
loop {
|
||||
let status = std::fs::read_to_string(format!("/proc/{child}/stat"));
|
||||
if status
|
||||
.as_ref()
|
||||
.map_or(true, |status| status.split_whitespace().nth(2) == Some("Z"))
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
host.stop().await;
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
//! Console command contracts against a temporary world and the raw probe component.
|
||||
use sausage::{
|
||||
Host,
|
||||
console::{execute_line, notification_text},
|
||||
world::World,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::PathBuf, sync::OnceLock};
|
||||
|
||||
fn fixture() -> PathBuf {
|
||||
static WASM: OnceLock<PathBuf> = OnceLock::new();
|
||||
WASM.get_or_init(|| {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let target = root.join("target/vfs/console-guests");
|
||||
let status = std::process::Command::new("cargo")
|
||||
.args([
|
||||
"build",
|
||||
"--offline",
|
||||
"--target",
|
||||
"wasm32-wasip2",
|
||||
"--manifest-path",
|
||||
])
|
||||
.arg(root.join("cli/tests/plugins/probe/Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(&target)
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
target.join("wasm32-wasip2/debug/probe.wasm")
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
struct TestWorld(PathBuf);
|
||||
impl TestWorld {
|
||||
fn new() -> Self {
|
||||
let root = std::env::temp_dir().join(format!("sausage-console-{}", uuid::Uuid::now_v7()));
|
||||
let world = World::create(&root).unwrap();
|
||||
for plugin in ["probe", "agent"] {
|
||||
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
|
||||
std::fs::copy(fixture(), world.plugin_dir(plugin).join("plugin.wasm")).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(world.state_dir("probe")).unwrap();
|
||||
std::fs::write(world.state_dir("probe").join("discover.json"),json!({"methods":[{"name":"echo","description":"Echo supplied text.","params":[{"name":"text","description":"The input text.","schema":{"type":"string"}}],"result":{"schema":{"type":"string"}}}]}).to_string()).unwrap();
|
||||
std::fs::copy(fixture(), root.join("candidate.wasm")).unwrap();
|
||||
Self(root)
|
||||
}
|
||||
async fn start(&self) -> Host {
|
||||
let cache = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("target/vfs/console-cache");
|
||||
Host::start_with_cache(&self.0, &cache).await.unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for TestWorld {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
async fn command(host: &Host, line: &str) -> String {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(60), execute_line(host, line))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Explicit calls return indented JSON data, and RPC errors remain visible.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn rpc_call() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let result = command(&host, "probe.echo {\"answer\":42}").await;
|
||||
assert!(result.contains("\n \"answer\": 42\n"));
|
||||
assert!(command(&host, "absent.echo {}").await.contains("error:"));
|
||||
assert!(execute_line(&host, "probe.echo not-json").await.is_err());
|
||||
host.stop().await;
|
||||
}
|
||||
/// Plain text calls agent.chat and its interim notification uses dim styling.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn chat() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let mut notifications = host.subscribe();
|
||||
assert_eq!(command(&host, "hello agent").await, "hello agent");
|
||||
let value = notifications.recv().await.unwrap();
|
||||
assert_eq!(notification_text(&value), "\x1b[2minterim\x1b[0m");
|
||||
assert_eq!(
|
||||
command(&host, "Hello. Another sentence.").await,
|
||||
"Hello. Another sentence."
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
/// Folder listings include the live mounts and accept an explicit folder.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ls() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let result = command(&host, "ls").await;
|
||||
for name in ["proc/", "run/", "self/"] {
|
||||
assert!(result.contains(name));
|
||||
}
|
||||
assert!(command(&host, "ls /proc/core").await.contains("plugins/"));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Cat reads multiple chunks and finite generated snapshots through the VFS.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn cat() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let text = "x".repeat(150_000);
|
||||
std::fs::write(world.0.join("large"), &text).unwrap();
|
||||
assert_eq!(command(&host, "cat /large").await, text);
|
||||
host.call("probe.write", json!({"path":"/run/shared","text":"memory"}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(command(&host, "cat /run/shared").await, "memory");
|
||||
assert!(
|
||||
command(&host, "cat /proc/core/PROTOCOL.md")
|
||||
.await
|
||||
.contains("filesystem")
|
||||
);
|
||||
assert!(execute_line(&host, "cat /missing").await.is_err());
|
||||
host.stop().await;
|
||||
}
|
||||
/// Method summaries include parameter types, result types, and documentation.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn methods() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let result = command(&host, "methods probe").await;
|
||||
for text in [
|
||||
"probe.echo(text: string) -> string",
|
||||
"Echo supplied text.",
|
||||
"The input text.",
|
||||
] {
|
||||
assert!(result.contains(text), "{result}");
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
/// Ps includes every requested column and the live process metadata.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ps() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
let text = command(&host, "ps").await;
|
||||
assert!(text.contains("PID PLUGIN METHOD PARENT STATE AGE"));
|
||||
assert!(text.contains(pid.as_str().unwrap()));
|
||||
assert!(text.contains("probe serve console"));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Log supports plugin filters, process filters, and a row count.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn log() {
|
||||
let world = TestWorld::new();
|
||||
std::fs::create_dir_all(world.0.join("plugins/data-logger")).unwrap();
|
||||
std::fs::copy(fixture(), world.0.join("plugins/data-logger/plugin.wasm")).unwrap();
|
||||
let host = world.start().await;
|
||||
host.call("data-logger.echo", json!({})).await.unwrap();
|
||||
let filtered: Value =
|
||||
serde_json::from_str(&command(&host, "log data-logger -n 1").await).unwrap();
|
||||
assert_eq!(filtered[0]["plugin"], "data-logger");
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
for line in [
|
||||
"log".to_owned(),
|
||||
"log probe -n 1".to_owned(),
|
||||
format!("log -n 1 {}", pid.as_str().unwrap()),
|
||||
] {
|
||||
let entries: Value = serde_json::from_str(&command(&host, &line).await).unwrap();
|
||||
assert!(!entries.as_array().unwrap().is_empty());
|
||||
if line != "log" {
|
||||
assert_eq!(entries.as_array().unwrap().len(), 1);
|
||||
assert_eq!(entries[0]["plugin"], "probe");
|
||||
}
|
||||
}
|
||||
assert!(execute_line(&host, "log -n").await.is_err());
|
||||
host.stop().await;
|
||||
}
|
||||
/// Activate installs a candidate through core.activate.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn activate() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let result = command(&host, "activate probe candidate.wasm").await;
|
||||
assert!(!result.starts_with("error:"), "{result}");
|
||||
assert!(world.0.join("plugins/probe/plugin.prev.wasm").exists());
|
||||
host.stop().await;
|
||||
}
|
||||
/// Revert restores the previous component through core.revert.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn revert() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
command(&host, "activate probe candidate.wasm").await;
|
||||
let result = command(&host, "revert probe").await;
|
||||
assert!(!result.starts_with("error:"), "{result}");
|
||||
host.stop().await;
|
||||
}
|
||||
/// Stop closes a live process gracefully.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stop() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
command(&host, &format!("stop {}", pid.as_str().unwrap())).await;
|
||||
assert!(!host.processes().contains_key(pid.as_str().unwrap()));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Kill ends a live process immediately.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kill() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let pid = host.call("probe.serve", json!({})).await.unwrap();
|
||||
command(&host, &format!("kill {}", pid.as_str().unwrap())).await;
|
||||
assert!(!host.processes().contains_key(pid.as_str().unwrap()));
|
||||
host.stop().await;
|
||||
}
|
||||
/// Help lists all console commands.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn help() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
let text = command(&host, "help").await;
|
||||
for name in [
|
||||
"agent",
|
||||
"plugin.method",
|
||||
"ls",
|
||||
"cat",
|
||||
"methods",
|
||||
"ps",
|
||||
"log",
|
||||
"activate",
|
||||
"revert",
|
||||
"stop",
|
||||
"kill",
|
||||
"help",
|
||||
"quit",
|
||||
] {
|
||||
assert!(text.contains(name));
|
||||
}
|
||||
host.stop().await;
|
||||
}
|
||||
/// Quit produces a final message, and blank input does nothing.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn quit() {
|
||||
let world = TestWorld::new();
|
||||
let host = world.start().await;
|
||||
assert_eq!(command(&host, "quit").await, "Goodbye.");
|
||||
assert_eq!(command(&host, " ").await, "");
|
||||
host.stop().await;
|
||||
}
|
||||
@@ -243,6 +243,34 @@ async fn progress_notifications_reach_the_caller() {
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.5: every stdout notification reaches the caller before the following result.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn stdout_notifications_precede_the_result() {
|
||||
let w = TestWorld::new();
|
||||
let h = w.start().await;
|
||||
let mut notifications = h.subscribe();
|
||||
for _ in 0..10 {
|
||||
assert_eq!(call(&h, "probe.burst", json!({"count":64})).await, 64);
|
||||
let mut call_id = None;
|
||||
for index in 0..64 {
|
||||
// A delayed notification fails here, even if it arrives later.
|
||||
let notification = notifications.try_recv().unwrap();
|
||||
assert_eq!(notification["params"]["name"], "burst");
|
||||
assert_eq!(notification["params"]["params"]["index"], index);
|
||||
if let Some(id) = &call_id {
|
||||
assert_eq!(¬ification["params"]["call"], id);
|
||||
} else {
|
||||
call_id = Some(notification["params"]["call"].clone());
|
||||
}
|
||||
}
|
||||
assert!(matches!(
|
||||
notifications.try_recv(),
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
|
||||
));
|
||||
}
|
||||
h.stop().await;
|
||||
}
|
||||
|
||||
/// PROTOCOL 3.1: non message stdout warns once without its content.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn non_message_stdout_warns_once_without_its_content() {
|
||||
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sausage-actor = "0.1"
|
||||
sausage-plugin = "0.1"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
//! Adds two numbers with a C function: `{"type": "add", "a", "b"}` -> `{"type": "sum", "value"}`.
|
||||
use sausage_actor::{json, messages, reply};
|
||||
|
||||
//! SDK plugin linked to a WASI C function.
|
||||
unsafe extern "C" {
|
||||
fn c_add(a: i32, b: i32) -> i32;
|
||||
}
|
||||
|
||||
fn add(a: i32, b: i32) -> i32 {
|
||||
unsafe { c_add(a, b) }
|
||||
}
|
||||
|
||||
fn main() {
|
||||
for message in messages() {
|
||||
let (a, b) = (message.body["a"].as_i64().unwrap_or(0), message.body["b"].as_i64().unwrap_or(0));
|
||||
let _ = reply(&message, json!({"type": "sum", "value": add(a as i32, b as i32)}));
|
||||
struct Plugin;
|
||||
#[sausage_plugin::rpc]
|
||||
impl Plugin {
|
||||
/// Add two fixed numbers through C.
|
||||
fn sum(&self) -> sausage_plugin::Result<i32> {
|
||||
Ok(add(2, 3))
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
sausage_plugin::run(Plugin);
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn c_code_runs_on_the_host_target() {
|
||||
fn c_code_runs_under_wasi() {
|
||||
assert_eq!(super::add(2, 3), 5);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "cargo-hang"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
@@ -0,0 +1,7 @@
|
||||
//! A build script descendant that stays alive until the native call ends.
|
||||
fn main() {
|
||||
std::process::Command::new("sh")
|
||||
.args(["-c", "echo $$ > child.pid; exec sleep 1000"])
|
||||
.status()
|
||||
.unwrap();
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
fn main() {}
|
||||
@@ -1,9 +1,13 @@
|
||||
//! A panic must not prevent the runner from executing another test.
|
||||
fn main() {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn deliberate_unit_test_failure() {
|
||||
assert_eq!(2 + 2, 5);
|
||||
fn a_panics() {
|
||||
panic!("deliberate WASI panic");
|
||||
}
|
||||
#[test]
|
||||
fn z_survives() {
|
||||
assert_eq!(2 + 2, 4);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,7 @@ name = "cargo-valid"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sausage-plugin = "0.1"
|
||||
|
||||
[workspace]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
fixture
|
||||
@@ -1,9 +1,74 @@
|
||||
fn main() {}
|
||||
|
||||
//! SDK plugin and tests for the native build pipeline.
|
||||
struct Plugin;
|
||||
#[sausage_plugin::rpc]
|
||||
impl Plugin {
|
||||
/// Return a greeting.
|
||||
fn hello(&self) -> sausage_plugin::Result<String> {
|
||||
Ok("hello".into())
|
||||
}
|
||||
}
|
||||
fn main() {
|
||||
sausage_plugin::run(Plugin);
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn unit_tests_run_on_the_host_target() {
|
||||
fn arithmetic() {
|
||||
assert_eq!(2 + 2, 4);
|
||||
}
|
||||
#[test]
|
||||
fn fixture_at_manifest_path() {
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/fixture.txt")).unwrap(),
|
||||
"fixture\n"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn current_directory() {
|
||||
println!(
|
||||
"WASI_CURRENT_DIR={}",
|
||||
std::env::current_dir().unwrap().display()
|
||||
);
|
||||
}
|
||||
#[sausage_plugin::test]
|
||||
async fn sdk_lock() {
|
||||
let guard = sausage_plugin::lock("/state/lock").await.unwrap();
|
||||
assert!(guard.held());
|
||||
let mut guard = Some(guard);
|
||||
let mut waiting = Box::pin(std::future::IntoFuture::into_future(sausage_plugin::lock(
|
||||
"/state/lock",
|
||||
)));
|
||||
let acquired = std::future::poll_fn(|context| {
|
||||
let result = std::future::Future::poll(waiting.as_mut(), context);
|
||||
if result.is_pending() {
|
||||
drop(guard.take());
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(acquired.held());
|
||||
let logs = sausage_plugin::call("core.log", sausage_plugin::serde_json::json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(logs.is_array());
|
||||
sausage_plugin::call(
|
||||
"core.notify",
|
||||
sausage_plugin::serde_json::json!({"name":"progress","params":{}}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
#[sausage_plugin::test]
|
||||
async fn other_plugin_fails() {
|
||||
let error = sausage_plugin::call("missing.method", sausage_plugin::serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("a test has no other plugins"));
|
||||
}
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn ignored_case() {
|
||||
assert_eq!(1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ fn receive(lines: &mut Lines) -> Value {
|
||||
fn echo(params: &Value) -> Value {
|
||||
params.clone()
|
||||
}
|
||||
fn chat(params: &Value) -> Value {
|
||||
send(json!({"jsonrpc":"2.0", "method":"core.notify", "params":{
|
||||
"name":"text", "params":{"text":"interim"}
|
||||
}}));
|
||||
std::thread::sleep(std::time::Duration::from_millis(30));
|
||||
json!({"text":params["text"]})
|
||||
}
|
||||
fn discover(params: &Value) -> Value {
|
||||
if let Ok(bytes) = std::fs::read("/state/probe/discover.json") {
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
@@ -105,6 +112,25 @@ fn blank() -> Value {
|
||||
println!("\n \t\n");
|
||||
json!({})
|
||||
}
|
||||
fn burst(request: &Value) {
|
||||
let count = request["params"]["count"].as_u64().unwrap();
|
||||
let mut output = String::new();
|
||||
for index in 0..count {
|
||||
let notification = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "core.notify",
|
||||
"params": {"name": "burst", "params": {"index": index}}
|
||||
});
|
||||
output.push_str(¬ification.to_string());
|
||||
output.push('\n');
|
||||
}
|
||||
let response = json!({"jsonrpc":"2.0", "id":request["id"], "result":count});
|
||||
output.push_str(&response.to_string());
|
||||
output.push('\n');
|
||||
let mut stdout = io::stdout().lock();
|
||||
stdout.write_all(output.as_bytes()).unwrap();
|
||||
stdout.flush().unwrap();
|
||||
}
|
||||
fn notify() -> Value {
|
||||
send(json!({
|
||||
"jsonrpc": "2.0",
|
||||
@@ -281,6 +307,7 @@ fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
|
||||
}
|
||||
"locked_write" => locked_write(lines),
|
||||
"echo" => echo(params),
|
||||
"chat" => chat(params),
|
||||
"rpc.discover" => discover(params),
|
||||
"env" => environment(),
|
||||
"multicall" => multicall(lines),
|
||||
@@ -304,6 +331,10 @@ fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
|
||||
"blank" => blank(),
|
||||
"invalid_utf8" => invalid_utf8(),
|
||||
"notify" => notify(),
|
||||
"burst" => {
|
||||
burst(request);
|
||||
return None;
|
||||
}
|
||||
"serve" => {
|
||||
serve(request, lines);
|
||||
return None;
|
||||
|
||||
Reference in New Issue
Block a user