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).
This commit is contained in:
2026-11-06 17:21:51 +00:00
parent afff3ccda5
commit 665b8a2a03
13 changed files with 1385 additions and 41 deletions
+145 -38
View File
@@ -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<State>),
Module(wasmtime::InstancePre<State>),
}
/// A finite snapshot of process metadata exposed through /proc/processes.
#[derive(Clone, Debug)]
@@ -53,6 +63,7 @@ pub struct ProcessInfo {
pub argv: Vec<String>,
pub cwd: String,
pub state: String,
pub conversation: Option<String>,
}
pub type Processes = Arc<Mutex<std::collections::BTreeMap<u32, ProcessInfo>>>;
@@ -66,7 +77,7 @@ struct Entry {
pub struct Runtime {
world: World,
engine: Engine,
cache: Mutex<HashMap<Vec<u8>, CommandPre<State>>>,
cache: Mutex<HashMap<Vec<u8>, Compiled>>,
entries: Mutex<HashMap<u32, Entry>>,
next: AtomicU32,
alive: Arc<AtomicBool>,
@@ -84,6 +95,19 @@ struct State {
pid: u32,
command: u32,
group: u32,
conversation: Option<String>,
stdio: Io,
preview1: Option<sausage_vfs::preview1::State>,
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<Option<i32>>,
input: DuplexStream,
output: DuplexStream,
error: DuplexStream,
input: Option<DuplexStream>,
output: Option<DuplexStream>,
error: Option<DuplexStream>,
}
impl Runtime {
pub fn open(path: &Path) -> anyhow::Result<Arc<Self>> {
Self::new(World::open(path)?)
}
pub fn new(world: World) -> anyhow::Result<Arc<Self>> {
// 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<String>,
inherited_io: Option<Io>,
) -> anyhow::Result<Started> {
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::<wasmtime_wasi::I32Exit>() {
@@ -348,7 +438,14 @@ impl Host for State {
) -> wasmtime::Result<Result<Resource<Child>, 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<Result<(), String>> {
@@ -463,10 +566,7 @@ impl Exec for Arc<Runtime> {
],
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<Runtime> {
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<Runtime> {
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 {