host: process table, shell, coreutils and harness (T8, T9)

T8: sausage:process WIT (spawn, wait, kill), one store per process over the
shared root, /proc/processes, /run, pipeline and cancellation groups, Exec.
brush and 78 uutils commands; the shared cwd-init object sets the cwd from PWD
and keeps numeric exit codes. The corpus matches native bash.

T9: V8 (deno_core) runs pi-durable, pi-ai and chord; SQLite storage in
<dir>/host/harness.sqlite (WAL, synchronous=FULL); file tools on the VFS, bash
through Exec; resumes after SIGKILL.
This commit is contained in:
2026-11-06 16:34:34 +00:00
parent 5ce2a2f2bd
commit 57a47b069e
41 changed files with 87601 additions and 82 deletions
+546 -6
View File
@@ -1,8 +1,548 @@
//! The process table: WASI programs in their own stores, the process interface (spawn, wait, kill),
//! `/proc`, and the `Exec` implementation. Owned by task T8.
use std::path::Path;
//! Runs components in independent stores over the world root. Owns process groups, stdio, and Exec.
use crate::{
exec::{Command, Exec, Options, Stream},
world::World,
};
use sausage_vfs::{
Mounts,
adapter::{self, Adapter, View},
};
use std::{
collections::HashMap,
path::Path,
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicU32, Ordering},
},
};
use tokio::{
io::{AsyncReadExt, DuplexStream},
sync::watch,
task::AbortHandle,
};
use wasmtime::{
Engine, Store,
component::{Component, Linker, Resource, ResourceTable},
};
use wasmtime_wasi::{
WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView,
cli::{AsyncStdinStream, AsyncStdoutStream},
p2::{
DynInputStream, DynOutputStream,
bindings::CommandPre,
pipe::{AsyncReadStream, AsyncWriteStream},
},
};
/// Run a shell in the world at `world`, or one command with `command`. Returns the exit status.
pub async fn sh(_world: &Path, _command: Option<String>) -> anyhow::Result<i32> {
anyhow::bail!("the process table does not exist yet (T8)")
mod bindings {
wasmtime::component::bindgen!({
path: "wit", world: "program",
imports: { default: async | trappable },
with: {
"wasi:io": wasmtime_wasi::p2::bindings::io,
"sausage:process/host.child": super::Child,
},
});
}
use bindings::sausage::process::host::{Host, HostChild, Request};
/// A finite snapshot of process metadata exposed through /proc/processes.
#[derive(Clone, Debug)]
pub struct ProcessInfo {
pub parent: u32,
pub argv: Vec<String>,
pub cwd: String,
pub state: String,
}
pub type Processes = Arc<Mutex<std::collections::BTreeMap<u32, ProcessInfo>>>;
struct Entry {
command: u32,
group: u32,
cancelled: Arc<AtomicBool>,
abort: AbortHandle,
}
/// One runtime keeps compiled components warm and shares the world's namespace across stores.
pub struct Runtime {
world: World,
engine: Engine,
cache: Mutex<HashMap<Vec<u8>, CommandPre<State>>>,
entries: Mutex<HashMap<u32, Entry>>,
next: AtomicU32,
alive: Arc<AtomicBool>,
}
impl Drop for Runtime {
fn drop(&mut self) {
self.alive.store(false, Ordering::Relaxed);
}
}
struct State {
wasi: WasiCtx,
table: ResourceTable,
fs: Adapter,
runtime: Arc<Runtime>,
pid: u32,
command: u32,
group: u32,
}
impl WasiView for State {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.wasi,
table: &mut self.table,
}
}
}
pub struct Child {
pid: u32,
group: u32,
code: watch::Receiver<Option<i32>>,
input: Option<DynOutputStream>,
output: Option<DynInputStream>,
error: Option<DynInputStream>,
}
struct Started {
pid: u32,
group: u32,
code: watch::Receiver<Option<i32>>,
input: DuplexStream,
output: DuplexStream,
error: 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>> {
let mut config = wasmtime::Config::new();
config.epoch_interruption(true);
let mut cache = wasmtime::CacheConfig::new();
cache.with_directory(world.host_dir.join("compile-cache"));
config.cache(Some(wasmtime::Cache::new(cache)?));
let engine = Engine::new(&config)?;
let alive = Arc::new(AtomicBool::new(true));
let ticking = alive.clone();
let clock = engine.clone();
std::thread::spawn(move || {
while ticking.load(Ordering::Relaxed) {
std::thread::sleep(std::time::Duration::from_millis(10));
clock.increment_epoch();
}
});
Ok(Arc::new(Self {
world,
engine,
alive,
cache: Mutex::new(HashMap::new()),
entries: Mutex::new(HashMap::new()),
next: AtomicU32::new(1),
}))
}
fn kill_command(&self, command: u32) {
for entry in self.entries.lock().unwrap().values() {
if entry.command == command {
entry.cancelled.store(true, Ordering::Relaxed);
entry.abort.abort();
}
}
}
fn kill_group(&self, group: u32) {
for entry in self.entries.lock().unwrap().values() {
if entry.group == group {
entry.abort.abort();
}
}
}
fn kill_pid(&self, pid: u32) -> anyhow::Result<()> {
let group = self
.entries
.lock()
.unwrap()
.get(&pid)
.map(|e| e.group)
.ok_or_else(|| anyhow::anyhow!("no live process {pid}"))?;
self.kill_group(group);
Ok(())
}
async fn start(
self: &Arc<Self>,
mut request: Request,
parent: u32,
command: u32,
inherited_group: u32,
) -> anyhow::Result<Started> {
anyhow::ensure!(!request.argv.is_empty(), "empty argv");
let cancelled = if parent == 0 {
Arc::new(AtomicBool::new(false))
} else {
self.entries
.lock()
.unwrap()
.get(&parent)
.map(|entry| entry.cancelled.clone())
.ok_or_else(|| anyhow::anyhow!("parent exited"))?
};
request.cwd = sausage_vfs::resolve("/", &request.cwd);
request.env.retain(|(name, _)| name != "PWD");
request.env.push(("PWD".into(), request.cwd.clone()));
let executable = sausage_vfs::resolve(&request.cwd, &request.program);
let file = self
.world
.root
.open(
&executable,
sausage_vfs::Open {
follow: true,
..Default::default()
},
)
.await?;
let bytes = file
.read(0, usize::try_from(file.stat().await?.size)?)
.await?;
let pre = {
let mut cache = self.cache.lock().unwrap();
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)?)?;
cache.insert(bytes, pre.clone());
pre
}
};
let pid = self.next.fetch_add(1, Ordering::Relaxed);
let command = if command == 0 { pid } else { command };
let group = match request.group {
Some(0) => pid,
Some(group) => group,
None if inherited_group != 0 => inherited_group,
None => pid,
};
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 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));
let mut store = Store::new(
&self.engine,
State {
wasi: wasi.build(),
table: ResourceTable::new(),
fs: Adapter::new(mounts),
runtime: self.clone(),
pid,
command,
group,
},
);
store.epoch_deadline_async_yield_and_update(1);
self.world.processes.lock().unwrap().insert(
pid,
ProcessInfo {
parent,
argv: request.argv,
cwd: request.cwd,
state: "running".into(),
},
);
let (tx, code) = watch::channel(None);
// Capture cleanup before the first poll, so an early abort still removes the entry.
let cleanup = Cleanup {
runtime: self.clone(),
pid,
tx,
};
// The start gate makes the entry visible before a guest can spawn children or exit.
let (gate, ready) = tokio::sync::oneshot::channel();
let task = tokio::spawn(async move {
let _cleanup = cleanup;
if ready.await.is_err() {
return;
}
let result = async {
let command = pre.instantiate_async(&mut store).await?;
match command.wasi_cli_run().call_run(&mut store).await {
Ok(Ok(())) => Ok(0),
Ok(Err(())) => Ok(1),
Err(e) => match e.downcast_ref::<wasmtime_wasi::I32Exit>() {
Some(exit) => Ok(exit.0),
None => Err(e),
},
}
}
.await;
_cleanup.tx.send_replace(Some(result.unwrap_or(125)));
});
{
let mut entries = self.entries.lock().unwrap();
entries.insert(
pid,
Entry {
command,
group,
cancelled: cancelled.clone(),
abort: task.abort_handle(),
},
);
// A spawn already in progress must observe a command-wide cancellation.
if cancelled.load(Ordering::Relaxed) {
task.abort();
} else {
let _ = gate.send(());
}
}
Ok(Started {
pid,
group,
code,
input,
output,
error,
})
}
}
struct Cleanup {
runtime: Arc<Runtime>,
pid: u32,
tx: watch::Sender<Option<i32>>,
}
impl Drop for Cleanup {
fn drop(&mut self) {
if self.tx.borrow().is_none() {
self.tx.send_replace(Some(137));
}
self.runtime.entries.lock().unwrap().remove(&self.pid);
self.runtime
.world
.processes
.lock()
.unwrap()
.remove(&self.pid);
}
}
impl Host for State {
async fn spawn(
&mut self,
request: Request,
) -> wasmtime::Result<Result<Resource<Child>, String>> {
let child = match self
.runtime
.start(request, self.pid, self.command, self.group)
.await
{
Ok(child) => child,
Err(e) => return Ok(Err(e.to_string())),
};
Ok(Ok(self.table.push(Child {
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))),
})?))
}
async fn kill(&mut self, pid: u32) -> wasmtime::Result<Result<(), String>> {
Ok(self.runtime.kill_pid(pid).map_err(|e| e.to_string()))
}
}
impl HostChild for State {
async fn pid(&mut self, child: Resource<Child>) -> wasmtime::Result<u32> {
Ok(self.table.get(&child)?.pid)
}
async fn group(&mut self, child: Resource<Child>) -> wasmtime::Result<u32> {
Ok(self.table.get(&child)?.group)
}
async fn stdin(
&mut self,
child: Resource<Child>,
) -> wasmtime::Result<Resource<DynOutputStream>> {
let stream = self
.table
.get_mut(&child)?
.input
.take()
.ok_or_else(|| wasmtime::format_err!("stdin already taken"))?;
Ok(self.table.push(stream)?)
}
async fn stdout(
&mut self,
child: Resource<Child>,
) -> wasmtime::Result<Resource<DynInputStream>> {
let stream = self
.table
.get_mut(&child)?
.output
.take()
.ok_or_else(|| wasmtime::format_err!("stdout already taken"))?;
Ok(self.table.push(stream)?)
}
async fn stderr(
&mut self,
child: Resource<Child>,
) -> wasmtime::Result<Resource<DynInputStream>> {
let stream = self
.table
.get_mut(&child)?
.error
.take()
.ok_or_else(|| wasmtime::format_err!("stderr already taken"))?;
Ok(self.table.push(stream)?)
}
async fn status(&mut self, child: Resource<Child>) -> wasmtime::Result<Option<i32>> {
Ok(*self.table.get(&child)?.code.borrow())
}
async fn wait(&mut self, child: Resource<Child>) -> wasmtime::Result<i32> {
let mut code = self.table.get(&child)?.code.clone();
loop {
if let Some(code) = *code.borrow_and_update() {
return Ok(code);
}
code.changed().await?;
}
}
async fn kill(&mut self, child: Resource<Child>) -> wasmtime::Result<()> {
self.runtime
.kill_pid(self.table.get(&child)?.pid)
.map_err(|e| wasmtime::format_err!("{e}"))
}
async fn drop(&mut self, child: Resource<Child>) -> wasmtime::Result<()> {
self.table.delete(child)?;
Ok(())
}
}
struct GroupGuard {
runtime: Arc<Runtime>,
group: u32,
}
impl Drop for GroupGuard {
fn drop(&mut self) {
self.runtime.kill_command(self.group);
}
}
/// An Arc runtime implements Exec so the harness can retain one warm process table.
#[async_trait::async_trait]
impl Exec for Arc<Runtime> {
async fn exec(
&self,
command: Command,
options: Options,
output: &mut (dyn for<'chunk> FnMut(Stream, &'chunk [u8]) + Send),
) -> anyhow::Result<i32> {
let deadline = options
.timeout
.map(|duration| tokio::time::Instant::now() + duration);
let argv = match command {
Command::Shell(script) => vec![
"/bin/sh".into(),
"--noprofile".into(),
"--norc".into(),
"-c".into(),
script,
],
Command::Argv(argv) => argv,
};
let mut env = options.env;
if !env.iter().any(|(k, _)| k == "PATH") {
env.push(("PATH".into(), "/bin".into()));
}
let start = self.start(
Request {
program: argv.first().cloned().unwrap_or_default(),
argv,
env,
cwd: options.cwd,
group: None,
},
0,
0,
0,
);
let mut child = if let Some(deadline) = deadline {
tokio::time::timeout_at(deadline, start)
.await
.map_err(|_| anyhow::anyhow!("command timed out"))??
} else {
start.await?
};
let _guard = GroupGuard {
runtime: self.clone(),
group: child.pid,
};
drop(child.input);
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]); } }
}
}
loop {
if let Some(code) = *child.code.borrow_and_update() {
return Ok(code);
}
child.code.changed().await?;
}
};
if let Some(deadline) = deadline {
tokio::time::timeout_at(deadline, run)
.await
.map_err(|_| anyhow::anyhow!("command timed out"))?
} else {
run.await
}
}
}
/// Construct the harness's shared executor over the same open world.
pub fn exec(world: World) -> anyhow::Result<Arc<dyn Exec>> {
Ok(Arc::new(Runtime::new(world)?))
}
/// Run the world's shell. Noninteractive shell scripts stream directly to host stdio.
pub async fn sh(world: &Path, command: Option<String>) -> anyhow::Result<i32> {
let runtime = Runtime::open(world)?;
let command = command.ok_or_else(|| anyhow::anyhow!("interactive shell is not implemented"))?;
runtime
.exec(
Command::Shell(command),
Options {
cwd: "/".into(),
..Options::default()
},
&mut |stream, bytes| {
use std::io::Write;
match stream {
Stream::Stdout => {
let _ = std::io::stdout().write_all(bytes);
}
Stream::Stderr => {
let _ = std::io::stderr().write_all(bytes);
}
}
},
)
.await
}