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
Generated
+4559 -65
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -14,3 +14,21 @@ async-trait = "0.1"
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
sausage-vfs = { version = "0.1.0", path = "../vfs" }
deno_core = "=0.413.0"
deno_web = "=0.291.0"
deno_webidl = "=0.260.0"
deno_crypto = "=0.274.0"
deno_fetch = "=0.284.0"
deno_net = "=0.252.0"
deno_telemetry = "=0.82.0"
deno_permissions = "=0.119.0"
deno_error = "=0.7.1"
serde_json = "1"
deno_ast = { version = "=0.53.3", features = ["transpiling"] }
rusqlite = { version = "0.38", features = ["bundled"] }
sys_traits = { version = "0.1", features = ["real", "libc"] }
wasmtime = "49"
wasmtime-wasi = "49"
[dev-dependencies]
tempfile = "3"
+1
View File
@@ -0,0 +1 @@
node_modules/
+32
View File
@@ -0,0 +1,32 @@
# Host JavaScript
The console uses pi-durable in V8. Rust owns the SQLite connection.
The console runs V8 on a dedicated current-thread Tokio runtime.
This satisfies Deno's runtime requirement without a change to the CLI runtime.
The database lives at `<dir>/host/harness.sqlite`, outside the world.
WAL preserves atomic transactions after SIGKILL. `synchronous=FULL` flushes each commit before SQLite reports success.
The coding tools access the shared VFS through Rust operations.
The shell sends commands to the host's `Exec` implementation.
The adapter supplies the filesystem operations that the four coding tools use.
It does not implement the other portable filesystem capabilities or shell spill files.
The console currently uses an explicit unavailable stub. Host integration can call `run_with_exec`.
## Reproduce the bundles
```sh
npm ci --prefix host/js
npm run --prefix host/js bundle
CARGO_TARGET_DIR=target/harness nix develop -c cargo test -p sausage --test harness
```
The lock file pins npm dependencies. The checked-in bundles let Cargo build without npm or network access for JavaScript.
Cargo still needs its normal Rust dependencies and the V8 archive.
For Nix, use a separate npm derivation with `importNpmLock` and the checked-in lock file.
Run the bundle script there. Pass its output to the Rust source derivation before Crane builds the host.
The Rust build must not execute npm or download JavaScript packages.
The console reads `PI_PROXY_BASE_URL` and `PI_PROXY_API_KEY` from the host environment.
It selects `openai/gpt-6.1-sol` with low reasoning effort.
This model rejects disabled reasoning. Tests use the faux provider without credentials.
+27
View File
@@ -0,0 +1,27 @@
// Install Deno's native Web APIs. No Node runtime is present.
const core = Deno.core;
const expose = (path, names) => {
const exports = core.loadExtScript(path);
for (const name of names) globalThis[name] = exports[name];
};
expose('ext:deno_web/01_dom_exception.js', ['DOMException']);
expose('ext:deno_web/02_event.js', ['Event', 'EventTarget', 'MessageEvent']);
expose('ext:deno_web/13_message_port.js', ['MessageChannel', 'MessagePort']);
expose('ext:deno_web/00_url.js', ['URL', 'URLSearchParams']);
expose('ext:deno_web/02_timers.js', ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']);
expose('ext:deno_web/03_abort_signal.js', ['AbortController', 'AbortSignal']);
expose('ext:deno_web/06_streams.js', ['ReadableStream', 'WritableStream', 'TransformStream']);
expose('ext:deno_web/08_text_encoding.js', ['TextEncoder', 'TextDecoder']);
expose('ext:deno_web/09_file.js', ['Blob', 'File']);
expose('ext:deno_web/15_performance.js', ['performance']);
expose('ext:deno_web/02_structured_clone.js', ['structuredClone']);
expose('ext:deno_web/05_base64.js', ['atob', 'btoa']);
expose('ext:deno_crypto/00_crypto.js', ['crypto']);
expose('ext:deno_fetch/20_headers.js', ['Headers']);
expose('ext:deno_fetch/21_formdata.js', ['FormData']);
expose('ext:deno_fetch/23_request.js', ['Request']);
expose('ext:deno_fetch/23_response.js', ['Response']);
expose('ext:deno_fetch/26_fetch.js', ['fetch']);
const { Console } = core.loadExtScript('ext:deno_web/01_console.js');
globalThis.console = new Console((message, level) => core.print(message, level > 1));
globalThis.process = { env: {} };
+44488
View File
File diff suppressed because one or more lines are too long
+87
View File
@@ -0,0 +1,87 @@
// The coding tools see only world paths. Rust owns the VFS and process execution.
import { LineScanner } from '@earendil-works/pi-durable/env';
const ops = Deno.core.ops;
const ok = (value: any) => ({ ok: true, value });
const path = (cwd: string, input: string) => {
const parts: string[] = [];
for (const p of (input.startsWith('/') ? input : `${cwd}/${input}`).split('/')) {
if (p === '..') parts.pop();
else if (p && p !== '.') parts.push(p);
}
return '/' + parts.join('/');
};
export function environment(cwd = '/') {
const call = (kind: string, p: string, bytes: number[] = []) => ops.op_file(kind, path(cwd, p), bytes);
const env: any = {
id: 'world', cwd,
absolutePath: async (p: string) => ok(path(cwd, p)),
canonicalPath: async () => ({ ok: false, error: { code: 'not_supported', message: 'The VFS does not expose canonical paths' } }),
joinPath: async (parts: string[]) => ok(path(cwd, parts.join('/'))),
fileInfo: (p: string) => call('stat', p),
exists: async (p: string) => {
const result = await call('stat', p);
return result.ok ? ok(true) : result.error.code === 'not_found' ? ok(false) : result;
},
readBinaryFile: async (p: string) => {
const r = await call('read', p);
return r.ok ? ok(new Uint8Array(r.value)) : r;
},
readTextFile: async (p: string) => {
const r = await env.readBinaryFile(p);
return r.ok ? ok(new TextDecoder().decode(r.value)) : r;
},
writeFile: async (p: string, content: string | Uint8Array) => {
const full = path(cwd, p);
const parent = full.slice(0, full.lastIndexOf('/')) || '/';
const created = await env.createDir(parent, { recursive: true });
if (!created.ok) return created;
return call('write', full, Array.from(typeof content === 'string' ? new TextEncoder().encode(content) : content));
},
createDir: async (p: string, options: any) => {
const full = path(cwd, p);
if (!options?.recursive) return call('mkdir', full);
let current = '';
for (const part of full.split('/').filter(Boolean)) {
current += '/' + part;
const exists = await call('stat', current);
if (!exists.ok) { const r = await call('mkdir', current); if (!r.ok) return r; }
}
return ok(undefined);
},
openBinaryReader: async (p: string) => {
const data = await env.readBinaryFile(p);
if (!data.ok) return data;
const info = await env.fileInfo(p);
return ok({
info: async () => info,
read: async (offset: number, length: number) => ok(data.value.slice(offset, offset + length)),
scanLines: async (options: any) => {
const scanner = new LineScanner(options.startLine, options.endLine);
scanner.push(data.value);
return ok(scanner.finish());
},
close: async () => {},
});
},
exec: async (command: any, options: any, context: any) => {
try {
const rid = ops.op_exec(command, options?.cwd ?? cwd, options?.timeout);
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
let result;
while (true) {
const chunk = await ops.op_exec_next(rid);
if (!chunk.stream) { result = chunk; break; }
options?.onOutput?.(decoders[chunk.stream].decode(new Uint8Array(chunk.bytes), { stream: true }), context, { stream: chunk.stream });
}
if (result.error) throw new Error(result.error);
for (const stream of ['stdout', 'stderr']) {
const text = decoders[stream].decode();
if (text) options?.onOutput?.(text, context, { stream });
}
return ok({ exitCode: result.exitCode });
} catch (error) { return { ok: false, error }; }
},
cleanup: async () => {},
};
return env;
}
+72
View File
@@ -0,0 +1,72 @@
// Durable root conversation and console. SQLite stays outside the world.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { openaiProvider } from '@earendil-works/pi-ai/providers/openai';
import { Harness, createRegistry } from '@earendil-works/pi-durable';
import { CodingTools } from '@earendil-works/pi-durable/tools';
import { openStorage } from './sqlite';
import { environment } from './environment';
if (!process.env.PI_PROXY_BASE_URL || !process.env.PI_PROXY_API_KEY) {
throw new Error('PI_PROXY_BASE_URL and PI_PROXY_API_KEY are required');
}
const models = createModels();
const provider = openaiProvider();
const model = {
id: 'gpt-6.1-sol', name: 'gpt-6.1-sol', provider: 'openai', api: 'openai-responses',
baseUrl: `${process.env.PI_PROXY_BASE_URL}/v1`, reasoning: true, input: ['text'],
thinkingLevelMap: { off: null, minimal: null },
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 200000, maxTokens: 4096,
};
models.setProvider({ ...provider, baseUrl: model.baseUrl, getModels: () => [model],
auth: { apiKey: { name: 'Sausage proxy', resolve: async () => ({
auth: { apiKey: process.env.PI_PROXY_API_KEY, baseUrl: model.baseUrl,
headers: { 'Shopify-Usage-Tag': JSON.stringify(['pi', 'sausage']) } },
source: 'PI_PROXY_API_KEY',
}) } },
});
const registry = createRegistry();
registry.install(CodingTools);
const harness = await Harness.open(await openStorage(globalThis.storagePath), {
models, registry, env: (target: any) => environment(target.cwd ?? '/'),
}, context);
const root = await harness.root(context, { agent: { model: { provider: 'openai', modelId: model.id }, thinkingLevel: 'low', cwd: '/', extensions: [CodingTools] } });
const watch = await root.watch(context);
const emitted = new Map<number, string>();
let partial = '';
const text = (message: any) => message?.content?.filter((b: any) => b.type === 'text').map((b: any) => b.text).join('') ?? '';
const render = (view: any) => {
for (const entry of view.entries) {
if (entry.kind !== 'pi.assistant') continue;
const value = text(entry.model?.[0]);
if (emitted.has(entry.id)) continue;
if (value.startsWith(partial)) Deno.core.ops.op_output(value.slice(partial.length));
else Deno.core.ops.op_output(value);
partial = '';
emitted.set(entry.id, value);
}
const live = text(view.docs['pi.live']?.generation?.message);
if (live.startsWith(partial)) Deno.core.ops.op_output(live.slice(partial.length));
partial = live;
};
// Do not replay the historical transcript to the console.
for (const entry of watch.value.entries) {
if (entry.kind === 'pi.assistant') emitted.set(entry.id, text(entry.model?.[0]));
}
watch.start((event: any) => { if (event.kind === 'value') render(event.value); });
harness.resume();
await harness.waitForIdle(context);
while (true) {
const line = await Deno.core.ops.op_input();
if (line === null) break;
if (!line.trim()) continue;
const submission = await root.submit({ type: 'input', content: line, requestId: crypto.randomUUID() }, context);
const result = await submission.wait(context);
await harness.waitForIdle(context);
const final = await root.watch(context);
render(final.value);
await final.stop();
Deno.core.ops.op_output('\n');
if (result.status !== 'done') throw new Error(`Run ended: ${JSON.stringify(result)}`);
}
await watch.stop();
await harness.close(context);
+1687
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
{
"private": true,
"type": "module",
"scripts": { "bundle": "esbuild main.ts --bundle --format=esm --platform=browser --outfile=bundle.js && esbuild test.ts --bundle --format=esm --platform=browser --outfile=test-bundle.js" },
"dependencies": {
"@earendil-works/pi-durable": "1.5.0",
"@earendil-works/pi-ai": "1.5.0",
"@earendil-works/chord": "1.5.0"
},
"devDependencies": { "esbuild": "0.28.2" }
}
+33
View File
@@ -0,0 +1,33 @@
// Portable SQLite facade. Rust owns the connection and executes every statement.
import { SqliteStorage } from '@earendil-works/pi-durable/storage/sqlite';
const op = (kind: string, sql = '', params: any[] = []) => Deno.core.ops.op_sql(kind, sql, params.map(p => p instanceof Uint8Array ? Array.from(p) : typeof p === 'bigint' ? Number(p) : p));
export async function openStorage(path: string) {
op('open', path);
let tail = Promise.resolve();
const queue = <T>(call: () => Promise<T>): Promise<T> => {
const result = tail.then(call);
tail = result.then(() => {}, () => {});
return result;
};
const executor = {
exec: async (sql: string) => { op('exec', sql); },
run: async (sql: string, ...params: any[]) => { op('run', sql, params); },
runCounted: async (sql: string, ...params: any[]) => op('run', sql, params).changes,
get: async (sql: string, ...params: any[]) => (await executor.all(sql, ...params))[0],
all: async (sql: string, ...params: any[]) => op('all', sql, params).map((row: any) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, Array.isArray(value) ? new Uint8Array(value) : value]))),
};
const database = Object.fromEntries(Object.entries(executor).map(([name, method]) => [name, (...args: any[]) => queue(() => method(...args))]));
database.transaction = (callback: any) => queue(async () => {
op('exec', 'BEGIN IMMEDIATE');
try {
const result = await callback(executor);
op('exec', 'COMMIT');
return result;
} catch (error) {
op('exec', 'ROLLBACK');
throw error;
}
});
database.close = () => queue(async () => { op('close'); });
return SqliteStorage.open(database as any);
}
+22288
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
// Faux-provider integration test. The Rust test checks VFS effects and Exec arguments.
import { BACKGROUND_CONTEXT as context } from '@earendil-works/chord/context';
import { createModels } from '@earendil-works/pi-ai/models';
import { fauxProvider, fauxAssistantMessage, fauxToolCall, fauxText } from '@earendil-works/pi-ai/providers/faux';
import { Harness, createRegistry } from '@earendil-works/pi-durable';
import { CodingTools } from '@earendil-works/pi-durable/tools';
import { environment } from './environment';
import { openStorage } from './sqlite';
const models = createModels();
const crash = globalThis.testCrash;
const faux = fauxProvider({ tokensPerSecond: crash === 'model' ? 1 : 100000 });
models.setProvider(faux.provider);
faux.setResponses([
fauxAssistantMessage([fauxToolCall('write', { path: '/test.txt', content: 'before\n' }, { id: 'write' })], { stopReason: 'toolUse' }),
fauxAssistantMessage([fauxToolCall('read', { path: '/test.txt' }, { id: 'read' })], { stopReason: 'toolUse' }),
fauxAssistantMessage([fauxToolCall('edit', { path: '/test.txt', edits: [{ oldText: 'before', newText: 'after' }] }, { id: 'edit' })], { stopReason: 'toolUse' }),
fauxAssistantMessage([fauxToolCall('bash', { command: 'echo test' }, { id: 'bash' })], { stopReason: 'toolUse' }),
fauxAssistantMessage([fauxText('done')]),
]);
if (crash) {
faux.setResponses(crash === 'tool' ? [
fauxAssistantMessage([fauxToolCall('bash', { command: 'echo test' }, { id: 'crash' })], { stopReason: 'toolUse' }),
] : crash === 'model' ? [
fauxAssistantMessage([fauxText('A long response that the test interrupts before it completes.')]),
] : [fauxAssistantMessage([fauxText('resumed done')])]);
}
const registry = createRegistry();
registry.install(CodingTools);
const harness = await Harness.open(await openStorage(globalThis.storagePath), { models, registry, env: () => environment('/') }, context);
const root = await harness.root(context, { agent: { model: { provider: 'faux', modelId: 'faux-1' }, extensions: [CodingTools], cwd: '/' } });
if (crash === 'model') {
const progress = await root.watch(context);
let ready = false;
progress.start((event: any) => {
if (event.kind !== 'value' || ready) return;
const message = event.value.docs['pi.live']?.generation?.message;
if (message?.content?.some((block: any) => block.type === 'text' && block.text.length > 0)) {
ready = true;
console.log('CRASH_READY');
}
});
}
harness.resume();
const result = await (await root.submit({ type: 'input', content: 'Exercise tools', requestId: 'test' }, context)).wait(context);
if (result.status !== 'done') throw new Error(JSON.stringify(result));
await harness.waitForIdle(context);
const watch = await root.watch(context);
const transcript = JSON.stringify(watch.value.entries);
if (!crash && transcript.includes('"isError":true')) throw new Error(transcript);
for (const expected of crash ? ['resumed done'] : ['before', 'after', 'streamed stdout', 'streamed stderr', 'done']) {
if (!transcript.includes(expected)) throw new Error(`Missing ${expected}: ${transcript}`);
}
await watch.stop();
await harness.close(context);
+147
View File
@@ -0,0 +1,147 @@
//! Rust operations for tools. File access uses the world's shared VFS layer.
use crate::exec::{Command, Exec, Options, Stream};
use deno_core::{OpState, op2};
use deno_error::JsErrorBox;
use sausage_vfs::{Layer, Open};
use serde_json::{Value, json};
use std::{cell::RefCell, io::Write, rc::Rc, sync::Arc};
pub(super) struct Environment {
pub root: Arc<dyn Layer>,
pub exec: Arc<dyn Exec>,
}
#[op2]
#[serde]
pub async fn op_file(
state: Rc<RefCell<OpState>>,
#[string] kind: String,
#[string] path: String,
#[serde] bytes: Vec<u8>,
) -> Result<serde_json::Value, JsErrorBox> {
let root = state.borrow().borrow::<Environment>().root.clone();
let result: std::io::Result<Value> = async {
match kind.as_str() {
"read" => {
let file = root.open(&path, Open { follow: true, ..Default::default() }).await?;
let size = file.stat().await?.size;
Ok(json!(file.read(0, size as usize).await?))
}
"write" => {
let file = root.open(&path, Open { follow: true, write: true, create: true, truncate: true, ..Default::default() }).await?;
let mut offset = 0;
while offset < bytes.len() {
let count = file.write(offset as u64, bytes[offset..].to_vec()).await?;
if count == 0 { return Err(std::io::ErrorKind::WriteZero.into()); }
offset += count;
}
file.sync().await?;
Ok(Value::Null)
}
"mkdir" => { root.mkdir(&path).await?; Ok(Value::Null) }
"stat" => {
let s = root.stat(&path, true).await?;
Ok(json!({"path": path, "name": path.rsplit('/').next().unwrap_or(""), "kind": if s.directory {"directory"} else {"file"}, "size": s.size, "mtimeMs": 0}))
}
_ => Err(std::io::ErrorKind::Unsupported.into()),
}
}.await;
Ok(match result {
Ok(value) => json!({"ok": true, "value": value}),
Err(e) => {
json!({"ok": false, "error": {"code": match e.kind() { std::io::ErrorKind::NotFound => "not_found", std::io::ErrorKind::PermissionDenied => "permission_denied", _ => "unknown" }, "message": e.to_string(), "path": path}})
}
})
}
struct Execution {
receiver: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<Value>>,
task: tokio::task::AbortHandle,
}
impl deno_core::Resource for Execution {
fn close(self: Rc<Self>) {
self.task.abort();
}
}
impl Drop for Execution {
fn drop(&mut self) {
self.task.abort();
}
}
#[op2]
pub fn op_exec(
state: &mut OpState,
#[serde] command: serde_json::Value,
#[string] cwd: String,
timeout: Option<f64>,
) -> Result<u32, JsErrorBox> {
let exec = state.borrow::<Environment>().exec.clone();
let command = if let Some(s) = command.as_str() {
Command::Shell(s.to_owned())
} else {
Command::Argv(
serde_json::from_value(command).map_err(|e| JsErrorBox::generic(e.to_string()))?,
)
};
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
let task = tokio::spawn(async move {
let result = exec.exec(command, Options { cwd, timeout: timeout.map(std::time::Duration::from_secs_f64), ..Default::default() }, &mut |stream, bytes| { let _ = sender.send(json!({"stream": if stream == Stream::Stdout {"stdout"} else {"stderr"}, "bytes": bytes})); }).await;
let _ = sender.send(match result {
Ok(exit) => json!({"exitCode": exit}),
Err(e) => json!({"error": e.to_string()}),
});
});
Ok(state.resource_table.add(Execution {
receiver: tokio::sync::Mutex::new(receiver),
task: task.abort_handle(),
}))
}
#[op2]
#[serde]
pub async fn op_exec_next(
state: Rc<RefCell<OpState>>,
rid: u32,
) -> Result<serde_json::Value, JsErrorBox> {
let execution = state
.borrow()
.resource_table
.get::<Execution>(rid)
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
let value = execution
.receiver
.lock()
.await
.recv()
.await
.unwrap_or(Value::Null);
if value.get("stream").is_none() {
let resource = state
.borrow_mut()
.resource_table
.take::<Execution>(rid)
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
deno_core::Resource::close(resource);
}
Ok(value)
}
#[op2]
#[string]
pub async fn op_input() -> Result<Option<String>, JsErrorBox> {
use tokio::io::AsyncBufReadExt;
let mut reader = tokio::io::BufReader::with_capacity(1, tokio::io::stdin());
let mut line = String::new();
let n = reader
.read_line(&mut line)
.await
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
Ok((n != 0).then_some(line))
}
#[op2(fast)]
pub fn op_output(#[string] text: &str) {
print!("{text}");
let _ = std::io::stdout().flush();
}
+209 -5
View File
@@ -1,8 +1,212 @@
//! The durable agent harness: V8 with pi-durable, harness storage, and the tools' environment.
//! Owned by task T9.
//! Durable V8 harness, host-side SQLite storage, and world tool operations.
mod environment;
use crate::{exec::Exec, world::World};
use deno_core::{JsRuntime, OpState, RuntimeOptions, op2};
use deno_error::JsErrorBox;
use rusqlite::{Connection, types::Value as SqlValue};
use serde_json::{Value, json};
use std::path::Path;
use std::sync::Arc;
/// Start the world at `world` with the harness and a console.
pub async fn run(_world: &Path) -> anyhow::Result<()> {
anyhow::bail!("the harness does not exist yet (T9)")
#[op2]
#[serde]
fn op_sql(
state: &mut OpState,
#[string] kind: String,
#[string] sql: String,
#[serde] params: Vec<serde_json::Value>,
) -> Result<serde_json::Value, JsErrorBox> {
let work = || -> Result<Value, Box<dyn std::error::Error>> {
if kind == "open" {
let db = Connection::open(sql)?;
db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?;
state.put(db);
return Ok(Value::Null);
}
if kind == "close" {
state.take::<Connection>();
return Ok(Value::Null);
}
let db = state.borrow::<Connection>();
if kind == "exec" {
db.execute_batch(&sql)?;
return Ok(Value::Null);
}
let params: Vec<SqlValue> = params
.into_iter()
.map(|v| match v {
Value::Null => SqlValue::Null,
Value::Number(n) => n
.as_i64()
.map(SqlValue::Integer)
.unwrap_or_else(|| SqlValue::Real(n.as_f64().unwrap())),
Value::String(s) => SqlValue::Text(s),
Value::Array(a) => {
SqlValue::Blob(a.into_iter().map(|n| n.as_u64().unwrap() as u8).collect())
}
_ => panic!("unsupported SQL parameter"),
})
.collect();
let mut stmt = db.prepare_cached(&sql)?;
if kind == "run" {
let changes = stmt.execute(rusqlite::params_from_iter(params))?;
return Ok(json!({"changes": changes, "lastInsertRowid": db.last_insert_rowid()}));
}
let names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
let mut rows = stmt.query(rusqlite::params_from_iter(params))?;
let mut result = vec![];
while let Some(row) = rows.next()? {
let mut obj = serde_json::Map::new();
for (i, name) in names.iter().enumerate() {
let v = match row.get::<_, SqlValue>(i)? {
SqlValue::Null => Value::Null,
SqlValue::Integer(n) => json!(n),
SqlValue::Real(n) => json!(n),
SqlValue::Text(s) => json!(s),
SqlValue::Blob(b) => json!(b),
};
obj.insert(name.clone(), v);
}
result.push(Value::Object(obj));
}
Ok(json!(result))
};
work().map_err(|e| JsErrorBox::generic(e.to_string()))
}
deno_core::extension!(
storage,
ops = [
op_sql,
environment::op_file,
environment::op_exec,
environment::op_exec_next,
environment::op_input,
environment::op_output
]
);
/// Start the console with the process implementation supplied by the host.
pub async fn run_with_exec(world: World, exec: Arc<dyn Exec>) -> anyhow::Result<()> {
// Deno's unsynchronized operations require a current-thread Tokio runtime.
tokio::task::spawn_blocking(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(run_module(world, exec, include_str!("../../js/bundle.js")))
})
.await?
.map_err(|error: anyhow::Error| {
let mut text = error.to_string();
for key in ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"] {
if let Ok(value) = std::env::var(key) {
if !value.is_empty() {
text = text.replace(&value, "[redacted]");
}
}
}
anyhow::anyhow!(text)
})
}
/// Evaluate a bundled harness module with the same storage and tool operations as the console.
/// The caller must use a current-thread Tokio runtime.
pub async fn run_module(world: World, exec: Arc<dyn Exec>, source: &str) -> anyhow::Result<()> {
let mut runtime = JsRuntime::new(RuntimeOptions {
extensions: vec![
deno_telemetry::deno_telemetry::init(),
deno_webidl::deno_webidl::init(),
deno_web::deno_web::init(
Arc::new(deno_web::BlobStore::default()),
None,
false,
Default::default(),
),
deno_crypto::deno_crypto::init(None),
deno_net::deno_net::init(None, None),
deno_fetch::deno_fetch::init(Default::default()),
storage::init(),
],
extension_transpiler: Some(std::rc::Rc::new(|name, source| {
if !name.ends_with(".ts") {
return Ok((source, None));
}
let parsed = deno_ast::parse_module(deno_ast::ParseParams {
specifier: deno_core::url::Url::parse(&name).unwrap(),
text: source.into(),
media_type: deno_ast::MediaType::TypeScript,
capture_tokens: false,
scope_analysis: false,
maybe_syntax: None,
})
.map_err(|e| JsErrorBox::generic(e.to_string()))?;
let emitted = parsed
.transpile(
&Default::default(),
&Default::default(),
&deno_ast::EmitOptions {
source_map: deno_ast::SourceMapOption::None,
..Default::default()
},
)
.map_err(|e| JsErrorBox::generic(e.to_string()))?
.into_source();
Ok((emitted.text.into(), None))
})),
..Default::default()
});
runtime
.op_state()
.borrow_mut()
.put(deno_permissions::PermissionsContainer::allow_all(Arc::new(
deno_permissions::RuntimePermissionDescriptorParser::new(sys_traits::impls::RealSys),
)));
runtime.execute_script("bootstrap.js", include_str!("../../js/bootstrap.js"))?;
let env: serde_json::Map<String, Value> = ["PI_PROXY_BASE_URL", "PI_PROXY_API_KEY"]
.into_iter()
.filter_map(|key| std::env::var(key).ok().map(|v| (key.to_owned(), json!(v))))
.collect();
runtime.execute_script("env.js", format!("process.env = {};", Value::Object(env)))?;
runtime
.op_state()
.borrow_mut()
.put(environment::Environment {
root: world.root,
exec,
});
runtime.execute_script(
"config.js",
format!(
"globalThis.storagePath = {};",
serde_json::to_string(&world.host_dir.join("harness.sqlite"))?
),
)?;
let specifier = deno_core::ModuleSpecifier::parse("file:///harness.js")?;
let module = runtime
.load_main_es_module_from_code(&specifier, source.to_owned())
.await?;
let result = runtime.mod_evaluate(module);
runtime
.with_event_loop_promise(result, Default::default())
.await?;
runtime.run_event_loop(Default::default()).await?;
Ok(())
}
struct Unavailable;
#[async_trait::async_trait]
impl Exec for Unavailable {
async fn exec(
&self,
_: crate::exec::Command,
_: crate::exec::Options,
_: &mut (dyn for<'chunk> FnMut(crate::exec::Stream, &'chunk [u8]) + Send),
) -> anyhow::Result<i32> {
anyhow::bail!("process execution is not wired yet")
}
}
/// Open a world and resume its durable root conversation.
pub async fn run(path: &Path) -> anyhow::Result<()> {
run_with_exec(World::open(path)?, Arc::new(Unavailable)).await
}
+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
}
+33 -5
View File
@@ -3,7 +3,7 @@
//! Layout of a world directory:
//! - `world/`: the virtual root `/` that every guest sees. Agents change everything in it.
//! - `host/`: host state outside the sandbox (harness storage, later the vault). No guest sees it.
use sausage_vfs::{Host, Layer, Mounts, Process};
use sausage_vfs::{Generated, Host, Layer, Memory, Mounts, Process};
use std::{
path::{Path, PathBuf},
sync::Arc,
@@ -15,18 +15,46 @@ pub struct World {
pub root: Arc<dyn Layer>,
/// The host state folder, `<dir>/host`.
pub host_dir: PathBuf,
/// Live process metadata shared with the process table.
pub processes: crate::process::Processes,
}
impl World {
/// Open (and create if missing) the world directory `dir`. The process table adds more mounts
/// (`/proc`, `/run`).
/// Open (and create if missing) the world directory `dir`, including `/proc` and `/run` mounts.
pub fn open(dir: &Path) -> anyhow::Result<Self> {
let root_dir = dir.join("world");
let host_dir = dir.join("host");
std::fs::create_dir_all(&root_dir)?;
std::fs::create_dir_all(&host_dir)?;
let mut mounts = Mounts::default();
mounts.mount("/", Arc::new(Host::new(&root_dir, Process::default())?), false);
Ok(World { root: Arc::new(mounts), host_dir })
mounts.mount(
"/",
Arc::new(Host::new(&root_dir, Process::default())?),
false,
);
let processes: crate::process::Processes =
Arc::new(std::sync::Mutex::new(Default::default()));
let snapshot = processes.clone();
let mut proc = Generated::default();
proc.insert("/processes", move || {
let mut text = String::from("PID\tPPID\tSTATE\tCWD\tARGV\n");
for (pid, info) in snapshot.lock().unwrap().iter() {
text.push_str(&format!(
"{pid}\t{}\t{}\t{}\t{}\n",
info.parent,
info.state,
info.cwd,
info.argv.join(" ")
));
}
Ok(text.into_bytes())
});
mounts.mount("/proc", Arc::new(proc), true);
mounts.mount("/run", Arc::new(Memory::default()), false);
Ok(World {
root: Arc::new(mounts),
host_dir,
processes,
})
}
}
+124
View File
@@ -0,0 +1,124 @@
//! Integration of pi-durable's coding tools with the shared VFS and Exec contract.
use sausage::{
exec::{Command, Exec, Options, Stream},
harness::run_module,
world::World,
};
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct FakeExec(Mutex<Vec<String>>);
#[async_trait::async_trait]
impl Exec for FakeExec {
async fn exec(
&self,
command: Command,
options: Options,
output: &mut (dyn for<'chunk> FnMut(Stream, &'chunk [u8]) + Send),
) -> anyhow::Result<i32> {
let Command::Shell(command) = command else {
anyhow::bail!("expected shell command")
};
self.0
.lock()
.unwrap()
.push(format!("{}:{}", options.cwd, command));
if std::env::var("HARNESS_CRASH_PHASE").as_deref() == Ok("tool") {
println!("CRASH_READY");
use std::io::Write;
std::io::stdout().flush()?;
std::future::pending::<()>().await;
}
output(Stream::Stdout, b"streamed stdout\n");
output(Stream::Stderr, b"streamed stderr\n");
Ok(0)
}
}
#[tokio::test(flavor = "current_thread")]
async fn crash_child() -> anyhow::Result<()> {
let Ok(dir) = std::env::var("HARNESS_CRASH_DIR") else {
return Ok(());
};
let phase = std::env::var("HARNESS_CRASH_PHASE")?;
let source = format!(
"globalThis.testCrash = {};\n{}",
serde_json::to_string(&phase)?,
include_str!("../js/test-bundle.js")
);
tokio::time::timeout(
std::time::Duration::from_secs(20),
run_module(
World::open(std::path::Path::new(&dir))?,
Arc::new(FakeExec::default()),
&source,
),
)
.await?
}
#[test]
fn sigkill_resumes_model_and_tool() -> anyhow::Result<()> {
use std::{
io::{BufRead, BufReader},
process::{Command, Stdio},
time::Duration,
};
for phase in ["model", "tool"] {
let dir =
std::env::temp_dir().join(format!("sausage-crash-{}-{phase}", std::process::id()));
let mut child = Command::new(std::env::current_exe()?)
.args(["--exact", "crash_child", "--nocapture"])
.env("HARNESS_CRASH_DIR", &dir)
.env("HARNESS_CRASH_PHASE", phase)
.stdout(Stdio::piped())
.spawn()?;
let stdout = child.stdout.take().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
if line
.as_deref()
.is_ok_and(|line| line.contains("CRASH_READY"))
{
let _ = tx.send(());
break;
}
}
});
let ready = rx.recv_timeout(Duration::from_secs(30));
child.kill()?; // SIGKILL on Unix, not a graceful harness close.
child.wait()?;
ready?;
let resumed = Command::new(std::env::current_exe()?)
.args(["--exact", "crash_child", "--nocapture"])
.env("HARNESS_CRASH_DIR", &dir)
.env("HARNESS_CRASH_PHASE", "resume")
.status()?;
assert!(resumed.success(), "failed to resume {phase}");
std::fs::remove_dir_all(dir)?;
}
Ok(())
}
#[tokio::test(flavor = "current_thread")]
async fn file_tools_and_exec() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join(format!("sausage-harness-{}", std::process::id()));
std::fs::create_dir_all(&dir)?;
let exec = Arc::new(FakeExec::default());
let result = run_module(
World::open(&dir)?,
exec.clone(),
include_str!("../js/test-bundle.js"),
)
.await;
result?;
assert_eq!(
std::fs::read_to_string(dir.join("world/test.txt"))?,
"after\n"
);
assert_eq!(*exec.0.lock().unwrap(), ["/:echo test"]);
assert!(dir.join("host/harness.sqlite").is_file());
std::fs::remove_dir_all(dir)?;
Ok(())
}
+319
View File
@@ -0,0 +1,319 @@
//! Process acceptance tests use persistent userland build artifacts and temporary worlds.
use sausage::{
exec::{Command, Exec, Options, Stream},
process::Runtime,
world::World,
};
use std::{
path::{Path, PathBuf},
sync::{Arc, Once},
time::Duration,
};
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(());
fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_owned()
}
fn build() {
BUILD.call_once(|| {
let root = repo();
let run = |args: &[&str], cwd: &Path| {
assert!(
std::process::Command::new(args[0])
.args(&args[1..])
.env_remove("CARGO_TARGET_DIR")
.env("CARGO_HOME", cwd.join(".cargo-home"))
.current_dir(cwd)
.status()
.unwrap()
.success(),
"guest build failed: {args:?}"
);
};
run(
&["nu", "--no-config-file", "userland/cwd-init/build.nu"],
&root,
);
run(
&[
"cargo",
"build",
"--release",
"-p",
"sausage-coreutils",
"-p",
"sausage-procps",
"-p",
"sausage-cwd-probe",
"--locked",
],
&root.join("userland"),
);
run(
&["nu", "--no-config-file", "userland/brush/build.nu"],
&root,
);
});
}
fn fixture(root: &Path) {
for dir in ["work/sub", "other", "bin", "dev"] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
for (name, text) in [
("work/f", "alpha\n"),
("work/file", "one\ntwo\n"),
("work/log", "before\n"),
("work/a.txt", ""),
("work/b.txt", ""),
("dev/null", ""),
("f", "root\n"),
] {
std::fs::write(root.join(name), text).unwrap();
}
}
fn world() -> (tempfile::TempDir, Arc<Runtime>, sausage::process::Processes) {
build();
let temp = tempfile::tempdir().unwrap();
let world = World::open(temp.path()).unwrap();
let cache = Path::new(env!("CARGO_TARGET_TMPDIR")).join("process-host/compile-cache");
std::fs::create_dir_all(&cache).unwrap();
std::os::unix::fs::symlink(cache, world.host_dir.join("compile-cache")).unwrap();
let processes = world.processes.clone();
fixture(&temp.path().join("world"));
let artifacts = repo().join("userland/target/wasm32-wasip2/release");
let bin = temp.path().join("world/bin");
for tool in ["cat", "tr", "ls", "wc", "yes", "head", "sleep"] {
std::fs::copy(artifacts.join("sausage-coreutils.wasm"), bin.join(tool)).unwrap();
}
for tool in ["ps", "kill"] {
std::fs::copy(artifacts.join("sausage-procps.wasm"), bin.join(tool)).unwrap();
}
std::fs::copy(artifacts.join("sausage-cwd-probe.wasm"), bin.join("probe")).unwrap();
std::fs::copy(
repo().join("userland/brush/source/target/wasm32-wasip2/release/brush.wasm"),
bin.join("sh"),
)
.unwrap();
(temp, Runtime::new(world).unwrap(), processes)
}
async fn run(runtime: &Arc<Runtime>, command: Command, cwd: &str) -> (i32, Vec<u8>, Vec<u8>) {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
let code = runtime
.exec(
command,
Options {
cwd: cwd.into(),
timeout: Some(Duration::from_secs(30)),
..Default::default()
},
&mut |stream, bytes| match stream {
Stream::Stdout => stdout.extend_from_slice(bytes),
Stream::Stderr => stderr.extend_from_slice(bytes),
},
)
.await
.unwrap();
(code, stdout, stderr)
}
async fn warm(runtime: &Arc<Runtime>) {
// A cold compile belongs to setup, not to the command deadline or its timing.
assert_eq!(
runtime
.exec(
Command::Shell("sleep 0".into()),
Options {
cwd: "/".into(),
..Default::default()
},
&mut |_, _| {},
)
.await
.unwrap(),
0
);
}
#[tokio::test(flavor = "multi_thread")]
async fn cwd_without_program_workaround() {
let _serial = SERIAL.lock().await;
let (_temp, runtime, _) = world();
let (code, output, _) = run(&runtime, Command::Argv(vec!["/bin/probe".into()]), "/work").await;
assert_eq!(code, 0);
assert_eq!(output, b"cwd=/work\nrelative=alpha\nabsolute=root\n");
for code in [0, 1, 2, 42, 255] {
assert_eq!(
run(
&runtime,
Command::Argv(vec!["/bin/probe".into(), code.to_string()]),
"/"
)
.await
.0,
code
);
}
let code = runtime
.exec(
Command::Argv(vec!["/bin/probe".into()]),
Options {
cwd: "/missing".into(),
..Default::default()
},
&mut |_, _| {},
)
.await
.unwrap();
assert_eq!(code, 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn corpus_matches_bash() {
let _serial = SERIAL.lock().await;
let (temp, runtime, _) = world();
warm(&runtime).await;
let native = tempfile::tempdir().unwrap();
fixture(native.path());
let script = std::fs::read_to_string(repo().join("spikes/s2-shell/corpus.sh")).unwrap();
let result = std::process::Command::new("bash")
.args(["--noprofile", "--norc", "-c", &script])
.current_dir(native.path())
.output()
.unwrap();
let expected = String::from_utf8(result.stdout)
.unwrap()
.replace(native.path().to_str().unwrap(), "");
let start = std::time::Instant::now();
let (code, stdout, _) = run(&runtime, Command::Shell(script.clone()), "/").await;
eprintln!("corpus elapsed={:?}", start.elapsed());
assert_eq!(Some(code), result.status.code());
assert_eq!(stdout, expected.as_bytes());
fixture(&temp.path().join("world"));
let cli = std::process::Command::new(env!("CARGO_BIN_EXE_sausage"))
.arg("sh")
.arg(temp.path())
.args(["-c", &script])
.output()
.unwrap();
assert_eq!(cli.status.code(), result.status.code());
assert_eq!(
cli.stdout,
expected.as_bytes(),
"CLI stderr: {}",
String::from_utf8_lossy(&cli.stderr)
);
for script in ["cat /work/f", "cd /work; cat f", "yes | head -n 2"] {
let start = std::time::Instant::now();
assert_eq!(run(&runtime, Command::Shell(script.into()), "/").await.0, 0);
eprintln!("warm {script:?}: {:?}", start.elapsed());
}
}
async fn empty(processes: &sausage::process::Processes) {
tokio::time::timeout(Duration::from_secs(5), async {
while !processes.lock().unwrap().is_empty() {
tokio::task::yield_now().await;
}
})
.await
.unwrap_or_else(|e| panic!("{e}: remaining processes {:?}", processes.lock().unwrap()));
}
#[tokio::test(flavor = "multi_thread")]
async fn timeout_and_drop_kill_descendants() {
let _serial = SERIAL.lock().await;
let (_temp, runtime, processes) = world();
warm(&runtime).await;
let result = runtime
.exec(
Command::Shell("sleep 60 | cat".into()),
Options {
timeout: Some(Duration::from_millis(100)),
..Default::default()
},
&mut |_, _| {},
)
.await;
assert!(result.unwrap_err().to_string().contains("timed out"));
empty(&processes).await;
let task_runtime = runtime.clone();
let task = tokio::spawn(async move {
task_runtime
.exec(
Command::Shell("sleep 60 | cat".into()),
Options::default(),
&mut |_, _| {},
)
.await
});
tokio::time::timeout(Duration::from_secs(10), async {
while processes.lock().unwrap().len() < 3 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
task.abort();
let _ = task.await;
empty(&processes).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn ps_and_kill_pipeline() {
let _serial = SERIAL.lock().await;
let (_temp, runtime, processes) = world();
warm(&runtime).await;
let task_runtime = runtime.clone();
let task = tokio::spawn(async move {
run(&task_runtime, Command::Shell("sleep 60 | cat".into()), "/").await
});
let pid = tokio::time::timeout(Duration::from_secs(10), async {
loop {
if let Some((&pid, _)) = processes
.lock()
.unwrap()
.iter()
.find(|(_, info)| info.argv.first().is_some_and(|arg| arg.ends_with("sleep")))
{
break pid;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let (_, stdout, _) = run(&runtime, Command::Argv(vec!["/bin/ps".into()]), "/").await;
assert!(
String::from_utf8(stdout)
.unwrap()
.contains(&format!("{pid}\t"))
);
assert_eq!(
run(
&runtime,
Command::Argv(vec!["/bin/kill".into(), pid.to_string()]),
"/"
)
.await
.0,
0
);
tokio::time::timeout(Duration::from_secs(5), task)
.await
.unwrap()
.unwrap();
empty(&processes).await;
}
#[tokio::test(flavor = "multi_thread")]
async fn output_keeps_stream_order() {
let _serial = SERIAL.lock().await;
let (_temp, runtime, _) = world();
warm(&runtime).await;
let (code, stdout, stderr) = run(
&runtime,
Command::Shell("printf a; printf b; printf c >&2; printf d >&2".into()),
"/",
)
.await;
assert_eq!(code, 0);
assert_eq!(stdout, b"ab");
assert_eq!(stderr, b"cd");
}
+299
View File
@@ -0,0 +1,299 @@
package wasi:io@0.2.12;
@since(version = 0.2.0)
interface error {
/// A resource which represents some error information.
///
/// The only method provided by this resource is `to-debug-string`,
/// which provides some human-readable information about the error.
///
/// In the `wasi:io` package, this resource is returned through the
/// `wasi:io/streams.stream-error` type.
///
/// To provide more specific error information, other interfaces may
/// offer functions to "downcast" this error into more specific types. For example,
/// errors returned from streams derived from filesystem types can be described using
/// the filesystem's own error-code type. This is done using the function
/// `wasi:filesystem/types.filesystem-error-code`, which takes a `borrow<error>`
/// parameter and returns an `option<wasi:filesystem/types.error-code>`.
///
/// The set of functions which can "downcast" an `error` into a more
/// concrete type is open.
@since(version = 0.2.0)
resource error {
/// Returns a string that is suitable to assist humans in debugging
/// this error.
///
/// WARNING: The returned string should not be consumed mechanically!
/// It may change across platforms, hosts, or other implementation
/// details. Parsing this string is a major platform-compatibility
/// hazard.
@since(version = 0.2.0)
to-debug-string: func() -> string;
}
}
/// A poll API intended to let users wait for I/O events on multiple handles
/// at once.
@since(version = 0.2.0)
interface poll {
/// `pollable` represents a single I/O event which may be ready, or not.
@since(version = 0.2.0)
resource pollable {
/// Return the readiness of a pollable. This function never blocks.
///
/// Returns `true` when the pollable is ready, and `false` otherwise.
@since(version = 0.2.0)
ready: func() -> bool;
/// `block` returns immediately if the pollable is ready, and otherwise
/// blocks until ready.
///
/// This function is equivalent to calling `poll.poll` on a list
/// containing only this pollable.
@since(version = 0.2.0)
block: func();
}
/// Poll for completion on a set of pollables.
///
/// This function takes a list of pollables, which identify I/O sources of
/// interest, and waits until one or more of the events is ready for I/O.
///
/// The result `list<u32>` contains one or more indices of handles in the
/// argument list that is ready for I/O.
///
/// This function traps if either:
/// - the list is empty, or:
/// - the list contains more elements than can be indexed with a `u32` value.
///
/// A timeout can be implemented by adding a pollable from the
/// wasi-clocks API to the list.
///
/// This function does not return a `result`; polling in itself does not
/// do any I/O so it doesn't fail. If any of the I/O sources identified by
/// the pollables has an error, it is indicated by marking the source as
/// being ready for I/O.
@since(version = 0.2.0)
poll: func(in: list<borrow<pollable>>) -> list<u32>;
}
/// WASI I/O is an I/O abstraction API which is currently focused on providing
/// stream types.
///
/// In the future, the component model is expected to add built-in stream types;
/// when it does, they are expected to subsume this API.
@since(version = 0.2.0)
interface streams {
@since(version = 0.2.0)
use error.{error};
@since(version = 0.2.0)
use poll.{pollable};
/// An error for input-stream and output-stream operations.
@since(version = 0.2.0)
variant stream-error {
/// The last operation (a write or flush) failed before completion.
///
/// More information is available in the `error` payload.
///
/// After this, the stream will be closed. All future operations return
/// `stream-error::closed`.
last-operation-failed(error),
/// The stream is closed: no more input will be accepted by the
/// stream. A closed output-stream will return this error on all
/// future operations.
closed,
}
/// An input bytestream.
///
/// `input-stream`s are *non-blocking* to the extent practical on underlying
/// platforms. I/O operations always return promptly; if fewer bytes are
/// promptly available than requested, they return the number of bytes promptly
/// available, which could even be zero. To wait for data to be available,
/// use the `subscribe` function to obtain a `pollable` which can be polled
/// for using `wasi:io/poll`.
@since(version = 0.2.0)
resource input-stream {
/// Perform a non-blocking read from the stream.
///
/// When the source of a `read` is binary data, the bytes from the source
/// are returned verbatim. When the source of a `read` is known to the
/// implementation to be text, bytes containing the UTF-8 encoding of the
/// text are returned.
///
/// This function returns a list of bytes containing the read data,
/// when successful. The returned list will contain up to `len` bytes;
/// it may return fewer than requested, but not more. The list is
/// empty when no bytes are available for reading at this time. The
/// pollable given by `subscribe` will be ready when more bytes are
/// available.
///
/// This function fails with a `stream-error` when the operation
/// encounters an error, giving `last-operation-failed`, or when the
/// stream is closed, giving `closed`.
///
/// When the caller gives a `len` of 0, it represents a request to
/// read 0 bytes. If the stream is still open, this call should
/// succeed and return an empty list, or otherwise fail with `closed`.
///
/// The `len` parameter is a `u64`, which could represent a list of u8 which
/// is not possible to allocate in wasm32, or not desirable to allocate as
/// as a return value by the callee. The callee may return a list of bytes
/// less than `len` in size while more bytes are available for reading.
@since(version = 0.2.0)
read: func(len: u64) -> result<list<u8>, stream-error>;
/// Read bytes from a stream, after blocking until at least one byte can
/// be read. Except for blocking, behavior is identical to `read`.
@since(version = 0.2.0)
blocking-read: func(len: u64) -> result<list<u8>, stream-error>;
/// Skip bytes from a stream. Returns number of bytes skipped.
///
/// Behaves identical to `read`, except instead of returning a list
/// of bytes, returns the number of bytes consumed from the stream.
@since(version = 0.2.0)
skip: func(len: u64) -> result<u64, stream-error>;
/// Skip bytes from a stream, after blocking until at least one byte
/// can be skipped. Except for blocking behavior, identical to `skip`.
@since(version = 0.2.0)
blocking-skip: func(len: u64) -> result<u64, stream-error>;
/// Create a `pollable` which will resolve once either the specified stream
/// has bytes available to read or the other end of the stream has been
/// closed.
/// The created `pollable` is a child resource of the `input-stream`.
/// Implementations may trap if the `input-stream` is dropped before
/// all derived `pollable`s created with this function are dropped.
@since(version = 0.2.0)
subscribe: func() -> pollable;
}
/// An output bytestream.
///
/// `output-stream`s are *non-blocking* to the extent practical on
/// underlying platforms. Except where specified otherwise, I/O operations also
/// always return promptly, after the number of bytes that can be written
/// promptly, which could even be zero. To wait for the stream to be ready to
/// accept data, the `subscribe` function to obtain a `pollable` which can be
/// polled for using `wasi:io/poll`.
///
/// Dropping an `output-stream` while there's still an active write in
/// progress may result in the data being lost. Before dropping the stream,
/// be sure to fully flush your writes.
@since(version = 0.2.0)
resource output-stream {
/// Check readiness for writing. This function never blocks.
///
/// Returns the number of bytes permitted for the next call to `write`,
/// or an error. Calling `write` with more bytes than this function has
/// permitted will trap.
///
/// When this function returns 0 bytes, the `subscribe` pollable will
/// become ready when this function will report at least 1 byte, or an
/// error.
@since(version = 0.2.0)
check-write: func() -> result<u64, stream-error>;
/// Perform a write. This function never blocks.
///
/// When the destination of a `write` is binary data, the bytes from
/// `contents` are written verbatim. When the destination of a `write` is
/// known to the implementation to be text, the bytes of `contents` are
/// transcoded from UTF-8 into the encoding of the destination and then
/// written.
///
/// Precondition: check-write gave permit of Ok(n) and contents has a
/// length of less than or equal to n. Otherwise, this function will trap.
///
/// returns Err(closed) without writing if the stream has closed since
/// the last call to check-write provided a permit.
@since(version = 0.2.0)
write: func(contents: list<u8>) -> result<_, stream-error>;
/// Perform a write of up to 4096 bytes, and then flush the stream. Block
/// until all of these operations are complete, or an error occurs.
///
/// Returns success when all of the contents written are successfully
/// flushed to output. If an error occurs at any point before all
/// contents are successfully flushed, that error is returned as soon as
/// possible. If writing and flushing the complete contents causes the
/// stream to become closed, this call should return success, and
/// subsequent calls to check-write or other interfaces should return
/// stream-error::closed.
@since(version = 0.2.0)
blocking-write-and-flush: func(contents: list<u8>) -> result<_, stream-error>;
/// Request to flush buffered output. This function never blocks.
///
/// This tells the output-stream that the caller intends any buffered
/// output to be flushed. the output which is expected to be flushed
/// is all that has been passed to `write` prior to this call.
///
/// Upon calling this function, the `output-stream` will not accept any
/// writes (`check-write` will return `ok(0)`) until the flush has
/// completed. The `subscribe` pollable will become ready when the
/// flush has completed and the stream can accept more writes.
@since(version = 0.2.0)
flush: func() -> result<_, stream-error>;
/// Request to flush buffered output, and block until flush completes
/// and stream is ready for writing again.
@since(version = 0.2.0)
blocking-flush: func() -> result<_, stream-error>;
/// Create a `pollable` which will resolve once the output-stream
/// is ready for more writing, or an error has occurred. When this
/// pollable is ready, `check-write` will return `ok(n)` with n>0, or an
/// error.
///
/// If the stream is closed, this pollable is always ready immediately.
///
/// The created `pollable` is a child resource of the `output-stream`.
/// Implementations may trap if the `output-stream` is dropped before
/// all derived `pollable`s created with this function are dropped.
@since(version = 0.2.0)
subscribe: func() -> pollable;
/// Write zeroes to a stream.
///
/// This should be used precisely like `write` with the exact same
/// preconditions (must use check-write first), but instead of
/// passing a list of bytes, you simply pass the number of zero-bytes
/// that should be written.
@since(version = 0.2.0)
write-zeroes: func(len: u64) -> result<_, stream-error>;
/// Perform a write of up to 4096 zeroes, and then flush the stream.
/// Block until all of these operations are complete, or an error
/// occurs.
///
/// Functionality is equivelant to `blocking-write-and-flush` with
/// contents given as a list of len containing only zeroes.
@since(version = 0.2.0)
blocking-write-zeroes-and-flush: func(len: u64) -> result<_, stream-error>;
/// Read from one stream and write to another.
///
/// The behavior of splice is equivalent to:
/// 1. calling `check-write` on the `output-stream`
/// 2. calling `read` on the `input-stream` with the smaller of the
/// `check-write` permitted length and the `len` provided to `splice`
/// 3. calling `write` on the `output-stream` with that read data.
///
/// Any error reported by the call to `check-write`, `read`, or
/// `write` ends the splice and reports that error.
///
/// This function returns the number of bytes transferred; it may be less
/// than `len`.
@since(version = 0.2.0)
splice: func(src: borrow<input-stream>, len: u64) -> result<u64, stream-error>;
/// Read from one stream and write to another, with blocking.
///
/// This is similar to `splice`, except that it blocks until the
/// `output-stream` is ready for writing, and the `input-stream`
/// is ready for reading, before performing the `splice`.
@since(version = 0.2.0)
blocking-splice: func(src: borrow<input-stream>, len: u64) -> result<u64, stream-error>;
}
}
@since(version = 0.2.0)
world imports {
@since(version = 0.2.0)
import error;
@since(version = 0.2.0)
import poll;
@since(version = 0.2.0)
import streams;
}
+29
View File
@@ -0,0 +1,29 @@
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};
record request {
program: string,
argv: list<string>,
env: list<tuple<string, string>>,
cwd: string,
/// None inherits the parent's group. Zero creates a group. A nonzero ID joins a group.
group: option<u32>,
}
resource child {
pid: func() -> u32;
group: func() -> u32;
stdin: func() -> output-stream;
stdout: func() -> input-stream;
stderr: func() -> input-stream;
status: func() -> option<s32>;
wait: func() -> s32;
kill: func();
}
spawn: func(request: request) -> result<child, string>;
kill: func(pid: u32) -> result<_, string>;
}
world program {
import host;
}
+7
View File
@@ -1,2 +1,9 @@
[build]
target = "wasm32-wasip2"
[env]
SAUSAGE_CWD_INIT = { value = "cwd-init/init.o", relative = true }
[target.wasm32-wasip2]
linker = "cwd-init/link.nu"
rustflags = ["-C", "link-arg=sausage-cwd-init"]
+5194
View File
File diff suppressed because it is too large Load Diff
+9 -1
View File
@@ -1,7 +1,15 @@
# The default CLIs of a world, built for wasm32-wasip2. Its own workspace, outside the host's.
[workspace]
resolver = "3"
members = []
members = ["git", "coreutils", "procps", "cwd-init/probe"]
# WASI compatibility patches: git/vendor/PATCHES.md.
[patch.crates-io]
gix-tempfile = { path = "git/vendor/gix-tempfile" }
gix-pack = { path = "git/vendor/gix-pack" }
gix-index = { path = "git/vendor/gix-index" }
gix-odb = { path = "git/vendor/gix-odb" }
filetime = { path = "git/vendor/filetime" }
[profile.release]
opt-level = 3
+1
View File
@@ -0,0 +1 @@
/source/
+6739
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env nu
# Build the pinned brush source with only its WASI launcher seam replaced.
def copy-if-changed [source: path, destination: path] {
if not ($destination | path exists) or (open --raw $source) != (open --raw $destination) {
cp $source $destination
}
}
def main [] {
let here = $env.FILE_PWD
let source = ($here | path join source)
let revision = '943553433ae04f5b3bd55d2cf207e39eb9ef2af7'
if not ($source | path exists) {
^git clone --no-checkout https://github.com/reubeno/brush $source
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush clone failed'} }
^git -C $source checkout $revision
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush checkout failed'} }
}
if (^git -C $source rev-parse HEAD | str trim) != $revision { error make {msg: 'unexpected brush revision'} }
let core = ($source | path join brush-core)
copy-if-changed ($here | path join process.rs) ($core | path join src sys wasm process.rs)
mkdir ($core | path join sausage-process)
let wit = ($here | path join .. .. host wit | path expand)
copy-if-changed ($wit | path join process.wit) ($core | path join sausage-process process.wit)
mkdir ($core | path join sausage-process deps)
copy-if-changed ($wit | path join deps io.wit) ($core | path join sausage-process deps io.wit)
let manifest = ($core | path join Cargo.toml)
let text = (open --raw $manifest)
let marker = "[target.'cfg(target_family = \"wasm\")'.dependencies]"
if not ($text | str contains 'wit-bindgen =') {
$text | str replace $marker ($marker + "\nwit-bindgen = \"0.46\"") | save -f $manifest
}
copy-if-changed ($here | path join Cargo.lock) ($source | path join Cargo.lock)
$env.SAUSAGE_CWD_INIT = ($here | path join .. cwd-init init.o | path expand)
$env.CARGO_TARGET_WASM32_WASIP2_LINKER = ($here | path join .. cwd-init link.nu | path expand)
$env.CARGO_TARGET_WASM32_WASIP2_RUSTFLAGS = '-C link-arg=sausage-cwd-init'
^cargo build --manifest-path ($source | path join Cargo.toml) -p brush-shell --release --features minimal --no-default-features --target wasm32-wasip2 --locked
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'brush build failed'} }
}
+124
View File
@@ -0,0 +1,124 @@
//! Launches world components and pumps each child's bounded streams independently.
#![allow(clippy::all, missing_docs)]
use crate::{
ExecutionResult,
openfiles::OpenFile,
processes::{ChildProcess, LaunchRequest},
};
use std::io::{Read, Write};
mod bindings {
wit_bindgen::generate!({ path: "sausage-process", world: "program", generate_all });
}
use bindings::{
sausage::process::host,
wasi::io::streams::{InputStream, StreamError},
};
pub(crate) type ProcessId = i32;
fn drain(stream: &mut Option<InputStream>) -> std::io::Result<Vec<u8>> {
let Some(reader) = stream.as_ref() else {
return Ok(Vec::new());
};
match reader.read(8192) {
Ok(bytes) => Ok(bytes),
Err(StreamError::Closed) => {
*stream = None;
Ok(Vec::new())
}
Err(_) => Err(std::io::Error::other("child stream failed")),
}
}
pub(crate) fn spawn(mut request: LaunchRequest) -> std::io::Result<ChildProcess> {
let child = host::spawn(&host::Request {
program: request.program.to_string_lossy().into_owned(),
argv: std::iter::once(request.argv0).chain(request.args).collect(),
env: request.env.into_iter().collect(),
cwd: request.working_dir.to_string_lossy().into_owned(),
group: match request.process_group {
crate::processes::ProcessGroup::Join(group) => Some(group as u32),
crate::processes::ProcessGroup::Inherit => None,
_ => Some(0),
},
})
.map_err(std::io::Error::other)?;
let pid = child.pid() as i32;
let group = child.group() as i32;
let mut send = Some(child.stdin());
let mut receive = Some(child.stdout());
let mut errors = Some(child.stderr());
let mut input = request.open_files.remove_fd(0);
if matches!(input, Some(OpenFile::Stdin(_))) {
input = None;
}
let mut output = request.open_files.remove_fd(1);
let mut error = request.open_files.remove_fd(2);
let pump = tokio::spawn(async move {
let mut pending = Vec::new();
loop {
if pending.is_empty() {
if let Some(reader) = input.as_mut() {
let mut buf = [0; 8192];
match reader.read(&mut buf) {
Ok(0) => input = None,
Ok(n) => pending.extend_from_slice(&buf[..n]),
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => return Err(e),
}
}
}
if let Some(writer) = send.as_ref() {
match writer.check_write() {
Ok(n) if n > 0 => {
let n = (n as usize).min(pending.len());
if n != 0 {
writer
.write(&pending[..n])
.map_err(|_| std::io::Error::other("child stdin failed"))?;
writer
.flush()
.map_err(|_| std::io::Error::other("child stdin flush failed"))?;
pending.drain(..n);
} else if input.is_none() {
// A completed flush must precede EOF, or the writer drops queued bytes.
send = None;
}
}
Ok(_) => {}
Err(_) => {
send = None;
input = None;
pending.clear();
}
}
}
if output.is_none() {
receive = None;
}
let out = drain(&mut receive)?;
let err = drain(&mut errors)?;
if let Some(writer) = output.as_mut() {
if let Err(e) = writer.write_all(&out) {
if e.kind() != std::io::ErrorKind::BrokenPipe {
return Err(e);
}
output = None;
}
}
if let Some(writer) = error.as_mut() {
writer.write_all(&err)?;
}
if receive.is_none() && errors.is_none() {
if let Some(code) = child.status() {
return Ok(ExecutionResult::new(code as u8));
}
}
tokio::task::yield_now().await;
}
});
Ok(
ChildProcess::from_future(async move { pump.await.map_err(std::io::Error::other)? })
.native_child(Some(pid), Some(group)),
)
}
pub(crate) fn exec(_request: LaunchRequest) -> std::io::Error {
std::io::ErrorKind::Unsupported.into()
}
+86
View File
@@ -0,0 +1,86 @@
[package]
name = "sausage-coreutils"
version = "0.1.0"
edition = "2024"
[dependencies]
rustix = { version = "1.1.6", features = ["std"] }
uucore = "=0.12.0"
uu_arch = "=0.12.0"
uu_base32 = "=0.12.0"
uu_base64 = "=0.12.0"
uu_basename = "=0.12.0"
uu_basenc = "=0.12.0"
uu_cat = "=0.12.0"
uu_cksum = "=0.12.0"
uu_b2sum = "=0.12.0"
uu_md5sum = "=0.12.0"
uu_nice = "=0.12.0"
uu_sha1sum = "=0.12.0"
uu_sha224sum = "=0.12.0"
uu_sha256sum = "=0.12.0"
uu_sha384sum = "=0.12.0"
uu_sha512sum = "=0.12.0"
uu_comm = "=0.12.0"
uu_cp = "=0.12.0"
uu_csplit = "=0.12.0"
uu_cut = "=0.12.0"
uu_date = "=0.12.0"
uu_dd = "=0.12.0"
uu_dir = "=0.12.0"
uu_dircolors = "=0.12.0"
uu_dirname = "=0.12.0"
uu_echo = "=0.12.0"
uu_expand = "=0.12.0"
uu_expr = "=0.12.0"
uu_factor = "=0.12.0"
uu_false = "=0.12.0"
uu_fmt = "=0.12.0"
uu_fold = "=0.12.0"
uu_head = "=0.12.0"
uu_hostid = "=0.12.0"
uu_join = "=0.12.0"
uu_link = "=0.12.0"
uu_ln = "=0.12.0"
uu_ls = "=0.12.0"
uu_mkdir = "=0.12.0"
uu_mktemp = "=0.12.0"
uu_mv = "=0.12.0"
uu_nl = "=0.12.0"
uu_numfmt = "=0.12.0"
uu_od = "=0.12.0"
uu_paste = "=0.12.0"
uu_pathchk = "=0.12.0"
uu_pr = "=0.12.0"
uu_printenv = "=0.12.0"
uu_printf = "=0.12.0"
uu_ptx = "=0.12.0"
uu_pwd = "=0.12.0"
uu_readlink = "=0.12.0"
uu_realpath = "=0.12.0"
uu_rm = "=0.12.0"
uu_rmdir = "=0.12.0"
uu_seq = "=0.12.0"
uu_shred = "=0.12.0"
uu_shuf = "=0.12.0"
uu_sleep = "=0.12.0"
uu_sort = "=0.12.0"
uu_split = "=0.12.0"
uu_sum = "=0.12.0"
uu_tail = "=0.12.0"
uu_tee = "=0.12.0"
uu_test = "=0.12.0"
uu_touch = "=0.12.0"
uu_tr = "=0.12.0"
uu_true = "=0.12.0"
uu_truncate = "=0.12.0"
uu_tsort = "=0.12.0"
uu_tty = "=0.12.0"
uu_uname = "=0.12.0"
uu_unexpand = "=0.12.0"
uu_uniq = "=0.12.0"
uu_unlink = "=0.12.0"
uu_vdir = "=0.12.0"
uu_wc = "=0.12.0"
uu_yes = "=0.12.0"
uu_nproc = "=0.12.0"
+98
View File
@@ -0,0 +1,98 @@
//! Dispatches the portable uutils 0.12 multicall component by argv[0].
fn main() {
let argv0 = std::env::args().next().unwrap();
let name = std::path::Path::new(&argv0)
.file_name()
.unwrap()
.to_str()
.unwrap();
let name = if name == "[" { "test" } else { name };
let locale = match name {
"dir" | "vdir" => "ls",
_ => name,
};
uucore::locale::setup_localization(locale).expect("utility supplies localization");
let args = uucore::args_os();
let code = match name {
"arch" => uu_arch::uumain(args),
"base32" => uu_base32::uumain(args),
"base64" => uu_base64::uumain(args),
"basename" => uu_basename::uumain(args),
"basenc" => uu_basenc::uumain(args),
"cat" => uu_cat::uumain(args),
"cksum" => uu_cksum::uumain(args),
"b2sum" => uu_b2sum::uumain(args),
"md5sum" => uu_md5sum::uumain(args),
"nice" => uu_nice::uumain(args),
"sha1sum" => uu_sha1sum::uumain(args),
"sha224sum" => uu_sha224sum::uumain(args),
"sha256sum" => uu_sha256sum::uumain(args),
"sha384sum" => uu_sha384sum::uumain(args),
"sha512sum" => uu_sha512sum::uumain(args),
"comm" => uu_comm::uumain(args),
"cp" => uu_cp::uumain(args),
"csplit" => uu_csplit::uumain(args),
"cut" => uu_cut::uumain(args),
"date" => uu_date::uumain(args),
"dd" => uu_dd::uumain(args),
"dir" => uu_dir::uumain(args),
"dircolors" => uu_dircolors::uumain(args),
"dirname" => uu_dirname::uumain(args),
"echo" => uu_echo::uumain(args),
"expand" => uu_expand::uumain(args),
"expr" => uu_expr::uumain(args),
"factor" => uu_factor::uumain(args),
"false" => uu_false::uumain(args),
"fmt" => uu_fmt::uumain(args),
"fold" => uu_fold::uumain(args),
"head" => uu_head::uumain(args),
"hostid" => uu_hostid::uumain(args),
"join" => uu_join::uumain(args),
"link" => uu_link::uumain(args),
"ln" => uu_ln::uumain(args),
"ls" => uu_ls::uumain(args),
"mkdir" => uu_mkdir::uumain(args),
"mktemp" => uu_mktemp::uumain(args),
"mv" => uu_mv::uumain(args),
"nl" => uu_nl::uumain(args),
"numfmt" => uu_numfmt::uumain(args),
"od" => uu_od::uumain(args),
"paste" => uu_paste::uumain(args),
"pathchk" => uu_pathchk::uumain(args),
"pr" => uu_pr::uumain(args),
"printenv" => uu_printenv::uumain(args),
"printf" => uu_printf::uumain(args),
"ptx" => uu_ptx::uumain(args),
"pwd" => uu_pwd::uumain(args),
"readlink" => uu_readlink::uumain(args),
"realpath" => uu_realpath::uumain(args),
"rm" => uu_rm::uumain(args),
"rmdir" => uu_rmdir::uumain(args),
"seq" => uu_seq::uumain(args),
"shred" => uu_shred::uumain(args),
"shuf" => uu_shuf::uumain(args),
"sleep" => uu_sleep::uumain(args),
"sort" => uu_sort::uumain(args),
"split" => uu_split::uumain(args),
"sum" => uu_sum::uumain(args),
"tail" => uu_tail::uumain(args),
"tee" => uu_tee::uumain(args),
"test" => uu_test::uumain(args),
"touch" => uu_touch::uumain(args),
"tr" => uu_tr::uumain(args),
"true" => uu_true::uumain(args),
"truncate" => uu_truncate::uumain(args),
"tsort" => uu_tsort::uumain(args),
"tty" => uu_tty::uumain(args),
"uname" => uu_uname::uumain(args),
"unexpand" => uu_unexpand::uumain(args),
"uniq" => uu_uniq::uumain(args),
"unlink" => uu_unlink::uumain(args),
"vdir" => uu_vdir::uumain(args),
"wc" => uu_wc::uumain(args),
"yes" => uu_yes::uumain(args),
"nproc" => uu_nproc::uumain(args),
_ => 127,
};
std::process::exit(code);
}
+78
View File
@@ -0,0 +1,78 @@
arch
base32
base64
basename
basenc
cat
cksum
b2sum
md5sum
nice
sha1sum
sha224sum
sha256sum
sha384sum
sha512sum
comm
cp
csplit
cut
date
dd
dir
dircolors
dirname
echo
expand
expr
factor
false
fmt
fold
head
hostid
join
link
ln
ls
mkdir
mktemp
mv
nl
numfmt
od
paste
pathchk
pr
printenv
printf
ptx
pwd
readlink
realpath
rm
rmdir
seq
shred
shuf
sleep
sort
split
sum
tail
tee
test
touch
tr
true
truncate
tsort
tty
uname
unexpand
uniq
unlink
vdir
wc
yes
nproc
+3
View File
@@ -0,0 +1,3 @@
/init.o
/raw.o
/exit-meta.bin
+31
View File
@@ -0,0 +1,31 @@
# Shared WASI startup object
This constructor adopts `PWD` before Rust calls `main`.
The process table supplies `PWD` from the spawn request, not from the host environment.
The constructor exits with status 1 if the directory does not exist.
It preserves one `/` preopen and the whole-world view.
Build the object before you build userland:
```sh
nix develop -c nu --no-config-file userland/cwd-init/build.nu
```
Run workspace Cargo commands from `userland/` so Cargo loads `.cargo/config.toml`.
Its rustflags request the startup object.
The linker wrapper resolves the object path independently of each crate's directory.
The builder must use the same rustflags, wrapper, and object for agent programs.
The object also replaces libc's success/failure exit conversion with standard `wasi:cli/exit.exit-with-code`.
Rust std on this toolchain does not use that interface directly.
The host registers this gated standard function.
The linker places the object before libc to resolve `_Exit` and `_exit`.
Numeric exit codes use the usual low eight bits.
`probe/` uses normal Rust APIs without startup code.
It reads relative and absolute files or calls `std::process::exit` with its first argument.
The standard `initial-cwd` import is preferable to `PWD`.
The current Rust component does not declare that import.
An upstream wasi-libc fix must declare it in the component type and initialize the local cwd before main.
The fix must initialize cwd even when the linker omits `chdir` from program code.
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env nu
# Build the shared startup object and describe its standard WASI numeric-exit import.
def main [] {
let here = $env.FILE_PWD
^wasm-tools component embed ($here | path join exit.wit) --only-custom -o ($here | path join exit-meta.bin)
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'startup metadata failed'} }
^rustc ($here | path join init.rs) --edition 2024 --crate-type lib --emit=obj --target wasm32-wasip2 -C opt-level=s -C panic=abort -o ($here | path join init.o)
if $env.LAST_EXIT_CODE != 0 { error make {msg: 'startup object compile failed'} }
}
+9
View File
@@ -0,0 +1,9 @@
package wasi:cli@0.2.12;
interface exit {
exit-with-code: func(status-code: u8);
}
world exit-hook {
import exit;
}
+46
View File
@@ -0,0 +1,46 @@
//! Initialize wasi-libc's cwd for every program that the world toolchain builds.
#![no_std]
#[used]
#[unsafe(link_section = "component-type:sausage-exit")]
static EXIT_INTERFACE: [u8; include_bytes!("exit-meta.bin").len()] =
*include_bytes!("exit-meta.bin");
unsafe extern "C" {
fn getenv(name: *const u8) -> *const u8;
fn chdir(path: *const u8) -> i32;
}
#[link(wasm_import_module = "wasi:cli/exit@0.2.12")]
unsafe extern "C" {
#[link_name = "exit-with-code"]
fn exit_with_code(code: u32);
}
/// Replace wasi-libc's success/failure conversion with the standard numeric exit interface.
#[unsafe(no_mangle)]
unsafe extern "C" fn _Exit(code: i32) -> ! {
unsafe {
exit_with_code((code as u32) & 255);
}
core::arch::wasm32::unreachable()
}
#[unsafe(no_mangle)]
unsafe extern "C" fn _exit(code: i32) -> ! {
unsafe { _Exit(code) }
}
extern "C" fn initialize() {
// PWD is supplied by the process table, never inferred from a host directory.
unsafe {
let cwd = getenv(c"PWD".as_ptr().cast());
if !cwd.is_null() && chdir(cwd) != 0 {
_exit(1);
}
}
}
#[used]
#[unsafe(link_section = ".init_array")]
static INITIALIZE: extern "C" fn() = initialize;
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env nu
# Cargo resolves the object path through its config, independently of each crate's directory.
def --wrapped main [...args: string] {
let include = ($args | any {|arg| $arg == 'sausage-cwd-init' })
let args = ($args | where {|arg| $arg != 'sausage-cwd-init' })
if $include {
# Resolve exit symbols before libc's archive selects its success/failure implementation.
let prefix = ($args | first 2)
let rest = ($args | skip 2)
^wasm-component-ld ...$prefix $env.SAUSAGE_CWD_INIT ...$rest
} else {
^wasm-component-ld ...$args
}
exit $env.LAST_EXIT_CODE
}
+4
View File
@@ -0,0 +1,4 @@
[package]
name = "sausage-cwd-probe"
version = "0.1.0"
edition = "2024"
+9
View File
@@ -0,0 +1,9 @@
//! Probe normal Rust filesystem APIs without any cwd startup code.
fn main() {
if let Some(code) = std::env::args().nth(1) {
std::process::exit(code.parse().unwrap());
}
println!("cwd={}", std::env::current_dir().unwrap().display());
println!("relative={}", std::fs::read_to_string("f").unwrap().trim());
println!("absolute={}", std::fs::read_to_string("/f").unwrap().trim());
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "sausage-procps"
version = "0.1.0"
edition = "2024"
[dependencies]
wit-bindgen = "0.46"
+31
View File
@@ -0,0 +1,31 @@
//! ps reads the process snapshot. kill stops a process group through the process interface.
mod bindings {
wit_bindgen::generate!({ path: "../../host/wit", world: "program", generate_all });
}
fn main() {
let args: Vec<String> = std::env::args().collect();
match std::path::Path::new(&args[0])
.file_name()
.unwrap()
.to_str()
.unwrap()
{
"ps" => print!(
"{}",
std::fs::read_to_string("/proc/processes").expect("process snapshot")
),
"kill" => {
if args.len() < 2 {
std::process::exit(2);
}
for pid in &args[1..] {
let pid: u32 = pid.parse().expect("numeric pid");
if let Err(error) = bindings::sausage::process::host::kill(pid) {
eprintln!("kill: {error}");
std::process::exit(1);
}
}
}
_ => std::process::exit(127),
}
}