Compare commits

..
3 Commits
Author SHA1 Message Date
agent-pool-8 6e07efaaa0 Borrow host descriptors while tracking VFS paths
ci / check (push) Successful in 13m25s
2026-10-16 21:09:59 +00:00
agent-pool-8 14f086423b Keep discovery test file at served root
ci / check (push) Failing after 6m54s
2026-10-16 20:59:09 +00:00
agent-pool-8 e32bfc57e2 Expose advertised actor-served files in directory listings 2026-10-16 20:59:09 +00:00
177 changed files with 5965 additions and 25949 deletions
+1 -5
View File
@@ -1,8 +1,4 @@
target/ /target
/.cargo-home /.cargo-home
/world /world
/result /result
# Lock files of test and example crates: they resolve against the workspace lock.
**/tests/**/Cargo.lock
/examples/*/Cargo.lock
+16 -27
View File
@@ -5,30 +5,30 @@ Keep it that way.
## Priorities ## Priorities
1. **Correct.** Every behavior in `PROTOCOL.md` has a test in `cli/tests/host/`. 1. **Correct.** Every behavior in `PROTOCOL.md` has a test in `cli/tests/`.
2. **Simple and short.** Prefer the obvious code. No abstraction for one 2. **Simple and short.** Prefer the obvious code. No abstraction for one
caller. No configuration that nobody asked for. caller. No configuration that nobody asked for.
3. **Readable.** A new reader must understand a file without the others. 3. **Readable.** A new reader must understand a file without the others.
Each module starts with a doc comment that says what it owns. Each module starts with a doc comment that says what it owns.
4. **Efficient** on the paths that run per call. 4. **Efficient** on the paths that run per message.
5. **Raw and minimal.** Sausage has no permissions, sandboxing beyond WASI, 5. **Raw and minimal.** Sausage has no permissions, sandboxing beyond WASI,
limits, or authentication. Do not add them to the core. Add nothing to limits, or authentication. Do not add them to the core. Add nothing to
the host that a plugin (or the agent, by changing itself) could do. the host that an actor (or the agent, by changing itself) could do.
## Layout ## Layout
- `cli/`: the host (`sausage` binary and library): the core, the native - `cli/`: the host (`sausage` binary and library).
`cargo` plugin, the WASI test runner, and the console. - `lib.rs`: `Host`, which starts and stops a world.
- `vfs/`: `sausage-vfs`, the virtual filesystem for WASI guests. It knows - `broker.rs`: routing, mailboxes, the version store, broker requests.
nothing about Sausage. - `supervisor.rs`: runs one WASM actor (restarts, swaps, rollbacks).
- `interface/`: `sausage-interface`: OpenRPC documents, the compatibility - `runtime.rs`: Wasmtime instances and what they get (mounts, network).
check, and the generator of client code. - `candidate.rs`: tests a new version. `cargo_actor.rs`: builds Rust.
- `sdk/`: `sausage-plugin`, the SDK for Rust plugins, and `sdk/macros/`, its - `world.rs`: the on-disk layout. `console.rs`: the terminal.
macros. Never published: `cargo` points `sausage-plugin = "0.1"` at its - `sdk/`: `sausage-actor`, the SDK for Rust actors. Never published: the
local copy. `cargo` actor points `sausage-actor = "0.1"` at its local copy.
- `agent/`: the agent plugin. `sausage init` embeds and builds it. - `agent/`: the agent actor. `sausage init` embeds and builds it.
- `examples/`: small plugins. They are not workspace members. - `examples/`: small actors. They are not workspace members.
- `PROTOCOL.md`: the contract between the core and the plugins. Change it in - `PROTOCOL.md`: the contract between the host and the actors. Change it in
the same commit as the behavior. `DECISIONS.md` says why; when the two the same commit as the behavior. `DECISIONS.md` says why; when the two
disagree, `PROTOCOL.md` and the code win. disagree, `PROTOCOL.md` and the code win.
@@ -36,21 +36,10 @@ Keep it that way.
```sh ```sh
nix flake check -L # all tests below; CI runs this on every push nix flake check -L # all tests below; CI runs this on every push
nix develop -c cargo test --workspace # fast with a warm cache: run it during development nix develop -c cargo test -p sausage -p sausage-actor -p agent
nix develop -c cargo test -p sausage --test host -- core:: # one topic of the host tests
nix develop -c cargo build --release -p agent --target wasm32-wasip2 nix develop -c cargo build --release -p agent --target wasm32-wasip2
nix develop -c cargo clippy --workspace nix develop -c cargo clippy --workspace
``` ```
`nix run -- init` and `nix run -- run` must keep working without other `nix run -- init` and `nix run -- run` must keep working without other
setup. setup.
Tests must stay fast:
- Only a few tests may wait for a timing (grace periods, deadlines).
- Poll for a condition with a deadline. Do not sleep.
- Do not test unrealistic load, such as many fast cycles.
- `#[ignore]` only disables a test for a short time, with the reason in the
attribute.
- Tests that build plugins use the persistent cache in
`env!("CARGO_TARGET_TMPDIR")`.
Generated
+35 -864
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,11 +1,10 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = ["cli", "vfs", "interface", "sdk", "sdk/macros", "agent"] members = ["cli", "agent", "sdk"]
default-members = ["cli"] default-members = ["cli"]
# Examples are standalone plugins, each with its own empty [workspace].
exclude = ["examples"] exclude = ["examples"]
# Wasmtime compiles components with Cranelift. Optimize dependencies in dev # Wasmtime compiles components with Cranelift. Optimize dependencies in dev
# builds, so that tests and debug runs start plugins quickly. # builds, so that tests and debug runs start actors quickly.
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 2 opt-level = 2
+40 -105
View File
@@ -1,120 +1,55 @@
# Decisions # Decisions
Why Sausage is the way it is. `PROTOCOL.md` and the code own the details. Why Sausage is the way it is. `PROTOCOL.md` and the code own the details;
When this file disagrees with them, they win. when this file disagrees with them, they win.
## The shape ## The shape
- **JSON-RPC, not component interfaces.** Every language can read and write - **Actors are WASI commands, not components with interfaces.** stdin,
lines of JSON, and models know JSON-RPC from LSP and MCP. A call has a stdout, files, HTTP, and sockets are enough, and every language can do
result, like an LLM tool call. them.
- **A process per call.** WASI has no threads, so processes give - **Messages are JSON lines through a broker, fire and forget.** Like
parallelism. A new version applies at the next call, and old processes Erlang: no acknowledgments, no request/response. A reply is a message with
retire after a grace period. `reply_to`. Sausage is not a reliable message bus.
- **Attached by default.** A process does not outlive its caller by - **The broker accepts only `.wasm` files.** It knows no source language.
accident. A long-running process must ask for `detach`. `cargo` is one compiler actor; others can add more languages.
- **Services in one file.** Only the core starts and restarts them, so - **The world folder is the source of truth.** Everything about an actor is
upgrades and singletons need no logic in plugins. in `actor/<id>/`. Copy a folder into another world, and it runs there.
- **Files to inspect, calls to act.** This keeps the core API small, and the - **The agent is a normal WASM actor.** It can read and upgrade its own
agent's file tools are enough to explore the world. source, and change how it does inference.
- **The core accepts only `.wasm` files.** It knows no source language.
`cargo` is one way to get a `.wasm` file.
- **The world folder is the source of truth.** Copy a plugin folder into
another world, and it works there.
- **The agent is a normal plugin.** It can read and upgrade its own source,
and change how it does inference.
## Interfaces
- **Generated imports and interface checks, without version numbers.** A
model that translates a description by hand makes errors. A generator does
not. The check catches the most common error in a world that changes
itself: a change to one plugin breaks another one.
- **A small schema subset.** With Draft 7 and a fixed subset, our own check
can decide every case. It never has to guess.
- **Docs are part of the interface.** Other agents work from them, so the
SDK requires a doc comment on each method, type, and field.
- **The check covers calls, not state.** Old and new versions share
`/state`. A plugin keeps its state format compatible, or migrates it in
`rpc.retired`.
## Minimal on purpose ## Minimal on purpose
The core does only what a plugin cannot do for itself. Everything else can be The harness does only what it needs to keep going. Everything else can be an
a plugin, or a change that the agent makes to itself. actor, or a change that the agent makes to itself.
- **No permissions, no limits, no hiding.** Any process can call, upgrade, - **The host is not self-modifiable; actors are.** So the host keeps only
or stop any plugin, read and write every file, and use the network. The what an actor cannot do for itself: routing, running instances, versions,
checks protect against mistakes, not against plugins. Locks are advisory. tests before an install, and the rollback of a version that crash-loops.
- **Locks are in the core.** A plugin cannot see that another process died, Without that rollback, a broken self-upgrade would end the agent.
so it cannot release that process's lock. The core can. - **No permissions, no limits, no hiding.** Any actor can message, upgrade,
- **Our own VFS, in its own crate.** It works on Linux and macOS, it makes or stop any actor, read every folder, and use the network. People who want rules
`/proc` live, and plugin-served files need it later. Mounts now, overlays can build them.
later. - **No re-delivery and no state snapshots.** A crash loses the message, and
- **TCP endpoints for now.** They need no host code. VFS pipes can replace all versions of an actor share `data/` and `public/`. A rollback restores
them later behind the same SDK API. code, not data.
- **Simple scheduling for now.** An epoch tick every 10 milliseconds keeps
the core responsive and makes kills reliable.
- **The agent is dumb.** It has a short prompt, a few tools, an append-only - **The agent is dumb.** It has a short prompt, a few tools, an append-only
history that it sends in full, and no compaction. When the context is too history that it sends in full, and no compaction. When the context is too
large, it stops sending the oldest turns, so it never gets stuck. large, it stops sending the oldest turns, so it never gets stuck.
## Small choices ## Small choices
- **Restart is a clean stop supervised by `#loop`.** `core.restart` requires - **Plain POSIX files.** An earlier version staged every write until close.
the host startup marker `SAUSAGE_LOOP=1`, which the loop exports for its That broke `tempfile`, git, and SQLite, which expect normal files. Atomic
invocation. Without a supervisor, restarting would only stop the world. publishing is the usual temporary file + `rename`.
A single pending request answers first, allows 250 ms for delivery, then - **Test first, then switch.** A new version is tested where it lies. Only
signals the existing shutdown path; it does not use `process::exit` or a version that passes goes to `versions/`, and the old one runs until
bypass detached-process cleanup, lock release, or journal draining. The then. `versions/` keeps every version that was installed, so a rollback
executable returns dedicated restart code **75** after cleanup. `#loop` can always go back. A file that fails stays where it was put.
refreshes and starts again on 75 (and other nonzero exits); 0 stops it. - **Tests are JSON transcripts** next to the source. They run on a copy of
`core.exit` is a compatibility alias with the same supervisor gate, the actor's state, without network, so a test cannot send real requests.
response grace, and coalescing; new callers use `core.restart`. - **`broker` and `cargo` never answer a reply,** so no two actors answer
**HARD RULE: the mediator and all agents NEVER call `core.restart` without each other forever. The agent starts a turn only for text that is not a
the person's explicit approval each time.** This reply, for the same reason.
approval rule also covers legacy `core.exit`; it must not bypass the gate. - **The SDK is not published.** `cargo` points `sausage-actor = "0.1"` at its
own copy, which it also shows in `/actor/cargo/public/sdk/`.
- **YAML for configuration, JSON for data.** A person edits configuration, so
it needs comments. `init` writes commented templates instead of asking
questions, so there is one way to configure a world.
- **Plain POSIX files.** Programs like `tempfile`, git, and SQLite expect
normal files. Atomic writes use a temporary file and `rename`.
- **Tests run under WASI.** `cargo test` on the host cannot find failures
that occur only in WASI, for example a missing WASI function or a panic at
start. The test runner uses the same Wasmtime and the same mounts as the
core.
- **Tests never run without a timeout.** `cargo.build` serializes its work
folder, so one hung test or build script must not block all later builds.
`timeout_secs` defaults to 900 seconds after mutex acquisition, excluding
queue time; `test_timeout_secs` defaults to 120 seconds per WASI instance.
Both are optional positive integers, never null/unlimited. Test timeouts name
the failing test and let other instances finish. Build deadlines and
cancellation kill cargo's process group before dropping the mutex; timeout
errors retain the phase, elapsed time, and recent output. Source preparation
and publication retain serialization while their blocking filesystem work
finishes, even if cancelled, to avoid overlapping writes to the work folder.
- **The SDK is not published.** `cargo` points `sausage-plugin = "0.1"` at
its own copy, which it also shows in `/plugins/cargo/sdk/`.
- **Compiled code is a cache, not state.** It lives outside the world, in
one cache folder that all worlds share: `wasmtime/` (owned by Wasmtime,
whose cleanup deletes unknown entries), `cargo-target/`, and
`sdk/<hash>/`. A path that depends only on the content of the SDK lets
cargo reuse the compiled SDK in every world. Tests run in the release
profile, so that they share compiled crates with the release build.
- **mold links host code where Nix provides it.** On Linux, the dev shell,
the Nix builds, and the packaged `sausage` (for the proc macros and build
scripts of plugins) link with mold, much faster than GNU ld. Plugins for
`wasm32-wasip2` link with rust-lld. Without Nix, the default linker stays.
- **No LTO.** Measured: fat LTO makes the binary 28 % smaller, but compiles
plugins and answers calls at most 5–8 % faster, for 4 more minutes per
release build. Thin LTO brings nothing.
- **Discovery has a deadline of 10 seconds.** It is an internal call of the
core, not a limit on plugins. Without it, one plugin that does not answer
would block every activation.
- **A broken plugin blocks nobody.** A plugin without a document is already
broken, so the check cannot protect it. The graph shows it as
"unavailable", and `core.revert` and `force` always work.
- **`rpc.retired` is best effort.** It does not run if the host stops first.
A persistent queue of retirements would be more core for a rare case, so a
new version checks its own migration marker instead.
+166 -578
View File
@@ -1,622 +1,210 @@
# The Sausage protocol # The Sausage protocol
This is the contract between the core and the plugins. A plugin in any This is the contract between the host and the actors. An actor in any
language that follows it works in a Sausage world. The core shows this file language that follows it works in Sausage. The host publishes this file at
at `/proc/core/PROTOCOL.md`. `DECISIONS.md` in the repository says why the `/actor/broker/public/PROTOCOL.md`, so every actor can read it.
contract is the way it is.
Rust plugins use the SDK `sausage-plugin`. Its source is at ## Actors
`/plugins/cargo/sdk/`, and its crate docs explain each item.
## 1. Terms An actor is a WASI 0.2 command (`wasm32-wasip2`): a program with `main`. The
host runs one instance per actor. An instance can run forever.
| Term | Meaning | The instance gets:
| --- | --- |
| world | The folder that a `sausage` host runs. It is `/` for every process. |
| core | The host. It runs processes, routes calls, serves the virtual files, and keeps the journal. |
| plugin | A folder `/plugins/<name>/` with a `plugin.wasm`. A plugin does not run by itself. |
| process | One run of a plugin version. Its ID is `<plugin>-<uuidv7>`, for example `notes-0197f3a2-7c1e-7d2b-9a41-5f0c2e8b1d34`. |
| call | A JSON-RPC request. `notes.add` calls the method `add` of the plugin `notes`. |
| interface | A set of methods. A plugin has one main interface. It can also declare endpoint interfaces. |
| endpoint | An interface that a running process serves on a TCP port. An endpoint file tells others where it is. |
| service | A call in `/config/boot.yaml`. The core starts it at boot, restarts it after a crash, and starts it again after an upgrade of its plugin. |
| import | An interface of another plugin that a plugin uses. |
| provider, consumer | The plugin that has an interface, and a plugin that imports it. |
| journal | The central log of the world. |
## 2. The world folder - **stdin:** one message per line, from the broker
- **stdout:** one message per line, to the broker
- **stderr:** free text, appended to `/actor/<id>/stderr.log`
- **environment:** `SAUSAGE_ACTOR` (its ID), `SAUSAGE_VERSION` (its version),
and `SAUSAGE_CANDIDATE=1` during a test run
- **the network:** outgoing HTTP and HTTPS through `wasi:http` (the host does
TLS), and TCP, UDP, and DNS through `wasi:sockets`, including listening on
a port of the host
- **the filesystem** described below
```text ## Messages
<world>/
plugins/<name>/ A line on **stdin**:
plugin.wasm the current version
plugin.prev.wasm the version before, for `core.revert` ```json
source/ the source. `cargo.build` builds it from here. {"id": "m-17", "from": "console", "reply_to": "agent/a5", "body": {"type": "text", "text": "hi"}}
imports/<plugin>.json descriptions of the imported plugins
state/<plugin>/ persistent state of a plugin, by convention
logs/core/journal.sqlite the journal
state/core/ console history (state, not logs)
config/ global configuration
boot.yaml the services
agent.yaml the API, base URL, model, and key of the agent
``` ```
- Every process can read and write all of it. There are no permissions. A line on **stdout**:
- Configuration files are YAML, with comments. Data and interface files are
JSON.
- Plugin names use `a-z`, `0-9`, `-`, and `_`. The name `core` is reserved.
- The world folder must not contain `proc`, `run`, or `self`. These names are
mount points (section 4). The host does not start a world that has them.
## 3. Calls and processes ```json
{"to": "console", "id": "a6", "reply_to": "m-17", "body": {"type": "text", "text": "hello"}}
```
### 3.1 The wire protocol - `body` is required. By convention it is an object with a `"type"`.
Text for people is `{"type": "text", "text": "..."}`.
- `to` is the recipient. Without `to`, the broker publishes the message to
every subscriber of the sender.
- `id` is an optional string. If the actor sets it, the message ID is `"<sender>/<id>"`.
Otherwise, the broker assigns `m-<n>`. The broker always sets `from`.
- `reply_to` marks a reply: its value is the ID of the message it answers.
There is no request/response. A reply is a new message.
- **Never answer a reply,** or two actors can answer each other forever.
`broker` and `cargo` ignore messages that have a `reply_to`.
- Messages are fire and forget: delivery is at most once. Each actor has a
mailbox of 1,000 messages, which keeps messages while the actor restarts
or is upgraded. If the broker cannot deliver a message, the sender gets
`{"type": "undeliverable", "reason": "...", "to": "..."}` from `broker`.
- An actor must keep reading stdin. If the broker must stop or upgrade an
actor while a message waits for it to read stdin, it kills the actor.
- A plugin is a `wasi:cli/command` component for `wasm32-wasip2`. Before the broker stops an instance, it sends
- stdin and stdout carry JSON-RPC 2.0, one message per line. `{"id": "broker/shutdown", "from": "broker", "body": {"type": "shutdown"}}`
- A line is a message only if it is a JSON object with `"jsonrpc": "2.0"`. and then closes stdin. The broker still routes what the actor writes until
The core discards every other line on stdout. For the first discarded line it exits. After 5 seconds, it kills the instance.
of a process, it writes one warning to the journal.
- Each line on stderr goes to the journal. Log to stderr.
- `params` is always an object. Batches are not supported.
- Write each message in one write, and write a newline before it. Then a
`print!` without a newline cannot damage a message.
### 3.2 Method names ## Filesystem
- A method name has the form `<target>.<method>`. The core splits it at the Each instance sees three folders:
first `.`.
- `notes.add` starts a new process of `notes` and sends it the request `add`.
`notes.rpc.discover` sends it `rpc.discover`.
- `core.<method>` is a method of the core (section 9).
- Method names that start with `rpc.` are reserved: `rpc.discover`,
`rpc.superseded`, `rpc.retired`, and `rpc.endpoint`.
### 3.3 What a call does | Path | Access | Contents |
1. The core gives the call a UUIDv7 and starts the process `<plugin>-<uuid>`
with the current version of the plugin.
2. The core writes the request to the stdin of the process. The request ID is
the process ID, and the method has no plugin prefix.
3. The process writes the response to stdout. The core sends the response to
the caller, with the caller's request ID.
4. If the process ends before it answers, the caller gets the error -32002.
Its `data` has the exit status and the last journal lines of the process.
Before the core decides this, it reads the stdout of the process to the
end.
- A process can write calls to stdout at any time, to the core and to
plugins. The responses come on stdin. A process can have many calls in
flight.
- The core keys each pending request by the process and the exact JSON ID.
The core drops a response with an unknown or late ID.
- A notification to `plugin.method` (without an ID) starts an attached
process. That process gets the notification without an ID, and nobody gets
a result.
### 3.4 Detached calls
`core.call {"method": "www.start", "params": {...}, "detach": true}` makes the
call and returns its result to the caller. The new process has no parent, so
it does not end with its caller.
Sent as a notification (without an ID), the same `core.call` starts the
process and forgets it.
### 3.5 Notifications to the caller
A process can tell its caller about progress. It sends the notification
`core.notify {"name": "text", "params": {...}}`. The core gives it to the
caller as `core.notify {"call": <ID of the caller's request>, "name": "text",
"params": {...}}`. After the call has ended, the core drops such
notifications.
So a process never needs to know who called it.
### 3.6 Lifetime
A process lives until `main` returns. A process that uses the SDK lives
while one of these runs:
- a handler of a call,
- a background task,
- an endpoint that listens.
A process has three states:
1. **running**
2. **stopping**: the core has closed stdin. EOF on stdin means "stop now".
- New calls of the process fail at once, and its pending calls fail.
- A stop hook can still write files and send notifications. It cannot
make calls, because no response can arrive.
- After 5 seconds, the core kills the process.
3. **ended**: the core releases the locks of the process (section 6) and
removes its folder in `/proc`.
Rules:
- `core.stop {pid}` starts the stopping state. `core.kill {pid}` kills the
process at once.
- When a process ends, the core stops each of its attached children. All
children stop in parallel, so a whole tree takes 5 seconds at most.
- A detached process has no parent. Only `core.stop`, `core.kill`, an
upgrade (section 3.7), and the end of the host stop it.
- At host shutdown, the core starts no new processes, and it stops every
process in parallel.
Note: on Unix, children outlive their parent. Here, an attached child ends
with its parent. Only `detach` makes a process independent.
### 3.7 Upgrades
A process is an instance of one plugin version. When a new version becomes
active (section 10.3):
1. New calls get the new version at once.
2. At grace start, the core sends each process of an older version the
JSON-RPC notification `rpc.superseded {"current": "<sha256 of the new file>"}`
on stdin, without an ID. The process is still running: calls and responses
continue to work. It can stop accepting work, finish its in-flight calls,
and exit early. Ignoring the notice changes nothing. The grace period is
the upper bound before the core stops the process (section 3.6); its
duration is 5 seconds for every plugin. Discovery metadata, including an
explicit `x-sausage-grace`, does not change it. After grace, stdin closes
and the separate 5-second stop budget applies (section 3.6): at most two
5-second windows. Discovery-only processes do not receive this notice or
enter upgrade grace; they follow the discovery lifetime (section 10.1).
3. When the old process of a service has ended, the core starts the service
again with the new version.
4. When the last process of an older version has ended, the core starts a
process of the new version and calls `rpc.retired {"previous":
"<sha256 of the older file>"}`. A plugin can use this hook to migrate its
state, or to write a marker that its other new processes wait for. The
result goes to the journal.
During the grace period, an old process can call a method that a new
provider no longer has.
If the host stops before the last old process has ended, `rpc.retired` does
not run for that version. So a new version must also work with state that
was not migrated. Check for a migration marker, and migrate when it is
missing.
### 3.8 Environment and network
- `SAUSAGE_PID`: the process ID.
- `SAUSAGE_PLUGIN`: the plugin name.
- `SAUSAGE_PARENT`: the parent: `console`, `core`, or a process ID.
Every process gets the network: outgoing HTTP and HTTPS through `wasi:http`,
and TCP, UDP, and DNS through `wasi:sockets`.
A process that computes for a long time does not block other processes. The
core interrupts it every 10 milliseconds.
### 3.9 Errors
The standard JSON-RPC codes apply. The core adds these codes:
| Code | Meaning |
| --- | --- |
| -32001 | There is no such plugin. |
| -32002 | The process ended without an answer. `data` has the exit status and the last journal lines. |
| -32003 | The activation was rejected. `data` has the reasons. |
| -32004 | `cargo.build` failed. `data` has the `output` of the failed step and the `tests`. |
## 4. The filesystem
Each process gets one preopen, `/`. The core resolves each path through a
mount table:
| Path | What it is | Access |
| --- | --- | --- | | --- | --- | --- |
| `/` | the world folder | read-write | | `/data` | read-write | the actor's state |
| `/proc` | generated by the core | read-only | | `/public` | read-write | the files that the actor publishes |
| `/self` | the folder `/proc/<own pid>/`, in memory | read-write | | `/actor` | read-only | the `actor/` folder of the world |
| `/run` | in memory, shared, empty at each host start | read-write |
A listing of `/` shows `proc`, `run`, and `self`. A rename across two of `/actor/<id>/` holds everything about an actor: `actor.wasm`, `actor.json`
these areas fails. (installed version, running, subscriptions, tapped), `source/`, `public/`, `data/`,
`stderr.log`, and `versions/<n>/`. `ls /actor` lists all actors.
```text Files are plain POSIX files: a write is visible at once. To publish a file
/proc/ so that readers never see half of it, write a temporary file and `rename` it
core/ (the Rust SDK has `write_atomic`).
PROTOCOL.md this file
graph.json plugins, imports, and the result of each check
graph.dot the same graph for Graphviz
locks.json held locks and waiting processes
plugins/<name>/
discover.json the rpc.discover document of the current plugin.wasm
client.rs the Rust code that import!("<name>") generates
<pid>/
info.json plugin, version, method, params, parent, detached, start time, state
log the journal lines of this process
<files> the files that the process writes to /self
```
Rules: Send large or binary data as a path under `/actor/<id>/public/`, not inside a
message.
- A process writes its own folder only through `/self`. Writes through ### Actor-served files: `/actor/<id>/fs/`
`/proc` always fail.
- In a process folder, the names `info.json`, `log`, and `fs` belong to the
core. A write to `/self/info.json` fails.
- A read of a generated file returns a snapshot that ends. For example,
`/proc/<pid>/log` returns the lines up to the time of the open.
- The folder of a process goes away when the process ends. Its files are in
memory. Large or persistent data belongs in `/state`.
- Do not send a `/self` path to another process. There it means a different
folder, like `localhost` on another machine.
Principle: **inspect through files, act through calls.** Files under `/actor/<id>/fs/` are not on disk: the actor `<id>` serves them.
Every actor can read and write them. The host turns each file operation into a
message from `fs` to `<id>` and waits up to 10 seconds for the reply. `path`
is relative to `fs/` and starts with `/` (the folder itself is `/`).
### File watching To make this virtual directory discoverable, an actor that serves it declares
`Actor-served files` and `fs_read` in its leading `//!` documentation in
`source/src/main.rs`. The host uses that declaration when listing
`/actor/<id>/`; it does not probe every actor (which could stall on actors that
do not handle filesystem messages). This is discovery metadata only: opening
and reading the path still requires a valid `fs_read` reply.
`core.watch {"path": "/state/session/mailbox"}` stays open for the caller | Request | Reply |
process's lifetime. It needs a request ID and a running plugin caller. | --- | --- |
Its first `core.notify` is `"armed"`, with empty params. Only then scan the | `{"type": "fs_read", "path"}` | `{"type": "fs_file", "text"}` (or `"base64"` instead of `"text"`), or `{"type": "fs_dir", "entries": [{"name", "type": "file" \| "dir"}]}` |
files. Writes before arming are visible in that scan; writes after arming | `{"type": "fs_write", "path", "text"}` (or `"base64"` for non-UTF-8 data) | `{"type": "fs_done"}` |
produce `"changed"` notifications (also with empty params). Rescan after | `{"type": "fs_mkdir", "path"}`, `{"type": "fs_remove", "path"}` | `{"type": "fs_done"}` |
each notification; files, not event counts, are the truth. | `{"type": "fs_rename", "path", "to"}` | `{"type": "fs_done"}` |
Notifications follow successful plugin-originated file mutations: creation, Any request can be answered with `{"type": "fs_error", "errno": "no-entry"}`.
truncation, descriptor/stream writes, rename (both source and destination), `errno` is a `wasi:filesystem` error code: `no-entry`, `exist`,
and deletion, including mounted `/run` and `/self` files. Paths are normalized `not-directory`, `is-directory`, `not-empty`, `access`, `not-permitted`,
in the virtual namespace; `/self` means the caller's `/proc/<pid>` folder. `invalid`, `unsupported`, `busy`, or `io` (also for anything else).
A watch matches itself and descendants, not sibling prefixes. Moving or
removing an ancestor also invalidates it. Paths need not exist when armed.
Open file writes follow plugin renames; unlinked/replaced handles no longer
name visible files. External host writes are outside this contract.
Changes may coalesce, including a change already visible in the initial - Opening a file sends `fs_read` once. Reads come from that copy.
scan, but a change after the last notification always produces another. - After a change, closing (or syncing) the file sends the whole file in one
Delivery holds no filesystem locks. Observation does not serialize descriptor `fs_write`.
IO; only overlapping opens and rename/removal wait for one another. - No reply within 10 seconds, or an actor that does not exist, makes the
Watch the narrowest useful path: delivery is not rate-limited, so watching a operation fail (`io`, or `no-entry`). An actor that opens its own `fs/`
busy parent can cause a rescan for every write. waits for itself and fails after the timeout.
- `fs` is reserved: no actor can have that ID.
The host-side watch ends only when its caller process exits, not on a change ## The broker
notification; there is no cancellation API or polling/retry loop. SDK callers
should poll the watch future alongside their handler's work and drop it on
return, not keep it in a background task that prevents an idle process from
exiting. Dropping the future removes the SDK's pending entry and callback, so
late notifications and responses are ignored; it does not cancel the host
watch. A long-lived service keeps one watch per purpose for its lifetime,
rather than repeatedly opening and dropping watches.
## 5. Endpoints Requests to `broker`. The answer is a message whose `reply_to` is the ID of
the request: the request's `type` in past tense (`spawned`, `upgraded`,
`rolled_back`, `stopped`, `started`, `removed`, `subscribed`, `unsubscribed`,
`tapping`, `untapped`, `scanned`, `exiting`), or `{"type": "error", "reason": "..."}`.
An endpoint is a JSON-RPC interface that a running process serves over TCP, | `type` | Fields | Effect |
for example the control interface of a web server.
- The process binds a free port on 127.0.0.1. When the listener is ready, it
writes an endpoint file in one atomic step (write a temporary file, then
rename it). The file has one line: the address, the owner process, and the
interface. Example: `tcp:127.0.0.1:41234 www-0197… control`.
- Usually the file is in `/self`. A service that others must find uses a
fixed path in `/run`, for example `/run/secrets`.
- A client reads the file and connects. Its first request is
`rpc.endpoint`, which returns `{"owner", "plugin", "interface"}`. If they
do not match the file, the client disconnects. So a stale file or a reused
port cannot connect a client to the wrong process.
- JSON-RPC runs on the connection, one message per line, in both directions.
Notifications of a handler (`core.notify`) go over the connection.
- A plugin declares its endpoint interfaces in `rpc.discover`
(`x-sausage-endpoints`).
## 6. Locks
- `core.lock {"path": "/state/notes/notes.json"}` returns `{"held": true}`
when the caller holds the lock.
- Each acquisition gets its own place in a FIFO queue. The same process
cannot take one lock twice: its second acquisition waits behind its first.
- With `"wait": false`, the call returns at once with `{"held": true}` or
`{"held": false}`.
- `core.unlock {path}` (a notification) releases the lock.
- When a process ends, the core releases all of its locks, also after a
crash or a kill. It waits until the last file operation of the process has
returned. So a write of a killed process cannot land after the next
process got the lock.
- Locks are advisory. The core does not block writes to the file: the path
is only a name.
- SQLite has no file locks in WASI. Hold the lock while the database is open.
## 7. The journal
- New entries go to `/logs/core/journal.sqlite`. Only the core writes it.
An existing `/state/core/journal.sqlite` stays in place and is opened
read-only; queries merge both databases by entry ID before applying the
limit. The host never renames a live SQLite/WAL trio. Console history stays
in `/state/core`. No journal or log files are automatically pruned.
- If a journal transaction fails, the complete batch (including original
IDs and timestamps) is saved in an exclusive, synced JSON file under
`/logs/core/failed-batches/`; the host reports its path on stderr. These
files are recovery artifacts, not query results: recovery/import is
deliberate, using IDs to avoid duplicates, and never deletes the source.
If spooling also fails, entries stay in memory and retry on the next
command (no polling); at writer shutdown their full text is reported to
host stderr. Storage failure plus host loss can still lose unsaved data.
- Each entry has a UUIDv7 (it sorts by time), the process ID, the plugin, the
stream (`stderr` or `core`), and the line.
- Traps retain their full diagnostic text in the journal; short exit-status
displays are UTF-8-boundary-safe.
- The core writes its own events too: start (with method and params), exit
status, stop, kill, upgrades, and the warning about discarded stdout.
- `core.log {plugin?, pid?, since?, until?, grep?, limit?}` queries the
journal. `/proc/<pid>/log` shows the lines of one running process.
## 8. Boot and services
At start, the core starts each service in `/config/boot.yaml`, in order:
```yaml
- method: secrets.serve
params: {}
- method: www.start
params:
port: 8080
```
- A service is a detached call with the parent `core`. The core waits for
its result before it starts the next one. The result shows that the
service is ready.
- If a service fails before answering, the core writes the error to the
journal and continues booting. A failure to admit a process (for example,
a missing plugin or invalid parameters) is logged, not retried in a loop.
- The core supervises the process lifetime, not its readiness response. A
nonzero exit, trap or failed host task restarts the same service with its
original method and parameters, including a crash before readiness. A
readiness response alone never triggers a restart; a clean exit does not
count as a crash or trigger an immediate restart.
- More than 5 crashes within 10 seconds gives up on that service and writes
`service gave up` to the journal. Each service has an independent sliding
window; crashes exactly 10 seconds old expire. There is no retry delay or
periodic liveness check. Giving up is latched for this host lifetime:
neither elapsed time nor an upgrade re-arms it.
- After an upgrade of the plugin of a service, the same supervisor starts
it again with the current version after its old process ends (section
3.7). Superseded exits are retirement, not crashes. Admission and cleanup
reserve a single owner/attempt, so an upgrade racing a crash cannot start
two owners. A matching boot-service start from `rpc.retired` is ignored
(returns `null`), even while restart is pending or after giving up; older
plugins' fallback starts do not bypass supervision.
- An explicit `core.stop` or `core.kill` disables automatic attempts for
that service, including during an upgrade. Host shutdown starts no new
processes. The core does not restart other detached processes.
- A singleton is a service with a fixed endpoint in `/run`. Only the core
starts it, so two instances cannot start at the same time.
## 9. The core API
| Method | Params | Result |
| --- | --- | --- | | --- | --- | --- |
| `core.call` | `method`, `params`, `detach`? | the result of the method | | `spawn` | `actor`, `wasm`, `source`?, `built_by`? | test, then start a new actor |
| `core.watch` | `path` | stays open; `armed`, then `changed` notifications (section 4) | | `upgrade` | `actor`, `wasm`, `source`?, `built_by`? | test, then swap the version |
| `core.notify` | `name`, `params` | none (a notification) | | `rollback` | `actor`, `version` | go back to an older version |
| `core.stop` | `pid` | `{}` when the process has ended | | `stop`, `start` | `actor` | stop or start an actor |
| `core.kill` | `pid` | `{}` | | `remove` | `actor` | stop the actor (normal shutdown), then delete its whole folder `/actor/<id>/` (all versions, `data/`, `public/`, logs); answer `{"type": "removed", "actor"}`. Messages to it are then undeliverable, and the ID can be spawned again. An unknown actor or a native one (`broker`, `console`, `cargo`, `fs`) is an `error` |
| `core.lock` | `path`, `wait`? | `{"held": bool}` | | `subscribe`, `unsubscribe` | `actor` | receive (or not) what `actor` publishes |
| `core.unlock` | `path` | none (a notification) | | `tap`, `untap` | | receive (or not) a copy of every message the broker routes |
| `core.activate` | `plugin` and `wasm`, or `plugins: [{plugin, wasm}]`. `force`? | short activation summaries, keyed by plugin | | `scan` | | boot new or changed `actor.wasm` files |
| `core.revert` | `plugin`, `force`? | like `core.activate` | | `exit` | | answer `exiting`, stop every actor, and end the `sausage` process (a wrapper like `nix run .#loop` starts it again, updated) |
| `core.log` | filters (section 7) | journal entries |
| `core.restart` | none | `{}`. With `SAUSAGE_LOOP=1`, schedule clean shutdown and exit code 75. |
| `core.exit` | none | Alias of `core.restart`: same supervisor gate, response, coalescing, and exit code 75. |
### Host restart Paths are `/actor/...`, or the sender's `/public/...` or `/data/...`.
**HARD RULE: the mediator and all agents NEVER call `core.restart` without **Taps.** After `tap`, the sender gets a copy of every message that the
the person's explicit approval each time.** The same approval rule applies broker routes (direct messages, replies, and publishes, between any actors,
to the legacy alias `core.exit`; it uses the same gate and pending request. including its own), for debugging, logging, or a live view of the world:
`core.restart {}` is enabled only when the host starts with `SAUSAGE_LOOP=1`. ```json
The host captures the marker at startup. An absent marker (or any value other {"type": "tapped", "message": {"id": "m-17", "from": "console", "to": "agent", "reply_to": null, "body": {...}}, "ts": 1792132579067}
than `1`) returns error -32600: `restart needs the loop supervisor ```
(SAUSAGE_LOOP not set); start with SAUSAGE_LOOP=1`, without scheduling an exit.
This is a supervisor marker, not a permission system.
The method returns `{}` immediately and schedules the shutdown request after - `to` is `null` for a publish: one copy per publish, not per subscriber.
250 milliseconds to let the response reach the caller before stdin closes. - `ts` is the time of routing, in milliseconds since the Unix epoch.
Repeated requests while a restart is pending return `{}` without scheduling - Copies come from `broker`. They are not routed themselves, so they are
another restart or extending that delay. The executable then uses the same never tapped, and each message is tapped once per tapper.
`Host::stop` path as a normal stop: reject new processes, stop all processes - A copy that does not fit into the tapper's mailbox is dropped; routing
(including detached ones) in parallel with the normal five-second grace, never waits for a tapper, and nobody gets `undeliverable` for a copy.
wait for outstanding file operations, release locks, and drain journal writes. - Never answer a copy: the answer would be tapped again, forever.
Only after that does the executable return the dedicated restart exit code - Like subscriptions, a tap is saved in `actor.json` (`"tapped": true`) and
**75**. The library signals this path; it never kills its embedding process. survives restarts and upgrades. `untap` ends it.
`nix run .#loop -- <world>` sets `SAUSAGE_LOOP=1` on the host invocation and **Versions.** A new version (from `spawn`, `upgrade`, or a changed
starts the newest host again after exit code 75 (and, as before, other nonzero `actor.wasm` on disk) is tested first, while the old version keeps running.
exits). Exit code 0 ends the loop. If using an older loop script, the person Only when its tests pass does the broker copy it to `versions/<n>/`
must set `SAUSAGE_LOOP=1` in that supervisor's environment; do not set it for (`actor.wasm`, `source/`, `meta.json`) and switch to it. So `versions/` holds
an unsupervised host. The NixEnv service invokes this flake's `#loop`, so a exactly the versions that were installed, and instances always run from
new loop script supplies the marker without changes to NixEnv. there. If the tests fail, nothing changes, and the sender gets an `error`
with the reason. All versions share the actor's `data/` and `public/`: a
new version must read the data of the old one.
Everything else is a file. **Tests.** The broker runs the candidate on a copy of the actor's `data/`
and `public/`, without network. The tests are JSON files in
`<source>/tests/*.json`, one scenario per file:
## 10. Interfaces ```json
{
"description": "add, then list",
"steps": [
{"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
{"send": {"type": "list"}, "expect": [{"type": "notes"}]}
]
}
```
### 10.1 `rpc.discover` Each step sends `send` (from `from`, default `test`), then reads the next
line that the candidate writes for each `expect` entry, in order, within 10
seconds. An entry matches when it is a subset of the message body. With
`to`, the message must also go to that recipient. Each file runs in a fresh
instance. A version without test files passes.
Each plugin answers `rpc.discover` with an OpenRPC 1.2 document. The schemas **Swap.** The broker holds the mailbox, stops the old instance, makes the
are JSON Schema Draft 7, as OpenRPC requires. Shared schemas are in top-level `actor.wasm` and `source/` a copy of the new version, and starts
`components.schemas`, and `$ref`s point to `#/components/schemas/<Name>`. it. A rollback does the same with the older version.
Params are by name. The document can have these extension fields:
- `x-sausage-params` for each method with params: a `$ref` to the schema of **Crashes.** The broker restarts a crashed actor after 0.5 s, then doubles
the params object. the delay up to 30 s. A new version that crashes 3 times within 30 seconds
- `x-sausage-endpoints`: the endpoint interfaces by name, each with its after an upgrade goes back to the previous version. The actor that asked for
`methods`. the upgrade gets `{"type": "rolled_back", "from", "to", "reason", "last_exit"}`.
- `x-sausage-imports`: for each provider, the part of its document that the
plugin uses.
- `x-sausage-grace`: legacy grace declaration in seconds. The core ignores
this field; upgrade grace is always 5 seconds (section 3.7).
The schema of params describes what the provider can read. The schema of a **The folder is the truth.** At start, and on `scan`, the broker boots every
result describes what the provider writes. Descriptions are part of the `actor/<id>/actor.wasm`. If the file differs from the installed version, it
interface: other agents read them to use it. is a new version, tested with the tests in `actor/<id>/source/`. If its
tests fail, the file stays where it is, the reason goes to `stderr.log`, and
the next scan tries again.
The core starts a process only to call `rpc.discover`, and it stops that ## The `cargo` actor
process after the answer. Do not do expensive work at start. If no answer
comes within 10 seconds, the discovery fails.
### 10.2 Compatibility ```json
{"type": "build", "source": "/actor/agent/public/drafts/notes"}
```
A provider can add methods, optional params, and result fields. For each The reply is `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "built_by": "cargo", ...}`
imported method: or `{"type": "build_failed", "errors": "..."}`. Pass `wasm`, `source`, and
`built_by` on to `spawn` or `upgrade`.
1. The provider has the method. A Rust actor depends on the SDK with `sausage-actor = "0.1"`. `cargo` points
2. Each params value that the consumer can send is valid for the provider. that dependency at the SDK of the host, which it publishes at
3. Each result that the provider can return is valid for the consumer. `/actor/cargo/public/sdk/`.
- The same rules apply to endpoint interfaces.
- There are no version numbers. This check replaces them.
- The check covers a fixed subset of JSON Schema: objects with `properties`
and `required`, the primitive types, arrays, optional and nullable values,
string enums, integer ranges, and `$ref`. A construct outside the subset
fails the check.
- The check covers calls, not state. Old and new processes share the files in
`/state`, and `core.revert` restores only code. A plugin must keep its
state format compatible, or migrate it in `rpc.retired`.
### 10.3 Activation
`core.activate {"plugin": "notes", "wasm": "/state/cargo/builds/7/notes.wasm"}`:
1. The core copies the file. All further steps use this copy, even if the
file changes.
2. It starts the copy and calls `rpc.discover`.
3. It gets the current documents of all plugins (section 10.4). A plugin
whose discovery fails is "unavailable". Its imports cannot be checked,
but it does not block the activation of other plugins.
4. It checks the imports of the new version against the current providers.
5. It checks each current consumer of the plugin against the new version.
6. If all checks pass, `plugin.wasm` becomes `plugin.prev.wasm`, and the copy
becomes `plugin.wasm`. Then the upgrade starts (section 3.7). If the copy
has the same hash as the current version, nothing changes.
If a check fails, nothing changes, and the error -32003 lists each problem,
with a counterexample where possible.
The result is an object keyed by plugin name. Each value contains:
- `plugin`: the plugin name.
- `wasm`: the installed world path (`/plugins/<name>/plugin.wasm`).
- `hash`: the SHA-256 of the installed copy.
- `previous`: the replaced version's SHA-256, or `null` on first installation.
For an identical-hash no-op, this is the current hash.
- `method_count`: the number of methods in the new main interface.
- `added`, `removed`, `changed`: sorted method-name arrays compared with the
previous discovery document. A change to a method description or any
transitively referenced local schema counts as changed. Unrelated schemas
and endpoint interfaces do not affect these arrays. With no previous
document, all methods are added.
- `interface_check`: `"passed"` after successful checks, or `"skipped"` with
`force: true` (not a claim of compatibility).
The result does not include schemas. The full document remains available
through `<plugin>.rpc.discover` and `/proc/core/plugins/<name>/discover.json`.
An identical-hash no-op and `core.revert` return the same summary shape.
- The core runs one activation at a time.
- `plugins: [...]` activates several plugins together. The checks then use
the new set.
- `force: true` skips steps 4 and 5.
- `core.revert {plugin}` activates `plugin.prev.wasm` in the same way, and
the current version becomes `plugin.prev.wasm`.
- A person can also copy a file to `plugin.wasm` by hand. That skips the
checks.
### 10.4 The graph
- The core keeps the `rpc.discover` documents of all plugins, by file hash.
At start, it gets them in the background.
- A file watcher on `/plugins/*/plugin.wasm` updates them, for example after
a copy by hand. `/proc/core/graph.json` then shows each broken import, and
the journal records it.
- An import of a plugin that does not exist is "missing". An import of a
plugin whose discovery failed is "unavailable".
## 11. Building and tests
The core has one native plugin: `cargo`. `cargo.build {"plugin": "notes"}`
builds `/plugins/notes/source/`:
1. It copies the source once. The tests and the build both use this copy.
2. It runs the tests under WASI, in the release profile.
3. It builds the release `.wasm` for `wasm32-wasip2`. The tests and the build
share the compiled dependencies.
4. It returns `{"wasm": "/state/cargo/builds/<n>/notes.wasm", "tests":
{"passed", "failed", "output"}, "warnings", "logs"}`. If a step fails, it returns
an error with the compiler or test output.
- Before running commands, it creates `/logs/cargo/<YYYY-MM-DD>/<build-id>.stdout.log`
and `.stderr.log` (UTC date, unique build ID). Both test and release phases
append their raw bytes directly to these files, before any parsing, progress
filtering or UTF-8 conversion. This includes successful JSON build output.
`/logs` is a top-level world folder, created if missing, not under `/state`.
- Success results and error -32004 `data` include `logs`:
`{"stdout":{"path":"/logs/cargo/...stdout.log","bytes":123},
"stderr":{"path":"/logs/cargo/...stderr.log","bytes":45},"total_bytes":168}`.
Counts are raw bytes, not characters. `logs` is null only if log creation
itself fails. Preparation failures otherwise include empty log files.
Inline diagnostics may be filtered or shortened; the files are the full truth.
- Logs remain after failure, total/test timeout, and cancellation. Cancellation
need not return a result; its files remain discoverable in `/logs/cargo/`.
Pruning numeric `/state/cargo/builds/` artifact folders does not prune logs.
- Optional `timeout_secs` is an integer of at least 1, default **900** (not
null or unlimited). The wall-clock budget starts when the build acquires its
mutex, excluding queue time, and covers tests and release compilation together.
On expiry, cargo and its descendant process group are killed and the mutex is
released. Error -32004 has reason/message `timeout`, with `data.reason`,
`data.elapsed_secs`, `data.step` (`tests` or `release build`), `data.tests`,
and `data.output` containing the last 50 output lines.
- Optional `test_timeout_secs` is an integer of at least 1, default **120**.
Each WASI test instance has this wall-clock budget. On expiry, the instance
is killed, its named test fails with `timed out after N s`, and remaining
tests continue. The overall build budget still applies.
- Cancellation kills cargo's process group, including build scripts and test
runners, before releasing the build mutex. An attached build also stops when
its caller ends (under the normal process stopping deadline).
- A call to `cargo.build` gets a process ID, a folder in `/proc`, and journal
entries, like other calls.
- `/plugins/cargo/sdk/` is the SDK used by builds. The dependency
`sausage-plugin = "0.1"` points directly to this folder, including its macros
and interface crate. Edits apply to the next build without a host restart.
- The host seeds a missing SDK folder from its embedded copy at startup or
before a build. An existing folder is never overwritten or repaired, even
after a host upgrade. To reset to the host's bundled SDK, remove the whole
folder; the next startup or build re-seeds it. Back up local edits first.
- Compiled crates stay in the host's cache, outside the world, and all worlds
share them. So a second build compiles only what changed.
- Build scripts can compile C: `CC_wasm32_wasip2` and `AR_wasm32_wasip2`
point to a WASI clang.
- The build does not activate the result. Call `core.activate` next.
Tests:
- Each test runs in its own instance, so a panic ends only its own test.
- The normal Rust test setup works: `#[test]`, `tests/*.rs`, name filters,
and `--ignored`. `#[should_panic]` cannot work, because WASI cannot catch
a panic.
- Each test gets an empty temporary world as `/`, the package folder at its
host path, and its own `/proc`, `/self`, and `/run`.
- The current folder of a test is `/`, not the package folder. Read fixture
files through `env!("CARGO_MANIFEST_DIR")`.
- The test runner answers `core.lock`, `core.unlock`, `core.notify`, and
`core.log`. A call to a plugin fails: a test has no other plugins.
## 12. Embedded agent tool output
The agent shipped by `sausage init` uses a context-size guard of 50 KiB or
2000 lines, not a data-retention limit. Oversized tool results, including
errors, are saved in full under `/logs/agent/<date>/default/` before history
and provider delivery receive an excerpt. Overflow JSON string values are
rendered as physical text lines so the reference's one-based inclusive line
ranges can be followed with `read_file {path, line_offset, line_limit}`
(zero-based offset; default 200 lines). Huge single lines give exact byte
ranges and continuation offsets. Command/log/build/test results show the
tail; other tools show the head. Reading an existing `/logs` file excerpts
that same file without making another archive. Archives are exclusive and
never automatically deleted. If archival fails, the agent reports the error
to stderr and keeps the complete result instead of discarding it. Existing
history formats and paths are unchanged.
+43 -96
View File
@@ -2,125 +2,72 @@
You can see how the sausage is made. You can see how the sausage is made.
Sausage is a world of small WASI programs ("plugins") that you grow by Sausage is a world of small programs ("actors") that you grow by talking to
talking to it. A plugin is a `.wasm` file with JSON-RPC methods. Each call it. Each actor is a WASI program with stdin and stdout. Actors send each other
to a plugin starts a new process. The agent is a plugin too: it writes new JSON messages, one per line, through a broker. The agent is an actor too: it
plugins in Rust, builds them, tests them, activates them, and can change its writes new actors in Rust, builds them, tests them, starts them, and can
own code. change its own code.
## Start ## Start
You need [Nix](https://nixos.org/download) with flakes, and an LLM: the OpenAI You need [Nix](https://nixos.org/download) with flakes, and an API token for
Responses API, an OpenAI-compatible Chat Completions API (OpenRouter, Ollama, an LLM (OpenAI Responses or Anthropic Messages API).
and most other providers), or the Anthropic Messages API. No clone needed:
```sh ```sh
nix run git+https://gitea.surma.technology/surma/sausage git clone https://gitea.surma.technology/surma/sausage && cd sausage
nix run -- init # creates ./world and builds the agent (a few minutes)
nix run -- run # starts the world and a console
``` ```
This creates a world in `./world` the first time (it builds the agent: the `init` asks for the API shape (`openai-responses` or `anthropic-messages`),
first build on a machine takes a few minutes, later worlds reuse the compiled the base URL, the model, and the token. It reads `SAUSAGE_API`,
crates), then starts it with a console. Later runs start the `SAUSAGE_BASE_URL`, `SAUSAGE_MODEL`, and `SAUSAGE_API_TOKEN` first. The
existing world. The two steps on their own: values go to `world/actor/agent/data/config.json`. Both commands take another
folder as an argument, for example `nix run -- run my-world`.
```sh
nix run git+https://gitea.surma.technology/surma/sausage -- init # creates ./world and builds the agent
nix run git+https://gitea.surma.technology/surma/sausage -- run # starts the world and a console
```
`init` asks nothing. It writes `world/config/agent.yaml`, with a comment for
each field and an example for each API. Edit it before the first chat: choose
the API, the base URL, the model, the key, and the thinking level. Until then, a chat answers with
an error that names the missing field. Both commands take another folder as
an argument, for example `... -- run my-world`.
Compiled code is cached outside the world, in `$XDG_CACHE_HOME/sausage/`
(`~/.cache/sausage/` without the variable). All worlds share it.
`--cache-dir <folder>` changes it for `init` and `run`.
To keep a world on the newest version, run it in a loop: To keep a world on the newest version, run it in a loop:
```sh ```sh
nix run git+https://gitea.surma.technology/surma/sausage#loop # ./world, created if needed nix run .#loop -- my-world
nix run git+https://gitea.surma.technology/surma/sausage#loop -- my-world # an existing world
``` ```
When the host crashes or a plugin calls `core.restart`, the loop fetches the The loop runs `nix run --refresh git+https://gitea.surma.technology/surma/sausage -- run my-world`.
newest commit and starts it again. The loop sets `SAUSAGE_LOOP=1` to enable When the host crashes or an actor sends `@broker {"type":"exit"}`, the loop
that RPC; without it, restart returns an error and leaves the host running. fetches the newest commit and starts it again. `/quit` ends the loop. Set
Restart answers first, then cleanly stops all processes and exits with code `SAUSAGE_FLAKE` to run another flake URL instead, for example your fork.
75. `quit` (code 0) ends the loop. Set `SAUSAGE_FLAKE` to run another flake
URL instead, for example your fork.
**The mediator and all agents NEVER call `core.restart` without the person's In the console:
explicit approval each time.** This also applies to legacy `core.exit`, an
alias with the same supervisor gate, response-before-exit behavior, and
request coalescing.
See [the protocol](PROTOCOL.md#host-restart) for the shutdown contract.
From a clone (`git clone https://gitea.surma.technology/surma/sausage`), the ```text
same commands are `nix run`, `nix run -- init`, `nix run -- run`, and write an actor "notes" that stores notes, and start it → text to the agent
`nix run .#loop`. @notes {"type":"add","text":"milk"} → a JSON message to an actor
@agent hello → text to an actor
### Headless /quit
`--headless` (or `SAUSAGE_HEADLESS=1`) turns the console input off. The host
runs until SIGTERM or SIGINT (exit code 0) or until a plugin calls
`core.restart` (requires `SAUSAGE_LOOP=1`; exit code 75 restarts the loop):
```sh
nix run git+https://gitea.surma.technology/surma/sausage#loop -- --headless my-world
``` ```
## The console
| Input | Effect |
| --- | --- |
| text | a message to the agent (`agent.chat`) |
| `<plugin>.<method> {json}` | a call. The console prints the result. |
| `ls [path]`, `cat <path>` | the filesystem as a process sees it, with `/proc` and `/run` |
| `methods <plugin>` | the methods of a plugin, with params and docs |
| `ps` | the running processes |
| `log [plugin or pid] [-n N]` | the journal |
| `activate <plugin> <wasm>`, `revert <plugin>` | activate a new version, or go back |
| `stop <pid>`, `kill <pid>` | stop a process |
| `help`, `quit` | |
## The world folder ## The world folder
The folder is the source of truth. Every actor lives in `world/actor/<id>/`:
```text ```text
plugins/<name>/plugin.wasm the current version of a plugin actor.wasm the current version
plugins/<name>/source/ its source actor.json installed version, running, subscriptions
state/<plugin>/ the state of a plugin, by convention source/ the source of the current version
state/core/journal.sqlite the journal: stderr of all processes, and core events public/ files that the actor publishes
config/boot.yaml ordered boot services, supervised for crashes (PROTOCOL §8) data/ the actor's state
config/agent.yaml the configuration of the agent stderr.log what the actor and the broker write about it
versions/<n>/ every installed version: actor.wasm, source/, meta.json
``` ```
Boot services restart on process crashes, not on readiness responses. More To add or replace an actor by hand, put an `actor.wasm` (and its `source/`,
than 5 crashes within 10 seconds stops automatic attempts for that service with the tests in `source/tests/`) into its folder. Then restart the world or
and journals `service gave up`; an upgrade does not reset that decision. send `@broker {"type":"scan"}`. The broker tests the file before it uses it.
Explicitly stopping or killing a service also leaves it stopped for this If the tests fail, the old version keeps running, and `stderr.log` says why.
host lifetime. See [Boot and services](PROTOCOL.md#8-boot-and-services).
Rust builds use the SDK in `plugins/cargo/sdk/` directly. The host seeds this
folder only when it is missing; restarts and host upgrades preserve existing
files. SDK edits take effect on the next `cargo.build`, without a restart.
To reset to the host's bundled SDK, back up any edits and remove the whole
folder; the next startup or build re-seeds it.
Each process also sees `/proc` (the processes and the state of the core),
`/self` (its own folder in `/proc`), and `/run` (shared files in memory).
To replace a plugin by hand, copy a `.wasm` file to its `plugin.wasm`. That
skips the interface checks. `revert <plugin>` goes back to the version
before the last activation.
## More ## More
- [PROTOCOL.md](PROTOCOL.md): calls, processes, the filesystem, locks, - [PROTOCOL.md](PROTOCOL.md): messages, the filesystem, the broker, `cargo`.
interfaces, and `cargo`. Read this to write a plugin in any language. Read this to write an actor in any language.
- [DECISIONS.md](DECISIONS.md): why Sausage is the way it is. - [`sdk/`](sdk/): `sausage-actor`, the SDK for Rust actors.
- [`sdk/`](sdk/): `sausage-plugin`, the SDK for Rust plugins. - [`examples/notes`](examples/notes): a small actor with a test.
- [`examples/`](examples/): small plugins.
- [AGENTS.md](AGENTS.md): how to work on Sausage itself. - [AGENTS.md](AGENTS.md): how to work on Sausage itself.
+2 -5
View File
@@ -2,13 +2,10 @@
name = "agent" name = "agent"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
description = "The Sausage agent plugin." description = "The Sausage agent actor."
[dependencies] [dependencies]
sausage-plugin = { version = "0.1", path = "../sdk" } sausage-actor = { version = "0.1", path = "../sdk", features = ["async"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml_ng = "0.10"
[target.'cfg(target_os = "wasi")'.dependencies]
wstd = "0.6" wstd = "0.6"
+1 -8
View File
@@ -2,7 +2,6 @@
use crate::llm::Entry; use crate::llm::Entry;
/// Recognize provider errors that permit a retry with fewer complete turns.
pub fn is_too_large(error: &str) -> bool { pub fn is_too_large(error: &str) -> bool {
let Some((status, body)) = error let Some((status, body)) = error
.strip_prefix("HTTP ") .strip_prefix("HTTP ")
@@ -10,10 +9,7 @@ pub fn is_too_large(error: &str) -> bool {
else { else {
return false; return false;
}; };
if status.split_whitespace().next() == Some("413") { if !matches!(status.split_whitespace().next(), Some("400" | "413")) {
return true;
}
if status.split_whitespace().next() != Some("400") {
return false; return false;
} }
let body = body.to_ascii_lowercase(); let body = body.to_ascii_lowercase();
@@ -27,7 +23,6 @@ pub fn is_too_large(error: &str) -> bool {
.any(|term| body.contains(term))) .any(|term| body.contains(term)))
} }
/// Find a user-turn boundary without discarding any of the current turn.
pub fn next_turn_start(history: &[Entry], from: usize, current: usize) -> Option<usize> { pub fn next_turn_start(history: &[Entry], from: usize, current: usize) -> Option<usize> {
let end = current.min(history.len().checked_sub(1)?); let end = current.min(history.len().checked_sub(1)?);
(from..=end).find(|&index| matches!(&history[index], Entry::User { .. })) (from..=end).find(|&index| matches!(&history[index], Entry::User { .. }))
@@ -41,8 +36,6 @@ mod tests {
fn detects_context_size_errors_only_for_http_400_or_413() { fn detects_context_size_errors_only_for_http_400_or_413() {
assert!(is_too_large("HTTP 400: context_length_exceeded")); assert!(is_too_large("HTTP 400: context_length_exceeded"));
assert!(is_too_large("HTTP 413: prompt is too long")); assert!(is_too_large("HTTP 413: prompt is too long"));
assert!(is_too_large("HTTP 413: {\"type\":\"request_too_large\"}"));
assert!(is_too_large("HTTP 413: {}"));
assert!(is_too_large("HTTP 400: maximum context length")); assert!(is_too_large("HTTP 400: maximum context length"));
assert!(is_too_large("HTTP 400: request is too large")); assert!(is_too_large("HTTP 400: request is too large"));
assert!(!is_too_large("HTTP 500: prompt is too long")); assert!(!is_too_large("HTTP 500: prompt is too long"));
+43 -494
View File
@@ -1,386 +1,73 @@
//! Inference through OpenAI Responses, Chat Completions, and Anthropic Messages. //! Inference through two API shapes: OpenAI Responses and Anthropic Messages.
//! //!
//! The conversation is stored in a neutral format, so the agent can switch //! The conversation is stored in a neutral format, so the agent can switch
//! between the three shapes. //! between the two shapes.
use sausage_plugin::schema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
#[cfg(target_os = "wasi")]
use wstd::http::{Body, Client, Request}; use wstd::http::{Body, Client, Request};
/// Inference configuration from `/config/agent.yaml`. #[derive(Deserialize, Clone)]
#[derive(Deserialize, Clone, Default)]
#[serde(default)]
pub struct Config { pub struct Config {
/// `openai-responses`, `openai-chat-completions`, or `anthropic-messages`. /// `openai-responses` or `anthropic-messages`.
pub api: String, pub api: String,
/// The SDK base URL: versioned for OpenAI, unversioned for Anthropic.
pub base_url: String, pub base_url: String,
/// The provider's model identifier.
pub model: String, pub model: String,
/// The API credential sent in the provider's authentication header.
pub token: String, pub token: String,
/// Permit an empty token and omit authentication headers. /// Optional reasoning effort for OpenAI models ("low", "medium", ...).
pub allow_no_token: bool, #[serde(default)]
/// An optional cap on generated tokens. pub reasoning_effort: Option<String>,
#[serde(default)] #[serde(default)]
pub max_tokens: Option<u32>, pub max_tokens: Option<u32>,
/// The thinking level of each turn. Unset: the model's default.
#[serde(default)]
pub thinking: Option<Thinking>,
} }
/// How much the model thinks before it answers.
///
/// OpenAI Responses gets `reasoning.effort`, Chat Completions gets `reasoning_effort`, and Anthropic
/// gets adaptive thinking with `output_config.effort`. If the model rejects a level, the agent uses
/// the nearest level that the model accepts, never `off`.
#[schema]
#[derive(Clone, Copy, PartialEq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Thinking {
/// No thinking: OpenAI gets the effort `none`, and Anthropic gets disabled thinking. If an
/// OpenAI model cannot switch thinking off, the agent uses `low`.
Off,
/// The least thinking.
Low,
/// Moderate thinking.
Medium,
/// Much thinking.
High,
/// More thinking than `high`, where the model supports it.
Xhigh,
/// The most thinking that the model supports.
Max,
}
impl Thinking {
/// The effort that an OpenAI request carries: `none`, or one of `LEVELS`.
fn effort(self) -> &'static str {
match self {
Thinking::Off => "none",
Thinking::Low => "low",
Thinking::Medium => "medium",
Thinking::High => "high",
Thinking::Xhigh => "xhigh",
Thinking::Max => "max",
}
}
}
impl Config {
pub fn load() -> Result<Self, String> {
let bytes = std::fs::read("/config/agent.yaml")
.map_err(|e| format!("/config/agent.yaml: missing configuration file: {e}"))?;
Self::parse(&bytes)
}
fn parse(bytes: &[u8]) -> Result<Self, String> {
let cfg = serde_yaml_ng::from_slice::<Option<Self>>(bytes)
.map_err(|e| format!("/config/agent.yaml: {e}"))?
.unwrap_or_default();
let invalid = if ![
"openai-responses",
"openai-chat-completions",
"anthropic-messages",
]
.contains(&cfg.api.as_str())
{
Some("api (missing or unknown API shape)")
} else if cfg.base_url.trim().is_empty() {
Some("base_url")
} else if cfg.model.trim().is_empty() {
Some("model")
} else if (cfg.token.trim().is_empty() && !cfg.allow_no_token)
|| cfg.token == "CHANGE_ME"
|| cfg.token.starts_with("YOUR_")
{
Some("token (set a key, or set token: \"\" and allow_no_token: true)")
} else {
None
};
if let Some(field) = invalid {
return Err(format!("/config/agent.yaml: configure field {field}"));
}
Ok(cfg)
}
fn bearer(&self) -> Vec<(&'static str, String)> {
if self.token.is_empty() {
vec![]
} else {
vec![("authorization", format!("Bearer {}", self.token))]
}
}
}
/// Thinking levels, lowest first.
pub const LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
/// The level nearest to `want` (lower on a tie) among those a 400 error lists in quotes (else all levels), not
/// yet `tried`. None: nothing left, so the request goes out without an effort field (model default).
fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option<String> {
let listed: Vec<&str> = error
.split('\'')
.skip(1)
.step_by(2)
.filter(|w| LEVELS.contains(w))
.collect();
let pool = if listed.iter().any(|l| *l != want) {
listed
} else {
LEVELS.to_vec()
};
// `none` is below all levels, so its nearest level is `low`.
let at = LEVELS
.iter()
.position(|l| *l == want)
.map_or(-1, |i| i as i32);
pool.into_iter()
.filter(|l| *l != want && !tried.iter().any(|t| t == l))
.min_by_key(|l| {
let i = LEVELS.iter().position(|x| x == l).unwrap_or(1) as i32;
((i - at).abs(), i > at)
})
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::fallback_effort;
#[test]
fn yaml_configuration_and_credentials() {
use super::Config;
for text in [
"",
"api: unknown",
"api: openai-responses\nbase_url: url\nmodel: model\ntoken: CHANGE_ME",
"api: openai-responses\nbase_url: url\nmodel: model\ntoken: ''",
] {
assert!(
Config::parse(text.as_bytes())
.err()
.unwrap()
.contains("/config/agent.yaml")
);
}
let cfg = Config::parse(b"api: openai-chat-completions\nbase_url: http://localhost/v1\nmodel: local\ntoken: ''\nallow_no_token: true").unwrap();
assert!(cfg.bearer().is_empty());
assert_eq!(cfg.thinking, None);
}
#[test]
fn thinking_levels() {
use super::{Config, LEVELS, Thinking};
let base = "api: openai-responses\nbase_url: url\nmodel: model\ntoken: key\nthinking: ";
let parse = |level: &str| Config::parse(format!("{base}{level}").as_bytes());
assert_eq!(parse("off").unwrap().thinking, Some(Thinking::Off));
assert_eq!(Thinking::Off.effort(), "none");
for level in LEVELS {
assert_eq!(parse(level).unwrap().thinking.unwrap().effort(), level);
}
let error = parse("hihg").err().unwrap();
assert!(error.contains("/config/agent.yaml"), "{error}");
assert!(error.contains("thinking"), "{error}");
}
#[test]
fn chat_request_and_response_mapping() {
use super::*;
let cfg = Config {
model: "test".into(),
max_tokens: Some(100),
..Config::default()
};
let response = json!({"choices":[{"message":{"content":"interim","tool_calls":[{"id":"one","function":{"name":"read_file","arguments":"{}"}},{"id":"two","function":{"name":"read_file","arguments":"{}"}}]}}]});
let mut history = chat_entries(&response);
assert_eq!(history.len(), 3);
history.push(Entry::ToolResult {
id: "one".into(),
output: "error".into(),
});
let tools = [Tool {
name: "read_file",
description: "read",
parameters: json!({"type":"object"}),
}];
let body = chat_body(&cfg, "system", &tools, &history, Some("high"));
assert_eq!(body["messages"][1]["content"], "interim");
assert_eq!(
body["messages"][1]["tool_calls"].as_array().unwrap().len(),
2
);
assert_eq!(
body["messages"][2],
json!({"role":"tool","tool_call_id":"one","content":"error"})
);
assert_eq!(body["tools"][0]["function"]["name"], "read_file");
assert_eq!(body["reasoning_effort"], "high");
assert_eq!(body["max_completion_tokens"], 100);
assert!(
matches!(&chat_entries(&json!({"choices":[{"message":{"content":"final"}}]}))[0], Entry::Assistant {text,..} if text == "final")
);
}
#[test]
fn nearest_accepted_level() {
let anthropic =
"HTTP 400: output_config.effort: Input should be 'low', 'medium', 'high' or 'max'";
assert_eq!(
fallback_effort("xhigh", anthropic, &["xhigh".into()]).as_deref(),
Some("high")
);
let openai = "HTTP 400: Invalid value: 'max'. Supported values are: 'none', 'minimal', 'low', 'medium', and 'high'.";
assert_eq!(
fallback_effort("max", openai, &["max".into()]).as_deref(),
Some("high")
);
// A thinking-only model rejects `none`: use the lowest level.
let glm = "HTTP 400: GLM-5.3 is a thinking-only model; disabling thinking (reasoning_effort='none') is not supported.";
assert_eq!(
fallback_effort("none", glm, &["none".into()]).as_deref(),
Some("low")
);
// No list in the error: step to the neighbour, never to off.
assert_eq!(
fallback_effort("max", "HTTP 400: effort max not supported", &["max".into()])
.as_deref(),
Some("xhigh")
);
}
}
/// One append-only entry in the provider-neutral conversation.
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "kind", rename_all = "snake_case")] #[serde(tag = "kind", rename_all = "snake_case")]
pub enum Entry { pub enum Entry {
/// A person's contribution starts a new turn. User { text: String },
User {
/// The complete contribution.
text: String,
},
/// Text produced by the model.
Assistant { Assistant {
/// The generated text.
text: String, text: String,
/// The optional provider phase, preserved for subsequent requests.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
phase: Option<String>, phase: Option<String>,
}, },
/// A requested tool operation. ToolCall { id: String, name: String, arguments: String },
ToolCall { ToolResult { id: String, output: String },
/// The provider's correlation identifier. /// A provider item that only this API shape understands (OpenAI reasoning).
id: String, Opaque { api: String, item: Value },
/// The tool name.
name: String,
/// The JSON parameter object as text.
arguments: String,
},
/// The outcome of a tool operation, including tool errors.
ToolResult {
/// The correlation identifier of the tool call.
id: String,
/// The JSON outcome as text.
output: String,
},
/// A provider item that only this API shape understands (OpenAI reasoning, Anthropic thinking).
Opaque {
/// The API shape that owns this entry.
api: String,
/// The model that produced this item. Legacy items without a model are not replayed.
#[serde(default)]
model: String,
/// The original provider object.
item: Value,
},
} }
/// A tool contract shared by all API shapes.
pub struct Tool { pub struct Tool {
/// The function identifier exposed to the model.
pub name: &'static str, pub name: &'static str,
/// The explanation of the tool's behavior.
pub description: &'static str, pub description: &'static str,
/// The JSON Schema for named parameters.
pub parameters: Value, pub parameters: Value,
} }
/// Request the next model step, with provider-specific reasoning items intact. pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
pub async fn complete( match cfg.api.as_str() {
cfg: &Config, "openai-responses" => openai(cfg, system, tools, history).await,
system: &str, "anthropic-messages" => anthropic(cfg, system, tools, history).await,
tools: &[Tool], other => Err(format!("unknown API shape {other:?}")),
history: &[Entry],
) -> Result<Vec<Entry>, String> {
let want = cfg.thinking.map(|level| level.effort().to_string());
let mut effort = want.clone();
let mut tried = Vec::new();
loop {
let result = match cfg.api.as_str() {
"openai-responses" => openai(cfg, system, tools, history, effort.as_deref()).await,
"openai-chat-completions" => chat(cfg, system, tools, history, effort.as_deref()).await,
"anthropic-messages" => anthropic(cfg, system, tools, history, effort.as_deref()).await,
other => return Err(format!("unknown API shape {other:?}")),
};
match (result, effort.clone()) {
// The model rejected the effort level: retry with the nearest accepted one (thinking stays on).
(Err(e), Some(current)) if e.starts_with("HTTP 400") && e.contains("effort") => {
tried.push(current.clone());
effort = fallback_effort(want.as_deref().unwrap_or(""), &e, &tried);
eprintln!(
"agent: {} rejected thinking effort {current:?}; retrying with {effort:?}: {e}",
cfg.model
);
}
(result, _) => return result,
}
} }
} }
/// Native unit tests exercise history logic without an HTTP runtime.
#[cfg(not(target_os = "wasi"))]
async fn post(_url: &str, _headers: &[(&str, String)], _body: &Value) -> Result<Value, String> {
Err("inference requires the WASI HTTP runtime".into())
}
/// Send JSON through wasi:http and retain error bodies for context-size detection.
#[cfg(target_os = "wasi")]
async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result<Value, String> { async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result<Value, String> {
let mut req = Request::post(url).header("content-type", "application/json"); let mut req = Request::post(url).header("content-type", "application/json");
for (k, v) in headers { for (k, v) in headers {
req = req.header(*k, v.as_str()); req = req.header(*k, v.as_str());
} }
let req = req let req = req.body(Body::from(body.to_string())).map_err(|e| format!("request: {e}"))?;
.body(Body::from(body.to_string()))
.map_err(|e| format!("request: {e}"))?;
let client = Client::new(); let client = Client::new();
let resp = client let resp = client.send(req).await.map_err(|e| format!("HTTP request failed: {e:?}"))?;
.send(req)
.await
.map_err(|e| format!("HTTP request failed: {e:?}"))?;
let status = resp.status(); let status = resp.status();
let mut body = resp.into_body(); let mut body = resp.into_body();
let text = body let text = body.str_contents().await.map_err(|e| format!("reading the response failed: {e:?}"))?.to_string();
.str_contents()
.await
.map_err(|e| format!("reading the response failed: {e:?}"))?
.to_string();
if !status.is_success() { if !status.is_success() {
return Err(format!("HTTP {status}: {text}")); return Err(format!("HTTP {status}: {text}"));
} }
serde_json::from_str(&text).map_err(|e| format!("invalid JSON in response: {e}")) serde_json::from_str(&text).map_err(|e| format!("invalid JSON in response: {e}"))
} }
/// Translate neutral history to OpenAI Responses and preserve returned reasoning items. async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
async fn openai(
cfg: &Config,
system: &str,
tools: &[Tool],
history: &[Entry],
effort: Option<&str>,
) -> Result<Vec<Entry>, String> {
let mut input = Vec::new(); let mut input = Vec::new();
for e in history { for e in history {
match e { match e {
@@ -398,7 +85,7 @@ async fn openai(
Entry::ToolResult { id, output } => { Entry::ToolResult { id, output } => {
input.push(json!({"type": "function_call_output", "call_id": id, "output": output})) input.push(json!({"type": "function_call_output", "call_id": id, "output": output}))
} }
Entry::Opaque { api, model, item } if api == "openai-responses" && model == &cfg.model => input.push(item.clone()), Entry::Opaque { api, item } if api == "openai-responses" => input.push(item.clone()),
Entry::Opaque { .. } => {} Entry::Opaque { .. } => {}
} }
} }
@@ -417,11 +104,11 @@ async fn openai(
if let Some(max_tokens) = cfg.max_tokens { if let Some(max_tokens) = cfg.max_tokens {
body["max_output_tokens"] = json!(max_tokens); body["max_output_tokens"] = json!(max_tokens);
} }
if let Some(effort) = effort { if let Some(effort) = &cfg.reasoning_effort {
body["reasoning"] = json!({"effort": effort}); body["reasoning"] = json!({"effort": effort});
} }
let url = format!("{}/responses", cfg.base_url.trim_end_matches('/')); let url = format!("{}/v1/responses", cfg.base_url);
let resp = post(&url, &cfg.bearer(), &body).await?; let resp = post(&url, &[("authorization", format!("Bearer {}", cfg.token))], &body).await?;
let mut out = Vec::new(); let mut out = Vec::new();
for item in resp["output"].as_array().cloned().unwrap_or_default() { for item in resp["output"].as_array().cloned().unwrap_or_default() {
match item["type"].as_str() { match item["type"].as_str() {
@@ -446,36 +133,17 @@ async fn openai(
name: item["name"].as_str().unwrap_or_default().to_string(), name: item["name"].as_str().unwrap_or_default().to_string(),
arguments: item["arguments"].as_str().unwrap_or("{}").to_string(), arguments: item["arguments"].as_str().unwrap_or("{}").to_string(),
}), }),
Some("reasoning") => out.push(Entry::Opaque { Some("reasoning") => out.push(Entry::Opaque { api: "openai-responses".into(), item }),
api: "openai-responses".into(),
model: cfg.model.clone(),
item,
}),
_ => {} _ => {}
} }
} }
if resp["status"] == "incomplete" if resp["status"] == "incomplete" && out.is_empty() {
&& !out return Err(format!("incomplete response: {}", resp["incomplete_details"]));
.iter()
.any(|entry| matches!(entry, Entry::Assistant { text, .. } if !text.is_empty()))
{
return Err(format!(
"incomplete response: {}",
resp["incomplete_details"]
));
} }
Ok(out) Ok(out)
} }
/// Translate neutral history to Anthropic Messages and merge consecutive roles. async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
async fn anthropic(
cfg: &Config,
system: &str,
tools: &[Tool],
history: &[Entry],
effort: Option<&str>,
) -> Result<Vec<Entry>, String> {
let disabled = cfg.thinking == Some(Thinking::Off);
// Merge consecutive blocks of the same role into one message. // Merge consecutive blocks of the same role into one message.
let mut messages: Vec<(String, Vec<Value>)> = Vec::new(); let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
let mut push = |role: &str, block: Value| match messages.last_mut() { let mut push = |role: &str, block: Value| match messages.last_mut() {
@@ -488,64 +156,35 @@ async fn anthropic(
Entry::Assistant { text, .. } => { Entry::Assistant { text, .. } => {
push("assistant", json!({"type": "text", "text": text})) push("assistant", json!({"type": "text", "text": text}))
} }
Entry::ToolCall { Entry::ToolCall { id, name, arguments } => {
id,
name,
arguments,
} => {
let input: Value = serde_json::from_str(arguments).unwrap_or(json!({})); let input: Value = serde_json::from_str(arguments).unwrap_or(json!({}));
push( push("assistant", json!({"type": "tool_use", "id": id, "name": name, "input": input}))
"assistant",
json!({"type": "tool_use", "id": id, "name": name, "input": input}),
)
} }
Entry::ToolResult { id, output } => push( Entry::ToolResult { id, output } => {
"user", push("user", json!({"type": "tool_result", "tool_use_id": id, "content": output}))
json!({"type": "tool_result", "tool_use_id": id, "content": output}),
),
// Thinking blocks go back unchanged (with their signature): required next to tool use.
Entry::Opaque { api, model, item }
if api == "anthropic-messages" && model == &cfg.model && !disabled =>
{
push("assistant", item.clone())
} }
Entry::Opaque { .. } => {} Entry::Opaque { .. } => {}
} }
} }
let messages: Vec<Value> = messages let messages: Vec<Value> = messages.into_iter().map(|(role, content)| json!({"role": role, "content": content})).collect();
.into_iter()
.map(|(role, content)| json!({"role": role, "content": content}))
.collect();
let tools: Vec<Value> = tools let tools: Vec<Value> = tools
.iter() .iter()
.map( .map(|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}))
|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}),
)
.collect(); .collect();
let mut body = json!({ let body = json!({
"model": cfg.model, "model": cfg.model,
"system": system, "system": system,
"messages": messages, "messages": messages,
"tools": tools, "tools": tools,
"max_tokens": cfg.max_tokens.unwrap_or(32000), "max_tokens": cfg.max_tokens.unwrap_or(32000),
}); });
// Unset: the model's default. Newer models think by default. let url = format!("{}/v1/messages", cfg.base_url);
match cfg.thinking { let resp = post(
None => {} &url,
Some(Thinking::Off) => body["thinking"] = json!({"type": "disabled"}), &[("x-api-key", cfg.token.clone()), ("anthropic-version", "2023-06-01".to_string())],
Some(_) => { &body,
body["thinking"] = json!({"type": "adaptive"}); )
if let Some(effort) = effort { .await?;
body["output_config"] = json!({"effort": effort});
}
}
}
let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/'));
let mut headers = vec![("anthropic-version", "2023-06-01".to_string())];
if !cfg.token.is_empty() {
headers.push(("x-api-key", cfg.token.clone()));
}
let resp = post(&url, &headers, &body).await?;
let mut out = Vec::new(); let mut out = Vec::new();
for block in resp["content"].as_array().cloned().unwrap_or_default() { for block in resp["content"].as_array().cloned().unwrap_or_default() {
match block["type"].as_str() { match block["type"].as_str() {
@@ -560,98 +199,8 @@ async fn anthropic(
name: block["name"].as_str().unwrap_or_default().to_string(), name: block["name"].as_str().unwrap_or_default().to_string(),
arguments: block["input"].to_string(), arguments: block["input"].to_string(),
}), }),
Some("thinking") | Some("redacted_thinking") => out.push(Entry::Opaque {
api: "anthropic-messages".into(),
model: cfg.model.clone(),
item: block,
}),
_ => {} _ => {}
} }
} }
Ok(out) Ok(out)
} }
/// Translate neutral entries into the Chat Completions message format.
fn chat_body(
cfg: &Config,
system: &str,
tools: &[Tool],
history: &[Entry],
effort: Option<&str>,
) -> Value {
let mut messages = vec![json!({"role":"system", "content":system})];
for entry in history {
match entry {
Entry::User { text } => messages.push(json!({"role":"user","content":text})),
Entry::Assistant { text, .. } => {
messages.push(json!({"role":"assistant","content":text}))
}
Entry::ToolCall {
id,
name,
arguments,
} => {
if messages.last().unwrap()["role"] != "assistant" {
messages.push(json!({"role":"assistant","content":null}));
}
let last = messages.last_mut().unwrap();
if !last["tool_calls"].is_array() {
last["tool_calls"] = json!([]);
}
last["tool_calls"].as_array_mut().unwrap().push(json!({"id":id,"type":"function","function":{"name":name,"arguments":arguments}}));
}
Entry::ToolResult { id, output } => {
messages.push(json!({"role":"tool","tool_call_id":id,"content":output}))
}
Entry::Opaque { .. } => {}
}
}
let tools: Vec<Value> = tools.iter().map(|t| json!({"type":"function","function":{"name":t.name,"description":t.description,"parameters":t.parameters}})).collect();
let mut body = json!({"model":cfg.model,"messages":messages,"tools":tools});
if let Some(max) = cfg.max_tokens {
body["max_completion_tokens"] = json!(max);
}
if let Some(effort) = effort {
body["reasoning_effort"] = json!(effort);
}
body
}
/// Map text and tool calls back into the shared history.
fn chat_entries(response: &Value) -> Vec<Entry> {
let message = &response["choices"][0]["message"];
let mut entries = Vec::new();
if let Some(text) = message["content"].as_str().filter(|s| !s.is_empty()) {
entries.push(Entry::Assistant {
text: text.into(),
phase: None,
});
}
for call in message["tool_calls"].as_array().into_iter().flatten() {
entries.push(Entry::ToolCall {
id: call["id"].as_str().unwrap_or_default().into(),
name: call["function"]["name"].as_str().unwrap_or_default().into(),
arguments: call["function"]["arguments"]
.as_str()
.unwrap_or("{}")
.into(),
});
}
entries
}
async fn chat(
cfg: &Config,
system: &str,
tools: &[Tool],
history: &[Entry],
effort: Option<&str>,
) -> Result<Vec<Entry>, String> {
let response = post(
&format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')),
&cfg.bearer(),
&chat_body(cfg, system, tools, history, effort),
)
.await?;
Ok(chat_entries(&response))
}
+304 -176
View File
@@ -1,173 +1,98 @@
//! Answer chat calls with an LLM and tools, under one lock for the complete turn. //! The Sausage agent actor.
mod history; mod history;
mod llm; mod llm;
mod tools; mod tools;
use history::{is_too_large, next_turn_start}; use history::{is_too_large, next_turn_start};
use llm::{Config, Entry, Thinking}; use llm::{Config, Entry};
use sausage_plugin::{Ctx, Error, Result, lock, rpc, schema}; use sausage_actor::{Inbox, Message, Value, json, publish, send};
use serde_json::json; use std::collections::{HashSet, VecDeque};
use std::io::Write; use std::io::Write;
use wstd::future::FutureExt;
use wstd::time::{Duration, Instant};
/// The instructions sent with every inference request.
const SYSTEM: &str = include_str!("prompt.md"); const SYSTEM: &str = include_str!("prompt.md");
/// The shared, append-only conversation. const CONFIG: &str = "/data/config.json";
const HISTORY: &str = "/state/agent/history.jsonl"; const HISTORY: &str = "/data/history.jsonl";
/// The first retained turn after a context-size rejection. const HISTORY_SKIP: &str = "/data/history.skip";
const SKIP: &str = "/state/agent/history.skip"; const TURN: &str = "/data/turn.json";
/// Each chat process reads the shared history after it acquires the lock.
struct Agent; fn main() {
/// The person's next contribution to the conversation. wstd::runtime::block_on(async { Agent::new().run().await });
#[schema] }
struct ChatParams {
/// The complete text of the person's request. struct Agent {
text: String, inbox: Inbox,
/// The thinking level of this turn only. Without it, the turn uses `thinking` from history: Vec<Entry>,
/// `/config/agent.yaml`. history_skip: usize,
#[serde(default, skip_serializing_if = "Option::is_none")] active: bool,
thinking: Option<Thinking>, queue: VecDeque<Message>,
notes: Vec<Message>,
} }
#[rpc(grace_seconds = 600)]
impl Agent { impl Agent {
/// Answer a person, with interim text notifications and a final text result. fn new() -> Self {
/// let history = std::fs::read_to_string(HISTORY)
/// The history lock covers inference and tools, so concurrent chats cannot interleave turns. .unwrap_or_default()
async fn chat(&self, ctx: &Ctx, p: ChatParams) -> Result<String> {
let _guard = lock(HISTORY).await?;
std::fs::create_dir_all("/state/agent")?;
let mut cfg = Config::load().map_err(|error| Error::new(-32603, error))?;
cfg.thinking = p.thinking.or(cfg.thinking);
let mut entries = load_history(HISTORY)?;
repair_tools(&mut entries)?;
let current = entries.len();
append(&mut entries, Entry::User { text: p.text })?;
let mut start = std::fs::read_to_string(SKIP)
.ok()
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
start = next_turn_start(&entries, start, current).unwrap_or(current);
let definitions = tools::definitions();
loop {
let response = match llm::complete(&cfg, SYSTEM, &definitions, &entries[start..]).await
{
Ok(response) => response,
Err(error) => {
if is_too_large(&error)
&& let Some(next) = next_turn_start(&entries, start + 1, current)
{
start = next;
continue;
}
return Err(Error::new(-32603, error));
}
};
std::fs::write(SKIP, start.to_string())?;
let has_tools = response
.iter()
.any(|entry| matches!(entry, Entry::ToolCall { .. }));
let mut final_text = String::new();
let mut calls = Vec::new();
for entry in response {
match &entry {
Entry::Assistant { text, phase }
if has_tools || phase.as_deref() == Some("commentary") =>
{
ctx.notify("text", json!({"text":text}))?
}
Entry::Assistant { text, .. } => {
if !final_text.is_empty() && !text.is_empty() {
final_text.push_str("\n\n");
}
final_text.push_str(text);
}
Entry::ToolCall {
id,
name,
arguments,
} => calls.push((id.clone(), name.clone(), arguments.clone())),
_ => {}
}
append(&mut entries, entry)?;
}
if !has_tools {
return Ok(final_text);
}
for (id, name, arguments) in calls {
let args = serde_json::from_str::<serde_json::Value>(&arguments);
let output = match &args {
Ok(args) => tools::run(&name, args)
.await
.unwrap_or_else(|error| json!({"error":error})),
Err(error) => json!({"error":format!("arguments are not valid JSON: {error}")}),
};
let output = if name == "read_file"
&& args
.as_ref()
.ok()
.and_then(|a| a["path"].as_str())
.is_some_and(|p| p.starts_with("/logs/"))
{
output["content"]
.as_str()
.map(str::to_owned)
.unwrap_or_else(|| output.to_string())
} else {
output.to_string()
};
let tail = args
.as_ref()
.ok()
.and_then(|a| a["method"].as_str())
.is_some_and(|m| {
[".exec", ".log", ".build", ".test"]
.iter()
.any(|suffix| m.ends_with(suffix))
});
let output = tools::overflow::guard(output, "default", &id, tail);
append(&mut entries, Entry::ToolResult { id, output })?;
}
}
}
}
/// Read persisted entries in their original order. A new world has no history file.
fn load_history(path: &str) -> Result<Vec<Entry>> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(text
.lines() .lines()
.filter_map(|line| match serde_json::from_str(line) { .filter_map(|line| serde_json::from_str(line).ok())
Ok(entry) => Some(entry), .collect();
Err(error) => { let history_skip = std::fs::read_to_string(HISTORY_SKIP)
eprintln!("skip damaged history line: {error}"); .ok()
None .and_then(|skip| skip.trim().parse::<usize>().ok())
.unwrap_or(0);
let active = std::fs::read_to_string(TURN)
.ok()
.and_then(|contents| serde_json::from_str::<bool>(&contents).ok())
.unwrap_or(false);
Self {
inbox: Inbox::start(),
history,
history_skip,
active,
queue: VecDeque::new(),
notes: Vec::new(),
} }
})
.collect::<Vec<_>>()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(error) => Err(error.into()),
} }
}
/// Persist an entry before it enters the current inference context. fn add_history(&mut self, entry: Entry) {
fn append(history: &mut Vec<Entry>, entry: Entry) -> Result<()> { let line = serde_json::to_string(&entry).unwrap();
let mut file = std::fs::OpenOptions::new() match std::fs::OpenOptions::new()
.create(true) .create(true)
.append(true) .append(true)
.open(HISTORY)?; .open(HISTORY)
file.write_all(format!("{}\n", serde_json::to_string(&entry)?).as_bytes())?; {
history.push(entry); Ok(mut file) => {
Ok(()) if let Err(e) = writeln!(file, "{line}") {
} eprintln!("agent: cannot append history: {e}");
/// Complete interrupted tool pairs before a new inference request. }
fn repair_tools(history: &mut Vec<Entry>) -> Result<()> { }
let answered: std::collections::HashSet<_> = history Err(e) => eprintln!("agent: cannot append history: {e}"),
}
self.history.push(entry);
}
fn finish_turn(&mut self) {
self.active = false;
let _ = std::fs::remove_file(TURN);
}
fn closing(&self) -> bool {
self.inbox.is_closed()
}
fn repair_dangling_tools(&mut self) {
let answered: HashSet<String> = self
.history
.iter() .iter()
.filter_map(|entry| match entry { .filter_map(|entry| match entry {
Entry::ToolResult { id, .. } => Some(id.clone()), Entry::ToolResult { id, .. } => Some(id.clone()),
_ => None, _ => None,
}) })
.collect(); .collect();
let dangling: Vec<_> = history let dangling: Vec<String> = self
.history
.iter() .iter()
.filter_map(|entry| match entry { .filter_map(|entry| match entry {
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()), Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
@@ -175,37 +100,240 @@ fn repair_tools(history: &mut Vec<Entry>) -> Result<()> {
}) })
.collect(); .collect();
for id in dangling { for id in dangling {
append( self.add_history(Entry::ToolResult {
history,
Entry::ToolResult {
id, id,
output: json!({"error":"interrupted before the tool finished"}).to_string(), output:
json!({"error": "interrupted: the agent restarted before this tool finished"})
.to_string(),
});
}
}
fn turn_text(&self, message: &Message) -> String {
let mut text = format!(
"Message {} from {}:\n{}",
message.id,
message.from,
message.body["text"].as_str().unwrap_or("")
);
if !self.notes.is_empty() {
let notes = self
.notes
.iter()
.map(|note| serde_json::to_string(note).unwrap())
.collect::<Vec<_>>()
.join("\n");
text = format!("Messages that arrived while you were idle:\n{notes}\n\n{text}");
}
text
}
fn not_configured(&mut self, error: String) {
let _ = publish(json!({
"type": "text",
"text": format!("I am not configured: cannot read {CONFIG}: {error}")
}));
self.finish_turn();
}
async fn run(mut self) {
self.repair_dangling_tools();
if self.active {
self.turn(None).await;
if self.closing() && self.active {
return;
}
}
loop {
let message = match self.queue.pop_front() {
Some(message) => message,
None => match self.inbox.recv().await {
Some(message) => message,
None => break,
}, },
)?; };
if starts_turn(&message) {
self.turn(Some(message)).await;
if self.closing() && self.active {
break;
}
} else {
self.notes.push(message);
}
}
}
async fn turn(&mut self, message: Option<Message>) {
let current_start = if message.is_some() {
self.active = true;
let _ = std::fs::write(TURN, "true");
self.history.len()
} else {
let current_start = self
.history
.iter()
.rposition(|entry| matches!(entry, Entry::User { text } if !text.starts_with("[note from your runtime]")))
.unwrap_or(self.history.len());
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_default();
self.add_history(Entry::User {
text: format!(
"[note from your runtime] you restarted and now run version {version}. Continue the active turn."
),
});
current_start
};
let cfg = match load_config() {
Ok(cfg) => cfg,
Err(error) => {
self.not_configured(error);
return;
}
};
if let Some(message) = message {
let text = self.turn_text(&message);
self.notes.clear();
self.add_history(Entry::User { text });
}
self.steps(&cfg, current_start).await;
if !self.closing() {
self.finish_turn();
}
}
async fn steps(&mut self, cfg: &Config, current_start: usize) {
let tools = tools::definitions();
loop {
if self.closing() {
return;
}
let start = next_turn_start(&self.history, self.history_skip, current_start)
.unwrap_or(current_start);
if start != self.history_skip {
self.history_skip = start;
let _ = std::fs::write(HISTORY_SKIP, start.to_string());
}
let response = llm::complete(cfg, SYSTEM, &tools, &self.history[start..]).await;
let entries = match response {
Ok(entries) => entries,
Err(e) => {
if is_too_large(&e) {
if let Some(next) =
next_turn_start(&self.history, start.saturating_add(1), current_start)
{
self.history_skip = next;
let _ = std::fs::write(HISTORY_SKIP, next.to_string());
continue;
}
}
let text = format!("Inference failed: {e}");
self.add_history(Entry::Assistant {
text: text.clone(),
phase: None,
});
let _ = publish(json!({"type": "text", "text": text}));
return;
}
};
let calls: Vec<(String, String, String)> = entries
.iter()
.filter_map(|entry| match entry {
Entry::ToolCall {
id,
name,
arguments,
} => Some((id.clone(), name.clone(), arguments.clone())),
_ => None,
})
.collect();
for entry in entries {
if let Entry::Assistant { text, .. } = &entry {
if !text.is_empty() {
let _ = publish(json!({"type": "text", "text": text}));
}
}
self.add_history(entry);
}
if self.closing() {
return;
}
if calls.is_empty() {
return;
}
for (id, name, args) in calls {
if self.closing() {
return;
}
let output = self.tool(&name, &args).await;
self.add_history(Entry::ToolResult {
id,
output: output.to_string(),
});
}
}
}
fn stash(&mut self, message: Message, others: &mut Vec<Value>) {
if starts_turn(&message) {
others.push(json!({"queued_for_a_later_turn": true, "message": message}));
self.queue.push_back(message);
} else {
others.push(serde_json::to_value(message).unwrap());
}
}
fn drain_inbox(&mut self, others: &mut Vec<Value>) {
while let Some(message) = self.inbox.try_recv() {
self.stash(message, others);
}
}
async fn tool(&mut self, name: &str, args: &str) -> Value {
let args: Value = match serde_json::from_str(args) {
Ok(args) => args,
Err(error) => return json!({"error": format!("arguments are not valid JSON: {error}")}),
};
if name == "send_message" {
return self.send_message(&args).await;
}
tools::run(name, &args).unwrap_or_else(|error| json!({"error": error.to_string()}))
}
async fn send_message(&mut self, args: &Value) -> Value {
let Some(to) = args["to"].as_str() else {
return json!({"error": "missing \"to\""});
};
if !args["body"].is_object() {
return json!({"error": "\"body\" must be a JSON object"});
}
let id = send(to, args["body"].clone());
let wait = args["wait_seconds"].as_f64().unwrap_or(0.0).max(0.0);
let mut reply = Value::Null;
let mut others = Vec::new();
if wait > 0.0 {
let deadline = Instant::now() + Duration::from_secs_f64(wait);
loop {
match self.inbox.recv().timeout(deadline).await {
Ok(Some(message)) if message.reply_to.as_deref() == Some(&id) => {
reply = serde_json::to_value(message).unwrap();
break;
}
Ok(Some(message)) => self.stash(message, &mut others),
Ok(None) => break,
Err(_) => break,
}
}
}
self.drain_inbox(&mut others);
json!({"id": id, "reply": reply, "waited": wait > 0.0, "other_messages": others})
} }
Ok(())
}
/// Start the plugin's JSON-RPC transport.
fn main() {
sausage_plugin::run(Agent);
} }
#[cfg(test)] fn load_config() -> Result<Config, String> {
mod tests { std::fs::read_to_string(CONFIG)
#[test] .map_err(|e| e.to_string())
fn damaged_history_keeps_valid_entries() { .and_then(|contents| serde_json::from_str(&contents).map_err(|e| e.to_string()))
let path = if cfg!(target_os = "wasi") { }
std::path::PathBuf::from("/history-test.jsonl")
} else { fn starts_turn(message: &Message) -> bool {
std::env::temp_dir().join(format!("sausage-history-{}", std::process::id())) message.kind() == "text" && message.reply_to.is_none()
};
std::fs::write(
&path,
"{\"kind\":\"user\",\"text\":\"preserved\"}\n{\"kind\":",
)
.unwrap();
let history = super::load_history(path.to_str().unwrap()).unwrap();
assert_eq!(history.len(), 1);
std::fs::remove_file(path).unwrap();
}
} }
-258
View File
@@ -1,258 +0,0 @@
//! Context-size guard; full output is in the referenced file. Never a data limit.
use std::io::{self, Write};
use std::sync::atomic::{AtomicU64, Ordering};
// One policy point: Pi's 50 KiB / 2000 lines, including room for the reference.
const CONTEXT_LIMIT: (usize, usize) = (50 * 1024, 2000);
static NEXT: AtomicU64 = AtomicU64::new(0);
fn component(s: &str) -> String {
s.bytes()
.take(80)
.map(|b| {
if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' {
b as char
} else {
'_'
}
})
.collect()
}
pub fn exceeds_limit(output: &str) -> bool {
output.len() > CONTEXT_LIMIT.0 || output.lines().count() > CONTEXT_LIMIT.1
}
/// Save before excerpting. On storage failure return all data, not a false reference.
pub fn guard(output: String, session: &str, id: &str, tail: bool) -> String {
if !exceeds_limit(&output) {
return output;
}
// Decode only results that crossed the context guard. Archive physical
// string lines, not JSON escapes, even if decoding makes the text smaller.
let output = serde_json::from_str::<serde_json::Value>(&output)
.map(|value| super::render(&value))
.unwrap_or(output);
let pid = std::env::var("SAUSAGE_PID").unwrap_or_else(|_| "test".into());
let dir = format!("/logs/agent/{}/{}", date(), component(session));
let saved = (|| -> io::Result<String> {
std::fs::create_dir_all(&dir)?;
// create_new prevents overwriting even if an ID is reused or a process restarts.
loop {
let path = format!(
"{dir}/{}-{}-{}.txt",
component(id),
component(&pid),
NEXT.fetch_add(1, Ordering::Relaxed)
);
match std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
{
Ok(mut f) => {
f.write_all(output.as_bytes())?;
return Ok(path);
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
})();
match saved {
Ok(path) => excerpt(&output, &path, tail, 0, output.len()),
Err(e) => {
eprintln!("overflow archive failed: {e}; retaining complete tool result");
output
}
}
}
fn date() -> String {
let days = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
/ 86400;
let z = days as i64 + 719468;
let era = z / 146097;
let doe = z - era * 146097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = mp + if mp < 10 { 3 } else { -9 };
format!("{:04}-{m:02}-{d:02}", y + i64::from(m <= 2))
}
/// Excerpt an existing archive in place. Byte/line ranges refer to that file,
/// including when a caller requests an oversized line or the entire archive.
pub fn existing(bytes: &[u8], path: &str, args: &serde_json::Value) -> String {
let text = String::from_utf8_lossy(bytes);
let (mut from, mut to) = if let Some(line) = args["line_offset"].as_u64() {
let from = text
.split_inclusive('\n')
.take(line as usize)
.map(str::len)
.sum::<usize>();
let len = text[from..]
.split_inclusive('\n')
.take(args["line_limit"].as_u64().unwrap_or(200) as usize)
.map(str::len)
.sum::<usize>();
(from, from + len)
} else {
let from = (args["offset"].as_u64().unwrap_or(0) as usize).min(text.len());
(
from,
args["max_bytes"].as_u64().map_or(text.len(), |n| {
from.saturating_add(n as usize).min(text.len())
}),
)
};
while !text.is_char_boundary(from) {
from += 1;
}
while to > from && !text.is_char_boundary(to) {
to -= 1;
}
to = to.max(from);
excerpt(&text, path, false, from, to)
}
fn excerpt(output: &str, path: &str, tail: bool, from: usize, to: usize) -> String {
let selected = &output[from..to];
let lines: Vec<&str> = selected.split_inclusive('\n').collect();
let budget = CONTEXT_LIMIT.0.saturating_sub(1024);
let mut start = 0;
let mut end = lines.len();
let mut bytes = 0;
if tail {
start = end;
while start > 0
&& end - start < CONTEXT_LIMIT.1.saturating_sub(2)
&& bytes + lines[start - 1].len() <= budget
{
start -= 1;
bytes += lines[start].len();
}
} else {
end = 0;
while end < lines.len()
&& end < CONTEXT_LIMIT.1.saturating_sub(2)
&& bytes + lines[end].len() <= budget
{
bytes += lines[end].len();
end += 1;
}
}
let mut shown = lines[start..end].concat();
// A single huge line: preserve valid UTF-8 and explicitly label a partial line.
let partial = start == end;
if partial && !lines.is_empty() {
if tail {
start = lines.len() - 1;
let line = lines[start];
let mut from = line.len().saturating_sub(budget);
while !line.is_char_boundary(from) {
from += 1;
}
shown.push_str(&line[from..]);
} else {
let mut to = budget.min(lines[0].len());
while !lines[0].is_char_boundary(to) {
to -= 1;
}
shown.push_str(&lines[0][..to]);
}
}
let mut byte_start = from + lines[..start].iter().map(|s| s.len()).sum::<usize>();
if partial && tail {
byte_start = to - shown.len();
}
let byte_end = byte_start + shown.len();
let line_start = output[..byte_start].bytes().filter(|b| *b == b'\n').count();
let line_end = line_start + shown.split_inclusive('\n').count();
let total_lines = output.split_inclusive('\n').count();
let partial = partial
|| (from > 0 && !output[..from].ends_with('\n'))
|| (to < output.len() && !output[..to].ends_with('\n'));
let read = if partial {
format!(
"Shown byte range [{byte_start},{byte_end}); continue with read_file path={path}, offset={byte_end}, max_bytes={budget} (or choose an earlier byte offset)"
)
} else {
format!(
"Read with read_file path={path}, line_offset={line_start}, line_limit={}; next line_offset={line_end}",
line_end - line_start
)
};
format!(
"{shown}\n[Showing lines {}-{} of {}{}; total {} bytes. Full output: {path}. {read}]",
line_start + 1,
line_end,
total_lines,
if partial { " (partial line)" } else { "" },
output.len()
)
}
/// Zero-based line offsets; byte-based reads remain unchanged when absent.
pub fn read_lines(bytes: &[u8], offset: usize, limit: usize) -> String {
String::from_utf8_lossy(bytes)
.split_inclusive('\n')
.skip(offset)
.take(limit)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn full_archive_and_context_guard() {
let original = "😀 line\n".repeat(5400);
for tail in [false, true] {
let shown = guard(original.clone(), "guard-test", "call", tail);
assert!(shown.len() <= CONTEXT_LIMIT.0);
assert!(shown.lines().count() <= CONTEXT_LIMIT.1);
assert!(shown.contains(if tail {
"Showing lines 3403-5400"
} else {
"Showing lines 1-1998"
}));
let path = shown
.split("Full output: ")
.nth(1)
.unwrap()
.split(". Read with")
.next()
.unwrap();
assert_eq!(std::fs::read_to_string(path).unwrap(), original);
}
assert_eq!(guard("small".into(), "test", "id", false), "small");
}
#[test]
fn archive_failure_does_not_discard_data() {
std::fs::create_dir_all(format!("/logs/agent/{}", date())).unwrap();
std::fs::write(format!("/logs/agent/{}/not-a-directory", date()), "block").unwrap();
let output = "no loss\n".repeat(6000);
assert_eq!(
guard(output.clone(), "not-a-directory", "test", false),
output
);
}
#[test]
fn huge_line_and_exact_boundaries() {
for tail in [false, true] {
let result = guard("😀".repeat(40000), "test", "utf8", tail);
assert!(result.len() <= CONTEXT_LIMIT.0);
assert!(result.contains("partial line"));
}
let exact = "a\n".repeat(CONTEXT_LIMIT.1);
assert_eq!(guard(exact.clone(), "test", "exact", false), exact);
assert_eq!(read_lines(b"one\ntwo\nthree", 1, 1), "two\n");
assert_eq!(read_lines(b"one", 3, 1), "");
}
}
+5 -10
View File
@@ -1,10 +1,5 @@
You are the Sausage agent. Answer the person. Use tools to inspect or change the world. You are the `agent` actor in a Sausage world of actors.
People talk to you through other actors. Your text goes to your subscribers.
Read the contract at `/proc/core/PROTOCOL.md`. The Rust plugin SDK and its documentation are at `/plugins/cargo/sdk/`. Before writing an actor, read `/actor/broker/public/PROTOCOL.md` for messages, the broker, `cargo`, and tests.
Build a plugin with `cargo.build`. Activate its WASM file with `core.activate`. Read the SDK at `/actor/cargo/public/sdk/` before you write an actor.
Your own source is at `/plugins/agent/source/`. You can change and rebuild it like any other plugin. You can change your own source at `/actor/agent/source`.
HARD RULE: the mediator and all agents NEVER call `core.restart` (or its alias `core.exit`) without the person's explicit approval each time.
Restart requires the host to run under `#loop` (`SAUSAGE_LOOP=1`); it answers before clean shutdown with exit code 75.
Never call `agent.chat`. It would wait on the history lock that your current turn holds.
Write logs to stderr. Use call results and notifications for communication.
The interface check does not cover persistent state. Preserve state compatibility when you change a plugin.
+15 -145
View File
@@ -1,84 +1,38 @@
//! Tool descriptions and file operations for the agent. //! Tool descriptions and file operations for the agent.
use crate::llm::Tool; use crate::llm::Tool;
use serde_json::{Value, json}; use sausage_actor::{Value, json};
use std::io; use std::io;
#[path = "overflow.rs"]
pub mod overflow;
/// Human-readable, lossless text representation of tool JSON. String values
/// retain real newlines, with JSON-quoted field/index labels for structure.
fn render(value: &Value) -> String {
fn visit(value: &Value, label: &str, out: &mut String) {
match value {
Value::Object(fields) => {
if fields.is_empty() {
out.push_str(&format!("{label}: {{}}\n"));
}
for (key, value) in fields {
visit(
value,
&format!("{label}[{}]", serde_json::to_string(key).unwrap()),
out,
);
}
}
Value::Array(items) => {
if items.is_empty() {
out.push_str(&format!("{label}: []\n"));
}
for (i, value) in items.iter().enumerate() {
visit(value, &format!("{label}[{i}]"), out);
}
}
Value::String(text) => {
out.push_str(&format!("{label} (string, {} bytes):\n", text.len()));
out.push_str(text);
out.push('\n');
}
_ => {
out.push_str(&format!("{label}: {value}\n"));
}
}
}
let mut out = String::new();
visit(value, "$", &mut out);
out
}
/// Describe the five tools sent to either inference API.
pub fn definitions() -> Vec<Tool> { pub fn definitions() -> Vec<Tool> {
vec![ vec![
Tool { Tool {
name: "call", name: "send_message",
description: "Call a plugin method through the core. Await its result. Set detach to let its process survive this caller. Large results show an excerpt with a full-output /logs reference.", description: "Send an actor a JSON body with a `type`. `broker` and `cargo` are built-in actors; see `/actor/broker/public/PROTOCOL.md`. With `wait_seconds`, the reply's `reply_to` matches the returned ID. Returns the ID, reply, and other messages received.",
parameters: schema( parameters: schema(
json!({ json!({
"method": {"type": "string", "description": "Qualified plugin method"}, "to": {"type": "string", "description": "Actor ID"},
"params": {"type": "object", "description": "Named method parameters"}, "body": {"type": "object", "description": "JSON object with a type field"},
"detach": {"type": "boolean", "description": "Detach the new process. Default false."} "wait_seconds": {"type": "number", "description": "Seconds to wait for a reply. Default 0."}
}), }),
&["method", "params"], &["to", "body"],
), ),
}, },
Tool { Tool {
name: "read_file", name: "read_file",
description: "Read a text file in the world, including `/proc`. `offset`/`max_bytes` select bytes; `line_offset`/`line_limit` select zero-based lines (default 200). Large results show an excerpt with a full-output /logs reference.", description: "Read a text file under `/data`, `/public`, or `/actor/<id>`. Reads all bytes by default; `offset` and `max_bytes` are optional.",
parameters: schema( parameters: schema(
json!({ json!({
"path": {"type": "string"}, "path": {"type": "string"},
"offset": {"type": "integer", "description": "Byte offset (default 0)"}, "offset": {"type": "integer", "description": "Byte offset (default 0)"},
"max_bytes": {"type": "integer", "description": "Optional maximum number of bytes to read"}, "max_bytes": {"type": "integer", "description": "Optional maximum number of bytes to read"}
"line_offset": {"type": "integer", "description": "Zero-based starting line; overrides bytes"},
"line_limit": {"type": "integer", "description": "Number of lines; default 200"}
}), }),
&["path"], &["path"],
), ),
}, },
Tool { Tool {
name: "write_file", name: "write_file",
description: "Write a text file in the world. Parent folders are created. Large results show an excerpt with a full-output /logs reference.", description: "Write text under `/data` or `/public`. Parent folders are created.",
parameters: schema( parameters: schema(
json!({"path": {"type": "string"}, "content": {"type": "string"}}), json!({"path": {"type": "string"}, "content": {"type": "string"}}),
&["path", "content"], &["path", "content"],
@@ -86,12 +40,12 @@ pub fn definitions() -> Vec<Tool> {
}, },
Tool { Tool {
name: "list_dir", name: "list_dir",
description: "List entries in a world directory. Returns each name, type, and size. Large results show an excerpt with a full-output /logs reference.", description: "List entries in a directory under `/data`, `/public`, or `/actor/<id>`. Returns each name, type, and size.",
parameters: schema(json!({"path": {"type": "string"}}), &["path"]), parameters: schema(json!({"path": {"type": "string"}}), &["path"]),
}, },
Tool { Tool {
name: "copy_dir", name: "copy_dir",
description: "Copy a directory tree, for example a plugin source to a draft folder. Skips `target/` folders. Large results show an excerpt with a full-output /logs reference.", description: "Copy a directory tree, for example `/actor/<id>/source` to `/public/drafts/<id>`. Skips `target/` folders.",
parameters: schema( parameters: schema(
json!({"from": {"type": "string"}, "to": {"type": "string"}}), json!({"from": {"type": "string"}, "to": {"type": "string"}}),
&["from", "to"], &["from", "to"],
@@ -100,47 +54,23 @@ pub fn definitions() -> Vec<Tool> {
] ]
} }
/// Create the named-parameter object required by a tool.
fn schema(properties: Value, required: &[&str]) -> Value { fn schema(properties: Value, required: &[&str]) -> Value {
json!({"type": "object", "properties": properties, "required": required}) json!({"type": "object", "properties": properties, "required": required})
} }
/// Execute a tool. The chat loop returns errors to the model as tool results. pub fn run(name: &str, args: &Value) -> io::Result<Value> {
pub async fn run(name: &str, args: &Value) -> sausage_plugin::Result<Value> { match name {
if name == "call" {
let method = args["method"]
.as_str()
.ok_or_else(|| sausage_plugin::Error::new(-32602, "missing method"))?;
let call = sausage_plugin::call(method, args.get("params").cloned().unwrap_or(json!({})));
return if args["detach"] == true {
call.detach().await
} else {
call.await
};
}
Ok(match name {
"read_file" => read_file(args), "read_file" => read_file(args),
"write_file" => write_file(args), "write_file" => write_file(args),
"list_dir" => list_dir(args), "list_dir" => list_dir(args),
"copy_dir" => copy_dir(args), "copy_dir" => copy_dir(args),
other => Ok(json!({"error": format!("unknown tool {other}")})), other => Ok(json!({"error": format!("unknown tool {other}")})),
}?) }
} }
/// Read an optional byte range and report the complete file size.
fn read_file(args: &Value) -> io::Result<Value> { fn read_file(args: &Value) -> io::Result<Value> {
let path = args["path"].as_str().unwrap_or_default(); let path = args["path"].as_str().unwrap_or_default();
let bytes = std::fs::read(path)?; let bytes = std::fs::read(path)?;
if path.starts_with("/logs/") {
return Ok(
json!({"path":path,"size":bytes.len(),"content":overflow::existing(&bytes, path, args)}),
);
}
if let Some(line) = args["line_offset"].as_u64() {
return Ok(
json!({"path":path,"size":bytes.len(),"line_offset":line,"content":overflow::read_lines(&bytes, line as usize, args["line_limit"].as_u64().unwrap_or(200) as usize)}),
);
}
let offset = (args["offset"].as_u64().unwrap_or(0) as usize).min(bytes.len()); let offset = (args["offset"].as_u64().unwrap_or(0) as usize).min(bytes.len());
let end = args["max_bytes"].as_u64().map_or(bytes.len(), |max| { let end = args["max_bytes"].as_u64().map_or(bytes.len(), |max| {
offset.saturating_add(max as usize).min(bytes.len()) offset.saturating_add(max as usize).min(bytes.len())
@@ -154,7 +84,6 @@ fn read_file(args: &Value) -> io::Result<Value> {
})) }))
} }
/// Create parent directories and replace a text file.
fn write_file(args: &Value) -> io::Result<Value> { fn write_file(args: &Value) -> io::Result<Value> {
let path = args["path"].as_str().unwrap_or_default(); let path = args["path"].as_str().unwrap_or_default();
if let Some(parent) = std::path::Path::new(path).parent() { if let Some(parent) = std::path::Path::new(path).parent() {
@@ -164,7 +93,6 @@ fn write_file(args: &Value) -> io::Result<Value> {
Ok(json!({"written": path})) Ok(json!({"written": path}))
} }
/// Return directory metadata in name order.
fn list_dir(args: &Value) -> io::Result<Value> { fn list_dir(args: &Value) -> io::Result<Value> {
let mut entries: Vec<Value> = std::fs::read_dir(args["path"].as_str().unwrap_or_default())? let mut entries: Vec<Value> = std::fs::read_dir(args["path"].as_str().unwrap_or_default())?
.filter_map(|entry| entry.ok()) .filter_map(|entry| entry.ok())
@@ -181,17 +109,7 @@ fn list_dir(args: &Value) -> io::Result<Value> {
Ok(json!({"entries": entries})) Ok(json!({"entries": entries}))
} }
/// Copy a tree and report its file count.
fn copy_dir(args: &Value) -> io::Result<Value> { fn copy_dir(args: &Value) -> io::Result<Value> {
let from = std::fs::canonicalize(args["from"].as_str().unwrap_or_default())?;
let to = args["to"].as_str().unwrap_or_default();
std::fs::create_dir_all(to)?;
if std::fs::canonicalize(to)?.starts_with(&from) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"destination is inside the source",
));
}
let count = copy_tree( let count = copy_tree(
args["from"].as_str().unwrap_or_default(), args["from"].as_str().unwrap_or_default(),
args["to"].as_str().unwrap_or_default(), args["to"].as_str().unwrap_or_default(),
@@ -199,7 +117,6 @@ fn copy_dir(args: &Value) -> io::Result<Value> {
Ok(json!({"copied_files": count})) Ok(json!({"copied_files": count}))
} }
/// Copy files recursively without build artifacts in target directories.
fn copy_tree(from: &str, to: &str) -> io::Result<u64> { fn copy_tree(from: &str, to: &str) -> io::Result<u64> {
std::fs::create_dir_all(to)?; std::fs::create_dir_all(to)?;
let mut count = 0; let mut count = 0;
@@ -219,50 +136,3 @@ fn copy_tree(from: &str, to: &str) -> io::Result<u64> {
} }
Ok(count) Ok(count)
} }
#[cfg(test)]
mod tests {
#[test]
fn overflow_json_can_be_followed_by_line_without_rearchiving() {
let value = serde_json::json!({"stdout":"hello 😀\n".repeat(4000),"exit":1,"error":{"text":"failure\n".repeat(4000)}});
let expected = super::render(&value);
let shown = super::overflow::guard(value.to_string(), "json-test", "call", true);
assert!(shown.len() <= 50 * 1024 && shown.lines().count() <= 2000);
let path = shown
.split("Full output: ")
.nth(1)
.unwrap()
.split(". Read with")
.next()
.unwrap();
assert_eq!(std::fs::read_to_string(path).unwrap(), expected);
let parent = std::path::Path::new(path).parent().unwrap();
let count = std::fs::read_dir(parent).unwrap().count();
let args = serde_json::json!({"path":path,"line_offset":30,"line_limit":5});
let read = super::read_file(&args).unwrap();
let read = read["content"].as_str().unwrap();
assert!(read.contains("Showing lines 31-35"));
assert!(read.contains(path));
assert_eq!(std::fs::read_dir(parent).unwrap().count(), count);
assert!(
super::overflow::existing(
expected.as_bytes(),
path,
&serde_json::json!({"line_offset":u64::MAX})
)
.contains("Full output:")
);
}
#[test]
fn copy_rejects_a_destination_inside_the_source() {
let root = if cfg!(target_os = "wasi") {
std::path::PathBuf::from("/copy-test")
} else {
std::env::temp_dir().join(format!("sausage-copy-{}", std::process::id()))
};
std::fs::create_dir_all(&root).unwrap();
let result = super::copy_dir(&serde_json::json!({"from":root,"to":root.join("target")}));
let _ = std::fs::remove_dir_all(root);
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"description": "the agent answers text with text, even without config or network",
"steps": [
{"send": {"type": "text", "text": "Hello"}, "expect": {"type": "text"}}
]
}
+2 -20
View File
@@ -2,7 +2,7 @@
name = "sausage" name = "sausage"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
description = "Grow a system of WASI plugins by talking to it." description = "Grow a system of WASI actors by talking to it."
[[bin]] [[bin]]
name = "sausage" name = "sausage"
@@ -11,32 +11,14 @@ path = "src/main.rs"
[dependencies] [dependencies]
anyhow = "1" anyhow = "1"
bytes = "1" bytes = "1"
clap = { version = "4", features = ["derive", "env"] } clap = { version = "4", features = ["derive"] }
http = "1" http = "1"
http-body-util = "0.1" http-body-util = "0.1"
include_dir = "0.7" include_dir = "0.7"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml_ng = "0.10"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
toml_edit = "0.25" toml_edit = "0.25"
wasmtime = "49" wasmtime = "49"
wasmtime-wasi = "49" wasmtime-wasi = "49"
wasmtime-wasi-http = "49" wasmtime-wasi-http = "49"
uuid = { version = "1", features = ["v7"] }
sha2 = "0.10"
rusqlite = { version = "0.38", features = ["bundled"] }
sausage-vfs = { version = "0.1.0", path = "../vfs" }
sausage-interface = { version = "0.1.0", path = "../interface" }
notify = "8.2.0"
rustyline = "19"
nix = { version = "0.31.3", features = ["signal", "process", "term"] }
chrono = { version = "0.4.42", default-features = false, features = ["clock"] }
tempfile = "3.27.0"
[dev-dependencies]
tokio = { version = "1", features = ["test-util"] }
hyper = { version = "1.12.0", features = ["server", "http1"] }
hyper-util = { version = "0.1.21", features = ["tokio"] }
sausage-interface = { path = "../interface" }
-302
View File
@@ -1,302 +0,0 @@
//! Serialized candidate-copy activation and revert under PROTOCOL.md section 10.3.
use crate::{
Host, RpcResult, error,
graph::{Interface, io_error, problems, read_hashed},
};
use serde_json::{Value, json};
use std::{collections::BTreeMap, path::PathBuf};
/// Method descriptions plus their reachable local references, without expanding cycles.
fn methods(document: &Value) -> BTreeMap<String, Value> {
fn references(value: &Value, document: &Value, found: &mut BTreeMap<String, Value>) {
match value {
Value::Object(object) => {
if let Some(reference) = object.get("$ref").and_then(Value::as_str)
&& let Some(pointer) = reference.strip_prefix('#')
&& !found.contains_key(reference)
&& let Some(target) = document.pointer(pointer)
{
found.insert(reference.into(), target.clone());
references(target, document, found);
}
for child in object.values() {
references(child, document, found);
}
}
Value::Array(array) => {
for child in array {
references(child, document, found);
}
}
_ => {}
}
}
document["methods"]
.as_array()
.into_iter()
.flatten()
.filter_map(|method| {
let name = method["name"].as_str()?;
let mut found = BTreeMap::new();
references(method, document, &mut found);
Some((name.into(), json!({"method":method,"references":found})))
})
.collect()
}
struct Candidate {
plugin: String,
copy: PathBuf,
interface: Interface,
}
impl Host {
/// Copies first, discovers that copy, checks the combined graph, then installs it.
pub(crate) async fn activation(&self, _caller: &str, method: &str, params: Value) -> RpcResult {
let host = self.clone();
let method = method.to_owned();
tokio::spawn(async move { host.activation_body(&method, params).await })
.await
.map_err(|failure| error(-32603, &failure.to_string()))?
}
async fn activation_body(&self, method: &str, params: Value) -> RpcResult {
let _activation = self.0.activations.lock().await;
let mut candidates = Vec::new();
let result = self
.activate_candidates(method, &params, &mut candidates)
.await;
for candidate in candidates {
let _ = tokio::fs::remove_file(candidate.copy).await;
}
result
}
/// Prepares every member before graph checks or changes to installed files.
async fn activate_candidates(
&self,
method: &str,
params: &Value,
candidates: &mut Vec<Candidate>,
) -> RpcResult {
let requests = params
.get("plugins")
.and_then(Value::as_array)
.cloned()
.unwrap_or_else(|| vec![params.clone()]);
if requests.is_empty() {
return Err(error(-32602, "plugins must not be empty"));
}
for request in requests {
let plugin = request["plugin"]
.as_str()
.ok_or_else(|| error(-32602, "plugin must be a string"))?;
if plugin.is_empty()
|| !plugin.bytes().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| byte == b'_'
|| byte == b'-'
})
|| candidates
.iter()
.any(|candidate| candidate.plugin == plugin)
{
return Err(error(-32602, "invalid or duplicate plugin"));
}
let directory = self.0.world.plugin_dir(plugin);
tokio::fs::create_dir_all(&directory)
.await
.map_err(io_error)?;
let source = if method == "revert" {
directory.join("plugin.prev.wasm")
} else {
let source = PathBuf::from(
request["wasm"]
.as_str()
.ok_or_else(|| error(-32602, "wasm must be a string"))?,
);
if source.starts_with(&self.0.world.root) {
source
} else {
self.0
.world
.root
.join(source.strip_prefix("/").unwrap_or(&source))
}
};
let copy = directory.join(format!("activate-{}.wasm", uuid::Uuid::now_v7()));
// Read once. Discovery and installation both use the same private bytes.
let (bytes, hash) = read_hashed(source).await.map_err(io_error)?;
tokio::fs::write(&copy, &bytes).await.map_err(io_error)?;
self.0
.journal
.write("core", plugin, "core", "activation copied candidate");
let discovery = self.discover_copy(plugin, &copy, hash).await;
let interface = match discovery {
Ok(interface) => interface,
Err(error) => {
let _ = tokio::fs::remove_file(copy).await;
return Err(error);
}
};
self.0
.journal
.write("core", plugin, "core", "activation discovered candidate");
candidates.push(Candidate {
plugin: plugin.into(),
copy,
interface,
});
}
let names: Vec<_> = candidates
.iter()
.map(|candidate| candidate.plugin.clone())
.collect();
self.refresh_graph_except(&names).await?;
let mut previous = self.0.graph.lock().unwrap().plugins.clone();
// Startup discovery may not yet have visited a replaced plugin. Discover
// its immutable old copy for the diff, but never prevent repairing it.
for candidate in candidates.iter() {
if let Some(old) = previous.get_mut(&candidate.plugin)
&& old.document.is_null()
&& old.error.is_none()
{
if old.hash == candidate.interface.hash {
old.document = candidate.interface.document.clone();
continue;
}
let directory = self.0.world.plugin_dir(&candidate.plugin);
if let Ok((bytes, hash)) = read_hashed(directory.join("plugin.wasm")).await {
let copy = directory.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
if tokio::fs::write(&copy, bytes).await.is_ok()
&& let Ok(interface) =
self.discover_copy(&candidate.plugin, &copy, hash).await
{
*old = interface;
}
let _ = tokio::fs::remove_file(copy).await;
}
}
}
let mut proposed = previous.clone();
for candidate in candidates.iter() {
proposed.insert(candidate.plugin.clone(), candidate.interface.clone());
}
if params["force"] != true {
let failures = problems(&proposed, Some(&names));
if !failures.is_empty() {
self.0.journal.write(
"core",
"core",
"core",
&format!("activation rejected {}", json!(failures)),
);
let details = failures
.iter()
.map(|problem| {
format!(
"- {} imports {}: {}",
problem["consumer"],
problem["provider"],
problem["display"]
.as_str()
.unwrap_or_else(|| problem["message"]
.as_str()
.unwrap_or("incompatible import"))
)
})
.collect::<Vec<_>>()
.join("\n");
return Err(
json!({"code":-32003,"message":format!("{} was not activated: consumers break.\n{details}\nUpdate consumers first, or activate both together.",names.join(", ")),"data":failures}),
);
}
}
self.install_candidates(candidates, previous, proposed, params["force"] != true)
.await
}
/// Installs staged files and snapshots all old admissions under the shared write gate.
async fn install_candidates(
&self,
candidates: &[Candidate],
previous: BTreeMap<String, Interface>,
proposed: BTreeMap<String, Interface>,
checked: bool,
) -> RpcResult {
let admission = self.0.admission.write().await;
// Every candidate already has a temporary name before any installed file changes.
for candidate in candidates {
if previous
.get(&candidate.plugin)
.is_some_and(|old| old.hash == candidate.interface.hash)
{
continue;
}
let directory = self.0.world.plugin_dir(&candidate.plugin);
let current = directory.join("plugin.wasm");
if tokio::fs::try_exists(&current).await.map_err(io_error)? {
let backup = candidate.copy.with_extension("previous");
tokio::fs::hard_link(&current, &backup)
.await
.map_err(io_error)?;
tokio::fs::rename(&backup, directory.join("plugin.prev.wasm"))
.await
.map_err(io_error)?;
}
tokio::fs::rename(&candidate.copy, &current)
.await
.map_err(io_error)?;
}
let mut upgrades = Vec::new();
let mut summaries = serde_json::Map::new();
for candidate in candidates {
let old = previous.get(&candidate.plugin);
let before = methods(old.map(|old| &old.document).unwrap_or(&Value::Null));
let after = methods(&candidate.interface.document);
let added: Vec<_> = after
.keys()
.filter(|name| !before.contains_key(*name))
.collect();
let removed: Vec<_> = before
.keys()
.filter(|name| !after.contains_key(*name))
.collect();
let changed: Vec<_> = after
.iter()
.filter(|(name, method)| before.get(*name).is_some_and(|old| old != *method))
.map(|(name, _)| name)
.collect();
summaries.insert(candidate.plugin.clone(), json!({
"plugin": candidate.plugin,
"wasm": format!("/plugins/{}/plugin.wasm", candidate.plugin),
"hash": candidate.interface.hash,
"previous": old.map(|old| &old.hash).filter(|hash| !hash.is_empty()),
"method_count": candidate.interface.document["methods"].as_array().map_or(0, Vec::len),
"added": added,
"removed": removed,
"changed": changed,
"interface_check": if checked { "passed" } else { "skipped" },
}));
if let Some(old) = previous
.get(&candidate.plugin)
.filter(|old| !old.hash.is_empty() && old.hash != candidate.interface.hash)
{
upgrades.push(self.snapshot_upgrade(
&candidate.plugin,
old.hash.clone(),
&candidate.interface.hash,
));
}
self.0.journal.write(
"core",
&candidate.plugin,
"core",
&format!("activation installed {}", candidate.interface.hash),
);
}
self.publish_graph(proposed);
drop(admission);
for upgrade in upgrades {
self.begin_upgrade(upgrade);
}
Ok(Value::Object(summaries))
}
}
-52
View File
@@ -1,52 +0,0 @@
# The configuration of the agent. Edit it before the first chat.
# api selects the request format. Choose one complete example below.
api: openai-responses
# OpenAI base URLs include /v1. Do not include /responses or /chat/completions.
base_url: https://api.openai.com/v1
# model is the provider's model identifier.
model: gpt-6.1-sol
# token is the API key. Replace CHANGE_ME with your key.
# An empty token requires allow_no_token: true. Then no authentication header is sent.
token: CHANGE_ME
allow_no_token: false
# thinking is how much the model thinks before it answers:
# off, low, medium, high, xhigh, or max. Remove the line to use the model's default,
# for example for a model without thinking.
# If the model rejects a level, the agent uses the nearest level that the model accepts.
# If the model cannot switch thinking off, off becomes low.
# A caller can set another level for one turn: agent.chat {"text": "...", "thinking": "high"}.
# The agent reads this file at the start of each turn, so a change applies to the next turn.
thinking: medium
# Optional output token cap. Anthropic defaults to 32000 if omitted.
# max_tokens: 32000
# OpenAI Responses example (base_url includes the version):
# api: openai-responses
# base_url: https://api.openai.com/v1
# model: gpt-6.1-sol
# token: YOUR_OPENAI_KEY
# allow_no_token: false
# thinking: medium
# OpenRouter example (OpenAI Chat Completions, the format of most providers):
# api: openai-chat-completions
# base_url: https://openrouter.ai/api/v1
# model: anthropic/claude-sonnet-5.5
# token: YOUR_OPENROUTER_KEY
# allow_no_token: false
# thinking: medium
# Local Ollama example (OpenAI Chat Completions, no key):
# api: openai-chat-completions
# base_url: http://localhost:11434/v1
# model: llama3.2
# token: ""
# allow_no_token: true
# Anthropic Messages example (base_url excludes the version):
# api: anthropic-messages
# base_url: https://api.anthropic.com
# model: claude-sonnet-5-5
# token: YOUR_ANTHROPIC_KEY
# allow_no_token: false
# thinking: medium
-12
View File
@@ -1,12 +0,0 @@
# The core starts these services in order. An empty list starts no services.
# Each method is a plugin.method name. params contains its named arguments.
# The core waits for each result before it starts the next service.
# After a plugin upgrade, the core restarts that plugin's services.
# Host restart is separate: #loop sets SAUSAGE_LOOP=1 to enable core.restart
# (and its alias core.exit). It answers before clean shutdown with exit code 75.
# Agents need the person's explicit approval each time; never add it as a service.
# Example (replace [] with this list):
# - method: www.start
# params:
# port: 8080
[]
+576
View File
@@ -0,0 +1,576 @@
//! The broker: message routing, mailboxes, subscriptions, taps, the version
//! store, and the requests to `broker` (spawn, upgrade, rollback, stop, start,
//! remove, subscribe, unsubscribe, tap, untap, scan, exit). `supervisor.rs`
//! runs each WASM actor.
//!
//! The broker accepts only `.wasm` files. It does not know any source language.
//! The world folder is the source of truth: `scan` boots every
//! `actor/<id>/actor.wasm` and treats a changed file as an upgrade.
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot};
use wasmtime::component::Component;
use crate::candidate;
use crate::runtime::{InstanceConfig, Mount, Runtime};
use crate::supervisor::{Control, Reply, Supervisor};
use crate::vfs;
use crate::world::{self, ActorMeta, NATIVE, VersionMeta, World};
pub const MAILBOX_SIZE: usize = 1000;
pub const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(5);
#[derive(Clone, Debug)]
pub struct Envelope {
pub id: String,
pub from: String,
pub to: String,
pub reply_to: Option<String>,
pub body: Value,
}
impl Envelope {
/// The NDJSON line that the recipient reads on stdin.
pub fn to_line(&self) -> String {
let mut v = json!({"id": self.id, "from": self.from, "body": self.body});
if let Some(r) = &self.reply_to {
v["reply_to"] = json!(r);
}
v.to_string()
}
}
pub fn shutdown_line() -> String {
json!({"id": "broker/shutdown", "from": "broker", "body": {"type": "shutdown"}}).to_string()
}
pub struct Broker {
pub world: World,
pub runtime: Arc<Runtime>,
mailboxes: Mutex<HashMap<String, mpsc::Sender<Envelope>>>,
controls: Mutex<HashMap<String, mpsc::Sender<Control>>>,
/// `actor.json` of every actor, native and WASM.
metas: Mutex<BTreeMap<String, ActorMeta>>,
op_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
seq: AtomicU64,
/// Set by the `exit` request: the host process should stop.
exit: tokio::sync::Notify,
/// Requests of the host from `fs` (see `vfs.rs`) that wait for a reply, by message ID.
asks: Mutex<HashMap<String, oneshot::Sender<Value>>>,
me: std::sync::Weak<Broker>,
}
impl Broker {
pub fn new(world: World, runtime: Arc<Runtime>) -> Result<Arc<Self>> {
let metas = world.actor_ids()?.into_iter().map(|id| (id.clone(), world.read_meta(&id))).collect();
Ok(Arc::new_cyclic(|me| Self {
world,
runtime,
mailboxes: Mutex::new(HashMap::new()),
controls: Mutex::new(HashMap::new()),
metas: Mutex::new(metas),
op_locks: Mutex::new(HashMap::new()),
seq: AtomicU64::new(1),
exit: tokio::sync::Notify::new(),
asks: Mutex::new(HashMap::new()),
me: me.clone(),
}))
}
/// Send `body` from `fs` to `to` now. The future resolves to the reply
/// body, or `None` after `vfs::ASK_TIMEOUT`.
pub fn ask(self: &Arc<Self>, to: &str, body: Value) -> impl Future<Output = Option<Value>> + Send + 'static {
let own = format!("q{}", self.seq.fetch_add(1, Ordering::Relaxed));
let id = format!("{}/{own}", vfs::ID);
let (tx, rx) = oneshot::channel();
self.asks.lock().unwrap().insert(id.clone(), tx);
self.send(vfs::ID, Some(to), Some(&own), None, body);
let this = self.clone();
async move {
let reply = tokio::time::timeout(vfs::ASK_TIMEOUT, rx).await;
this.asks.lock().unwrap().remove(&id);
reply.ok()?.ok()
}
}
/// Wait until an actor sends `{"type": "exit"}` to `broker`. The `sausage`
/// binary then stops all actors and exits, so that a wrapper such as
/// `nix run .#loop` can start a fresh (updated) host.
pub async fn exit_requested(&self) {
self.exit.notified().await;
}
pub fn meta(&self, id: &str) -> Option<ActorMeta> {
self.metas.lock().unwrap().get(id).cloned()
}
/// Change the `actor.json` of `id` (creating it if needed) and save it.
fn update_meta(&self, id: &str, f: impl FnOnce(&mut ActorMeta)) -> Result<()> {
let mut metas = self.metas.lock().unwrap();
let meta = metas.entry(id.to_string()).or_insert_with(|| ActorMeta { running: true, ..Default::default() });
f(meta);
self.world.write_meta(id, meta)
}
/// Create the mailbox of an actor. Messages wait here while the actor is
/// down, during a swap, or while it restarts.
pub fn open_mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
let (tx, rx) = mpsc::channel(MAILBOX_SIZE);
self.mailboxes.lock().unwrap().insert(id.to_string(), tx);
rx
}
/// Send a message. `to: None` publishes to the subscribers of `from`.
/// With `own_id`, the message ID is `"<from>/<own_id>"`. Returns the ID.
pub fn send(&self, from: &str, to: Option<&str>, own_id: Option<&str>, reply_to: Option<&str>, body: Value) -> String {
let id = match own_id {
Some(own) => format!("{from}/{own}"),
None => format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)),
};
let (recipients, tappers) = {
let metas = self.metas.lock().unwrap();
let recipients: Vec<String> = match to {
Some(to) => vec![to.to_string()],
None => metas
.iter()
.filter(|(_, m)| m.subscriptions.contains(from))
.map(|(id, _)| id.clone())
.collect(),
};
let tappers: Vec<String> = metas.iter().filter(|(_, m)| m.tapped).map(|(id, _)| id.clone()).collect();
(recipients, tappers)
};
if !tappers.is_empty() {
let message = json!({"id": id, "from": from, "to": to, "reply_to": reply_to, "body": body});
self.tap(&tappers, message);
}
for to in recipients {
self.deliver(Envelope {
id: id.clone(),
from: from.to_string(),
to,
reply_to: reply_to.map(str::to_string),
body: body.clone(),
});
}
id
}
/// Give every tapper a copy of one routed message. Copies skip `send`, so
/// they are never tapped themselves; a full mailbox drops the copy.
fn tap(&self, tappers: &[String], message: Value) {
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64);
let body = json!({"type": "tapped", "message": message, "ts": ts});
let mailboxes = self.mailboxes.lock().unwrap();
for tapper in tappers {
if let Some(tx) = mailboxes.get(tapper) {
let _ = tx.try_send(Envelope {
id: format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)),
from: "broker".into(),
to: tapper.clone(),
reply_to: None,
body: body.clone(),
});
}
}
}
/// Route one stdout line of a WASM actor.
pub fn route_line(&self, from: &str, line: &str) {
let line = line.trim();
if line.is_empty() {
return;
}
let Ok(Value::Object(obj)) = serde_json::from_str::<Value>(line) else {
self.log(from, &format!("stdout line is not a JSON object, ignored: {}", truncate(line, 500)));
return;
};
let Some(body) = obj.get("body").cloned() else {
self.log(from, &format!("message without \"body\", ignored: {}", truncate(line, 500)));
return;
};
let own_id = obj.get("id").and_then(Value::as_str);
let to = obj.get("to").and_then(Value::as_str);
let reply_to = obj.get("reply_to").and_then(Value::as_str);
self.send(from, to, own_id, reply_to, body);
}
fn deliver(&self, env: Envelope) {
if env.to == vfs::ID {
let waiting = env.reply_to.as_ref().and_then(|r| self.asks.lock().unwrap().remove(r));
if let Some(tx) = waiting {
let _ = tx.send(env.body);
}
return;
}
let tx = self.mailboxes.lock().unwrap().get(&env.to).cloned();
let error = match tx {
None => format!("no actor with ID {}", env.to),
Some(tx) => match tx.try_send(env.clone()) {
Ok(()) => return,
Err(mpsc::error::TrySendError::Full(_)) => format!("mailbox of {} is full", env.to),
Err(mpsc::error::TrySendError::Closed(_)) => format!("actor {} is gone", env.to),
},
};
if env.from != "broker" {
let body = json!({"type": "undeliverable", "reason": error, "to": env.to});
self.send("broker", Some(&env.from), None, Some(&env.id), body);
}
}
/// Append a host note to the stderr log of an actor.
pub fn log(&self, id: &str, text: &str) {
use std::io::Write;
let path = self.world.actor_dir(id).join("stderr.log");
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "[sausage {}] {text}", world::now());
}
}
fn op_lock(&self, id: &str) -> Arc<tokio::sync::Mutex<()>> {
self.op_locks.lock().unwrap().entry(id.to_string()).or_default().clone()
}
/// What an instance of `id` gets (see PROTOCOL.md). A candidate runs in
/// `scratch`, which holds a copy of the actor's `data/` and `public/`,
/// and has no network.
pub fn instance_config(&self, id: &str, version: u64, scratch: Option<&Path>) -> InstanceConfig {
let own = scratch.map_or_else(|| self.world.actor_dir(id), Path::to_path_buf);
let mut env = vec![("SAUSAGE_ACTOR".into(), id.to_string()), ("SAUSAGE_VERSION".into(), version.to_string())];
if scratch.is_some() {
env.push(("SAUSAGE_CANDIDATE".into(), "1".into()));
}
InstanceConfig {
mounts: vec![
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false },
Mount { guest: "/public".into(), host: own.join("public"), writable: true },
Mount { guest: "/data".into(), host: own.join("data"), writable: true },
],
network: scratch.is_none(),
stderr_log: own.join("stderr.log"),
env,
actor: id.to_string(),
broker: self.me.clone(),
}
}
// ---------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------
fn start_supervisor(self: &Arc<Self>, id: &str, version: u64, running: bool) {
let mailbox = self.open_mailbox(id);
let (ctl_tx, ctl_rx) = mpsc::channel(16);
self.controls.lock().unwrap().insert(id.to_string(), ctl_tx);
tokio::spawn(Supervisor::new(self.clone(), id.to_string(), version, running, mailbox, ctl_rx).run());
}
fn is_supervised(&self, id: &str) -> bool {
self.controls.lock().unwrap().contains_key(id)
}
async fn control(&self, id: &str, make: impl FnOnce(Reply) -> Control) -> Result<Value> {
let tx = self.controls.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no WASM actor with ID {id}"))?;
let (reply, rx) = oneshot::channel();
tx.send(make(reply)).await.map_err(|_| anyhow!("supervisor of {id} is gone"))?;
rx.await.map_err(|_| anyhow!("supervisor of {id} did not answer"))?
}
/// Stop every actor gracefully, in parallel (host shutdown). The
/// `running` flags stay, so the next `sausage run` starts them again.
pub async fn shutdown_all(self: &Arc<Self>) {
let ids: Vec<String> = self.controls.lock().unwrap().keys().cloned().collect();
let mut tasks = tokio::task::JoinSet::new();
for id in ids {
let this = self.clone();
tasks.spawn(async move { this.control(&id, |reply| Control::Shutdown { reply }).await });
}
tasks.join_all().await;
}
/// Compile `versions/<version>/actor.wasm`.
pub fn load_version(&self, id: &str, version: u64) -> Result<Component> {
self.load(id, &self.world.version_dir(id, version).join("actor.wasm"))
}
fn load(&self, id: &str, wasm: &Path) -> Result<Component> {
let started = Instant::now();
let component = self.runtime.load(wasm)?;
self.log(id, &format!("compiled {} in {} ms", wasm.display(), started.elapsed().as_millis()));
Ok(component)
}
/// Test a new version where it lies. Only when the tests pass, copy it
/// to `versions/<n>/`: that folder holds only versions that run(ran).
/// Returns the component and the test report.
async fn try_version(&self, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<(Component, Value)> {
let component = self.load(id, wasm)?;
let tests = candidate::test(self, id, meta.version, &component, source).await;
if let Err(e) = &tests {
self.log(id, &format!("version {} from {} failed its tests: {e:#}", meta.version, meta.wasm));
}
let tests = tests?;
let dir = self.world.version_dir(id, meta.version);
std::fs::create_dir_all(&dir)?;
std::fs::copy(wasm, dir.join("actor.wasm")).with_context(|| format!("copy {}", wasm.display()))?;
world::replace_source(source.unwrap_or(&dir.join("no-source")), &dir.join("source"))?;
world::write_atomic(&dir.join("meta.json"), serde_json::to_string_pretty(meta)?.as_bytes())?;
Ok((component, tests))
}
/// Make `version` the current `actor.wasm`, `source/`, and `actor.json`.
pub fn install(&self, id: &str, version: u64) -> Result<()> {
self.world.install_files(id, version)?;
self.update_meta(id, |m| {
m.version = Some(version);
m.running = true;
})
}
pub fn set_running(&self, id: &str, running: bool) -> Result<()> {
self.update_meta(id, |m| m.running = running)
}
/// Create a new actor from a `.wasm` file: test it, install it, start it.
pub async fn create_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, meta: VersionMeta) -> Result<Value> {
if !world::valid_id(id) {
bail!("invalid actor ID {id:?}: use a-z, 0-9, '-' and '_'");
}
let lock = self.op_lock(id);
let _guard = lock.lock().await;
if NATIVE.contains(&id) || self.is_supervised(id) || self.world.wasm_path(id).exists() {
bail!("an actor with ID {id} already exists");
}
let meta = VersionMeta { version: self.world.next_version(id), ..meta };
self.create_version(id, &meta, wasm, source).await
}
async fn create_version(self: &Arc<Self>, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<Value> {
let (_, tests) = self.try_version(id, meta, wasm, source).await?;
self.install(id, meta.version)?;
self.start_supervisor(id, meta.version, true);
Ok(json!({"type": "spawned", "actor": id, "version": meta.version, "tests": tests}))
}
pub async fn upgrade_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, meta: VersionMeta) -> Result<Value> {
let lock = self.op_lock(id);
let _guard = lock.lock().await;
if !self.is_supervised(id) {
bail!("no WASM actor with ID {id}");
}
let version = self.world.next_version(id);
self.upgrade_version(id, &VersionMeta { version, ..meta }, wasm, source).await
}
async fn upgrade_version(self: &Arc<Self>, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<Value> {
let (component, tests) = self.try_version(id, meta, wasm, source).await?;
let (version, requester) = (meta.version, meta.requested_by.clone());
let mut result = self.control(id, |reply| Control::Upgrade { version, component, requester, reply }).await?;
result["tests"] = tests;
Ok(result)
}
/// Stop an actor (normal shutdown) and delete its folder `actor/<id>/`.
/// Afterwards the ID is unknown: messages to it are undeliverable, and
/// it can be spawned again from scratch.
pub async fn remove_actor(&self, id: &str) -> Result<Value> {
if NATIVE.contains(&id) {
bail!("{id} is part of the host and cannot be removed");
}
if !world::valid_id(id) {
bail!("invalid actor ID {id:?}");
}
let lock = self.op_lock(id);
let _guard = lock.lock().await;
let dir = self.world.actor_dir(id);
if !self.is_supervised(id) && !dir.is_dir() {
bail!("no actor with ID {id}");
}
if self.is_supervised(id) {
self.control(id, |reply| Control::Shutdown { reply }).await?;
}
// Without its mailbox, messages to `id` are undeliverable.
self.controls.lock().unwrap().remove(id);
self.mailboxes.lock().unwrap().remove(id);
self.metas.lock().unwrap().remove(id);
if dir.exists() {
std::fs::remove_dir_all(&dir).with_context(|| format!("delete {}", dir.display()))?;
}
Ok(json!({"type": "removed", "actor": id}))
}
/// Boot the actors in `actor/`. Called at start and on `scan`.
///
/// - An `actor.wasm` equal to the installed version starts as is.
/// - A new or changed `actor.wasm` is tested where it lies. A new actor
/// is installed, a changed one is an upgrade. If its tests fail,
/// nothing changes, and the file stays for the next scan.
pub async fn scan(self: &Arc<Self>) -> Vec<Value> {
let mut report = Vec::new();
for id in self.world.actor_ids().unwrap_or_default() {
if NATIVE.contains(&id.as_str()) || !self.world.wasm_path(&id).is_file() {
continue;
}
let lock = self.op_lock(&id);
let _guard = lock.lock().await;
if let Some(r) = self.scan_actor(&id).await {
report.push(r);
}
}
report
}
async fn scan_actor(self: &Arc<Self>, id: &str) -> Option<Value> {
let meta = self.world.read_meta(id);
let installed = meta.version.filter(|v| self.world.version_dir(id, *v).join("actor.wasm").is_file());
self.metas.lock().unwrap().insert(id.to_string(), meta.clone());
if let Some(v) = installed
&& !self.is_supervised(id)
{
self.start_supervisor(id, v, meta.running);
}
let wasm = self.world.wasm_path(id);
if installed.is_some_and(|v| same_file(&wasm, &self.world.version_dir(id, v).join("actor.wasm"))) {
return None;
}
let source = self.world.source_dir(id);
let vmeta = VersionMeta {
version: self.world.next_version(id),
wasm: format!("/actor/{id}/actor.wasm"),
source: Some(format!("/actor/{id}/source")),
built_by: None,
requested_by: "scan".into(),
created_at: world::now(),
};
let source = source.is_dir().then_some(source.as_path());
let result = match installed {
None => self.create_version(id, &vmeta, &wasm, source).await,
Some(_) => self.upgrade_version(id, &vmeta, &wasm, source).await,
};
Some(result.unwrap_or_else(|e| json!({"type": "error", "actor": id, "reason": format!("{e:#}")})))
}
/// Handle one message to `broker`.
async fn handle(self: Arc<Self>, env: Envelope) {
let body = self.clone().command(&env).await.unwrap_or_else(|e| {
json!({"type": "error", "request": env.body.get("type"), "reason": format!("{e:#}")})
});
self.send("broker", Some(&env.from), None, Some(&env.id), body);
}
async fn command(self: Arc<Self>, env: &Envelope) -> Result<Value> {
let b = &env.body;
let ty = b["type"].as_str().unwrap_or("");
let actor = || b["actor"].as_str().map(str::to_string).ok_or_else(|| anyhow!("missing \"actor\""));
let version_meta = || -> Result<(std::path::PathBuf, Option<std::path::PathBuf>, VersionMeta)> {
let wasm_s = b["wasm"].as_str().ok_or_else(|| anyhow!("missing \"wasm\""))?;
let wasm = self.world.resolve(&env.from, wasm_s)?;
if !wasm.is_file() {
bail!("{wasm_s} is not a file");
}
let source_s = b["source"].as_str();
let source = source_s.map(|s| self.world.resolve(&env.from, s)).transpose()?;
if let (Some(s), Some(p)) = (source_s, &source)
&& !p.is_dir()
{
bail!("{s} is not a folder");
}
let meta = VersionMeta {
version: 0,
wasm: wasm_s.to_string(),
source: source_s.map(str::to_string),
built_by: b["built_by"].as_str().map(str::to_string),
requested_by: env.from.clone(),
created_at: world::now(),
};
Ok((wasm, source, meta))
};
match ty {
"spawn" => {
let (wasm, source, meta) = version_meta()?;
self.create_actor(&actor()?, &wasm, source.as_deref(), meta).await
}
"upgrade" => {
let (wasm, source, meta) = version_meta()?;
self.upgrade_actor(&actor()?, &wasm, source.as_deref(), meta).await
}
"rollback" => {
let id = actor()?;
let version = b["version"].as_u64().ok_or_else(|| anyhow!("missing \"version\""))?;
let lock = self.op_lock(&id);
let _guard = lock.lock().await;
self.control(&id, |reply| Control::Rollback { version, reply }).await
}
"stop" => self.control(&actor()?, |reply| Control::Stop { reply }).await,
"start" => self.control(&actor()?, |reply| Control::Start { reply }).await,
"remove" => self.remove_actor(&actor()?).await,
"subscribe" | "unsubscribe" => {
let publisher = actor()?;
let on = ty == "subscribe";
self.update_meta(&env.from, |m| {
if on {
m.subscriptions.insert(publisher.clone());
} else {
m.subscriptions.remove(&publisher);
}
})?;
Ok(json!({"type": format!("{ty}d"), "actor": publisher}))
}
"tap" | "untap" => {
let on = ty == "tap";
self.update_meta(&env.from, |m| m.tapped = on)?;
let answer = if on { "tapping" } else { "untapped" };
Ok(json!({"type": answer}))
}
"scan" => Ok(json!({"type": "scanned", "changes": self.scan().await})),
"exit" => {
self.log("broker", &format!("exit requested by {}", env.from));
// notify_one keeps the permit if nobody waits yet.
self.exit.notify_one();
Ok(json!({"type": "exiting"}))
}
other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, remove, subscribe, unsubscribe, tap, untap, scan, exit"),
}
}
/// The native `broker` actor. Requests run concurrently. Aborting this
/// task aborts the requests that still run. Replies (messages with
/// `reply_to`) get no answer, so that two actors never answer each other
/// forever.
pub async fn run_actor(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
let mut requests = tokio::task::JoinSet::new();
loop {
tokio::select! {
env = mailbox.recv() => match env {
Some(env) if env.reply_to.is_none() => { requests.spawn(self.clone().handle(env)); }
Some(_) => {}
None => break,
},
Some(_) = requests.join_next() => {}
}
}
}
}
fn same_file(a: &Path, b: &Path) -> bool {
matches!((std::fs::read(a), std::fs::read(b)), (Ok(x), Ok(y)) if x == y)
}
pub fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}… ({} bytes)", &s[..end], s.len())
}
+156
View File
@@ -0,0 +1,156 @@
//! Tests for a candidate version, before the broker installs it.
//!
//! A test is a JSON file in `source/tests/*.json`, one scenario per file:
//!
//! ```json
//! {
//! "description": "add, then list",
//! "steps": [
//! {"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
//! {"send": {"type": "list"}, "from": "someone", "expect": [{"type": "notes"}], "to": "someone"}
//! ]
//! }
//! ```
//!
//! Each step sends `send` (optional, from `from` or "test"), then reads one
//! message for each entry of `expect` (an object or a list). An expected body
//! matches if it is a subset of the actual body. With `to`, each message must
//! also go to that recipient.
//!
//! Each file runs in a fresh instance on a copy of the actor's `/data` and
//! `/public` (empty folders for a new actor), without network. A version
//! without test files passes.
use std::path::Path;
use std::time::Duration;
use anyhow::{Result, anyhow, bail};
use serde_json::{Value, json};
use wasmtime::component::Component;
use crate::broker::{Broker, SHUTDOWN_DEADLINE, shutdown_line, truncate};
use crate::runtime::Instance;
use crate::world;
const EXPECT_TIMEOUT: Duration = Duration::from_secs(10);
/// `expected` matches `actual` if every key of an expected object exists in
/// the actual object with a matching value. Other values must be equal.
pub fn subset_match(expected: &Value, actual: &Value) -> bool {
match (expected, actual) {
(Value::Object(e), Value::Object(a)) => e.iter().all(|(k, v)| a.get(k).is_some_and(|av| subset_match(v, av))),
(Value::Array(e), Value::Array(a)) => e.len() == a.len() && e.iter().zip(a).all(|(x, y)| subset_match(x, y)),
_ => expected == actual,
}
}
struct Scratch {
dir: std::path::PathBuf,
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn stderr_tail(path: &Path) -> String {
let text = std::fs::read_to_string(path).unwrap_or_default();
let start = text.len().saturating_sub(2000);
let mut s = start;
while !text.is_char_boundary(s) {
s += 1;
}
text[s..].to_string()
}
/// The version under test, which starts from a copy of the actor's state.
struct Candidate<'a> {
broker: &'a Broker,
id: &'a str,
version: u64,
component: &'a Component,
}
/// Run every test file of `<source>/tests/`.
pub async fn test(broker: &Broker, id: &str, version: u64, component: &Component, source: Option<&Path>) -> Result<Value> {
let candidate = Candidate { broker, id, version, component };
let Some(source) = source else { return Ok(json!({"passed": []})) };
let mut files: Vec<_> = std::fs::read_dir(source.join("tests"))
.map(|rd| rd.filter_map(|e| Some(e.ok()?.path())).filter(|p| p.extension().is_some_and(|x| x == "json")).collect())
.unwrap_or_default();
files.sort();
let mut passed = Vec::new();
for file in &files {
let name = file.file_name().unwrap().to_string_lossy().to_string();
candidate.run_file(file).await.map_err(|e| anyhow!("test {name} failed: {e:#}"))?;
passed.push(name);
}
Ok(json!({"passed": passed}))
}
impl Candidate<'_> {
/// Start an instance on a fresh copy of the state, without network.
fn start(&self) -> Result<(Instance, Scratch)> {
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = self.broker.world.scratch_dir().join(format!("{}-{}-{n}", self.id, std::process::id()));
let scratch = Scratch { dir: dir.clone() };
let world = &self.broker.world;
for (from, name) in [(world.data_dir(self.id), "data"), (world.public_dir(self.id), "public")] {
std::fs::create_dir_all(dir.join(name))?;
if from.is_dir() {
world::copy_tree(&from, &dir.join(name))?;
}
}
let cfg = self.broker.instance_config(self.id, self.version, Some(&dir));
Ok((self.broker.runtime.start(self.component, cfg)?, scratch))
}
async fn run_file(&self, file: &Path) -> Result<()> {
let test: Value = serde_json::from_str(&std::fs::read_to_string(file)?).map_err(|e| anyhow!("invalid JSON: {e}"))?;
let steps = test["steps"].as_array().ok_or_else(|| anyhow!("missing \"steps\" list"))?;
let (mut inst, scratch) = self.start()?;
let stderr = scratch.dir.join("stderr.log");
// Lines that the candidate wrote while a step was sent.
let mut early = std::collections::VecDeque::new();
for (n, step) in steps.iter().enumerate() {
let n = n + 1;
if let Some(body) = step.get("send") {
let from = step["from"].as_str().unwrap_or("test");
let env = json!({"id": format!("test/{n}"), "from": from, "body": body});
inst.send_line(&env.to_string(), |line| early.push_back(line))
.await
.map_err(|e| anyhow!("step {n}: candidate does not read stdin: {e}"))?;
}
let expected = match step.get("expect") {
None => vec![],
Some(Value::Array(list)) => list.clone(),
Some(one) => vec![one.clone()],
};
for expected in expected {
let next = async { if early.is_empty() { inst.stdout.recv().await } else { early.pop_front() } };
let line = match tokio::time::timeout(EXPECT_TIMEOUT, next).await {
Err(_) => bail!("step {n}: no message within {EXPECT_TIMEOUT:?}; expected {expected}"),
Ok(None) => {
let exit = inst.wait().await;
bail!("step {n}: candidate ended ({exit}); expected {expected}. stderr:\n{}", stderr_tail(&stderr))
}
Ok(Some(l)) => l,
};
let msg: Value = serde_json::from_str(&line)
.map_err(|_| anyhow!("step {n}: candidate wrote a non-JSON line: {}", truncate(&line, 300)))?;
if !subset_match(&expected, &msg["body"]) {
bail!("step {n}: expected body {expected}, got {}", truncate(&msg["body"].to_string(), 1000));
}
if let Some(to) = step.get("to")
&& msg.get("to") != Some(to)
{
bail!("step {n}: expected recipient {to}, got {}", msg.get("to").unwrap_or(&Value::Null));
}
}
}
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE, |_| {}).await;
Ok(())
}
}
-836
View File
@@ -1,836 +0,0 @@
//! Native cargo builds, one immutable source copy, SDK installation, and process-group cancellation.
use crate::{Host, world::World};
use anyhow::{Context, Result, bail};
use include_dir::{Dir, include_dir};
use serde_json::{Value, json};
use std::time::Duration;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use tokio::{process::Command, time::Instant};
/// Default wall-clock budget after acquiring the build mutex (not queue time).
pub const DEFAULT_TIMEOUT_SECS: u64 = 900;
/// Default wall-clock budget for each WASI test instance.
pub const DEFAULT_TEST_TIMEOUT_SECS: u64 = 120;
/// Wall-clock budgets for a build and its individual WASI tests.
#[derive(Clone, Copy, Debug)]
pub struct BuildOptions {
pub timeout_secs: u64,
pub test_timeout_secs: u64,
}
impl Default for BuildOptions {
fn default() -> Self {
Self {
timeout_secs: DEFAULT_TIMEOUT_SECS,
test_timeout_secs: DEFAULT_TEST_TIMEOUT_SECS,
}
}
}
/// Optional RPC seconds are positive integers; null does not disable a deadline.
fn seconds(params: &Value, name: &str, default: u64) -> Result<u64, Value> {
match params.get(name) {
None => Ok(default),
Some(value) => value
.as_u64()
.filter(|seconds| *seconds >= 1)
.ok_or_else(|| {
crate::error(-32602, &format!("{name} must be an integer of at least 1"))
}),
}
}
/// The native build plugin.
pub const ID: &str = "cargo";
/// The target used for builds and tests.
pub const TARGET: &str = "wasm32-wasip2";
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/src");
static MACROS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/macros/src");
static INTERFACE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../interface/src");
#[derive(Clone, Default, Debug, serde::Serialize)]
/// Test totals and captured output.
pub struct TestResults {
pub passed: u64,
pub failed: u64,
pub output: String,
}
/// Raw, unfiltered output shared by the test and release phases of one build.
#[derive(Clone, Debug, serde::Serialize)]
pub struct BuildLogs {
pub stdout: StreamLog,
pub stderr: StreamLog,
pub total_bytes: u64,
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct StreamLog {
pub path: String,
pub bytes: u64,
}
/// Persistent descriptors are inherited by cargo, so cancellation cannot lose
/// bytes already written by cargo or its descendants. No buffered writer is used.
struct LogFiles {
stdout: std::fs::File,
stderr: std::fs::File,
stdout_path: PathBuf,
stderr_path: PathBuf,
}
impl LogFiles {
fn create(world: &Path) -> Result<Self> {
let directory = world
.join("logs/cargo")
.join(chrono::Utc::now().format("%Y-%m-%d").to_string());
std::fs::create_dir_all(&directory).context("create cargo log directory")?;
let id = uuid::Uuid::now_v7();
let stdout_path = directory.join(format!("{id}.stdout.log"));
let stderr_path = directory.join(format!("{id}.stderr.log"));
let create = |path: &Path| {
std::fs::OpenOptions::new()
.create_new(true)
.append(true)
.open(path)
.with_context(|| format!("create {}", path.display()))
};
Ok(Self {
stdout: create(&stdout_path)?,
stderr: create(&stderr_path)?,
stdout_path,
stderr_path,
})
}
fn info(&self, world: &Path) -> Result<BuildLogs> {
let stream = |file: &std::fs::File, path: &Path| -> Result<StreamLog> {
Ok(StreamLog {
path: format!("/{}", path.strip_prefix(world)?.display()),
bytes: file.metadata()?.len(),
})
};
let stdout = stream(&self.stdout, &self.stdout_path)?;
let stderr = stream(&self.stderr, &self.stderr_path)?;
Ok(BuildLogs {
total_bytes: stdout.bytes + stderr.bytes,
stdout,
stderr,
})
}
fn read(&self, path: &Path, offset: u64) -> Result<Vec<u8>> {
use std::io::{Read, Seek, SeekFrom};
let mut file =
std::fs::File::open(path).with_context(|| format!("read {}", path.display()))?;
file.seek(SeekFrom::Start(offset))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
Ok(bytes)
}
}
/// A successful build, usable without a running host.
#[derive(Debug)]
pub struct BuildOutput {
pub logs: Option<BuildLogs>,
pub wasm: PathBuf,
pub warnings: String,
pub tests: TestResults,
}
/// A failed compiler or test phase, with its complete diagnostics.
#[derive(Debug)]
pub struct BuildFailure {
pub logs: Option<BuildLogs>,
pub reason: String,
pub tests: TestResults,
pub output: String,
pub elapsed_secs: Option<f64>,
pub step: Option<&'static str>,
}
impl std::fmt::Display for BuildFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}\n{}", self.reason, self.output)
}
}
impl std::error::Error for BuildFailure {}
impl From<anyhow::Error> for BuildFailure {
fn from(error: anyhow::Error) -> Self {
Self {
logs: None,
reason: "build preparation failed".into(),
output: format!("{error:#}"),
tests: TestResults::default(),
elapsed_secs: None,
step: None,
}
}
}
/// A host-independent builder. Each builder serializes access to its work and target folders.
pub struct Cargo {
world: World,
builds: Arc<tokio::sync::Mutex<()>>,
runner: PathBuf,
target: PathBuf,
sdk: PathBuf,
}
impl Cargo {
/// Seeds a missing world SDK and selects the current sausage executable as runner.
pub fn new(world: World, cache: &Path) -> Result<Self> {
let mut runner = std::env::current_exe()?;
if runner
.parent()
.is_some_and(|parent| parent.file_name().is_some_and(|name| name == "deps"))
{
runner = runner.parent().unwrap().parent().unwrap().join("sausage");
}
Self::with_runner(world, runner, cache)
}
/// Seeds a missing world SDK and accepts an explicit sausage binary, including for init integrations.
pub fn with_runner(world: World, runner: PathBuf, cache: &Path) -> Result<Self> {
let sdk = world.plugin_dir(ID).join("sdk");
Self::seed_sdk(&sdk)?;
Ok(Self {
sdk,
target: std::path::absolute(cache)?.join("cargo-target"),
world,
runner,
builds: Arc::new(tokio::sync::Mutex::new(())),
})
}
/// Seeds only an absent SDK; an existing world owns all of its SDK files.
fn seed_sdk(sdk: &Path) -> Result<()> {
if sdk.try_exists()? {
return Ok(());
}
let mut files = std::collections::BTreeMap::new();
collect_files(&SDK, Path::new("src"), &mut files);
collect_files(&MACROS, Path::new("macros/src"), &mut files);
collect_files(&INTERFACE, Path::new("interface/src"), &mut files);
for (manifest, destination, interface_path) in [
(
include_str!("../../sdk/Cargo.toml"),
PathBuf::from("Cargo.toml"),
Some("interface"),
),
(
include_str!("../../sdk/macros/Cargo.toml"),
PathBuf::from("macros/Cargo.toml"),
Some("../interface"),
),
(
include_str!("../../interface/Cargo.toml"),
PathBuf::from("interface/Cargo.toml"),
None,
),
] {
let mut document: toml_edit::DocumentMut = manifest.parse()?;
if !document.contains_key("workspace") {
document["workspace"] = toml_edit::table();
}
if let Some(path) = interface_path {
for section in ["dependencies", "dev-dependencies"] {
if let Some(dependency) = document
.get_mut(section)
.and_then(|table| table.get_mut("sausage-interface"))
{
dependency["path"] = toml_edit::value(path);
}
}
}
files.insert(destination, document.to_string().into_bytes());
}
let parent = sdk.parent().context("SDK has no parent")?;
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
let staging = tempfile::Builder::new()
.prefix("seed-sdk-")
.tempdir_in(parent)
.with_context(|| format!("stage SDK in {}", parent.display()))?;
for (path, bytes) in &files {
write_changed(&staging.path().join(path), bytes)?;
}
// Another builder may have seeded the world while we prepared the files.
if !sdk.try_exists()?
&& let Err(error) = std::fs::rename(staging.path(), sdk)
&& !sdk.try_exists()?
{
return Err(error).with_context(|| format!("seed {}", sdk.display()));
}
Ok(())
}
/// Registers cargo.build as a cancellable native process.
pub fn register(self, host: &Host) {
let builder = Arc::new(self);
host.register_native(ID,Arc::new(move |_context,method,params| {
let builder=builder.clone();
Box::pin(async move {
if method=="rpc.discover" { return Ok(json!({"openrpc":"1.2.6","info":{"title":"cargo","version":"native"},"methods":[{"name":"build","paramStructure":"by-name","description":"Copies source, runs bounded WASI tests, then builds a release component. Deadlines kill cargo and its descendants; cancellation releases the build mutex.","params":[{"name":"plugin","required":true,"schema":{"type":"string"}},{"name":"timeout_secs","required":false,"description":"Total wall-clock limit in seconds for tests and release build after acquiring the build mutex (queue time excluded). Default 900; minimum 1. Cannot be disabled.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TIMEOUT_SECS}},{"name":"test_timeout_secs","required":false,"description":"Wall-clock limit in seconds for each WASI test instance. Default 120; minimum 1. A timed-out test is named and fails; remaining tests continue.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TEST_TIMEOUT_SECS}}],"result":{"name":"build","schema":{"type":"object","required":["wasm","tests","warnings","logs"],"properties":{"logs":{"type":"object","required":["stdout","stderr","total_bytes"],"properties":{"stdout":{"type":"object","required":["path","bytes"],"properties":{"path":{"type":"string"},"bytes":{"type":"integer","minimum":0}}},"stderr":{"type":"object","required":["path","bytes"],"properties":{"path":{"type":"string"},"bytes":{"type":"integer","minimum":0}}},"total_bytes":{"type":"integer","minimum":0}}},"wasm":{"type":"string"},"warnings":{"type":"string"},"tests":{"type":"object","required":["passed","failed","output"],"properties":{"passed":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"output":{"type":"string"}}}}}},"errors":[{"code":-32004,"message":"Build failed"}]}]})); }
if method!="build" { return Err(crate::error(-32601,"cargo method not found")); }
let plugin=params["plugin"].as_str().ok_or_else(||crate::error(-32602,"plugin must be a string"))?;
let options = BuildOptions {
timeout_secs: seconds(&params, "timeout_secs", DEFAULT_TIMEOUT_SECS)?,
test_timeout_secs: seconds(&params, "test_timeout_secs", DEFAULT_TEST_TIMEOUT_SECS)?,
};
match builder.build_with_options(&builder.world.plugin_dir(plugin).join("source"), options).await {
Ok(output)=>Ok(json!({"wasm":format!("/{}",output.wasm.strip_prefix(&builder.world.root).unwrap().display()),"tests":output.tests,"warnings":output.warnings,"logs":output.logs})),
Err(failure)=>Err(json!({"code":-32004,"message":failure.reason,"data":{"reason":failure.reason,"tests":failure.tests,"output":failure.output,"elapsed_secs":failure.elapsed_secs,"step":failure.step,"logs":failure.logs}})),
}
})
}));
}
/// Copies source once, runs WASI tests, then publishes a release component under state/cargo/builds.
pub async fn build(&self, source: &Path) -> std::result::Result<BuildOutput, BuildFailure> {
self.build_with_options(source, BuildOptions::default())
.await
}
/// Like build, with explicit positive wall-clock budgets. Waiting for the mutex is unbounded.
pub async fn build_with_options(
&self,
source: &Path,
options: BuildOptions,
) -> std::result::Result<BuildOutput, BuildFailure> {
let logs = LogFiles::create(&self.world.root)?;
let result = self.build_logged(source, options, &logs).await;
let info = logs.info(&self.world.root)?;
match result {
Ok(mut output) => {
output.logs = Some(info);
Ok(output)
}
Err(mut failure) => {
failure.logs = Some(info);
Err(failure)
}
}
}
async fn build_logged(
&self,
source: &Path,
options: BuildOptions,
logs: &LogFiles,
) -> std::result::Result<BuildOutput, BuildFailure> {
if options.timeout_secs == 0 || options.test_timeout_secs == 0 {
return Err(anyhow::anyhow!("timeouts must be at least 1 second").into());
}
let build_guard = self.builds.clone().lock_owned().await;
let started = Instant::now();
let deadline = started
.checked_add(Duration::from_secs(options.timeout_secs))
.ok_or_else(|| anyhow::anyhow!("timeout_secs is too large"))?;
let work = self.world.state_dir(ID).join("work");
let target = self.target.clone();
let temporary = self.world.state_dir(ID).join("tmp");
let cleanup = temporary.clone();
let source = source.to_path_buf();
let copy = work.clone();
let sdk = self.sdk.clone();
let build_guard = tokio::task::spawn_blocking(move || -> Result<_> {
Self::seed_sdk(&sdk)?;
if cleanup.exists() {
std::fs::remove_dir_all(&cleanup)
.with_context(|| format!("remove {}", cleanup.display()))?;
}
std::fs::create_dir_all(&cleanup)
.with_context(|| format!("create {}", cleanup.display()))?;
if copy.exists() {
std::fs::remove_dir_all(&copy)
.with_context(|| format!("remove {}", copy.display()))?;
}
copy_source(&source, &copy, true)?;
let manifest = copy.join("Cargo.toml");
let text = std::fs::read_to_string(&manifest)
.with_context(|| format!("read {}", manifest.display()))?;
std::fs::write(
&manifest,
prepare_manifest(&text, &sdk)
.with_context(|| format!("prepare {}", manifest.display()))?,
)
.with_context(|| format!("write {}", manifest.display()))?;
Ok(build_guard)
})
.await
.map_err(anyhow::Error::from)??;
let mut test = Command::new("cargo");
test.args([
"test",
"--no-fail-fast",
"--release",
"--target",
TARGET,
"--target-dir",
])
.arg(&target)
.current_dir(&work)
.env("TMPDIR", &temporary)
.env(
"SAUSAGE_TEST_TIMEOUT_SECS",
options.test_timeout_secs.to_string(),
)
.arg("--config")
.arg(format!(
"target.wasm32-wasip2.runner={}",
json!([self.runner, "test-runner"])
));
let output = run_command(
test,
started,
deadline,
"tests",
TestResults::default(),
logs,
)
.await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let tests = TestResults {
passed: test_count(&stdout, "passed"),
failed: if output.status.success() {
0
} else {
test_count(&stdout, "failed").max(1)
},
output: strip_progress(&format!("{stdout}\n{stderr}")),
};
if !output.status.success() {
return Err(BuildFailure {
logs: None,
reason: "WASI tests failed".into(),
output: tests.output.clone(),
tests,
elapsed_secs: None,
step: None,
});
}
let mut build = Command::new("cargo");
build
.args([
"build",
"--release",
"--target",
TARGET,
"--message-format=json-render-diagnostics",
"--target-dir",
])
.arg(&target)
.current_dir(&work);
let output = run_command(
build,
started,
deadline,
"release build",
tests.clone(),
logs,
)
.await?;
let result = async {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let diagnostics = stderr.to_string();
let mut artifacts = Vec::new();
for line in stdout.lines() {
if let Ok(message) = serde_json::from_str::<Value>(line)
&& message["reason"] == "compiler-artifact"
&& message["target"]["kind"]
.as_array()
.is_some_and(|kinds| kinds.iter().any(|kind| kind == "bin"))
&& let Some(executable) = message["executable"].as_str()
{
artifacts.push(PathBuf::from(executable));
}
}
if !output.status.success() {
bail!("{diagnostics}");
}
if artifacts.len() != 1 {
bail!(
"cargo must build exactly one binary, found {}",
artifacts.len()
);
}
let artifact = artifacts.pop().unwrap();
let builds = self.world.state_dir(ID).join("builds");
let destination = tokio::task::spawn_blocking(move || {
let _guard = build_guard;
publish(&builds, &artifact)
})
.await??;
Ok(BuildOutput {
logs: None,
wasm: destination,
warnings: strip_progress(&diagnostics),
tests: tests.clone(),
})
}
.await;
result.map_err(|error: anyhow::Error| BuildFailure {
logs: None,
reason: "release build failed".into(),
output: format!("{error:#}"),
tests,
elapsed_secs: None,
step: None,
})
}
}
/// Kills cargo and every descendant, including tests and build scripts, on cancellation.
struct ProcessGroup(u32);
impl Drop for ProcessGroup {
fn drop(&mut self) {
let _ = nix::sys::signal::killpg(
nix::unistd::Pid::from_raw(self.0 as i32),
nix::sys::signal::Signal::SIGKILL,
);
}
}
/// Starts one process group and retains its guard across all output collection.
async fn run_command(
mut command: Command,
started: Instant,
deadline: Instant,
step: &'static str,
mut tests: TestResults,
logs: &LogFiles,
) -> std::result::Result<std::process::Output, BuildFailure> {
command
.process_group(0)
.kill_on_drop(true)
.stdin(std::process::Stdio::null())
// Child writes go straight to persistent files: no pipe buffers or reader
// tasks can be dropped on cancellation. Parsing happens only afterwards.
.stdout(logs.stdout.try_clone().context("clone stdout log")?)
.stderr(logs.stderr.try_clone().context("clone stderr log")?);
let out_start = logs.stdout.metadata().context("stat stdout log")?.len();
let err_start = logs.stderr.metadata().context("stat stderr log")?.len();
let mut child = command.spawn().context("start cargo")?;
let group = ProcessGroup(child.id().unwrap());
let (status, timed_out) = {
let collect = child.wait();
tokio::pin!(collect);
tokio::select! {
biased;
_ = tokio::time::sleep_until(deadline) => {
// Kill the entire tree before waiting for EOF/reaping cargo. The guard also
// covers cancellation while collecting, and runs before the mutex drops.
let _ = nix::sys::signal::killpg(nix::unistd::Pid::from_raw(group.0 as i32), nix::sys::signal::Signal::SIGKILL);
(collect.await, true)
}
status = &mut collect => (status, false),
}
};
let status = status.map_err(anyhow::Error::from)?;
// Also stop any descendants that kept the streams open after cargo exited.
drop(group);
let out = logs.read(&logs.stdout_path, out_start)?;
let err = logs.read(&logs.stderr_path, err_start)?;
if timed_out {
let output = format!(
"{}\n{}",
String::from_utf8_lossy(&out),
String::from_utf8_lossy(&err)
);
let output = output
.trim_end()
.lines()
.rev()
.take(50)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
if step == "tests" {
tests.passed = test_count(&String::from_utf8_lossy(&out), "passed");
tests.failed = test_count(&String::from_utf8_lossy(&out), "failed");
tests.output = output.clone();
}
return Err(BuildFailure {
logs: None,
reason: "timeout".into(),
tests,
output,
elapsed_secs: Some(started.elapsed().as_secs_f64()),
step: Some(step),
});
}
Ok(std::process::Output {
status,
stdout: out,
stderr: err,
})
}
/// Copies only source, excluding build outputs and git internals at every level.
fn copy_source(source: &Path, destination: &Path, root: bool) -> Result<()> {
std::fs::create_dir_all(destination)
.with_context(|| format!("create {}", destination.display()))?;
for entry in std::fs::read_dir(source).with_context(|| format!("read {}", source.display()))? {
let entry = entry.with_context(|| format!("read entry in {}", source.display()))?;
if root && matches!(entry.file_name().to_str(), Some("target" | ".git")) {
continue;
}
let from = entry.path();
let to = destination.join(entry.file_name());
if from.is_dir() {
copy_source(&from, &to, false)?;
} else {
std::fs::copy(&from, &to)
.with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
}
}
Ok(())
}
/// Repoints SDK dependencies without discarding feature flags and detaches the copied crate's workspace.
fn prepare_manifest(text: &str, sdk: &Path) -> Result<String> {
let mut document: toml_edit::DocumentMut = text.parse()?;
rewrite_dependencies(document.as_table_mut(), sdk);
if !document.contains_key("workspace") {
document["workspace"] = toml_edit::table();
}
Ok(document.to_string())
}
/// Rewrites dependency entries recursively, including target and workspace tables.
fn rewrite_dependencies(table: &mut dyn toml_edit::TableLike, sdk: &Path) {
for (name, item) in table.iter_mut() {
if matches!(
&*name,
"dependencies" | "dev-dependencies" | "build-dependencies"
) {
if let Some(dependencies) = item.as_table_like_mut() {
for (name, dependency) in dependencies.iter_mut() {
if dependency
.get("workspace")
.and_then(toml_edit::Item::as_bool)
== Some(true)
{
continue;
}
if &*name == "sausage-plugin"
|| dependency.get("package").and_then(toml_edit::Item::as_str)
== Some("sausage-plugin")
{
if dependency.is_str() {
let version = dependency.as_str().unwrap().to_string();
*dependency = toml_edit::value(toml_edit::InlineTable::new());
dependency["version"] = toml_edit::value(version);
}
dependency["path"] = toml_edit::value(sdk.display().to_string());
}
}
}
} else if let Some(nested) = item.as_table_like_mut() {
rewrite_dependencies(nested, sdk);
}
}
}
/// Publishes a complete artifact and retains only the ten newest numeric build folders.
fn publish(builds: &Path, artifact: &Path) -> Result<PathBuf> {
std::fs::create_dir_all(builds).with_context(|| format!("create {}", builds.display()))?;
let mut numbers: Vec<u64> = std::fs::read_dir(builds)
.with_context(|| format!("read {}", builds.display()))?
.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok())
.collect();
numbers.sort_unstable();
let number = numbers.last().copied().unwrap_or(0) + 1;
let destination = builds
.join(number.to_string())
.join(artifact.file_name().unwrap());
crate::world::write_atomic(
&destination,
&std::fs::read(artifact).with_context(|| format!("read {}", artifact.display()))?,
)
.with_context(|| format!("publish {}", destination.display()))?;
for old in numbers.iter().take(numbers.len().saturating_sub(9)) {
let path = builds.join(old.to_string());
std::fs::remove_dir_all(&path).with_context(|| format!("remove {}", path.display()))?;
}
Ok(destination)
}
/// Writes embedded sources only when their bytes changed.
fn write_changed(path: &Path, bytes: &[u8]) -> Result<()> {
if std::fs::read(path).ok().as_deref() != Some(bytes) {
std::fs::create_dir_all(path.parent().unwrap())
.with_context(|| format!("create parent of {}", path.display()))?;
std::fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
}
Ok(())
}
/// Collects embedded source files for seeding a missing world SDK.
fn collect_files(
directory: &Dir<'_>,
destination: &Path,
files: &mut std::collections::BTreeMap<PathBuf, Vec<u8>>,
) {
for file in directory.files() {
files.insert(destination.join(file.path()), file.contents().to_vec());
}
for directory in directory.dirs() {
collect_files(directory, destination, files);
}
}
/// Counts only final libtest summaries, never individual status lines.
fn test_count(output: &str, field: &str) -> u64 {
output
.lines()
.filter_map(|line| line.strip_prefix("test result:"))
.flat_map(|summary| summary.split(';'))
.filter_map(|part| {
let words: Vec<_> = part.split_whitespace().collect();
let index = words.iter().position(|word| *word == field)?;
words.get(index.checked_sub(1)?)?.parse::<u64>().ok()
})
.sum()
}
/// Removes progress lines while retaining compiler diagnostics.
fn strip_progress(output: &str) -> String {
output
.lines()
.filter(|line| {
![
"Compiling ",
"Finished ",
"Blocking ",
"Updating ",
"Locking ",
"Adding ",
]
.iter()
.any(|prefix| line.trim_start().starts_with(prefix))
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod timeout_tests {
use super::*;
/// Streams retain exact bytes, including non-UTF8, progress and JSON; phases append.
#[tokio::test]
async fn raw_streams_append_before_parsing() {
let directory = tempfile::tempdir().unwrap();
let logs = LogFiles::create(directory.path()).unwrap();
for _ in 0..2 {
let mut command = Command::new("sh");
command.args(["-c", "printf '\\377raw\\r'; printf ' Compiling progress\\n{\"reason\":\"compiler-artifact\"}\\n' >&2; exit 3"]);
let now = Instant::now();
let output = tokio::time::timeout(
Duration::from_secs(5),
run_command(
command,
now,
now + Duration::from_secs(3),
"tests",
TestResults::default(),
&logs,
),
)
.await
.unwrap()
.unwrap();
assert!(!output.status.success());
assert_eq!(output.stdout, b"\xffraw\r");
assert!(String::from_utf8_lossy(&output.stderr).contains("Compiling progress"));
}
assert_eq!(
std::fs::read(&logs.stdout_path).unwrap(),
b"\xffraw\r\xffraw\r"
);
let info = logs.info(directory.path()).unwrap();
assert_eq!(info.stdout.bytes, 10);
assert_eq!(info.total_bytes, info.stdout.bytes + info.stderr.bytes);
assert!(info.stdout.path.starts_with("/logs/cargo/"));
assert!(!directory.path().join("state/cargo").exists());
}
/// Cancellation leaves kernel-written data intact, even without a newline.
#[tokio::test]
async fn cancelled_command_keeps_logs() {
use notify::Watcher;
let directory = tempfile::tempdir().unwrap();
let logs = LogFiles::create(directory.path()).unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
if let Ok(event) = event {
if event
.paths
.iter()
.any(|path| path.file_name().is_some_and(|name| name == "ready"))
{
let _ = tx.send(());
}
}
})
.unwrap();
watcher
.watch(directory.path(), notify::RecursiveMode::NonRecursive)
.unwrap();
let mut command = Command::new("sh");
command.args(["-c", "printf 'partial\\377'; printf 'stderr\\r' >&2; touch \"$1/ready\"; exec sleep 1000", "sh"]).arg(directory.path());
let now = Instant::now();
let task = tokio::spawn(async move {
run_command(
command,
now,
now + Duration::from_secs(60),
"tests",
TestResults::default(),
&logs,
)
.await
});
tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.unwrap()
.unwrap();
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
let paths: Vec<_> = std::fs::read_dir(directory.path().join("logs/cargo"))
.unwrap()
.flat_map(|day| std::fs::read_dir(day.unwrap().path()).unwrap())
.map(|file| file.unwrap().path())
.collect();
assert_eq!(paths.len(), 2);
for path in paths {
let expected: &[u8] = if path.to_string_lossy().ends_with(".stdout.log") {
b"partial\xff"
} else {
b"stderr\r"
};
assert_eq!(std::fs::read(path).unwrap(), expected);
}
}
/// Deadline errors retain the shared start time, phase, test totals, and output tail.
#[tokio::test]
async fn timeout_diagnostics() {
let mut command = Command::new("sh");
command.args([
"-c",
"i=0; while [ $i -lt 100 ]; do echo diagnostic-$i; i=$((i+1)); done; exec sleep 1000",
]);
let directory = tempfile::tempdir().unwrap();
let logs = LogFiles::create(directory.path()).unwrap();
let now = Instant::now();
let failure = run_command(
command,
now - Duration::from_secs(10),
now + Duration::from_secs(1),
"release build",
TestResults {
passed: 7,
failed: 0,
output: "successful tests".into(),
},
&logs,
)
.await
.unwrap_err();
assert_eq!(failure.reason, "timeout");
assert_eq!(failure.step, Some("release build"));
assert!(failure.elapsed_secs.unwrap() >= 11.0);
assert_eq!(failure.tests.passed, 7);
assert_eq!(failure.tests.output, "successful tests");
assert_eq!(failure.output.lines().count(), 50);
assert!(failure.output.contains("diagnostic-99"));
assert!(!failure.output.contains("diagnostic-0\n"));
let raw = std::fs::read_to_string(&logs.stdout_path).unwrap();
assert_eq!(raw.lines().count(), 100);
assert!(raw.starts_with("diagnostic-0\n"));
}
}
+202
View File
@@ -0,0 +1,202 @@
//! The native `cargo` actor: builds Rust source folders into `.wasm` files.
//!
//! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}`
//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", ...}`
//! `{"type": "build_failed", "errors": "..."}`
//!
//! The broker knows nothing about Rust. This actor is one way to get a
//! `.wasm` file. Other actors can add other languages.
//!
//! Actors depend on the SDK with `sausage-actor = "0.1"`. The SDK is not
//! published: this actor copies the SDK of this host to `public/sdk/` (so
//! that every actor can read it), and points the dependency there in the
//! build copy of `Cargo.toml`.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};
use tokio::process::Command;
use tokio::sync::mpsc;
use include_dir::{Dir, include_dir};
use crate::broker::{Broker, Envelope};
use crate::world::{self, World};
pub const ID: &str = "cargo";
pub const TARGET: &str = "wasm32-wasip2";
/// The source of the `sausage-actor` SDK.
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk");
pub struct BuildOutput {
/// Host path of the published `.wasm` file.
pub wasm: PathBuf,
pub seconds: f64,
pub warnings: String,
}
/// All builds share one target folder, so they run one at a time.
pub struct Cargo {
world: World,
}
impl Cargo {
/// Create the actor and install the SDK of this host into `public/sdk/`.
pub fn new(world: World) -> Result<Self> {
write_dir(&SDK, &world.public_dir(ID).join("sdk"))?;
Ok(Self { world })
}
/// Build the crate in `source` (a host path) and publish the `.wasm` file
/// under `/actor/cargo/public/builds/<n>/`.
pub async fn build(&self, source: &Path) -> Result<BuildOutput> {
if !source.join("Cargo.toml").is_file() {
bail!("{} has no Cargo.toml", source.display());
}
let started = Instant::now();
let data = self.world.data_dir(ID);
let build_dir = data.join("work");
world::replace_source(source, &build_dir)?;
let manifest = build_dir.join("Cargo.toml");
std::fs::write(&manifest, prepare_manifest(&std::fs::read_to_string(&manifest)?, Some(&self.world.public_dir(ID).join("sdk")))?)?;
let mut cmd = Command::new("cargo");
cmd.arg("build")
.arg("--release")
.arg("--target")
.arg(TARGET)
.arg("--message-format=json-render-diagnostics")
.arg("--target-dir")
.arg(data.join("target"))
.current_dir(&build_dir)
.kill_on_drop(true);
let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if !out.status.success() {
bail!("{}", strip_progress(&stderr));
}
let mut artifact = None;
for line in String::from_utf8_lossy(&out.stdout).lines() {
if let Ok(v) = serde_json::from_str::<Value>(line)
&& v["reason"] == "compiler-artifact"
&& let Some(exe) = v["executable"].as_str()
{
artifact = Some(PathBuf::from(exe));
}
}
let artifact = artifact.ok_or_else(|| anyhow!("cargo built no binary. Does the crate have a src/main.rs?"))?;
let n = next_build_number(&self.world.public_dir(ID).join("builds"))?;
let dest_dir = self.world.public_dir(ID).join("builds").join(n.to_string());
let name = artifact.file_name().context("artifact has no file name")?;
let dest = dest_dir.join(name);
world::write_atomic(&dest, &std::fs::read(&artifact)?)?;
Ok(BuildOutput { wasm: dest, seconds: started.elapsed().as_secs_f64(), warnings: strip_progress(&stderr) })
}
/// The native `cargo` actor. Builds run one at a time, in order.
/// Replies (messages with `reply_to`) get no answer.
pub async fn run_actor(self, broker: Arc<Broker>, mut mailbox: mpsc::Receiver<Envelope>) {
while let Some(env) = mailbox.recv().await {
if env.reply_to.is_some() {
continue;
}
let body = self.handle(&broker, &env).await.unwrap_or_else(|e| {
json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}")})
});
broker.send(ID, Some(&env.from), None, Some(&env.id), body);
}
}
async fn handle(&self, broker: &Broker, env: &Envelope) -> Result<Value> {
match env.body.get("type").and_then(Value::as_str) {
Some("build") => {
let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?;
let source = broker.world.resolve(&env.from, source_s)?;
let out = self.build(&source).await?;
let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string());
Ok(json!({
"type": "built",
"wasm": wasm,
"source": source_s,
"built_by": ID,
"seconds": (out.seconds * 10.0).round() / 10.0,
"warnings": out.warnings,
}))
}
other => bail!("unknown request {other:?}. Known: build"),
}
}
}
/// Point a `sausage-actor` dependency at `sdk` (keeping its features), and
/// add an empty `[workspace]`, so the crate never joins a parent workspace.
fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> {
let mut doc: toml_edit::DocumentMut = text.parse()?;
if let Some(sdk) = sdk {
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(dep) = doc.get_mut(section).and_then(|t| t.get_mut("sausage-actor")) else { continue };
let mut table = dep.as_inline_table().cloned().unwrap_or_default();
for key in ["version", "path", "git", "branch", "tag", "rev", "registry"] {
table.remove(key);
}
table.insert("path", sdk.display().to_string().into());
*dep = toml_edit::value(table);
}
}
if doc.get("workspace").is_none() {
doc["workspace"] = toml_edit::table();
}
Ok(doc.to_string())
}
/// Write the files of `dir` below `to`. Skip files that did not change, so
/// that cargo does not rebuild the SDK at every start.
fn write_dir(dir: &Dir<'_>, to: &Path) -> Result<()> {
for file in dir.files() {
let dest = to.join(file.path());
let contents = match file.path().file_name() {
Some(n) if n == "Cargo.toml" => prepare_manifest(file.contents_utf8().context("Cargo.toml")?, None)?.into_bytes(),
_ => file.contents().to_vec(),
};
if std::fs::read(&dest).ok() != Some(contents.clone()) {
std::fs::create_dir_all(dest.parent().context("no parent")?)?;
std::fs::write(&dest, contents)?;
}
}
for sub in dir.dirs() {
write_dir(sub, to)?;
}
Ok(())
}
fn next_build_number(dir: &Path) -> Result<u64> {
std::fs::create_dir_all(dir)?;
let max = std::fs::read_dir(dir)?
.filter_map(|e| e.ok()?.file_name().to_str()?.parse::<u64>().ok())
.max()
.unwrap_or(0);
Ok(max + 1)
}
/// Remove cargo's "Compiling ..." progress lines. Keep diagnostics.
fn strip_progress(stderr: &str) -> String {
stderr
.lines()
.filter(|l| {
let t = l.trim_start();
!(t.starts_with("Compiling ")
|| t.starts_with("Downloaded ")
|| t.starts_with("Downloading ")
|| t.starts_with("Updating ")
|| t.starts_with("Locking ")
|| t.starts_with("Adding ")
|| t.starts_with("Blocking "))
})
.collect::<Vec<_>>()
.join("\n")
}
-217
View File
@@ -1,217 +0,0 @@
//! Metadata-keyed component loads and weak compiled versions; candidate copies never enter the path cache.
#[cfg(test)]
mod tests {
use super::*;
/// PROTOCOL 10.3: a pending component load does not occupy admission.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn loading_does_not_hold_admission() {
let folder =
std::env::temp_dir().join(format!("sausage-admission-{}", uuid::Uuid::now_v7()));
let world = crate::world::World::create(&folder).unwrap();
let path = world.plugin_dir("probe").join("plugin.wasm");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, []).unwrap();
let host = crate::Host::start_with_cache(&folder, &folder.join("cache"))
.await
.unwrap();
let entry = host
.0
.components
.paths
.lock()
.unwrap()
.entry(path)
.or_default()
.clone();
let blocked = entry.lock().await;
let caller = host.clone();
let pending =
tokio::spawn(async move { caller.call("probe.echo", serde_json::json!({})).await });
tokio::time::timeout(std::time::Duration::from_secs(5), async {
while Arc::strong_count(&entry) < 3 {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
let admitted = host.0.admission.try_write().is_ok();
drop(blocked);
assert!(pending.await.unwrap().is_err());
host.stop().await;
std::fs::remove_dir_all(folder).unwrap();
assert!(admitted, "a component load held the admission gate");
}
/// PROTOCOL 10.3: temporary discovery and activation copies never accumulate cached paths.
#[tokio::test]
async fn candidate_copies_do_not_enter_path_cache() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let target = root.join("target/core/guests");
assert!(
std::process::Command::new("cargo")
.args([
"build",
"--offline",
"--target",
"wasm32-wasip2",
"--manifest-path"
])
.arg(root.join("cli/tests/plugins/probe/Cargo.toml"))
.arg("--target-dir")
.arg(&target)
.status()
.unwrap()
.success()
);
let folder =
std::env::temp_dir().join(format!("sausage-component-cache-{}", uuid::Uuid::now_v7()));
std::fs::create_dir_all(&folder).unwrap();
let runtime = Runtime::new(&root.join("target/core/cache-test")).unwrap();
let cache = Components::default();
for name in ["discover-test.wasm", "activate-test.wasm"] {
let path = folder.join(name);
std::fs::copy(target.join("wasm32-wasip2/debug/probe.wasm"), &path).unwrap();
let loaded = cache.load(path.clone(), runtime.clone()).await.unwrap();
assert!(cache.paths.lock().unwrap().is_empty());
let weak = Arc::downgrade(&loaded.prepared);
drop(loaded);
std::fs::remove_file(path).unwrap();
assert!(weak.upgrade().is_none());
}
std::fs::remove_dir(folder).unwrap();
}
}
use crate::{
error,
runtime::{Prepared, Runtime},
};
use sha2::{Digest, Sha256};
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, Mutex, Weak},
time::SystemTime,
};
use tokio::sync::{Mutex as AsyncMutex, OnceCell};
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct Fingerprint {
size: u64,
modified: SystemTime,
#[cfg(unix)]
identity: (u64, u64, i64, i64),
}
impl Fingerprint {
pub(crate) async fn read(path: &Path) -> Result<Self, serde_json::Value> {
let metadata = tokio::fs::metadata(path)
.await
.map_err(|_| error(-32001, "no such plugin"))?;
Ok(Self {
size: metadata.len(),
modified: metadata
.modified()
.map_err(|e| error(-32603, &e.to_string()))?,
#[cfg(unix)]
identity: {
use std::os::unix::fs::MetadataExt;
(
metadata.dev(),
metadata.ino(),
metadata.ctime(),
metadata.ctime_nsec(),
)
},
})
}
}
#[derive(Clone)]
pub(crate) struct Loaded {
pub(crate) prepared: Arc<Prepared>,
pub(crate) hash: String,
pub(crate) fingerprint: Fingerprint,
// Retain the per-hash cell only while a cached path refers to this version.
_compiled: Arc<OnceCell<Arc<Prepared>>>,
}
type PathEntry = Arc<AsyncMutex<Option<Loaded>>>;
#[derive(Default)]
pub(crate) struct Components {
paths: Mutex<HashMap<PathBuf, PathEntry>>,
compiled: Mutex<HashMap<String, Weak<OnceCell<Arc<Prepared>>>>>,
}
impl Components {
pub(crate) async fn load(
&self,
path: PathBuf,
runtime: Arc<Runtime>,
) -> Result<Loaded, serde_json::Value> {
let candidate = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("activate-") || n.starts_with("discover-"));
let entry = if candidate {
Arc::default()
} else {
self.paths
.lock()
.unwrap()
.entry(path.clone())
.or_default()
.clone()
};
let mut cached = entry.lock().await;
let fingerprint = match Fingerprint::read(&path).await {
Ok(metadata) => metadata,
Err(error) => {
*cached = None;
return Err(error);
}
};
if let Some(version) = cached.as_ref()
&& version.fingerprint == fingerprint
{
return Ok(version.clone());
}
let (bytes, hash) = tokio::task::spawn_blocking(move || -> std::io::Result<_> {
let bytes = std::fs::read(path)?;
let hash = format!("{:x}", Sha256::digest(&bytes));
Ok((bytes, hash))
})
.await
.map_err(|e| error(-32603, &e.to_string()))?
.map_err(|e| error(-32603, &e.to_string()))?;
let compiled = {
let mut versions = self.compiled.lock().unwrap();
versions.retain(|_, v| v.strong_count() > 0);
if let Some(cell) = versions.get(&hash).and_then(Weak::upgrade) {
cell
} else {
let cell = Arc::new(OnceCell::new());
versions.insert(hash.clone(), Arc::downgrade(&cell));
cell
}
};
let prepared = compiled
.get_or_try_init(|| async move {
tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
let component = runtime.compile(&bytes)?;
Ok(Arc::new(runtime.prepare(&component)))
})
.await
.map_err(|e| error(-32603, &e.to_string()))?
.map_err(|e| error(-32603, &e.to_string()))
})
.await?
.clone();
let loaded = Loaded {
prepared,
hash,
fingerprint,
_compiled: compiled,
};
if !candidate {
*cached = Some(loaded.clone());
}
Ok(loaded)
}
}
+52 -299
View File
@@ -1,314 +1,67 @@
//! Sequential console commands, line editing, and live notifications. //! The native `console` actor: your terminal.
use crate::Host; //!
use anyhow::Result; //! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends
use rustyline::{DefaultEditor, ExternalPrinter, error::ReadlineError}; //! `{"type": "text", "text": ...}`.
use sausage_vfs::{Layer, Open}; //! - A line without `@` goes to `agent` as text.
//! - `/help`, `/quit`. EOF on stdin also quits.
use std::sync::Arc;
use serde_json::{Value, json}; use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
const HELP: &str = "text chat with the agent use crate::broker::{Broker, Envelope};
plugin.method {json} call a plugin
ls [path], cat <path>, methods <plugin>
ps, log [plugin or pid] [-n N]
activate <plugin> <wasm>, revert <plugin>
stop <pid>, kill <pid>, help, quit";
/// Read a complete file through the host filesystem. pub const ID: &str = "console";
async fn read(host: &Host, path: &str) -> Result<String> {
let file = host const HELP: &str = "\
.filesystem() @<actor> <json> send a JSON body to an actor
.await? @<actor> <text> send {\"type\":\"text\",\"text\":...}
.open( <text> send text to the agent
path, /quit stop all actors and exit";
Open {
follow: true, /// Print a message: text bodies as text, everything else as JSON.
..Open::default() fn show(env: &Envelope) {
}, let reply = if env.reply_to.is_some() { " ↩" } else { "" };
) match env.body["text"].as_str() {
.await?; Some(text) if env.body["type"] == "text" => println!("[{}{reply}] {text}", env.from),
let mut bytes = Vec::new(); _ => println!("[{}{reply}] {}", env.from, env.body),
loop {
let chunk = file.read(bytes.len() as u64, 65536).await?;
if chunk.is_empty() {
return Ok(String::from_utf8(bytes)?);
}
bytes.extend(chunk);
} }
} }
/// Recognize the process IDs accepted by stop, kill, and log. /// Print every message that arrives for the console.
fn is_pid(text: &str) -> bool { pub async fn run_mailbox(mut mailbox: mpsc::Receiver<Envelope>) {
text.rsplit_once('-').is_some() while let Some(env) = mailbox.recv().await {
&& text show(&env);
.get(text.len().saturating_sub(36)..) }
.is_some_and(|id| uuid::Uuid::try_parse(id).is_ok())
} }
/// Render method signatures and their documentation. /// Read lines until EOF or `/quit`, and send them as messages.
fn methods(plugin: &str, doc: &Value) -> String { pub async fn run_input(broker: Arc<Broker>) {
let mut lines = Vec::new(); println!("Sausage console. Type /help for commands.");
for method in doc["methods"].as_array().into_iter().flatten() { let mut lines = BufReader::new(tokio::io::stdin()).lines();
let params = method["params"].as_array().cloned().unwrap_or_default(); while let Ok(Some(line)) = lines.next_line().await {
let signature = params
.iter()
.map(|p| {
format!(
"{}: {}",
p["name"].as_str().unwrap_or("?"),
p["schema"]["type"].as_str().unwrap_or("any")
)
})
.collect::<Vec<_>>()
.join(", ");
lines.push(format!(
"{plugin}.{}({signature}) -> {}",
method["name"].as_str().unwrap_or("?"),
method["result"]["schema"]["type"].as_str().unwrap_or("any")
));
for field in ["summary", "description"] {
if let Some(text) = method[field].as_str() {
lines.push(format!(" {text}"));
}
}
for p in params {
if let Some(text) = p["description"].as_str() {
lines.push(format!(" {}: {text}", p["name"].as_str().unwrap_or("?")));
}
}
}
lines.join("\n")
}
/// Parse journal filters, or treat invalid arguments as chat.
fn log_params(words: &[&str]) -> Option<Value> {
let mut params = json!({});
let mut words = words.iter();
while let Some(word) = words.next() {
if *word == "-n" {
params["limit"] = json!(words.next()?.parse::<u64>().ok()?);
} else if params.get("plugin").is_none() && params.get("pid").is_none() {
params[if is_pid(word) { "pid" } else { "plugin" }] = json!(word);
} else {
return None;
}
}
Some(params)
}
/// Execute one line and return its unlabelled result.
pub async fn execute_line(host: &Host, line: &str) -> Result<String> {
let line = line.trim(); let line = line.trim();
if line.is_empty() { let (to, body) = match line {
return Ok(String::new()); "" => continue,
"/quit" => break,
"/help" => {
println!("{HELP}");
continue;
} }
let (command, rest) = line.split_once(char::is_whitespace).unwrap_or((line, "")); _ => match line.strip_prefix('@') {
let rest = rest.trim(); Some(rest) => {
let words: Vec<_> = rest.split_whitespace().collect(); let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
match (command, words.as_slice()) { let body = match serde_json::from_str::<Value>(text.trim()) {
("help", []) => return Ok(HELP.into()), Ok(v @ Value::Object(_)) => v,
("quit", []) => return Ok("Goodbye.".into()), _ => json!({"type": "text", "text": text.trim()}),
("cat", [path]) => return read(host, path).await, };
("ls", [] | [_]) => { (to, body)
let mut entries = host
.filesystem()
.await?
.list(words.first().copied().unwrap_or("/"))
.await?;
entries.sort_by(|a, b| a.name.cmp(&b.name));
return Ok(entries
.iter()
.map(|e| format!("{}{}", e.name, if e.directory { "/" } else { "" }))
.collect::<Vec<_>>()
.join("\n"));
} }
("methods", [plugin]) => { None => ("agent", json!({"type": "text", "text": line})),
let _activation = host.0.activations.lock().await;
host.refresh_graph()
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
return Ok(methods(
plugin,
&serde_json::from_str(
&read(host, &format!("/proc/core/plugins/{plugin}/discover.json")).await?,
)?,
));
}
("ps", []) => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis();
let mut processes: Vec<_> = host.processes().into_iter().collect();
processes.sort_by(|a, b| a.0.cmp(&b.0));
let mut lines = vec!["PID PLUGIN METHOD PARENT STATE AGE".into()];
lines.extend(processes.iter().map(|(pid, p)| {
format!(
"{pid} {} {} {} {} {}s",
p.plugin,
p.method,
p.parent.as_deref().unwrap_or("-"),
p.state,
now.saturating_sub(p.start_time) / 1000
)
}));
return Ok(lines.join("\n"));
}
_ => {}
}
let explicit = (rest.is_empty() || rest.starts_with('{'))
&& command
.split_once('.')
.is_some_and(|(p, m)| !p.is_empty() && !m.is_empty());
let (method, params) = match (command, words.as_slice()) {
("activate", [plugin, wasm]) => {
("core.activate".into(), json!({"plugin":plugin,"wasm":wasm}))
}
("revert", [plugin]) => ("core.revert".into(), json!({"plugin":plugin})),
("stop" | "kill", [pid]) if is_pid(pid) => (format!("core.{command}"), json!({"pid":pid})),
("log", _) if log_params(&words).is_some() => {
("core.log".into(), log_params(&words).unwrap())
}
_ if explicit => (
command.into(),
if rest.is_empty() {
json!({})
} else {
serde_json::from_str(rest)?
}, },
),
_ => ("agent.chat".into(), json!({"text":line})),
}; };
let value = match host.call(&method, params).await { broker.send(ID, Some(to), None, None, body);
Ok(value) => value,
Err(error) => return Ok(format!("error: {}", serde_json::to_string_pretty(&error)?)),
};
if method == "agent.chat" && !explicit {
return Ok(value["text"]
.as_str()
.or_else(|| value.as_str())
.map(str::to_owned)
.unwrap_or(serde_json::to_string_pretty(&value)?));
}
if method == "core.log" && command == "log" {
return value
.as_array()
.unwrap()
.iter()
.map(|e| {
let time = chrono::DateTime::parse_from_rfc3339(e["time"].as_str().unwrap())?
.with_timezone(&chrono::Local);
let pid = e["pid"].as_str().unwrap();
let prefix = format!("{}-", e["plugin"].as_str().unwrap());
let short = pid
.strip_prefix(&prefix)
.map(|id| format!("{prefix}{}", &id[..8]))
.unwrap_or(pid.into());
Ok(format!(
"{} {short} {} {}",
time.format("%H:%M:%S%.3f"),
e["stream"].as_str().unwrap(),
e["line"].as_str().unwrap()
))
})
.collect::<Result<Vec<_>>>()
.map(|lines| lines.join("\n"));
}
Ok(serde_json::to_string_pretty(&value)?)
}
/// Dim text notifications and preserve other payloads.
pub fn notification_text(value: &Value) -> String {
let params = &value["params"];
if params["name"] != "text" {
return value.to_string();
}
let payload = &params["params"];
let text = payload["text"]
.as_str()
.or_else(|| payload.as_str())
.map(str::to_owned)
.unwrap_or(payload.to_string());
format!("\x1b[2m{text}\x1b[0m")
}
/// Read and execute one line at a time. Interrupting a wait does not cancel its host process.
pub async fn run_input(host: Host) -> Result<()> {
let terminal = std::io::IsTerminal::is_terminal(&std::io::stdin());
let history = host.0.world.state_dir("core").join("history");
let mut editor = if terminal {
Some(DefaultEditor::new()?)
} else {
None
};
if let Some(editor) = &mut editor {
let _ = editor.load_history(&history);
}
let mut printer = editor
.as_mut()
.map(|e| e.create_external_printer())
.transpose()?;
let (request, requests) =
std::sync::mpsc::channel::<tokio::sync::oneshot::Sender<Result<Option<String>>>>();
std::thread::spawn(move || {
for response in requests {
let line = (|| -> Result<_> {
if let Some(editor) = &mut editor {
match editor.readline("> ") {
Ok(line) => {
editor.add_history_entry(&line)?;
editor.save_history(&history)?;
Ok(Some(line))
}
Err(ReadlineError::Interrupted | ReadlineError::Eof) => Ok(None),
Err(e) => Err(e.into()),
}
} else {
std::io::stdin()
.lines()
.next()
.transpose()
.map_err(Into::into)
}
})();
if response.send(line).is_err() {
break;
}
}
});
let mut notifications = host.subscribe();
let mut notification_task = tokio::task::JoinSet::new();
notification_task.spawn(async move {
loop {
match notifications.recv().await {
Ok(value) => {
let text = notification_text(&value);
if let Some(printer) = &mut printer {
let _ = printer.print(format!("{text}\n"));
} else {
println!("{text}");
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
});
let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
loop {
let (send, line) = tokio::sync::oneshot::channel();
request.send(send)?;
let line =
tokio::select! { result = line => result??, _ = interrupt.recv() => return Ok(()) };
let Some(line) = line else {
return Ok(());
};
let text = tokio::select! {
result = execute_line(&host, &line) => result.unwrap_or_else(|e| format!("error: {e}")),
_ = interrupt.recv() => "stopped waiting; the call continues, see ps".into(),
};
if !text.is_empty() {
println!("{text}");
}
if line.trim() == "quit" {
return Ok(());
}
} }
} }
-75
View File
@@ -1,75 +0,0 @@
//! The native core methods and their JSON parameters.
use crate::{Host, RpcResult, error};
use serde_json::{Value, json};
impl Host {
/// Execute a core method without starting a guest process.
pub(crate) async fn core(
&self,
caller: &str,
id: Option<Value>,
method: &str,
p: Value,
) -> RpcResult {
match method {
"watch" => self.watch(caller, id, required_str(&p, "path")?).await,
"call" => {
self.route(
caller.into(),
id,
required_str(&p, "method")?.into(),
p.get("params").cloned().unwrap_or(json!({})),
p["detach"].as_bool().unwrap_or(false),
)
.await
}
"notify" => {
self.notify(
caller,
required_str(&p, "name")?,
p.get("params").cloned().unwrap_or(json!({})),
);
Ok(Value::Null)
}
"kill" => {
let pid = required_str(&p, "pid")?;
let ended = self
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.map(|p| p.ended.subscribe());
self.kill(pid);
if let Some(mut ended) = ended {
let _ = ended.wait_for(|ended| *ended).await;
}
Ok(json!({}))
}
"stop" => {
if let Some(mut n) = self.begin_stop(required_str(&p, "pid")?) {
let _ = n.wait_for(|ended| *ended).await;
}
Ok(json!({}))
}
"lock" => {
self.lock(caller, required_str(&p, "path")?, p["wait"] != false)
.await
}
"unlock" => {
self.unlock(caller, required_str(&p, "path")?);
Ok(Value::Null)
}
"log" => Ok(self.0.journal.query(p).await),
"activate" | "revert" => self.activation(caller, method, p).await,
"restart" | "exit" => self.request_restart(),
_ => Err(error(-32601, "method not found")),
}
}
}
fn required_str<'a>(params: &'a Value, name: &str) -> Result<&'a str, Value> {
params[name]
.as_str()
.ok_or_else(|| error(-32602, &format!("{name} must be a string")))
}
-129
View File
@@ -1,129 +0,0 @@
//! Execution of native futures and WASI processes, including transport drainage.
use crate::{
Host, NativeContext, NativePlugin,
process::ProcessInfo,
runtime::{Instance, InstanceConfig},
};
use serde_json::{Value, json};
use tokio::sync::mpsc;
impl Host {
/// Run one process and retain its cleanup guard until all transport reads finish.
pub(crate) async fn run_process(
&self,
pid: String,
info: ProcessInfo,
component: Option<std::sync::Arc<crate::runtime::Prepared>>,
native: Option<NativePlugin>,
incoming: mpsc::UnboundedReceiver<Value>,
has_id: bool,
) -> String {
if let Some(native) = native {
let context = NativeContext {
host: self.clone(),
pid: pid.clone(),
};
let result = native(context, info.method, info.params).await;
self.answer(&pid, result);
return "exit 0".into();
}
let result = self
.run_guest(&pid, &info, &component.unwrap(), incoming, has_id)
.await;
result.unwrap_or_else(|error| error.to_string())
}
/// Drain stdout before deciding whether the guest returned an answer.
async fn run_guest(
&self,
pid: &str,
info: &ProcessInfo,
component: &crate::runtime::Prepared,
mut incoming: mpsc::UnboundedReceiver<Value>,
has_id: bool,
) -> anyhow::Result<String> {
let (memory, tracker, store_ended) = {
let state = self.0.state.lock().unwrap();
let p = state.processes.get(pid).unwrap();
(p.memory.clone(), p.files.clone(), p.store_ended.clone())
};
let config = InstanceConfig {
filesystem: self.mounts(Some(pid.into()), memory, tracker).await?,
store_ended,
network: true,
env: vec![
("SAUSAGE_PID".into(), pid.into()),
("SAUSAGE_PLUGIN".into(), info.plugin.clone()),
(
"SAUSAGE_PARENT".into(),
info.parent.clone().unwrap_or_else(|| "core".into()),
),
],
};
let mut instance = self
.0
.runtime
.start_logged(
component,
config,
self.0.journal.clone(),
pid.into(),
info.plugin.clone(),
)
.await?;
let mut initial = json!({
"jsonrpc": "2.0",
"method": info.method,
"params": info.params,
});
if has_id {
initial["id"] = json!(pid);
}
let mut stdin_open = self.forward_input(pid, &mut instance, Some(initial)).await;
loop {
tokio::select! {
line = instance.stdout.recv() => {
if !self.stdout_line(pid,line) { break; }
}
value = incoming.recv(), if stdin_open => {
stdin_open = self.forward_input(pid,&mut instance,value).await;
}
}
}
Ok(instance.wait().await.to_string())
}
/// Close stdin only after the queued responses reach the guest.
async fn forward_input(
&self,
pid: &str,
instance: &mut Instance,
value: Option<Value>,
) -> bool {
if let Some(value) = value
&& self.write_input(pid, instance, value).await.is_ok()
{
return true;
}
instance.stdin.take();
false
}
async fn write_input(
&self,
pid: &str,
instance: &mut Instance,
value: Value,
) -> std::io::Result<()> {
instance
.send_line(&value.to_string(), |line| self.output(pid, line))
.await
}
fn stdout_line(&self, pid: &str, line: Option<String>) -> bool {
if let Some(line) = line {
self.output(pid, line);
true
} else {
false
}
}
}
-324
View File
@@ -1,324 +0,0 @@
//! Per-process mount tables, reserved self files, and the live process namespace.
use crate::Host;
use sausage_vfs::{
Entry, File, Generated, Layer, Memory, Mounts, Open, Process, Result, Stat, async_trait,
};
use std::{io, sync::Arc};
fn denied<T>() -> Result<T> {
Err(io::ErrorKind::ReadOnlyFilesystem.into())
}
fn missing<T>() -> Result<T> {
Err(io::ErrorKind::NotFound.into())
}
fn directory() -> Stat {
Stat {
directory: true,
symlink: false,
size: 0,
accessed: None,
modified: None,
}
}
fn entry(name: String, directory: bool) -> Entry {
Entry {
name,
directory,
symlink: false,
}
}
fn reserved(path: &str) -> bool {
matches!(
path.trim_start_matches('/').split('/').next(),
Some("info.json" | "log" | "fs")
)
}
impl Host {
/// Create a console filesystem view without adding a process. Its `/self` is private and ephemeral.
pub async fn filesystem(&self) -> anyhow::Result<Mounts> {
self.mounts(None, Memory::default(), Process::default())
.await
}
pub(crate) async fn mounts(
&self,
pid: Option<String>,
memory: Memory,
tracker: Process,
) -> anyhow::Result<Mounts> {
let root = self.0.world.root.clone();
let operations = tracker.clone();
let host = tracker
.clone()
.blocking(move || sausage_vfs::Host::new(root, tracker))
.await?;
let mut mounts = Mounts::default();
mounts.mount(
"/",
self.0
.observer
.layer(Arc::new(host), "/", operations.clone()),
false,
);
mounts.mount("/proc", Arc::new(Proc(self.clone())), true);
mounts.mount(
"/self",
self.0.observer.layer(
Arc::new(SelfFolder {
host: self.clone(),
pid: pid.clone(),
memory,
}),
&format!("/proc/{}", pid.as_deref().unwrap_or("console")),
operations.clone(),
),
false,
);
mounts.mount(
"/run",
self.0
.observer
.layer(Arc::new(self.0.run.clone()), "/run", operations),
false,
);
Ok(mounts)
}
fn core_files(&self) -> Generated {
let mut files = Generated::default();
let protocol = include_bytes!("../../PROTOCOL.md");
files.insert("/PROTOCOL.md", move || Ok(protocol.to_vec()));
let graph = self.graph_json();
files.insert("/graph.json", move || Ok(graph.clone()));
let dot = self.graph_dot();
files.insert("/graph.dot", move || Ok(dot.clone()));
let locks = {
let state = self.0.state.lock().unwrap();
let locks: std::collections::BTreeMap<_, _> = state
.locks
.iter()
.map(|(path, lock)| {
let waiting: Vec<_> = lock.queue.iter().map(|(pid, _)| pid).collect();
let info = serde_json::json!({
"owner": lock.owner,
"waiting": waiting,
});
(path.clone(), info)
})
.collect();
serde_json::to_vec(&locks).unwrap()
};
files.insert("/locks.json", move || Ok(locks.clone()));
for name in self.discovered_plugins() {
if let Some(doc) = self.discover_document(&name) {
let bytes = serde_json::to_vec(&doc).unwrap();
files.insert(&format!("/plugins/{name}/discover.json"), move || {
Ok(bytes.clone())
});
files.insert(&format!("/plugins/{name}/client.rs"), move || {
sausage_interface::client_rs(&name, &doc)
.map(String::into_bytes)
.map_err(io::Error::other)
});
}
}
files
}
}
struct Proc(Host);
impl Proc {
fn folder(&self, path: &str) -> Result<(SelfFolder, String)> {
let (pid, rest) = path
.trim_start_matches('/')
.split_once('/')
.unwrap_or((path.trim_start_matches('/'), ""));
let memory = self
.0
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.map(|p| p.memory.clone())
.ok_or(io::ErrorKind::NotFound)?;
Ok((
SelfFolder {
host: self.0.clone(),
pid: Some(pid.into()),
memory,
},
format!("/{rest}"),
))
}
}
#[async_trait]
impl Layer for Proc {
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
let path = path.trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
return Ok(directory());
}
if let Some(path) = path.strip_prefix("/core/") {
return self.0.core_files().stat(&format!("/{path}"), follow).await;
}
let (folder, path) = self.folder(path)?;
folder.stat(&path, follow).await
}
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
let path = path.trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
if path == "/" {
let mut entries = vec![entry("core".into(), true)];
entries.extend(self.0.processes().into_keys().map(|pid| entry(pid, true)));
entries.sort_by(|a, b| a.name.cmp(&b.name));
return Ok(entries);
}
if path.trim_end_matches('/') == "/core/plugins" {
return Ok(self
.0
.discovered_plugins()
.into_iter()
.filter(|name| self.0.discover_document(name).is_some())
.map(|name| entry(name, true))
.collect());
}
if path.trim_end_matches('/') == "/core" {
let mut entries = self.0.core_files().list("/").await?;
if !entries.iter().any(|e| e.name == "plugins") {
entries.push(entry("plugins".into(), true));
}
return Ok(entries);
}
if let Some(path) = path.strip_prefix("/core/") {
return self.0.core_files().list(&format!("/{path}")).await;
}
let (folder, path) = self.folder(path)?;
folder.list(&path).await
}
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
let path = path.trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
if options.write || options.create || options.truncate {
return denied();
}
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
return Memory::default().open("/", options).await;
}
if let Some(path) = path.strip_prefix("/core/") {
return self.0.core_files().open(&format!("/{path}"), options).await;
}
let (folder, path) = self.folder(path)?;
folder.open(&path, options).await
}
async fn mkdir(&self, _: &str) -> Result<()> {
denied()
}
async fn remove_file(&self, _: &str) -> Result<()> {
denied()
}
async fn remove_dir(&self, _: &str) -> Result<()> {
denied()
}
async fn rename(&self, _: &str, _: &str) -> Result<()> {
denied()
}
}
struct SelfFolder {
host: Host,
pid: Option<String>,
memory: Memory,
}
impl SelfFolder {
async fn generated(&self, path: &str) -> Result<Generated> {
let pid = self.pid.as_ref().ok_or(io::ErrorKind::NotFound)?;
let bytes = match path {
"/info.json" => {
let info = self
.host
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.map(|p| p.info.clone())
.ok_or(io::ErrorKind::NotFound)?;
serde_json::to_vec(&info).unwrap()
}
"/log" => {
let lines = self
.host
.0
.journal
.query(serde_json::json!({"pid":pid,"limit":i64::MAX}))
.await;
let mut bytes = Vec::new();
for line in lines.as_array().unwrap() {
serde_json::to_writer(&mut bytes, line).unwrap();
bytes.push(b'\n');
}
bytes
}
_ => return missing(),
};
let mut files = Generated::default();
files.insert(path, move || Ok(bytes.clone()));
Ok(files)
}
}
#[async_trait]
impl Layer for SelfFolder {
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
if reserved(path) {
self.generated(path).await?.stat(path, follow).await
} else {
self.memory.stat(path, follow).await
}
}
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
let mut entries = self.memory.list(path).await?;
if path == "/" && self.pid.is_some() {
entries.extend([entry("info.json".into(), false), entry("log".into(), false)]);
}
Ok(entries)
}
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
if reserved(path) {
if options.write || options.create || options.truncate {
return denied();
}
self.generated(path).await?.open(path, options).await
} else {
self.memory.open(path, options).await
}
}
async fn mkdir(&self, path: &str) -> Result<()> {
if reserved(path) {
denied()
} else {
self.memory.mkdir(path).await
}
}
async fn remove_file(&self, path: &str) -> Result<()> {
if reserved(path) {
denied()
} else {
self.memory.remove_file(path).await
}
}
async fn remove_dir(&self, path: &str) -> Result<()> {
if reserved(path) || path == "/" {
denied()
} else {
self.memory.remove_dir(path).await
}
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
if reserved(from) || reserved(to) || from == "/" || to == "/" {
denied()
} else {
self.memory.rename(from, to).await
}
}
}
-288
View File
@@ -1,288 +0,0 @@
//! Cached discovery documents and import checks under PROTOCOL.md section 10.4.
//! Refreshes use immutable copies. The proc layer only reads serialized cache entries.
use crate::{Host, RpcResult};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
#[derive(Clone)]
pub(crate) struct Interface {
pub(crate) hash: String,
pub(crate) document: Value,
pub(crate) error: Option<Value>,
}
pub(crate) struct Graph {
pub(crate) plugins: BTreeMap<String, Interface>,
json: Vec<u8>,
dot: Vec<u8>,
}
impl Default for Graph {
/// Exposes a valid empty graph before background discovery completes.
fn default() -> Self {
Self {
plugins: BTreeMap::new(),
json: b"{\"plugins\":{}}".to_vec(),
dot: b"digraph plugins {}\n".to_vec(),
}
}
}
/// Hashes exactly the bytes copied for discovery or installation.
pub(crate) fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
/// Reads and hashes one immutable snapshot without blocking an async worker.
pub(crate) async fn read_hashed(path: PathBuf) -> std::io::Result<(Vec<u8>, String)> {
tokio::task::spawn_blocking(move || -> std::io::Result<_> {
let bytes = std::fs::read(path)?;
let hash = hash(&bytes);
Ok((bytes, hash))
})
.await
.map_err(std::io::Error::other)?
}
/// Lists broken imports, optionally restricted to changed consumers or providers.
pub(crate) fn problems(
plugins: &BTreeMap<String, Interface>,
changed: Option<&[String]>,
) -> Vec<Value> {
let mut failures = Vec::new();
for (consumer, interface) in plugins {
if let Some(imports) = interface.document["x-sausage-imports"].as_object() {
for (provider, expected) in imports {
if changed
.is_some_and(|names| !names.contains(consumer) && !names.contains(provider))
{
continue;
}
let Some(actual) = plugins.get(provider) else {
failures.push(json!({"consumer":consumer,"provider":provider,"status":"missing","message":"the provider does not exist"}));
continue;
};
if let Some(error) = &actual.error {
failures.push(json!({"consumer":consumer,"provider":provider,"status":"unavailable","message":"discovery failed","error":error}));
continue;
}
for problem in sausage_interface::compat::check(expected, &actual.document) {
failures.push(json!({"consumer":consumer,"provider":provider,"status":"broken","method":problem.method,"message":problem.message,"counterexample":problem.counterexample,"display":problem.to_string()}));
}
}
}
}
failures
}
impl Host {
/// The cached JSON graph. This does not perform filesystem or RPC operations.
pub(crate) fn graph_json(&self) -> Vec<u8> {
self.0.graph.lock().unwrap().json.clone()
}
/// The cached DOT graph, with an edge for every import.
pub(crate) fn graph_dot(&self) -> Vec<u8> {
self.0.graph.lock().unwrap().dot.clone()
}
/// Plugin names currently present in the discovery cache.
pub(crate) fn discovered_plugins(&self) -> Vec<String> {
self.0
.graph
.lock()
.unwrap()
.plugins
.keys()
.cloned()
.collect()
}
/// The current document, if discovery succeeded.
pub(crate) fn discover_document(&self, plugin: &str) -> Option<Value> {
self.0
.graph
.lock()
.unwrap()
.plugins
.get(plugin)
.filter(|entry| entry.error.is_none())
.map(|entry| entry.document.clone())
}
/// Discovers a private copy and caches successful documents by file hash.
pub(crate) async fn discover_copy(
&self,
plugin: &str,
path: &Path,
hash: String,
) -> Result<Interface, Value> {
let request = json!(uuid::Uuid::now_v7().to_string());
let discovery = self.route_from(
"core".into(),
Some(request.clone()),
format!("{plugin}.rpc.discover"),
json!({}),
true,
Some(path.to_path_buf()),
);
let result = tokio::time::timeout(std::time::Duration::from_secs(10), discovery).await;
let processes: Vec<_> = self
.0
.state
.lock()
.unwrap()
.processes
.iter()
.filter(|(_, process)| process.id.as_ref() == Some(&request))
.map(|(pid, _)| pid.clone())
.collect();
for pid in processes {
self.begin_stop(&pid);
}
let document = result
.map_err(|_| crate::error(-32003, "plugin discovery timed out after 10 seconds"))??;
if !document["methods"].is_array() {
return Err(crate::error(
-32003,
"rpc.discover did not return an OpenRPC document",
));
}
Ok(Interface {
hash,
document,
error: None,
})
}
/// Refreshes every installed plugin while the caller holds the activation mutex.
pub(crate) async fn refresh_graph(&self) -> RpcResult {
self.refresh_graph_except(&[]).await
}
pub(crate) async fn refresh_graph_except(&self, candidates: &[String]) -> RpcResult {
let mut directory = tokio::fs::read_dir(self.0.world.root.join("plugins"))
.await
.map_err(io_error)?;
let mut plugins = BTreeMap::new();
while let Some(entry) = directory.next_entry().await.map_err(io_error)? {
if !entry.file_type().await.map_err(io_error)?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let source = entry.path().join("plugin.wasm");
let (bytes, hash) = match read_hashed(source).await {
Ok(snapshot) => snapshot,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
plugins.insert(
name,
Interface {
hash: String::new(),
document: Value::Null,
error: Some(io_error(error)),
},
);
continue;
}
};
let previous = self.0.graph.lock().unwrap().plugins.get(&name).cloned();
let interface = if candidates.contains(&name) {
previous
.filter(|entry| entry.hash == hash)
.unwrap_or(Interface {
hash,
document: Value::Null,
error: None,
})
} else if let Some(previous) =
previous.filter(|entry| entry.hash == hash && entry.error.is_none())
{
previous
} else {
let copy = entry
.path()
.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
let result = match tokio::fs::write(&copy, &bytes).await {
Ok(()) => self.discover_copy(&name, &copy, hash.clone()).await,
Err(error) => Err(io_error(error)),
};
let _ = tokio::fs::remove_file(&copy).await;
match result {
Ok(interface) => interface,
Err(error) => Interface {
hash,
document: Value::Null,
error: Some(error),
},
}
};
plugins.insert(name, interface);
}
let admission = self.0.admission.write().await;
let old_plugins = self.0.graph.lock().unwrap().plugins.clone();
let mut upgrades = Vec::new();
for (plugin, current) in &plugins {
if let Some(old) = old_plugins.get(plugin).filter(|old| {
!old.hash.is_empty() && old.hash != current.hash && current.error.is_none()
}) {
upgrades.push(self.snapshot_upgrade(plugin, old.hash.clone(), &current.hash));
}
}
self.publish_graph(plugins);
drop(admission);
for upgrade in upgrades {
self.begin_upgrade(upgrade);
}
Ok(json!({}))
}
/// Rebuilds serialized snapshots after a watcher refresh or an activation.
pub(crate) fn publish_graph(&self, plugins: BTreeMap<String, Interface>) {
let failures = problems(&plugins, None);
for failure in &failures {
self.0
.journal
.write("core", "core", "core", &format!("broken import {failure}"));
}
let mut entries = serde_json::Map::new();
let mut dot = String::from("digraph plugins {\n");
for (name, interface) in &plugins {
dot.push_str(&format!(" {};\n", json!(name)));
let mut imports = serde_json::Map::new();
for provider in interface.document["x-sausage-imports"]
.as_object()
.into_iter()
.flat_map(|imports| imports.keys())
{
let issues: Vec<_> = failures
.iter()
.filter(|problem| {
problem["consumer"] == *name && problem["provider"] == *provider
})
.cloned()
.collect();
let status = issues
.first()
.and_then(|problem| problem["status"].as_str())
.unwrap_or("compatible");
imports.insert(provider.clone(), json!({"status":status,"problems":issues}));
dot.push_str(&format!(
" {} -> {} [label={}];\n",
json!(name),
json!(provider),
json!(status)
));
}
entries.insert(
name.clone(),
json!({"hash":interface.hash,"imports":imports,"error":interface.error}),
);
}
dot.push_str("}\n");
let mut graph = self.0.graph.lock().unwrap();
graph.json = serde_json::to_vec(&json!({"plugins":entries})).unwrap();
graph.dot = dot.into_bytes();
graph.plugins = plugins;
}
}
/// Converts a filesystem failure into a JSON-RPC internal error.
pub(crate) fn io_error(error: std::io::Error) -> Value {
crate::error(-32603, &error.to_string())
}
-170
View File
@@ -1,170 +0,0 @@
//! Host startup, boot calls, and the public library API.
use crate::{RpcResult, components, journal, runtime::Runtime, world::World};
use crate::{
locks::Lock,
native::NativePlugin,
process::{Process, ProcessInfo},
};
use serde_json::{Value, json};
use std::{
collections::HashMap,
path::Path,
sync::{Arc, Mutex},
};
use tokio::sync::watch;
use uuid::Uuid;
pub(crate) struct State {
pub(crate) processes: HashMap<String, Process>,
pub(crate) locks: HashMap<String, Lock>,
pub(crate) native: HashMap<String, NativePlugin>,
pub(crate) services: Vec<crate::services::Service>,
}
pub(crate) struct Inner {
pub(crate) world: World,
pub(crate) runtime: Arc<Runtime>,
pub(crate) state: Mutex<State>,
pub(crate) journal: Arc<journal::Journal>,
pub(crate) notifications: tokio::sync::broadcast::Sender<Value>,
pub(crate) exit: watch::Sender<bool>,
pub(crate) loop_supervised: bool,
pub(crate) restart_pending: std::sync::atomic::AtomicBool,
pub(crate) components: components::Components,
/// Calls hold it for reading from the choice of a version until their
/// process is in the table. Activation holds it for writing while it
/// installs a version, so that it sees every process of the old version.
pub(crate) admission: tokio::sync::RwLock<()>,
/// Set when the host starts to stop. After that, the core starts no new
/// processes, and upgrade tasks do nothing.
pub(crate) shutdown: std::sync::atomic::AtomicBool,
pub(crate) run: sausage_vfs::Memory,
pub(crate) watches: crate::watches::Watches,
pub(crate) observer: sausage_vfs::observe::Observer,
pub(crate) graph: Mutex<crate::graph::Graph>,
pub(crate) activations: tokio::sync::Mutex<()>,
pub(crate) watcher: Mutex<Option<notify::RecommendedWatcher>>,
pub(crate) retirements: Mutex<std::collections::HashSet<(String, String)>>,
}
#[derive(Clone)]
/// A cloneable handle to a world and its running processes.
pub struct Host(pub(crate) Arc<Inner>);
impl Host {
/// Start a world with the default compile cache and its ordered boot calls.
pub async fn start(path: &Path) -> anyhow::Result<Self> {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
.join(".cache")
});
Self::start_with_cache(path, &base.join("sausage")).await
}
/// Start a world with an explicit compile cache folder.
pub async fn start_with_cache(path: &Path, cache: &Path) -> anyhow::Result<Self> {
let path = path.to_path_buf();
let cargo_cache = cache.to_path_buf();
let cache = cache.to_path_buf();
let (world, runtime) = tokio::task::spawn_blocking(move || -> anyhow::Result<_> {
Ok((World::open(&path)?, Runtime::new(&cache)?))
})
.await??;
let journal = journal::Journal::open_with_legacy(
world.root.join("logs/core/journal.sqlite"),
Some(world.state_dir("core").join("journal.sqlite")),
)
.await?;
let host = Self::with_parts(world, runtime, journal);
let cargo_world = host.0.world.clone();
let cargo = tokio::task::spawn_blocking(move || {
crate::cargo::Cargo::new(cargo_world, &cargo_cache)
})
.await??;
cargo.register(&host);
host.boot().await?;
Ok(host)
}
/// Creates an empty test world with a shared engine and an in-memory journal.
pub async fn test_world(path: &Path, runtime: Arc<Runtime>) -> anyhow::Result<Self> {
std::fs::create_dir_all(path)?;
Ok(Self::with_parts(
World::open(path)?,
runtime,
journal::Journal::memory().await?,
))
}
fn with_parts(world: World, runtime: Arc<Runtime>, journal: journal::Journal) -> Self {
let watches = crate::watches::Watches::default();
let events = watches.clone();
let observer =
sausage_vfs::observe::Observer::new(move |path| crate::watches::changed(&events, path));
Self(Arc::new(Inner {
world,
runtime,
journal: Arc::new(journal),
state: Mutex::new(State {
processes: HashMap::new(),
locks: HashMap::new(),
native: HashMap::new(),
services: Vec::new(),
}),
notifications: tokio::sync::broadcast::channel(1024).0,
exit: watch::channel(false).0,
loop_supervised: crate::restart::enabled(std::env::var_os("SAUSAGE_LOOP").as_deref()),
restart_pending: std::sync::atomic::AtomicBool::new(false),
components: components::Components::default(),
admission: tokio::sync::RwLock::new(()),
shutdown: std::sync::atomic::AtomicBool::new(false),
run: sausage_vfs::Memory::default(),
watches,
observer,
graph: Mutex::new(crate::graph::Graph::default()),
activations: tokio::sync::Mutex::new(()),
watcher: Mutex::new(None),
retirements: Mutex::new(std::collections::HashSet::new()),
}))
}
/// Register a native plugin for subsequent calls.
pub fn register_native(&self, name: &str, plugin: NativePlugin) {
self.0
.state
.lock()
.unwrap()
.native
.insert(name.into(), plugin);
}
/// Subscribe to notifications addressed to the console.
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Value> {
self.0.notifications.subscribe()
}
/// Snapshot the metadata of each process that has not ended.
pub fn processes(&self) -> HashMap<String, ProcessInfo> {
self.0
.state
.lock()
.unwrap()
.processes
.iter()
.map(|(p, s)| (p.clone(), s.info.clone()))
.collect()
}
/// Make a console-side call and wait for its JSON result.
pub async fn call(&self, method: &str, params: Value) -> RpcResult {
self.route(
"console".into(),
Some(json!(Uuid::now_v7().to_string())),
method.into(),
params,
false,
)
.await
}
/// Wait until core.restart (or its alias core.exit) requests shutdown after its response grace.
pub async fn exit_requested(&self) {
let mut exit = self.0.exit.subscribe();
let _ = exit.wait_for(|requested| *requested).await;
}
/// Return whether a restart has reached the normal shutdown path.
pub fn restart_requested(&self) -> bool {
*self.0.exit.borrow()
}
}
-411
View File
@@ -1,411 +0,0 @@
//! A dedicated SQLite writer batches entries and serves ordered queries without propagating disk failures into the host.
use rusqlite::{Connection, OpenFlags, params};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::io::Write;
use std::{
collections::HashSet,
path::PathBuf,
sync::{Mutex, mpsc},
};
use tokio::sync::oneshot;
#[derive(Debug, Serialize, Deserialize)]
struct Entry {
time: String,
id: String,
pid: String,
plugin: String,
stream: String,
line: String,
}
enum Command {
Write(Entry),
Query(Value, oneshot::Sender<Value>),
}
pub(crate) struct Journal {
sender: mpsc::Sender<Command>,
warned: Mutex<HashSet<String>>,
spool: PathBuf,
}
impl Journal {
pub(crate) async fn memory() -> anyhow::Result<Self> {
Self::open(PathBuf::from(":memory:")).await
}
pub(crate) async fn open(path: PathBuf) -> anyhow::Result<Self> {
Self::open_with_legacy(path, None).await
}
pub(crate) async fn open_with_legacy(
path: PathBuf,
legacy: Option<PathBuf>,
) -> anyhow::Result<Self> {
let spool = path
.parent()
.unwrap_or(std::path::Path::new("."))
.join("failed-batches");
let worker_spool = spool.clone();
let (sender, receiver) = mpsc::channel();
let (ready, initialized) = oneshot::channel();
std::thread::spawn(move || {
match (|| -> anyhow::Result<_> {
let db = open_database(path)?;
// Never rename, checkpoint, or write the old SQLite/WAL files.
let old = legacy
.filter(|p| p.exists())
.map(|p| Connection::open_with_flags(p, OpenFlags::SQLITE_OPEN_READ_ONLY))
.transpose()?;
Ok((db, old))
})() {
Ok((mut db, old)) => {
let _ = ready.send(Ok(()));
let mut pending = Vec::new();
while let Ok(first) = receiver.recv() {
let mut batch = vec![first];
batch.extend(receiver.try_iter().take(255));
pending = write_batch(&mut db, old.as_ref(), &worker_spool, pending, batch);
}
if !pending.is_empty() {
// Both storage locations failed. Last resort: retain full text in host stderr.
eprintln!(
"journal shutdown with unpersisted entries: {}",
serde_json::to_string(&pending).unwrap()
);
}
}
Err(error) => {
eprintln!("journal open failed: {error}");
let _ = ready.send(Err(error));
}
}
});
initialized.await??;
Ok(Self {
sender,
warned: Mutex::new(HashSet::new()),
spool,
})
}
pub(crate) fn write(&self, pid: &str, plugin: &str, stream: &str, line: &str) {
let entry = Entry {
time: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
id: uuid::Uuid::now_v7().to_string(),
pid: pid.into(),
plugin: plugin.into(),
stream: stream.into(),
line: line.into(),
};
if let Err(mpsc::SendError(Command::Write(entry))) = self.sender.send(Command::Write(entry))
{
eprintln!("journal writer stopped; saving entry outside SQLite");
if let Err(error) = spool_batch(&self.spool, std::slice::from_ref(&entry)) {
eprintln!(
"journal spool failed: {error}; unpersisted entry: {}",
serde_json::to_string(&entry).unwrap()
);
}
}
}
pub(crate) fn warning(&self, pid: &str, plugin: &str) {
if self.warned.lock().unwrap().insert(pid.into()) {
self.write(pid, plugin, "core", "warning: discarded stdout");
}
}
pub(crate) fn forget(&self, pid: &str) {
self.warned.lock().unwrap().remove(pid);
}
pub(crate) fn query_later(&self, filters: Value) -> oneshot::Receiver<Value> {
let (tx, rx) = oneshot::channel();
if self.sender.send(Command::Query(filters, tx)).is_err() {
eprintln!("journal writer stopped");
}
rx
}
pub(crate) async fn query(&self, filters: Value) -> Value {
self.query_later(filters)
.await
.unwrap_or_else(|_| json!([]))
}
}
fn open_database(path: PathBuf) -> anyhow::Result<Connection> {
if path != std::path::Path::new(":memory:") {
std::fs::create_dir_all(path.parent().unwrap())?;
}
let db = Connection::open(path)?;
db.execute_batch("PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
CREATE TABLE IF NOT EXISTS journal (id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT);
CREATE INDEX IF NOT EXISTS journal_pid ON journal(pid);
CREATE INDEX IF NOT EXISTS journal_plugin ON journal(plugin);")?;
Ok(db)
}
/// Exclusive, synced JSON files preserve failed transactions without a busy retry loop.
/// Recovery is deliberate: files are never automatically removed or replayed.
fn spool_batch(dir: &std::path::Path, entries: &[Entry]) -> anyhow::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{}.json", uuid::Uuid::now_v7()));
// A failed/partial write must not look like a complete recovery batch.
let mut file = tempfile::NamedTempFile::new_in(dir)?;
serde_json::to_writer(&mut file, entries)?;
file.write_all(b"\n")?;
file.as_file().sync_all()?;
file.persist_noclobber(&path)?;
std::fs::File::open(dir)?.sync_all()?;
eprintln!(
"journal failed batch preserved: {} ({} entries)",
path.display(),
entries.len()
);
Ok(path)
}
fn write_batch(
db: &mut Connection,
old: Option<&Connection>,
spool: &std::path::Path,
mut pending: Vec<Entry>,
batch: Vec<Command>,
) -> Vec<Entry> {
let mut queries = Vec::new();
for command in batch {
match command {
Command::Write(entry) => pending.push(entry),
Command::Query(filters, response) => queries.push((filters, response)),
}
}
if !pending.is_empty() {
let write = (|| -> rusqlite::Result<()> {
let transaction = db.transaction()?;
for e in &pending {
transaction.execute("INSERT OR IGNORE INTO journal (id,time,pid,plugin,stream,line) VALUES (?1,?2,?3,?4,?5,?6)",params![e.id,e.time,e.pid,e.plugin,e.stream,e.line])?;
}
transaction.commit()
})();
match write {
Ok(()) => pending.clear(),
Err(error) => {
eprintln!("journal write failed: {error}");
match spool_batch(spool, &pending) {
Ok(_) => pending.clear(),
Err(error) => eprintln!(
"journal spool failed: {error}; retaining {} entries in memory until next command",
pending.len()
),
}
}
}
}
for (filters, response) in queries {
let value = query_all(db, old, &filters).unwrap_or_else(|error| {
eprintln!("journal query failed: {error}");
json!([])
});
let _ = response.send(value);
}
pending
}
fn query_all(
db: &Connection,
old: Option<&Connection>,
filters: &Value,
) -> rusqlite::Result<Value> {
let mut entries = query_database(db, filters)?.as_array().unwrap().clone();
if let Some(old) = old {
entries.extend(
query_database(old, filters)?
.as_array()
.unwrap()
.iter()
.cloned(),
);
entries.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
entries.dedup_by(|a, b| a["id"] == b["id"]);
let limit = filters["limit"].as_i64().unwrap_or(100);
if limit >= 0 {
entries.drain(..entries.len().saturating_sub(limit as usize));
}
}
Ok(json!(entries))
}
fn query_database(db: &Connection, filters: &Value) -> rusqlite::Result<Value> {
let mut statement = db.prepare(
"SELECT id,time,pid,plugin,stream,line FROM journal
WHERE (?1 IS NULL OR plugin=?1) AND (?2 IS NULL OR pid=?2)
AND (?3 IS NULL OR id>=?3) AND (?4 IS NULL OR id<=?4)
AND (?5 IS NULL OR instr(line,?5)>0) ORDER BY id DESC LIMIT ?6",
)?;
let rows=statement.query_map(params![filters["plugin"].as_str(),filters["pid"].as_str(),filters["since"].as_str(),filters["until"].as_str(),filters["grep"].as_str(),filters["limit"].as_i64().unwrap_or(100)],|row| Ok(json!({
"id":row.get::<_,String>(0)?,"time":row.get::<_,String>(1)?,"pid":row.get::<_,String>(2)?,"plugin":row.get::<_,String>(3)?,"stream":row.get::<_,String>(4)?,"line":row.get::<_,String>(5)?,
})))?;
let mut entries: Vec<_> = rows.collect::<rusqlite::Result<_>>()?;
entries.reverse();
Ok(json!(entries))
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(line: &str) -> Entry {
Entry {
id: uuid::Uuid::now_v7().to_string(),
time: "2026-10-26T12:00:00.000Z".into(),
pid: "pid".into(),
plugin: "test".into(),
stream: "core".into(),
line: line.into(),
}
}
#[test]
fn failed_transaction_is_spooled_in_full_and_queries_finish() {
let dir = tempfile::tempdir().unwrap();
let mut db = open_database(PathBuf::from(":memory:")).unwrap();
db.execute_batch("PRAGMA query_only=ON").unwrap();
let original = "😀 full failure\n".repeat(6000);
let (tx, mut rx) = oneshot::channel();
let pending = write_batch(
&mut db,
None,
dir.path(),
vec![],
vec![
Command::Write(entry(&original)),
Command::Query(json!({}), tx),
],
);
assert!(pending.is_empty());
assert_eq!(rx.try_recv().unwrap(), json!([]));
let path = std::fs::read_dir(dir.path())
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
let saved: Vec<Entry> = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].line, original);
assert_eq!(saved[0].time, "2026-10-26T12:00:00.000Z");
}
#[test]
fn failed_spool_retries_on_next_command_without_duplicates() {
let dir = tempfile::tempdir().unwrap();
let blocked = dir.path().join("blocked");
std::fs::write(&blocked, "not a directory").unwrap();
let mut db = open_database(PathBuf::from(":memory:")).unwrap();
db.execute_batch("PRAGMA query_only=ON").unwrap();
let pending = write_batch(
&mut db,
None,
&blocked,
vec![],
vec![Command::Write(entry("retained"))],
);
assert_eq!(pending.len(), 1);
db.execute_batch("PRAGMA query_only=OFF").unwrap();
let (tx, mut rx) = oneshot::channel();
let pending = write_batch(
&mut db,
None,
&blocked,
pending,
vec![Command::Query(json!({}), tx)],
);
assert!(pending.is_empty());
assert_eq!(rx.try_recv().unwrap()[0]["line"], "retained");
assert_eq!(
query_all(&db, None, &json!({}))
.unwrap()
.as_array()
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn legacy_wal_and_new_journal_share_filters_order_and_limit() {
let dir = tempfile::tempdir().unwrap();
let legacy = dir.path().join("state/core/journal.sqlite");
let mut old = open_database(legacy.clone()).unwrap();
write_batch(
&mut old,
None,
dir.path(),
vec![],
vec![Command::Write(entry("old match"))],
);
// Leave the WAL connection open: opening the new journal must not move its files.
let wal = legacy.with_extension("sqlite-wal");
let before = std::fs::read(&wal).unwrap();
let new = dir.path().join("logs/core/journal.sqlite");
let journal = Journal::open_with_legacy(new.clone(), Some(legacy.clone()))
.await
.unwrap();
journal.write("pid", "test", "core", "new match");
let entries = tokio::time::timeout(
std::time::Duration::from_secs(5),
journal.query(json!({"pid":"pid","grep":"match","limit":-1})),
)
.await
.unwrap();
assert_eq!(entries.as_array().unwrap().len(), 2);
assert_eq!(entries[0]["line"], "old match");
assert_eq!(entries[1]["line"], "new match");
assert_eq!(
journal.query(json!({"limit":1})).await[0]["line"],
"new match"
);
assert_eq!(
journal.query(json!({"until":entries[0]["id"]})).await[0]["line"],
"old match"
);
assert_eq!(
journal
.query(json!({"since":entries[1]["id"],"plugin":"test"}))
.await[0]["line"],
"new match"
);
assert_eq!(journal.query(json!({"grep":"absent"})).await, json!([]));
assert_eq!(std::fs::read(wal).unwrap(), before);
assert!(legacy.exists() && new.exists());
assert_eq!(
query_database(&old, &json!({}))
.unwrap()
.as_array()
.unwrap()
.len(),
1
);
}
/// PROTOCOL 7: completed processes do not leave warning identities in memory.
#[tokio::test]
async fn completion_forgets_warning_identity() {
let folder = std::env::temp_dir().join(format!("sausage-warning-{}", uuid::Uuid::now_v7()));
crate::world::World::create(&folder).unwrap();
let host = crate::Host::start_with_cache(&folder, &folder.join("cache"))
.await
.unwrap();
host.register_native(
"warn",
std::sync::Arc::new(|ctx, _, _| {
Box::pin(async move {
ctx.host.output(&ctx.pid, "not JSON".into());
Ok(json!(ctx.pid))
})
}),
);
let pid = host.call("warn.once", json!({})).await.unwrap();
host.call("core.stop", json!({"pid":pid})).await.unwrap();
let retained = host
.0
.journal
.warned
.lock()
.unwrap()
.contains(pid.as_str().unwrap());
host.stop().await;
std::fs::remove_dir_all(folder).unwrap();
assert!(!retained);
}
}
+74 -27
View File
@@ -1,31 +1,78 @@
//! A host for worlds of JSON-RPC plugins. //! Sausage: grow a system of WASI actors by talking to it.
mod activation; //!
pub mod cargo; //! The host runs the broker, the native actors (`broker`, `cargo`, and the
mod components; //! console), and one supervisor per WASM actor. See `PROTOCOL.md`.
pub mod broker;
pub mod candidate;
pub mod cargo_actor;
pub mod console; pub mod console;
mod core;
mod execution;
mod filesystem;
mod graph;
mod host;
mod journal;
mod locks;
mod native;
mod process;
mod restart;
mod routing;
pub mod runtime; pub mod runtime;
mod services; pub mod supervisor;
mod upgrades; pub mod vfs;
mod watcher;
mod watches;
pub mod world; pub mod world;
pub use host::Host;
pub use native::{NativeContext, NativeFuture, NativePlugin}; use std::path::Path;
pub use process::ProcessInfo; use std::sync::Arc;
use serde_json::{Value, json};
/// A JSON result or a JSON-RPC error object. use anyhow::{Result, bail};
pub type RpcResult = Result<Value, Value>; use tokio::sync::mpsc;
fn error(code: i64, text: &str) -> Value {
json!({"code":code,"message":text}) pub use broker::{Broker, Envelope};
pub use world::World;
/// The contract between the host and the actors. The host publishes it at
/// `/actor/broker/public/PROTOCOL.md`, so that every actor can read it.
const PROTOCOL: &str = include_str!("../../PROTOCOL.md");
/// A running world.
pub struct Host {
pub world: World,
pub broker: Arc<Broker>,
native: Vec<tokio::task::JoinHandle<()>>,
_lock: std::fs::File,
}
impl Host {
/// Start the broker, `cargo`, and every registered actor of the world at
/// `root`. Only one host can run a world at a time.
pub async fn start(root: &Path) -> Result<Host> {
let world = World::new(std::path::absolute(root)?);
if !world.exists() {
bail!("{} is not a Sausage world. Create one with: sausage init", root.display());
}
let lock = std::fs::File::create(world.root.join(".lock"))?;
if lock.try_lock().is_err() {
bail!("another sausage process runs {}", root.display());
}
let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?;
world::write_atomic(&world.public_dir("broker").join("PROTOCOL.md"), PROTOCOL.as_bytes())?;
let cargo = cargo_actor::Cargo::new(world.clone())?;
let native = vec![
tokio::spawn(broker.clone().run_actor(broker.open_mailbox("broker"))),
tokio::spawn(cargo.run_actor(broker.clone(), broker.open_mailbox(cargo_actor::ID))),
];
for change in broker.scan().await {
eprintln!("sausage: {change}");
}
Ok(Host { world, broker, native, _lock: lock })
}
/// Register a native actor with the ID `id` and return its mailbox.
pub fn mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
self.broker.open_mailbox(id)
}
/// Stop the broker and `cargo` (with the requests they still run), then
/// every actor, gracefully.
pub async fn stop(self) {
for task in &self.native {
task.abort();
}
for task in self.native {
let _ = task.await;
}
self.broker.shutdown_all().await;
}
} }
-90
View File
@@ -1,90 +0,0 @@
//! Advisory FIFO locks and cleanup of ended processes.
use crate::error;
use crate::{Host, RpcResult, host::State};
use serde_json::json;
use std::collections::VecDeque;
use tokio::sync::oneshot;
pub(crate) struct Lock {
pub(crate) owner: String,
pub(crate) queue: VecDeque<(String, oneshot::Sender<RpcResult>)>,
}
impl Host {
/// Acquire a FIFO queue position, or report whether the lock is free.
pub(crate) async fn lock(&self, caller: &str, path: &str, wait: bool) -> RpcResult {
let rx = {
let mut state = self.0.state.lock().unwrap();
if caller != "console"
&& caller != "core"
&& !state
.processes
.get(caller)
.is_some_and(|p| p.info.state == "running")
{
return Err(error(-32600, "process is stopping or ended"));
}
if let Some(lock) = state.locks.get_mut(path) {
if !wait {
return Ok(json!({"held":false}));
}
let (tx, rx) = oneshot::channel();
lock.queue.push_back((caller.into(), tx));
Some(rx)
} else {
state.locks.insert(
path.into(),
Lock {
owner: caller.into(),
queue: VecDeque::new(),
},
);
None
}
};
match rx {
Some(rx) => rx
.await
.unwrap_or_else(|_| Err(error(-32603, "lock canceled"))),
None => Ok(json!({"held":true})),
}
}
/// Release a held lock only when this process owns it.
pub(crate) fn unlock(&self, caller: &str, path: &str) {
let mut state = self.0.state.lock().unwrap();
if state
.locks
.get(path)
.is_some_and(|lock| lock.owner == caller)
{
grant(&mut state, path);
}
}
/// Remove queued acquisitions and grant the next owner of each held lock.
pub(crate) fn release_locks(state: &mut State, pid: &str) {
for lock in state.locks.values_mut() {
lock.queue.retain(|(p, _)| p != pid);
}
let paths: Vec<_> = state
.locks
.iter()
.filter(|(_, l)| l.owner == pid)
.map(|(p, _)| p.clone())
.collect();
for path in paths {
grant(state, &path);
}
}
}
pub(crate) fn grant(state: &mut State, path: &str) {
if let Some(mut lock) = state.locks.remove(path) {
while let Some((owner, tx)) = lock.queue.pop_front() {
if tx.send(Ok(json!({"held":true}))).is_ok() {
lock.owner = owner;
state.locks.insert(path.into(), lock);
break;
}
}
}
}
+87 -159
View File
@@ -1,190 +1,118 @@
//! Command parsing and the minimal line console. use std::io::Write;
use anyhow::{Result, bail}; use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use include_dir::{Dir, include_dir}; use include_dir::{Dir, include_dir};
use sausage::{Host, world::World}; use serde_json::json;
use std::path::{Path, PathBuf};
use sausage::world::{ActorMeta, World};
use sausage::{Host, cargo_actor, console};
/// The source of the agent actor. `init` builds it like any other actor.
static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent");
static AGENT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent/src");
mod test_runner;
#[derive(Parser)] #[derive(Parser)]
#[command(version)] #[command(version, about)]
struct Cli { struct Cli {
#[arg(long, global = true)]
headless: bool,
#[arg(long, global = true)]
cache_dir: Option<PathBuf>,
#[command(subcommand)] #[command(subcommand)]
command: Option<Cmd>, command: Cmd,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
enum Cmd { enum Cmd {
/// Create a new world and build the agent actor.
///
/// Reads SAUSAGE_API, SAUSAGE_BASE_URL, SAUSAGE_MODEL and
/// SAUSAGE_API_TOKEN, and asks for every value that is missing.
Init { Init {
/// Folder of the new world.
#[arg(default_value = "world")] #[arg(default_value = "world")]
dir: PathBuf, dir: PathBuf,
}, },
/// Start the broker, all actors, and the console.
Run { Run {
#[arg(default_value = "world")] #[arg(default_value = "world")]
dir: PathBuf, dir: PathBuf,
}, },
/// Run a WASI libtest component in isolated instances.
TestRunner {
wasm: PathBuf,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
} }
fn headless(value: &str) -> std::result::Result<bool, String> {
match value.to_ascii_lowercase().as_str() { /// The value of `var`, or the answer to `question`.
"1" | "true" => Ok(true), fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
"" | "0" | "false" => Ok(false), if let Ok(v) = std::env::var(var)
_ => Err("expected 1, true, 0, false, or an empty value".into()), && !v.is_empty()
{
return Ok(v);
}
match default {
Some(d) => print!("{question} [{d}]: "),
None => print!("{question}: "),
}
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
match (line.trim(), default) {
("", Some(d)) => Ok(d.to_string()),
("", None) => bail!("{question} is required (or set {var})"),
(v, _) => Ok(v.to_string()),
} }
} }
// Readline can still wait on its detached thread when the host exits. async fn init(dir: PathBuf) -> Result<()> {
struct TerminalMode(Option<nix::sys::termios::Termios>); let api = setting("SAUSAGE_API", "API shape (openai-responses or anthropic-messages)", Some("openai-responses"))?;
impl Drop for TerminalMode { if api != "openai-responses" && api != "anthropic-messages" {
fn drop(&mut self) { bail!("unknown API shape {api:?}");
if let Some(mode) = &self.0 {
let _ = nix::sys::termios::tcsetattr(
std::io::stdin(),
nix::sys::termios::SetArg::TCSANOW,
mode,
);
} }
} let base_url = setting("SAUSAGE_BASE_URL", "API base URL", None)?;
} let model = setting("SAUSAGE_MODEL", "Model", None)?;
let token = setting("SAUSAGE_API_TOKEN", "API token", None)?;
/// Creates a world with editable configuration and builds its embedded agent. let world = World::create(&dir)?;
async fn init(dir: &Path, cache: Option<&Path>) -> Result<()> { let console = ActorMeta { subscriptions: ["agent".to_string()].into(), ..world.read_meta(console::ID) };
if dir.exists() { world.write_meta(console::ID, &console)?;
bail!("world already exists: {}", dir.display());
} // The agent's configuration, in its /data.
let parent = dir world.ensure_actor_dirs("agent")?;
.parent() let config = json!({"api": api, "base_url": base_url.trim_end_matches('/'), "model": model, "token": token});
.filter(|p| !p.as_os_str().is_empty()) let config_path = world.data_dir("agent").join("config.json");
.unwrap_or(Path::new(".")); std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
std::fs::create_dir_all(parent)?;
let staging = tempfile::Builder::new() // The agent is an ordinary actor folder: `run` finds its `actor.wasm`,
.prefix(".sausage-init-") // tests it, and installs it as version 1.
.tempdir_in(parent)?; let source = world.source_dir("agent");
let world = World::create(&staging.path().join("world"))?; std::fs::create_dir_all(&source)?;
std::fs::write( AGENT_SOURCE.extract(&source).context("extract agent source")?;
world.root.join("config/agent.yaml"), println!("Building the agent actor (the first build takes a few minutes) …");
include_str!("agent.yaml"), let out = cargo_actor::Cargo::new(world.clone())?.build(&source).await.context("build agent")?;
)?; std::fs::copy(&out.wasm, world.wasm_path("agent"))?;
std::fs::write( println!("Built in {:.1} s. Start the world with: sausage run {}", out.seconds, dir.display());
world.root.join("config/boot.yaml"),
include_str!("boot.yaml"),
)?;
let cache = cache.map(Path::to_path_buf).unwrap_or_else(|| {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache")
});
base.join("sausage")
});
let builder = sausage::cargo::Cargo::new(world.clone(), &cache)?;
let source = world.plugin_dir("agent").join("source");
std::fs::create_dir_all(source.join("src"))?;
AGENT.extract(source.join("src"))?;
std::fs::write(
source.join("Cargo.toml"),
include_str!("../../agent/Cargo.toml"),
)?;
std::fs::write(
source.join("Cargo.lock"),
include_bytes!("../../Cargo.lock"),
)?;
println!("The first agent build takes a few minutes.");
let output = builder.build(&source).await?;
std::fs::copy(output.wasm, world.plugin_dir("agent").join("plugin.wasm"))?;
std::fs::rename(&world.root, dir)?;
println!(
"Edit {} before chatting.",
dir.join("config/agent.yaml").display()
);
println!("Start the world with: sausage run {}", dir.display());
Ok(()) Ok(())
} }
#[tokio::main]
async fn main() -> Result<std::process::ExitCode> { async fn run(dir: PathBuf) -> Result<()> {
let cli = Cli::parse(); let host = Host::start(&dir).await?;
let _terminal = TerminalMode(nix::sys::termios::tcgetattr(std::io::stdin()).ok()); tokio::spawn(console::run_mailbox(host.mailbox(console::ID)));
let command = match cli.command { let broker = host.broker.clone();
Some(command) => command,
None => {
let dir = PathBuf::from("world");
if !dir.join("config/boot.yaml").is_file() {
init(&dir, cli.cache_dir.as_deref()).await?;
}
Cmd::Run { dir }
}
};
match command {
Cmd::TestRunner { wasm, args } => {
if !test_runner::run(&wasm, args).await? {
return Ok(std::process::ExitCode::FAILURE);
}
}
Cmd::Init { dir } => init(&dir, cli.cache_dir.as_deref()).await?,
Cmd::Run { dir } => {
let headless = cli.headless
|| std::env::var("SAUSAGE_HEADLESS")
.ok()
.map(|value| headless(&value).map_err(anyhow::Error::msg))
.transpose()?
.unwrap_or(false);
let mut term =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let mut interrupt =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
let start = async {
match cli.cache_dir {
Some(cache) => Host::start_with_cache(&dir, &cache).await,
None => Host::start(&dir).await,
}
};
let host = tokio::select! {
result=start=>result?,
_=term.recv()=>return Ok(std::process::ExitCode::SUCCESS),
_=interrupt.recv()=>return Ok(std::process::ExitCode::SUCCESS),
};
let mut notifications = host.subscribe();
let notification_task = headless.then(|| {
tokio::spawn(async move {
loop {
match notifications.recv().await {
Ok(notification) => println!("{notification}"),
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
})
});
let console = sausage::console::run_input(host.clone());
let mut console_result = Ok(());
let exit = tokio::select! { let exit = tokio::select! {
_=host.exit_requested()=>true, _ = console::run_input(host.broker.clone()) => false,
_=term.recv()=>false, _ = broker.exit_requested() => true,
_=interrupt.recv(), if headless=>false,
result = console, if !headless => {
console_result=result;
false
},
}; };
let exit = exit || host.restart_requested(); println!("Stopping actors …");
host.stop().await; host.stop().await;
if let Some(task) = notification_task {
task.abort();
}
if exit { if exit {
return Ok(std::process::ExitCode::from(75)); // The console still blocks on a stdin read that cannot be cancelled,
// so the runtime would not shut down on its own. Exit code 75 tells
// `nix run .#loop` to start the host again (0 = /quit = stop the loop).
println!("Exiting, as an actor asked the broker to.");
std::process::exit(75);
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
match Cli::parse().command {
Cmd::Init { dir } => init(dir).await,
Cmd::Run { dir } => run(dir).await,
} }
console_result?;
}
}
Ok(std::process::ExitCode::SUCCESS)
} }
-35
View File
@@ -1,35 +0,0 @@
//! Native plugin futures and their process context.
use crate::{Host, RpcResult};
use serde_json::{Value, json};
use std::{future::Future, pin::Pin, sync::Arc};
use uuid::Uuid;
/// The cancellable future of a native process.
pub type NativeFuture = Pin<Box<dyn Future<Output = RpcResult> + Send>>;
/// A handler for the methods of one native plugin.
pub type NativePlugin = Arc<dyn Fn(NativeContext, String, Value) -> NativeFuture + Send + Sync>;
#[derive(Clone)]
/// The process identity and host access supplied to a native handler.
pub struct NativeContext {
/// The host that owns this native process.
pub host: Host,
/// The ID assigned to this native process.
pub pid: String,
}
impl NativeContext {
/// Make an outgoing call with this native process as its caller.
pub async fn call(&self, method: &str, params: Value) -> RpcResult {
self.host
.route(
self.pid.clone(),
Some(json!(Uuid::now_v7().to_string())),
method.into(),
params,
false,
)
.await
}
/// Send progress to this process's caller while its call remains unanswered.
pub fn notify(&self, name: &str, params: Value) {
self.host.notify(&self.pid, name, params);
}
}
-178
View File
@@ -1,178 +0,0 @@
//! Process metadata, process lifetime, and attached children.
use crate::{Host, RpcResult};
use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot, watch};
#[derive(Clone, serde::Serialize)]
/// A snapshot of process metadata for inspection and the VFS.
pub struct ProcessInfo {
/// The plugin that supplied the process code.
pub plugin: String,
/// The SHA-256 of its component, or native for a native plugin.
pub version: String,
/// The method without its plugin prefix.
pub method: String,
/// The object supplied by the caller.
pub params: Value,
/// The attached parent, or no parent for a detached process.
pub parent: Option<String>,
/// Whether this process survives the end of its caller.
pub detached: bool,
/// The start time in milliseconds since the Unix epoch.
pub start_time: u128,
/// The current lifecycle state.
pub state: String,
}
pub(crate) struct Process {
pub(crate) info: ProcessInfo,
pub(crate) memory: sausage_vfs::Memory,
pub(crate) files: sausage_vfs::Process,
pub(crate) store_ended: watch::Sender<bool>,
pub(crate) input: Option<mpsc::UnboundedSender<Value>>,
pub(crate) abort: Option<tokio::task::AbortHandle>,
pub(crate) caller: String,
pub(crate) id: Option<Value>,
pub(crate) answer: Option<oneshot::Sender<RpcResult>>,
pub(crate) ended: watch::Sender<bool>,
pub(crate) stopped: bool,
pub(crate) superseded: bool,
}
impl Host {
/// Called only after the execution task ends. Wait for the store and file operations before releasing locks.
pub(crate) async fn finish(&self, pid: &str, status: &str) {
let (files, mut store_ended) = {
let mut state = self.0.state.lock().unwrap();
let Some(p) = state.processes.get_mut(pid) else {
return;
};
p.info.state = "stopping".into();
(p.files.clone(), p.store_ended.subscribe())
};
let _ = store_ended.wait_for(|ended| *ended).await;
files.idle().await;
let mut state = self.0.state.lock().unwrap();
let process = state.processes.remove(pid);
if let Some(p) = process {
self.0.journal.write(pid, &p.info.plugin, "core", status);
let logs = p
.answer
.as_ref()
.map(|_| self.0.journal.query_later(json!({"pid":pid,"limit":20})));
self.0.journal.forget(pid);
Self::release_locks(&mut state, pid);
self.service_ended(&mut state, pid, status, p.stopped, p.superseded);
let status = status.to_string();
tokio::spawn(async move {
if let Some(answer) = p.answer {
let lines = logs.unwrap().await.unwrap_or_else(|_| json!([]));
let failure = json!({
"code": -32002,
"message": "process ended without an answer",
"data": { "status": status, "lines": lines }
});
let _ = answer.send(Err(failure));
}
p.ended.send_replace(true);
});
let children: Vec<_> = state
.processes
.iter()
.filter(|(_, p)| p.info.parent.as_deref() == Some(pid))
.map(|(id, _)| id.clone())
.collect();
drop(state);
for child in children {
self.begin_stop(&child);
}
}
}
/// Close stdin and start the same five-second deadline for each attached child.
pub(crate) fn begin_stop(&self, pid: &str) -> Option<watch::Receiver<bool>> {
self.stop_process(pid, false)
}
/// Grace expiry is retirement, not an operator stopping the service.
pub(crate) fn begin_retire(&self, pid: &str) -> Option<watch::Receiver<bool>> {
self.stop_process(pid, true)
}
fn stop_process(&self, pid: &str, retirement: bool) -> Option<watch::Receiver<bool>> {
let mut state = self.0.state.lock().unwrap();
let p = state.processes.get_mut(pid)?;
if !retirement {
p.superseded = false;
}
let ended = p.ended.subscribe();
if p.info.state == "stopping" {
return Some(ended);
}
p.info.state = "stopping".into();
p.stopped = true;
p.input.take();
self.0.journal.write(pid, &p.info.plugin, "core", "stop");
for lock in state.locks.values_mut() {
lock.queue.retain(|(queued, _)| queued != pid);
}
let children: Vec<_> = state
.processes
.iter()
.filter(|(_, p)| p.info.parent.as_deref() == Some(pid))
.map(|(id, _)| id.clone())
.collect();
drop(state);
for child in children {
self.begin_stop(&child);
}
let h = self.clone();
let pid = pid.to_string();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
h.kill_process(&pid, retirement);
});
Some(ended)
}
/// Abort a process task and perform its ended-state cleanup.
pub(crate) fn kill(&self, pid: &str) {
self.kill_process(pid, false);
}
fn kill_process(&self, pid: &str, retirement: bool) {
let mut state = self.0.state.lock().unwrap();
for lock in state.locks.values_mut() {
lock.queue.retain(|(queued, _)| queued != pid);
}
if let Some(p) = state.processes.get_mut(pid) {
if !retirement {
p.superseded = false;
}
p.stopped = true;
p.info.state = "stopping".into();
p.input.take();
self.0.journal.write(pid, &p.info.plugin, "core", "kill");
if let Some(a) = &p.abort {
a.abort();
}
}
}
/// Stop every process in parallel and wait for journal writes to finish.
pub async fn stop(&self) {
{
let _state = self.0.state.lock().unwrap();
self.0
.shutdown
.store(true, std::sync::atomic::Ordering::SeqCst);
}
loop {
let ids: Vec<_> = self.processes().into_keys().collect();
if ids.is_empty() {
break;
}
let mut waits = Vec::new();
for pid in ids {
if let Some(n) = self.begin_stop(&pid) {
waits.push(n);
}
}
for mut n in waits {
let _ = n.wait_for(|ended| *ended).await;
}
}
self.0.journal.query(json!({"limit":0})).await;
}
}
-129
View File
@@ -1,129 +0,0 @@
//! Supervisor-gated restart requests; the executable still owns normal shutdown.
use crate::{Host, RpcResult, error};
use serde_json::json;
use std::{ffi::OsStr, sync::atomic::Ordering, time::Duration};
pub(crate) fn enabled(value: Option<&OsStr>) -> bool {
value == Some(OsStr::new("1"))
}
impl Host {
pub(crate) fn request_restart(&self) -> RpcResult {
if !self.0.loop_supervised {
return Err(error(
-32600,
"restart needs the loop supervisor (SAUSAGE_LOOP not set); start with SAUSAGE_LOOP=1",
));
}
if !self.0.restart_pending.swap(true, Ordering::SeqCst) {
let host = self.clone();
tokio::spawn(async move {
// Let the RPC response reach the caller before closing any stdin,
// including that of a detached gateway carrying this response.
tokio::time::sleep(Duration::from_millis(250)).await;
host.0.exit.send_replace(true);
});
}
Ok(json!({}))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{runtime::Runtime, world::World};
use std::sync::Arc;
use tokio::time::timeout;
#[test]
fn only_explicit_loop_marker_enables_restart() {
assert!(!enabled(None));
for value in ["", "0", "true", "yes", "2"] {
assert!(!enabled(Some(OsStr::new(value))));
}
assert!(enabled(Some(OsStr::new("1"))));
}
async fn host(supervised: bool) -> (tempfile::TempDir, Host) {
let dir = tempfile::tempdir().unwrap();
World::create(&dir.path().join("world")).unwrap();
let runtime = Runtime::new(&dir.path().join("cache")).unwrap();
let mut host = Host::test_world(&dir.path().join("world"), runtime)
.await
.unwrap();
// No global environment mutation: parallel tests have independent gates.
Arc::get_mut(&mut host.0).unwrap().loop_supervised = supervised;
(dir, host)
}
#[tokio::test]
async fn restart_without_supervisor_never_triggers_exit() {
timeout(Duration::from_secs(10), async {
let (_dir, host) = host(false).await;
for method in ["core.restart", "core.exit"] {
let error = host.call(method, json!({})).await.unwrap_err();
assert_eq!(error["code"], -32600);
assert!(
error["message"]
.as_str()
.unwrap()
.contains("SAUSAGE_LOOP not set")
);
let indirect = host
.call("core.call", json!({"method":method}))
.await
.unwrap_err();
assert_eq!(indirect, error);
}
assert!(!host.0.restart_pending.load(Ordering::SeqCst));
assert!(
timeout(Duration::from_millis(300), host.exit_requested())
.await
.is_err()
);
host.stop().await;
})
.await
.unwrap();
}
#[tokio::test]
async fn restart_and_exit_share_response_grace_and_coalescing() {
timeout(Duration::from_secs(10), async {
for first in ["core.restart", "core.exit"] {
let (_dir, host) = host(true).await;
// Same dispatch as a plugin's core.call, without killing this runner.
assert_eq!(
host.call("core.call", json!({"method":first}))
.await
.unwrap(),
json!({})
);
assert!(!host.restart_requested());
assert!(host.0.restart_pending.load(Ordering::SeqCst));
assert_eq!(
host.call("core.restart", json!({})).await.unwrap(),
json!({})
);
timeout(Duration::from_secs(1), host.exit_requested())
.await
.unwrap();
// A duplicate after the trigger must not schedule a second task.
host.0.exit.send_replace(false);
assert_eq!(
host.call("core.restart", json!({})).await.unwrap(),
json!({})
);
assert_eq!(host.call("core.exit", json!({})).await.unwrap(), json!({}));
assert!(
timeout(Duration::from_millis(300), host.exit_requested())
.await
.is_err()
);
host.stop().await;
}
})
.await
.unwrap();
}
}
-397
View File
@@ -1,397 +0,0 @@
//! JSON-RPC routing and responses between callers and processes.
use crate::{
Host, NativeFuture, RpcResult, error,
process::{Process, ProcessInfo},
};
use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot, watch};
use uuid::Uuid;
fn params_not_object(params: &Value) -> Value {
let kind = match params {
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Number(_) => "a number",
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Object(_) => "an object",
};
error(
-32602,
&format!(
"params must be a JSON object, got {kind}. Send the object itself, not a JSON-encoded string."
),
)
}
impl Host {
/// Queue an incoming response or notification for its destination.
pub(crate) fn send(&self, pid: &str, value: Value) {
if pid == "console" {
let _ = self.0.notifications.send(value);
} else if let Some(tx) = self
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.and_then(|p| p.input.as_ref())
{
let _ = tx.send(value);
}
}
/// Remap progress to the caller's original request ID.
pub(crate) fn notify(&self, pid: &str, name: &str, params: Value) {
let target = self
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.and_then(|p| p.answer.as_ref().map(|_| (p.caller.clone(), p.id.clone())));
if let Some((caller, Some(id))) = target {
self.call_notify(&caller, &id, name, params);
}
}
/// Deliver a notification on an open core call using the same envelope as plugin progress.
pub(crate) fn call_notify(&self, caller: &str, id: &Value, name: &str, params: Value) {
self.send(
caller,
json!({
"jsonrpc":"2.0", "method":"core.notify",
"params": {"call":id, "name":name, "params":params}
}),
);
}
/// Validate and route a request, then start its native or WASI process.
pub(crate) fn route(
&self,
caller: String,
id: Option<Value>,
method: String,
params: Value,
detached: bool,
) -> NativeFuture {
self.route_from(caller, id, method, params, detached, None)
}
/// Like `route`, but a WASI process runs the component at `wasm` instead
/// of the current `plugin.wasm` of its plugin. Activation uses this to
/// call `rpc.discover` on a candidate file.
pub(crate) fn route_from(
&self,
caller: String,
id: Option<Value>,
method: String,
params: Value,
detached: bool,
wasm: Option<std::path::PathBuf>,
) -> NativeFuture {
let host = self.clone();
Box::pin(async move {
if !params.is_object() {
return Err(params_not_object(&params));
}
if caller != "console" && caller != "core" {
let state = host.0.state.lock().unwrap();
let Some(process) = state.processes.get(&caller) else {
return Err(error(-32600, "process ended"));
};
if process.info.state != "running" && method != "core.notify" {
return Err(error(-32600, "process is stopping"));
}
}
let (plugin, method) = method
.split_once('.')
.ok_or_else(|| error(-32601, "method not found"))?;
if plugin == "core" {
return host.core(&caller, id, method, params).await;
}
if plugin.is_empty()
|| !plugin
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
{
return Err(error(-32601, "invalid plugin name"));
}
let path = wasm.unwrap_or_else(|| host.0.world.plugin_dir(plugin).join("plugin.wasm"));
let (native, component, version, admission) = loop {
if host.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
return Err(error(-32600, "host is stopping"));
}
let native = host.0.state.lock().unwrap().native.get(plugin).cloned();
let loaded = if native.is_none() {
Some(
host.0
.components
.load(path.clone(), host.0.runtime.clone())
.await?,
)
} else {
None
};
let admission = host.0.admission.read().await;
if let Some(loaded) = loaded {
if crate::components::Fingerprint::read(&path).await? != loaded.fingerprint {
drop(admission);
continue;
}
break (native, Some(loaded.prepared), loaded.hash, admission);
}
break (native, None, "native".into(), admission);
};
let pid = format!("{plugin}-{}", Uuid::now_v7());
let (tx, rx) = oneshot::channel();
let (input, incoming) = mpsc::unbounded_channel::<Value>();
let info = ProcessInfo {
plugin: plugin.into(),
version,
method: method.into(),
params: params.clone(),
parent: if detached { None } else { Some(caller.clone()) },
detached,
start_time: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis(),
state: "running".into(),
};
{
let mut state = host.0.state.lock().unwrap();
if host.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
return Err(error(-32600, "host is stopping"));
}
if caller != "console"
&& caller != "core"
&& !state
.processes
.get(&caller)
.is_some_and(|p| p.info.state == "running")
{
return Err(error(-32600, "caller stopped or ended"));
}
// Older plugins may still try to start their boot service from
// rpc.retired. The supervisor owns it even while a restart is
// pending, or after it has given up. Do not create a second owner.
if state
.processes
.get(&caller)
.is_some_and(|p| p.info.method == "rpc.retired")
&& state
.services
.iter()
.any(|s| s.method == format!("{plugin}.{method}") && s.params == params)
{
return Ok(Value::Null);
}
if caller == "core"
&& let Some(service) = state
.services
.iter_mut()
.find(|s| Some(&s.request) == id.as_ref())
{
if !service.enabled || service.owner.is_some() {
return Err(error(-32600, "service attempt is no longer current"));
}
service.owner = Some(pid.clone());
service.pending = false;
service.version = Some(info.version.clone());
}
host.0
.journal
.write(&pid, plugin, "core", &format!("start {method} {params}"));
state.processes.insert(
pid.clone(),
Process {
info: info.clone(),
memory: sausage_vfs::Memory::default(),
files: sausage_vfs::Process::default(),
store_ended: watch::channel(true).0,
input: Some(input),
abort: None,
caller,
id: id.clone(),
answer: if id.is_some() { Some(tx) } else { None },
ended: watch::channel(false).0,
stopped: false,
superseded: false,
},
);
}
let h = host.clone();
let p = pid.clone();
let has_id = id.is_some();
let (start, ready) = oneshot::channel();
let task = tokio::spawn(async move {
let _ = ready.await;
h.run_process(p, info, component, native, incoming, has_id)
.await
});
if let Some(p) = host.0.state.lock().unwrap().processes.get_mut(&pid) {
p.abort = Some(task.abort_handle());
if p.info.state == "stopping" {
task.abort();
}
} else {
task.abort();
}
drop(admission);
let cleanup = host.clone();
let finished_pid = pid.clone();
tokio::spawn(async move {
let status = task.await.unwrap_or_else(|error| {
if error.is_cancelled() {
"killed".into()
} else {
format!("host task failed: {error}")
}
});
cleanup.finish(&finished_pid, &status).await;
});
let _ = start.send(());
if id.is_none() {
return Ok(Value::Null);
}
rx.await
.unwrap_or_else(|_| Err(error(-32002, "process ended")))
})
}
/// Complete the initial call once and discard late responses.
pub(crate) fn answer(&self, pid: &str, result: RpcResult) {
if let Some(tx) = self
.0
.state
.lock()
.unwrap()
.processes
.get_mut(pid)
.and_then(|p| p.answer.take())
{
let _ = tx.send(result);
}
}
/// Classify a stdout line and route requests or the initial response.
pub(crate) fn output(&self, pid: &str, line: String) {
if line.trim().is_empty() {
return;
}
let Ok(v) = serde_json::from_str::<Value>(&line) else {
self.discard(pid);
return;
};
if !v.is_object() || v["jsonrpc"] != "2.0" {
self.discard(pid);
return;
}
if let Some(method) = v["method"].as_str() {
if method == "core.notify" || method == "core.unlock" {
// Deliver progress before a following response removes its destination.
let params = v.get("params").cloned().unwrap_or(json!({}));
let result = if !params.is_object() {
Err(params_not_object(&params))
} else if method == "core.unlock" {
if let Some(path) = params["path"].as_str() {
self.unlock(pid, path);
Ok(Value::Null)
} else {
Err(error(-32602, "path must be a string"))
}
} else if let Some(name) = params["name"].as_str() {
self.notify(
pid,
name,
params.get("params").cloned().unwrap_or(json!({})),
);
Ok(Value::Null)
} else {
Err(error(-32602, "name must be a string"))
};
if let Some(id) = v.get("id") {
let response = match result {
Ok(result) => json!({"jsonrpc":"2.0", "id":id, "result":result}),
Err(error) => json!({"jsonrpc":"2.0", "id":id, "error":error}),
};
self.send(pid, response);
}
return;
}
let h = self.clone();
let caller = pid.to_string();
let method = method.to_string();
tokio::spawn(async move {
let id = v.get("id").cloned();
let result = h
.route(
caller.clone(),
id.clone(),
method,
v.get("params").cloned().unwrap_or(json!({})),
false,
)
.await;
if let Some(id) = id {
let value = match result {
Ok(result) => json!({
"jsonrpc": "2.0",
"id": id,
"result": result
}),
Err(error) => json!({
"jsonrpc": "2.0",
"id": id,
"error": error
}),
};
h.send(&caller, value);
}
});
} else if v["id"] == pid {
self.answer(
pid,
if let Some(e) = v.get("error").filter(|e| !e.is_null()) {
Err(e.clone())
} else {
Ok(v["result"].clone())
},
);
}
}
pub(crate) fn discard(&self, pid: &str) {
// The journal deduplicates this warning for each process.
if let Some(plugin) = self
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.map(|p| p.info.plugin.clone())
{
self.0.journal.warning(pid, &plugin);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// PROTOCOL 6: an unlock from stdout completes before output returns.
#[tokio::test]
async fn stdout_unlock_is_synchronous() {
let folder = std::env::temp_dir().join(format!("sausage-unlock-{}", Uuid::now_v7()));
crate::world::World::create(&folder).unwrap();
let host = Host::start_with_cache(&folder, &folder.join("cache"))
.await
.unwrap();
host.lock("core", "ordered", true).await.unwrap();
host.output(
"core",
r#"{"jsonrpc":"2.0","method":"core.unlock","params":{"path":"ordered"}}"#.into(),
);
let held = host.lock("console", "ordered", false).await.unwrap();
host.stop().await;
std::fs::remove_dir_all(folder).unwrap();
assert_eq!(held, json!({"held":true}));
}
}
+125 -227
View File
@@ -1,29 +1,36 @@
//! Wasmtime engine and process instances. //! Wasmtime engine and actor instances.
//! //!
//! An instance is one run of a `wasi:cli/command` component. The host writes //! An instance is one run of a `wasi:cli/command` component. The host writes
//! NDJSON lines to its stdin and reads NDJSON lines from its stdout. //! NDJSON lines to its stdin and reads NDJSON lines from its stdout.
use std::path::Path; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::{Arc, Weak};
use std::time::Duration; use std::time::Duration;
use anyhow::Result; use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use wasmtime::component::{Component, Linker, ResourceTable}; use wasmtime::component::{Component, Linker, ResourceTable};
use wasmtime::{Config, Engine, Store}; use wasmtime::{Config, Engine, Store};
use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream}; use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
use wasmtime_wasi::filesystem::WasiFilesystemCtxView;
use wasmtime_wasi::p2::bindings::CommandPre; use wasmtime_wasi::p2::bindings::Command;
use wasmtime_wasi::p2::bindings::filesystem;
/// A component with its imports resolved once for this runtime. use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView};
pub(crate) struct Prepared(Result<CommandPre<ProcessState>, String>);
use wasmtime_wasi::{I32Exit, WasiCtx, WasiCtxView, WasiView};
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView}; use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
/// The HTTP transport hooks supplied to WASI processes. use crate::broker::Broker;
use crate::vfs::{Vfs, VfsState, VfsView};
/// A host folder that the guest sees at `guest`.
#[derive(Clone, Debug)]
pub struct Mount {
pub guest: String,
pub host: PathBuf,
pub writable: bool,
}
pub struct HttpHooks { pub struct HttpHooks {
allow: bool, allow: bool,
} }
@@ -58,38 +65,33 @@ impl WasiHttpHooks for HttpHooks {
} }
} }
/// The WASI and HTTP resources owned by one store. pub struct ActorState {
pub struct ProcessState {
ctx: WasiCtx, ctx: WasiCtx,
table: ResourceTable, table: ResourceTable,
http: WasiHttpCtx, http: WasiHttpCtx,
hooks: HttpHooks, hooks: HttpHooks,
fs: sausage_vfs::adapter::Adapter, vfs: VfsState,
} }
impl WasiView for ProcessState { fn vfs_view(s: &mut ActorState) -> VfsView<'_> {
VfsView { inner: WasiFilesystemCtxView { ctx: s.ctx.filesystem(), table: &mut s.table }, state: &mut s.vfs }
}
impl WasiView for ActorState {
fn ctx(&mut self) -> WasiCtxView<'_> { fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView { WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
ctx: &mut self.ctx,
table: &mut self.table,
}
} }
} }
impl WasiHttpView for ProcessState { impl WasiHttpView for ActorState {
fn http(&mut self) -> WasiHttpCtxView<'_> { fn http(&mut self) -> WasiHttpCtxView<'_> {
WasiHttpCtxView { WasiHttpCtxView { ctx: &mut self.http, table: &mut self.table, hooks: &mut self.hooks }
ctx: &mut self.http,
table: &mut self.table,
hooks: &mut self.hooks,
}
} }
} }
/// A Wasmtime engine and linker shared by the world.
pub struct Runtime { pub struct Runtime {
engine: Engine, engine: Engine,
linker: Linker<ProcessState>, linker: Linker<ActorState>,
} }
/// How an instance ended. /// How an instance ended.
@@ -113,50 +115,29 @@ impl std::fmt::Display for Exit {
} }
} }
/// The filesystem, network, and environment supplied to one process.
pub struct InstanceConfig { pub struct InstanceConfig {
/// The virtual namespace exposed through the single root preopen. pub mounts: Vec<Mount>,
pub filesystem: sausage_vfs::Mounts,
/// The store completion barrier used by process cleanup.
pub store_ended: tokio::sync::watch::Sender<bool>,
/// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`. /// Outgoing HTTP, and TCP, UDP, and DNS through `wasi:sockets`.
pub network: bool, pub network: bool,
/// The environment variables supplied to the guest. pub stderr_log: PathBuf,
pub env: Vec<(String, String)>, pub env: Vec<(String, String)>,
/// The actor that runs in the instance.
pub actor: String,
/// Asks the actors that serve `/actor/<id>/fs/` (see `vfs.rs`).
pub broker: Weak<Broker>,
} }
/// A running guest. Dropping it kills the guest. /// A running guest. Dropping it kills the guest.
pub struct Instance { pub struct Instance {
/// The host end of stdin, removed when the process stops. stdin: Option<DuplexStream>,
pub stdin: Option<DuplexStream>,
/// Lines that the guest writes to stdout. Closed when the guest ends. /// Lines that the guest writes to stdout. Closed when the guest ends.
pub stdout: mpsc::Receiver<String>, pub stdout: mpsc::Receiver<String>,
/// The stderr lines that the process writes. task: JoinHandle<Exit>,
pub stderr: mpsc::Receiver<String>,
stderr_task: Option<JoinHandle<()>>,
task: Option<JoinHandle<Exit>>,
store_ended: tokio::sync::watch::Sender<bool>,
} }
impl Drop for Instance { impl Drop for Instance {
fn drop(&mut self) { fn drop(&mut self) {
let task = self.task.take(); self.task.abort();
let stderr = self.stderr_task.take();
if let Some(task) = &task {
task.abort();
}
if task.is_some() || stderr.is_some() {
let ended = self.store_ended.clone();
tokio::spawn(async move {
if let Some(task) = task {
let _ = task.await;
}
if let Some(stderr) = stderr {
let _ = stderr.await;
}
ended.send_replace(true);
});
}
} }
} }
@@ -164,11 +145,7 @@ impl Instance {
/// Write one line to the guest's stdin, in one write. Meanwhile, pass the /// Write one line to the guest's stdin, in one write. Meanwhile, pass the
/// lines that the guest writes to `route`: a guest that blocks on a full /// lines that the guest writes to `route`: a guest that blocks on a full
/// stdout would never read its stdin. /// stdout would never read its stdin.
pub async fn send_line( pub async fn send_line(&mut self, line: &str, mut route: impl FnMut(String)) -> std::io::Result<()> {
&mut self,
line: &str,
mut route: impl FnMut(String),
) -> std::io::Result<()> {
let stdin = self.stdin.as_mut().ok_or(std::io::ErrorKind::BrokenPipe)?; let stdin = self.stdin.as_mut().ok_or(std::io::ErrorKind::BrokenPipe)?;
let bytes = format!("{line}\n"); let bytes = format!("{line}\n");
let write = async { let write = async {
@@ -190,19 +167,55 @@ impl Instance {
/// Wait until the guest ends. /// Wait until the guest ends.
pub async fn wait(&mut self) -> Exit { pub async fn wait(&mut self) -> Exit {
let result = self.task.as_mut().unwrap().await; match (&mut self.task).await {
self.task.take();
if let Some(stderr) = self.stderr_task.as_mut() {
let _ = stderr.await;
}
self.stderr_task.take();
self.store_ended.send_replace(true);
match result {
Ok(exit) => exit, Ok(exit) => exit,
Err(e) if e.is_cancelled() => Exit::Trap("killed".into()), Err(e) if e.is_cancelled() => Exit::Trap("killed".into()),
Err(e) => Exit::Trap(format!("host task failed: {e}")), Err(e) => Exit::Trap(format!("host task failed: {e}")),
} }
} }
pub fn is_finished(&self) -> bool {
self.task.is_finished()
}
/// Kill the guest. Its stdout closes when it has ended.
pub fn kill(&self) {
self.task.abort();
}
/// Send `shutdown_line`, close stdin, and wait until the guest ends.
/// Pass every line that the guest writes meanwhile to `route`. Kill the
/// guest when `deadline` has passed.
pub async fn stop(mut self, shutdown_line: &str, deadline: Duration, mut route: impl FnMut(String)) -> Exit {
let stdin = self.stdin.take();
let stdout = &mut self.stdout;
let finished = tokio::time::timeout(deadline, async {
let close = async {
if let Some(mut stdin) = stdin {
let _ = stdin.write_all(format!("{shutdown_line}\n").as_bytes()).await;
let _ = stdin.shutdown().await;
}
};
let drain = async {
while let Some(line) = stdout.recv().await {
route(line);
}
};
tokio::join!(close, drain);
})
.await;
match finished {
Ok(()) => self.wait().await,
Err(_) => Exit::Trap("killed after shutdown deadline".into()),
}
}
}
fn log_note(path: &Path, text: &str) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "[sausage] {text}");
}
} }
/// The most useful line of a trap: the `wasm trap: ...` reason. /// The most useful line of a trap: the `wasm trap: ...` reason.
@@ -212,188 +225,95 @@ fn short_trap(full: &str) -> String {
.find_map(|l| l.find("wasm trap:").map(|i| l[i..].to_string())) .find_map(|l| l.find("wasm trap:").map(|i| l[i..].to_string()))
.unwrap_or_else(|| full.lines().next().unwrap_or("trap").to_string()); .unwrap_or_else(|| full.lines().next().unwrap_or("trap").to_string());
let mut line = line.trim().to_string(); let mut line = line.trim().to_string();
let mut end = line.len().min(300); line.truncate(300);
while !line.is_char_boundary(end) {
end -= 1;
}
line.truncate(end);
line line
} }
impl Runtime { impl Runtime {
/// Create an engine with disk caching and a ten-millisecond epoch tick. pub fn new() -> Result<Arc<Self>> {
pub fn new(cache: &Path) -> Result<Arc<Self>> {
let mut config = Config::new(); let mut config = Config::new();
config.epoch_interruption(true); config.epoch_interruption(true);
let disk_cache = (|| -> Result<_> {
let cache = cache.join("wasmtime");
std::fs::create_dir_all(&cache)?;
let mut cache_config = wasmtime::CacheConfig::new();
cache_config.with_directory(cache.canonicalize()?);
Ok(wasmtime::Cache::new(cache_config)?)
})();
match disk_cache {
Ok(cache) => {
config.cache(Some(cache));
}
Err(error) => eprintln!(
"compile cache {} unavailable: {error}; running without disk cache",
cache.display()
),
}
let engine = Engine::new(&config)?; let engine = Engine::new(&config)?;
let mut linker: Linker<ProcessState> = Linker::new(&engine); let mut linker: Linker<ActorState> = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?; wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
sausage_vfs::adapter::add_to_linker(&mut linker, |state| sausage_vfs::adapter::View {
adapter: &mut state.fs,
table: &mut state.table,
})?;
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?; wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
// Replace `wasi:filesystem` with the wrapper that serves `/actor/<id>/fs/`.
linker.allow_shadowing(true);
filesystem::types::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
filesystem::preopens::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
linker.allow_shadowing(false);
// Time slices: every 10 ms each running guest yields to the scheduler. // Time slices: every 10 ms each running guest yields to the scheduler.
let runtime = Arc::new(Self { engine, linker }); let ticker = engine.clone();
let ticker = Arc::downgrade(&runtime);
std::thread::spawn(move || { std::thread::spawn(move || {
loop { loop {
std::thread::sleep(Duration::from_millis(10)); std::thread::sleep(Duration::from_millis(10));
let Some(runtime) = ticker.upgrade() else { ticker.increment_epoch();
break;
};
runtime.engine.increment_epoch();
} }
}); });
Ok(runtime) Ok(Arc::new(Self { engine, linker }))
} }
/// Compile component bytes through Wasmtime's disk cache. pub fn load(&self, wasm: &Path) -> Result<Component> {
pub fn compile(&self, bytes: &[u8]) -> Result<Component> { Component::from_file(&self.engine, wasm)
Ok(Component::new(&self.engine, bytes)?) .map_err(anyhow::Error::from)
.with_context(|| format!("load {}", wasm.display()))
} }
pub(crate) fn prepare(&self, component: &Component) -> Prepared { pub fn start(self: &Arc<Self>, component: &Component, cfg: InstanceConfig) -> Result<Instance> {
// Preserve link failures as process exits, rather than load errors.
Prepared(
self.linker
.instantiate_pre(component)
.and_then(CommandPre::new)
.map_err(|error| error.to_string()),
)
}
pub(crate) async fn start_logged(
self: &Arc<Self>,
prepared: &Prepared,
cfg: InstanceConfig,
journal: Arc<crate::journal::Journal>,
pid: String,
plugin: String,
) -> Result<Instance> {
self.start_inner(prepared.0.clone(), cfg, &[], Some((journal, pid, plugin)))
.await
}
/// Start a WASI task with the supplied virtual namespace.
pub async fn start(
self: &Arc<Self>,
component: &Component,
cfg: InstanceConfig,
) -> Result<Instance> {
self.start_with_args(component, cfg, &[]).await
}
/// Start a WASI task with explicit CLI arguments, including its program name.
pub async fn start_with_args(
self: &Arc<Self>,
component: &Component,
cfg: InstanceConfig,
args: &[String],
) -> Result<Instance> {
self.start_inner(self.prepare(component).0, cfg, args, None)
.await
}
async fn start_inner(
self: &Arc<Self>,
prepared: Result<CommandPre<ProcessState>, String>,
cfg: InstanceConfig,
args: &[String],
log: Option<(Arc<crate::journal::Journal>, String, String)>,
) -> Result<Instance> {
let (host_in, guest_in) = tokio::io::duplex(64 * 1024); let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
let (guest_out, host_out) = tokio::io::duplex(64 * 1024); let (guest_out, host_out) = tokio::io::duplex(64 * 1024);
let (guest_err, host_err) = tokio::io::duplex(64 * 1024); let stderr = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&cfg.stderr_log)
.with_context(|| format!("open {}", cfg.stderr_log.display()))?;
let mut b = WasiCtx::builder(); let mut b = WasiCtx::builder();
b.args(args);
if cfg.network { if cfg.network {
b.inherit_network() b.inherit_network().allow_tcp(true).allow_udp(true).allow_ip_name_lookup(true);
.allow_tcp(true)
.allow_udp(true)
.allow_ip_name_lookup(true);
} }
b.stdin(AsyncStdinStream::new(guest_in)); b.stdin(AsyncStdinStream::new(guest_in));
b.stdout(AsyncStdoutStream::new(8192, guest_out)); b.stdout(AsyncStdoutStream::new(8192, guest_out));
b.stderr(AsyncStdoutStream::new(8192, guest_err)); b.stderr(AsyncStdoutStream::new(8192, tokio::fs::File::from_std(stderr)));
for (k, v) in &cfg.env { for (k, v) in &cfg.env {
b.env(k, v); b.env(k, v);
} }
for m in &cfg.mounts {
let state = ProcessState { let perms = if m.writable { FsPerms::ReadWrite } else { FsPerms::ReadOnly };
b.preopened_dir(&m.host, &m.guest, perms)
.map_err(anyhow::Error::from)
.with_context(|| format!("mount {} at {}", m.host.display(), m.guest))?;
}
let state = ActorState {
ctx: b.build(), ctx: b.build(),
table: ResourceTable::new(), table: ResourceTable::new(),
http: WasiHttpCtx::new(), http: WasiHttpCtx::new(),
hooks: HttpHooks { allow: cfg.network }, hooks: HttpHooks { allow: cfg.network },
fs: sausage_vfs::adapter::Adapter::new(cfg.filesystem), vfs: VfsState::new(cfg.broker.clone(), cfg.actor.clone()),
}; };
let (line_tx, line_rx) = mpsc::channel(1024); let (line_tx, line_rx) = mpsc::channel(1024);
tokio::spawn(async move { tokio::spawn(async move {
let mut reader = BufReader::new(host_out); let mut lines = BufReader::new(host_out).lines();
let mut bytes = Vec::new(); while let Ok(Some(line)) = lines.next_line().await {
while let Ok(n) = reader.read_until(b'\n', &mut bytes).await {
if n == 0 {
break;
}
// Preserve a non-blank invalid line so routing emits its warning.
let line = String::from_utf8(std::mem::take(&mut bytes))
.unwrap_or_else(|_| "\u{fffd}".into());
if line_tx.send(line).await.is_err() { if line_tx.send(line).await.is_err() {
break; break;
} }
} }
}); });
let (err_tx, err_rx) = mpsc::channel(1024);
let trap_log = log.clone();
let stderr_task = tokio::spawn(async move {
let mut reader = BufReader::new(host_err);
let mut bytes = Vec::new();
while let Ok(n) = reader.read_until(b'\n', &mut bytes).await {
if n == 0 {
break;
}
let line = String::from_utf8_lossy(&bytes)
.trim_end_matches(['\r', '\n'])
.to_string();
bytes.clear();
if let Some((journal, pid, plugin)) = &log {
journal.write(pid, plugin, "stderr", &line);
} else if err_tx.send(line).await.is_err() {
break;
}
}
});
let rt = self.clone(); let rt = self.clone();
cfg.store_ended.send_replace(false); let component = component.clone();
let stderr_log = cfg.stderr_log.clone();
let task = tokio::spawn(async move { let task = tokio::spawn(async move {
let mut store = Store::new(&rt.engine, state); let mut store = Store::new(&rt.engine, state);
store.epoch_deadline_async_yield_and_update(1); store.epoch_deadline_async_yield_and_update(1);
let result = async { let result = async {
let cmd = prepared let cmd = Command::instantiate_async(&mut store, &component, &rt.linker).await?;
.map_err(wasmtime::Error::msg)?
.instantiate_async(&mut store)
.await?;
cmd.wasi_cli_run().call_run(&mut store).await cmd.wasi_cli_run().call_run(&mut store).await
} }
.await; .await;
@@ -404,37 +324,15 @@ impl Runtime {
Some(I32Exit(0)) => Exit::Clean, Some(I32Exit(0)) => Exit::Clean,
Some(I32Exit(c)) => Exit::Code(*c), Some(I32Exit(c)) => Exit::Code(*c),
None => { None => {
// Keep the full backtrace in the log, and a short reason in the status.
let full = format!("{e:?}"); let full = format!("{e:?}");
if let Some((journal, pid, plugin)) = &trap_log { log_note(&stderr_log, &format!("trap details:\n{full}"));
journal.write(pid, plugin, "core", &full);
}
Exit::Trap(short_trap(&full)) Exit::Trap(short_trap(&full))
} }
}, },
} }
}); });
Ok(Instance { Ok(Instance { stdin: Some(host_in), stdout: line_rx, task })
stdin: Some(host_in),
stdout: line_rx,
stderr: err_rx,
stderr_task: Some(stderr_task),
task: Some(task),
store_ended: cfg.store_ended,
})
}
}
#[cfg(test)]
mod trap_text_tests {
use super::*;
#[test]
fn short_trap_display_is_utf8_safe() {
let full = format!("backtrace\nwasm trap: {}\nmore detail", "😀".repeat(1000));
let status = short_trap(&full);
assert!(status.len() <= 300);
assert!(status.starts_with("wasm trap:"));
assert_eq!(short_trap(""), "trap");
assert_eq!(short_trap("first line\nsecond"), "first line");
} }
} }
-166
View File
@@ -1,166 +0,0 @@
//! Ordered boot calls and the single restart owner for each service (PROTOCOL.md section 8).
use crate::{Host, host::State};
use serde_json::{Value, json};
use std::{collections::VecDeque, sync::atomic::Ordering, time::Duration};
use tokio::time::Instant;
// Systemd-derived values:
// https://www.man7.org/linux/man-pages/man5/systemd-system.conf.5.html.
const CRASH_LIMIT: usize = 5;
const CRASH_WINDOW: Duration = Duration::from_secs(10);
pub(crate) struct Service {
pub(crate) method: String,
pub(crate) params: Value,
pub(crate) request: Value,
pub(crate) owner: Option<String>,
pub(crate) pending: bool,
pub(crate) version: Option<String>,
pub(crate) enabled: bool,
crashes: VecDeque<Instant>,
}
impl Service {
fn crashed(&mut self, now: Instant) -> bool {
while self
.crashes
.front()
.is_some_and(|t| now.duration_since(*t) >= CRASH_WINDOW)
{
self.crashes.pop_front();
}
self.crashes.push_back(now);
self.crashes.len() <= CRASH_LIMIT
}
}
impl Host {
/// Starts discovery in the background, then waits for each boot service in order.
pub(crate) async fn boot(&self) -> anyhow::Result<()> {
self.watch_plugins()?;
let boot: Vec<Value> = serde_yaml_ng::from_slice(
&tokio::fs::read(self.0.world.root.join("config/boot.yaml")).await?,
)?;
for service in boot {
self.start_service(
service["method"].as_str().unwrap_or("").into(),
service["params"].clone(),
)
.await;
}
Ok(())
}
/// Register once, before admission or readiness. Restarts reuse this registration.
pub(crate) async fn start_service(&self, method: String, params: Value) {
let request = json!(uuid::Uuid::now_v7().to_string());
{
let mut state = self.0.state.lock().unwrap();
if self.0.shutdown.load(Ordering::SeqCst) {
return;
}
state.services.push(Service {
method,
params,
request: request.clone(),
owner: None,
enabled: true,
pending: true,
version: None,
crashes: VecDeque::new(),
});
}
self.run_service(request).await;
}
/// Await readiness only; process cleanup, not this response, decides whether to restart.
async fn run_service(&self, request: Value) {
let (method, params) = {
let state = self.0.state.lock().unwrap();
if self.0.shutdown.load(Ordering::SeqCst) {
return;
}
let Some(service) = state
.services
.iter()
.find(|s| s.request == request && s.enabled)
else {
return;
};
(service.method.clone(), service.params.clone())
};
self.0
.journal
.write("core", "core", "core", &format!("service start {method}"));
let result = self
.route("core".into(), Some(request.clone()), method, params, true)
.await;
{
let mut state = self.0.state.lock().unwrap();
if let Some(service) = state.services.iter_mut().find(|s| s.request == request) {
service.pending = false;
}
}
if let Err(error) = result {
// Admission failures have no process lifetime to supervise. A process crash
// before readiness is handled by finish just like one after readiness.
self.0
.journal
.write("core", "core", "core", &format!("service failed {error}"));
}
}
/// Called under the state lock after the old owner and its files/locks are gone.
pub(crate) fn service_ended(
&self,
state: &mut State,
pid: &str,
status: &str,
stopped: bool,
superseded: bool,
) {
let Some(service) = state
.services
.iter_mut()
.find(|s| s.owner.as_deref() == Some(pid))
else {
return;
};
service.owner = None;
if self.0.shutdown.load(Ordering::SeqCst) || !service.enabled {
return;
}
if !superseded {
if stopped {
service.enabled = false;
return;
}
if status == "exit 0" {
return;
}
if !service.crashed(Instant::now()) {
service.enabled = false;
self.0.journal.write(
"core",
"core",
"core",
&format!(
"service gave up {}: more than {CRASH_LIMIT} crashes within {} seconds",
service.method,
CRASH_WINDOW.as_secs(),
),
);
return;
}
}
self.queue_service(service);
}
/// Reserve the next attempt before spawning it; upgrades and crashes share this owner.
pub(crate) fn queue_service(&self, service: &mut Service) {
service.request = json!(uuid::Uuid::now_v7().to_string());
service.pending = true;
let request = service.request.clone();
let host = self.clone();
tokio::spawn(async move {
host.run_service(request).await;
});
}
}
#[cfg(test)]
mod tests;
-356
View File
@@ -1,356 +0,0 @@
//! Deterministic service supervision tests: native tasks stand in for guest lifetimes.
use super::*;
use crate::{NativePlugin, runtime::Runtime};
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot, watch};
struct Rig {
host: Host,
starts: mpsc::UnboundedReceiver<(String, oneshot::Sender<()>)>,
_world: tempfile::TempDir,
}
impl Rig {
async fn new() -> Self {
let world = tempfile::tempdir().unwrap();
let runtime = Runtime::new(world.path()).unwrap();
let host = Host::test_world(world.path(), runtime).await.unwrap();
let (tx, starts) = mpsc::unbounded_channel();
let plugin: NativePlugin = Arc::new(move |ctx, method, _| {
let tx = tx.clone();
Box::pin(async move {
if method != "serve" {
return Ok(Value::Null);
}
let (end, ended) = oneshot::channel();
tx.send((ctx.pid, end)).unwrap();
let _ = ended.await;
Ok(Value::Null)
})
});
host.register_native("demo", plugin.clone());
host.register_native("other", plugin);
Self {
host,
starts,
_world: world,
}
}
async fn start(&mut self, method: &str) -> (String, oneshot::Sender<()>) {
let host = self.host.clone();
let method = method.to_string();
tokio::spawn(async move {
host.start_service(method, json!({"kept":true})).await;
});
self.next().await
}
async fn next(&mut self) -> (String, oneshot::Sender<()>) {
// Reuse the host integration call bound (activation.rs::call).
tokio::time::timeout(Duration::from_secs(60), self.starts.recv())
.await
.unwrap()
.unwrap()
}
fn ended(&self, pid: &str) -> watch::Receiver<bool> {
self.host.0.state.lock().unwrap().processes[pid]
.ended
.subscribe()
}
async fn crash(&self, pid: &str) {
let mut ended = self.ended(pid);
// Abort execution without asking the supervisor for an intentional stop.
self.host.0.state.lock().unwrap().processes[pid]
.abort
.as_ref()
.unwrap()
.abort();
ended.wait_for(|done| *done).await.unwrap();
}
async fn clean(&self, pid: &str, end: oneshot::Sender<()>) {
let mut ended = self.ended(pid);
end.send(()).unwrap();
ended.wait_for(|done| *done).await.unwrap();
}
fn assert_single(&self, pid: &str, registrations: usize) {
let state = self.host.0.state.lock().unwrap();
assert_eq!(state.services.len(), registrations);
let p = &state.processes[pid];
assert_eq!(p.info.params, json!({"kept":true}));
assert!(p.info.detached);
assert_eq!(p.caller, "core");
assert_eq!(
state
.services
.iter()
.filter(|s| s.owner.as_deref() == Some(pid))
.count(),
1
);
}
fn no_start(&mut self) {
assert!(self.starts.try_recv().is_err());
}
}
#[tokio::test(start_paused = true)]
async fn crash_before_and_after_readiness_observes_process_not_response() {
let mut rig = Rig::new().await;
let (pid, _end) = rig.start("demo.serve").await;
rig.assert_single(&pid, 1);
rig.crash(&pid).await; // No ready RPC response was sent.
let (pid, end) = rig.next().await;
rig.assert_single(&pid, 1);
rig.host.answer(&pid, Ok(json!({"ready":true})));
tokio::task::yield_now().await;
rig.no_start(); // Ready does not end the service lifetime.
rig.crash(&pid).await;
drop(end);
let (pid, end) = rig.next().await;
rig.assert_single(&pid, 1);
rig.clean(&pid, end).await;
rig.no_start(); // A clean, intentional return is not a crash.
rig.host.stop().await;
}
#[tokio::test(start_paused = true)]
async fn fifth_restarts_sixth_gives_up_and_journals_once() {
let mut rig = Rig::new().await;
let (mut pid, mut end) = rig.start("demo.serve").await;
for crash in 1..=CRASH_LIMIT {
rig.crash(&pid).await;
drop(end);
(pid, end) = rig.next().await;
rig.assert_single(&pid, 1);
assert_eq!(
rig.host.0.state.lock().unwrap().services[0].crashes.len(),
crash
);
}
rig.crash(&pid).await;
drop(end);
rig.no_start();
let logs = rig
.host
.call("core.log", json!({"grep":"service gave up"}))
.await
.unwrap();
assert_eq!(logs.as_array().unwrap().len(), 1);
assert!(
logs[0]["line"]
.as_str()
.unwrap()
.contains("more than 5 crashes within 10 seconds")
);
assert!(!rig.host.0.state.lock().unwrap().services[0].enabled);
// A retired hook cannot bypass give-up while its supervisor has no owner.
rig.host.register_native(
"fallback",
Arc::new(move |ctx, _, _| {
Box::pin(async move {
ctx.call(
"core.call",
json!({"method":"demo.serve","params":{"kept":true},"detach":true}),
)
.await
})
}),
);
assert_eq!(
rig.host
.call("fallback.rpc.retired", json!({}))
.await
.unwrap(),
Value::Null
);
rig.no_start();
// Neither time passing nor an upgrade re-arms a given-up service.
tokio::time::advance(CRASH_WINDOW).await;
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new");
rig.host.begin_upgrade(upgrade);
tokio::task::yield_now().await;
rig.no_start();
rig.host.stop().await;
}
#[test]
fn sliding_window_boundary_and_progress() {
let now = Instant::now();
let service = || Service {
method: "demo.serve".into(),
params: json!({}),
request: json!("request"),
owner: None,
enabled: true,
pending: false,
version: None,
crashes: VecDeque::new(),
};
let mut before = service();
let mut exact = service();
let mut after = service();
for _ in 0..CRASH_LIMIT {
assert!(before.crashed(now));
assert!(exact.crashed(now));
assert!(after.crashed(now));
}
assert!(!before.crashed(now + CRASH_WINDOW - Duration::from_nanos(1)));
assert!(exact.crashed(now + CRASH_WINDOW));
assert_eq!(exact.crashes.len(), 1);
assert!(after.crashed(now + CRASH_WINDOW + Duration::from_nanos(1)));
// Repeated progress beyond the window never accumulates old failures.
for step in 2..=CRASH_LIMIT as u32 + 1 {
assert!(exact.crashed(now + CRASH_WINDOW * step));
assert_eq!(exact.crashes.len(), 1);
}
}
#[tokio::test(start_paused = true)]
async fn services_have_independent_crash_budgets() {
let mut rig = Rig::new().await;
let (mut a, mut a_end) = rig.start("demo.serve").await;
let (b, b_end) = rig.start("other.serve").await;
for _ in 0..CRASH_LIMIT {
rig.crash(&a).await;
drop(a_end);
(a, a_end) = rig.next().await;
}
rig.crash(&a).await;
drop(a_end);
rig.crash(&b).await;
drop(b_end);
let (b, b_end) = rig.next().await;
rig.assert_single(&b, 2);
rig.clean(&b, b_end).await;
rig.no_start();
rig.host.stop().await;
}
#[tokio::test(start_paused = true)]
async fn upgrade_crash_race_has_one_restart_and_retired_fallback_cannot_duplicate_it() {
let mut rig = Rig::new().await;
let (pid, end) = rig.start("demo.serve").await;
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new");
rig.crash(&pid).await; // Exits between snapshot and begin_upgrade.
drop(end);
rig.host.begin_upgrade(upgrade);
let (pid, end) = rig.next().await;
rig.assert_single(&pid, 1);
assert!(
rig.host.0.state.lock().unwrap().services[0]
.crashes
.is_empty()
);
// A native retired hook exercises the same routing path as a guest fallback.
rig.host.register_native(
"fallback",
Arc::new(move |ctx, _, _| {
Box::pin(async move {
ctx.call(
"core.call",
json!({"method":"demo.serve","params":{"kept":true},"detach":true}),
)
.await
})
}),
);
assert_eq!(
rig.host
.call("fallback.rpc.retired", json!({}))
.await
.unwrap(),
Value::Null
);
rig.no_start();
rig.clean(&pid, end).await;
rig.host.stop().await;
}
#[tokio::test(start_paused = true)]
async fn crash_before_upgrade_snapshot_also_has_one_restart() {
let mut rig = Rig::new().await;
let (pid, end) = rig.start("demo.serve").await;
rig.crash(&pid).await;
drop(end);
// Restart may be pending admission or already admitted: snapshot handles both.
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "native");
rig.host.begin_upgrade(upgrade);
let (pid, end) = rig.next().await;
rig.assert_single(&pid, 1);
rig.no_start();
rig.clean(&pid, end).await;
rig.host.stop().await;
}
#[tokio::test(start_paused = true)]
async fn pending_admission_is_single_owned_across_upgrade() {
let mut rig = Rig::new().await;
let (pid, end) = rig.start("demo.serve").await;
let host = rig.host.clone();
let admission = host.0.admission.write().await;
rig.crash(&pid).await;
drop(end);
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "native");
rig.host.begin_upgrade(upgrade);
assert!(rig.host.0.state.lock().unwrap().services[0].pending);
drop(admission);
let (pid, end) = rig.next().await;
rig.assert_single(&pid, 1);
rig.no_start();
rig.clean(&pid, end).await;
rig.host.stop().await;
}
#[tokio::test(start_paused = true)]
async fn shutdown_rejects_restart_already_waiting_for_admission() {
let mut rig = Rig::new().await;
let (pid, end) = rig.start("demo.serve").await;
let host = rig.host.clone();
let admission = host.0.admission.write().await;
rig.crash(&pid).await;
drop(end);
rig.host.stop().await;
drop(admission);
tokio::task::yield_now().await;
rig.no_start();
assert!(rig.host.processes().is_empty());
}
#[tokio::test(start_paused = true)]
async fn clean_service_stays_dormant_until_upgrade_and_explicit_stop_stays_disabled() {
let mut rig = Rig::new().await;
let (pid, end) = rig.start("demo.serve").await;
rig.clean(&pid, end).await;
rig.no_start();
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new");
rig.host.begin_upgrade(upgrade);
let (pid, end) = rig.next().await;
rig.assert_single(&pid, 1);
// An explicit stop stays disabled even if the task subsequently exits cleanly.
let mut ended = rig.host.begin_stop(&pid).unwrap();
end.send(()).unwrap();
ended.wait_for(|done| *done).await.unwrap();
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "newer");
rig.host.begin_upgrade(upgrade);
tokio::task::yield_now().await;
rig.no_start();
rig.host.stop().await;
}
#[tokio::test(start_paused = true)]
async fn intentional_stop_and_shutdown_never_restart_even_during_upgrade() {
let mut rig = Rig::new().await;
let (pid, end) = rig.start("demo.serve").await;
let upgrade = rig.host.snapshot_upgrade("demo", "old".into(), "new");
// An explicit kill after supersession overrides the automatic upgrade restart.
rig.host
.call("core.kill", json!({"pid":pid}))
.await
.unwrap();
drop(end);
rig.host.begin_upgrade(upgrade);
tokio::task::yield_now().await;
rig.no_start();
let (_pid, end) = rig.start("other.serve").await;
rig.host.stop().await;
drop(end);
rig.no_start();
}
+287
View File
@@ -0,0 +1,287 @@
//! The supervisor of one WASM actor: it runs the instance, feeds it the
//! mailbox, routes its output, restarts it after a crash, and swaps versions.
//!
//! The broker talks to a supervisor only through `Control` requests.
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot};
use wasmtime::component::Component;
use crate::broker::{Broker, Envelope, SHUTDOWN_DEADLINE, shutdown_line};
use crate::runtime::{Exit, Instance};
/// A new version that crashes this often within `CRASH_WINDOW` after an
/// upgrade is rolled back automatically.
const CRASH_LIMIT: usize = 3;
const CRASH_WINDOW: Duration = Duration::from_secs(30);
pub type Reply = oneshot::Sender<Result<Value>>;
pub enum Control {
/// Swap to `version` (already in `versions/`, compiled as `component`).
Upgrade { version: u64, component: Component, requester: String, reply: Reply },
Rollback { version: u64, reply: Reply },
Stop { reply: Reply },
Start { reply: Reply },
Shutdown { reply: Reply },
}
/// The time after an upgrade in which crashes count toward a rollback.
struct Probation {
previous: u64,
since: Instant,
crashes: usize,
requester: String,
}
pub struct Supervisor {
broker: Arc<Broker>,
id: String,
version: u64,
running: bool,
mailbox: mpsc::Receiver<Envelope>,
control: mpsc::Receiver<Control>,
component: Option<Component>,
instance: Option<Instance>,
started_at: Instant,
restarts: u32,
restart_at: Option<Instant>,
probation: Option<Probation>,
/// A control request that arrived during a blocked delivery.
next_control: Option<Control>,
}
async fn next_line(instance: &mut Option<Instance>) -> Option<String> {
match instance {
Some(i) => i.stdout.recv().await,
None => std::future::pending().await,
}
}
async fn sleep_until(t: Option<Instant>) {
match t {
Some(t) => tokio::time::sleep_until(t.into()).await,
None => std::future::pending().await,
}
}
impl Supervisor {
pub fn new(
broker: Arc<Broker>,
id: String,
version: u64,
running: bool,
mailbox: mpsc::Receiver<Envelope>,
control: mpsc::Receiver<Control>,
) -> Self {
Self {
broker,
id,
version,
running,
mailbox,
control,
component: None,
instance: None,
started_at: Instant::now(),
restarts: 0,
restart_at: None,
probation: None,
next_control: None,
}
}
pub async fn run(mut self) {
loop {
if let Some(ctl) = self.next_control.take() {
if self.handle(ctl).await {
break;
}
continue;
}
if self.running && self.instance.is_none() && self.restart_at.is_none_or(|t| Instant::now() >= t) {
self.restart_at = None;
let _ = self.start().await;
}
let has_instance = self.instance.is_some();
tokio::select! {
ctl = self.control.recv() => {
let Some(ctl) = ctl else { break };
if self.handle(ctl).await {
break;
}
}
msg = self.mailbox.recv(), if has_instance => {
let Some(msg) = msg else { break };
self.deliver(msg).await;
}
line = next_line(&mut self.instance) => match line {
Some(line) => self.broker.route_line(&self.id, &line),
None => {
let exit = self.instance.as_mut().unwrap().wait().await;
self.instance = None;
self.on_exit(exit).await;
}
},
_ = sleep_until(self.restart_at), if !has_instance && self.running => {}
}
}
}
/// Write a message to the instance's stdin, and route its output
/// meanwhile. If a control request arrives before the actor reads the
/// message, the instance is killed (a partly written line would corrupt
/// the next message) and the request runs.
async fn deliver(&mut self, msg: Envelope) {
let Some(inst) = self.instance.as_mut() else { return };
let (broker, id, line) = (self.broker.clone(), self.id.clone(), msg.to_line());
let stuck = tokio::select! {
written = inst.send_line(&line, |l| broker.route_line(&id, &l)) => match written {
Ok(()) => return,
// The instance is ending. The main loop sees its closed stdout.
Err(_) => false,
},
Some(ctl) = self.control.recv() => {
self.next_control = Some(ctl);
true
}
};
if msg.from != "broker" {
let body = json!({"type": "undeliverable", "reason": "the actor stopped", "to": self.id});
self.broker.send("broker", Some(&msg.from), None, Some(&msg.id), body);
}
if stuck && let Some(inst) = &self.instance {
self.broker.log(&self.id, "killed: it did not read stdin when the broker needed it");
inst.kill();
}
}
/// Start an instance of the current version. If that fails, the actor
/// stays stopped until the next start, upgrade, or rollback.
async fn start(&mut self) -> Result<()> {
let started = self.try_start();
match &started {
Ok(()) => self.started_at = Instant::now(),
Err(e) => {
self.broker.log(&self.id, &format!("cannot start version {}: {e:#}", self.version));
self.running = false;
}
}
started
}
fn try_start(&mut self) -> Result<()> {
if self.component.is_none() {
self.component = Some(self.broker.load_version(&self.id, self.version)?);
}
let cfg = self.broker.instance_config(&self.id, self.version, None);
self.instance = Some(self.broker.runtime.start(self.component.as_ref().unwrap(), cfg)?);
Ok(())
}
/// Stop the instance gracefully. Its last messages still get routed.
async fn stop(&mut self) {
let Some(inst) = self.instance.take() else { return };
let (broker, id) = (self.broker.clone(), self.id.clone());
let exit = inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE, |line| broker.route_line(&id, &line)).await;
self.broker.log(&self.id, &format!("stopped: {exit}"));
}
async fn on_exit(&mut self, exit: Exit) {
self.broker.log(&self.id, &format!("instance ended unexpectedly: {exit}"));
if let Some(p) = &mut self.probation
&& p.since.elapsed() < CRASH_WINDOW
{
p.crashes += 1;
if p.crashes >= CRASH_LIMIT {
let (from, to, requester) = (self.version, p.previous, p.requester.clone());
self.broker.log(&self.id, &format!("version {from} crashed {CRASH_LIMIT} times after the upgrade; rolling back to {to}"));
let body = match self.rollback(to).await {
Ok(_) => json!({"type": "rolled_back", "actor": self.id, "from": from, "to": to,
"reason": "crash loop after upgrade", "last_exit": exit.to_string()}),
Err(e) => json!({"type": "error", "actor": self.id, "reason": format!("automatic rollback failed: {e:#}")}),
};
self.broker.send("broker", Some(&requester), None, None, body);
return;
}
}
// Restart after 0.5 s, doubling up to 30 s; a minute of uptime resets it.
if self.started_at.elapsed() > Duration::from_secs(60) {
self.restarts = 0;
}
let delay = Duration::from_millis(500 << self.restarts.min(6)).min(Duration::from_secs(30));
self.restarts += 1;
self.restart_at = Some(Instant::now() + delay);
}
/// Make `version` current. It is compiled first (unless `component` is
/// given): if that fails, nothing changes. `data/` and `public/` stay as
/// they are.
async fn install(&mut self, version: u64, component: Option<Component>) -> Result<()> {
let component = match component {
Some(c) => c,
None => self.broker.load_version(&self.id, version)?,
};
self.stop().await;
self.broker.install(&self.id, version)?;
self.version = version;
self.component = Some(component);
self.running = true;
self.restarts = 0;
self.restart_at = None;
Ok(())
}
async fn upgrade(&mut self, version: u64, component: Component, requester: String) -> Result<Value> {
let previous = self.version;
self.install(version, Some(component)).await?;
self.probation = Some(Probation { previous, since: Instant::now(), crashes: 0, requester });
if let Err(e) = self.start().await {
self.rollback(previous).await?;
bail!("version {version} did not start ({e:#}); rolled back to {previous}");
}
Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous}))
}
async fn rollback(&mut self, version: u64) -> Result<Value> {
let from = self.version;
self.install(version, None).await?;
self.probation = None;
self.start().await?;
Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": version}))
}
/// Handle a control request. Returns `true` when the supervisor ends.
async fn handle(&mut self, ctl: Control) -> bool {
match ctl {
Control::Upgrade { version, component, requester, reply } => {
let _ = reply.send(self.upgrade(version, component, requester).await);
}
Control::Rollback { version, reply } => {
let _ = reply.send(self.rollback(version).await);
}
Control::Stop { reply } => {
self.running = false;
self.stop().await;
let r = self.broker.set_running(&self.id, false);
let _ = reply.send(r.map(|_| json!({"type": "stopped", "actor": self.id})));
}
Control::Start { reply } => {
self.running = true;
self.restart_at = None;
let r = self.broker.set_running(&self.id, true);
let _ = reply.send(r.map(|_| json!({"type": "started", "actor": self.id})));
}
Control::Shutdown { reply } => {
self.stop().await;
let _ = reply.send(Ok(json!({"type": "shut_down"})));
return true;
}
}
false
}
}
-293
View File
@@ -1,293 +0,0 @@
//! Cargo's WASI test runner: one compiled component, isolated instances, and a small core transport.
use anyhow::{Result, bail};
use sausage::{
Host,
runtime::{Exit, InstanceConfig, Runtime},
};
use serde_json::{Value, json};
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use wasmtime::component::Component;
/// A temporary world remains alive through the end of its WASI store.
struct TestWorld(PathBuf);
impl Drop for TestWorld {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
struct Run {
exit: Exit,
output: String,
}
/// Runs one instance and separates JSON-RPC requests from ordinary test output.
async fn instance(
runtime: Arc<Runtime>,
component: Component,
args: Vec<String>,
package: PathBuf,
) -> Result<Run> {
let path = std::env::temp_dir().join(format!("sausage-test-{}", uuid::Uuid::now_v7()));
let world = TestWorld(path);
let host = Host::test_world(&world.0, runtime.clone()).await?;
let tracker = sausage_vfs::Process::default();
let mut filesystem = host.filesystem().await?;
// Virtual ancestors keep directory entries of the absolute package mount traversable.
for ancestor in package
.ancestors()
.skip(1)
.filter(|path| *path != Path::new("/"))
{
filesystem.mount(
ancestor.to_str().unwrap(),
Arc::new(sausage_vfs::Memory::default()),
false,
);
}
filesystem.mount(
package.to_str().unwrap(),
Arc::new(sausage_vfs::Host::new(&package, tracker.clone())?),
false,
);
let mut env: Vec<_> = std::env::vars_os()
.filter_map(|(key, value)| Some((key.into_string().ok()?, value.into_string().ok()?)))
.filter(|(key, _)| {
["CARGO_", "RUST_", "SAUSAGE_"]
.iter()
.any(|prefix| key.starts_with(prefix))
})
.collect();
env.extend([
("SAUSAGE_PID".into(), "test".into()),
("SAUSAGE_PLUGIN".into(), "test".into()),
("SAUSAGE_PARENT".into(), "core".into()),
]);
let timeout_secs = std::env::var("SAUSAGE_TEST_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value >= 1)
.unwrap_or(sausage::cargo::DEFAULT_TEST_TIMEOUT_SECS);
let deadline = tokio::time::Instant::now()
.checked_add(std::time::Duration::from_secs(timeout_secs))
.ok_or_else(|| anyhow::anyhow!("test_timeout_secs is too large"))?;
let (ended, mut ended_rx) = tokio::sync::watch::channel(true);
let mut guest = runtime
.start_with_args(
&component,
InstanceConfig {
filesystem,
store_ended: ended,
network: true,
env,
},
&args,
)
.await?;
let mut pending = std::collections::VecDeque::new();
let mut stderr = std::mem::replace(&mut guest.stderr, tokio::sync::mpsc::channel(1).1);
let (errors, mut stderr_lines) = tokio::sync::mpsc::unbounded_channel();
let stderr_task = tokio::spawn(async move {
while let Some(line) = stderr.recv().await {
let _ = errors.send(line);
}
});
let mut output = String::new();
let mut stdout_open = true;
let mut stderr_open = true;
let mut handlers = tokio::task::JoinSet::new();
let result = tokio::time::timeout_at(deadline, async {
while stdout_open || stderr_open || !pending.is_empty() {
tokio::select! {
line=async { if let Some(line) = pending.pop_front() { Some(line) } else { guest.stdout.recv().await } }, if stdout_open || !pending.is_empty()=>match line {
Some(line)=> {
// Libtest can prefix its status line before SDK output. The SDK starts JSON on a fresh line.
if let Ok(request)=serde_json::from_str::<Value>(&line) && request["jsonrpc"]=="2.0" && request["method"].is_string() {
let method = request["method"].as_str().unwrap();
if !matches!(method, "core.lock" | "core.log") {
let params = request.get("params").cloned().unwrap_or(json!({}));
let result = match method {
"core.unlock" => host.call(method, params).await,
"core.notify" => Ok(Value::Null),
_ => Err(json!({"code":-32601,"message":"a test has no other plugins"})),
};
if let Some(id) = request.get("id") {
let response = match result { Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}), Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error}) };
if guest.send_line(&response.to_string(), |line| pending.push_back(line)).await.is_err() { guest.stdin.take(); }
}
continue;
}
let host = host.clone();
handlers.spawn(async move {
let method=request["method"].as_str().unwrap();
let params=request.get("params").cloned().unwrap_or(json!({}));
let result=match method {
"core.lock"|"core.unlock"|"core.log"=>host.call(method,params).await,
"core.notify"=>Ok(Value::Null),
_=>Err(json!({"code":-32601,"message":"a test has no other plugins"})),
};
request.get("id").map(|id|match result {Ok(result)=>json!({"jsonrpc":"2.0","id":id,"result":result}),Err(error)=>json!({"jsonrpc":"2.0","id":id,"error":error})})
});
} else {output.push_str(line.trim_end_matches(['\r', '\n']));output.push('\n');}
},None=>stdout_open=false,
},
line=stderr_lines.recv(), if stderr_open=>match line {Some(line)=>{output.push_str(&line);output.push('\n');},None=>stderr_open=false},
response=handlers.join_next(), if !handlers.is_empty()=> {
if let Some(response) = response.transpose()?.flatten()
&& guest.send_line(&response.to_string(), |line| pending.push_back(line)).await.is_err() { guest.stdin.take(); }
},
}
}
Ok::<_, anyhow::Error>(guest.wait().await)
}).await;
handlers.abort_all();
// Dropping the instance aborts even a CPU-bound WASI loop. Wait for the
// store and outstanding filesystem work before removing its test world.
drop(guest);
let _ = ended_rx.wait_for(|ended| *ended).await;
let _ = stderr_task.await;
tracker.idle().await;
host.stop().await;
let exit =
result.map_err(|_| anyhow::anyhow!("timed out after {timeout_secs} s\n{output}"))??;
Ok(Run { exit, output })
}
/// Extracts libtest names from its stable list format.
fn names(output: &str) -> Vec<String> {
output
.lines()
.filter_map(|line| line.strip_suffix(": test").map(str::to_owned))
.collect()
}
/// Lists tests, selects libtest filters, and runs each selected test on the core pool.
pub async fn run(wasm: &Path, args: Vec<String>) -> Result<bool> {
let package = std::env::current_dir()?;
let cache = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".cache")
})
.join("sausage");
let compile_path = wasm.to_path_buf();
let (runtime, component) = tokio::task::spawn_blocking(move || -> Result<_> {
let runtime = Runtime::new(&cache)?;
let component = runtime.compile(&std::fs::read(compile_path)?)?;
Ok((runtime, component))
})
.await??;
let mut listing_args = vec![wasm.display().to_string(), "--list".into()];
listing_args.extend(args.iter().filter(|arg| arg.as_str() != "--list").cloned());
let listing = instance(
runtime.clone(),
component.clone(),
listing_args.clone(),
package.clone(),
)
.await?;
if !matches!(listing.exit, Exit::Clean) {
bail!(
"cannot list WASI tests: {}\n{}",
listing.exit,
listing.output
);
}
if args.iter().any(|arg| arg == "--list") {
print!("{}", listing.output);
return Ok(true);
}
let selected = names(&listing.output);
listing_args.push("--ignored".into());
let ignored = if args
.iter()
.any(|arg| matches!(arg.as_str(), "--ignored" | "--include-ignored"))
{
Vec::new()
} else {
names(
&instance(
runtime.clone(),
component.clone(),
listing_args,
package.clone(),
)
.await?
.output,
)
};
println!("running {} tests", selected.len());
let cores = std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(1);
let semaphore = Arc::new(tokio::sync::Semaphore::new(cores));
let mut tasks = tokio::task::JoinSet::new();
let mut skipped = 0;
for name in selected {
if ignored.contains(&name) {
println!("test {name} ... ignored");
skipped += 1;
continue;
}
let runtime = runtime.clone();
let component = component.clone();
let package = package.clone();
let semaphore = semaphore.clone();
let arguments = vec![
wasm.display().to_string(),
"--exact".into(),
name.clone(),
"--nocapture".into(),
"--test-threads=1".into(),
"--include-ignored".into(),
];
tasks.spawn(async move {
let _permit = semaphore.acquire_owned().await?;
let result = instance(runtime, component, arguments, package).await;
Ok::<_, anyhow::Error>((name, result))
});
}
let mut passed = 0;
let mut failed = 0;
while let Some(result) = tasks.join_next().await {
let (name, result) = result??;
let success = result
.as_ref()
.is_ok_and(|run| matches!(run.exit, Exit::Clean));
if success {
passed += 1;
} else {
failed += 1;
}
println!("test {name} ... {}", if success { "ok" } else { "FAILED" });
match result {
Ok(run) => {
for line in run
.output
.lines()
.filter(|line| {
!line.trim().is_empty()
&& !line.starts_with("test result:")
&& !matches!(*line, "running 1 test" | "running 0 tests")
&& !matches!(*line, "ok" | "FAILED")
})
.map(|line| {
line.strip_prefix(&format!("test {name} ... "))
.unwrap_or(line)
})
.filter(|line| {
!matches!(*line, "ok" | "FAILED" | "ignored") && !line.is_empty()
})
{
// Indentation keeps ordinary test output separate from our libtest status and totals.
println!(" {line}");
}
}
Err(error) => println!("{error:#}"),
}
}
println!(
"\ntest result: {}. {passed} passed; {failed} failed; {skipped} ignored; 0 measured; 0 filtered out",
if failed == 0 { "ok" } else { "FAILED" }
);
Ok(failed == 0)
}
-192
View File
@@ -1,192 +0,0 @@
//! Grace periods, service restarts, and retirement of replaced versions (PROTOCOL.md section 3.7).
use crate::Host;
use serde_json::json;
use std::{collections::BTreeMap, sync::atomic::Ordering, time::Duration};
use tokio::sync::watch;
/// Uniform host policy (PROTOCOL 3.7), independent of discovery metadata.
const UPGRADE_GRACE: Duration = Duration::from_secs(5);
#[cfg(test)]
mod tests;
pub(crate) struct OldProcess {
pid: String,
version: String,
ended: watch::Receiver<bool>,
}
pub(crate) struct Upgrade {
plugin: String,
previous: String,
current: String,
processes: Vec<OldProcess>,
}
impl Host {
/// Snapshots old admissions while activation holds the admission write guard.
pub(crate) fn snapshot_upgrade(
&self,
plugin: &str,
previous: String,
current: &str,
) -> Upgrade {
let mut state = self.0.state.lock().unwrap();
let processes = state
.processes
.iter_mut()
.filter(|(_, process)| {
process.info.plugin == plugin
&& process.info.version != current
&& process.info.method != "rpc.discover"
})
.map(|(pid, process)| {
// The service supervisor owns the restart, including an exit on
// the superseded notice before the grace timer is installed.
process.superseded = !process.stopped;
OldProcess {
pid: pid.clone(),
version: process.info.version.clone(),
ended: process.ended.subscribe(),
}
})
.collect();
// A cleanly returned service is dormant until an upgrade. A pending
// admission will choose the current version itself. Disabled services
// (explicit stop or crash-limit give-up) must never be re-armed here.
for service in &mut state.services {
if service.enabled
&& !service.pending
&& service.owner.is_none()
&& service.version.as_deref() != Some(current)
&& service
.method
.split_once('.')
.is_some_and(|(name, _)| name == plugin)
{
self.queue_service(service);
}
}
Upgrade {
plugin: plugin.into(),
previous,
current: current.into(),
processes,
}
}
/// Lets old calls finish, stops them after grace, and starts each lifecycle hook once.
pub(crate) fn begin_upgrade(&self, upgrade: Upgrade) {
// Queue the notice before returning from activation, without waiting on guest code.
for process in &upgrade.processes {
self.send(
&process.pid,
json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":upgrade.current}}),
);
}
let host = self.clone();
tokio::spawn(async move {
if host.0.shutdown.load(Ordering::SeqCst) {
return;
}
host.0.journal.write(
"core",
&upgrade.plugin,
"core",
&format!("upgrade grace {} seconds", UPGRADE_GRACE.as_secs()),
);
let mut versions: BTreeMap<String, Vec<watch::Receiver<bool>>> = BTreeMap::new();
versions.entry(upgrade.previous).or_default();
for process in upgrade.processes {
versions
.entry(process.version)
.or_default()
.push(process.ended.clone());
let host = host.clone();
tokio::spawn(async move {
let mut ended = process.ended;
tokio::select! {
_ = ended.wait_for(|ended|*ended) => (),
_ = tokio::time::sleep(UPGRADE_GRACE) => { host.begin_retire(&process.pid); }
}
});
}
for (version, processes) in versions {
let host = host.clone();
let plugin = upgrade.plugin.clone();
let retirement = (plugin.clone(), version.clone());
if !host
.0
.retirements
.lock()
.unwrap()
.insert(retirement.clone())
{
continue;
}
tokio::spawn(async move {
for mut ended in processes {
let _ = ended.wait_for(|ended| *ended).await;
}
loop {
if host.0.shutdown.load(Ordering::SeqCst) {
host.0.retirements.lock().unwrap().remove(&retirement);
return;
}
let live: Vec<_> = host
.0
.state
.lock()
.unwrap()
.processes
.values()
.filter(|process| {
process.info.plugin == plugin
&& process.info.version == version
&& process.info.method != "rpc.discover"
})
.map(|process| process.ended.subscribe())
.collect();
if live.is_empty() {
break;
}
for mut ended in live {
let _ = ended.wait_for(|ended| *ended).await;
}
}
if host
.0
.graph
.lock()
.unwrap()
.plugins
.get(&plugin)
.is_some_and(|current| current.hash == version)
{
host.0.retirements.lock().unwrap().remove(&retirement);
return;
}
host.0.journal.write(
"core",
&plugin,
"core",
&format!("retired start {version}"),
);
let result = host
.route(
"core".into(),
Some(json!(uuid::Uuid::now_v7().to_string())),
format!("{plugin}.rpc.retired"),
json!({"previous":version}),
true,
)
.await;
host.0.journal.write(
"core",
&plugin,
"core",
&format!("retired result {result:?}"),
);
host.0.retirements.lock().unwrap().remove(&retirement);
});
}
});
}
}
-115
View File
@@ -1,115 +0,0 @@
//! Controlled-clock coverage of both upgrade windows and discovery exclusion.
use super::*;
use crate::{
process::{Process, ProcessInfo},
runtime::Runtime,
world::World,
};
use tokio::{
sync::mpsc,
task::JoinHandle,
time::{Instant, advance},
};
/// A process with active work that ignores both superseded and EOF.
fn process(
host: &Host,
pid: &str,
method: &str,
) -> (mpsc::UnboundedReceiver<serde_json::Value>, JoinHandle<()>) {
let (input, incoming) = mpsc::unbounded_channel();
let task = tokio::spawn(std::future::pending());
host.0.state.lock().unwrap().processes.insert(
pid.into(),
Process {
info: ProcessInfo {
plugin: "notes".into(),
version: "old".into(),
method: method.into(),
params: json!({}),
parent: None,
detached: true,
start_time: 0,
state: "running".into(),
},
memory: sausage_vfs::Memory::default(),
files: sausage_vfs::Process::default(),
store_ended: watch::channel(true).0,
input: Some(input),
abort: Some(task.abort_handle()),
caller: "console".into(),
id: None,
answer: None,
ended: watch::channel(false).0,
stopped: false,
superseded: false,
},
);
(incoming, task)
}
/// PROTOCOL 3.7/3.6: five seconds running, then five seconds stopping; discovery is untouched.
#[tokio::test]
async fn two_five_second_windows_and_discovery_exclusion() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("world");
World::create(&root).unwrap();
let host = Host::test_world(&root, Runtime::new(&dir.path().join("cache")).unwrap())
.await
.unwrap();
let (mut input, work) = process(&host, "old-work", "work");
let (mut discovery_input, discovery) = process(&host, "old-discovery", "rpc.discover");
let upgrade = host.snapshot_upgrade("notes", "old".into(), "new");
assert_eq!(upgrade.processes.len(), 1);
tokio::time::pause();
let start = Instant::now();
host.begin_upgrade(upgrade);
assert_eq!(
input.recv().await.unwrap(),
json!({"jsonrpc":"2.0","method":"rpc.superseded","params":{"current":"new"}})
);
// Run the upgrade dispatcher, then its per-process task, so the deadline is armed.
tokio::task::yield_now().await;
tokio::task::yield_now().await;
let window = Duration::from_secs(5);
assert_eq!(UPGRADE_GRACE, window);
// tokio::time::sleep documents millisecond granularity; the paused driver
// may round a deadline up by one tick. Check each window independently.
let tick = Duration::from_millis(1);
let before_deadline = window - tick;
advance(before_deadline).await;
tokio::task::yield_now().await;
assert_eq!(host.processes()["old-work"].state, "running");
assert!(!work.is_finished());
advance(tick).await;
assert!(
input.recv().await.is_none(),
"EOF at grace, even with active work"
);
let stopped = Instant::now();
assert!((window..=window + tick).contains(&(stopped - start)));
assert_eq!(host.processes()["old-work"].state, "stopping");
// begin_retire schedules the independent PROTOCOL 3.6 deadline.
tokio::task::yield_now().await;
advance(before_deadline).await;
tokio::task::yield_now().await;
assert!(
!work.is_finished(),
"on_stop still has its full five seconds"
);
advance(tick).await;
assert!(work.await.unwrap_err().is_cancelled());
assert!((window..=window + tick).contains(&(Instant::now() - stopped)));
assert_eq!(host.processes()["old-discovery"].state, "running");
assert!(matches!(
discovery_input.try_recv(),
Err(mpsc::error::TryRecvError::Empty)
));
assert!(!discovery.is_finished());
discovery.abort();
let _ = discovery.await;
// Synthetic processes have no execution cleanup task; discard only this test table.
host.0.shutdown.store(true, Ordering::SeqCst);
host.0.state.lock().unwrap().processes.clear();
host.stop().await;
}
+796
View File
@@ -0,0 +1,796 @@
//! Actor-served files: `/actor/<id>/fs/...` (see PROTOCOL.md, "Actor-served
//! files").
//!
//! This module wraps the `wasi:filesystem` implementation of `wasmtime-wasi`.
//! A path under `/actor/<id>/fs/` does not reach the disk: the host asks the
//! actor `<id>` with a message from `fs` and waits up to `ASK_TIMEOUT` for
//! the reply. Other paths go to `wasmtime-wasi` unchanged except that an
//! actor directory listing advertises `fs/` when its source docs declare it.
//!
//! - `fs_read {path}` -> `fs_file {text | base64}`, `fs_dir {entries: [{name, type}]}`
//! - `fs_write {path, text | base64}`, `fs_mkdir {path}`, `fs_remove {path}`,
//! `fs_rename {path, to}` -> `fs_done`
//! - any of them -> `fs_error {errno}` (a `wasi:filesystem` error-code name)
//!
//! A file is read once when it is opened. After a change, it is written back
//! in one `fs_write` when it is synced or closed.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Weak};
use bytes::Bytes;
use serde_json::{Value, json};
use wasmtime::component::{HasData, Resource};
use wasmtime_wasi::filesystem::WasiFilesystemCtxView;
use wasmtime_wasi::p2::bindings::filesystem::types::{
self, DescriptorFlags, DescriptorType, DirectoryEntry, ErrorCode, HostDescriptor, HostDirectoryEntryStream, OpenFlags,
};
use wasmtime_wasi::p2::bindings::filesystem::preopens;
use wasmtime_wasi::p2::pipe::MemoryInputPipe;
use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult, OutputStream, Pollable, StreamResult};
use crate::broker::Broker;
/// The `from` of the host's requests. Replies to it go back to the waiting call.
pub const ID: &str = "fs";
pub const ASK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Per-instance state.
pub struct VfsState {
broker: Weak<Broker>,
/// The actor of this instance, for notes in its `stderr.log`.
me: String,
/// Native directory descriptors and their absolute paths. Keeping every
/// `/actor` child (not only the preopen) lets `fs/` work after traversal.
dirs: HashMap<u32, String>,
}
impl VfsState {
pub fn new(broker: Weak<Broker>, me: String) -> Self {
Self { broker, me, dirs: HashMap::new() }
}
}
/// [`HasData`] for the linker: the filesystem functions get a [`VfsView`].
pub struct Vfs;
impl HasData for Vfs {
type Data<'a> = VfsView<'a>;
}
pub struct VfsView<'a> {
pub inner: WasiFilesystemCtxView<'a>,
pub state: &'a mut VfsState,
}
/// A descriptor of an actor-served file or folder (in the resource table in
/// place of a `wasmtime-wasi` descriptor).
#[derive(Clone)]
enum Virt {
Dir { actor: String, path: String },
File(VFile),
}
#[derive(Clone)]
struct VFile {
actor: String,
path: String,
data: Arc<Mutex<Vec<u8>>>,
dirty: Arc<AtomicBool>,
write: bool,
}
/// A directory-entry-stream of an actor-served folder.
struct VDir(std::vec::IntoIter<DirectoryEntry>);
enum Node {
File(Vec<u8>),
Dir(Vec<DirectoryEntry>),
}
/// Sends the requests. Owned, so that no borrow of the view lives across an await.
struct Asker {
broker: Option<Arc<Broker>>,
me: String,
}
impl Asker {
async fn ask(&self, actor: &str, body: Value) -> Result<Value, ErrorCode> {
let broker = self.broker.as_ref().ok_or(ErrorCode::Io)?;
let what = format!("{} {} to {actor}", body["type"].as_str().unwrap_or(""), body["path"].as_str().unwrap_or(""));
match broker.ask(actor, body).await {
None => {
broker.log(&self.me, &format!("{what}: no reply within {} s", ASK_TIMEOUT.as_secs()));
Err(ErrorCode::Io)
}
Some(reply) => match reply["type"].as_str() {
Some("fs_error") => Err(errno(&reply["errno"])),
Some("undeliverable") => Err(ErrorCode::NoEntry),
_ => Ok(reply),
},
}
}
async fn read(&self, actor: &str, path: &str) -> Result<Node, ErrorCode> {
let reply = self.ask(actor, json!({"type": "fs_read", "path": path})).await?;
match reply["type"].as_str() {
Some("fs_file") => content(&reply).map(Node::File),
Some("fs_dir") => Ok(Node::Dir(
reply["entries"]
.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let type_ = if e["type"] == "dir" { DescriptorType::Directory } else { DescriptorType::RegularFile };
Some(DirectoryEntry { type_, name: e["name"].as_str()?.to_string() })
})
.collect(),
)),
_ => {
if let Some(b) = &self.broker {
b.log(&self.me, &format!("fs_read {path} to {actor}: unexpected reply {reply}"));
}
Err(ErrorCode::Io)
}
}
}
async fn change(&self, actor: &str, body: Value) -> FsResult<()> {
self.ask(actor, body).await.map(|_| ()).map_err(FsError::from)
}
async fn flush(&self, f: &VFile) -> FsResult<()> {
if f.dirty.swap(false, Ordering::SeqCst)
&& let Err(e) = self.ask(&f.actor, write_body(f)).await
{
f.dirty.store(true, Ordering::SeqCst);
return Err(e.into());
}
Ok(())
}
}
fn errno(v: &Value) -> ErrorCode {
match v.as_str().unwrap_or("") {
"no-entry" => ErrorCode::NoEntry,
"exist" => ErrorCode::Exist,
"not-directory" => ErrorCode::NotDirectory,
"is-directory" => ErrorCode::IsDirectory,
"not-empty" => ErrorCode::NotEmpty,
"access" => ErrorCode::Access,
"not-permitted" => ErrorCode::NotPermitted,
"invalid" => ErrorCode::Invalid,
"unsupported" => ErrorCode::Unsupported,
"busy" => ErrorCode::Busy,
_ => ErrorCode::Io,
}
}
fn content(v: &Value) -> Result<Vec<u8>, ErrorCode> {
if let Some(text) = v["text"].as_str() {
return Ok(text.as_bytes().to_vec());
}
match v["base64"].as_str() {
Some(b) => base64_decode(b).ok_or(ErrorCode::IllegalByteSequence),
None => Ok(Vec::new()),
}
}
fn write_body(f: &VFile) -> Value {
let data = f.data.lock().unwrap().clone();
match String::from_utf8(data) {
Ok(text) => json!({"type": "fs_write", "path": f.path, "text": text}),
Err(e) => json!({"type": "fs_write", "path": f.path, "base64": base64_encode(e.as_bytes())}),
}
}
/// `rel` resolved against the folder `base`, as an absolute path.
fn join(base: &str, rel: &str) -> String {
let mut parts: Vec<&str> = base.split('/').filter(|p| !p.is_empty()).collect();
for p in rel.split('/') {
match p {
"" | "." => {}
".." => {
parts.pop();
}
p => parts.push(p),
}
}
format!("/{}", parts.join("/"))
}
/// Split a global path under an actor's virtual `fs/` into its owner and
/// actor-relative path.
fn actor_fs_path(full: &str) -> Option<(String, String)> {
let rest = full.strip_prefix("/actor/")?;
let (actor, rest) = rest.split_once('/')?;
let (fs, path) = rest.split_once('/').unwrap_or((rest, ""));
(fs == "fs" && !actor.is_empty()).then(|| (actor.to_string(), format!("/{path}")))
}
fn actor_root(path: &str) -> Option<&str> {
let actor = path.strip_prefix("/actor/")?;
(!actor.is_empty() && !actor.contains('/')).then_some(actor)
}
/// An actor advertises its virtual filesystem in its leading `//!` docs.
/// This is discovery metadata; actual reads still go through `fs_read`.
fn declares_actor_fs(source: &str) -> bool {
let docs = source
.lines()
.take_while(|line| line.trim().starts_with("//!"))
.map(|line| line.trim().trim_start_matches("//!").trim())
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
docs.contains("actor-served files") && docs.contains("fs_read")
}
fn stat(type_: DescriptorType, size: u64) -> types::DescriptorStat {
types::DescriptorStat {
type_,
link_count: 1,
size,
data_access_timestamp: None,
data_modification_timestamp: None,
status_change_timestamp: None,
}
}
fn node_stat(node: &Node) -> types::DescriptorStat {
match node {
Node::File(data) => stat(DescriptorType::RegularFile, data.len() as u64),
Node::Dir(_) => stat(DescriptorType::Directory, 0),
}
}
fn hash(actor: &str, path: &str) -> types::MetadataHashValue {
let mut h = std::collections::hash_map::DefaultHasher::new();
(actor, path).hash(&mut h);
types::MetadataHashValue { lower: h.finish(), upper: 0 }
}
fn write_at(data: &mut Vec<u8>, at: usize, bytes: &[u8]) {
if data.len() < at + bytes.len() {
data.resize(at + bytes.len(), 0);
}
data[at..at + bytes.len()].copy_from_slice(bytes);
}
/// An output stream into an actor-served file (`pos: None` appends).
struct VWriter {
file: VFile,
pos: Option<usize>,
}
#[wasmtime_wasi::async_trait]
impl Pollable for VWriter {
async fn ready(&mut self) {}
}
#[wasmtime_wasi::async_trait]
impl OutputStream for VWriter {
fn write(&mut self, bytes: Bytes) -> StreamResult<()> {
let mut data = self.file.data.lock().unwrap();
let at = self.pos.unwrap_or(data.len());
write_at(&mut data, at, &bytes);
if let Some(p) = &mut self.pos {
*p += bytes.len();
}
self.file.dirty.store(true, Ordering::SeqCst);
Ok(())
}
fn flush(&mut self) -> StreamResult<()> {
Ok(())
}
fn check_write(&mut self) -> StreamResult<usize> {
Ok(64 * 1024)
}
}
impl VfsView<'_> {
fn asker(&self) -> Asker {
Asker { broker: self.state.broker.upgrade(), me: self.state.me.clone() }
}
fn actor_serves_fs(&self, actor: &str) -> bool {
self.state
.broker
.upgrade()
.and_then(|broker| std::fs::read_to_string(broker.world.source_dir(actor).join("src/main.rs")).ok())
.is_some_and(|source| declares_actor_fs(&source))
}
fn virt(&mut self, fd: &Resource<types::Descriptor>) -> Option<Virt> {
self.inner.table.get_any_mut(fd.rep()).ok()?.downcast_ref::<Virt>().cloned()
}
fn file(&mut self, fd: &Resource<types::Descriptor>) -> Option<FsResult<VFile>> {
match self.virt(fd)? {
Virt::File(f) => Some(Ok(f)),
Virt::Dir { .. } => Some(Err(ErrorCode::IsDirectory.into())),
}
}
/// The actor and its path, if `path` relative to `fd` is actor-served.
fn target(&mut self, fd: &Resource<types::Descriptor>, path: &str) -> Option<(String, String)> {
match self.virt(fd) {
Some(Virt::Dir { actor, path: base }) => Some((actor, join(&base, path))),
Some(Virt::File(f)) => Some((f.actor, join(&f.path, path))),
None => {
let base = self.state.dirs.get(&fd.rep())?;
actor_fs_path(&join(base, path))
}
}
}
async fn native_directory_entries(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Vec<DirectoryEntry>> {
let stream = HostDescriptor::read_directory(&mut self.inner, fd).await?;
let stream_rep = stream.rep();
let mut entries = Vec::new();
loop {
match HostDirectoryEntryStream::read_directory_entry(&mut self.inner, Resource::new_borrow(stream_rep)).await? {
Some(entry) => entries.push(entry),
None => break,
}
}
HostDirectoryEntryStream::drop(&mut self.inner, stream).map_err(|_| ErrorCode::Io)?;
Ok(entries)
}
fn push(&mut self, v: Virt) -> FsResult<Resource<types::Descriptor>> {
let r = self.inner.table.push(v)?;
Ok(Resource::new_own(r.rep()))
}
async fn open(&mut self, actor: String, path: String, oflags: OpenFlags, flags: DescriptorFlags) -> FsResult<Resource<types::Descriptor>> {
let write = flags.contains(DescriptorFlags::WRITE);
let create = oflags.contains(OpenFlags::CREATE);
let exclusive = create && oflags.contains(OpenFlags::EXCLUSIVE);
let virt = match self.asker().read(&actor, &path).await {
Ok(_) if exclusive => return Err(ErrorCode::Exist.into()),
Ok(Node::Dir(_)) if write => return Err(ErrorCode::IsDirectory.into()),
Ok(Node::Dir(_)) => Virt::Dir { actor, path },
Ok(Node::File(_)) if oflags.contains(OpenFlags::DIRECTORY) => return Err(ErrorCode::NotDirectory.into()),
Ok(Node::File(data)) => {
let truncate = oflags.contains(OpenFlags::TRUNCATE);
let data = if truncate { Vec::new() } else { data };
Virt::File(VFile { actor, path, data: Arc::new(Mutex::new(data)), dirty: Arc::new(AtomicBool::new(truncate)), write })
}
Err(ErrorCode::NoEntry) if create && !oflags.contains(OpenFlags::DIRECTORY) => Virt::File(VFile {
actor,
path,
data: Arc::default(),
dirty: Arc::new(AtomicBool::new(true)),
write,
}),
Err(e) => return Err(e.into()),
};
self.push(virt)
}
/// Send the changed file without waiting (the descriptor is gone). The
/// request leaves now, so later requests of this instance come after it.
fn write_back_later(&self, f: &VFile) {
let Some(broker) = self.state.broker.upgrade() else { return };
let reply = broker.ask(&f.actor, write_body(f));
let (me, what) = (self.state.me.clone(), format!("fs_write {} to {}", f.path, f.actor));
tokio::spawn(async move {
match reply.await {
Some(r) if r["type"] != "fs_error" && r["type"] != "undeliverable" => {}
r => broker.log(&me, &format!("{what} failed: {}", r.map_or("no reply".to_string(), |r| r.to_string()))),
}
});
}
}
impl preopens::Host for VfsView<'_> {
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
let dirs = preopens::Host::get_directories(&mut self.inner)?;
for (fd, name) in &dirs {
if name == "/actor" {
self.state.dirs.insert(fd.rep(), "/actor".to_string());
}
}
Ok(dirs)
}
}
impl types::Host for VfsView<'_> {
fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result<ErrorCode> {
types::Host::convert_error_code(&mut self.inner, err)
}
fn filesystem_error_code(&mut self, err: Resource<wasmtime::Error>) -> wasmtime::Result<Option<ErrorCode>> {
types::Host::filesystem_error_code(&mut self.inner, err)
}
}
impl HostDescriptor for VfsView<'_> {
async fn advise(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize, len: types::Filesize, advice: types::Advice) -> FsResult<()> {
if self.virt(&fd).is_some() {
return Ok(());
}
HostDescriptor::advise(&mut self.inner, fd, offset, len, advice).await
}
async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
match self.virt(&fd) {
Some(Virt::File(f)) => self.asker().flush(&f).await,
Some(Virt::Dir { .. }) => Ok(()),
None => HostDescriptor::sync_data(&mut self.inner, fd).await,
}
}
async fn get_flags(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorFlags> {
match self.virt(&fd) {
Some(Virt::File(f)) if f.write => Ok(DescriptorFlags::READ | DescriptorFlags::WRITE),
Some(Virt::File(_)) => Ok(DescriptorFlags::READ),
Some(Virt::Dir { .. }) => Ok(DescriptorFlags::READ | DescriptorFlags::MUTATE_DIRECTORY),
None => HostDescriptor::get_flags(&mut self.inner, fd).await,
}
}
async fn get_type(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorType> {
match self.virt(&fd) {
Some(Virt::File(_)) => Ok(DescriptorType::RegularFile),
Some(Virt::Dir { .. }) => Ok(DescriptorType::Directory),
None => HostDescriptor::get_type(&mut self.inner, fd).await,
}
}
async fn set_size(&mut self, fd: Resource<types::Descriptor>, size: types::Filesize) -> FsResult<()> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
f.data.lock().unwrap().resize(size as usize, 0);
f.dirty.store(true, Ordering::SeqCst);
Ok(())
}
None => HostDescriptor::set_size(&mut self.inner, fd, size).await,
}
}
async fn set_times(&mut self, fd: Resource<types::Descriptor>, atim: types::NewTimestamp, mtim: types::NewTimestamp) -> FsResult<()> {
if self.virt(&fd).is_some() {
return Ok(());
}
HostDescriptor::set_times(&mut self.inner, fd, atim, mtim).await
}
async fn read(&mut self, fd: Resource<types::Descriptor>, len: types::Filesize, offset: types::Filesize) -> FsResult<(Vec<u8>, bool)> {
match self.file(&fd) {
Some(f) => {
let f = f?;
let data = f.data.lock().unwrap();
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
let end = start.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).min(data.len());
Ok((data[start..end].to_vec(), start == end))
}
None => HostDescriptor::read(&mut self.inner, fd, len, offset).await,
}
}
async fn write(&mut self, fd: Resource<types::Descriptor>, buf: Vec<u8>, offset: types::Filesize) -> FsResult<types::Filesize> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
write_at(&mut f.data.lock().unwrap(), offset as usize, &buf);
f.dirty.store(true, Ordering::SeqCst);
Ok(buf.len() as u64)
}
None => HostDescriptor::write(&mut self.inner, fd, buf, offset).await,
}
}
async fn read_directory(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<types::DirectoryEntryStream>> {
match self.virt(&fd) {
Some(Virt::Dir { actor, path }) => match self.asker().read(&actor, &path).await? {
Node::Dir(entries) => {
let r = self.inner.table.push(VDir(entries.into_iter()))?;
Ok(Resource::new_own(r.rep()))
}
Node::File(_) => Err(ErrorCode::NotDirectory.into()),
},
Some(Virt::File(_)) => Err(ErrorCode::NotDirectory.into()),
None => {
let actor = self.state.dirs.get(&fd.rep()).and_then(|path| actor_root(path)).map(str::to_string);
if actor.as_deref().is_some_and(|id| self.actor_serves_fs(id)) {
let mut entries = self.native_directory_entries(fd).await?;
if !entries.iter().any(|entry| entry.name == "fs") {
entries.push(DirectoryEntry { type_: DescriptorType::Directory, name: "fs".to_string() });
}
let r = self.inner.table.push(VDir(entries.into_iter()))?;
Ok(Resource::new_own(r.rep()))
} else {
HostDescriptor::read_directory(&mut self.inner, fd).await
}
}
}
}
async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
match self.virt(&fd) {
Some(Virt::File(f)) => self.asker().flush(&f).await,
Some(Virt::Dir { .. }) => Ok(()),
None => HostDescriptor::sync(&mut self.inner, fd).await,
}
}
async fn create_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
match self.target(&fd, &path) {
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_mkdir", "path": path})).await,
None => HostDescriptor::create_directory_at(&mut self.inner, fd, path).await,
}
}
async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
match self.virt(&fd) {
Some(Virt::File(f)) => Ok(stat(DescriptorType::RegularFile, f.data.lock().unwrap().len() as u64)),
Some(Virt::Dir { .. }) => Ok(stat(DescriptorType::Directory, 0)),
None => HostDescriptor::stat(&mut self.inner, fd).await,
}
}
async fn stat_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::DescriptorStat> {
match self.target(&fd, &path) {
Some((actor, path)) => Ok(node_stat(&self.asker().read(&actor, &path).await?)),
None => HostDescriptor::stat_at(&mut self.inner, fd, path_flags, path).await,
}
}
async fn set_times_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
atim: types::NewTimestamp,
mtim: types::NewTimestamp,
) -> FsResult<()> {
if self.target(&fd, &path).is_some() {
return Ok(());
}
HostDescriptor::set_times_at(&mut self.inner, fd, path_flags, path, atim, mtim).await
}
async fn link_at(
&mut self,
fd: Resource<types::Descriptor>,
old_path_flags: types::PathFlags,
old_path: String,
new_descriptor: Resource<types::Descriptor>,
new_path: String,
) -> FsResult<()> {
if self.target(&fd, &old_path).is_some() || self.target(&new_descriptor, &new_path).is_some() {
return Err(ErrorCode::Unsupported.into());
}
HostDescriptor::link_at(&mut self.inner, fd, old_path_flags, old_path, new_descriptor, new_path).await
}
async fn open_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
oflags: OpenFlags,
flags: DescriptorFlags,
) -> FsResult<Resource<types::Descriptor>> {
match self.target(&fd, &path) {
Some((actor, path)) => self.open(actor, path, oflags, flags).await,
None => {
let child_path = self.state.dirs.get(&fd.rep()).map(|base| join(base, &path));
let opened = HostDescriptor::open_at(&mut self.inner, fd, path_flags, path, oflags, flags).await?;
if let Some(path) = child_path {
if HostDescriptor::get_type(&mut self.inner, Resource::new_borrow(opened.rep())).await? == DescriptorType::Directory {
self.state.dirs.insert(opened.rep(), path);
}
}
Ok(opened)
}
}
}
fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
if self.virt(&fd).is_some() {
if let Virt::File(f) = self.inner.table.delete(Resource::<Virt>::new_own(fd.rep()))?
&& f.dirty.load(Ordering::SeqCst)
{
self.write_back_later(&f);
}
return Ok(());
}
self.state.dirs.remove(&fd.rep());
HostDescriptor::drop(&mut self.inner, fd)
}
async fn readlink_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<String> {
if self.target(&fd, &path).is_some() {
return Err(ErrorCode::Invalid.into());
}
HostDescriptor::readlink_at(&mut self.inner, fd, path).await
}
async fn remove_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
match self.target(&fd, &path) {
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
None => HostDescriptor::remove_directory_at(&mut self.inner, fd, path).await,
}
}
async fn rename_at(&mut self, fd: Resource<types::Descriptor>, old_path: String, new_fd: Resource<types::Descriptor>, new_path: String) -> FsResult<()> {
match (self.target(&fd, &old_path), self.target(&new_fd, &new_path)) {
(None, None) => HostDescriptor::rename_at(&mut self.inner, fd, old_path, new_fd, new_path).await,
(Some((a, from)), Some((b, to))) if a == b => {
self.asker().change(&a, json!({"type": "fs_rename", "path": from, "to": to})).await
}
_ => Err(ErrorCode::Unsupported.into()),
}
}
async fn symlink_at(&mut self, fd: Resource<types::Descriptor>, src_path: String, dest_path: String) -> FsResult<()> {
if self.target(&fd, &dest_path).is_some() {
return Err(ErrorCode::Unsupported.into());
}
HostDescriptor::symlink_at(&mut self.inner, fd, src_path, dest_path).await
}
async fn unlink_file_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
match self.target(&fd, &path) {
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
None => HostDescriptor::unlink_file_at(&mut self.inner, fd, path).await,
}
}
fn read_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynInputStream>> {
match self.file(&fd) {
Some(f) => {
let f = f?;
let data = f.data.lock().unwrap();
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
let stream: DynInputStream = Box::new(MemoryInputPipe::new(data[start..].to_vec()));
std::mem::drop(data);
Ok(self.inner.table.push(stream)?)
}
None => HostDescriptor::read_via_stream(&mut self.inner, fd, offset),
}
}
fn write_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynOutputStream>> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: Some(offset as usize) });
Ok(self.inner.table.push(stream)?)
}
None => HostDescriptor::write_via_stream(&mut self.inner, fd, offset),
}
}
fn append_via_stream(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<DynOutputStream>> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: None });
Ok(self.inner.table.push(stream)?)
}
None => HostDescriptor::append_via_stream(&mut self.inner, fd),
}
}
async fn is_same_object(&mut self, a: Resource<types::Descriptor>, b: Resource<types::Descriptor>) -> wasmtime::Result<bool> {
let key = |v: Virt| match v {
Virt::Dir { actor, path } => (actor, path),
Virt::File(f) => (f.actor, f.path),
};
match (self.virt(&a), self.virt(&b)) {
(None, None) => HostDescriptor::is_same_object(&mut self.inner, a, b).await,
(Some(x), Some(y)) => Ok(key(x) == key(y)),
_ => Ok(false),
}
}
async fn metadata_hash(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::MetadataHashValue> {
match self.virt(&fd) {
Some(Virt::Dir { actor, path }) => Ok(hash(&actor, &path)),
Some(Virt::File(f)) => Ok(hash(&f.actor, &f.path)),
None => HostDescriptor::metadata_hash(&mut self.inner, fd).await,
}
}
async fn metadata_hash_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::MetadataHashValue> {
match self.target(&fd, &path) {
Some((actor, path)) => Ok(hash(&actor, &path)),
None => HostDescriptor::metadata_hash_at(&mut self.inner, fd, path_flags, path).await,
}
}
}
impl HostDirectoryEntryStream for VfsView<'_> {
async fn read_directory_entry(&mut self, stream: Resource<types::DirectoryEntryStream>) -> FsResult<Option<DirectoryEntry>> {
if let Some(dir) = self.inner.table.get_any_mut(stream.rep()).ok().and_then(|a| a.downcast_mut::<VDir>()) {
return Ok(dir.0.next());
}
HostDirectoryEntryStream::read_directory_entry(&mut self.inner, stream).await
}
fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
let ours = self.inner.table.get_any_mut(stream.rep()).ok().is_some_and(|a| a.is::<VDir>());
if ours {
self.inner.table.delete(Resource::<VDir>::new_own(stream.rep()))?;
return Ok(());
}
HostDirectoryEntryStream::drop(&mut self.inner, stream)
}
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn base64_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i));
for i in 0..4 {
out.push(if i <= chunk.len() { B64[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' });
}
}
out
}
fn base64_decode(text: &str) -> Option<Vec<u8>> {
let (mut out, mut n, mut bits) = (Vec::new(), 0u32, 0);
for c in text.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
n = (n << 6 | B64.iter().position(|b| *b == c)? as u32) & 0xFF_FFFF;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((n >> bits) as u8);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn actor_fs_paths_and_discovery_docs() {
assert_eq!(actor_fs_path("/actor/brain/fs"), Some(("brain".to_string(), "/".to_string())));
assert_eq!(actor_fs_path("/actor/brain/fs/docs/readme.md"), Some(("brain".to_string(), "/docs/readme.md".to_string())));
assert_eq!(actor_fs_path("/actor/brain/public/fs/file"), None);
assert_eq!(actor_root("/actor/brain"), Some("brain"));
assert_eq!(actor_root("/actor/brain/fs"), None);
assert!(declares_actor_fs("//! brain\n//! Actor-served files include fs_read.\nfn main() {}"));
assert!(!declares_actor_fs("//! actor without fs support\nfn main() {}"));
}
#[test]
fn base64_round_trip() {
for s in ["", "a", "ab", "abc", "abcd", "\u{0}\u{ff}binary"] {
let enc = base64_encode(s.as_bytes());
assert_eq!(base64_decode(&enc).unwrap(), s.as_bytes());
}
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
}
#[test]
fn join_resolves_dots() {
assert_eq!(join("/a/b", "../c/./d"), "/a/c/d");
assert_eq!(join("/", "x/fs"), "/x/fs");
assert_eq!(join("/", ".."), "/");
}
}
-84
View File
@@ -1,84 +0,0 @@
//! Background discovery and nonrecursive notifications for installed plugin versions.
use crate::Host;
use notify::{RecursiveMode, Watcher};
impl Host {
/// Watches only the plugin root and its immediate folders. Discovery survives watcher failure.
pub(crate) fn watch_plugins(&self) -> anyhow::Result<()> {
let root = self.0.world.root.join("plugins");
let watched_root = root.clone();
let (events, mut incoming) = tokio::sync::mpsc::unbounded_channel();
let setup = (|| -> anyhow::Result<_> {
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
if let Ok(event) = event
&& !matches!(event.kind, notify::EventKind::Access(_))
&& event.paths.iter().any(|path| {
path.parent() == Some(&watched_root)
|| path.file_name().is_some_and(|name| name == "plugin.wasm")
})
{
let _ = events.send(());
}
})?;
watcher.watch(&root, RecursiveMode::NonRecursive)?;
for entry in std::fs::read_dir(&root)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
watcher.watch(&entry.path(), RecursiveMode::NonRecursive)?;
}
}
Ok(watcher)
})();
match setup {
Ok(watcher) => *self.0.watcher.lock().unwrap() = Some(watcher),
Err(error) => {
self.0
.journal
.write("core", "core", "core", &format!("watcher failed {error}"))
}
}
let weak = std::sync::Arc::downgrade(&self.0);
tokio::spawn(async move {
loop {
let Some(inner) = weak.upgrade() else {
break;
};
let host = Host(inner);
// Root events also cover plugin folders created after startup.
if let Some(watcher) = host.0.watcher.lock().unwrap().as_mut()
&& let Ok(entries) = std::fs::read_dir(&root)
{
for entry in entries.flatten() {
if entry.file_type().is_ok_and(|kind| kind.is_dir())
&& let Err(error) =
watcher.watch(&entry.path(), RecursiveMode::NonRecursive)
{
host.0.journal.write(
"core",
"core",
"core",
&format!("watcher failed {error}"),
);
}
}
}
let _activation = host.0.activations.lock().await;
if let Err(error) = host.refresh_graph().await {
host.0.journal.write(
"core",
"core",
"core",
&format!("discovery failed {error}"),
);
}
drop(_activation);
drop(host);
if incoming.recv().await.is_none() {
break;
}
while incoming.try_recv().is_ok() {}
}
});
Ok(())
}
}
-178
View File
@@ -1,178 +0,0 @@
//! Armed plugin-file watches. A Notify permit coalesces changes without losing a later change.
use crate::{Host, RpcResult, error};
use sausage_vfs::observe::under;
use serde_json::{Value, json};
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use tokio::sync::Notify;
use uuid::Uuid;
pub(crate) struct Watch {
path: String,
changed: Arc<Notify>,
}
pub(crate) type Watches = Arc<Mutex<HashMap<Uuid, Watch>>>;
pub(crate) fn changed(watches: &Watches, path: &str) {
let targets: Vec<_> = watches
.lock()
.unwrap()
.values()
// Removing/moving an ancestor also invalidates a watch inside that subtree.
.filter(|w| under(path, &w.path) || under(&w.path, path))
.map(|w| w.changed.clone())
.collect();
for changed in targets {
changed.notify_one();
}
}
struct Registration {
watches: Watches,
key: Uuid,
}
impl Drop for Registration {
fn drop(&mut self) {
self.watches.lock().unwrap().remove(&self.key);
}
}
fn path(caller: &str, path: &str) -> String {
let path = sausage_vfs::resolve("/", path);
let path = path.trim_end_matches('/');
if path.is_empty() {
return "/".into();
}
if under(path, "/self") {
format!("/proc/{caller}{}", &path[5..])
} else {
path.into()
}
}
impl Host {
pub(crate) async fn watch(&self, caller: &str, id: Option<Value>, target: &str) -> RpcResult {
let id = id.ok_or_else(|| error(-32600, "watch needs a request ID"))?;
let mut ended = self
.0
.state
.lock()
.unwrap()
.processes
.get(caller)
.filter(|p| p.info.state == "running")
.map(|p| p.ended.subscribe())
.ok_or_else(|| error(-32600, "watch needs a running caller process"))?;
let changed = Arc::new(Notify::new());
let registration = Registration {
watches: self.0.watches.clone(),
key: Uuid::now_v7(),
};
self.0.watches.lock().unwrap().insert(
registration.key,
Watch {
path: path(caller, target),
changed: changed.clone(),
},
);
self.call_notify(caller, &id, "armed", json!({}));
loop {
tokio::select! {
biased;
_ = ended.wait_for(|ended| *ended) => return Ok(Value::Null),
_ = changed.notified() => self.call_notify(caller, &id, "changed", json!({})),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Pending changes coalesce; a change after consuming the last permit is retained.
#[tokio::test]
async fn coalescing_and_cleanup() {
let watches = Watches::default();
let n = Arc::new(Notify::new());
let r = Registration {
watches: watches.clone(),
key: Uuid::now_v7(),
};
watches.lock().unwrap().insert(
r.key,
Watch {
path: "/mailbox".into(),
changed: n.clone(),
},
);
changed(&watches, "/mailboxes/a");
assert!(
tokio::time::timeout(std::time::Duration::ZERO, n.notified())
.await
.is_err()
);
changed(&watches, "/mailbox/a");
changed(&watches, "/mailbox/b");
n.notified().await;
assert!(
tokio::time::timeout(std::time::Duration::ZERO, n.notified())
.await
.is_err()
);
changed(&watches, "/mailbox/c");
n.notified().await;
drop(r);
assert!(watches.lock().unwrap().is_empty());
changed(&watches, "/mailbox/d");
assert!(
tokio::time::timeout(std::time::Duration::ZERO, n.notified())
.await
.is_err()
);
}
#[test]
fn self_alias_and_normalization() {
assert_eq!(path("writer", "/self/../self/a/"), "/proc/writer/a");
assert_eq!(path("writer", "/selfish/a"), "/selfish/a");
assert_eq!(path("writer", "/"), "/");
}
}
#[cfg(test)]
mod lifetime_tests {
use super::*;
/// The real caller's ended event releases the registration, without a cancellation RPC.
#[tokio::test]
async fn caller_death_removes_watch() {
let temp = tempfile::tempdir().unwrap();
let runtime = crate::runtime::Runtime::new(&temp.path().join("cache")).unwrap();
let host = Host::test_world(&temp.path().join("world"), runtime)
.await
.unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
host.register_native(
"holder",
Arc::new(move |ctx, _, _| {
let tx = tx.clone();
Box::pin(async move {
tx.send(ctx.pid.clone()).unwrap();
std::future::pending().await
})
}),
);
let h = host.clone();
let task = tokio::spawn(async move { h.call("holder.hold", json!({})).await });
let pid = rx.recv().await.unwrap();
let mut watch = Box::pin(host.watch(&pid, Some(json!("watch")), "/mailbox"));
assert!(
watch
.as_mut()
.poll(&mut std::task::Context::from_waker(std::task::Waker::noop()))
.is_pending()
);
assert_eq!(host.0.watches.lock().unwrap().len(), 1);
host.call("core.kill", json!({"pid":pid})).await.unwrap();
watch.await.unwrap();
assert!(host.0.watches.lock().unwrap().is_empty());
assert!(task.await.unwrap().is_err());
host.stop().await;
}
}
+271 -44
View File
@@ -1,71 +1,298 @@
//! World folders and atomic build output helpers. //! On-disk layout of a Sausage world. The folder is the source of truth:
use anyhow::{Result, bail}; //! `sausage run` boots every actor that it finds in `actor/`.
use std::path::{Path, PathBuf}; //!
//! ```text
//! <world>/
//! actor/<id>/
//! actor.wasm the current version
//! actor.json version, running, subscriptions, tapped (broker)
//! source/ source of the current version
//! public/ written by the actor, read-only for others
//! data/ state, writable only by the actor
//! stderr.log stderr of the actor and notes of the broker
//! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json
//! ```
use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
/// Name prefix of the host's temporary files and folders. Copies skip them.
pub const TMP_PREFIX: &str = ".sausage-tmp-";
/// IDs of the actors that the host implements natively. `fs` sends the
/// requests for actor-served files (`vfs.rs`).
pub const NATIVE: &[&str] = &["broker", "console", "cargo", "fs"];
#[derive(Clone)] #[derive(Clone)]
/// The absolute host folder shared by all processes.
pub struct World { pub struct World {
/// The absolute path of the world on the host.
pub root: PathBuf, pub root: PathBuf,
} }
/// `actor/<id>/actor.json`.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct ActorMeta {
/// The installed version. `None` for native actors and for a new
/// `actor.wasm` that the broker has not installed yet.
#[serde(default)]
pub version: Option<u64>,
#[serde(default = "yes")]
pub running: bool,
/// The actors whose published messages this actor receives.
#[serde(default)]
pub subscriptions: BTreeSet<String>,
/// Whether this actor gets a copy of every routed message (`tap`).
#[serde(default)]
pub tapped: bool,
}
fn yes() -> bool {
true
}
/// `versions/<n>/meta.json`.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct VersionMeta {
pub version: u64,
/// Path of the `.wasm` file, as the requester gave it.
pub wasm: String,
/// Path of the source folder, as the requester gave it.
pub source: Option<String>,
/// The actor that built the `.wasm` file.
pub built_by: Option<String>,
/// The actor that requested the version ("scan" for a file found on disk).
pub requested_by: String,
pub created_at: u64,
}
pub fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
pub fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
impl World { impl World {
/// Validate reserved root names and resolve the world path. pub fn new(root: impl Into<PathBuf>) -> Self {
pub fn open(path: &Path) -> Result<Self> { Self { root: root.into() }
for name in ["proc", "run", "self"] { }
if path.join(name).symlink_metadata().is_ok() {
bail!("reserved world path: {name}"); pub fn actors_root(&self) -> PathBuf {
self.root.join("actor")
}
pub fn actor_dir(&self, id: &str) -> PathBuf {
self.actors_root().join(id)
}
pub fn wasm_path(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("actor.wasm")
}
pub fn public_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("public")
}
pub fn source_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("source")
}
pub fn data_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("data")
}
pub fn versions_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("versions")
}
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
self.versions_dir(id).join(v.to_string())
}
/// Private scratch space of the broker (candidate copies, seeds).
pub fn scratch_dir(&self) -> PathBuf {
self.data_dir("broker").join("scratch")
}
pub fn exists(&self) -> bool {
self.actors_root().is_dir()
}
/// Create an empty world with the folders of the native actors.
pub fn create(root: &Path) -> Result<World> {
let world = World::new(std::path::absolute(root)?);
if world.exists() {
bail!("{} is already a Sausage world", root.display());
}
for id in NATIVE {
world.ensure_actor_dirs(id)?;
}
Ok(world)
}
pub fn ensure_actor_dirs(&self, id: &str) -> Result<()> {
std::fs::create_dir_all(self.public_dir(id))?;
std::fs::create_dir_all(self.data_dir(id))?;
Ok(())
}
/// IDs of every folder in `actor/`, sorted.
pub fn actor_ids(&self) -> Result<Vec<String>> {
let mut ids: Vec<String> = std::fs::read_dir(self.actors_root())?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|id| valid_id(id))
.collect();
ids.sort();
Ok(ids)
}
pub fn read_meta(&self, id: &str) -> ActorMeta {
std::fs::read_to_string(self.actor_dir(id).join("actor.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(ActorMeta { running: true, ..Default::default() })
}
pub fn write_meta(&self, id: &str, meta: &ActorMeta) -> Result<()> {
write_atomic(&self.actor_dir(id).join("actor.json"), serde_json::to_string_pretty(meta)?.as_bytes())
}
/// Make `actor.wasm` and `source/` a copy of `versions/<version>/`,
/// unless `actor.wasm` already is that version (it came from there).
pub fn install_files(&self, id: &str, version: u64) -> Result<()> {
let dir = self.version_dir(id, version);
self.ensure_actor_dirs(id)?;
let wasm = std::fs::read(dir.join("actor.wasm"))?;
if std::fs::read(self.wasm_path(id)).ok().as_ref() == Some(&wasm) {
return Ok(());
}
write_atomic(&self.wasm_path(id), &wasm)?;
replace_tree(&dir.join("source"), &self.source_dir(id))
}
/// Version numbers in `versions/`, ascending.
pub fn versions(&self, id: &str) -> Vec<u64> {
let mut v: Vec<u64> = std::fs::read_dir(self.versions_dir(id))
.map(|rd| rd.filter_map(|e| e.ok()?.file_name().to_str()?.parse().ok()).collect())
.unwrap_or_default();
v.sort();
v
}
pub fn next_version(&self, id: &str) -> u64 {
self.versions(id).last().map_or(1, |v| v + 1)
}
/// Map a path in an actor's namespace to a host path.
///
/// - `/actor/<id>/...` is the global filesystem.
/// - `/public/...` and `/data/...` belong to `sender`.
/// - Native senders (the console) can also give absolute host paths.
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
let mut out = base;
for c in Path::new(rest).components() {
match c {
Component::Normal(n) => out.push(n),
Component::CurDir | Component::RootDir => {}
_ => bail!("path must not contain '..': {path}"),
} }
} }
Ok(Self { Ok(out)
root: path.canonicalize()?, };
}) let prefixed = |prefix: &str| {
path.strip_prefix(prefix).filter(|rest| rest.is_empty() || rest.starts_with('/'))
};
if let Some(rest) = prefixed("/actor") {
return under(self.actors_root(), rest);
} }
/// Create the folders and empty boot configuration of a new world. if let Some(rest) = prefixed("/public") {
pub fn create(path: &Path) -> Result<Self> { return under(self.public_dir(sender), rest);
if path.exists() {
bail!("world already exists: {}", path.display());
} }
for dir in ["plugins", "state/core", "config"] { if let Some(rest) = prefixed("/data") {
std::fs::create_dir_all(path.join(dir))?; return under(self.data_dir(sender), rest);
} }
std::fs::write(path.join("config/boot.yaml"), "[]\n")?; if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
Self::open(path) return Ok(PathBuf::from(path));
} }
/// Return the folder that contains a plugin component. bail!("cannot resolve path {path} for {sender}: use /actor/<id>/..., /public/... or /data/...")
pub fn plugin_dir(&self, name: &str) -> PathBuf {
self.root.join("plugins").join(name)
} }
/// Return the persistent state folder of a plugin.
pub fn state_dir(&self, name: &str) -> PathBuf { /// The path of a host file in the global filesystem, if it has one.
self.root.join("state").join(name) pub fn global_path(&self, host: &Path) -> Option<String> {
let rel = host.strip_prefix(self.actors_root()).ok()?;
Some(format!("/actor/{}", rel.display()))
} }
} }
/// Write a temporary sibling and rename it over the destination.
/// Write a file so that readers never see a partial version.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
std::fs::create_dir_all(path.parent().unwrap())?; let dir = path.parent().context("path has no parent")?;
let tmp = path.with_extension("tmp"); std::fs::create_dir_all(dir)?;
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
let tmp = dir.join(format!("{TMP_PREFIX}host-{}-{name}", std::process::id()));
std::fs::write(&tmp, bytes)?; std::fs::write(&tmp, bytes)?;
std::fs::rename(tmp, path)?; std::fs::rename(&tmp, path)?;
Ok(()) Ok(())
} }
/// Replace a build copy with a source tree, without target folders.
pub fn replace_source(from: &Path, to: &Path) -> Result<()> { /// Folders that a copy of a source folder leaves out.
if to.exists() { const BUILD_DIRS: &[&str] = &["target", ".git"];
std::fs::remove_dir_all(to)?;
} /// Copy a directory tree, except uncommitted files.
copy_tree(from, to) pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
copy_filtered(from, to, &[])
} }
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
fn copy_filtered(from: &Path, to: &Path, skip: &[&str]) -> Result<()> {
std::fs::create_dir_all(to)?; std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from)? { for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? {
let entry = entry?; let entry = entry?;
if entry.file_name() == "target" { let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(TMP_PREFIX) {
continue; continue;
} }
if entry.file_type()?.is_dir() { let dst = to.join(&*name);
copy_tree(&entry.path(), &to.join(entry.file_name()))?; let ty = entry.file_type()?;
} else { if ty.is_dir() && !skip.contains(&&*name) {
std::fs::copy(entry.path(), to.join(entry.file_name()))?; copy_filtered(&entry.path(), &dst, skip)?;
} else if ty.is_file() {
std::fs::copy(entry.path(), &dst)?;
} }
} }
Ok(()) Ok(())
} }
/// Replace the directory `dst` with a copy of `src` (an empty folder if `src`
/// does not exist): copy to a sibling, then swap with two renames.
pub fn replace_tree(src: &Path, dst: &Path) -> Result<()> {
replace_filtered(src, dst, &[])
}
/// Like `replace_tree`, for a source folder: leaves out `target/` and `.git/`.
pub fn replace_source(src: &Path, dst: &Path) -> Result<()> {
replace_filtered(src, dst, BUILD_DIRS)
}
fn replace_filtered(src: &Path, dst: &Path, skip: &[&str]) -> Result<()> {
let parent = dst.parent().context("path has no parent")?;
let name = dst.file_name().and_then(|n| n.to_str()).unwrap_or("dir");
let fresh = parent.join(format!("{TMP_PREFIX}new-{name}"));
let old = parent.join(format!("{TMP_PREFIX}old-{name}"));
let _ = std::fs::remove_dir_all(&fresh);
let _ = std::fs::remove_dir_all(&old);
if src.is_dir() {
copy_filtered(src, &fresh, skip)?;
} else {
std::fs::create_dir_all(&fresh)?;
}
if dst.exists() {
std::fs::rename(dst, &old)?;
}
std::fs::rename(&fresh, dst)?;
let _ = std::fs::remove_dir_all(&old);
Ok(())
}
+105
View File
@@ -0,0 +1,105 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "toolbox"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -1,9 +1,9 @@
[package] [package]
name = "activation-guest" name = "toolbox"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[workspace]
[dependencies] [dependencies]
serde_json = "1" serde_json = "1"
[workspace]
+180
View File
@@ -0,0 +1,180 @@
use std::collections::HashMap;
use std::io::{self, BufRead, Write};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
fn send(out: &mut impl Write, message: Value) {
writeln!(out, "{message}").unwrap();
out.flush().unwrap();
}
fn reply(out: &mut impl Write, message: &Value, body: Value) {
send(
out,
json!({"to": message["from"], "reply_to": message["id"], "body": body}),
);
}
/// Connect to `address`, send one line, and return the line that comes back.
fn tcp_line(address: &str, line: &str) -> io::Result<String> {
let mut stream = std::net::TcpStream::connect(address)?;
writeln!(stream, "{line}")?;
let mut answer = String::new();
io::BufReader::new(stream).read_line(&mut answer)?;
Ok(answer.trim_end().to_string())
}
fn io_error(error: io::Error) -> Value {
json!({"type": "error", "error": error.to_string()})
}
fn main() {
let actor = std::env::var("SAUSAGE_ACTOR").unwrap_or_default();
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_else(|_| "candidate".to_string());
let candidate = std::env::var_os("SAUSAGE_CANDIDATE").is_some();
if actor == "crashloop" && version == "2" && !candidate {
std::fs::write("/data/state", "bad-v2").unwrap();
panic!("version 2 crashes on startup");
}
let mut open = HashMap::<String, std::fs::File>::new();
// Files that this actor serves at /actor/<id>/fs/ (path -> text).
let mut served = std::collections::BTreeMap::<String, String>::new();
let stdin = io::stdin();
let mut out = io::stdout().lock();
let mut emit_shutdown = false;
if actor == "spawn-race" && !candidate {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::fs::write(format!("/data/start-{stamp}"), "started").unwrap();
}
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
let Ok(message) = serde_json::from_str::<Value>(&line) else {
continue;
};
let body = &message["body"];
let path = body["path"].as_str().unwrap_or_default().to_string();
let text = body["text"].as_str().unwrap_or_default();
let response = match body["type"].as_str() {
Some("shutdown") => {
if actor == "queue" {
let _ = std::fs::write("/data/shutdown", "stopping");
std::thread::sleep(Duration::from_millis(800));
}
if emit_shutdown {
send(
&mut out,
json!({"to": "console", "reply_to": message["id"], "body": {"type": "shutdown-reply"}}),
);
send(&mut out, json!({"body": {"type": "shutdown-publish"}}));
}
break;
}
Some("echo") => json!({"type": "echo", "value": body["value"]}),
Some("fs_read") => {
let dir = if path == "/" { "/".to_string() } else { format!("{path}/") };
let mut names: Vec<String> = served
.keys()
.filter_map(|k| k.strip_prefix(&dir))
.map(|rest| match rest.split_once('/') {
Some((first, _)) => format!("{first}/"),
None => rest.to_string(),
})
.collect();
names.dedup();
if let Some(text) = served.get(&path) {
json!({"type": "fs_file", "text": text})
} else if path == "/" || !names.is_empty() {
let entries: Vec<Value> = names
.iter()
.map(|n| match n.strip_suffix('/') {
Some(d) => json!({"name": d, "type": "dir"}),
None => json!({"name": n, "type": "file"}),
})
.collect();
json!({"type": "fs_dir", "entries": entries})
} else {
json!({"type": "fs_error", "errno": "no-entry"})
}
}
Some("fs_write") => {
served.insert(path.clone(), text.to_string());
json!({"type": "fs_done"})
}
Some("fs_remove") => match served.remove(&path) {
Some(_) => json!({"type": "fs_done"}),
None => json!({"type": "fs_error", "errno": "no-entry"}),
},
Some("publish") => {
send(
&mut out,
json!({"body": {"type": "published", "value": body["value"]}}),
);
continue;
}
Some("version") => json!({"type": "version", "version": version}),
Some("ordered") => json!({"type": "ordered", "n": body["n"], "version": version}),
Some("write") => match std::fs::write(&path, text) {
Ok(()) => json!({"type": "ok"}),
Err(error) => io_error(error),
},
Some("mkdir") => match std::fs::create_dir_all(&path) {
Ok(()) => json!({"type": "ok"}),
Err(error) => io_error(error),
},
Some("rename") => match std::fs::rename(&path, body["to"].as_str().unwrap_or_default())
{
Ok(()) => json!({"type": "ok"}),
Err(error) => io_error(error),
},
Some("open") => {
let result = (|| -> io::Result<()> {
let mut file = std::fs::File::create(&path)?;
file.write_all(text.as_bytes())?;
open.insert(path.clone(), file);
Ok(())
})();
match result {
Ok(()) => json!({"type": "opened"}),
Err(error) => io_error(error),
}
}
Some("close") => json!({"type": "closed", "closed": open.remove(&path).is_some()}),
Some("arm-shutdown") => {
emit_shutdown = true;
json!({"type": "armed"})
}
Some("ignore-stdin") => json!({"type": "ignoring"}),
Some("tcp") => match tcp_line(body["address"].as_str().unwrap_or_default(), text) {
Ok(line) => json!({"type": "tcp", "line": line}),
Err(error) => io_error(error),
},
Some("read") => match std::fs::read_to_string(&path) {
Ok(text) => json!({"type": "content", "text": text}),
Err(error) => io_error(error),
},
Some("list") => match std::fs::read_dir(&path) {
Ok(entries) => {
let mut names: Vec<String> = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
names.sort();
json!({"type": "entries", "names": names})
}
Err(error) => io_error(error),
},
_ => json!({"type": "error", "error": "unknown message"}),
};
reply(&mut out, &message, response);
if body["type"] == "ignore-stdin" {
loop {
std::thread::sleep(Duration::from_secs(60));
}
}
}
}
+555
View File
@@ -0,0 +1,555 @@
//! Integration tests for the agent's inference and restart behavior.
#[allow(dead_code)]
mod common;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use std::sync::OnceLock;
use std::time::Duration;
use common::{create_world, receive_message, receive_reply, request, source_with_test, start};
use sausage::{Envelope, Host, World};
use serde_json::{Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot};
use tokio::task::{JoinHandle, JoinSet};
const APIS: [Api; 2] = [Api::OpenAi, Api::Anthropic];
const TEST_TOKEN: &str = "fake-token";
#[derive(Clone, Copy)]
enum Api {
OpenAi,
Anthropic,
}
impl Api {
fn name(self) -> &'static str {
match self {
Self::OpenAi => "openai-responses",
Self::Anthropic => "anthropic-messages",
}
}
fn path(self) -> &'static str {
match self {
Self::OpenAi => "/v1/responses",
Self::Anthropic => "/v1/messages",
}
}
fn text_response(self, text: &str) -> Value {
match self {
Self::OpenAi => {
json!({"output": [{"type": "message", "content": [{"type": "output_text", "text": text}]}]})
}
Self::Anthropic => json!({"content": [{"type": "text", "text": text}]}),
}
}
fn tool_response(self, id: &str, name: &str, arguments: &Value) -> Value {
match self {
Self::OpenAi => {
json!({"output": [{"type": "function_call", "call_id": id, "name": name, "arguments": arguments.to_string()}]})
}
Self::Anthropic => {
json!({"content": [{"type": "tool_use", "id": id, "name": name, "input": arguments}]})
}
}
}
fn check_request(self, request: &HttpRequest) {
assert_eq!(request.method, "POST");
assert_eq!(request.path, self.path());
assert_eq!(request.headers["content-type"], "application/json");
assert_eq!(request.body["model"], "fake-model");
match self {
Self::OpenAi => assert_eq!(request.headers["authorization"], "Bearer fake-token"),
Self::Anthropic => {
assert_eq!(request.headers["x-api-key"], TEST_TOKEN);
assert_eq!(request.headers["anthropic-version"], "2023-06-01");
}
}
}
}
struct HttpRequest {
method: String,
path: String,
headers: HashMap<String, String>,
body: Value,
}
enum ReplyPlan {
Send(Value),
Hold(Value, oneshot::Receiver<()>),
}
struct FakeServer {
base_url: String,
plans: mpsc::Sender<ReplyPlan>,
requests: mpsc::Receiver<HttpRequest>,
task: JoinHandle<()>,
}
impl FakeServer {
async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let (plans, mut plan_rx) = mpsc::channel(16);
let (request_tx, requests) = mpsc::channel(16);
let task = tokio::spawn(async move {
let mut handlers = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
let (mut stream, _) = accepted.expect("fake listener accept failed");
let request = read_http_request(&mut stream).await;
let Some(plan) = plan_rx.recv().await else { break };
if request_tx.send(request).await.is_err() {
break;
}
handlers.spawn(async move {
let (body, release) = match plan {
ReplyPlan::Send(body) => (body, None),
ReplyPlan::Hold(body, release) => (body, Some(release)),
};
if let Some(release) = release {
let _ = release.await;
}
write_http_response(stream, body).await;
});
}
Some(_) = handlers.join_next(), if !handlers.is_empty() => {}
}
}
});
Self {
base_url: format!("http://127.0.0.1:{}", address.port()),
plans,
requests,
task,
}
}
async fn answer(&self, body: Value) {
self.plans.send(ReplyPlan::Send(body)).await.unwrap();
}
async fn hold(&self, body: Value) -> oneshot::Sender<()> {
let (release, wait) = oneshot::channel();
self.plans.send(ReplyPlan::Hold(body, wait)).await.unwrap();
release
}
async fn next_request(&mut self) -> HttpRequest {
tokio::time::timeout(Duration::from_secs(20), self.requests.recv())
.await
.expect("timed out waiting for fake inference request")
.expect("fake server request channel closed")
}
}
impl Drop for FakeServer {
fn drop(&mut self) {
self.task.abort();
}
}
async fn read_http_request(stream: &mut TcpStream) -> HttpRequest {
let mut received = Vec::new();
let mut chunk = [0; 4096];
let header_end = loop {
let count = stream.read(&mut chunk).await.expect("read HTTP request");
assert_ne!(count, 0, "client closed before HTTP headers arrived");
received.extend_from_slice(&chunk[..count]);
if let Some(end) = received.windows(4).position(|part| part == b"\r\n\r\n") {
break end;
}
};
let header_text = String::from_utf8_lossy(&received[..header_end]);
let mut lines = header_text.split("\r\n");
let mut request_line = lines.next().unwrap().split_whitespace();
let method = request_line.next().unwrap().to_string();
let path = request_line.next().unwrap().to_string();
let mut headers = HashMap::new();
for line in lines {
if let Some((name, value)) = line.split_once(':') {
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
}
}
let body_start = header_end + 4;
let request_body = if let Some(length) = headers.get("content-length") {
let body_length = length.parse::<usize>().unwrap();
while received.len() < body_start + body_length {
let count = stream.read(&mut chunk).await.expect("read HTTP body");
assert_ne!(count, 0, "client closed before HTTP body arrived");
received.extend_from_slice(&chunk[..count]);
}
received[body_start..body_start + body_length].to_vec()
} else {
assert!(
headers
.get("transfer-encoding")
.is_some_and(|value| value.eq_ignore_ascii_case("chunked")),
"request has neither Content-Length nor chunked transfer encoding: {headers:?}"
);
read_chunked_body(stream, &mut received, body_start).await
};
let body = serde_json::from_slice(&request_body).unwrap();
HttpRequest {
method,
path,
headers,
body,
}
}
async fn read_chunked_body(
stream: &mut TcpStream,
received: &mut Vec<u8>,
mut offset: usize,
) -> Vec<u8> {
let mut body = Vec::new();
loop {
let line_end = loop {
if let Some(end) = received[offset..]
.windows(2)
.position(|part| part == b"\r\n")
{
break offset + end;
}
let mut chunk = [0; 4096];
let count = stream.read(&mut chunk).await.expect("read chunk size");
assert_ne!(count, 0, "client closed before chunk size arrived");
received.extend_from_slice(&chunk[..count]);
};
let size = String::from_utf8_lossy(&received[offset..line_end]);
let size = size.split(';').next().unwrap().trim();
let size = usize::from_str_radix(size, 16).unwrap();
offset = line_end + 2;
if size == 0 {
return body;
}
while received.len() < offset + size + 2 {
let mut chunk = [0; 4096];
let count = stream.read(&mut chunk).await.expect("read chunk data");
assert_ne!(count, 0, "client closed before chunk data arrived");
received.extend_from_slice(&chunk[..count]);
}
body.extend_from_slice(&received[offset..offset + size]);
assert_eq!(&received[offset + size..offset + size + 2], b"\r\n");
offset += size + 2;
}
}
async fn write_http_response(mut stream: TcpStream, body: Value) {
let body = serde_json::to_vec(&body).unwrap();
let head = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
let mut response = head.into_bytes();
response.extend_from_slice(&body);
let _ = stream.write_all(&response).await;
let _ = stream.shutdown().await;
}
fn agent_wasm() -> &'static PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| {
let root = common::repo_root();
let output = Command::new("cargo")
.args([
"build",
"--release",
"-p",
"agent",
"--target",
"wasm32-wasip2",
"--target-dir",
"target/test-actors",
])
.current_dir(&root)
.output()
.expect("run cargo to build the agent actor");
assert!(
output.status.success(),
"building agent failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let wasm = root.join("target/test-actors/wasm32-wasip2/release/agent.wasm");
assert!(
wasm.is_file(),
"agent build did not create {}",
wasm.display()
);
wasm
})
}
async fn start_agent(
api: Api,
fake: &FakeServer,
label: &str,
) -> (World, Host, mpsc::Receiver<Envelope>) {
let world = create_world(label);
let data = world.data_dir("agent");
std::fs::create_dir_all(&data).unwrap();
let config = json!({"api": api.name(), "base_url": fake.base_url, "model": "fake-model", "token": TEST_TOKEN});
std::fs::write(
data.join("config.json"),
serde_json::to_vec(&config).unwrap(),
)
.unwrap();
let source = source_with_test(
&world,
"agent-candidate",
json!({"steps": [{"send": {"type": "text", "text": "candidate"}, "expect": {"type": "text"}}]}),
);
let (host, mut mailbox) = start(&world).await;
let spawned = common::spawn_actor(&host, &mut mailbox, "agent", agent_wasm(), &source).await;
assert_eq!(
spawned.body["type"], "spawned",
"agent spawn failed: {spawned:?}"
);
let subscribed = request(
&host,
&mut mailbox,
json!({"type": "subscribe", "actor": "agent"}),
)
.await;
assert_eq!(subscribed.body["type"], "subscribed");
(world, host, mailbox)
}
fn send_text(host: &Host, text: &str) -> String {
host.broker.send(
"console",
Some("agent"),
None,
None,
json!({"type": "text", "text": text}),
)
}
async fn assert_published_text(mailbox: &mut mpsc::Receiver<Envelope>, text: &str) {
let message = receive_message(mailbox, Duration::from_secs(20)).await;
assert_eq!(message.from, "agent");
assert_eq!(message.to, "console");
assert_eq!(message.reply_to, None);
assert_eq!(message.body, json!({"type": "text", "text": text}));
}
fn history(world: &World) -> Vec<Value> {
std::fs::read_to_string(world.data_dir("agent").join("history.jsonl"))
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}
fn assert_history_tools_have_results(history: &[Value]) {
let calls: Vec<_> = history
.iter()
.filter(|entry| entry["kind"] == "tool_call")
.collect();
let results: Vec<_> = history
.iter()
.filter(|entry| entry["kind"] == "tool_result")
.collect();
assert_eq!(calls.len(), results.len());
for call in calls {
assert!(
results.iter().any(|result| result["id"] == call["id"]),
"tool call has no result: {call}"
);
}
}
fn assert_tool_pair(
request: &HttpRequest,
api: Api,
id: &str,
name: &str,
arguments: &Value,
) -> String {
match api {
Api::OpenAi => {
let input = request.body["input"].as_array().unwrap();
let call_index = input
.iter()
.position(|item| item["type"] == "function_call")
.unwrap();
let result_index = input
.iter()
.position(|item| item["type"] == "function_call_output")
.unwrap();
assert!(call_index < result_index);
let call = &input[call_index];
assert_eq!(call["call_id"], id);
assert_eq!(call["name"], name);
assert_eq!(
serde_json::from_str::<Value>(call["arguments"].as_str().unwrap()).unwrap(),
*arguments
);
let result = &input[result_index];
assert_eq!(result["call_id"], id);
result["output"].as_str().unwrap().to_string()
}
Api::Anthropic => {
let messages = request.body["messages"].as_array().unwrap();
let mut call_position = None;
let mut result_position = None;
let mut call = None;
let mut result = None;
for (index, message) in messages.iter().enumerate() {
let Some(blocks) = message["content"].as_array() else {
continue;
};
for block in blocks {
match block["type"].as_str() {
Some("tool_use") => {
call_position = Some(index);
call = Some(block);
}
Some("tool_result") => {
result_position = Some(index);
result = Some(block);
}
_ => {}
}
}
}
assert!(call_position.unwrap() < result_position.unwrap());
let call = call.unwrap();
assert_eq!(call["id"], id);
assert_eq!(call["name"], name);
assert_eq!(call["input"], *arguments);
let result = result.unwrap();
assert_eq!(result["tool_use_id"], id);
result["content"].as_str().unwrap().to_string()
}
}
}
#[tokio::test]
async fn text_answers_use_both_inference_apis() {
for api in APIS {
let mut fake = FakeServer::start().await;
fake.answer(api.text_response("hello from the fake model"))
.await;
let (_, host, mut mailbox) =
start_agent(api, &fake, &format!("agent-text-{}", api.name())).await;
send_text(&host, "say hello");
api.check_request(&fake.next_request().await);
assert_published_text(&mut mailbox, "hello from the fake model").await;
host.stop().await;
}
}
#[tokio::test]
async fn tool_calls_pair_with_results_in_both_inference_apis() {
let args = json!({"path": "/actor"});
for api in APIS {
let mut fake = FakeServer::start().await;
fake.answer(api.tool_response("list-1", "list_dir", &args))
.await;
let (world, host, mut mailbox) =
start_agent(api, &fake, &format!("agent-tools-{}", api.name())).await;
send_text(&host, "list actors");
api.check_request(&fake.next_request().await);
fake.answer(api.text_response("the actor list is ready"))
.await;
let second = fake.next_request().await;
api.check_request(&second);
let output = assert_tool_pair(&second, api, "list-1", "list_dir", &args);
let result: Value = serde_json::from_str(&output).unwrap();
assert!(
result["entries"]
.as_array()
.unwrap()
.iter()
.any(|entry| entry["name"] == "agent")
);
assert_published_text(&mut mailbox, "the actor list is ready").await;
host.stop().await;
let history = history(&world);
assert_eq!(
history
.iter()
.map(|entry| entry["kind"].as_str().unwrap())
.collect::<Vec<_>>(),
["user", "tool_call", "tool_result", "assistant"]
);
assert_eq!(history[1]["id"], "list-1");
assert_eq!(history[2]["id"], "list-1");
}
}
#[tokio::test]
async fn restart_resumes_a_turn_with_completed_tool_history() {
let args = json!({"path": "/actor"});
for api in [Api::OpenAi] {
let mut fake = FakeServer::start().await;
fake.answer(api.tool_response("list-1", "list_dir", &args))
.await;
let (world, host, mut mailbox) =
start_agent(api, &fake, &format!("agent-resume-{}", api.name())).await;
let id = send_text(&host, "list actors before the restart");
api.check_request(&fake.next_request().await);
let release = fake.hold(api.text_response("discarded response")).await;
let second = fake.next_request().await;
api.check_request(&second);
assert_tool_pair(&second, api, "list-1", "list_dir", &args);
let stopped = request(
&host,
&mut mailbox,
json!({"type": "stop", "actor": "agent"}),
)
.await;
assert_eq!(stopped.body["type"], "stopped");
assert_eq!(
serde_json::from_slice::<Value>(
&std::fs::read(world.data_dir("agent").join("turn.json")).unwrap()
)
.unwrap(),
json!(true)
);
let _ = release.send(());
fake.answer(api.text_response("resumed answer")).await;
let start_id = host.broker.send(
"console",
Some("broker"),
None,
None,
json!({"type": "start", "actor": "agent"}),
);
let resumed = fake.next_request().await;
api.check_request(&resumed);
assert_tool_pair(&resumed, api, "list-1", "list_dir", &args);
let started = receive_reply(&mut mailbox, &start_id, Duration::from_secs(20)).await;
assert_eq!(started.body["type"], "started");
assert_published_text(&mut mailbox, "resumed answer").await;
let history = history(&world);
assert_history_tools_have_results(&history);
assert_eq!(
history
.iter()
.filter(|entry| entry["kind"] == "user"
&& entry["text"].as_str().unwrap_or_default().contains(&id))
.count(),
1
);
host.stop().await;
}
}
+248
View File
@@ -0,0 +1,248 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sausage::{Envelope, Host, World};
use serde_json::{Value, json};
use tokio::sync::mpsc;
const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
static WORLD_SEQUENCE: AtomicU64 = AtomicU64::new(0);
pub fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf()
}
pub fn create_world(label: &str) -> World {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let sequence = WORLD_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = repo_root()
.join("target/test-worlds")
.join(format!("{label}-{}-{nonce}-{sequence}", std::process::id()));
World::create(&path).unwrap()
}
pub fn actor_fixture_source(name: &str) -> PathBuf {
repo_root().join("cli/tests/actors").join(name)
}
pub fn cargo_source(name: &str) -> PathBuf {
repo_root().join("cli/tests/crates").join(name)
}
pub fn toolbox_wasm() -> &'static PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| build_fixture("toolbox"))
}
fn build_fixture(name: &str) -> PathBuf {
let root = repo_root();
let manifest = actor_fixture_source(name).join("Cargo.toml");
let target_dir = root.join("target/test-actors");
let output = Command::new("cargo")
.args([
"build",
"--release",
"--target",
"wasm32-wasip2",
"--target-dir",
])
.arg(&target_dir)
.arg("--manifest-path")
.arg(&manifest)
.current_dir(&root)
.output()
.expect("run cargo to build the test actor");
assert!(
output.status.success(),
"building fixture {name} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let wasm = target_dir
.join("wasm32-wasip2/release")
.join(format!("{name}.wasm"));
assert!(
wasm.is_file(),
"fixture build did not create {}",
wasm.display()
);
wasm
}
pub fn passing_source(world: &World, name: &str) -> PathBuf {
source_with_test(
world,
name,
json!({
"steps": [{
"send": {"type": "echo", "value": "candidate"},
"expect": {"type": "echo", "value": "candidate"}
}]
}),
)
}
pub fn source_with_test(world: &World, name: &str, test: Value) -> PathBuf {
let source = world.source_dir("console").join("test-fixtures").join(name);
std::fs::create_dir_all(source.join("tests")).unwrap();
std::fs::write(
source.join("tests/candidate.json"),
serde_json::to_vec(&test).unwrap(),
)
.unwrap();
source
}
pub async fn start(world: &World) -> (Host, mpsc::Receiver<Envelope>) {
let host = Host::start(&world.root).await.unwrap();
let mailbox = host.mailbox("console");
(host, mailbox)
}
pub async fn request(host: &Host, mailbox: &mut mpsc::Receiver<Envelope>, body: Value) -> Envelope {
let id = host
.broker
.send("console", Some("broker"), None, None, body);
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
}
pub async fn receive_message(
mailbox: &mut mpsc::Receiver<Envelope>,
timeout: Duration,
) -> Envelope {
tokio::time::timeout(timeout, mailbox.recv())
.await
.expect("timed out waiting for a message")
.expect("console mailbox closed")
}
pub async fn receive_reply(
mailbox: &mut mpsc::Receiver<Envelope>,
id: &str,
timeout: Duration,
) -> Envelope {
tokio::time::timeout(timeout, async {
loop {
let envelope = mailbox.recv().await.expect("console mailbox closed");
if envelope.reply_to.as_deref() == Some(id) {
return envelope;
}
}
})
.await
.unwrap_or_else(|_| panic!("timed out waiting for reply to {id}"))
}
pub async fn receive_replies(
mailbox: &mut mpsc::Receiver<Envelope>,
ids: &[String],
timeout: Duration,
) -> Vec<Envelope> {
let mut replies = Vec::with_capacity(ids.len());
tokio::time::timeout(timeout, async {
while replies.len() < ids.len() {
let message = mailbox.recv().await.expect("console mailbox closed");
assert!(
ids.iter()
.any(|id| message.reply_to.as_deref() == Some(id.as_str())),
"unexpected message while waiting for replies: {message:?}"
);
replies.push(message);
}
})
.await
.expect("timed out waiting for replies");
replies
}
pub async fn ask(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
body: Value,
) -> Envelope {
let id = host.broker.send("console", Some(actor), None, None, body);
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
}
pub async fn ask_type(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
body: Value,
expected: &str,
) -> Envelope {
let reply = ask(host, mailbox, actor, body).await;
assert_eq!(reply.body["type"], expected, "unexpected reply: {reply:?}");
reply
}
pub fn spawn_request(host: &Host, actor: &str, wasm: &Path, source: &Path) -> String {
host.broker.send(
"console",
Some("broker"),
None,
None,
json!({
"type": "spawn",
"actor": actor,
"wasm": wasm.display().to_string(),
"source": source.display().to_string()
}),
)
}
pub async fn spawn_actor(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
wasm: &Path,
source: &Path,
) -> Envelope {
let id = spawn_request(host, actor, wasm, source);
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
}
pub async fn spawn_type(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
wasm: &Path,
source: &Path,
) -> Envelope {
let reply = spawn_actor(host, mailbox, actor, wasm, source).await;
assert_eq!(
reply.body["type"], "spawned",
"unexpected spawn reply: {reply:?}"
);
reply
}
pub async fn upgrade_actor(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
wasm: &Path,
source: &Path,
) -> Envelope {
request(
host,
mailbox,
json!({
"type": "upgrade",
"actor": actor,
"wasm": wasm.display().to_string(),
"source": source.display().to_string()
}),
)
.await
}
-12
View File
@@ -1,12 +0,0 @@
[package]
name = "cargo-c"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-plugin = "0.1"
[build-dependencies]
cc = "1"
[workspace]
-5
View File
@@ -1,5 +0,0 @@
// Compile C with the `cc` crate: natively for the unit test, and with the
// WASI clang (`CC_wasm32_wasip2`) for the actor.
fn main() {
cc::Build::new().file("src/add.c").compile("add");
}
-1
View File
@@ -1 +0,0 @@
int c_add(int a, int b) { return a + b; }
-25
View File
@@ -1,25 +0,0 @@
//! SDK plugin linked to a WASI C function.
unsafe extern "C" {
fn c_add(a: i32, b: i32) -> i32;
}
fn add(a: i32, b: i32) -> i32 {
unsafe { c_add(a, b) }
}
struct Plugin;
#[sausage_plugin::rpc]
impl Plugin {
/// Add two fixed numbers through C.
fn sum(&self) -> sausage_plugin::Result<i32> {
Ok(add(2, 3))
}
}
fn main() {
sausage_plugin::run(Plugin);
}
#[cfg(test)]
mod tests {
#[test]
fn c_code_runs_under_wasi() {
assert_eq!(super::add(2, 3), 5);
}
}
-6
View File
@@ -1,6 +0,0 @@
[package]
name = "cargo-hang"
version = "0.1.0"
edition = "2024"
[workspace]
-7
View File
@@ -1,7 +0,0 @@
//! A build script descendant that stays alive until the native call ends.
fn main() {
std::process::Command::new("sh")
.args(["-c", "echo $$ > child.pid; exec sleep 1000"])
.status()
.unwrap();
}
-1
View File
@@ -1 +0,0 @@
fn main() {}
@@ -1,6 +0,0 @@
[package]
name = "cargo-test-fail"
version = "0.1.0"
edition = "2024"
[workspace]
@@ -1,13 +0,0 @@
//! A panic must not prevent the runner from executing another test.
fn main() {}
#[cfg(test)]
mod tests {
#[test]
fn a_panics() {
panic!("deliberate WASI panic");
}
#[test]
fn z_survives() {
assert_eq!(2 + 2, 4);
}
}
+1 -10
View File
@@ -1,15 +1,6 @@
[package] [package]
name = "cargo-valid" name = "cargo-valid"
version = "0.1.0" version = "0.1.0"
edition.workspace = true edition = "2024"
[target.'cfg(target_arch = "wasm32")'.dependencies]
sausage_plugin = { package = "sausage-plugin", version = "0.1", optional = true }
[features]
default = ["dep:sausage_plugin"]
[workspace] [workspace]
[workspace.package]
edition = "2024"
-4
View File
@@ -1,4 +0,0 @@
//! Produces a warning so the build response can retain diagnostics.
fn main() {
println!("cargo:warning=fixture build warning");
}
-1
View File
@@ -1 +0,0 @@
fixture
+1 -165
View File
@@ -1,165 +1 @@
//! SDK plugin and tests for the native build pipeline. fn main() {}
struct Plugin;
#[sausage_plugin::rpc]
impl Plugin {
/// Return a greeting.
fn hello(&self) -> sausage_plugin::Result<String> {
Ok("hello".into())
}
}
fn main() {
sausage_plugin::run(Plugin);
}
#[cfg(test)]
mod tests {
#[test]
fn arithmetic() {
assert_eq!(2 + 2, 4);
}
#[test]
fn fixture_at_manifest_path() {
assert_eq!(
std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/fixture.txt")).unwrap(),
"fixture\n"
);
}
#[test]
fn current_directory() {
println!(
"WASI_CURRENT_DIR={}",
std::env::current_dir().unwrap().display()
);
}
#[sausage_plugin::test]
async fn sdk_lock() {
let guard = sausage_plugin::lock("/state/lock").await.unwrap();
assert!(guard.held());
let locks: sausage_plugin::serde_json::Value = sausage_plugin::serde_json::from_str(
&std::fs::read_to_string("/proc/core/locks.json").unwrap(),
)
.unwrap();
assert_eq!(locks.as_object().unwrap().len(), 1);
assert!(locks.get("/state/lock").is_some());
let mut guard = Some(guard);
let mut waiting = Box::pin(std::future::IntoFuture::into_future(sausage_plugin::lock(
"/state/lock",
)));
let acquired = std::future::poll_fn(|context| {
let result = std::future::Future::poll(waiting.as_mut(), context);
if result.is_pending() {
drop(guard.take());
}
result
})
.await
.unwrap();
assert!(acquired.held());
let logs = sausage_plugin::call("core.log", sausage_plugin::serde_json::json!({}))
.await
.unwrap();
assert!(logs.is_array());
sausage_plugin::call(
"core.notify",
sausage_plugin::serde_json::json!({"name":"progress","params":{}}),
)
.await
.unwrap();
}
#[sausage_plugin::test]
async fn other_plugin_fails() {
let error = sausage_plugin::call("missing.method", sausage_plugin::serde_json::json!({}))
.await
.unwrap_err();
assert!(error.to_string().contains("a test has no other plugins"));
}
#[sausage_plugin::test]
async fn unlock_order() {
for iteration in 0..300 {
let guard = sausage_plugin::lock("/state/order").await.unwrap();
assert!(guard.held());
drop(guard);
let guard = sausage_plugin::lock("/state/order")
.try_now()
.await
.unwrap();
assert!(guard.held(), "iteration {iteration}");
}
}
#[test]
fn empty_world() {
for path in [
"/plugins",
"/config",
"/state",
"/state/core/journal.sqlite",
"/logs/core/journal.sqlite",
] {
assert!(!std::path::Path::new(path).exists(), "{path}");
}
let names: Vec<_> = std::fs::read_dir("/")
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
// The runner mounts the package folder at its host path, so the first
// folder of that path also appears in `/` (for example `tmp` or `build`).
let package = env!("CARGO_MANIFEST_DIR")
.trim_start_matches('/')
.split('/')
.next()
.unwrap();
assert!(
names.iter().all(|name| {
["proc", "self", "run", package]
.iter()
.any(|allowed| name == allowed)
}),
"{names:?}"
);
for path in ["/proc", "/self", "/run"] {
assert!(std::path::Path::new(path).is_dir());
}
assert!(std::fs::write("/proc/forbidden", "no").is_err());
}
fn isolated() {
assert!(!std::path::Path::new("/self/own").exists());
assert!(!std::path::Path::new("/run/own").exists());
std::fs::write("/self/own", "self").unwrap();
std::fs::write("/run/own", "run").unwrap();
assert_eq!(std::fs::read_to_string("/self/own").unwrap(), "self");
}
#[test]
fn isolation_one() {
isolated();
}
#[test]
fn isolation_two() {
isolated();
}
#[test]
fn environment() {
for (key, _) in std::env::vars() {
assert!(
["CARGO_", "RUST_", "SAUSAGE_"]
.iter()
.any(|prefix| key.starts_with(prefix)),
"{key}"
);
}
assert!(std::env::var("PWD").is_err());
}
#[test]
fn late_response() {
use std::io::Write;
for id in 0..100 {
println!(
"\n{{\"jsonrpc\":\"2.0\",\"id\":{id},\"method\":\"core.log\",\"params\":{{}}}}"
);
}
std::io::stdout().flush().unwrap();
}
#[test]
#[ignore]
fn ignored_case() {
assert_eq!(1, 1);
}
}
@@ -1,5 +0,0 @@
//! Checks that cargo invokes integration test binaries through the WASI runner.
#[test]
fn integration_case() {
println!("INTEGRATION_CASE");
}
+976
View File
@@ -0,0 +1,976 @@
mod common;
use std::time::Duration;
use common::*;
use sausage::{Envelope, Host, World};
use serde_json::{Value, json};
use tokio::sync::mpsc;
async fn filesystem_world(label: &str) -> (World, Host, mpsc::Receiver<Envelope>) {
let world = create_world(label);
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
for actor in ["writer", "reader"] {
let source = passing_source(&world, actor);
let spawned = spawn_actor(&host, &mut mailbox, actor, &wasm, &source).await;
assert_eq!(spawned.body["type"], "spawned");
}
(world, host, mailbox)
}
async fn receive_shutdown_messages(
mailbox: &mut mpsc::Receiver<Envelope>,
id: &str,
expected: &str,
) {
let (mut result, mut reply, mut publish) = (false, false, false);
tokio::time::timeout(Duration::from_secs(10), async {
while !result || !reply || !publish {
let message = mailbox.recv().await.expect("console mailbox closed");
if message.reply_to.as_deref() == Some(id) {
assert_eq!(message.body["type"], expected);
result = true;
} else if message.reply_to.as_deref() == Some("broker/shutdown") {
assert_eq!(message.from, "shutdown-output");
assert_eq!(message.body["type"], "shutdown-reply");
reply = true;
} else {
assert_eq!(message.from, "shutdown-output");
assert_eq!(message.reply_to, None);
assert_eq!(message.body["type"], "shutdown-publish");
publish = true;
}
}
})
.await
.expect("shutdown output or lifecycle reply did not arrive");
}
#[tokio::test]
async fn routes_replies_publishes_and_reports_undeliverable() {
let world = create_world("routing");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let missing_id = host.broker.send(
"console",
Some("missing"),
None,
None,
json!({"type": "hello"}),
);
let missing = receive_reply(&mut mailbox, &missing_id, Duration::from_secs(10)).await;
assert_eq!(missing.body["type"], "undeliverable");
assert_eq!(missing.body["to"], "missing");
let source = passing_source(&world, "router");
let spawned = spawn_actor(&host, &mut mailbox, "router", &wasm, &source).await;
assert_eq!(spawned.body["type"], "spawned");
let echo = ask(
&host,
&mut mailbox,
"router",
json!({"type": "echo", "value": "hello"}),
)
.await;
assert_eq!(echo.from, "router");
assert_eq!(echo.body, json!({"type": "echo", "value": "hello"}));
let subscribed = request(
&host,
&mut mailbox,
json!({"type": "subscribe", "actor": "router"}),
)
.await;
assert_eq!(subscribed.body["type"], "subscribed");
host.broker.send(
"console",
Some("router"),
None,
None,
json!({"type": "publish", "value": "news"}),
);
let published = receive_message(&mut mailbox, Duration::from_secs(10)).await;
assert_eq!(published.from, "router");
assert_eq!(
published.body,
json!({"type": "published", "value": "news"})
);
assert_eq!(published.reply_to, None);
host.stop().await;
}
#[tokio::test]
async fn broker_does_not_answer_messages_with_reply_to() {
let world = create_world("broker-reply-to");
let (host, mut mailbox) = start(&world).await;
let scanned = request(&host, &mut mailbox, json!({"type": "scan"})).await;
assert_eq!(scanned.body["type"], "scanned");
host.broker.send(
"console",
Some("broker"),
None,
Some("upstream-message"),
json!({"type": "scan"}),
);
assert!(
tokio::time::timeout(Duration::from_secs(1), mailbox.recv())
.await
.is_err(),
"broker replied to a message that already had reply_to"
);
host.stop().await;
}
#[tokio::test]
async fn exit_request_is_answered_and_signals_the_host() {
let world = create_world("broker-exit");
let (host, mut mailbox) = start(&world).await;
let exiting = request(&host, &mut mailbox, json!({"type": "exit"})).await;
assert_eq!(exiting.body["type"], "exiting");
tokio::time::timeout(Duration::from_secs(1), host.broker.exit_requested())
.await
.expect("exit request did not reach the host");
host.stop().await;
}
#[tokio::test]
async fn filesystem_permissions_and_public_sharing() {
let (_, host, mut mailbox) = filesystem_world("filesystem-permissions").await;
for path in [
"/actor/reader/public/denied.txt",
"/actor/writer/public/denied.txt",
] {
let denied = ask(
&host,
&mut mailbox,
"writer",
json!({"type": "write", "path": path, "text": "denied"}),
)
.await;
assert_eq!(denied.body["type"], "error", "write to {path} succeeded");
}
let written = ask(
&host,
&mut mailbox,
"writer",
json!({"type": "write", "path": "/public/shared.txt", "text": "public"}),
)
.await;
assert_eq!(written.body["type"], "ok");
let shared = ask(
&host,
&mut mailbox,
"reader",
json!({"type": "read", "path": "/actor/writer/public/shared.txt"}),
)
.await;
assert_eq!(shared.body, json!({"type": "content", "text": "public"}));
host.stop().await;
}
#[tokio::test]
async fn actors_can_read_but_not_write_other_actor_data() {
let (_, host, mut mailbox) = filesystem_world("actor-data-sharing").await;
ask_type(
&host,
&mut mailbox,
"writer",
json!({"type": "write", "path": "/data/x", "text": "shared"}),
"ok",
)
.await;
let shared = ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "read", "path": "/actor/writer/data/x"}),
"content",
)
.await;
assert_eq!(shared.body["text"], "shared");
ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "write", "path": "/actor/writer/data/x", "text": "changed"}),
"error",
)
.await;
let unchanged = ask_type(
&host,
&mut mailbox,
"writer",
json!({"type": "read", "path": "/data/x"}),
"content",
)
.await;
assert_eq!(unchanged.body["text"], "shared");
host.stop().await;
}
#[tokio::test]
async fn actors_serve_files_under_fs() {
// "writer" serves its fs/ from memory (toolbox fs_read / fs_write / fs_remove).
let (_, host, mut mailbox) = filesystem_world("actor-fs").await;
let file = "/actor/writer/fs/notes/a.txt";
let write = json!({"type": "write", "path": file, "text": "hello"});
ask_type(&host, &mut mailbox, "reader", write, "ok").await;
// The write reached the owner as one fs_write message.
let direct = json!({"type": "fs_read", "path": "/notes/a.txt"});
let served = ask_type(&host, &mut mailbox, "writer", direct, "fs_file").await;
assert_eq!(served.body["text"], "hello");
let read = json!({"type": "read", "path": file});
assert_eq!(ask_type(&host, &mut mailbox, "reader", read, "content").await.body["text"], "hello");
let list = json!({"type": "list", "path": "/actor/writer/fs/notes"});
assert_eq!(ask_type(&host, &mut mailbox, "reader", list, "entries").await.body["names"], json!(["a.txt"]));
let root = json!({"type": "list", "path": "/actor/writer/fs"});
assert_eq!(ask_type(&host, &mut mailbox, "reader", root, "entries").await.body["names"], json!(["notes"]));
// fs_error and undeliverable become errors of the file operation.
for path in ["/actor/writer/fs/missing.txt", "/actor/nobody/fs/x"] {
ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": path}), "error").await;
}
// An actor that does not answer (here: it is busy reading itself) fails after the timeout.
let id = host.broker.send("console", Some("reader"), None, None, json!({"type": "read", "path": "/actor/reader/fs/x"}));
let timed_out = receive_reply(&mut mailbox, &id, Duration::from_secs(20)).await;
assert_eq!(timed_out.body["type"], "error");
host.stop().await;
}
#[tokio::test]
async fn actor_fs_is_discoverable_only_when_its_source_docs_advertise_it() {
let world = create_world("actor-fs-discovery");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let advertised = passing_source(&world, "advertised-fs");
std::fs::create_dir_all(advertised.join("src")).unwrap();
std::fs::write(
advertised.join("src/main.rs"),
"//! test actor\n//! Actor-served files: implements fs_read and fs_dir.\n",
)
.unwrap();
spawn_type(&host, &mut mailbox, "served", &wasm, &advertised).await;
let undocumented = passing_source(&world, "undocumented-fs");
std::fs::create_dir_all(undocumented.join("src")).unwrap();
std::fs::write(undocumented.join("src/main.rs"), "//! test actor without a virtual filesystem\n").unwrap();
spawn_type(&host, &mut mailbox, "plain", &wasm, &undocumented).await;
let reader_source = passing_source(&world, "fs-reader");
spawn_type(&host, &mut mailbox, "reader", &wasm, &reader_source).await;
let served_entries = ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "list", "path": "/actor/served"}),
"entries",
)
.await;
assert!(served_entries.body["names"].as_array().unwrap().iter().any(|name| name.as_str() == Some("fs")));
let plain_entries = ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "list", "path": "/actor/plain"}),
"entries",
)
.await;
assert!(!plain_entries.body["names"].as_array().unwrap().iter().any(|name| name.as_str() == Some("fs")));
let path = "/actor/served/fs/readme.txt";
ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "write", "path": path, "text": "virtual document"}),
"ok",
)
.await;
let document = ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "read", "path": path}),
"content",
)
.await;
assert_eq!(document.body["text"], "virtual document");
host.stop().await;
}
#[tokio::test]
async fn files_are_plain_posix_files() {
let (_, host, mut mailbox) = filesystem_world("posix").await;
// An open file is visible by path, to the writer and to every reader.
let open = json!({"type": "open", "path": "/public/new.txt", "text": "new"});
ask_type(&host, &mut mailbox, "writer", open, "opened").await;
let own = json!({"type": "read", "path": "/public/new.txt"});
assert_eq!(ask_type(&host, &mut mailbox, "writer", own, "content").await.body["text"], "new");
let other = json!({"type": "read", "path": "/actor/writer/public/new.txt"});
assert_eq!(ask_type(&host, &mut mailbox, "reader", other, "content").await.body["text"], "new");
// It can be renamed while open.
let rename = json!({"type": "rename", "path": "/public/new.txt", "to": "/public/moved.txt"});
ask_type(&host, &mut mailbox, "writer", rename, "ok").await;
ask_type(&host, &mut mailbox, "writer", json!({"type": "close", "path": "/public/new.txt"}), "closed").await;
let moved = json!({"type": "read", "path": "/actor/writer/public/moved.txt"});
assert_eq!(ask_type(&host, &mut mailbox, "reader", moved, "content").await.body["text"], "new");
host.stop().await;
}
#[tokio::test]
async fn filesystem_rejects_paths_that_escape_data() {
let (_, host, mut mailbox) = filesystem_world("filesystem-path-escape").await;
let escaped = ask(
&host,
&mut mailbox,
"writer",
json!({"type": "write", "path": "/data/../../..", "text": "outside"}),
)
.await;
assert_eq!(escaped.body["type"], "error");
host.stop().await;
}
#[tokio::test]
async fn upgrades_test_candidates_keep_old_versions_and_queue_messages_in_order() {
let world = create_world("upgrade");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let passing = passing_source(&world, "passing");
let failing = source_with_test(
&world,
"failing",
json!({
"steps": [{
"send": {"type": "echo", "value": "candidate"},
"expect": {"type": "wrong"}
}]
}),
);
assert_eq!(
spawn_actor(&host, &mut mailbox, "queue", &wasm, &passing)
.await
.body["type"],
"spawned"
);
let failed = upgrade_actor(&host, &mut mailbox, "queue", &wasm, &failing).await;
assert_eq!(failed.body["type"], "error");
let old_version = ask(&host, &mut mailbox, "queue", json!({"type": "version"})).await;
assert_eq!(old_version.body["version"], "1");
// A version that fails its tests never enters versions/.
assert!(!world.actor_dir("queue").join("versions/2").exists());
let upgrade_id = host.broker.send(
"console",
Some("broker"),
None,
None,
json!({"type": "upgrade", "actor": "queue", "wasm": wasm.display().to_string(), "source": passing.display().to_string()}),
);
tokio::time::timeout(Duration::from_secs(10), async {
while !world.data_dir("queue").join("shutdown").is_file() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("old actor did not enter shutdown during upgrade");
let mut message_ids = Vec::new();
for n in 1..=3 {
message_ids.push(host.broker.send(
"console",
Some("queue"),
None,
None,
json!({"type": "ordered", "n": n}),
));
}
let mut upgrade_result = None;
let mut actor_replies = Vec::new();
tokio::time::timeout(Duration::from_secs(15), async {
while upgrade_result.is_none() || actor_replies.len() < message_ids.len() {
let envelope = mailbox.recv().await.expect("console mailbox closed");
if envelope.reply_to.as_deref() == Some(upgrade_id.as_str()) {
upgrade_result = Some(envelope);
} else if message_ids
.iter()
.any(|id| envelope.reply_to.as_deref() == Some(id.as_str()))
{
actor_replies.push(envelope);
} else {
panic!("unexpected message during queued upgrade: {envelope:?}");
}
}
})
.await
.expect("upgrade did not deliver queued messages");
assert_eq!(upgrade_result.unwrap().body["type"], "upgraded");
let actual_ids: Vec<String> = actor_replies
.iter()
.map(|message| message.reply_to.clone().unwrap())
.collect();
assert_eq!(actual_ids, message_ids);
for (index, envelope) in actor_replies.iter().enumerate() {
assert_eq!(envelope.from, "queue");
assert_eq!(envelope.body["type"], "ordered");
assert_eq!(envelope.body["n"], json!(index + 1));
assert_eq!(envelope.body["version"], "2");
}
host.stop().await;
}
#[tokio::test]
async fn rolls_back_a_crash_loop_to_the_previous_version_without_restoring_data() {
let world = create_world("crash-loop");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let source = passing_source(&world, "source");
assert_eq!(
spawn_actor(&host, &mut mailbox, "crashloop", &wasm, &source)
.await
.body["type"],
"spawned"
);
assert_eq!(
ask(
&host,
&mut mailbox,
"crashloop",
json!({"type": "write", "path": "/data/state", "text": "stable"})
)
.await
.body["type"],
"ok"
);
let upgrade_id = host.broker.send(
"console",
Some("broker"),
None,
None,
json!({"type": "upgrade", "actor": "crashloop", "wasm": wasm.display().to_string(), "source": source.display().to_string()}),
);
let mut upgraded: Option<Value> = None;
let mut rolled_back: Option<Value> = None;
tokio::time::timeout(Duration::from_secs(35), async {
while upgraded.is_none() || rolled_back.is_none() {
let envelope = mailbox.recv().await.expect("console mailbox closed");
let kind = envelope.body["type"].as_str().map(str::to_string);
match kind.as_deref() {
Some("upgraded") if envelope.reply_to.as_deref() == Some(upgrade_id.as_str()) => {
upgraded = Some(envelope.body);
}
Some("rolled_back") => rolled_back = Some(envelope.body),
other => panic!("unexpected automatic rollback message: {other:?}, {envelope:?}"),
}
}
})
.await
.expect("crash loop did not trigger automatic rollback");
assert_eq!(upgraded.unwrap()["version"], 2);
let rollback = rolled_back.unwrap();
assert_eq!(rollback["actor"], "crashloop");
assert_eq!(rollback["from"], 2);
assert_eq!(rollback["to"], 1);
assert_eq!(rollback["reason"], "crash loop after upgrade");
let version = ask(&host, &mut mailbox, "crashloop", json!({"type": "version"})).await;
assert_eq!(version.body["version"], "1");
let current = ask(
&host,
&mut mailbox,
"crashloop",
json!({"type": "read", "path": "/data/state"}),
)
.await;
assert_eq!(current.body["text"], "bad-v2");
host.stop().await;
}
#[tokio::test]
async fn explicit_rollback_keeps_current_data() {
let world = create_world("explicit-rollback");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let source = passing_source(&world, "source");
assert_eq!(
spawn_actor(&host, &mut mailbox, "recover", &wasm, &source)
.await
.body["type"],
"spawned"
);
ask(
&host,
&mut mailbox,
"recover",
json!({"type": "write", "path": "/data/state", "text": "version-one"}),
)
.await;
let upgraded = upgrade_actor(&host, &mut mailbox, "recover", &wasm, &source).await;
assert_eq!(upgraded.body["type"], "upgraded");
ask(
&host,
&mut mailbox,
"recover",
json!({"type": "write", "path": "/data/state", "text": "version-two"}),
)
.await;
let rolled_back = request(
&host,
&mut mailbox,
json!({"type": "rollback", "actor": "recover", "version": 1}),
)
.await;
assert_eq!(rolled_back.body["type"], "rolled_back");
assert_eq!(rolled_back.body["from"], 2);
assert_eq!(rolled_back.body["to"], 1);
let version = ask(&host, &mut mailbox, "recover", json!({"type": "version"})).await;
assert_eq!(version.body["version"], "1");
let current = ask(
&host,
&mut mailbox,
"recover",
json!({"type": "read", "path": "/data/state"}),
)
.await;
assert_eq!(current.body["text"], "version-two");
host.stop().await;
}
#[tokio::test]
async fn remove_shuts_an_actor_down_and_deletes_its_folder() {
let world = create_world("remove");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let source = passing_source(&world, "source");
spawn_type(&host, &mut mailbox, "doomed", &wasm, &source).await;
let write = json!({"type": "write", "path": "/data/state", "text": "x"});
ask_type(&host, &mut mailbox, "doomed", write, "ok").await;
ask_type(&host, &mut mailbox, "doomed", json!({"type": "arm-shutdown"}), "armed").await;
// Normal shutdown: the actor gets "shutdown" and its last message is routed.
let id = host.broker.send("console", Some("broker"), None, None, json!({"type": "remove", "actor": "doomed"}));
let (mut removed, mut last_words) = (false, false);
tokio::time::timeout(Duration::from_secs(10), async {
while !removed || !last_words {
let message = mailbox.recv().await.expect("console mailbox closed");
if message.reply_to.as_deref() == Some(id.as_str()) {
assert_eq!(message.body, json!({"type": "removed", "actor": "doomed"}));
removed = true;
} else if message.reply_to.as_deref() == Some("broker/shutdown") {
assert_eq!(message.body["type"], "shutdown-reply");
last_words = true;
}
}
})
.await
.expect("remove did not answer or did not shut the actor down");
assert!(!world.actor_dir("doomed").exists());
let gone = ask(&host, &mut mailbox, "doomed", json!({"type": "echo", "value": "hi"})).await;
assert_eq!(gone.body["type"], "undeliverable");
let again = request(&host, &mut mailbox, json!({"type": "remove", "actor": "doomed"})).await;
assert_eq!(again.body["type"], "error");
let broker = request(&host, &mut mailbox, json!({"type": "remove", "actor": "broker"})).await;
assert_eq!(broker.body["type"], "error");
assert!(world.actor_dir("broker").exists());
// The ID is free again: a new spawn starts at version 1 with empty data.
assert_eq!(spawn_type(&host, &mut mailbox, "doomed", &wasm, &source).await.body["version"], 1);
let read = json!({"type": "read", "path": "/data/state"});
ask_type(&host, &mut mailbox, "doomed", read, "error").await;
host.stop().await;
}
#[tokio::test]
async fn unknown_broker_request_lists_the_known_ones() {
let world = create_world("unknown-request");
let (host, mut mailbox) = start(&world).await;
let unknown = request(&host, &mut mailbox, json!({"type": "frobnicate"})).await;
assert_eq!(unknown.body["type"], "error");
let reason = unknown.body["reason"].as_str().unwrap();
assert!(reason.contains("remove") && reason.contains("tap"), "{reason}");
host.stop().await;
}
#[tokio::test]
async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() {
let world = create_world("cargo-actor");
let (host, mut mailbox) = start(&world).await;
let valid_source = cargo_source("cargo-valid");
let valid_id = host.broker.send(
"console",
Some("cargo"),
None,
None,
json!({"type": "build", "source": valid_source.display().to_string()}),
);
let built = receive_reply(&mut mailbox, &valid_id, Duration::from_secs(120)).await;
assert_eq!(built.body["type"], "built");
let wasm = built.body["wasm"].as_str().unwrap();
assert!(wasm.ends_with(".wasm"));
assert!(host.world.resolve("console", wasm).unwrap().is_file());
let invalid_source = cargo_source("cargo-fail");
let invalid_id = host.broker.send(
"console",
Some("cargo"),
None,
None,
json!({"type": "build", "source": invalid_source.display().to_string()}),
);
let failed = receive_reply(&mut mailbox, &invalid_id, Duration::from_secs(120)).await;
assert_eq!(failed.body["type"], "build_failed");
assert!(
failed.body["errors"]
.as_str()
.unwrap()
.contains("missing_function")
);
host.stop().await;
}
#[tokio::test]
async fn rejects_invalid_actor_ids() {
let world = create_world("invalid-id");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let invalid = request(
&host,
&mut mailbox,
json!({"type": "spawn", "actor": "bad/actor", "wasm": wasm.display().to_string()}),
)
.await;
assert_eq!(invalid.body["type"], "error");
assert!(
invalid.body["reason"]
.as_str()
.unwrap()
.contains("invalid actor ID")
);
host.stop().await;
}
#[tokio::test]
async fn rejects_parent_paths() {
let world = create_world("parent-path");
let (host, mut mailbox) = start(&world).await;
let parent_path = request(
&host,
&mut mailbox,
json!({"type": "spawn", "actor": "safe", "wasm": "/public/../escape.wasm"}),
)
.await;
assert_eq!(parent_path.body["type"], "error");
assert!(
parent_path.body["reason"]
.as_str()
.unwrap()
.contains("must not contain '..'")
);
host.stop().await;
}
#[tokio::test]
async fn concurrent_spawns_of_one_id_leave_one_live_instance() {
let world = create_world("concurrent-spawn");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let source = passing_source(&world, "source");
let ids = [
spawn_request(&host, "spawn-race", &wasm, &source),
spawn_request(&host, "spawn-race", &wasm, &source),
];
let replies = receive_replies(&mut mailbox, &ids, Duration::from_secs(20)).await;
let spawned = replies
.iter()
.filter(|r| r.body["type"] == "spawned")
.count();
let failed = replies.iter().filter(|r| r.body["type"] == "error").count();
assert_eq!((spawned, failed), (1, 1));
let echo = ask_type(
&host,
&mut mailbox,
"spawn-race",
json!({"type": "echo", "value": "live"}),
"echo",
)
.await;
assert_eq!(echo.body["value"], "live");
tokio::time::sleep(Duration::from_millis(100)).await;
let list = ask_type(
&host,
&mut mailbox,
"spawn-race",
json!({"type": "list", "path": "/data"}),
"entries",
)
.await;
let starts = list.body["names"]
.as_array()
.unwrap()
.iter()
.filter(|n| n.as_str().unwrap().starts_with("start-"))
.count();
assert_eq!(starts, 1);
host.stop().await;
}
#[tokio::test]
async fn stop_finishes_when_actor_stdin_is_full() {
let world = create_world("full-stdin-stop");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let source = passing_source(&world, "source");
spawn_type(&host, &mut mailbox, "full-stdin", &wasm, &source).await;
let ignore = json!({"type": "ignore-stdin"});
ask_type(&host, &mut mailbox, "full-stdin", ignore, "ignoring").await;
let large = json!({"type": "echo", "value": "x".repeat(256 * 1024)});
host.broker
.send("console", Some("full-stdin"), None, None, large);
tokio::time::sleep(Duration::from_millis(100)).await;
let stop = json!({"type": "stop", "actor": "full-stdin"});
let id = host
.broker
.send("console", Some("broker"), None, None, stop);
let stopped = tokio::time::timeout(
Duration::from_secs(6),
receive_reply(&mut mailbox, &id, Duration::from_secs(10)),
)
.await
.expect("stop exceeded the five-second shutdown deadline");
assert_eq!(stopped.body["type"], "stopped");
host.stop().await;
}
#[tokio::test]
async fn shutdown_reply_and_publish_survive_stop_and_upgrade() {
let world = create_world("shutdown-output");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let source = passing_source(&world, "source");
spawn_type(&host, &mut mailbox, "shutdown-output", &wasm, &source).await;
let subscribe = json!({"type": "subscribe", "actor": "shutdown-output"});
ask_type(&host, &mut mailbox, "broker", subscribe, "subscribed").await;
for operation in ["stop", "upgrade"] {
let arm = json!({"type": "arm-shutdown"});
ask_type(&host, &mut mailbox, "shutdown-output", arm, "armed").await;
let body = match operation {
"stop" => json!({"type": "stop", "actor": "shutdown-output"}),
_ => {
json!({"type": "upgrade", "actor": "shutdown-output", "wasm": wasm.display().to_string(), "source": source.display().to_string()})
}
};
let id = host
.broker
.send("console", Some("broker"), None, None, body);
receive_shutdown_messages(
&mut mailbox,
&id,
if operation == "stop" {
"stopped"
} else {
"upgraded"
},
)
.await;
if operation == "stop" {
let start = json!({"type": "start", "actor": "shutdown-output"});
ask_type(&host, &mut mailbox, "broker", start, "started").await;
}
}
host.stop().await;
}
#[tokio::test]
async fn candidates_receive_version_and_preexisting_spawn_state() {
let world = create_world("candidate-context");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let version_step =
json!({"send": {"type": "version"}, "expect": {"type": "version", "version": "1"}});
let version_source = source_with_test(&world, "version", json!({"steps": [version_step]}));
let state_step = json!({"send": {"type": "read", "path": "/data/state.json"}, "expect": {"type": "content", "text": "seeded"}});
let state_source = source_with_test(&world, "state", json!({"steps": [state_step]}));
let state_dir = world.data_dir("stateful");
std::fs::create_dir_all(&state_dir).unwrap();
std::fs::write(state_dir.join("state.json"), "seeded").unwrap();
let ids = [
spawn_request(&host, "versioned", &wasm, &version_source),
spawn_request(&host, "stateful", &wasm, &state_source),
];
let replies = receive_replies(&mut mailbox, &ids, Duration::from_secs(20)).await;
let successful = replies
.iter()
.filter(|reply| reply.body["type"] == "spawned")
.count();
assert_eq!(successful, 2);
host.stop().await;
}
#[tokio::test]
async fn running_actors_have_sockets_and_candidates_do_not() {
// A TCP server on the host that answers each line with "echo: <line>".
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap().to_string();
tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
tokio::spawn(async move {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
let (read, mut write) = stream.into_split();
let mut lines = tokio::io::BufReader::new(read).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = write.write_all(format!("echo: {line}\n").as_bytes()).await;
}
});
}
});
let world = create_world("sockets");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let tcp = json!({"type": "tcp", "address": address, "text": "hi"});
// The candidate test expects the connection to fail.
let offline = source_with_test(&world, "offline", json!({"steps": [{"send": tcp, "expect": {"type": "error"}}]}));
assert_eq!(spawn_actor(&host, &mut mailbox, "net", &wasm, &offline).await.body["type"], "spawned");
let answer = ask(&host, &mut mailbox, "net", tcp).await;
assert_eq!(answer.body, json!({"type": "tcp", "line": "echo: hi"}));
host.stop().await;
}
/// Every message that arrives until the mailbox is quiet for `quiet`.
async fn drain(mailbox: &mut mpsc::Receiver<Envelope>, quiet: Duration) -> Vec<Envelope> {
let mut messages = Vec::new();
while let Ok(Some(message)) = tokio::time::timeout(quiet, mailbox.recv()).await {
messages.push(message);
}
messages
}
#[tokio::test]
async fn tap_copies_every_routed_message_once() {
let world = create_world("tap");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let source = passing_source(&world, "source");
spawn_type(&host, &mut mailbox, "tapped", &wasm, &source).await;
let subscribe = json!({"type": "subscribe", "actor": "tapped"});
assert_eq!(request(&host, &mut mailbox, subscribe).await.body["type"], "subscribed");
assert_eq!(request(&host, &mut mailbox, json!({"type": "tap"})).await.body["type"], "tapping");
let echo = json!({"type": "echo", "value": "hi"});
let echo_id = host.broker.send("console", Some("tapped"), None, None, echo.clone());
let publish = json!({"type": "publish", "value": "news"});
host.broker.send("console", Some("tapped"), None, None, publish);
let messages = drain(&mut mailbox, Duration::from_secs(2)).await;
let copies: Vec<&Value> = messages
.iter()
.filter(|m| m.from == "broker" && m.body["type"] == "tapped")
.map(|m| &m.body)
.collect();
// 4 copies (2 requests, 1 reply, 1 publish) + the reply + the publish itself.
assert_eq!((copies.len(), messages.len()), (4, 6), "{messages:?}");
for copy in &copies {
assert!(copy["ts"].as_u64().unwrap() > 0, "{copy}");
}
let count = |want: Value| copies.iter().filter(|c| c["message"] == want).count();
let request_copy = json!({"id": echo_id, "from": "console", "to": "tapped", "reply_to": null, "body": echo});
assert_eq!(count(request_copy), 1, "{copies:?}");
let reply_copies = copies
.iter()
.filter(|c| c["message"]["reply_to"] == echo_id.as_str() && c["message"]["from"] == "tapped" && c["message"]["to"] == "console")
.count();
assert_eq!(reply_copies, 1, "{copies:?}");
let publish_copy = copies.iter().find(|c| c["message"]["body"]["type"] == "published").expect("publish not tapped");
assert_eq!(publish_copy["message"]["from"], "tapped");
assert_eq!(publish_copy["message"]["to"], Value::Null);
assert_eq!(request(&host, &mut mailbox, json!({"type": "untap"})).await.body["type"], "untapped");
let echo_id = host.broker.send("console", Some("tapped"), None, None, json!({"type": "echo", "value": "quiet"}));
let messages = drain(&mut mailbox, Duration::from_secs(1)).await;
assert_eq!(messages.len(), 1, "{messages:?}");
assert_eq!(messages[0].reply_to.as_deref(), Some(echo_id.as_str()));
host.stop().await;
}
#[tokio::test]
async fn tap_survives_a_restart() {
let world = create_world("tap-restart");
let (host, mut mailbox) = start(&world).await;
assert_eq!(request(&host, &mut mailbox, json!({"type": "tap"})).await.body["type"], "tapping");
host.stop().await;
assert!(world.read_meta("console").tapped);
let (host, mut mailbox) = start(&world).await;
let id = host.broker.send("console", Some("nobody"), None, None, json!({"type": "hello"}));
let copy = tokio::time::timeout(Duration::from_secs(10), async {
loop {
let m = mailbox.recv().await.expect("console mailbox closed");
if m.body["type"] == "tapped" && m.body["message"]["id"] == id.as_str() {
return m;
}
}
})
.await
.expect("no tapped copy after the restart");
assert_eq!(copy.body["message"]["to"], "nobody");
host.stop().await;
}
#[tokio::test]
async fn a_full_tapper_mailbox_drops_copies_without_blocking() {
let world = create_world("tap-full");
let (host, mut mailbox) = start(&world).await;
// "ghost" taps and never reads its mailbox.
let mut ghost = host.mailbox("ghost");
let mut sink = host.mailbox("sink");
host.broker.send("ghost", Some("broker"), None, None, json!({"type": "tap"}));
tokio::time::timeout(Duration::from_secs(10), async {
while !world.read_meta("ghost").tapped {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("tap was not saved");
let total = sausage::broker::MAILBOX_SIZE + 500;
for n in 0..total {
host.broker.send("console", Some("sink"), None, None, json!({"type": "n", "n": n}));
assert_eq!(sink.try_recv().unwrap().body["n"], n);
}
assert!(mailbox.try_recv().is_err(), "a dropped copy reported something to the sender");
let mut held = 0;
while ghost.try_recv().is_ok() {
held += 1;
}
assert_eq!(held, sausage::broker::MAILBOX_SIZE);
host.broker.send("console", Some("sink"), None, None, json!({"type": "after"}));
assert_eq!(ghost.try_recv().unwrap().body["type"], "tapped");
host.stop().await;
}
File diff suppressed because it is too large Load Diff
-669
View File
@@ -1,669 +0,0 @@
//! Run the agent in the real core against scripted OpenAI and Anthropic HTTP servers.
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::{Response, server::conn::http1, service::service_fn};
use hyper_util::rt::TokioIo;
use sausage::{Host, world::World};
use sausage_vfs::Layer;
use serde_json::{Value, json};
use std::{convert::Infallible, path::PathBuf, sync::OnceLock, time::Duration};
use tokio::{
net::TcpListener,
sync::{mpsc, oneshot},
};
/// The two HTTP contracts supported by the agent.
const APIS: [&str; 3] = [
"openai-responses",
"anthropic-messages",
"openai-chat-completions",
];
/// A captured request and the script's delayed HTTP response.
type Exchange = (String, Value, oneshot::Sender<(u16, Value)>);
/// The script controls each response, including concurrent requests.
pub(super) struct Llm {
pub(super) url: String,
api: &'static str,
requests: mpsc::UnboundedReceiver<Exchange>,
task: tokio::task::JoinHandle<()>,
}
impl Llm {
pub(super) async fn new(api: &'static str) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let (tx, requests) = mpsc::unbounded_channel::<Exchange>();
let task = tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let tx = tx.clone();
tokio::spawn(async move {
let service =
service_fn(move |request: hyper::Request<hyper::body::Incoming>| {
let tx = tx.clone();
async move {
assert_eq!(request.method(), "POST");
// The local Chat Completions fixture explicitly disables authentication.
if api == APIS[2] {
assert!(!request.headers().contains_key("authorization"));
}
let path = request.uri().path().to_string();
let bytes = request.into_body().collect().await.unwrap().to_bytes();
let body = serde_json::from_slice(&bytes).unwrap();
let (reply, answer) = oneshot::channel();
tx.send((path, body, reply)).unwrap();
let (status, body) = answer.await.unwrap();
Ok::<_, Infallible>(
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Full::new(Bytes::from(body.to_string())))
.unwrap(),
)
}
});
http1::Builder::new()
.serve_connection(TokioIo::new(stream), service)
.await
.unwrap();
});
}
});
Self {
url,
api,
requests,
task,
}
}
pub(super) async fn next(&mut self) -> (Value, oneshot::Sender<(u16, Value)>) {
let (path, body, reply) =
tokio::time::timeout(Duration::from_secs(60), self.requests.recv())
.await
.unwrap()
.unwrap();
assert_eq!(
path,
if self.api == APIS[0] {
"/v1/responses"
} else if self.api == APIS[2] {
"/v1/chat/completions"
} else {
"/v1/messages"
}
);
assert_eq!(body["model"], "scripted-test");
(body, reply)
}
pub(super) fn answer(&self, text: &str, calls: &[(&str, Value)]) -> Value {
if self.api == APIS[2] {
let calls: Vec<Value> = calls.iter().enumerate().map(|(n,(name,args))| json!({"id":format!("tool-{n}"),"type":"function","function":{"name":name,"arguments":args.to_string()}})).collect();
json!({"choices":[{"message":{"role":"assistant","content":text,"tool_calls":calls}}]})
} else if self.api == APIS[0] {
let mut output =
vec![json!({"type":"message","content":[{"type":"output_text","text":text}]})];
for (n, (name, args)) in calls.iter().enumerate() {
output.push(json!({"type":"function_call","call_id":format!("tool-{n}"),"name":name,"arguments":args.to_string()}));
}
json!({"status":"completed","output":output})
} else {
let mut content = vec![json!({"type":"text","text":text})];
for (n, (name, args)) in calls.iter().enumerate() {
content.push(
json!({"type":"tool_use","id":format!("tool-{n}"),"name":name,"input":args}),
);
}
json!({"content":content,"stop_reason":if calls.is_empty(){"end_turn"}else{"tool_use"}})
}
}
}
impl Drop for Llm {
fn drop(&mut self) {
self.task.abort();
}
}
fn fixtures() -> &'static PathBuf {
static TARGET: OnceLock<PathBuf> = OnceLock::new();
TARGET.get_or_init(|| {
super::common::build("agent", "agent");
super::common::sdk_probe().parent().unwrap().to_owned()
})
}
struct TestWorld(PathBuf);
impl TestWorld {
fn new(server: &Llm) -> Self {
let path = std::env::temp_dir().join(format!("sausage-agent-{}", uuid::Uuid::now_v7()));
let world = World::create(&path).unwrap();
for (plugin, binary) in [("agent", "agent"), ("probe", "sdk-probe")] {
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
std::fs::copy(
fixtures().join(format!("{binary}.wasm")),
world.plugin_dir(plugin).join("plugin.wasm"),
)
.unwrap();
}
std::fs::write(path.join("config/agent.yaml"), serde_yaml_ng::to_string(&json!({"api":server.api,"base_url":if server.api == APIS[1] {server.url.clone()} else {format!("{}/v1",server.url)},"model":"scripted-test","token":if server.api == APIS[2] {""} else {"fake-token"},"allow_no_token":server.api == APIS[2]})).unwrap()).unwrap();
Self(path)
}
async fn start(&self) -> Host {
Host::start_with_cache(
&self.0,
std::path::Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
)
.await
.unwrap()
}
fn history(&self) -> String {
std::fs::read_to_string(self.0.join("state/agent/history.jsonl")).unwrap()
}
}
impl Drop for TestWorld {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn chat(host: &Host, text: &str) -> tokio::task::JoinHandle<Value> {
let host = host.clone();
let text = text.to_owned();
tokio::spawn(async move {
tokio::time::timeout(
Duration::from_secs(90),
host.call("agent.chat", json!({"text":text})),
)
.await
.unwrap()
.unwrap()
})
}
fn input(body: &Value, api: &str) -> Value {
body[if api == APIS[0] { "input" } else { "messages" }].clone()
}
fn outputs(body: &Value, api: &str) -> Vec<Value> {
if api == APIS[2] {
body["messages"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["role"] == "tool")
.map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap())
.collect()
} else if api == APIS[0] {
body["input"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["type"] == "function_call_output")
.map(|e| serde_json::from_str(e["output"].as_str().unwrap()).unwrap())
.collect()
} else {
body["messages"]
.as_array()
.unwrap()
.iter()
.flat_map(|e| e["content"].as_array().unwrap())
.filter(|e| e["type"] == "tool_result")
.map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap())
.collect()
}
}
/// Invalid configuration names the file and the field before any inference request.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unconfigured_chat_names_the_configuration() {
let server = Llm::new(APIS[0]).await;
let world = TestWorld::new(&server);
let path = world.0.join("config/agent.yaml");
std::fs::remove_file(&path).unwrap();
let host = world.start().await;
let error = host
.call("agent.chat", json!({"text":"hello"}))
.await
.unwrap_err();
assert!(
error["message"]
.as_str()
.unwrap()
.contains("/config/agent.yaml")
);
for (config, field) in [
("", "api"),
("api: unknown", "api"),
("api: openai-responses", "base_url"),
(
"api: openai-responses\nbase_url: http://localhost/v1",
"model",
),
(
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: ''",
"token",
),
(
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: CHANGE_ME",
"token",
),
(
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: key\nthinking: hihg",
"thinking",
),
] {
std::fs::write(&path, config).unwrap();
let error = host
.call("agent.chat", json!({"text":"hello"}))
.await
.unwrap_err();
let message = error["message"].as_str().unwrap();
assert!(message.contains("/config/agent.yaml"), "{message}");
assert!(message.contains(field), "{message}");
}
host.stop().await;
}
/// All API shapes return final text and retain the complete history after a core restart.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn final_answers_and_history_survive_restart() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let turn = chat(&host, "first request alpha");
let (body, reply) = server.next().await;
assert!(
input(&body, api)
.to_string()
.contains("first request alpha")
);
assert_eq!(body["tools"].as_array().unwrap().len(), 5);
reply
.send((200, server.answer("first answer alpha", &[])))
.unwrap();
assert_eq!(turn.await.unwrap(), "first answer alpha");
host.stop().await;
let prefix = world.history();
drop(host);
let host = world.start().await;
let turn = chat(&host, "second request beta");
let (body, reply) = server.next().await;
let text = input(&body, api).to_string();
for expected in [
"first request alpha",
"first answer alpha",
"second request beta",
] {
assert!(text.contains(expected));
}
reply
.send((200, server.answer("second answer beta", &[])))
.unwrap();
assert_eq!(turn.await.unwrap(), "second answer beta");
assert!(world.history().starts_with(&prefix));
let entries: Vec<Value> = world
.history()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(
entries
.iter()
.map(|e| e["kind"].as_str().unwrap())
.collect::<Vec<_>>(),
["user", "assistant", "user", "assistant"]
);
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
assert_eq!(doc["x-sausage-grace"], 600);
host.stop().await;
}
}
/// Both API shapes execute every tool, return tool errors, and deliver interim text.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tools_and_interim_notifications() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let mut events = host.subscribe();
let turn = chat(&host, "exercise the tools");
let (_, reply) = server.next().await;
let calls = [
(
"call",
json!({"method":"probe.add","params":{"a":4,"b":5},"detach":true}),
),
("read_file", json!({"path":"/proc/core/PROTOCOL.md"})),
(
"write_file",
json!({"path":"/state/work/source/note.txt","content":"saved"}),
),
("list_dir", json!({"path":"/state/work/source"})),
(
"copy_dir",
json!({"from":"/state/work/source","to":"/state/work/copied"}),
),
("read_file", json!({"path":"/state/work/missing"})),
("call", json!({"method":"absent.run","params":{}})),
("call", json!({"method":"probe.fail","params":{}})),
];
reply
.send((200, server.answer("I will inspect the world.", &calls)))
.unwrap();
let (body, reply) = server.next().await;
let results = outputs(&body, api);
assert_eq!(results.len(), 8);
assert_eq!(results[0], 9);
assert!(results[1]["content"].as_str().unwrap().contains("JSON-RPC"));
assert_eq!(results[2]["written"], "/state/work/source/note.txt");
assert_eq!(results[3]["entries"][0]["name"], "note.txt");
assert_eq!(results[4]["copied_files"], 1);
assert!(results[5]["error"]["message"].is_string());
assert!(results[6]["error"]["code"].is_number());
assert_eq!(
results[7]["error"],
json!({"code":-32010,"message":"deliberate","data":{"diagnostic":"probe error details"}})
);
assert_eq!(
std::fs::read_to_string(world.0.join("state/work/copied/note.txt")).unwrap(),
"saved"
);
let event = tokio::time::timeout(Duration::from_secs(5), events.recv())
.await
.unwrap()
.unwrap();
assert_eq!(event["params"]["name"], "text");
assert_eq!(
event["params"]["params"]["text"],
"I will inspect the world."
);
reply
.send((200, server.answer("The tools finished.", &[])))
.unwrap();
assert_eq!(turn.await.unwrap(), "The tools finished.");
host.stop().await;
}
}
/// The history lock holds the second chat until the complete first turn ends.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_chats_serialize_complete_turns() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let first = chat(&host, "first concurrent request");
let (_, reply) = server.next().await;
let second = chat(&host, "second concurrent request");
let view = host.filesystem().await.unwrap();
tokio::time::timeout(Duration::from_secs(10), async {
loop {
let file = view
.open("/proc/core/locks.json", sausage_vfs::Open::default())
.await
.unwrap();
let locks: Value =
serde_json::from_slice(&file.read(0, 65536).await.unwrap()).unwrap();
if locks["/state/agent/history.jsonl"]["waiting"]
.as_array()
.is_some_and(|waiting| waiting.len() == 1)
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(server.requests.try_recv().is_err());
reply
.send((200, server.answer("first concurrent answer", &[])))
.unwrap();
assert_eq!(first.await.unwrap(), "first concurrent answer");
let (body, reply) = server.next().await;
let text = input(&body, api).to_string();
assert!(text.contains("first concurrent answer"));
assert!(text.contains("second concurrent request"));
reply
.send((200, server.answer("second concurrent answer", &[])))
.unwrap();
assert_eq!(second.await.unwrap(), "second concurrent answer");
host.stop().await;
}
}
/// Context-size failures discard old complete turns without changing the append-only history.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn context_window_advances_by_complete_turns() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let first = chat(&host, "old turn");
let (_, reply) = server.next().await;
reply.send((200, server.answer("old answer", &[]))).unwrap();
first.await.unwrap();
let second = chat(&host, "current turn");
let (_, reply) = server.next().await;
reply
.send((400, json!({"error":"context_length_exceeded"})))
.unwrap();
let (body, reply) = server.next().await;
let text = input(&body, api).to_string();
assert!(!text.contains("old turn"));
assert!(!text.contains("old answer"));
assert!(text.contains("current turn"));
reply
.send((200, server.answer("current answer", &[])))
.unwrap();
assert_eq!(second.await.unwrap(), "current answer");
assert_eq!(world.history().lines().count(), 4);
host.stop().await;
}
}
/// Oversized tool outputs are bounded, and failed retries do not commit a new history cursor.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn oversized_current_turn_preserves_the_previous_cursor() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
std::fs::write(world.0.join("large.txt"), "x".repeat(200_000)).unwrap();
let host = world.start().await;
let first = chat(&host, "old retained turn");
let (_, reply) = server.next().await;
reply
.send((200, server.answer("old retained answer", &[])))
.unwrap();
first.await.unwrap();
let skip = world.0.join("state/agent/history.skip");
std::fs::write(&skip, "0").unwrap();
let caller = host.clone();
let turn = tokio::spawn(async move {
caller
.call("agent.chat", json!({"text":"read a large tool result"}))
.await
});
let (_, reply) = server.next().await;
reply
.send((
200,
server.answer("", &[("read_file", json!({"path":"/large.txt"}))]),
))
.unwrap();
let (body, reply) = server.next().await;
let serialized = input(&body, api).to_string();
assert!(serialized.contains("Full output: /logs/agent/"));
let history = world.history();
let result: Value = history
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.find(|entry| {
entry["output"]
.as_str()
.is_some_and(|out| out.contains("Full output: "))
})
.unwrap();
let output = result["output"].as_str().unwrap();
let path = output
.split("Full output: ")
.nth(1)
.unwrap()
.split(". ")
.next()
.unwrap();
let archive = std::fs::read_to_string(world.0.join(path.trim_start_matches('/'))).unwrap();
assert!(archive.contains(&"x".repeat(200_000)));
assert!(output.len() <= 50 * 1024);
assert!(serialized.len() < 70_000);
reply
.send((413, json!({"type":"request_too_large"})))
.unwrap();
let (body, reply) = server.next().await;
assert!(!input(&body, api).to_string().contains("old retained turn"));
reply
.send((413, json!({"type":"request_too_large"})))
.unwrap();
assert!(turn.await.unwrap().is_err());
assert_eq!(std::fs::read_to_string(&skip).unwrap(), "0");
host.stop().await;
}
}
/// Provider-private items are replayed only for their original API and model.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn private_items_are_scoped_to_the_model() {
for api in [APIS[0], APIS[1]] {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
// Without a thinking level, newer models think by default, so their items return.
std::fs::create_dir_all(world.0.join("state/agent")).unwrap();
let mut entries = vec![json!({"kind":"user","text":"old turn"})];
for (model, marker) in [
("another-model", "old-private-marker"),
("scripted-test", "current-private-marker"),
] {
let item = if api == APIS[0] {
json!({"type":"reasoning","encrypted_content":marker})
} else {
json!({"type":"thinking","thinking":"private","signature":marker})
};
entries.push(json!({"kind":"opaque","api":api,"model":model,"item":item}));
}
entries.push(json!({"kind":"assistant","text":"old answer"}));
std::fs::write(
world.0.join("state/agent/history.jsonl"),
entries
.iter()
.map(|entry| format!("{entry}\n"))
.collect::<String>(),
)
.unwrap();
let host = world.start().await;
let turn = chat(&host, "next turn");
let (body, reply) = server.next().await;
let text = input(&body, api).to_string();
assert!(!text.contains("old-private-marker"));
assert!(text.contains("current-private-marker"));
reply.send((200, server.answer("done", &[]))).unwrap();
assert_eq!(turn.await.unwrap(), "done");
host.stop().await;
}
}
/// Each API gets the configured thinking level. A level in the call applies to that turn only.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn thinking_levels_reach_each_api() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let config_path = world.0.join("config/agent.yaml");
let mut config: Value =
serde_yaml_ng::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
config["thinking"] = json!("high");
std::fs::write(config_path, serde_yaml_ng::to_string(&config).unwrap()).unwrap();
let host = world.start().await;
// Off is `none` for OpenAI, and disabled thinking without an effort for Anthropic.
let off = if api == APIS[1] { None } else { Some("none") };
for (thinking, effort) in [
(None, Some("high")),
(Some("low"), Some("low")),
(Some("off"), off),
(None, Some("high")),
] {
let mut params = json!({"text":"hello"});
if let Some(thinking) = thinking {
params["thinking"] = json!(thinking);
}
let caller = host.clone();
let turn =
tokio::spawn(async move { caller.call("agent.chat", params).await.unwrap() });
let (body, reply) = server.next().await;
let sent = if api == APIS[0] {
&body["reasoning"]["effort"]
} else if api == APIS[2] {
&body["reasoning_effort"]
} else {
let mode = if thinking == Some("off") {
"disabled"
} else {
"adaptive"
};
assert_eq!(body["thinking"]["type"], mode, "{body}");
&body["output_config"]["effort"]
};
assert_eq!(sent.as_str(), effort, "{api} {thinking:?}");
reply.send((200, server.answer("done", &[]))).unwrap();
assert_eq!(turn.await.unwrap(), "done");
}
let error = host
.call("agent.chat", json!({"text":"hello","thinking":"hihg"}))
.await
.unwrap_err();
assert_eq!(error["code"], -32602, "{error}");
host.stop().await;
}
}
/// Separate assistant messages retain a separator in the final answer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn final_texts_have_a_separator() {
for api in [APIS[0], APIS[1]] {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let turn = chat(&host, "two paragraphs");
let (_, reply) = server.next().await;
let mut answer = server.answer("first", &[]);
if api == APIS[0] {
answer["output"]
.as_array_mut()
.unwrap()
.push(json!({"type":"message","content":[{"type":"output_text","text":"second"}]}));
} else {
answer["content"]
.as_array_mut()
.unwrap()
.push(json!({"type":"text","text":"second"}));
}
reply.send((200, answer)).unwrap();
assert_eq!(turn.await.unwrap(), "first\n\nsecond");
host.stop().await;
}
}
/// An incomplete response containing reasoning but no text is an error.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn incomplete_without_text_is_an_error() {
let mut server = Llm::new(APIS[0]).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let caller = host.clone();
let turn =
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"answer"})).await });
let (_, reply) = server.next().await;
reply.send((200, json!({"status":"incomplete","output":[{"type":"reasoning","encrypted_content":"private"}]}))).unwrap();
assert!(
turn.await.unwrap().unwrap_err()["message"]
.as_str()
.unwrap()
.contains("incomplete response")
);
host.stop().await;
}
File diff suppressed because it is too large Load Diff
-450
View File
@@ -1,450 +0,0 @@
//! Binary contracts for atomic init, environment flags, signals, and the piped console.
use sausage::{Host, world::World};
use serde_json::{Value, json};
use std::{
path::{Path, PathBuf},
process::Stdio,
time::Duration,
};
use tokio::{
io::AsyncWriteExt,
process::{Child, Command},
};
use super::common::{CACHE, probe};
fn binary() -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_sausage"));
command
.arg("--cache-dir")
.arg(CACHE)
.env_remove("SAUSAGE_HEADLESS")
.env_remove("SAUSAGE_LOOP")
.kill_on_drop(true);
command
}
fn world() -> (tempfile::TempDir, PathBuf) {
let folder = tempfile::tempdir().unwrap();
let world = World::create(&folder.path().join("world")).unwrap();
(folder, world.root)
}
async fn status(child: &mut Child) -> std::process::ExitStatus {
tokio::time::timeout(Duration::from_secs(15), child.wait())
.await
.expect("CLI did not exit")
.unwrap()
}
async fn journal_exists(world: &Path) {
tokio::time::timeout(Duration::from_secs(15), async {
while !world.join("logs/core/journal.sqlite").exists() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
fn signal(child: &Child, signal: nix::sys::signal::Signal) {
nix::sys::signal::kill(
nix::unistd::Pid::from_raw(child.id().unwrap() as i32),
signal,
)
.unwrap();
}
fn install_probe(world: &Path) {
std::fs::create_dir_all(world.join("plugins/probe")).unwrap();
std::fs::copy(probe(), world.join("plugins/probe/plugin.wasm")).unwrap();
}
/// Failed init leaves no world; successful init seeds the restart policy and a working agent.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn init_builds_agent() {
tokio::time::timeout(Duration::from_secs(300), init_builds_agent_inner())
.await
.expect("init and seeded-agent checks exceeded 300 seconds");
}
async fn init_builds_agent_inner() {
let temporary = tempfile::tempdir().unwrap();
let world = temporary.path().join("world");
// Legacy configuration variables must not affect the templates.
let configured = || {
let mut command = binary();
command
.current_dir(temporary.path())
.env("SAUSAGE_API", "not-an-api")
.env("SAUSAGE_BASE_URL", "http://127.0.0.1:1/")
.env("SAUSAGE_MODEL", "init-test")
.env("SAUSAGE_API_TOKEN", "fake-token")
.stdin(Stdio::null());
command
};
let failed = configured()
.arg("init")
.env("SAUSAGE_HEADLESS", "not-a-boolean")
.env("RUSTC", "/nonexistent-sausage-rustc")
.output()
.await
.unwrap();
assert!(!failed.status.success());
assert!(String::from_utf8_lossy(&failed.stderr).contains("nonexistent-sausage-rustc"));
assert!(!world.exists());
let output = configured().arg("init").output().await.unwrap();
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes")
);
// Bare sausage reopens the completed world instead of trying to recreate it.
assert!(configured().output().await.unwrap().status.success());
let prompt = std::fs::read_to_string(world.join("plugins/agent/source/src/prompt.md")).unwrap();
assert!(prompt.contains("HARD RULE: the mediator and all agents NEVER call `core.restart`"));
assert!(prompt.contains("without the person's explicit approval each time"));
assert!(prompt.contains("alias `core.exit`"));
for text in [
"`#loop`",
"SAUSAGE_LOOP=1",
"answers before",
"exit code 75",
] {
assert!(prompt.contains(text), "seeded prompt missing {text}");
}
let boot = std::fs::read_to_string(world.join("config/boot.yaml")).unwrap();
assert!(boot.contains("SAUSAGE_LOOP=1"));
let mut config: Value =
serde_yaml_ng::from_slice(&std::fs::read(world.join("config/agent.yaml")).unwrap())
.unwrap();
assert_eq!(config["token"], "CHANGE_ME");
assert_eq!(config["api"], "openai-responses");
assert!(String::from_utf8_lossy(&output.stdout).contains("config/agent.yaml"));
assert_eq!(
serde_yaml_ng::from_slice::<Value>(&std::fs::read(world.join("config/boot.yaml")).unwrap())
.unwrap(),
json!([])
);
for file in [
"plugins/cargo/sdk/Cargo.toml",
"plugins/cargo/sdk/src/lib.rs",
"plugins/agent/source/Cargo.toml",
"plugins/agent/source/src/main.rs",
"plugins/agent/plugin.wasm",
] {
assert!(world.join(file).is_file(), "missing {file}");
}
assert_eq!(
std::fs::read(world.join("plugins/agent/source/Cargo.lock")).unwrap(),
include_bytes!("../../../Cargo.lock")
);
assert!(!world.join("state/cargo/target").exists());
assert!(
Path::new(CACHE)
.join("cargo-target/wasm32-wasip2/release")
.is_dir()
);
let host = Host::start_with_cache(&world, Path::new(CACHE))
.await
.unwrap();
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
assert_eq!(doc["x-sausage-grace"], 600);
assert!(
doc["methods"]
.as_array()
.unwrap()
.iter()
.any(|method| method["name"] == "chat")
);
let error = host
.call("agent.chat", json!({"text":"hello"}))
.await
.unwrap_err();
assert!(
error["message"]
.as_str()
.unwrap()
.contains("/config/agent.yaml")
);
assert!(error["message"].as_str().unwrap().contains("token"));
let mut server = super::agent::Llm::new("anthropic-messages").await;
config["api"] = json!("anthropic-messages");
config["base_url"] = json!(server.url);
config["model"] = json!("scripted-test");
config["token"] = json!("fake-token");
std::fs::write(
world.join("config/agent.yaml"),
serde_yaml_ng::to_string(&config).unwrap(),
)
.unwrap();
let caller = host.clone();
let turn =
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"hello"})).await });
let (_, reply) = server.next().await;
reply
.send((200, server.answer("configured agent", &[])))
.unwrap();
assert_eq!(turn.await.unwrap().unwrap(), "configured agent");
host.stop().await;
}
/// Both headless forms ignore EOF. False and empty values preserve the plain console.
#[tokio::test]
async fn headless_flag_and_environment() {
for value in [None, Some("1"), Some("true")] {
let (_folder, world) = world();
let mut command = binary();
command.arg("run").arg(&world).stdin(Stdio::null());
if let Some(value) = value {
command.env("SAUSAGE_HEADLESS", value);
} else {
command.arg("--headless");
}
let mut child = command.spawn().unwrap();
journal_exists(&world).await;
assert!(
tokio::time::timeout(Duration::from_millis(300), child.wait())
.await
.is_err()
);
signal(&child, nix::sys::signal::Signal::SIGTERM);
assert_eq!(status(&mut child).await.code(), Some(0));
}
for value in ["0", ""] {
let (_folder, world) = world();
let mut child = binary()
.arg("run")
.arg(world)
.env("SAUSAGE_HEADLESS", value)
.stdin(Stdio::null())
.spawn()
.unwrap();
assert_eq!(status(&mut child).await.code(), Some(0));
}
}
/// Signals interrupt an unanswered boot service instead of waiting forever.
#[tokio::test]
async fn signals_work_during_boot() {
for sig in [
nix::sys::signal::Signal::SIGTERM,
nix::sys::signal::Signal::SIGINT,
] {
let (_folder, world) = world();
std::fs::write(
world.join("config/boot.yaml"),
json!([
{"method":"core.lock","params":{"path":"boot"}},
{"method":"core.lock","params":{"path":"boot"}}
])
.to_string(),
)
.unwrap();
let mut child = binary()
.arg("run")
.arg(&world)
.arg("--headless")
.stdin(Stdio::null())
.spawn()
.unwrap();
journal_exists(&world).await;
tokio::time::timeout(Duration::from_secs(15), async {
loop {
let db =
rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
if let Ok(2) = db.query_row(
"SELECT count(*) FROM journal WHERE line='service start core.lock'",
[],
|row| row.get::<_, i64>(0),
) {
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
signal(&child, sig);
assert_eq!(status(&mut child).await.code(), Some(0));
}
}
/// Piped input supports commands, quit, and the restart exit code.
#[tokio::test]
async fn piped_console_and_exit_codes() {
for (line, supervised, expected) in [
("help\n", false, 0),
("quit\n", false, 0),
("core.restart\n", false, 0),
("core.exit\n", false, 0),
("core.restart\n", true, 75),
("core.exit\n", true, 75),
] {
let (_folder, world) = world();
let mut command = binary();
command.arg("run").arg(world);
if supervised {
command.env("SAUSAGE_LOOP", "1");
}
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
// Supervised calls must finish via restart, not a racing console EOF.
let mut input = child.stdin.take().unwrap();
input.write_all(line.as_bytes()).await.unwrap();
if !supervised {
drop(input);
}
let output = tokio::time::timeout(Duration::from_secs(15), child.wait_with_output())
.await
.unwrap()
.unwrap();
assert_eq!(output.status.code(), Some(expected));
if !supervised && line.starts_with("core.") {
assert!(String::from_utf8_lossy(&output.stdout).contains("SAUSAGE_LOOP not set"));
}
if expected == 0 {
assert!(!output.stdout.is_empty());
}
}
}
/// A console input error still stops live actors and flushes the journal.
#[tokio::test]
async fn console_error_stops_host() {
let (_folder, world) = world();
install_probe(&world);
std::fs::write(
world.join("config/boot.yaml"),
json!([{"method":"probe.serve","params":{}}]).to_string(),
)
.unwrap();
let mut child = binary()
.arg("run")
.arg(&world)
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(&[255, 10])
.await
.unwrap();
assert_eq!(status(&mut child).await.code(), Some(1));
let db = rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
let stopped: i64 = db
.query_row(
"SELECT count(*) FROM journal WHERE plugin='probe' AND line='stop'",
[],
|row| row.get(0),
)
.unwrap();
assert!(stopped > 0);
}
/// SIGINT stops only the wait, so ps can still find the live call.
#[tokio::test]
async fn interrupt_leaves_call_running() {
let (_folder, world) = world();
install_probe(&world);
let mut child = binary()
.arg("run")
.arg(&world)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
input.write_all(b"probe.busy\n").await.unwrap();
let pid = tokio::time::timeout(Duration::from_secs(20), async {
loop {
if world.join("logs/core/journal.sqlite").exists() {
let db =
rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
if let Ok(pid) = db.query_row(
"SELECT pid FROM journal WHERE plugin='probe' AND line='start busy {}'",
[],
|r| r.get::<_, String>(0),
) {
break pid;
}
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
signal(&child, nix::sys::signal::Signal::SIGINT);
input
.write_all(format!("ps\nkill {pid}\nquit\n").as_bytes())
.await
.unwrap();
drop(input);
let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output())
.await
.unwrap()
.unwrap();
let text = String::from_utf8(output.stdout).unwrap();
assert_eq!(output.status.code(), Some(0), "{text}");
assert!(
text.contains("stopped waiting; the call continues, see ps"),
"{text}"
);
assert!(
text.contains(&format!("{pid} probe busy console running")),
"{text}"
);
}
/// Piped calls wait for activation, and EOF or quit preserves the last result.
#[tokio::test]
async fn piped_console_runs_dependent_commands_in_order() {
for ending in ["", "quit\n"] {
let (_folder, world) = world();
std::fs::copy(probe(), world.join("candidate.wasm")).unwrap();
std::fs::create_dir_all(world.join("state/probe")).unwrap();
std::fs::write(world.join("state/probe/discover.json"), r#"{"methods":[]}"#).unwrap();
let activate = r#"core.activate {"plugin":"notes","wasm":"candidate.wasm","force":true}"#;
let call = r#"notes.echo {"ordered":true}"#;
let mut child = binary()
.arg("run")
.arg(&world)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(format!("{activate}\n{call}\n{ending}").as_bytes())
.await
.unwrap();
let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output())
.await
.unwrap()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert_eq!(
output.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!stdout.contains("error:"), "{stdout}");
assert!(stdout.contains(r#""ordered": true"#), "{stdout}");
assert!(!stdout.contains("> core.activate"), "{stdout}");
if !ending.is_empty() {
assert!(
stdout.find(r#""ordered": true"#).unwrap() < stdout.find("Goodbye.").unwrap(),
"{stdout}"
);
}
}
}
-43
View File
@@ -1,43 +0,0 @@
//! Persistent caches and guest builds shared by the host integration modules.
use std::{
path::{Path, PathBuf},
sync::OnceLock,
};
pub const CACHE: &str = concat!(env!("CARGO_TARGET_TMPDIR"), "/cache");
pub fn build(package: &str, binary: &str) -> PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let target = Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/guests"));
assert!(
std::process::Command::new("cargo")
.args([
"build",
"--offline",
"--target",
"wasm32-wasip2",
"--manifest-path"
])
.arg(root.join(package).join("Cargo.toml"))
.arg("--target-dir")
.arg(target)
.status()
.unwrap()
.success()
);
target
.join("wasm32-wasip2/debug")
.join(format!("{binary}.wasm"))
}
pub fn probe() -> PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| build("cli/tests/plugins/probe", "probe"))
.clone()
}
pub fn sdk_probe() -> PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| build("sdk/tests/plugins/probe", "sdk-probe"))
.clone()
}
-298
View File
@@ -1,298 +0,0 @@
//! Console command contracts against a temporary world and the raw probe component.
use super::common::probe as fixture;
use sausage::{
Host,
console::{execute_line, notification_text},
world::World,
};
use serde_json::{Value, json};
use std::path::PathBuf;
struct TestWorld(PathBuf);
impl TestWorld {
fn new() -> Self {
let root = std::env::temp_dir().join(format!("sausage-console-{}", uuid::Uuid::now_v7()));
let world = World::create(&root).unwrap();
for plugin in ["probe", "agent"] {
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
std::fs::copy(fixture(), world.plugin_dir(plugin).join("plugin.wasm")).unwrap();
}
std::fs::create_dir_all(world.state_dir("probe")).unwrap();
std::fs::write(world.state_dir("probe").join("discover.json"),json!({"methods":[{"name":"echo","description":"Echo supplied text.","params":[{"name":"text","description":"The input text.","schema":{"type":"string"}}],"result":{"schema":{"type":"string"}}}]}).to_string()).unwrap();
let mut candidate = std::fs::read(fixture()).unwrap();
// A custom section creates a new version without changing probe behavior.
candidate.extend_from_slice(&[0, 2, 1, b'c']);
std::fs::write(root.join("candidate.wasm"), candidate).unwrap();
Self(root)
}
async fn start(&self) -> Host {
let cache = PathBuf::from(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache"));
Host::start_with_cache(&self.0, &cache).await.unwrap()
}
}
impl Drop for TestWorld {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
async fn command(host: &Host, line: &str) -> String {
tokio::time::timeout(std::time::Duration::from_secs(60), execute_line(host, line))
.await
.unwrap()
.unwrap()
}
/// Explicit calls return indented JSON data, and RPC errors remain visible.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rpc_call() {
let world = TestWorld::new();
let host = world.start().await;
let result = command(&host, "probe.echo {\"answer\":42}").await;
assert!(result.contains("\n \"answer\": 42\n"));
assert!(command(&host, "absent.echo {}").await.contains("error:"));
assert_eq!(
serde_json::from_str::<Value>(&command(&host, r#"agent.chat {"text":"explicit"}"#).await)
.unwrap(),
json!({"text":"explicit"})
);
assert_eq!(
command(&host, "probe.echo not-json").await,
"probe.echo not-json"
);
host.stop().await;
}
/// Plain text calls agent.chat and its interim notification uses dim styling.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn chat() {
let world = TestWorld::new();
let host = world.start().await;
let mut notifications = host.subscribe();
assert_eq!(command(&host, "hello agent").await, "hello agent");
let value = notifications.recv().await.unwrap();
assert_eq!(notification_text(&value), "\x1b[2minterim\x1b[0m");
assert_eq!(
command(&host, "Hello. Another sentence.").await,
"Hello. Another sentence."
);
host.stop().await;
}
/// Invalid command arguments and dotted prose remain agent chat.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn prose_is_not_a_command() {
let world = TestWorld::new();
let host = world.start().await;
for line in [
"help me write a notes plugin",
"ps is useful",
"stop it",
"kill that",
"cat",
"methods probe extra",
"ls too many words",
"log -n nope",
"README.md says hello",
"agent.chat not-json",
"e.g. write notes",
] {
assert_eq!(command(&host, line).await, line);
}
host.stop().await;
}
/// Folder listings include the live mounts and accept an explicit folder.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ls() {
let world = TestWorld::new();
let host = world.start().await;
let result = command(&host, "ls").await;
for name in ["proc/", "run/", "self/"] {
assert!(result.contains(name));
}
assert!(command(&host, "ls /proc/core").await.contains("plugins/"));
host.stop().await;
}
/// Cat reads multiple chunks and finite generated snapshots through the VFS.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cat() {
let world = TestWorld::new();
let host = world.start().await;
let text = "x".repeat(150_000);
std::fs::write(world.0.join("large"), &text).unwrap();
assert_eq!(command(&host, "cat /large").await, text);
host.call("probe.write", json!({"path":"/run/shared","text":"memory"}))
.await
.unwrap();
assert_eq!(command(&host, "cat /run/shared").await, "memory");
assert!(
command(&host, "cat /proc/core/PROTOCOL.md")
.await
.contains("filesystem")
);
assert!(execute_line(&host, "cat /missing").await.is_err());
host.stop().await;
}
/// Method summaries include parameter types, result types, and documentation.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn methods() {
let world = TestWorld::new();
let host = world.start().await;
let result = command(&host, "methods probe").await;
for text in [
"probe.echo(text: string) -> string",
"Echo supplied text.",
"The input text.",
] {
assert!(result.contains(text), "{result}");
}
host.stop().await;
}
/// Ps includes every requested column and the live process metadata.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn ps() {
let world = TestWorld::new();
let host = world.start().await;
let pid = host.call("probe.serve", json!({})).await.unwrap();
let text = command(&host, "ps").await;
assert!(text.contains("PID PLUGIN METHOD PARENT STATE AGE"));
assert!(text.contains(pid.as_str().unwrap()));
assert!(text.contains("probe serve console"));
host.stop().await;
}
/// Log formats local times and short pids, while core.log preserves JSON and filters.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn log() {
let world = TestWorld::new();
std::fs::create_dir_all(world.0.join("plugins/data-logger")).unwrap();
std::fs::copy(fixture(), world.0.join("plugins/data-logger/plugin.wasm")).unwrap();
let host = world.start().await;
host.call("data-logger.echo", json!({})).await.unwrap();
// Finish discovery before comparing two snapshots of the journal.
command(&host, "methods data-logger").await;
let pid = host.call("probe.serve", json!({})).await.unwrap();
for (line, filters) in [
("log".to_owned(), json!({})),
(
"log data-logger -n 1".to_owned(),
json!({"plugin":"data-logger","limit":1}),
),
(
"log probe -n 1".to_owned(),
json!({"plugin":"probe","limit":1}),
),
(
format!("log -n 1 {}", pid.as_str().unwrap()),
json!({"pid":pid,"limit":1}),
),
] {
let raw = command(&host, &format!("core.log {filters}")).await;
let entries: Value = serde_json::from_str(&raw).unwrap();
let entries = entries.as_array().unwrap();
assert!(!entries.is_empty());
let printed = command(&host, &line).await;
let lines: Vec<_> = printed.lines().collect();
assert_eq!(lines.len(), entries.len(), "{printed}");
if line != "log" {
assert_eq!(entries.len(), 1);
}
for (line, entry) in lines.into_iter().zip(entries) {
let time = chrono::DateTime::parse_from_rfc3339(entry["time"].as_str().unwrap())
.unwrap()
.with_timezone(&chrono::Local)
.format("%H:%M:%S%.3f")
.to_string();
let plugin = entry["plugin"].as_str().unwrap();
let pid = entry["pid"].as_str().unwrap();
let prefix = format!("{plugin}-");
let short = pid
.strip_prefix(&prefix)
.map(|uuid| format!("{prefix}{}", &uuid[..8]))
.unwrap_or_else(|| pid.to_owned());
assert_eq!(
line,
format!(
"{} {} {} {}",
time,
short,
entry["stream"].as_str().unwrap(),
entry["line"].as_str().unwrap()
)
);
}
}
assert_eq!(command(&host, "log absent-plugin").await, "");
assert_eq!(command(&host, "log -n").await, "log -n");
host.stop().await;
}
/// Activate installs a candidate through core.activate.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn activate() {
let world = TestWorld::new();
let host = world.start().await;
let result = command(&host, "activate probe candidate.wasm").await;
assert!(!result.starts_with("error:"), "{result}");
assert!(world.0.join("plugins/probe/plugin.prev.wasm").exists());
host.stop().await;
}
/// Revert restores the previous component through core.revert.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn revert() {
let world = TestWorld::new();
let host = world.start().await;
command(&host, "activate probe candidate.wasm").await;
let result = command(&host, "revert probe").await;
assert!(!result.starts_with("error:"), "{result}");
host.stop().await;
}
/// Stop closes a live process gracefully.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stop() {
let world = TestWorld::new();
let host = world.start().await;
let pid = host.call("probe.serve", json!({})).await.unwrap();
command(&host, &format!("stop {}", pid.as_str().unwrap())).await;
assert!(!host.processes().contains_key(pid.as_str().unwrap()));
host.stop().await;
}
/// Kill ends a live process immediately.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn kill() {
let world = TestWorld::new();
let host = world.start().await;
let pid = host.call("probe.serve", json!({})).await.unwrap();
command(&host, &format!("kill {}", pid.as_str().unwrap())).await;
assert!(!host.processes().contains_key(pid.as_str().unwrap()));
host.stop().await;
}
/// Help lists all console commands.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn help() {
let world = TestWorld::new();
let host = world.start().await;
let text = command(&host, "help").await;
for name in [
"agent",
"plugin.method",
"ls",
"cat",
"methods",
"ps",
"log",
"activate",
"revert",
"stop",
"kill",
"help",
"quit",
] {
assert!(text.contains(name));
}
host.stop().await;
}
/// Quit produces a final message, and blank input does nothing.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn quit() {
let world = TestWorld::new();
let host = world.start().await;
assert_eq!(command(&host, "quit").await, "Goodbye.");
assert_eq!(command(&host, " ").await, "");
host.stop().await;
}
File diff suppressed because it is too large Load Diff
-10
View File
@@ -1,10 +0,0 @@
//! Host integration contracts run in one parallel libtest pool.
mod activation;
mod agent;
mod cargo;
mod cli;
mod common;
mod console;
mod core;
mod sdk;
mod watches;
-399
View File
@@ -1,399 +0,0 @@
//! Exercise SDK plugins through the real host, without a fake core.
use sausage::{Host, world::World};
use serde_json::{Value, json};
use std::{path::PathBuf, sync::OnceLock, time::Duration};
fn fixtures() -> &'static PathBuf {
static TARGET: OnceLock<PathBuf> = OnceLock::new();
TARGET.get_or_init(|| {
for (package, binary) in [
("examples/notes", "notes"),
("examples/consumer", "consumer"),
("examples/www", "www"),
("examples/secrets", "secrets"),
("sdk/tests/plugins/consumer", "sdk-consumer"),
] {
super::common::build(package, binary);
}
super::common::sdk_probe().parent().unwrap().to_owned()
})
}
struct TestWorld(PathBuf);
impl TestWorld {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("sausage-sdk-{}", uuid::Uuid::now_v7()));
let world = World::create(&path).unwrap();
for (plugin, binary) in [
("notes", "notes"),
("consumer", "consumer"),
("www", "www"),
("secrets", "secrets"),
("probe", "sdk-probe"),
("typed", "sdk-consumer"),
] {
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
std::fs::copy(
fixtures().join(format!("{binary}.wasm")),
world.plugin_dir(plugin).join("plugin.wasm"),
)
.unwrap();
}
Self(path)
}
async fn start(&self) -> Host {
Host::start_with_cache(
&self.0,
std::path::Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
)
.await
.unwrap()
}
}
impl Drop for TestWorld {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
async fn call(host: &Host, method: &str, params: Value) -> Value {
tokio::time::timeout(Duration::from_secs(60), host.call(method, params))
.await
.unwrap()
.unwrap()
}
/// Concurrent read-modify-write calls retain every note under the SDK lock.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn notes_lock_preserves_parallel_additions() {
let world = TestWorld::new();
let host = world.start().await;
assert_eq!(
call(&host, "notes.add", json!({"text":"first"})).await["count"],
1
);
let calls = (0..12)
.map(|n| {
let h = host.clone();
tokio::spawn(
async move { call(&h, "notes.add", json!({"text":format!("note-{n}")})).await },
)
})
.collect::<Vec<_>>();
for task in calls {
task.await.unwrap();
}
let notes = call(&host, "notes.list", json!({})).await;
assert_eq!(notes.as_array().unwrap().len(), 13);
assert_eq!(notes[0], "first");
for n in 0..12 {
assert!(
notes
.as_array()
.unwrap()
.contains(&json!(format!("note-{n}")))
);
}
host.stop().await;
}
/// The example consumer invokes notes through its generated import.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn generated_notes_import() {
let world = TestWorld::new();
let host = world.start().await;
assert_eq!(
call(&host, "consumer.note", json!({"text":"typed"})).await,
1
);
assert_eq!(call(&host, "notes.list", json!({})).await, json!(["typed"]));
host.stop().await;
}
/// Discovery retains the complete document and satisfies the consumer contract.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn discovery_is_compatible() {
let world = TestWorld::new();
let host = world.start().await;
let actual = call(&host, "notes.rpc.discover", json!({})).await;
let consumer = call(&host, "consumer.rpc.discover", json!({})).await;
let expected: Value = serde_json::from_str(include_str!(
"../../../examples/consumer/imports/notes.json"
))
.unwrap();
assert_eq!(actual, expected);
assert_eq!(actual["openrpc"], "1.2.6");
assert!(actual["components"]["schemas"]["AddParams"].is_object());
assert!(
actual["methods"]
.as_array()
.unwrap()
.iter()
.find(|m| m["name"] == "list")
.unwrap()
.get("x-sausage-params")
.is_none()
);
assert!(actual["components"]["schemas"].get("Empty").is_none());
assert!(
sausage_interface::compat::check(&consumer["x-sausage-imports"]["notes"], &actual)
.is_empty()
);
host.stop().await;
}
/// Detached SDK calls receive progress and forward it through core.notify.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn detached_call_notifications() {
let world = TestWorld::new();
let host = world.start().await;
let mut events = host.subscribe();
assert_eq!(call(&host, "probe.relay", json!({})).await, 42);
let event = tokio::time::timeout(Duration::from_secs(5), events.recv())
.await
.unwrap()
.unwrap();
assert_eq!(event["params"]["name"], "step");
host.stop().await;
}
/// Detached typed calls complete and detached services survive their original caller.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn detached_service_survives_parent() {
let world = TestWorld::new();
let host = world.start().await;
assert_eq!(call(&host, "typed.run", json!({})).await, 5);
let child = call(&host, "probe.launch", json!({})).await;
tokio::time::timeout(Duration::from_secs(5), async {
while host.processes().values().any(|p| p.method == "launch") {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(host.processes().contains_key(child.as_str().unwrap()));
host.stop().await;
}
/// A core.stop request closes stdin and executes the SDK stop hook.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn stop_runs_hook() {
let world = TestWorld::new();
let host = world.start().await;
let pid = call(&host, "probe.service", json!({})).await;
call(&host, "core.stop", json!({"pid":pid})).await;
assert_eq!(std::fs::read(world.0.join("stopped")).unwrap(), b"yes");
host.stop().await;
}
/// PROTOCOL 3.7: an idle SDK listener opts into drain and exits before EOF/grace.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn superseded_idle_listener_drains_before_grace() {
let world = TestWorld::new();
let host = world.start().await;
let (notice, mut notices) = tokio::sync::mpsc::unbounded_channel();
host.register_native(
"other",
std::sync::Arc::new(move |_, method, _| {
let notice = notice.clone();
Box::pin(async move {
assert_eq!(method, "superseded");
notice.send(std::time::Instant::now()).unwrap();
Ok(json!({}))
})
}),
);
let pid = call(&host, "probe.drain_service", json!({})).await;
// Replace with a different real component; force only skips unrelated test consumers.
std::fs::copy(
fixtures().join("notes.wasm"),
world.0.join("state/next.wasm"),
)
.unwrap();
call(
&host,
"core.activate",
json!({"plugin":"probe","wasm":"/state/next.wasm","force":true}),
)
.await;
// Activation preparation/discovery is outside upgrade grace. Time from the
// hook's outgoing call, not from the start of candidate compilation.
let before = tokio::time::timeout(Duration::from_secs(60), notices.recv())
.await
.unwrap()
.unwrap();
// Waiting on its lifetime lock is an exit event, not process-liveness polling.
call(&host, "core.lock", json!({"path":"/drain-owner"})).await;
assert!(before.elapsed() < Duration::from_secs(5));
assert!(!host.processes().contains_key(pid.as_str().unwrap()));
assert!(
!world
.0
.join(format!("stopped-{}", pid.as_str().unwrap()))
.exists(),
"drain must not run on_stop (discovery processes may run their own hooks)"
);
host.stop().await;
}
/// SDK framing carries payloads above 64 KiB in both directions.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn large_messages() {
let world = TestWorld::new();
let host = world.start().await;
let text = "z".repeat(192 * 1024);
assert_eq!(call(&host, "probe.echo", json!({"text":text})).await, text);
host.stop().await;
}
/// Normal SDK stdout never produces the discarded-output journal warning.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn no_discarded_stdout() {
let world = TestWorld::new();
let host = world.start().await;
call(&host, "probe.echo", json!({"text":"framing"})).await;
host.stop().await;
assert_eq!(
call(&host, "core.log", json!({"grep":"discarded stdout"})).await,
json!([])
);
}
/// Two SDK processes exchange endpoint requests and progress at a world path.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn world_endpoint() {
let world = TestWorld::new();
let host = world.start().await;
call(&host, "probe.service", json!({})).await;
assert_eq!(call(&host, "typed.world_endpoint", json!({})).await, 7);
host.stop().await;
}
/// The HTTP example exposes its self endpoint through the process namespace.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn www_control_through_proc() {
let world = TestWorld::new();
let host = world.start().await;
assert_eq!(
call(&host, "consumer.demo", json!({"text":"web","port":0})).await,
0
);
host.stop().await;
}
/// The vault example publishes a fixed endpoint in the shared run namespace.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn secrets_fixed_endpoint() {
let world = TestWorld::new();
let host = world.start().await;
call(&host, "secrets.serve", json!({})).await;
assert_eq!(call(&host, "consumer.vault", json!({})).await, "hello");
host.stop().await;
}
/// PROTOCOL 3.6: a background task retains its SDK process until the task completes.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn background_task_retains_process() {
let world = TestWorld::new();
let host = world.start().await;
let pid = call(&host, "probe.background_hold", json!({})).await;
assert!(host.processes().contains_key(pid.as_str().unwrap()));
assert_eq!(call(&host, "notes.list", json!({})).await, json!([]));
assert!(host.processes().contains_key(pid.as_str().unwrap()));
std::fs::write(world.0.join("release-background"), "release").unwrap();
tokio::time::timeout(Duration::from_secs(10), async {
while host.processes().contains_key(pid.as_str().unwrap()) {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert_eq!(
std::fs::read_to_string(world.0.join("background-ended")).unwrap(),
"done"
);
host.stop().await;
}
/// PROTOCOL 5: stale endpoint identities and interfaces fail through genuine SDK processes.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn endpoint_handshake_rejects_mismatch() {
let world = TestWorld::new();
let host = world.start().await;
let pid = call(&host, "probe.service", json!({})).await;
let path = world.0.join("state/control");
let original = std::fs::read_to_string(&path).unwrap();
let words: Vec<_> = original.split_whitespace().collect();
for changed in [
format!("{} wrong-owner {}\n", words[0], words[2]),
format!("{} {} wrong-interface\n", words[0], words[1]),
] {
std::fs::write(&path, changed).unwrap();
let error = host
.call("typed.world_endpoint", json!({}))
.await
.unwrap_err();
assert_eq!(error["code"], -32000);
assert!(error["message"].as_str().unwrap().contains("mismatch"));
assert!(host.processes().contains_key(pid.as_str().unwrap()));
}
std::fs::write(path, original).unwrap();
assert_eq!(call(&host, "typed.world_endpoint", json!({})).await, 7);
host.stop().await;
}
/// PROTOCOL 10.1 and 5: discovery through core retains params, endpoints, imports, and grace.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn discovery_extensions_through_core() {
let world = TestWorld::new();
let host = world.start().await;
let provider = call(&host, "probe.rpc.discover", json!({})).await;
assert_eq!(provider["openrpc"], "1.2.6");
assert_eq!(provider["x-sausage-grace"], 12);
assert!(
provider["x-sausage-endpoints"]["control"]["methods"]
.as_array()
.unwrap()
.iter()
.any(|m| m["name"] == "stats")
);
let add = provider["methods"]
.as_array()
.unwrap()
.iter()
.find(|m| m["name"] == "add")
.unwrap();
assert_eq!(add["paramStructure"], "by-name");
let reference = add["x-sausage-params"]["$ref"].as_str().unwrap();
assert_eq!(
provider.pointer(reference.trim_start_matches('#')).unwrap()["type"],
"object"
);
let consumer = call(&host, "typed.rpc.discover", json!({})).await;
assert!(
consumer["x-sausage-imports"]["probe"]["methods"]
.as_array()
.unwrap()
.iter()
.any(|m| m["name"] == "add")
);
host.stop().await;
}
/// PROTOCOL 3.2: rpc.discover is SDK control traffic and endpoint control is not a main method.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reserved_rpc_control_names() {
let world = TestWorld::new();
let host = world.start().await;
assert!(call(&host, "probe.rpc.discover", json!({})).await["methods"].is_array());
for method in ["probe.rpc.endpoint", "probe.rpc.unknown"] {
assert_eq!(
host.call(method, json!({})).await.unwrap_err()["code"],
-32601
);
}
host.stop().await;
}
/// PROTOCOL 3.1: the SDK's leading newline isolates an unfinished diagnostic from its response.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn leading_newline_protects_sdk_response() {
let world = TestWorld::new();
let host = world.start().await;
assert_eq!(
call(&host, "probe.framed_output", json!({})).await,
"intact"
);
host.stop().await;
let warnings = call(&host, "core.log", json!({"grep":"discarded stdout"})).await;
assert_eq!(warnings.as_array().unwrap().len(), 1);
assert!(!warnings.to_string().contains("partial diagnostic"));
}
-174
View File
@@ -1,174 +0,0 @@
//! Real SDK watch callbacks and raw WASI filesystem mutations share the host namespace.
use super::common::{CACHE, probe, sdk_probe};
use sausage::{Host, world::World};
use serde_json::{Value, json};
use std::time::Duration;
// Same call bound as core.rs; no sleeps or polling.
async fn call(h: &Host, method: &str, p: Value) -> Value {
tokio::time::timeout(Duration::from_secs(20), h.call(method, p))
.await
.unwrap()
.unwrap()
}
async fn event(h: &Host, rx: &mut tokio::sync::broadcast::Receiver<Value>, name: &str) -> Value {
let result = tokio::time::timeout(Duration::from_secs(20), async {
loop {
let e = rx.recv().await.unwrap();
if e["params"]["name"] == name {
return e["params"]["params"].clone();
}
}
})
.await;
match result {
Ok(v) => v,
Err(e) => {
eprintln!(
"JOURNAL {}",
h.call("core.log", json!({"limit":100})).await.unwrap()
);
panic!("{e}");
}
}
}
async fn start() -> (tempfile::TempDir, Host) {
let dir = tempfile::tempdir().unwrap();
let world = World::create(&dir.path().join("world")).unwrap();
for (name, wasm) in [("probe", probe()), ("sdk", sdk_probe())] {
std::fs::create_dir_all(world.plugin_dir(name)).unwrap();
std::fs::copy(wasm, world.plugin_dir(name).join("plugin.wasm")).unwrap();
}
let host = Host::start_with_cache(&world.root, std::path::Path::new(CACHE))
.await
.unwrap();
(dir, host)
}
fn watch(h: &Host, path: &str) -> tokio::task::JoinHandle<sausage::RpcResult> {
let (h, path) = (h.clone(), path.to_owned());
tokio::spawn(async move { h.call("sdk.watch", json!({"path":path})).await })
}
async fn mutate(h: &Host, op: &str, path: &str) {
call(h, "probe.mutate", json!({"op":op,"path":path})).await;
}
/// PROTOCOL 4: pre-arm state is in the first scan; each post-scan mutation wakes
/// the SDK without completing the watch. Covers descriptor/stream/truncate paths.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn armed_scan_and_later_changes() {
let (_dir, h) = start().await;
mutate(&h, "mkdir", "/state/mailbox").await;
call(
&h,
"probe.write",
json!({"path":"/state/mailbox/before", "text":"before"}),
)
.await;
let mut rx = h.subscribe();
let task = watch(&h, "/state/mailbox/../mailbox/");
let armed = event(&h, &mut rx, "armed").await;
assert_eq!(armed["entries"], json!(["before"]));
assert!(!task.is_finished());
for (op, expected) in [
("descriptor", "descriptor"),
("stream", "streamptor"),
("append", "streamptorappend"),
("size", ""),
("descriptor", "descriptor"),
("truncate", ""),
] {
mutate(&h, op, "/state/mailbox/before").await;
let mut scan = event(&h, &mut rx, "changed").await;
while scan["text"] != expected {
scan = event(&h, &mut rx, "changed").await;
}
}
mutate(&h, "burst", "/state/mailbox").await;
let mut scanned = event(&h, &mut rx, "changed").await;
while !scanned["entries"].as_array().unwrap().contains(&json!("4")) {
scanned = event(&h, &mut rx, "changed").await;
}
while rx.try_recv().is_ok() {}
mutate(&h, "delete", "/state/mailbox/4").await;
let mut scanned = event(&h, &mut rx, "changed").await;
while scanned["entries"].as_array().unwrap().contains(&json!("4")) {
scanned = event(&h, &mut rx, "changed").await;
}
call(&h, "core.kill", json!({"pid":armed["pid"]})).await;
assert!(task.await.unwrap().is_err());
h.stop().await;
}
/// PROTOCOL 4: atomic publication, rename source/destination, mounted paths,
/// absent paths, and caller-death cleanup. No external host writes are required.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rename_delete_and_mounts() {
let (_dir, h) = start().await;
for base in ["/state/inbox", "/run/inbox"] {
let mut rx = h.subscribe();
let task = watch(&h, base);
let armed = event(&h, &mut rx, "armed").await;
assert_eq!(armed["entries"], json!([]));
let staging = format!("{base}-staging");
mutate(&h, "mkdir", &staging).await;
call(
&h,
"probe.mutate",
json!({"op":"rename","path":staging,"to":base}),
)
.await;
event(&h, &mut rx, "changed").await;
mutate(&h, "atomic", &format!("{base}/published")).await;
let mut scan = event(&h, &mut rx, "changed").await;
while !scan["entries"]
.as_array()
.unwrap()
.contains(&json!("published"))
{
scan = event(&h, &mut rx, "changed").await;
}
assert_eq!(
call(
&h,
"probe.read",
json!({"path":format!("{base}/published")})
)
.await,
"complete"
);
while rx.try_recv().is_ok() {}
call(
&h,
"probe.mutate",
json!({"op":"rename","path":base,"to":format!("{base}-moved")}),
)
.await;
let mut scan = event(&h, &mut rx, "changed").await;
while scan["entries"] != json!([]) {
scan = event(&h, &mut rx, "changed").await;
}
call(&h, "core.kill", json!({"pid":armed["pid"]})).await;
assert!(task.await.unwrap().is_err());
}
// /self watches bind to this caller, and mutations are visible via /proc/<pid>.
let mut rx = h.subscribe();
let task = watch(&h, "/self");
let armed = event(&h, &mut rx, "armed").await;
assert!(
armed["entries"]
.as_array()
.unwrap()
.contains(&json!("owned"))
);
event(&h, &mut rx, "changed").await;
assert_eq!(
call(
&h,
"probe.read",
json!({"path":format!("/proc/{}/owned", armed["pid"].as_str().unwrap())})
)
.await,
"visible via proc"
);
call(&h, "core.kill", json!({"pid":armed["pid"]})).await;
assert!(task.await.unwrap().is_err());
h.stop().await;
}
-154
View File
@@ -1,154 +0,0 @@
//! Raw discovery and lifecycle guest for activation protocol tests.
use serde_json::{Value, json};
use std::io::{self, BufRead, Write};
/// Emits one flushed JSON-RPC response.
fn send(value: Value) {
println!("{value}");
io::stdout().flush().unwrap();
}
/// Calls a provider after activation releases an old consumer process.
fn deferred(request: &Value, plugin: &str, lines: &mut impl Iterator<Item = io::Result<String>>) {
send(
json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()}),
);
let before = std::time::Instant::now();
while !std::path::Path::new(&format!("/state/{plugin}/release-call")).exists() {
assert!(before.elapsed() < std::time::Duration::from_secs(30));
}
send(json!({"jsonrpc":"2.0","id":"deferred","method":"notes.legacy","params":{}}));
for line in lines {
let response: Value = serde_json::from_str(&line.unwrap()).unwrap();
if response["id"] == "deferred" {
std::fs::write(format!("/state/{plugin}/call-result"), response.to_string()).unwrap();
break;
}
}
}
/// Answers a method that version two removes from its interface.
fn legacy(request: &Value, document: &Value, version: &str) {
if document["methods"]
.as_array()
.unwrap()
.iter()
.any(|m| m["name"] == "legacy")
{
send(json!({"jsonrpc":"2.0","id":request["id"],"result":version}));
} else {
send(
json!({"jsonrpc":"2.0","id":request["id"],"error":{"code":-32601,"message":"legacy removed","data":{"version":version}}}),
);
}
}
/// Reads a version-specific discovery document without the SDK.
fn main() {
let version = env!("FIXTURE_VERSION");
let plugin = std::env::var("SAUSAGE_PLUGIN").unwrap();
let mut lines = io::stdin().lock().lines();
let request: Value = serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap();
let document: Value =
serde_json::from_slice(&std::fs::read(format!("/state/{plugin}/{version}.json")).unwrap())
.unwrap();
let result = match request["method"].as_str().unwrap() {
"deferred" => {
deferred(&request, &plugin, &mut lines);
return;
}
"legacy" => {
legacy(&request, &document, version);
return;
}
"rpc.discover" => {
if document["x-test-no-answer"] == true {
for _ in &mut lines {}
return;
}
if document["x-test-wait"] == true {
std::fs::write(format!("/state/{plugin}/discover-started"), "ready").unwrap();
let before = std::time::Instant::now();
while !std::path::Path::new(&format!("/state/{plugin}/discover-release")).exists() {
assert!(before.elapsed() < std::time::Duration::from_secs(30));
}
}
document
}
"read" => {
let text =
std::fs::read_to_string(request["params"]["path"].as_str().unwrap()).unwrap();
serde_json::from_str(&text).unwrap_or(json!(text))
}
"rpc.retired" => {
std::fs::write(
format!("/state/{plugin}/retired"),
request["params"]["previous"].as_str().unwrap(),
)
.unwrap();
json!({"retired":version})
}
"work" => {
// Keep an actual request unanswered through grace; notices do not drain it.
send(
json!({"jsonrpc":"2.0","method":"core.notify","params":{"name":"ready","params":{}}}),
);
for _ in lines {}
return;
}
"serve" | "hold" => {
// One genuine process failure, followed by a healthy service, without
// timers or liveness polling in either the fixture or supervisor.
let crash = request["params"]["crash_once"].as_str();
let first = crash.is_some()
&& std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(format!("/state/{plugin}/crashed-once"))
.is_ok();
if first && crash == Some("before") {
std::process::exit(1);
}
send(json!({"jsonrpc":"2.0","id":request["id"],"result":{"version":version}}));
if first && crash == Some("after") {
std::process::exit(1);
}
if request["method"] == "serve" {
std::fs::write(format!("/state/{plugin}/service"), version).unwrap();
}
for line in lines {
let Ok(message) = serde_json::from_str::<Value>(&line.unwrap()) else {
continue;
};
if message["method"] == "rpc.superseded" {
std::fs::write(
format!(
"/state/{plugin}/superseded-{}",
std::env::var("SAUSAGE_PID").unwrap()
),
message.to_string(),
)
.unwrap();
if request["params"]["exit_on_superseded"] == true {
return;
}
}
}
std::fs::write(format!("/state/{plugin}/ended-{version}"), "ended").unwrap();
return;
}
_ => json!({"version":version}),
};
send(json!({"jsonrpc":"2.0","id":request["id"],"result":result}));
if request["method"] == "rpc.discover" && result["x-test-linger"] == true {
if result["x-test-stubborn"] == true {
let before = std::time::Instant::now();
while !std::path::Path::new(&format!("/state/{plugin}/discovery-exit")).exists() {
assert!(before.elapsed() < std::time::Duration::from_secs(30));
}
} else {
for _ in lines {}
}
std::fs::write(
format!("/state/{plugin}/discovery-ended-{version}"),
"ended",
)
.unwrap();
}
}
-589
View File
@@ -1,589 +0,0 @@
//! Raw JSON-RPC methods used by the host's protocol tests.
use serde_json::{Value, json};
use std::io::{self, BufRead, Read, Write};
type Lines = io::Lines<io::BufReader<io::Stdin>>;
fn send(value: Value) {
let mut output = io::stdout().lock();
writeln!(output, "\n{value}").unwrap();
output.flush().unwrap();
}
fn receive(lines: &mut Lines) -> Value {
serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap()
}
fn echo(params: &Value) -> Value {
params.clone()
}
fn chat(params: &Value) -> Value {
send(json!({"jsonrpc":"2.0", "method":"core.notify", "params":{
"name":"text", "params":{"text":"interim"}
}}));
json!({"text":params["text"]})
}
fn discover(params: &Value) -> Value {
if let Ok(bytes) = std::fs::read("/state/probe/discover.json") {
serde_json::from_slice(&bytes).unwrap()
} else {
params.clone()
}
}
fn environment() -> Value {
json!({
"pid": std::env::var("SAUSAGE_PID").unwrap(),
"plugin": std::env::var("SAUSAGE_PLUGIN").unwrap(),
"parent": std::env::var("SAUSAGE_PARENT").unwrap(),
})
}
fn multicall(lines: &mut Lines) -> Value {
send(json!({
"jsonrpc": "2.0",
"id": 1,
"method": "probe.echo",
"params": {"n": 1}
}));
send(json!({
"jsonrpc": "2.0",
"id": "1",
"method": "probe.echo",
"params": {"n": 2}
}));
for _ in 0..2 {
let response = receive(lines);
if response["id"] == 1 {
assert_eq!(response["result"]["n"], 1);
} else {
assert_eq!(response["id"], "1");
assert_eq!(response["result"]["n"], 2);
}
}
json!({"count":2})
}
fn notification(params: &Value) -> Value {
send(json!({
"jsonrpc": "2.0",
"method": "probe.write",
"params": params
}));
let before = std::time::Instant::now();
loop {
let contents = std::fs::read_to_string(params["path"].as_str().unwrap()).ok();
if contents.as_deref() == params["text"].as_str() {
break;
}
assert!(before.elapsed() < std::time::Duration::from_secs(10));
}
json!({})
}
fn unknown_id() {
send(json!({
"jsonrpc": "2.0",
"id": "late",
"result": 7
}));
}
fn wait_eof(lines: &mut Lines) {
std::fs::write("/state/waiting", "yes").unwrap();
send(json!({"jsonrpc":"2.0","method":"core.notify","params":{
"name":"waiting-eof", "params":{"pid":std::env::var("SAUSAGE_PID").unwrap()}
}}));
for _ in lines {}
std::fs::write("/state/stopped", "yes").unwrap();
}
fn busy() -> Value {
loop {
std::hint::spin_loop();
}
}
fn exit_without_answer() {
eprintln!("last line");
}
fn junk() -> Value {
io::stdout().write_all(&[255, 10]).unwrap();
println!("junk\n{{}}\n[]");
eprintln!("stderr line");
json!({})
}
fn invalid_utf8() -> Value {
io::stdout().write_all(&[255, 10]).unwrap();
json!({})
}
fn blank() -> Value {
println!("\n \t\n");
json!({})
}
fn burst(request: &Value) {
let count = request["params"]["count"].as_u64().unwrap();
let mut output = String::new();
for index in 0..count {
let notification = json!({
"jsonrpc": "2.0",
"method": "core.notify",
"params": {"name": "burst", "params": {"index": index}}
});
output.push_str(&notification.to_string());
output.push('\n');
}
let response = json!({"jsonrpc":"2.0", "id":request["id"], "result":count});
output.push_str(&response.to_string());
output.push('\n');
let mut stdout = io::stdout().lock();
stdout.write_all(output.as_bytes()).unwrap();
stdout.flush().unwrap();
}
fn notify() -> Value {
send(json!({
"jsonrpc": "2.0",
"method": "core.notify",
"params": {
"name": "progress",
"params": {"n": 1}
}
}));
json!({})
}
fn serve(request: &Value, lines: &mut Lines) {
send(json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": std::env::var("SAUSAGE_PID").unwrap()
}));
for _ in lines {}
}
fn network(params: &Value) -> Value {
let mut socket = std::net::TcpStream::connect(params["tcp"].as_str().unwrap()).unwrap();
let mut text = String::new();
socket.read_to_string(&mut text).unwrap();
let udp = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
udp.send_to(b"udp", params["udp"].as_str().unwrap())
.unwrap();
json!(text)
}
fn read_file(params: &Value) -> Value {
json!(std::fs::read_to_string(params["path"].as_str().unwrap()).unwrap())
}
fn write_file(params: &Value) -> Value {
std::fs::write(
params["path"].as_str().unwrap(),
params["text"].as_str().unwrap(),
)
.unwrap();
json!({})
}
fn outgoing_call(params: &Value, lines: &mut Lines) -> Value {
send(json!({
"jsonrpc": "2.0",
"id": {"nested": 7},
"method": params["method"],
"params": params["params"]
}));
loop {
let response = receive(lines);
if response["id"] == json!({"nested":7}) {
return response["result"].clone();
}
}
}
fn lock(params: &Value, lines: &mut Lines) -> Value {
outgoing_call(&json!({"method":"core.lock", "params":params}), lines)
}
fn filesystem(params: &Value) -> Value {
use std::fs;
let pid = std::env::var("SAUSAGE_PID").unwrap();
match params["topic"].as_str().unwrap() {
"root" => {
let preopens = wasip2::filesystem::preopens::get_directories();
assert_eq!(preopens.len(), 1);
assert_eq!(preopens[0].1, "/");
let names: Vec<_> = fs::read_dir("/")
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
for name in ["proc", "self", "run"] {
assert!(names.iter().any(|n| n == name));
}
fs::write("/state/root", "world").unwrap();
fs::write("/run/shared", "shared").unwrap();
assert!(fs::rename("/run/shared", "/state/moved").is_err());
assert!(fs::rename("/state/root", "/self/moved").is_err());
assert_eq!(fs::read_to_string("/run/../state/root").unwrap(), "world");
}
"reserved" => {
for name in ["info.json", "log", "fs"] {
assert!(fs::write(format!("/self/{name}"), "bad").is_err());
assert!(fs::create_dir(format!("/self/{name}")).is_err());
fs::write("/self/temp", "ok").unwrap();
assert!(fs::rename("/self/temp", format!("/self/{name}")).is_err());
fs::remove_file("/self/temp").unwrap();
}
}
"self" => {
fs::write("/self/endpoint.tmp", "endpoint").unwrap();
fs::rename("/self/endpoint.tmp", "/self/endpoint").unwrap();
assert_eq!(
fs::read_to_string(format!("/proc/{pid}/endpoint")).unwrap(),
"endpoint"
);
assert!(fs::write(format!("/proc/{pid}/endpoint"), "bad").is_err());
assert!(fs::remove_file(format!("/proc/{pid}/endpoint")).is_err());
let info: Value =
serde_json::from_slice(&fs::read("/self/info.json").unwrap()).unwrap();
assert_eq!(info["plugin"], "probe");
assert_eq!(info["method"], "filesystem");
return json!(pid);
}
"isolated" => {
assert!(!fs::exists("/self/endpoint").unwrap());
}
"snapshot" => {
eprintln!("before snapshot");
let mut file = fs::File::open("/self/log").unwrap();
eprintln!("after snapshot");
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap();
assert!(contents.contains("start filesystem"));
assert!(!contents.contains("after snapshot"));
assert_eq!(file.read(&mut [0; 1]).unwrap(), 0);
}
"core" => {
assert!(
fs::read_to_string("/proc/core/PROTOCOL.md")
.unwrap()
.contains("## 4. The filesystem")
);
for name in ["graph.json", "locks.json"] {
let _: Value =
serde_json::from_slice(&fs::read(format!("/proc/core/{name}")).unwrap())
.unwrap();
}
assert!(
fs::read_to_string("/proc/core/graph.dot")
.unwrap()
.contains("digraph")
);
assert!(fs::read_dir("/proc/core/plugins").is_ok());
assert!(fs::write("/proc/core/graph.json", "bad").is_err());
}
_ => panic!("unknown filesystem topic"),
}
json!({})
}
fn write_loop(request: &Value, lines: &mut Lines) {
assert_eq!(
lock(&json!({"path":"/state/loop"}), lines),
json!({"held":true})
);
std::fs::write("/state/loop", "old").unwrap();
send(
json!({"jsonrpc":"2.0", "id":request["id"], "result":std::env::var("SAUSAGE_PID").unwrap()}),
);
loop {
std::fs::write("/state/loop", "old").unwrap();
}
}
fn locked_write(lines: &mut Lines) -> Value {
assert_eq!(
lock(&json!({"path":"/state/loop"}), lines),
json!({"held":true})
);
std::fs::write("/state/loop", "new").unwrap();
outgoing_call(&json!({"method":"probe.echo","params":{}}), lines);
assert_eq!(std::fs::read_to_string("/state/loop").unwrap(), "new");
json!({})
}
fn unlock_answer(request: &Value, lines: &mut Lines) {
lock(&json!({"path":"ordered-unlock"}), lines);
let unlock = json!({"jsonrpc":"2.0","method":"core.unlock","params":{"path":"ordered-unlock"}});
let answer =
json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()});
let mut stdout = io::stdout().lock();
writeln!(stdout, "{unlock}\n{answer}").unwrap();
stdout.flush().unwrap();
drop(stdout);
for _ in lines {}
}
fn notification_child(lines: &mut Lines) -> Value {
send(json!({"jsonrpc":"2.0","method":"probe.orphan_notify","params":{}}));
let before = std::time::Instant::now();
let pid = loop {
if let Ok(pid) = std::fs::read_to_string("/state/orphan") {
break pid;
}
assert!(before.elapsed() < std::time::Duration::from_secs(10));
};
send(json!({"jsonrpc":"2.0","id":7,"method":"core.stop","params":{"pid":pid}}));
let mut count = 0;
loop {
let response = receive(lines);
if response["method"] == "core.notify" {
count += 1;
}
if response["id"] == 7 {
break;
}
}
json!(count)
}
fn batch(lines: &mut Lines) -> Value {
send(
json!([{"jsonrpc":"2.0","method":"probe.write","params":{"path":"/state/batch","text":"bad"}}]),
);
outgoing_call(
&json!({"method":"probe.echo","params":{"barrier":true}}),
lines,
);
json!(!std::path::Path::new("/state/batch").exists())
}
fn late_answer(request: &Value, lines: &mut Lines) {
send(json!({"jsonrpc":"2.0","id":request["id"],"result":42}));
send(json!({"jsonrpc":"2.0","id":request["id"],"result":99}));
for _ in lines {}
}
fn nested_environment(lines: &mut Lines) -> Value {
let child = outgoing_call(&json!({"method":"probe.env","params":{}}), lines);
json!({"caller":std::env::var("SAUSAGE_PID").unwrap(),"child":child})
}
fn forgotten(request: &Value, lines: &mut Lines) {
std::fs::write("/state/forgotten", environment().to_string()).unwrap();
serve(request, lines);
}
fn forget(lines: &mut Lines) -> Value {
send(
json!({"jsonrpc":"2.0","method":"core.call","params":{"method":"probe.forgotten","params":{},"detach":true}}),
);
let before = std::time::Instant::now();
while !std::path::Path::new("/state/forgotten").exists() {
assert!(before.elapsed() < std::time::Duration::from_secs(10));
}
send(json!({"jsonrpc":"2.0","id":"barrier","method":"probe.echo","params":{}}));
let response = receive(lines);
assert_eq!(
response["id"], "barrier",
"a forgotten call must not send a result"
);
environment()
}
fn tree(request: &Value, lines: &mut Lines) {
let depth = request["params"]["depth"].as_u64().unwrap();
if depth > 0 {
outgoing_call(
&json!({"method":"probe.tree","params":{"depth":depth-1}}),
lines,
);
}
send(
json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()}),
);
busy();
}
fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
let params = &request["params"];
let result = match request["method"].as_str().unwrap() {
"batch" => batch(lines),
"trap" => std::arch::wasm32::unreachable(),
"nested_environment" => nested_environment(lines),
"forget" => forget(lines),
"forgotten" => {
forgotten(request, lines);
return None;
}
"tree" => {
tree(request, lines);
return None;
}
"late_answer" => {
late_answer(request, lines);
return None;
}
"invalid_stderr" => {
io::stderr().write_all(&[255, 10]).unwrap();
eprintln!("after invalid UTF-8");
json!(std::env::var("SAUSAGE_PID").unwrap())
}
"unlock_answer" => {
unlock_answer(request, lines);
return None;
}
"null_error" => {
send(json!({"jsonrpc":"2.0","id":request["id"],"error":null,"result":42}));
return None;
}
"notification_child" => notification_child(lines),
"orphan_notify" => {
std::fs::write("/state/orphan", std::env::var("SAUSAGE_PID").unwrap()).unwrap();
notify()
}
"self_serve" => {
std::fs::write("/self/endpoint", "endpoint").unwrap();
serve(request, lines);
return None;
}
"filesystem" => filesystem(params),
"write_loop" => {
write_loop(request, lines);
return None;
}
"locked_write" => locked_write(lines),
"echo" => echo(params),
"chat" => chat(params),
"rpc.discover" => discover(params),
"env" => environment(),
"multicall" => multicall(lines),
"notification" => notification(params),
"unknownid" => {
unknown_id();
return None;
}
"request_id" => request["id"].clone(),
"wait_eof" => {
wait_eof(lines);
return None;
}
"busy" => busy(),
"exit" => {
exit_without_answer();
return None;
}
"junk" => junk(),
"blank" => blank(),
"invalid_utf8" => invalid_utf8(),
"notify" => notify(),
"burst" => {
burst(request);
return None;
}
"restart" => {
assert_eq!(
lock(&json!({"path":"/state/restart-stop"}), lines),
json!({"held":true})
);
assert_eq!(
outgoing_call(&json!({"method":"core.restart", "params":{}}), lines),
json!({})
);
std::fs::write("/state/restart-answer", "received").unwrap();
serve(request, lines);
std::fs::write("/state/restart-stop", "persisted after EOF").unwrap();
eprintln!("restart stop hook finished");
return None;
}
"serve" => {
serve(request, lines);
return None;
}
"network" => network(params),
"mutate" => mutate(params),
"read" => read_file(params),
"write" => write_file(params),
"raw_call" => {
send(params.clone());
receive(lines)
}
"call" => outgoing_call(params, lines),
"lock" => lock(params, lines),
_ => {
send(json!({
"jsonrpc": "2.0",
"id": request["id"],
"error": {
"code": -32601,
"message": "method not found"
}
}));
return None;
}
};
Some(result)
}
fn main() {
if let Ok(mode) = std::fs::read_to_string("/state/early") {
if mode == "flood" {
for index in 0..5000 {
eprintln!("flood {index} {}", "x".repeat(128));
}
} else {
eprintln!("last early log");
if mode == "answer" {
send(
json!({"jsonrpc":"2.0","id":std::env::var("SAUSAGE_PID").unwrap(),"result":"early"}),
);
}
std::process::exit(23);
}
}
let mut lines = io::BufReader::new(io::stdin()).lines();
let request = receive(&mut lines);
if let Some(result) = dispatch(&request, &mut lines)
&& let Some(id) = request.get("id")
{
send(json!({
"jsonrpc": "2.0",
"id": id,
"result": result
}));
}
}
/// Separate mutation calls let the host await a scan before each subsequent change.
fn mutate(p: &Value) -> Value {
use wasip2::filesystem::{
preopens,
types::{DescriptorFlags as D, OpenFlags as O, PathFlags as P},
};
let path = p["path"].as_str().unwrap();
match p["op"].as_str().unwrap() {
"mkdir" => std::fs::create_dir_all(path).unwrap(),
"rename" => std::fs::rename(path, p["to"].as_str().unwrap()).unwrap(),
"delete" => std::fs::remove_file(path).unwrap(),
"rmdir" => std::fs::remove_dir(path).unwrap(),
"atomic" => {
let temp = format!("{path}.tmp");
std::fs::write(&temp, "complete").unwrap();
std::fs::rename(temp, path).unwrap();
}
"burst" => {
for i in 0..5 {
std::fs::write(format!("{path}/{i}"), "complete").unwrap();
}
}
op => {
let root = preopens::get_directories().remove(0).0;
let file = root
.open_at(
P::empty(),
path.trim_start_matches('/'),
O::empty(),
D::READ | D::WRITE,
)
.unwrap();
match op {
"descriptor" => {
file.write(b"descriptor", 0).unwrap();
}
"stream" => {
file.write_via_stream(0)
.unwrap()
.blocking_write_and_flush(b"stream")
.unwrap();
}
"append" => {
file.append_via_stream()
.unwrap()
.blocking_write_and_flush(b"append")
.unwrap();
}
"size" => file.set_size(0).unwrap(),
"truncate" => {
root.open_at(
P::empty(),
path.trim_start_matches('/'),
O::TRUNCATE,
D::WRITE,
)
.unwrap();
}
_ => panic!("unknown mutation"),
}
}
}
json!({})
}
-7
View File
@@ -1,7 +0,0 @@
[package]
name = "consumer"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../sdk" }
-85
View File
@@ -1,85 +0,0 @@
{
"components": {
"schemas": {
"AddParams": {
"description": "Parameters for a new note.",
"properties": {
"text": {
"description": "The complete note text, including any newlines.",
"type": "string"
}
},
"required": [
"text"
],
"type": "object"
},
"Added": {
"description": "The result after a note enters the list.",
"properties": {
"count": {
"description": "The number of notes, including the new note.",
"format": "uint",
"minimum": 0,
"type": "integer"
}
},
"required": [
"count"
],
"type": "object"
}
}
},
"info": {
"title": "notes",
"version": "0.1.0"
},
"methods": [
{
"description": "Add a note and return the new count.",
"name": "add",
"paramStructure": "by-name",
"params": [
{
"description": "The complete note text, including any newlines.",
"name": "text",
"required": true,
"schema": {
"description": "The complete note text, including any newlines.",
"type": "string"
}
}
],
"result": {
"name": "result",
"schema": {
"$ref": "#/components/schemas/Added"
}
},
"summary": "Add a note and return the new count.",
"x-sausage-params": {
"$ref": "#/components/schemas/AddParams"
}
},
{
"description": "List all notes, oldest first.",
"name": "list",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"items": {
"type": "string"
},
"type": "array"
}
},
"summary": "List all notes, oldest first."
}
],
"openrpc": "1.2.6",
"x-sausage-endpoints": {},
"x-sausage-imports": {}
}
-128
View File
@@ -1,128 +0,0 @@
{
"components": {
"schemas": {
"GetParams": {
"description": "The name of a secret to read.",
"properties": {
"name": {
"description": "The unique name within this vault.",
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"PutParams": {
"description": "The name and value of a secret.",
"properties": {
"name": {
"description": "The unique name within this vault.",
"type": "string"
},
"value": {
"description": "The text to store until process exit.",
"type": "string"
}
},
"required": [
"name",
"value"
],
"type": "object"
}
}
},
"info": {
"title": "secrets",
"version": "0.1.0"
},
"methods": [
{
"description": "Open the in-memory vault and publish its endpoint.",
"name": "serve",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "null"
}
},
"summary": "Open the in-memory vault and publish its endpoint."
}
],
"openrpc": "1.2.6",
"x-sausage-endpoints": {
"vault": {
"methods": [
{
"description": "Store a secret and report its name to the caller.",
"name": "put",
"paramStructure": "by-name",
"params": [
{
"description": "The unique name within this vault.",
"name": "name",
"required": true,
"schema": {
"description": "The unique name within this vault.",
"type": "string"
}
},
{
"description": "The text to store until process exit.",
"name": "value",
"required": true,
"schema": {
"description": "The text to store until process exit.",
"type": "string"
}
}
],
"result": {
"name": "result",
"schema": {
"type": "null"
}
},
"summary": "Store a secret and report its name to the caller.",
"x-sausage-params": {
"$ref": "#/components/schemas/PutParams"
}
},
{
"description": "Read a secret, or return null when its name is absent.",
"name": "get",
"paramStructure": "by-name",
"params": [
{
"description": "The unique name within this vault.",
"name": "name",
"required": true,
"schema": {
"description": "The unique name within this vault.",
"type": "string"
}
}
],
"result": {
"name": "result",
"schema": {
"type": [
"string",
"null"
]
}
},
"summary": "Read a secret, or return null when its name is absent.",
"x-sausage-params": {
"$ref": "#/components/schemas/GetParams"
}
}
]
}
},
"x-sausage-imports": {}
}
-94
View File
@@ -1,94 +0,0 @@
{
"components": {
"schemas": {
"StartParams": {
"description": "The server configuration.",
"properties": {
"port": {
"description": "The HTTP port on all network interfaces.",
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": "integer"
}
},
"required": [
"port"
],
"type": "object"
}
}
},
"info": {
"title": "www",
"version": "0.1.0"
},
"methods": [
{
"description": "Start the HTTP server and return its process ID.",
"name": "start",
"paramStructure": "by-name",
"params": [
{
"description": "The HTTP port on all network interfaces.",
"name": "port",
"required": true,
"schema": {
"description": "The HTTP port on all network interfaces.",
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": "integer"
}
}
],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Start the HTTP server and return its process ID.",
"x-sausage-params": {
"$ref": "#/components/schemas/StartParams"
}
}
],
"openrpc": "1.2.6",
"x-sausage-endpoints": {
"control": {
"methods": [
{
"description": "Count the HTTP requests this server answered.",
"name": "stats",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"format": "uint64",
"minimum": 0,
"type": "integer"
}
},
"summary": "Count the HTTP requests this server answered."
},
{
"description": "End this process after the response reaches the client.",
"name": "stop",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "null"
}
},
"summary": "End this process after the response reaches the client."
}
]
}
},
"x-sausage-grace": 5,
"x-sausage-imports": {}
}
-81
View File
@@ -1,81 +0,0 @@
//! Use typed calls from discovered provider documents in `imports/`.
//! Generated modules own the wire types, so calls need no manual JSON.
//! The detached HTTP service survives this consumer. Its endpoint refers to that service's process-local state.
//! Copy fresh documents from `/proc/core/plugins/<provider>/discover.json` after an interface changes.
use sausage_plugin::{Result, rpc, schema};
sausage_plugin::import!("notes");
sausage_plugin::import!("www");
sausage_plugin::import!("secrets");
/// Demonstrate typed imports without manual JSON messages.
struct Consumer;
/// A note for the imported notes plugin.
#[schema]
struct NoteParams {
/// The text to append.
text: String,
}
/// The text and HTTP port for this demonstration.
#[schema]
struct DemoParams {
/// The note text to append.
text: String,
/// The port for the detached HTTP server.
port: u16,
}
#[rpc]
impl Consumer {
/// Add a note through the generated import and return its count.
async fn note(&self, p: NoteParams) -> Result<usize> {
Ok(notes::add(notes::AddParams { text: p.text }).await?.count as usize)
}
/// Add a note, start a server, and inspect its control endpoint.
async fn demo(&self, p: DemoParams) -> Result<u64> {
let added = notes::add(notes::AddParams { text: p.text }).await?;
eprintln!("{} notes", added.count);
let pid = www::start(www::StartParams { port: p.port })
.detach()
.await?;
let control = www::control::connect(format!("/proc/{pid}/control")).await?;
control.stats().await
}
/// Store and read a value through the boot service's fixed endpoint.
async fn vault(&self) -> Result<Option<String>> {
let vault = secrets::vault::connect("/run/secrets").await?;
vault
.put(secrets::PutParams {
name: "example".into(),
value: "hello".into(),
})
.on_notify(|name, params| eprintln!("{name}: {params}"))
.await?;
vault
.get(secrets::GetParams {
name: "example".into(),
})
.await
}
}
/// Start the SDK transport for this plugin.
fn main() {
sausage_plugin::run(Consumer)
}
/// Tests check generated wire types without other plugins in the test world.
#[cfg(test)]
mod tests {
use super::*;
/// Generated params retain their wire field names.
#[test]
fn generated_params_use_text() {
let value = sausage_plugin::serde_json::to_value(notes::AddParams {
text: "milk".into(),
})
.unwrap();
assert_eq!(value, sausage_plugin::serde_json::json!({"text":"milk"}));
}
/// A generated result decodes without another plugin in the test world.
#[test]
fn generated_result_decodes_count() {
let added: notes::Added = sausage_plugin::serde_json::from_str(r#"{"count":2}"#).unwrap();
assert_eq!(added.count, 2);
}
}
+115
View File
@@ -0,0 +1,115 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "notes"
version = "0.1.0"
dependencies = [
"sausage-actor",
"serde_json",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "sausage-actor"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -1,8 +1,10 @@
[package] [package]
name = "probe" name = "notes"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[workspace]
[dependencies] [dependencies]
sausage-actor = { version = "0.1", path = "../../sdk" }
serde_json = "1" serde_json = "1"
wasip2 = "1"
[workspace]
+31
View File
@@ -0,0 +1,31 @@
//! Version 2 adds a note count and fixes the crash command.
use sausage_actor::{json, messages, reply};
const DATA: &str = "/data/notes.json";
fn save(notes: &[String]) {
std::fs::write(DATA, serde_json::to_string(notes).unwrap()).unwrap();
std::fs::write("/public/notes.txt", notes.join("\n") + "\n").unwrap();
}
fn main() {
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default();
for message in messages() {
let body = &message.body;
let response = match message.kind() {
"add" => {
notes.push(body["text"].as_str().unwrap_or_default().to_string());
save(&notes);
json!({"type": "added", "count": notes.len()})
}
"list" => json!({"type": "notes", "items": notes}),
"count" => json!({"type": "count", "count": notes.len(), "version": 2}),
"crash" => json!({"type": "not_crashing", "version": 2}),
_ => json!({"type": "error", "reason": "unknown message"}),
};
let _ = reply(&message, response);
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"description": "version 2 counts notes and no longer crashes",
"steps": [
{"send": {"type": "add", "text": "test note"}, "expect": {"type": "added"}},
{"send": {"type": "list"}, "expect": {"type": "notes"}},
{"send": {"type": "count"}, "expect": {"type": "count", "version": 2}},
{"send": {"type": "crash"}, "expect": {"type": "not_crashing"}}
]
}
+115
View File
@@ -0,0 +1,115 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "notes"
version = "0.1.0"
dependencies = [
"sausage-actor",
"serde_json",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "sausage-actor"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+5 -2
View File
@@ -2,6 +2,9 @@
name = "notes" name = "notes"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[workspace]
[dependencies] [dependencies]
sausage-plugin = { path = "../../sdk" } sausage-actor = { version = "0.1", path = "../../sdk" }
serde_json = "1"
[workspace]
+25 -79
View File
@@ -1,84 +1,30 @@
//! Persistent notes in `/state/notes/notes.json`. //! Keep notes in `/data/notes.json` and publish them at `/public/notes.txt`.
//! Each call has its own process. The add method locks the file before its read-modify-write operation. use sausage_actor::{json, messages, reply};
//! Atomic replacement lets readers observe a complete list without a lock.
use sausage_plugin::serde_json; const DATA: &str = "/data/notes.json";
use sausage_plugin::{Result, lock, rpc, schema, state_dir, write_atomic};
/// The stateless notes plugin reads its file on each call. fn save(notes: &[String]) {
struct Notes; std::fs::write(DATA, serde_json::to_string(notes).unwrap()).unwrap();
/// Parameters for a new note. std::fs::write("/public/notes.txt", notes.join("\n") + "\n").unwrap();
#[schema]
struct AddParams {
/// The complete note text, including any newlines.
text: String,
}
/// The result after a note enters the list.
#[schema]
struct Added {
/// The number of notes, including the new note.
count: usize,
}
#[rpc]
impl Notes {
/// Add a note and return the new count.
async fn add(&self, AddParams { text }: AddParams) -> Result<Added> {
let path = state_dir().join("notes.json");
let _guard = lock(&path).await?;
let mut notes = load()?;
notes.push(text);
std::fs::create_dir_all(state_dir())?;
write_atomic(path, serde_json::to_vec(&notes)?)?;
Ok(Added { count: notes.len() })
}
/// List all notes, oldest first.
fn list(&self) -> Result<Vec<String>> {
load()
}
}
/// Read the list, or return an empty list when the file does not exist.
fn load() -> Result<Vec<String>> {
match std::fs::read(state_dir().join("notes.json")) {
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
Err(e) => Err(e.into()),
}
}
/// Start the SDK transport for notes.
fn main() {
sausage_plugin::run(Notes)
} }
/// Tests run in fresh worlds under the WASI test runner. fn main() {
#[cfg(test)] let mut notes: Vec<String> = std::fs::read_to_string(DATA)
mod tests { .ok()
use super::*; .and_then(|text| serde_json::from_str(&text).ok())
/// The core lock protects two sequential additions and their counts. .unwrap_or_default();
#[sausage_plugin::test] for message in messages() {
async fn add_appends_and_counts() { let body = &message.body;
assert_eq!( let response = match message.kind() {
Notes "add" => {
.add(AddParams { notes.push(body["text"].as_str().unwrap_or_default().to_string());
text: "milk".into() save(&notes);
}) json!({"type": "added", "count": notes.len()})
.await
.unwrap()
.count,
1
);
assert_eq!(
Notes
.add(AddParams {
text: "eggs".into()
})
.await
.unwrap()
.count,
2
);
assert_eq!(Notes.list().unwrap(), ["milk", "eggs"]);
} }
/// A fresh test world has no notes file. "list" => json!({"type": "notes", "items": notes}),
#[test] "crash" => panic!("asked to crash"),
fn list_is_empty_without_a_file() { _ => json!({"type": "error", "reason": "unknown message"}),
assert!(Notes.list().unwrap().is_empty()); };
let _ = reply(&message, response);
} }
} }
+7
View File
@@ -0,0 +1,7 @@
{
"description": "add a note, then list the notes",
"steps": [
{"send": {"type": "add", "text": "test note"}, "expect": {"type": "added"}},
{"send": {"type": "list"}, "expect": {"type": "notes"}}
]
}
-7
View File
@@ -1,7 +0,0 @@
[package]
name = "secrets"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../sdk" }
-90
View File
@@ -1,90 +0,0 @@
//! Keep a process-local vault behind a fixed endpoint at `/run/secrets`.
//! Start `secrets.serve` as a detached boot service. Its memory lasts until process exit.
use sausage_plugin::{Ctx, Result, listen, rpc, schema};
use std::{cell::RefCell, collections::BTreeMap, rc::Rc};
/// The service entry point publishes one process-local vault.
struct Secrets;
#[rpc(endpoints(vault=Vault))]
impl Secrets {
/// Open the in-memory vault and publish its endpoint.
async fn serve(&self) -> Result<()> {
listen("/run/secrets", Rc::new(Vault::default())).await
}
}
/// The endpoint owns secrets in memory, not on disk.
#[derive(Default)]
struct Vault {
/// Named values disappear when this process ends.
entries: RefCell<BTreeMap<String, String>>,
}
/// The name and value of a secret.
#[schema]
struct PutParams {
/// The unique name within this vault.
name: String,
/// The text to store until process exit.
value: String,
}
/// The name of a secret to read.
#[schema]
struct GetParams {
/// The unique name within this vault.
name: String,
}
#[rpc]
impl Vault {
/// Store a secret and report its name to the caller.
fn put(&self, ctx: &Ctx, p: PutParams) -> Result<()> {
ctx.notify("stored", sausage_plugin::serde_json::json!({"name":p.name}))?;
self.entries.borrow_mut().insert(p.name, p.value);
Ok(())
}
/// Read a secret, or return null when its name is absent.
fn get(&self, p: GetParams) -> Result<Option<String>> {
Ok(self.entries.borrow().get(&p.name).cloned())
}
}
/// Start the SDK transport for the vault service.
fn main() {
sausage_plugin::run(Secrets)
}
/// Tests verify absent keys and process-local storage.
#[cfg(test)]
mod tests {
use super::*;
/// Missing names return null instead of an RPC error.
#[test]
fn absent_secret() {
assert_eq!(
Vault::default()
.get(GetParams {
name: "missing".into()
})
.unwrap(),
None
);
}
/// Store a value and retrieve it through the same process-local vault.
#[sausage_plugin::test]
async fn stores_secret() {
let vault = Vault::default();
vault
.put(
&Ctx::default(),
PutParams {
name: "token".into(),
value: "example".into(),
},
)
.unwrap();
assert_eq!(
vault
.get(GetParams {
name: "token".into()
})
.unwrap()
.as_deref(),
Some("example")
);
}
}
-8
View File
@@ -1,8 +0,0 @@
[package]
name = "www"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../sdk" }
wstd = "0.6"
-97
View File
@@ -1,97 +0,0 @@
//! Serve files from `/state/www` and expose a control endpoint at `/self/control`.
//! Start this process detached or as a service so it outlives its caller.
//! Clients read `/proc/<pid>/control`. A fresh core call cannot inspect this process's counters.
use sausage_plugin::{Ctx, Result, background, listen, pid, rpc, schema, state_dir};
use std::{cell::Cell, rc::Rc};
use wstd::{
io::{AsyncRead, AsyncWrite},
iter::AsyncIterator,
net::TcpListener,
};
/// The HTTP plugin starts a server and returns its process ID.
struct Www;
/// The server configuration.
#[schema]
struct StartParams {
/// The HTTP port on all network interfaces.
port: u16,
}
#[rpc(endpoints(control=Control),grace_seconds=5)]
impl Www {
/// Start the HTTP server and return its process ID.
async fn start(&self, p: StartParams) -> Result<String> {
let tcp = TcpListener::bind(&format!("0.0.0.0:{}", p.port)).await?;
let control = Rc::new(Control { hits: Cell::new(0) });
listen("/self/control", control.clone()).await?;
background(serve(tcp, control));
Ok(pid())
}
}
/// Shared process-local state for the HTTP task and its endpoint.
struct Control {
/// The number of answered requests.
hits: Cell<u64>,
}
#[rpc]
impl Control {
/// Count the HTTP requests this server answered.
fn stats(&self) -> Result<u64> {
Ok(self.hits.get())
}
/// End this process after the response reaches the client.
fn stop(&self, ctx: &Ctx) -> Result<()> {
ctx.exit_after_response();
Ok(())
}
}
/// Answer HTTP requests with files and count all responses, including missing files.
async fn serve(tcp: TcpListener, control: Rc<Control>) {
let mut incoming = tcp.incoming();
while let Some(Ok(mut stream)) = incoming.next().await {
let mut request = [0; 8192];
let Ok(size) = stream.read(&mut request).await else {
continue;
};
let line = String::from_utf8_lossy(&request[..size]);
let path = line
.split_whitespace()
.nth(1)
.unwrap_or("/")
.trim_start_matches('/');
let (status, body) = match std::fs::read(state_dir().join(if path.is_empty() {
"index.html"
} else {
path
})) {
Ok(body) => ("200 OK", body),
Err(_) => ("404 Not Found", b"Not found".to_vec()),
};
control.hits.set(control.hits.get() + 1);
let header = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(header.as_bytes()).await;
let _ = stream.write_all(&body).await;
let _ = stream.flush().await;
}
}
/// Start the SDK transport for the HTTP plugin.
fn main() {
sausage_plugin::run(Www)
}
/// Tests check local counters and the service entry point.
#[cfg(test)]
mod tests {
use super::*;
/// Control reports its shared counter without a core round trip.
#[test]
fn stats_reads_counter() {
assert_eq!(Control { hits: Cell::new(3) }.stats().unwrap(), 3);
}
/// The listener binds an ephemeral port and publishes its control endpoint.
#[sausage_plugin::test]
async fn starts_on_available_port() {
assert_eq!(Www.start(StartParams { port: 0 }).await.unwrap(), pid());
}
}
Generated
-16
View File
@@ -1,20 +1,5 @@
{ {
"nodes": { "nodes": {
"crane": {
"locked": {
"lastModified": 1791754810,
"narHash": "sha256-cN0Mgk+sQtCJcGWs9H4M0CnLRAuC4I8aCwiBTcBJs6E=",
"owner": "ipetkov",
"repo": "crane",
"rev": "bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-utils": { "flake-utils": {
"inputs": { "inputs": {
"systems": "systems" "systems": "systems"
@@ -51,7 +36,6 @@
}, },
"root": { "root": {
"inputs": { "inputs": {
"crane": "crane",
"flake-utils": "flake-utils", "flake-utils": "flake-utils",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay" "rust-overlay": "rust-overlay"
+30 -160
View File
@@ -1,96 +1,52 @@
{ {
description = "Sausage: grow a system of WASI plugins by talking to it"; description = "Sausage: grow a system of WASI actors by talking to it";
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
crane.url = "github:ipetkov/crane";
}; };
outputs = { nixpkgs, flake-utils, rust-overlay, crane, ... }: outputs = { nixpkgs, flake-utils, rust-overlay, ... }:
flake-utils.lib.eachSystem flake-utils.lib.allSystems (system: flake-utils.lib.eachSystem flake-utils.lib.allSystems (system:
let let
pkgs = import nixpkgs { pkgs = import nixpkgs {
inherit system; inherit system;
overlays = [ rust-overlay.overlays.default ]; overlays = [ rust-overlay.overlays.default ];
}; };
# The toolchain that builds the host and, at runtime, the plugins. # The toolchain that builds the host and, at runtime, the actors.
rust = pkgs.rust-bin.stable.latest.default.override { rust = pkgs.rust-bin.stable.latest.default.override {
targets = [ "wasm32-wasip2" ]; targets = [ "wasm32-wasip2" ];
extensions = [ "rust-src" ]; extensions = [ "rust-src" ];
}; };
# The native cargo plugin needs Cargo, rustc (with wasm32-wasip2), and rustPlatform = pkgs.makeRustPlatform { cargo = rust; rustc = rust; };
# the native linker, even when the host itself is started with a bare sausage = rustPlatform.buildRustPackage {
# PATH. Keep this same runtime path in the packaged host and exercise
# the installed binary below with PATH deliberately empty.
# mold links host code much faster than GNU ld. Only on Linux, and only
# for the host target: plugins for wasm32-wasip2 link with rust-lld. Nix
# brings mold along, so a build without Nix keeps the default linker.
mold = pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.mold ];
hostTarget = pkgs.lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ]
pkgs.stdenv.hostPlatform.rust.rustcTarget);
moldVar = "CARGO_TARGET_${hostTarget}_RUSTFLAGS";
moldEnv = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
${moldVar} = "-C link-arg=-fuse-ld=mold";
};
runtimePath = pkgs.lib.makeBinPath ([ rust pkgs.stdenv.cc ] ++ mold);
# A WASI clang with wasi-libc, so that build scripts (the `cc` crate)
# can compile C for wasm32-wasip2. Its headers are WASI 0.1: C code
# that needs sockets does not build.
wasiCc = pkgs.pkgsCross.wasi32.stdenv.cc;
wasiCcEnv = {
CC_wasm32_wasip2 = "${wasiCc}/bin/${wasiCc.targetPrefix}cc";
AR_wasm32_wasip2 = "${wasiCc}/bin/${wasiCc.targetPrefix}ar";
};
craneLib = (crane.mkLib pkgs).overrideToolchain rust;
common = {
pname = "sausage"; pname = "sausage";
version = "0.1.0"; version = "0.1.0";
src = ./.; src = ./.;
nativeBuildInputs = mold; cargoLock.lockFile = ./Cargo.lock;
} // moldEnv; cargoBuildFlags = [ "-p" "sausage" ];
# The test packages for `checks.tests`.
testPackages = "--workspace";
cargoArtifacts = craneLib.buildDepsOnly (common // {
doCheck = false; doCheck = false;
# The dependencies of the host binary and of the tests. nativeBuildInputs = [ pkgs.makeWrapper ];
buildPhaseCargoCommand = '' # The `cargo` actor runs cargo at runtime. Build scripts need a C linker.
cargoWithProfile build --locked -p sausage
cargoWithProfile test --locked --no-run ${testPackages}
'';
});
sausage = craneLib.buildPackage (common // {
inherit cargoArtifacts;
doNotRemoveReferencesToRustToolchain = true;
cargoExtraArgs = "--locked -p sausage";
doCheck = false;
nativeBuildInputs = [ pkgs.makeWrapper ] ++ mold;
# The native `cargo` plugin runs cargo at runtime. Build scripts need a C linker,
# and C code for WASI needs the WASI clang.
postInstall = '' postInstall = ''
wrapProgram $out/bin/sausage --prefix PATH : ${runtimePath} \ wrapProgram $out/bin/sausage --prefix PATH : ${pkgs.lib.makeBinPath [ rust pkgs.stdenv.cc ]}
--set-default CC_wasm32_wasip2 ${wasiCcEnv.CC_wasm32_wasip2} \
--set-default AR_wasm32_wasip2 ${wasiCcEnv.AR_wasm32_wasip2} \
${pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux "--set-default ${moldVar} '-C link-arg=-fuse-ld=mold'"}
''; '';
meta.mainProgram = "sausage"; meta.mainProgram = "sausage";
}); };
# `nix run .#loop -- <world>` runs the newest `sausage run <world>` # `nix run .#loop -- <world>` runs the newest `sausage run <world>`
# (without a folder: bare `sausage`, i.e. ./world, created if needed) # from the public repo, again and again: after a crash or a broker
# from the public repo, again and again: after a crash or a call of # `exit` request it refreshes the flake and starts the host again,
# `core.restart` (exit code 75) it refreshes the flake and starts the host again, so # so the world updates itself. `/quit` (exit code 0) ends the loop.
# the world updates itself. `quit` (exit code 0) ends the loop.
# SAUSAGE_FLAKE overrides where it fetches from (any flake URL, # SAUSAGE_FLAKE overrides where it fetches from (any flake URL,
# e.g. a fork or a local checkout). # e.g. a fork or a local checkout).
repoUrl = "git+https://gitea.surma.technology/surma/sausage"; repoUrl = "git+https://gitea.surma.technology/surma/sausage";
loop = pkgs.writeShellScriptBin "sausage-loop" '' loop = pkgs.writeShellScriptBin "sausage-loop" ''
url="''${SAUSAGE_FLAKE:-${repoUrl}}" url="''${SAUSAGE_FLAKE:-${repoUrl}}"
# No folder: bare `sausage` (creates ./world first if needed). if [ $# -eq 0 ]; then set -- world; fi
if [ $# -gt 0 ]; then set -- run "$@"; fi
while true; do while true; do
SAUSAGE_LOOP=1 nix run --refresh "$url#default" -- "$@" nix run --refresh "$url#default" -- run "$@"
code=$? code=$?
if [ "$code" -eq 0 ]; then exit 0; fi if [ "$code" -eq 0 ]; then exit 0; fi
echo "sausage-loop: sausage exited with $code, starting the newest version in 2 s …" >&2 echo "sausage-loop: sausage exited with $code, starting the newest version in 2 s …" >&2
@@ -101,113 +57,27 @@
{ {
packages.default = sausage; packages.default = sausage;
# `nix flake check` runs every test of the workspace. The tests build # `nix flake check` runs every test of the host, the SDK, and the
# WASM plugins with cargo, offline, from the vendored crates of # agent. The tests build WASM actors with cargo, offline, from the
# Cargo.lock. # vendored crates of Cargo.lock.
# The binary the host loop runs (`nix run`), built from the same checks.tests = sausage.overrideAttrs (old: {
# cached dependencies as the tests. pname = "sausage-tests";
checks.package = sausage; doCheck = true;
checks.tests = craneLib.cargoTest (common // wasiCcEnv // { cargoTestFlags = [ "-p" "sausage" "-p" "sausage-actor" "-p" "agent" ];
inherit cargoArtifacts; preCheck = ''
cargoExtraArgs = "--locked ${testPackages}"; # Its own lock file pins crates that the vendored set may lack.
# Nothing builds on the test run's target folder: don't store it. rm -f cli/tests/actors/toolbox/Cargo.lock
doInstallCargoArtifacts = false;
# The SDK tests run sample plugins with the wasmtime CLI.
nativeBuildInputs = [ pkgs.wasmtime ] ++ mold;
});
# Exercise the installed host exactly as the loop launches it: the
# caller has no Cargo on PATH, so only the packaged runtime toolchain
# can run `cargo.build`: the tests under WASI and the wasm32-wasip2
# build, with C code from the packaged WASI clang.
checks.runtime-toolchain = pkgs.runCommand "sausage-runtime-toolchain" { } ''
set -eu
root="$TMPDIR/runtime-toolchain"
world="$root/world"
emptyPath="$root/empty-bin"
mkdir -p "$emptyPath" "$root/home" "$root/cargo-home"
mkdir -p "$world/plugins" "$world/state" "$world/config"
echo "[]" > "$world/config/boot.yaml"
source="$world/plugins/probe/source"
mkdir -p "$source/src" "$source/tests"
cat > "$source/Cargo.toml" <<'TOML'
[package]
name = "runtime-probe"
version = "0.1.0"
edition = "2024"
TOML
# C code for WASI, compiled with the packaged WASI clang.
printf 'int probe_add(int a, int b) { return a + b; }\n' > "$source/src/add.c"
cat > "$source/build.rs" <<'RUST'
use std::process::Command;
fn main() {
if std::env::var("TARGET").unwrap() != "wasm32-wasip2" {
return;
}
let out = std::env::var("OUT_DIR").unwrap();
let cc = std::env::var("CC_wasm32_wasip2").expect("CC_wasm32_wasip2");
let ar = std::env::var("AR_wasm32_wasip2").expect("AR_wasm32_wasip2");
let object = format!("{out}/add.o");
assert!(Command::new(cc).args(["-c", "src/add.c", "-o", &object]).status().unwrap().success());
assert!(Command::new(ar).args(["crs", &format!("{out}/libadd.a"), &object]).status().unwrap().success());
println!("cargo:rustc-link-search=native={out}");
println!("cargo:rustc-link-lib=static=add");
}
RUST
cat > "$source/src/main.rs" <<'RUST'
#[cfg(target_os = "wasi")]
unsafe extern "C" {
fn probe_add(a: i32, b: i32) -> i32;
}
fn main() {
#[cfg(target_os = "wasi")]
assert_eq!(unsafe { probe_add(2, 3) }, 5);
}
RUST
cat > "$source/tests/probe.rs" <<'RUST'
#[test]
fn test_runs_under_wasi() {
assert_eq!(2 + 2, 4);
}
RUST
echo "runtime-check: run packaged host with an empty PATH"
coproc host {
${pkgs.coreutils}/bin/env -i PATH="$emptyPath" HOME="$root/home" TMPDIR="$root" \
CARGO_HOME="$root/cargo-home" ${sausage}/bin/sausage run "$world"
}
hostInput=''${host[1]}
hostOutput=''${host[0]}
hostPid=$host_PID
trap 'kill "$hostPid" 2>/dev/null || true' EXIT
printf '%s\n' 'cargo.build {"plugin":"probe"}' >&"$hostInput"
built=0
while IFS= read -r -t 600 line <&"$hostOutput"; do
echo "$line"
case "$line" in
*'error:'*)
echo "packaged cargo.build failed with an empty caller PATH" >&2
exit 1
;;
*'"passed": 1'*) built=1; break ;;
esac
done
if [ "$built" -ne 1 ]; then
echo "packaged host did not build the probe, or its test did not pass" >&2
exit 1
fi
printf '%s\n' 'quit' >&"$hostInput"
wait "$hostPid"
mkdir -p "$out"
touch "$out/passed"
''; '';
});
apps.default = { type = "app"; program = "${sausage}/bin/sausage"; }; apps.default = { type = "app"; program = "${sausage}/bin/sausage"; };
apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; }; apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; };
devShells.default = pkgs.mkShell (wasiCcEnv // moldEnv // { devShells.default = pkgs.mkShell {
packages = [ rust pkgs.wasmtime pkgs.wasm-tools ] ++ mold; packages = [ rust pkgs.wasmtime pkgs.wasm-tools ];
shellHook = '' shellHook = ''
export CARGO_HOME="$PWD/.cargo-home" export CARGO_HOME="$PWD/.cargo-home"
''; '';
}); };
}); });
} }

Some files were not shown because too many files have changed in this diff Show More