Compare commits
3
Commits
c7d478754d
..
spike
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d02befc8 | ||
|
|
0be152dda9 | ||
|
|
83bbbb33a8 |
@@ -1,11 +0,0 @@
|
||||
# Runs on a self-hosted Nix runner (label `nixos`, host executor).
|
||||
name: ci
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: nixos
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: nix flake check
|
||||
run: nix flake check -L --show-trace
|
||||
+1
-5
@@ -1,8 +1,4 @@
|
||||
target/
|
||||
/target
|
||||
/.cargo-home
|
||||
/world
|
||||
/result
|
||||
|
||||
# Lock files of test and example crates: they resolve against the workspace lock.
|
||||
**/tests/**/Cargo.lock
|
||||
/examples/*/Cargo.lock
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
# Working on Sausage
|
||||
|
||||
Sausage is meant to be read and changed by agents, including its own agent.
|
||||
Keep it that way.
|
||||
|
||||
## Priorities
|
||||
|
||||
1. **Correct.** Every behavior in `PROTOCOL.md` has a test in `cli/tests/host/`.
|
||||
2. **Simple and short.** Prefer the obvious code. No abstraction for one
|
||||
caller. No configuration that nobody asked for.
|
||||
3. **Readable.** A new reader must understand a file without the others.
|
||||
Each module starts with a doc comment that says what it owns.
|
||||
4. **Efficient** on the paths that run per call.
|
||||
5. **Raw and minimal.** Sausage has no permissions, sandboxing beyond WASI,
|
||||
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.
|
||||
|
||||
## Layout
|
||||
|
||||
- `cli/`: the host (`sausage` binary and library): the core, the native
|
||||
`cargo` plugin, the WASI test runner, and the console.
|
||||
- `vfs/`: `sausage-vfs`, the virtual filesystem for WASI guests. It knows
|
||||
nothing about Sausage.
|
||||
- `interface/`: `sausage-interface`: OpenRPC documents, the compatibility
|
||||
check, and the generator of client code.
|
||||
- `sdk/`: `sausage-plugin`, the SDK for Rust plugins, and `sdk/macros/`, its
|
||||
macros. Never published: `cargo` points `sausage-plugin = "0.1"` at its
|
||||
local copy.
|
||||
- `agent/`: the agent plugin. `sausage init` embeds and builds it.
|
||||
- `examples/`: small plugins. They are not workspace members.
|
||||
- `PROTOCOL.md`: the contract between the core and the plugins. Change it in
|
||||
the same commit as the behavior. `DECISIONS.md` says why; when the two
|
||||
disagree, `PROTOCOL.md` and the code win.
|
||||
|
||||
## Checks
|
||||
|
||||
```sh
|
||||
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 --test host -- core:: # one topic of the host tests
|
||||
nix develop -c cargo build --release -p agent --target wasm32-wasip2
|
||||
nix develop -c cargo clippy --workspace
|
||||
```
|
||||
|
||||
`nix run -- init` and `nix run -- run` must keep working without other
|
||||
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
+33
-891
File diff suppressed because it is too large
Load Diff
+1
-7
@@ -1,11 +1,5 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["cli", "vfs", "interface", "sdk", "sdk/macros", "agent"]
|
||||
members = ["cli", "agent"]
|
||||
default-members = ["cli"]
|
||||
# Examples are standalone plugins, each with its own empty [workspace].
|
||||
exclude = ["examples"]
|
||||
|
||||
# Wasmtime compiles components with Cranelift. Optimize dependencies in dev
|
||||
# builds, so that tests and debug runs start plugins quickly.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
+142
-108
@@ -1,120 +1,154 @@
|
||||
# Decisions
|
||||
|
||||
Why Sausage is the way it is. `PROTOCOL.md` and the code own the details.
|
||||
When this file disagrees with them, they win.
|
||||
This file lists the design decisions of the spike. **(you)** marks a decision
|
||||
that Surma made. **(spike)** marks a decision that Scout made alone during the
|
||||
night and that needs review.
|
||||
|
||||
## The shape
|
||||
## Architecture
|
||||
|
||||
- **JSON-RPC, not component interfaces.** Every language can read and write
|
||||
lines of JSON, and models know JSON-RPC from LSP and MCP. A call has a
|
||||
result, like an LLM tool call.
|
||||
- **A process per call.** WASI has no threads, so processes give
|
||||
parallelism. A new version applies at the next call, and old processes
|
||||
retire after a grace period.
|
||||
- **Attached by default.** A process does not outlive its caller by
|
||||
accident. A long-running process must ask for `detach`.
|
||||
- **Services in one file.** Only the core starts and restarts them, so
|
||||
upgrades and singletons need no logic in plugins.
|
||||
- **Files to inspect, calls to act.** This keeps the core API small, and the
|
||||
agent's file tools are enough to explore the world.
|
||||
- **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.
|
||||
- **(you)** Actors are WASI commands (`wasm32-wasip2`). They use stdin, stdout,
|
||||
files, and HTTP. No WIT interfaces, no component composition.
|
||||
- **(you)** Actors exchange newline-delimited JSON through the host, which is
|
||||
the broker. No built-in request/response. A reply is a message with
|
||||
`reply_to`. Delivery is at most once.
|
||||
- **(you)** The broker accepts only `.wasm` files. It knows no source language.
|
||||
- **(you)** `cargo` is a built-in Rust compiler actor. **(spike)** It is native
|
||||
(host code), because cargo cannot run inside WASM.
|
||||
- **(you)** The agent is a normal WASM actor with the fixed ID `agent`. It can
|
||||
read and upgrade its own source.
|
||||
- **(spike)** Three native actors: `broker`, `console`, `cargo`.
|
||||
- **(you)** One Cargo workspace with two crates: `cli` (binary `sausage`) and
|
||||
`agent`. **(spike)** Example actors are in `examples/`, outside the workspace.
|
||||
- **(you)** `sausage init` builds the agent and asks for the API shape, the
|
||||
endpoint, the model, and the token. **(spike)** The token can also come from
|
||||
`SAUSAGE_API_TOKEN`. The source contains no endpoint and no token.
|
||||
|
||||
## Interfaces
|
||||
## Messages
|
||||
|
||||
- **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`.
|
||||
- **(spike)** Stdin line: `{"id", "from", "reply_to"?, "body"}`.
|
||||
Stdout line: `{"to"?, "id"?, "reply_to"?, "body"}`.
|
||||
- **(spike)** The broker sets `from`. An actor can choose its own `id`. The
|
||||
broker then uses `"<sender>/<id>"`, so IDs cannot collide or be faked.
|
||||
Without an `id`, the broker assigns `m-<n>`.
|
||||
- **(you)** Without `to`, a message goes to the subscribers of the sender.
|
||||
**(spike)** `subscribe` / `unsubscribe` messages to the broker. The registry
|
||||
stores subscriptions, so they survive restarts and upgrades. `init`
|
||||
subscribes `console` to `agent`.
|
||||
- **(spike)** Errors from routing come back as
|
||||
`{"type": "undeliverable", "reason", "to"}` from `broker`.
|
||||
- **(spike)** Lifecycle: `{"type": "shutdown"}` from `broker`, then EOF on stdin.
|
||||
After 5 seconds the broker kills the instance.
|
||||
- **(spike)** Lines on stdout that are not JSON objects with `body` go to the
|
||||
actor's `stderr.log` with a note.
|
||||
|
||||
## Minimal on purpose
|
||||
## Filesystem
|
||||
|
||||
The core does only what a plugin cannot do for itself. Everything else can be
|
||||
a plugin, or a change that the agent makes to itself.
|
||||
- **(you)** `/data` private, `/public` own public files,
|
||||
`/actor/<id>/public` and `/actor/<id>/source` read-only for everyone.
|
||||
- **(spike)** The broker also writes `/actor/<id>/status.json` and
|
||||
`/actor/<id>/stderr.log`. `ls /actor` is the registry.
|
||||
- **(you)** Commit on close: a write becomes visible only when the writer
|
||||
closes the file. **(spike)** This also applies to `/data`. Implementation: a
|
||||
wrapper around the `wasmtime-wasi` filesystem. An open for write goes to a
|
||||
hidden `.sausage-tmp-*` file next to the target. The descriptor drop renames
|
||||
it into place. Details:
|
||||
- A normal exit commits every open file (process exit closes files).
|
||||
- A trap or kill discards uncommitted writes.
|
||||
- An append or an edit without truncate copies the old content first.
|
||||
- Other descriptors see the old content until the close.
|
||||
- Directory operations take effect immediately.
|
||||
- The layer hides `.sausage-tmp-*` names from listings, `stat`, `open`,
|
||||
`rename`, and `unlink`.
|
||||
- Symlinks are disabled (they would break the path tracking).
|
||||
- No `fsync`. A power loss can lose committed data.
|
||||
- **(spike)** Raw sockets are not available (no network permission), but the
|
||||
`wasi:sockets` interfaces are linked, so programs that import them still
|
||||
start.
|
||||
|
||||
- **No permissions, no limits, no hiding.** Any process can call, upgrade,
|
||||
or stop any plugin, read and write every file, and use the network. The
|
||||
checks protect against mistakes, not against plugins. Locks are advisory.
|
||||
- **Locks are in the core.** A plugin cannot see that another process died,
|
||||
so it cannot release that process's lock. The core can.
|
||||
- **Our own VFS, in its own crate.** It works on Linux and macOS, it makes
|
||||
`/proc` live, and plugin-served files need it later. Mounts now, overlays
|
||||
later.
|
||||
- **TCP endpoints for now.** They need no host code. VFS pipes can replace
|
||||
them later behind the same SDK API.
|
||||
- **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
|
||||
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.
|
||||
## Broker requests
|
||||
|
||||
## Small choices
|
||||
- **(spike)** `spawn`, `upgrade`, `rollback`, `stop`, `start`, `remove`,
|
||||
`subscribe`, `unsubscribe`, `list`.
|
||||
- **(you)** `spawn` and `upgrade` take `wasm`, plus optional `source` and
|
||||
`built_by` metadata. **(spike)** The broker copies the `.wasm` file and the
|
||||
source folder into `broker/versions/<id>/<n>/`, with `meta.json` (paths,
|
||||
builder, requester, time).
|
||||
- **(spike)** Path resolution: `/actor/...` is global, and `/public/...` and
|
||||
`/data/...` belong to the sender. `..` is rejected. Native senders (the
|
||||
console) can give absolute host paths.
|
||||
- **(you)** Anyone can upgrade anyone. No permissions.
|
||||
- **(spike)** Actor IDs: `a-z`, `0-9`, `-`, `_`, at most 64 characters.
|
||||
- **(spike)** `remove` archives every folder of the actor to `archive/`. Nothing
|
||||
is deleted.
|
||||
|
||||
- **Restart is a clean stop supervised by `#loop`.** `core.restart` requires
|
||||
the host startup marker `SAUSAGE_LOOP=1`, which the loop exports for its
|
||||
invocation. Without a supervisor, restarting would only stop the world.
|
||||
A single pending request answers first, allows 250 ms for delivery, then
|
||||
signals the existing shutdown path; it does not use `process::exit` or
|
||||
bypass detached-process cleanup, lock release, or journal draining. The
|
||||
executable returns dedicated restart code **75** after cleanup. `#loop`
|
||||
refreshes and starts again on 75 (and other nonzero exits); 0 stops it.
|
||||
`core.exit` is a compatibility alias with the same supervisor gate,
|
||||
response grace, and coalescing; new callers use `core.restart`.
|
||||
**HARD RULE: the mediator and all agents NEVER call `core.restart` without
|
||||
the person's explicit approval each time.** This
|
||||
approval rule also covers legacy `core.exit`; it must not bypass the gate.
|
||||
## Upgrades, tests, rollback
|
||||
|
||||
- **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.
|
||||
- **(spike)** The tests are message transcripts in `source/tests/*.jsonl`:
|
||||
`{"send": body}` and `{"expect": subset-of-body, "to"?}`. Each file runs in
|
||||
a fresh candidate on a copy of the actor's `/data` and `/public`. The
|
||||
candidate has no HTTP access. An `expect` waits up to 10 s. Without tests,
|
||||
the candidate must stay up for 2 s (smoke test).
|
||||
- **(spike)** Swap: hold the mailbox, stop the old instance gracefully,
|
||||
snapshot `/data` + `/public` into the old version's folder, install the new
|
||||
source in `/actor/<id>/source`, start the new instance, release the mailbox.
|
||||
- **(spike)** Restart after a crash: backoff 0.5 s × 2ⁿ, at most 30 s. The
|
||||
counter resets after 60 s of uptime.
|
||||
- **(spike)** Parked message: when an instance traps, the broker keeps the
|
||||
last delivered message. It delivers that message again after the next
|
||||
upgrade, not after a plain restart (that would loop).
|
||||
- **(spike)** Crash loop: 3 crashes within 30 s after an upgrade → automatic
|
||||
rollback to the previous version, with its data snapshot. The broker
|
||||
informs the actor that requested the upgrade.
|
||||
- **(spike)** `rollback` without `version` goes to the highest older version.
|
||||
It restores that version's snapshot, and it first snapshots the current
|
||||
state, so a roll-forward is possible.
|
||||
- **(spike)** Versions are per actor, not global.
|
||||
|
||||
## Limits
|
||||
|
||||
- **(spike)** Mailbox: 1,000 messages. When it is full, the sender gets
|
||||
`undeliverable`.
|
||||
- **(spike)** The broker waits 30 s when it writes to an actor's stdin. Then it
|
||||
drops the message and logs it.
|
||||
- **(spike)** Time slices: the epoch advances every 10 ms, and each guest
|
||||
yields at each epoch.
|
||||
- **(spike)** One `sausage run` per world (file lock on `world/.lock`).
|
||||
- **(spike)** HTTP for all running actors, with no allow-list yet.
|
||||
|
||||
## Agent
|
||||
|
||||
- **(you)** API shapes: OpenAI Responses and Anthropic Messages, chosen by
|
||||
`"api"` in `/data/config.json`.
|
||||
- **(spike)** Config fields: `api`, `base_url`, `model`, `token`, optional
|
||||
`reasoning_effort` and `max_tokens` (16,000). OpenAI: `POST
|
||||
{base_url}/v1/responses` with `store: false` and encrypted reasoning passed
|
||||
back. Anthropic: `POST {base_url}/v1/messages` with `x-api-key`.
|
||||
- **(spike)** One shared conversation in `/data/history.json`, in a neutral
|
||||
format, so the API shape can change. The history is limited to 600 entries,
|
||||
with the cut at a user message. There is no compaction yet.
|
||||
- **(spike)** A turn starts only for `{"type": "text"}` messages *without*
|
||||
`reply_to`. Replies never start turns, so two actors cannot keep the agent
|
||||
in an endless loop. Other messages are collected and shown at the next
|
||||
turn.
|
||||
- **(spike)** Tools: `send_message` (with optional wait for the reply),
|
||||
`wait_for_messages`, `read_file`, `list_dir`, `write_file`, `copy_dir`,
|
||||
`remove_path`. At most 40 steps per turn. Tool output is cut at 30 KB.
|
||||
- **(spike)** The final text of a turn goes to the sender, as a reply to the
|
||||
message that started the turn.
|
||||
- **(spike)** A shutdown during a turn saves the turn to `/data/turn.json`. The
|
||||
next start (for example after a self-upgrade) resumes the turn and answers
|
||||
the original message.
|
||||
|
||||
## Console
|
||||
|
||||
- **(spike)** `@<actor> <json|text>`, plain text to `agent`, `/deploy`,
|
||||
`/list`, `/sleep`, `/await`, `/help`, `/quit`. `/sleep` and `/await` exist
|
||||
for scripted tests.
|
||||
|
||||
## Known risks (accepted for now)
|
||||
|
||||
- Build scripts and proc macros run as native code in the host environment.
|
||||
- Any actor can upgrade the agent with code that reads the token.
|
||||
- Any actor can message, stop, upgrade, or remove any actor.
|
||||
- A full copy of `/data` and `/public` at every upgrade is slow for large data.
|
||||
- Mailboxes are in memory. A host crash loses queued messages.
|
||||
|
||||
-636
@@ -1,636 +0,0 @@
|
||||
# The Sausage protocol
|
||||
|
||||
This is the contract between the core and the plugins. A plugin in any
|
||||
language that follows it works in a Sausage world. The core shows this file
|
||||
at `/proc/core/PROTOCOL.md`. `DECISIONS.md` in the repository says why the
|
||||
contract is the way it is.
|
||||
|
||||
Rust plugins use the SDK `sausage-plugin`. Its source is at
|
||||
`/plugins/cargo/sdk/`, and its crate docs explain each item.
|
||||
|
||||
## 1. Terms
|
||||
|
||||
| Term | Meaning |
|
||||
| --- | --- |
|
||||
| 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
|
||||
|
||||
```text
|
||||
<world>/
|
||||
plugins/<name>/
|
||||
plugin.wasm the current version
|
||||
plugin.prev.wasm the version before, for `core.revert`
|
||||
source/ the source. `cargo.build` builds it from here.
|
||||
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.
|
||||
- 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
|
||||
|
||||
### 3.1 The wire protocol
|
||||
|
||||
- A plugin is a `wasi:cli/command` component for `wasm32-wasip2`.
|
||||
- stdin and stdout carry JSON-RPC 2.0, one message per line.
|
||||
- A line is a message only if it is a JSON object with `"jsonrpc": "2.0"`.
|
||||
The core discards every other line on stdout. For the first discarded line
|
||||
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
|
||||
|
||||
- A method name has the form `<target>.<method>`. The core splits it at the
|
||||
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
|
||||
|
||||
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 |
|
||||
| `/proc` | generated by the core | read-only |
|
||||
| `/self` | the folder `/proc/<own pid>/`, in memory | read-write |
|
||||
| `/run` | in memory, shared, empty at each host start | read-write |
|
||||
|
||||
A listing of `/` shows `proc`, `run`, and `self`. A rename across two of
|
||||
these areas fails.
|
||||
|
||||
```text
|
||||
/proc/
|
||||
core/
|
||||
PROTOCOL.md this file
|
||||
discover.json generated core.rpc.discover document
|
||||
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:
|
||||
|
||||
- A process writes its own folder only through `/self`. Writes through
|
||||
`/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.**
|
||||
|
||||
### File watching
|
||||
|
||||
`core.watch {"path": "/state/session/mailbox"}` stays open for the caller
|
||||
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
|
||||
files. Writes before arming are visible in that scan; writes after arming
|
||||
produce `"changed"` notifications (also with empty params). Rescan after
|
||||
each notification; files, not event counts, are the truth.
|
||||
|
||||
Notifications follow successful plugin-originated file mutations: creation,
|
||||
truncation, descriptor/stream writes, rename (both source and destination),
|
||||
and deletion, including mounted `/run` and `/self` files. Paths are normalized
|
||||
in the virtual namespace; `/self` means the caller's `/proc/<pid>` folder.
|
||||
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
|
||||
scan, but a change after the last notification always produces another.
|
||||
Delivery holds no filesystem locks. Observation does not serialize descriptor
|
||||
IO; only overlapping opens and rename/removal wait for one another.
|
||||
Watch the narrowest useful path: delivery is not rate-limited, so watching a
|
||||
busy parent can cause a rescan for every write.
|
||||
|
||||
The host-side watch ends only when its caller process exits, not on a change
|
||||
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
|
||||
|
||||
An endpoint is a JSON-RPC interface that a running process serves over TCP,
|
||||
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.rpc.discover` | none | generated OpenRPC 1.2 document for every native core method |
|
||||
| `core.call` | `method`, `params`, `detach`? | the result of the method |
|
||||
| `core.watch` | `path` | stays open; `armed`, then `changed` notifications (section 4) |
|
||||
| `core.notify` | `name`, `params` | none (a notification) |
|
||||
| `core.stop` | `pid` | `{}` when the process has ended |
|
||||
| `core.kill` | `pid` | `{}` |
|
||||
| `core.lock` | `path`, `wait`? | `{"held": bool}` |
|
||||
| `core.unlock` | `path` | none (a notification) |
|
||||
| `core.activate` | `plugin` and `wasm`, or `plugins: [{plugin, wasm}]`. `force`? | short activation summaries, keyed by plugin |
|
||||
| `core.revert` | `plugin`, `force`? | like `core.activate` |
|
||||
| `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. |
|
||||
|
||||
`core.rpc.discover` and `/proc/core/discover.json` expose the same document,
|
||||
with unprefixed method names, just like a plugin's discovery document. The
|
||||
core generates it from the parameter and result types used by its dispatch;
|
||||
there is no separate JSON schema file to maintain. Copy the proc file to
|
||||
`imports/core.json` and use `import!("core")` to get typed calls, including
|
||||
`core::lock` and `core::log`. Dotted wire names become underscores in Rust
|
||||
(`rpc.discover` becomes `rpc_discover`). Import checks recognize the native
|
||||
core as a provider without requiring a `plugins/core` directory. Forwarded
|
||||
results and discovery documents contain arbitrary JSON; other core results
|
||||
are typed, including activation summaries and journal entries.
|
||||
|
||||
### Host restart
|
||||
|
||||
**HARD RULE: the mediator and all agents NEVER call `core.restart` without
|
||||
the person's explicit approval each time.** The same approval rule applies
|
||||
to the legacy alias `core.exit`; it uses the same gate and pending request.
|
||||
|
||||
`core.restart {}` is enabled only when the host starts with `SAUSAGE_LOOP=1`.
|
||||
The host captures the marker at startup. An absent marker (or any value other
|
||||
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
|
||||
250 milliseconds to let the response reach the caller before stdin closes.
|
||||
Repeated requests while a restart is pending return `{}` without scheduling
|
||||
another restart or extending that delay. The executable then uses the same
|
||||
`Host::stop` path as a normal stop: reject new processes, stop all processes
|
||||
(including detached ones) in parallel with the normal five-second grace,
|
||||
wait for outstanding file operations, release locks, and drain journal writes.
|
||||
Only after that does the executable return the dedicated restart exit code
|
||||
**75**. The library signals this path; it never kills its embedding process.
|
||||
|
||||
`nix run .#loop -- <world>` sets `SAUSAGE_LOOP=1` on the host invocation and
|
||||
starts the newest host again after exit code 75 (and, as before, other nonzero
|
||||
exits). Exit code 0 ends the loop. If using an older loop script, the person
|
||||
must set `SAUSAGE_LOOP=1` in that supervisor's environment; do not set it for
|
||||
an unsupervised host. The NixEnv service invokes this flake's `#loop`, so a
|
||||
new loop script supplies the marker without changes to NixEnv.
|
||||
|
||||
Everything else is a file.
|
||||
|
||||
## 10. Interfaces
|
||||
|
||||
### 10.1 `rpc.discover`
|
||||
|
||||
Each plugin answers `rpc.discover` with an OpenRPC 1.2 document. The schemas
|
||||
are JSON Schema Draft 7, as OpenRPC requires. Shared schemas are in
|
||||
`components.schemas`, and `$ref`s point to `#/components/schemas/<Name>`.
|
||||
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
|
||||
the params object.
|
||||
- `x-sausage-endpoints`: the endpoint interfaces by name, each with its
|
||||
`methods`.
|
||||
- `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
|
||||
result describes what the provider writes. Descriptions are part of the
|
||||
interface: other agents read them to use it.
|
||||
|
||||
The core starts a process only to call `rpc.discover`, and it stops that
|
||||
process after the answer. Do not do expensive work at start. If no answer
|
||||
comes within 10 seconds, the discovery fails.
|
||||
|
||||
### 10.2 Compatibility
|
||||
|
||||
A provider can add methods, optional params, and result fields. For each
|
||||
imported method:
|
||||
|
||||
1. The provider has the method.
|
||||
2. Each params value that the consumer can send is valid for the provider.
|
||||
3. Each result that the provider can return is valid for the consumer.
|
||||
|
||||
- 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, typed dictionaries (`additionalProperties`
|
||||
with a value schema), 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.
|
||||
@@ -2,125 +2,64 @@
|
||||
|
||||
You can see how the sausage is made.
|
||||
|
||||
Sausage is a world of small WASI programs ("plugins") that you grow by
|
||||
talking to it. A plugin is a `.wasm` file with JSON-RPC methods. Each call
|
||||
to a plugin starts a new process. The agent is a plugin too: it writes new
|
||||
plugins in Rust, builds them, tests them, activates them, and can change its
|
||||
own code.
|
||||
Sausage is a system of actors that you grow by talking to it. Every actor is a
|
||||
WASI program (a `.wasm` file) with stdin and stdout. Actors send each other
|
||||
newline-delimited JSON messages through a broker. The agent is an actor too:
|
||||
it does inference over HTTP, writes new actors, and can change its own code.
|
||||
|
||||
## Start
|
||||
Inspired by [Jiti](https://ghuntley.com/lisp/), but with WASM instead of a
|
||||
Lisp image.
|
||||
|
||||
You need [Nix](https://nixos.org/download) with flakes, and an LLM: the OpenAI
|
||||
Responses API, an OpenAI-compatible Chat Completions API (OpenRouter, Ollama,
|
||||
and most other providers), or the Anthropic Messages API. No clone needed:
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
nix run git+https://gitea.surma.technology/surma/sausage
|
||||
nix develop
|
||||
cargo build --release
|
||||
export SAUSAGE_API_TOKEN=... # or type it at the prompt
|
||||
./target/release/sausage init world --api openai-responses \
|
||||
--base-url https://api.example.com --model some-model
|
||||
./target/release/sausage run world
|
||||
```
|
||||
|
||||
This creates a world in `./world` the first time (it builds the agent: the
|
||||
first build on a machine takes a few minutes, later worlds reuse the compiled
|
||||
crates), then starts it with a console. Later runs start the
|
||||
existing world. The two steps on their own:
|
||||
`init` builds the agent (the first build takes a few minutes) and writes its
|
||||
configuration to `world/private/agent/data/config.json`. The source contains
|
||||
no endpoint and no token.
|
||||
|
||||
```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:
|
||||
|
||||
```sh
|
||||
nix run git+https://gitea.surma.technology/surma/sausage#loop # ./world, created if needed
|
||||
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
|
||||
newest commit and starts it again. The loop sets `SAUSAGE_LOOP=1` to enable
|
||||
that RPC; without it, restart returns an error and leaves the host running.
|
||||
Restart answers first, then cleanly stops all processes and exits with code
|
||||
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
|
||||
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
|
||||
same commands are `nix run`, `nix run -- init`, `nix run -- run`, and
|
||||
`nix run .#loop`.
|
||||
|
||||
### Headless
|
||||
|
||||
`--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
|
||||
In the console:
|
||||
|
||||
```text
|
||||
plugins/<name>/plugin.wasm the current version of a plugin
|
||||
plugins/<name>/source/ its source
|
||||
state/<plugin>/ the state of a plugin, by convention
|
||||
state/core/journal.sqlite the journal: stderr of all processes, and core events
|
||||
config/boot.yaml ordered boot services, supervised for crashes (PROTOCOL §8)
|
||||
config/agent.yaml the configuration of the agent
|
||||
hello → text to the agent
|
||||
@notes {"type":"add","text":"milk"} → a JSON message to an actor
|
||||
@agent what actors exist? → text to an actor
|
||||
/deploy notes examples/notes → build with cargo, then spawn or upgrade
|
||||
/list → list actors
|
||||
/await agent 300 → wait for the next message (scripts)
|
||||
/quit
|
||||
```
|
||||
|
||||
Boot services restart on process crashes, not on readiness responses. More
|
||||
than 5 crashes within 10 seconds stops automatic attempts for that service
|
||||
and journals `service gave up`; an upgrade does not reset that decision.
|
||||
Explicitly stopping or killing a service also leaves it stopped for this
|
||||
host lifetime. See [Boot and services](PROTOCOL.md#8-boot-and-services).
|
||||
## Concepts
|
||||
|
||||
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.
|
||||
- **Actor**: a `wasi:cli/command` program. It reads messages on stdin and
|
||||
writes messages on stdout. Logs go to stderr.
|
||||
- **Message**: `{"id", "from", "reply_to"?, "body"}` on stdin;
|
||||
`{"to"?, "id"?, "reply_to"?, "body"}` on stdout. No `to` = publish to
|
||||
subscribers. There is no request/response: a reply is a new message with
|
||||
`reply_to`. Delivery is at most once.
|
||||
- **Broker**: routes messages, owns mailboxes, and runs actors. It accepts
|
||||
only `.wasm` files: `spawn`, `upgrade`, `rollback`, `stop`, `start`,
|
||||
`remove`, `subscribe`, `unsubscribe`, `list`.
|
||||
- **cargo**: the built-in Rust compiler actor. `build` a source folder, get a
|
||||
`.wasm` path back. Other actors can add other languages.
|
||||
- **Filesystem** of each actor:
|
||||
- `/data`: private state
|
||||
- `/public`: its public files
|
||||
- `/actor/<id>/{public,source,status.json,stderr.log}`: every actor, read-only
|
||||
|
||||
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).
|
||||
A write becomes visible when the writer closes the file (commit on close).
|
||||
- **Upgrade**: the broker tests the candidate on a copy of `/data` and
|
||||
`/public` (transcripts in `source/tests/*.jsonl`). Then it holds new
|
||||
messages, stops the old version, starts the new one, and delivers the held
|
||||
messages. A new version that crashes 3 times in 30 seconds is rolled back.
|
||||
|
||||
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
|
||||
|
||||
- [PROTOCOL.md](PROTOCOL.md): calls, processes, the filesystem, locks,
|
||||
interfaces, and `cargo`. Read this to write a plugin in any language.
|
||||
- [DECISIONS.md](DECISIONS.md): why Sausage is the way it is.
|
||||
- [`sdk/`](sdk/): `sausage-plugin`, the SDK for Rust plugins.
|
||||
- [`examples/`](examples/): small plugins.
|
||||
- [AGENTS.md](AGENTS.md): how to work on Sausage itself.
|
||||
See [DECISIONS.md](DECISIONS.md) for every design decision and
|
||||
[SPIKE.md](SPIKE.md) for the spike results.
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Spike results
|
||||
|
||||
Date: 2026-10-15. Host: Scout container (8 cores), Rust 1.99, Wasmtime 49.0.3,
|
||||
`wstd` 0.6.8. All numbers come from `sausage run` with release builds.
|
||||
|
||||
## 1. Host, broker, routing, filesystem — works
|
||||
|
||||
- `sausage init` builds the agent and registers it. `sausage run` starts the
|
||||
broker, `cargo`, the console, and every registered actor.
|
||||
- Routing, replies (`reply_to`), publish to subscribers, and `undeliverable`
|
||||
errors work.
|
||||
- **Read-only:** a write to `/actor/<other>/public/...` fails with "Operation
|
||||
not permitted". A write to `/actor/<self>/public/...` fails too. A write to
|
||||
`/public/...` works.
|
||||
- **Commit on close:** while `fs-a` holds `/public/y.txt` open, `fs-b` gets
|
||||
"No such file". `fs-a` itself also gets it through a second descriptor. The
|
||||
temporary file does not appear in listings. After the close, `fs-b` reads
|
||||
the content.
|
||||
- **Append:** `append` to an existing file copies the old content. Readers
|
||||
see the old version until the close.
|
||||
- **Crash with an open file:** the write is discarded, and the old version
|
||||
stays.
|
||||
- **Escapes:** `/data/../../../../etc/hostname` and
|
||||
`/actor/../private/agent/data/config.json` fail.
|
||||
- **Hard kill of the host:** after `kill`, no `.sausage-tmp-*` files remained,
|
||||
and the next `sausage run` started every actor again.
|
||||
|
||||
## 2. Async stdin + HTTPS in one actor — works
|
||||
|
||||
`examples/fetch` (`wstd`) handles `ping` at once while a 2-second HTTPS
|
||||
request runs. HTTPS and plain HTTP both work through `wasi:http`, and the host
|
||||
does TLS. Bodies go to `/public/fetch/<n>`, and the reply contains the
|
||||
global path.
|
||||
|
||||
One server (`http://neverssl.com/`) never answered. `curl` also hangs on it,
|
||||
so the server is the cause. But a request without a timeout keeps its task
|
||||
alive, and the actor then did not exit on `shutdown`. The broker killed it
|
||||
after 5 s, as designed. Actors should set HTTP timeouts.
|
||||
|
||||
## 3. Upgrade cycle — works
|
||||
|
||||
- **No loss during a swap:** `ticker` sent 200 messages to `notes` at 50 ms
|
||||
intervals. `notes` was upgraded from v1 to v2 during the stream. The swap
|
||||
happened after tick 68. The v1 snapshot has ticks 1–68, and v2 has ticks
|
||||
1–200, in order, with no gaps.
|
||||
- **Parked message:** `{"type":"crash"}` crashed v1. The broker restarted v1
|
||||
and kept the message. After the upgrade to v2 (which handles `crash`), the
|
||||
broker delivered it again, and v2 answered `not_crashing`.
|
||||
- **Crash loop:** an upgrade to `examples/crasher` passed the smoke test (it
|
||||
crashes after 3 s). It crashed 3 times in about 10 s. The broker rolled back
|
||||
to v2 with its data and sent `rolled_back` to the console.
|
||||
- **Explicit rollback** v2 → v1 restored the v1 snapshot.
|
||||
- **Rejections:** a compiler error comes back as `build_failed` with the
|
||||
rendered diagnostics. A failing transcript rejects the upgrade, and the old
|
||||
version keeps running. Invalid IDs and `..` paths are rejected.
|
||||
|
||||
## 4. Agent — works with both API shapes
|
||||
|
||||
- **`gpt-6-luna`** (OpenAI Responses) through the vendors proxy:
|
||||
- Asked "which actors exist", it called `broker` `list` and answered in
|
||||
about 4 s (2 inferences).
|
||||
- Asked for a `counter` actor, it took these steps without help:
|
||||
1. It wrote `Cargo.toml`, `src/main.rs`, and `tests/basic.jsonl`.
|
||||
2. It built the crate with `cargo` (1.9 s).
|
||||
3. It spawned the actor (the tests passed).
|
||||
4. It sent increment, increment, get (1, 2, 2).
|
||||
5. It read `/actor/counter/public/value.txt`.
|
||||
- **Self-upgrade:** asked to change its own prompt, it took these steps:
|
||||
1. It copied its source and changed only `prompt.md` (a 3-line diff).
|
||||
2. It built the change and upgraded `agent`.
|
||||
3. The upgrade stopped it in the middle of the turn. The new version
|
||||
resumed the turn from `/data/turn.json` and answered with the new
|
||||
signature.
|
||||
- **`claude-haiku-4-5`** (Anthropic Messages): the same conversation
|
||||
continued, with tool calls.
|
||||
|
||||
## 5. Measurements
|
||||
|
||||
| What | Result |
|
||||
| --- | --- |
|
||||
| Round trip actor → actor → actor | p50 88 µs, p99 172 µs |
|
||||
| Sequential round trips | ~10,400 per second |
|
||||
| Warm `cargo` build, small actor | 0.5–1.9 s |
|
||||
| Warm `cargo` build, agent | 5.5 s |
|
||||
| First build of the agent (`init`) | 17 s |
|
||||
| Component compile, small actor | ~70 ms |
|
||||
| Component compile, agent (870 KB) | ~290 ms |
|
||||
| Host start, 8 actors compiled in parallel | ~0.8 s each |
|
||||
| Host RSS with 8 running actors | ~117 MB |
|
||||
| First release build of `sausage` | ~9 min (Wasmtime) |
|
||||
|
||||
Caveat: `~/.cargo/config.toml` in this container sets `kache` as the
|
||||
`rustc-wrapper`. `kache` caches compiler outputs across projects, so the
|
||||
first build of the agent (17 s) is probably faster than on a clean machine.
|
||||
|
||||
## Next steps I suggest
|
||||
|
||||
1. A `matrix` actor (WASM, `wstd`, long-polled `/sync`). The agent could write
|
||||
it, with you checking the result.
|
||||
2. Cache compiled components (`Component::serialize`, `.cwasm` files) to
|
||||
remove the compile time at host start.
|
||||
3. A network allow-list per actor, for example in a manifest in the source
|
||||
folder.
|
||||
4. A sandbox for builds (for example `bwrap`), because build scripts run
|
||||
natively.
|
||||
5. Context compaction for the agent's conversation.
|
||||
6. A better console (line editing, history).
|
||||
+2
-5
@@ -2,13 +2,10 @@
|
||||
name = "agent"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "The Sausage agent plugin."
|
||||
description = "The Sausage agent actor."
|
||||
|
||||
[dependencies]
|
||||
sausage-plugin = { version = "0.1", path = "../sdk" }
|
||||
async-channel = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml_ng = "0.10"
|
||||
|
||||
[target.'cfg(target_os = "wasi")'.dependencies]
|
||||
wstd = "0.6"
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
//! History window selection and context-size error detection.
|
||||
|
||||
use crate::llm::Entry;
|
||||
|
||||
/// Recognize provider errors that permit a retry with fewer complete turns.
|
||||
pub fn is_too_large(error: &str) -> bool {
|
||||
let Some((status, body)) = error
|
||||
.strip_prefix("HTTP ")
|
||||
.and_then(|error| error.split_once(':'))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if status.split_whitespace().next() == Some("413") {
|
||||
return true;
|
||||
}
|
||||
if status.split_whitespace().next() != Some("400") {
|
||||
return false;
|
||||
}
|
||||
let body = body.to_ascii_lowercase();
|
||||
body.contains("context_length_exceeded")
|
||||
|| body.contains("maximum context length")
|
||||
|| body.contains("context length exceeded")
|
||||
|| body.contains("too large")
|
||||
|| (body.contains("too long")
|
||||
&& ["context", "input", "prompt"]
|
||||
.iter()
|
||||
.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> {
|
||||
let end = current.min(history.len().checked_sub(1)?);
|
||||
(from..=end).find(|&index| matches!(&history[index], Entry::User { .. }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
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 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: request is too large"));
|
||||
assert!(!is_too_large("HTTP 500: prompt is too long"));
|
||||
assert!(!is_too_large("HTTP 400: invalid token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_skip_advances_by_whole_user_turns() {
|
||||
let history = vec![
|
||||
Entry::User {
|
||||
text: "first".into(),
|
||||
},
|
||||
Entry::ToolCall {
|
||||
id: "a".into(),
|
||||
name: "tool".into(),
|
||||
arguments: "{}".into(),
|
||||
},
|
||||
Entry::ToolResult {
|
||||
id: "a".into(),
|
||||
output: "done".into(),
|
||||
},
|
||||
Entry::User {
|
||||
text: "second".into(),
|
||||
},
|
||||
Entry::ToolCall {
|
||||
id: "b".into(),
|
||||
name: "tool".into(),
|
||||
arguments: "{}".into(),
|
||||
},
|
||||
Entry::ToolResult {
|
||||
id: "b".into(),
|
||||
output: "done".into(),
|
||||
},
|
||||
Entry::User {
|
||||
text: "current".into(),
|
||||
},
|
||||
];
|
||||
assert_eq!(next_turn_start(&history, 0, 6), Some(0));
|
||||
assert_eq!(next_turn_start(&history, 1, 6), Some(3));
|
||||
assert_eq!(next_turn_start(&history, 4, 6), Some(6));
|
||||
assert_eq!(next_turn_start(&history, usize::MAX, 6).unwrap_or(6), 6);
|
||||
}
|
||||
}
|
||||
+61
-518
@@ -1,404 +1,90 @@
|
||||
//! 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
|
||||
//! between the three shapes.
|
||||
//! between the two shapes.
|
||||
|
||||
use sausage_plugin::schema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
#[cfg(target_os = "wasi")]
|
||||
use wstd::http::{Body, Client, Request};
|
||||
use wstd::time::Duration;
|
||||
|
||||
/// Inference configuration from `/config/agent.yaml`.
|
||||
#[derive(Deserialize, Clone, Default)]
|
||||
#[serde(default)]
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct Config {
|
||||
/// `openai-responses`, `openai-chat-completions`, or `anthropic-messages`.
|
||||
/// `openai-responses` or `anthropic-messages`.
|
||||
pub api: String,
|
||||
/// The SDK base URL: versioned for OpenAI, unversioned for Anthropic.
|
||||
pub base_url: String,
|
||||
/// The provider's model identifier.
|
||||
pub model: String,
|
||||
/// The API credential sent in the provider's authentication header.
|
||||
pub token: String,
|
||||
/// Permit an empty token and omit authentication headers.
|
||||
pub allow_no_token: bool,
|
||||
/// An optional cap on generated tokens.
|
||||
/// Optional reasoning effort for OpenAI models ("low", "medium", ...).
|
||||
#[serde(default)]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// The thinking level of each turn. Unset: the model's default.
|
||||
#[serde(default)]
|
||||
pub thinking: Option<Thinking>,
|
||||
pub reasoning_effort: Option<String>,
|
||||
#[serde(default = "default_max_tokens")]
|
||||
pub max_tokens: u32,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
fn default_max_tokens() -> u32 {
|
||||
16000
|
||||
}
|
||||
|
||||
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)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Entry {
|
||||
/// A person's contribution starts a new turn.
|
||||
User {
|
||||
/// The complete contribution.
|
||||
text: String,
|
||||
},
|
||||
/// Text produced by the model.
|
||||
Assistant {
|
||||
/// The generated text.
|
||||
text: String,
|
||||
/// The optional provider phase, preserved for subsequent requests.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
phase: Option<String>,
|
||||
},
|
||||
/// A requested tool operation.
|
||||
ToolCall {
|
||||
/// The provider's correlation identifier.
|
||||
id: String,
|
||||
/// 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,
|
||||
},
|
||||
User { text: String },
|
||||
Assistant { text: String },
|
||||
ToolCall { id: String, name: String, arguments: String },
|
||||
ToolResult { id: String, output: String },
|
||||
/// A provider item that only this API shape understands (OpenAI reasoning).
|
||||
Opaque { api: String, item: Value },
|
||||
}
|
||||
|
||||
/// A tool contract shared by all API shapes.
|
||||
pub struct Tool {
|
||||
/// The function identifier exposed to the model.
|
||||
pub name: &'static str,
|
||||
/// The explanation of the tool's behavior.
|
||||
pub description: &'static str,
|
||||
/// The JSON Schema for named parameters.
|
||||
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> {
|
||||
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,
|
||||
}
|
||||
pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
|
||||
match cfg.api.as_str() {
|
||||
"openai-responses" => openai(cfg, system, tools, history).await,
|
||||
"anthropic-messages" => anthropic(cfg, system, tools, history).await,
|
||||
other => Err(format!("unknown API shape {other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let mut req = Request::post(url).header("content-type", "application/json");
|
||||
for (k, v) in headers {
|
||||
req = req.header(*k, v.as_str());
|
||||
}
|
||||
let req = req
|
||||
.body(Body::from(body.to_string()))
|
||||
.map_err(|e| format!("request: {e}"))?;
|
||||
let client = Client::new();
|
||||
let resp = client
|
||||
.send(req)
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {e:?}"))?;
|
||||
let req = req.body(Body::from(body.to_string())).map_err(|e| format!("request: {e}"))?;
|
||||
let mut client = Client::new();
|
||||
client.set_first_byte_timeout(Duration::from_secs(600));
|
||||
client.set_between_bytes_timeout(Duration::from_secs(600));
|
||||
let resp = client.send(req).await.map_err(|e| format!("HTTP request failed: {e:?}"))?;
|
||||
let status = resp.status();
|
||||
let mut body = resp.into_body();
|
||||
let text = body
|
||||
.str_contents()
|
||||
.await
|
||||
.map_err(|e| format!("reading the response failed: {e:?}"))?
|
||||
.to_string();
|
||||
let text = body.str_contents().await.map_err(|e| format!("reading the response failed: {e:?}"))?.to_string();
|
||||
if !status.is_success() {
|
||||
return Err(format!("HTTP {status}: {text}"));
|
||||
let mut t = text;
|
||||
t.truncate(2000);
|
||||
return Err(format!("HTTP {status}: {t}"));
|
||||
}
|
||||
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],
|
||||
effort: Option<&str>,
|
||||
) -> Result<Vec<Entry>, String> {
|
||||
async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
|
||||
let mut input = Vec::new();
|
||||
for e in history {
|
||||
match e {
|
||||
Entry::User { text } => input.push(json!({"role": "user", "content": text})),
|
||||
Entry::Assistant { text, phase } => {
|
||||
let mut item = json!({"role": "assistant", "content": text});
|
||||
if let Some(phase) = phase {
|
||||
item["phase"] = json!(phase);
|
||||
}
|
||||
input.push(item);
|
||||
}
|
||||
Entry::Assistant { text } => input.push(json!({"role": "assistant", "content": text})),
|
||||
Entry::ToolCall { id, name, arguments } => {
|
||||
input.push(json!({"type": "function_call", "call_id": id, "name": name, "arguments": arguments}))
|
||||
}
|
||||
Entry::ToolResult { id, 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 { .. } => {}
|
||||
}
|
||||
}
|
||||
@@ -413,15 +99,13 @@ async fn openai(
|
||||
"tools": tools,
|
||||
"store": false,
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"max_output_tokens": cfg.max_tokens,
|
||||
});
|
||||
if let Some(max_tokens) = cfg.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});
|
||||
}
|
||||
let url = format!("{}/responses", cfg.base_url.trim_end_matches('/'));
|
||||
let resp = post(&url, &cfg.bearer(), &body).await?;
|
||||
let url = format!("{}/v1/responses", cfg.base_url);
|
||||
let resp = post(&url, &[("authorization", format!("Bearer {}", cfg.token))], &body).await?;
|
||||
let mut out = Vec::new();
|
||||
for item in resp["output"].as_array().cloned().unwrap_or_default() {
|
||||
match item["type"].as_str() {
|
||||
@@ -436,9 +120,8 @@ async fn openai(
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let phase = item["phase"].as_str().map(str::to_string);
|
||||
if !text.is_empty() || phase.is_some() {
|
||||
out.push(Entry::Assistant { text, phase });
|
||||
if !text.is_empty() {
|
||||
out.push(Entry::Assistant { text });
|
||||
}
|
||||
}
|
||||
Some("function_call") => out.push(Entry::ToolCall {
|
||||
@@ -446,36 +129,17 @@ async fn openai(
|
||||
name: item["name"].as_str().unwrap_or_default().to_string(),
|
||||
arguments: item["arguments"].as_str().unwrap_or("{}").to_string(),
|
||||
}),
|
||||
Some("reasoning") => out.push(Entry::Opaque {
|
||||
api: "openai-responses".into(),
|
||||
model: cfg.model.clone(),
|
||||
item,
|
||||
}),
|
||||
Some("reasoning") => out.push(Entry::Opaque { api: "openai-responses".into(), item }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if resp["status"] == "incomplete"
|
||||
&& !out
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, Entry::Assistant { text, .. } if !text.is_empty()))
|
||||
{
|
||||
return Err(format!(
|
||||
"incomplete response: {}",
|
||||
resp["incomplete_details"]
|
||||
));
|
||||
if resp["status"] == "incomplete" && out.is_empty() {
|
||||
return Err(format!("incomplete response: {}", resp["incomplete_details"]));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Translate neutral history to Anthropic Messages and merge consecutive roles.
|
||||
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);
|
||||
async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
|
||||
// Merge consecutive blocks of the same role into one message.
|
||||
let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
|
||||
let mut push = |role: &str, block: Value| match messages.last_mut() {
|
||||
@@ -485,74 +149,43 @@ async fn anthropic(
|
||||
for e in history {
|
||||
match e {
|
||||
Entry::User { text } => push("user", json!({"type": "text", "text": text})),
|
||||
Entry::Assistant { text, .. } => {
|
||||
push("assistant", json!({"type": "text", "text": text}))
|
||||
}
|
||||
Entry::ToolCall {
|
||||
id,
|
||||
name,
|
||||
arguments,
|
||||
} => {
|
||||
Entry::Assistant { text } => push("assistant", json!({"type": "text", "text": text})),
|
||||
Entry::ToolCall { id, name, arguments } => {
|
||||
let input: Value = serde_json::from_str(arguments).unwrap_or(json!({}));
|
||||
push(
|
||||
"assistant",
|
||||
json!({"type": "tool_use", "id": id, "name": name, "input": input}),
|
||||
)
|
||||
push("assistant", json!({"type": "tool_use", "id": id, "name": name, "input": input}))
|
||||
}
|
||||
Entry::ToolResult { id, output } => push(
|
||||
"user",
|
||||
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::ToolResult { id, output } => {
|
||||
push("user", json!({"type": "tool_result", "tool_use_id": id, "content": output}))
|
||||
}
|
||||
Entry::Opaque { .. } => {}
|
||||
}
|
||||
}
|
||||
let messages: Vec<Value> = messages
|
||||
.into_iter()
|
||||
.map(|(role, content)| json!({"role": role, "content": content}))
|
||||
.collect();
|
||||
let messages: Vec<Value> = messages.into_iter().map(|(role, content)| json!({"role": role, "content": content})).collect();
|
||||
let tools: Vec<Value> = tools
|
||||
.iter()
|
||||
.map(
|
||||
|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}),
|
||||
)
|
||||
.map(|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}))
|
||||
.collect();
|
||||
let mut body = json!({
|
||||
let body = json!({
|
||||
"model": cfg.model,
|
||||
"system": system,
|
||||
"messages": messages,
|
||||
"tools": tools,
|
||||
"max_tokens": cfg.max_tokens.unwrap_or(32000),
|
||||
"max_tokens": cfg.max_tokens,
|
||||
});
|
||||
// Unset: the model's default. Newer models think by default.
|
||||
match cfg.thinking {
|
||||
None => {}
|
||||
Some(Thinking::Off) => body["thinking"] = json!({"type": "disabled"}),
|
||||
Some(_) => {
|
||||
body["thinking"] = json!({"type": "adaptive"});
|
||||
if let Some(effort) = effort {
|
||||
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 url = format!("{}/v1/messages", cfg.base_url);
|
||||
let resp = post(
|
||||
&url,
|
||||
&[("x-api-key", cfg.token.clone()), ("anthropic-version", "2023-06-01".to_string())],
|
||||
&body,
|
||||
)
|
||||
.await?;
|
||||
let mut out = Vec::new();
|
||||
for block in resp["content"].as_array().cloned().unwrap_or_default() {
|
||||
match block["type"].as_str() {
|
||||
Some("text") => {
|
||||
let text = block["text"].as_str().unwrap_or_default().to_string();
|
||||
if !text.is_empty() {
|
||||
out.push(Entry::Assistant { text, phase: None });
|
||||
out.push(Entry::Assistant { text });
|
||||
}
|
||||
}
|
||||
Some("tool_use") => out.push(Entry::ToolCall {
|
||||
@@ -560,98 +193,8 @@ async fn anthropic(
|
||||
name: block["name"].as_str().unwrap_or_default().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)
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
+499
-190
@@ -1,211 +1,520 @@
|
||||
//! Answer chat calls with an LLM and tools, under one lock for the complete turn.
|
||||
mod history;
|
||||
//! The Sausage agent: a normal WASM actor that does inference over HTTP and
|
||||
//! grows the world through messages to `cargo` and `broker`.
|
||||
//!
|
||||
//! Configuration: `/data/config.json` (see `llm::Config`).
|
||||
//! Conversation: `/data/history.json` (one shared conversation).
|
||||
|
||||
mod llm;
|
||||
mod tools;
|
||||
|
||||
use history::{is_too_large, next_turn_start};
|
||||
use llm::{Config, Entry, Thinking};
|
||||
use sausage_plugin::{Ctx, Error, Result, lock, rpc, schema};
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use async_channel::{Receiver, Sender};
|
||||
use llm::{Config, Entry, Tool};
|
||||
use serde_json::{Value, json};
|
||||
use wstd::future::FutureExt;
|
||||
use wstd::io::{AsyncRead, AsyncWrite};
|
||||
use wstd::time::{Duration, Instant};
|
||||
|
||||
/// The instructions sent with every inference request.
|
||||
const SYSTEM: &str = include_str!("prompt.md");
|
||||
/// The shared, append-only conversation.
|
||||
const HISTORY: &str = "/state/agent/history.jsonl";
|
||||
/// The first retained turn after a context-size rejection.
|
||||
const SKIP: &str = "/state/agent/history.skip";
|
||||
/// Each chat process reads the shared history after it acquires the lock.
|
||||
struct Agent;
|
||||
/// The person's next contribution to the conversation.
|
||||
#[schema]
|
||||
struct ChatParams {
|
||||
/// The complete text of the person's request.
|
||||
text: String,
|
||||
/// The thinking level of this turn only. Without it, the turn uses `thinking` from
|
||||
/// `/config/agent.yaml`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
thinking: Option<Thinking>,
|
||||
const CONFIG: &str = "/data/config.json";
|
||||
const HISTORY: &str = "/data/history.json";
|
||||
/// An interrupted turn (sender and message ID), resumed at the next start.
|
||||
const TURN: &str = "/data/turn.json";
|
||||
const MAX_STEPS: usize = 40;
|
||||
const MAX_HISTORY: usize = 600;
|
||||
const MAX_TOOL_OUTPUT: usize = 30_000;
|
||||
const MAX_NOTES: usize = 50;
|
||||
|
||||
fn main() {
|
||||
wstd::runtime::block_on(async {
|
||||
let (tx, rx) = async_channel::unbounded();
|
||||
wstd::runtime::spawn(read_stdin(tx)).detach();
|
||||
Agent::new(rx).run().await;
|
||||
});
|
||||
}
|
||||
|
||||
#[rpc(grace_seconds = 600)]
|
||||
impl Agent {
|
||||
/// Answer a person, with interim text notifications and a final text result.
|
||||
///
|
||||
/// The history lock covers inference and tools, so concurrent chats cannot interleave turns.
|
||||
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)
|
||||
/// Read NDJSON messages from stdin into the channel.
|
||||
async fn read_stdin(tx: Sender<Value>) {
|
||||
let mut stdin = wstd::io::stdin();
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = vec![0u8; 16 * 1024];
|
||||
loop {
|
||||
match stdin.read(&mut chunk).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
while let Some(pos) = buf.iter().position(|b| *b == b'\n') {
|
||||
let line: Vec<u8> = buf.drain(..=pos).collect();
|
||||
if let Ok(v) = serde_json::from_slice::<Value>(&line)
|
||||
&& tx.send(v).await.is_err()
|
||||
{
|
||||
start = next;
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
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 })?;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.close();
|
||||
}
|
||||
|
||||
/// 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()
|
||||
.filter_map(|line| match serde_json::from_str(line) {
|
||||
Ok(entry) => Some(entry),
|
||||
Err(error) => {
|
||||
eprintln!("skip damaged history line: {error}");
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
|
||||
Err(error) => Err(error.into()),
|
||||
fn truncate(mut s: String, max: usize) -> String {
|
||||
if s.len() > max {
|
||||
let mut end = max;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
let total = s.len();
|
||||
s.truncate(end);
|
||||
s.push_str(&format!("\n… (truncated, {total} bytes in total)"));
|
||||
}
|
||||
}
|
||||
/// Persist an entry before it enters the current inference context.
|
||||
fn append(history: &mut Vec<Entry>, entry: Entry) -> Result<()> {
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(HISTORY)?;
|
||||
file.write_all(format!("{}\n", serde_json::to_string(&entry)?).as_bytes())?;
|
||||
history.push(entry);
|
||||
Ok(())
|
||||
}
|
||||
/// Complete interrupted tool pairs before a new inference request.
|
||||
fn repair_tools(history: &mut Vec<Entry>) -> Result<()> {
|
||||
let answered: std::collections::HashSet<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::ToolResult { id, .. } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let dangling: Vec<_> = history
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for id in dangling {
|
||||
append(
|
||||
history,
|
||||
Entry::ToolResult {
|
||||
id,
|
||||
output: json!({"error":"interrupted before the tool finished"}).to_string(),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Start the plugin's JSON-RPC transport.
|
||||
fn main() {
|
||||
sausage_plugin::run(Agent);
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn damaged_history_keeps_valid_entries() {
|
||||
let path = if cfg!(target_os = "wasi") {
|
||||
std::path::PathBuf::from("/history-test.jsonl")
|
||||
fn tools() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool {
|
||||
name: "send_message",
|
||||
description: "Send a message to an actor. With wait_seconds > 0, wait for the reply (a message whose reply_to is the ID of this message). Returns the message ID, the reply (or null), and other messages that arrived meanwhile.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"to": {"type": "string", "description": "Actor ID"},
|
||||
"body": {"type": "object", "description": "Message body. Must have a \"type\" field."},
|
||||
"wait_seconds": {"type": "number", "description": "Seconds to wait for the reply. 0 = do not wait."}
|
||||
},
|
||||
"required": ["to", "body"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "wait_for_messages",
|
||||
description: "Wait up to `seconds` for messages to arrive and return them.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"seconds": {"type": "number"}},
|
||||
"required": ["seconds"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "read_file",
|
||||
description: "Read a text file. Paths: /data/..., /public/..., /actor/<id>/...",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string"},
|
||||
"offset": {"type": "integer", "description": "Byte offset (default 0)"},
|
||||
"max_bytes": {"type": "integer", "description": "Default 30000"}
|
||||
},
|
||||
"required": ["path"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "list_dir",
|
||||
description: "List a folder.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"required": ["path"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "write_file",
|
||||
description: "Write a text file (only under /data or /public). Creates parent folders.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}, "content": {"type": "string"}},
|
||||
"required": ["path", "content"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "copy_dir",
|
||||
description: "Copy a folder tree, for example /actor/<id>/source to /public/drafts/<id>. Skips target/ folders.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"from": {"type": "string"}, "to": {"type": "string"}},
|
||||
"required": ["from", "to"]
|
||||
}),
|
||||
},
|
||||
Tool {
|
||||
name: "remove_path",
|
||||
description: "Remove a file or folder under /data or /public.",
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": {"path": {"type": "string"}},
|
||||
"required": ["path"]
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn copy_tree(from: &str, to: &str) -> std::io::Result<u64> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
let mut n = 0;
|
||||
for entry in std::fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let src = format!("{from}/{name}");
|
||||
let dst = format!("{to}/{name}");
|
||||
if entry.file_type()?.is_dir() {
|
||||
if name != "target" {
|
||||
n += copy_tree(&src, &dst)?;
|
||||
}
|
||||
} else {
|
||||
std::env::temp_dir().join(format!("sausage-history-{}", std::process::id()))
|
||||
std::fs::copy(&src, &dst)?;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
struct Agent {
|
||||
rx: Receiver<Value>,
|
||||
stdout: wstd::io::Stdout,
|
||||
history: Vec<Entry>,
|
||||
/// Text messages that start a new turn later.
|
||||
queue: VecDeque<Value>,
|
||||
/// Other messages that arrived while the agent was idle.
|
||||
notes: Vec<Value>,
|
||||
seq: u64,
|
||||
shutdown: bool,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
fn new(rx: Receiver<Value>) -> Self {
|
||||
let history = std::fs::read_to_string(HISTORY)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Self {
|
||||
rx,
|
||||
stdout: wstd::io::stdout(),
|
||||
history,
|
||||
queue: VecDeque::new(),
|
||||
notes: Vec::new(),
|
||||
seq: 0,
|
||||
shutdown: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(mut self) {
|
||||
eprintln!("agent: started with {} history entries", self.history.len());
|
||||
self.resume().await;
|
||||
while !self.shutdown {
|
||||
let msg = match self.queue.pop_front() {
|
||||
Some(m) => m,
|
||||
None => match self.rx.recv().await {
|
||||
Ok(m) => m,
|
||||
Err(_) => break,
|
||||
},
|
||||
};
|
||||
if msg["body"]["type"] == "shutdown" {
|
||||
break;
|
||||
}
|
||||
if starts_turn(&msg) {
|
||||
self.turn(msg).await;
|
||||
} else {
|
||||
self.notes.push(msg);
|
||||
if self.notes.len() > MAX_NOTES {
|
||||
self.notes.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.save();
|
||||
eprintln!("agent: stopped");
|
||||
}
|
||||
|
||||
/// Continue a turn that a restart (usually an upgrade of the agent itself)
|
||||
/// interrupted.
|
||||
async fn resume(&mut self) {
|
||||
let Some(turn) = std::fs::read_to_string(TURN).ok().and_then(|s| serde_json::from_str::<Value>(&s).ok()) else {
|
||||
return;
|
||||
};
|
||||
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();
|
||||
let _ = std::fs::remove_file(TURN);
|
||||
let Ok(cfg) = std::fs::read_to_string(CONFIG).map_err(|_| ()).and_then(|s| serde_json::from_str::<Config>(&s).map_err(|_| ())) else {
|
||||
return;
|
||||
};
|
||||
// Every tool call needs a result, or the APIs reject the history.
|
||||
let answered: std::collections::HashSet<String> = self
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Entry::ToolResult { id, .. } => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let dangling: Vec<String> = self
|
||||
.history
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for id in dangling {
|
||||
self.history.push(Entry::ToolResult {
|
||||
id,
|
||||
output: json!({"error": "interrupted: the agent restarted before this tool finished"}).to_string(),
|
||||
});
|
||||
}
|
||||
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_default();
|
||||
self.history.push(Entry::User {
|
||||
text: format!(
|
||||
"[note from your runtime] You restarted and now run version {version}. The restart interrupted your last turn (probably your own upgrade). Check the state you need, then finish the turn and answer the original message."
|
||||
),
|
||||
});
|
||||
eprintln!("agent: resuming an interrupted turn");
|
||||
self.steps(&cfg, turn["from"].clone(), turn["id"].clone()).await;
|
||||
}
|
||||
|
||||
fn save(&mut self) {
|
||||
if self.history.len() > MAX_HISTORY {
|
||||
let start = self.history.len() - MAX_HISTORY;
|
||||
let cut = (start..self.history.len())
|
||||
.find(|i| matches!(self.history[*i], Entry::User { .. }))
|
||||
.unwrap_or(start);
|
||||
self.history.drain(..cut);
|
||||
}
|
||||
if let Ok(s) = serde_json::to_string(&self.history)
|
||||
&& let Err(e) = std::fs::write(HISTORY, s)
|
||||
{
|
||||
eprintln!("agent: cannot save history: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn emit(&mut self, msg: Value) {
|
||||
let mut line = msg.to_string();
|
||||
line.push('\n');
|
||||
if self.stdout.write_all(line.as_bytes()).await.is_err() || self.stdout.flush().await.is_err() {
|
||||
eprintln!("agent: cannot write to stdout");
|
||||
}
|
||||
}
|
||||
|
||||
async fn reply(&mut self, to: &Value, reply_to: &Value, text: String) {
|
||||
self.emit(json!({"to": to, "reply_to": reply_to, "body": {"type": "text", "text": text}})).await;
|
||||
}
|
||||
|
||||
/// Sort a message that arrived during a turn.
|
||||
fn stash(&mut self, m: Value, others: &mut Vec<Value>) {
|
||||
if m["body"]["type"] == "shutdown" {
|
||||
self.shutdown = true;
|
||||
} else if starts_turn(&m) {
|
||||
others.push(json!({"queued_for_a_later_turn": true, "message": m}));
|
||||
self.queue.push_back(m);
|
||||
} else {
|
||||
others.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
async fn turn(&mut self, msg: Value) {
|
||||
let from = msg["from"].clone();
|
||||
let id = msg["id"].clone();
|
||||
let cfg: Config = match std::fs::read_to_string(CONFIG).map_err(|e| e.to_string()).and_then(|s| serde_json::from_str(&s).map_err(|e| e.to_string())) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
self.reply(&from, &id, format!("I am not configured: cannot read {CONFIG}: {e}")).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut text = format!(
|
||||
"Message {} from {}:\n{}",
|
||||
id.as_str().unwrap_or("?"),
|
||||
from.as_str().unwrap_or("?"),
|
||||
msg["body"]["text"].as_str().unwrap_or("")
|
||||
);
|
||||
if !self.notes.is_empty() {
|
||||
let notes: Vec<String> = self.notes.drain(..).map(|n| n.to_string()).collect();
|
||||
text = format!("Messages that arrived while you were idle:\n{}\n\n{text}", notes.join("\n"));
|
||||
}
|
||||
self.history.push(Entry::User { text });
|
||||
self.steps(&cfg, from, id).await;
|
||||
}
|
||||
|
||||
/// Run inference and tools until the model answers. If a shutdown
|
||||
/// interrupts the turn, remember it in TURN so the next start resumes it.
|
||||
async fn steps(&mut self, cfg: &Config, from: Value, id: Value) {
|
||||
let tools = tools();
|
||||
let cfg = cfg.clone();
|
||||
for _ in 0..MAX_STEPS {
|
||||
let started = Instant::now();
|
||||
let result = llm::complete(&cfg, SYSTEM, &tools, &self.history).await;
|
||||
let entries = match result {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("agent: inference failed: {e}");
|
||||
self.history.push(Entry::Assistant { text: format!("(inference failed: {e})") });
|
||||
self.save();
|
||||
self.reply(&from, &id, format!("Inference failed: {e}")).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
eprintln!("agent: inference took {:?}", started.elapsed());
|
||||
let calls: Vec<(String, String, String)> = entries
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Entry::ToolCall { id, name, arguments } => Some((id.clone(), name.clone(), arguments.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let answer: Vec<String> = entries
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
Entry::Assistant { text } => Some(text.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
self.history.extend(entries);
|
||||
if calls.is_empty() {
|
||||
self.save();
|
||||
let answer = if answer.is_empty() { "(no answer)".to_string() } else { answer.join("\n\n") };
|
||||
self.reply(&from, &id, answer).await;
|
||||
return;
|
||||
}
|
||||
for (call_id, name, args) in calls {
|
||||
let output = self.tool(&name, &args).await;
|
||||
self.history.push(Entry::ToolResult { id: call_id, output: truncate(output.to_string(), MAX_TOOL_OUTPUT) });
|
||||
}
|
||||
self.save();
|
||||
if self.shutdown {
|
||||
let _ = std::fs::write(TURN, json!({"from": from, "id": id}).to_string());
|
||||
eprintln!("agent: turn interrupted by shutdown; it resumes after the restart");
|
||||
return;
|
||||
}
|
||||
}
|
||||
self.reply(&from, &id, format!("I stopped after {MAX_STEPS} steps. Tell me to continue if you want.")).await;
|
||||
}
|
||||
|
||||
async fn tool(&mut self, name: &str, args: &str) -> Value {
|
||||
let a: Value = match serde_json::from_str(args) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return json!({"error": format!("arguments are not valid JSON: {e}")}),
|
||||
};
|
||||
let path = |k: &str| a[k].as_str().unwrap_or_default().to_string();
|
||||
let err = |e: std::io::Error| json!({"error": e.to_string()});
|
||||
match name {
|
||||
"send_message" => self.send_message(&a).await,
|
||||
"wait_for_messages" => {
|
||||
let secs = a["seconds"].as_f64().unwrap_or(10.0).clamp(0.0, 900.0);
|
||||
let mut others = Vec::new();
|
||||
match self.rx.recv().timeout(Duration::from_secs_f64(secs)).await {
|
||||
Ok(Ok(m)) => {
|
||||
self.stash(m, &mut others);
|
||||
while let Ok(m) = self.rx.try_recv() {
|
||||
self.stash(m, &mut others);
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => self.shutdown = true,
|
||||
Err(_) => {}
|
||||
}
|
||||
json!({"messages": others})
|
||||
}
|
||||
"read_file" => {
|
||||
let p = path("path");
|
||||
match std::fs::read(&p) {
|
||||
Ok(bytes) => {
|
||||
let offset = (a["offset"].as_u64().unwrap_or(0) as usize).min(bytes.len());
|
||||
let max = a["max_bytes"].as_u64().unwrap_or(30_000) as usize;
|
||||
let end = (offset + max).min(bytes.len());
|
||||
json!({
|
||||
"path": p, "size": bytes.len(), "offset": offset,
|
||||
"content": String::from_utf8_lossy(&bytes[offset..end]),
|
||||
"truncated": end < bytes.len(),
|
||||
})
|
||||
}
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
"list_dir" => match std::fs::read_dir(path("path")) {
|
||||
Ok(rd) => {
|
||||
let mut entries: Vec<Value> = rd
|
||||
.filter_map(|e| e.ok())
|
||||
.take(500)
|
||||
.map(|e| {
|
||||
let meta = e.metadata().ok();
|
||||
json!({
|
||||
"name": e.file_name().to_string_lossy(),
|
||||
"type": if meta.as_ref().is_some_and(|m| m.is_dir()) { "dir" } else { "file" },
|
||||
"size": meta.map(|m| m.len()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|x, y| x["name"].as_str().cmp(&y["name"].as_str()));
|
||||
json!({"entries": entries})
|
||||
}
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"write_file" => {
|
||||
let p = path("path");
|
||||
if let Some(parent) = std::path::Path::new(&p).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
match std::fs::write(&p, a["content"].as_str().unwrap_or_default()) {
|
||||
Ok(()) => json!({"written": p}),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
"copy_dir" => match copy_tree(&path("from"), &path("to")) {
|
||||
Ok(n) => json!({"copied_files": n}),
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"remove_path" => {
|
||||
let p = path("path");
|
||||
let r = if std::path::Path::new(&p).is_dir() { std::fs::remove_dir_all(&p) } else { std::fs::remove_file(&p) };
|
||||
match r {
|
||||
Ok(()) => json!({"removed": p}),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
other => json!({"error": format!("unknown tool {other}")}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_message(&mut self, a: &Value) -> Value {
|
||||
let Some(to) = a["to"].as_str() else { return json!({"error": "missing \"to\""}) };
|
||||
if !a["body"].is_object() {
|
||||
return json!({"error": "\"body\" must be a JSON object"});
|
||||
}
|
||||
self.seq += 1;
|
||||
let own = format!("a{}-{}", wstd_now_ms(), self.seq);
|
||||
let full = format!("agent/{own}");
|
||||
self.emit(json!({"to": to, "id": own, "body": a["body"]})).await;
|
||||
let wait = a["wait_seconds"].as_f64().unwrap_or(0.0).clamp(0.0, 900.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.rx.recv().timeout(deadline).await {
|
||||
Ok(Ok(m)) => {
|
||||
if m["reply_to"] == full.as_str() {
|
||||
reply = m;
|
||||
break;
|
||||
}
|
||||
self.stash(m, &mut others);
|
||||
if self.shutdown {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
// stdin closed: the broker stops us.
|
||||
self.shutdown = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({"id": full, "reply": reply, "waited": wait > 0.0, "other_messages": others})
|
||||
}
|
||||
}
|
||||
|
||||
/// A text message that is not a reply starts a turn. Replies never do, so
|
||||
/// two actors cannot keep the agent in an endless conversation.
|
||||
fn starts_turn(m: &Value) -> bool {
|
||||
m["body"]["type"] == "text" && m.get("reply_to").is_none_or(Value::is_null)
|
||||
}
|
||||
|
||||
fn wstd_now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
@@ -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), "");
|
||||
}
|
||||
}
|
||||
+79
-9
@@ -1,10 +1,80 @@
|
||||
You are the Sausage agent. Answer the person. Use tools to inspect or change the world.
|
||||
You are the agent of a Sausage world. Your actor ID is `agent`.
|
||||
|
||||
Read the contract at `/proc/core/PROTOCOL.md`. The Rust plugin SDK and its documentation are at `/plugins/cargo/sdk/`.
|
||||
Build a plugin with `cargo.build`. Activate its WASM file with `core.activate`.
|
||||
Your own source is at `/plugins/agent/source/`. You can change and rebuild it like any other plugin.
|
||||
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.
|
||||
Sausage is a system of actors. Each actor is a WASI program (a `.wasm` file). Actors talk only through messages. People talk to you through other actors, for example `console` (a terminal) or later `matrix`. You help them by using actors, and by writing, testing, and upgrading actors. You can also change your own code.
|
||||
|
||||
# Messages
|
||||
|
||||
Every message has a sender (`from`), an ID, an optional `reply_to` (the ID of the message it answers), and a JSON `body`. A body is an object with a `"type"` field. There is no request/response: a reply is a new message with `reply_to` set. Delivery is at most once.
|
||||
|
||||
A WASM actor reads one JSON object per line on stdin:
|
||||
`{"id":"m-17","from":"console","body":{...}}`
|
||||
and writes one JSON object per line on stdout:
|
||||
`{"to":"console","reply_to":"m-17","body":{...}}`
|
||||
Without `"to"`, the broker publishes the message to the subscribers of the sender. An actor may set its own `"id"`. The broker then uses `"<sender>/<id>"`. stdout is only for messages. Logs go to stderr. The broker sends `{"type":"shutdown"}` before it stops an actor. EOF on stdin means "stop now".
|
||||
|
||||
# Filesystem
|
||||
|
||||
Each actor sees:
|
||||
- `/data`: private state, read-write. Only the actor itself sees it.
|
||||
- `/public`: its public files, read-write.
|
||||
- `/actor/<id>/public`: the public files of every actor, read-only.
|
||||
- `/actor/<id>/source`: the source of the running version of every actor, read-only.
|
||||
- `/actor/<id>/status.json` and `/actor/<id>/stderr.log`: state and stderr log of every actor.
|
||||
|
||||
`ls /actor` lists all actors. A write becomes visible to other actors only when the writer closes the file. Send large or binary data as a file path, not in a message.
|
||||
|
||||
# Built-in actors
|
||||
|
||||
`broker` (only accepts `.wasm` files, knows no source language):
|
||||
- `{"type":"spawn","actor":"<new-id>","wasm":"<path>","source":"<folder>","built_by":"cargo"}`
|
||||
- `{"type":"upgrade","actor":"<id>","wasm":"<path>","source":"<folder>","built_by":"cargo"}`
|
||||
- `{"type":"rollback","actor":"<id>"}` (optional `"version"`)
|
||||
- `{"type":"stop","actor":"<id>"}`, `{"type":"start","actor":"<id>"}`, `{"type":"remove","actor":"<id>"}` (archives, never deletes)
|
||||
- `{"type":"subscribe","actor":"<id>"}`, `{"type":"unsubscribe","actor":"<id>"}`, `{"type":"list"}`
|
||||
|
||||
Before an upgrade or spawn, the broker tests the candidate on a copy of the actor's `/data` and `/public`, with no HTTP access. The tests are message transcripts in `<source>/tests/*.jsonl`. Each line is `{"send": <body>}` or `{"expect": <subset of the reply body>}` (optional `"to"`). Without test files, the candidate must start and stay up for 2 seconds. If a new version crashes 3 times within 30 seconds, the broker rolls it back automatically. The broker keeps the message that crashed an actor and delivers it again after the next upgrade.
|
||||
|
||||
`cargo` (built-in Rust compiler):
|
||||
- `{"type":"build","source":"<folder with Cargo.toml>"}` → `{"type":"built","wasm":"/actor/cargo/public/builds/<n>/<name>.wasm",...}` or `{"type":"build_failed","errors":"..."}`. A build can take a minute. Wait up to 600 seconds for the reply.
|
||||
|
||||
# Writing an actor in Rust
|
||||
|
||||
1. Write the crate to `/public/drafts/<name>/`: `Cargo.toml`, `src/main.rs`, and `tests/*.jsonl`. Use `edition = "2024"`. Use `serde_json` for messages.
|
||||
2. Send `build` to `cargo` with the folder path `/actor/agent/public/drafts/<name>` and wait for the reply. Fix compiler errors and build again.
|
||||
3. Send `spawn` (new actor) or `upgrade` (existing actor) to `broker` with the `wasm` path from the build reply and the same `source` folder. Wait for the reply.
|
||||
4. Send a message to the new actor to check it.
|
||||
|
||||
A simple actor that only reacts to messages can block on stdin:
|
||||
|
||||
```rust
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
fn main() {
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
let body = &msg["body"];
|
||||
let reply = match body["type"].as_str() {
|
||||
Some("shutdown") => break,
|
||||
Some("ping") => json!({"type": "pong"}),
|
||||
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||
};
|
||||
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
An actor that needs HTTP or timers and must also read messages uses the async `wstd` crate (`wstd = "0.6"`): `#[wstd::main] async fn main()`, `wstd::io::stdin()` (`AsyncRead`), `wstd::io::stdout()` (`AsyncWrite`, `write_all`, `flush`), `wstd::http::{Client, Request, Body}` (`Client::new().send(Request::get(url).body(Body::empty())?).await?`, then `response.into_body().contents().await?`), `wstd::task::sleep(wstd::time::Duration::from_secs(n)).await`, and `wstd::runtime::spawn(future)`. Actors have outgoing HTTP and HTTPS only, no raw sockets. Threads are not available. Store state in `/data` so it survives restarts and upgrades. A new version must read the data format of the old version.
|
||||
|
||||
# Changing yourself
|
||||
|
||||
Your own source is at `/actor/agent/source`. To change yourself, copy it to `/public/drafts/agent`, edit the copy, build it, and upgrade `agent`. The upgrade restarts you, and the conversation continues from `/data`. Be careful: if the new version cannot answer, the person must roll you back through the console. Change yourself only when the person asks for it.
|
||||
|
||||
# How to work
|
||||
|
||||
- Use tools to look at the real state before you claim anything. Read files instead of guessing.
|
||||
- Keep answers short and concrete. Say what you changed and whether it worked.
|
||||
- Your final text in a turn goes back to the person who wrote to you.
|
||||
|
||||
@@ -1,268 +0,0 @@
|
||||
//! Tool descriptions and file operations for the agent.
|
||||
|
||||
use crate::llm::Tool;
|
||||
use serde_json::{Value, json};
|
||||
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> {
|
||||
vec![
|
||||
Tool {
|
||||
name: "call",
|
||||
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.",
|
||||
parameters: schema(
|
||||
json!({
|
||||
"method": {"type": "string", "description": "Qualified plugin method"},
|
||||
"params": {"type": "object", "description": "Named method parameters"},
|
||||
"detach": {"type": "boolean", "description": "Detach the new process. Default false."}
|
||||
}),
|
||||
&["method", "params"],
|
||||
),
|
||||
},
|
||||
Tool {
|
||||
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.",
|
||||
parameters: schema(
|
||||
json!({
|
||||
"path": {"type": "string"},
|
||||
"offset": {"type": "integer", "description": "Byte offset (default 0)"},
|
||||
"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"],
|
||||
),
|
||||
},
|
||||
Tool {
|
||||
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.",
|
||||
parameters: schema(
|
||||
json!({"path": {"type": "string"}, "content": {"type": "string"}}),
|
||||
&["path", "content"],
|
||||
),
|
||||
},
|
||||
Tool {
|
||||
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.",
|
||||
parameters: schema(json!({"path": {"type": "string"}}), &["path"]),
|
||||
},
|
||||
Tool {
|
||||
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.",
|
||||
parameters: schema(
|
||||
json!({"from": {"type": "string"}, "to": {"type": "string"}}),
|
||||
&["from", "to"],
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// Create the named-parameter object required by a tool.
|
||||
fn schema(properties: Value, required: &[&str]) -> Value {
|
||||
json!({"type": "object", "properties": properties, "required": required})
|
||||
}
|
||||
|
||||
/// Execute a tool. The chat loop returns errors to the model as tool results.
|
||||
pub async fn run(name: &str, args: &Value) -> sausage_plugin::Result<Value> {
|
||||
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),
|
||||
"write_file" => write_file(args),
|
||||
"list_dir" => list_dir(args),
|
||||
"copy_dir" => copy_dir(args),
|
||||
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> {
|
||||
let path = args["path"].as_str().unwrap_or_default();
|
||||
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 end = args["max_bytes"].as_u64().map_or(bytes.len(), |max| {
|
||||
offset.saturating_add(max as usize).min(bytes.len())
|
||||
});
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"size": bytes.len(),
|
||||
"offset": offset,
|
||||
"content": String::from_utf8_lossy(&bytes[offset..end]),
|
||||
"truncated": end < bytes.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Create parent directories and replace a text file.
|
||||
fn write_file(args: &Value) -> io::Result<Value> {
|
||||
let path = args["path"].as_str().unwrap_or_default();
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
std::fs::write(path, args["content"].as_str().unwrap_or_default())?;
|
||||
Ok(json!({"written": path}))
|
||||
}
|
||||
|
||||
/// Return directory metadata in name order.
|
||||
fn list_dir(args: &Value) -> io::Result<Value> {
|
||||
let mut entries: Vec<Value> = std::fs::read_dir(args["path"].as_str().unwrap_or_default())?
|
||||
.filter_map(|entry| entry.ok())
|
||||
.map(|entry| {
|
||||
let metadata = entry.metadata().ok();
|
||||
json!({
|
||||
"name": entry.file_name().to_string_lossy(),
|
||||
"type": if metadata.as_ref().is_some_and(|meta| meta.is_dir()) { "dir" } else { "file" },
|
||||
"size": metadata.map(|meta| meta.len()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
entries.sort_by(|left, right| left["name"].as_str().cmp(&right["name"].as_str()));
|
||||
Ok(json!({"entries": entries}))
|
||||
}
|
||||
|
||||
/// Copy a tree and report its file count.
|
||||
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(
|
||||
args["from"].as_str().unwrap_or_default(),
|
||||
args["to"].as_str().unwrap_or_default(),
|
||||
)?;
|
||||
Ok(json!({"copied_files": count}))
|
||||
}
|
||||
|
||||
/// Copy files recursively without build artifacts in target directories.
|
||||
fn copy_tree(from: &str, to: &str) -> io::Result<u64> {
|
||||
std::fs::create_dir_all(to)?;
|
||||
let mut count = 0;
|
||||
for entry in std::fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let src = format!("{from}/{name}");
|
||||
let dst = format!("{to}/{name}");
|
||||
if entry.file_type()?.is_dir() {
|
||||
if name != "target" {
|
||||
count += copy_tree(&src, &dst)?;
|
||||
}
|
||||
} else {
|
||||
std::fs::copy(src, dst)?;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// The agent answers a text message with text, even without config or network.
|
||||
{"send": {"type": "text", "text": "Hello"}}
|
||||
{"expect": {"type": "text"}}
|
||||
+2
-23
@@ -2,7 +2,7 @@
|
||||
name = "sausage"
|
||||
version = "0.1.0"
|
||||
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]]
|
||||
name = "sausage"
|
||||
@@ -11,34 +11,13 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
bytes = "1"
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
http = "1"
|
||||
http-body-util = "0.1"
|
||||
include_dir = "0.7"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
schemars = "1"
|
||||
sausage-plugin = { path = "../sdk" }
|
||||
serde_yaml_ng = "0.10"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
toml_edit = "0.25"
|
||||
wasmtime = "49"
|
||||
wasmtime-wasi = "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" }
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
//! Serialized candidate-copy activation and revert under PROTOCOL.md section 10.3.
|
||||
use crate::{
|
||||
Host, RpcResult,
|
||||
core::{ActivationSummary, InterfaceCheck},
|
||||
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, ¶ms, &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(©, &bytes).await.map_err(io_error)?;
|
||||
self.0
|
||||
.journal
|
||||
.write("core", plugin, "core", "activation copied candidate");
|
||||
let discovery = self.discover_copy(plugin, ©, 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(©, bytes).await.is_ok()
|
||||
&& let Ok(interface) =
|
||||
self.discover_copy(&candidate.plugin, ©, 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(¤t).await.map_err(io_error)? {
|
||||
let backup = candidate.copy.with_extension("previous");
|
||||
tokio::fs::hard_link(¤t, &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, ¤t)
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
}
|
||||
let mut upgrades = Vec::new();
|
||||
let mut summaries = BTreeMap::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))
|
||||
.cloned()
|
||||
.collect();
|
||||
let removed: Vec<_> = before
|
||||
.keys()
|
||||
.filter(|name| !after.contains_key(*name))
|
||||
.cloned()
|
||||
.collect();
|
||||
let changed: Vec<_> = after
|
||||
.iter()
|
||||
.filter(|(name, method)| before.get(*name).is_some_and(|old| old != *method))
|
||||
.map(|(name, _)| name.clone())
|
||||
.collect();
|
||||
summaries.insert(
|
||||
candidate.plugin.clone(),
|
||||
ActivationSummary {
|
||||
plugin: candidate.plugin.clone(),
|
||||
wasm: format!("/plugins/{}/plugin.wasm", candidate.plugin),
|
||||
hash: candidate.interface.hash.clone(),
|
||||
previous: old
|
||||
.map(|old| old.hash.clone())
|
||||
.filter(|hash| !hash.is_empty()),
|
||||
method_count: candidate.interface.document["methods"]
|
||||
.as_array()
|
||||
.map_or(0, Vec::len),
|
||||
added,
|
||||
removed,
|
||||
changed,
|
||||
interface_check: if checked {
|
||||
InterfaceCheck::Passed
|
||||
} else {
|
||||
InterfaceCheck::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(json!(summaries))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
[]
|
||||
@@ -0,0 +1,856 @@
|
||||
//! The broker: message routing, mailboxes, subscriptions, and the lifecycle of
|
||||
//! WASM actors (spawn, upgrade, rollback, stop, start, remove).
|
||||
//!
|
||||
//! The broker accepts only `.wasm` files. It does not know any source language.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
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::fs::Mount;
|
||||
use crate::runtime::{Exit, Instance, InstanceConfig, Runtime};
|
||||
use crate::world::{self, ActorRecord, NATIVE, Registry, VersionMeta, World};
|
||||
|
||||
pub const MAILBOX_SIZE: usize = 1000;
|
||||
pub const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(5);
|
||||
/// A new version that crashes this often within `CRASH_WINDOW` after a swap
|
||||
/// is rolled back automatically.
|
||||
const CRASH_LIMIT: usize = 3;
|
||||
const CRASH_WINDOW: Duration = Duration::from_secs(30);
|
||||
const STDIN_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[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()
|
||||
}
|
||||
|
||||
enum Control {
|
||||
Upgrade { version: u64, requester: String, reply: oneshot::Sender<Result<Value>> },
|
||||
Rollback { target: Option<u64>, reply: oneshot::Sender<Result<Value>> },
|
||||
Stop { reply: oneshot::Sender<Result<Value>> },
|
||||
Start { reply: oneshot::Sender<Result<Value>> },
|
||||
Remove { reply: oneshot::Sender<Result<Value>> },
|
||||
Shutdown { reply: oneshot::Sender<Result<Value>> },
|
||||
}
|
||||
|
||||
pub struct Broker {
|
||||
pub world: World,
|
||||
pub runtime: Arc<Runtime>,
|
||||
mailboxes: Mutex<HashMap<String, mpsc::Sender<Envelope>>>,
|
||||
controls: Mutex<HashMap<String, mpsc::Sender<Control>>>,
|
||||
registry: Mutex<Registry>,
|
||||
op_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
seq: AtomicU64,
|
||||
}
|
||||
|
||||
impl Broker {
|
||||
pub fn new(world: World, runtime: Arc<Runtime>) -> Result<Arc<Self>> {
|
||||
let registry = world.load_registry()?;
|
||||
Ok(Arc::new(Self {
|
||||
world,
|
||||
runtime,
|
||||
mailboxes: Mutex::new(HashMap::new()),
|
||||
controls: Mutex::new(HashMap::new()),
|
||||
registry: Mutex::new(registry),
|
||||
op_locks: Mutex::new(HashMap::new()),
|
||||
seq: AtomicU64::new(1),
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn registry(&self) -> Registry {
|
||||
self.registry.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn update_registry(&self, f: impl FnOnce(&mut Registry)) -> Result<()> {
|
||||
let mut reg = self.registry.lock().unwrap();
|
||||
f(&mut reg);
|
||||
self.world.save_registry(®)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
fn next_id(&self) -> String {
|
||||
format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Send a message from a native actor. `to: None` publishes to the
|
||||
/// subscribers of `from`. Returns the message ID.
|
||||
pub fn send(&self, from: &str, to: Option<&str>, id: Option<&str>, reply_to: Option<&str>, body: Value) -> String {
|
||||
let id = match id {
|
||||
Some(own) => format!("{from}/{own}"),
|
||||
None => self.next_id(),
|
||||
};
|
||||
let recipients: Vec<String> = match to {
|
||||
Some(to) => vec![to.to_string()],
|
||||
None => self
|
||||
.registry
|
||||
.lock()
|
||||
.unwrap()
|
||||
.subscriptions
|
||||
.get(from)
|
||||
.map(|s| s.iter().cloned().collect())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
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
|
||||
}
|
||||
|
||||
/// 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 parsed: Option<Value> = serde_json::from_str(line).ok();
|
||||
let Some(Value::Object(obj)) = parsed 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 to = obj.get("to").and_then(Value::as_str);
|
||||
let own_id = obj.get("id").and_then(|v| match v {
|
||||
Value::String(s) => Some(s.clone()),
|
||||
Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
});
|
||||
let reply_to = obj.get("reply_to").and_then(Value::as_str);
|
||||
self.send(from, to, own_id.as_deref(), reply_to, body);
|
||||
}
|
||||
|
||||
fn deliver(&self, env: Envelope) {
|
||||
let tx = self.mailboxes.lock().unwrap().get(&env.to).cloned();
|
||||
let error = match tx {
|
||||
None => Some(format!("no actor with ID {}", env.to)),
|
||||
Some(tx) => match tx.try_send(env.clone()) {
|
||||
Ok(()) => None,
|
||||
Err(mpsc::error::TrySendError::Full(_)) => Some(format!("mailbox of {} is full", env.to)),
|
||||
Err(mpsc::error::TrySendError::Closed(_)) => Some(format!("actor {} is gone", env.to)),
|
||||
},
|
||||
};
|
||||
if let Some(reason) = error
|
||||
&& env.from != "broker"
|
||||
&& env.from != "test"
|
||||
{
|
||||
self.send(
|
||||
"broker",
|
||||
Some(&env.from),
|
||||
None,
|
||||
Some(&env.id),
|
||||
json!({"type": "undeliverable", "reason": reason, "to": env.to}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Mounts of a running actor.
|
||||
pub fn mounts(&self, id: &str) -> Vec<Mount> {
|
||||
vec![
|
||||
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false },
|
||||
Mount { guest: "/public".into(), host: self.world.public_dir(id), writable: true },
|
||||
Mount { guest: "/data".into(), host: self.world.data_dir(id), writable: true },
|
||||
]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/// Start the supervisors of all registered WASM actors.
|
||||
pub fn start_all(self: &Arc<Self>) {
|
||||
let reg = self.registry();
|
||||
for (id, rec) in reg.actors {
|
||||
self.start_supervisor(&id, rec.version, rec.running);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
let sv = Supervisor::new(self.clone(), id.to_string(), version, running, mailbox, ctl_rx);
|
||||
tokio::spawn(sv.run());
|
||||
}
|
||||
|
||||
async fn control(&self, id: &str, make: impl FnOnce(oneshot::Sender<Result<Value>>) -> 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 (host shutdown). The registry keeps their
|
||||
/// `running` flags, so the next `sausage run` starts them again.
|
||||
pub async fn shutdown_all(&self) {
|
||||
let ids: Vec<String> = self.controls.lock().unwrap().keys().cloned().collect();
|
||||
let mut waits = Vec::new();
|
||||
for id in ids {
|
||||
waits.push(async move {
|
||||
let _ = self.control(&id, |reply| Control::Shutdown { reply }).await;
|
||||
});
|
||||
}
|
||||
futures_join_all(waits).await;
|
||||
}
|
||||
|
||||
/// Store a new version: copy the `.wasm` file and the source folder into
|
||||
/// the version store. Returns the version directory and the component.
|
||||
fn store_version(&self, id: &str, version: u64, wasm: &Path, source: Option<&Path>, meta: &VersionMeta) -> Result<(PathBuf, Component)> {
|
||||
let dir = self.world.version_dir(id, version);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::copy(wasm, dir.join("actor.wasm")).with_context(|| format!("copy {}", wasm.display()))?;
|
||||
match source {
|
||||
Some(src) => world::copy_tree(src, &dir.join("source"))?,
|
||||
None => std::fs::create_dir_all(dir.join("source"))?,
|
||||
}
|
||||
world::write_atomic(&dir.join("meta.json"), serde_json::to_string_pretty(meta)?.as_bytes())?;
|
||||
let component = self.runtime.load(&dir.join("actor.wasm"))?;
|
||||
Ok((dir, component))
|
||||
}
|
||||
|
||||
/// Create a new actor from a `.wasm` file. With `start`, also start its
|
||||
/// supervisor (in a running host). Without `start` (during `init`), only
|
||||
/// register it.
|
||||
pub async fn create_actor(
|
||||
self: &Arc<Self>,
|
||||
id: &str,
|
||||
wasm: &Path,
|
||||
source: Option<&Path>,
|
||||
meta: VersionMeta,
|
||||
start: bool,
|
||||
) -> Result<Value> {
|
||||
if !world::valid_id(id) {
|
||||
bail!("invalid actor ID {id:?}: use a-z, 0-9, '-' and '_'");
|
||||
}
|
||||
if NATIVE.contains(&id) || self.registry().actors.contains_key(id) {
|
||||
bail!("an actor with ID {id} already exists");
|
||||
}
|
||||
let lock = self.op_lock(id);
|
||||
let _guard = lock.lock().await;
|
||||
let (dir, component) = self.store_version(id, 1, wasm, source, &meta)?;
|
||||
let report = candidate::test(self, id, &component, &dir.join("source"), None, None).await;
|
||||
let report = match report {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_dir_all(self.world.versions_dir(id));
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
self.world.ensure_actor_dirs(id)?;
|
||||
world::replace_tree(&dir.join("source"), &self.world.source_dir(id))?;
|
||||
self.update_registry(|r| {
|
||||
r.actors.insert(id.to_string(), ActorRecord { version: 1, next_version: 2, running: true });
|
||||
})?;
|
||||
write_status(&self.world, id, &json!({"id": id, "version": 1, "state": "created"}));
|
||||
if start {
|
||||
self.start_supervisor(id, 1, true);
|
||||
}
|
||||
Ok(json!({"type": "spawned", "actor": id, "version": 1, "tests": report}))
|
||||
}
|
||||
|
||||
pub async fn upgrade_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, mut meta: VersionMeta) -> Result<Value> {
|
||||
let lock = self.op_lock(id);
|
||||
let _guard = lock.lock().await;
|
||||
let rec = self.registry().actors.get(id).cloned().ok_or_else(|| anyhow!("no WASM actor with ID {id}"))?;
|
||||
let version = rec.next_version;
|
||||
self.update_registry(|r| {
|
||||
if let Some(a) = r.actors.get_mut(id) {
|
||||
a.next_version = version + 1;
|
||||
}
|
||||
})?;
|
||||
meta.version = version;
|
||||
let (dir, component) = self.store_version(id, version, wasm, source, &meta)?;
|
||||
let report = candidate::test(
|
||||
self,
|
||||
id,
|
||||
&component,
|
||||
&dir.join("source"),
|
||||
Some(&self.world.data_dir(id)),
|
||||
Some(&self.world.public_dir(id)),
|
||||
)
|
||||
.await;
|
||||
if let Err(e) = report {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return Err(e);
|
||||
}
|
||||
let mut result = self
|
||||
.control(id, |reply| Control::Upgrade { version, requester: meta.requested_by.clone(), reply })
|
||||
.await?;
|
||||
result["tests"] = report.unwrap();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Handle one message to `broker`.
|
||||
async fn handle(self: Arc<Self>, env: Envelope) {
|
||||
let result = self.clone().command(&env).await;
|
||||
let body = match result {
|
||||
Ok(v) => v,
|
||||
Err(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.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let actor = || -> Result<String> {
|
||||
b.get("actor").and_then(Value::as_str).map(str::to_string).ok_or_else(|| anyhow!("missing \"actor\""))
|
||||
};
|
||||
let meta = |version: u64| -> Result<(PathBuf, Option<PathBuf>, VersionMeta)> {
|
||||
let wasm_s = b.get("wasm").and_then(Value::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.get("source").and_then(Value::as_str);
|
||||
let source = source_s.map(|s| self.world.resolve(&env.from, s)).transpose()?;
|
||||
if let Some(s) = &source
|
||||
&& !s.is_dir()
|
||||
{
|
||||
bail!("{} is not a folder", source_s.unwrap());
|
||||
}
|
||||
let m = VersionMeta {
|
||||
version,
|
||||
wasm: wasm_s.to_string(),
|
||||
source: source_s.map(str::to_string),
|
||||
built_by: b.get("built_by").and_then(Value::as_str).map(str::to_string),
|
||||
requested_by: env.from.clone(),
|
||||
created_at: world::now(),
|
||||
};
|
||||
Ok((wasm, source, m))
|
||||
};
|
||||
match ty {
|
||||
"spawn" => {
|
||||
let id = actor()?;
|
||||
let (wasm, source, m) = meta(1)?;
|
||||
self.create_actor(&id, &wasm, source.as_deref(), m, true).await
|
||||
}
|
||||
"upgrade" => {
|
||||
let id = actor()?;
|
||||
let (wasm, source, m) = meta(0)?;
|
||||
self.upgrade_actor(&id, &wasm, source.as_deref(), m).await
|
||||
}
|
||||
"rollback" => {
|
||||
let id = actor()?;
|
||||
let target = b.get("version").and_then(Value::as_u64);
|
||||
let lock = self.op_lock(&id);
|
||||
let _g = lock.lock().await;
|
||||
self.control(&id, |reply| Control::Rollback { target, reply }).await
|
||||
}
|
||||
"stop" => self.control(&actor()?, |reply| Control::Stop { reply }).await,
|
||||
"start" => self.control(&actor()?, |reply| Control::Start { reply }).await,
|
||||
"remove" => {
|
||||
let id = actor()?;
|
||||
let lock = self.op_lock(&id);
|
||||
let _g = lock.lock().await;
|
||||
let r = self.control(&id, |reply| Control::Remove { reply }).await?;
|
||||
self.mailboxes.lock().unwrap().remove(&id);
|
||||
self.controls.lock().unwrap().remove(&id);
|
||||
self.update_registry(|reg| {
|
||||
reg.actors.remove(&id);
|
||||
reg.subscriptions.remove(&id);
|
||||
for subs in reg.subscriptions.values_mut() {
|
||||
subs.remove(&id);
|
||||
}
|
||||
})?;
|
||||
Ok(r)
|
||||
}
|
||||
"subscribe" | "unsubscribe" => {
|
||||
let publisher = actor()?;
|
||||
let sub = env.from.clone();
|
||||
let on = ty == "subscribe";
|
||||
self.update_registry(|reg| {
|
||||
let set = reg.subscriptions.entry(publisher.clone()).or_insert_with(BTreeSet::new);
|
||||
if on {
|
||||
set.insert(sub.clone());
|
||||
} else {
|
||||
set.remove(&sub);
|
||||
}
|
||||
})?;
|
||||
Ok(json!({"type": format!("{ty}d"), "actor": publisher}))
|
||||
}
|
||||
"list" => {
|
||||
let reg = self.registry();
|
||||
let mut actors: Vec<Value> = NATIVE.iter().map(|id| json!({"id": id, "kind": "native"})).collect();
|
||||
for (id, rec) in reg.actors {
|
||||
let status = std::fs::read_to_string(self.world.actor_dir(&id).join("status.json"))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str::<Value>(&s).ok());
|
||||
actors.push(json!({
|
||||
"id": id, "kind": "wasm", "version": rec.version, "running": rec.running,
|
||||
"state": status.as_ref().and_then(|s| s.get("state")).cloned(),
|
||||
}));
|
||||
}
|
||||
Ok(json!({"type": "actors", "actors": actors, "subscriptions": reg.subscriptions}))
|
||||
}
|
||||
"shutdown" => Ok(json!({"type": "ignored"})),
|
||||
other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, remove, subscribe, unsubscribe, list"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The native `broker` actor.
|
||||
pub async fn run_actor(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||
while let Some(env) = mailbox.recv().await {
|
||||
tokio::spawn(self.clone().handle(env));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn futures_join_all<F: Future<Output = ()>>(futs: Vec<F>) {
|
||||
let mut set = Vec::new();
|
||||
for f in futs {
|
||||
set.push(Box::pin(f));
|
||||
}
|
||||
for f in set {
|
||||
f.await;
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
fn write_status(world: &World, id: &str, status: &Value) {
|
||||
let mut s = status.clone();
|
||||
s["updated_at"] = json!(world::now());
|
||||
let _ = world::write_atomic(
|
||||
&world.actor_dir(id).join("status.json"),
|
||||
serde_json::to_string_pretty(&s).unwrap_or_default().as_bytes(),
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Supervisor: owns one WASM actor and its instance.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
struct Supervisor {
|
||||
broker: Arc<Broker>,
|
||||
id: String,
|
||||
version: u64,
|
||||
desired_running: bool,
|
||||
mailbox: mpsc::Receiver<Envelope>,
|
||||
control: mpsc::Receiver<Control>,
|
||||
component: Option<Component>,
|
||||
instance: Option<Instance>,
|
||||
state: &'static str,
|
||||
started_at: Instant,
|
||||
restarts: u32,
|
||||
restart_at: Option<Instant>,
|
||||
last_exit: Option<String>,
|
||||
last_delivered: Option<Envelope>,
|
||||
/// The message that the actor got just before it crashed. The broker
|
||||
/// delivers it again after the next upgrade.
|
||||
parked: Option<Envelope>,
|
||||
redeliver: Option<Envelope>,
|
||||
previous: Option<u64>,
|
||||
swapped_at: Option<Instant>,
|
||||
crashes_since_swap: usize,
|
||||
upgrade_requester: Option<String>,
|
||||
}
|
||||
|
||||
async fn recv_line(instance: &mut Option<Instance>) -> Option<String> {
|
||||
match instance {
|
||||
Some(i) => i.stdout.recv().await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep_until_opt(t: Option<Instant>) {
|
||||
match t {
|
||||
Some(t) => tokio::time::sleep_until(t.into()).await,
|
||||
None => std::future::pending().await,
|
||||
}
|
||||
}
|
||||
|
||||
impl Supervisor {
|
||||
fn new(
|
||||
broker: Arc<Broker>,
|
||||
id: String,
|
||||
version: u64,
|
||||
running: bool,
|
||||
mailbox: mpsc::Receiver<Envelope>,
|
||||
control: mpsc::Receiver<Control>,
|
||||
) -> Self {
|
||||
Self {
|
||||
broker,
|
||||
id,
|
||||
version,
|
||||
desired_running: running,
|
||||
mailbox,
|
||||
control,
|
||||
component: None,
|
||||
instance: None,
|
||||
state: if running { "starting" } else { "stopped" },
|
||||
started_at: Instant::now(),
|
||||
restarts: 0,
|
||||
restart_at: None,
|
||||
last_exit: None,
|
||||
last_delivered: None,
|
||||
parked: None,
|
||||
redeliver: None,
|
||||
previous: None,
|
||||
swapped_at: None,
|
||||
crashes_since_swap: 0,
|
||||
upgrade_requester: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn world(&self) -> &World {
|
||||
&self.broker.world
|
||||
}
|
||||
|
||||
fn status(&self) {
|
||||
write_status(
|
||||
self.world(),
|
||||
&self.id,
|
||||
&json!({
|
||||
"id": self.id,
|
||||
"version": self.version,
|
||||
"state": self.state,
|
||||
"restarts": self.restarts,
|
||||
"last_exit": self.last_exit,
|
||||
"parked_message": self.parked.as_ref().map(|m| json!({"id": m.id, "from": m.from, "body": m.body})),
|
||||
"previous_version": self.previous,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async fn run(mut self) {
|
||||
self.status();
|
||||
loop {
|
||||
if self.desired_running && self.instance.is_none() && self.restart_at.is_none_or(|t| Instant::now() >= t) {
|
||||
self.restart_at = None;
|
||||
self.start_instance().await;
|
||||
}
|
||||
let has_instance = self.instance.is_some();
|
||||
let waiting = !has_instance && self.desired_running;
|
||||
tokio::select! {
|
||||
ctl = self.control.recv() => {
|
||||
let Some(ctl) = ctl else { break };
|
||||
if self.handle_control(ctl).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
msg = self.mailbox.recv(), if has_instance => {
|
||||
let Some(msg) = msg else { break };
|
||||
self.deliver(msg).await;
|
||||
}
|
||||
line = recv_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_opt(self.restart_at), if waiting => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn deliver(&mut self, msg: Envelope) {
|
||||
let Some(inst) = self.instance.as_mut() else { return };
|
||||
let line = msg.to_line();
|
||||
match tokio::time::timeout(STDIN_WRITE_TIMEOUT, inst.send_line(&line)).await {
|
||||
Ok(Ok(())) => self.last_delivered = Some(msg),
|
||||
Ok(Err(_)) => {} // The instance is ending. The exit path handles it.
|
||||
Err(_) => {
|
||||
let note = format!("actor did not read stdin for {STDIN_WRITE_TIMEOUT:?}; message {} dropped", msg.id);
|
||||
self.broker.log(&self.id, ¬e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn start_instance(&mut self) {
|
||||
if self.component.is_none() {
|
||||
let path = self.world().version_dir(&self.id, self.version).join("actor.wasm");
|
||||
let t = Instant::now();
|
||||
match self.broker.runtime.load(&path) {
|
||||
Ok(c) => {
|
||||
self.broker.log(&self.id, &format!("compiled version {} in {} ms", self.version, t.elapsed().as_millis()));
|
||||
self.component = Some(c);
|
||||
}
|
||||
Err(e) => {
|
||||
self.broker.log(&self.id, &format!("cannot load version {}: {e:#}", self.version));
|
||||
self.state = "broken";
|
||||
self.desired_running = false;
|
||||
self.status();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let cfg = InstanceConfig {
|
||||
mounts: self.broker.mounts(&self.id),
|
||||
allow_http: true,
|
||||
stderr_log: self.world().actor_dir(&self.id).join("stderr.log"),
|
||||
env: vec![
|
||||
("SAUSAGE_ACTOR".into(), self.id.clone()),
|
||||
("SAUSAGE_VERSION".into(), self.version.to_string()),
|
||||
],
|
||||
};
|
||||
match self.broker.runtime.start(self.component.as_ref().unwrap(), cfg) {
|
||||
Ok(inst) => {
|
||||
self.instance = Some(inst);
|
||||
self.state = "running";
|
||||
self.started_at = Instant::now();
|
||||
self.status();
|
||||
if let Some(msg) = self.redeliver.take() {
|
||||
self.broker.log(&self.id, &format!("delivering parked message {} again", msg.id));
|
||||
self.deliver(msg).await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.broker.log(&self.id, &format!("cannot start: {e:#}"));
|
||||
self.state = "broken";
|
||||
self.desired_running = false;
|
||||
self.status();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_instance(&mut self) -> Option<Exit> {
|
||||
let inst = self.instance.take()?;
|
||||
let exit = inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
|
||||
self.broker.log(&self.id, &format!("stopped: {exit}"));
|
||||
self.last_exit = Some(exit.to_string());
|
||||
Some(exit)
|
||||
}
|
||||
|
||||
async fn on_exit(&mut self, exit: Exit) {
|
||||
self.broker.log(&self.id, &format!("instance ended unexpectedly: {exit}"));
|
||||
self.last_exit = Some(exit.to_string());
|
||||
if !exit.is_clean()
|
||||
&& let Some(m) = self.last_delivered.take()
|
||||
{
|
||||
self.parked = Some(m);
|
||||
}
|
||||
if self.started_at.elapsed() > Duration::from_secs(60) {
|
||||
self.restarts = 0;
|
||||
}
|
||||
if let (Some(prev), Some(at)) = (self.previous, self.swapped_at)
|
||||
&& at.elapsed() < CRASH_WINDOW
|
||||
{
|
||||
self.crashes_since_swap += 1;
|
||||
if self.crashes_since_swap >= CRASH_LIMIT {
|
||||
let from = self.version;
|
||||
self.broker.log(&self.id, &format!("version {from} crashed {CRASH_LIMIT} times after the upgrade; rolling back to {prev}"));
|
||||
let result = self.rollback(Some(prev)).await;
|
||||
let body = match result {
|
||||
Ok(_) => json!({"type": "rolled_back", "actor": self.id, "from": from, "to": prev, "reason": "crash loop after upgrade", "last_exit": exit.to_string()}),
|
||||
Err(e) => json!({"type": "error", "actor": self.id, "reason": format!("automatic rollback failed: {e:#}")}),
|
||||
};
|
||||
if let Some(r) = self.upgrade_requester.clone() {
|
||||
self.broker.send("broker", Some(&r), None, None, body);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
let delay = Duration::from_millis(500 * 2u64.pow(self.restarts.min(6))).min(Duration::from_secs(30));
|
||||
self.restarts += 1;
|
||||
self.restart_at = Some(Instant::now() + delay);
|
||||
self.state = "restarting";
|
||||
self.status();
|
||||
}
|
||||
|
||||
fn snapshot_current(&self) -> Result<()> {
|
||||
let snap = self.world().version_dir(&self.id, self.version).join("snapshot");
|
||||
world::replace_tree(&self.world().data_dir(&self.id), &snap.join("data"))?;
|
||||
world::replace_tree(&self.world().public_dir(&self.id), &snap.join("public"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn install(&mut self, version: u64) -> Result<()> {
|
||||
let src = self.world().version_dir(&self.id, version).join("source");
|
||||
world::replace_tree(&src, &self.world().source_dir(&self.id))?;
|
||||
self.version = version;
|
||||
self.component = None;
|
||||
let id = self.id.clone();
|
||||
self.broker.update_registry(|r| {
|
||||
if let Some(a) = r.actors.get_mut(&id) {
|
||||
a.version = version;
|
||||
a.running = true;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn upgrade(&mut self, version: u64, requester: String) -> Result<Value> {
|
||||
self.stop_instance().await;
|
||||
self.snapshot_current()?;
|
||||
let previous = self.version;
|
||||
self.install(version)?;
|
||||
self.previous = Some(previous);
|
||||
self.swapped_at = Some(Instant::now());
|
||||
self.crashes_since_swap = 0;
|
||||
self.restarts = 0;
|
||||
self.restart_at = None;
|
||||
self.upgrade_requester = Some(requester);
|
||||
self.redeliver = self.parked.take();
|
||||
self.desired_running = true;
|
||||
self.start_instance().await;
|
||||
Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous}))
|
||||
}
|
||||
|
||||
async fn rollback(&mut self, target: Option<u64>) -> Result<Value> {
|
||||
let target = match target {
|
||||
Some(t) => t,
|
||||
None => (1..self.version)
|
||||
.rev()
|
||||
.find(|v| self.world().version_dir(&self.id, *v).join("actor.wasm").is_file())
|
||||
.ok_or_else(|| anyhow!("{} has no version before {}", self.id, self.version))?,
|
||||
};
|
||||
let dir = self.world().version_dir(&self.id, target);
|
||||
if !dir.join("actor.wasm").is_file() {
|
||||
bail!("{} has no version {target}", self.id);
|
||||
}
|
||||
self.stop_instance().await;
|
||||
self.snapshot_current()?;
|
||||
let from = self.version;
|
||||
let snap = dir.join("snapshot");
|
||||
if snap.is_dir() {
|
||||
world::replace_tree(&snap.join("data"), &self.world().data_dir(&self.id))?;
|
||||
world::replace_tree(&snap.join("public"), &self.world().public_dir(&self.id))?;
|
||||
}
|
||||
self.install(target)?;
|
||||
self.previous = None;
|
||||
self.swapped_at = None;
|
||||
self.restarts = 0;
|
||||
self.restart_at = None;
|
||||
self.desired_running = true;
|
||||
self.start_instance().await;
|
||||
Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": target, "restored_state": snap.is_dir()}))
|
||||
}
|
||||
|
||||
/// Returns `true` when the supervisor must end.
|
||||
async fn handle_control(&mut self, ctl: Control) -> bool {
|
||||
match ctl {
|
||||
Control::Upgrade { version, requester, reply } => {
|
||||
let r = self.upgrade(version, requester).await;
|
||||
let _ = reply.send(r);
|
||||
}
|
||||
Control::Rollback { target, reply } => {
|
||||
let r = self.rollback(target).await;
|
||||
let _ = reply.send(r);
|
||||
}
|
||||
Control::Stop { reply } => {
|
||||
self.desired_running = false;
|
||||
self.restart_at = None;
|
||||
self.stop_instance().await;
|
||||
self.state = "stopped";
|
||||
let id = self.id.clone();
|
||||
let r = self.broker.update_registry(|r| {
|
||||
if let Some(a) = r.actors.get_mut(&id) {
|
||||
a.running = false;
|
||||
}
|
||||
});
|
||||
let _ = reply.send(r.map(|_| json!({"type": "stopped", "actor": self.id})));
|
||||
}
|
||||
Control::Start { reply } => {
|
||||
self.desired_running = true;
|
||||
self.restart_at = None;
|
||||
let id = self.id.clone();
|
||||
let r = self.broker.update_registry(|r| {
|
||||
if let Some(a) = r.actors.get_mut(&id) {
|
||||
a.running = true;
|
||||
}
|
||||
});
|
||||
let _ = reply.send(r.map(|_| json!({"type": "started", "actor": self.id})));
|
||||
}
|
||||
Control::Remove { reply } => {
|
||||
self.desired_running = false;
|
||||
self.stop_instance().await;
|
||||
let r = self.archive();
|
||||
let _ = reply.send(r);
|
||||
return true;
|
||||
}
|
||||
Control::Shutdown { reply } => {
|
||||
self.stop_instance().await;
|
||||
self.state = "host stopped";
|
||||
self.status();
|
||||
let _ = reply.send(Ok(json!({"type": "shut_down"})));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.status();
|
||||
false
|
||||
}
|
||||
|
||||
/// Move every folder of the actor into `archive/`. Nothing is deleted.
|
||||
fn archive(&self) -> Result<Value> {
|
||||
let dest = self.world().root.join("archive").join(format!("{}-{}", self.id, world::now()));
|
||||
std::fs::create_dir_all(&dest)?;
|
||||
for (from, name) in [
|
||||
(self.world().actor_dir(&self.id), "actor"),
|
||||
(self.world().root.join("private").join(&self.id), "private"),
|
||||
(self.world().versions_dir(&self.id), "versions"),
|
||||
] {
|
||||
if from.exists() {
|
||||
std::fs::rename(&from, dest.join(name))?;
|
||||
}
|
||||
}
|
||||
Ok(json!({"type": "removed", "actor": self.id, "archive": dest.display().to_string()}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Tests for a candidate version, before the broker installs it.
|
||||
//!
|
||||
//! A test is a message transcript in `source/tests/*.jsonl`. Each line is one
|
||||
//! of:
|
||||
//!
|
||||
//! ```text
|
||||
//! {"send": {...body...}} send a message to the candidate
|
||||
//! {"send": {...}, "from": "someone"} with an explicit sender
|
||||
//! {"expect": {...subset of body...}} the next message from the candidate
|
||||
//! {"expect": {...}, "to": "someone"} and its recipient
|
||||
//! ```
|
||||
//!
|
||||
//! Each file runs in a fresh instance on a copy of the actor's `/data` and
|
||||
//! `/public`. The candidate has no HTTP access. Without test files, the
|
||||
//! candidate must start and stay up for `SMOKE_TIME`.
|
||||
|
||||
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::fs::Mount;
|
||||
use crate::runtime::{Instance, InstanceConfig};
|
||||
use crate::world;
|
||||
|
||||
const SMOKE_TIME: Duration = Duration::from_secs(2);
|
||||
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()
|
||||
}
|
||||
|
||||
pub async fn test(
|
||||
broker: &Broker,
|
||||
id: &str,
|
||||
component: &Component,
|
||||
source: &Path,
|
||||
data: Option<&Path>,
|
||||
public: Option<&Path>,
|
||||
) -> Result<Value> {
|
||||
let mut files: Vec<_> = std::fs::read_dir(source.join("tests"))
|
||||
.map(|rd| {
|
||||
rd.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.extension().is_some_and(|x| x == "jsonl"))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
files.sort();
|
||||
|
||||
if files.is_empty() {
|
||||
let (mut inst, scratch) = start(broker, id, component, data, public)?;
|
||||
tokio::time::sleep(SMOKE_TIME).await;
|
||||
if inst.is_finished() {
|
||||
let exit = inst.wait().await;
|
||||
bail!(
|
||||
"candidate ended during the smoke test ({exit}). stderr:\n{}",
|
||||
stderr_tail(&scratch.dir.join("stderr.log"))
|
||||
);
|
||||
}
|
||||
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
|
||||
return Ok(json!({"smoke_test": "passed", "files": 0}));
|
||||
}
|
||||
|
||||
let mut passed = Vec::new();
|
||||
for file in &files {
|
||||
let name = file.file_name().unwrap().to_string_lossy().to_string();
|
||||
run_file(broker, id, component, data, public, file)
|
||||
.await
|
||||
.map_err(|e| anyhow!("test {name} failed: {e:#}"))?;
|
||||
passed.push(name);
|
||||
}
|
||||
Ok(json!({"passed": passed}))
|
||||
}
|
||||
|
||||
fn start(broker: &Broker, id: &str, component: &Component, data: Option<&Path>, public: Option<&Path>) -> Result<(Instance, Scratch)> {
|
||||
let dir = broker.world.scratch_dir().join(format!("{id}-{}-{}", world::now(), rand_suffix()));
|
||||
let scratch = Scratch { dir: dir.clone() };
|
||||
std::fs::create_dir_all(dir.join("data"))?;
|
||||
std::fs::create_dir_all(dir.join("public"))?;
|
||||
if let Some(d) = data.filter(|d| d.is_dir()) {
|
||||
world::copy_tree(d, &dir.join("data"))?;
|
||||
}
|
||||
if let Some(p) = public.filter(|p| p.is_dir()) {
|
||||
world::copy_tree(p, &dir.join("public"))?;
|
||||
}
|
||||
let cfg = InstanceConfig {
|
||||
mounts: vec![
|
||||
Mount { guest: "/actor".into(), host: broker.world.actors_root(), writable: false },
|
||||
Mount { guest: "/public".into(), host: dir.join("public"), writable: true },
|
||||
Mount { guest: "/data".into(), host: dir.join("data"), writable: true },
|
||||
],
|
||||
allow_http: false,
|
||||
stderr_log: dir.join("stderr.log"),
|
||||
env: vec![("SAUSAGE_ACTOR".into(), id.to_string()), ("SAUSAGE_CANDIDATE".into(), "1".into())],
|
||||
};
|
||||
Ok((broker.runtime.start(component, cfg)?, scratch))
|
||||
}
|
||||
|
||||
fn rand_suffix() -> u32 {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
||||
h.write_u64(std::process::id() as u64);
|
||||
h.finish() as u32
|
||||
}
|
||||
|
||||
async fn run_file(
|
||||
broker: &Broker,
|
||||
id: &str,
|
||||
component: &Component,
|
||||
data: Option<&Path>,
|
||||
public: Option<&Path>,
|
||||
file: &Path,
|
||||
) -> Result<()> {
|
||||
let text = std::fs::read_to_string(file)?;
|
||||
let (mut inst, scratch) = start(broker, id, component, data, public)?;
|
||||
let stderr = scratch.dir.join("stderr.log");
|
||||
let mut sent = 0u32;
|
||||
for (n, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
let step: Value = serde_json::from_str(line).map_err(|e| anyhow!("line {}: invalid JSON: {e}", n + 1))?;
|
||||
if let Some(body) = step.get("send") {
|
||||
sent += 1;
|
||||
let from = step.get("from").and_then(Value::as_str).unwrap_or("test");
|
||||
let env = json!({"id": format!("test/{sent}"), "from": from, "body": body});
|
||||
inst.send_line(&env.to_string())
|
||||
.await
|
||||
.map_err(|e| anyhow!("line {}: candidate does not read stdin: {e}", n + 1))?;
|
||||
} else if let Some(expected) = step.get("expect") {
|
||||
let got = tokio::time::timeout(EXPECT_TIMEOUT, inst.stdout.recv()).await;
|
||||
let got = match got {
|
||||
Err(_) => bail!("line {}: no message within {EXPECT_TIMEOUT:?}; expected {expected}", n + 1),
|
||||
Ok(None) => {
|
||||
let exit = inst.wait().await;
|
||||
bail!("line {}: candidate ended ({exit}); expected {expected}. stderr:\n{}", n + 1, stderr_tail(&stderr))
|
||||
}
|
||||
Ok(Some(l)) => l,
|
||||
};
|
||||
let msg: Value = serde_json::from_str(&got)
|
||||
.map_err(|_| anyhow!("line {}: candidate wrote a non-JSON line: {}", n + 1, truncate(&got, 300)))?;
|
||||
let body = msg.get("body").cloned().unwrap_or(Value::Null);
|
||||
if !subset_match(expected, &body) {
|
||||
bail!("line {}: expected body {expected}, got {}", n + 1, truncate(&body.to_string(), 1000));
|
||||
}
|
||||
if let Some(to) = step.get("to")
|
||||
&& msg.get("to") != Some(to)
|
||||
{
|
||||
bail!("line {}: expected recipient {to}, got {}", n + 1, msg.get("to").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
} else {
|
||||
bail!("line {}: use \"send\" or \"expect\"", n + 1);
|
||||
}
|
||||
}
|
||||
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -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(¶ms, "timeout_secs", DEFAULT_TIMEOUT_SECS)?,
|
||||
test_timeout_secs: seconds(¶ms, "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(©)
|
||||
.with_context(|| format!("remove {}", copy.display()))?;
|
||||
}
|
||||
copy_source(&source, ©, 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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! 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.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
|
||||
use crate::broker::{Broker, Envelope, truncate};
|
||||
use crate::world::{self, World};
|
||||
|
||||
pub const ID: &str = "cargo";
|
||||
pub const TARGET: &str = "wasm32-wasip2";
|
||||
const BUILD_TIMEOUT: Duration = Duration::from_secs(900);
|
||||
|
||||
pub struct BuildOutput {
|
||||
/// Host path of the published `.wasm` file.
|
||||
pub wasm: PathBuf,
|
||||
pub seconds: f64,
|
||||
pub warnings: String,
|
||||
}
|
||||
|
||||
pub struct Cargo {
|
||||
world: World,
|
||||
/// One build at a time: all builds share one target folder.
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl Cargo {
|
||||
pub fn new(world: World) -> Self {
|
||||
Self { world, lock: Mutex::new(()) }
|
||||
}
|
||||
|
||||
/// 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 _guard = self.lock.lock().await;
|
||||
let started = Instant::now();
|
||||
let data = self.world.data_dir(ID);
|
||||
let key = format!("{:016x}", fxhash(source.to_string_lossy().as_bytes()));
|
||||
let build_dir = data.join("work").join(&key);
|
||||
world::replace_tree(source, &build_dir)?;
|
||||
// The crate must not join a parent workspace by accident.
|
||||
let manifest = build_dir.join("Cargo.toml");
|
||||
let text = std::fs::read_to_string(&manifest)?;
|
||||
if !text.contains("[workspace]") {
|
||||
std::fs::write(&manifest, format!("{text}\n[workspace]\n"))?;
|
||||
}
|
||||
|
||||
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 = tokio::time::timeout(BUILD_TIMEOUT, cmd.output())
|
||||
.await
|
||||
.map_err(|_| anyhow!("cargo build took longer than {BUILD_TIMEOUT:?}"))?
|
||||
.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!("{}", truncate(&strip_progress(&stderr), 20_000));
|
||||
}
|
||||
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) })
|
||||
}
|
||||
|
||||
pub async fn run_actor(self: Arc<Self>, broker: Arc<Broker>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||
while let Some(env) = mailbox.recv().await {
|
||||
let this = self.clone();
|
||||
let broker = broker.clone();
|
||||
tokio::spawn(async move {
|
||||
let body = this.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": truncate(&out.warnings, 4000),
|
||||
}))
|
||||
}
|
||||
Some("shutdown") => Ok(json!({"type": "ignored"})),
|
||||
other => bail!("unknown request {other:?}. Known: build"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
fn fxhash(bytes: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for b in bytes {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+183
-290
@@ -1,314 +1,207 @@
|
||||
//! Sequential console commands, line editing, and live notifications.
|
||||
use crate::Host;
|
||||
use anyhow::Result;
|
||||
use rustyline::{DefaultEditor, ExternalPrinter, error::ReadlineError};
|
||||
use sausage_vfs::{Layer, Open};
|
||||
//! The native `console` actor: your terminal.
|
||||
//!
|
||||
//! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends
|
||||
//! `{"type": "text", "text": ...}`.
|
||||
//! - A line without `@` goes to `agent` as text.
|
||||
//! - `/deploy <actor> <folder>` builds a Rust folder with `cargo`, then spawns
|
||||
//! or upgrades the actor.
|
||||
//! - `/list`, `/sleep <seconds>`, `/help`, `/quit`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
const HELP: &str = "text chat with the agent
|
||||
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";
|
||||
use crate::broker::{Broker, Envelope};
|
||||
|
||||
/// Read a complete file through the host filesystem.
|
||||
async fn read(host: &Host, path: &str) -> Result<String> {
|
||||
let file = host
|
||||
.filesystem()
|
||||
.await?
|
||||
.open(
|
||||
path,
|
||||
Open {
|
||||
follow: true,
|
||||
..Open::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let chunk = file.read(bytes.len() as u64, 65536).await?;
|
||||
if chunk.is_empty() {
|
||||
return Ok(String::from_utf8(bytes)?);
|
||||
}
|
||||
bytes.extend(chunk);
|
||||
pub const ID: &str = "console";
|
||||
|
||||
const HELP: &str = "\
|
||||
@<actor> <json> send a JSON body to an actor
|
||||
@<actor> <text> send {\"type\":\"text\",\"text\":...}
|
||||
<text> send text to the agent
|
||||
/deploy <actor> <dir> build a Rust folder with cargo, then spawn or upgrade
|
||||
/list list actors
|
||||
/sleep <seconds> wait (for scripts)
|
||||
/await <actor> <secs> wait for the next message from an actor (for scripts)
|
||||
/quit stop all actors and exit";
|
||||
|
||||
pub struct Console {
|
||||
broker: Arc<Broker>,
|
||||
waiters: Mutex<HashMap<String, oneshot::Sender<Envelope>>>,
|
||||
seq: AtomicU64,
|
||||
/// Number of messages received per sender, for `/await`.
|
||||
seen: Mutex<HashMap<String, u64>>,
|
||||
arrived: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
fn show(env: &Envelope) {
|
||||
let reply = if env.reply_to.is_some() { " ↩" } else { "" };
|
||||
let b = &env.body;
|
||||
if b["type"] == "text"
|
||||
&& let Some(t) = b["text"].as_str()
|
||||
{
|
||||
println!("[{}{reply}] {t}", env.from);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize the process IDs accepted by stop, kill, and log.
|
||||
fn is_pid(text: &str) -> bool {
|
||||
text.rsplit_once('-').is_some()
|
||||
&& text
|
||||
.get(text.len().saturating_sub(36)..)
|
||||
.is_some_and(|id| uuid::Uuid::try_parse(id).is_ok())
|
||||
}
|
||||
|
||||
/// Render method signatures and their documentation.
|
||||
fn methods(plugin: &str, doc: &Value) -> String {
|
||||
let mut lines = Vec::new();
|
||||
for method in doc["methods"].as_array().into_iter().flatten() {
|
||||
let params = method["params"].as_array().cloned().unwrap_or_default();
|
||||
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("?")));
|
||||
let mut compact = b.clone();
|
||||
let mut extra = Vec::new();
|
||||
if let Some(obj) = compact.as_object_mut() {
|
||||
for key in ["errors", "warnings", "reason"] {
|
||||
if let Some(Value::String(s)) = obj.get(key)
|
||||
&& s.contains('\n')
|
||||
{
|
||||
extra.push((key, s.clone()));
|
||||
obj.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
println!("[{}{reply}] {compact}", env.from);
|
||||
for (k, s) in extra {
|
||||
println!(" {k}:\n{}", s.lines().map(|l| format!(" {l}")).collect::<Vec<_>>().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;
|
||||
}
|
||||
impl Console {
|
||||
pub fn new(broker: Arc<Broker>) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
broker,
|
||||
waiters: Mutex::new(HashMap::new()),
|
||||
seq: AtomicU64::new(1),
|
||||
seen: Mutex::new(HashMap::new()),
|
||||
arrived: tokio::sync::Notify::new(),
|
||||
})
|
||||
}
|
||||
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();
|
||||
if line.is_empty() {
|
||||
return Ok(String::new());
|
||||
pub async fn run_mailbox(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
|
||||
while let Some(env) = mailbox.recv().await {
|
||||
show(&env);
|
||||
*self.seen.lock().unwrap().entry(env.from.clone()).or_default() += 1;
|
||||
self.arrived.notify_waiters();
|
||||
if let Some(r) = &env.reply_to
|
||||
&& let Some(tx) = self.waiters.lock().unwrap().remove(r)
|
||||
{
|
||||
let _ = tx.send(env);
|
||||
}
|
||||
}
|
||||
}
|
||||
let (command, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
|
||||
let rest = rest.trim();
|
||||
let words: Vec<_> = rest.split_whitespace().collect();
|
||||
match (command, words.as_slice()) {
|
||||
("help", []) => return Ok(HELP.into()),
|
||||
("quit", []) => return Ok("Goodbye.".into()),
|
||||
("cat", [path]) => return read(host, path).await,
|
||||
("ls", [] | [_]) => {
|
||||
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]) => {
|
||||
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 {
|
||||
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();
|
||||
fn send(&self, to: &str, body: Value) -> String {
|
||||
let own = format!("c{}", self.seq.fetch_add(1, Ordering::Relaxed));
|
||||
self.broker.send(ID, Some(to), Some(&own), None, body)
|
||||
}
|
||||
let payload = ¶ms["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);
|
||||
async fn request(&self, to: &str, body: Value, timeout: Duration) -> Result<Envelope> {
|
||||
let own = format!("c{}", self.seq.fetch_add(1, Ordering::Relaxed));
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.waiters.lock().unwrap().insert(format!("{ID}/{own}"), tx);
|
||||
self.broker.send(ID, Some(to), Some(&own), None, body);
|
||||
tokio::time::timeout(timeout, rx)
|
||||
.await
|
||||
.map_err(|_| anyhow!("no reply from {to} within {timeout:?}"))?
|
||||
.map_err(|_| anyhow!("reply channel closed"))
|
||||
}
|
||||
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))
|
||||
|
||||
async fn deploy(&self, actor: &str, dir: &str) -> Result<()> {
|
||||
let path = std::path::absolute(dir)?;
|
||||
let source = path.display().to_string();
|
||||
println!("· building {source} …");
|
||||
let built = self.request(crate::cargo_actor::ID, json!({"type": "build", "source": source}), Duration::from_secs(900)).await?;
|
||||
if built.body["type"] != "built" {
|
||||
bail!("build failed");
|
||||
}
|
||||
let exists = self.broker.registry().actors.contains_key(actor);
|
||||
let ty = if exists { "upgrade" } else { "spawn" };
|
||||
println!("· {ty} {actor} …");
|
||||
let body = json!({
|
||||
"type": ty,
|
||||
"actor": actor,
|
||||
"wasm": built.body["wasm"],
|
||||
"source": source,
|
||||
"built_by": built.body["built_by"],
|
||||
});
|
||||
let r = self.request("broker", body, Duration::from_secs(300)).await?;
|
||||
if r.body["type"] == "error" {
|
||||
bail!("{ty} failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read commands until EOF or `/quit`.
|
||||
pub async fn run_input(self: Arc<Self>) {
|
||||
println!("Sausage console. Type /help for commands.");
|
||||
let mut lines = BufReader::new(tokio::io::stdin()).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = self.command(line).await {
|
||||
if e.to_string() == "quit" {
|
||||
break;
|
||||
}
|
||||
println!("! {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn command(&self, line: &str) -> Result<()> {
|
||||
if let Some(rest) = line.strip_prefix('@') {
|
||||
let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||||
let text = text.trim();
|
||||
let body = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v @ Value::Object(_)) => v,
|
||||
_ => json!({"type": "text", "text": text}),
|
||||
};
|
||||
self.send(to, body);
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(cmd) = line.strip_prefix('/') {
|
||||
let mut parts = cmd.split_whitespace();
|
||||
match parts.next() {
|
||||
Some("help") => println!("{HELP}"),
|
||||
Some("quit") => bail!("quit"),
|
||||
Some("list") => {
|
||||
self.send("broker", json!({"type": "list"}));
|
||||
}
|
||||
Some("sleep") => {
|
||||
let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(1.0);
|
||||
tokio::time::sleep(Duration::from_secs_f64(secs)).await;
|
||||
}
|
||||
Some("await") => {
|
||||
let from = parts.next().unwrap_or("agent").to_string();
|
||||
let secs: f64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(60.0);
|
||||
let count = |c: &Self| c.seen.lock().unwrap().get(&from).copied().unwrap_or(0);
|
||||
let before = count(self);
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs_f64(secs);
|
||||
loop {
|
||||
let notified = self.arrived.notified();
|
||||
tokio::pin!(notified);
|
||||
notified.as_mut().enable();
|
||||
if count(self) != before {
|
||||
break;
|
||||
}
|
||||
if tokio::time::timeout_at(deadline, notified).await.is_err() {
|
||||
bail!("no message from {from} within {secs} s");
|
||||
}
|
||||
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,
|
||||
Some("deploy") => {
|
||||
let (Some(actor), Some(dir)) = (parts.next(), parts.next()) else {
|
||||
bail!("usage: /deploy <actor> <dir>");
|
||||
};
|
||||
self.deploy(actor, dir).await?;
|
||||
}
|
||||
_ => bail!("unknown command. Type /help."),
|
||||
}
|
||||
}
|
||||
});
|
||||
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(());
|
||||
}
|
||||
self.send("agent", json!({"type": "text", "text": line}));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
-159
@@ -1,159 +0,0 @@
|
||||
//! Native core dispatch and OpenRPC, generated from one typed method registry.
|
||||
mod types;
|
||||
use types::*;
|
||||
pub(crate) use types::{ActivationSummary, Held, InterfaceCheck};
|
||||
|
||||
use crate::{Host, RpcResult, error};
|
||||
use sausage_plugin::{Empty, document, finish_document, method};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
// Every dispatched method must supply docs, a parameter type, and a result
|
||||
// type. The same entry generates its descriptor; neither list can drift.
|
||||
macro_rules! core_methods {
|
||||
($host:ident, $caller:ident, $id:ident, $wire:ident, $p:ident;
|
||||
$($name:literal: $params:ty => $result:ty, $docs:literal $body:block)*) => {
|
||||
#[cfg(test)]
|
||||
const METHOD_NAMES: &[&str] = &[$($name),*];
|
||||
|
||||
pub(crate) fn discover() -> &'static Value {
|
||||
static DOCUMENT: OnceLock<Value> = OnceLock::new();
|
||||
DOCUMENT.get_or_init(|| {
|
||||
let mut doc = document("core", env!("CARGO_PKG_VERSION"));
|
||||
$(method::<$params, $result>(&mut doc, $name, $docs);)*
|
||||
finish_document(doc)
|
||||
})
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Execute a native method without starting a guest process.
|
||||
pub(crate) async fn core(
|
||||
&$host,
|
||||
$caller: &str,
|
||||
$id: Option<Value>,
|
||||
$wire: &str,
|
||||
$p: Value,
|
||||
) -> RpcResult {
|
||||
match $wire {
|
||||
$($name => {
|
||||
let $p: $params = params($p)?;
|
||||
let result: $result = $body;
|
||||
Ok(json!(result))
|
||||
},)*
|
||||
_ => Err(error(-32601, "method not found")),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
core_methods! { self, caller, id, wire, p;
|
||||
"rpc.discover": Empty => Json,
|
||||
"Describe every native core method as OpenRPC 1.2, generated from its wire types." {
|
||||
let _ = p;
|
||||
Json(discover().clone())
|
||||
}
|
||||
"watch": Path => (),
|
||||
"Watch a path for the running caller's lifetime. Needs a request ID; sends armed, then changed notifications with empty params. Scan after armed and rescan after changed." {
|
||||
self.watch(caller, id, &p.path).await?;
|
||||
}
|
||||
"call": Call => Json,
|
||||
"Call a qualified method, optionally detached. Returns that method's result unchanged." {
|
||||
Json(self.route(caller.into(), id, p.method, p.params.0, p.detach.unwrap_or(false)).await?)
|
||||
}
|
||||
"notify": Notify => (),
|
||||
"Notify the caller about progress. Normally sent as a notification, without an ID." {
|
||||
self.notify(caller, &p.name, p.params.0);
|
||||
}
|
||||
"kill": Pid => Empty,
|
||||
"Kill a process immediately; return after it has ended. An unknown process is a no-op." {
|
||||
let ended = self.0.state.lock().unwrap().processes.get(&p.pid).map(|p| p.ended.subscribe());
|
||||
self.kill(&p.pid);
|
||||
if let Some(mut ended) = ended {
|
||||
let _ = ended.wait_for(|ended| *ended).await;
|
||||
}
|
||||
Empty {}
|
||||
}
|
||||
"stop": Pid => Empty,
|
||||
"Close a process's stdin and wait until it ends, killing it after the five-second stop budget. An unknown process is a no-op." {
|
||||
if let Some(mut ended) = self.begin_stop(&p.pid) {
|
||||
let _ = ended.wait_for(|ended| *ended).await;
|
||||
}
|
||||
Empty {}
|
||||
}
|
||||
"lock": Lock => Held,
|
||||
"Acquire an advisory FIFO lock. Wait unless wait is false; locks are released when their owner ends." {
|
||||
result(self.lock(caller, &p.path, p.wait.unwrap_or(true)).await?)?
|
||||
}
|
||||
"unlock": Path => (),
|
||||
"Release an advisory lock held by this caller. Normally sent as a notification, without an ID." {
|
||||
self.unlock(caller, &p.path);
|
||||
}
|
||||
"log": Log => Vec<crate::journal::Entry>,
|
||||
"Query the journal. Return the newest matching entries in ascending entry-ID order." {
|
||||
result(self.0.journal.query(json!(p)).await)?
|
||||
}
|
||||
"activate": Activate => ActivationResult,
|
||||
"Copy, discover, check and install one or more plugin WASMs. Return short summaries keyed by plugin name; force skips interface checks." {
|
||||
result(self.activation(caller, wire, json!(p)).await?)?
|
||||
}
|
||||
"revert": Revert => ActivationResult,
|
||||
"Restore one or more plugins from plugin.prev.wasm, using the same checks and summaries as activate." {
|
||||
result(self.activation(caller, wire, json!(p)).await?)?
|
||||
}
|
||||
"restart": Empty => Empty,
|
||||
"Request a clean host restart. Requires the person's explicit approval each time and the SAUSAGE_LOOP=1 supervisor marker; otherwise fails with -32600." {
|
||||
let _ = p;
|
||||
self.request_restart()?;
|
||||
Empty {}
|
||||
}
|
||||
"exit": Empty => Empty,
|
||||
"Legacy alias of restart: same explicit-approval rule, supervisor gate and coalesced shutdown request." {
|
||||
let _ = p;
|
||||
self.request_restart()?;
|
||||
Empty {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{runtime::Runtime, world::World};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Every descriptor names a dispatched method, and vice versa.
|
||||
#[tokio::test]
|
||||
async fn discovery_covers_dispatch_exactly() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let world = dir.path().join("world");
|
||||
World::create(&world).unwrap();
|
||||
let host = Host::test_world(&world, Runtime::new(&dir.path().join("cache")).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let doc = host.call("core.rpc.discover", json!({})).await.unwrap();
|
||||
let names: BTreeSet<_> = doc["methods"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|m| {
|
||||
assert!(!m["description"].as_str().unwrap().is_empty());
|
||||
m["name"].as_str().unwrap()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(names, METHOD_NAMES.iter().copied().collect());
|
||||
assert_eq!(names.len(), doc["methods"].as_array().unwrap().len());
|
||||
for name in names {
|
||||
let response = host.call(&format!("core.{name}"), json!({})).await;
|
||||
if let Err(error) = response {
|
||||
assert_ne!(error["code"], -32601, "descriptor without dispatch: {name}");
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
host.call("core.no_such_method", json!({}))
|
||||
.await
|
||||
.unwrap_err()["code"],
|
||||
-32601
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
//! Native wire types shared by dispatch, discovery, and result producers.
|
||||
use sausage_plugin::{Params, SchemaType, schema};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
// Core forwarding/discovery carry arbitrary JSON, and activation returns a map
|
||||
// keyed by plugin name. These native contracts are wider than #[schema]'s
|
||||
// plugin subset; schemars still derives them from their actual wire types.
|
||||
#[derive(Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(transparent)]
|
||||
pub(super) struct Json(pub Value);
|
||||
impl SchemaType for Json {}
|
||||
|
||||
/// Forward a call, optionally detached from its caller.
|
||||
#[derive(Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub(super) struct Call {
|
||||
/// Qualified target method.
|
||||
pub method: String,
|
||||
/// Target parameters; omitted means an empty object.
|
||||
#[serde(default = "empty_params")]
|
||||
pub params: Json,
|
||||
/// Detach the new process; default false.
|
||||
#[serde(default, deserialize_with = "optional")]
|
||||
pub detach: Option<bool>,
|
||||
}
|
||||
fn empty_params() -> Json {
|
||||
Json(serde_json::json!({}))
|
||||
}
|
||||
|
||||
/// Notify the caller of progress.
|
||||
#[schema]
|
||||
pub(super) struct Notify {
|
||||
/// Notification name.
|
||||
pub name: String,
|
||||
/// Notification payload; omitted means an empty object.
|
||||
#[serde(default = "empty_params")]
|
||||
pub params: Json,
|
||||
}
|
||||
|
||||
/// Select a process.
|
||||
#[schema]
|
||||
pub(super) struct Pid {
|
||||
/// Process ID.
|
||||
pub pid: String,
|
||||
}
|
||||
|
||||
/// Select a virtual path or advisory lock name.
|
||||
#[schema]
|
||||
pub(super) struct Path {
|
||||
/// Path to observe or unlock.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
/// Acquire an advisory FIFO lock.
|
||||
#[derive(Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub(super) struct Lock {
|
||||
/// Advisory lock name (usually a state file path).
|
||||
pub path: String,
|
||||
/// Wait for ownership unless false; omitted or null means true.
|
||||
#[serde(default, deserialize_with = "optional")]
|
||||
pub wait: Option<bool>,
|
||||
}
|
||||
|
||||
/// Lock ownership after acquisition.
|
||||
#[schema]
|
||||
pub(crate) struct Held {
|
||||
/// Whether the caller holds the lock.
|
||||
pub held: bool,
|
||||
}
|
||||
|
||||
/// Query journal entries, returned in ascending ID order.
|
||||
#[derive(Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub(super) struct Log {
|
||||
/// Exact plugin name.
|
||||
#[serde(default, deserialize_with = "optional")]
|
||||
pub plugin: Option<String>,
|
||||
/// Exact process ID.
|
||||
#[serde(default, deserialize_with = "optional")]
|
||||
pub pid: Option<String>,
|
||||
/// Inclusive lower entry ID.
|
||||
#[serde(default, deserialize_with = "optional")]
|
||||
pub since: Option<String>,
|
||||
/// Inclusive upper entry ID.
|
||||
#[serde(default, deserialize_with = "optional")]
|
||||
pub until: Option<String>,
|
||||
/// Literal substring in the line.
|
||||
#[serde(default, deserialize_with = "optional")]
|
||||
pub grep: Option<String>,
|
||||
/// Maximum newest entries; default 100, negative means no limit.
|
||||
#[serde(default, deserialize_with = "optional")]
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
// Keep the existing core's forgiving optional filters/flags: values of the
|
||||
// wrong type act like omitted values, rather than adding new validation.
|
||||
fn optional<'de, T: serde::de::DeserializeOwned, D: Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<T>, D::Error> {
|
||||
Ok(serde_json::from_value(Value::deserialize(deserializer)?).ok())
|
||||
}
|
||||
|
||||
/// One candidate for activation.
|
||||
#[schema]
|
||||
pub(super) struct ActivatePlugin {
|
||||
/// Plugin name.
|
||||
pub plugin: String,
|
||||
/// Candidate WASM world path.
|
||||
pub wasm: String,
|
||||
}
|
||||
|
||||
/// Activate one candidate, or a group checked and installed together.
|
||||
#[derive(Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub(super) struct Activate {
|
||||
/// Single plugin name, used when plugins is omitted.
|
||||
pub plugin: Option<String>,
|
||||
/// Single candidate WASM world path, used when plugins is omitted.
|
||||
pub wasm: Option<String>,
|
||||
/// Group of candidates; takes precedence over plugin and wasm. Must not be empty.
|
||||
pub plugins: Option<Vec<ActivatePlugin>>,
|
||||
/// Skip interface checks only when true; default false.
|
||||
#[serde(default, deserialize_with = "optional")]
|
||||
pub force: Option<bool>,
|
||||
}
|
||||
|
||||
/// One plugin to restore from plugin.prev.wasm.
|
||||
#[schema]
|
||||
pub(super) struct RevertPlugin {
|
||||
/// Plugin name.
|
||||
pub plugin: String,
|
||||
}
|
||||
|
||||
/// Revert one plugin or a group to their previous WASM files.
|
||||
#[derive(Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub(super) struct Revert {
|
||||
/// Single plugin name, used when plugins is omitted.
|
||||
pub plugin: Option<String>,
|
||||
/// Group of plugins; takes precedence over plugin. Must not be empty.
|
||||
pub plugins: Option<Vec<RevertPlugin>>,
|
||||
/// Skip interface checks only when true; default false.
|
||||
#[serde(default, deserialize_with = "optional")]
|
||||
pub force: Option<bool>,
|
||||
}
|
||||
|
||||
/// A short summary of one installed plugin.
|
||||
#[schema]
|
||||
pub(crate) struct ActivationSummary {
|
||||
/// Plugin name.
|
||||
pub plugin: String,
|
||||
/// Installed world path.
|
||||
pub wasm: String,
|
||||
/// SHA-256 of the installed copy.
|
||||
pub hash: String,
|
||||
/// Replaced hash, or null on first installation.
|
||||
pub previous: Option<String>,
|
||||
/// Number of methods in the main interface.
|
||||
pub method_count: usize,
|
||||
/// Sorted names added to the interface.
|
||||
pub added: Vec<String>,
|
||||
/// Sorted names removed from the interface.
|
||||
pub removed: Vec<String>,
|
||||
/// Sorted names whose description or reachable schema changed.
|
||||
pub changed: Vec<String>,
|
||||
/// Whether interface checks passed or were skipped.
|
||||
pub interface_check: InterfaceCheck,
|
||||
}
|
||||
|
||||
/// Outcome of the activation's interface check.
|
||||
#[derive(Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[schemars(inline)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum InterfaceCheck {
|
||||
/// The interface checks passed.
|
||||
Passed,
|
||||
/// The caller explicitly skipped the checks.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl SchemaType for InterfaceCheck {}
|
||||
|
||||
#[derive(Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(transparent)]
|
||||
pub(super) struct ActivationResult(pub BTreeMap<String, ActivationSummary>);
|
||||
impl SchemaType for ActivationResult {}
|
||||
|
||||
// Native optional fields retain the core's lenient deserializers, which are
|
||||
// intentionally outside the SDK attribute macro's plugin schema subset.
|
||||
macro_rules! native_params {
|
||||
($($name:ty),*) => { $(impl SchemaType for $name {} impl Params for $name {})* };
|
||||
}
|
||||
native_params!(Call, Lock, Log, Activate, Revert);
|
||||
|
||||
// Parameter decoding stays at the core boundary, not in the schema builder.
|
||||
pub(super) fn params<P: Params + serde::de::DeserializeOwned>(p: Value) -> Result<P, Value> {
|
||||
serde_json::from_value(p).map_err(|error| crate::error(-32602, &error.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn result<R: serde::de::DeserializeOwned>(value: Value) -> Result<R, Value> {
|
||||
serde_json::from_value(value).map_err(|error| crate::error(-32603, &error.to_string()))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,327 +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()));
|
||||
files.insert("/discover.json", || {
|
||||
Ok(serde_json::to_vec(crate::core::discover()).unwrap())
|
||||
});
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
//! Filesystem layer with commit-on-close semantics.
|
||||
//!
|
||||
//! The layer wraps the wasmtime-wasi `wasi:filesystem` implementation. For
|
||||
//! mounts with `commit_on_close`, an open for write goes to a hidden temporary
|
||||
//! file next to the target. When the guest drops the descriptor (close), the
|
||||
//! layer renames the temporary file into place. Until then, no other reader
|
||||
//! sees the change. If the instance traps or is killed, the changes are lost.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use wasmtime::component::{HasData, Resource};
|
||||
use wasmtime_wasi::filesystem::{Descriptor, WasiFilesystemCtxView};
|
||||
use wasmtime_wasi::p2::bindings::filesystem::types::{
|
||||
self, ErrorCode, HostDescriptor, HostDirectoryEntryStream,
|
||||
};
|
||||
use wasmtime_wasi::p2::bindings::filesystem::preopens;
|
||||
use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult};
|
||||
|
||||
/// Name prefix of uncommitted files. The layer hides these names from guests.
|
||||
pub const TMP_PREFIX: &str = ".sausage-tmp-";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mount {
|
||||
pub guest: String,
|
||||
pub host: PathBuf,
|
||||
pub writable: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DirInfo {
|
||||
host: PathBuf,
|
||||
root: PathBuf,
|
||||
commit: bool,
|
||||
}
|
||||
|
||||
struct Pending {
|
||||
tmp: PathBuf,
|
||||
target: PathBuf,
|
||||
}
|
||||
|
||||
/// Per-instance state of the layer.
|
||||
pub struct FsExt {
|
||||
mounts: Vec<Mount>,
|
||||
dirs: HashMap<u32, DirInfo>,
|
||||
pending: HashMap<u32, Pending>,
|
||||
counter: u64,
|
||||
}
|
||||
|
||||
impl FsExt {
|
||||
pub fn new(mounts: Vec<Mount>) -> Self {
|
||||
Self { mounts, dirs: HashMap::new(), pending: HashMap::new(), counter: 0 }
|
||||
}
|
||||
|
||||
/// Commit every open write. Use this when the guest exits normally,
|
||||
/// because process exit closes all files.
|
||||
pub fn commit_all(&mut self) {
|
||||
for (_, p) in self.pending.drain() {
|
||||
commit(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FsExt {
|
||||
/// Discard writes that were not committed (trap, kill).
|
||||
fn drop(&mut self) {
|
||||
for (_, p) in self.pending.drain() {
|
||||
let _ = std::fs::remove_file(&p.tmp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn commit(p: Pending) {
|
||||
if let Err(e) = std::fs::rename(&p.tmp, &p.target) {
|
||||
eprintln!("sausage: commit of {} failed: {e}", p.target.display());
|
||||
let _ = std::fs::remove_file(&p.tmp);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_tmp_name(path: &str) -> bool {
|
||||
path.rsplit('/').next().is_some_and(|n| n.starts_with(TMP_PREFIX))
|
||||
}
|
||||
|
||||
/// Join `rel` to the directory `dir` lexically. Return `None` if the result
|
||||
/// leaves the mount root.
|
||||
fn resolve(dir: &DirInfo, rel: &str) -> Option<PathBuf> {
|
||||
let mut out = dir.host.clone();
|
||||
for c in Path::new(rel).components() {
|
||||
match c {
|
||||
Component::Normal(n) => out.push(n),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if !out.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Component::RootDir | Component::Prefix(_) => return None,
|
||||
}
|
||||
}
|
||||
out.starts_with(&dir.root).then_some(out)
|
||||
}
|
||||
|
||||
fn io_err(e: std::io::Error) -> FsError {
|
||||
ErrorCode::from(e).into()
|
||||
}
|
||||
|
||||
pub struct ActorFs<'a> {
|
||||
pub inner: WasiFilesystemCtxView<'a>,
|
||||
pub ext: &'a mut FsExt,
|
||||
}
|
||||
|
||||
pub struct ActorFsData;
|
||||
|
||||
impl HasData for ActorFsData {
|
||||
type Data<'a> = ActorFs<'a>;
|
||||
}
|
||||
|
||||
impl preopens::Host for ActorFs<'_> {
|
||||
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<Descriptor>, String)>> {
|
||||
let dirs = preopens::Host::get_directories(&mut self.inner)?;
|
||||
for (r, name) in &dirs {
|
||||
if let Some(m) = self.ext.mounts.iter().find(|m| &m.guest == name) {
|
||||
self.ext.dirs.insert(
|
||||
r.rep(),
|
||||
DirInfo { host: m.host.clone(), root: m.host.clone(), commit: m.writable },
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(dirs)
|
||||
}
|
||||
}
|
||||
|
||||
impl types::Host for ActorFs<'_> {
|
||||
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 ActorFs<'_> {
|
||||
async fn advise(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
offset: types::Filesize,
|
||||
len: types::Filesize,
|
||||
advice: types::Advice,
|
||||
) -> FsResult<()> {
|
||||
HostDescriptor::advise(&mut self.inner, fd, offset, len, advice).await
|
||||
}
|
||||
|
||||
async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
|
||||
HostDescriptor::sync_data(&mut self.inner, fd).await
|
||||
}
|
||||
|
||||
async fn get_flags(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorFlags> {
|
||||
HostDescriptor::get_flags(&mut self.inner, fd).await
|
||||
}
|
||||
|
||||
async fn get_type(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorType> {
|
||||
HostDescriptor::get_type(&mut self.inner, fd).await
|
||||
}
|
||||
|
||||
async fn set_size(&mut self, fd: Resource<types::Descriptor>, size: types::Filesize) -> FsResult<()> {
|
||||
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<()> {
|
||||
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)> {
|
||||
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> {
|
||||
HostDescriptor::write(&mut self.inner, fd, buf, offset).await
|
||||
}
|
||||
|
||||
async fn read_directory(
|
||||
&mut self,
|
||||
fd: Resource<types::Descriptor>,
|
||||
) -> FsResult<Resource<types::DirectoryEntryStream>> {
|
||||
HostDescriptor::read_directory(&mut self.inner, fd).await
|
||||
}
|
||||
|
||||
async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
|
||||
HostDescriptor::sync(&mut self.inner, fd).await
|
||||
}
|
||||
|
||||
async fn create_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||
HostDescriptor::create_directory_at(&mut self.inner, fd, path).await
|
||||
}
|
||||
|
||||
async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
|
||||
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> {
|
||||
if is_tmp_name(&path) {
|
||||
return Err(ErrorCode::NoEntry.into());
|
||||
}
|
||||
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<()> {
|
||||
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<()> {
|
||||
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: types::OpenFlags,
|
||||
flags: types::DescriptorFlags,
|
||||
) -> FsResult<Resource<types::Descriptor>> {
|
||||
if is_tmp_name(&path) {
|
||||
return Err(ErrorCode::NoEntry.into());
|
||||
}
|
||||
let parent = self.ext.dirs.get(&fd.rep()).cloned();
|
||||
|
||||
// Commit-on-close: redirect writes to a hidden temporary file.
|
||||
if let Some(dir) = parent.as_ref().filter(|d| d.commit)
|
||||
&& flags.contains(types::DescriptorFlags::WRITE)
|
||||
&& !oflags.contains(types::OpenFlags::DIRECTORY)
|
||||
{
|
||||
let target = resolve(dir, &path).ok_or(FsError::from(ErrorCode::NotPermitted))?;
|
||||
let meta = std::fs::metadata(&target).ok();
|
||||
if meta.as_ref().is_some_and(|m| m.is_dir()) {
|
||||
return Err(ErrorCode::IsDirectory.into());
|
||||
}
|
||||
let exists = meta.is_some();
|
||||
if exists && oflags.contains(types::OpenFlags::EXCLUSIVE) {
|
||||
return Err(ErrorCode::Exist.into());
|
||||
}
|
||||
if !exists && !oflags.contains(types::OpenFlags::CREATE) {
|
||||
return Err(ErrorCode::NoEntry.into());
|
||||
}
|
||||
let target_dir = target.parent().ok_or(FsError::from(ErrorCode::NotPermitted))?;
|
||||
if !target_dir.is_dir() {
|
||||
return Err(ErrorCode::NoEntry.into());
|
||||
}
|
||||
self.ext.counter += 1;
|
||||
let tmp_name = format!("{TMP_PREFIX}{}-{}", std::process::id(), self.ext.counter);
|
||||
let tmp_rel = match path.rsplit_once('/') {
|
||||
Some((d, _)) => format!("{d}/{tmp_name}"),
|
||||
None => tmp_name.clone(),
|
||||
};
|
||||
let tmp = target_dir.join(&tmp_name);
|
||||
let truncate = oflags.contains(types::OpenFlags::TRUNCATE);
|
||||
if exists && !truncate {
|
||||
std::fs::copy(&target, &tmp).map_err(io_err)?;
|
||||
}
|
||||
let oflags = (oflags | types::OpenFlags::CREATE)
|
||||
& !(types::OpenFlags::EXCLUSIVE | types::OpenFlags::TRUNCATE);
|
||||
return match HostDescriptor::open_at(&mut self.inner, fd, path_flags, tmp_rel, oflags, flags).await {
|
||||
Ok(r) => {
|
||||
self.ext.pending.insert(r.rep(), Pending { tmp, target });
|
||||
Ok(r)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
Err(e)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let r = HostDescriptor::open_at(&mut self.inner, fd, path_flags, path.clone(), oflags, flags).await?;
|
||||
if let Some(dir) = parent
|
||||
&& matches!(self.inner.table.get(&r)?, Descriptor::Dir(_))
|
||||
&& let Some(host) = resolve(&dir, &path)
|
||||
{
|
||||
self.ext.dirs.insert(r.rep(), DirInfo { host, root: dir.root, commit: dir.commit });
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
|
||||
let rep = fd.rep();
|
||||
HostDescriptor::drop(&mut self.inner, fd)?;
|
||||
self.ext.dirs.remove(&rep);
|
||||
if let Some(p) = self.ext.pending.remove(&rep) {
|
||||
commit(p);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn readlink_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<String> {
|
||||
HostDescriptor::readlink_at(&mut self.inner, fd, path).await
|
||||
}
|
||||
|
||||
async fn remove_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||
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<()> {
|
||||
if is_tmp_name(&old_path) || is_tmp_name(&new_path) {
|
||||
return Err(ErrorCode::NoEntry.into());
|
||||
}
|
||||
HostDescriptor::rename_at(&mut self.inner, fd, old_path, new_fd, new_path).await
|
||||
}
|
||||
|
||||
async fn symlink_at(
|
||||
&mut self,
|
||||
_fd: Resource<types::Descriptor>,
|
||||
_src_path: String,
|
||||
_dest_path: String,
|
||||
) -> FsResult<()> {
|
||||
// Symlinks would break the lexical path tracking of commit-on-close.
|
||||
Err(ErrorCode::NotPermitted.into())
|
||||
}
|
||||
|
||||
async fn unlink_file_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
|
||||
if is_tmp_name(&path) {
|
||||
return Err(ErrorCode::NoEntry.into());
|
||||
}
|
||||
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>> {
|
||||
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>> {
|
||||
HostDescriptor::write_via_stream(&mut self.inner, fd, offset)
|
||||
}
|
||||
|
||||
fn append_via_stream(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<DynOutputStream>> {
|
||||
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> {
|
||||
HostDescriptor::is_same_object(&mut self.inner, a, b).await
|
||||
}
|
||||
|
||||
async fn metadata_hash(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::MetadataHashValue> {
|
||||
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> {
|
||||
HostDescriptor::metadata_hash_at(&mut self.inner, fd, path_flags, path).await
|
||||
}
|
||||
}
|
||||
|
||||
impl HostDirectoryEntryStream for ActorFs<'_> {
|
||||
async fn read_directory_entry(
|
||||
&mut self,
|
||||
stream: Resource<types::DirectoryEntryStream>,
|
||||
) -> FsResult<Option<types::DirectoryEntry>> {
|
||||
let rep = stream.rep();
|
||||
loop {
|
||||
let entry = HostDirectoryEntryStream::read_directory_entry(
|
||||
&mut self.inner,
|
||||
Resource::new_borrow(rep),
|
||||
)
|
||||
.await?;
|
||||
match entry {
|
||||
Some(e) if e.name.starts_with(TMP_PREFIX) => continue,
|
||||
other => return Ok(other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
|
||||
HostDirectoryEntryStream::drop(&mut self.inner, stream)
|
||||
}
|
||||
}
|
||||
@@ -1,297 +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;
|
||||
}
|
||||
// Core is a native provider, not an installed plugin or cached WASM.
|
||||
if provider == "core" {
|
||||
for problem in
|
||||
sausage_interface::compat::check(expected, crate::core::discover())
|
||||
{
|
||||
failures.push(json!({"consumer":consumer,"provider":provider,"status":"broken","method":problem.method,"message":problem.message,"counterexample":problem.counterexample,"display":problem.to_string()}));
|
||||
}
|
||||
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(©, &bytes).await {
|
||||
Ok(()) => self.discover_copy(&name, ©, hash.clone()).await,
|
||||
Err(error) => Err(io_error(error)),
|
||||
};
|
||||
let _ = tokio::fs::remove_file(©).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(), ¤t.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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,436 +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;
|
||||
/// One journal entry, with its original ID and timestamp.
|
||||
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub(crate) struct Entry {
|
||||
/// UTC timestamp.
|
||||
time: String,
|
||||
/// UUIDv7 entry ID, ordered by time.
|
||||
id: String,
|
||||
/// Process ID.
|
||||
pid: String,
|
||||
/// Plugin name.
|
||||
plugin: String,
|
||||
/// stderr or core.
|
||||
stream: String,
|
||||
/// Complete log line.
|
||||
line: String,
|
||||
}
|
||||
impl sausage_plugin::SchemaType for Entry {}
|
||||
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!(Entry {
|
||||
id: row.get(0)?,
|
||||
time: row.get(1)?,
|
||||
pid: row.get(2)?,
|
||||
plugin: row.get(3)?,
|
||||
stream: row.get(4)?,
|
||||
line: row.get(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);
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
//! A host for worlds of JSON-RPC plugins.
|
||||
mod activation;
|
||||
pub mod cargo;
|
||||
mod components;
|
||||
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;
|
||||
mod services;
|
||||
mod upgrades;
|
||||
mod watcher;
|
||||
mod watches;
|
||||
pub mod world;
|
||||
pub use host::Host;
|
||||
pub use native::{NativeContext, NativeFuture, NativePlugin};
|
||||
pub use process::ProcessInfo;
|
||||
use serde_json::{Value, json};
|
||||
/// A JSON result or a JSON-RPC error object.
|
||||
pub type RpcResult = Result<Value, Value>;
|
||||
fn error(code: i64, text: &str) -> Value {
|
||||
json!({"code":code,"message":text})
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! Advisory FIFO locks and cleanup of ended processes.
|
||||
use crate::{Host, RpcResult, host::State};
|
||||
use crate::{core::Held, error};
|
||||
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 { 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 { 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 { held: true }))).is_ok() {
|
||||
lock.owner = owner;
|
||||
state.locks.insert(path.into(), lock);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
-160
@@ -1,190 +1,184 @@
|
||||
//! Command parsing and the minimal line console.
|
||||
use anyhow::{Result, bail};
|
||||
//! Sausage: grow a system of WASI actors by talking to it.
|
||||
|
||||
mod broker;
|
||||
mod candidate;
|
||||
mod cargo_actor;
|
||||
mod console;
|
||||
mod fs;
|
||||
mod runtime;
|
||||
mod world;
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use clap::{Parser, Subcommand};
|
||||
use include_dir::{Dir, include_dir};
|
||||
use sausage::{Host, world::World};
|
||||
use std::path::{Path, PathBuf};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::broker::Broker;
|
||||
use crate::world::{NATIVE, Registry, VersionMeta, World};
|
||||
|
||||
/// 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)]
|
||||
#[command(version)]
|
||||
#[command(version, about)]
|
||||
struct Cli {
|
||||
#[arg(long, global = true)]
|
||||
headless: bool,
|
||||
#[arg(long, global = true)]
|
||||
cache_dir: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
command: Option<Cmd>,
|
||||
command: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Create a new world and build the agent actor.
|
||||
Init {
|
||||
/// Folder of the new world.
|
||||
#[arg(default_value = "world")]
|
||||
dir: PathBuf,
|
||||
/// API shape: `openai-responses` or `anthropic-messages`.
|
||||
#[arg(long)]
|
||||
api: Option<String>,
|
||||
/// Base URL of the inference API (for example `https://api.openai.com`).
|
||||
#[arg(long)]
|
||||
base_url: Option<String>,
|
||||
/// Model ID.
|
||||
#[arg(long)]
|
||||
model: Option<String>,
|
||||
},
|
||||
/// Start the broker, all actors, and the console.
|
||||
Run {
|
||||
#[arg(default_value = "world")]
|
||||
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() {
|
||||
"1" | "true" => Ok(true),
|
||||
"" | "0" | "false" => Ok(false),
|
||||
_ => Err("expected 1, true, 0, false, or an empty value".into()),
|
||||
|
||||
fn prompt(question: &str, default: Option<&str>) -> Result<String> {
|
||||
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)?;
|
||||
let v = line.trim().to_string();
|
||||
match (v.is_empty(), default) {
|
||||
(true, Some(d)) => Ok(d.to_string()),
|
||||
(true, None) => bail!("{question} is required"),
|
||||
_ => Ok(v),
|
||||
}
|
||||
}
|
||||
|
||||
// Readline can still wait on its detached thread when the host exits.
|
||||
struct TerminalMode(Option<nix::sys::termios::Termios>);
|
||||
impl Drop for TerminalMode {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mode) = &self.0 {
|
||||
let _ = nix::sys::termios::tcsetattr(
|
||||
std::io::stdin(),
|
||||
nix::sys::termios::SetArg::TCSANOW,
|
||||
mode,
|
||||
);
|
||||
}
|
||||
async fn init(dir: PathBuf, api: Option<String>, base_url: Option<String>, model: Option<String>) -> Result<()> {
|
||||
let world = World::new(std::path::absolute(&dir)?);
|
||||
if world.exists() {
|
||||
bail!("{} is already a Sausage world", dir.display());
|
||||
}
|
||||
}
|
||||
let api = match api {
|
||||
Some(a) => a,
|
||||
None => prompt("API shape (openai-responses or anthropic-messages)", Some("openai-responses"))?,
|
||||
};
|
||||
if api != "openai-responses" && api != "anthropic-messages" {
|
||||
bail!("unknown API shape {api:?}");
|
||||
}
|
||||
let base_url = match base_url {
|
||||
Some(u) => u,
|
||||
None => prompt("API base URL", None)?,
|
||||
};
|
||||
let model = match model {
|
||||
Some(m) => m,
|
||||
None => prompt("Model", None)?,
|
||||
};
|
||||
let token = match std::env::var("SAUSAGE_API_TOKEN") {
|
||||
Ok(t) if !t.is_empty() => t,
|
||||
_ => prompt("API token", None)?,
|
||||
};
|
||||
|
||||
/// Creates a world with editable configuration and builds its embedded agent.
|
||||
async fn init(dir: &Path, cache: Option<&Path>) -> Result<()> {
|
||||
if dir.exists() {
|
||||
bail!("world already exists: {}", dir.display());
|
||||
std::fs::create_dir_all(&world.root)?;
|
||||
let mut reg = Registry::default();
|
||||
reg.subscriptions.entry("agent".into()).or_default().insert("console".into());
|
||||
world.save_registry(®)?;
|
||||
for id in NATIVE {
|
||||
world.ensure_actor_dirs(id)?;
|
||||
world::write_atomic(
|
||||
&world.actor_dir(id).join("status.json"),
|
||||
serde_json::to_string_pretty(&json!({"id": id, "kind": "native", "state": "built-in"}))?.as_bytes(),
|
||||
)?;
|
||||
}
|
||||
let parent = dir
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
.unwrap_or(Path::new("."));
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix(".sausage-init-")
|
||||
.tempdir_in(parent)?;
|
||||
let world = World::create(&staging.path().join("world"))?;
|
||||
std::fs::write(
|
||||
world.root.join("config/agent.yaml"),
|
||||
include_str!("agent.yaml"),
|
||||
)?;
|
||||
std::fs::write(
|
||||
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());
|
||||
|
||||
// The agent's private configuration. Only the agent can read /data.
|
||||
world.ensure_actor_dirs("agent")?;
|
||||
let config = json!({"api": api, "base_url": base_url.trim_end_matches('/'), "model": model, "token": token});
|
||||
let config_path = world.data_dir("agent").join("config.json");
|
||||
std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
|
||||
// Build the agent with the same path as every other Rust actor.
|
||||
let source = world.root.join("broker").join("seed").join("agent");
|
||||
std::fs::create_dir_all(&source)?;
|
||||
AGENT_SOURCE.extract(&source).context("extract agent source")?;
|
||||
println!("Building the agent actor (the first build takes a few minutes) …");
|
||||
let cargo = cargo_actor::Cargo::new(world.clone());
|
||||
let out = cargo.build(&source).await.context("build agent")?;
|
||||
println!("Built in {:.1} s.", out.seconds);
|
||||
|
||||
let runtime = runtime::Runtime::new()?;
|
||||
let broker = Broker::new(world.clone(), runtime)?;
|
||||
let meta = VersionMeta {
|
||||
version: 1,
|
||||
wasm: world.global_path(&out.wasm).unwrap_or_default(),
|
||||
source: Some(source.display().to_string()),
|
||||
built_by: Some(cargo_actor::ID.into()),
|
||||
requested_by: "init".into(),
|
||||
created_at: world::now(),
|
||||
};
|
||||
broker.create_actor("agent", &out.wasm, Some(&source), meta, false).await?;
|
||||
println!("World ready at {}. Start it with: sausage run {}", world.root.display(), dir.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run(dir: PathBuf) -> Result<()> {
|
||||
let world = World::new(std::path::absolute(&dir)?);
|
||||
if !world.exists() {
|
||||
bail!("{} is not a Sausage world. Create one with: sausage init {}", dir.display(), dir.display());
|
||||
}
|
||||
// One host process per world.
|
||||
let lock = std::fs::File::create(world.root.join(".lock"))?;
|
||||
if lock.try_lock().is_err() {
|
||||
bail!("another sausage process runs {}", dir.display());
|
||||
}
|
||||
let runtime = runtime::Runtime::new()?;
|
||||
let broker = Broker::new(world.clone(), runtime)?;
|
||||
|
||||
let rx = broker.open_mailbox("broker");
|
||||
tokio::spawn(broker.clone().run_actor(rx));
|
||||
|
||||
let rx = broker.open_mailbox(cargo_actor::ID);
|
||||
let cargo = Arc::new(cargo_actor::Cargo::new(world.clone()));
|
||||
tokio::spawn(cargo.run_actor(broker.clone(), rx));
|
||||
|
||||
let rx = broker.open_mailbox(console::ID);
|
||||
let console = console::Console::new(broker.clone());
|
||||
tokio::spawn(console.clone().run_mailbox(rx));
|
||||
|
||||
broker.start_all();
|
||||
console.run_input().await;
|
||||
println!("Stopping actors …");
|
||||
broker.shutdown_all().await;
|
||||
drop(lock);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<std::process::ExitCode> {
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let _terminal = TerminalMode(nix::sys::termios::tcgetattr(std::io::stdin()).ok());
|
||||
let command = match cli.command {
|
||||
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! {
|
||||
_=host.exit_requested()=>true,
|
||||
_=term.recv()=>false,
|
||||
_=interrupt.recv(), if headless=>false,
|
||||
result = console, if !headless => {
|
||||
console_result=result;
|
||||
false
|
||||
},
|
||||
};
|
||||
let exit = exit || host.restart_requested();
|
||||
host.stop().await;
|
||||
if let Some(task) = notification_task {
|
||||
task.abort();
|
||||
}
|
||||
if exit {
|
||||
return Ok(std::process::ExitCode::from(75));
|
||||
}
|
||||
console_result?;
|
||||
}
|
||||
match cli.command {
|
||||
Cmd::Init { dir, api, base_url, model } => init(dir, api, base_url, model).await,
|
||||
Cmd::Run { dir } => run(dir).await,
|
||||
}
|
||||
Ok(std::process::ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms));
|
||||
}
|
||||
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(¶ms))
|
||||
} 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}));
|
||||
}
|
||||
}
|
||||
+126
-262
@@ -1,29 +1,37 @@
|
||||
//! Wasmtime engine and process instances.
|
||||
//! Wasmtime engine and actor instances.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
use wasmtime::component::{Component, Linker, ResourceTable};
|
||||
use wasmtime::{Config, Engine, Store};
|
||||
use wasmtime_wasi::cli::{AsyncStdinStream, AsyncStdoutStream};
|
||||
|
||||
use wasmtime_wasi::p2::bindings::CommandPre;
|
||||
|
||||
/// A component with its imports resolved once for this runtime.
|
||||
pub(crate) struct Prepared(Result<CommandPre<ProcessState>, String>);
|
||||
|
||||
use wasmtime_wasi::{I32Exit, WasiCtx, WasiCtxView, WasiView};
|
||||
use wasmtime_wasi::p2::bindings::Command;
|
||||
use wasmtime_wasi::p2::bindings::filesystem::{preopens, types};
|
||||
use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView};
|
||||
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
|
||||
|
||||
/// The HTTP transport hooks supplied to WASI processes.
|
||||
use crate::fs::{ActorFs, ActorFsData, FsExt, Mount};
|
||||
|
||||
pub struct WasiPart {
|
||||
ctx: WasiCtx,
|
||||
table: ResourceTable,
|
||||
}
|
||||
|
||||
impl WasiView for WasiPart {
|
||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HttpHooks {
|
||||
allow: bool,
|
||||
}
|
||||
@@ -58,38 +66,35 @@ impl WasiHttpHooks for HttpHooks {
|
||||
}
|
||||
}
|
||||
|
||||
/// The WASI and HTTP resources owned by one store.
|
||||
pub struct ProcessState {
|
||||
ctx: WasiCtx,
|
||||
table: ResourceTable,
|
||||
pub struct ActorState {
|
||||
wasi: WasiPart,
|
||||
fsx: FsExt,
|
||||
http: WasiHttpCtx,
|
||||
hooks: HttpHooks,
|
||||
fs: sausage_vfs::adapter::Adapter,
|
||||
}
|
||||
|
||||
impl WasiView for ProcessState {
|
||||
impl WasiView for ActorState {
|
||||
fn ctx(&mut self) -> WasiCtxView<'_> {
|
||||
WasiCtxView {
|
||||
ctx: &mut self.ctx,
|
||||
table: &mut self.table,
|
||||
}
|
||||
self.wasi.ctx()
|
||||
}
|
||||
}
|
||||
|
||||
impl WasiHttpView for ProcessState {
|
||||
impl WasiHttpView for ActorState {
|
||||
fn http(&mut self) -> WasiHttpCtxView<'_> {
|
||||
WasiHttpCtxView {
|
||||
ctx: &mut self.http,
|
||||
table: &mut self.table,
|
||||
hooks: &mut self.hooks,
|
||||
}
|
||||
WasiHttpCtxView { ctx: &mut self.http, table: &mut self.wasi.table, hooks: &mut self.hooks }
|
||||
}
|
||||
}
|
||||
|
||||
fn actor_fs(s: &mut ActorState) -> ActorFs<'_> {
|
||||
ActorFs {
|
||||
inner: wasmtime_wasi::filesystem::WasiFilesystemView::filesystem(&mut s.wasi),
|
||||
ext: &mut s.fsx,
|
||||
}
|
||||
}
|
||||
|
||||
/// A Wasmtime engine and linker shared by the world.
|
||||
pub struct Runtime {
|
||||
engine: Engine,
|
||||
linker: Linker<ProcessState>,
|
||||
linker: Linker<ActorState>,
|
||||
}
|
||||
|
||||
/// How an instance ended.
|
||||
@@ -103,6 +108,12 @@ pub enum Exit {
|
||||
Trap(String),
|
||||
}
|
||||
|
||||
impl Exit {
|
||||
pub fn is_clean(&self) -> bool {
|
||||
matches!(self, Exit::Clean)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Exit {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -113,96 +124,63 @@ impl std::fmt::Display for Exit {
|
||||
}
|
||||
}
|
||||
|
||||
/// The filesystem, network, and environment supplied to one process.
|
||||
pub struct InstanceConfig {
|
||||
/// The virtual namespace exposed through the single root preopen.
|
||||
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`.
|
||||
pub network: bool,
|
||||
/// The environment variables supplied to the guest.
|
||||
pub mounts: Vec<Mount>,
|
||||
pub allow_http: bool,
|
||||
pub stderr_log: PathBuf,
|
||||
pub env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// A running guest. Dropping it kills the guest.
|
||||
pub struct Instance {
|
||||
/// The host end of stdin, removed when the process stops.
|
||||
pub stdin: Option<DuplexStream>,
|
||||
stdin: DuplexStream,
|
||||
/// Lines that the guest writes to stdout. Closed when the guest ends.
|
||||
pub stdout: mpsc::Receiver<String>,
|
||||
/// The stderr lines that the process writes.
|
||||
pub stderr: mpsc::Receiver<String>,
|
||||
stderr_task: Option<JoinHandle<()>>,
|
||||
task: Option<JoinHandle<Exit>>,
|
||||
store_ended: tokio::sync::watch::Sender<bool>,
|
||||
}
|
||||
|
||||
impl Drop for Instance {
|
||||
fn drop(&mut self) {
|
||||
let task = self.task.take();
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
task: JoinHandle<Exit>,
|
||||
}
|
||||
|
||||
impl Instance {
|
||||
/// 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
|
||||
/// stdout would never read its stdin.
|
||||
pub async fn send_line(
|
||||
&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 bytes = format!("{line}\n");
|
||||
let write = async {
|
||||
stdin.write_all(bytes.as_bytes()).await?;
|
||||
stdin.flush().await
|
||||
};
|
||||
tokio::pin!(write);
|
||||
let mut open = true;
|
||||
loop {
|
||||
tokio::select! {
|
||||
done = &mut write => return done,
|
||||
out = self.stdout.recv(), if open => match out {
|
||||
Some(out) => route(out),
|
||||
None => open = false,
|
||||
},
|
||||
}
|
||||
}
|
||||
/// Write one line to the guest's stdin.
|
||||
pub async fn send_line(&mut self, line: &str) -> std::io::Result<()> {
|
||||
self.stdin.write_all(line.as_bytes()).await?;
|
||||
self.stdin.write_all(b"\n").await?;
|
||||
self.stdin.flush().await
|
||||
}
|
||||
|
||||
/// Wait until the guest ends.
|
||||
pub async fn wait(&mut self) -> Exit {
|
||||
let result = self.task.as_mut().unwrap().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 {
|
||||
match (&mut self.task).await {
|
||||
Ok(exit) => exit,
|
||||
Err(e) if e.is_cancelled() => Exit::Trap("killed".into()),
|
||||
Err(e) => Exit::Trap(format!("host task failed: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_finished(&self) -> bool {
|
||||
self.task.is_finished()
|
||||
}
|
||||
|
||||
/// Send `shutdown`, close stdin, and wait. Kill the guest after `deadline`.
|
||||
pub async fn stop(mut self, shutdown_line: &str, deadline: Duration) -> Exit {
|
||||
let _ = self.send_line(shutdown_line).await;
|
||||
let _ = self.stdin.shutdown().await;
|
||||
drop(self.stdin);
|
||||
match tokio::time::timeout(deadline, &mut self.task).await {
|
||||
Ok(Ok(exit)) => exit,
|
||||
Ok(Err(e)) => Exit::Trap(format!("host task failed: {e}")),
|
||||
Err(_) => {
|
||||
self.task.abort();
|
||||
let _ = (&mut self.task).await;
|
||||
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.
|
||||
@@ -212,229 +190,115 @@ fn short_trap(full: &str) -> 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());
|
||||
let mut line = line.trim().to_string();
|
||||
let mut end = line.len().min(300);
|
||||
while !line.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
line.truncate(end);
|
||||
line.truncate(300);
|
||||
line
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
/// Create an engine with disk caching and a ten-millisecond epoch tick.
|
||||
pub fn new(cache: &Path) -> Result<Arc<Self>> {
|
||||
pub fn new() -> Result<Arc<Self>> {
|
||||
let mut config = Config::new();
|
||||
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 mut linker: Linker<ProcessState> = Linker::new(&engine);
|
||||
let mut linker: Linker<ActorState> = Linker::new(&engine);
|
||||
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)?;
|
||||
// Replace the filesystem with the commit-on-close layer.
|
||||
linker.allow_shadowing(true);
|
||||
types::add_to_linker::<ActorState, ActorFsData>(&mut linker, actor_fs)?;
|
||||
preopens::add_to_linker::<ActorState, ActorFsData>(&mut linker, actor_fs)?;
|
||||
linker.allow_shadowing(false);
|
||||
|
||||
// Time slices: every 10 ms each running guest yields to the scheduler.
|
||||
let runtime = Arc::new(Self { engine, linker });
|
||||
let ticker = Arc::downgrade(&runtime);
|
||||
let ticker = engine.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
let Some(runtime) = ticker.upgrade() else {
|
||||
break;
|
||||
};
|
||||
runtime.engine.increment_epoch();
|
||||
ticker.increment_epoch();
|
||||
}
|
||||
});
|
||||
|
||||
Ok(runtime)
|
||||
Ok(Arc::new(Self { engine, linker }))
|
||||
}
|
||||
|
||||
/// Compile component bytes through Wasmtime's disk cache.
|
||||
pub fn compile(&self, bytes: &[u8]) -> Result<Component> {
|
||||
Ok(Component::new(&self.engine, bytes)?)
|
||||
pub fn load(&self, wasm: &Path) -> Result<Component> {
|
||||
Component::from_file(&self.engine, wasm)
|
||||
.map_err(anyhow::Error::from)
|
||||
.with_context(|| format!("load {}", wasm.display()))
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(&self, component: &Component) -> Prepared {
|
||||
// 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> {
|
||||
pub fn start(self: &Arc<Self>, component: &Component, cfg: InstanceConfig) -> Result<Instance> {
|
||||
let (host_in, guest_in) = 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();
|
||||
b.args(args);
|
||||
if cfg.network {
|
||||
b.inherit_network()
|
||||
.allow_tcp(true)
|
||||
.allow_udp(true)
|
||||
.allow_ip_name_lookup(true);
|
||||
}
|
||||
b.stdin(AsyncStdinStream::new(guest_in));
|
||||
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 {
|
||||
b.env(k, v);
|
||||
}
|
||||
|
||||
let state = ProcessState {
|
||||
ctx: b.build(),
|
||||
table: ResourceTable::new(),
|
||||
for m in &cfg.mounts {
|
||||
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 {
|
||||
wasi: WasiPart { ctx: b.build(), table: ResourceTable::new() },
|
||||
fsx: FsExt::new(cfg.mounts),
|
||||
http: WasiHttpCtx::new(),
|
||||
hooks: HttpHooks { allow: cfg.network },
|
||||
fs: sausage_vfs::adapter::Adapter::new(cfg.filesystem),
|
||||
hooks: HttpHooks { allow: cfg.allow_http },
|
||||
};
|
||||
|
||||
let (line_tx, line_rx) = mpsc::channel(1024);
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(host_out);
|
||||
let mut bytes = Vec::new();
|
||||
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());
|
||||
let mut lines = BufReader::new(host_out).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line_tx.send(line).await.is_err() {
|
||||
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();
|
||||
cfg.store_ended.send_replace(false);
|
||||
let component = component.clone();
|
||||
let stderr_log = cfg.stderr_log.clone();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut store = Store::new(&rt.engine, state);
|
||||
store.epoch_deadline_async_yield_and_update(1);
|
||||
let result = async {
|
||||
let cmd = prepared
|
||||
.map_err(wasmtime::Error::msg)?
|
||||
.instantiate_async(&mut store)
|
||||
.await?;
|
||||
let cmd = Command::instantiate_async(&mut store, &component, &rt.linker).await?;
|
||||
cmd.wasi_cli_run().call_run(&mut store).await
|
||||
}
|
||||
.await;
|
||||
match result {
|
||||
let exit = match result {
|
||||
Ok(Ok(())) => Exit::Clean,
|
||||
Ok(Err(())) => Exit::Code(1),
|
||||
Err(e) => match e.downcast_ref::<I32Exit>() {
|
||||
Some(I32Exit(0)) => Exit::Clean,
|
||||
Some(I32Exit(c)) => Exit::Code(*c),
|
||||
None => {
|
||||
// Keep the full backtrace in the log, and a short reason in the status.
|
||||
let full = format!("{e:?}");
|
||||
if let Some((journal, pid, plugin)) = &trap_log {
|
||||
journal.write(pid, plugin, "core", &full);
|
||||
}
|
||||
log_note(&stderr_log, &format!("trap details:\n{full}"));
|
||||
Exit::Trap(short_trap(&full))
|
||||
}
|
||||
},
|
||||
};
|
||||
// Process exit closes every file, so a clean exit commits.
|
||||
if exit.is_clean() {
|
||||
store.data_mut().fsx.commit_all();
|
||||
}
|
||||
exit
|
||||
});
|
||||
|
||||
Ok(Instance {
|
||||
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");
|
||||
Ok(Instance { stdin: host_in, stdout: line_rx, task })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+198
-46
@@ -1,71 +1,223 @@
|
||||
//! World folders and atomic build output helpers.
|
||||
use anyhow::{Result, bail};
|
||||
//! On-disk layout of a Sausage workspace ("world").
|
||||
//!
|
||||
//! ```text
|
||||
//! <world>/
|
||||
//! sausage.json registry: actors, versions, subscriptions
|
||||
//! actor/<id>/public/ written by the actor, read-only for others
|
||||
//! actor/<id>/source/ source of the running version (broker)
|
||||
//! actor/<id>/status.json state of the actor (broker)
|
||||
//! actor/<id>/stderr.log stderr of the actor (broker)
|
||||
//! private/<id>/data/ private state of the actor
|
||||
//! broker/versions/<id>/<n>/ actor.wasm, source/, meta.json, snapshot/
|
||||
//! archive/ removed actors
|
||||
//! ```
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fs::TMP_PREFIX;
|
||||
|
||||
/// IDs of the actors that the host implements natively.
|
||||
pub const NATIVE: &[&str] = &["broker", "console", "cargo"];
|
||||
|
||||
#[derive(Clone)]
|
||||
/// The absolute host folder shared by all processes.
|
||||
pub struct World {
|
||||
/// The absolute path of the world on the host.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize, Clone)]
|
||||
pub struct Registry {
|
||||
pub actors: BTreeMap<String, ActorRecord>,
|
||||
/// Publisher ID → subscriber IDs.
|
||||
pub subscriptions: BTreeMap<String, BTreeSet<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ActorRecord {
|
||||
pub version: u64,
|
||||
pub next_version: u64,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
#[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 spawn or upgrade.
|
||||
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 {
|
||||
/// Validate reserved root names and resolve the world path.
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
for name in ["proc", "run", "self"] {
|
||||
if path.join(name).symlink_metadata().is_ok() {
|
||||
bail!("reserved world path: {name}");
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
pub fn registry_path(&self) -> PathBuf {
|
||||
self.root.join("sausage.json")
|
||||
}
|
||||
pub fn actor_dir(&self, id: &str) -> PathBuf {
|
||||
self.root.join("actor").join(id)
|
||||
}
|
||||
pub fn actors_root(&self) -> PathBuf {
|
||||
self.root.join("actor")
|
||||
}
|
||||
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.root.join("private").join(id).join("data")
|
||||
}
|
||||
pub fn versions_dir(&self, id: &str) -> PathBuf {
|
||||
self.root.join("broker").join("versions").join(id)
|
||||
}
|
||||
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
|
||||
self.versions_dir(id).join(v.to_string())
|
||||
}
|
||||
pub fn scratch_dir(&self) -> PathBuf {
|
||||
self.root.join("broker").join("scratch")
|
||||
}
|
||||
|
||||
pub fn exists(&self) -> bool {
|
||||
self.registry_path().is_file()
|
||||
}
|
||||
|
||||
/// Create the directories of one actor.
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn load_registry(&self) -> Result<Registry> {
|
||||
let text = std::fs::read_to_string(self.registry_path())
|
||||
.with_context(|| format!("no Sausage world at {}", self.root.display()))?;
|
||||
Ok(serde_json::from_str(&text)?)
|
||||
}
|
||||
|
||||
pub fn save_registry(&self, reg: &Registry) -> Result<()> {
|
||||
write_atomic(&self.registry_path(), serde_json::to_string_pretty(reg)?.as_bytes())
|
||||
}
|
||||
|
||||
/// 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 clean = |base: PathBuf, rest: &str| -> Result<PathBuf> {
|
||||
let mut out = base.clone();
|
||||
for c in Path::new(rest).components() {
|
||||
match c {
|
||||
std::path::Component::Normal(n) => out.push(n),
|
||||
std::path::Component::CurDir | std::path::Component::RootDir => {}
|
||||
_ => bail!("path must not contain '..': {path}"),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
};
|
||||
if let Some(rest) = path.strip_prefix("/actor/") {
|
||||
return clean(self.actors_root(), rest);
|
||||
}
|
||||
Ok(Self {
|
||||
root: path.canonicalize()?,
|
||||
})
|
||||
}
|
||||
/// Create the folders and empty boot configuration of a new world.
|
||||
pub fn create(path: &Path) -> Result<Self> {
|
||||
if path.exists() {
|
||||
bail!("world already exists: {}", path.display());
|
||||
if let Some(rest) = path.strip_prefix("/public/").or(path.strip_prefix("/public").filter(|r| r.is_empty())) {
|
||||
return clean(self.public_dir(sender), rest);
|
||||
}
|
||||
for dir in ["plugins", "state/core", "config"] {
|
||||
std::fs::create_dir_all(path.join(dir))?;
|
||||
if let Some(rest) = path.strip_prefix("/data/").or(path.strip_prefix("/data").filter(|r| r.is_empty())) {
|
||||
return clean(self.data_dir(sender), rest);
|
||||
}
|
||||
std::fs::write(path.join("config/boot.yaml"), "[]\n")?;
|
||||
Self::open(path)
|
||||
if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
bail!("cannot resolve path {path} for {sender}: use /actor/<id>/..., /public/... or /data/...")
|
||||
}
|
||||
/// Return the folder that contains a plugin component.
|
||||
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 {
|
||||
self.root.join("state").join(name)
|
||||
|
||||
/// The path of a host file in the global filesystem, if it has one.
|
||||
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<()> {
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
let dir = path.parent().context("path has no parent")?;
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let tmp = dir.join(format!(
|
||||
"{TMP_PREFIX}host-{}-{}",
|
||||
std::process::id(),
|
||||
path.file_name().and_then(|n| n.to_str()).unwrap_or("file")
|
||||
));
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
std::fs::rename(tmp, path)?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
Ok(())
|
||||
}
|
||||
/// Replace a build copy with a source tree, without target folders.
|
||||
pub fn replace_source(from: &Path, to: &Path) -> Result<()> {
|
||||
if to.exists() {
|
||||
std::fs::remove_dir_all(to)?;
|
||||
}
|
||||
copy_tree(from, to)
|
||||
}
|
||||
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
||||
|
||||
/// Copy a directory tree. Skips `target/` folders and uncommitted files.
|
||||
pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
|
||||
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?;
|
||||
if entry.file_name() == "target" {
|
||||
let name = entry.file_name();
|
||||
let name_str = name.to_string_lossy();
|
||||
if name_str.starts_with(TMP_PREFIX) {
|
||||
continue;
|
||||
}
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_tree(&entry.path(), &to.join(entry.file_name()))?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), to.join(entry.file_name()))?;
|
||||
let ty = entry.file_type()?;
|
||||
let dst = to.join(&name);
|
||||
if ty.is_dir() {
|
||||
if name_str == "target" || name_str == ".git" {
|
||||
continue;
|
||||
}
|
||||
copy_tree(&entry.path(), &dst)?;
|
||||
} else if ty.is_file() {
|
||||
std::fs::copy(entry.path(), &dst)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace the directory `dst` with a copy of `src`, as close to atomic as a
|
||||
/// directory can be: copy to a sibling, then swap with two renames.
|
||||
pub fn replace_tree(src: &Path, dst: &Path) -> 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_tree(src, &fresh)?;
|
||||
} 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(())
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "cargo-c"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
sausage-plugin = "0.1"
|
||||
|
||||
[build-dependencies]
|
||||
cc = "1"
|
||||
|
||||
[workspace]
|
||||
@@ -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 +0,0 @@
|
||||
int c_add(int a, int b) { return a + b; }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
missing_function();
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
[package]
|
||||
name = "cargo-hang"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
@@ -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 +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,15 +0,0 @@
|
||||
[package]
|
||||
name = "cargo-valid"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
sausage_plugin = { package = "sausage-plugin", version = "0.1", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["dep:sausage_plugin"]
|
||||
|
||||
[workspace]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
@@ -1,4 +0,0 @@
|
||||
//! Produces a warning so the build response can retain diagnostics.
|
||||
fn main() {
|
||||
println!("cargo:warning=fixture build warning");
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
fixture
|
||||
@@ -1,165 +0,0 @@
|
||||
//! SDK plugin and tests for the native build pipeline.
|
||||
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");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -1,146 +0,0 @@
|
||||
//! Generated native discovery, proc projection, and a real SDK import consumer.
|
||||
use sausage::{Host, world::World};
|
||||
use sausage_vfs::{Layer, Open};
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
async fn call(host: &Host, method: &str, params: Value) -> Value {
|
||||
// Same call budget as the core/watches host tests.
|
||||
tokio::time::timeout(Duration::from_secs(20), host.call(method, params))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn read(host: &Host, path: &str) -> Value {
|
||||
let fs = host.filesystem().await.unwrap();
|
||||
let file = fs.open(path, Open::default()).await.unwrap();
|
||||
let bytes = file
|
||||
.read(0, file.stat().await.unwrap().size as usize)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
|
||||
/// No saved core.json: the RPC, proc file, importer and admission use generated types.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn generated_core_import_runs_under_wasi() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let world = dir.path().join("world");
|
||||
World::create(&world).unwrap();
|
||||
let host = Host::start_with_cache(&world, Path::new(super::common::CACHE))
|
||||
.await
|
||||
.unwrap();
|
||||
let doc = call(&host, "core.rpc.discover", json!({})).await;
|
||||
assert_eq!(doc["openrpc"], "1.2.6");
|
||||
assert_eq!(doc["info"]["title"], "core");
|
||||
assert_eq!(read(&host, "/proc/core/discover.json").await, doc);
|
||||
let fs = host.filesystem().await.unwrap();
|
||||
assert!(
|
||||
fs.list("/proc/core")
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|e| e.name == "discover.json")
|
||||
);
|
||||
assert!(
|
||||
fs.open(
|
||||
"/proc/core/discover.json",
|
||||
Open {
|
||||
write: true,
|
||||
..Open::default()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let schemas = &doc["components"]["schemas"];
|
||||
assert_eq!(schemas["Held"]["properties"]["held"]["type"], "boolean");
|
||||
assert_eq!(schemas["Entry"]["properties"]["line"]["type"], "string");
|
||||
let problems = sausage_interface::compat::check(&doc, &doc);
|
||||
assert!(problems.is_empty(), "{problems:?}");
|
||||
|
||||
let source = dir.path().join("consumer");
|
||||
std::fs::create_dir_all(source.join("imports")).unwrap();
|
||||
std::fs::create_dir_all(source.join("src")).unwrap();
|
||||
// This is exactly the user's copy-from-proc workflow, not a JSON fixture.
|
||||
std::fs::write(source.join("imports/core.json"), doc.to_string()).unwrap();
|
||||
let sdk = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap()
|
||||
.join("sdk");
|
||||
std::fs::write(
|
||||
source.join("Cargo.toml"),
|
||||
format!(
|
||||
r#"
|
||||
[package]
|
||||
name = "native-core-consumer"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
[dependencies]
|
||||
sausage-plugin = {{ path = {sdk:?} }}
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
source.join("src/main.rs"),
|
||||
r#"
|
||||
use sausage_plugin::{Result, rpc, serde_json::json};
|
||||
sausage_plugin::import!("core");
|
||||
struct Consumer;
|
||||
#[rpc]
|
||||
impl Consumer {
|
||||
/// Exercise actual generated parameter and result types.
|
||||
async fn run(&self) -> Result<bool> {
|
||||
let held = core::lock(core::Lock { path: "/typed-lock".into(), wait: Some(false) }).await?;
|
||||
let filters: core::Log = sausage_plugin::serde_json::from_value(json!({"limit":1}))?;
|
||||
for entry in core::log(filters).await? {
|
||||
let _: String = entry.line;
|
||||
}
|
||||
core::unlock(core::Path { path: "/typed-lock".into() }).await?;
|
||||
let doc = core::rpc_discover().await?;
|
||||
Ok(held.held && doc["info"]["title"] == "core")
|
||||
}
|
||||
}
|
||||
fn main() { sausage_plugin::run(Consumer) }
|
||||
"#,
|
||||
)
|
||||
.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(source.join("Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(target)
|
||||
.status()
|
||||
.unwrap()
|
||||
.success()
|
||||
);
|
||||
std::fs::copy(
|
||||
target.join("wasm32-wasip2/debug/native-core-consumer.wasm"),
|
||||
world.join("candidate.wasm"),
|
||||
)
|
||||
.unwrap();
|
||||
let activation = call(
|
||||
&host,
|
||||
"core.activate",
|
||||
json!({"plugin":"consumer","wasm":"/candidate.wasm"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(activation["consumer"]["interface_check"], "passed");
|
||||
assert_eq!(call(&host, "consumer.run", json!({})).await, true);
|
||||
assert_eq!(
|
||||
read(&host, "/proc/core/graph.json").await["plugins"]["consumer"]["imports"]["core"]["status"],
|
||||
"compatible"
|
||||
);
|
||||
host.stop().await;
|
||||
}
|
||||
@@ -1,11 +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 core_discovery;
|
||||
mod sdk;
|
||||
mod watches;
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,592 +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 {
|
||||
send(json!({"jsonrpc":"2.0", "method":"core.notify", "params":{
|
||||
"name":"busy-ready", "params":{}
|
||||
}}));
|
||||
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(¬ification.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!({})
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
[package]
|
||||
name = "activation-guest"
|
||||
name = "bench"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,45 @@
|
||||
//! {"type":"bench","target":"echo","n":1000}: ping-pong with the target, one
|
||||
//! message at a time, and report the round-trip times.
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let mut out = std::io::stdout().lock();
|
||||
let mut lines = std::io::stdin().lock().lines();
|
||||
while let Some(Ok(line)) = lines.next() {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
match msg["body"]["type"].as_str() {
|
||||
Some("shutdown") => break,
|
||||
Some("bench") => {
|
||||
let target = msg["body"]["target"].as_str().unwrap_or("echo").to_string();
|
||||
let n = msg["body"]["n"].as_u64().unwrap_or(1000);
|
||||
let mut times = Vec::new();
|
||||
let started = Instant::now();
|
||||
for i in 0..n {
|
||||
let t = Instant::now();
|
||||
writeln!(out, "{}", json!({"to": target, "id": i, "body": {"type": "ping", "i": i}})).unwrap();
|
||||
out.flush().unwrap();
|
||||
// Wait for the reply.
|
||||
loop {
|
||||
let Some(Ok(l)) = lines.next() else { return };
|
||||
let Ok(r) = serde_json::from_str::<Value>(&l) else { continue };
|
||||
if r["reply_to"].as_str().is_some_and(|s| s.ends_with(&format!("/{i}"))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
times.push(t.elapsed().as_micros() as u64);
|
||||
}
|
||||
let total = started.elapsed().as_secs_f64();
|
||||
times.sort();
|
||||
let p = |q: f64| times[((times.len() as f64 - 1.0) * q) as usize];
|
||||
let reply = json!({"type": "bench_result", "n": n, "total_s": total,
|
||||
"msgs_per_s": (n as f64 / total).round(),
|
||||
"p50_us": p(0.5), "p90_us": p(0.9), "p99_us": p(0.99), "max_us": p(1.0)});
|
||||
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
[package]
|
||||
name = "consumer"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
[dependencies]
|
||||
sausage-plugin = { path = "../../sdk" }
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "cargo-fail"
|
||||
name = "crasher"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[workspace]
|
||||
[dependencies]
|
||||
@@ -0,0 +1,6 @@
|
||||
//! A bad version: it starts, waits 3 seconds (long enough for the smoke
|
||||
//! test), then panics. Used to test the automatic rollback.
|
||||
fn main() {
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
panic!("this version is broken");
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
[package]
|
||||
name = "probe"
|
||||
name = "echo"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
wasip2 = "1"
|
||||
@@ -0,0 +1,15 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
fn main() {
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line.unwrap()) else { continue };
|
||||
if msg["body"]["type"] == "shutdown" {
|
||||
break;
|
||||
}
|
||||
let env = json!({"to": msg["from"], "reply_to": msg["id"], "body": {"type": "echo", "got": msg["body"]}});
|
||||
writeln!(out, "{env}").unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "www"
|
||||
name = "fetch"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
sausage-plugin = { path = "../../sdk" }
|
||||
serde_json = "1"
|
||||
wstd = "0.6"
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Fetches URLs over HTTP(S) and writes the body to /public/fetch/<n>.
|
||||
//! Reads stdin and runs requests at the same time: while a fetch runs,
|
||||
//! `ping` still answers at once.
|
||||
use serde_json::{Value, json};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use wstd::http::{Body, Client, Request};
|
||||
use wstd::io::{AsyncRead, AsyncWrite};
|
||||
|
||||
type Out = Rc<RefCell<Vec<String>>>;
|
||||
|
||||
async fn flush(out: &Out) {
|
||||
let lines: Vec<String> = out.borrow_mut().drain(..).collect();
|
||||
let mut stdout = wstd::io::stdout();
|
||||
for l in lines {
|
||||
let _ = stdout.write_all(format!("{l}\n").as_bytes()).await;
|
||||
}
|
||||
let _ = stdout.flush().await;
|
||||
}
|
||||
|
||||
async fn fetch(n: u64, msg: Value, out: Out) {
|
||||
let url = msg["body"]["url"].as_str().unwrap_or_default().to_string();
|
||||
let started = wstd::time::Instant::now();
|
||||
let result = async {
|
||||
let req = Request::get(&url).body(Body::empty()).map_err(|e| e.to_string())?;
|
||||
let resp = Client::new().send(req).await.map_err(|e| format!("{e:?}"))?;
|
||||
let status = resp.status().as_u16();
|
||||
let bytes = resp.into_body().contents().await.map_err(|e| format!("{e:?}"))?.to_vec();
|
||||
std::fs::create_dir_all("/public/fetch").map_err(|e| e.to_string())?;
|
||||
let path = format!("/public/fetch/{n}");
|
||||
std::fs::write(&path, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok::<_, String>((status, bytes.len(), path))
|
||||
}
|
||||
.await;
|
||||
let ms = started.elapsed().as_millis();
|
||||
let body = match result {
|
||||
Ok((status, size, path)) => {
|
||||
let id = std::env::var("SAUSAGE_ACTOR").unwrap_or("fetch".into());
|
||||
json!({"type": "fetched", "status": status, "bytes": size, "path": path.replacen("/public", &format!("/actor/{id}/public"), 1), "ms": ms})
|
||||
}
|
||||
Err(e) => json!({"type": "error", "reason": e, "ms": ms}),
|
||||
};
|
||||
out.borrow_mut().push(json!({"to": msg["from"], "reply_to": msg["id"], "body": body}).to_string());
|
||||
flush(&out).await;
|
||||
}
|
||||
|
||||
fn main() {
|
||||
wstd::runtime::block_on(async {
|
||||
let out: Out = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut stdin = wstd::io::stdin();
|
||||
let mut buf = Vec::new();
|
||||
let mut chunk = vec![0u8; 8192];
|
||||
let mut n = 0u64;
|
||||
loop {
|
||||
let Ok(len) = stdin.read(&mut chunk).await else { break };
|
||||
if len == 0 {
|
||||
break;
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..len]);
|
||||
while let Some(pos) = buf.iter().position(|b| *b == b'\n') {
|
||||
let line: Vec<u8> = buf.drain(..=pos).collect();
|
||||
let Ok(msg) = serde_json::from_slice::<Value>(&line) else { continue };
|
||||
match msg["body"]["type"].as_str() {
|
||||
Some("shutdown") => return,
|
||||
Some("fetch") => {
|
||||
n += 1;
|
||||
wstd::runtime::spawn(fetch(n, msg, out.clone())).detach();
|
||||
}
|
||||
Some("ping") => {
|
||||
out.borrow_mut().push(json!({"to": msg["from"], "reply_to": msg["id"], "body": {"type": "pong"}}).to_string());
|
||||
flush(&out).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "fstest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Filesystem probe for the spike. Keeps files open across messages.
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
fn main() {
|
||||
let mut open: HashMap<String, std::fs::File> = HashMap::new();
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
let b = &msg["body"];
|
||||
let path = b["path"].as_str().unwrap_or_default().to_string();
|
||||
let r = |res: std::io::Result<Value>| match res {
|
||||
Ok(v) => v,
|
||||
Err(e) => json!({"type": "error", "error": e.to_string()}),
|
||||
};
|
||||
let reply = match b["type"].as_str() {
|
||||
Some("shutdown") => break,
|
||||
Some("write") => r(std::fs::write(&path, b["text"].as_str().unwrap_or_default()).map(|_| json!({"type": "ok"}))),
|
||||
Some("open") => r((|| {
|
||||
let mut f = std::fs::File::create(&path)?;
|
||||
f.write_all(b["text"].as_str().unwrap_or_default().as_bytes())?;
|
||||
open.insert(path.clone(), f);
|
||||
Ok(json!({"type": "ok", "open": true}))
|
||||
})()),
|
||||
Some("append") => r((|| {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
f.write_all(b["text"].as_str().unwrap_or_default().as_bytes())?;
|
||||
open.insert(path.clone(), f);
|
||||
Ok(json!({"type": "ok", "open": true}))
|
||||
})()),
|
||||
Some("close") => json!({"type": "ok", "closed": open.remove(&path).is_some()}),
|
||||
Some("read") => r(std::fs::read_to_string(&path).map(|t| json!({"type": "content", "text": t}))),
|
||||
Some("list") => r(std::fs::read_dir(&path).map(|rd| {
|
||||
let mut names: Vec<String> = rd.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
||||
names.sort();
|
||||
json!({"type": "entries", "names": names})
|
||||
})),
|
||||
Some("remove") => r(std::fs::remove_file(&path).map(|_| json!({"type": "ok"}))),
|
||||
Some("crash") => panic!("asked to crash"),
|
||||
_ => json!({"type": "error", "error": "unknown message"}),
|
||||
};
|
||||
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "notes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -0,0 +1,39 @@
|
||||
//! Keeps a list of notes in /data/notes.json and publishes them as
|
||||
//! /public/notes.txt. Version 2: adds `count` and no longer crashes.
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
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(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
let body = &msg["body"];
|
||||
let reply = match body["type"].as_str() {
|
||||
Some("shutdown") => break,
|
||||
Some("add") => {
|
||||
notes.push(body["text"].as_str().unwrap_or_default().to_string());
|
||||
save(¬es);
|
||||
json!({"type": "added", "count": notes.len()})
|
||||
}
|
||||
Some("list") => json!({"type": "notes", "items": notes}),
|
||||
Some("count") => json!({"type": "count", "count": notes.len(), "version": 2}),
|
||||
// Version 2 fixes the crash.
|
||||
Some("crash") => json!({"type": "not_crashing", "version": 2}),
|
||||
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||
};
|
||||
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{"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"}}
|
||||
@@ -2,6 +2,6 @@
|
||||
name = "notes"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
sausage-plugin = { path = "../../sdk" }
|
||||
serde_json = "1"
|
||||
|
||||
+33
-80
@@ -1,84 +1,37 @@
|
||||
//! Persistent notes in `/state/notes/notes.json`.
|
||||
//! Each call has its own process. The add method locks the file before its read-modify-write operation.
|
||||
//! Atomic replacement lets readers observe a complete list without a lock.
|
||||
use sausage_plugin::serde_json;
|
||||
use sausage_plugin::{Result, lock, rpc, schema, state_dir, write_atomic};
|
||||
/// The stateless notes plugin reads its file on each call.
|
||||
struct Notes;
|
||||
/// Parameters for a new note.
|
||||
#[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(¬es)?)?;
|
||||
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)
|
||||
//! Keeps a list of notes in /data/notes.json and publishes them as
|
||||
//! /public/notes.txt.
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// Tests run in fresh worlds under the WASI test runner.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
/// The core lock protects two sequential additions and their counts.
|
||||
#[sausage_plugin::test]
|
||||
async fn add_appends_and_counts() {
|
||||
assert_eq!(
|
||||
Notes
|
||||
.add(AddParams {
|
||||
text: "milk".into()
|
||||
})
|
||||
.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.
|
||||
#[test]
|
||||
fn list_is_empty_without_a_file() {
|
||||
assert!(Notes.list().unwrap().is_empty());
|
||||
fn main() {
|
||||
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
let mut out = std::io::stdout().lock();
|
||||
for line in std::io::stdin().lock().lines() {
|
||||
let Ok(line) = line else { break };
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
let body = &msg["body"];
|
||||
let reply = match body["type"].as_str() {
|
||||
Some("shutdown") => break,
|
||||
Some("add") => {
|
||||
notes.push(body["text"].as_str().unwrap_or_default().to_string());
|
||||
save(¬es);
|
||||
json!({"type": "added", "count": notes.len()})
|
||||
}
|
||||
Some("list") => json!({"type": "notes", "items": notes}),
|
||||
Some("crash") => panic!("asked to crash"),
|
||||
_ => json!({"type": "error", "reason": "unknown message"}),
|
||||
};
|
||||
writeln!(out, "{}", json!({"to": msg["from"], "reply_to": msg["id"], "body": reply})).unwrap();
|
||||
out.flush().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{"send": {"type": "add", "text": "test note"}}
|
||||
{"expect": {"type": "added"}}
|
||||
{"send": {"type": "list"}}
|
||||
{"expect": {"type": "notes"}}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user