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:
Generated
+4559
-65
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -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.
|
||||
Vendored
+27
@@ -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
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
Generated
+1687
File diff suppressed because it is too large
Load Diff
@@ -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" }
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
Generated
+5194
File diff suppressed because it is too large
Load Diff
+9
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/source/
|
||||
Generated
+6739
File diff suppressed because it is too large
Load Diff
@@ -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'} }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
/init.o
|
||||
/raw.o
|
||||
/exit-meta.bin
|
||||
@@ -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.
|
||||
@@ -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'} }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package wasi:cli@0.2.12;
|
||||
|
||||
interface exit {
|
||||
exit-with-code: func(status-code: u8);
|
||||
}
|
||||
|
||||
world exit-hook {
|
||||
import exit;
|
||||
}
|
||||
@@ -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;
|
||||
Executable
+15
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
[package]
|
||||
name = "sausage-cwd-probe"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "sausage-procps"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "0.46"
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user