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
Generated
+115
View File
@@ -11,6 +11,16 @@ dependencies = [
"gimli", "gimli",
] ]
[[package]]
name = "agent"
version = "0.1.0"
dependencies = [
"sausage-plugin",
"serde",
"serde_json",
"wstd",
]
[[package]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.21" version = "0.2.21"
@@ -310,6 +320,15 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]] [[package]]
name = "cobs" name = "cobs"
version = "0.3.0" version = "0.3.0"
@@ -637,6 +656,12 @@ dependencies = [
"simdutf8", "simdutf8",
] ]
[[package]]
name = "endian-type"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.3" version = "1.0.3"
@@ -653,6 +678,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "error-code"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@@ -957,6 +988,15 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "home"
version = "0.5.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
dependencies = [
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "http" name = "http"
version = "1.5.0" version = "1.5.0"
@@ -1024,6 +1064,20 @@ dependencies = [
"want", "want",
] ]
[[package]]
name = "hyper-util"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.3.0" version = "2.3.0"
@@ -1429,6 +1483,27 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "nibble_vec"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
dependencies = [
"smallvec",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]] [[package]]
name = "notify" name = "notify"
version = "8.2.0" version = "8.2.0"
@@ -1659,6 +1734,16 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radix_trie"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a"
dependencies = [
"endian-type",
"nibble_vec",
]
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.10.3" version = "0.10.3"
@@ -1903,6 +1988,26 @@ dependencies = [
"wait-timeout", "wait-timeout",
] ]
[[package]]
name = "rustyline"
version = "19.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e1d6bd81c2d5feafd2419138df9183c45a10a492c4e873b8b0acb8eb2b549e"
dependencies = [
"bitflags",
"clipboard-win",
"home",
"libc",
"log",
"memchr",
"nix",
"radix_trie",
"unicode-segmentation",
"unicode-width",
"utf8parse",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "same-file" name = "same-file"
version = "1.0.6" version = "1.0.6"
@@ -1921,9 +2026,13 @@ dependencies = [
"clap", "clap",
"http", "http",
"http-body-util", "http-body-util",
"hyper",
"hyper-util",
"include_dir", "include_dir",
"nix",
"notify", "notify",
"rusqlite", "rusqlite",
"rustyline",
"sausage-interface", "sausage-interface",
"sausage-vfs", "sausage-vfs",
"serde", "serde",
@@ -2573,6 +2682,12 @@ version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-segmentation"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
version = "0.2.2" version = "0.2.2"
+4
View File
@@ -29,6 +29,10 @@ rusqlite = { version = "0.38", features = ["bundled"] }
sausage-vfs = { version = "0.1.0", path = "../vfs" } sausage-vfs = { version = "0.1.0", path = "../vfs" }
sausage-interface = { version = "0.1.0", path = "../interface" } sausage-interface = { version = "0.1.0", path = "../interface" }
notify = "8.2.0" notify = "8.2.0"
rustyline = "19"
nix = { version = "0.31.3", features = ["signal", "process"] }
[dev-dependencies] [dev-dependencies]
hyper = { version = "1.12.0", features = ["server", "http1"] }
hyper-util = { version = "0.1.21", features = ["tokio"] }
sausage-interface = { path = "../interface" } sausage-interface = { path = "../interface" }
+274 -250
View File
@@ -1,69 +1,48 @@
//! Rust build helpers for the native cargo plugin. //! Native cargo builds, one immutable source copy, SDK installation, and process-group cancellation.
use crate::{Host, world::World};
use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail};
use std::time::Instant; use include_dir::{Dir, include_dir};
use serde_json::{Value, json};
use anyhow::{Context, Result, anyhow, bail}; use std::{
use serde_json::Value; path::{Path, PathBuf},
sync::Arc,
};
use tokio::process::Command; use tokio::process::Command;
use include_dir::{Dir, include_dir}; /// The native build plugin.
use crate::world::{self, World};
/// The name reserved for the native Rust build plugin.
pub const ID: &str = "cargo"; 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"; 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. #[derive(Clone, Default, Debug, serde::Serialize)]
static SDK_SRC: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/src"); /// Test totals and captured output.
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.
pub struct TestResults { pub struct TestResults {
/// The number of tests that passed.
pub passed: u64, pub passed: u64,
/// The number of tests that failed.
pub failed: u64, pub failed: u64,
/// The test output retained for the caller.
pub output: String, pub output: String,
} }
/// A successful build, usable without a running host.
/// 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,
}
#[derive(Debug)] #[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 { pub struct BuildFailure {
/// The reason that the build could not complete.
pub reason: String, pub reason: String,
/// The totals and output of the test phase.
pub tests: TestResults, pub tests: TestResults,
} }
impl std::fmt::Display for BuildFailure { impl std::fmt::Display for BuildFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.tests.failed > 0 && !self.tests.output.is_empty() { write!(formatter, "{}\n{}", self.reason, self.tests.output)
write!(f, "{}:\n{}", self.reason, self.tests.output)
} else {
f.write_str(&self.reason)
}
} }
} }
impl std::error::Error for BuildFailure {} impl std::error::Error for BuildFailure {}
impl From<anyhow::Error> for BuildFailure { impl From<anyhow::Error> for BuildFailure {
fn from(error: anyhow::Error) -> Self { fn from(error: anyhow::Error) -> Self {
Self { Self {
@@ -72,170 +51,271 @@ impl From<anyhow::Error> for BuildFailure {
} }
} }
} }
/// A host-independent builder. Each builder serializes access to its work and target folders.
/// All builds share one target folder, so they run one at a time.
pub struct Cargo { pub struct Cargo {
world: World, world: World,
builds: tokio::sync::Mutex<()>,
runner: PathBuf,
} }
impl Cargo { 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> { pub fn new(world: World) -> Result<Self> {
let sdk = world.plugin_dir(ID).join("sdk"); let mut runner = std::env::current_exe()?;
write_dir(&SDK_SRC, &sdk.join("src"))?; if runner
write_dir(&SDK_MACROS, &sdk.join("macros/src"))?; .parent()
world::write_atomic( .is_some_and(|parent| parent.file_name().is_some_and(|name| name == "deps"))
&sdk.join("Cargo.toml"), {
include_bytes!("../../sdk/Cargo.toml"), runner = runner.parent().unwrap().parent().unwrap().join("sausage");
)?;
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 started = Instant::now(); Self::with_runner(world, runner)
let data = self.world.state_dir(ID); }
let build_dir = data.join("work"); /// Installs the SDK and accepts an explicit sausage binary, including for init integrations.
world::replace_source(source, &build_dir)?; pub fn with_runner(world: World, runner: PathBuf) -> Result<Self> {
let manifest = build_dir.join("Cargo.toml"); let sdk = world.plugin_dir(ID).join("sdk");
let original = std::fs::read_to_string(&manifest).context("read build manifest")?; write_dir(&SDK, &sdk.join("src"))?;
let prepared = prepare_manifest(&original, Some(&self.world.plugin_dir(ID).join("sdk")))?; write_dir(&MACROS, &sdk.join("macros/src"))?;
std::fs::write(&manifest, prepared).context("write prepared manifest")?; write_dir(&INTERFACE, &sdk.join("interface/src"))?;
for (manifest, destination, interface_path) in [
let target_dir = data.join("target"); (
let tests = if skip_tests { include_str!("../../sdk/Cargo.toml"),
TestResults { sdk.join("Cargo.toml"),
output: "skipped (skip_tests=true)".into(), Some("interface"),
..TestResults::default() ),
} (
} else { include_str!("../../sdk/macros/Cargo.toml"),
match run_tests(&build_dir, &target_dir).await { sdk.join("macros/Cargo.toml"),
Ok(tests) => tests, Some("../interface"),
Err(error) => { ),
let output = format!("cannot run cargo tests: {error:#}"); (
return Err(BuildFailure { include_str!("../../interface/Cargo.toml"),
reason: output.clone(), sdk.join("interface/Cargo.toml"),
tests: TestResults { None,
failed: 1, ),
output, ] {
..TestResults::default() 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)?;
}
copy_source(&source, &copy)?;
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 { return Err(BuildFailure {
reason: "unit tests failed".into(), reason: "WASI tests failed".into(),
tests, tests,
}); });
} }
let result = async {
let build_result: Result<BuildOutput> = async { let mut build = Command::new("cargo");
let mut cmd = Command::new("cargo"); build
cmd.arg("build") .args([
.arg("--release") "build",
.arg("--target") "--offline",
.arg(TARGET) "--release",
.arg("--message-format=json-render-diagnostics") "--target",
.arg("--target-dir") TARGET,
.arg(&target_dir) "--message-format=json-render-diagnostics",
.current_dir(&build_dir) "--target-dir",
.kill_on_drop(true); ])
let out = cmd .arg(&target)
.output() .current_dir(&work);
.await let output = run_command(build).await?;
.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?; let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&out.stderr).to_string(); let stderr = String::from_utf8_lossy(&output.stderr);
if !out.status.success() { let mut diagnostics = stderr.to_string();
bail!("{}", strip_progress(&stderr));
}
let mut artifact = None; let mut artifact = None;
for line in String::from_utf8_lossy(&out.stdout).lines() { for line in stdout.lines() {
if let Ok(v) = serde_json::from_str::<Value>(line) if let Ok(message) = serde_json::from_str::<Value>(line) {
&& v["reason"] == "compiler-artifact" if message["reason"] == "compiler-artifact"
&& let Some(exe) = v["executable"].as_str() && let Some(executable) = message["executable"].as_str()
{ {
artifact = Some(PathBuf::from(exe)); artifact = Some(PathBuf::from(executable));
}
if let Some(rendered) = message["message"]["rendered"].as_str() {
diagnostics.push_str(rendered);
}
} }
} }
let artifact = artifact.ok_or_else(|| { if !output.status.success() {
anyhow!("cargo built no binary. Does the crate have a src/main.rs?") bail!("{diagnostics}");
})?; }
let artifact = artifact.context("cargo built no binary")?;
let n = next_build_number(&self.world.plugin_dir(ID).join("builds"))?; let builds = self.world.state_dir(ID).join("builds");
let dest_dir = self.world.plugin_dir(ID).join("builds").join(n.to_string()); std::fs::create_dir_all(&builds)?;
let name = artifact.file_name().context("artifact has no file name")?; let number = std::fs::read_dir(&builds)?
let dest = dest_dir.join(name); .filter_map(|entry| entry.ok()?.file_name().to_str()?.parse::<u64>().ok())
world::write_atomic(&dest, &std::fs::read(&artifact)?)?; .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 { Ok(BuildOutput {
wasm: dest, wasm: destination,
seconds: started.elapsed().as_secs_f64(), warnings: strip_progress(&diagnostics),
warnings: strip_progress(&stderr),
tests: tests.clone(), tests: tests.clone(),
}) })
} }
.await; .await;
build_result.map_err(|error| BuildFailure { result.map_err(|error: anyhow::Error| BuildFailure {
reason: format!("{error:#}"), reason: format!("{error:#}"),
tests, tests,
}) })
} }
} }
/// Kills cargo and every descendant, including tests and build scripts, on cancellation.
/// Run tests for the host triple: WASI crates' pure-logic tests can use the struct ProcessGroup(u32);
/// normal Rust test harness without needing a WASI test runner. impl Drop for ProcessGroup {
async fn run_tests(build_dir: &Path, target_dir: &Path) -> Result<TestResults> { fn drop(&mut self) {
let mut cmd = Command::new("cargo"); let _ = nix::sys::signal::killpg(
cmd.arg("test") nix::unistd::Pid::from_raw(self.0 as i32),
.arg("--target-dir") nix::sys::signal::Signal::SIGKILL,
.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;
} }
Ok(TestResults {
passed,
failed,
output,
})
} }
/// Starts one process group and retains its guard across all output collection.
/// Count the `passed` or `failed` totals in cargo's per-target test summaries. 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 { fn test_count(output: &str, field: &str) -> u64 {
output output
.lines() .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(';')) .flat_map(|summary| summary.split(';'))
.filter_map(|part| { .filter_map(|part| {
let words: Vec<_> = part.split_whitespace().collect(); let words: Vec<_> = part.split_whitespace().collect();
@@ -244,77 +324,21 @@ fn test_count(output: &str, field: &str) -> u64 {
}) })
.sum() .sum()
} }
/// Removes progress lines while retaining compiler diagnostics.
/// Point a `sausage-actor` dependency at `sdk` (keeping its features), and fn strip_progress(output: &str) -> String {
/// add an empty `[workspace]`, so the crate never joins a parent workspace. output
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
.lines() .lines()
.filter(|l| { .filter(|line| {
let t = l.trim_start(); ![
!(t.starts_with("Compiling ") "Compiling ",
|| t.starts_with("Downloaded ") "Finished ",
|| t.starts_with("Downloading ") "Blocking ",
|| t.starts_with("Updating ") "Updating ",
|| t.starts_with("Locking ") "Locking ",
|| t.starts_with("Adding ") "Adding ",
|| t.starts_with("Blocking ")) ]
.iter()
.any(|prefix| line.trim_start().starts_with(prefix))
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n") .join("\n")
+320 -19
View File
@@ -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 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. const HELP: &str = "text chat with the agent
pub async fn run_input(host: Host) -> anyhow::Result<()> { plugin.method {json} call a plugin
let mut lines = BufReader::new(tokio::io::stdin()).lines(); ls [path] list a folder (default /)
while let Some(line) = lines.next_line().await? { cat <path> read a file
if line == "quit" { methods <plugin> show methods, params, types, and docs
break; ps show pid, plugin, method, parent, state, age
} log [plugin or pid] [-n N] read the journal
let (method, params) = line.split_once(' ').unwrap_or((&line, "{}")); activate <plugin> <wasm> activate a version
match serde_json::from_str(params) { revert <plugin> restore the previous version
Ok(params) => { stop <pid> request a graceful stop
let value = match host.call(method, params).await { kill <pid> kill a process
Ok(value) | Err(value) => value, help show this help
}; quit leave the console";
println!("{value}");
} async fn read(host: &Host, path: &str) -> Result<Vec<u8>> {
Err(error) => eprintln!("{error}"), 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 = &notification["params"];
if params["name"] == "text" {
let payload = &params["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(&notification_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}
} }
+4
View File
@@ -82,6 +82,10 @@ impl Host {
watcher: Mutex::new(None), watcher: Mutex::new(None),
retirements: Mutex::new(std::collections::HashSet::new()), 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?; host.boot().await?;
Ok(host) Ok(host)
} }
+27 -7
View File
@@ -4,6 +4,7 @@ use clap::{Parser, Subcommand};
use sausage::{Host, world::World}; use sausage::{Host, world::World};
use serde_json::json; use serde_json::json;
use std::{io::Write, path::PathBuf}; use std::{io::Write, path::PathBuf};
mod test_runner;
#[derive(Parser)] #[derive(Parser)]
#[command(version)] #[command(version)]
struct Cli { struct Cli {
@@ -16,8 +17,18 @@ struct Cli {
} }
#[derive(Subcommand)] #[derive(Subcommand)]
enum Cmd { enum Cmd {
Init { dir: PathBuf }, Init {
Run { dir: PathBuf }, 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> { fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
if let Ok(v) = std::env::var(var) 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<()> { async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
match cli.command { match cli.command {
Cmd::TestRunner { wasm, args } => {
if !test_runner::run(&wasm, args).await? {
std::process::exit(1);
}
}
Cmd::Init { dir } => { Cmd::Init { dir } => {
let api = setting("SAUSAGE_API", "API shape", Some("openai-responses"))?; let api = setting("SAUSAGE_API", "API shape", Some("openai-responses"))?;
if api != "openai-responses" && api != "anthropic-messages" { if api != "openai-responses" && api != "anthropic-messages" {
@@ -65,10 +81,12 @@ async fn main() -> Result<()> {
None => Host::start(&dir).await?, None => Host::start(&dir).await?,
}; };
let mut notifications = host.subscribe(); let mut notifications = host.subscribe();
let notification_task = tokio::spawn(async move { let notification_task = cli.headless.then(|| {
while let Ok(notification) = notifications.recv().await { tokio::spawn(async move {
println!("{notification}"); while let Ok(notification) = notifications.recv().await {
} println!("{notification}");
}
})
}); });
let console = sausage::console::run_input(host.clone()); let console = sausage::console::run_input(host.clone());
let exit = tokio::select! { let exit = tokio::select! {
@@ -82,7 +100,9 @@ async fn main() -> Result<()> {
}; };
let exit = exit || host.restart_requested(); let exit = exit || host.restart_requested();
host.stop().await; host.stop().await;
notification_task.abort(); if let Some(task) = notification_task {
task.abort();
}
if exit { if exit {
std::process::exit(75); std::process::exit(75);
} }
+24
View File
@@ -220,6 +220,30 @@ impl Host {
return; return;
} }
if let Some(method) = v["method"].as_str() { 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 h = self.clone();
let caller = pid.to_string(); let caller = pid.to_string();
let method = method.to_string(); let method = method.to_string();
+11
View File
@@ -243,12 +243,23 @@ impl Runtime {
self: &Arc<Self>, self: &Arc<Self>,
component: &Component, component: &Component,
cfg: InstanceConfig, 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> { ) -> Result<Instance> {
let (host_in, guest_in) = tokio::io::duplex(64 * 1024); let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
let (guest_out, host_out) = 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 (guest_err, host_err) = tokio::io::duplex(64 * 1024);
let mut b = WasiCtx::builder(); let mut b = WasiCtx::builder();
b.args(args);
if cfg.network { if cfg.network {
b.inherit_network() b.inherit_network()
.allow_tcp(true) .allow_tcp(true)
+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)
}
+349
View File
@@ -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;
}
+255
View File
@@ -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;
}
+28
View File
@@ -243,6 +243,34 @@ async fn progress_notifications_reach_the_caller() {
h.stop().await; 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!(&notification["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. /// PROTOCOL 3.1: non message stdout warns once without its content.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn non_message_stdout_warns_once_without_its_content() { async fn non_message_stdout_warns_once_without_its_content() {
+1 -1
View File
@@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
sausage-actor = "0.1" sausage-plugin = "0.1"
[build-dependencies] [build-dependencies]
cc = "1" cc = "1"
+11 -11
View File
@@ -1,25 +1,25 @@
//! Adds two numbers with a C function: `{"type": "add", "a", "b"}` -> `{"type": "sum", "value"}`. //! SDK plugin linked to a WASI C function.
use sausage_actor::{json, messages, reply};
unsafe extern "C" { unsafe extern "C" {
fn c_add(a: i32, b: i32) -> i32; fn c_add(a: i32, b: i32) -> i32;
} }
fn add(a: i32, b: i32) -> i32 { fn add(a: i32, b: i32) -> i32 {
unsafe { c_add(a, b) } unsafe { c_add(a, b) }
} }
struct Plugin;
fn main() { #[sausage_plugin::rpc]
for message in messages() { impl Plugin {
let (a, b) = (message.body["a"].as_i64().unwrap_or(0), message.body["b"].as_i64().unwrap_or(0)); /// Add two fixed numbers through C.
let _ = reply(&message, json!({"type": "sum", "value": add(a as i32, b as i32)})); fn sum(&self) -> sausage_plugin::Result<i32> {
Ok(add(2, 3))
} }
} }
fn main() {
sausage_plugin::run(Plugin);
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]
fn c_code_runs_on_the_host_target() { fn c_code_runs_under_wasi() {
assert_eq!(super::add(2, 3), 5); assert_eq!(super::add(2, 3), 5);
} }
} }
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "cargo-hang"
version = "0.1.0"
edition = "2024"
[workspace]
+7
View File
@@ -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();
}
+1
View File
@@ -0,0 +1 @@
fn main() {}
+7 -3
View File
@@ -1,9 +1,13 @@
//! A panic must not prevent the runner from executing another test.
fn main() {} fn main() {}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]
fn deliberate_unit_test_failure() { fn a_panics() {
assert_eq!(2 + 2, 5); panic!("deliberate WASI panic");
}
#[test]
fn z_survives() {
assert_eq!(2 + 2, 4);
} }
} }
+3
View File
@@ -3,4 +3,7 @@ name = "cargo-valid"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies]
sausage-plugin = "0.1"
[workspace] [workspace]
+1
View File
@@ -0,0 +1 @@
fixture
+68 -3
View File
@@ -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)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]
fn unit_tests_run_on_the_host_target() { fn arithmetic() {
assert_eq!(2 + 2, 4); 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);
}
} }
+31
View File
@@ -14,6 +14,13 @@ fn receive(lines: &mut Lines) -> Value {
fn echo(params: &Value) -> Value { fn echo(params: &Value) -> Value {
params.clone() 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 { fn discover(params: &Value) -> Value {
if let Ok(bytes) = std::fs::read("/state/probe/discover.json") { if let Ok(bytes) = std::fs::read("/state/probe/discover.json") {
serde_json::from_slice(&bytes).unwrap() serde_json::from_slice(&bytes).unwrap()
@@ -105,6 +112,25 @@ fn blank() -> Value {
println!("\n \t\n"); println!("\n \t\n");
json!({}) 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(&notification.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 { fn notify() -> Value {
send(json!({ send(json!({
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -281,6 +307,7 @@ fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
} }
"locked_write" => locked_write(lines), "locked_write" => locked_write(lines),
"echo" => echo(params), "echo" => echo(params),
"chat" => chat(params),
"rpc.discover" => discover(params), "rpc.discover" => discover(params),
"env" => environment(), "env" => environment(),
"multicall" => multicall(lines), "multicall" => multicall(lines),
@@ -304,6 +331,10 @@ fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
"blank" => blank(), "blank" => blank(),
"invalid_utf8" => invalid_utf8(), "invalid_utf8" => invalid_utf8(),
"notify" => notify(), "notify" => notify(),
"burst" => {
burst(request);
return None;
}
"serve" => { "serve" => {
serve(request, lines); serve(request, lines);
return None; return None;
+2 -2
View File
@@ -4,7 +4,7 @@
pub mod compat; pub mod compat;
pub mod document; pub mod document;
pub use document::Document; pub use document::Document;
#[cfg(feature="codegen")] #[cfg(feature = "codegen")]
mod client; mod client;
#[cfg(feature="codegen")] #[cfg(feature = "codegen")]
pub use client::client_rs; pub use client::client_rs;