Compare commits

...
Author SHA1 Message Date
SAU-317 implementer c54edea596 Name the received JSON type in non-object params errors
ci / check (push) Failing after 2m38s
Keep -32602 and existing validation order; tell callers to send the object itself.
Cover host routing, stdout fast paths, SDK calls and generated dispatch.

Card: SAU-317
2026-10-28 19:16:39 +00:00
Sausage Worker 9ef73e6bc7 Use the world SDK for cargo builds and seed it only when missing 2026-10-28 08:39:36 +00:00
SAU-249 implementer dc81f78c20 Add shared SQLite open macro with WASI crash recovery and in-memory temp storage
ci / check (push) Failing after 3m53s
Card: SAU-249
2026-10-28 00:33:33 +00:00
SAU-158 implementer 9a8c143856 Return compact activation summaries instead of discovery schemas
ci / check (push) Failing after 2m40s
Card: SAU-158
2026-10-26 22:24:43 +00:00
surma a14f051f97 Merge 'e26260d36eb8e7bf9e0b7edfed90c0dcff77fc19'
ci / check (push) Failing after 11m35s
2026-10-26 13:59:16 +00:00
surma e26260d36e Spool native cargo build and test streams to persistent world logs 2026-10-26 13:54:15 +00:00
SAU-143 implementer 037377fd27 host: preserve journals, full traps and embedded agent output
Write new journal entries under logs/core and merge read-only legacy
history without moving SQLite/WAL files. Atomically spool failed batches,
retaining unspoolable entries for event-driven retries. Journal full trap
diagnostics before UTF-8-safe status display. Port session overflow archives
and followable line/byte excerpts to the still-shipped embedded agent.
Document retention/recovery and cover the contracts with regressions.
Include one rustfmt-only normalization in the existing SDK WASI test.

Card: SAU-143
2026-10-26 13:40:15 +00:00
surma 4be9ae41de Merge 'card/sau-126' 2026-10-26 12:49:01 +00:00
SAU-135 implementer 168795b408 seed: carry restart approval policy into new worlds
Make legacy core.exit share core.restart's supervisor gate and pending
request. Seed the stable agent prompt and boot comments with the approval
and shutdown contract, and check real init output under a deadline.
Cover both aliases' gate, response grace, coalescing and exit status.

Card: SAU-135
2026-10-26 10:29:12 +00:00
SAU-134 implementer 8c46d9b9d0 host: add supervisor-gated clean core.restart RPC
Return success before scheduling the existing shutdown path, coalesce pending
requests, and mark #loop invocations with SAUSAGE_LOOP=1. Keep core.exit
compatibility and document explicit per-call approval for all agents.

Card: SAU-134
2026-10-26 09:16:10 +00:00
surma c15e50d752 Bound native cargo builds and individual WASI tests with cancellable deadlines 2026-10-25 20:14:32 +00:00
surma 895be183c9 agent: a thinking level in agent.yaml and per call
ci / check (push) Successful in 9m11s
- thinking (off, low, medium, high, xhigh, max) is an enum: serde rejects
  unknown levels in the config, and the schema of agent.chat lists them.
  reasoning_effort, a second name for the same setting, is gone.
- agent.chat takes an optional thinking level for one turn.
- off really switches thinking off: OpenAI gets the effort none, and
  Anthropic gets disabled thinking (newer Claude models think by
  default). A model that cannot switch thinking off gets low.
- Unset means the model's default. Anthropic thinking blocks now return
  unless thinking is off, because newer models think by default.
- The template sets thinking: medium and shows it in each example, with
  current model names.
- Checked against the proxy with gpt-6-luna, glm-5.3-flash, and
  claude-haiku-5-5.
2026-10-23 13:01:36 +01:00
surma b536beb069 YAML configuration, init without a wizard, and openai-chat-completions
ci / check (push) Successful in 15m52s
- /config/agent.yaml and /config/boot.yaml replace the JSON files, read
  with serde_yaml_ng (also in the agent, inside WASM).
- sausage init asks nothing and reads no SAUSAGE_* variables. It writes
  commented templates with an example for each API, and prints the path
  to edit. Until the agent is configured, agent.chat names the missing
  field. allow_no_token: true permits a local model without a key.
- The agent speaks openai-chat-completions, the format of OpenRouter,
  Ollama, and most providers. base_url follows each SDK: with /v1 for
  the OpenAI shapes, without it for Anthropic.
- Checked against the proxy with glm-5.3-flash: the agent wrote, built,
  and activated a counter plugin in 55 s.
2026-10-23 11:27:48 +01:00
surma ab1adbd5e3 flake: link host code with mold on Linux; DECISIONS: mold, and no LTO
mold applies to the host target only, in the dev shell, the crane builds,
and the packaged binary (proc macros and build scripts of plugins). A
rebuild of the host test binary after a change takes 0.9 s (15 s with
GNU ld). Measured LTO: no gain worth 4 more minutes per release build.
2026-10-23 10:20:39 +01:00
surma 9fb1e05686 sdk tests: run the wasmtime CLI without a disk cache; show its stderr on EOF
The Nix sandbox has no writable home folder, so wasmtime run failed to
create its cache folder.
2026-10-23 09:26:12 +01:00
surma 1617dfc1ee console: one sequential loop; host tests in one binary
The console reads a line, runs it to the end, and prints the result,
with and without a terminal. Notifications print while a command runs.
Ctrl-C during a command stops only the wait. 314 lines (was 480).

All host integration tests are modules of cli/tests/host/, so they run
in one parallel pool, and the probes build once. Warm, the 184 host
tests take 24 s (was about 45 s as seven binaries in sequence), and
cargo test --workspace takes 45 s.
2026-10-23 09:14:26 +01:00
surma e3935031ac console: piped lines run in order; cargo-valid: empty_world allows the first folder of the package path
The runner mounts the package at its host path, so / shows its first
folder: tmp locally, build in the Nix sandbox.
2026-10-23 08:48:14 +01:00
surma c926f3bc72 tests: remove the singleton stress test; #[ignore] means disabled again
128 fast activate/revert cycles are not a realistic load. restarts_service
covers the restart of a service after an upgrade. nix flake check runs
the normal tests only.
2026-10-23 08:31:48 +01:00
surma 41818107a2 tests: fast host suites
The timing rules are in two tests (upgrade_and_discovery_deadlines,
stop_deadlines). All other tests use a grace period of 0 or core.stop,
poll with deadlines instead of sleeping, and use the persistent cache in
CARGO_TARGET_TMPDIR. The 128-cycle singleton test is an ignored stress
test that nix flake check runs; the normal run has a 4-cycle sample.
Warm, the activation suite takes 12 s (was 47 s), and cargo test
--workspace takes 65 s.
2026-10-23 08:20:49 +01:00
surma 1386acc924 cargo.build: write the SDK copy in the cache only when a build needs it
An unwritable cache makes only a build fail, with the folder in
data.output. The host still starts.
2026-10-23 08:09:37 +01:00
surma 93f092e02b cargo.build: a persistent build cache shared by all worlds
- The cargo target folder is <cache>/cargo-target, next to Wasmtime's
  cache, which moves to <cache>/wasmtime: Wasmtime's cleanup worker
  deletes unknown entries in its folder, also Cargo's artifacts.
- Builds use the SDK at <cache>/sdk/<content hash>/, so cargo reuses
  the compiled SDK in every world. /plugins/cargo/sdk/ stays for agents.
- Tests run in the release profile and share compiled crates with the
  release build.
- The cargo and cli tests use a persistent cache in CARGO_TARGET_TMPDIR:
  warm, the cargo suite takes 11 s (was 235 s) and the cli suite 6 s
  (was 149 s).
- nix flake check runs ignored stress tests. AGENTS.md: rules for fast
  tests.
2026-10-23 08:05:15 +01:00
surma a090e1fd57 host: review fixes for cargo.build, the test runner, the console, and main; tests for every PROTOCOL.md rule
cargo.build and the runner (review T22R): builds use the network again;
core.unlock and core.notify in order under the runner; quiet test
output; manifest preparation keeps [workspace], target tables, renamed
and optional entries; cargo.rpc.discover; -32004 with data.output;
retention of 10 builds; light, empty test worlds with an in-memory
journal; only CARGO_/RUST_/SAUSAGE_ variables; stdin is null; the runner
through --config. A compile cache that cannot be created is not fatal.
Activation replaces plugin.wasm without a gap.

Console and main: init ships the workspace Cargo.lock and creates the
world atomically; each console command runs in its own task, so ps,
stop, and kill work during a slow call; SAUSAGE_HEADLESS; the terminal
mode is restored; signals during boot; chat text is not taken as a
command.

Tests: 20 more tests for PROTOCOL.md sections 2 to 10; the cargo tests
choose the libtest binary from cargo's JSON output and time out.
2026-10-23 07:47:39 +01:00
surma 61d5d5f817 sausage-plugin: the crate docs are the manual for agents; remove PROPOSAL.md
Its content lives in PROTOCOL.md (the contract), DECISIONS.md (the
reasons), and the SDK docs (how to write a plugin).
2026-10-23 02:46:47 +01:00
surma c728429d04 sdk, agent: review fixes (T18R)
- The serve loop ends the process after EOF or exit_after_response,
  also with background tasks or listeners.
- One schema generator per direction for the whole document, so types
  with the same name get distinct names.
- The agent gives the complete error (code, message, data) to the
  model, writes SKIP only after a successful request, caps tool output
  at 64 KiB, writes each history line in one write, and skips a damaged
  line.
- Endpoints skip blank and non-RPC lines. client_rs does not panic.
- #[must_use] on builders and the lock guard; #[schemars] attributes
  and duplicate hooks are compile errors; raw identifiers; -32000 for
  local SDK errors; x-sausage-params only for methods with params.
2026-10-23 02:38:35 +01:00
surma d7b78ed0fd core: review fixes, init builds the agent, console without a terminal
Activation path (review T6R): a broken plugin or a stray file in
/plugins blocks no activation; candidates replace their own entries;
discovery has a 10 s deadline and its process is stopped after the
answer; an unchanged hash keeps plugin.prev.wasm; activations run in
their own task; the watcher is not recursive; no duplicate services;
rpc.retired waits for live processes of its version; reads and hashes
on blocking threads.

Guest I/O and per-call paths: a stdin write error is EOF, stdout and
stderr are drained before the status; stderr is lossy UTF-8 and goes
to the journal from its own reader; compiled components are freed;
shutdown starts no new processes; lock queues drop stopping processes;
compile before the admission gate; CommandPre per component;
core.unlock in order; null errors are successes; the journal thread
survives SQLite errors.

sausage init builds the agent with cargo.build; sausage without a
command creates ./world. The console reads plain lines without a
terminal. PROTOCOL.md and DECISIONS.md describe the decided rules. The
flake tests the whole workspace and checks the packaged cargo.build.
2026-10-23 02:25:03 +01:00
surma 53b90a8ecf agent: a plugin on the new SDK (agent.chat, tools call/read_file/write_file/list_dir/copy_dir)
Holds the history lock for a turn, 600 s grace period, interim texts as
core.notify. Tests with a fake LLM in the real core. The SDK tests of
/proc, /self, /run, and detached notifications now run.
2026-10-23 01:42:20 +01:00
surma 8d058666dc cargo.build, the WASI test runner, the console, and core.notify ordering
- cargo.build (native): one source copy, tests under WASI, release
  build to /state/cargo/builds/<n>/, process groups killed on cancel.
  The host writes the SDK copy to /plugins/cargo/sdk/ at start.
- sausage test-runner: one instance per test, in parallel, with its own
  mount table (empty world, package folder at its path); answers
  core.lock, core.unlock, core.notify, core.log.
- The console: rustyline, notifications while typing, ls/cat through the
  process view, methods, ps, log, activate, revert, stop, kill.
- core.notify on stdout is handled in order, before the response.
2026-10-23 01:42:19 +01:00
surma 0516158883 sdk: readable clients (no Empty wrapper, no unused error module), documented examples with tests 2026-10-23 01:05:19 +01:00
surma edc2d1a63b core: sausage-vfs per process (/, /proc, /self, /run), activation, graph, watcher, upgrades
- /proc is a core layer: core/ (PROTOCOL.md, graph.json, graph.dot,
  locks.json, plugins/<name>/discover.json and client.rs) and one folder
  per process (info.json, log, and its /self files).
- Locks are released only after the task ended, the store dropped, and
  in-flight file operations returned.
- core.activate and core.revert with copies, discovery, compatibility
  checks of imports and consumers, groups, force. An admission gate
  makes the snapshot of old processes complete.
- Graph of discover documents by hash, refreshed by a file watcher.
- Upgrades: grace periods (x-sausage-grace), stop, service restarts,
  rpc.retired.
- SDK plugins in the real core (cli/tests/sdk.rs).
2026-10-23 01:05:19 +01:00
surma 151550242b sausage-vfs: review fixes (fallible growth, symlink flags, stream cancel, sized reads, timestamps, error kinds, linker shadowing)
API: Layer::stat(path, follow), Layer::readlink, Open::follow,
Stat::symlink and Entry::symlink. Process::idle documents the shutdown
order: abort, await the task, await idle, release locks.
2026-10-23 00:49:46 +01:00
surma 7bbcd47837 core: route_from runs a plugin from another .wasm file (seam for activation)
Stop tracking examples/notes/Cargo.lock.
2026-10-23 00:37:09 +01:00
surma 8d3c163992 sausage-plugin part 2: import! with typed clients (sausage_interface::client_rs), TCP endpoints with rpc.endpoint, examples
Part 1 fixes: complete stdout writes, buffered stdin, Rust type names
in schemas, enum variant docs, -32603 for serde errors in handlers,
crate docs and modules. End-to-end check of a generated consumer through
a fake core.
2026-10-23 00:35:45 +01:00
surma ae1cb4cc99 core: seams for parallel work: services.rs (boot), graph.rs (documents for /proc), activation.rs 2026-10-23 00:34:34 +01:00
surma 8542b9fcb8 sausage core: modules per topic, journal writer thread with WAL and batches, hash cache by metadata, 49 tests per rule
Blank stdout lines give no warning. core.exit requests the exit in its
own handler.
2026-10-23 00:32:33 +01:00
surma 218a718a3d sausage-vfs: tests per topic under WASI, nested differential tests, contract through Mounts 2026-10-23 00:12:54 +01:00
surma 4882edf243 sausage core part 1: a process per call, routing, tree, stop and kill, locks, journal, boot
Checkpoint. Removes the old host. A quality rework follows (modules,
journal writer thread, hash cache, finer tests).
2026-10-23 00:12:02 +01:00
surma 0a479b5800 sausage-vfs: layers (host, memory, generated, mounts), process guards, wasi:filesystem adapter
Checkpoint. More tests and the review findings follow.
2026-10-23 00:08:02 +01:00
surma 01712e745a sausage-plugin part 1: run, #[rpc], #[schema], rpc.discover, calls, locks, Ctx, hooks
Checkpoint. Known issues, fixed in part 2: short writes on stdout,
byte-wise stdin reads, Result suffix on all result schemas, lost enum
variant docs, serde errors as invalid params, missing item docs.
2026-10-23 00:05:37 +01:00
surma 4e41061ed4 sausage-interface: compatibility check for imports (PROTOCOL.md 10.2)
Params go from consumer to provider, results from provider to consumer.
Sender objects are closed, receiver objects open unless they reject
additional properties. Covers the Draft 7 subset with refs, cycles,
nullable values, string enums, arrays, and integer ranges, rejects other
constructs, and gives counterexamples. Property test over random subset
schemas.
2026-10-23 00:03:03 +01:00
surma d1be22f653 PROTOCOL.md, DECISIONS.md, README.md, AGENTS.md for plugins, processes, and JSON-RPC 2026-10-22 23:50:24 +01:00
surma c901568f56 PROPOSAL.md: sausage-interface crate and #[on_stop] 2026-10-22 23:39:51 +01:00
surma fa3ca7c235 Workspace skeleton for plugins: sausage-vfs, sausage-interface, sausage-plugin, sausage-plugin-macros
The agent leaves the workspace until it is rewritten on the new SDK.
2026-10-22 23:37:35 +01:00
surma a7eb96bbbb PROPOSAL.md: results of the review, no mention of the old model
ci / check (push) Successful in 2m33s
Upgrades retire old processes after a grace period, then call
rpc.retired. Services live in boot.json. One preopen / with a mount
table in the sausage-vfs crate, with guards for in-flight file
operations. Draft 7 and our own compatibility check on a fixed subset.
Imports register with inventory. Rules for liveness, stop states,
routing, locks, activation, endpoints (rpc.endpoint handshake), boot,
builds, and a 10 ms scheduling tick.
2026-10-22 23:27:08 +01:00
surma 2415bb361f PROPOSAL.md: plugins, processes, and JSON-RPC
ci / check (push) Successful in 2m17s
The design from the redesign discussion: a process per call, JSON-RPC on
stdin and stdout, a virtual /proc, /self, and /run, TCP endpoints, locks,
a central journal, generated imports with interface checks at activation,
and the SDK with examples.
2026-10-22 20:58:30 +01:00
surma a536d873e6 cargo: compile C for wasm32-wasip2 with a WASI clang
ci / check (push) Successful in 18s
The Nix package, the dev shell, and the test check set CC_wasm32_wasip2
and AR_wasm32_wasip2 to the clang of pkgsCross.wasi32 (wasi-libc), so
build scripts with the cc crate work, for example rusqlite with bundled
SQLite. The runtime-toolchain check links C into a probe with the
packaged host and an empty PATH.
2026-10-22 20:16:38 +01:00
Sausage Agent 34e2f35272 Add writable /self alias alongside /public and /data
ci / check (push) Successful in 15s
2026-10-22 13:35:13 +00:00
Sausage Agent f932ab9719 Mount the writable world root for every actor
ci / check (push) Successful in 3m9s
2026-10-22 13:29:38 +00:00
surma 3d3f6f35fb Merge pull request 'broker: upgrade and rollback keep a stopped actor stopped' (#3) from upgrade-keeps-stopped into main
ci / check (push) Successful in 13s
Reviewed-on: #3
2026-10-22 10:50:15 +01:00
surma f1af07506f broker: upgrade and rollback keep a stopped actor stopped
ci / check (push) Successful in 8m42s
ci / check (pull_request) Successful in 4m38s
A stopped actor gets the new version installed, keeps its mailbox and does
not run main until the next start. Running actors still restart on the new
version. The upgraded and rolled_back answers say whether the actor runs.
2026-10-22 10:11:36 +01:00
Sausage Agent a72e42a8c9 headless: register signal handlers synchronously, split tests, drop deployment examples and wording
ci / check (pull_request) Successful in 1m36s
ci / check (push) Successful in 11s
2026-10-18 15:31:41 +00:00
Sausage Agent 01a821ab72 Add --headless mode (no stdin read, runs until SIGTERM/exit) + systemd/NixOS examples
ci / check (push) Successful in 2m44s
2026-10-18 15:24:20 +00:00
Sausage Agent c204e1ef0c docs: clarify actor data is world-readable
ci / check (pull_request) Successful in 12s
ci / check (push) Successful in 6s
2026-10-17 17:22:50 +00:00
Sausage Agent 6923ca3e60 preserve the packaged runtime Rust toolchain
ci / check (push) Successful in 6s
2026-10-17 16:36:08 +00:00
Sausage Agent c504532513 drive packaged host runtime check over pipes
ci / check (push) Failing after 1m16s
2026-10-17 16:33:31 +00:00
Sausage Agent eb35a79698 trace the packaged host runtime check
ci / check (push) Failing after 1m47s
2026-10-17 16:29:02 +00:00
Sausage Agent bfcd71e2f4 test packaged host cargo runtime with an empty PATH
ci / check (push) Failing after 1m33s
2026-10-17 16:25:14 +00:00
170 changed files with 23144 additions and 6759 deletions
+5 -1
View File
@@ -1,4 +1,8 @@
/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
+27 -16
View File
@@ -5,30 +5,30 @@ Keep it that way.
## Priorities
1. **Correct.** Every behavior in `PROTOCOL.md` has a test in `cli/tests/`.
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 message.
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 an actor (or the agent, by changing itself) could do.
the host that a plugin (or the agent, by changing itself) could do.
## Layout
- `cli/`: the host (`sausage` binary and library).
- `lib.rs`: `Host`, which starts and stops a world.
- `broker.rs`: routing, mailboxes, the version store, broker requests.
- `supervisor.rs`: runs one WASM actor (restarts, swaps, rollbacks).
- `runtime.rs`: Wasmtime instances and what they get (mounts, network).
- `candidate.rs`: tests a new version. `cargo_actor.rs`: builds Rust.
- `world.rs`: the on-disk layout. `console.rs`: the terminal.
- `sdk/`: `sausage-actor`, the SDK for Rust actors. Never published: the
`cargo` actor points `sausage-actor = "0.1"` at its local copy.
- `agent/`: the agent actor. `sausage init` embeds and builds it.
- `examples/`: small actors. They are not workspace members.
- `PROTOCOL.md`: the contract between the host and the actors. Change it in
- `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.
@@ -36,10 +36,21 @@ Keep it that way.
```sh
nix flake check -L # all tests below; CI runs this on every push
nix develop -c cargo test -p sausage -p sausage-actor -p agent
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
+767 -35
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,10 +1,11 @@
[workspace]
resolver = "3"
members = ["cli", "agent", "sdk"]
members = ["cli", "vfs", "interface", "sdk", "sdk/macros", "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 actors quickly.
# builds, so that tests and debug runs start plugins quickly.
[profile.dev.package."*"]
opt-level = 2
+105 -40
View File
@@ -1,55 +1,120 @@
# Decisions
Why Sausage is the way it is. `PROTOCOL.md` and the code own the details;
when this file disagrees with them, they win.
Why Sausage is the way it is. `PROTOCOL.md` and the code own the details.
When this file disagrees with them, they win.
## The shape
- **Actors are WASI commands, not components with interfaces.** stdin,
stdout, files, HTTP, and sockets are enough, and every language can do
them.
- **Messages are JSON lines through a broker, fire and forget.** Like
Erlang: no acknowledgments, no request/response. A reply is a message with
`reply_to`. Sausage is not a reliable message bus.
- **The broker accepts only `.wasm` files.** It knows no source language.
`cargo` is one compiler actor; others can add more languages.
- **The world folder is the source of truth.** Everything about an actor is
in `actor/<id>/`. Copy a folder into another world, and it runs there.
- **The agent is a normal WASM actor.** It can read and upgrade its own
source, and change how it does inference.
- **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.
## Interfaces
- **Generated imports and interface checks, without version numbers.** A
model that translates a description by hand makes errors. A generator does
not. The check catches the most common error in a world that changes
itself: a change to one plugin breaks another one.
- **A small schema subset.** With Draft 7 and a fixed subset, our own check
can decide every case. It never has to guess.
- **Docs are part of the interface.** Other agents work from them, so the
SDK requires a doc comment on each method, type, and field.
- **The check covers calls, not state.** Old and new versions share
`/state`. A plugin keeps its state format compatible, or migrates it in
`rpc.retired`.
## Minimal on purpose
The harness does only what it needs to keep going. Everything else can be an
actor, or a change that the agent makes to itself.
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.
- **The host is not self-modifiable; actors are.** So the host keeps only
what an actor cannot do for itself: routing, running instances, versions,
tests before an install, and the rollback of a version that crash-loops.
Without that rollback, a broken self-upgrade would end the agent.
- **No permissions, no limits, no hiding.** Any actor can message, upgrade,
or stop any actor, read every folder, and use the network. People who want rules
can build them.
- **No re-delivery and no state snapshots.** A crash loses the message, and
all versions of an actor share `data/` and `public/`. A rollback restores
code, not data.
- **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.
## Small choices
- **Plain POSIX files.** An earlier version staged every write until close.
That broke `tempfile`, git, and SQLite, which expect normal files. Atomic
publishing is the usual temporary file + `rename`.
- **Test first, then switch.** A new version is tested where it lies. Only
a version that passes goes to `versions/`, and the old one runs until
then. `versions/` keeps every version that was installed, so a rollback
can always go back. A file that fails stays where it was put.
- **Tests are JSON transcripts** next to the source. They run on a copy of
the actor's state, without network, so a test cannot send real requests.
- **`broker` and `cargo` never answer a reply,** so no two actors answer
each other forever. The agent starts a turn only for text that is not a
reply, for the same reason.
- **The SDK is not published.** `cargo` points `sausage-actor = "0.1"` at its
own copy, which it also shows in `/actor/cargo/public/sdk/`.
- **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.
- **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.
+527 -218
View File
@@ -1,250 +1,559 @@
# The Sausage protocol
This is the contract between the host and the actors. An actor in any
language that follows it works in Sausage. The host publishes this file at
`/actor/broker/public/PROTOCOL.md`, so every actor can read it.
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.
## Actors
Rust plugins use the SDK `sausage-plugin`. Its source is at
`/plugins/cargo/sdk/`, and its crate docs explain each item.
An actor is a WASI 0.2 command (`wasm32-wasip2`): a program with `main`. The
host runs one instance per actor. An instance can run forever.
## 1. Terms
The instance gets:
- **stdin:** one message per line, from the broker
- **stdout:** one message per line, to the broker
- **stderr:** free text, appended to `/actor/<id>/stderr.log`
- **environment:** `SAUSAGE_ACTOR` (its ID), `SAUSAGE_VERSION` (its version),
and `SAUSAGE_CANDIDATE=1` during a test run
- **the network:** outgoing HTTP and HTTPS through `wasi:http` (the host does
TLS), and TCP, UDP, and DNS through `wasi:sockets`, including listening on
a port of the host
- **the filesystem** described below
## Messages
A line on **stdin**:
```json
{"id": "m-17", "from": "console", "reply_to": "agent/a5", "body": {"type": "text", "text": "hi"}}
```
A line on **stdout**:
```json
{"to": "console", "id": "a6", "reply_to": "m-17", "body": {"type": "text", "text": "hello"}}
```
- `body` is required. By convention it is an object with a `"type"`.
Text for people is `{"type": "text", "text": "..."}`.
- `to` is the recipient. Without `to`, the broker publishes the message to
every subscriber of the sender.
- `id` is an optional string. If the actor sets it, the message ID is `"<sender>/<id>"`.
Otherwise, the broker assigns `m-<n>`. The broker always sets `from`.
- `reply_to` marks a reply: its value is the ID of the message it answers.
There is no request/response. A reply is a new message.
- **Never answer a reply,** or two actors can answer each other forever.
`broker` and `cargo` ignore messages that have a `reply_to`.
- Messages are fire and forget: delivery is at most once. Each actor has a
mailbox of 1,000 messages, which keeps messages while the actor restarts
or is upgraded. If the broker cannot deliver a message, the sender gets
`{"type": "undeliverable", "reason": "...", "to": "..."}` from `broker`.
- An actor must keep reading stdin. If the broker must stop or upgrade an
actor while a message waits for it to read stdin, it kills the actor.
Before the broker stops an instance, it sends
`{"id": "broker/shutdown", "from": "broker", "body": {"type": "shutdown"}}`
and then closes stdin. The broker still routes what the actor writes until
it exits. After 5 seconds, it kills the instance.
## Filesystem
Each instance sees three folders:
| Path | Access | Contents |
| --- | --- | --- |
| `/data` | read-write | the actor's state |
| `/public` | read-write | the files that the actor publishes |
| `/actor` | read-only | the `actor/` folder of the world |
`/actor/<id>/` holds everything about an actor: `actor.wasm`, `actor.json`
(installed version, running, subscriptions, tapped, watches), `source/`, `public/`, `data/`,
`stderr.log`, and `versions/<n>/`. `ls /actor` lists all actors.
Files are plain POSIX files: a write is visible at once. To publish a file
so that readers never see half of it, write a temporary file and `rename` it
(the Rust SDK has `write_atomic`).
Send large or binary data as a path under `/actor/<id>/public/`, not inside a
message.
### Actor-served files: `/actor/<id>/fs/`
Files under `/actor/<id>/fs/` are not on disk: the actor `<id>` serves them.
Every actor can read and write them. The host turns each file operation into a
message from `fs` to `<id>` and waits up to 10 seconds for the reply. `path`
is relative to `fs/` and starts with `/` (the folder itself is `/`).
To make this virtual directory discoverable, an actor that serves it declares
`Actor-served files` and `fs_read` in its leading `//!` documentation in
`source/src/main.rs`. The host uses that declaration when listing
`/actor/<id>/`; it does not probe every actor (which could stall on actors that
do not handle filesystem messages). This is discovery metadata only: opening
and reading the path still requires a valid `fs_read` reply.
| Request | Reply |
| Term | Meaning |
| --- | --- |
| `{"type": "fs_read", "path"}` | `{"type": "fs_file", "text"}` (or `"base64"` instead of `"text"`), or `{"type": "fs_dir", "entries": [{"name", "type": "file" \| "dir"}]}` |
| `{"type": "fs_write", "path", "text"}` (or `"base64"` for non-UTF-8 data) | `{"type": "fs_done"}` |
| `{"type": "fs_mkdir", "path"}`, `{"type": "fs_remove", "path"}` | `{"type": "fs_done"}` |
| `{"type": "fs_rename", "path", "to"}` | `{"type": "fs_done"}` |
| 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 and 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. |
Any request can be answered with `{"type": "fs_error", "errno": "no-entry"}`.
`errno` is a `wasi:filesystem` error code: `no-entry`, `exist`,
`not-directory`, `is-directory`, `not-empty`, `access`, `not-permitted`,
`invalid`, `unsupported`, `busy`, or `io` (also for anything else).
## 2. The world folder
- Opening a file sends `fs_read` once. Reads come from that copy.
- After a change, closing (or syncing) the file sends the whole file in one
`fs_write`.
- No reply within 10 seconds, or an actor that does not exist, makes the
operation fail (`io`, or `no-entry`). An actor that opens its own `fs/`
waits for itself and fails after the timeout.
- `fs` is reserved: no actor can have that ID. It also sends `fs_changed`
to watchers (see Watches).
```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
```
## The broker
- 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.
Requests to `broker`. The answer is a message whose `reply_to` is the ID of
the request: the request's `type` in past tense (`spawned`, `upgraded`,
`rolled_back`, `stopped`, `started`, `removed`, `subscribed`, `unsubscribed`,
`tapping`, `untapped`, `watching`, `unwatched`, `exiting`), or `{"type": "error", "reason": "..."}`.
## 3. Calls and processes
| `type` | Fields | Effect |
### 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.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. Each process of an older version gets a grace period to finish its work.
The default is 60 seconds. A plugin can declare its own value in
`rpc.discover` (`x-sausage-grace`). Then the core stops the process.
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 |
| --- | --- | --- |
| `spawn` | `actor`, `wasm`, `source`?, `built_by`? | test, then start a new actor |
| `upgrade` | `actor`, `wasm`, `source`?, `built_by`? | test, then swap the version |
| `rollback` | `actor`, `version` | go back to an older version |
| `stop`, `start` | `actor` | stop or start an actor |
| `remove` | `actor` | stop the actor (normal shutdown), then delete its whole folder `/actor/<id>/` (all versions, `data/`, `public/`, logs); answer `{"type": "removed", "actor"}`. Messages to it are then undeliverable, and the ID can be spawned again. An unknown actor or a native one (`broker`, `console`, `cargo`, `fs`) is an `error` |
| `subscribe`, `unsubscribe` | `actor` | receive (or not) what `actor` publishes |
| `tap`, `untap` | | receive (or not) a copy of every message the broker routes |
| `watch`, `unwatch` | `path` | receive (or not) `fs_changed` for every change of `path` or anything under it; answer `{"type": "watching" \| "unwatched", "path"}` with the path as an `/actor/...` path |
| `exit` | | answer `exiting`, stop every actor, and end the `sausage` process (a wrapper like `nix run .#loop` starts it again, updated) |
| `/` | 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 |
Paths are `/actor/...`, or the sender's `/public/...` or `/data/...`.
A listing of `/` shows `proc`, `run`, and `self`. A rename across two of
these areas fails.
**Taps.** After `tap`, the sender gets a copy of every message that the
broker routes (direct messages, replies, and publishes, between any actors,
including its own), for debugging, logging, or a live view of the world:
```json
{"type": "tapped", "message": {"id": "m-17", "from": "console", "to": "agent", "reply_to": null, "body": {...}}, "ts": 1792132579067}
```text
/proc/
core/
PROTOCOL.md this file
graph.json plugins, imports, and the result of each check
graph.dot the same graph for Graphviz
locks.json held locks and waiting processes
plugins/<name>/
discover.json the rpc.discover document of the current plugin.wasm
client.rs the Rust code that import!("<name>") generates
<pid>/
info.json plugin, version, method, params, parent, detached, start time, state
log the journal lines of this process
<files> the files that the process writes to /self
```
- `to` is `null` for a publish: one copy per publish, not per subscriber.
- `ts` is the time of routing, in milliseconds since the Unix epoch.
- Copies come from `broker`. They are not routed themselves, so they are
never tapped, and each message is tapped once per tapper.
- A copy that does not fit into the tapper's mailbox is dropped; routing
never waits for a tapper, and nobody gets `undeliverable` for a copy.
- Never answer a copy: the answer would be tapped again, forever.
- Like subscriptions, a tap is saved in `actor.json` (`"tapped": true`) and
survives restarts and upgrades. `untap` ends it.
Rules:
**Watches.** The host watches the whole world folder with the OS file
watcher: every change inside it counts, whoever makes it (actors, a 9P mount,
a shell or `git` on the host). Actor-served files (`/actor/<id>/fs/`) are not
on disk and do not count. After `watch {path}`, the sender gets for each
change of `path` or a path under it (`/actor/a` covers `/actor/a/x`, not
`/actor/ab`):
- 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.
```json
{"type": "fs_changed", "path": "/actor/notes/public/list.md", "op": "write"}
Principle: **inspect through files, act through calls.**
## 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
```
- `op` is `write`, `create`, `remove`, or `rename` (then `to` is the new
path; a watcher of either path gets it).
- Changes of one path within about 100 ms become one event (a `write` after
a `create` stays a `create`).
- The host's temporary files (names starting with `.sausage-tmp-`) are
skipped; renaming one onto a file is a `write` of that file.
- Events come from `fs`. Like tap copies, they are not routed, a full
mailbox drops them, and they never block the writer. Never answer them.
- If the OS drops events, every watcher gets one
`{"type": "fs_changed", "path": "/actor", "op": "rescan"}`: anything may
have changed.
- A watch is saved in `actor.json` (`"watches": [...]`) and survives
restarts and upgrades. `unwatch` with the same path ends it.
- 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, the core writes the error to the journal and continues.
- After an upgrade of the plugin of a service, the core starts the service
again with the new version (section 3.7).
- 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.
**Versions.** A new version (from `spawn`, `upgrade`, or a changed
`actor.wasm` on disk) is tested first, while the old version keeps running.
Only when its tests pass does the broker copy it to `versions/<n>/`
(`actor.wasm`, `source/`, `meta.json`) and switch to it. So `versions/` holds
exactly the versions that were installed, and instances always run from
there. If the tests fail, nothing changes, and the sender gets an `error`
with the reason. All versions share the actor's `data/` and `public/`: a
new version must read the data of the old one.
## 9. The core API
**Tests.** The broker runs the candidate on a copy of the actor's `data/`
and `public/`, without network. The tests are JSON files in
`<source>/tests/*.json`, one scenario per file:
| Method | Params | Result |
| --- | --- | --- |
| `core.call` | `method`, `params`, `detach`? | the result of the method |
| `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. |
```json
{
"description": "add, then list",
"steps": [
{"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
{"send": {"type": "list"}, "expect": [{"type": "notes"}]}
]
}
```
### Host restart
Each step sends `send` (from `from`, default `test`), then reads the next
line that the candidate writes for each `expect` entry, in order, within 10
seconds. An entry matches when it is a subset of the message body. With
`to`, the message must also go to that recipient. Each file runs in a fresh
instance. A version without test files passes.
**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.
**Swap.** The broker holds the mailbox, stops the old instance, makes the
top-level `actor.wasm` and `source/` a copy of the new version, and starts
it. A rollback does the same with the older version.
`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.
**Crashes.** The broker restarts a crashed actor after 0.5 s, then doubles
the delay up to 30 s. A new version that crashes 3 times within 30 seconds
after an upgrade goes back to the previous version. The actor that asked for
the upgrade gets `{"type": "rolled_back", "from", "to", "reason", "last_exit"}`.
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.
**The folder is the truth.** At start, the broker boots every
`actor/<id>/actor.wasm`. Afterwards, the file watcher reports a new or
changed `actor/<id>/actor.wasm` (or a new folder `actor/<id>/`) once it is
quiet for about 100 ms: a new file becomes a new actor, a changed one an
upgrade, tested first with the tests in `actor/<id>/source/`. A file equal
to the installed version changes nothing. If its tests fail, the file stays
where it is, the reason goes to `stderr.log`, and the next change of the
file tries again. When the OS drops events, the broker checks every actor
folder again, as at start.
`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.
## The `cargo` actor
Everything else is a file.
```json
{"type": "build", "source": "/actor/agent/public/drafts/notes"}
```
## 10. Interfaces
The build request may include `"skip_tests": true` to skip Rust unit tests.
By default, `cargo` first runs `cargo test` for the host target (not WASI),
so pure-logic unit tests in a `wasm32-wasip2` actor crate can run without a
WASI test runner. If tests fail or cannot compile, the build fails before the
WASI build. The reply carries `tests: {"passed": <count>, "failed": <count>,
"output": "<cargo test stdout and stderr>"}`; counts sum Cargo's test-result
summaries, and a test command/compilation failure without a summary counts as
one failure. With `skip_tests: true`, counts are zero and output says tests
were skipped.
### 10.1 `rpc.discover`
The reply is `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "built_by": "cargo", "tests": {...}, ...}`
or `{"type": "build_failed", "errors": "...", "tests": {...}}`. Pass
`wasm`, `source`, and `built_by` on to `spawn` or `upgrade`.
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:
A Rust actor depends on the SDK with `sausage-actor = "0.1"`. `cargo` points
that dependency at the SDK of the host, which it publishes at
`/actor/cargo/public/sdk/`.
- `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`: the grace period in seconds.
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, 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.
+77 -43
View File
@@ -2,23 +2,25 @@
You can see how the sausage is made.
Sausage is a world of small programs ("actors") that you grow by talking to
it. Each actor is a WASI program with stdin and stdout. Actors send each other
JSON messages, one per line, through a broker. The agent is an actor too: it
writes new actors in Rust, builds them, tests them, starts them, and can
change its own code.
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.
## Start
You need [Nix](https://nixos.org/download) with flakes, and an API token for
an LLM (OpenAI Responses or Anthropic Messages API). No clone needed:
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:
```sh
nix run git+https://gitea.surma.technology/surma/sausage
```
This creates a world in `./world` the first time (it builds the agent, which
takes a few minutes), then starts it with a console. Later runs start the
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:
```sh
@@ -26,11 +28,15 @@ nix run git+https://gitea.surma.technology/surma/sausage -- init # creates ./w
nix run git+https://gitea.surma.technology/surma/sausage -- run # starts the world and a console
```
`init` asks for the API shape (`openai-responses` or `anthropic-messages`),
the base URL, the model, and the token. It reads `SAUSAGE_API`,
`SAUSAGE_BASE_URL`, `SAUSAGE_MODEL`, and `SAUSAGE_API_TOKEN` first. The
values go to `world/actor/agent/data/config.json`. Both commands take another
folder as an argument, for example `... -- run my-world`.
`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:
@@ -39,48 +45,76 @@ nix run git+https://gitea.surma.technology/surma/sausage#loop # ./
nix run git+https://gitea.surma.technology/surma/sausage#loop -- my-world # an existing world
```
The loop runs `nix run --refresh git+https://gitea.surma.technology/surma/sausage -- run my-world`.
When the host crashes or an actor sends `@broker {"type":"exit"}`, the loop
fetches the newest commit and starts it again. `/quit` ends the loop. Set
`SAUSAGE_FLAKE` to run another flake URL instead, for example your fork.
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
same commands are `nix run`, `nix run -- init`, `nix run -- run`, and
`nix run .#loop`.
In the console:
### Headless
```text
write an actor "notes" that stores notes, and start it → text to the agent
@notes {"type":"add","text":"milk"} → a JSON message to an actor
@agent hello → text to an actor
/quit
`--headless` (or `SAUSAGE_HEADLESS=1`) turns the console input off. The host
runs until SIGTERM or SIGINT (exit code 0) or until a plugin calls
`core.restart` (requires `SAUSAGE_LOOP=1`; exit code 75 restarts the loop):
```sh
nix run git+https://gitea.surma.technology/surma/sausage#loop -- --headless my-world
```
## The console
| Input | Effect |
| --- | --- |
| text | a message to the agent (`agent.chat`) |
| `<plugin>.<method> {json}` | a call. The console prints the result. |
| `ls [path]`, `cat <path>` | the filesystem as a process sees it, with `/proc` and `/run` |
| `methods <plugin>` | the methods of a plugin, with params and docs |
| `ps` | the running processes |
| `log [plugin or pid] [-n N]` | the journal |
| `activate <plugin> <wasm>`, `revert <plugin>` | activate a new version, or go back |
| `stop <pid>`, `kill <pid>` | stop a process |
| `help`, `quit` | |
## The world folder
The folder is the source of truth. Every actor lives in `world/actor/<id>/`:
```text
actor.wasm the current version
actor.json installed version, running, subscriptions
source/ the source of the current version
public/ files that the actor publishes
data/ the actor's state
stderr.log what the actor and the broker write about it
versions/<n>/ every installed version: actor.wasm, source/, meta.json
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 the services that start with the world
config/agent.yaml the configuration of the agent
```
To add or replace an actor by hand, put an `actor.wasm` (and its `source/`,
with the tests in `source/tests/`) into its folder; the host watches the
world folder and picks the file up once it stops changing. The broker tests
the file before it uses it.
If the tests fail, the old version keeps running, and `stderr.log` says why.
Rust builds use the SDK in `plugins/cargo/sdk/` directly. The host seeds this
folder only when it is missing; restarts and host upgrades preserve existing
files. SDK edits take effect on the next `cargo.build`, without a restart.
To reset to the host's bundled SDK, back up any edits and remove the whole
folder; the next startup or build re-seeds it.
Each process also sees `/proc` (the processes and the state of the core),
`/self` (its own folder in `/proc`), and `/run` (shared files in memory).
To replace a plugin by hand, copy a `.wasm` file to its `plugin.wasm`. That
skips the interface checks. `revert <plugin>` goes back to the version
before the last activation.
## More
- [PROTOCOL.md](PROTOCOL.md): messages, the filesystem, the broker, `cargo`.
Read this to write an actor in any language.
- [`sdk/`](sdk/): `sausage-actor`, the SDK for Rust actors.
- [`examples/notes`](examples/notes): a small actor with a test.
- [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.
+5 -2
View File
@@ -2,10 +2,13 @@
name = "agent"
version = "0.1.0"
edition = "2024"
description = "The Sausage agent actor."
description = "The Sausage agent plugin."
[dependencies]
sausage-actor = { version = "0.1", path = "../sdk", features = ["async"] }
sausage-plugin = { version = "0.1", path = "../sdk" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml_ng = "0.10"
[target.'cfg(target_os = "wasi")'.dependencies]
wstd = "0.6"
+8 -1
View File
@@ -2,6 +2,7 @@
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 ")
@@ -9,7 +10,10 @@ pub fn is_too_large(error: &str) -> bool {
else {
return false;
};
if !matches!(status.split_whitespace().next(), Some("400" | "413")) {
if status.split_whitespace().next() == Some("413") {
return true;
}
if status.split_whitespace().next() != Some("400") {
return false;
}
let body = body.to_ascii_lowercase();
@@ -23,6 +27,7 @@ pub fn is_too_large(error: &str) -> bool {
.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 { .. }))
@@ -36,6 +41,8 @@ mod tests {
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"));
+442 -65
View File
@@ -1,29 +1,118 @@
//! Inference through two API shapes: OpenAI Responses and Anthropic Messages.
//! Inference through OpenAI Responses, Chat Completions, and Anthropic Messages.
//!
//! The conversation is stored in a neutral format, so the agent can switch
//! between the two shapes.
//! between the three shapes.
use sausage_plugin::schema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
#[cfg(target_os = "wasi")]
use wstd::http::{Body, Client, Request};
#[derive(Deserialize, Clone)]
/// Inference configuration from `/config/agent.yaml`.
#[derive(Deserialize, Clone, Default)]
#[serde(default)]
pub struct Config {
/// `openai-responses` or `anthropic-messages`.
/// `openai-responses`, `openai-chat-completions`, 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,
/// Optional reasoning effort for OpenAI models only (wins over `thinking` there).
#[serde(default)]
pub reasoning_effort: Option<String>,
/// Permit an empty token and omit authentication headers.
pub allow_no_token: bool,
/// An optional cap on generated tokens.
#[serde(default)]
pub max_tokens: Option<u32>,
/// Thinking effort for both APIs: "low", "medium", "high", "xhigh", "max" (`LEVELS`), or "off"/unset for none.
/// Anthropic: adaptive thinking + output_config.effort; OpenAI: reasoning.effort. A level the model rejects is
/// replaced by the nearest accepted one (logged), never by "off".
/// The thinking level of each turn. Unset: the model's default.
#[serde(default)]
pub thinking: Option<String>,
pub thinking: Option<Thinking>,
}
/// How much the model thinks before it answers.
///
/// OpenAI Responses gets `reasoning.effort`, Chat Completions gets `reasoning_effort`, and Anthropic
/// gets adaptive thinking with `output_config.effort`. If the model rejects a level, the agent uses
/// the nearest level that the model accepts, never `off`.
#[schema]
#[derive(Clone, Copy, PartialEq, Debug)]
#[serde(rename_all = "lowercase")]
pub enum Thinking {
/// No thinking: OpenAI gets the effort `none`, and Anthropic gets disabled thinking. If an
/// OpenAI model cannot switch thinking off, the agent uses `low`.
Off,
/// The least thinking.
Low,
/// Moderate thinking.
Medium,
/// Much thinking.
High,
/// More thinking than `high`, where the model supports it.
Xhigh,
/// The most thinking that the model supports.
Max,
}
impl Thinking {
/// The effort that an OpenAI request carries: `none`, or one of `LEVELS`.
fn effort(self) -> &'static str {
match self {
Thinking::Off => "none",
Thinking::Low => "low",
Thinking::Medium => "medium",
Thinking::High => "high",
Thinking::Xhigh => "xhigh",
Thinking::Max => "max",
}
}
}
impl Config {
pub fn load() -> Result<Self, String> {
let bytes = std::fs::read("/config/agent.yaml")
.map_err(|e| format!("/config/agent.yaml: missing configuration file: {e}"))?;
Self::parse(&bytes)
}
fn parse(bytes: &[u8]) -> Result<Self, String> {
let cfg = serde_yaml_ng::from_slice::<Option<Self>>(bytes)
.map_err(|e| format!("/config/agent.yaml: {e}"))?
.unwrap_or_default();
let invalid = if ![
"openai-responses",
"openai-chat-completions",
"anthropic-messages",
]
.contains(&cfg.api.as_str())
{
Some("api (missing or unknown API shape)")
} else if cfg.base_url.trim().is_empty() {
Some("base_url")
} else if cfg.model.trim().is_empty() {
Some("model")
} else if (cfg.token.trim().is_empty() && !cfg.allow_no_token)
|| cfg.token == "CHANGE_ME"
|| cfg.token.starts_with("YOUR_")
{
Some("token (set a key, or set token: \"\" and allow_no_token: true)")
} else {
None
};
if let Some(field) = invalid {
return Err(format!("/config/agent.yaml: configure field {field}"));
}
Ok(cfg)
}
fn bearer(&self) -> Vec<(&'static str, String)> {
if self.token.is_empty() {
vec![]
} else {
vec![("authorization", format!("Bearer {}", self.token))]
}
}
}
/// Thinking levels, lowest first.
@@ -32,9 +121,22 @@ 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() };
let at = LEVELS.iter().position(|l| *l == want).unwrap_or(1) as i32;
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| {
@@ -48,50 +150,178 @@ fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option<String>
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: 'ultra'. Supported values are: 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', and 'max'.";
assert_eq!(fallback_effort("ultra", openai, &["ultra".into()]).as_deref(), Some("medium"));
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"));
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 {
User { text: String },
Assistant {
/// 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>,
},
ToolCall { id: String, name: String, arguments: String },
ToolResult { id: String, output: 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 { api: String, item: Value },
Opaque {
/// The API shape that owns this entry.
api: String,
/// The model that produced this item. Legacy items without a model are not replayed.
#[serde(default)]
model: String,
/// The original provider object.
item: Value,
},
}
/// A tool contract shared by all API shapes.
pub struct Tool {
/// 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,
}
pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
let want = match cfg.api.as_str() {
"openai-responses" => cfg.reasoning_effort.clone().or_else(|| cfg.thinking.clone()),
_ => cfg.thinking.clone(),
}
.filter(|t| t != "off");
/// 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,
"anthropic-messages" => anthropic(cfg, system, tools, history, want.is_some(), 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()) {
@@ -99,31 +329,58 @@ pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Ent
(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);
eprintln!(
"agent: {} rejected thinking effort {current:?}; retrying with {effort:?}: {e}",
cfg.model
);
}
(result, _) => return result,
}
}
}
/// Native unit tests exercise history logic without an HTTP runtime.
#[cfg(not(target_os = "wasi"))]
async fn post(_url: &str, _headers: &[(&str, String)], _body: &Value) -> Result<Value, String> {
Err("inference requires the WASI HTTP runtime".into())
}
/// Send JSON through wasi:http and retain error bodies for context-size detection.
#[cfg(target_os = "wasi")]
async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result<Value, String> {
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 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 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}"));
}
serde_json::from_str(&text).map_err(|e| format!("invalid JSON in response: {e}"))
}
async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry], effort: Option<&str>) -> Result<Vec<Entry>, String> {
/// 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> {
let mut input = Vec::new();
for e in history {
match e {
@@ -141,7 +398,7 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry], e
Entry::ToolResult { id, output } => {
input.push(json!({"type": "function_call_output", "call_id": id, "output": output}))
}
Entry::Opaque { api, item } if api == "openai-responses" => input.push(item.clone()),
Entry::Opaque { api, model, item } if api == "openai-responses" && model == &cfg.model => input.push(item.clone()),
Entry::Opaque { .. } => {}
}
}
@@ -163,8 +420,8 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry], e
if let Some(effort) = effort {
body["reasoning"] = json!({"effort": effort});
}
let url = format!("{}/v1/responses", cfg.base_url);
let resp = post(&url, &[("authorization", format!("Bearer {}", cfg.token))], &body).await?;
let url = format!("{}/responses", cfg.base_url.trim_end_matches('/'));
let resp = post(&url, &cfg.bearer(), &body).await?;
let mut out = Vec::new();
for item in resp["output"].as_array().cloned().unwrap_or_default() {
match item["type"].as_str() {
@@ -189,24 +446,36 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry], e
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(), item }),
Some("reasoning") => out.push(Entry::Opaque {
api: "openai-responses".into(),
model: cfg.model.clone(),
item,
}),
_ => {}
}
}
if resp["status"] == "incomplete" && out.is_empty() {
return Err(format!("incomplete response: {}", resp["incomplete_details"]));
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"]
));
}
Ok(out)
}
/// Translate neutral history to Anthropic Messages and merge consecutive roles.
async fn anthropic(
cfg: &Config,
system: &str,
tools: &[Tool],
history: &[Entry],
thinking: bool,
effort: Option<&str>,
) -> Result<Vec<Entry>, String> {
let disabled = cfg.thinking == Some(Thinking::Off);
// 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() {
@@ -219,22 +488,39 @@ async fn anthropic(
Entry::Assistant { text, .. } => {
push("assistant", json!({"type": "text", "text": text}))
}
Entry::ToolCall { id, name, arguments } => {
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}))
}
Entry::ToolResult { id, output } => {
push("user", json!({"type": "tool_result", "tool_use_id": id, "content": output}))
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, item } if api == "anthropic-messages" && thinking => push("assistant", item.clone()),
Entry::Opaque { api, model, item }
if api == "anthropic-messages" && model == &cfg.model && !disabled =>
{
push("assistant", item.clone())
}
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!({
"model": cfg.model,
@@ -243,19 +529,23 @@ async fn anthropic(
"tools": tools,
"max_tokens": cfg.max_tokens.unwrap_or(32000),
});
if thinking {
body["thinking"] = json!({"type": "adaptive"});
if let Some(effort) = effort {
body["output_config"] = json!({"effort": effort});
// 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);
let resp = post(
&url,
&[("x-api-key", cfg.token.clone()), ("anthropic-version", "2023-06-01".to_string())],
&body,
)
.await?;
let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/'));
let mut headers = vec![("anthropic-version", "2023-06-01".to_string())];
if !cfg.token.is_empty() {
headers.push(("x-api-key", cfg.token.clone()));
}
let resp = post(&url, &headers, &body).await?;
let mut out = Vec::new();
for block in resp["content"].as_array().cloned().unwrap_or_default() {
match block["type"].as_str() {
@@ -270,11 +560,98 @@ 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(), item: block })
}
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))
}
+178 -306
View File
@@ -1,339 +1,211 @@
//! The Sausage agent actor.
//! Answer chat calls with an LLM and tools, under one lock for the complete turn.
mod history;
mod llm;
mod tools;
use history::{is_too_large, next_turn_start};
use llm::{Config, Entry};
use sausage_actor::{Inbox, Message, Value, json, publish, send};
use std::collections::{HashSet, VecDeque};
use llm::{Config, Entry, Thinking};
use sausage_plugin::{Ctx, Error, Result, lock, rpc, schema};
use serde_json::json;
use std::io::Write;
use wstd::future::FutureExt;
use wstd::time::{Duration, Instant};
/// The instructions sent with every inference request.
const SYSTEM: &str = include_str!("prompt.md");
const CONFIG: &str = "/data/config.json";
const HISTORY: &str = "/data/history.jsonl";
const HISTORY_SKIP: &str = "/data/history.skip";
const TURN: &str = "/data/turn.json";
fn main() {
wstd::runtime::block_on(async { Agent::new().run().await });
}
struct Agent {
inbox: Inbox,
history: Vec<Entry>,
history_skip: usize,
active: bool,
queue: VecDeque<Message>,
notes: Vec<Message>,
/// 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>,
}
#[rpc(grace_seconds = 600)]
impl Agent {
fn new() -> Self {
let history = std::fs::read_to_string(HISTORY)
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect();
let history_skip = std::fs::read_to_string(HISTORY_SKIP)
/// 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(|skip| skip.trim().parse::<usize>().ok())
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
let active = std::fs::read_to_string(TURN)
.ok()
.and_then(|contents| serde_json::from_str::<bool>(&contents).ok())
.unwrap_or(false);
Self {
inbox: Inbox::start(),
history,
history_skip,
active,
queue: VecDeque::new(),
notes: Vec::new(),
}
}
fn add_history(&mut self, entry: Entry) {
let line = serde_json::to_string(&entry).unwrap();
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(HISTORY)
{
Ok(mut file) => {
if let Err(e) = writeln!(file, "{line}") {
eprintln!("agent: cannot append history: {e}");
}
}
Err(e) => eprintln!("agent: cannot append history: {e}"),
}
self.history.push(entry);
}
fn finish_turn(&mut self) {
self.active = false;
let _ = std::fs::remove_file(TURN);
}
fn closing(&self) -> bool {
self.inbox.is_closed()
}
fn repair_dangling_tools(&mut self) {
let answered: HashSet<String> = self
.history
.iter()
.filter_map(|entry| match entry {
Entry::ToolResult { id, .. } => Some(id.clone()),
_ => None,
})
.collect();
let dangling: Vec<String> = self
.history
.iter()
.filter_map(|entry| match entry {
Entry::ToolCall { id, .. } if !answered.contains(id) => Some(id.clone()),
_ => None,
})
.collect();
for id in dangling {
self.add_history(Entry::ToolResult {
id,
output:
json!({"error": "interrupted: the agent restarted before this tool finished"})
.to_string(),
});
}
}
fn turn_text(&self, message: &Message) -> String {
let mut text = format!(
"Message {} from {}:\n{}",
message.id,
message.from,
message.body["text"].as_str().unwrap_or("")
);
if !self.notes.is_empty() {
let notes = self
.notes
.iter()
.map(|note| serde_json::to_string(note).unwrap())
.collect::<Vec<_>>()
.join("\n");
text = format!("Messages that arrived while you were idle:\n{notes}\n\n{text}");
}
text
}
fn not_configured(&mut self, error: String) {
let _ = publish(json!({
"type": "text",
"text": format!("I am not configured: cannot read {CONFIG}: {error}")
}));
self.finish_turn();
}
async fn run(mut self) {
self.repair_dangling_tools();
if self.active {
self.turn(None).await;
if self.closing() && self.active {
return;
}
}
start = next_turn_start(&entries, start, current).unwrap_or(current);
let definitions = tools::definitions();
loop {
let message = match self.queue.pop_front() {
Some(message) => message,
None => match self.inbox.recv().await {
Some(message) => message,
None => break,
},
};
if starts_turn(&message) {
self.turn(Some(message)).await;
if self.closing() && self.active {
break;
}
} else {
self.notes.push(message);
}
}
}
async fn turn(&mut self, message: Option<Message>) {
let current_start = if message.is_some() {
self.active = true;
let _ = std::fs::write(TURN, "true");
self.history.len()
} else {
let current_start = self
.history
.iter()
.rposition(|entry| matches!(entry, Entry::User { text } if !text.starts_with("[note from your runtime]")))
.unwrap_or(self.history.len());
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_default();
self.add_history(Entry::User {
text: format!(
"[note from your runtime] you restarted and now run version {version}. Continue the active turn."
),
});
current_start
};
let cfg = match load_config() {
Ok(cfg) => cfg,
Err(error) => {
self.not_configured(error);
return;
}
};
if let Some(message) = message {
let text = self.turn_text(&message);
self.notes.clear();
self.add_history(Entry::User { text });
}
self.steps(&cfg, current_start).await;
if !self.closing() {
self.finish_turn();
}
}
async fn steps(&mut self, cfg: &Config, current_start: usize) {
let tools = tools::definitions();
loop {
if self.closing() {
return;
}
let start = next_turn_start(&self.history, self.history_skip, current_start)
.unwrap_or(current_start);
if start != self.history_skip {
self.history_skip = start;
let _ = std::fs::write(HISTORY_SKIP, start.to_string());
}
let response = llm::complete(cfg, SYSTEM, &tools, &self.history[start..]).await;
let entries = match response {
Ok(entries) => entries,
Err(e) => {
if is_too_large(&e) {
if let Some(next) =
next_turn_start(&self.history, start.saturating_add(1), current_start)
{
self.history_skip = next;
let _ = std::fs::write(HISTORY_SKIP, next.to_string());
continue;
}
let response = match llm::complete(&cfg, SYSTEM, &definitions, &entries[start..]).await
{
Ok(response) => response,
Err(error) => {
if is_too_large(&error)
&& let Some(next) = next_turn_start(&entries, start + 1, current)
{
start = next;
continue;
}
let text = format!("Inference failed: {e}");
self.add_history(Entry::Assistant {
text: text.clone(),
phase: None,
});
let _ = publish(json!({"type": "text", "text": text}));
return;
return Err(Error::new(-32603, error));
}
};
let calls: Vec<(String, String, String)> = entries
std::fs::write(SKIP, start.to_string())?;
let has_tools = response
.iter()
.filter_map(|entry| match entry {
.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,
} => Some((id.clone(), name.clone(), arguments.clone())),
_ => None,
})
.collect();
for entry in entries {
if let Entry::Assistant { text, .. } = &entry {
if !text.is_empty() {
let _ = publish(json!({"type": "text", "text": text}));
}
} => calls.push((id.clone(), name.clone(), arguments.clone())),
_ => {}
}
self.add_history(entry);
append(&mut entries, entry)?;
}
if self.closing() {
return;
if !has_tools {
return Ok(final_text);
}
if calls.is_empty() {
return;
}
for (id, name, args) in calls {
if self.closing() {
return;
}
let output = self.tool(&name, &args).await;
self.add_history(Entry::ToolResult {
id,
output: output.to_string(),
});
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 })?;
}
}
}
}
fn stash(&mut self, message: Message, others: &mut Vec<Value>) {
if starts_turn(&message) {
others.push(json!({"queued_for_a_later_turn": true, "message": message}));
self.queue.push_back(message);
/// 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()),
}
}
/// 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);
}
#[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")
} else {
others.push(serde_json::to_value(message).unwrap());
}
}
fn drain_inbox(&mut self, others: &mut Vec<Value>) {
while let Some(message) = self.inbox.try_recv() {
self.stash(message, others);
}
}
async fn tool(&mut self, name: &str, args: &str) -> Value {
let args: Value = match serde_json::from_str(args) {
Ok(args) => args,
Err(error) => return json!({"error": format!("arguments are not valid JSON: {error}")}),
std::env::temp_dir().join(format!("sausage-history-{}", std::process::id()))
};
if name == "send_message" {
return self.send_message(&args).await;
}
tools::run(name, &args).unwrap_or_else(|error| json!({"error": error.to_string()}))
}
async fn send_message(&mut self, args: &Value) -> Value {
let Some(to) = args["to"].as_str() else {
return json!({"error": "missing \"to\""});
};
if !args["body"].is_object() {
return json!({"error": "\"body\" must be a JSON object"});
}
let id = send(to, args["body"].clone());
let wait = args["wait_seconds"].as_f64().unwrap_or(0.0).max(0.0);
let mut reply = Value::Null;
let mut others = Vec::new();
if wait > 0.0 {
let deadline = Instant::now() + Duration::from_secs_f64(wait);
loop {
match self.inbox.recv().timeout(deadline).await {
Ok(Some(message)) if message.reply_to.as_deref() == Some(&id) => {
reply = serde_json::to_value(message).unwrap();
break;
}
Ok(Some(message)) => self.stash(message, &mut others),
Ok(None) => break,
Err(_) => break,
}
}
}
self.drain_inbox(&mut others);
json!({"id": id, "reply": reply, "waited": wait > 0.0, "other_messages": others})
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();
}
}
fn load_config() -> Result<Config, String> {
std::fs::read_to_string(CONFIG)
.map_err(|e| e.to_string())
.and_then(|contents| serde_json::from_str(&contents).map_err(|e| e.to_string()))
}
fn starts_turn(message: &Message) -> bool {
message.kind() == "text" && message.reply_to.is_none()
}
+258
View File
@@ -0,0 +1,258 @@
//! 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), "");
}
}
+10 -5
View File
@@ -1,5 +1,10 @@
You are the `agent` actor in a Sausage world of actors.
People talk to you through other actors. Your text goes to your subscribers.
Before writing an actor, read `/actor/broker/public/PROTOCOL.md` for messages, the broker, `cargo`, and tests.
Read the SDK at `/actor/cargo/public/sdk/` before you write an actor.
You can change your own source at `/actor/agent/source`.
You are the Sausage agent. Answer the person. Use tools to inspect or change the world.
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.
+145 -15
View File
@@ -1,38 +1,84 @@
//! Tool descriptions and file operations for the agent.
use crate::llm::Tool;
use sausage_actor::{Value, json};
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: "send_message",
description: "Send an actor a JSON body with a `type`. `broker` and `cargo` are built-in actors; see `/actor/broker/public/PROTOCOL.md`. With `wait_seconds`, the reply's `reply_to` matches the returned ID. Returns the ID, reply, and other messages received.",
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!({
"to": {"type": "string", "description": "Actor ID"},
"body": {"type": "object", "description": "JSON object with a type field"},
"wait_seconds": {"type": "number", "description": "Seconds to wait for a reply. Default 0."}
"method": {"type": "string", "description": "Qualified plugin method"},
"params": {"type": "object", "description": "Named method parameters"},
"detach": {"type": "boolean", "description": "Detach the new process. Default false."}
}),
&["to", "body"],
&["method", "params"],
),
},
Tool {
name: "read_file",
description: "Read a text file under `/data`, `/public`, or `/actor/<id>`. Reads all bytes by default; `offset` and `max_bytes` are optional.",
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"}
"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 text under `/data` or `/public`. Parent folders are created.",
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"],
@@ -40,12 +86,12 @@ pub fn definitions() -> Vec<Tool> {
},
Tool {
name: "list_dir",
description: "List entries in a directory under `/data`, `/public`, or `/actor/<id>`. Returns each name, type, and size.",
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 `/actor/<id>/source` to `/public/drafts/<id>`. Skips `target/` folders.",
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"],
@@ -54,23 +100,47 @@ pub fn definitions() -> Vec<Tool> {
]
}
/// Create the named-parameter object required by a tool.
fn schema(properties: Value, required: &[&str]) -> Value {
json!({"type": "object", "properties": properties, "required": required})
}
pub fn run(name: &str, args: &Value) -> io::Result<Value> {
match name {
/// 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())
@@ -84,6 +154,7 @@ fn read_file(args: &Value) -> io::Result<Value> {
}))
}
/// 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() {
@@ -93,6 +164,7 @@ fn write_file(args: &Value) -> io::Result<Value> {
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())
@@ -109,7 +181,17 @@ fn list_dir(args: &Value) -> io::Result<Value> {
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(),
@@ -117,6 +199,7 @@ fn copy_dir(args: &Value) -> io::Result<Value> {
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;
@@ -136,3 +219,50 @@ fn copy_tree(from: &str, to: &str) -> io::Result<u64> {
}
Ok(count)
}
#[cfg(test)]
mod tests {
#[test]
fn overflow_json_can_be_followed_by_line_without_rearchiving() {
let value = serde_json::json!({"stdout":"hello 😀\n".repeat(4000),"exit":1,"error":{"text":"failure\n".repeat(4000)}});
let expected = super::render(&value);
let shown = super::overflow::guard(value.to_string(), "json-test", "call", true);
assert!(shown.len() <= 50 * 1024 && shown.lines().count() <= 2000);
let path = shown
.split("Full output: ")
.nth(1)
.unwrap()
.split(". Read with")
.next()
.unwrap();
assert_eq!(std::fs::read_to_string(path).unwrap(), expected);
let parent = std::path::Path::new(path).parent().unwrap();
let count = std::fs::read_dir(parent).unwrap().count();
let args = serde_json::json!({"path":path,"line_offset":30,"line_limit":5});
let read = super::read_file(&args).unwrap();
let read = read["content"].as_str().unwrap();
assert!(read.contains("Showing lines 31-35"));
assert!(read.contains(path));
assert_eq!(std::fs::read_dir(parent).unwrap().count(), count);
assert!(
super::overflow::existing(
expected.as_bytes(),
path,
&serde_json::json!({"line_offset":u64::MAX})
)
.contains("Full output:")
);
}
#[test]
fn copy_rejects_a_destination_inside_the_source() {
let root = if cfg!(target_os = "wasi") {
std::path::PathBuf::from("/copy-test")
} else {
std::env::temp_dir().join(format!("sausage-copy-{}", std::process::id()))
};
std::fs::create_dir_all(&root).unwrap();
let result = super::copy_dir(&serde_json::json!({"from":root,"to":root.join("target")}));
let _ = std::fs::remove_dir_all(root);
assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::InvalidInput);
}
}
-6
View File
@@ -1,6 +0,0 @@
{
"description": "the agent answers text with text, even without config or network",
"steps": [
{"send": {"type": "text", "text": "Hello"}, "expect": {"type": "text"}}
]
}
+20 -3
View File
@@ -2,7 +2,7 @@
name = "sausage"
version = "0.1.0"
edition = "2024"
description = "Grow a system of WASI actors by talking to it."
description = "Grow a system of WASI plugins by talking to it."
[[bin]]
name = "sausage"
@@ -11,15 +11,32 @@ path = "src/main.rs"
[dependencies]
anyhow = "1"
bytes = "1"
clap = { version = "4", features = ["derive"] }
clap = { version = "4", features = ["derive", "env"] }
http = "1"
http-body-util = "0.1"
include_dir = "0.7"
notify = "8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
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" }
+307
View File
@@ -0,0 +1,307 @@
//! Serialized candidate-copy activation and revert under PROTOCOL.md section 10.3.
use crate::{
Host, RpcResult, error,
graph::{Interface, io_error, problems, read_hashed},
};
use serde_json::{Value, json};
use std::{collections::BTreeMap, path::PathBuf};
/// Method descriptions plus their reachable local references, without expanding cycles.
fn methods(document: &Value) -> BTreeMap<String, Value> {
fn references(value: &Value, document: &Value, found: &mut BTreeMap<String, Value>) {
match value {
Value::Object(object) => {
if let Some(reference) = object.get("$ref").and_then(Value::as_str)
&& let Some(pointer) = reference.strip_prefix('#')
&& !found.contains_key(reference)
&& let Some(target) = document.pointer(pointer)
{
found.insert(reference.into(), target.clone());
references(target, document, found);
}
for child in object.values() {
references(child, document, found);
}
}
Value::Array(array) => {
for child in array {
references(child, document, found);
}
}
_ => {}
}
}
document["methods"]
.as_array()
.into_iter()
.flatten()
.filter_map(|method| {
let name = method["name"].as_str()?;
let mut found = BTreeMap::new();
references(method, document, &mut found);
Some((name.into(), json!({"method":method,"references":found})))
})
.collect()
}
struct Candidate {
plugin: String,
copy: PathBuf,
interface: Interface,
}
impl Host {
/// Copies first, discovers that copy, checks the combined graph, then installs it.
pub(crate) async fn activation(&self, _caller: &str, method: &str, params: Value) -> RpcResult {
let host = self.clone();
let method = method.to_owned();
tokio::spawn(async move { host.activation_body(&method, params).await })
.await
.map_err(|failure| error(-32603, &failure.to_string()))?
}
async fn activation_body(&self, method: &str, params: Value) -> RpcResult {
let _activation = self.0.activations.lock().await;
let mut candidates = Vec::new();
let result = self
.activate_candidates(method, &params, &mut candidates)
.await;
for candidate in candidates {
let _ = tokio::fs::remove_file(candidate.copy).await;
}
result
}
/// Prepares every member before graph checks or changes to installed files.
async fn activate_candidates(
&self,
method: &str,
params: &Value,
candidates: &mut Vec<Candidate>,
) -> RpcResult {
let requests = params
.get("plugins")
.and_then(Value::as_array)
.cloned()
.unwrap_or_else(|| vec![params.clone()]);
if requests.is_empty() {
return Err(error(-32602, "plugins must not be empty"));
}
for request in requests {
let plugin = request["plugin"]
.as_str()
.ok_or_else(|| error(-32602, "plugin must be a string"))?;
if plugin.is_empty()
|| !plugin.bytes().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| byte == b'_'
|| byte == b'-'
})
|| candidates
.iter()
.any(|candidate| candidate.plugin == plugin)
{
return Err(error(-32602, "invalid or duplicate plugin"));
}
let directory = self.0.world.plugin_dir(plugin);
tokio::fs::create_dir_all(&directory)
.await
.map_err(io_error)?;
let source = if method == "revert" {
directory.join("plugin.prev.wasm")
} else {
let source = PathBuf::from(
request["wasm"]
.as_str()
.ok_or_else(|| error(-32602, "wasm must be a string"))?,
);
if source.starts_with(&self.0.world.root) {
source
} else {
self.0
.world
.root
.join(source.strip_prefix("/").unwrap_or(&source))
}
};
let copy = directory.join(format!("activate-{}.wasm", uuid::Uuid::now_v7()));
// Read once. Discovery and installation both use the same private bytes.
let (bytes, hash) = read_hashed(source).await.map_err(io_error)?;
tokio::fs::write(&copy, &bytes).await.map_err(io_error)?;
self.0
.journal
.write("core", plugin, "core", "activation copied candidate");
let discovery = self.discover_copy(plugin, &copy, hash).await;
let interface = match discovery {
Ok(interface) => interface,
Err(error) => {
let _ = tokio::fs::remove_file(copy).await;
return Err(error);
}
};
self.0
.journal
.write("core", plugin, "core", "activation discovered candidate");
candidates.push(Candidate {
plugin: plugin.into(),
copy,
interface,
});
}
let names: Vec<_> = candidates
.iter()
.map(|candidate| candidate.plugin.clone())
.collect();
self.refresh_graph_except(&names).await?;
let mut previous = self.0.graph.lock().unwrap().plugins.clone();
// Startup discovery may not yet have visited a replaced plugin. Discover
// its immutable old copy for the diff, but never prevent repairing it.
for candidate in candidates.iter() {
if let Some(old) = previous.get_mut(&candidate.plugin)
&& old.document.is_null()
&& old.error.is_none()
{
if old.hash == candidate.interface.hash {
old.document = candidate.interface.document.clone();
continue;
}
let directory = self.0.world.plugin_dir(&candidate.plugin);
if let Ok((bytes, hash)) = read_hashed(directory.join("plugin.wasm")).await {
let copy = directory.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
if tokio::fs::write(&copy, bytes).await.is_ok()
&& let Ok(interface) =
self.discover_copy(&candidate.plugin, &copy, hash).await
{
*old = interface;
}
let _ = tokio::fs::remove_file(copy).await;
}
}
}
let mut proposed = previous.clone();
for candidate in candidates.iter() {
proposed.insert(candidate.plugin.clone(), candidate.interface.clone());
}
if params["force"] != true {
let failures = problems(&proposed, Some(&names));
if !failures.is_empty() {
self.0.journal.write(
"core",
"core",
"core",
&format!("activation rejected {}", json!(failures)),
);
let details = failures
.iter()
.map(|problem| {
format!(
"- {} imports {}: {}",
problem["consumer"],
problem["provider"],
problem["display"]
.as_str()
.unwrap_or_else(|| problem["message"]
.as_str()
.unwrap_or("incompatible import"))
)
})
.collect::<Vec<_>>()
.join("\n");
return Err(
json!({"code":-32003,"message":format!("{} was not activated: consumers break.\n{details}\nUpdate consumers first, or activate both together.",names.join(", ")),"data":failures}),
);
}
}
self.install_candidates(candidates, previous, proposed, params["force"] != true)
.await
}
/// Installs staged files and snapshots all old admissions under the shared write gate.
async fn install_candidates(
&self,
candidates: &[Candidate],
previous: BTreeMap<String, Interface>,
proposed: BTreeMap<String, Interface>,
checked: bool,
) -> RpcResult {
let admission = self.0.admission.write().await;
// Every candidate already has a temporary name before any installed file changes.
for candidate in candidates {
if previous
.get(&candidate.plugin)
.is_some_and(|old| old.hash == candidate.interface.hash)
{
continue;
}
let directory = self.0.world.plugin_dir(&candidate.plugin);
let current = directory.join("plugin.wasm");
if tokio::fs::try_exists(&current).await.map_err(io_error)? {
let backup = candidate.copy.with_extension("previous");
tokio::fs::hard_link(&current, &backup)
.await
.map_err(io_error)?;
tokio::fs::rename(&backup, directory.join("plugin.prev.wasm"))
.await
.map_err(io_error)?;
}
tokio::fs::rename(&candidate.copy, &current)
.await
.map_err(io_error)?;
}
let mut upgrades = Vec::new();
let mut summaries = serde_json::Map::new();
for candidate in candidates {
let old = previous.get(&candidate.plugin);
let before = methods(old.map(|old| &old.document).unwrap_or(&Value::Null));
let after = methods(&candidate.interface.document);
let added: Vec<_> = after
.keys()
.filter(|name| !before.contains_key(*name))
.collect();
let removed: Vec<_> = before
.keys()
.filter(|name| !after.contains_key(*name))
.collect();
let changed: Vec<_> = after
.iter()
.filter(|(name, method)| before.get(*name).is_some_and(|old| old != *method))
.map(|(name, _)| name)
.collect();
summaries.insert(candidate.plugin.clone(), json!({
"plugin": candidate.plugin,
"wasm": format!("/plugins/{}/plugin.wasm", candidate.plugin),
"hash": candidate.interface.hash,
"previous": old.map(|old| &old.hash).filter(|hash| !hash.is_empty()),
"method_count": candidate.interface.document["methods"].as_array().map_or(0, Vec::len),
"added": added,
"removed": removed,
"changed": changed,
"interface_check": if checked { "passed" } else { "skipped" },
}));
if let Some(old) = previous
.get(&candidate.plugin)
.filter(|old| !old.hash.is_empty() && old.hash != candidate.interface.hash)
{
let grace = old.document["x-sausage-grace"]
.as_f64()
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(60.0);
upgrades.push(self.snapshot_upgrade(
&candidate.plugin,
old.hash.clone(),
&candidate.interface.hash,
grace,
));
}
self.0.journal.write(
"core",
&candidate.plugin,
"core",
&format!("activation installed {}", candidate.interface.hash),
);
}
self.publish_graph(proposed);
drop(admission);
for upgrade in upgrades {
self.begin_upgrade(upgrade);
}
Ok(Value::Object(summaries))
}
}
+52
View File
@@ -0,0 +1,52 @@
# The configuration of the agent. Edit it before the first chat.
# api selects the request format. Choose one complete example below.
api: openai-responses
# OpenAI base URLs include /v1. Do not include /responses or /chat/completions.
base_url: https://api.openai.com/v1
# model is the provider's model identifier.
model: gpt-6.1-sol
# token is the API key. Replace CHANGE_ME with your key.
# An empty token requires allow_no_token: true. Then no authentication header is sent.
token: CHANGE_ME
allow_no_token: false
# thinking is how much the model thinks before it answers:
# off, low, medium, high, xhigh, or max. Remove the line to use the model's default,
# for example for a model without thinking.
# If the model rejects a level, the agent uses the nearest level that the model accepts.
# If the model cannot switch thinking off, off becomes low.
# A caller can set another level for one turn: agent.chat {"text": "...", "thinking": "high"}.
# The agent reads this file at the start of each turn, so a change applies to the next turn.
thinking: medium
# Optional output token cap. Anthropic defaults to 32000 if omitted.
# max_tokens: 32000
# OpenAI Responses example (base_url includes the version):
# api: openai-responses
# base_url: https://api.openai.com/v1
# model: gpt-6.1-sol
# token: YOUR_OPENAI_KEY
# allow_no_token: false
# thinking: medium
# OpenRouter example (OpenAI Chat Completions, the format of most providers):
# api: openai-chat-completions
# base_url: https://openrouter.ai/api/v1
# model: anthropic/claude-sonnet-5.5
# token: YOUR_OPENROUTER_KEY
# allow_no_token: false
# thinking: medium
# Local Ollama example (OpenAI Chat Completions, no key):
# api: openai-chat-completions
# base_url: http://localhost:11434/v1
# model: llama3.2
# token: ""
# allow_no_token: true
# Anthropic Messages example (base_url excludes the version):
# api: anthropic-messages
# base_url: https://api.anthropic.com
# model: claude-sonnet-5-5
# token: YOUR_ANTHROPIC_KEY
# allow_no_token: false
# thinking: medium
+12
View File
@@ -0,0 +1,12 @@
# 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
[]
-664
View File
@@ -1,664 +0,0 @@
//! The broker: message routing, mailboxes, subscriptions, taps, watches, the
//! version store, and the requests to `broker` (spawn, upgrade, rollback,
//! stop, start, remove, subscribe, unsubscribe, tap, untap, watch, unwatch,
//! exit). `supervisor.rs` runs each WASM actor.
//!
//! The broker accepts only `.wasm` files. It does not know any source language.
//! The world folder is the source of truth: at start, the broker boots every
//! `actor/<id>/actor.wasm`; afterwards the file watcher (`watch.rs`) reports
//! changes: a new `actor.wasm` becomes an actor, a changed one an upgrade.
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot};
use wasmtime::component::Component;
use crate::candidate;
use crate::runtime::{InstanceConfig, Mount, Runtime};
use crate::supervisor::{Control, Reply, Supervisor};
use crate::vfs;
use crate::world::{self, ActorMeta, NATIVE, VersionMeta, World};
pub const MAILBOX_SIZE: usize = 1000;
pub const SHUTDOWN_DEADLINE: Duration = Duration::from_secs(5);
#[derive(Clone, Debug)]
pub struct Envelope {
pub id: String,
pub from: String,
pub to: String,
pub reply_to: Option<String>,
pub body: Value,
}
impl Envelope {
/// The NDJSON line that the recipient reads on stdin.
pub fn to_line(&self) -> String {
let mut v = json!({"id": self.id, "from": self.from, "body": self.body});
if let Some(r) = &self.reply_to {
v["reply_to"] = json!(r);
}
v.to_string()
}
}
pub fn shutdown_line() -> String {
json!({"id": "broker/shutdown", "from": "broker", "body": {"type": "shutdown"}}).to_string()
}
pub struct Broker {
pub world: World,
pub runtime: Arc<Runtime>,
mailboxes: Mutex<HashMap<String, mpsc::Sender<Envelope>>>,
controls: Mutex<HashMap<String, mpsc::Sender<Control>>>,
/// `actor.json` of every actor, native and WASM.
metas: Mutex<BTreeMap<String, ActorMeta>>,
op_locks: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
seq: AtomicU64,
/// Set by the `exit` request: the host process should stop.
exit: tokio::sync::Notify,
/// Requests of the host from `fs` (see `vfs.rs`) that wait for a reply, by message ID.
asks: Mutex<HashMap<String, oneshot::Sender<Value>>>,
me: std::sync::Weak<Broker>,
}
impl Broker {
pub fn new(world: World, runtime: Arc<Runtime>) -> Result<Arc<Self>> {
let metas = world.actor_ids()?.into_iter().map(|id| (id.clone(), world.read_meta(&id))).collect();
Ok(Arc::new_cyclic(|me| Self {
world,
runtime,
mailboxes: Mutex::new(HashMap::new()),
controls: Mutex::new(HashMap::new()),
metas: Mutex::new(metas),
op_locks: Mutex::new(HashMap::new()),
seq: AtomicU64::new(1),
exit: tokio::sync::Notify::new(),
asks: Mutex::new(HashMap::new()),
me: me.clone(),
}))
}
/// Send `body` from `fs` to `to` now. The future resolves to the reply
/// body, or `None` after `vfs::ASK_TIMEOUT`.
pub fn ask(self: &Arc<Self>, to: &str, body: Value) -> impl Future<Output = Option<Value>> + Send + 'static {
let own = format!("q{}", self.seq.fetch_add(1, Ordering::Relaxed));
let id = format!("{}/{own}", vfs::ID);
let (tx, rx) = oneshot::channel();
self.asks.lock().unwrap().insert(id.clone(), tx);
self.send(vfs::ID, Some(to), Some(&own), None, body);
let this = self.clone();
async move {
let reply = tokio::time::timeout(vfs::ASK_TIMEOUT, rx).await;
this.asks.lock().unwrap().remove(&id);
reply.ok()?.ok()
}
}
/// Wait until an actor sends `{"type": "exit"}` to `broker`. The `sausage`
/// binary then stops all actors and exits, so that a wrapper such as
/// `nix run .#loop` can start a fresh (updated) host.
pub async fn exit_requested(&self) {
self.exit.notified().await;
}
pub fn meta(&self, id: &str) -> Option<ActorMeta> {
self.metas.lock().unwrap().get(id).cloned()
}
/// Change the `actor.json` of `id` (creating it if needed) and save it.
fn update_meta(&self, id: &str, f: impl FnOnce(&mut ActorMeta)) -> Result<()> {
let mut metas = self.metas.lock().unwrap();
let meta = metas.entry(id.to_string()).or_insert_with(|| ActorMeta { running: true, ..Default::default() });
f(meta);
self.world.write_meta(id, meta)
}
/// Create the mailbox of an actor. Messages wait here while the actor is
/// down, during a swap, or while it restarts.
pub fn open_mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
let (tx, rx) = mpsc::channel(MAILBOX_SIZE);
self.mailboxes.lock().unwrap().insert(id.to_string(), tx);
rx
}
/// Send a message. `to: None` publishes to the subscribers of `from`.
/// With `own_id`, the message ID is `"<from>/<own_id>"`. Returns the ID.
pub fn send(&self, from: &str, to: Option<&str>, own_id: Option<&str>, reply_to: Option<&str>, body: Value) -> String {
let id = match own_id {
Some(own) => format!("{from}/{own}"),
None => format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)),
};
let (recipients, tappers) = {
let metas = self.metas.lock().unwrap();
let recipients: Vec<String> = match to {
Some(to) => vec![to.to_string()],
None => metas
.iter()
.filter(|(_, m)| m.subscriptions.contains(from))
.map(|(id, _)| id.clone())
.collect(),
};
let tappers: Vec<String> = metas.iter().filter(|(_, m)| m.tapped).map(|(id, _)| id.clone()).collect();
(recipients, tappers)
};
if !tappers.is_empty() {
let message = json!({"id": id, "from": from, "to": to, "reply_to": reply_to, "body": body});
self.tap(&tappers, message);
}
for to in recipients {
self.deliver(Envelope {
id: id.clone(),
from: from.to_string(),
to,
reply_to: reply_to.map(str::to_string),
body: body.clone(),
});
}
id
}
/// Give every tapper a copy of one routed message. Copies skip `send`, so
/// they are never tapped themselves; a full mailbox drops the copy.
fn tap(&self, tappers: &[String], message: Value) {
let ts = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64);
self.inject(tappers, "broker", json!({"type": "tapped", "message": message, "ts": ts}));
}
/// Put a message of the host straight into the mailboxes of `to`: not
/// routed (so not tapped), never blocking, dropped if a mailbox is full.
fn inject(&self, to: &[String], from: &str, body: Value) {
let mailboxes = self.mailboxes.lock().unwrap();
for id in to {
if let Some(tx) = mailboxes.get(id) {
let _ = tx.try_send(Envelope {
id: format!("m-{}", self.seq.fetch_add(1, Ordering::Relaxed)),
from: from.into(),
to: id.clone(),
reply_to: None,
body: body.clone(),
});
}
}
}
/// Route one stdout line of a WASM actor.
pub fn route_line(&self, from: &str, line: &str) {
let line = line.trim();
if line.is_empty() {
return;
}
let Ok(Value::Object(obj)) = serde_json::from_str::<Value>(line) else {
self.log(from, &format!("stdout line is not a JSON object, ignored: {}", truncate(line, 500)));
return;
};
let Some(body) = obj.get("body").cloned() else {
self.log(from, &format!("message without \"body\", ignored: {}", truncate(line, 500)));
return;
};
let own_id = obj.get("id").and_then(Value::as_str);
let to = obj.get("to").and_then(Value::as_str);
let reply_to = obj.get("reply_to").and_then(Value::as_str);
self.send(from, to, own_id, reply_to, body);
}
fn deliver(&self, env: Envelope) {
if env.to == vfs::ID {
let waiting = env.reply_to.as_ref().and_then(|r| self.asks.lock().unwrap().remove(r));
if let Some(tx) = waiting {
let _ = tx.send(env.body);
}
return;
}
let tx = self.mailboxes.lock().unwrap().get(&env.to).cloned();
let error = match tx {
None => format!("no actor with ID {}", env.to),
Some(tx) => match tx.try_send(env.clone()) {
Ok(()) => return,
Err(mpsc::error::TrySendError::Full(_)) => format!("mailbox of {} is full", env.to),
Err(mpsc::error::TrySendError::Closed(_)) => format!("actor {} is gone", env.to),
},
};
if env.from != "broker" {
let body = json!({"type": "undeliverable", "reason": error, "to": env.to});
self.send("broker", Some(&env.from), None, Some(&env.id), body);
}
}
/// Append a host note to the stderr log of an actor.
pub fn log(&self, id: &str, text: &str) {
use std::io::Write;
let path = self.world.actor_dir(id).join("stderr.log");
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "[sausage {}] {text}", world::now());
}
}
fn op_lock(&self, id: &str) -> Arc<tokio::sync::Mutex<()>> {
self.op_locks.lock().unwrap().entry(id.to_string()).or_default().clone()
}
/// What an instance of `id` gets (see PROTOCOL.md). A candidate runs in
/// `scratch`, which holds a copy of the actor's `data/` and `public/`,
/// and has no network.
pub fn instance_config(&self, id: &str, version: u64, scratch: Option<&Path>) -> InstanceConfig {
let own = scratch.map_or_else(|| self.world.actor_dir(id), Path::to_path_buf);
let mut env = vec![("SAUSAGE_ACTOR".into(), id.to_string()), ("SAUSAGE_VERSION".into(), version.to_string())];
if scratch.is_some() {
env.push(("SAUSAGE_CANDIDATE".into(), "1".into()));
}
InstanceConfig {
mounts: vec![
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false },
Mount { guest: "/public".into(), host: own.join("public"), writable: true },
Mount { guest: "/data".into(), host: own.join("data"), writable: true },
],
network: scratch.is_none(),
stderr_log: own.join("stderr.log"),
env,
actor: id.to_string(),
broker: self.me.clone(),
}
}
// ---------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------
fn start_supervisor(self: &Arc<Self>, id: &str, version: u64, running: bool) {
let mailbox = self.open_mailbox(id);
let (ctl_tx, ctl_rx) = mpsc::channel(16);
self.controls.lock().unwrap().insert(id.to_string(), ctl_tx);
tokio::spawn(Supervisor::new(self.clone(), id.to_string(), version, running, mailbox, ctl_rx).run());
}
fn is_supervised(&self, id: &str) -> bool {
self.controls.lock().unwrap().contains_key(id)
}
async fn control(&self, id: &str, make: impl FnOnce(Reply) -> Control) -> Result<Value> {
let tx = self.controls.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no WASM actor with ID {id}"))?;
let (reply, rx) = oneshot::channel();
tx.send(make(reply)).await.map_err(|_| anyhow!("supervisor of {id} is gone"))?;
rx.await.map_err(|_| anyhow!("supervisor of {id} did not answer"))?
}
/// Stop every actor gracefully, in parallel (host shutdown). The
/// `running` flags stay, so the next `sausage run` starts them again.
pub async fn shutdown_all(self: &Arc<Self>) {
let ids: Vec<String> = self.controls.lock().unwrap().keys().cloned().collect();
let mut tasks = tokio::task::JoinSet::new();
for id in ids {
let this = self.clone();
tasks.spawn(async move { this.control(&id, |reply| Control::Shutdown { reply }).await });
}
tasks.join_all().await;
}
/// Compile `versions/<version>/actor.wasm`.
pub fn load_version(&self, id: &str, version: u64) -> Result<Component> {
self.load(id, &self.world.version_dir(id, version).join("actor.wasm"))
}
fn load(&self, id: &str, wasm: &Path) -> Result<Component> {
let started = Instant::now();
let component = self.runtime.load(wasm)?;
self.log(id, &format!("compiled {} in {} ms", wasm.display(), started.elapsed().as_millis()));
Ok(component)
}
/// Test a new version where it lies. Only when the tests pass, copy it
/// to `versions/<n>/`: that folder holds only versions that run(ran).
/// Returns the component and the test report.
async fn try_version(&self, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<(Component, Value)> {
let component = self.load(id, wasm)?;
let tests = candidate::test(self, id, meta.version, &component, source).await;
if let Err(e) = &tests {
self.log(id, &format!("version {} from {} failed its tests: {e:#}", meta.version, meta.wasm));
}
let tests = tests?;
let dir = self.world.version_dir(id, meta.version);
std::fs::create_dir_all(&dir)?;
std::fs::copy(wasm, dir.join("actor.wasm")).with_context(|| format!("copy {}", wasm.display()))?;
world::replace_source(source.unwrap_or(&dir.join("no-source")), &dir.join("source"))?;
world::write_atomic(&dir.join("meta.json"), serde_json::to_string_pretty(meta)?.as_bytes())?;
Ok((component, tests))
}
/// Make `version` the current `actor.wasm`, `source/`, and `actor.json`.
pub fn install(&self, id: &str, version: u64) -> Result<()> {
self.world.install_files(id, version)?;
self.update_meta(id, |m| {
m.version = Some(version);
m.running = true;
})
}
pub fn set_running(&self, id: &str, running: bool) -> Result<()> {
self.update_meta(id, |m| m.running = running)
}
/// Create a new actor from a `.wasm` file: test it, install it, start it.
pub async fn create_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, meta: VersionMeta) -> Result<Value> {
if !world::valid_id(id) {
bail!("invalid actor ID {id:?}: use a-z, 0-9, '-' and '_'");
}
let lock = self.op_lock(id);
let _guard = lock.lock().await;
if NATIVE.contains(&id) || self.is_supervised(id) || self.world.wasm_path(id).exists() {
bail!("an actor with ID {id} already exists");
}
let meta = VersionMeta { version: self.world.next_version(id), ..meta };
self.create_version(id, &meta, wasm, source).await
}
async fn create_version(self: &Arc<Self>, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<Value> {
let (_, tests) = self.try_version(id, meta, wasm, source).await?;
self.install(id, meta.version)?;
self.start_supervisor(id, meta.version, true);
Ok(json!({"type": "spawned", "actor": id, "version": meta.version, "tests": tests}))
}
pub async fn upgrade_actor(self: &Arc<Self>, id: &str, wasm: &Path, source: Option<&Path>, meta: VersionMeta) -> Result<Value> {
let lock = self.op_lock(id);
let _guard = lock.lock().await;
if !self.is_supervised(id) {
bail!("no WASM actor with ID {id}");
}
let version = self.world.next_version(id);
self.upgrade_version(id, &VersionMeta { version, ..meta }, wasm, source).await
}
async fn upgrade_version(self: &Arc<Self>, id: &str, meta: &VersionMeta, wasm: &Path, source: Option<&Path>) -> Result<Value> {
let (component, tests) = self.try_version(id, meta, wasm, source).await?;
let (version, requester) = (meta.version, meta.requested_by.clone());
let mut result = self.control(id, |reply| Control::Upgrade { version, component, requester, reply }).await?;
result["tests"] = tests;
Ok(result)
}
/// Stop an actor (normal shutdown) and delete its folder `actor/<id>/`.
/// Afterwards the ID is unknown: messages to it are undeliverable, and
/// it can be spawned again from scratch.
pub async fn remove_actor(&self, id: &str) -> Result<Value> {
if NATIVE.contains(&id) {
bail!("{id} is part of the host and cannot be removed");
}
if !world::valid_id(id) {
bail!("invalid actor ID {id:?}");
}
let lock = self.op_lock(id);
let _guard = lock.lock().await;
let dir = self.world.actor_dir(id);
if !self.is_supervised(id) && !dir.is_dir() {
bail!("no actor with ID {id}");
}
if self.is_supervised(id) {
self.control(id, |reply| Control::Shutdown { reply }).await?;
}
// Without its mailbox, messages to `id` are undeliverable.
self.controls.lock().unwrap().remove(id);
self.mailboxes.lock().unwrap().remove(id);
self.metas.lock().unwrap().remove(id);
if dir.exists() {
std::fs::remove_dir_all(&dir).with_context(|| format!("delete {}", dir.display()))?;
}
Ok(json!({"type": "removed", "actor": id}))
}
/// Boot the actors in `actor/`: at start, and as a full pass when the
/// file watcher dropped events (`overflowed`).
///
/// - An `actor.wasm` equal to the installed version starts as is.
/// - A new or changed `actor.wasm` is tested where it lies. A new actor
/// is installed, a changed one is an upgrade. If its tests fail,
/// nothing changes, and the file stays until it changes again.
pub async fn scan(self: &Arc<Self>) -> Vec<Value> {
let mut report = Vec::new();
for id in self.world.actor_ids().unwrap_or_default() {
if let Some(r) = self.boot_actor(&id).await {
report.push(r);
}
}
report
}
/// The file watcher saw `actor/<id>/actor.wasm` (or the folder) appear or
/// change: start, install or upgrade `id` as at boot, and log the result.
pub async fn check_actor(self: Arc<Self>, id: String) {
if let Some(result) = self.boot_actor(&id).await {
self.log(&id, &format!("actor.wasm changed: {result}"));
eprintln!("sausage: {result}");
}
}
/// The OS file watcher dropped events: tell every watcher with one
/// `fs_changed {path: "/actor", op: "rescan"}`, then run a full pass.
pub async fn overflowed(self: Arc<Self>) -> Vec<Value> {
self.host_error("the file watcher dropped events; watchers get a rescan, actors a full pass");
let watchers: Vec<String> =
self.metas.lock().unwrap().iter().filter(|(_, m)| !m.watches.is_empty()).map(|(id, _)| id.clone()).collect();
self.inject(&watchers, vfs::ID, json!({"type": "fs_changed", "path": "/actor", "op": "rescan"}));
let report = self.scan().await;
for change in &report {
eprintln!("sausage: {change}");
}
report
}
/// Send `fs_changed` for one coalesced change (from `watch.rs`) to every
/// actor that watches `path` or `to`. Like taps: straight into the
/// mailbox, dropped if it is full.
pub fn notify_watchers(&self, path: &str, op: &str, to: Option<&str>) {
let watchers: Vec<String> = {
let metas = self.metas.lock().unwrap();
metas
.iter()
.filter(|(_, m)| m.watches.iter().any(|w| under(path, w) || to.is_some_and(|t| under(t, w))))
.map(|(id, _)| id.clone())
.collect()
};
if watchers.is_empty() {
return;
}
let mut body = json!({"type": "fs_changed", "path": path, "op": op});
if let Some(to) = to {
body["to"] = json!(to);
}
self.inject(&watchers, vfs::ID, body);
}
/// A problem of the host itself: the broker's `stderr.log` and the terminal.
pub fn host_error(&self, text: &str) {
self.log("broker", text);
eprintln!("sausage: {text}");
}
async fn boot_actor(self: &Arc<Self>, id: &str) -> Option<Value> {
if NATIVE.contains(&id) || !world::valid_id(id) {
return None;
}
let lock = self.op_lock(id);
let _guard = lock.lock().await;
if !self.world.wasm_path(id).is_file() {
return None;
}
self.scan_actor(id).await
}
async fn scan_actor(self: &Arc<Self>, id: &str) -> Option<Value> {
// The broker's copy wins: actors change theirs (subscribe, ...) without the op lock.
let meta = self.metas.lock().unwrap().entry(id.to_string()).or_insert_with(|| self.world.read_meta(id)).clone();
let installed = meta.version.filter(|v| self.world.version_dir(id, *v).join("actor.wasm").is_file());
if let Some(v) = installed
&& !self.is_supervised(id)
{
self.start_supervisor(id, v, meta.running);
}
let wasm = self.world.wasm_path(id);
if installed.is_some_and(|v| same_file(&wasm, &self.world.version_dir(id, v).join("actor.wasm"))) {
return None;
}
let source = self.world.source_dir(id);
let vmeta = VersionMeta {
version: self.world.next_version(id),
wasm: format!("/actor/{id}/actor.wasm"),
source: Some(format!("/actor/{id}/source")),
built_by: None,
requested_by: "scan".into(),
created_at: world::now(),
};
let source = source.is_dir().then_some(source.as_path());
let result = match installed {
None => self.create_version(id, &vmeta, &wasm, source).await,
Some(_) => self.upgrade_version(id, &vmeta, &wasm, source).await,
};
Some(result.unwrap_or_else(|e| json!({"type": "error", "actor": id, "reason": format!("{e:#}")})))
}
/// Handle one message to `broker`.
async fn handle(self: Arc<Self>, env: Envelope) {
let body = self.clone().command(&env).await.unwrap_or_else(|e| {
json!({"type": "error", "request": env.body.get("type"), "reason": format!("{e:#}")})
});
self.send("broker", Some(&env.from), None, Some(&env.id), body);
}
async fn command(self: Arc<Self>, env: &Envelope) -> Result<Value> {
let b = &env.body;
let ty = b["type"].as_str().unwrap_or("");
let actor = || b["actor"].as_str().map(str::to_string).ok_or_else(|| anyhow!("missing \"actor\""));
let version_meta = || -> Result<(std::path::PathBuf, Option<std::path::PathBuf>, VersionMeta)> {
let wasm_s = b["wasm"].as_str().ok_or_else(|| anyhow!("missing \"wasm\""))?;
let wasm = self.world.resolve(&env.from, wasm_s)?;
if !wasm.is_file() {
bail!("{wasm_s} is not a file");
}
let source_s = b["source"].as_str();
let source = source_s.map(|s| self.world.resolve(&env.from, s)).transpose()?;
if let (Some(s), Some(p)) = (source_s, &source)
&& !p.is_dir()
{
bail!("{s} is not a folder");
}
let meta = VersionMeta {
version: 0,
wasm: wasm_s.to_string(),
source: source_s.map(str::to_string),
built_by: b["built_by"].as_str().map(str::to_string),
requested_by: env.from.clone(),
created_at: world::now(),
};
Ok((wasm, source, meta))
};
match ty {
"spawn" => {
let (wasm, source, meta) = version_meta()?;
self.create_actor(&actor()?, &wasm, source.as_deref(), meta).await
}
"upgrade" => {
let (wasm, source, meta) = version_meta()?;
self.upgrade_actor(&actor()?, &wasm, source.as_deref(), meta).await
}
"rollback" => {
let id = actor()?;
let version = b["version"].as_u64().ok_or_else(|| anyhow!("missing \"version\""))?;
let lock = self.op_lock(&id);
let _guard = lock.lock().await;
self.control(&id, |reply| Control::Rollback { version, reply }).await
}
"stop" => self.control(&actor()?, |reply| Control::Stop { reply }).await,
"start" => self.control(&actor()?, |reply| Control::Start { reply }).await,
"remove" => self.remove_actor(&actor()?).await,
"subscribe" | "unsubscribe" => {
let publisher = actor()?;
let on = ty == "subscribe";
self.update_meta(&env.from, |m| {
if on {
m.subscriptions.insert(publisher.clone());
} else {
m.subscriptions.remove(&publisher);
}
})?;
Ok(json!({"type": format!("{ty}d"), "actor": publisher}))
}
"tap" | "untap" => {
let on = ty == "tap";
self.update_meta(&env.from, |m| m.tapped = on)?;
let answer = if on { "tapping" } else { "untapped" };
Ok(json!({"type": answer}))
}
"watch" | "unwatch" => {
let on = ty == "watch";
let path_s = b["path"].as_str().ok_or_else(|| anyhow!("missing \"path\""))?;
let global = self.world.global_path(&self.world.resolve(&env.from, path_s)?);
let path = global.ok_or_else(|| anyhow!("{path_s} is not under /actor"))?;
let path = match path.trim_end_matches('/') {
"" => "/actor".to_string(),
p => p.to_string(),
};
self.update_meta(&env.from, |m| {
if on {
m.watches.insert(path.clone());
} else {
m.watches.remove(&path);
}
})?;
let answer = if on { "watching" } else { "unwatched" };
Ok(json!({"type": answer, "path": path}))
}
"exit" => {
self.log("broker", &format!("exit requested by {}", env.from));
// notify_one keeps the permit if nobody waits yet.
self.exit.notify_one();
Ok(json!({"type": "exiting"}))
}
other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, remove, subscribe, unsubscribe, tap, untap, watch, unwatch, exit"),
}
}
/// The native `broker` actor. Requests run concurrently. Aborting this
/// task aborts the requests that still run. Replies (messages with
/// `reply_to`) get no answer, so that two actors never answer each other
/// forever.
pub async fn run_actor(self: Arc<Self>, mut mailbox: mpsc::Receiver<Envelope>) {
let mut requests = tokio::task::JoinSet::new();
loop {
tokio::select! {
env = mailbox.recv() => match env {
Some(env) if env.reply_to.is_none() => { requests.spawn(self.clone().handle(env)); }
Some(_) => {}
None => break,
},
Some(_) = requests.join_next() => {}
}
}
}
}
fn same_file(a: &Path, b: &Path) -> bool {
matches!((std::fs::read(a), std::fs::read(b)), (Ok(x), Ok(y)) if x == y)
}
/// Whether the namespace path `path` is `prefix` or inside it.
fn under(path: &str, prefix: &str) -> bool {
path.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
pub fn truncate(s: &str, max: usize) -> String {
if s.len() <= max {
return s.to_string();
}
let mut end = max;
while !s.is_char_boundary(end) {
end -= 1;
}
format!("{}… ({} bytes)", &s[..end], s.len())
}
-156
View File
@@ -1,156 +0,0 @@
//! Tests for a candidate version, before the broker installs it.
//!
//! A test is a JSON file in `source/tests/*.json`, one scenario per file:
//!
//! ```json
//! {
//! "description": "add, then list",
//! "steps": [
//! {"send": {"type": "add", "text": "milk"}, "expect": {"type": "added"}},
//! {"send": {"type": "list"}, "from": "someone", "expect": [{"type": "notes"}], "to": "someone"}
//! ]
//! }
//! ```
//!
//! Each step sends `send` (optional, from `from` or "test"), then reads one
//! message for each entry of `expect` (an object or a list). An expected body
//! matches if it is a subset of the actual body. With `to`, each message must
//! also go to that recipient.
//!
//! Each file runs in a fresh instance on a copy of the actor's `/data` and
//! `/public` (empty folders for a new actor), without network. A version
//! without test files passes.
use std::path::Path;
use std::time::Duration;
use anyhow::{Result, anyhow, bail};
use serde_json::{Value, json};
use wasmtime::component::Component;
use crate::broker::{Broker, SHUTDOWN_DEADLINE, shutdown_line, truncate};
use crate::runtime::Instance;
use crate::world;
const EXPECT_TIMEOUT: Duration = Duration::from_secs(10);
/// `expected` matches `actual` if every key of an expected object exists in
/// the actual object with a matching value. Other values must be equal.
pub fn subset_match(expected: &Value, actual: &Value) -> bool {
match (expected, actual) {
(Value::Object(e), Value::Object(a)) => e.iter().all(|(k, v)| a.get(k).is_some_and(|av| subset_match(v, av))),
(Value::Array(e), Value::Array(a)) => e.len() == a.len() && e.iter().zip(a).all(|(x, y)| subset_match(x, y)),
_ => expected == actual,
}
}
struct Scratch {
dir: std::path::PathBuf,
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn stderr_tail(path: &Path) -> String {
let text = std::fs::read_to_string(path).unwrap_or_default();
let start = text.len().saturating_sub(2000);
let mut s = start;
while !text.is_char_boundary(s) {
s += 1;
}
text[s..].to_string()
}
/// The version under test, which starts from a copy of the actor's state.
struct Candidate<'a> {
broker: &'a Broker,
id: &'a str,
version: u64,
component: &'a Component,
}
/// Run every test file of `<source>/tests/`.
pub async fn test(broker: &Broker, id: &str, version: u64, component: &Component, source: Option<&Path>) -> Result<Value> {
let candidate = Candidate { broker, id, version, component };
let Some(source) = source else { return Ok(json!({"passed": []})) };
let mut files: Vec<_> = std::fs::read_dir(source.join("tests"))
.map(|rd| rd.filter_map(|e| Some(e.ok()?.path())).filter(|p| p.extension().is_some_and(|x| x == "json")).collect())
.unwrap_or_default();
files.sort();
let mut passed = Vec::new();
for file in &files {
let name = file.file_name().unwrap().to_string_lossy().to_string();
candidate.run_file(file).await.map_err(|e| anyhow!("test {name} failed: {e:#}"))?;
passed.push(name);
}
Ok(json!({"passed": passed}))
}
impl Candidate<'_> {
/// Start an instance on a fresh copy of the state, without network.
fn start(&self) -> Result<(Instance, Scratch)> {
static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = self.broker.world.scratch_dir().join(format!("{}-{}-{n}", self.id, std::process::id()));
let scratch = Scratch { dir: dir.clone() };
let world = &self.broker.world;
for (from, name) in [(world.data_dir(self.id), "data"), (world.public_dir(self.id), "public")] {
std::fs::create_dir_all(dir.join(name))?;
if from.is_dir() {
world::copy_tree(&from, &dir.join(name))?;
}
}
let cfg = self.broker.instance_config(self.id, self.version, Some(&dir));
Ok((self.broker.runtime.start(self.component, cfg)?, scratch))
}
async fn run_file(&self, file: &Path) -> Result<()> {
let test: Value = serde_json::from_str(&std::fs::read_to_string(file)?).map_err(|e| anyhow!("invalid JSON: {e}"))?;
let steps = test["steps"].as_array().ok_or_else(|| anyhow!("missing \"steps\" list"))?;
let (mut inst, scratch) = self.start()?;
let stderr = scratch.dir.join("stderr.log");
// Lines that the candidate wrote while a step was sent.
let mut early = std::collections::VecDeque::new();
for (n, step) in steps.iter().enumerate() {
let n = n + 1;
if let Some(body) = step.get("send") {
let from = step["from"].as_str().unwrap_or("test");
let env = json!({"id": format!("test/{n}"), "from": from, "body": body});
inst.send_line(&env.to_string(), |line| early.push_back(line))
.await
.map_err(|e| anyhow!("step {n}: candidate does not read stdin: {e}"))?;
}
let expected = match step.get("expect") {
None => vec![],
Some(Value::Array(list)) => list.clone(),
Some(one) => vec![one.clone()],
};
for expected in expected {
let next = async { if early.is_empty() { inst.stdout.recv().await } else { early.pop_front() } };
let line = match tokio::time::timeout(EXPECT_TIMEOUT, next).await {
Err(_) => bail!("step {n}: no message within {EXPECT_TIMEOUT:?}; expected {expected}"),
Ok(None) => {
let exit = inst.wait().await;
bail!("step {n}: candidate ended ({exit}); expected {expected}. stderr:\n{}", stderr_tail(&stderr))
}
Ok(Some(l)) => l,
};
let msg: Value = serde_json::from_str(&line)
.map_err(|_| anyhow!("step {n}: candidate wrote a non-JSON line: {}", truncate(&line, 300)))?;
if !subset_match(&expected, &msg["body"]) {
bail!("step {n}: expected body {expected}, got {}", truncate(&msg["body"].to_string(), 1000));
}
if let Some(to) = step.get("to")
&& msg.get("to") != Some(to)
{
bail!("step {n}: expected recipient {to}, got {}", msg.get("to").unwrap_or(&Value::Null));
}
}
}
inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE, |_| {}).await;
Ok(())
}
}
+836
View File
@@ -0,0 +1,836 @@
//! Native cargo builds, one immutable source copy, SDK installation, and process-group cancellation.
use crate::{Host, world::World};
use anyhow::{Context, Result, bail};
use include_dir::{Dir, include_dir};
use serde_json::{Value, json};
use std::time::Duration;
use std::{
path::{Path, PathBuf},
sync::Arc,
};
use tokio::{process::Command, time::Instant};
/// Default wall-clock budget after acquiring the build mutex (not queue time).
pub const DEFAULT_TIMEOUT_SECS: u64 = 900;
/// Default wall-clock budget for each WASI test instance.
pub const DEFAULT_TEST_TIMEOUT_SECS: u64 = 120;
/// Wall-clock budgets for a build and its individual WASI tests.
#[derive(Clone, Copy, Debug)]
pub struct BuildOptions {
pub timeout_secs: u64,
pub test_timeout_secs: u64,
}
impl Default for BuildOptions {
fn default() -> Self {
Self {
timeout_secs: DEFAULT_TIMEOUT_SECS,
test_timeout_secs: DEFAULT_TEST_TIMEOUT_SECS,
}
}
}
/// Optional RPC seconds are positive integers; null does not disable a deadline.
fn seconds(params: &Value, name: &str, default: u64) -> Result<u64, Value> {
match params.get(name) {
None => Ok(default),
Some(value) => value
.as_u64()
.filter(|seconds| *seconds >= 1)
.ok_or_else(|| {
crate::error(-32602, &format!("{name} must be an integer of at least 1"))
}),
}
}
/// The native build plugin.
pub const ID: &str = "cargo";
/// The target used for builds and tests.
pub const TARGET: &str = "wasm32-wasip2";
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/src");
static MACROS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk/macros/src");
static INTERFACE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../interface/src");
#[derive(Clone, Default, Debug, serde::Serialize)]
/// Test totals and captured output.
pub struct TestResults {
pub passed: u64,
pub failed: u64,
pub output: String,
}
/// Raw, unfiltered output shared by the test and release phases of one build.
#[derive(Clone, Debug, serde::Serialize)]
pub struct BuildLogs {
pub stdout: StreamLog,
pub stderr: StreamLog,
pub total_bytes: u64,
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct StreamLog {
pub path: String,
pub bytes: u64,
}
/// Persistent descriptors are inherited by cargo, so cancellation cannot lose
/// bytes already written by cargo or its descendants. No buffered writer is used.
struct LogFiles {
stdout: std::fs::File,
stderr: std::fs::File,
stdout_path: PathBuf,
stderr_path: PathBuf,
}
impl LogFiles {
fn create(world: &Path) -> Result<Self> {
let directory = world
.join("logs/cargo")
.join(chrono::Utc::now().format("%Y-%m-%d").to_string());
std::fs::create_dir_all(&directory).context("create cargo log directory")?;
let id = uuid::Uuid::now_v7();
let stdout_path = directory.join(format!("{id}.stdout.log"));
let stderr_path = directory.join(format!("{id}.stderr.log"));
let create = |path: &Path| {
std::fs::OpenOptions::new()
.create_new(true)
.append(true)
.open(path)
.with_context(|| format!("create {}", path.display()))
};
Ok(Self {
stdout: create(&stdout_path)?,
stderr: create(&stderr_path)?,
stdout_path,
stderr_path,
})
}
fn info(&self, world: &Path) -> Result<BuildLogs> {
let stream = |file: &std::fs::File, path: &Path| -> Result<StreamLog> {
Ok(StreamLog {
path: format!("/{}", path.strip_prefix(world)?.display()),
bytes: file.metadata()?.len(),
})
};
let stdout = stream(&self.stdout, &self.stdout_path)?;
let stderr = stream(&self.stderr, &self.stderr_path)?;
Ok(BuildLogs {
total_bytes: stdout.bytes + stderr.bytes,
stdout,
stderr,
})
}
fn read(&self, path: &Path, offset: u64) -> Result<Vec<u8>> {
use std::io::{Read, Seek, SeekFrom};
let mut file =
std::fs::File::open(path).with_context(|| format!("read {}", path.display()))?;
file.seek(SeekFrom::Start(offset))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
Ok(bytes)
}
}
/// A successful build, usable without a running host.
#[derive(Debug)]
pub struct BuildOutput {
pub logs: Option<BuildLogs>,
pub wasm: PathBuf,
pub warnings: String,
pub tests: TestResults,
}
/// A failed compiler or test phase, with its complete diagnostics.
#[derive(Debug)]
pub struct BuildFailure {
pub logs: Option<BuildLogs>,
pub reason: String,
pub tests: TestResults,
pub output: String,
pub elapsed_secs: Option<f64>,
pub step: Option<&'static str>,
}
impl std::fmt::Display for BuildFailure {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "{}\n{}", self.reason, self.output)
}
}
impl std::error::Error for BuildFailure {}
impl From<anyhow::Error> for BuildFailure {
fn from(error: anyhow::Error) -> Self {
Self {
logs: None,
reason: "build preparation failed".into(),
output: format!("{error:#}"),
tests: TestResults::default(),
elapsed_secs: None,
step: None,
}
}
}
/// A host-independent builder. Each builder serializes access to its work and target folders.
pub struct Cargo {
world: World,
builds: Arc<tokio::sync::Mutex<()>>,
runner: PathBuf,
target: PathBuf,
sdk: PathBuf,
}
impl Cargo {
/// Seeds a missing world SDK and selects the current sausage executable as runner.
pub fn new(world: World, cache: &Path) -> Result<Self> {
let mut runner = std::env::current_exe()?;
if runner
.parent()
.is_some_and(|parent| parent.file_name().is_some_and(|name| name == "deps"))
{
runner = runner.parent().unwrap().parent().unwrap().join("sausage");
}
Self::with_runner(world, runner, cache)
}
/// Seeds a missing world SDK and accepts an explicit sausage binary, including for init integrations.
pub fn with_runner(world: World, runner: PathBuf, cache: &Path) -> Result<Self> {
let sdk = world.plugin_dir(ID).join("sdk");
Self::seed_sdk(&sdk)?;
Ok(Self {
sdk,
target: std::path::absolute(cache)?.join("cargo-target"),
world,
runner,
builds: Arc::new(tokio::sync::Mutex::new(())),
})
}
/// Seeds only an absent SDK; an existing world owns all of its SDK files.
fn seed_sdk(sdk: &Path) -> Result<()> {
if sdk.try_exists()? {
return Ok(());
}
let mut files = std::collections::BTreeMap::new();
collect_files(&SDK, Path::new("src"), &mut files);
collect_files(&MACROS, Path::new("macros/src"), &mut files);
collect_files(&INTERFACE, Path::new("interface/src"), &mut files);
for (manifest, destination, interface_path) in [
(
include_str!("../../sdk/Cargo.toml"),
PathBuf::from("Cargo.toml"),
Some("interface"),
),
(
include_str!("../../sdk/macros/Cargo.toml"),
PathBuf::from("macros/Cargo.toml"),
Some("../interface"),
),
(
include_str!("../../interface/Cargo.toml"),
PathBuf::from("interface/Cargo.toml"),
None,
),
] {
let mut document: toml_edit::DocumentMut = manifest.parse()?;
if !document.contains_key("workspace") {
document["workspace"] = toml_edit::table();
}
if let Some(path) = interface_path {
for section in ["dependencies", "dev-dependencies"] {
if let Some(dependency) = document
.get_mut(section)
.and_then(|table| table.get_mut("sausage-interface"))
{
dependency["path"] = toml_edit::value(path);
}
}
}
files.insert(destination, document.to_string().into_bytes());
}
let parent = sdk.parent().context("SDK has no parent")?;
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
let staging = tempfile::Builder::new()
.prefix("seed-sdk-")
.tempdir_in(parent)
.with_context(|| format!("stage SDK in {}", parent.display()))?;
for (path, bytes) in &files {
write_changed(&staging.path().join(path), bytes)?;
}
// Another builder may have seeded the world while we prepared the files.
if !sdk.try_exists()?
&& let Err(error) = std::fs::rename(staging.path(), sdk)
&& !sdk.try_exists()?
{
return Err(error).with_context(|| format!("seed {}", sdk.display()));
}
Ok(())
}
/// Registers cargo.build as a cancellable native process.
pub fn register(self, host: &Host) {
let builder = Arc::new(self);
host.register_native(ID,Arc::new(move |_context,method,params| {
let builder=builder.clone();
Box::pin(async move {
if method=="rpc.discover" { return Ok(json!({"openrpc":"1.2.6","info":{"title":"cargo","version":"native"},"methods":[{"name":"build","paramStructure":"by-name","description":"Copies source, runs bounded WASI tests, then builds a release component. Deadlines kill cargo and its descendants; cancellation releases the build mutex.","params":[{"name":"plugin","required":true,"schema":{"type":"string"}},{"name":"timeout_secs","required":false,"description":"Total wall-clock limit in seconds for tests and release build after acquiring the build mutex (queue time excluded). Default 900; minimum 1. Cannot be disabled.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TIMEOUT_SECS}},{"name":"test_timeout_secs","required":false,"description":"Wall-clock limit in seconds for each WASI test instance. Default 120; minimum 1. A timed-out test is named and fails; remaining tests continue.","schema":{"type":"integer","minimum":1,"default":DEFAULT_TEST_TIMEOUT_SECS}}],"result":{"name":"build","schema":{"type":"object","required":["wasm","tests","warnings","logs"],"properties":{"logs":{"type":"object","required":["stdout","stderr","total_bytes"],"properties":{"stdout":{"type":"object","required":["path","bytes"],"properties":{"path":{"type":"string"},"bytes":{"type":"integer","minimum":0}}},"stderr":{"type":"object","required":["path","bytes"],"properties":{"path":{"type":"string"},"bytes":{"type":"integer","minimum":0}}},"total_bytes":{"type":"integer","minimum":0}}},"wasm":{"type":"string"},"warnings":{"type":"string"},"tests":{"type":"object","required":["passed","failed","output"],"properties":{"passed":{"type":"integer","minimum":0},"failed":{"type":"integer","minimum":0},"output":{"type":"string"}}}}}},"errors":[{"code":-32004,"message":"Build failed"}]}]})); }
if method!="build" { return Err(crate::error(-32601,"cargo method not found")); }
let plugin=params["plugin"].as_str().ok_or_else(||crate::error(-32602,"plugin must be a string"))?;
let options = BuildOptions {
timeout_secs: seconds(&params, "timeout_secs", DEFAULT_TIMEOUT_SECS)?,
test_timeout_secs: seconds(&params, "test_timeout_secs", DEFAULT_TEST_TIMEOUT_SECS)?,
};
match builder.build_with_options(&builder.world.plugin_dir(plugin).join("source"), options).await {
Ok(output)=>Ok(json!({"wasm":format!("/{}",output.wasm.strip_prefix(&builder.world.root).unwrap().display()),"tests":output.tests,"warnings":output.warnings,"logs":output.logs})),
Err(failure)=>Err(json!({"code":-32004,"message":failure.reason,"data":{"reason":failure.reason,"tests":failure.tests,"output":failure.output,"elapsed_secs":failure.elapsed_secs,"step":failure.step,"logs":failure.logs}})),
}
})
}));
}
/// Copies source once, runs WASI tests, then publishes a release component under state/cargo/builds.
pub async fn build(&self, source: &Path) -> std::result::Result<BuildOutput, BuildFailure> {
self.build_with_options(source, BuildOptions::default())
.await
}
/// Like build, with explicit positive wall-clock budgets. Waiting for the mutex is unbounded.
pub async fn build_with_options(
&self,
source: &Path,
options: BuildOptions,
) -> std::result::Result<BuildOutput, BuildFailure> {
let logs = LogFiles::create(&self.world.root)?;
let result = self.build_logged(source, options, &logs).await;
let info = logs.info(&self.world.root)?;
match result {
Ok(mut output) => {
output.logs = Some(info);
Ok(output)
}
Err(mut failure) => {
failure.logs = Some(info);
Err(failure)
}
}
}
async fn build_logged(
&self,
source: &Path,
options: BuildOptions,
logs: &LogFiles,
) -> std::result::Result<BuildOutput, BuildFailure> {
if options.timeout_secs == 0 || options.test_timeout_secs == 0 {
return Err(anyhow::anyhow!("timeouts must be at least 1 second").into());
}
let build_guard = self.builds.clone().lock_owned().await;
let started = Instant::now();
let deadline = started
.checked_add(Duration::from_secs(options.timeout_secs))
.ok_or_else(|| anyhow::anyhow!("timeout_secs is too large"))?;
let work = self.world.state_dir(ID).join("work");
let target = self.target.clone();
let temporary = self.world.state_dir(ID).join("tmp");
let cleanup = temporary.clone();
let source = source.to_path_buf();
let copy = work.clone();
let sdk = self.sdk.clone();
let build_guard = tokio::task::spawn_blocking(move || -> Result<_> {
Self::seed_sdk(&sdk)?;
if cleanup.exists() {
std::fs::remove_dir_all(&cleanup)
.with_context(|| format!("remove {}", cleanup.display()))?;
}
std::fs::create_dir_all(&cleanup)
.with_context(|| format!("create {}", cleanup.display()))?;
if copy.exists() {
std::fs::remove_dir_all(&copy)
.with_context(|| format!("remove {}", copy.display()))?;
}
copy_source(&source, &copy, true)?;
let manifest = copy.join("Cargo.toml");
let text = std::fs::read_to_string(&manifest)
.with_context(|| format!("read {}", manifest.display()))?;
std::fs::write(
&manifest,
prepare_manifest(&text, &sdk)
.with_context(|| format!("prepare {}", manifest.display()))?,
)
.with_context(|| format!("write {}", manifest.display()))?;
Ok(build_guard)
})
.await
.map_err(anyhow::Error::from)??;
let mut test = Command::new("cargo");
test.args([
"test",
"--no-fail-fast",
"--release",
"--target",
TARGET,
"--target-dir",
])
.arg(&target)
.current_dir(&work)
.env("TMPDIR", &temporary)
.env(
"SAUSAGE_TEST_TIMEOUT_SECS",
options.test_timeout_secs.to_string(),
)
.arg("--config")
.arg(format!(
"target.wasm32-wasip2.runner={}",
json!([self.runner, "test-runner"])
));
let output = run_command(
test,
started,
deadline,
"tests",
TestResults::default(),
logs,
)
.await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let tests = TestResults {
passed: test_count(&stdout, "passed"),
failed: if output.status.success() {
0
} else {
test_count(&stdout, "failed").max(1)
},
output: strip_progress(&format!("{stdout}\n{stderr}")),
};
if !output.status.success() {
return Err(BuildFailure {
logs: None,
reason: "WASI tests failed".into(),
output: tests.output.clone(),
tests,
elapsed_secs: None,
step: None,
});
}
let mut build = Command::new("cargo");
build
.args([
"build",
"--release",
"--target",
TARGET,
"--message-format=json-render-diagnostics",
"--target-dir",
])
.arg(&target)
.current_dir(&work);
let output = run_command(
build,
started,
deadline,
"release build",
tests.clone(),
logs,
)
.await?;
let result = async {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let diagnostics = stderr.to_string();
let mut artifacts = Vec::new();
for line in stdout.lines() {
if let Ok(message) = serde_json::from_str::<Value>(line)
&& message["reason"] == "compiler-artifact"
&& message["target"]["kind"]
.as_array()
.is_some_and(|kinds| kinds.iter().any(|kind| kind == "bin"))
&& let Some(executable) = message["executable"].as_str()
{
artifacts.push(PathBuf::from(executable));
}
}
if !output.status.success() {
bail!("{diagnostics}");
}
if artifacts.len() != 1 {
bail!(
"cargo must build exactly one binary, found {}",
artifacts.len()
);
}
let artifact = artifacts.pop().unwrap();
let builds = self.world.state_dir(ID).join("builds");
let destination = tokio::task::spawn_blocking(move || {
let _guard = build_guard;
publish(&builds, &artifact)
})
.await??;
Ok(BuildOutput {
logs: None,
wasm: destination,
warnings: strip_progress(&diagnostics),
tests: tests.clone(),
})
}
.await;
result.map_err(|error: anyhow::Error| BuildFailure {
logs: None,
reason: "release build failed".into(),
output: format!("{error:#}"),
tests,
elapsed_secs: None,
step: None,
})
}
}
/// Kills cargo and every descendant, including tests and build scripts, on cancellation.
struct ProcessGroup(u32);
impl Drop for ProcessGroup {
fn drop(&mut self) {
let _ = nix::sys::signal::killpg(
nix::unistd::Pid::from_raw(self.0 as i32),
nix::sys::signal::Signal::SIGKILL,
);
}
}
/// Starts one process group and retains its guard across all output collection.
async fn run_command(
mut command: Command,
started: Instant,
deadline: Instant,
step: &'static str,
mut tests: TestResults,
logs: &LogFiles,
) -> std::result::Result<std::process::Output, BuildFailure> {
command
.process_group(0)
.kill_on_drop(true)
.stdin(std::process::Stdio::null())
// Child writes go straight to persistent files: no pipe buffers or reader
// tasks can be dropped on cancellation. Parsing happens only afterwards.
.stdout(logs.stdout.try_clone().context("clone stdout log")?)
.stderr(logs.stderr.try_clone().context("clone stderr log")?);
let out_start = logs.stdout.metadata().context("stat stdout log")?.len();
let err_start = logs.stderr.metadata().context("stat stderr log")?.len();
let mut child = command.spawn().context("start cargo")?;
let group = ProcessGroup(child.id().unwrap());
let (status, timed_out) = {
let collect = child.wait();
tokio::pin!(collect);
tokio::select! {
biased;
_ = tokio::time::sleep_until(deadline) => {
// Kill the entire tree before waiting for EOF/reaping cargo. The guard also
// covers cancellation while collecting, and runs before the mutex drops.
let _ = nix::sys::signal::killpg(nix::unistd::Pid::from_raw(group.0 as i32), nix::sys::signal::Signal::SIGKILL);
(collect.await, true)
}
status = &mut collect => (status, false),
}
};
let status = status.map_err(anyhow::Error::from)?;
// Also stop any descendants that kept the streams open after cargo exited.
drop(group);
let out = logs.read(&logs.stdout_path, out_start)?;
let err = logs.read(&logs.stderr_path, err_start)?;
if timed_out {
let output = format!(
"{}\n{}",
String::from_utf8_lossy(&out),
String::from_utf8_lossy(&err)
);
let output = output
.trim_end()
.lines()
.rev()
.take(50)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
if step == "tests" {
tests.passed = test_count(&String::from_utf8_lossy(&out), "passed");
tests.failed = test_count(&String::from_utf8_lossy(&out), "failed");
tests.output = output.clone();
}
return Err(BuildFailure {
logs: None,
reason: "timeout".into(),
tests,
output,
elapsed_secs: Some(started.elapsed().as_secs_f64()),
step: Some(step),
});
}
Ok(std::process::Output {
status,
stdout: out,
stderr: err,
})
}
/// Copies only source, excluding build outputs and git internals at every level.
fn copy_source(source: &Path, destination: &Path, root: bool) -> Result<()> {
std::fs::create_dir_all(destination)
.with_context(|| format!("create {}", destination.display()))?;
for entry in std::fs::read_dir(source).with_context(|| format!("read {}", source.display()))? {
let entry = entry.with_context(|| format!("read entry in {}", source.display()))?;
if root && matches!(entry.file_name().to_str(), Some("target" | ".git")) {
continue;
}
let from = entry.path();
let to = destination.join(entry.file_name());
if from.is_dir() {
copy_source(&from, &to, false)?;
} else {
std::fs::copy(&from, &to)
.with_context(|| format!("copy {} to {}", from.display(), to.display()))?;
}
}
Ok(())
}
/// Repoints SDK dependencies without discarding feature flags and detaches the copied crate's workspace.
fn prepare_manifest(text: &str, sdk: &Path) -> Result<String> {
let mut document: toml_edit::DocumentMut = text.parse()?;
rewrite_dependencies(document.as_table_mut(), sdk);
if !document.contains_key("workspace") {
document["workspace"] = toml_edit::table();
}
Ok(document.to_string())
}
/// Rewrites dependency entries recursively, including target and workspace tables.
fn rewrite_dependencies(table: &mut dyn toml_edit::TableLike, sdk: &Path) {
for (name, item) in table.iter_mut() {
if matches!(
&*name,
"dependencies" | "dev-dependencies" | "build-dependencies"
) {
if let Some(dependencies) = item.as_table_like_mut() {
for (name, dependency) in dependencies.iter_mut() {
if dependency
.get("workspace")
.and_then(toml_edit::Item::as_bool)
== Some(true)
{
continue;
}
if &*name == "sausage-plugin"
|| dependency.get("package").and_then(toml_edit::Item::as_str)
== Some("sausage-plugin")
{
if dependency.is_str() {
let version = dependency.as_str().unwrap().to_string();
*dependency = toml_edit::value(toml_edit::InlineTable::new());
dependency["version"] = toml_edit::value(version);
}
dependency["path"] = toml_edit::value(sdk.display().to_string());
}
}
}
} else if let Some(nested) = item.as_table_like_mut() {
rewrite_dependencies(nested, sdk);
}
}
}
/// Publishes a complete artifact and retains only the ten newest numeric build folders.
fn publish(builds: &Path, artifact: &Path) -> Result<PathBuf> {
std::fs::create_dir_all(builds).with_context(|| format!("create {}", builds.display()))?;
let mut numbers: Vec<u64> = std::fs::read_dir(builds)
.with_context(|| format!("read {}", builds.display()))?
.filter_map(|entry| entry.ok()?.file_name().to_str()?.parse().ok())
.collect();
numbers.sort_unstable();
let number = numbers.last().copied().unwrap_or(0) + 1;
let destination = builds
.join(number.to_string())
.join(artifact.file_name().unwrap());
crate::world::write_atomic(
&destination,
&std::fs::read(artifact).with_context(|| format!("read {}", artifact.display()))?,
)
.with_context(|| format!("publish {}", destination.display()))?;
for old in numbers.iter().take(numbers.len().saturating_sub(9)) {
let path = builds.join(old.to_string());
std::fs::remove_dir_all(&path).with_context(|| format!("remove {}", path.display()))?;
}
Ok(destination)
}
/// Writes embedded sources only when their bytes changed.
fn write_changed(path: &Path, bytes: &[u8]) -> Result<()> {
if std::fs::read(path).ok().as_deref() != Some(bytes) {
std::fs::create_dir_all(path.parent().unwrap())
.with_context(|| format!("create parent of {}", path.display()))?;
std::fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
}
Ok(())
}
/// Collects embedded source files for seeding a missing world SDK.
fn collect_files(
directory: &Dir<'_>,
destination: &Path,
files: &mut std::collections::BTreeMap<PathBuf, Vec<u8>>,
) {
for file in directory.files() {
files.insert(destination.join(file.path()), file.contents().to_vec());
}
for directory in directory.dirs() {
collect_files(directory, destination, files);
}
}
/// Counts only final libtest summaries, never individual status lines.
fn test_count(output: &str, field: &str) -> u64 {
output
.lines()
.filter_map(|line| line.strip_prefix("test result:"))
.flat_map(|summary| summary.split(';'))
.filter_map(|part| {
let words: Vec<_> = part.split_whitespace().collect();
let index = words.iter().position(|word| *word == field)?;
words.get(index.checked_sub(1)?)?.parse::<u64>().ok()
})
.sum()
}
/// Removes progress lines while retaining compiler diagnostics.
fn strip_progress(output: &str) -> String {
output
.lines()
.filter(|line| {
![
"Compiling ",
"Finished ",
"Blocking ",
"Updating ",
"Locking ",
"Adding ",
]
.iter()
.any(|prefix| line.trim_start().starts_with(prefix))
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod timeout_tests {
use super::*;
/// Streams retain exact bytes, including non-UTF8, progress and JSON; phases append.
#[tokio::test]
async fn raw_streams_append_before_parsing() {
let directory = tempfile::tempdir().unwrap();
let logs = LogFiles::create(directory.path()).unwrap();
for _ in 0..2 {
let mut command = Command::new("sh");
command.args(["-c", "printf '\\377raw\\r'; printf ' Compiling progress\\n{\"reason\":\"compiler-artifact\"}\\n' >&2; exit 3"]);
let now = Instant::now();
let output = tokio::time::timeout(
Duration::from_secs(5),
run_command(
command,
now,
now + Duration::from_secs(3),
"tests",
TestResults::default(),
&logs,
),
)
.await
.unwrap()
.unwrap();
assert!(!output.status.success());
assert_eq!(output.stdout, b"\xffraw\r");
assert!(String::from_utf8_lossy(&output.stderr).contains("Compiling progress"));
}
assert_eq!(
std::fs::read(&logs.stdout_path).unwrap(),
b"\xffraw\r\xffraw\r"
);
let info = logs.info(directory.path()).unwrap();
assert_eq!(info.stdout.bytes, 10);
assert_eq!(info.total_bytes, info.stdout.bytes + info.stderr.bytes);
assert!(info.stdout.path.starts_with("/logs/cargo/"));
assert!(!directory.path().join("state/cargo").exists());
}
/// Cancellation leaves kernel-written data intact, even without a newline.
#[tokio::test]
async fn cancelled_command_keeps_logs() {
use notify::Watcher;
let directory = tempfile::tempdir().unwrap();
let logs = LogFiles::create(directory.path()).unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut watcher =
notify::recommended_watcher(move |event: notify::Result<notify::Event>| {
if let Ok(event) = event {
if event
.paths
.iter()
.any(|path| path.file_name().is_some_and(|name| name == "ready"))
{
let _ = tx.send(());
}
}
})
.unwrap();
watcher
.watch(directory.path(), notify::RecursiveMode::NonRecursive)
.unwrap();
let mut command = Command::new("sh");
command.args(["-c", "printf 'partial\\377'; printf 'stderr\\r' >&2; touch \"$1/ready\"; exec sleep 1000", "sh"]).arg(directory.path());
let now = Instant::now();
let task = tokio::spawn(async move {
run_command(
command,
now,
now + Duration::from_secs(60),
"tests",
TestResults::default(),
&logs,
)
.await
});
tokio::time::timeout(Duration::from_secs(5), rx.recv())
.await
.unwrap()
.unwrap();
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
let paths: Vec<_> = std::fs::read_dir(directory.path().join("logs/cargo"))
.unwrap()
.flat_map(|day| std::fs::read_dir(day.unwrap().path()).unwrap())
.map(|file| file.unwrap().path())
.collect();
assert_eq!(paths.len(), 2);
for path in paths {
let expected: &[u8] = if path.to_string_lossy().ends_with(".stdout.log") {
b"partial\xff"
} else {
b"stderr\r"
};
assert_eq!(std::fs::read(path).unwrap(), expected);
}
}
/// Deadline errors retain the shared start time, phase, test totals, and output tail.
#[tokio::test]
async fn timeout_diagnostics() {
let mut command = Command::new("sh");
command.args([
"-c",
"i=0; while [ $i -lt 100 ]; do echo diagnostic-$i; i=$((i+1)); done; exec sleep 1000",
]);
let directory = tempfile::tempdir().unwrap();
let logs = LogFiles::create(directory.path()).unwrap();
let now = Instant::now();
let failure = run_command(
command,
now - Duration::from_secs(10),
now + Duration::from_secs(1),
"release build",
TestResults {
passed: 7,
failed: 0,
output: "successful tests".into(),
},
&logs,
)
.await
.unwrap_err();
assert_eq!(failure.reason, "timeout");
assert_eq!(failure.step, Some("release build"));
assert!(failure.elapsed_secs.unwrap() >= 11.0);
assert_eq!(failure.tests.passed, 7);
assert_eq!(failure.tests.output, "successful tests");
assert_eq!(failure.output.lines().count(), 50);
assert!(failure.output.contains("diagnostic-99"));
assert!(!failure.output.contains("diagnostic-0\n"));
let raw = std::fs::read_to_string(&logs.stdout_path).unwrap();
assert_eq!(raw.lines().count(), 100);
assert!(raw.starts_with("diagnostic-0\n"));
}
}
-328
View File
@@ -1,328 +0,0 @@
//! The native `cargo` actor: builds Rust source folders into `.wasm` files.
//!
//! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}`
//! optional `"skip_tests": true` skips native `cargo test` (tests run by default).
//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "tests": {"passed": 1, "failed": 0, "output": "..."}, ...}`
//! `{"type": "build_failed", "errors": "...", "tests": {"passed": 0, "failed": 1, "output": "..."}}`
//!
//! The broker knows nothing about Rust. This actor is one way to get a
//! `.wasm` file. Other actors can add other languages.
//!
//! Actors depend on the SDK with `sausage-actor = "0.1"`. The SDK is not
//! published: this actor copies the SDK of this host to `public/sdk/` (so
//! that every actor can read it), and points the dependency there in the
//! build copy of `Cargo.toml`.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
use anyhow::{Context, Result, anyhow, bail};
use serde_json::{Value, json};
use tokio::process::Command;
use tokio::sync::mpsc;
use include_dir::{Dir, include_dir};
use crate::broker::{Broker, Envelope};
use crate::world::{self, World};
pub const ID: &str = "cargo";
pub const TARGET: &str = "wasm32-wasip2";
/// The source of the `sausage-actor` SDK.
static SDK: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../sdk");
#[derive(Clone, Default, Debug)]
pub struct TestResults {
pub passed: u64,
pub failed: u64,
pub output: String,
}
impl TestResults {
fn value(&self) -> Value {
json!({"passed": self.passed, "failed": self.failed, "output": self.output})
}
}
pub struct BuildOutput {
/// Host path of the published `.wasm` file.
pub wasm: PathBuf,
pub seconds: f64,
pub warnings: String,
pub tests: TestResults,
}
#[derive(Debug)]
pub struct BuildFailure {
pub reason: String,
pub tests: TestResults,
}
impl std::fmt::Display for BuildFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.tests.failed > 0 && !self.tests.output.is_empty() {
write!(f, "{}:\n{}", self.reason, self.tests.output)
} else {
f.write_str(&self.reason)
}
}
}
impl std::error::Error for BuildFailure {}
impl From<anyhow::Error> for BuildFailure {
fn from(error: anyhow::Error) -> Self {
Self { reason: format!("{error:#}"), tests: TestResults::default() }
}
}
/// All builds share one target folder, so they run one at a time.
pub struct Cargo {
world: World,
}
impl Cargo {
/// Create the actor and install the SDK of this host into `public/sdk/`.
pub fn new(world: World) -> Result<Self> {
write_dir(&SDK, &world.public_dir(ID).join("sdk"))?;
Ok(Self { world })
}
/// Run native `cargo test` unless opted out, then build and publish the
/// crate's `wasm32-wasip2` binary under `/actor/cargo/public/builds/<n>/`.
pub async fn build(&self, source: &Path, skip_tests: bool) -> std::result::Result<BuildOutput, BuildFailure> {
if !source.join("Cargo.toml").is_file() {
return Err(BuildFailure {
reason: format!("{} has no Cargo.toml", source.display()),
tests: TestResults::default(),
});
}
let started = Instant::now();
let data = self.world.data_dir(ID);
let build_dir = data.join("work");
world::replace_source(source, &build_dir)?;
let manifest = build_dir.join("Cargo.toml");
let original = std::fs::read_to_string(&manifest).context("read build manifest")?;
let prepared = prepare_manifest(&original, Some(&self.world.public_dir(ID).join("sdk")))?;
std::fs::write(&manifest, prepared).context("write prepared manifest")?;
let target_dir = data.join("target");
let tests = if skip_tests {
TestResults { output: "skipped (skip_tests=true)".into(), ..TestResults::default() }
} else {
match run_tests(&build_dir, &target_dir).await {
Ok(tests) => tests,
Err(error) => {
let output = format!("cannot run cargo tests: {error:#}");
return Err(BuildFailure {
reason: output.clone(),
tests: TestResults { failed: 1, output, ..TestResults::default() },
});
}
}
};
if tests.failed > 0 {
return Err(BuildFailure { reason: "unit tests failed".into(), tests });
}
let build_result: Result<BuildOutput> = async {
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(&target_dir)
.current_dir(&build_dir)
.kill_on_drop(true);
let out = cmd.output().await.context("cannot run cargo (is it on PATH, with the wasm32-wasip2 target?)")?;
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
if !out.status.success() {
bail!("{}", strip_progress(&stderr));
}
let mut artifact = None;
for line in String::from_utf8_lossy(&out.stdout).lines() {
if let Ok(v) = serde_json::from_str::<Value>(line)
&& v["reason"] == "compiler-artifact"
&& let Some(exe) = v["executable"].as_str()
{
artifact = Some(PathBuf::from(exe));
}
}
let artifact = artifact.ok_or_else(|| anyhow!("cargo built no binary. Does the crate have a src/main.rs?"))?;
let n = next_build_number(&self.world.public_dir(ID).join("builds"))?;
let dest_dir = self.world.public_dir(ID).join("builds").join(n.to_string());
let name = artifact.file_name().context("artifact has no file name")?;
let dest = dest_dir.join(name);
world::write_atomic(&dest, &std::fs::read(&artifact)?)?;
Ok(BuildOutput {
wasm: dest,
seconds: started.elapsed().as_secs_f64(),
warnings: strip_progress(&stderr),
tests: tests.clone(),
})
}
.await;
build_result.map_err(|error| BuildFailure { reason: format!("{error:#}"), tests })
}
/// The native `cargo` actor. Builds run one at a time, in order.
/// Replies (messages with `reply_to`) get no answer.
pub async fn run_actor(self, broker: Arc<Broker>, mut mailbox: mpsc::Receiver<Envelope>) {
while let Some(env) = mailbox.recv().await {
if env.reply_to.is_some() {
continue;
}
let body = self.handle(&broker, &env).await.unwrap_or_else(|e| {
json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{e:#}"), "tests": TestResults::default().value()})
});
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 skip_tests = env.body.get("skip_tests").and_then(Value::as_bool).unwrap_or(false);
match self.build(&source, skip_tests).await {
Ok(out) => {
let wasm = self.world.global_path(&out.wasm).unwrap_or_else(|| out.wasm.display().to_string());
Ok(json!({
"type": "built",
"wasm": wasm,
"source": source_s,
"built_by": ID,
"seconds": (out.seconds * 10.0).round() / 10.0,
"warnings": out.warnings,
"tests": out.tests.value(),
}))
}
Err(failure) => Ok(json!({
"type": "build_failed",
"source": source_s,
"errors": failure.reason,
"tests": failure.tests.value(),
})),
}
}
other => bail!("unknown request {other:?}. Known: build"),
}
}
}
/// Run tests for the host triple: WASI crates' pure-logic tests can use the
/// normal Rust test harness without needing a WASI test runner.
async fn run_tests(build_dir: &Path, target_dir: &Path) -> Result<TestResults> {
let mut cmd = Command::new("cargo");
cmd.arg("test")
.arg("--target-dir")
.arg(target_dir)
.current_dir(build_dir)
.kill_on_drop(true);
let out = cmd.output().await.context("cannot run cargo test")?;
let stdout = String::from_utf8_lossy(&out.stdout).to_string();
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
let combined = match (stdout.is_empty(), stderr.is_empty()) {
(true, true) => String::new(),
(false, true) => stdout,
(true, false) => stderr,
(false, false) => format!("{stdout}\n{stderr}"),
};
let output = strip_progress(&combined);
let passed = test_count(&output, "passed");
let mut failed = test_count(&output, "failed");
// Compilation failures and runner errors have no `test result` summary,
// but still make the default test phase fail.
if !out.status.success() && failed == 0 {
failed = 1;
}
Ok(TestResults { passed, failed, output })
}
/// Count the `passed` or `failed` totals in cargo's per-target test summaries.
fn test_count(output: &str, field: &str) -> u64 {
output
.lines()
.filter_map(|line| line.split_once("test result:").map(|(_, rest)| rest))
.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()
}
/// Point a `sausage-actor` dependency at `sdk` (keeping its features), and
/// add an empty `[workspace]`, so the crate never joins a parent workspace.
fn prepare_manifest(text: &str, sdk: Option<&Path>) -> Result<String> {
let mut doc: toml_edit::DocumentMut = text.parse()?;
if let Some(sdk) = sdk {
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(dep) = doc.get_mut(section).and_then(|t| t.get_mut("sausage-actor")) else { continue };
let mut table = dep.as_inline_table().cloned().unwrap_or_default();
for key in ["version", "path", "git", "branch", "tag", "rev", "registry"] {
table.remove(key);
}
table.insert("path", sdk.display().to_string().into());
*dep = toml_edit::value(table);
}
}
if doc.get("workspace").is_none() {
doc["workspace"] = toml_edit::table();
}
Ok(doc.to_string())
}
/// Write the files of `dir` below `to`. Skip files that did not change, so
/// that cargo does not rebuild the SDK at every start.
fn write_dir(dir: &Dir<'_>, to: &Path) -> Result<()> {
for file in dir.files() {
let dest = to.join(file.path());
let contents = match file.path().file_name() {
Some(n) if n == "Cargo.toml" => prepare_manifest(file.contents_utf8().context("Cargo.toml")?, None)?.into_bytes(),
_ => file.contents().to_vec(),
};
if std::fs::read(&dest).ok() != Some(contents.clone()) {
std::fs::create_dir_all(dest.parent().context("no parent")?)?;
std::fs::write(&dest, contents)?;
}
}
for sub in dir.dirs() {
write_dir(sub, to)?;
}
Ok(())
}
fn next_build_number(dir: &Path) -> Result<u64> {
std::fs::create_dir_all(dir)?;
let max = std::fs::read_dir(dir)?
.filter_map(|e| e.ok()?.file_name().to_str()?.parse::<u64>().ok())
.max()
.unwrap_or(0);
Ok(max + 1)
}
/// Remove cargo's "Compiling ..." progress lines. Keep diagnostics.
fn strip_progress(stderr: &str) -> String {
stderr
.lines()
.filter(|l| {
let t = l.trim_start();
!(t.starts_with("Compiling ")
|| t.starts_with("Downloaded ")
|| t.starts_with("Downloading ")
|| t.starts_with("Updating ")
|| t.starts_with("Locking ")
|| t.starts_with("Adding ")
|| t.starts_with("Blocking "))
})
.collect::<Vec<_>>()
.join("\n")
}
+217
View File
@@ -0,0 +1,217 @@
//! 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)
}
}
+307 -60
View File
@@ -1,67 +1,314 @@
//! 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.
//! - `/help`, `/quit`. EOF on stdin also quits.
use std::sync::Arc;
//! 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};
use serde_json::{Value, json};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::sync::mpsc;
use crate::broker::{Broker, Envelope};
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";
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
/quit stop all actors and exit";
/// Print a message: text bodies as text, everything else as JSON.
fn show(env: &Envelope) {
let reply = if env.reply_to.is_some() { " ↩" } else { "" };
match env.body["text"].as_str() {
Some(text) if env.body["type"] == "text" => println!("[{}{reply}] {text}", env.from),
_ => println!("[{}{reply}] {}", env.from, env.body),
}
}
/// Print every message that arrives for the console.
pub async fn run_mailbox(mut mailbox: mpsc::Receiver<Envelope>) {
while let Some(env) = mailbox.recv().await {
show(&env);
}
}
/// Read lines until EOF or `/quit`, and send them as messages.
pub async fn run_input(broker: Arc<Broker>) {
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();
let (to, body) = match line {
"" => continue,
"/quit" => break,
"/help" => {
println!("{HELP}");
continue;
}
_ => match line.strip_prefix('@') {
Some(rest) => {
let (to, text) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
let body = match serde_json::from_str::<Value>(text.trim()) {
Ok(v @ Value::Object(_)) => v,
_ => json!({"type": "text", "text": text.trim()}),
};
(to, body)
}
None => ("agent", json!({"type": "text", "text": line})),
/// 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);
}
}
/// 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("?")));
}
}
}
lines.join("\n")
}
/// Parse journal filters, or treat invalid arguments as chat.
fn log_params(words: &[&str]) -> Option<Value> {
let mut params = json!({});
let mut words = words.iter();
while let Some(word) = words.next() {
if *word == "-n" {
params["limit"] = json!(words.next()?.parse::<u64>().ok()?);
} else if params.get("plugin").is_none() && params.get("pid").is_none() {
params[if is_pid(word) { "pid" } else { "plugin" }] = json!(word);
} else {
return None;
}
}
Some(params)
}
/// Execute one line and return its unlabelled result.
pub async fn execute_line(host: &Host, line: &str) -> Result<String> {
let line = line.trim();
if line.is_empty() {
return Ok(String::new());
}
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();
}
let payload = &params["params"];
let text = payload["text"]
.as_str()
.or_else(|| payload.as_str())
.map(str::to_owned)
.unwrap_or(payload.to_string());
format!("\x1b[2m{text}\x1b[0m")
}
/// Read and execute one line at a time. Interrupting a wait does not cancel its host process.
pub async fn run_input(host: Host) -> Result<()> {
let terminal = std::io::IsTerminal::is_terminal(&std::io::stdin());
let history = host.0.world.state_dir("core").join("history");
let mut editor = if terminal {
Some(DefaultEditor::new()?)
} else {
None
};
if let Some(editor) = &mut editor {
let _ = editor.load_history(&history);
}
let mut printer = editor
.as_mut()
.map(|e| e.create_external_printer())
.transpose()?;
let (request, requests) =
std::sync::mpsc::channel::<tokio::sync::oneshot::Sender<Result<Option<String>>>>();
std::thread::spawn(move || {
for response in requests {
let line = (|| -> Result<_> {
if let Some(editor) = &mut editor {
match editor.readline("> ") {
Ok(line) => {
editor.add_history_entry(&line)?;
editor.save_history(&history)?;
Ok(Some(line))
}
Err(ReadlineError::Interrupted | ReadlineError::Eof) => Ok(None),
Err(e) => Err(e.into()),
}
} else {
std::io::stdin()
.lines()
.next()
.transpose()
.map_err(Into::into)
}
})();
if response.send(line).is_err() {
break;
}
}
});
let mut notifications = host.subscribe();
let mut notification_task = tokio::task::JoinSet::new();
notification_task.spawn(async move {
loop {
match notifications.recv().await {
Ok(value) => {
let text = notification_text(&value);
if let Some(printer) = &mut printer {
let _ = printer.print(format!("{text}\n"));
} else {
println!("{text}");
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
});
let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
loop {
let (send, line) = tokio::sync::oneshot::channel();
request.send(send)?;
let line =
tokio::select! { result = line => result??, _ = interrupt.recv() => return Ok(()) };
let Some(line) = line else {
return Ok(());
};
broker.send(ID, Some(to), None, None, body);
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(());
}
}
}
+74
View File
@@ -0,0 +1,74 @@
//! The native core methods and their JSON parameters.
use crate::{Host, RpcResult, error};
use serde_json::{Value, json};
impl Host {
/// Execute a core method without starting a guest process.
pub(crate) async fn core(
&self,
caller: &str,
id: Option<Value>,
method: &str,
p: Value,
) -> RpcResult {
match method {
"call" => {
self.route(
caller.into(),
id,
required_str(&p, "method")?.into(),
p.get("params").cloned().unwrap_or(json!({})),
p["detach"].as_bool().unwrap_or(false),
)
.await
}
"notify" => {
self.notify(
caller,
required_str(&p, "name")?,
p.get("params").cloned().unwrap_or(json!({})),
);
Ok(Value::Null)
}
"kill" => {
let pid = required_str(&p, "pid")?;
let ended = self
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.map(|p| p.ended.subscribe());
self.kill(pid);
if let Some(mut ended) = ended {
let _ = ended.wait_for(|ended| *ended).await;
}
Ok(json!({}))
}
"stop" => {
if let Some(mut n) = self.begin_stop(required_str(&p, "pid")?) {
let _ = n.wait_for(|ended| *ended).await;
}
Ok(json!({}))
}
"lock" => {
self.lock(caller, required_str(&p, "path")?, p["wait"] != false)
.await
}
"unlock" => {
self.unlock(caller, required_str(&p, "path")?);
Ok(Value::Null)
}
"log" => Ok(self.0.journal.query(p).await),
"activate" | "revert" => self.activation(caller, method, p).await,
"restart" | "exit" => self.request_restart(),
_ => Err(error(-32601, "method not found")),
}
}
}
fn required_str<'a>(params: &'a Value, name: &str) -> Result<&'a str, Value> {
params[name]
.as_str()
.ok_or_else(|| error(-32602, &format!("{name} must be a string")))
}
+129
View File
@@ -0,0 +1,129 @@
//! 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
}
}
}
+307
View File
@@ -0,0 +1,307 @@
//! 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 host = tracker
.clone()
.blocking(move || sausage_vfs::Host::new(root, tracker))
.await?;
let mut mounts = Mounts::default();
mounts.mount("/", Arc::new(host), false);
mounts.mount("/proc", Arc::new(Proc(self.clone())), true);
mounts.mount(
"/self",
Arc::new(SelfFolder {
host: self.clone(),
pid,
memory,
}),
false,
);
mounts.mount("/run", Arc::new(self.0.run.clone()), false);
Ok(mounts)
}
fn core_files(&self) -> Generated {
let mut files = Generated::default();
let protocol = include_bytes!("../../PROTOCOL.md");
files.insert("/PROTOCOL.md", move || Ok(protocol.to_vec()));
let graph = self.graph_json();
files.insert("/graph.json", move || Ok(graph.clone()));
let dot = self.graph_dot();
files.insert("/graph.dot", move || Ok(dot.clone()));
let locks = {
let state = self.0.state.lock().unwrap();
let locks: std::collections::BTreeMap<_, _> = state
.locks
.iter()
.map(|(path, lock)| {
let waiting: Vec<_> = lock.queue.iter().map(|(pid, _)| pid).collect();
let info = serde_json::json!({
"owner": lock.owner,
"waiting": waiting,
});
(path.clone(), info)
})
.collect();
serde_json::to_vec(&locks).unwrap()
};
files.insert("/locks.json", move || Ok(locks.clone()));
for name in self.discovered_plugins() {
if let Some(doc) = self.discover_document(&name) {
let bytes = serde_json::to_vec(&doc).unwrap();
files.insert(&format!("/plugins/{name}/discover.json"), move || {
Ok(bytes.clone())
});
files.insert(&format!("/plugins/{name}/client.rs"), move || {
sausage_interface::client_rs(&name, &doc)
.map(String::into_bytes)
.map_err(io::Error::other)
});
}
}
files
}
}
struct Proc(Host);
impl Proc {
fn folder(&self, path: &str) -> Result<(SelfFolder, String)> {
let (pid, rest) = path
.trim_start_matches('/')
.split_once('/')
.unwrap_or((path.trim_start_matches('/'), ""));
let memory = self
.0
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.map(|p| p.memory.clone())
.ok_or(io::ErrorKind::NotFound)?;
Ok((
SelfFolder {
host: self.0.clone(),
pid: Some(pid.into()),
memory,
},
format!("/{rest}"),
))
}
}
#[async_trait]
impl Layer for Proc {
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
let path = path.trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
return Ok(directory());
}
if let Some(path) = path.strip_prefix("/core/") {
return self.0.core_files().stat(&format!("/{path}"), follow).await;
}
let (folder, path) = self.folder(path)?;
folder.stat(&path, follow).await
}
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
let path = path.trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
if path == "/" {
let mut entries = vec![entry("core".into(), true)];
entries.extend(self.0.processes().into_keys().map(|pid| entry(pid, true)));
entries.sort_by(|a, b| a.name.cmp(&b.name));
return Ok(entries);
}
if path.trim_end_matches('/') == "/core/plugins" {
return Ok(self
.0
.discovered_plugins()
.into_iter()
.filter(|name| self.0.discover_document(name).is_some())
.map(|name| entry(name, true))
.collect());
}
if path.trim_end_matches('/') == "/core" {
let mut entries = self.0.core_files().list("/").await?;
if !entries.iter().any(|e| e.name == "plugins") {
entries.push(entry("plugins".into(), true));
}
return Ok(entries);
}
if let Some(path) = path.strip_prefix("/core/") {
return self.0.core_files().list(&format!("/{path}")).await;
}
let (folder, path) = self.folder(path)?;
folder.list(&path).await
}
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
let path = path.trim_end_matches('/');
let path = if path.is_empty() { "/" } else { path };
if options.write || options.create || options.truncate {
return denied();
}
if matches!(path.trim_end_matches('/'), "" | "/core" | "/core/plugins") {
return Memory::default().open("/", options).await;
}
if let Some(path) = path.strip_prefix("/core/") {
return self.0.core_files().open(&format!("/{path}"), options).await;
}
let (folder, path) = self.folder(path)?;
folder.open(&path, options).await
}
async fn mkdir(&self, _: &str) -> Result<()> {
denied()
}
async fn remove_file(&self, _: &str) -> Result<()> {
denied()
}
async fn remove_dir(&self, _: &str) -> Result<()> {
denied()
}
async fn rename(&self, _: &str, _: &str) -> Result<()> {
denied()
}
}
struct SelfFolder {
host: Host,
pid: Option<String>,
memory: Memory,
}
impl SelfFolder {
async fn generated(&self, path: &str) -> Result<Generated> {
let pid = self.pid.as_ref().ok_or(io::ErrorKind::NotFound)?;
let bytes = match path {
"/info.json" => {
let info = self
.host
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.map(|p| p.info.clone())
.ok_or(io::ErrorKind::NotFound)?;
serde_json::to_vec(&info).unwrap()
}
"/log" => {
let lines = self
.host
.0
.journal
.query(serde_json::json!({"pid":pid,"limit":i64::MAX}))
.await;
let mut bytes = Vec::new();
for line in lines.as_array().unwrap() {
serde_json::to_writer(&mut bytes, line).unwrap();
bytes.push(b'\n');
}
bytes
}
_ => return missing(),
};
let mut files = Generated::default();
files.insert(path, move || Ok(bytes.clone()));
Ok(files)
}
}
#[async_trait]
impl Layer for SelfFolder {
async fn stat(&self, path: &str, follow: bool) -> Result<Stat> {
if reserved(path) {
self.generated(path).await?.stat(path, follow).await
} else {
self.memory.stat(path, follow).await
}
}
async fn list(&self, path: &str) -> Result<Vec<Entry>> {
let mut entries = self.memory.list(path).await?;
if path == "/" && self.pid.is_some() {
entries.extend([entry("info.json".into(), false), entry("log".into(), false)]);
}
Ok(entries)
}
async fn open(&self, path: &str, options: Open) -> Result<Arc<dyn File>> {
if reserved(path) {
if options.write || options.create || options.truncate {
return denied();
}
self.generated(path).await?.open(path, options).await
} else {
self.memory.open(path, options).await
}
}
async fn mkdir(&self, path: &str) -> Result<()> {
if reserved(path) {
denied()
} else {
self.memory.mkdir(path).await
}
}
async fn remove_file(&self, path: &str) -> Result<()> {
if reserved(path) {
denied()
} else {
self.memory.remove_file(path).await
}
}
async fn remove_dir(&self, path: &str) -> Result<()> {
if reserved(path) || path == "/" {
denied()
} else {
self.memory.remove_dir(path).await
}
}
async fn rename(&self, from: &str, to: &str) -> Result<()> {
if reserved(from) || reserved(to) || from == "/" || to == "/" {
denied()
} else {
self.memory.rename(from, to).await
}
}
}
+297
View File
@@ -0,0 +1,297 @@
//! Cached discovery documents and import checks under PROTOCOL.md section 10.4.
//! Refreshes use immutable copies. The proc layer only reads serialized cache entries.
use crate::{Host, RpcResult};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
#[derive(Clone)]
pub(crate) struct Interface {
pub(crate) hash: String,
pub(crate) document: Value,
pub(crate) error: Option<Value>,
}
pub(crate) struct Graph {
pub(crate) plugins: BTreeMap<String, Interface>,
json: Vec<u8>,
dot: Vec<u8>,
}
impl Default for Graph {
/// Exposes a valid empty graph before background discovery completes.
fn default() -> Self {
Self {
plugins: BTreeMap::new(),
json: b"{\"plugins\":{}}".to_vec(),
dot: b"digraph plugins {}\n".to_vec(),
}
}
}
/// Hashes exactly the bytes copied for discovery or installation.
pub(crate) fn hash(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
/// Reads and hashes one immutable snapshot without blocking an async worker.
pub(crate) async fn read_hashed(path: PathBuf) -> std::io::Result<(Vec<u8>, String)> {
tokio::task::spawn_blocking(move || -> std::io::Result<_> {
let bytes = std::fs::read(path)?;
let hash = hash(&bytes);
Ok((bytes, hash))
})
.await
.map_err(std::io::Error::other)?
}
/// Lists broken imports, optionally restricted to changed consumers or providers.
pub(crate) fn problems(
plugins: &BTreeMap<String, Interface>,
changed: Option<&[String]>,
) -> Vec<Value> {
let mut failures = Vec::new();
for (consumer, interface) in plugins {
if let Some(imports) = interface.document["x-sausage-imports"].as_object() {
for (provider, expected) in imports {
if changed
.is_some_and(|names| !names.contains(consumer) && !names.contains(provider))
{
continue;
}
let Some(actual) = plugins.get(provider) else {
failures.push(json!({"consumer":consumer,"provider":provider,"status":"missing","message":"the provider does not exist"}));
continue;
};
if let Some(error) = &actual.error {
failures.push(json!({"consumer":consumer,"provider":provider,"status":"unavailable","message":"discovery failed","error":error}));
continue;
}
for problem in sausage_interface::compat::check(expected, &actual.document) {
failures.push(json!({"consumer":consumer,"provider":provider,"status":"broken","method":problem.method,"message":problem.message,"counterexample":problem.counterexample,"display":problem.to_string()}));
}
}
}
}
failures
}
impl Host {
/// The cached JSON graph. This does not perform filesystem or RPC operations.
pub(crate) fn graph_json(&self) -> Vec<u8> {
self.0.graph.lock().unwrap().json.clone()
}
/// The cached DOT graph, with an edge for every import.
pub(crate) fn graph_dot(&self) -> Vec<u8> {
self.0.graph.lock().unwrap().dot.clone()
}
/// Plugin names currently present in the discovery cache.
pub(crate) fn discovered_plugins(&self) -> Vec<String> {
self.0
.graph
.lock()
.unwrap()
.plugins
.keys()
.cloned()
.collect()
}
/// The current document, if discovery succeeded.
pub(crate) fn discover_document(&self, plugin: &str) -> Option<Value> {
self.0
.graph
.lock()
.unwrap()
.plugins
.get(plugin)
.filter(|entry| entry.error.is_none())
.map(|entry| entry.document.clone())
}
/// Discovers a private copy and caches successful documents by file hash.
pub(crate) async fn discover_copy(
&self,
plugin: &str,
path: &Path,
hash: String,
) -> Result<Interface, Value> {
let request = json!(uuid::Uuid::now_v7().to_string());
let discovery = self.route_from(
"core".into(),
Some(request.clone()),
format!("{plugin}.rpc.discover"),
json!({}),
true,
Some(path.to_path_buf()),
);
let result = tokio::time::timeout(std::time::Duration::from_secs(10), discovery).await;
let processes: Vec<_> = self
.0
.state
.lock()
.unwrap()
.processes
.iter()
.filter(|(_, process)| process.id.as_ref() == Some(&request))
.map(|(pid, _)| pid.clone())
.collect();
for pid in processes {
self.begin_stop(&pid);
}
let document = result
.map_err(|_| crate::error(-32003, "plugin discovery timed out after 10 seconds"))??;
if !document["methods"].is_array() {
return Err(crate::error(
-32003,
"rpc.discover did not return an OpenRPC document",
));
}
Ok(Interface {
hash,
document,
error: None,
})
}
/// Refreshes every installed plugin while the caller holds the activation mutex.
pub(crate) async fn refresh_graph(&self) -> RpcResult {
self.refresh_graph_except(&[]).await
}
pub(crate) async fn refresh_graph_except(&self, candidates: &[String]) -> RpcResult {
let mut directory = tokio::fs::read_dir(self.0.world.root.join("plugins"))
.await
.map_err(io_error)?;
let mut plugins = BTreeMap::new();
while let Some(entry) = directory.next_entry().await.map_err(io_error)? {
if !entry.file_type().await.map_err(io_error)?.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
let source = entry.path().join("plugin.wasm");
let (bytes, hash) = match read_hashed(source).await {
Ok(snapshot) => snapshot,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => {
plugins.insert(
name,
Interface {
hash: String::new(),
document: Value::Null,
error: Some(io_error(error)),
},
);
continue;
}
};
let previous = self.0.graph.lock().unwrap().plugins.get(&name).cloned();
let interface = if candidates.contains(&name) {
previous
.filter(|entry| entry.hash == hash)
.unwrap_or(Interface {
hash,
document: Value::Null,
error: None,
})
} else if let Some(previous) =
previous.filter(|entry| entry.hash == hash && entry.error.is_none())
{
previous
} else {
let copy = entry
.path()
.join(format!("discover-{}.wasm", uuid::Uuid::now_v7()));
let result = match tokio::fs::write(&copy, &bytes).await {
Ok(()) => self.discover_copy(&name, &copy, hash.clone()).await,
Err(error) => Err(io_error(error)),
};
let _ = tokio::fs::remove_file(&copy).await;
match result {
Ok(interface) => interface,
Err(error) => Interface {
hash,
document: Value::Null,
error: Some(error),
},
}
};
plugins.insert(name, interface);
}
let admission = self.0.admission.write().await;
let old_plugins = self.0.graph.lock().unwrap().plugins.clone();
let mut upgrades = Vec::new();
for (plugin, current) in &plugins {
if let Some(old) = old_plugins.get(plugin).filter(|old| {
!old.hash.is_empty() && old.hash != current.hash && current.error.is_none()
}) {
let grace = old.document["x-sausage-grace"]
.as_f64()
.filter(|value| value.is_finite() && *value >= 0.0)
.unwrap_or(60.0);
upgrades.push(self.snapshot_upgrade(
plugin,
old.hash.clone(),
&current.hash,
grace,
));
}
}
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())
}
+162
View File
@@ -0,0 +1,162 @@
//! 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) 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 {
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(),
graph: Mutex::new(crate::graph::Graph::default()),
activations: tokio::sync::Mutex::new(()),
watcher: Mutex::new(None),
retirements: Mutex::new(std::collections::HashSet::new()),
}))
}
/// Register a native plugin for subsequent calls.
pub fn register_native(&self, name: &str, plugin: NativePlugin) {
self.0
.state
.lock()
.unwrap()
.native
.insert(name.into(), plugin);
}
/// Subscribe to notifications addressed to the console.
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<Value> {
self.0.notifications.subscribe()
}
/// Snapshot the metadata of each process that has not ended.
pub fn processes(&self) -> HashMap<String, ProcessInfo> {
self.0
.state
.lock()
.unwrap()
.processes
.iter()
.map(|(p, s)| (p.clone(), s.info.clone()))
.collect()
}
/// Make a console-side call and wait for its JSON result.
pub async fn call(&self, method: &str, params: Value) -> RpcResult {
self.route(
"console".into(),
Some(json!(Uuid::now_v7().to_string())),
method.into(),
params,
false,
)
.await
}
/// Wait until core.restart (or its alias core.exit) requests shutdown after its response grace.
pub async fn exit_requested(&self) {
let mut exit = self.0.exit.subscribe();
let _ = exit.wait_for(|requested| *requested).await;
}
/// Return whether a restart has reached the normal shutdown path.
pub fn restart_requested(&self) -> bool {
*self.0.exit.borrow()
}
}
+411
View File
@@ -0,0 +1,411 @@
//! A dedicated SQLite writer batches entries and serves ordered queries without propagating disk failures into the host.
use rusqlite::{Connection, OpenFlags, params};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::io::Write;
use std::{
collections::HashSet,
path::PathBuf,
sync::{Mutex, mpsc},
};
use tokio::sync::oneshot;
#[derive(Debug, Serialize, Deserialize)]
struct Entry {
time: String,
id: String,
pid: String,
plugin: String,
stream: String,
line: String,
}
enum Command {
Write(Entry),
Query(Value, oneshot::Sender<Value>),
}
pub(crate) struct Journal {
sender: mpsc::Sender<Command>,
warned: Mutex<HashSet<String>>,
spool: PathBuf,
}
impl Journal {
pub(crate) async fn memory() -> anyhow::Result<Self> {
Self::open(PathBuf::from(":memory:")).await
}
pub(crate) async fn open(path: PathBuf) -> anyhow::Result<Self> {
Self::open_with_legacy(path, None).await
}
pub(crate) async fn open_with_legacy(
path: PathBuf,
legacy: Option<PathBuf>,
) -> anyhow::Result<Self> {
let spool = path
.parent()
.unwrap_or(std::path::Path::new("."))
.join("failed-batches");
let worker_spool = spool.clone();
let (sender, receiver) = mpsc::channel();
let (ready, initialized) = oneshot::channel();
std::thread::spawn(move || {
match (|| -> anyhow::Result<_> {
let db = open_database(path)?;
// Never rename, checkpoint, or write the old SQLite/WAL files.
let old = legacy
.filter(|p| p.exists())
.map(|p| Connection::open_with_flags(p, OpenFlags::SQLITE_OPEN_READ_ONLY))
.transpose()?;
Ok((db, old))
})() {
Ok((mut db, old)) => {
let _ = ready.send(Ok(()));
let mut pending = Vec::new();
while let Ok(first) = receiver.recv() {
let mut batch = vec![first];
batch.extend(receiver.try_iter().take(255));
pending = write_batch(&mut db, old.as_ref(), &worker_spool, pending, batch);
}
if !pending.is_empty() {
// Both storage locations failed. Last resort: retain full text in host stderr.
eprintln!(
"journal shutdown with unpersisted entries: {}",
serde_json::to_string(&pending).unwrap()
);
}
}
Err(error) => {
eprintln!("journal open failed: {error}");
let _ = ready.send(Err(error));
}
}
});
initialized.await??;
Ok(Self {
sender,
warned: Mutex::new(HashSet::new()),
spool,
})
}
pub(crate) fn write(&self, pid: &str, plugin: &str, stream: &str, line: &str) {
let entry = Entry {
time: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
id: uuid::Uuid::now_v7().to_string(),
pid: pid.into(),
plugin: plugin.into(),
stream: stream.into(),
line: line.into(),
};
if let Err(mpsc::SendError(Command::Write(entry))) = self.sender.send(Command::Write(entry))
{
eprintln!("journal writer stopped; saving entry outside SQLite");
if let Err(error) = spool_batch(&self.spool, std::slice::from_ref(&entry)) {
eprintln!(
"journal spool failed: {error}; unpersisted entry: {}",
serde_json::to_string(&entry).unwrap()
);
}
}
}
pub(crate) fn warning(&self, pid: &str, plugin: &str) {
if self.warned.lock().unwrap().insert(pid.into()) {
self.write(pid, plugin, "core", "warning: discarded stdout");
}
}
pub(crate) fn forget(&self, pid: &str) {
self.warned.lock().unwrap().remove(pid);
}
pub(crate) fn query_later(&self, filters: Value) -> oneshot::Receiver<Value> {
let (tx, rx) = oneshot::channel();
if self.sender.send(Command::Query(filters, tx)).is_err() {
eprintln!("journal writer stopped");
}
rx
}
pub(crate) async fn query(&self, filters: Value) -> Value {
self.query_later(filters)
.await
.unwrap_or_else(|_| json!([]))
}
}
fn open_database(path: PathBuf) -> anyhow::Result<Connection> {
if path != std::path::Path::new(":memory:") {
std::fs::create_dir_all(path.parent().unwrap())?;
}
let db = Connection::open(path)?;
db.execute_batch("PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
CREATE TABLE IF NOT EXISTS journal (id TEXT PRIMARY KEY, time TEXT, pid TEXT, plugin TEXT, stream TEXT, line TEXT);
CREATE INDEX IF NOT EXISTS journal_pid ON journal(pid);
CREATE INDEX IF NOT EXISTS journal_plugin ON journal(plugin);")?;
Ok(db)
}
/// Exclusive, synced JSON files preserve failed transactions without a busy retry loop.
/// Recovery is deliberate: files are never automatically removed or replayed.
fn spool_batch(dir: &std::path::Path, entries: &[Entry]) -> anyhow::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let path = dir.join(format!("{}.json", uuid::Uuid::now_v7()));
// A failed/partial write must not look like a complete recovery batch.
let mut file = tempfile::NamedTempFile::new_in(dir)?;
serde_json::to_writer(&mut file, entries)?;
file.write_all(b"\n")?;
file.as_file().sync_all()?;
file.persist_noclobber(&path)?;
std::fs::File::open(dir)?.sync_all()?;
eprintln!(
"journal failed batch preserved: {} ({} entries)",
path.display(),
entries.len()
);
Ok(path)
}
fn write_batch(
db: &mut Connection,
old: Option<&Connection>,
spool: &std::path::Path,
mut pending: Vec<Entry>,
batch: Vec<Command>,
) -> Vec<Entry> {
let mut queries = Vec::new();
for command in batch {
match command {
Command::Write(entry) => pending.push(entry),
Command::Query(filters, response) => queries.push((filters, response)),
}
}
if !pending.is_empty() {
let write = (|| -> rusqlite::Result<()> {
let transaction = db.transaction()?;
for e in &pending {
transaction.execute("INSERT OR IGNORE INTO journal (id,time,pid,plugin,stream,line) VALUES (?1,?2,?3,?4,?5,?6)",params![e.id,e.time,e.pid,e.plugin,e.stream,e.line])?;
}
transaction.commit()
})();
match write {
Ok(()) => pending.clear(),
Err(error) => {
eprintln!("journal write failed: {error}");
match spool_batch(spool, &pending) {
Ok(_) => pending.clear(),
Err(error) => eprintln!(
"journal spool failed: {error}; retaining {} entries in memory until next command",
pending.len()
),
}
}
}
}
for (filters, response) in queries {
let value = query_all(db, old, &filters).unwrap_or_else(|error| {
eprintln!("journal query failed: {error}");
json!([])
});
let _ = response.send(value);
}
pending
}
fn query_all(
db: &Connection,
old: Option<&Connection>,
filters: &Value,
) -> rusqlite::Result<Value> {
let mut entries = query_database(db, filters)?.as_array().unwrap().clone();
if let Some(old) = old {
entries.extend(
query_database(old, filters)?
.as_array()
.unwrap()
.iter()
.cloned(),
);
entries.sort_by(|a, b| a["id"].as_str().cmp(&b["id"].as_str()));
entries.dedup_by(|a, b| a["id"] == b["id"]);
let limit = filters["limit"].as_i64().unwrap_or(100);
if limit >= 0 {
entries.drain(..entries.len().saturating_sub(limit as usize));
}
}
Ok(json!(entries))
}
fn query_database(db: &Connection, filters: &Value) -> rusqlite::Result<Value> {
let mut statement = db.prepare(
"SELECT id,time,pid,plugin,stream,line FROM journal
WHERE (?1 IS NULL OR plugin=?1) AND (?2 IS NULL OR pid=?2)
AND (?3 IS NULL OR id>=?3) AND (?4 IS NULL OR id<=?4)
AND (?5 IS NULL OR instr(line,?5)>0) ORDER BY id DESC LIMIT ?6",
)?;
let rows=statement.query_map(params![filters["plugin"].as_str(),filters["pid"].as_str(),filters["since"].as_str(),filters["until"].as_str(),filters["grep"].as_str(),filters["limit"].as_i64().unwrap_or(100)],|row| Ok(json!({
"id":row.get::<_,String>(0)?,"time":row.get::<_,String>(1)?,"pid":row.get::<_,String>(2)?,"plugin":row.get::<_,String>(3)?,"stream":row.get::<_,String>(4)?,"line":row.get::<_,String>(5)?,
})))?;
let mut entries: Vec<_> = rows.collect::<rusqlite::Result<_>>()?;
entries.reverse();
Ok(json!(entries))
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(line: &str) -> Entry {
Entry {
id: uuid::Uuid::now_v7().to_string(),
time: "2026-10-26T12:00:00.000Z".into(),
pid: "pid".into(),
plugin: "test".into(),
stream: "core".into(),
line: line.into(),
}
}
#[test]
fn failed_transaction_is_spooled_in_full_and_queries_finish() {
let dir = tempfile::tempdir().unwrap();
let mut db = open_database(PathBuf::from(":memory:")).unwrap();
db.execute_batch("PRAGMA query_only=ON").unwrap();
let original = "😀 full failure\n".repeat(6000);
let (tx, mut rx) = oneshot::channel();
let pending = write_batch(
&mut db,
None,
dir.path(),
vec![],
vec![
Command::Write(entry(&original)),
Command::Query(json!({}), tx),
],
);
assert!(pending.is_empty());
assert_eq!(rx.try_recv().unwrap(), json!([]));
let path = std::fs::read_dir(dir.path())
.unwrap()
.next()
.unwrap()
.unwrap()
.path();
let saved: Vec<Entry> = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
assert_eq!(saved.len(), 1);
assert_eq!(saved[0].line, original);
assert_eq!(saved[0].time, "2026-10-26T12:00:00.000Z");
}
#[test]
fn failed_spool_retries_on_next_command_without_duplicates() {
let dir = tempfile::tempdir().unwrap();
let blocked = dir.path().join("blocked");
std::fs::write(&blocked, "not a directory").unwrap();
let mut db = open_database(PathBuf::from(":memory:")).unwrap();
db.execute_batch("PRAGMA query_only=ON").unwrap();
let pending = write_batch(
&mut db,
None,
&blocked,
vec![],
vec![Command::Write(entry("retained"))],
);
assert_eq!(pending.len(), 1);
db.execute_batch("PRAGMA query_only=OFF").unwrap();
let (tx, mut rx) = oneshot::channel();
let pending = write_batch(
&mut db,
None,
&blocked,
pending,
vec![Command::Query(json!({}), tx)],
);
assert!(pending.is_empty());
assert_eq!(rx.try_recv().unwrap()[0]["line"], "retained");
assert_eq!(
query_all(&db, None, &json!({}))
.unwrap()
.as_array()
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn legacy_wal_and_new_journal_share_filters_order_and_limit() {
let dir = tempfile::tempdir().unwrap();
let legacy = dir.path().join("state/core/journal.sqlite");
let mut old = open_database(legacy.clone()).unwrap();
write_batch(
&mut old,
None,
dir.path(),
vec![],
vec![Command::Write(entry("old match"))],
);
// Leave the WAL connection open: opening the new journal must not move its files.
let wal = legacy.with_extension("sqlite-wal");
let before = std::fs::read(&wal).unwrap();
let new = dir.path().join("logs/core/journal.sqlite");
let journal = Journal::open_with_legacy(new.clone(), Some(legacy.clone()))
.await
.unwrap();
journal.write("pid", "test", "core", "new match");
let entries = tokio::time::timeout(
std::time::Duration::from_secs(5),
journal.query(json!({"pid":"pid","grep":"match","limit":-1})),
)
.await
.unwrap();
assert_eq!(entries.as_array().unwrap().len(), 2);
assert_eq!(entries[0]["line"], "old match");
assert_eq!(entries[1]["line"], "new match");
assert_eq!(
journal.query(json!({"limit":1})).await[0]["line"],
"new match"
);
assert_eq!(
journal.query(json!({"until":entries[0]["id"]})).await[0]["line"],
"old match"
);
assert_eq!(
journal
.query(json!({"since":entries[1]["id"],"plugin":"test"}))
.await[0]["line"],
"new match"
);
assert_eq!(journal.query(json!({"grep":"absent"})).await, json!([]));
assert_eq!(std::fs::read(wal).unwrap(), before);
assert!(legacy.exists() && new.exists());
assert_eq!(
query_database(&old, &json!({}))
.unwrap()
.as_array()
.unwrap()
.len(),
1
);
}
/// PROTOCOL 7: completed processes do not leave warning identities in memory.
#[tokio::test]
async fn completion_forgets_warning_identity() {
let folder = std::env::temp_dir().join(format!("sausage-warning-{}", uuid::Uuid::now_v7()));
crate::world::World::create(&folder).unwrap();
let host = crate::Host::start_with_cache(&folder, &folder.join("cache"))
.await
.unwrap();
host.register_native(
"warn",
std::sync::Arc::new(|ctx, _, _| {
Box::pin(async move {
ctx.host.output(&ctx.pid, "not JSON".into());
Ok(json!(ctx.pid))
})
}),
);
let pid = host.call("warn.once", json!({})).await.unwrap();
host.call("core.stop", json!({"pid":pid})).await.unwrap();
let retained = host
.0
.journal
.warned
.lock()
.unwrap()
.contains(pid.as_str().unwrap());
host.stop().await;
std::fs::remove_dir_all(folder).unwrap();
assert!(!retained);
}
}
+26 -82
View File
@@ -1,86 +1,30 @@
//! Sausage: grow a system of WASI actors by talking to it.
//!
//! The host runs the broker, the native actors (`broker`, `cargo`, and the
//! console), one supervisor per WASM actor, and the file watcher of the
//! world folder (`watch.rs`). See `PROTOCOL.md`.
pub mod broker;
pub mod candidate;
pub mod cargo_actor;
//! 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;
pub mod supervisor;
pub mod vfs;
pub mod watch;
mod services;
mod upgrades;
mod watcher;
pub mod world;
use std::path::Path;
use std::sync::Arc;
use anyhow::{Result, bail};
use tokio::sync::mpsc;
pub use broker::{Broker, Envelope};
pub use world::World;
/// The contract between the host and the actors. The host publishes it at
/// `/actor/broker/public/PROTOCOL.md`, so that every actor can read it.
const PROTOCOL: &str = include_str!("../../PROTOCOL.md");
/// A running world.
pub struct Host {
pub world: World,
pub broker: Arc<Broker>,
native: Vec<tokio::task::JoinHandle<()>>,
watcher: Option<watch::Watcher>,
_lock: std::fs::File,
}
impl Host {
/// Start the broker, `cargo`, the file watcher, and every registered
/// actor of the world at `root`. Only one host can run a world at a time.
pub async fn start(root: &Path) -> Result<Host> {
let world = World::new(std::path::absolute(root)?);
if !world.exists() {
bail!("{} is not a Sausage world. Create one with: sausage init", root.display());
}
let lock = std::fs::File::create(world.root.join(".lock"))?;
if lock.try_lock().is_err() {
bail!("another sausage process runs {}", root.display());
}
let broker = Broker::new(world.clone(), runtime::Runtime::new()?)?;
world::write_atomic(&world.public_dir("broker").join("PROTOCOL.md"), PROTOCOL.as_bytes())?;
let cargo = cargo_actor::Cargo::new(world.clone())?;
let native = vec![
tokio::spawn(broker.clone().run_actor(broker.open_mailbox("broker"))),
tokio::spawn(cargo.run_actor(broker.clone(), broker.open_mailbox(cargo_actor::ID))),
];
// Watch first, so that no change during the boot pass is lost.
let watcher = watch::start(broker.clone())
.inspect_err(|e| broker.host_error(&format!("no file watching: {e}")))
.ok();
for change in broker.scan().await {
eprintln!("sausage: {change}");
}
Ok(Host { world, broker, native, watcher, _lock: lock })
}
/// Register a native actor with the ID `id` and return its mailbox.
pub fn mailbox(&self, id: &str) -> mpsc::Receiver<Envelope> {
self.broker.open_mailbox(id)
}
/// Stop the file watcher, the broker and `cargo` (with the requests they
/// still run), then every actor, gracefully.
pub async fn stop(mut self) {
drop(self.watcher.take());
for task in &self.native {
task.abort();
}
for task in self.native {
let _ = task.await;
}
self.broker.shutdown_all().await;
}
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})
}
+90
View File
@@ -0,0 +1,90 @@
//! Advisory FIFO locks and cleanup of ended processes.
use crate::error;
use crate::{Host, RpcResult, host::State};
use serde_json::json;
use std::collections::VecDeque;
use tokio::sync::oneshot;
pub(crate) struct Lock {
pub(crate) owner: String,
pub(crate) queue: VecDeque<(String, oneshot::Sender<RpcResult>)>,
}
impl Host {
/// Acquire a FIFO queue position, or report whether the lock is free.
pub(crate) async fn lock(&self, caller: &str, path: &str, wait: bool) -> RpcResult {
let rx = {
let mut state = self.0.state.lock().unwrap();
if caller != "console"
&& caller != "core"
&& !state
.processes
.get(caller)
.is_some_and(|p| p.info.state == "running")
{
return Err(error(-32600, "process is stopping or ended"));
}
if let Some(lock) = state.locks.get_mut(path) {
if !wait {
return Ok(json!({"held":false}));
}
let (tx, rx) = oneshot::channel();
lock.queue.push_back((caller.into(), tx));
Some(rx)
} else {
state.locks.insert(
path.into(),
Lock {
owner: caller.into(),
queue: VecDeque::new(),
},
);
None
}
};
match rx {
Some(rx) => rx
.await
.unwrap_or_else(|_| Err(error(-32603, "lock canceled"))),
None => Ok(json!({"held":true})),
}
}
/// Release a held lock only when this process owns it.
pub(crate) fn unlock(&self, caller: &str, path: &str) {
let mut state = self.0.state.lock().unwrap();
if state
.locks
.get(path)
.is_some_and(|lock| lock.owner == caller)
{
grant(&mut state, path);
}
}
/// Remove queued acquisitions and grant the next owner of each held lock.
pub(crate) fn release_locks(state: &mut State, pid: &str) {
for lock in state.locks.values_mut() {
lock.queue.retain(|(p, _)| p != pid);
}
let paths: Vec<_> = state
.locks
.iter()
.filter(|(_, l)| l.owner == pid)
.map(|(p, _)| p.clone())
.collect();
for path in paths {
grant(state, &path);
}
}
}
pub(crate) fn grant(state: &mut State, path: &str) {
if let Some(mut lock) = state.locks.remove(path) {
while let Some((owner, tx)) = lock.queue.pop_front() {
if tx.send(Ok(json!({"held":true}))).is_ok() {
lock.owner = owner;
state.locks.insert(path.into(), lock);
break;
}
}
}
}
+165 -103
View File
@@ -1,128 +1,190 @@
use std::io::Write;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
//! Command parsing and the minimal line console.
use anyhow::{Result, bail};
use clap::{Parser, Subcommand};
use include_dir::{Dir, include_dir};
use serde_json::json;
use sausage::{Host, world::World};
use std::path::{Path, PathBuf};
use sausage::world::{ActorMeta, World};
use sausage::{Host, cargo_actor, console};
/// The source of the agent actor. `init` builds it like any other actor.
static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent");
// Without a command, `sausage` runs the world in `./world` and creates it
// first (like `init`) if it does not exist yet.
static AGENT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent/src");
mod test_runner;
#[derive(Parser)]
#[command(version, about)]
#[command(version)]
struct Cli {
#[arg(long, global = true)]
headless: bool,
#[arg(long, global = true)]
cache_dir: Option<PathBuf>,
#[command(subcommand)]
command: Option<Cmd>,
}
#[derive(Subcommand)]
enum Cmd {
/// Create a new world and build the agent actor.
///
/// Reads SAUSAGE_API, SAUSAGE_BASE_URL, SAUSAGE_MODEL and
/// SAUSAGE_API_TOKEN, and asks for every value that is missing.
Init {
/// Folder of the new world.
#[arg(default_value = "world")]
dir: PathBuf,
},
/// 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>,
},
}
/// The value of `var`, or the answer to `question`.
fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
if let Ok(v) = std::env::var(var)
&& !v.is_empty()
{
return Ok(v);
}
match default {
Some(d) => print!("{question} [{d}]: "),
None => print!("{question}: "),
}
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
match (line.trim(), default) {
("", Some(d)) => Ok(d.to_string()),
("", None) => bail!("{question} is required (or set {var})"),
(v, _) => Ok(v.to_string()),
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()),
}
}
async fn init(dir: PathBuf) -> Result<()> {
let api = setting("SAUSAGE_API", "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 = setting("SAUSAGE_BASE_URL", "API base URL", None)?;
let model = setting("SAUSAGE_MODEL", "Model", None)?;
let token = setting("SAUSAGE_API_TOKEN", "API token", None)?;
let world = World::create(&dir)?;
let console = ActorMeta { subscriptions: ["agent".to_string()].into(), ..world.read_meta(console::ID) };
world.write_meta(console::ID, &console)?;
// The agent's configuration, in its /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)?)?;
// The agent is an ordinary actor folder: `run` finds its `actor.wasm`,
// tests it, and installs it as version 1.
let source = world.source_dir("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 out = cargo_actor::Cargo::new(world.clone())?.build(&source, false).await.context("build agent")?;
std::fs::copy(&out.wasm, world.wasm_path("agent"))?;
println!("Built in {:.1} s. Start the world with: sausage run {}", out.seconds, dir.display());
Ok(())
}
async fn run(dir: PathBuf) -> Result<()> {
let host = Host::start(&dir).await?;
tokio::spawn(console::run_mailbox(host.mailbox(console::ID)));
let broker = host.broker.clone();
let exit = tokio::select! {
_ = console::run_input(host.broker.clone()) => false,
_ = broker.exit_requested() => true,
};
println!("Stopping actors …");
host.stop().await;
if exit {
// The console still blocks on a stdin read that cannot be cancelled,
// so the runtime would not shut down on its own. Exit code 75 tells
// `nix run .#loop` to start the host again (0 = /quit = stop the loop).
println!("Exiting, as an actor asked the broker to.");
std::process::exit(75);
}
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
match Cli::parse().command {
Some(Cmd::Init { dir }) => init(dir).await,
Some(Cmd::Run { dir }) => run(dir).await,
None => {
let dir = PathBuf::from("world");
if !dir.join("actor").is_dir() {
println!("No world in ./world yet, creating it (same as `sausage init`).");
init(dir.clone()).await?;
}
run(dir).await
// 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,
);
}
}
}
/// 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());
}
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());
Ok(())
}
#[tokio::main]
async fn main() -> Result<std::process::ExitCode> {
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?;
}
}
Ok(std::process::ExitCode::SUCCESS)
}
+35
View File
@@ -0,0 +1,35 @@
//! 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);
}
}
+157
View File
@@ -0,0 +1,157 @@
//! 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>,
}
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);
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>> {
let mut state = self.0.state.lock().unwrap();
let p = state.processes.get_mut(pid)?;
let ended = p.ended.subscribe();
if p.info.state == "stopping" {
return Some(ended);
}
p.info.state = "stopping".into();
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(&pid);
});
Some(ended)
}
/// Abort a process task and perform its ended-state cleanup.
pub(crate) fn kill(&self, pid: &str) {
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) {
p.info.state = "stopping".into();
p.input.take();
self.0.journal.write(pid, &p.info.plugin, "core", "kill");
if let Some(a) = &p.abort {
a.abort();
}
}
}
/// Stop every process in parallel and wait for journal writes to finish.
pub async fn stop(&self) {
{
let _state = self.0.state.lock().unwrap();
self.0
.shutdown
.store(true, std::sync::atomic::Ordering::SeqCst);
}
loop {
let ids: Vec<_> = self.processes().into_keys().collect();
if ids.is_empty() {
break;
}
let mut waits = Vec::new();
for pid in ids {
if let Some(n) = self.begin_stop(&pid) {
waits.push(n);
}
}
for mut n in waits {
let _ = n.wait_for(|ended| *ended).await;
}
}
self.0.journal.query(json!({"limit":0})).await;
}
}
+129
View File
@@ -0,0 +1,129 @@
//! 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();
}
}
+363
View File
@@ -0,0 +1,363 @@
//! 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, id)) = target {
let notification = json!({
"jsonrpc": "2.0",
"method": "core.notify",
"params": { "call": id, "name": name, "params": params }
});
self.send(&caller, notification);
}
}
/// Validate and route a request, then start its native or WASI process.
pub(crate) fn route(
&self,
caller: String,
id: Option<Value>,
method: String,
params: Value,
detached: bool,
) -> NativeFuture {
self.route_from(caller, id, method, params, detached, None)
}
/// Like `route`, but a WASI process runs the component at `wasm` instead
/// of the current `plugin.wasm` of its plugin. Activation uses this to
/// call `rpc.discover` on a candidate file.
pub(crate) fn route_from(
&self,
caller: String,
id: Option<Value>,
method: String,
params: Value,
detached: bool,
wasm: Option<std::path::PathBuf>,
) -> NativeFuture {
let host = self.clone();
Box::pin(async move {
if !params.is_object() {
return Err(params_not_object(&params));
}
if caller != "console" && caller != "core" {
let state = host.0.state.lock().unwrap();
let Some(process) = state.processes.get(&caller) else {
return Err(error(-32600, "process ended"));
};
if process.info.state != "running" && method != "core.notify" {
return Err(error(-32600, "process is stopping"));
}
}
let (plugin, method) = method
.split_once('.')
.ok_or_else(|| error(-32601, "method not found"))?;
if plugin == "core" {
return host.core(&caller, id, method, params).await;
}
if plugin.is_empty()
|| !plugin
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
{
return Err(error(-32601, "invalid plugin name"));
}
let path = wasm.unwrap_or_else(|| host.0.world.plugin_dir(plugin).join("plugin.wasm"));
let (native, component, version, admission) = loop {
if host.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
return Err(error(-32600, "host is stopping"));
}
let native = host.0.state.lock().unwrap().native.get(plugin).cloned();
let loaded = if native.is_none() {
Some(
host.0
.components
.load(path.clone(), host.0.runtime.clone())
.await?,
)
} else {
None
};
let admission = host.0.admission.read().await;
if let Some(loaded) = loaded {
if crate::components::Fingerprint::read(&path).await? != loaded.fingerprint {
drop(admission);
continue;
}
break (native, Some(loaded.prepared), loaded.hash, admission);
}
break (native, None, "native".into(), admission);
};
let pid = format!("{plugin}-{}", Uuid::now_v7());
let (tx, rx) = oneshot::channel();
let (input, incoming) = mpsc::unbounded_channel::<Value>();
let info = ProcessInfo {
plugin: plugin.into(),
version,
method: method.into(),
params: params.clone(),
parent: if detached { None } else { Some(caller.clone()) },
detached,
start_time: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis(),
state: "running".into(),
};
{
let mut state = host.0.state.lock().unwrap();
if host.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
return Err(error(-32600, "host is stopping"));
}
if caller != "console"
&& caller != "core"
&& !state
.processes
.get(&caller)
.is_some_and(|p| p.info.state == "running")
{
return Err(error(-32600, "caller stopped or ended"));
}
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,
},
);
}
let h = host.clone();
let p = pid.clone();
let has_id = id.is_some();
let (start, ready) = oneshot::channel();
let task = tokio::spawn(async move {
let _ = ready.await;
h.run_process(p, info, component, native, incoming, has_id)
.await
});
if let Some(p) = host.0.state.lock().unwrap().processes.get_mut(&pid) {
p.abort = Some(task.abort_handle());
if p.info.state == "stopping" {
task.abort();
}
} else {
task.abort();
}
drop(admission);
let cleanup = host.clone();
let finished_pid = pid.clone();
tokio::spawn(async move {
let status = task.await.unwrap_or_else(|error| {
if error.is_cancelled() {
"killed".into()
} else {
format!("host task failed: {error}")
}
});
cleanup.finish(&finished_pid, &status).await;
});
let _ = start.send(());
if id.is_none() {
return Ok(Value::Null);
}
rx.await
.unwrap_or_else(|_| Err(error(-32002, "process ended")))
})
}
/// Complete the initial call once and discard late responses.
pub(crate) fn answer(&self, pid: &str, result: RpcResult) {
if let Some(tx) = self
.0
.state
.lock()
.unwrap()
.processes
.get_mut(pid)
.and_then(|p| p.answer.take())
{
let _ = tx.send(result);
}
}
/// Classify a stdout line and route requests or the initial response.
pub(crate) fn output(&self, pid: &str, line: String) {
if line.trim().is_empty() {
return;
}
let Ok(v) = serde_json::from_str::<Value>(&line) else {
self.discard(pid);
return;
};
if !v.is_object() || v["jsonrpc"] != "2.0" {
self.discard(pid);
return;
}
if let Some(method) = v["method"].as_str() {
if method == "core.notify" || method == "core.unlock" {
// Deliver progress before a following response removes its destination.
let params = v.get("params").cloned().unwrap_or(json!({}));
let result = if !params.is_object() {
Err(params_not_object(&params))
} else if method == "core.unlock" {
if let Some(path) = params["path"].as_str() {
self.unlock(pid, path);
Ok(Value::Null)
} else {
Err(error(-32602, "path must be a string"))
}
} else if let Some(name) = params["name"].as_str() {
self.notify(
pid,
name,
params.get("params").cloned().unwrap_or(json!({})),
);
Ok(Value::Null)
} else {
Err(error(-32602, "name must be a string"))
};
if let Some(id) = v.get("id") {
let response = match result {
Ok(result) => json!({"jsonrpc":"2.0", "id":id, "result":result}),
Err(error) => json!({"jsonrpc":"2.0", "id":id, "error":error}),
};
self.send(pid, response);
}
return;
}
let h = self.clone();
let caller = pid.to_string();
let method = method.to_string();
tokio::spawn(async move {
let id = v.get("id").cloned();
let result = h
.route(
caller.clone(),
id.clone(),
method,
v.get("params").cloned().unwrap_or(json!({})),
false,
)
.await;
if let Some(id) = id {
let value = match result {
Ok(result) => json!({
"jsonrpc": "2.0",
"id": id,
"result": result
}),
Err(error) => json!({
"jsonrpc": "2.0",
"id": id,
"error": error
}),
};
h.send(&caller, value);
}
});
} else if v["id"] == pid {
self.answer(
pid,
if let Some(e) = v.get("error").filter(|e| !e.is_null()) {
Err(e.clone())
} else {
Ok(v["result"].clone())
},
);
}
}
pub(crate) fn discard(&self, pid: &str) {
// The journal deduplicates this warning for each process.
if let Some(plugin) = self
.0
.state
.lock()
.unwrap()
.processes
.get(pid)
.map(|p| p.info.plugin.clone())
{
self.0.journal.warning(pid, &plugin);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// PROTOCOL 6: an unlock from stdout completes before output returns.
#[tokio::test]
async fn stdout_unlock_is_synchronous() {
let folder = std::env::temp_dir().join(format!("sausage-unlock-{}", Uuid::now_v7()));
crate::world::World::create(&folder).unwrap();
let host = Host::start_with_cache(&folder, &folder.join("cache"))
.await
.unwrap();
host.lock("core", "ordered", true).await.unwrap();
host.output(
"core",
r#"{"jsonrpc":"2.0","method":"core.unlock","params":{"path":"ordered"}}"#.into(),
);
let held = host.lock("console", "ordered", false).await.unwrap();
host.stop().await;
std::fs::remove_dir_all(folder).unwrap();
assert_eq!(held, json!({"held":true}));
}
}
+227 -125
View File
@@ -1,36 +1,29 @@
//! Wasmtime engine and actor instances.
//! Wasmtime engine and process 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, PathBuf};
use std::sync::{Arc, Weak};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use anyhow::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::filesystem::WasiFilesystemCtxView;
use wasmtime_wasi::p2::bindings::Command;
use wasmtime_wasi::p2::bindings::filesystem;
use wasmtime_wasi::{FsPerms, I32Exit, WasiCtx, WasiCtxView, WasiView};
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_http::{WasiHttpCtx, WasiHttpCtxView, WasiHttpHooks, WasiHttpView};
use crate::broker::Broker;
use crate::vfs::{Vfs, VfsState, VfsView};
/// A host folder that the guest sees at `guest`.
#[derive(Clone, Debug)]
pub struct Mount {
pub guest: String,
pub host: PathBuf,
pub writable: bool,
}
/// The HTTP transport hooks supplied to WASI processes.
pub struct HttpHooks {
allow: bool,
}
@@ -65,33 +58,38 @@ impl WasiHttpHooks for HttpHooks {
}
}
pub struct ActorState {
/// The WASI and HTTP resources owned by one store.
pub struct ProcessState {
ctx: WasiCtx,
table: ResourceTable,
http: WasiHttpCtx,
hooks: HttpHooks,
vfs: VfsState,
fs: sausage_vfs::adapter::Adapter,
}
fn vfs_view(s: &mut ActorState) -> VfsView<'_> {
VfsView { inner: WasiFilesystemCtxView { ctx: s.ctx.filesystem(), table: &mut s.table }, state: &mut s.vfs }
}
impl WasiView for ActorState {
impl WasiView for ProcessState {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView { ctx: &mut self.ctx, table: &mut self.table }
WasiCtxView {
ctx: &mut self.ctx,
table: &mut self.table,
}
}
}
impl WasiHttpView for ActorState {
impl WasiHttpView for ProcessState {
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.table,
hooks: &mut self.hooks,
}
}
}
/// A Wasmtime engine and linker shared by the world.
pub struct Runtime {
engine: Engine,
linker: Linker<ActorState>,
linker: Linker<ProcessState>,
}
/// How an instance ended.
@@ -115,29 +113,50 @@ impl std::fmt::Display for Exit {
}
}
/// The filesystem, network, and environment supplied to one process.
pub struct InstanceConfig {
pub mounts: Vec<Mount>,
/// 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,
pub stderr_log: PathBuf,
/// The environment variables supplied to the guest.
pub env: Vec<(String, String)>,
/// The actor that runs in the instance.
pub actor: String,
/// Asks the actors that serve `/actor/<id>/fs/` (see `vfs.rs`).
pub broker: Weak<Broker>,
}
/// A running guest. Dropping it kills the guest.
pub struct Instance {
stdin: Option<DuplexStream>,
/// The host end of stdin, removed when the process stops.
pub stdin: Option<DuplexStream>,
/// Lines that the guest writes to stdout. Closed when the guest ends.
pub stdout: mpsc::Receiver<String>,
task: JoinHandle<Exit>,
/// 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) {
self.task.abort();
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);
});
}
}
}
@@ -145,7 +164,11 @@ 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<()> {
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 {
@@ -167,55 +190,19 @@ impl Instance {
/// Wait until the guest ends.
pub async fn wait(&mut self) -> Exit {
match (&mut self.task).await {
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 {
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()
}
/// Kill the guest. Its stdout closes when it has ended.
pub fn kill(&self) {
self.task.abort();
}
/// Send `shutdown_line`, close stdin, and wait until the guest ends.
/// Pass every line that the guest writes meanwhile to `route`. Kill the
/// guest when `deadline` has passed.
pub async fn stop(mut self, shutdown_line: &str, deadline: Duration, mut route: impl FnMut(String)) -> Exit {
let stdin = self.stdin.take();
let stdout = &mut self.stdout;
let finished = tokio::time::timeout(deadline, async {
let close = async {
if let Some(mut stdin) = stdin {
let _ = stdin.write_all(format!("{shutdown_line}\n").as_bytes()).await;
let _ = stdin.shutdown().await;
}
};
let drain = async {
while let Some(line) = stdout.recv().await {
route(line);
}
};
tokio::join!(close, drain);
})
.await;
match finished {
Ok(()) => self.wait().await,
Err(_) => Exit::Trap("killed after shutdown deadline".into()),
}
}
}
fn log_note(path: &Path, text: &str) {
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path) {
let _ = writeln!(f, "[sausage] {text}");
}
}
/// The most useful line of a trap: the `wasm trap: ...` reason.
@@ -225,95 +212,188 @@ 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();
line.truncate(300);
let mut end = line.len().min(300);
while !line.is_char_boundary(end) {
end -= 1;
}
line.truncate(end);
line
}
impl Runtime {
pub fn new() -> Result<Arc<Self>> {
/// Create an engine with disk caching and a ten-millisecond epoch tick.
pub fn new(cache: &Path) -> 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<ActorState> = Linker::new(&engine);
let mut linker: Linker<ProcessState> = 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 `wasi:filesystem` with the wrapper that serves `/actor/<id>/fs/`.
linker.allow_shadowing(true);
filesystem::types::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
filesystem::preopens::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
linker.allow_shadowing(false);
// Time slices: every 10 ms each running guest yields to the scheduler.
let ticker = engine.clone();
let runtime = Arc::new(Self { engine, linker });
let ticker = Arc::downgrade(&runtime);
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_millis(10));
ticker.increment_epoch();
let Some(runtime) = ticker.upgrade() else {
break;
};
runtime.engine.increment_epoch();
}
});
Ok(Arc::new(Self { engine, linker }))
Ok(runtime)
}
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()))
/// Compile component bytes through Wasmtime's disk cache.
pub fn compile(&self, bytes: &[u8]) -> Result<Component> {
Ok(Component::new(&self.engine, bytes)?)
}
pub fn start(self: &Arc<Self>, component: &Component, cfg: InstanceConfig) -> Result<Instance> {
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> {
let (host_in, guest_in) = tokio::io::duplex(64 * 1024);
let (guest_out, host_out) = 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 (guest_err, host_err) = tokio::io::duplex(64 * 1024);
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.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, tokio::fs::File::from_std(stderr)));
b.stderr(AsyncStdoutStream::new(8192, guest_err));
for (k, v) in &cfg.env {
b.env(k, v);
}
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 {
let state = ProcessState {
ctx: b.build(),
table: ResourceTable::new(),
http: WasiHttpCtx::new(),
hooks: HttpHooks { allow: cfg.network },
vfs: VfsState::new(cfg.broker.clone(), cfg.actor.clone()),
fs: sausage_vfs::adapter::Adapter::new(cfg.filesystem),
};
let (line_tx, line_rx) = mpsc::channel(1024);
tokio::spawn(async move {
let mut lines = BufReader::new(host_out).lines();
while let Ok(Some(line)) = lines.next_line().await {
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());
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();
let component = component.clone();
let stderr_log = cfg.stderr_log.clone();
cfg.store_ended.send_replace(false);
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 = Command::instantiate_async(&mut store, &component, &rt.linker).await?;
let cmd = prepared
.map_err(wasmtime::Error::msg)?
.instantiate_async(&mut store)
.await?;
cmd.wasi_cli_run().call_run(&mut store).await
}
.await;
@@ -324,15 +404,37 @@ impl Runtime {
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:?}");
log_note(&stderr_log, &format!("trap details:\n{full}"));
if let Some((journal, pid, plugin)) = &trap_log {
journal.write(pid, plugin, "core", &full);
}
Exit::Trap(short_trap(&full))
}
},
}
});
Ok(Instance { stdin: Some(host_in), stdout: line_rx, task })
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");
}
}
+53
View File
@@ -0,0 +1,53 @@
//! Ordered boot services and their identities for upgrade restarts (PROTOCOL.md section 8).
use crate::Host;
use serde_json::{Value, json};
#[derive(Clone)]
pub(crate) struct Service {
pub(crate) method: String,
pub(crate) params: Value,
pub(crate) request: Value,
pub(crate) starting: std::sync::Arc<std::sync::atomic::AtomicBool>,
}
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(())
}
/// Records a unique request identity before the service can answer or exit.
pub(crate) async fn start_service(&self, method: String, params: Value) {
if self.0.shutdown.load(std::sync::atomic::Ordering::SeqCst) {
return;
}
let request = json!(uuid::Uuid::now_v7().to_string());
let starting = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
self.0.state.lock().unwrap().services.push(Service {
method: method.clone(),
params: params.clone(),
request: request.clone(),
starting: starting.clone(),
});
self.0
.journal
.write("core", "core", "core", &format!("service start {method}"));
if let Err(error) = self
.route("core".into(), Some(request), method, params, true)
.await
{
self.0
.journal
.write("core", "core", "core", &format!("service failed {error}"));
}
starting.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
-287
View File
@@ -1,287 +0,0 @@
//! The supervisor of one WASM actor: it runs the instance, feeds it the
//! mailbox, routes its output, restarts it after a crash, and swaps versions.
//!
//! The broker talks to a supervisor only through `Control` requests.
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{Result, bail};
use serde_json::{Value, json};
use tokio::sync::{mpsc, oneshot};
use wasmtime::component::Component;
use crate::broker::{Broker, Envelope, SHUTDOWN_DEADLINE, shutdown_line};
use crate::runtime::{Exit, Instance};
/// A new version that crashes this often within `CRASH_WINDOW` after an
/// upgrade is rolled back automatically.
const CRASH_LIMIT: usize = 3;
const CRASH_WINDOW: Duration = Duration::from_secs(30);
pub type Reply = oneshot::Sender<Result<Value>>;
pub enum Control {
/// Swap to `version` (already in `versions/`, compiled as `component`).
Upgrade { version: u64, component: Component, requester: String, reply: Reply },
Rollback { version: u64, reply: Reply },
Stop { reply: Reply },
Start { reply: Reply },
Shutdown { reply: Reply },
}
/// The time after an upgrade in which crashes count toward a rollback.
struct Probation {
previous: u64,
since: Instant,
crashes: usize,
requester: String,
}
pub struct Supervisor {
broker: Arc<Broker>,
id: String,
version: u64,
running: bool,
mailbox: mpsc::Receiver<Envelope>,
control: mpsc::Receiver<Control>,
component: Option<Component>,
instance: Option<Instance>,
started_at: Instant,
restarts: u32,
restart_at: Option<Instant>,
probation: Option<Probation>,
/// A control request that arrived during a blocked delivery.
next_control: Option<Control>,
}
async fn next_line(instance: &mut Option<Instance>) -> Option<String> {
match instance {
Some(i) => i.stdout.recv().await,
None => std::future::pending().await,
}
}
async fn sleep_until(t: Option<Instant>) {
match t {
Some(t) => tokio::time::sleep_until(t.into()).await,
None => std::future::pending().await,
}
}
impl Supervisor {
pub fn new(
broker: Arc<Broker>,
id: String,
version: u64,
running: bool,
mailbox: mpsc::Receiver<Envelope>,
control: mpsc::Receiver<Control>,
) -> Self {
Self {
broker,
id,
version,
running,
mailbox,
control,
component: None,
instance: None,
started_at: Instant::now(),
restarts: 0,
restart_at: None,
probation: None,
next_control: None,
}
}
pub async fn run(mut self) {
loop {
if let Some(ctl) = self.next_control.take() {
if self.handle(ctl).await {
break;
}
continue;
}
if self.running && self.instance.is_none() && self.restart_at.is_none_or(|t| Instant::now() >= t) {
self.restart_at = None;
let _ = self.start().await;
}
let has_instance = self.instance.is_some();
tokio::select! {
ctl = self.control.recv() => {
let Some(ctl) = ctl else { break };
if self.handle(ctl).await {
break;
}
}
msg = self.mailbox.recv(), if has_instance => {
let Some(msg) = msg else { break };
self.deliver(msg).await;
}
line = next_line(&mut self.instance) => match line {
Some(line) => self.broker.route_line(&self.id, &line),
None => {
let exit = self.instance.as_mut().unwrap().wait().await;
self.instance = None;
self.on_exit(exit).await;
}
},
_ = sleep_until(self.restart_at), if !has_instance && self.running => {}
}
}
}
/// Write a message to the instance's stdin, and route its output
/// meanwhile. If a control request arrives before the actor reads the
/// message, the instance is killed (a partly written line would corrupt
/// the next message) and the request runs.
async fn deliver(&mut self, msg: Envelope) {
let Some(inst) = self.instance.as_mut() else { return };
let (broker, id, line) = (self.broker.clone(), self.id.clone(), msg.to_line());
let stuck = tokio::select! {
written = inst.send_line(&line, |l| broker.route_line(&id, &l)) => match written {
Ok(()) => return,
// The instance is ending. The main loop sees its closed stdout.
Err(_) => false,
},
Some(ctl) = self.control.recv() => {
self.next_control = Some(ctl);
true
}
};
if msg.from != "broker" {
let body = json!({"type": "undeliverable", "reason": "the actor stopped", "to": self.id});
self.broker.send("broker", Some(&msg.from), None, Some(&msg.id), body);
}
if stuck && let Some(inst) = &self.instance {
self.broker.log(&self.id, "killed: it did not read stdin when the broker needed it");
inst.kill();
}
}
/// Start an instance of the current version. If that fails, the actor
/// stays stopped until the next start, upgrade, or rollback.
async fn start(&mut self) -> Result<()> {
let started = self.try_start();
match &started {
Ok(()) => self.started_at = Instant::now(),
Err(e) => {
self.broker.log(&self.id, &format!("cannot start version {}: {e:#}", self.version));
self.running = false;
}
}
started
}
fn try_start(&mut self) -> Result<()> {
if self.component.is_none() {
self.component = Some(self.broker.load_version(&self.id, self.version)?);
}
let cfg = self.broker.instance_config(&self.id, self.version, None);
self.instance = Some(self.broker.runtime.start(self.component.as_ref().unwrap(), cfg)?);
Ok(())
}
/// Stop the instance gracefully. Its last messages still get routed.
async fn stop(&mut self) {
let Some(inst) = self.instance.take() else { return };
let (broker, id) = (self.broker.clone(), self.id.clone());
let exit = inst.stop(&shutdown_line(), SHUTDOWN_DEADLINE, |line| broker.route_line(&id, &line)).await;
self.broker.log(&self.id, &format!("stopped: {exit}"));
}
async fn on_exit(&mut self, exit: Exit) {
self.broker.log(&self.id, &format!("instance ended unexpectedly: {exit}"));
if let Some(p) = &mut self.probation
&& p.since.elapsed() < CRASH_WINDOW
{
p.crashes += 1;
if p.crashes >= CRASH_LIMIT {
let (from, to, requester) = (self.version, p.previous, p.requester.clone());
self.broker.log(&self.id, &format!("version {from} crashed {CRASH_LIMIT} times after the upgrade; rolling back to {to}"));
let body = match self.rollback(to).await {
Ok(_) => json!({"type": "rolled_back", "actor": self.id, "from": from, "to": to,
"reason": "crash loop after upgrade", "last_exit": exit.to_string()}),
Err(e) => json!({"type": "error", "actor": self.id, "reason": format!("automatic rollback failed: {e:#}")}),
};
self.broker.send("broker", Some(&requester), None, None, body);
return;
}
}
// Restart after 0.5 s, doubling up to 30 s; a minute of uptime resets it.
if self.started_at.elapsed() > Duration::from_secs(60) {
self.restarts = 0;
}
let delay = Duration::from_millis(500 << self.restarts.min(6)).min(Duration::from_secs(30));
self.restarts += 1;
self.restart_at = Some(Instant::now() + delay);
}
/// Make `version` current. It is compiled first (unless `component` is
/// given): if that fails, nothing changes. `data/` and `public/` stay as
/// they are.
async fn install(&mut self, version: u64, component: Option<Component>) -> Result<()> {
let component = match component {
Some(c) => c,
None => self.broker.load_version(&self.id, version)?,
};
self.stop().await;
self.broker.install(&self.id, version)?;
self.version = version;
self.component = Some(component);
self.running = true;
self.restarts = 0;
self.restart_at = None;
Ok(())
}
async fn upgrade(&mut self, version: u64, component: Component, requester: String) -> Result<Value> {
let previous = self.version;
self.install(version, Some(component)).await?;
self.probation = Some(Probation { previous, since: Instant::now(), crashes: 0, requester });
if let Err(e) = self.start().await {
self.rollback(previous).await?;
bail!("version {version} did not start ({e:#}); rolled back to {previous}");
}
Ok(json!({"type": "upgraded", "actor": self.id, "version": version, "previous": previous}))
}
async fn rollback(&mut self, version: u64) -> Result<Value> {
let from = self.version;
self.install(version, None).await?;
self.probation = None;
self.start().await?;
Ok(json!({"type": "rolled_back", "actor": self.id, "from": from, "to": version}))
}
/// Handle a control request. Returns `true` when the supervisor ends.
async fn handle(&mut self, ctl: Control) -> bool {
match ctl {
Control::Upgrade { version, component, requester, reply } => {
let _ = reply.send(self.upgrade(version, component, requester).await);
}
Control::Rollback { version, reply } => {
let _ = reply.send(self.rollback(version).await);
}
Control::Stop { reply } => {
self.running = false;
self.stop().await;
let r = self.broker.set_running(&self.id, false);
let _ = reply.send(r.map(|_| json!({"type": "stopped", "actor": self.id})));
}
Control::Start { reply } => {
self.running = true;
self.restart_at = None;
let r = self.broker.set_running(&self.id, true);
let _ = reply.send(r.map(|_| json!({"type": "started", "actor": self.id})));
}
Control::Shutdown { reply } => {
self.stop().await;
let _ = reply.send(Ok(json!({"type": "shut_down"})));
return true;
}
}
false
}
}
+293
View File
@@ -0,0 +1,293 @@
//! 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)
}
+197
View File
@@ -0,0 +1,197 @@
//! Grace periods, service restarts, and retirement of replaced versions (PROTOCOL.md section 3.7).
use crate::{Host, services::Service};
use serde_json::json;
use std::{collections::BTreeMap, sync::atomic::Ordering, time::Duration};
use tokio::sync::watch;
pub(crate) struct OldProcess {
pid: String,
version: String,
ended: watch::Receiver<bool>,
}
pub(crate) struct Upgrade {
plugin: String,
previous: String,
grace: Duration,
processes: Vec<OldProcess>,
services: Vec<(Service, Option<watch::Receiver<bool>>)>,
}
impl Host {
/// Snapshots old admissions while activation holds the admission write guard.
pub(crate) fn snapshot_upgrade(
&self,
plugin: &str,
previous: String,
current: &str,
grace: f64,
) -> Upgrade {
let mut state = self.0.state.lock().unwrap();
let processes = state
.processes
.iter()
.filter(|(_, process)| {
process.info.plugin == plugin
&& process.info.version != current
&& process.info.method != "rpc.discover"
})
.map(|(pid, process)| OldProcess {
pid: pid.clone(),
version: process.info.version.clone(),
ended: process.ended.subscribe(),
})
.collect();
let mut services = Vec::new();
let mut retained = Vec::new();
for service in std::mem::take(&mut state.services) {
if service
.method
.split_once('.')
.is_some_and(|(name, _)| name == plugin)
{
let process = state
.processes
.values()
.find(|process| process.id.as_ref() == Some(&service.request));
if process.is_some_and(|process| process.info.version == current)
|| (process.is_none() && service.starting.load(Ordering::SeqCst))
{
retained.push(service);
} else {
services.push((service, process.map(|process| process.ended.subscribe())));
}
} else {
retained.push(service);
}
}
state.services = retained;
Upgrade {
plugin: plugin.into(),
previous,
grace: Duration::from_secs_f64(grace),
processes,
services,
}
}
/// Lets old calls finish, stops them after grace, and starts each lifecycle hook once.
pub(crate) fn begin_upgrade(&self, upgrade: Upgrade) {
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_f64()),
);
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();
let grace = upgrade.grace;
tokio::spawn(async move {
let mut ended = process.ended;
tokio::select! {
_ = ended.wait_for(|ended|*ended) => (),
_ = tokio::time::sleep(grace) => { host.begin_stop(&process.pid); }
}
});
}
for (service, ended) in upgrade.services {
let host = host.clone();
tokio::spawn(async move {
if let Some(mut ended) = ended {
let _ = ended.wait_for(|ended| *ended).await;
}
if !host.0.shutdown.load(Ordering::SeqCst) {
host.start_service(service.method, service.params).await;
}
});
}
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);
});
}
});
}
}
-796
View File
@@ -1,796 +0,0 @@
//! Actor-served files: `/actor/<id>/fs/...` (see PROTOCOL.md, "Actor-served
//! files").
//!
//! This module wraps the `wasi:filesystem` implementation of `wasmtime-wasi`.
//! A path under `/actor/<id>/fs/` does not reach the disk: the host asks the
//! actor `<id>` with a message from `fs` and waits up to `ASK_TIMEOUT` for
//! the reply. Other paths go to `wasmtime-wasi` unchanged except that an
//! actor directory listing advertises `fs/` when its source docs declare it.
//!
//! - `fs_read {path}` -> `fs_file {text | base64}`, `fs_dir {entries: [{name, type}]}`
//! - `fs_write {path, text | base64}`, `fs_mkdir {path}`, `fs_remove {path}`,
//! `fs_rename {path, to}` -> `fs_done`
//! - any of them -> `fs_error {errno}` (a `wasi:filesystem` error-code name)
//!
//! A file is read once when it is opened. After a change, it is written back
//! in one `fs_write` when it is synced or closed.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Weak};
use bytes::Bytes;
use serde_json::{Value, json};
use wasmtime::component::{HasData, Resource};
use wasmtime_wasi::filesystem::WasiFilesystemCtxView;
use wasmtime_wasi::p2::bindings::filesystem::types::{
self, DescriptorFlags, DescriptorType, DirectoryEntry, ErrorCode, HostDescriptor, HostDirectoryEntryStream, OpenFlags,
};
use wasmtime_wasi::p2::bindings::filesystem::preopens;
use wasmtime_wasi::p2::pipe::MemoryInputPipe;
use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult, OutputStream, Pollable, StreamResult};
use crate::broker::Broker;
/// The `from` of the host's requests. Replies to it go back to the waiting call.
pub const ID: &str = "fs";
pub const ASK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Per-instance state.
pub struct VfsState {
broker: Weak<Broker>,
/// The actor of this instance, for notes in its `stderr.log`.
me: String,
/// Native directory descriptors and their absolute paths. Keeping every
/// `/actor` child (not only the preopen) lets `fs/` work after traversal.
dirs: HashMap<u32, String>,
}
impl VfsState {
pub fn new(broker: Weak<Broker>, me: String) -> Self {
Self { broker, me, dirs: HashMap::new() }
}
}
/// [`HasData`] for the linker: the filesystem functions get a [`VfsView`].
pub struct Vfs;
impl HasData for Vfs {
type Data<'a> = VfsView<'a>;
}
pub struct VfsView<'a> {
pub inner: WasiFilesystemCtxView<'a>,
pub state: &'a mut VfsState,
}
/// A descriptor of an actor-served file or folder (in the resource table in
/// place of a `wasmtime-wasi` descriptor).
#[derive(Clone)]
enum Virt {
Dir { actor: String, path: String },
File(VFile),
}
#[derive(Clone)]
struct VFile {
actor: String,
path: String,
data: Arc<Mutex<Vec<u8>>>,
dirty: Arc<AtomicBool>,
write: bool,
}
/// A directory-entry-stream of an actor-served folder.
struct VDir(std::vec::IntoIter<DirectoryEntry>);
enum Node {
File(Vec<u8>),
Dir(Vec<DirectoryEntry>),
}
/// Sends the requests. Owned, so that no borrow of the view lives across an await.
struct Asker {
broker: Option<Arc<Broker>>,
me: String,
}
impl Asker {
async fn ask(&self, actor: &str, body: Value) -> Result<Value, ErrorCode> {
let broker = self.broker.as_ref().ok_or(ErrorCode::Io)?;
let what = format!("{} {} to {actor}", body["type"].as_str().unwrap_or(""), body["path"].as_str().unwrap_or(""));
match broker.ask(actor, body).await {
None => {
broker.log(&self.me, &format!("{what}: no reply within {} s", ASK_TIMEOUT.as_secs()));
Err(ErrorCode::Io)
}
Some(reply) => match reply["type"].as_str() {
Some("fs_error") => Err(errno(&reply["errno"])),
Some("undeliverable") => Err(ErrorCode::NoEntry),
_ => Ok(reply),
},
}
}
async fn read(&self, actor: &str, path: &str) -> Result<Node, ErrorCode> {
let reply = self.ask(actor, json!({"type": "fs_read", "path": path})).await?;
match reply["type"].as_str() {
Some("fs_file") => content(&reply).map(Node::File),
Some("fs_dir") => Ok(Node::Dir(
reply["entries"]
.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let type_ = if e["type"] == "dir" { DescriptorType::Directory } else { DescriptorType::RegularFile };
Some(DirectoryEntry { type_, name: e["name"].as_str()?.to_string() })
})
.collect(),
)),
_ => {
if let Some(b) = &self.broker {
b.log(&self.me, &format!("fs_read {path} to {actor}: unexpected reply {reply}"));
}
Err(ErrorCode::Io)
}
}
}
async fn change(&self, actor: &str, body: Value) -> FsResult<()> {
self.ask(actor, body).await.map(|_| ()).map_err(FsError::from)
}
async fn flush(&self, f: &VFile) -> FsResult<()> {
if f.dirty.swap(false, Ordering::SeqCst)
&& let Err(e) = self.ask(&f.actor, write_body(f)).await
{
f.dirty.store(true, Ordering::SeqCst);
return Err(e.into());
}
Ok(())
}
}
fn errno(v: &Value) -> ErrorCode {
match v.as_str().unwrap_or("") {
"no-entry" => ErrorCode::NoEntry,
"exist" => ErrorCode::Exist,
"not-directory" => ErrorCode::NotDirectory,
"is-directory" => ErrorCode::IsDirectory,
"not-empty" => ErrorCode::NotEmpty,
"access" => ErrorCode::Access,
"not-permitted" => ErrorCode::NotPermitted,
"invalid" => ErrorCode::Invalid,
"unsupported" => ErrorCode::Unsupported,
"busy" => ErrorCode::Busy,
_ => ErrorCode::Io,
}
}
fn content(v: &Value) -> Result<Vec<u8>, ErrorCode> {
if let Some(text) = v["text"].as_str() {
return Ok(text.as_bytes().to_vec());
}
match v["base64"].as_str() {
Some(b) => base64_decode(b).ok_or(ErrorCode::IllegalByteSequence),
None => Ok(Vec::new()),
}
}
fn write_body(f: &VFile) -> Value {
let data = f.data.lock().unwrap().clone();
match String::from_utf8(data) {
Ok(text) => json!({"type": "fs_write", "path": f.path, "text": text}),
Err(e) => json!({"type": "fs_write", "path": f.path, "base64": base64_encode(e.as_bytes())}),
}
}
/// `rel` resolved against the folder `base`, as an absolute path.
fn join(base: &str, rel: &str) -> String {
let mut parts: Vec<&str> = base.split('/').filter(|p| !p.is_empty()).collect();
for p in rel.split('/') {
match p {
"" | "." => {}
".." => {
parts.pop();
}
p => parts.push(p),
}
}
format!("/{}", parts.join("/"))
}
/// Split a global path under an actor's virtual `fs/` into its owner and
/// actor-relative path.
fn actor_fs_path(full: &str) -> Option<(String, String)> {
let rest = full.strip_prefix("/actor/")?;
let (actor, rest) = rest.split_once('/')?;
let (fs, path) = rest.split_once('/').unwrap_or((rest, ""));
(fs == "fs" && !actor.is_empty()).then(|| (actor.to_string(), format!("/{path}")))
}
fn actor_root(path: &str) -> Option<&str> {
let actor = path.strip_prefix("/actor/")?;
(!actor.is_empty() && !actor.contains('/')).then_some(actor)
}
/// An actor advertises its virtual filesystem in its leading `//!` docs.
/// This is discovery metadata; actual reads still go through `fs_read`.
fn declares_actor_fs(source: &str) -> bool {
let docs = source
.lines()
.take_while(|line| line.trim().starts_with("//!"))
.map(|line| line.trim().trim_start_matches("//!").trim())
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
docs.contains("actor-served files") && docs.contains("fs_read")
}
fn stat(type_: DescriptorType, size: u64) -> types::DescriptorStat {
types::DescriptorStat {
type_,
link_count: 1,
size,
data_access_timestamp: None,
data_modification_timestamp: None,
status_change_timestamp: None,
}
}
fn node_stat(node: &Node) -> types::DescriptorStat {
match node {
Node::File(data) => stat(DescriptorType::RegularFile, data.len() as u64),
Node::Dir(_) => stat(DescriptorType::Directory, 0),
}
}
fn hash(actor: &str, path: &str) -> types::MetadataHashValue {
let mut h = std::collections::hash_map::DefaultHasher::new();
(actor, path).hash(&mut h);
types::MetadataHashValue { lower: h.finish(), upper: 0 }
}
fn write_at(data: &mut Vec<u8>, at: usize, bytes: &[u8]) {
if data.len() < at + bytes.len() {
data.resize(at + bytes.len(), 0);
}
data[at..at + bytes.len()].copy_from_slice(bytes);
}
/// An output stream into an actor-served file (`pos: None` appends).
struct VWriter {
file: VFile,
pos: Option<usize>,
}
#[wasmtime_wasi::async_trait]
impl Pollable for VWriter {
async fn ready(&mut self) {}
}
#[wasmtime_wasi::async_trait]
impl OutputStream for VWriter {
fn write(&mut self, bytes: Bytes) -> StreamResult<()> {
let mut data = self.file.data.lock().unwrap();
let at = self.pos.unwrap_or(data.len());
write_at(&mut data, at, &bytes);
if let Some(p) = &mut self.pos {
*p += bytes.len();
}
self.file.dirty.store(true, Ordering::SeqCst);
Ok(())
}
fn flush(&mut self) -> StreamResult<()> {
Ok(())
}
fn check_write(&mut self) -> StreamResult<usize> {
Ok(64 * 1024)
}
}
impl VfsView<'_> {
fn asker(&self) -> Asker {
Asker { broker: self.state.broker.upgrade(), me: self.state.me.clone() }
}
fn actor_serves_fs(&self, actor: &str) -> bool {
self.state
.broker
.upgrade()
.and_then(|broker| std::fs::read_to_string(broker.world.source_dir(actor).join("src/main.rs")).ok())
.is_some_and(|source| declares_actor_fs(&source))
}
fn virt(&mut self, fd: &Resource<types::Descriptor>) -> Option<Virt> {
self.inner.table.get_any_mut(fd.rep()).ok()?.downcast_ref::<Virt>().cloned()
}
fn file(&mut self, fd: &Resource<types::Descriptor>) -> Option<FsResult<VFile>> {
match self.virt(fd)? {
Virt::File(f) => Some(Ok(f)),
Virt::Dir { .. } => Some(Err(ErrorCode::IsDirectory.into())),
}
}
/// The actor and its path, if `path` relative to `fd` is actor-served.
fn target(&mut self, fd: &Resource<types::Descriptor>, path: &str) -> Option<(String, String)> {
match self.virt(fd) {
Some(Virt::Dir { actor, path: base }) => Some((actor, join(&base, path))),
Some(Virt::File(f)) => Some((f.actor, join(&f.path, path))),
None => {
let base = self.state.dirs.get(&fd.rep())?;
actor_fs_path(&join(base, path))
}
}
}
async fn native_directory_entries(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Vec<DirectoryEntry>> {
let stream = HostDescriptor::read_directory(&mut self.inner, fd).await?;
let stream_rep = stream.rep();
let mut entries = Vec::new();
loop {
match HostDirectoryEntryStream::read_directory_entry(&mut self.inner, Resource::new_borrow(stream_rep)).await? {
Some(entry) => entries.push(entry),
None => break,
}
}
HostDirectoryEntryStream::drop(&mut self.inner, stream).map_err(|_| ErrorCode::Io)?;
Ok(entries)
}
fn push(&mut self, v: Virt) -> FsResult<Resource<types::Descriptor>> {
let r = self.inner.table.push(v)?;
Ok(Resource::new_own(r.rep()))
}
async fn open(&mut self, actor: String, path: String, oflags: OpenFlags, flags: DescriptorFlags) -> FsResult<Resource<types::Descriptor>> {
let write = flags.contains(DescriptorFlags::WRITE);
let create = oflags.contains(OpenFlags::CREATE);
let exclusive = create && oflags.contains(OpenFlags::EXCLUSIVE);
let virt = match self.asker().read(&actor, &path).await {
Ok(_) if exclusive => return Err(ErrorCode::Exist.into()),
Ok(Node::Dir(_)) if write => return Err(ErrorCode::IsDirectory.into()),
Ok(Node::Dir(_)) => Virt::Dir { actor, path },
Ok(Node::File(_)) if oflags.contains(OpenFlags::DIRECTORY) => return Err(ErrorCode::NotDirectory.into()),
Ok(Node::File(data)) => {
let truncate = oflags.contains(OpenFlags::TRUNCATE);
let data = if truncate { Vec::new() } else { data };
Virt::File(VFile { actor, path, data: Arc::new(Mutex::new(data)), dirty: Arc::new(AtomicBool::new(truncate)), write })
}
Err(ErrorCode::NoEntry) if create && !oflags.contains(OpenFlags::DIRECTORY) => Virt::File(VFile {
actor,
path,
data: Arc::default(),
dirty: Arc::new(AtomicBool::new(true)),
write,
}),
Err(e) => return Err(e.into()),
};
self.push(virt)
}
/// Send the changed file without waiting (the descriptor is gone). The
/// request leaves now, so later requests of this instance come after it.
fn write_back_later(&self, f: &VFile) {
let Some(broker) = self.state.broker.upgrade() else { return };
let reply = broker.ask(&f.actor, write_body(f));
let (me, what) = (self.state.me.clone(), format!("fs_write {} to {}", f.path, f.actor));
tokio::spawn(async move {
match reply.await {
Some(r) if r["type"] != "fs_error" && r["type"] != "undeliverable" => {}
r => broker.log(&me, &format!("{what} failed: {}", r.map_or("no reply".to_string(), |r| r.to_string()))),
}
});
}
}
impl preopens::Host for VfsView<'_> {
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
let dirs = preopens::Host::get_directories(&mut self.inner)?;
for (fd, name) in &dirs {
if name == "/actor" {
self.state.dirs.insert(fd.rep(), "/actor".to_string());
}
}
Ok(dirs)
}
}
impl types::Host for VfsView<'_> {
fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result<ErrorCode> {
types::Host::convert_error_code(&mut self.inner, err)
}
fn filesystem_error_code(&mut self, err: Resource<wasmtime::Error>) -> wasmtime::Result<Option<ErrorCode>> {
types::Host::filesystem_error_code(&mut self.inner, err)
}
}
impl HostDescriptor for VfsView<'_> {
async fn advise(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize, len: types::Filesize, advice: types::Advice) -> FsResult<()> {
if self.virt(&fd).is_some() {
return Ok(());
}
HostDescriptor::advise(&mut self.inner, fd, offset, len, advice).await
}
async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
match self.virt(&fd) {
Some(Virt::File(f)) => self.asker().flush(&f).await,
Some(Virt::Dir { .. }) => Ok(()),
None => HostDescriptor::sync_data(&mut self.inner, fd).await,
}
}
async fn get_flags(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorFlags> {
match self.virt(&fd) {
Some(Virt::File(f)) if f.write => Ok(DescriptorFlags::READ | DescriptorFlags::WRITE),
Some(Virt::File(_)) => Ok(DescriptorFlags::READ),
Some(Virt::Dir { .. }) => Ok(DescriptorFlags::READ | DescriptorFlags::MUTATE_DIRECTORY),
None => HostDescriptor::get_flags(&mut self.inner, fd).await,
}
}
async fn get_type(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorType> {
match self.virt(&fd) {
Some(Virt::File(_)) => Ok(DescriptorType::RegularFile),
Some(Virt::Dir { .. }) => Ok(DescriptorType::Directory),
None => HostDescriptor::get_type(&mut self.inner, fd).await,
}
}
async fn set_size(&mut self, fd: Resource<types::Descriptor>, size: types::Filesize) -> FsResult<()> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
f.data.lock().unwrap().resize(size as usize, 0);
f.dirty.store(true, Ordering::SeqCst);
Ok(())
}
None => HostDescriptor::set_size(&mut self.inner, fd, size).await,
}
}
async fn set_times(&mut self, fd: Resource<types::Descriptor>, atim: types::NewTimestamp, mtim: types::NewTimestamp) -> FsResult<()> {
if self.virt(&fd).is_some() {
return Ok(());
}
HostDescriptor::set_times(&mut self.inner, fd, atim, mtim).await
}
async fn read(&mut self, fd: Resource<types::Descriptor>, len: types::Filesize, offset: types::Filesize) -> FsResult<(Vec<u8>, bool)> {
match self.file(&fd) {
Some(f) => {
let f = f?;
let data = f.data.lock().unwrap();
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
let end = start.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).min(data.len());
Ok((data[start..end].to_vec(), start == end))
}
None => HostDescriptor::read(&mut self.inner, fd, len, offset).await,
}
}
async fn write(&mut self, fd: Resource<types::Descriptor>, buf: Vec<u8>, offset: types::Filesize) -> FsResult<types::Filesize> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
write_at(&mut f.data.lock().unwrap(), offset as usize, &buf);
f.dirty.store(true, Ordering::SeqCst);
Ok(buf.len() as u64)
}
None => HostDescriptor::write(&mut self.inner, fd, buf, offset).await,
}
}
async fn read_directory(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<types::DirectoryEntryStream>> {
match self.virt(&fd) {
Some(Virt::Dir { actor, path }) => match self.asker().read(&actor, &path).await? {
Node::Dir(entries) => {
let r = self.inner.table.push(VDir(entries.into_iter()))?;
Ok(Resource::new_own(r.rep()))
}
Node::File(_) => Err(ErrorCode::NotDirectory.into()),
},
Some(Virt::File(_)) => Err(ErrorCode::NotDirectory.into()),
None => {
let actor = self.state.dirs.get(&fd.rep()).and_then(|path| actor_root(path)).map(str::to_string);
if actor.as_deref().is_some_and(|id| self.actor_serves_fs(id)) {
let mut entries = self.native_directory_entries(fd).await?;
if !entries.iter().any(|entry| entry.name == "fs") {
entries.push(DirectoryEntry { type_: DescriptorType::Directory, name: "fs".to_string() });
}
let r = self.inner.table.push(VDir(entries.into_iter()))?;
Ok(Resource::new_own(r.rep()))
} else {
HostDescriptor::read_directory(&mut self.inner, fd).await
}
}
}
}
async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
match self.virt(&fd) {
Some(Virt::File(f)) => self.asker().flush(&f).await,
Some(Virt::Dir { .. }) => Ok(()),
None => HostDescriptor::sync(&mut self.inner, fd).await,
}
}
async fn create_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
match self.target(&fd, &path) {
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_mkdir", "path": path})).await,
None => HostDescriptor::create_directory_at(&mut self.inner, fd, path).await,
}
}
async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
match self.virt(&fd) {
Some(Virt::File(f)) => Ok(stat(DescriptorType::RegularFile, f.data.lock().unwrap().len() as u64)),
Some(Virt::Dir { .. }) => Ok(stat(DescriptorType::Directory, 0)),
None => HostDescriptor::stat(&mut self.inner, fd).await,
}
}
async fn stat_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::DescriptorStat> {
match self.target(&fd, &path) {
Some((actor, path)) => Ok(node_stat(&self.asker().read(&actor, &path).await?)),
None => HostDescriptor::stat_at(&mut self.inner, fd, path_flags, path).await,
}
}
async fn set_times_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
atim: types::NewTimestamp,
mtim: types::NewTimestamp,
) -> FsResult<()> {
if self.target(&fd, &path).is_some() {
return Ok(());
}
HostDescriptor::set_times_at(&mut self.inner, fd, path_flags, path, atim, mtim).await
}
async fn link_at(
&mut self,
fd: Resource<types::Descriptor>,
old_path_flags: types::PathFlags,
old_path: String,
new_descriptor: Resource<types::Descriptor>,
new_path: String,
) -> FsResult<()> {
if self.target(&fd, &old_path).is_some() || self.target(&new_descriptor, &new_path).is_some() {
return Err(ErrorCode::Unsupported.into());
}
HostDescriptor::link_at(&mut self.inner, fd, old_path_flags, old_path, new_descriptor, new_path).await
}
async fn open_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
oflags: OpenFlags,
flags: DescriptorFlags,
) -> FsResult<Resource<types::Descriptor>> {
match self.target(&fd, &path) {
Some((actor, path)) => self.open(actor, path, oflags, flags).await,
None => {
let child_path = self.state.dirs.get(&fd.rep()).map(|base| join(base, &path));
let opened = HostDescriptor::open_at(&mut self.inner, fd, path_flags, path, oflags, flags).await?;
if let Some(path) = child_path {
if HostDescriptor::get_type(&mut self.inner, Resource::new_borrow(opened.rep())).await? == DescriptorType::Directory {
self.state.dirs.insert(opened.rep(), path);
}
}
Ok(opened)
}
}
}
fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
if self.virt(&fd).is_some() {
if let Virt::File(f) = self.inner.table.delete(Resource::<Virt>::new_own(fd.rep()))?
&& f.dirty.load(Ordering::SeqCst)
{
self.write_back_later(&f);
}
return Ok(());
}
self.state.dirs.remove(&fd.rep());
HostDescriptor::drop(&mut self.inner, fd)
}
async fn readlink_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<String> {
if self.target(&fd, &path).is_some() {
return Err(ErrorCode::Invalid.into());
}
HostDescriptor::readlink_at(&mut self.inner, fd, path).await
}
async fn remove_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
match self.target(&fd, &path) {
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
None => HostDescriptor::remove_directory_at(&mut self.inner, fd, path).await,
}
}
async fn rename_at(&mut self, fd: Resource<types::Descriptor>, old_path: String, new_fd: Resource<types::Descriptor>, new_path: String) -> FsResult<()> {
match (self.target(&fd, &old_path), self.target(&new_fd, &new_path)) {
(None, None) => HostDescriptor::rename_at(&mut self.inner, fd, old_path, new_fd, new_path).await,
(Some((a, from)), Some((b, to))) if a == b => {
self.asker().change(&a, json!({"type": "fs_rename", "path": from, "to": to})).await
}
_ => Err(ErrorCode::Unsupported.into()),
}
}
async fn symlink_at(&mut self, fd: Resource<types::Descriptor>, src_path: String, dest_path: String) -> FsResult<()> {
if self.target(&fd, &dest_path).is_some() {
return Err(ErrorCode::Unsupported.into());
}
HostDescriptor::symlink_at(&mut self.inner, fd, src_path, dest_path).await
}
async fn unlink_file_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
match self.target(&fd, &path) {
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
None => HostDescriptor::unlink_file_at(&mut self.inner, fd, path).await,
}
}
fn read_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynInputStream>> {
match self.file(&fd) {
Some(f) => {
let f = f?;
let data = f.data.lock().unwrap();
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
let stream: DynInputStream = Box::new(MemoryInputPipe::new(data[start..].to_vec()));
std::mem::drop(data);
Ok(self.inner.table.push(stream)?)
}
None => HostDescriptor::read_via_stream(&mut self.inner, fd, offset),
}
}
fn write_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynOutputStream>> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: Some(offset as usize) });
Ok(self.inner.table.push(stream)?)
}
None => HostDescriptor::write_via_stream(&mut self.inner, fd, offset),
}
}
fn append_via_stream(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<DynOutputStream>> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: None });
Ok(self.inner.table.push(stream)?)
}
None => HostDescriptor::append_via_stream(&mut self.inner, fd),
}
}
async fn is_same_object(&mut self, a: Resource<types::Descriptor>, b: Resource<types::Descriptor>) -> wasmtime::Result<bool> {
let key = |v: Virt| match v {
Virt::Dir { actor, path } => (actor, path),
Virt::File(f) => (f.actor, f.path),
};
match (self.virt(&a), self.virt(&b)) {
(None, None) => HostDescriptor::is_same_object(&mut self.inner, a, b).await,
(Some(x), Some(y)) => Ok(key(x) == key(y)),
_ => Ok(false),
}
}
async fn metadata_hash(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::MetadataHashValue> {
match self.virt(&fd) {
Some(Virt::Dir { actor, path }) => Ok(hash(&actor, &path)),
Some(Virt::File(f)) => Ok(hash(&f.actor, &f.path)),
None => HostDescriptor::metadata_hash(&mut self.inner, fd).await,
}
}
async fn metadata_hash_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::MetadataHashValue> {
match self.target(&fd, &path) {
Some((actor, path)) => Ok(hash(&actor, &path)),
None => HostDescriptor::metadata_hash_at(&mut self.inner, fd, path_flags, path).await,
}
}
}
impl HostDirectoryEntryStream for VfsView<'_> {
async fn read_directory_entry(&mut self, stream: Resource<types::DirectoryEntryStream>) -> FsResult<Option<DirectoryEntry>> {
if let Some(dir) = self.inner.table.get_any_mut(stream.rep()).ok().and_then(|a| a.downcast_mut::<VDir>()) {
return Ok(dir.0.next());
}
HostDirectoryEntryStream::read_directory_entry(&mut self.inner, stream).await
}
fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
let ours = self.inner.table.get_any_mut(stream.rep()).ok().is_some_and(|a| a.is::<VDir>());
if ours {
self.inner.table.delete(Resource::<VDir>::new_own(stream.rep()))?;
return Ok(());
}
HostDirectoryEntryStream::drop(&mut self.inner, stream)
}
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn base64_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i));
for i in 0..4 {
out.push(if i <= chunk.len() { B64[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' });
}
}
out
}
fn base64_decode(text: &str) -> Option<Vec<u8>> {
let (mut out, mut n, mut bits) = (Vec::new(), 0u32, 0);
for c in text.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
n = (n << 6 | B64.iter().position(|b| *b == c)? as u32) & 0xFF_FFFF;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((n >> bits) as u8);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn actor_fs_paths_and_discovery_docs() {
assert_eq!(actor_fs_path("/actor/brain/fs"), Some(("brain".to_string(), "/".to_string())));
assert_eq!(actor_fs_path("/actor/brain/fs/docs/readme.md"), Some(("brain".to_string(), "/docs/readme.md".to_string())));
assert_eq!(actor_fs_path("/actor/brain/public/fs/file"), None);
assert_eq!(actor_root("/actor/brain"), Some("brain"));
assert_eq!(actor_root("/actor/brain/fs"), None);
assert!(declares_actor_fs("//! brain\n//! Actor-served files include fs_read.\nfn main() {}"));
assert!(!declares_actor_fs("//! actor without fs support\nfn main() {}"));
}
#[test]
fn base64_round_trip() {
for s in ["", "a", "ab", "abc", "abcd", "\u{0}\u{ff}binary"] {
let enc = base64_encode(s.as_bytes());
assert_eq!(base64_decode(&enc).unwrap(), s.as_bytes());
}
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
}
#[test]
fn join_resolves_dots() {
assert_eq!(join("/a/b", "../c/./d"), "/a/c/d");
assert_eq!(join("/", "x/fs"), "/x/fs");
assert_eq!(join("/", ".."), "/");
}
}
-256
View File
@@ -1,256 +0,0 @@
//! File watching: the host watches the whole world folder with the OS file
//! watcher (`notify`, inotify on Linux), whoever writes: actors, a 9P mount,
//! a shell or `git` on the host. Nothing outside the folder counts.
//!
//! Events are coalesced per path: once a path is quiet for `QUIET` (or has
//! kept changing for `MAX_DELAY`), it becomes one change
//! `{path, op: write | create | remove | rename, to?}` with a namespace path
//! (`/actor/...`). Then the broker
//!
//! - sends `fs_changed` to the actors that watch the path (`watch` request),
//! - starts or upgrades an actor whose `actor/<id>/actor.wasm` (or folder
//! `actor/<id>`) changed, tested first, as at boot (`Broker::check_actor`).
//!
//! The host's temporary files (`.sausage-tmp-*`) are skipped: a rename of one
//! onto a file is a `write` of that file. If the OS drops events, the broker
//! runs a full pass and tells every watcher (`Broker::overflowed`).
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use notify::event::{EventKind, ModifyKind, RenameMode};
use notify::{RecursiveMode, Watcher as _};
use tokio::sync::mpsc;
use tokio::time::Instant;
use crate::broker::Broker;
use crate::world::TMP_PREFIX;
/// A path that is quiet this long becomes one change.
pub const QUIET: Duration = Duration::from_millis(100);
/// A path that keeps changing becomes a change at least this often.
pub const MAX_DELAY: Duration = Duration::from_secs(1);
/// The running watcher. Dropping it stops watching.
pub struct Watcher {
_os: notify::RecommendedWatcher,
task: tokio::task::JoinHandle<()>,
}
impl Drop for Watcher {
fn drop(&mut self) {
self.task.abort();
}
}
/// Watch the world folder of `broker` recursively. A failure to watch part
/// of it (for example too few inotify watches) is logged, and the rest is
/// watched.
pub fn start(broker: Arc<Broker>) -> notify::Result<Watcher> {
let (tx, rx) = mpsc::unbounded_channel();
let mut os = notify::recommended_watcher(move |event| {
let _ = tx.send(event);
})?;
if let Err(e) = os.watch(&broker.world.root, RecursiveMode::Recursive) {
broker.host_error(&format!("cannot watch all of {}: {e}", broker.world.root.display()));
}
let task = tokio::spawn(run(broker, rx));
Ok(Watcher { _os: os, task })
}
/// A change waiting for its path to be quiet.
struct Pending {
op: &'static str,
to: Option<String>,
first: Instant,
last: Instant,
}
impl Pending {
fn due(&self) -> Instant {
(self.last + QUIET).min(self.first + MAX_DELAY)
}
}
async fn run(broker: Arc<Broker>, mut events: mpsc::UnboundedReceiver<notify::Result<notify::Event>>) {
let root = broker.world.actors_root();
let mut pending: HashMap<String, Pending> = HashMap::new();
// Actor checks and full passes. Dropping the set (when the host stops) aborts them.
let mut tasks = tokio::task::JoinSet::new();
loop {
let due = pending.values().map(Pending::due).min();
tokio::select! {
event = events.recv() => match event {
Some(Ok(event)) => {
if event.need_rescan() {
let broker = broker.clone();
tasks.spawn(async move {
broker.overflowed().await;
});
}
let now = Instant::now();
for (path, op, to) in changes(&root, &event) {
add(&mut pending, path, op, to, now);
}
}
Some(Err(e)) => broker.host_error(&format!("file watcher: {e}")),
None => break,
},
_ = sleep_until(due) => {
let now = Instant::now();
let ready: Vec<String> = pending.iter().filter(|(_, p)| p.due() <= now).map(|(k, _)| k.clone()).collect();
for path in ready {
let p = pending.remove(&path).unwrap();
let op = if p.op == "moved-in" { "create" } else { p.op };
broker.notify_watchers(&path, op, p.to.as_deref());
if let Some(id) = new_wasm(&path, op, p.to.as_deref()) {
tasks.spawn(broker.clone().check_actor(id));
}
}
}
Some(_) = tasks.join_next() => {}
}
}
}
async fn sleep_until(t: Option<Instant>) {
match t {
Some(t) => tokio::time::sleep_until(t).await,
None => std::future::pending().await,
}
}
/// The namespace path (`/actor/...`) of a host path, unless it is outside
/// `actor/` or a temporary file of the host.
fn namespace(root: &Path, path: &Path) -> Option<String> {
let rel = path.strip_prefix(root).ok()?;
let mut out = String::from("/actor");
for part in rel.components() {
let name = part.as_os_str().to_str()?;
if name.starts_with(TMP_PREFIX) {
return None;
}
out.push('/');
out.push_str(name);
}
Some(out)
}
/// One change: namespace path, op, and the new path of a rename.
type Change = (String, &'static str, Option<String>);
/// The changes of one OS event. inotify reports a rename three times:
/// `From` (the old name), `To` (the new name) and `Both`. `To` becomes the
/// internal op `moved-in`, which `Both` replaces.
fn changes(root: &Path, event: &notify::Event) -> Vec<Change> {
let path = |i: usize| event.paths.get(i).and_then(|p| namespace(root, p));
let one = |op: &'static str| -> Vec<Change> { path(0).map(|p| (p, op, None)).into_iter().collect() };
match event.kind {
EventKind::Create(_) => one("create"),
EventKind::Remove(_) => one("remove"),
EventKind::Modify(ModifyKind::Metadata(_)) => vec![],
EventKind::Modify(ModifyKind::Name(RenameMode::From)) => one("remove"),
EventKind::Modify(ModifyKind::Name(RenameMode::To)) => one("moved-in"),
EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => match (path(0), path(1)) {
(Some(from), Some(to)) => vec![(from, "rename", Some(to))],
// Renamed to a temporary name: gone.
(Some(from), None) => vec![(from, "remove", None)],
// A temporary file renamed onto `to`: an atomic write.
(None, Some(to)) => vec![(to, "write", None)],
(None, None) => vec![],
},
EventKind::Modify(_) => one("write"),
_ => vec![],
}
}
/// Coalesce a change into `pending`. The last op wins, except that a write
/// after a create stays a create. A rename replaces the `moved-in` of its
/// new name.
fn add(pending: &mut HashMap<String, Pending>, path: String, op: &'static str, to: Option<String>, now: Instant) {
if let Some(to) = &to
&& pending.get(to).is_some_and(|p| p.op == "moved-in")
{
pending.remove(to);
}
match pending.get_mut(&path) {
Some(p) => {
if !(p.op == "create" && op == "write") {
p.op = op;
p.to = to;
}
p.last = now;
}
None => {
pending.insert(path, Pending { op, to, first: now, last: now });
}
}
}
/// The actor to check when `actor/<id>/actor.wasm` or the folder `actor/<id>`
/// appeared or changed.
fn new_wasm(path: &str, op: &str, to: Option<&str>) -> Option<String> {
let target = match op {
"remove" => return None,
"rename" => to?,
_ => path,
};
let rest = target.strip_prefix("/actor/")?;
let id = rest.strip_suffix("/actor.wasm").unwrap_or(rest);
(!id.is_empty() && !id.contains('/')).then(|| id.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wasm_and_folder_changes_name_the_actor() {
assert_eq!(new_wasm("/actor/a/actor.wasm", "write", None).as_deref(), Some("a"));
assert_eq!(new_wasm("/actor/a", "create", None).as_deref(), Some("a"));
assert_eq!(new_wasm("/actor/x/y", "rename", Some("/actor/b/actor.wasm")).as_deref(), Some("b"));
assert_eq!(new_wasm("/actor/a/actor.wasm", "remove", None), None);
assert_eq!(new_wasm("/actor/a/versions/1/actor.wasm", "write", None), None);
assert_eq!(new_wasm("/actor/a/public/x", "write", None), None);
assert_eq!(new_wasm("/actor", "write", None), None);
}
#[test]
fn a_rename_is_one_change_and_temp_files_are_skipped() {
let root = Path::new("/w/actor");
let now = Instant::now();
let mut pending = HashMap::new();
let ev = |kind, paths: &[&str]| {
let mut e = notify::Event::new(kind);
for p in paths {
e = e.add_path(std::path::PathBuf::from(*p));
}
e
};
let name = |mode| EventKind::Modify(ModifyKind::Name(mode));
for e in [
ev(name(RenameMode::From), &["/w/actor/a/public/x"]),
ev(name(RenameMode::To), &["/w/actor/a/public/y"]),
ev(name(RenameMode::Both), &["/w/actor/a/public/x", "/w/actor/a/public/y"]),
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/actor/a/.sausage-tmp-host-1-actor.json"]),
ev(name(RenameMode::Both), &["/w/actor/a/.sausage-tmp-host-1-actor.json", "/w/actor/a/actor.json"]),
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/.lock"]),
] {
for (path, op, to) in changes(root, &e) {
add(&mut pending, path, op, to, now);
}
}
let mut got: Vec<(String, &str, Option<String>)> =
pending.into_iter().map(|(k, p)| (k, p.op, p.to)).collect();
got.sort();
assert_eq!(
got,
vec![
("/actor/a/actor.json".to_string(), "write", None),
("/actor/a/public/x".to_string(), "rename", Some("/actor/a/public/y".to_string())),
]
);
}
}
+84
View File
@@ -0,0 +1,84 @@
//! 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(())
}
}
+48 -279
View File
@@ -1,302 +1,71 @@
//! On-disk layout of a Sausage world. The folder is the source of truth:
//! `sausage run` boots every actor that it finds in `actor/`, and then
//! watches the folder (`watch.rs`).
//!
//! ```text
//! <world>/
//! actor/<id>/
//! actor.wasm the current version
//! actor.json version, running, subscriptions, tapped, watches (broker)
//! source/ source of the current version
//! public/ written by the actor, read-only for others
//! data/ state, writable only by the actor
//! stderr.log stderr of the actor and notes of the broker
//! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json
//! ```
use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
/// Name prefix of the host's temporary files and folders. Copies skip them.
pub const TMP_PREFIX: &str = ".sausage-tmp-";
/// IDs of the actors that the host implements natively. `fs` sends the
/// requests for actor-served files (`vfs.rs`).
pub const NATIVE: &[&str] = &["broker", "console", "cargo", "fs"];
//! World folders and atomic build output helpers.
use anyhow::{Result, bail};
use std::path::{Path, PathBuf};
#[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,
}
/// `actor/<id>/actor.json`.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct ActorMeta {
/// The installed version. `None` for native actors and for a new
/// `actor.wasm` that the broker has not installed yet.
#[serde(default)]
pub version: Option<u64>,
#[serde(default = "yes")]
pub running: bool,
/// The actors whose published messages this actor receives.
#[serde(default)]
pub subscriptions: BTreeSet<String>,
/// Whether this actor gets a copy of every routed message (`tap`).
#[serde(default)]
pub tapped: bool,
/// Namespace paths whose changes this actor gets as `fs_changed` (`watch`).
#[serde(default)]
pub watches: BTreeSet<String>,
}
fn yes() -> bool {
true
}
/// `versions/<n>/meta.json`.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct VersionMeta {
pub version: u64,
/// Path of the `.wasm` file, as the requester gave it.
pub wasm: String,
/// Path of the source folder, as the requester gave it.
pub source: Option<String>,
/// The actor that built the `.wasm` file.
pub built_by: Option<String>,
/// The actor that requested the version ("scan" for a file found on disk).
pub requested_by: String,
pub created_at: u64,
}
pub fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
pub fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
impl World {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn actors_root(&self) -> PathBuf {
self.root.join("actor")
}
pub fn actor_dir(&self, id: &str) -> PathBuf {
self.actors_root().join(id)
}
pub fn wasm_path(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("actor.wasm")
}
pub fn public_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("public")
}
pub fn source_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("source")
}
pub fn data_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("data")
}
pub fn versions_dir(&self, id: &str) -> PathBuf {
self.actor_dir(id).join("versions")
}
pub fn version_dir(&self, id: &str, v: u64) -> PathBuf {
self.versions_dir(id).join(v.to_string())
}
/// Private scratch space of the broker (candidate copies, seeds).
pub fn scratch_dir(&self) -> PathBuf {
self.data_dir("broker").join("scratch")
}
pub fn exists(&self) -> bool {
self.actors_root().is_dir()
}
/// Create an empty world with the folders of the native actors.
pub fn create(root: &Path) -> Result<World> {
let world = World::new(std::path::absolute(root)?);
if world.exists() {
bail!("{} is already a Sausage world", root.display());
}
for id in NATIVE {
world.ensure_actor_dirs(id)?;
}
Ok(world)
}
pub fn ensure_actor_dirs(&self, id: &str) -> Result<()> {
std::fs::create_dir_all(self.public_dir(id))?;
std::fs::create_dir_all(self.data_dir(id))?;
Ok(())
}
/// IDs of every folder in `actor/`, sorted.
pub fn actor_ids(&self) -> Result<Vec<String>> {
let mut ids: Vec<String> = std::fs::read_dir(self.actors_root())?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| e.file_name().into_string().ok())
.filter(|id| valid_id(id))
.collect();
ids.sort();
Ok(ids)
}
pub fn read_meta(&self, id: &str) -> ActorMeta {
std::fs::read_to_string(self.actor_dir(id).join("actor.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or(ActorMeta { running: true, ..Default::default() })
}
pub fn write_meta(&self, id: &str, meta: &ActorMeta) -> Result<()> {
write_atomic(&self.actor_dir(id).join("actor.json"), serde_json::to_string_pretty(meta)?.as_bytes())
}
/// Make `actor.wasm` and `source/` a copy of `versions/<version>/`,
/// unless `actor.wasm` already is that version (it came from there).
pub fn install_files(&self, id: &str, version: u64) -> Result<()> {
let dir = self.version_dir(id, version);
self.ensure_actor_dirs(id)?;
let wasm = std::fs::read(dir.join("actor.wasm"))?;
if std::fs::read(self.wasm_path(id)).ok().as_ref() == Some(&wasm) {
return Ok(());
}
write_atomic(&self.wasm_path(id), &wasm)?;
replace_tree(&dir.join("source"), &self.source_dir(id))
}
/// Version numbers in `versions/`, ascending.
pub fn versions(&self, id: &str) -> Vec<u64> {
let mut v: Vec<u64> = std::fs::read_dir(self.versions_dir(id))
.map(|rd| rd.filter_map(|e| e.ok()?.file_name().to_str()?.parse().ok()).collect())
.unwrap_or_default();
v.sort();
v
}
pub fn next_version(&self, id: &str) -> u64 {
self.versions(id).last().map_or(1, |v| v + 1)
}
/// Map a path in an actor's namespace to a host path.
///
/// - `/actor/<id>/...` is the global filesystem.
/// - `/public/...` and `/data/...` belong to `sender`.
/// - Native senders (the console) can also give absolute host paths.
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
let mut out = base;
for c in Path::new(rest).components() {
match c {
Component::Normal(n) => out.push(n),
Component::CurDir | Component::RootDir => {}
_ => bail!("path must not contain '..': {path}"),
}
/// 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}");
}
Ok(out)
};
let prefixed = |prefix: &str| {
path.strip_prefix(prefix).filter(|rest| rest.is_empty() || rest.starts_with('/'))
};
if let Some(rest) = prefixed("/actor") {
return under(self.actors_root(), rest);
}
if let Some(rest) = prefixed("/public") {
return under(self.public_dir(sender), rest);
}
if let Some(rest) = prefixed("/data") {
return under(self.data_dir(sender), rest);
}
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/...")
Ok(Self {
root: path.canonicalize()?,
})
}
/// 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()))
/// 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());
}
for dir in ["plugins", "state/core", "config"] {
std::fs::create_dir_all(path.join(dir))?;
}
std::fs::write(path.join("config/boot.yaml"), "[]\n")?;
Self::open(path)
}
/// 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)
}
}
/// Write a file so that readers never see a partial version.
/// Write a temporary sibling and rename it over the destination.
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
let dir = path.parent().context("path has no parent")?;
std::fs::create_dir_all(dir)?;
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file");
let tmp = dir.join(format!("{TMP_PREFIX}host-{}-{name}", std::process::id()));
std::fs::create_dir_all(path.parent().unwrap())?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path)?;
std::fs::rename(tmp, path)?;
Ok(())
}
/// Folders that a copy of a source folder leaves out.
const BUILD_DIRS: &[&str] = &["target", ".git"];
/// Copy a directory tree, except uncommitted files.
pub fn copy_tree(from: &Path, to: &Path) -> Result<()> {
copy_filtered(from, to, &[])
/// 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_filtered(from: &Path, to: &Path, skip: &[&str]) -> Result<()> {
fn copy_tree(from: &Path, to: &Path) -> Result<()> {
std::fs::create_dir_all(to)?;
for entry in std::fs::read_dir(from).with_context(|| format!("read {}", from.display()))? {
for entry in std::fs::read_dir(from)? {
let entry = entry?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(TMP_PREFIX) {
if entry.file_name() == "target" {
continue;
}
let dst = to.join(&*name);
let ty = entry.file_type()?;
if ty.is_dir() && !skip.contains(&&*name) {
copy_filtered(&entry.path(), &dst, skip)?;
} else if ty.is_file() {
std::fs::copy(entry.path(), &dst)?;
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()))?;
}
}
Ok(())
}
/// Replace the directory `dst` with a copy of `src` (an empty folder if `src`
/// does not exist): copy to a sibling, then swap with two renames.
pub fn replace_tree(src: &Path, dst: &Path) -> Result<()> {
replace_filtered(src, dst, &[])
}
/// Like `replace_tree`, for a source folder: leaves out `target/` and `.git/`.
pub fn replace_source(src: &Path, dst: &Path) -> Result<()> {
replace_filtered(src, dst, BUILD_DIRS)
}
fn replace_filtered(src: &Path, dst: &Path, skip: &[&str]) -> Result<()> {
let parent = dst.parent().context("path has no parent")?;
let name = dst.file_name().and_then(|n| n.to_str()).unwrap_or("dir");
let fresh = parent.join(format!("{TMP_PREFIX}new-{name}"));
let old = parent.join(format!("{TMP_PREFIX}old-{name}"));
let _ = std::fs::remove_dir_all(&fresh);
let _ = std::fs::remove_dir_all(&old);
if src.is_dir() {
copy_filtered(src, &fresh, skip)?;
} else {
std::fs::create_dir_all(&fresh)?;
}
if dst.exists() {
std::fs::rename(dst, &old)?;
}
std::fs::rename(&fresh, dst)?;
let _ = std::fs::remove_dir_all(&old);
Ok(())
}
-105
View File
@@ -1,105 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "toolbox"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-180
View File
@@ -1,180 +0,0 @@
use std::collections::HashMap;
use std::io::{self, BufRead, Write};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
fn send(out: &mut impl Write, message: Value) {
writeln!(out, "{message}").unwrap();
out.flush().unwrap();
}
fn reply(out: &mut impl Write, message: &Value, body: Value) {
send(
out,
json!({"to": message["from"], "reply_to": message["id"], "body": body}),
);
}
/// Connect to `address`, send one line, and return the line that comes back.
fn tcp_line(address: &str, line: &str) -> io::Result<String> {
let mut stream = std::net::TcpStream::connect(address)?;
writeln!(stream, "{line}")?;
let mut answer = String::new();
io::BufReader::new(stream).read_line(&mut answer)?;
Ok(answer.trim_end().to_string())
}
fn io_error(error: io::Error) -> Value {
json!({"type": "error", "error": error.to_string()})
}
fn main() {
let actor = std::env::var("SAUSAGE_ACTOR").unwrap_or_default();
let version = std::env::var("SAUSAGE_VERSION").unwrap_or_else(|_| "candidate".to_string());
let candidate = std::env::var_os("SAUSAGE_CANDIDATE").is_some();
if actor == "crashloop" && version == "2" && !candidate {
std::fs::write("/data/state", "bad-v2").unwrap();
panic!("version 2 crashes on startup");
}
let mut open = HashMap::<String, std::fs::File>::new();
// Files that this actor serves at /actor/<id>/fs/ (path -> text).
let mut served = std::collections::BTreeMap::<String, String>::new();
let stdin = io::stdin();
let mut out = io::stdout().lock();
let mut emit_shutdown = false;
if actor == "spawn-race" && !candidate {
let stamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::fs::write(format!("/data/start-{stamp}"), "started").unwrap();
}
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
let Ok(message) = serde_json::from_str::<Value>(&line) else {
continue;
};
let body = &message["body"];
let path = body["path"].as_str().unwrap_or_default().to_string();
let text = body["text"].as_str().unwrap_or_default();
let response = match body["type"].as_str() {
Some("shutdown") => {
if actor == "queue" {
let _ = std::fs::write("/data/shutdown", "stopping");
std::thread::sleep(Duration::from_millis(800));
}
if emit_shutdown {
send(
&mut out,
json!({"to": "console", "reply_to": message["id"], "body": {"type": "shutdown-reply"}}),
);
send(&mut out, json!({"body": {"type": "shutdown-publish"}}));
}
break;
}
Some("echo") => json!({"type": "echo", "value": body["value"]}),
Some("fs_read") => {
let dir = if path == "/" { "/".to_string() } else { format!("{path}/") };
let mut names: Vec<String> = served
.keys()
.filter_map(|k| k.strip_prefix(&dir))
.map(|rest| match rest.split_once('/') {
Some((first, _)) => format!("{first}/"),
None => rest.to_string(),
})
.collect();
names.dedup();
if let Some(text) = served.get(&path) {
json!({"type": "fs_file", "text": text})
} else if path == "/" || !names.is_empty() {
let entries: Vec<Value> = names
.iter()
.map(|n| match n.strip_suffix('/') {
Some(d) => json!({"name": d, "type": "dir"}),
None => json!({"name": n, "type": "file"}),
})
.collect();
json!({"type": "fs_dir", "entries": entries})
} else {
json!({"type": "fs_error", "errno": "no-entry"})
}
}
Some("fs_write") => {
served.insert(path.clone(), text.to_string());
json!({"type": "fs_done"})
}
Some("fs_remove") => match served.remove(&path) {
Some(_) => json!({"type": "fs_done"}),
None => json!({"type": "fs_error", "errno": "no-entry"}),
},
Some("publish") => {
send(
&mut out,
json!({"body": {"type": "published", "value": body["value"]}}),
);
continue;
}
Some("version") => json!({"type": "version", "version": version}),
Some("ordered") => json!({"type": "ordered", "n": body["n"], "version": version}),
Some("write") => match std::fs::write(&path, text) {
Ok(()) => json!({"type": "ok"}),
Err(error) => io_error(error),
},
Some("mkdir") => match std::fs::create_dir_all(&path) {
Ok(()) => json!({"type": "ok"}),
Err(error) => io_error(error),
},
Some("rename") => match std::fs::rename(&path, body["to"].as_str().unwrap_or_default())
{
Ok(()) => json!({"type": "ok"}),
Err(error) => io_error(error),
},
Some("open") => {
let result = (|| -> io::Result<()> {
let mut file = std::fs::File::create(&path)?;
file.write_all(text.as_bytes())?;
open.insert(path.clone(), file);
Ok(())
})();
match result {
Ok(()) => json!({"type": "opened"}),
Err(error) => io_error(error),
}
}
Some("close") => json!({"type": "closed", "closed": open.remove(&path).is_some()}),
Some("arm-shutdown") => {
emit_shutdown = true;
json!({"type": "armed"})
}
Some("ignore-stdin") => json!({"type": "ignoring"}),
Some("tcp") => match tcp_line(body["address"].as_str().unwrap_or_default(), text) {
Ok(line) => json!({"type": "tcp", "line": line}),
Err(error) => io_error(error),
},
Some("read") => match std::fs::read_to_string(&path) {
Ok(text) => json!({"type": "content", "text": text}),
Err(error) => io_error(error),
},
Some("list") => match std::fs::read_dir(&path) {
Ok(entries) => {
let mut names: Vec<String> = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.file_name().to_string_lossy().to_string())
.collect();
names.sort();
json!({"type": "entries", "names": names})
}
Err(error) => io_error(error),
},
_ => json!({"type": "error", "error": "unknown message"}),
};
reply(&mut out, &message, response);
if body["type"] == "ignore-stdin" {
loop {
std::thread::sleep(Duration::from_secs(60));
}
}
}
}
-555
View File
@@ -1,555 +0,0 @@
//! Integration tests for the agent's inference and restart behavior.
#[allow(dead_code)]
mod common;
use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Command;
use std::sync::OnceLock;
use std::time::Duration;
use common::{create_world, receive_message, receive_reply, request, source_with_test, start};
use sausage::{Envelope, Host, World};
use serde_json::{Value, json};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{mpsc, oneshot};
use tokio::task::{JoinHandle, JoinSet};
const APIS: [Api; 2] = [Api::OpenAi, Api::Anthropic];
const TEST_TOKEN: &str = "fake-token";
#[derive(Clone, Copy)]
enum Api {
OpenAi,
Anthropic,
}
impl Api {
fn name(self) -> &'static str {
match self {
Self::OpenAi => "openai-responses",
Self::Anthropic => "anthropic-messages",
}
}
fn path(self) -> &'static str {
match self {
Self::OpenAi => "/v1/responses",
Self::Anthropic => "/v1/messages",
}
}
fn text_response(self, text: &str) -> Value {
match self {
Self::OpenAi => {
json!({"output": [{"type": "message", "content": [{"type": "output_text", "text": text}]}]})
}
Self::Anthropic => json!({"content": [{"type": "text", "text": text}]}),
}
}
fn tool_response(self, id: &str, name: &str, arguments: &Value) -> Value {
match self {
Self::OpenAi => {
json!({"output": [{"type": "function_call", "call_id": id, "name": name, "arguments": arguments.to_string()}]})
}
Self::Anthropic => {
json!({"content": [{"type": "tool_use", "id": id, "name": name, "input": arguments}]})
}
}
}
fn check_request(self, request: &HttpRequest) {
assert_eq!(request.method, "POST");
assert_eq!(request.path, self.path());
assert_eq!(request.headers["content-type"], "application/json");
assert_eq!(request.body["model"], "fake-model");
match self {
Self::OpenAi => assert_eq!(request.headers["authorization"], "Bearer fake-token"),
Self::Anthropic => {
assert_eq!(request.headers["x-api-key"], TEST_TOKEN);
assert_eq!(request.headers["anthropic-version"], "2023-06-01");
}
}
}
}
struct HttpRequest {
method: String,
path: String,
headers: HashMap<String, String>,
body: Value,
}
enum ReplyPlan {
Send(Value),
Hold(Value, oneshot::Receiver<()>),
}
struct FakeServer {
base_url: String,
plans: mpsc::Sender<ReplyPlan>,
requests: mpsc::Receiver<HttpRequest>,
task: JoinHandle<()>,
}
impl FakeServer {
async fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let (plans, mut plan_rx) = mpsc::channel(16);
let (request_tx, requests) = mpsc::channel(16);
let task = tokio::spawn(async move {
let mut handlers = JoinSet::new();
loop {
tokio::select! {
accepted = listener.accept() => {
let (mut stream, _) = accepted.expect("fake listener accept failed");
let request = read_http_request(&mut stream).await;
let Some(plan) = plan_rx.recv().await else { break };
if request_tx.send(request).await.is_err() {
break;
}
handlers.spawn(async move {
let (body, release) = match plan {
ReplyPlan::Send(body) => (body, None),
ReplyPlan::Hold(body, release) => (body, Some(release)),
};
if let Some(release) = release {
let _ = release.await;
}
write_http_response(stream, body).await;
});
}
Some(_) = handlers.join_next(), if !handlers.is_empty() => {}
}
}
});
Self {
base_url: format!("http://127.0.0.1:{}", address.port()),
plans,
requests,
task,
}
}
async fn answer(&self, body: Value) {
self.plans.send(ReplyPlan::Send(body)).await.unwrap();
}
async fn hold(&self, body: Value) -> oneshot::Sender<()> {
let (release, wait) = oneshot::channel();
self.plans.send(ReplyPlan::Hold(body, wait)).await.unwrap();
release
}
async fn next_request(&mut self) -> HttpRequest {
tokio::time::timeout(Duration::from_secs(20), self.requests.recv())
.await
.expect("timed out waiting for fake inference request")
.expect("fake server request channel closed")
}
}
impl Drop for FakeServer {
fn drop(&mut self) {
self.task.abort();
}
}
async fn read_http_request(stream: &mut TcpStream) -> HttpRequest {
let mut received = Vec::new();
let mut chunk = [0; 4096];
let header_end = loop {
let count = stream.read(&mut chunk).await.expect("read HTTP request");
assert_ne!(count, 0, "client closed before HTTP headers arrived");
received.extend_from_slice(&chunk[..count]);
if let Some(end) = received.windows(4).position(|part| part == b"\r\n\r\n") {
break end;
}
};
let header_text = String::from_utf8_lossy(&received[..header_end]);
let mut lines = header_text.split("\r\n");
let mut request_line = lines.next().unwrap().split_whitespace();
let method = request_line.next().unwrap().to_string();
let path = request_line.next().unwrap().to_string();
let mut headers = HashMap::new();
for line in lines {
if let Some((name, value)) = line.split_once(':') {
headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string());
}
}
let body_start = header_end + 4;
let request_body = if let Some(length) = headers.get("content-length") {
let body_length = length.parse::<usize>().unwrap();
while received.len() < body_start + body_length {
let count = stream.read(&mut chunk).await.expect("read HTTP body");
assert_ne!(count, 0, "client closed before HTTP body arrived");
received.extend_from_slice(&chunk[..count]);
}
received[body_start..body_start + body_length].to_vec()
} else {
assert!(
headers
.get("transfer-encoding")
.is_some_and(|value| value.eq_ignore_ascii_case("chunked")),
"request has neither Content-Length nor chunked transfer encoding: {headers:?}"
);
read_chunked_body(stream, &mut received, body_start).await
};
let body = serde_json::from_slice(&request_body).unwrap();
HttpRequest {
method,
path,
headers,
body,
}
}
async fn read_chunked_body(
stream: &mut TcpStream,
received: &mut Vec<u8>,
mut offset: usize,
) -> Vec<u8> {
let mut body = Vec::new();
loop {
let line_end = loop {
if let Some(end) = received[offset..]
.windows(2)
.position(|part| part == b"\r\n")
{
break offset + end;
}
let mut chunk = [0; 4096];
let count = stream.read(&mut chunk).await.expect("read chunk size");
assert_ne!(count, 0, "client closed before chunk size arrived");
received.extend_from_slice(&chunk[..count]);
};
let size = String::from_utf8_lossy(&received[offset..line_end]);
let size = size.split(';').next().unwrap().trim();
let size = usize::from_str_radix(size, 16).unwrap();
offset = line_end + 2;
if size == 0 {
return body;
}
while received.len() < offset + size + 2 {
let mut chunk = [0; 4096];
let count = stream.read(&mut chunk).await.expect("read chunk data");
assert_ne!(count, 0, "client closed before chunk data arrived");
received.extend_from_slice(&chunk[..count]);
}
body.extend_from_slice(&received[offset..offset + size]);
assert_eq!(&received[offset + size..offset + size + 2], b"\r\n");
offset += size + 2;
}
}
async fn write_http_response(mut stream: TcpStream, body: Value) {
let body = serde_json::to_vec(&body).unwrap();
let head = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
body.len()
);
let mut response = head.into_bytes();
response.extend_from_slice(&body);
let _ = stream.write_all(&response).await;
let _ = stream.shutdown().await;
}
fn agent_wasm() -> &'static PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| {
let root = common::repo_root();
let output = Command::new("cargo")
.args([
"build",
"--release",
"-p",
"agent",
"--target",
"wasm32-wasip2",
"--target-dir",
"target/test-actors",
])
.current_dir(&root)
.output()
.expect("run cargo to build the agent actor");
assert!(
output.status.success(),
"building agent failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let wasm = root.join("target/test-actors/wasm32-wasip2/release/agent.wasm");
assert!(
wasm.is_file(),
"agent build did not create {}",
wasm.display()
);
wasm
})
}
async fn start_agent(
api: Api,
fake: &FakeServer,
label: &str,
) -> (World, Host, mpsc::Receiver<Envelope>) {
let world = create_world(label);
let data = world.data_dir("agent");
std::fs::create_dir_all(&data).unwrap();
let config = json!({"api": api.name(), "base_url": fake.base_url, "model": "fake-model", "token": TEST_TOKEN});
std::fs::write(
data.join("config.json"),
serde_json::to_vec(&config).unwrap(),
)
.unwrap();
let source = source_with_test(
&world,
"agent-candidate",
json!({"steps": [{"send": {"type": "text", "text": "candidate"}, "expect": {"type": "text"}}]}),
);
let (host, mut mailbox) = start(&world).await;
let spawned = common::spawn_actor(&host, &mut mailbox, "agent", agent_wasm(), &source).await;
assert_eq!(
spawned.body["type"], "spawned",
"agent spawn failed: {spawned:?}"
);
let subscribed = request(
&host,
&mut mailbox,
json!({"type": "subscribe", "actor": "agent"}),
)
.await;
assert_eq!(subscribed.body["type"], "subscribed");
(world, host, mailbox)
}
fn send_text(host: &Host, text: &str) -> String {
host.broker.send(
"console",
Some("agent"),
None,
None,
json!({"type": "text", "text": text}),
)
}
async fn assert_published_text(mailbox: &mut mpsc::Receiver<Envelope>, text: &str) {
let message = receive_message(mailbox, Duration::from_secs(20)).await;
assert_eq!(message.from, "agent");
assert_eq!(message.to, "console");
assert_eq!(message.reply_to, None);
assert_eq!(message.body, json!({"type": "text", "text": text}));
}
fn history(world: &World) -> Vec<Value> {
std::fs::read_to_string(world.data_dir("agent").join("history.jsonl"))
.unwrap()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect()
}
fn assert_history_tools_have_results(history: &[Value]) {
let calls: Vec<_> = history
.iter()
.filter(|entry| entry["kind"] == "tool_call")
.collect();
let results: Vec<_> = history
.iter()
.filter(|entry| entry["kind"] == "tool_result")
.collect();
assert_eq!(calls.len(), results.len());
for call in calls {
assert!(
results.iter().any(|result| result["id"] == call["id"]),
"tool call has no result: {call}"
);
}
}
fn assert_tool_pair(
request: &HttpRequest,
api: Api,
id: &str,
name: &str,
arguments: &Value,
) -> String {
match api {
Api::OpenAi => {
let input = request.body["input"].as_array().unwrap();
let call_index = input
.iter()
.position(|item| item["type"] == "function_call")
.unwrap();
let result_index = input
.iter()
.position(|item| item["type"] == "function_call_output")
.unwrap();
assert!(call_index < result_index);
let call = &input[call_index];
assert_eq!(call["call_id"], id);
assert_eq!(call["name"], name);
assert_eq!(
serde_json::from_str::<Value>(call["arguments"].as_str().unwrap()).unwrap(),
*arguments
);
let result = &input[result_index];
assert_eq!(result["call_id"], id);
result["output"].as_str().unwrap().to_string()
}
Api::Anthropic => {
let messages = request.body["messages"].as_array().unwrap();
let mut call_position = None;
let mut result_position = None;
let mut call = None;
let mut result = None;
for (index, message) in messages.iter().enumerate() {
let Some(blocks) = message["content"].as_array() else {
continue;
};
for block in blocks {
match block["type"].as_str() {
Some("tool_use") => {
call_position = Some(index);
call = Some(block);
}
Some("tool_result") => {
result_position = Some(index);
result = Some(block);
}
_ => {}
}
}
}
assert!(call_position.unwrap() < result_position.unwrap());
let call = call.unwrap();
assert_eq!(call["id"], id);
assert_eq!(call["name"], name);
assert_eq!(call["input"], *arguments);
let result = result.unwrap();
assert_eq!(result["tool_use_id"], id);
result["content"].as_str().unwrap().to_string()
}
}
}
#[tokio::test]
async fn text_answers_use_both_inference_apis() {
for api in APIS {
let mut fake = FakeServer::start().await;
fake.answer(api.text_response("hello from the fake model"))
.await;
let (_, host, mut mailbox) =
start_agent(api, &fake, &format!("agent-text-{}", api.name())).await;
send_text(&host, "say hello");
api.check_request(&fake.next_request().await);
assert_published_text(&mut mailbox, "hello from the fake model").await;
host.stop().await;
}
}
#[tokio::test]
async fn tool_calls_pair_with_results_in_both_inference_apis() {
let args = json!({"path": "/actor"});
for api in APIS {
let mut fake = FakeServer::start().await;
fake.answer(api.tool_response("list-1", "list_dir", &args))
.await;
let (world, host, mut mailbox) =
start_agent(api, &fake, &format!("agent-tools-{}", api.name())).await;
send_text(&host, "list actors");
api.check_request(&fake.next_request().await);
fake.answer(api.text_response("the actor list is ready"))
.await;
let second = fake.next_request().await;
api.check_request(&second);
let output = assert_tool_pair(&second, api, "list-1", "list_dir", &args);
let result: Value = serde_json::from_str(&output).unwrap();
assert!(
result["entries"]
.as_array()
.unwrap()
.iter()
.any(|entry| entry["name"] == "agent")
);
assert_published_text(&mut mailbox, "the actor list is ready").await;
host.stop().await;
let history = history(&world);
assert_eq!(
history
.iter()
.map(|entry| entry["kind"].as_str().unwrap())
.collect::<Vec<_>>(),
["user", "tool_call", "tool_result", "assistant"]
);
assert_eq!(history[1]["id"], "list-1");
assert_eq!(history[2]["id"], "list-1");
}
}
#[tokio::test]
async fn restart_resumes_a_turn_with_completed_tool_history() {
let args = json!({"path": "/actor"});
for api in [Api::OpenAi] {
let mut fake = FakeServer::start().await;
fake.answer(api.tool_response("list-1", "list_dir", &args))
.await;
let (world, host, mut mailbox) =
start_agent(api, &fake, &format!("agent-resume-{}", api.name())).await;
let id = send_text(&host, "list actors before the restart");
api.check_request(&fake.next_request().await);
let release = fake.hold(api.text_response("discarded response")).await;
let second = fake.next_request().await;
api.check_request(&second);
assert_tool_pair(&second, api, "list-1", "list_dir", &args);
let stopped = request(
&host,
&mut mailbox,
json!({"type": "stop", "actor": "agent"}),
)
.await;
assert_eq!(stopped.body["type"], "stopped");
assert_eq!(
serde_json::from_slice::<Value>(
&std::fs::read(world.data_dir("agent").join("turn.json")).unwrap()
)
.unwrap(),
json!(true)
);
let _ = release.send(());
fake.answer(api.text_response("resumed answer")).await;
let start_id = host.broker.send(
"console",
Some("broker"),
None,
None,
json!({"type": "start", "actor": "agent"}),
);
let resumed = fake.next_request().await;
api.check_request(&resumed);
assert_tool_pair(&resumed, api, "list-1", "list_dir", &args);
let started = receive_reply(&mut mailbox, &start_id, Duration::from_secs(20)).await;
assert_eq!(started.body["type"], "started");
assert_published_text(&mut mailbox, "resumed answer").await;
let history = history(&world);
assert_history_tools_have_results(&history);
assert_eq!(
history
.iter()
.filter(|entry| entry["kind"] == "user"
&& entry["text"].as_str().unwrap_or_default().contains(&id))
.count(),
1
);
host.stop().await;
}
}
-41
View File
@@ -1,41 +0,0 @@
//! Tests of the `sausage` command line.
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::time::{SystemTime, UNIX_EPOCH};
fn empty_dir(label: &str) -> PathBuf {
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("target/test-cli")
.join(format!("{label}-{}-{nonce}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Bare `sausage` (as in `nix run <flake>`) without a world starts `init`
/// for ./world instead of printing a usage error.
#[test]
fn bare_command_without_a_world_starts_init() {
let dir = empty_dir("bare");
let output = Command::new(env!("CARGO_BIN_EXE_sausage"))
.current_dir(&dir)
.env_remove("SAUSAGE_API")
.env_remove("SAUSAGE_BASE_URL")
.env_remove("SAUSAGE_MODEL")
.env_remove("SAUSAGE_API_TOKEN")
.stdin(Stdio::null())
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// With no answers on stdin, init stops at the first required value.
assert!(!output.status.success(), "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(stdout.contains("No world in ./world yet"), "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(stderr.contains("SAUSAGE_BASE_URL"), "stdout:\n{stdout}\nstderr:\n{stderr}");
assert!(!stderr.contains("Usage:"), "stderr:\n{stderr}");
// It asks before it creates anything.
assert!(!dir.join("world").exists());
}
-248
View File
@@ -1,248 +0,0 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use sausage::{Envelope, Host, World};
use serde_json::{Value, json};
use tokio::sync::mpsc;
const REPLY_TIMEOUT: Duration = Duration::from_secs(10);
static WORLD_SEQUENCE: AtomicU64 = AtomicU64::new(0);
pub fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf()
}
pub fn create_world(label: &str) -> World {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let sequence = WORLD_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = repo_root()
.join("target/test-worlds")
.join(format!("{label}-{}-{nonce}-{sequence}", std::process::id()));
World::create(&path).unwrap()
}
pub fn actor_fixture_source(name: &str) -> PathBuf {
repo_root().join("cli/tests/actors").join(name)
}
pub fn cargo_source(name: &str) -> PathBuf {
repo_root().join("cli/tests/crates").join(name)
}
pub fn toolbox_wasm() -> &'static PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| build_fixture("toolbox"))
}
fn build_fixture(name: &str) -> PathBuf {
let root = repo_root();
let manifest = actor_fixture_source(name).join("Cargo.toml");
let target_dir = root.join("target/test-actors");
let output = Command::new("cargo")
.args([
"build",
"--release",
"--target",
"wasm32-wasip2",
"--target-dir",
])
.arg(&target_dir)
.arg("--manifest-path")
.arg(&manifest)
.current_dir(&root)
.output()
.expect("run cargo to build the test actor");
assert!(
output.status.success(),
"building fixture {name} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let wasm = target_dir
.join("wasm32-wasip2/release")
.join(format!("{name}.wasm"));
assert!(
wasm.is_file(),
"fixture build did not create {}",
wasm.display()
);
wasm
}
pub fn passing_source(world: &World, name: &str) -> PathBuf {
source_with_test(
world,
name,
json!({
"steps": [{
"send": {"type": "echo", "value": "candidate"},
"expect": {"type": "echo", "value": "candidate"}
}]
}),
)
}
pub fn source_with_test(world: &World, name: &str, test: Value) -> PathBuf {
let source = world.source_dir("console").join("test-fixtures").join(name);
std::fs::create_dir_all(source.join("tests")).unwrap();
std::fs::write(
source.join("tests/candidate.json"),
serde_json::to_vec(&test).unwrap(),
)
.unwrap();
source
}
pub async fn start(world: &World) -> (Host, mpsc::Receiver<Envelope>) {
let host = Host::start(&world.root).await.unwrap();
let mailbox = host.mailbox("console");
(host, mailbox)
}
pub async fn request(host: &Host, mailbox: &mut mpsc::Receiver<Envelope>, body: Value) -> Envelope {
let id = host
.broker
.send("console", Some("broker"), None, None, body);
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
}
pub async fn receive_message(
mailbox: &mut mpsc::Receiver<Envelope>,
timeout: Duration,
) -> Envelope {
tokio::time::timeout(timeout, mailbox.recv())
.await
.expect("timed out waiting for a message")
.expect("console mailbox closed")
}
pub async fn receive_reply(
mailbox: &mut mpsc::Receiver<Envelope>,
id: &str,
timeout: Duration,
) -> Envelope {
tokio::time::timeout(timeout, async {
loop {
let envelope = mailbox.recv().await.expect("console mailbox closed");
if envelope.reply_to.as_deref() == Some(id) {
return envelope;
}
}
})
.await
.unwrap_or_else(|_| panic!("timed out waiting for reply to {id}"))
}
pub async fn receive_replies(
mailbox: &mut mpsc::Receiver<Envelope>,
ids: &[String],
timeout: Duration,
) -> Vec<Envelope> {
let mut replies = Vec::with_capacity(ids.len());
tokio::time::timeout(timeout, async {
while replies.len() < ids.len() {
let message = mailbox.recv().await.expect("console mailbox closed");
assert!(
ids.iter()
.any(|id| message.reply_to.as_deref() == Some(id.as_str())),
"unexpected message while waiting for replies: {message:?}"
);
replies.push(message);
}
})
.await
.expect("timed out waiting for replies");
replies
}
pub async fn ask(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
body: Value,
) -> Envelope {
let id = host.broker.send("console", Some(actor), None, None, body);
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
}
pub async fn ask_type(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
body: Value,
expected: &str,
) -> Envelope {
let reply = ask(host, mailbox, actor, body).await;
assert_eq!(reply.body["type"], expected, "unexpected reply: {reply:?}");
reply
}
pub fn spawn_request(host: &Host, actor: &str, wasm: &Path, source: &Path) -> String {
host.broker.send(
"console",
Some("broker"),
None,
None,
json!({
"type": "spawn",
"actor": actor,
"wasm": wasm.display().to_string(),
"source": source.display().to_string()
}),
)
}
pub async fn spawn_actor(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
wasm: &Path,
source: &Path,
) -> Envelope {
let id = spawn_request(host, actor, wasm, source);
receive_reply(mailbox, &id, REPLY_TIMEOUT).await
}
pub async fn spawn_type(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
wasm: &Path,
source: &Path,
) -> Envelope {
let reply = spawn_actor(host, mailbox, actor, wasm, source).await;
assert_eq!(
reply.body["type"], "spawned",
"unexpected spawn reply: {reply:?}"
);
reply
}
pub async fn upgrade_actor(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
wasm: &Path,
source: &Path,
) -> Envelope {
request(
host,
mailbox,
json!({
"type": "upgrade",
"actor": actor,
"wasm": wasm.display().to_string(),
"source": source.display().to_string()
}),
)
.await
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "cargo-c"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-plugin = "0.1"
[build-dependencies]
cc = "1"
[workspace]
+5
View File
@@ -0,0 +1,5 @@
// Compile C with the `cc` crate: natively for the unit test, and with the
// WASI clang (`CC_wasm32_wasip2`) for the actor.
fn main() {
cc::Build::new().file("src/add.c").compile("add");
}
+1
View File
@@ -0,0 +1 @@
int c_add(int a, int b) { return a + b; }
+25
View File
@@ -0,0 +1,25 @@
//! SDK plugin linked to a WASI C function.
unsafe extern "C" {
fn c_add(a: i32, b: i32) -> i32;
}
fn add(a: i32, b: i32) -> i32 {
unsafe { c_add(a, b) }
}
struct Plugin;
#[sausage_plugin::rpc]
impl Plugin {
/// Add two fixed numbers through C.
fn sum(&self) -> sausage_plugin::Result<i32> {
Ok(add(2, 3))
}
}
fn main() {
sausage_plugin::run(Plugin);
}
#[cfg(test)]
mod tests {
#[test]
fn c_code_runs_under_wasi() {
assert_eq!(super::add(2, 3), 5);
}
}
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "cargo-hang"
version = "0.1.0"
edition = "2024"
[workspace]
+7
View File
@@ -0,0 +1,7 @@
//! A build script descendant that stays alive until the native call ends.
fn main() {
std::process::Command::new("sh")
.args(["-c", "echo $$ > child.pid; exec sleep 1000"])
.status()
.unwrap();
}
+1
View File
@@ -0,0 +1 @@
fn main() {}
+7 -3
View File
@@ -1,9 +1,13 @@
//! A panic must not prevent the runner from executing another test.
fn main() {}
#[cfg(test)]
mod tests {
#[test]
fn deliberate_unit_test_failure() {
assert_eq!(2 + 2, 5);
fn a_panics() {
panic!("deliberate WASI panic");
}
#[test]
fn z_survives() {
assert_eq!(2 + 2, 4);
}
}
+10 -1
View File
@@ -1,6 +1,15 @@
[package]
name = "cargo-valid"
version = "0.1.0"
edition = "2024"
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"
+4
View File
@@ -0,0 +1,4 @@
//! Produces a warning so the build response can retain diagnostics.
fn main() {
println!("cargo:warning=fixture build warning");
}
+1
View File
@@ -0,0 +1 @@
fixture
+159 -3
View File
@@ -1,9 +1,165 @@
fn main() {}
//! 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 unit_tests_run_on_the_host_target() {
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);
}
}
@@ -0,0 +1,5 @@
//! Checks that cargo invokes integration test binaries through the WASI runner.
#[test]
fn integration_case() {
println!("INTEGRATION_CASE");
}
-1150
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+669
View File
@@ -0,0 +1,669 @@
//! Run the agent in the real core against scripted OpenAI and Anthropic HTTP servers.
use bytes::Bytes;
use http_body_util::{BodyExt, Full};
use hyper::{Response, server::conn::http1, service::service_fn};
use hyper_util::rt::TokioIo;
use sausage::{Host, world::World};
use sausage_vfs::Layer;
use serde_json::{Value, json};
use std::{convert::Infallible, path::PathBuf, sync::OnceLock, time::Duration};
use tokio::{
net::TcpListener,
sync::{mpsc, oneshot},
};
/// The two HTTP contracts supported by the agent.
const APIS: [&str; 3] = [
"openai-responses",
"anthropic-messages",
"openai-chat-completions",
];
/// A captured request and the script's delayed HTTP response.
type Exchange = (String, Value, oneshot::Sender<(u16, Value)>);
/// The script controls each response, including concurrent requests.
pub(super) struct Llm {
pub(super) url: String,
api: &'static str,
requests: mpsc::UnboundedReceiver<Exchange>,
task: tokio::task::JoinHandle<()>,
}
impl Llm {
pub(super) async fn new(api: &'static str) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let (tx, requests) = mpsc::unbounded_channel::<Exchange>();
let task = tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let tx = tx.clone();
tokio::spawn(async move {
let service =
service_fn(move |request: hyper::Request<hyper::body::Incoming>| {
let tx = tx.clone();
async move {
assert_eq!(request.method(), "POST");
// The local Chat Completions fixture explicitly disables authentication.
if api == APIS[2] {
assert!(!request.headers().contains_key("authorization"));
}
let path = request.uri().path().to_string();
let bytes = request.into_body().collect().await.unwrap().to_bytes();
let body = serde_json::from_slice(&bytes).unwrap();
let (reply, answer) = oneshot::channel();
tx.send((path, body, reply)).unwrap();
let (status, body) = answer.await.unwrap();
Ok::<_, Infallible>(
Response::builder()
.status(status)
.header("content-type", "application/json")
.body(Full::new(Bytes::from(body.to_string())))
.unwrap(),
)
}
});
http1::Builder::new()
.serve_connection(TokioIo::new(stream), service)
.await
.unwrap();
});
}
});
Self {
url,
api,
requests,
task,
}
}
pub(super) async fn next(&mut self) -> (Value, oneshot::Sender<(u16, Value)>) {
let (path, body, reply) =
tokio::time::timeout(Duration::from_secs(60), self.requests.recv())
.await
.unwrap()
.unwrap();
assert_eq!(
path,
if self.api == APIS[0] {
"/v1/responses"
} else if self.api == APIS[2] {
"/v1/chat/completions"
} else {
"/v1/messages"
}
);
assert_eq!(body["model"], "scripted-test");
(body, reply)
}
pub(super) fn answer(&self, text: &str, calls: &[(&str, Value)]) -> Value {
if self.api == APIS[2] {
let calls: Vec<Value> = calls.iter().enumerate().map(|(n,(name,args))| json!({"id":format!("tool-{n}"),"type":"function","function":{"name":name,"arguments":args.to_string()}})).collect();
json!({"choices":[{"message":{"role":"assistant","content":text,"tool_calls":calls}}]})
} else if self.api == APIS[0] {
let mut output =
vec![json!({"type":"message","content":[{"type":"output_text","text":text}]})];
for (n, (name, args)) in calls.iter().enumerate() {
output.push(json!({"type":"function_call","call_id":format!("tool-{n}"),"name":name,"arguments":args.to_string()}));
}
json!({"status":"completed","output":output})
} else {
let mut content = vec![json!({"type":"text","text":text})];
for (n, (name, args)) in calls.iter().enumerate() {
content.push(
json!({"type":"tool_use","id":format!("tool-{n}"),"name":name,"input":args}),
);
}
json!({"content":content,"stop_reason":if calls.is_empty(){"end_turn"}else{"tool_use"}})
}
}
}
impl Drop for Llm {
fn drop(&mut self) {
self.task.abort();
}
}
fn fixtures() -> &'static PathBuf {
static TARGET: OnceLock<PathBuf> = OnceLock::new();
TARGET.get_or_init(|| {
super::common::build("agent", "agent");
super::common::sdk_probe().parent().unwrap().to_owned()
})
}
struct TestWorld(PathBuf);
impl TestWorld {
fn new(server: &Llm) -> Self {
let path = std::env::temp_dir().join(format!("sausage-agent-{}", uuid::Uuid::now_v7()));
let world = World::create(&path).unwrap();
for (plugin, binary) in [("agent", "agent"), ("probe", "sdk-probe")] {
std::fs::create_dir_all(world.plugin_dir(plugin)).unwrap();
std::fs::copy(
fixtures().join(format!("{binary}.wasm")),
world.plugin_dir(plugin).join("plugin.wasm"),
)
.unwrap();
}
std::fs::write(path.join("config/agent.yaml"), serde_yaml_ng::to_string(&json!({"api":server.api,"base_url":if server.api == APIS[1] {server.url.clone()} else {format!("{}/v1",server.url)},"model":"scripted-test","token":if server.api == APIS[2] {""} else {"fake-token"},"allow_no_token":server.api == APIS[2]})).unwrap()).unwrap();
Self(path)
}
async fn start(&self) -> Host {
Host::start_with_cache(
&self.0,
std::path::Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/cache")),
)
.await
.unwrap()
}
fn history(&self) -> String {
std::fs::read_to_string(self.0.join("state/agent/history.jsonl")).unwrap()
}
}
impl Drop for TestWorld {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn chat(host: &Host, text: &str) -> tokio::task::JoinHandle<Value> {
let host = host.clone();
let text = text.to_owned();
tokio::spawn(async move {
tokio::time::timeout(
Duration::from_secs(90),
host.call("agent.chat", json!({"text":text})),
)
.await
.unwrap()
.unwrap()
})
}
fn input(body: &Value, api: &str) -> Value {
body[if api == APIS[0] { "input" } else { "messages" }].clone()
}
fn outputs(body: &Value, api: &str) -> Vec<Value> {
if api == APIS[2] {
body["messages"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["role"] == "tool")
.map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap())
.collect()
} else if api == APIS[0] {
body["input"]
.as_array()
.unwrap()
.iter()
.filter(|e| e["type"] == "function_call_output")
.map(|e| serde_json::from_str(e["output"].as_str().unwrap()).unwrap())
.collect()
} else {
body["messages"]
.as_array()
.unwrap()
.iter()
.flat_map(|e| e["content"].as_array().unwrap())
.filter(|e| e["type"] == "tool_result")
.map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap())
.collect()
}
}
/// Invalid configuration names the file and the field before any inference request.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unconfigured_chat_names_the_configuration() {
let server = Llm::new(APIS[0]).await;
let world = TestWorld::new(&server);
let path = world.0.join("config/agent.yaml");
std::fs::remove_file(&path).unwrap();
let host = world.start().await;
let error = host
.call("agent.chat", json!({"text":"hello"}))
.await
.unwrap_err();
assert!(
error["message"]
.as_str()
.unwrap()
.contains("/config/agent.yaml")
);
for (config, field) in [
("", "api"),
("api: unknown", "api"),
("api: openai-responses", "base_url"),
(
"api: openai-responses\nbase_url: http://localhost/v1",
"model",
),
(
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: ''",
"token",
),
(
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: CHANGE_ME",
"token",
),
(
"api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: key\nthinking: hihg",
"thinking",
),
] {
std::fs::write(&path, config).unwrap();
let error = host
.call("agent.chat", json!({"text":"hello"}))
.await
.unwrap_err();
let message = error["message"].as_str().unwrap();
assert!(message.contains("/config/agent.yaml"), "{message}");
assert!(message.contains(field), "{message}");
}
host.stop().await;
}
/// All API shapes return final text and retain the complete history after a core restart.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn final_answers_and_history_survive_restart() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let turn = chat(&host, "first request alpha");
let (body, reply) = server.next().await;
assert!(
input(&body, api)
.to_string()
.contains("first request alpha")
);
assert_eq!(body["tools"].as_array().unwrap().len(), 5);
reply
.send((200, server.answer("first answer alpha", &[])))
.unwrap();
assert_eq!(turn.await.unwrap(), "first answer alpha");
host.stop().await;
let prefix = world.history();
drop(host);
let host = world.start().await;
let turn = chat(&host, "second request beta");
let (body, reply) = server.next().await;
let text = input(&body, api).to_string();
for expected in [
"first request alpha",
"first answer alpha",
"second request beta",
] {
assert!(text.contains(expected));
}
reply
.send((200, server.answer("second answer beta", &[])))
.unwrap();
assert_eq!(turn.await.unwrap(), "second answer beta");
assert!(world.history().starts_with(&prefix));
let entries: Vec<Value> = world
.history()
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
assert_eq!(
entries
.iter()
.map(|e| e["kind"].as_str().unwrap())
.collect::<Vec<_>>(),
["user", "assistant", "user", "assistant"]
);
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
assert_eq!(doc["x-sausage-grace"], 600);
host.stop().await;
}
}
/// Both API shapes execute every tool, return tool errors, and deliver interim text.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn tools_and_interim_notifications() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let mut events = host.subscribe();
let turn = chat(&host, "exercise the tools");
let (_, reply) = server.next().await;
let calls = [
(
"call",
json!({"method":"probe.add","params":{"a":4,"b":5},"detach":true}),
),
("read_file", json!({"path":"/proc/core/PROTOCOL.md"})),
(
"write_file",
json!({"path":"/state/work/source/note.txt","content":"saved"}),
),
("list_dir", json!({"path":"/state/work/source"})),
(
"copy_dir",
json!({"from":"/state/work/source","to":"/state/work/copied"}),
),
("read_file", json!({"path":"/state/work/missing"})),
("call", json!({"method":"absent.run","params":{}})),
("call", json!({"method":"probe.fail","params":{}})),
];
reply
.send((200, server.answer("I will inspect the world.", &calls)))
.unwrap();
let (body, reply) = server.next().await;
let results = outputs(&body, api);
assert_eq!(results.len(), 8);
assert_eq!(results[0], 9);
assert!(results[1]["content"].as_str().unwrap().contains("JSON-RPC"));
assert_eq!(results[2]["written"], "/state/work/source/note.txt");
assert_eq!(results[3]["entries"][0]["name"], "note.txt");
assert_eq!(results[4]["copied_files"], 1);
assert!(results[5]["error"]["message"].is_string());
assert!(results[6]["error"]["code"].is_number());
assert_eq!(
results[7]["error"],
json!({"code":-32010,"message":"deliberate","data":{"diagnostic":"probe error details"}})
);
assert_eq!(
std::fs::read_to_string(world.0.join("state/work/copied/note.txt")).unwrap(),
"saved"
);
let event = tokio::time::timeout(Duration::from_secs(5), events.recv())
.await
.unwrap()
.unwrap();
assert_eq!(event["params"]["name"], "text");
assert_eq!(
event["params"]["params"]["text"],
"I will inspect the world."
);
reply
.send((200, server.answer("The tools finished.", &[])))
.unwrap();
assert_eq!(turn.await.unwrap(), "The tools finished.");
host.stop().await;
}
}
/// The history lock holds the second chat until the complete first turn ends.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn concurrent_chats_serialize_complete_turns() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let first = chat(&host, "first concurrent request");
let (_, reply) = server.next().await;
let second = chat(&host, "second concurrent request");
let view = host.filesystem().await.unwrap();
tokio::time::timeout(Duration::from_secs(10), async {
loop {
let file = view
.open("/proc/core/locks.json", sausage_vfs::Open::default())
.await
.unwrap();
let locks: Value =
serde_json::from_slice(&file.read(0, 65536).await.unwrap()).unwrap();
if locks["/state/agent/history.jsonl"]["waiting"]
.as_array()
.is_some_and(|waiting| waiting.len() == 1)
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(server.requests.try_recv().is_err());
reply
.send((200, server.answer("first concurrent answer", &[])))
.unwrap();
assert_eq!(first.await.unwrap(), "first concurrent answer");
let (body, reply) = server.next().await;
let text = input(&body, api).to_string();
assert!(text.contains("first concurrent answer"));
assert!(text.contains("second concurrent request"));
reply
.send((200, server.answer("second concurrent answer", &[])))
.unwrap();
assert_eq!(second.await.unwrap(), "second concurrent answer");
host.stop().await;
}
}
/// Context-size failures discard old complete turns without changing the append-only history.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn context_window_advances_by_complete_turns() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let first = chat(&host, "old turn");
let (_, reply) = server.next().await;
reply.send((200, server.answer("old answer", &[]))).unwrap();
first.await.unwrap();
let second = chat(&host, "current turn");
let (_, reply) = server.next().await;
reply
.send((400, json!({"error":"context_length_exceeded"})))
.unwrap();
let (body, reply) = server.next().await;
let text = input(&body, api).to_string();
assert!(!text.contains("old turn"));
assert!(!text.contains("old answer"));
assert!(text.contains("current turn"));
reply
.send((200, server.answer("current answer", &[])))
.unwrap();
assert_eq!(second.await.unwrap(), "current answer");
assert_eq!(world.history().lines().count(), 4);
host.stop().await;
}
}
/// Oversized tool outputs are bounded, and failed retries do not commit a new history cursor.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn oversized_current_turn_preserves_the_previous_cursor() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
std::fs::write(world.0.join("large.txt"), "x".repeat(200_000)).unwrap();
let host = world.start().await;
let first = chat(&host, "old retained turn");
let (_, reply) = server.next().await;
reply
.send((200, server.answer("old retained answer", &[])))
.unwrap();
first.await.unwrap();
let skip = world.0.join("state/agent/history.skip");
std::fs::write(&skip, "0").unwrap();
let caller = host.clone();
let turn = tokio::spawn(async move {
caller
.call("agent.chat", json!({"text":"read a large tool result"}))
.await
});
let (_, reply) = server.next().await;
reply
.send((
200,
server.answer("", &[("read_file", json!({"path":"/large.txt"}))]),
))
.unwrap();
let (body, reply) = server.next().await;
let serialized = input(&body, api).to_string();
assert!(serialized.contains("Full output: /logs/agent/"));
let history = world.history();
let result: Value = history
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.find(|entry| {
entry["output"]
.as_str()
.is_some_and(|out| out.contains("Full output: "))
})
.unwrap();
let output = result["output"].as_str().unwrap();
let path = output
.split("Full output: ")
.nth(1)
.unwrap()
.split(". ")
.next()
.unwrap();
let archive = std::fs::read_to_string(world.0.join(path.trim_start_matches('/'))).unwrap();
assert!(archive.contains(&"x".repeat(200_000)));
assert!(output.len() <= 50 * 1024);
assert!(serialized.len() < 70_000);
reply
.send((413, json!({"type":"request_too_large"})))
.unwrap();
let (body, reply) = server.next().await;
assert!(!input(&body, api).to_string().contains("old retained turn"));
reply
.send((413, json!({"type":"request_too_large"})))
.unwrap();
assert!(turn.await.unwrap().is_err());
assert_eq!(std::fs::read_to_string(&skip).unwrap(), "0");
host.stop().await;
}
}
/// Provider-private items are replayed only for their original API and model.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn private_items_are_scoped_to_the_model() {
for api in [APIS[0], APIS[1]] {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
// Without a thinking level, newer models think by default, so their items return.
std::fs::create_dir_all(world.0.join("state/agent")).unwrap();
let mut entries = vec![json!({"kind":"user","text":"old turn"})];
for (model, marker) in [
("another-model", "old-private-marker"),
("scripted-test", "current-private-marker"),
] {
let item = if api == APIS[0] {
json!({"type":"reasoning","encrypted_content":marker})
} else {
json!({"type":"thinking","thinking":"private","signature":marker})
};
entries.push(json!({"kind":"opaque","api":api,"model":model,"item":item}));
}
entries.push(json!({"kind":"assistant","text":"old answer"}));
std::fs::write(
world.0.join("state/agent/history.jsonl"),
entries
.iter()
.map(|entry| format!("{entry}\n"))
.collect::<String>(),
)
.unwrap();
let host = world.start().await;
let turn = chat(&host, "next turn");
let (body, reply) = server.next().await;
let text = input(&body, api).to_string();
assert!(!text.contains("old-private-marker"));
assert!(text.contains("current-private-marker"));
reply.send((200, server.answer("done", &[]))).unwrap();
assert_eq!(turn.await.unwrap(), "done");
host.stop().await;
}
}
/// Each API gets the configured thinking level. A level in the call applies to that turn only.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn thinking_levels_reach_each_api() {
for api in APIS {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let config_path = world.0.join("config/agent.yaml");
let mut config: Value =
serde_yaml_ng::from_slice(&std::fs::read(&config_path).unwrap()).unwrap();
config["thinking"] = json!("high");
std::fs::write(config_path, serde_yaml_ng::to_string(&config).unwrap()).unwrap();
let host = world.start().await;
// Off is `none` for OpenAI, and disabled thinking without an effort for Anthropic.
let off = if api == APIS[1] { None } else { Some("none") };
for (thinking, effort) in [
(None, Some("high")),
(Some("low"), Some("low")),
(Some("off"), off),
(None, Some("high")),
] {
let mut params = json!({"text":"hello"});
if let Some(thinking) = thinking {
params["thinking"] = json!(thinking);
}
let caller = host.clone();
let turn =
tokio::spawn(async move { caller.call("agent.chat", params).await.unwrap() });
let (body, reply) = server.next().await;
let sent = if api == APIS[0] {
&body["reasoning"]["effort"]
} else if api == APIS[2] {
&body["reasoning_effort"]
} else {
let mode = if thinking == Some("off") {
"disabled"
} else {
"adaptive"
};
assert_eq!(body["thinking"]["type"], mode, "{body}");
&body["output_config"]["effort"]
};
assert_eq!(sent.as_str(), effort, "{api} {thinking:?}");
reply.send((200, server.answer("done", &[]))).unwrap();
assert_eq!(turn.await.unwrap(), "done");
}
let error = host
.call("agent.chat", json!({"text":"hello","thinking":"hihg"}))
.await
.unwrap_err();
assert_eq!(error["code"], -32602, "{error}");
host.stop().await;
}
}
/// Separate assistant messages retain a separator in the final answer.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn final_texts_have_a_separator() {
for api in [APIS[0], APIS[1]] {
let mut server = Llm::new(api).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let turn = chat(&host, "two paragraphs");
let (_, reply) = server.next().await;
let mut answer = server.answer("first", &[]);
if api == APIS[0] {
answer["output"]
.as_array_mut()
.unwrap()
.push(json!({"type":"message","content":[{"type":"output_text","text":"second"}]}));
} else {
answer["content"]
.as_array_mut()
.unwrap()
.push(json!({"type":"text","text":"second"}));
}
reply.send((200, answer)).unwrap();
assert_eq!(turn.await.unwrap(), "first\n\nsecond");
host.stop().await;
}
}
/// An incomplete response containing reasoning but no text is an error.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn incomplete_without_text_is_an_error() {
let mut server = Llm::new(APIS[0]).await;
let world = TestWorld::new(&server);
let host = world.start().await;
let caller = host.clone();
let turn =
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"answer"})).await });
let (_, reply) = server.next().await;
reply.send((200, json!({"status":"incomplete","output":[{"type":"reasoning","encrypted_content":"private"}]}))).unwrap();
assert!(
turn.await.unwrap().unwrap_err()["message"]
.as_str()
.unwrap()
.contains("incomplete response")
);
host.stop().await;
}
File diff suppressed because it is too large Load Diff
+450
View File
@@ -0,0 +1,450 @@
//! Binary contracts for atomic init, environment flags, signals, and the piped console.
use sausage::{Host, world::World};
use serde_json::{Value, json};
use std::{
path::{Path, PathBuf},
process::Stdio,
time::Duration,
};
use tokio::{
io::AsyncWriteExt,
process::{Child, Command},
};
use super::common::{CACHE, probe};
fn binary() -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_sausage"));
command
.arg("--cache-dir")
.arg(CACHE)
.env_remove("SAUSAGE_HEADLESS")
.env_remove("SAUSAGE_LOOP")
.kill_on_drop(true);
command
}
fn world() -> (tempfile::TempDir, PathBuf) {
let folder = tempfile::tempdir().unwrap();
let world = World::create(&folder.path().join("world")).unwrap();
(folder, world.root)
}
async fn status(child: &mut Child) -> std::process::ExitStatus {
tokio::time::timeout(Duration::from_secs(15), child.wait())
.await
.expect("CLI did not exit")
.unwrap()
}
async fn journal_exists(world: &Path) {
tokio::time::timeout(Duration::from_secs(15), async {
while !world.join("logs/core/journal.sqlite").exists() {
tokio::task::yield_now().await;
}
})
.await
.unwrap();
}
fn signal(child: &Child, signal: nix::sys::signal::Signal) {
nix::sys::signal::kill(
nix::unistd::Pid::from_raw(child.id().unwrap() as i32),
signal,
)
.unwrap();
}
fn install_probe(world: &Path) {
std::fs::create_dir_all(world.join("plugins/probe")).unwrap();
std::fs::copy(probe(), world.join("plugins/probe/plugin.wasm")).unwrap();
}
/// Failed init leaves no world; successful init seeds the restart policy and a working agent.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn init_builds_agent() {
tokio::time::timeout(Duration::from_secs(300), init_builds_agent_inner())
.await
.expect("init and seeded-agent checks exceeded 300 seconds");
}
async fn init_builds_agent_inner() {
let temporary = tempfile::tempdir().unwrap();
let world = temporary.path().join("world");
// Legacy configuration variables must not affect the templates.
let configured = || {
let mut command = binary();
command
.current_dir(temporary.path())
.env("SAUSAGE_API", "not-an-api")
.env("SAUSAGE_BASE_URL", "http://127.0.0.1:1/")
.env("SAUSAGE_MODEL", "init-test")
.env("SAUSAGE_API_TOKEN", "fake-token")
.stdin(Stdio::null());
command
};
let failed = configured()
.arg("init")
.env("SAUSAGE_HEADLESS", "not-a-boolean")
.env("RUSTC", "/nonexistent-sausage-rustc")
.output()
.await
.unwrap();
assert!(!failed.status.success());
assert!(String::from_utf8_lossy(&failed.stderr).contains("nonexistent-sausage-rustc"));
assert!(!world.exists());
let output = configured().arg("init").output().await.unwrap();
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
assert!(
String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes")
);
// Bare sausage reopens the completed world instead of trying to recreate it.
assert!(configured().output().await.unwrap().status.success());
let prompt = std::fs::read_to_string(world.join("plugins/agent/source/src/prompt.md")).unwrap();
assert!(prompt.contains("HARD RULE: the mediator and all agents NEVER call `core.restart`"));
assert!(prompt.contains("without the person's explicit approval each time"));
assert!(prompt.contains("alias `core.exit`"));
for text in [
"`#loop`",
"SAUSAGE_LOOP=1",
"answers before",
"exit code 75",
] {
assert!(prompt.contains(text), "seeded prompt missing {text}");
}
let boot = std::fs::read_to_string(world.join("config/boot.yaml")).unwrap();
assert!(boot.contains("SAUSAGE_LOOP=1"));
let mut config: Value =
serde_yaml_ng::from_slice(&std::fs::read(world.join("config/agent.yaml")).unwrap())
.unwrap();
assert_eq!(config["token"], "CHANGE_ME");
assert_eq!(config["api"], "openai-responses");
assert!(String::from_utf8_lossy(&output.stdout).contains("config/agent.yaml"));
assert_eq!(
serde_yaml_ng::from_slice::<Value>(&std::fs::read(world.join("config/boot.yaml")).unwrap())
.unwrap(),
json!([])
);
for file in [
"plugins/cargo/sdk/Cargo.toml",
"plugins/cargo/sdk/src/lib.rs",
"plugins/agent/source/Cargo.toml",
"plugins/agent/source/src/main.rs",
"plugins/agent/plugin.wasm",
] {
assert!(world.join(file).is_file(), "missing {file}");
}
assert_eq!(
std::fs::read(world.join("plugins/agent/source/Cargo.lock")).unwrap(),
include_bytes!("../../../Cargo.lock")
);
assert!(!world.join("state/cargo/target").exists());
assert!(
Path::new(CACHE)
.join("cargo-target/wasm32-wasip2/release")
.is_dir()
);
let host = Host::start_with_cache(&world, Path::new(CACHE))
.await
.unwrap();
let doc = host.call("agent.rpc.discover", json!({})).await.unwrap();
assert_eq!(doc["x-sausage-grace"], 600);
assert!(
doc["methods"]
.as_array()
.unwrap()
.iter()
.any(|method| method["name"] == "chat")
);
let error = host
.call("agent.chat", json!({"text":"hello"}))
.await
.unwrap_err();
assert!(
error["message"]
.as_str()
.unwrap()
.contains("/config/agent.yaml")
);
assert!(error["message"].as_str().unwrap().contains("token"));
let mut server = super::agent::Llm::new("anthropic-messages").await;
config["api"] = json!("anthropic-messages");
config["base_url"] = json!(server.url);
config["model"] = json!("scripted-test");
config["token"] = json!("fake-token");
std::fs::write(
world.join("config/agent.yaml"),
serde_yaml_ng::to_string(&config).unwrap(),
)
.unwrap();
let caller = host.clone();
let turn =
tokio::spawn(async move { caller.call("agent.chat", json!({"text":"hello"})).await });
let (_, reply) = server.next().await;
reply
.send((200, server.answer("configured agent", &[])))
.unwrap();
assert_eq!(turn.await.unwrap().unwrap(), "configured agent");
host.stop().await;
}
/// Both headless forms ignore EOF. False and empty values preserve the plain console.
#[tokio::test]
async fn headless_flag_and_environment() {
for value in [None, Some("1"), Some("true")] {
let (_folder, world) = world();
let mut command = binary();
command.arg("run").arg(&world).stdin(Stdio::null());
if let Some(value) = value {
command.env("SAUSAGE_HEADLESS", value);
} else {
command.arg("--headless");
}
let mut child = command.spawn().unwrap();
journal_exists(&world).await;
assert!(
tokio::time::timeout(Duration::from_millis(300), child.wait())
.await
.is_err()
);
signal(&child, nix::sys::signal::Signal::SIGTERM);
assert_eq!(status(&mut child).await.code(), Some(0));
}
for value in ["0", ""] {
let (_folder, world) = world();
let mut child = binary()
.arg("run")
.arg(world)
.env("SAUSAGE_HEADLESS", value)
.stdin(Stdio::null())
.spawn()
.unwrap();
assert_eq!(status(&mut child).await.code(), Some(0));
}
}
/// Signals interrupt an unanswered boot service instead of waiting forever.
#[tokio::test]
async fn signals_work_during_boot() {
for sig in [
nix::sys::signal::Signal::SIGTERM,
nix::sys::signal::Signal::SIGINT,
] {
let (_folder, world) = world();
std::fs::write(
world.join("config/boot.yaml"),
json!([
{"method":"core.lock","params":{"path":"boot"}},
{"method":"core.lock","params":{"path":"boot"}}
])
.to_string(),
)
.unwrap();
let mut child = binary()
.arg("run")
.arg(&world)
.arg("--headless")
.stdin(Stdio::null())
.spawn()
.unwrap();
journal_exists(&world).await;
tokio::time::timeout(Duration::from_secs(15), async {
loop {
let db =
rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
if let Ok(2) = db.query_row(
"SELECT count(*) FROM journal WHERE line='service start core.lock'",
[],
|row| row.get::<_, i64>(0),
) {
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
signal(&child, sig);
assert_eq!(status(&mut child).await.code(), Some(0));
}
}
/// Piped input supports commands, quit, and the restart exit code.
#[tokio::test]
async fn piped_console_and_exit_codes() {
for (line, supervised, expected) in [
("help\n", false, 0),
("quit\n", false, 0),
("core.restart\n", false, 0),
("core.exit\n", false, 0),
("core.restart\n", true, 75),
("core.exit\n", true, 75),
] {
let (_folder, world) = world();
let mut command = binary();
command.arg("run").arg(world);
if supervised {
command.env("SAUSAGE_LOOP", "1");
}
let mut child = command
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
// Supervised calls must finish via restart, not a racing console EOF.
let mut input = child.stdin.take().unwrap();
input.write_all(line.as_bytes()).await.unwrap();
if !supervised {
drop(input);
}
let output = tokio::time::timeout(Duration::from_secs(15), child.wait_with_output())
.await
.unwrap()
.unwrap();
assert_eq!(output.status.code(), Some(expected));
if !supervised && line.starts_with("core.") {
assert!(String::from_utf8_lossy(&output.stdout).contains("SAUSAGE_LOOP not set"));
}
if expected == 0 {
assert!(!output.stdout.is_empty());
}
}
}
/// A console input error still stops live actors and flushes the journal.
#[tokio::test]
async fn console_error_stops_host() {
let (_folder, world) = world();
install_probe(&world);
std::fs::write(
world.join("config/boot.yaml"),
json!([{"method":"probe.serve","params":{}}]).to_string(),
)
.unwrap();
let mut child = binary()
.arg("run")
.arg(&world)
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(&[255, 10])
.await
.unwrap();
assert_eq!(status(&mut child).await.code(), Some(1));
let db = rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
let stopped: i64 = db
.query_row(
"SELECT count(*) FROM journal WHERE plugin='probe' AND line='stop'",
[],
|row| row.get(0),
)
.unwrap();
assert!(stopped > 0);
}
/// SIGINT stops only the wait, so ps can still find the live call.
#[tokio::test]
async fn interrupt_leaves_call_running() {
let (_folder, world) = world();
install_probe(&world);
let mut child = binary()
.arg("run")
.arg(&world)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
input.write_all(b"probe.busy\n").await.unwrap();
let pid = tokio::time::timeout(Duration::from_secs(20), async {
loop {
if world.join("logs/core/journal.sqlite").exists() {
let db =
rusqlite::Connection::open(world.join("logs/core/journal.sqlite")).unwrap();
if let Ok(pid) = db.query_row(
"SELECT pid FROM journal WHERE plugin='probe' AND line='start busy {}'",
[],
|r| r.get::<_, String>(0),
) {
break pid;
}
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
signal(&child, nix::sys::signal::Signal::SIGINT);
input
.write_all(format!("ps\nkill {pid}\nquit\n").as_bytes())
.await
.unwrap();
drop(input);
let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output())
.await
.unwrap()
.unwrap();
let text = String::from_utf8(output.stdout).unwrap();
assert_eq!(output.status.code(), Some(0), "{text}");
assert!(
text.contains("stopped waiting; the call continues, see ps"),
"{text}"
);
assert!(
text.contains(&format!("{pid} probe busy console running")),
"{text}"
);
}
/// Piped calls wait for activation, and EOF or quit preserves the last result.
#[tokio::test]
async fn piped_console_runs_dependent_commands_in_order() {
for ending in ["", "quit\n"] {
let (_folder, world) = world();
std::fs::copy(probe(), world.join("candidate.wasm")).unwrap();
std::fs::create_dir_all(world.join("state/probe")).unwrap();
std::fs::write(world.join("state/probe/discover.json"), r#"{"methods":[]}"#).unwrap();
let activate = r#"core.activate {"plugin":"notes","wasm":"candidate.wasm","force":true}"#;
let call = r#"notes.echo {"ordered":true}"#;
let mut child = binary()
.arg("run")
.arg(&world)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
child
.stdin
.take()
.unwrap()
.write_all(format!("{activate}\n{call}\n{ending}").as_bytes())
.await
.unwrap();
let output = tokio::time::timeout(Duration::from_secs(20), child.wait_with_output())
.await
.unwrap()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert_eq!(
output.status.code(),
Some(0),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(!stdout.contains("error:"), "{stdout}");
assert!(stdout.contains(r#""ordered": true"#), "{stdout}");
assert!(!stdout.contains("> core.activate"), "{stdout}");
if !ending.is_empty() {
assert!(
stdout.find(r#""ordered": true"#).unwrap() < stdout.find("Goodbye.").unwrap(),
"{stdout}"
);
}
}
}
+43
View File
@@ -0,0 +1,43 @@
//! Persistent caches and guest builds shared by the host integration modules.
use std::{
path::{Path, PathBuf},
sync::OnceLock,
};
pub const CACHE: &str = concat!(env!("CARGO_TARGET_TMPDIR"), "/cache");
pub fn build(package: &str, binary: &str) -> PathBuf {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap();
let target = Path::new(concat!(env!("CARGO_TARGET_TMPDIR"), "/guests"));
assert!(
std::process::Command::new("cargo")
.args([
"build",
"--offline",
"--target",
"wasm32-wasip2",
"--manifest-path"
])
.arg(root.join(package).join("Cargo.toml"))
.arg("--target-dir")
.arg(target)
.status()
.unwrap()
.success()
);
target
.join("wasm32-wasip2/debug")
.join(format!("{binary}.wasm"))
}
pub fn probe() -> PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| build("cli/tests/plugins/probe", "probe"))
.clone()
}
pub fn sdk_probe() -> PathBuf {
static WASM: OnceLock<PathBuf> = OnceLock::new();
WASM.get_or_init(|| build("sdk/tests/plugins/probe", "sdk-probe"))
.clone()
}
+298
View File
@@ -0,0 +1,298 @@
//! 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
+9
View File
@@ -0,0 +1,9 @@
//! Host integration contracts run in one parallel libtest pool.
mod activation;
mod agent;
mod cargo;
mod cli;
mod common;
mod console;
mod core;
mod sdk;
+349
View File
@@ -0,0 +1,349 @@
//! 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;
}
/// 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,9 +1,9 @@
[package]
name = "toolbox"
name = "activation-guest"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
serde_json = "1"
[workspace]
+114
View File
@@ -0,0 +1,114 @@
//! 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})
}
"serve" | "hold" => {
send(json!({"jsonrpc":"2.0","id":request["id"],"result":{"version":version}}));
if request["method"] == "serve" {
std::fs::write(format!("/state/{plugin}/service"), version).unwrap();
}
for _ in lines {}
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,10 +1,8 @@
[package]
name = "notes"
name = "probe"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-actor = { version = "0.1", path = "../../sdk" }
serde_json = "1"
[workspace]
[dependencies]
serde_json = "1"
wasip2 = "1"
+520
View File
@@ -0,0 +1,520 @@
//! 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();
for _ in lines {}
std::fs::write("/state/stopped", "yes").unwrap();
}
fn busy() -> Value {
loop {
std::hint::spin_loop();
}
}
fn exit_without_answer() {
eprintln!("last line");
}
fn junk() -> Value {
io::stdout().write_all(&[255, 10]).unwrap();
println!("junk\n{{}}\n[]");
eprintln!("stderr line");
json!({})
}
fn invalid_utf8() -> Value {
io::stdout().write_all(&[255, 10]).unwrap();
json!({})
}
fn blank() -> Value {
println!("\n \t\n");
json!({})
}
fn burst(request: &Value) {
let count = request["params"]["count"].as_u64().unwrap();
let mut output = String::new();
for index in 0..count {
let notification = json!({
"jsonrpc": "2.0",
"method": "core.notify",
"params": {"name": "burst", "params": {"index": index}}
});
output.push_str(&notification.to_string());
output.push('\n');
}
let response = json!({"jsonrpc":"2.0", "id":request["id"], "result":count});
output.push_str(&response.to_string());
output.push('\n');
let mut stdout = io::stdout().lock();
stdout.write_all(output.as_bytes()).unwrap();
stdout.flush().unwrap();
}
fn notify() -> Value {
send(json!({
"jsonrpc": "2.0",
"method": "core.notify",
"params": {
"name": "progress",
"params": {"n": 1}
}
}));
json!({})
}
fn serve(request: &Value, lines: &mut Lines) {
send(json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": std::env::var("SAUSAGE_PID").unwrap()
}));
for _ in lines {}
}
fn network(params: &Value) -> Value {
let mut socket = std::net::TcpStream::connect(params["tcp"].as_str().unwrap()).unwrap();
let mut text = String::new();
socket.read_to_string(&mut text).unwrap();
let udp = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
udp.send_to(b"udp", params["udp"].as_str().unwrap())
.unwrap();
json!(text)
}
fn read_file(params: &Value) -> Value {
json!(std::fs::read_to_string(params["path"].as_str().unwrap()).unwrap())
}
fn write_file(params: &Value) -> Value {
std::fs::write(
params["path"].as_str().unwrap(),
params["text"].as_str().unwrap(),
)
.unwrap();
json!({})
}
fn outgoing_call(params: &Value, lines: &mut Lines) -> Value {
send(json!({
"jsonrpc": "2.0",
"id": {"nested": 7},
"method": params["method"],
"params": params["params"]
}));
loop {
let response = receive(lines);
if response["id"] == json!({"nested":7}) {
return response["result"].clone();
}
}
}
fn lock(params: &Value, lines: &mut Lines) -> Value {
outgoing_call(&json!({"method":"core.lock", "params":params}), lines)
}
fn filesystem(params: &Value) -> Value {
use std::fs;
let pid = std::env::var("SAUSAGE_PID").unwrap();
match params["topic"].as_str().unwrap() {
"root" => {
let preopens = wasip2::filesystem::preopens::get_directories();
assert_eq!(preopens.len(), 1);
assert_eq!(preopens[0].1, "/");
let names: Vec<_> = fs::read_dir("/")
.unwrap()
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
for name in ["proc", "self", "run"] {
assert!(names.iter().any(|n| n == name));
}
fs::write("/state/root", "world").unwrap();
fs::write("/run/shared", "shared").unwrap();
assert!(fs::rename("/run/shared", "/state/moved").is_err());
assert!(fs::rename("/state/root", "/self/moved").is_err());
assert_eq!(fs::read_to_string("/run/../state/root").unwrap(), "world");
}
"reserved" => {
for name in ["info.json", "log", "fs"] {
assert!(fs::write(format!("/self/{name}"), "bad").is_err());
assert!(fs::create_dir(format!("/self/{name}")).is_err());
fs::write("/self/temp", "ok").unwrap();
assert!(fs::rename("/self/temp", format!("/self/{name}")).is_err());
fs::remove_file("/self/temp").unwrap();
}
}
"self" => {
fs::write("/self/endpoint.tmp", "endpoint").unwrap();
fs::rename("/self/endpoint.tmp", "/self/endpoint").unwrap();
assert_eq!(
fs::read_to_string(format!("/proc/{pid}/endpoint")).unwrap(),
"endpoint"
);
assert!(fs::write(format!("/proc/{pid}/endpoint"), "bad").is_err());
assert!(fs::remove_file(format!("/proc/{pid}/endpoint")).is_err());
let info: Value =
serde_json::from_slice(&fs::read("/self/info.json").unwrap()).unwrap();
assert_eq!(info["plugin"], "probe");
assert_eq!(info["method"], "filesystem");
return json!(pid);
}
"isolated" => {
assert!(!fs::exists("/self/endpoint").unwrap());
}
"snapshot" => {
eprintln!("before snapshot");
let mut file = fs::File::open("/self/log").unwrap();
eprintln!("after snapshot");
let mut contents = String::new();
file.read_to_string(&mut contents).unwrap();
assert!(contents.contains("start filesystem"));
assert!(!contents.contains("after snapshot"));
assert_eq!(file.read(&mut [0; 1]).unwrap(), 0);
}
"core" => {
assert!(
fs::read_to_string("/proc/core/PROTOCOL.md")
.unwrap()
.contains("## 4. The filesystem")
);
for name in ["graph.json", "locks.json"] {
let _: Value =
serde_json::from_slice(&fs::read(format!("/proc/core/{name}")).unwrap())
.unwrap();
}
assert!(
fs::read_to_string("/proc/core/graph.dot")
.unwrap()
.contains("digraph")
);
assert!(fs::read_dir("/proc/core/plugins").is_ok());
assert!(fs::write("/proc/core/graph.json", "bad").is_err());
}
_ => panic!("unknown filesystem topic"),
}
json!({})
}
fn write_loop(request: &Value, lines: &mut Lines) {
assert_eq!(
lock(&json!({"path":"/state/loop"}), lines),
json!({"held":true})
);
std::fs::write("/state/loop", "old").unwrap();
send(
json!({"jsonrpc":"2.0", "id":request["id"], "result":std::env::var("SAUSAGE_PID").unwrap()}),
);
loop {
std::fs::write("/state/loop", "old").unwrap();
}
}
fn locked_write(lines: &mut Lines) -> Value {
assert_eq!(
lock(&json!({"path":"/state/loop"}), lines),
json!({"held":true})
);
std::fs::write("/state/loop", "new").unwrap();
outgoing_call(&json!({"method":"probe.echo","params":{}}), lines);
assert_eq!(std::fs::read_to_string("/state/loop").unwrap(), "new");
json!({})
}
fn unlock_answer(request: &Value, lines: &mut Lines) {
lock(&json!({"path":"ordered-unlock"}), lines);
let unlock = json!({"jsonrpc":"2.0","method":"core.unlock","params":{"path":"ordered-unlock"}});
let answer =
json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()});
let mut stdout = io::stdout().lock();
writeln!(stdout, "{unlock}\n{answer}").unwrap();
stdout.flush().unwrap();
drop(stdout);
for _ in lines {}
}
fn notification_child(lines: &mut Lines) -> Value {
send(json!({"jsonrpc":"2.0","method":"probe.orphan_notify","params":{}}));
let before = std::time::Instant::now();
let pid = loop {
if let Ok(pid) = std::fs::read_to_string("/state/orphan") {
break pid;
}
assert!(before.elapsed() < std::time::Duration::from_secs(10));
};
send(json!({"jsonrpc":"2.0","id":7,"method":"core.stop","params":{"pid":pid}}));
let mut count = 0;
loop {
let response = receive(lines);
if response["method"] == "core.notify" {
count += 1;
}
if response["id"] == 7 {
break;
}
}
json!(count)
}
fn batch(lines: &mut Lines) -> Value {
send(
json!([{"jsonrpc":"2.0","method":"probe.write","params":{"path":"/state/batch","text":"bad"}}]),
);
outgoing_call(
&json!({"method":"probe.echo","params":{"barrier":true}}),
lines,
);
json!(!std::path::Path::new("/state/batch").exists())
}
fn late_answer(request: &Value, lines: &mut Lines) {
send(json!({"jsonrpc":"2.0","id":request["id"],"result":42}));
send(json!({"jsonrpc":"2.0","id":request["id"],"result":99}));
for _ in lines {}
}
fn nested_environment(lines: &mut Lines) -> Value {
let child = outgoing_call(&json!({"method":"probe.env","params":{}}), lines);
json!({"caller":std::env::var("SAUSAGE_PID").unwrap(),"child":child})
}
fn forgotten(request: &Value, lines: &mut Lines) {
std::fs::write("/state/forgotten", environment().to_string()).unwrap();
serve(request, lines);
}
fn forget(lines: &mut Lines) -> Value {
send(
json!({"jsonrpc":"2.0","method":"core.call","params":{"method":"probe.forgotten","params":{},"detach":true}}),
);
let before = std::time::Instant::now();
while !std::path::Path::new("/state/forgotten").exists() {
assert!(before.elapsed() < std::time::Duration::from_secs(10));
}
send(json!({"jsonrpc":"2.0","id":"barrier","method":"probe.echo","params":{}}));
let response = receive(lines);
assert_eq!(
response["id"], "barrier",
"a forgotten call must not send a result"
);
environment()
}
fn tree(request: &Value, lines: &mut Lines) {
let depth = request["params"]["depth"].as_u64().unwrap();
if depth > 0 {
outgoing_call(
&json!({"method":"probe.tree","params":{"depth":depth-1}}),
lines,
);
}
send(
json!({"jsonrpc":"2.0","id":request["id"],"result":std::env::var("SAUSAGE_PID").unwrap()}),
);
busy();
}
fn dispatch(request: &Value, lines: &mut Lines) -> Option<Value> {
let params = &request["params"];
let result = match request["method"].as_str().unwrap() {
"batch" => batch(lines),
"trap" => std::arch::wasm32::unreachable(),
"nested_environment" => nested_environment(lines),
"forget" => forget(lines),
"forgotten" => {
forgotten(request, lines);
return None;
}
"tree" => {
tree(request, lines);
return None;
}
"late_answer" => {
late_answer(request, lines);
return None;
}
"invalid_stderr" => {
io::stderr().write_all(&[255, 10]).unwrap();
eprintln!("after invalid UTF-8");
json!(std::env::var("SAUSAGE_PID").unwrap())
}
"unlock_answer" => {
unlock_answer(request, lines);
return None;
}
"null_error" => {
send(json!({"jsonrpc":"2.0","id":request["id"],"error":null,"result":42}));
return None;
}
"notification_child" => notification_child(lines),
"orphan_notify" => {
std::fs::write("/state/orphan", std::env::var("SAUSAGE_PID").unwrap()).unwrap();
notify()
}
"self_serve" => {
std::fs::write("/self/endpoint", "endpoint").unwrap();
serve(request, lines);
return None;
}
"filesystem" => filesystem(params),
"write_loop" => {
write_loop(request, lines);
return None;
}
"locked_write" => locked_write(lines),
"echo" => echo(params),
"chat" => chat(params),
"rpc.discover" => discover(params),
"env" => environment(),
"multicall" => multicall(lines),
"notification" => notification(params),
"unknownid" => {
unknown_id();
return None;
}
"request_id" => request["id"].clone(),
"wait_eof" => {
wait_eof(lines);
return None;
}
"busy" => busy(),
"exit" => {
exit_without_answer();
return None;
}
"junk" => junk(),
"blank" => blank(),
"invalid_utf8" => invalid_utf8(),
"notify" => notify(),
"burst" => {
burst(request);
return None;
}
"restart" => {
assert_eq!(
lock(&json!({"path":"/state/restart-stop"}), lines),
json!({"held":true})
);
assert_eq!(
outgoing_call(&json!({"method":"core.restart", "params":{}}), lines),
json!({})
);
std::fs::write("/state/restart-answer", "received").unwrap();
serve(request, lines);
std::fs::write("/state/restart-stop", "persisted after EOF").unwrap();
eprintln!("restart stop hook finished");
return None;
}
"serve" => {
serve(request, lines);
return None;
}
"network" => network(params),
"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
}));
}
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "consumer"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../sdk" }
+85
View File
@@ -0,0 +1,85 @@
{
"components": {
"schemas": {
"AddParams": {
"description": "Parameters for a new note.",
"properties": {
"text": {
"description": "The complete note text, including any newlines.",
"type": "string"
}
},
"required": [
"text"
],
"type": "object"
},
"Added": {
"description": "The result after a note enters the list.",
"properties": {
"count": {
"description": "The number of notes, including the new note.",
"format": "uint",
"minimum": 0,
"type": "integer"
}
},
"required": [
"count"
],
"type": "object"
}
}
},
"info": {
"title": "notes",
"version": "0.1.0"
},
"methods": [
{
"description": "Add a note and return the new count.",
"name": "add",
"paramStructure": "by-name",
"params": [
{
"description": "The complete note text, including any newlines.",
"name": "text",
"required": true,
"schema": {
"description": "The complete note text, including any newlines.",
"type": "string"
}
}
],
"result": {
"name": "result",
"schema": {
"$ref": "#/components/schemas/Added"
}
},
"summary": "Add a note and return the new count.",
"x-sausage-params": {
"$ref": "#/components/schemas/AddParams"
}
},
{
"description": "List all notes, oldest first.",
"name": "list",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"items": {
"type": "string"
},
"type": "array"
}
},
"summary": "List all notes, oldest first."
}
],
"openrpc": "1.2.6",
"x-sausage-endpoints": {},
"x-sausage-imports": {}
}
+128
View File
@@ -0,0 +1,128 @@
{
"components": {
"schemas": {
"GetParams": {
"description": "The name of a secret to read.",
"properties": {
"name": {
"description": "The unique name within this vault.",
"type": "string"
}
},
"required": [
"name"
],
"type": "object"
},
"PutParams": {
"description": "The name and value of a secret.",
"properties": {
"name": {
"description": "The unique name within this vault.",
"type": "string"
},
"value": {
"description": "The text to store until process exit.",
"type": "string"
}
},
"required": [
"name",
"value"
],
"type": "object"
}
}
},
"info": {
"title": "secrets",
"version": "0.1.0"
},
"methods": [
{
"description": "Open the in-memory vault and publish its endpoint.",
"name": "serve",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "null"
}
},
"summary": "Open the in-memory vault and publish its endpoint."
}
],
"openrpc": "1.2.6",
"x-sausage-endpoints": {
"vault": {
"methods": [
{
"description": "Store a secret and report its name to the caller.",
"name": "put",
"paramStructure": "by-name",
"params": [
{
"description": "The unique name within this vault.",
"name": "name",
"required": true,
"schema": {
"description": "The unique name within this vault.",
"type": "string"
}
},
{
"description": "The text to store until process exit.",
"name": "value",
"required": true,
"schema": {
"description": "The text to store until process exit.",
"type": "string"
}
}
],
"result": {
"name": "result",
"schema": {
"type": "null"
}
},
"summary": "Store a secret and report its name to the caller.",
"x-sausage-params": {
"$ref": "#/components/schemas/PutParams"
}
},
{
"description": "Read a secret, or return null when its name is absent.",
"name": "get",
"paramStructure": "by-name",
"params": [
{
"description": "The unique name within this vault.",
"name": "name",
"required": true,
"schema": {
"description": "The unique name within this vault.",
"type": "string"
}
}
],
"result": {
"name": "result",
"schema": {
"type": [
"string",
"null"
]
}
},
"summary": "Read a secret, or return null when its name is absent.",
"x-sausage-params": {
"$ref": "#/components/schemas/GetParams"
}
}
]
}
},
"x-sausage-imports": {}
}
+94
View File
@@ -0,0 +1,94 @@
{
"components": {
"schemas": {
"StartParams": {
"description": "The server configuration.",
"properties": {
"port": {
"description": "The HTTP port on all network interfaces.",
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": "integer"
}
},
"required": [
"port"
],
"type": "object"
}
}
},
"info": {
"title": "www",
"version": "0.1.0"
},
"methods": [
{
"description": "Start the HTTP server and return its process ID.",
"name": "start",
"paramStructure": "by-name",
"params": [
{
"description": "The HTTP port on all network interfaces.",
"name": "port",
"required": true,
"schema": {
"description": "The HTTP port on all network interfaces.",
"format": "uint16",
"maximum": 65535,
"minimum": 0,
"type": "integer"
}
}
],
"result": {
"name": "result",
"schema": {
"type": "string"
}
},
"summary": "Start the HTTP server and return its process ID.",
"x-sausage-params": {
"$ref": "#/components/schemas/StartParams"
}
}
],
"openrpc": "1.2.6",
"x-sausage-endpoints": {
"control": {
"methods": [
{
"description": "Count the HTTP requests this server answered.",
"name": "stats",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"format": "uint64",
"minimum": 0,
"type": "integer"
}
},
"summary": "Count the HTTP requests this server answered."
},
{
"description": "End this process after the response reaches the client.",
"name": "stop",
"paramStructure": "by-name",
"params": [],
"result": {
"name": "result",
"schema": {
"type": "null"
}
},
"summary": "End this process after the response reaches the client."
}
]
}
},
"x-sausage-grace": 5,
"x-sausage-imports": {}
}
+81
View File
@@ -0,0 +1,81 @@
//! Use typed calls from discovered provider documents in `imports/`.
//! Generated modules own the wire types, so calls need no manual JSON.
//! The detached HTTP service survives this consumer. Its endpoint refers to that service's process-local state.
//! Copy fresh documents from `/proc/core/plugins/<provider>/discover.json` after an interface changes.
use sausage_plugin::{Result, rpc, schema};
sausage_plugin::import!("notes");
sausage_plugin::import!("www");
sausage_plugin::import!("secrets");
/// Demonstrate typed imports without manual JSON messages.
struct Consumer;
/// A note for the imported notes plugin.
#[schema]
struct NoteParams {
/// The text to append.
text: String,
}
/// The text and HTTP port for this demonstration.
#[schema]
struct DemoParams {
/// The note text to append.
text: String,
/// The port for the detached HTTP server.
port: u16,
}
#[rpc]
impl Consumer {
/// Add a note through the generated import and return its count.
async fn note(&self, p: NoteParams) -> Result<usize> {
Ok(notes::add(notes::AddParams { text: p.text }).await?.count as usize)
}
/// Add a note, start a server, and inspect its control endpoint.
async fn demo(&self, p: DemoParams) -> Result<u64> {
let added = notes::add(notes::AddParams { text: p.text }).await?;
eprintln!("{} notes", added.count);
let pid = www::start(www::StartParams { port: p.port })
.detach()
.await?;
let control = www::control::connect(format!("/proc/{pid}/control")).await?;
control.stats().await
}
/// Store and read a value through the boot service's fixed endpoint.
async fn vault(&self) -> Result<Option<String>> {
let vault = secrets::vault::connect("/run/secrets").await?;
vault
.put(secrets::PutParams {
name: "example".into(),
value: "hello".into(),
})
.on_notify(|name, params| eprintln!("{name}: {params}"))
.await?;
vault
.get(secrets::GetParams {
name: "example".into(),
})
.await
}
}
/// Start the SDK transport for this plugin.
fn main() {
sausage_plugin::run(Consumer)
}
/// Tests check generated wire types without other plugins in the test world.
#[cfg(test)]
mod tests {
use super::*;
/// Generated params retain their wire field names.
#[test]
fn generated_params_use_text() {
let value = sausage_plugin::serde_json::to_value(notes::AddParams {
text: "milk".into(),
})
.unwrap();
assert_eq!(value, sausage_plugin::serde_json::json!({"text":"milk"}));
}
/// A generated result decodes without another plugin in the test world.
#[test]
fn generated_result_decodes_count() {
let added: notes::Added = sausage_plugin::serde_json::from_str(r#"{"count":2}"#).unwrap();
assert_eq!(added.count, 2);
}
}
-115
View File
@@ -1,115 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "notes"
version = "0.1.0"
dependencies = [
"sausage-actor",
"serde_json",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "sausage-actor"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
-31
View File
@@ -1,31 +0,0 @@
//! Version 2 adds a note count and fixes the crash command.
use sausage_actor::{json, messages, reply};
const DATA: &str = "/data/notes.json";
fn save(notes: &[String]) {
std::fs::write(DATA, serde_json::to_string(notes).unwrap()).unwrap();
std::fs::write("/public/notes.txt", notes.join("\n") + "\n").unwrap();
}
fn main() {
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default();
for message in messages() {
let body = &message.body;
let response = match message.kind() {
"add" => {
notes.push(body["text"].as_str().unwrap_or_default().to_string());
save(&notes);
json!({"type": "added", "count": notes.len()})
}
"list" => json!({"type": "notes", "items": notes}),
"count" => json!({"type": "count", "count": notes.len(), "version": 2}),
"crash" => json!({"type": "not_crashing", "version": 2}),
_ => json!({"type": "error", "reason": "unknown message"}),
};
let _ = reply(&message, response);
}
}
-9
View File
@@ -1,9 +0,0 @@
{
"description": "version 2 counts notes and no longer crashes",
"steps": [
{"send": {"type": "add", "text": "test note"}, "expect": {"type": "added"}},
{"send": {"type": "list"}, "expect": {"type": "notes"}},
{"send": {"type": "count"}, "expect": {"type": "count", "version": 2}},
{"send": {"type": "crash"}, "expect": {"type": "not_crashing"}}
]
}
-115
View File
@@ -1,115 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "notes"
version = "0.1.0"
dependencies = [
"sausage-actor",
"serde_json",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "sausage-actor"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+2 -5
View File
@@ -2,9 +2,6 @@
name = "notes"
version = "0.1.0"
edition = "2024"
[dependencies]
sausage-actor = { version = "0.1", path = "../../sdk" }
serde_json = "1"
[workspace]
[dependencies]
sausage-plugin = { path = "../../sdk" }
+80 -26
View File
@@ -1,30 +1,84 @@
//! Keep notes in `/data/notes.json` and publish them at `/public/notes.txt`.
use sausage_actor::{json, messages, reply};
const DATA: &str = "/data/notes.json";
fn save(notes: &[String]) {
std::fs::write(DATA, serde_json::to_string(notes).unwrap()).unwrap();
std::fs::write("/public/notes.txt", notes.join("\n") + "\n").unwrap();
//! 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(&notes)?)?;
Ok(Added { count: notes.len() })
}
/// List all notes, oldest first.
fn list(&self) -> Result<Vec<String>> {
load()
}
}
/// Read the list, or return an empty list when the file does not exist.
fn load() -> Result<Vec<String>> {
match std::fs::read(state_dir().join("notes.json")) {
Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(vec![]),
Err(e) => Err(e.into()),
}
}
/// Start the SDK transport for notes.
fn main() {
sausage_plugin::run(Notes)
}
fn main() {
let mut notes: Vec<String> = std::fs::read_to_string(DATA)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default();
for message in messages() {
let body = &message.body;
let response = match message.kind() {
"add" => {
notes.push(body["text"].as_str().unwrap_or_default().to_string());
save(&notes);
json!({"type": "added", "count": notes.len()})
}
"list" => json!({"type": "notes", "items": notes}),
"crash" => panic!("asked to crash"),
_ => json!({"type": "error", "reason": "unknown message"}),
};
let _ = reply(&message, response);
/// 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());
}
}
-7
View File
@@ -1,7 +0,0 @@
{
"description": "add a note, then list the notes",
"steps": [
{"send": {"type": "add", "text": "test note"}, "expect": {"type": "added"}},
{"send": {"type": "list"}, "expect": {"type": "notes"}}
]
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "secrets"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../sdk" }
+90
View File
@@ -0,0 +1,90 @@
//! Keep a process-local vault behind a fixed endpoint at `/run/secrets`.
//! Start `secrets.serve` as a detached boot service. Its memory lasts until process exit.
use sausage_plugin::{Ctx, Result, listen, rpc, schema};
use std::{cell::RefCell, collections::BTreeMap, rc::Rc};
/// The service entry point publishes one process-local vault.
struct Secrets;
#[rpc(endpoints(vault=Vault))]
impl Secrets {
/// Open the in-memory vault and publish its endpoint.
async fn serve(&self) -> Result<()> {
listen("/run/secrets", Rc::new(Vault::default())).await
}
}
/// The endpoint owns secrets in memory, not on disk.
#[derive(Default)]
struct Vault {
/// Named values disappear when this process ends.
entries: RefCell<BTreeMap<String, String>>,
}
/// The name and value of a secret.
#[schema]
struct PutParams {
/// The unique name within this vault.
name: String,
/// The text to store until process exit.
value: String,
}
/// The name of a secret to read.
#[schema]
struct GetParams {
/// The unique name within this vault.
name: String,
}
#[rpc]
impl Vault {
/// Store a secret and report its name to the caller.
fn put(&self, ctx: &Ctx, p: PutParams) -> Result<()> {
ctx.notify("stored", sausage_plugin::serde_json::json!({"name":p.name}))?;
self.entries.borrow_mut().insert(p.name, p.value);
Ok(())
}
/// Read a secret, or return null when its name is absent.
fn get(&self, p: GetParams) -> Result<Option<String>> {
Ok(self.entries.borrow().get(&p.name).cloned())
}
}
/// Start the SDK transport for the vault service.
fn main() {
sausage_plugin::run(Secrets)
}
/// Tests verify absent keys and process-local storage.
#[cfg(test)]
mod tests {
use super::*;
/// Missing names return null instead of an RPC error.
#[test]
fn absent_secret() {
assert_eq!(
Vault::default()
.get(GetParams {
name: "missing".into()
})
.unwrap(),
None
);
}
/// Store a value and retrieve it through the same process-local vault.
#[sausage_plugin::test]
async fn stores_secret() {
let vault = Vault::default();
vault
.put(
&Ctx::default(),
PutParams {
name: "token".into(),
value: "example".into(),
},
)
.unwrap();
assert_eq!(
vault
.get(GetParams {
name: "token".into()
})
.unwrap()
.as_deref(),
Some("example")
);
}
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "www"
version = "0.1.0"
edition = "2024"
[workspace]
[dependencies]
sausage-plugin = { path = "../../sdk" }
wstd = "0.6"
+97
View File
@@ -0,0 +1,97 @@
//! Serve files from `/state/www` and expose a control endpoint at `/self/control`.
//! Start this process detached or as a service so it outlives its caller.
//! Clients read `/proc/<pid>/control`. A fresh core call cannot inspect this process's counters.
use sausage_plugin::{Ctx, Result, background, listen, pid, rpc, schema, state_dir};
use std::{cell::Cell, rc::Rc};
use wstd::{
io::{AsyncRead, AsyncWrite},
iter::AsyncIterator,
net::TcpListener,
};
/// The HTTP plugin starts a server and returns its process ID.
struct Www;
/// The server configuration.
#[schema]
struct StartParams {
/// The HTTP port on all network interfaces.
port: u16,
}
#[rpc(endpoints(control=Control),grace_seconds=5)]
impl Www {
/// Start the HTTP server and return its process ID.
async fn start(&self, p: StartParams) -> Result<String> {
let tcp = TcpListener::bind(&format!("0.0.0.0:{}", p.port)).await?;
let control = Rc::new(Control { hits: Cell::new(0) });
listen("/self/control", control.clone()).await?;
background(serve(tcp, control));
Ok(pid())
}
}
/// Shared process-local state for the HTTP task and its endpoint.
struct Control {
/// The number of answered requests.
hits: Cell<u64>,
}
#[rpc]
impl Control {
/// Count the HTTP requests this server answered.
fn stats(&self) -> Result<u64> {
Ok(self.hits.get())
}
/// End this process after the response reaches the client.
fn stop(&self, ctx: &Ctx) -> Result<()> {
ctx.exit_after_response();
Ok(())
}
}
/// Answer HTTP requests with files and count all responses, including missing files.
async fn serve(tcp: TcpListener, control: Rc<Control>) {
let mut incoming = tcp.incoming();
while let Some(Ok(mut stream)) = incoming.next().await {
let mut request = [0; 8192];
let Ok(size) = stream.read(&mut request).await else {
continue;
};
let line = String::from_utf8_lossy(&request[..size]);
let path = line
.split_whitespace()
.nth(1)
.unwrap_or("/")
.trim_start_matches('/');
let (status, body) = match std::fs::read(state_dir().join(if path.is_empty() {
"index.html"
} else {
path
})) {
Ok(body) => ("200 OK", body),
Err(_) => ("404 Not Found", b"Not found".to_vec()),
};
control.hits.set(control.hits.get() + 1);
let header = format!(
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
let _ = stream.write_all(header.as_bytes()).await;
let _ = stream.write_all(&body).await;
let _ = stream.flush().await;
}
}
/// Start the SDK transport for the HTTP plugin.
fn main() {
sausage_plugin::run(Www)
}
/// Tests check local counters and the service entry point.
#[cfg(test)]
mod tests {
use super::*;
/// Control reports its shared counter without a core round trip.
#[test]
fn stats_reads_counter() {
assert_eq!(Control { hits: Cell::new(3) }.stats().unwrap(), 3);
}
/// The listener binds an ephemeral port and publishes its control endpoint.
#[sausage_plugin::test]
async fn starts_on_available_port() {
assert_eq!(Www.start(StartParams { port: 0 }).await.unwrap(), pid());
}
}
+133 -25
View File
@@ -1,5 +1,5 @@
{
description = "Sausage: grow a system of WASI actors by talking to it";
description = "Sausage: grow a system of WASI plugins by talking to it";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
@@ -16,22 +16,43 @@
inherit system;
overlays = [ rust-overlay.overlays.default ];
};
# The toolchain that builds the host and, at runtime, the actors.
# The toolchain that builds the host and, at runtime, the plugins.
rust = pkgs.rust-bin.stable.latest.default.override {
targets = [ "wasm32-wasip2" ];
extensions = [ "rust-src" ];
};
# Crane builds the dependencies once (`cargoArtifacts`, keyed only on
# Cargo.toml/Cargo.lock), so a change to our own crates rebuilds
# just them.
# The native cargo plugin needs Cargo, rustc (with wasm32-wasip2), and
# the native linker, even when the host itself is started with a bare
# PATH. Keep this same runtime path in the packaged host and exercise
# the installed binary below with PATH deliberately empty.
# mold links host code much faster than GNU ld. Only on Linux, and only
# for the host target: plugins for wasm32-wasip2 link with rust-lld. Nix
# brings mold along, so a build without Nix keeps the default linker.
mold = pkgs.lib.optionals pkgs.stdenv.hostPlatform.isLinux [ pkgs.mold ];
hostTarget = pkgs.lib.toUpper (builtins.replaceStrings [ "-" ] [ "_" ]
pkgs.stdenv.hostPlatform.rust.rustcTarget);
moldVar = "CARGO_TARGET_${hostTarget}_RUSTFLAGS";
moldEnv = pkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux {
${moldVar} = "-C link-arg=-fuse-ld=mold";
};
runtimePath = pkgs.lib.makeBinPath ([ rust pkgs.stdenv.cc ] ++ mold);
# A WASI clang with wasi-libc, so that build scripts (the `cc` crate)
# can compile C for wasm32-wasip2. Its headers are WASI 0.1: C code
# that needs sockets does not build.
wasiCc = pkgs.pkgsCross.wasi32.stdenv.cc;
wasiCcEnv = {
CC_wasm32_wasip2 = "${wasiCc}/bin/${wasiCc.targetPrefix}cc";
AR_wasm32_wasip2 = "${wasiCc}/bin/${wasiCc.targetPrefix}ar";
};
craneLib = (crane.mkLib pkgs).overrideToolchain rust;
common = {
pname = "sausage";
version = "0.1.0";
src = ./.;
};
nativeBuildInputs = mold;
} // moldEnv;
# The test packages for `checks.tests`.
testPackages = "-p sausage -p sausage-actor -p agent";
testPackages = "--workspace";
cargoArtifacts = craneLib.buildDepsOnly (common // {
doCheck = false;
# The dependencies of the host binary and of the tests.
@@ -42,20 +63,25 @@
});
sausage = craneLib.buildPackage (common // {
inherit cargoArtifacts;
doNotRemoveReferencesToRustToolchain = true;
cargoExtraArgs = "--locked -p sausage";
doCheck = false;
nativeBuildInputs = [ pkgs.makeWrapper ];
# The `cargo` actor runs cargo at runtime. Build scripts need a C linker.
nativeBuildInputs = [ pkgs.makeWrapper ] ++ mold;
# The native `cargo` plugin runs cargo at runtime. Build scripts need a C linker,
# and C code for WASI needs the WASI clang.
postInstall = ''
wrapProgram $out/bin/sausage --prefix PATH : ${pkgs.lib.makeBinPath [ rust pkgs.stdenv.cc ]}
wrapProgram $out/bin/sausage --prefix PATH : ${runtimePath} \
--set-default CC_wasm32_wasip2 ${wasiCcEnv.CC_wasm32_wasip2} \
--set-default AR_wasm32_wasip2 ${wasiCcEnv.AR_wasm32_wasip2} \
${pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isLinux "--set-default ${moldVar} '-C link-arg=-fuse-ld=mold'"}
'';
meta.mainProgram = "sausage";
});
# `nix run .#loop -- <world>` runs the newest `sausage run <world>`
# (without a folder: bare `sausage`, i.e. ./world, created if needed)
# from the public repo, again and again: after a crash or a broker
# `exit` request it refreshes the flake and starts the host again,
# so the world updates itself. `/quit` (exit code 0) ends the loop.
# from the public repo, again and again: after a crash or a call of
# `core.restart` (exit code 75) it refreshes the flake and starts the host again, so
# the world updates itself. `quit` (exit code 0) ends the loop.
# SAUSAGE_FLAKE overrides where it fetches from (any flake URL,
# e.g. a fork or a local checkout).
repoUrl = "git+https://gitea.surma.technology/surma/sausage";
@@ -64,7 +90,7 @@
# No folder: bare `sausage` (creates ./world first if needed).
if [ $# -gt 0 ]; then set -- run "$@"; fi
while true; do
nix run --refresh "$url#default" -- "$@"
SAUSAGE_LOOP=1 nix run --refresh "$url#default" -- "$@"
code=$?
if [ "$code" -eq 0 ]; then exit 0; fi
echo "sausage-loop: sausage exited with $code, starting the newest version in 2 s …" >&2
@@ -75,31 +101,113 @@
{
packages.default = sausage;
# `nix flake check` runs every test of the host, the SDK, and the
# agent. The tests build WASM actors with cargo, offline, from the
# vendored crates of Cargo.lock.
# `nix flake check` runs every test of the workspace. The tests build
# WASM plugins with cargo, offline, from the vendored crates of
# Cargo.lock.
# The binary the host loop runs (`nix run`), built from the same
# cached dependencies as the tests.
checks.package = sausage;
checks.tests = craneLib.cargoTest (common // {
checks.tests = craneLib.cargoTest (common // wasiCcEnv // {
inherit cargoArtifacts;
cargoExtraArgs = "--locked ${testPackages}";
# Nothing builds on the test run's target folder: don't store it.
doInstallCargoArtifacts = false;
preCheck = ''
# Its own lock file pins crates that the vendored set may lack.
rm -f cli/tests/actors/toolbox/Cargo.lock
'';
# The SDK tests run sample plugins with the wasmtime CLI.
nativeBuildInputs = [ pkgs.wasmtime ] ++ mold;
});
# Exercise the installed host exactly as the loop launches it: the
# caller has no Cargo on PATH, so only the packaged runtime toolchain
# can run `cargo.build`: the tests under WASI and the wasm32-wasip2
# build, with C code from the packaged WASI clang.
checks.runtime-toolchain = pkgs.runCommand "sausage-runtime-toolchain" { } ''
set -eu
root="$TMPDIR/runtime-toolchain"
world="$root/world"
emptyPath="$root/empty-bin"
mkdir -p "$emptyPath" "$root/home" "$root/cargo-home"
mkdir -p "$world/plugins" "$world/state" "$world/config"
echo "[]" > "$world/config/boot.yaml"
source="$world/plugins/probe/source"
mkdir -p "$source/src" "$source/tests"
cat > "$source/Cargo.toml" <<'TOML'
[package]
name = "runtime-probe"
version = "0.1.0"
edition = "2024"
TOML
# C code for WASI, compiled with the packaged WASI clang.
printf 'int probe_add(int a, int b) { return a + b; }\n' > "$source/src/add.c"
cat > "$source/build.rs" <<'RUST'
use std::process::Command;
fn main() {
if std::env::var("TARGET").unwrap() != "wasm32-wasip2" {
return;
}
let out = std::env::var("OUT_DIR").unwrap();
let cc = std::env::var("CC_wasm32_wasip2").expect("CC_wasm32_wasip2");
let ar = std::env::var("AR_wasm32_wasip2").expect("AR_wasm32_wasip2");
let object = format!("{out}/add.o");
assert!(Command::new(cc).args(["-c", "src/add.c", "-o", &object]).status().unwrap().success());
assert!(Command::new(ar).args(["crs", &format!("{out}/libadd.a"), &object]).status().unwrap().success());
println!("cargo:rustc-link-search=native={out}");
println!("cargo:rustc-link-lib=static=add");
}
RUST
cat > "$source/src/main.rs" <<'RUST'
#[cfg(target_os = "wasi")]
unsafe extern "C" {
fn probe_add(a: i32, b: i32) -> i32;
}
fn main() {
#[cfg(target_os = "wasi")]
assert_eq!(unsafe { probe_add(2, 3) }, 5);
}
RUST
cat > "$source/tests/probe.rs" <<'RUST'
#[test]
fn test_runs_under_wasi() {
assert_eq!(2 + 2, 4);
}
RUST
echo "runtime-check: run packaged host with an empty PATH"
coproc host {
${pkgs.coreutils}/bin/env -i PATH="$emptyPath" HOME="$root/home" TMPDIR="$root" \
CARGO_HOME="$root/cargo-home" ${sausage}/bin/sausage run "$world"
}
hostInput=''${host[1]}
hostOutput=''${host[0]}
hostPid=$host_PID
trap 'kill "$hostPid" 2>/dev/null || true' EXIT
printf '%s\n' 'cargo.build {"plugin":"probe"}' >&"$hostInput"
built=0
while IFS= read -r -t 600 line <&"$hostOutput"; do
echo "$line"
case "$line" in
*'error:'*)
echo "packaged cargo.build failed with an empty caller PATH" >&2
exit 1
;;
*'"passed": 1'*) built=1; break ;;
esac
done
if [ "$built" -ne 1 ]; then
echo "packaged host did not build the probe, or its test did not pass" >&2
exit 1
fi
printf '%s\n' 'quit' >&"$hostInput"
wait "$hostPid"
mkdir -p "$out"
touch "$out/passed"
'';
apps.default = { type = "app"; program = "${sausage}/bin/sausage"; };
apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; };
devShells.default = pkgs.mkShell {
packages = [ rust pkgs.wasmtime pkgs.wasm-tools ];
devShells.default = pkgs.mkShell (wasiCcEnv // moldEnv // {
packages = [ rust pkgs.wasmtime pkgs.wasm-tools ] ++ mold;
shellHook = ''
export CARGO_HOME="$PWD/.cargo-home"
'';
};
});
});
}
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "sausage-interface"
version = "0.1.0"
edition = "2024"
description = "Sausage plugin interfaces: OpenRPC documents, the compatibility check, and client code generation."
[features]
default = ["codegen"]
codegen = ["dep:typify", "dep:schemars08", "dep:syn", "dep:quote", "dep:prettyplease", "dep:proc-macro2"]
[dependencies]
serde_json = "1"
typify = { version = "0.6", optional = true }
schemars08 = { package = "schemars", version = "0.8", optional = true }
syn = { version = "2", features = ["full", "visit-mut"], optional = true }
quote = { version = "1", optional = true }
prettyplease = { version = "0.2", optional = true }
proc-macro2 = { version = "1", optional = true }
[dev-dependencies]
proptest = "1"
schemars = "1"
serde = { version = "1", features = ["derive"] }
+202
View File
@@ -0,0 +1,202 @@
//! Generate Rust clients from OpenRPC documents with typify's Draft 7 converter.
use quote::quote;
use serde_json::{Value, json};
/// Generate a formatted private client module for a provider's document.
pub fn client_rs(name: &str, doc: &Value) -> Result<String, String> {
fn local_refs(value: &mut Value) {
match value {
Value::Object(map) => {
if let Some(Value::String(r)) = map.get_mut("$ref") {
*r = r.replace("#/components/schemas/", "#/definitions/");
}
for v in map.values_mut() {
local_refs(v);
}
}
Value::Array(a) => {
for v in a {
local_refs(v);
}
}
_ => {}
}
}
let module = syn::parse_str::<syn::Ident>(&name.replace('-', "_"))
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{}", name.replace('-', "_"))))
.map_err(|e| e.to_string())?;
let mut space = typify::TypeSpace::default();
let mut defs = doc["components"]["schemas"]
.as_object()
.cloned()
.unwrap_or_default();
for schema in defs.values_mut() {
local_refs(schema);
}
let defs: Vec<(String, schemars08::schema::Schema)> = defs
.into_iter()
.map(|(n, v)| serde_json::from_value(v).map(|v| (n, v)))
.collect::<Result<_, _>>()
.map_err(|e| e.to_string())?;
space.add_ref_types(defs).map_err(|e| e.to_string())?;
fn methods(
space: &mut typify::TypeSpace,
provider: &str,
methods: &Value,
endpoint: bool,
) -> Result<Vec<proc_macro2::TokenStream>, String> {
let mut result = vec![];
for method in methods.as_array().into_iter().flatten() {
let wire = method["name"].as_str().ok_or("method name missing")?;
let ident = syn::parse_str::<syn::Ident>(wire)
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{wire}")))
.map_err(|e| e.to_string())?;
let mut rs = method["result"]["schema"].clone();
local_refs(&mut rs);
let empty = method["params"].as_array().is_none_or(Vec::is_empty);
let p = if empty {
None
} else {
let mut properties = serde_json::Map::new();
let mut required = vec![];
for param in method["params"].as_array().into_iter().flatten() {
let name = param["name"].as_str().ok_or("parameter name missing")?;
properties.insert(name.into(), param["schema"].clone());
if param["required"] == true {
required.push(name);
}
}
let mut ps = method.get("x-sausage-params").cloned().unwrap_or_else(|| {
let title = wire.split('_').filter(|s| !s.is_empty()).map(|s| {
let mut chars = s.chars();
chars.next().unwrap().to_uppercase().to_string() + chars.as_str()
}).collect::<String>();
json!({"title":format!("{title}Params"),"type":"object","properties":properties,"required":required})
});
local_refs(&mut ps);
Some(
space
.add_type(&serde_json::from_value(ps).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?,
)
};
let r = space
.add_type(&serde_json::from_value(rs).map_err(|e| e.to_string())?)
.map_err(|e| e.to_string())?;
let p = p
.map(|p| {
space
.get_type(&p)
.map(|p| p.ident())
.map_err(|e| e.to_string())
})
.transpose()?;
let r = space.get_type(&r).map_err(|e| e.to_string())?.ident();
let docs = method
.get("description")
.or_else(|| method.get("summary"))
.and_then(Value::as_str)
.unwrap_or("");
let arg = if empty { quote!() } else { quote!(params:#p) };
let params = if empty {
quote!(::sausage_plugin::serde_json::json!({}))
} else {
quote!(params)
};
let target = format!("{provider}.{wire}");
if endpoint {
let arg = if empty { quote!() } else { quote!(, #arg) };
result.push(quote!(#[doc=#docs] #[warn(dead_code)] pub fn #ident(&self #arg)->::sausage_plugin::EndpointCall<#r>{self.0.call(#wire,#params)}));
} else {
result.push(quote!(#[doc=#docs] #[warn(dead_code)] pub fn #ident(#arg)->::sausage_plugin::TypedCall<#r>{::sausage_plugin::TypedCall::new(::sausage_plugin::call(#target,#params))}));
}
}
Ok(result)
}
let functions = methods(&mut space, name, &doc["methods"], false)?;
let mut endpoints = vec![];
for (name, description) in doc["x-sausage-endpoints"].as_object().into_iter().flatten() {
let ident = syn::parse_str::<syn::Ident>(name)
.or_else(|_| syn::parse_str::<syn::Ident>(&format!("r#{name}")))
.map_err(|e| e.to_string())?;
let functions = methods(&mut space, "", &description["methods"], true)?;
endpoints.push(quote!{pub mod #ident {use super::*;pub struct Client(::sausage_plugin::Endpoint);pub async fn connect(path:impl AsRef<::std::path::Path>)->::sausage_plugin::Result<Client>{let endpoint=::sausage_plugin::connect(path).await?;endpoint.require_interface(#name)?;Ok(Client(endpoint))} impl Client {#(#functions)*}}});
}
let types = space.to_stream().to_string();
let mut file = syn::parse_file(&types).map_err(|e| e.to_string())?;
fn serde_attrs(items: &mut [syn::Item]) {
for item in items {
match item {
syn::Item::Struct(s) => s
.attrs
.push(syn::parse_quote!(#[serde(crate="::sausage_plugin::serde")])),
syn::Item::Enum(e) => e
.attrs
.push(syn::parse_quote!(#[serde(crate="::sausage_plugin::serde")])),
syn::Item::Mod(m) => {
if let Some((_, items)) = &mut m.content {
serde_attrs(items)
}
}
_ => {}
}
}
}
// Only data types have serde derives. ConversionError is a support type.
for item in &mut file.items {
if let syn::Item::Mod(m) = item
&& m.ident == "error"
{
continue;
}
serde_attrs(std::slice::from_mut(item));
}
let uses_error = file
.items
.iter()
.filter(|item| !matches!(item, syn::Item::Mod(m) if m.ident == "error"))
.any(|item| {
quote!(#item)
.to_string()
.contains("error :: ConversionError")
});
if !uses_error {
file.items
.retain(|item| !matches!(item, syn::Item::Mod(m) if m.ident == "error"));
}
let types = &file.items;
let mut file =
syn::parse2::<syn::File>(quote!(mod #module {#(#types)* #(#functions)* #(#endpoints)*}))
.map_err(|e| e.to_string())?;
struct Reexports;
impl syn::visit_mut::VisitMut for Reexports {
fn visit_path_mut(&mut self, path: &mut syn::Path) {
syn::visit_mut::visit_path_mut(self, path);
if path.leading_colon.is_some()
&& path
.segments
.first()
.is_some_and(|s| s.ident == "serde" || s.ident == "serde_json")
{
path.segments.insert(0, syn::parse_quote!(sausage_plugin));
}
}
fn visit_attribute_mut(&mut self, attr: &mut syn::Attribute) {
if attr.path().is_ident("derive") {
let mut paths = attr
.parse_args_with(
syn::punctuated::Punctuated::<syn::Path, syn::Token![,]>::parse_terminated,
)
.expect("derive paths");
for path in &mut paths {
self.visit_path_mut(path);
}
*attr = syn::parse_quote!(#[derive(#paths)]);
} else {
syn::visit_mut::visit_attribute_mut(self, attr);
}
}
}
syn::visit_mut::VisitMut::visit_file_mut(&mut Reexports, &mut file);
Ok(prettyplease::unparse(&file))
}
+548
View File
@@ -0,0 +1,548 @@
//! Checks imports against provider interfaces under PROTOCOL.md section 10.2.
//! Every sender value must fit the receiver schema. Sender objects are closed,
//! while receiver objects are open unless they reject additional properties.
use serde_json::{Map, Value, json};
use std::{collections::HashSet, fmt};
/// One incompatible method, with a sender value when a finite example exists.
#[derive(Clone, Debug, PartialEq)]
pub struct Problem {
pub method: String,
pub message: String,
pub counterexample: Option<Value>,
}
impl fmt::Display for Problem {
/// Formats the failure and identifies the receiver of its counterexample.
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "- {}: {}.", self.method, self.message)?;
if let Some(value) = &self.counterexample {
write!(
formatter,
"\n Counterexample: {value} is not valid for {}.",
if self.message.starts_with("the provider's params") {
"the provider's params"
} else {
"the consumer's result"
}
)?;
}
Ok(())
}
}
/// Checks every imported method, including endpoint methods. Extra provider methods are harmless.
pub fn check(expected: &Value, actual: &Value) -> Vec<Problem> {
let mut problems = Vec::new();
methods(expected, actual, expected, actual, "", &mut problems);
if let Some(endpoints) = expected["x-sausage-endpoints"].as_object() {
for (name, interface) in endpoints {
methods(
interface,
&actual["x-sausage-endpoints"][name],
expected,
actual,
&format!("{name}."),
&mut problems,
);
}
}
problems
}
/// Builds an object schema from the OpenRPC by-name parameter list.
fn params(method: &Value) -> Value {
let mut properties = Map::new();
let mut required = Vec::new();
for param in method["params"].as_array().into_iter().flatten() {
let name = param["name"].as_str().unwrap_or_default();
properties.insert(name.into(), param["schema"].clone());
if param["required"] == true {
required.push(name);
}
}
json!({"type":"object", "properties":properties, "required":required})
}
/// Checks imported methods in both directions and records contextual diagnostics.
fn methods(
expected: &Value,
actual: &Value,
expected_doc: &Value,
actual_doc: &Value,
prefix: &str,
problems: &mut Vec<Problem>,
) {
for method in expected["methods"].as_array().into_iter().flatten() {
let name = method["name"].as_str().unwrap_or_default();
let label = format!("{prefix}{name}");
let Some(provider) = actual["methods"]
.as_array()
.into_iter()
.flatten()
.find(|m| m["name"] == name)
else {
problems.push(Problem {
method: label,
message: "the provider has no such method".into(),
counterexample: None,
});
continue;
};
for (part, sender, receiver, sender_doc, receiver_doc) in [
(
"params",
params(method),
params(provider),
expected_doc,
actual_doc,
),
(
"result",
provider["result"]["schema"].clone(),
method["result"]["schema"].clone(),
actual_doc,
expected_doc,
),
] {
let failure = supported(&sender, sender_doc, &mut HashSet::new())
.and_then(|()| supported(&receiver, receiver_doc, &mut HashSet::new()))
.err()
.map(|message| (message, None))
.or_else(|| {
subset(
&sender,
&receiver,
sender_doc,
receiver_doc,
&mut HashSet::new(),
)
.err()
});
if let Some((message, counterexample)) = failure {
problems.push(Problem {
method: label.clone(),
message: {
let (sender_name, receiver_name) = if part == "params" {
("consumer", "provider")
} else {
("provider", "consumer")
};
let detail = message
.replace("the receiver", &format!("the {receiver_name}"))
.replace("the sender", &format!("the {sender_name}"));
let detail = if part == "params" {
detail.replacen("the field", "the param", 1)
} else {
detail
};
let verb = if part == "params" { "are" } else { "is" };
format!("the {receiver_name}'s {part} {verb} incompatible: {detail}")
},
counterexample,
});
}
}
}
}
/// Resolves references in one document and stops at reference cycles.
fn resolve<'document>(sender: &'document Value, document: &'document Value) -> &'document Value {
let mut sender = sender;
let mut seen = HashSet::new();
while let Some(reference) = sender["$ref"].as_str() {
if !seen.insert(reference) {
break;
}
let Some(next) = reference
.strip_prefix('#')
.and_then(|p| document.pointer(p))
else {
break;
};
sender = next;
}
sender
}
/// Rejects constructs outside the supported subset before comparison.
fn supported(sender: &Value, document: &Value, seen: &mut HashSet<usize>) -> Result<(), String> {
if !seen.insert(sender as *const Value as usize) {
return Ok(());
}
if sender.is_boolean() {
return Ok(());
}
let Some(object) = sender.as_object() else {
return Err("unsupported schema construct non-schema".into());
};
for (key, value) in object {
match key.as_str() {
"$ref" => {
let target = resolve(sender, document);
if std::ptr::eq(target, sender) {
return Err("unsupported schema construct unresolved $ref".into());
}
supported(target, document, seen)?;
}
"properties" | "definitions" | "$defs" => {
for child in value.as_object().into_iter().flat_map(|m| m.values()) {
supported(child, document, seen)?;
}
}
"items" => supported(value, document, seen)?,
"anyOf" | "oneOf" => {
let branches = value
.as_array()
.ok_or_else(|| format!("unsupported schema construct {key}"))?;
if !branches.iter().all(|b| strings(b).is_some())
&& !(branches.len() == 2 && branches.iter().any(|b| b["type"] == "null"))
{
return Err(format!("unsupported schema construct {key}"));
}
for child in branches {
supported(child, document, seen)?;
}
}
"additionalProperties" if value.is_boolean() => (),
"enum"
if value
.as_array()
.is_some_and(|v| v.iter().all(Value::is_string)) => {}
"const" if value.is_string() => (),
"type" => {
let types: Vec<_> = value
.as_array()
.map(|actual| actual.iter().collect())
.unwrap_or_else(|| vec![value]);
if !types.iter().all(|t| {
matches!(
t.as_str(),
Some(
"object"
| "array"
| "string"
| "integer"
| "number"
| "null"
| "boolean"
)
)
}) {
return Err("unsupported schema construct type".into());
}
}
"required" | "minimum" | "maximum" | "title" | "description" | "default"
| "examples" | "format" | "$schema" | "$id" | "deprecated" | "readOnly"
| "writeOnly" => (),
_ => return Err(format!("unsupported schema construct {key}")),
}
}
Ok(())
}
/// Expands nullable schemas and string unions into individual alternatives.
fn alternatives(sender: &Value) -> Vec<Value> {
if let Some(types) = sender["type"].as_array() {
return types
.iter()
.map(|t| {
let mut alternative = sender.clone();
alternative["type"] = t.clone();
alternative
})
.collect();
}
for key in ["anyOf", "oneOf"] {
if let Some(branches) = sender[key].as_array() {
return branches.iter().flat_map(alternatives).collect();
}
}
vec![sender.clone()]
}
/// Returns the primitive type, including the implicit type of string enums.
fn kind(sender: &Value) -> &str {
sender["type"].as_str().unwrap_or(
if sender["const"].is_string() || sender["enum"].is_array() {
"string"
} else {
"any"
},
)
}
/// Returns the finite string values of an enum or constant schema.
fn strings(sender: &Value) -> Option<Vec<Value>> {
sender["enum"]
.as_array()
.cloned()
.or_else(|| sender.get("const").map(|v| vec![v.clone()]))
}
/// Constructs a finite sender value, or returns None for an unavoidable cycle.
fn sample(sender: &Value, document: &Value, seen: &mut HashSet<usize>) -> Option<Value> {
let sender = resolve(sender, document);
if !seen.insert(sender as *const Value as usize) {
return None;
}
let result = alternatives(sender).into_iter().find_map(|sender| {
if sender == false {
return None;
}
if let Some(values) = strings(&sender) {
return values.first().cloned();
}
Some(match kind(&sender) {
"object" => {
let mut object = Map::new();
for key in sender["required"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
{
object.insert(
key.into(),
sample(&sender["properties"][key], document, seen)?,
);
}
Value::Object(object)
}
"array" => json!([]),
"string" => json!("milk"),
"boolean" => json!(false),
"integer" | "number" => sender
.get("minimum")
.or(sender.get("maximum"))
.cloned()
.unwrap_or(json!(0)),
_ => Value::Null,
})
});
seen.remove(&(sender as *const Value as usize));
result
}
type Failure = (String, Option<Value>);
/// Checks sender inclusion in the receiver and assumes active reference pairs fit.
fn subset(
sender: &Value,
receiver: &Value,
sender_doc: &Value,
receiver_doc: &Value,
active: &mut HashSet<(usize, usize)>,
) -> Result<(), Failure> {
let (sender, receiver) = (resolve(sender, sender_doc), resolve(receiver, receiver_doc));
let pair = (
sender as *const Value as usize,
receiver as *const Value as usize,
);
if !active.insert(pair) {
return Ok(());
}
let result = (|| {
if sender == false || receiver == true {
return Ok(());
}
let sender_alternatives: Vec<_> = alternatives(sender)
.into_iter()
.flat_map(|v| {
if let Some(values) = v["enum"].as_array() {
values.iter().map(|value| json!({"const": value})).collect()
} else {
vec![v]
}
})
.collect();
let mut receiver_alternatives = alternatives(receiver);
if receiver_alternatives.iter().all(|v| strings(v).is_some()) {
receiver_alternatives = vec![
json!({"enum": receiver_alternatives.iter().flat_map(|v| strings(v).unwrap()).collect::<Vec<_>>()}),
];
}
for sender_variant in &sender_alternatives {
let mut failure = None;
let mut accepted = false;
for receiver_variant in &receiver_alternatives {
match compare_variants(
sender_variant,
receiver_variant,
sender_doc,
receiver_doc,
active,
) {
Ok(()) => {
accepted = true;
break;
}
Err(problem) => failure = Some(problem),
}
}
if !accepted {
return Err(failure.unwrap_or((
"no receiver alternative accepts the sender value".into(),
sample(sender_variant, sender_doc, &mut HashSet::new()),
)));
}
}
Ok(())
})();
active.remove(&pair);
result
}
/// Compares individual alternatives and wraps nested counterexamples in their parent values.
fn compare_variants(
sender: &Value,
receiver: &Value,
sender_doc: &Value,
receiver_doc: &Value,
active: &mut HashSet<(usize, usize)>,
) -> Result<(), Failure> {
if sender.get("$ref").is_some() || receiver.get("$ref").is_some() {
return subset(sender, receiver, sender_doc, receiver_doc, active);
}
let fail = |message: String| (message, sample(sender, sender_doc, &mut HashSet::new()));
let (sender_type, receiver_type) = (kind(sender), kind(receiver));
if sender_type == "number" && receiver_type == "integer" {
let low = sender["minimum"]
.as_f64()
.unwrap_or_else(|| sender["maximum"].as_f64().map_or(0.0, |v| v.floor() - 1.0));
let high = sender["maximum"].as_f64().unwrap_or(low + 2.0);
let value = if low.fract() != 0.0 { low } else { low + 0.5 };
return Err((
"the sender sends number, but the receiver expects integer".into(),
(value <= high).then(|| json!(value)),
));
}
if receiver == false
|| (receiver_type != "any"
&& sender_type != receiver_type
&& !(sender_type == "integer" && receiver_type == "number"))
{
return Err(fail(format!(
"the sender sends {sender_type}, but the receiver expects {receiver_type}"
)));
}
if receiver_type == "any" {
return Ok(());
}
match sender_type {
"object" => {
for key in receiver["required"].as_array().into_iter().flatten() {
if !sender["required"]
.as_array()
.is_some_and(|v| v.contains(key))
{
return Err(fail(format!(
"the receiver requires the field {key} ({}), but the sender does not send it",
kind(resolve(
&receiver["properties"][key.as_str().unwrap_or_default()],
receiver_doc
))
)));
}
}
for (key, child) in sender["properties"]
.as_object()
.into_iter()
.flat_map(|m| m.iter())
{
let error = if let Some(other) = receiver["properties"].get(key) {
subset(child, other, sender_doc, receiver_doc, active).err()
} else if receiver["additionalProperties"] == false {
Some((
format!(
"the receiver rejects the field {key:?} ({}), which the sender can send",
kind(resolve(child, sender_doc))
),
sample(child, sender_doc, &mut HashSet::new()),
))
} else {
None
};
if let Some((message, example)) = error {
let mut object = sample(sender, sender_doc, &mut HashSet::new());
if let (Some(Value::Object(object)), Some(value)) = (&mut object, example) {
object.insert(key.clone(), value);
} else {
object = None;
}
return Err((
format!("the field {key:?} is incompatible: {message}"),
object,
));
}
}
}
"array" => {
if let Err((message, example)) = subset(
sender.get("items").unwrap_or(&Value::Bool(true)),
receiver.get("items").unwrap_or(&Value::Bool(true)),
sender_doc,
receiver_doc,
active,
) {
return Err((message, example.map(|v| json!([v]))));
}
}
"integer" | "number" => {
for (key, low) in [("minimum", true), ("maximum", false)] {
if let Some(bound) = receiver[key].as_f64()
&& sender[key].as_f64().is_none_or(|v| {
let integer = |v: &Value| {
v.as_i64()
.map(i128::from)
.or_else(|| v.as_u64().map(i128::from))
};
if let (Some(sender), Some(receiver)) =
(integer(&sender[key]), integer(&receiver[key]))
{
if low {
sender < receiver
} else {
sender > receiver
}
} else if low {
v < bound
} else {
v > bound
}
})
{
let witness = sender
.get(key)
.cloned()
.unwrap_or_else(|| json!(if low { bound - 1.0 } else { bound + 1.0 }));
return Err((
format!(
"the sender sends {sender_type} outside the receiver's {key} of {}",
receiver[key]
),
Some(witness),
));
}
}
}
"string" => {
if let Some(allowed) = strings(receiver) {
let bad = match strings(sender) {
Some(values) => values.into_iter().find(|v| !allowed.contains(v)),
None => {
let mut text = "milk".to_string();
while allowed.contains(&json!(text)) {
text.push('!');
}
Some(json!(text))
}
};
if let Some(value) = bad {
return Err((
"the sender sends a string variant outside the receiver's string enum"
.into(),
Some(value),
));
}
}
}
_ => (),
}
Ok(())
}
+35
View File
@@ -0,0 +1,35 @@
//! Shared access to OpenRPC documents and their Sausage extensions.
use serde_json::{Value, json};
/// An OpenRPC document, including JSON Schema and extension fields.
#[derive(Clone, Debug, PartialEq)]
pub struct Document(pub Value);
impl Document {
/// Create an empty main interface.
pub fn new(title: &str, version: &str) -> Self {
Self(
json!({"openrpc":"1.2.6", "info":{"title":title,"version":version},
"methods":[], "components":{"schemas":{}},
"x-sausage-endpoints":{}, "x-sausage-imports":{}}),
)
}
/// Read the method descriptions.
pub fn methods(&self) -> &[Value] {
self.0["methods"]
.as_array()
.map(Vec::as_slice)
.unwrap_or_default()
}
/// Read a named schema.
pub fn schema(&self, name: &str) -> Option<&Value> {
self.0.get("components")?.get("schemas")?.get(name)
}
/// Consume the document as a JSON value.
pub fn into_value(self) -> Value {
self.0
}
}

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