From 665b8a2a03e93d276eea4be0f3e5f574021c861f Mon Sep 17 00:00:00 2001 From: Surma Date: Fri, 6 Nov 2026 17:21:51 +0000 Subject: [PATCH] host: wasip1 processes, wasi:http, identity, stdio options, services (T12) - Core modules run on the host-side preview1 over the shared VFS (D41). - wasi:http default handler in the linker; anonymous HTTPS git clone works. Sockets stay denied until P2. - Each process carries the conversation (from Exec, inherited by children, shown in /proc/processes) and a host-assigned SAUSAGE_PID. - Spawn requests choose pipe, inherit or null per stream. - /config/services.yaml supervisor: live edits, restarts, crash limit 5/10 s, graceful stop (stdin EOF, then kill after 5 s) (D44). - Compile caches keyed by content; PATH=/usr/local/bin:/bin. world.rs also carries T13's optional read-only /bin mount (D43). --- Cargo.lock | 50 ++++ host/Cargo.toml | 5 + host/src/process/mod.rs | 183 ++++++++++--- host/src/process/services.rs | 169 ++++++++++++ host/src/process/stdio.rs | 73 +++++ host/src/world.rs | 19 +- host/tests/process.rs | 393 +++++++++++++++++++++++++++ host/wit/process.wit | 4 + userland/brush/probe/Cargo.lock | 444 +++++++++++++++++++++++++++++++ userland/brush/probe/Cargo.toml | 7 + userland/brush/probe/src/main.rs | 74 ++++++ userland/brush/process.rs | 3 + userland/procps/tools.txt | 2 + 13 files changed, 1385 insertions(+), 41 deletions(-) create mode 100644 host/src/process/services.rs create mode 100644 host/src/process/stdio.rs create mode 100644 userland/brush/probe/Cargo.lock create mode 100644 userland/brush/probe/Cargo.toml create mode 100644 userland/brush/probe/src/main.rs create mode 100644 userland/procps/tools.txt diff --git a/Cargo.lock b/Cargo.lock index c91d893..7297307 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4583,6 +4583,7 @@ dependencies = [ "aws-lc-rs", "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4703,13 +4704,18 @@ dependencies = [ "deno_web", "deno_webidl", "rusqlite", + "rustls", "sausage-vfs", + "serde", "serde_json", + "serde_yaml_ng", "sys_traits", "tempfile", "tokio", "wasmtime", "wasmtime-wasi", + "wasmtime-wasi-http", + "wat", ] [[package]] @@ -4898,6 +4904,19 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serdect" version = "0.2.0" @@ -6158,6 +6177,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.7.1" @@ -6713,6 +6738,31 @@ dependencies = [ "winx", ] +[[package]] +name = "wasmtime-wasi-http" +version = "49.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9248a9ab044a9a4f414afdd14ffb38d7c58d4e2cc0f119ace9464d698f6f3bde" +dependencies = [ + "async-trait", + "bytes", + "futures", + "http", + "http-body", + "http-body-util", + "hyper", + "pin-project-lite", + "rustls", + "tokio", + "tokio-rustls", + "tokio-util", + "tracing", + "wasmtime", + "wasmtime-wasi", + "wasmtime-wasi-io", + "webpki-roots", +] + [[package]] name = "wasmtime-wasi-io" version = "49.0.3" diff --git a/host/Cargo.toml b/host/Cargo.toml index aab8e27..1c7244e 100644 --- a/host/Cargo.toml +++ b/host/Cargo.toml @@ -29,6 +29,11 @@ rusqlite = { version = "0.38", features = ["bundled"] } sys_traits = { version = "0.1", features = ["real", "libc"] } wasmtime = "49" wasmtime-wasi = "49" +wasmtime-wasi-http = "=49.0.3" +serde = { version = "1.0.229", features = ["derive"] } +serde_yaml_ng = "0.10.0" +rustls = "0.23" [dev-dependencies] tempfile = "3" +wat = "1" diff --git a/host/src/process/mod.rs b/host/src/process/mod.rs index 2695003..187c423 100644 --- a/host/src/process/mod.rs +++ b/host/src/process/mod.rs @@ -1,4 +1,4 @@ -//! Runs components in independent stores over the world root. Owns process groups, stdio, and Exec. +//! Runs WASI programs in independent stores over the world root. Owns process groups, stdio, and Exec. use crate::{ exec::{Command, Exec, Options, Stream}, world::World, @@ -26,7 +26,7 @@ use wasmtime::{ }; use wasmtime_wasi::{ WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView, - cli::{AsyncStdinStream, AsyncStdoutStream}, + cli::AsyncStdoutStream, p2::{ DynInputStream, DynOutputStream, bindings::CommandPre, @@ -44,7 +44,17 @@ mod bindings { }, }); } -use bindings::sausage::process::host::{Host, HostChild, Request}; +use bindings::sausage::process::host::{Host, HostChild, Request, Stdio}; +mod services; +mod stdio; +pub use services::supervise; +use stdio::Io; + +#[derive(Clone)] +enum Compiled { + Component(CommandPre), + Module(wasmtime::InstancePre), +} /// A finite snapshot of process metadata exposed through /proc/processes. #[derive(Clone, Debug)] @@ -53,6 +63,7 @@ pub struct ProcessInfo { pub argv: Vec, pub cwd: String, pub state: String, + pub conversation: Option, } pub type Processes = Arc>>; @@ -66,7 +77,7 @@ struct Entry { pub struct Runtime { world: World, engine: Engine, - cache: Mutex, CommandPre>>, + cache: Mutex, Compiled>>, entries: Mutex>, next: AtomicU32, alive: Arc, @@ -84,6 +95,19 @@ struct State { pid: u32, command: u32, group: u32, + conversation: Option, + stdio: Io, + preview1: Option, + http: wasmtime_wasi_http::WasiHttpCtx, +} +impl wasmtime_wasi_http::WasiHttpView for State { + fn http(&mut self) -> wasmtime_wasi_http::WasiHttpCtxView<'_> { + wasmtime_wasi_http::WasiHttpCtxView { + ctx: &mut self.http, + table: &mut self.table, + hooks: Default::default(), + } + } } impl WasiView for State { fn ctx(&mut self) -> WasiCtxView<'_> { @@ -105,15 +129,17 @@ struct Started { pid: u32, group: u32, code: watch::Receiver>, - input: DuplexStream, - output: DuplexStream, - error: DuplexStream, + input: Option, + output: Option, + error: Option, } impl Runtime { pub fn open(path: &Path) -> anyhow::Result> { Self::new(World::open(path)?) } pub fn new(world: World) -> anyhow::Result> { + // Deno and WASI HTTP enable different providers. Select one before HTTPS creates a client. + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); let mut config = wasmtime::Config::new(); config.epoch_interruption(true); let mut cache = wasmtime::CacheConfig::new(); @@ -138,6 +164,10 @@ impl Runtime { next: AtomicU32::new(1), })) } + /// Number of distinct program contents compiled by this runtime. + pub fn compiled_programs(&self) -> usize { + self.cache.lock().unwrap().len() + } fn kill_command(&self, command: u32) { for entry in self.entries.lock().unwrap().values() { if entry.command == command { @@ -170,6 +200,8 @@ impl Runtime { parent: u32, command: u32, inherited_group: u32, + conversation: Option, + inherited_io: Option, ) -> anyhow::Result { anyhow::ensure!(!request.argv.is_empty(), "empty argv"); let cancelled = if parent == 0 { @@ -182,6 +214,11 @@ impl Runtime { .map(|entry| entry.cancelled.clone()) .ok_or_else(|| anyhow::anyhow!("parent exited"))? }; + if !request.env.iter().any(|(k, _)| k == "PATH") { + request + .env + .push(("PATH".into(), "/usr/local/bin:/bin".into())); + } request.cwd = sausage_vfs::resolve("/", &request.cwd); request.env.retain(|(name, _)| name != "PWD"); request.env.push(("PWD".into(), request.cwd.clone())); @@ -205,19 +242,29 @@ impl Runtime { if let Some(pre) = cache.get(&bytes) { pre.clone() } else { - let component = Component::new(&self.engine, &bytes)?; - let mut linker = Linker::new(&self.engine); - wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; - // The library linker includes exit-with-code. Only Wasmtime's CLI gates it. - adapter::add_to_linker(&mut linker, |s: &mut State| View { - adapter: &mut s.fs, - table: &mut s.table, - })?; - bindings::sausage::process::host::add_to_linker::< - _, - wasmtime::component::HasSelf<_>, - >(&mut linker, |s| s)?; - let pre = CommandPre::new(linker.instantiate_pre(&component)?)?; + let pre = if bytes.get(..8) == Some(b"\0asm\x01\0\0\0") { + let module = wasmtime::Module::new(&self.engine, &bytes)?; + let mut linker = wasmtime::Linker::new(&self.engine); + sausage_vfs::preview1::add_to_linker(&mut linker, |s: &mut State| { + s.preview1.as_mut().unwrap() + })?; + Compiled::Module(linker.instantiate_pre(&module)?) + } else { + let component = Component::new(&self.engine, &bytes)?; + let mut linker = Linker::new(&self.engine); + wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; + // The library linker includes exit-with-code. Only Wasmtime's CLI gates it. + wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; + adapter::add_to_linker(&mut linker, |s: &mut State| View { + adapter: &mut s.fs, + table: &mut s.table, + })?; + bindings::sausage::process::host::add_to_linker::< + _, + wasmtime::component::HasSelf<_>, + >(&mut linker, |s| s)?; + Compiled::Component(CommandPre::new(linker.instantiate_pre(&component)?)?) + }; cache.insert(bytes, pre.clone()); pre } @@ -230,20 +277,45 @@ impl Runtime { None if inherited_group != 0 => inherited_group, None => pid, }; + request.env.retain(|(name, _)| name != "SAUSAGE_PID"); + request.env.push(("SAUSAGE_PID".into(), pid.to_string())); request.env.push(("PID".into(), pid.to_string())); request.env.push(("PPID".into(), parent.to_string())); - let (input, child_input) = tokio::io::duplex(8192); - let (output, child_output) = tokio::io::duplex(8192); - let (error, child_error) = tokio::io::duplex(8192); + let (input, child_input) = stdio::input(request.stdin, inherited_io.as_ref()); + let (output, child_output) = + stdio::output(request.stdout, inherited_io.as_ref().map(|io| &io.output)); + let (error, child_error) = + stdio::output(request.stderr, inherited_io.as_ref().map(|io| &io.error)); + let stdio = Io { + input: child_input, + output: child_output, + error: child_error, + }; + let preview1 = if matches!(pre, Compiled::Module(_)) { + Some(sausage_vfs::preview1::State::new( + self.world.root.clone(), + &request.cwd, + request.argv.clone(), + request.env.clone(), + stdio.input(), + Box::new(stdio.output.clone()), + Box::new(stdio.error.clone()), + )) + } else { + None + }; let mut mounts = Mounts::default(); mounts.mount("/", self.world.root.clone(), false); let mut wasi = WasiCtxBuilder::new(); wasi.args(&request.argv) .envs(&request.env) .initial_cwd(&request.cwd) - .stdin(AsyncStdinStream::new(child_input)) - .stdout(AsyncStdoutStream::new(8192, child_output)) - .stderr(AsyncStdoutStream::new(8192, child_error)); + .stdin(stdio.input.clone()) + .stdout(AsyncStdoutStream::new(8192, stdio.output.clone())) + .stderr(AsyncStdoutStream::new(8192, stdio.error.clone())) + .allow_tcp(false) + .allow_udp(false) + .allow_ip_name_lookup(false); let mut store = Store::new( &self.engine, State { @@ -254,6 +326,10 @@ impl Runtime { pid, command, group, + conversation: conversation.clone(), + stdio, + preview1, + http: wasmtime_wasi_http::WasiHttpCtx::new(), }, ); store.epoch_deadline_async_yield_and_update(1); @@ -264,6 +340,7 @@ impl Runtime { argv: request.argv, cwd: request.cwd, state: "running".into(), + conversation, }, ); let (tx, code) = watch::channel(None); @@ -281,8 +358,21 @@ impl Runtime { return; } let result = async { - let command = pre.instantiate_async(&mut store).await?; - match command.wasi_cli_run().call_run(&mut store).await { + let result = match pre { + Compiled::Component(pre) => { + let command = pre.instantiate_async(&mut store).await?; + command.wasi_cli_run().call_run(&mut store).await + } + Compiled::Module(pre) => { + let module = pre.instantiate_async(&mut store).await?; + module + .get_typed_func::<(), ()>(&mut store, "_start")? + .call_async(&mut store, ()) + .await + .map(|()| Ok(())) + } + }; + match result { Ok(Ok(())) => Ok(0), Ok(Err(())) => Ok(1), Err(e) => match e.downcast_ref::() { @@ -348,7 +438,14 @@ impl Host for State { ) -> wasmtime::Result, String>> { let child = match self .runtime - .start(request, self.pid, self.command, self.group) + .start( + request, + self.pid, + self.command, + self.group, + self.conversation.clone(), + Some(self.stdio.clone()), + ) .await { Ok(child) => child, @@ -358,9 +455,15 @@ impl Host for State { pid: child.pid, group: child.group, code: child.code, - input: Some(Box::new(AsyncWriteStream::new(8192, child.input))), - output: Some(Box::new(AsyncReadStream::new(child.output))), - error: Some(Box::new(AsyncReadStream::new(child.error))), + input: child + .input + .map(|s| Box::new(AsyncWriteStream::new(8192, s)) as DynOutputStream), + output: child + .output + .map(|s| Box::new(AsyncReadStream::new(s)) as DynInputStream), + error: child + .error + .map(|s| Box::new(AsyncReadStream::new(s)) as DynInputStream), })?)) } async fn kill(&mut self, pid: u32) -> wasmtime::Result> { @@ -463,10 +566,7 @@ impl Exec for Arc { ], Command::Argv(argv) => argv, }; - let mut env = options.env; - if !env.iter().any(|(k, _)| k == "PATH") { - env.push(("PATH".into(), "/bin".into())); - } + let env = options.env; let start = self.start( Request { program: argv.first().cloned().unwrap_or_default(), @@ -474,10 +574,15 @@ impl Exec for Arc { env, cwd: options.cwd, group: None, + stdin: Stdio::Pipe, + stdout: Stdio::Pipe, + stderr: Stdio::Pipe, }, 0, 0, 0, + options.conversation, + None, ); let mut child = if let Some(deadline) = deadline { tokio::time::timeout_at(deadline, start) @@ -491,13 +596,15 @@ impl Exec for Arc { group: child.pid, }; drop(child.input); + let mut stdout = child.output.take().unwrap(); + let mut stderr = child.error.take().unwrap(); let run = async { let (mut out_open, mut err_open) = (true, true); let (mut out, mut err) = ([0; 8192], [0; 8192]); while out_open || err_open { tokio::select! { - n = child.output.read(&mut out), if out_open => { let n = n?; out_open = n != 0; if n != 0 { output(Stream::Stdout, &out[..n]); } } - n = child.error.read(&mut err), if err_open => { let n = n?; err_open = n != 0; if n != 0 { output(Stream::Stderr, &err[..n]); } } + n = stdout.read(&mut out), if out_open => { let n = n?; out_open = n != 0; if n != 0 { output(Stream::Stdout, &out[..n]); } } + n = stderr.read(&mut err), if err_open => { let n = n?; err_open = n != 0; if n != 0 { output(Stream::Stderr, &err[..n]); } } } } loop { diff --git a/host/src/process/services.rs b/host/src/process/services.rs new file mode 100644 index 0000000..f61e065 --- /dev/null +++ b/host/src/process/services.rs @@ -0,0 +1,169 @@ +//! Supervises the world's services list, live edits, crash limits, and graceful stops. +use super::{GroupGuard, Request, Runtime, Started, Stdio}; +use serde::Deserialize; +use std::{ + collections::{BTreeMap, VecDeque}, + sync::Arc, + time::Duration, +}; +use tokio::time::Instant; + +#[derive(Clone, Deserialize, PartialEq, Eq)] +struct Spec { + name: String, + argv: Vec, + #[serde(default = "root")] + cwd: String, + #[serde(default)] + env: BTreeMap, +} +fn root() -> String { + "/".into() +} +struct Live { + child: Started, + _guard: GroupGuard, +} +struct Service { + spec: Spec, + live: Option, + crashes: VecDeque, + stopped: bool, +} +impl Service { + async fn start(&mut self, runtime: &Arc) { + match runtime + .start( + Request { + program: self.spec.argv.first().cloned().unwrap_or_default(), + argv: self.spec.argv.clone(), + cwd: self.spec.cwd.clone(), + env: self + .spec + .env + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + group: None, + stdin: Stdio::Pipe, + stdout: Stdio::Null, + stderr: Stdio::Null, + }, + 0, + 0, + 0, + None, + None, + ) + .await + { + Ok(child) => { + let guard = GroupGuard { + runtime: runtime.clone(), + group: child.pid, + }; + self.live = Some(Live { + child, + _guard: guard, + }); + } + Err(_) => self.crashed(), + } + } + fn crashed(&mut self) { + let now = Instant::now(); + while self + .crashes + .front() + .is_some_and(|t| now.duration_since(*t) >= Duration::from_secs(10)) + { + self.crashes.pop_front(); + } + self.crashes.push_back(now); + self.stopped = self.crashes.len() > 5; + } + async fn stop(&mut self) { + if let Some(mut live) = self.live.take() { + drop(live.child.input.take()); + let _ = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if live.child.code.borrow().is_some() { + break; + } + if live.child.code.changed().await.is_err() { + break; + } + } + }) + .await; + // The guard stops remaining descendants, or the process after its grace period. + } + } +} +/// Start the supervisor. Aborting its task stops its service processes. +/// The YAML file contains name, argv, optional cwd, and an optional map of environment variables. +pub fn supervise(runtime: Arc) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut services: BTreeMap = BTreeMap::new(); + let mut previous = Vec::new(); + let mut interval = tokio::time::interval(Duration::from_millis(100)); + loop { + interval.tick().await; + let content = match runtime + .world + .root + .open("/config/services.yaml", Default::default()) + .await + { + Ok(file) => match file.stat().await { + Ok(stat) => file.read(0, stat.size as usize).await.ok(), + Err(_) => None, + }, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(b"[]".to_vec()), + Err(_) => None, + }; + if let Some(content) = content.filter(|c| *c != previous) + && let Ok(specs) = serde_yaml_ng::from_slice::>(&content) + { + let specs: BTreeMap<_, _> = + specs.into_iter().map(|s| (s.name.clone(), s)).collect(); + let removed: Vec<_> = services + .keys() + .filter(|name| !specs.contains_key(*name)) + .cloned() + .collect(); + for name in removed { + services.remove(&name).unwrap().stop().await; + } + for (name, spec) in specs { + let service = services.entry(name).or_insert_with(|| Service { + spec: spec.clone(), + live: None, + crashes: VecDeque::new(), + stopped: false, + }); + if service.spec != spec { + service.stop().await; + } + service.spec = spec; + service.crashes.clear(); + service.stopped = false; + } + previous = content; + } + for service in services.values_mut() { + if service + .live + .as_ref() + .is_some_and(|live| live.child.code.borrow().is_some()) + { + service.live = None; + service.crashed(); + } + if service.live.is_none() && !service.stopped { + service.start(&runtime).await; + } + } + } + }) +} diff --git a/host/src/process/stdio.rs b/host/src/process/stdio.rs new file mode 100644 index 0000000..01e231b --- /dev/null +++ b/host/src/process/stdio.rs @@ -0,0 +1,73 @@ +//! Shares process stdio across stores without a parent-side IO pump. +use super::bindings::sausage::process::host::Stdio; +use std::{ + pin::Pin, + sync::{Arc, Mutex}, + task::{Context, Poll}, +}; +use tokio::io::AsyncWrite; +use wasmtime_wasi::cli::{AsyncStdinStream, StdinStream}; + +#[derive(Clone)] +pub(super) struct Io { + pub input: Arc, + pub output: Writer, + pub error: Writer, +} +impl Io { + pub fn input(&self) -> sausage_vfs::preview1::Input { + Box::new(Pin::from(self.input.async_stream())) + } +} +#[derive(Clone)] +pub(super) struct Writer(Arc>>); +impl Writer { + pub fn new(writer: impl AsyncWrite + Send + Sync + Unpin + 'static) -> Self { + Self(Arc::new(Mutex::new(Box::new(writer)))) + } +} +impl AsyncWrite for Writer { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + Pin::new(&mut **self.0.lock().unwrap()).poll_write(cx, bytes) + } + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut **self.0.lock().unwrap()).poll_flush(cx) + } + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + // Closing one inherited handle must not close the other handles. + self.poll_flush(cx) + } +} +pub(super) fn input( + mode: Stdio, + parent: Option<&Io>, +) -> (Option, Arc) { + match mode { + Stdio::Pipe => { + let (host, guest) = tokio::io::duplex(8192); + (Some(host), Arc::new(AsyncStdinStream::new(guest))) + } + Stdio::Inherit => ( + None, + parent.expect("inherit requires a parent").input.clone(), + ), + Stdio::Null => (None, Arc::new(AsyncStdinStream::new(tokio::io::empty()))), + } +} +pub(super) fn output( + mode: Stdio, + parent: Option<&Writer>, +) -> (Option, Writer) { + match mode { + Stdio::Pipe => { + let (host, guest) = tokio::io::duplex(8192); + (Some(host), Writer::new(guest)) + } + Stdio::Inherit => (None, parent.expect("inherit requires a parent").clone()), + Stdio::Null => (None, Writer::new(tokio::io::sink())), + } +} diff --git a/host/src/world.rs b/host/src/world.rs index f0423bb..0185b1f 100644 --- a/host/src/world.rs +++ b/host/src/world.rs @@ -22,9 +22,14 @@ pub struct World { impl World { /// Open (and create if missing) the world directory `dir`, including `/proc` and `/run` mounts. pub fn open(dir: &Path) -> anyhow::Result { + Self::open_with_userland(dir, None) + } + + /// Mount an optional host bundle read-only at `/bin` (D43). + pub fn open_with_userland(dir: &Path, userland: Option<&Path>) -> anyhow::Result { let root_dir = dir.join("world"); let host_dir = dir.join("host"); - std::fs::create_dir_all(&root_dir)?; + std::fs::create_dir_all(root_dir.join("usr/local/bin"))?; std::fs::create_dir_all(&host_dir)?; let mut mounts = Mounts::default(); mounts.mount( @@ -37,18 +42,26 @@ impl World { let snapshot = processes.clone(); let mut proc = Generated::default(); proc.insert("/processes", move || { - let mut text = String::from("PID\tPPID\tSTATE\tCWD\tARGV\n"); + let mut text = String::from("PID\tPPID\tSTATE\tCWD\tCONVERSATION\tARGV\n"); for (pid, info) in snapshot.lock().unwrap().iter() { text.push_str(&format!( - "{pid}\t{}\t{}\t{}\t{}\n", + "{pid}\t{}\t{}\t{}\t{}\t{}\n", info.parent, info.state, info.cwd, + info.conversation.as_deref().unwrap_or("-"), info.argv.join(" ") )); } Ok(text.into_bytes()) }); + if let Some(bundle) = userland { + mounts.mount( + "/bin", + Arc::new(Host::new(bundle, Process::default())?), + true, + ); + } mounts.mount("/proc", Arc::new(proc), true); mounts.mount("/run", Arc::new(Memory::default()), false); Ok(World { diff --git a/host/tests/process.rs b/host/tests/process.rs index 577ab49..3332f75 100644 --- a/host/tests/process.rs +++ b/host/tests/process.rs @@ -9,6 +9,363 @@ use std::{ sync::{Arc, Once}, time::Duration, }; +mod features { + //! Acceptance coverage for preview1, identity, inherited IO, services, HTTP, and bundle aliases. + use super::*; + use std::{collections::BTreeMap, time::Instant}; + + async fn poll(mut condition: impl FnMut() -> bool) { + tokio::time::timeout(Duration::from_secs(10), async { + while !condition() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + } + fn lines(path: &Path) -> Vec { + std::fs::read_to_string(path) + .unwrap_or_default() + .lines() + .map(str::to_owned) + .collect() + } + async fn prepare(runtime: &Arc, argv: Vec) { + assert_eq!( + runtime + .exec( + Command::Argv(argv), + Options { + cwd: "/".into(), + ..Default::default() + }, + &mut |_, _| {} + ) + .await + .unwrap(), + 0 + ); + } + #[tokio::test(flavor = "multi_thread")] + async fn core_module_exit_and_cancellation() { + let _serial = SERIAL.lock().await; + let (temp, runtime, processes) = world(); + let bin = temp.path().join("world/bin"); + std::fs::write(bin.join("exit23"), wat::parse_str(r#"(module (import "wasi_snapshot_preview1" "proc_exit" (func (param i32))) (memory (export "memory") 1) (func (export "_start") i32.const 23 call 0))"#).unwrap()).unwrap(); + assert_eq!( + run(&runtime, Command::Argv(vec!["/bin/exit23".into()]), "/") + .await + .0, + 23 + ); + std::fs::write( + bin.join("spin"), + wat::parse_str( + r#"(module (memory (export "memory") 1) (func (export "_start") (loop br 0)))"#, + ) + .unwrap(), + ) + .unwrap(); + let result = runtime + .exec( + Command::Argv(vec!["/bin/spin".into()]), + Options { + timeout: Some(Duration::from_millis(100)), + ..Default::default() + }, + &mut |_, _| {}, + ) + .await; + assert!(result.unwrap_err().to_string().contains("timed out")); + empty(&processes).await; + } + #[tokio::test(flavor = "multi_thread")] + async fn preview1_pipeline_and_esbuild() { + let _serial = SERIAL.lock().await; + let (temp, runtime, _) = world(); + warm(&runtime).await; + let root = temp.path().join("world"); + std::fs::copy("/tmp/wasi-userland-lab/qjs-wasi.wasm", root.join("bin/qjs")).unwrap(); + std::fs::copy("/tmp/s6-esbuild/esbuild.wasm", root.join("bin/esbuild")).unwrap(); + prepare(&runtime, vec!["/bin/qjs".into(), "--help".into()]).await; + let direct = run( + &runtime, + Command::Argv(vec![ + "/bin/qjs".into(), + "--std".into(), + "-e".into(), + "console.log(42)".into(), + ]), + "/", + ) + .await; + assert_eq!(direct.0, 0, "{}", String::from_utf8_lossy(&direct.2)); + assert_eq!(direct.1, b"42\n"); + let (code, out, err) = run( + &runtime, + Command::Shell( + "echo 'console.log(6*7)' | qjs --std -e 'std.evalScript(std.in.readAsString())' | cat" + .into(), + ), + "/", + ) + .await; + assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err)); + assert_eq!(out, b"42\n"); + assert_eq!( + run( + &runtime, + Command::Shell("qjs --std -e 'std.exit(7)'".into()), + "/" + ) + .await + .0, + 7 + ); + for name in ["app.tsx", "package.json", "package-lock.json"] { + std::fs::copy(repo().join("spikes/s6-esbuild").join(name), root.join(name)).unwrap(); + } + assert!( + std::process::Command::new("cp") + .args(["-r", "/tmp/s6-esbuild/app/node_modules"]) + .arg(&root) + .status() + .unwrap() + .success() + ); + prepare(&runtime, vec!["/bin/esbuild".into(), "--version".into()]).await; + let flags = [ + "app.tsx", + "--bundle", + "--minify", + "--format=iife", + "--platform=browser", + "--jsx=automatic", + "--jsx-import-source=preact", + "--legal-comments=none", + "--log-level=error", + ]; + assert!( + std::process::Command::new("/tmp/s6-esbuild/esbuild-native") + .current_dir(&root) + .args(flags) + .arg("--outfile=native.js") + .status() + .unwrap() + .success() + ); + let script = format!("esbuild {} --outfile=module.js", flags.join(" ")); + let (code, _, err) = run(&runtime, Command::Shell(script), "/").await; + assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err)); + assert_eq!( + std::fs::read(root.join("native.js")).unwrap(), + std::fs::read(root.join("module.js")).unwrap() + ); + } + #[tokio::test(flavor = "multi_thread")] + async fn inherited_stdio_identity_and_conversation() { + let _serial = SERIAL.lock().await; + let (_temp, runtime, processes) = world(); + warm(&runtime).await; + let (code, out, err) = run( + &runtime, + Command::Shell( + "echo inherited | /bin/launcher inherit /bin/launcher read | cat".into(), + ), + "/", + ) + .await; + assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err)); + assert_eq!(out, b"inherited\n"); + let (code, out, err) = run( + &runtime, + Command::Shell("/bin/launcher inherit /bin/launcher emit".into()), + "/", + ) + .await; + assert_eq!(code, 0); + assert_eq!(out, b"out\n"); + assert_eq!(err, b"err\n"); + let (code, out, err) = run( + &runtime, + Command::Shell("echo unused | /bin/launcher null /bin/launcher check-null".into()), + "/", + ) + .await; + assert_eq!(code, 0); + assert!(out.is_empty()); + assert!(err.is_empty()); + assert_eq!( + run( + &runtime, + Command::Argv(vec!["/bin/launcher".into(), "path".into()]), + "/" + ) + .await + .1, + b"/usr/local/bin:/bin\n" + ); + let mut out = Vec::new(); + let code = runtime + .exec( + Command::Argv(vec![ + "/bin/launcher".into(), + "identity".into(), + "/bin/launcher".into(), + ]), + Options { + cwd: "/".into(), + env: vec![("SAUSAGE_PID".into(), "spoof".into())], + ..Default::default() + }, + &mut |_, bytes| out.extend_from_slice(bytes), + ) + .await + .unwrap(); + assert_eq!(code, 0); + let text = String::from_utf8(out).unwrap(); + let ids: BTreeMap<_, _> = text + .lines() + .map(|line| { + let (name, value) = line.split_once('=').unwrap(); + (name, value.parse::().unwrap()) + }) + .collect(); + assert_eq!(ids["pid"], ids["child"]); + assert_ne!(ids["pid"], ids["parent"]); + let exec = runtime.clone(); + let task = tokio::spawn(async move { + exec.exec( + Command::Shell("sleep 60 | cat".into()), + Options { + conversation: Some("c1".into()), + ..Default::default() + }, + &mut |_, _| {}, + ) + .await + }); + poll(|| processes.lock().unwrap().len() >= 3).await; + assert!( + processes + .lock() + .unwrap() + .values() + .all(|p| p.conversation.as_deref() == Some("c1")) + ); + let (_, out, _) = run(&runtime, Command::Shell("cat /proc/processes".into()), "/").await; + assert!(String::from_utf8(out).unwrap().matches("\tc1\t").count() >= 3); + task.abort(); + let _ = task.await; + empty(&processes).await; + } + #[tokio::test(flavor = "multi_thread")] + async fn relative_aliases_share_compilation() { + let _serial = SERIAL.lock().await; + let (temp, runtime, _) = world(); + let bin = temp.path().join("world/bin"); + for name in ["true", "echo"] { + std::os::unix::fs::symlink("cat", bin.join(name)).unwrap(); + } + assert_eq!( + run(&runtime, Command::Argv(vec!["/bin/true".into()]), "/") + .await + .0, + 0 + ); + assert_eq!(runtime.compiled_programs(), 1); + let (code, out, _) = run( + &runtime, + Command::Argv(vec!["/bin/echo".into(), "shared".into()]), + "/", + ) + .await; + assert_eq!(code, 0); + assert_eq!(out, b"shared\n"); + assert_eq!(runtime.compiled_programs(), 1); + } + #[tokio::test(flavor = "multi_thread")] + async fn services_restart_edit_limit_and_eof() { + let _serial = SERIAL.lock().await; + let (temp, runtime, processes) = world(); + prepare(&runtime, vec!["/bin/launcher".into()]).await; + let root = temp.path().join("world"); + std::fs::create_dir_all(root.join("config")).unwrap(); + let spec = |name: &str, mode: &str, log: &str| serde_json::json!({"name": name, "argv": ["/bin/launcher", mode, log]}); + let write = |specs: Vec| { + std::fs::write( + root.join("config/services.yaml"), + serde_yaml_ng::to_string(&specs).unwrap(), + ) + .unwrap() + }; + write(vec![ + spec("a", "server", "/a.log"), + spec("crash", "crash", "/crash.log"), + ]); + let supervisor = sausage::process::supervise(runtime.clone()); + poll(|| { + lines(&root.join("a.log")) == ["start"] && lines(&root.join("crash.log")).len() == 6 + }) + .await; + prepare(&runtime, vec!["/bin/launcher".into()]).await; + assert_eq!(lines(&root.join("crash.log")).len(), 6); + assert!( + processes + .lock() + .unwrap() + .values() + .all(|p| p.conversation.is_none()) + ); + write(vec![spec("b", "server", "/b.log")]); + poll(|| { + lines(&root.join("a.log")) == ["start", "stop"] + && lines(&root.join("b.log")) == ["start"] + }) + .await; + let mut changed = spec("b", "server", "/b.log"); + changed["env"] = serde_json::json!({"REV": "new"}); + write(vec![changed]); + poll(|| lines(&root.join("b.log")) == ["start", "stop", "start"]).await; + write(vec![]); + poll(|| lines(&root.join("b.log")) == ["start", "stop", "start", "stop"]).await; + write(vec![spec("linger", "linger", "/linger.log")]); + poll(|| lines(&root.join("linger.log")) == ["start"]).await; + let stop = Instant::now(); + write(vec![]); + poll(|| processes.lock().unwrap().is_empty()).await; + assert!(stop.elapsed() >= Duration::from_secs(5)); + supervisor.abort(); + let _ = supervisor.await; + empty(&processes).await; + } + #[tokio::test(flavor = "multi_thread")] + async fn git_local_and_anonymous_https_clone() { + let _serial = SERIAL.lock().await; + let (temp, runtime, _) = world(); + warm(&runtime).await; + prepare(&runtime, vec!["/bin/git".into(), "--help".into()]).await; + let mut out = Vec::new(); + let mut err = Vec::new(); + let code = runtime.exec(Command::Shell("git init /repo && cd /repo && echo hello > file && git add file && git commit -m 'process test' && git log --oneline".into()), + Options { cwd: "/".into(), env: vec![("GIT_AUTHOR_NAME".into(), "Test".into()), ("GIT_AUTHOR_EMAIL".into(), "test@example.com".into()), ("GIT_COMMITTER_NAME".into(), "Test".into()), ("GIT_COMMITTER_EMAIL".into(), "test@example.com".into())], ..Default::default() }, + &mut |stream, bytes| match stream { Stream::Stdout => out.extend_from_slice(bytes), Stream::Stderr => err.extend_from_slice(bytes) }).await.unwrap(); + assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err)); + assert!(String::from_utf8(out).unwrap().contains("process test")); + let (code, _, err) = run( + &runtime, + Command::Shell("git clone https://github.com/octocat/Hello-World.git /clone".into()), + "/", + ) + .await; + assert_eq!(code, 0, "{}", String::from_utf8_lossy(&err)); + assert!( + std::fs::read_to_string(temp.path().join("world/clone/README")) + .unwrap() + .contains("Hello World") + ); + } +} static BUILD: Once = Once::new(); // Share the persistent compile cache without concurrent cold compilation of identical components. static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); @@ -57,6 +414,32 @@ fn build() { &["nu", "--no-config-file", "userland/brush/build.nu"], &root, ); + run( + &[ + "cargo", + "build", + "--release", + "--target", + "wasm32-wasip2", + "--manifest-path", + "brush/probe/Cargo.toml", + "--locked", + ], + &root.join("userland"), + ); + run( + &[ + "cargo", + "build", + "--release", + "-p", + "git", + "--target-dir", + "../target/process-userland", + "--locked", + ], + &root.join("userland"), + ); }); } fn fixture(root: &Path) { @@ -98,6 +481,16 @@ fn world() -> (tempfile::TempDir, Arc, sausage::process::Processes) { bin.join("sh"), ) .unwrap(); + std::fs::copy( + repo().join("userland/brush/probe/target/wasm32-wasip2/release/process-probe.wasm"), + bin.join("launcher"), + ) + .unwrap(); + std::fs::copy( + repo().join("target/process-userland/wasm32-wasip2/release/git.wasm"), + bin.join("git"), + ) + .unwrap(); (temp, Runtime::new(world).unwrap(), processes) } async fn run(runtime: &Arc, command: Command, cwd: &str) -> (i32, Vec, Vec) { diff --git a/host/wit/process.wit b/host/wit/process.wit index 754162f..c38a856 100644 --- a/host/wit/process.wit +++ b/host/wit/process.wit @@ -3,6 +3,7 @@ package sausage:process@0.1.0; /// Programs share one world. These resources provide lifecycle and bounded stdio transport. interface host { use wasi:io/streams@0.2.12.{input-stream, output-stream}; + enum stdio { pipe, inherit, null } record request { program: string, argv: list, @@ -10,6 +11,9 @@ interface host { cwd: string, /// None inherits the parent's group. Zero creates a group. A nonzero ID joins a group. group: option, + stdin: stdio, + stdout: stdio, + stderr: stdio, } resource child { pid: func() -> u32; diff --git a/userland/brush/probe/Cargo.lock b/userland/brush/probe/Cargo.lock new file mode 100644 index 0000000..686bccd --- /dev/null +++ b/userland/brush/probe/Cargo.lock @@ -0,0 +1,444 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "process-probe" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasm-encoder" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be00faa2b4950c76fe618c409d2c3ea5a3c9422013e079482d78544bb2d184c" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20b3ec880a9ac69ccd92fbdbcf46ee833071cf09f82bb005b2327c7ae6025ae2" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9d90bb93e764f6beabf1d02028c70a2156a6583e63ac4218dd07ef733368b0" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +dependencies = [ + "bitflags", + "futures", + "once_cell", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cabd629f94da277abc739c71353397046401518efb2c707669f805205f0b9890" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a4232e841089fa5f3c4fc732a92e1c74e1a3958db3b12f1de5934da2027f1f4" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0d4698c2913d8d9c2b220d116409c3f51a7aa8d7765151b886918367179ee9" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a866b19dba2c94d706ec58c92a4c62ab63e482b4c935d2a085ac94caecb136" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.239.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55c92c939d667b7bf0c6bf2d1f67196529758f99a2a45a3355cc56964fd5315d" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/userland/brush/probe/Cargo.toml b/userland/brush/probe/Cargo.toml new file mode 100644 index 0000000..f5beaea --- /dev/null +++ b/userland/brush/probe/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "process-probe" +version = "0.1.0" +edition = "2024" +[workspace] +[dependencies] +wit-bindgen = "0.46" diff --git a/userland/brush/probe/src/main.rs b/userland/brush/probe/src/main.rs new file mode 100644 index 0000000..3c8ece2 --- /dev/null +++ b/userland/brush/probe/src/main.rs @@ -0,0 +1,74 @@ +//! Exercises inherited stdio and the service lifecycle through ordinary guest APIs. +use std::io::{Read, Write}; +mod bindings { + wit_bindgen::generate!({ path: "../../../host/wit", world: "program", generate_all }); +} +use bindings::sausage::process::host::{self, Request, Stdio}; +fn mark(path: &str, text: &str) { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(file, "{text}").unwrap(); +} +fn main() { + let args: Vec<_> = std::env::args().collect(); + match args.get(1).map(String::as_str) { + Some(mode @ ("inherit" | "identity" | "null")) => { + let io = if mode == "null" { + Stdio::Null + } else { + Stdio::Inherit + }; + let child = host::spawn(&Request { + program: args[2].clone(), + argv: args[2..].to_vec(), + cwd: std::env::current_dir().unwrap().to_str().unwrap().into(), + env: std::env::vars().collect(), + group: None, + stdin: io, + stdout: io, + stderr: io, + }) + .unwrap(); + if mode == "identity" { + println!("parent={}", std::env::var("SAUSAGE_PID").unwrap()); + println!("child={}", child.pid()); + } + std::process::exit(child.wait()); + } + Some("server") => { + mark(&args[2], "start"); + let mut input = Vec::new(); + std::io::stdin().read_to_end(&mut input).unwrap(); + mark(&args[2], "stop"); + } + Some("linger") => { + mark(&args[2], "start"); + std::thread::sleep(std::time::Duration::from_secs(60)); + } + Some("crash") => { + mark(&args[2], "crash"); + std::process::exit(1); + } + Some("emit") => { + println!("out"); + eprintln!("err"); + } + Some("check-null") => { + let mut input = Vec::new(); + std::io::stdin().read_to_end(&mut input).unwrap(); + assert!(input.is_empty()); + println!("out"); + eprintln!("err"); + } + Some("path") => println!("{}", std::env::var("PATH").unwrap()), + Some("read") => { + let mut input = Vec::new(); + std::io::stdin().read_to_end(&mut input).unwrap(); + std::io::stdout().write_all(&input).unwrap(); + } + _ => println!("pid={}", std::env::var("SAUSAGE_PID").unwrap()), + } +} diff --git a/userland/brush/process.rs b/userland/brush/process.rs index c3f1e23..9050413 100644 --- a/userland/brush/process.rs +++ b/userland/brush/process.rs @@ -38,6 +38,9 @@ pub(crate) fn spawn(mut request: LaunchRequest) -> std::io::Result crate::processes::ProcessGroup::Inherit => None, _ => Some(0), }, + stdin: host::Stdio::Pipe, + stdout: host::Stdio::Pipe, + stderr: host::Stdio::Pipe, }) .map_err(std::io::Error::other)?; let pid = child.pid() as i32; diff --git a/userland/procps/tools.txt b/userland/procps/tools.txt new file mode 100644 index 0000000..a76ff1d --- /dev/null +++ b/userland/procps/tools.txt @@ -0,0 +1,2 @@ +ps +kill