Compare commits

...
Author SHA1 Message Date
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
agent-pool-12 172a776eec cargo: annotate test summary count parsing
ci / check (push) Successful in 5s
2026-10-17 16:00:54 +00:00
agent-pool-12 6a9d35ceb8 cargo: preserve build failures in result wrapper
ci / check (push) Failing after 6m15s
2026-10-17 15:52:37 +00:00
agent-pool-12 22590c608d cargo: run host unit tests before builds
ci / check (push) Failing after 8m51s
2026-10-17 15:50:09 +00:00
Sausage Agent 3bc462ffd2 agent seed: thinking levels low|medium|high|xhigh|max|off on both APIs (card #128)
ci / check (push) Successful in 5s
New config field `thinking` (OpenAI: reasoning_effort still wins): Anthropic adaptive thinking +
output_config.effort (thinking blocks kept and sent back), OpenAI reasoning.effort. A 400 that rejects
the effort is retried with the nearest accepted level (from the error's list, lower on a tie), logged,
never dropped to off.
2026-10-17 15:28:44 +00:00
Sausage Agent f008d0cc60 nix: lock crane; don't store the test run's target folder
ci / check (push) Successful in 39s
2026-10-17 08:02:08 +00:00
Sausage Agent 81daddfd70 nix: build with crane, dependencies cached separately
ci / check (push) Successful in 10m50s
buildDepsOnly builds the dependencies of the host and the tests once
(keyed only on Cargo.toml/Cargo.lock); packages.default and checks.tests
reuse them, so a change to our crates no longer recompiles every crate.
checks.package builds the binary the host loop runs.
2026-10-17 07:49:54 +00:00
agent-pool-9 e8df22fe06 host tests: reuse the existing drain helper
ci / check (push) Successful in 8s
2026-10-17 07:23:30 +00:00
agent-pool-9 0995824c01 Watch the world folder: auto spawn/upgrade, broker watch/unwatch, retire scan
ci / check (push) Failing after 7m36s
The host watches the whole world folder recursively with the notify crate
(any writer counts, .sausage-tmp-* skipped, ~100 ms coalescing per path).
A new or changed actor/<id>/actor.wasm (or new folder actor/<id>) is
booted as at start once quiet: new = spawn (tested first), changed =
upgrade. The broker `scan` request is gone; the boot pass stays, plus a
full pass when the OS watcher overflows.

New broker requests watch/unwatch {path} (saved in actor.json `watches`)
deliver fs_changed {path, op: write|create|remove|rename, to?} from `fs`,
not routed, dropped when the mailbox is full; overflow sends every watcher
one fs_changed {path:"/actor", op:"rescan"}.

Card #91 (multi-host step 1).
2026-10-17 07:12:04 +00:00
agent-pool-8 6a136c1b17 Borrow host descriptors while tracking VFS paths
ci / check (push) Successful in 19s
2026-10-16 21:47:17 +00:00
agent-pool-8 3e90cd73d3 Keep discovery test file at served root 2026-10-16 21:47:17 +00:00
agent-pool-8 845c961f4f Expose advertised actor-served files in directory listings 2026-10-16 21:47:17 +00:00
Sausage agent d6d3c9a2f2 Bare sausage creates ./world if needed and runs it (default app)
ci / check (push) Successful in 5s
- cli: the subcommand is optional; without one, sausage runs ./world and
  runs init first when ./world is not a world yet (test: cli/tests/cli.rs).
  So `nix run git+https://gitea.surma.technology/surma/sausage` works.
- flake: .#loop without a folder runs bare sausage.
- README: mention the bare command and the bare loop.
2026-10-16 21:09:06 +00:00
Sausage Agent 0951ba18e1 README: run straight from the public URL (nix run git+https://...), clone optional
ci / check (push) Successful in 8s
2026-10-16 20:55:39 +00:00
Sausage Agent d0876626df Public repo: .#loop fetches over public HTTPS, README for outsiders
ci / check (push) Successful in 8s
- flake.nix: .#loop runs git+https://gitea.surma.technology/surma/sausage
  (SAUSAGE_FLAKE overrides it); drop loop-internal / --internal (LAN SSH).
- README: clone step, LLM token requirement, loop URL and SAUSAGE_FLAKE.
- ci.yml: comment without internal host name.
2026-10-16 20:40:26 +00:00
Sausage Agent c1f182d9b6 broker: remove op deletes an actor (on top of tap/untap)
ci / check (push) Successful in 8s
{"type":"remove","actor"} shuts the actor down normally, drops its
mailbox, supervisor and actor.json entry (so also its subscriptions and
tap), and deletes /actor/<id>/. Native actors cannot be removed.
Re-applied from card-1-broker-remove (3c91747) onto card-2-broker-tap.
2026-10-16 11:48:50 +00:00
Sausage Agent 49453fa9af broker: tap/untap = receive a copy of every routed message
ci / check (push) Successful in 12m13s
Tapped copies {type:tapped, message:{id,from,to,reply_to,body}, ts} are
delivered directly (never tapped themselves), dropped when the tapper's
mailbox is full, and the tap is saved in actor.json like subscriptions.
2026-10-16 11:14:09 +00:00
Sausage Agent 2ff30ecf70 Actor-served files: /actor/<id>/fs/
ci / check (push) Successful in 10s
File operations under /actor/<id>/fs/ become messages from `fs` to <id>
(fs_read, fs_write, fs_mkdir, fs_remove, fs_rename) with a 10 s timeout.
vfs.rs wraps the wasi:filesystem implementation of wasmtime-wasi.
2026-10-16 01:25:11 +00:00
24 changed files with 2593 additions and 168 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Runs on the Nix runner on nexus (label `nixos`, host executor).
# Runs on a self-hosted Nix runner (label `nixos`, host executor).
name: ci
on: [push, pull_request]
Generated
+97
View File
@@ -678,6 +678,15 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]]
name = "futures"
version = "0.3.34"
@@ -1066,6 +1075,26 @@ dependencies = [
"serde_core",
]
[[package]]
name = "inotify"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
dependencies = [
"bitflags",
"inotify-sys",
"libc",
]
[[package]]
name = "inotify-sys"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d"
dependencies = [
"libc",
]
[[package]]
name = "instant"
version = "0.1.13"
@@ -1165,6 +1194,26 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "kqueue"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea"
dependencies = [
"kqueue-sys",
"libc",
]
[[package]]
name = "kqueue-sys"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [
"bitflags",
"libc",
]
[[package]]
name = "leb128"
version = "0.2.7"
@@ -1259,6 +1308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"log",
"wasi",
"windows-sys 0.61.2",
]
@@ -1269,6 +1319,33 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "notify"
version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags",
"fsevent-sys",
"inotify",
"kqueue",
"libc",
"log",
"mio",
"notify-types",
"walkdir",
"windows-sys 0.60.2",
]
[[package]]
name = "notify-types"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
"bitflags",
]
[[package]]
name = "object"
version = "0.40.0"
@@ -1580,6 +1657,15 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "sausage"
version = "0.1.0"
@@ -1590,6 +1676,7 @@ dependencies = [
"http",
"http-body-util",
"include_dir",
"notify",
"serde",
"serde_json",
"tokio",
@@ -2075,6 +2162,16 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "want"
version = "0.3.2"
+112 -14
View File
@@ -63,12 +63,14 @@ Each instance sees three folders:
| Path | Access | Contents |
| --- | --- | --- |
| `/data` | read-write | the actor's state |
| `/data` | read-write by its actor; readable by every actor | the actor's state |
| `/public` | read-write | the files that the actor publishes |
| `/actor` | read-only | the `actor/` folder of the world |
An actor can write only its own `/data`, but can read every actor's `/data` through `/actor/<id>/data/`. `/data` is not private: any unprotected secret file there is readable by every actor.
`/actor/<id>/` holds everything about an actor: `actor.wasm`, `actor.json`
(installed version, running, subscriptions), `source/`, `public/`, `data/`,
(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
@@ -78,12 +80,47 @@ so that readers never see half of it, write a temporary file and `rename` it
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 |
| --- | --- |
| `{"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"}` |
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).
- 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).
## The broker
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`, `subscribed`, `unsubscribed`,
`scanned`, `exiting`), or `{"type": "error", "reason": "..."}`.
`rolled_back`, `stopped`, `started`, `removed`, `subscribed`, `unsubscribed`,
`tapping`, `untapped`, `watching`, `unwatched`, `exiting`), or `{"type": "error", "reason": "..."}`.
| `type` | Fields | Effect |
| --- | --- | --- |
@@ -91,12 +128,59 @@ the request: the request's `type` in past tense (`spawned`, `upgraded`,
| `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 |
| `scan` | | boot new or changed `actor.wasm` files |
| `exit` | | answer `exiting`, stop every actor, and end the `sausage` process (a wrapper like `nix run .#loop` starts it again, updated) |
| `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 with exit code 75 (a wrapper like `nix run .#loop` starts it again, updated) |
**Headless.** `sausage run --headless [dir]` (or `sausage --headless`, or `SAUSAGE_HEADLESS=1`) turns the console input off: the host does not read stdin, so end of input (`/dev/null`) does not stop the world. `nix run .#loop -- --headless <world>` passes it through the loop. It runs until SIGTERM or SIGINT (stops every actor, exit code 0) or a broker `exit` request (exit code 75, as above). Without the flag, EOF on stdin or `/quit` ends the host.
Paths are `/actor/...`, or the sender's `/public/...` or `/data/...`.
**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}
```
- `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.
**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`):
```json
{"type": "fs_changed", "path": "/actor/notes/public/list.md", "op": "write"}
```
- `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.
**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>/`
@@ -135,11 +219,15 @@ 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 folder is the truth.** At start, and on `scan`, the broker boots every
`actor/<id>/actor.wasm`. If the file differs from the installed version, it
is a new version, tested with the tests in `actor/<id>/source/`. If its
tests fail, the file stays where it is, the reason goes to `stderr.log`, and
the next scan tries again.
**The 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.
## The `cargo` actor
@@ -147,9 +235,19 @@ the next scan tries again.
{"type": "build", "source": "/actor/agent/public/drafts/notes"}
```
The reply is `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", "built_by": "cargo", ...}`
or `{"type": "build_failed", "errors": "..."}`. Pass `wasm`, `source`, and
`built_by` on to `spawn` or `upgrade`.
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.
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`.
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
+38 -10
View File
@@ -10,28 +10,55 @@ change its own code.
## Start
You need [Nix](https://nixos.org/download) with flakes.
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:
```sh
nix run -- init # creates ./world and builds the agent (a few minutes)
nix run -- run # starts the world and a console
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
existing world. The two steps on their own:
```sh
nix run git+https://gitea.surma.technology/surma/sausage -- init # creates ./world and builds the agent
nix run git+https://gitea.surma.technology/surma/sausage -- run # starts the world and a console
```
`init` asks 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 `nix run -- run my-world`.
folder as an argument, for example `... -- run my-world`.
To keep a world on the newest version, run it in a loop:
```sh
nix run .#loop -- my-world # or .#loop-internal (Gitea on the LAN)
nix run git+https://gitea.surma.technology/surma/sausage#loop # ./world, created if needed
nix run git+https://gitea.surma.technology/surma/sausage#loop -- my-world # an existing world
```
The loop runs `nix run --refresh git+ssh://…/surma/sausage -- run my-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.
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.
From a clone (`git clone https://gitea.surma.technology/surma/sausage`), the
same commands are `nix run`, `nix run -- init`, `nix run -- run` and
`nix run .#loop`.
### Headless
`--headless` (or `SAUSAGE_HEADLESS=1`) turns the console input off: the host
never reads stdin, so end of input does not stop the world. It runs until
SIGTERM/SIGINT (clean stop, exit code 0) or a broker `exit` request (exit
code 75, so the loop starts the newest version). Create the world first
(`init`), then pass the flag through the loop:
```sh
nix run git+https://gitea.surma.technology/surma/sausage#loop -- --headless my-world
```
In the console:
@@ -51,14 +78,15 @@ 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
data/ actor state; owner-writable, world-readable via /actor/<id>/data/ (not private)
stderr.log what the actor and the broker write about it
versions/<n>/ every installed version: actor.wasm, source/, meta.json
```
To add or replace an actor by hand, put an `actor.wasm` (and its `source/`,
with the tests in `source/tests/`) into its folder. Then restart the world or
send `@broker {"type":"scan"}`. The broker tests the file before it uses it.
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.
## More
+84 -10
View File
@@ -14,11 +14,49 @@ pub struct Config {
pub base_url: String,
pub model: String,
pub token: String,
/// Optional reasoning effort for OpenAI models ("low", "medium", ...).
/// Optional reasoning effort for OpenAI models only (wins over `thinking` there).
#[serde(default)]
pub reasoning_effort: Option<String>,
#[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".
#[serde(default)]
pub thinking: Option<String>,
}
/// Thinking levels, lowest first.
pub const LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
/// The level nearest to `want` (lower on a tie) among those a 400 error lists in quotes (else all levels), not
/// yet `tried`. None: nothing left, so the request goes out without an effort field (model default).
fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option<String> {
let listed: Vec<&str> = error.split('\'').skip(1).step_by(2).filter(|w| LEVELS.contains(w)).collect();
let pool = if listed.iter().any(|l| *l != want) { listed } else { LEVELS.to_vec() };
let at = LEVELS.iter().position(|l| *l == want).unwrap_or(1) as i32;
pool.into_iter()
.filter(|l| *l != want && !tried.iter().any(|t| t == l))
.min_by_key(|l| {
let i = LEVELS.iter().position(|x| x == l).unwrap_or(1) as i32;
((i - at).abs(), i > at)
})
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::fallback_effort;
#[test]
fn 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"));
// 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"));
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -32,7 +70,7 @@ pub enum Entry {
},
ToolCall { id: String, name: String, arguments: String },
ToolResult { id: String, output: String },
/// A provider item that only this API shape understands (OpenAI reasoning).
/// A provider item that only this API shape understands (OpenAI reasoning, Anthropic thinking).
Opaque { api: String, item: Value },
}
@@ -43,10 +81,28 @@ pub struct Tool {
}
pub async fn complete(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
match cfg.api.as_str() {
"openai-responses" => openai(cfg, system, tools, history).await,
"anthropic-messages" => anthropic(cfg, system, tools, history).await,
other => Err(format!("unknown API shape {other:?}")),
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");
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,
other => return Err(format!("unknown API shape {other:?}")),
};
match (result, effort.clone()) {
// The model rejected the effort level: retry with the nearest accepted one (thinking stays on).
(Err(e), Some(current)) if e.starts_with("HTTP 400") && e.contains("effort") => {
tried.push(current.clone());
effort = fallback_effort(want.as_deref().unwrap_or(""), &e, &tried);
eprintln!("agent: {} rejected thinking effort {current:?}; retrying with {effort:?}: {e}", cfg.model);
}
(result, _) => return result,
}
}
}
@@ -67,7 +123,7 @@ async fn post(url: &str, headers: &[(&str, String)], body: &Value) -> Result<Val
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]) -> Result<Vec<Entry>, String> {
async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry], effort: Option<&str>) -> Result<Vec<Entry>, String> {
let mut input = Vec::new();
for e in history {
match e {
@@ -104,7 +160,7 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -
if let Some(max_tokens) = cfg.max_tokens {
body["max_output_tokens"] = json!(max_tokens);
}
if let Some(effort) = &cfg.reasoning_effort {
if let Some(effort) = effort {
body["reasoning"] = json!({"effort": effort});
}
let url = format!("{}/v1/responses", cfg.base_url);
@@ -143,7 +199,14 @@ async fn openai(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -
Ok(out)
}
async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]) -> Result<Vec<Entry>, String> {
async fn anthropic(
cfg: &Config,
system: &str,
tools: &[Tool],
history: &[Entry],
thinking: bool,
effort: Option<&str>,
) -> Result<Vec<Entry>, String> {
// Merge consecutive blocks of the same role into one message.
let mut messages: Vec<(String, Vec<Value>)> = Vec::new();
let mut push = |role: &str, block: Value| match messages.last_mut() {
@@ -163,6 +226,8 @@ async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]
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 { .. } => {}
}
}
@@ -171,13 +236,19 @@ async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]
.iter()
.map(|t| json!({"name": t.name, "description": t.description, "input_schema": t.parameters}))
.collect();
let body = json!({
let mut body = json!({
"model": cfg.model,
"system": system,
"messages": messages,
"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});
}
}
let url = format!("{}/v1/messages", cfg.base_url);
let resp = post(
&url,
@@ -199,6 +270,9 @@ async fn anthropic(cfg: &Config, system: &str, tools: &[Tool], history: &[Entry]
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 })
}
_ => {}
}
}
+2
View File
@@ -3,3 +3,5 @@ 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`.
The global filesystem is readable by every actor, including every actor's `/data` at `/actor/<id>/data/`. An actor can write only its own `/data` and `/public`. `/data` is not private: any unprotected secret file there is readable by every actor.
+1
View File
@@ -15,6 +15,7 @@ clap = { version = "4", features = ["derive"] }
http = "1"
http-body-util = "0.1"
include_dir = "0.7"
notify = "8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
+203 -22
View File
@@ -1,10 +1,12 @@
//! The broker: message routing, mailboxes, subscriptions, the version store,
//! and the requests to `broker` (spawn, upgrade, rollback, stop, start,
//! subscribe, unsubscribe, scan, exit). `supervisor.rs` runs each WASM actor.
//! 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: `scan` boots every
//! `actor/<id>/actor.wasm` and treats a changed file as an upgrade.
//! 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;
@@ -20,6 +22,7 @@ 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;
@@ -60,12 +63,15 @@ pub struct Broker {
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(Self {
Ok(Arc::new_cyclic(|me| Self {
world,
runtime,
mailboxes: Mutex::new(HashMap::new()),
@@ -74,9 +80,27 @@ impl Broker {
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.
@@ -111,17 +135,23 @@ impl Broker {
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 => self
.metas
.lock()
.unwrap()
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(),
@@ -134,6 +164,30 @@ impl Broker {
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();
@@ -155,6 +209,13 @@ impl Broker {
}
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),
@@ -201,6 +262,8 @@ impl Broker {
network: scratch.is_none(),
stderr_log: own.join("stderr.log"),
env,
actor: id.to_string(),
broker: self.me.clone(),
}
}
@@ -320,31 +383,119 @@ impl Broker {
Ok(result)
}
/// Boot the actors in `actor/`. Called at start and on `scan`.
/// 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 for the next scan.
/// 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 NATIVE.contains(&id.as_str()) || !self.world.wasm_path(&id).is_file() {
continue;
}
let lock = self.op_lock(&id);
let _guard = lock.lock().await;
if let Some(r) = self.scan_actor(&id).await {
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> {
let meta = self.world.read_meta(id);
// 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());
self.metas.lock().unwrap().insert(id.to_string(), meta.clone());
if let Some(v) = installed
&& !self.is_supervised(id)
{
@@ -424,6 +575,7 @@ impl Broker {
}
"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";
@@ -436,14 +588,38 @@ impl Broker {
})?;
Ok(json!({"type": format!("{ty}d"), "actor": publisher}))
}
"scan" => Ok(json!({"type": "scanned", "changes": self.scan().await})),
"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, subscribe, unsubscribe, scan, exit"),
other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, remove, subscribe, unsubscribe, tap, untap, watch, unwatch, exit"),
}
}
@@ -470,6 +646,11 @@ 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();
+137 -11
View File
@@ -1,8 +1,9 @@
//! The native `cargo` actor: builds Rust source folders into `.wasm` files.
//!
//! Request: `{"type": "build", "source": "/actor/<id>/public/<folder>"}`
//! Replies: `{"type": "built", "wasm": "/actor/cargo/public/builds/<n>/<name>.wasm", ...}`
//! `{"type": "build_failed", "errors": "..."}`
//! 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.
@@ -32,11 +33,49 @@ 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.
@@ -51,19 +90,44 @@ impl Cargo {
Ok(Self { world })
}
/// Build the crate in `source` (a host path) and publish the `.wasm` file
/// under `/actor/cargo/public/builds/<n>/`.
pub async fn build(&self, source: &Path) -> Result<BuildOutput> {
/// 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() {
bail!("{} has no Cargo.toml", source.display());
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");
std::fs::write(&manifest, prepare_manifest(&std::fs::read_to_string(&manifest)?, Some(&self.world.public_dir(ID).join("sdk")))?)?;
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")
@@ -71,7 +135,7 @@ impl Cargo {
.arg(TARGET)
.arg("--message-format=json-render-diagnostics")
.arg("--target-dir")
.arg(data.join("target"))
.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?)")?;
@@ -95,7 +159,15 @@ impl Cargo {
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) })
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.
@@ -106,7 +178,7 @@ impl Cargo {
continue;
}
let body = self.handle(&broker, &env).await.unwrap_or_else(|e| {
json!({"type": "build_failed", "source": env.body.get("source"), "errors": format!("{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);
}
@@ -117,7 +189,9 @@ impl Cargo {
Some("build") => {
let source_s = env.body.get("source").and_then(Value::as_str).ok_or_else(|| anyhow!("missing \"source\""))?;
let source = broker.world.resolve(&env.from, source_s)?;
let out = self.build(&source).await?;
let 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",
@@ -126,13 +200,65 @@ impl Cargo {
"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> {
+2 -1
View File
@@ -3,7 +3,8 @@
//! - `@<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.
//! - `/help`, `/quit`. EOF on stdin also quits (not with `--headless`, which
//! never reads stdin).
use std::sync::Arc;
+16 -7
View File
@@ -1,7 +1,8 @@
//! Sausage: grow a system of WASI actors by talking to it.
//!
//! The host runs the broker, the native actors (`broker`, `cargo`, and the
//! console), and one supervisor per WASM actor. See `PROTOCOL.md`.
//! 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;
@@ -9,6 +10,8 @@ pub mod cargo_actor;
pub mod console;
pub mod runtime;
pub mod supervisor;
pub mod vfs;
pub mod watch;
pub mod world;
use std::path::Path;
@@ -29,12 +32,13 @@ 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`, and every registered actor of the world at
/// `root`. Only one host can run a world at a time.
/// 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() {
@@ -52,10 +56,14 @@ impl Host {
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, _lock: lock })
Ok(Host { world, broker, native, watcher, _lock: lock })
}
/// Register a native actor with the ID `id` and return its mailbox.
@@ -63,9 +71,10 @@ impl Host {
self.broker.open_mailbox(id)
}
/// Stop the broker and `cargo` (with the requests they still run), then
/// every actor, gracefully.
pub async fn stop(self) {
/// 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();
}
+53 -7
View File
@@ -5,6 +5,7 @@ use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use include_dir::{Dir, include_dir};
use serde_json::json;
use tokio::signal::unix::Signal;
use sausage::world::{ActorMeta, World};
use sausage::{Host, cargo_actor, console};
@@ -12,11 +13,18 @@ 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.
#[derive(Parser)]
#[command(version, about)]
struct Cli {
/// Do not read stdin, so end of input does not stop the world. It runs
/// until SIGTERM (or SIGINT), or until an actor sends
/// `@broker {"type":"exit"}`. Also set by SAUSAGE_HEADLESS=1.
#[arg(long, global = true)]
headless: bool,
#[command(subcommand)]
command: Cmd,
command: Option<Cmd>,
}
#[derive(Subcommand)]
@@ -83,19 +91,47 @@ async fn init(dir: PathBuf) -> Result<()> {
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).await.context("build agent")?;
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<()> {
/// Install the SIGTERM and SIGINT handlers now (not when first polled).
fn termination_signals() -> Result<(Signal, Signal)> {
use tokio::signal::unix::{SignalKind, signal};
let term = signal(SignalKind::terminate()).context("install SIGTERM handler")?;
let int = signal(SignalKind::interrupt()).context("install SIGINT handler")?;
Ok((term, int))
}
/// Resolves on SIGTERM or SIGINT.
async fn termination((mut term, mut int): (Signal, Signal)) {
tokio::select! {
_ = term.recv() => {}
_ = int.recv() => {}
}
}
async fn run(dir: PathBuf, headless: bool) -> Result<()> {
// Register the signal handlers before the host starts, so a signal that
// arrives early is never the default (kill) action.
let signals = if headless { Some(termination_signals()?) } else { None };
let host = Host::start(&dir).await?;
tokio::spawn(console::run_mailbox(host.mailbox(console::ID)));
let broker = host.broker.clone();
let exit = tokio::select! {
let exit = match signals {
Some(signals) => {
println!("Sausage headless: running until SIGTERM or an exit request.");
tokio::select! {
_ = termination(signals) => false,
_ = broker.exit_requested() => true,
}
}
None => tokio::select! {
_ = console::run_input(host.broker.clone()) => false,
_ = broker.exit_requested() => true,
},
};
println!("Stopping actors …");
host.stop().await;
@@ -111,8 +147,18 @@ async fn run(dir: PathBuf) -> Result<()> {
#[tokio::main]
async fn main() -> Result<()> {
match Cli::parse().command {
Cmd::Init { dir } => init(dir).await,
Cmd::Run { dir } => run(dir).await,
let cli = Cli::parse();
let headless = cli.headless || std::env::var("SAUSAGE_HEADLESS").is_ok_and(|v| !v.is_empty() && v != "0");
match cli.command {
Some(Cmd::Init { dir }) => init(dir).await,
Some(Cmd::Run { dir }) => run(dir, headless).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, headless).await
}
}
}
+21 -1
View File
@@ -4,7 +4,7 @@
//! NDJSON lines to its stdin and reads NDJSON lines from its stdout.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::{Arc, Weak};
use std::time::Duration;
use anyhow::{Context, Result};
@@ -14,10 +14,15 @@ 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_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 {
@@ -65,6 +70,11 @@ pub struct ActorState {
table: ResourceTable,
http: WasiHttpCtx,
hooks: HttpHooks,
vfs: VfsState,
}
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 {
@@ -111,6 +121,10 @@ pub struct InstanceConfig {
pub network: bool,
pub stderr_log: PathBuf,
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.
@@ -224,6 +238,11 @@ impl Runtime {
let mut linker: Linker<ActorState> = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_async(&mut linker)?;
wasmtime_wasi_http::p2::add_only_http_to_linker_async(&mut linker)?;
// Replace `wasi:filesystem` with the wrapper that serves `/actor/<id>/fs/`.
linker.allow_shadowing(true);
filesystem::types::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
filesystem::preopens::add_to_linker::<ActorState, Vfs>(&mut linker, vfs_view)?;
linker.allow_shadowing(false);
// Time slices: every 10 ms each running guest yields to the scheduler.
let ticker = engine.clone();
@@ -274,6 +293,7 @@ impl Runtime {
table: ResourceTable::new(),
http: WasiHttpCtx::new(),
hooks: HttpHooks { allow: cfg.network },
vfs: VfsState::new(cfg.broker.clone(), cfg.actor.clone()),
};
let (line_tx, line_rx) = mpsc::channel(1024);
+796
View File
@@ -0,0 +1,796 @@
//! Actor-served files: `/actor/<id>/fs/...` (see PROTOCOL.md, "Actor-served
//! files").
//!
//! This module wraps the `wasi:filesystem` implementation of `wasmtime-wasi`.
//! A path under `/actor/<id>/fs/` does not reach the disk: the host asks the
//! actor `<id>` with a message from `fs` and waits up to `ASK_TIMEOUT` for
//! the reply. Other paths go to `wasmtime-wasi` unchanged except that an
//! actor directory listing advertises `fs/` when its source docs declare it.
//!
//! - `fs_read {path}` -> `fs_file {text | base64}`, `fs_dir {entries: [{name, type}]}`
//! - `fs_write {path, text | base64}`, `fs_mkdir {path}`, `fs_remove {path}`,
//! `fs_rename {path, to}` -> `fs_done`
//! - any of them -> `fs_error {errno}` (a `wasi:filesystem` error-code name)
//!
//! A file is read once when it is opened. After a change, it is written back
//! in one `fs_write` when it is synced or closed.
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, Weak};
use bytes::Bytes;
use serde_json::{Value, json};
use wasmtime::component::{HasData, Resource};
use wasmtime_wasi::filesystem::WasiFilesystemCtxView;
use wasmtime_wasi::p2::bindings::filesystem::types::{
self, DescriptorFlags, DescriptorType, DirectoryEntry, ErrorCode, HostDescriptor, HostDirectoryEntryStream, OpenFlags,
};
use wasmtime_wasi::p2::bindings::filesystem::preopens;
use wasmtime_wasi::p2::pipe::MemoryInputPipe;
use wasmtime_wasi::p2::{DynInputStream, DynOutputStream, FsError, FsResult, OutputStream, Pollable, StreamResult};
use crate::broker::Broker;
/// The `from` of the host's requests. Replies to it go back to the waiting call.
pub const ID: &str = "fs";
pub const ASK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// Per-instance state.
pub struct VfsState {
broker: Weak<Broker>,
/// The actor of this instance, for notes in its `stderr.log`.
me: String,
/// Native directory descriptors and their absolute paths. Keeping every
/// `/actor` child (not only the preopen) lets `fs/` work after traversal.
dirs: HashMap<u32, String>,
}
impl VfsState {
pub fn new(broker: Weak<Broker>, me: String) -> Self {
Self { broker, me, dirs: HashMap::new() }
}
}
/// [`HasData`] for the linker: the filesystem functions get a [`VfsView`].
pub struct Vfs;
impl HasData for Vfs {
type Data<'a> = VfsView<'a>;
}
pub struct VfsView<'a> {
pub inner: WasiFilesystemCtxView<'a>,
pub state: &'a mut VfsState,
}
/// A descriptor of an actor-served file or folder (in the resource table in
/// place of a `wasmtime-wasi` descriptor).
#[derive(Clone)]
enum Virt {
Dir { actor: String, path: String },
File(VFile),
}
#[derive(Clone)]
struct VFile {
actor: String,
path: String,
data: Arc<Mutex<Vec<u8>>>,
dirty: Arc<AtomicBool>,
write: bool,
}
/// A directory-entry-stream of an actor-served folder.
struct VDir(std::vec::IntoIter<DirectoryEntry>);
enum Node {
File(Vec<u8>),
Dir(Vec<DirectoryEntry>),
}
/// Sends the requests. Owned, so that no borrow of the view lives across an await.
struct Asker {
broker: Option<Arc<Broker>>,
me: String,
}
impl Asker {
async fn ask(&self, actor: &str, body: Value) -> Result<Value, ErrorCode> {
let broker = self.broker.as_ref().ok_or(ErrorCode::Io)?;
let what = format!("{} {} to {actor}", body["type"].as_str().unwrap_or(""), body["path"].as_str().unwrap_or(""));
match broker.ask(actor, body).await {
None => {
broker.log(&self.me, &format!("{what}: no reply within {} s", ASK_TIMEOUT.as_secs()));
Err(ErrorCode::Io)
}
Some(reply) => match reply["type"].as_str() {
Some("fs_error") => Err(errno(&reply["errno"])),
Some("undeliverable") => Err(ErrorCode::NoEntry),
_ => Ok(reply),
},
}
}
async fn read(&self, actor: &str, path: &str) -> Result<Node, ErrorCode> {
let reply = self.ask(actor, json!({"type": "fs_read", "path": path})).await?;
match reply["type"].as_str() {
Some("fs_file") => content(&reply).map(Node::File),
Some("fs_dir") => Ok(Node::Dir(
reply["entries"]
.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let type_ = if e["type"] == "dir" { DescriptorType::Directory } else { DescriptorType::RegularFile };
Some(DirectoryEntry { type_, name: e["name"].as_str()?.to_string() })
})
.collect(),
)),
_ => {
if let Some(b) = &self.broker {
b.log(&self.me, &format!("fs_read {path} to {actor}: unexpected reply {reply}"));
}
Err(ErrorCode::Io)
}
}
}
async fn change(&self, actor: &str, body: Value) -> FsResult<()> {
self.ask(actor, body).await.map(|_| ()).map_err(FsError::from)
}
async fn flush(&self, f: &VFile) -> FsResult<()> {
if f.dirty.swap(false, Ordering::SeqCst)
&& let Err(e) = self.ask(&f.actor, write_body(f)).await
{
f.dirty.store(true, Ordering::SeqCst);
return Err(e.into());
}
Ok(())
}
}
fn errno(v: &Value) -> ErrorCode {
match v.as_str().unwrap_or("") {
"no-entry" => ErrorCode::NoEntry,
"exist" => ErrorCode::Exist,
"not-directory" => ErrorCode::NotDirectory,
"is-directory" => ErrorCode::IsDirectory,
"not-empty" => ErrorCode::NotEmpty,
"access" => ErrorCode::Access,
"not-permitted" => ErrorCode::NotPermitted,
"invalid" => ErrorCode::Invalid,
"unsupported" => ErrorCode::Unsupported,
"busy" => ErrorCode::Busy,
_ => ErrorCode::Io,
}
}
fn content(v: &Value) -> Result<Vec<u8>, ErrorCode> {
if let Some(text) = v["text"].as_str() {
return Ok(text.as_bytes().to_vec());
}
match v["base64"].as_str() {
Some(b) => base64_decode(b).ok_or(ErrorCode::IllegalByteSequence),
None => Ok(Vec::new()),
}
}
fn write_body(f: &VFile) -> Value {
let data = f.data.lock().unwrap().clone();
match String::from_utf8(data) {
Ok(text) => json!({"type": "fs_write", "path": f.path, "text": text}),
Err(e) => json!({"type": "fs_write", "path": f.path, "base64": base64_encode(e.as_bytes())}),
}
}
/// `rel` resolved against the folder `base`, as an absolute path.
fn join(base: &str, rel: &str) -> String {
let mut parts: Vec<&str> = base.split('/').filter(|p| !p.is_empty()).collect();
for p in rel.split('/') {
match p {
"" | "." => {}
".." => {
parts.pop();
}
p => parts.push(p),
}
}
format!("/{}", parts.join("/"))
}
/// Split a global path under an actor's virtual `fs/` into its owner and
/// actor-relative path.
fn actor_fs_path(full: &str) -> Option<(String, String)> {
let rest = full.strip_prefix("/actor/")?;
let (actor, rest) = rest.split_once('/')?;
let (fs, path) = rest.split_once('/').unwrap_or((rest, ""));
(fs == "fs" && !actor.is_empty()).then(|| (actor.to_string(), format!("/{path}")))
}
fn actor_root(path: &str) -> Option<&str> {
let actor = path.strip_prefix("/actor/")?;
(!actor.is_empty() && !actor.contains('/')).then_some(actor)
}
/// An actor advertises its virtual filesystem in its leading `//!` docs.
/// This is discovery metadata; actual reads still go through `fs_read`.
fn declares_actor_fs(source: &str) -> bool {
let docs = source
.lines()
.take_while(|line| line.trim().starts_with("//!"))
.map(|line| line.trim().trim_start_matches("//!").trim())
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
docs.contains("actor-served files") && docs.contains("fs_read")
}
fn stat(type_: DescriptorType, size: u64) -> types::DescriptorStat {
types::DescriptorStat {
type_,
link_count: 1,
size,
data_access_timestamp: None,
data_modification_timestamp: None,
status_change_timestamp: None,
}
}
fn node_stat(node: &Node) -> types::DescriptorStat {
match node {
Node::File(data) => stat(DescriptorType::RegularFile, data.len() as u64),
Node::Dir(_) => stat(DescriptorType::Directory, 0),
}
}
fn hash(actor: &str, path: &str) -> types::MetadataHashValue {
let mut h = std::collections::hash_map::DefaultHasher::new();
(actor, path).hash(&mut h);
types::MetadataHashValue { lower: h.finish(), upper: 0 }
}
fn write_at(data: &mut Vec<u8>, at: usize, bytes: &[u8]) {
if data.len() < at + bytes.len() {
data.resize(at + bytes.len(), 0);
}
data[at..at + bytes.len()].copy_from_slice(bytes);
}
/// An output stream into an actor-served file (`pos: None` appends).
struct VWriter {
file: VFile,
pos: Option<usize>,
}
#[wasmtime_wasi::async_trait]
impl Pollable for VWriter {
async fn ready(&mut self) {}
}
#[wasmtime_wasi::async_trait]
impl OutputStream for VWriter {
fn write(&mut self, bytes: Bytes) -> StreamResult<()> {
let mut data = self.file.data.lock().unwrap();
let at = self.pos.unwrap_or(data.len());
write_at(&mut data, at, &bytes);
if let Some(p) = &mut self.pos {
*p += bytes.len();
}
self.file.dirty.store(true, Ordering::SeqCst);
Ok(())
}
fn flush(&mut self) -> StreamResult<()> {
Ok(())
}
fn check_write(&mut self) -> StreamResult<usize> {
Ok(64 * 1024)
}
}
impl VfsView<'_> {
fn asker(&self) -> Asker {
Asker { broker: self.state.broker.upgrade(), me: self.state.me.clone() }
}
fn actor_serves_fs(&self, actor: &str) -> bool {
self.state
.broker
.upgrade()
.and_then(|broker| std::fs::read_to_string(broker.world.source_dir(actor).join("src/main.rs")).ok())
.is_some_and(|source| declares_actor_fs(&source))
}
fn virt(&mut self, fd: &Resource<types::Descriptor>) -> Option<Virt> {
self.inner.table.get_any_mut(fd.rep()).ok()?.downcast_ref::<Virt>().cloned()
}
fn file(&mut self, fd: &Resource<types::Descriptor>) -> Option<FsResult<VFile>> {
match self.virt(fd)? {
Virt::File(f) => Some(Ok(f)),
Virt::Dir { .. } => Some(Err(ErrorCode::IsDirectory.into())),
}
}
/// The actor and its path, if `path` relative to `fd` is actor-served.
fn target(&mut self, fd: &Resource<types::Descriptor>, path: &str) -> Option<(String, String)> {
match self.virt(fd) {
Some(Virt::Dir { actor, path: base }) => Some((actor, join(&base, path))),
Some(Virt::File(f)) => Some((f.actor, join(&f.path, path))),
None => {
let base = self.state.dirs.get(&fd.rep())?;
actor_fs_path(&join(base, path))
}
}
}
async fn native_directory_entries(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Vec<DirectoryEntry>> {
let stream = HostDescriptor::read_directory(&mut self.inner, fd).await?;
let stream_rep = stream.rep();
let mut entries = Vec::new();
loop {
match HostDirectoryEntryStream::read_directory_entry(&mut self.inner, Resource::new_borrow(stream_rep)).await? {
Some(entry) => entries.push(entry),
None => break,
}
}
HostDirectoryEntryStream::drop(&mut self.inner, stream).map_err(|_| ErrorCode::Io)?;
Ok(entries)
}
fn push(&mut self, v: Virt) -> FsResult<Resource<types::Descriptor>> {
let r = self.inner.table.push(v)?;
Ok(Resource::new_own(r.rep()))
}
async fn open(&mut self, actor: String, path: String, oflags: OpenFlags, flags: DescriptorFlags) -> FsResult<Resource<types::Descriptor>> {
let write = flags.contains(DescriptorFlags::WRITE);
let create = oflags.contains(OpenFlags::CREATE);
let exclusive = create && oflags.contains(OpenFlags::EXCLUSIVE);
let virt = match self.asker().read(&actor, &path).await {
Ok(_) if exclusive => return Err(ErrorCode::Exist.into()),
Ok(Node::Dir(_)) if write => return Err(ErrorCode::IsDirectory.into()),
Ok(Node::Dir(_)) => Virt::Dir { actor, path },
Ok(Node::File(_)) if oflags.contains(OpenFlags::DIRECTORY) => return Err(ErrorCode::NotDirectory.into()),
Ok(Node::File(data)) => {
let truncate = oflags.contains(OpenFlags::TRUNCATE);
let data = if truncate { Vec::new() } else { data };
Virt::File(VFile { actor, path, data: Arc::new(Mutex::new(data)), dirty: Arc::new(AtomicBool::new(truncate)), write })
}
Err(ErrorCode::NoEntry) if create && !oflags.contains(OpenFlags::DIRECTORY) => Virt::File(VFile {
actor,
path,
data: Arc::default(),
dirty: Arc::new(AtomicBool::new(true)),
write,
}),
Err(e) => return Err(e.into()),
};
self.push(virt)
}
/// Send the changed file without waiting (the descriptor is gone). The
/// request leaves now, so later requests of this instance come after it.
fn write_back_later(&self, f: &VFile) {
let Some(broker) = self.state.broker.upgrade() else { return };
let reply = broker.ask(&f.actor, write_body(f));
let (me, what) = (self.state.me.clone(), format!("fs_write {} to {}", f.path, f.actor));
tokio::spawn(async move {
match reply.await {
Some(r) if r["type"] != "fs_error" && r["type"] != "undeliverable" => {}
r => broker.log(&me, &format!("{what} failed: {}", r.map_or("no reply".to_string(), |r| r.to_string()))),
}
});
}
}
impl preopens::Host for VfsView<'_> {
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
let dirs = preopens::Host::get_directories(&mut self.inner)?;
for (fd, name) in &dirs {
if name == "/actor" {
self.state.dirs.insert(fd.rep(), "/actor".to_string());
}
}
Ok(dirs)
}
}
impl types::Host for VfsView<'_> {
fn convert_error_code(&mut self, err: FsError) -> wasmtime::Result<ErrorCode> {
types::Host::convert_error_code(&mut self.inner, err)
}
fn filesystem_error_code(&mut self, err: Resource<wasmtime::Error>) -> wasmtime::Result<Option<ErrorCode>> {
types::Host::filesystem_error_code(&mut self.inner, err)
}
}
impl HostDescriptor for VfsView<'_> {
async fn advise(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize, len: types::Filesize, advice: types::Advice) -> FsResult<()> {
if self.virt(&fd).is_some() {
return Ok(());
}
HostDescriptor::advise(&mut self.inner, fd, offset, len, advice).await
}
async fn sync_data(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
match self.virt(&fd) {
Some(Virt::File(f)) => self.asker().flush(&f).await,
Some(Virt::Dir { .. }) => Ok(()),
None => HostDescriptor::sync_data(&mut self.inner, fd).await,
}
}
async fn get_flags(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorFlags> {
match self.virt(&fd) {
Some(Virt::File(f)) if f.write => Ok(DescriptorFlags::READ | DescriptorFlags::WRITE),
Some(Virt::File(_)) => Ok(DescriptorFlags::READ),
Some(Virt::Dir { .. }) => Ok(DescriptorFlags::READ | DescriptorFlags::MUTATE_DIRECTORY),
None => HostDescriptor::get_flags(&mut self.inner, fd).await,
}
}
async fn get_type(&mut self, fd: Resource<types::Descriptor>) -> FsResult<DescriptorType> {
match self.virt(&fd) {
Some(Virt::File(_)) => Ok(DescriptorType::RegularFile),
Some(Virt::Dir { .. }) => Ok(DescriptorType::Directory),
None => HostDescriptor::get_type(&mut self.inner, fd).await,
}
}
async fn set_size(&mut self, fd: Resource<types::Descriptor>, size: types::Filesize) -> FsResult<()> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
f.data.lock().unwrap().resize(size as usize, 0);
f.dirty.store(true, Ordering::SeqCst);
Ok(())
}
None => HostDescriptor::set_size(&mut self.inner, fd, size).await,
}
}
async fn set_times(&mut self, fd: Resource<types::Descriptor>, atim: types::NewTimestamp, mtim: types::NewTimestamp) -> FsResult<()> {
if self.virt(&fd).is_some() {
return Ok(());
}
HostDescriptor::set_times(&mut self.inner, fd, atim, mtim).await
}
async fn read(&mut self, fd: Resource<types::Descriptor>, len: types::Filesize, offset: types::Filesize) -> FsResult<(Vec<u8>, bool)> {
match self.file(&fd) {
Some(f) => {
let f = f?;
let data = f.data.lock().unwrap();
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
let end = start.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).min(data.len());
Ok((data[start..end].to_vec(), start == end))
}
None => HostDescriptor::read(&mut self.inner, fd, len, offset).await,
}
}
async fn write(&mut self, fd: Resource<types::Descriptor>, buf: Vec<u8>, offset: types::Filesize) -> FsResult<types::Filesize> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
write_at(&mut f.data.lock().unwrap(), offset as usize, &buf);
f.dirty.store(true, Ordering::SeqCst);
Ok(buf.len() as u64)
}
None => HostDescriptor::write(&mut self.inner, fd, buf, offset).await,
}
}
async fn read_directory(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<types::DirectoryEntryStream>> {
match self.virt(&fd) {
Some(Virt::Dir { actor, path }) => match self.asker().read(&actor, &path).await? {
Node::Dir(entries) => {
let r = self.inner.table.push(VDir(entries.into_iter()))?;
Ok(Resource::new_own(r.rep()))
}
Node::File(_) => Err(ErrorCode::NotDirectory.into()),
},
Some(Virt::File(_)) => Err(ErrorCode::NotDirectory.into()),
None => {
let actor = self.state.dirs.get(&fd.rep()).and_then(|path| actor_root(path)).map(str::to_string);
if actor.as_deref().is_some_and(|id| self.actor_serves_fs(id)) {
let mut entries = self.native_directory_entries(fd).await?;
if !entries.iter().any(|entry| entry.name == "fs") {
entries.push(DirectoryEntry { type_: DescriptorType::Directory, name: "fs".to_string() });
}
let r = self.inner.table.push(VDir(entries.into_iter()))?;
Ok(Resource::new_own(r.rep()))
} else {
HostDescriptor::read_directory(&mut self.inner, fd).await
}
}
}
}
async fn sync(&mut self, fd: Resource<types::Descriptor>) -> FsResult<()> {
match self.virt(&fd) {
Some(Virt::File(f)) => self.asker().flush(&f).await,
Some(Virt::Dir { .. }) => Ok(()),
None => HostDescriptor::sync(&mut self.inner, fd).await,
}
}
async fn create_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
match self.target(&fd, &path) {
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_mkdir", "path": path})).await,
None => HostDescriptor::create_directory_at(&mut self.inner, fd, path).await,
}
}
async fn stat(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::DescriptorStat> {
match self.virt(&fd) {
Some(Virt::File(f)) => Ok(stat(DescriptorType::RegularFile, f.data.lock().unwrap().len() as u64)),
Some(Virt::Dir { .. }) => Ok(stat(DescriptorType::Directory, 0)),
None => HostDescriptor::stat(&mut self.inner, fd).await,
}
}
async fn stat_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::DescriptorStat> {
match self.target(&fd, &path) {
Some((actor, path)) => Ok(node_stat(&self.asker().read(&actor, &path).await?)),
None => HostDescriptor::stat_at(&mut self.inner, fd, path_flags, path).await,
}
}
async fn set_times_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
atim: types::NewTimestamp,
mtim: types::NewTimestamp,
) -> FsResult<()> {
if self.target(&fd, &path).is_some() {
return Ok(());
}
HostDescriptor::set_times_at(&mut self.inner, fd, path_flags, path, atim, mtim).await
}
async fn link_at(
&mut self,
fd: Resource<types::Descriptor>,
old_path_flags: types::PathFlags,
old_path: String,
new_descriptor: Resource<types::Descriptor>,
new_path: String,
) -> FsResult<()> {
if self.target(&fd, &old_path).is_some() || self.target(&new_descriptor, &new_path).is_some() {
return Err(ErrorCode::Unsupported.into());
}
HostDescriptor::link_at(&mut self.inner, fd, old_path_flags, old_path, new_descriptor, new_path).await
}
async fn open_at(
&mut self,
fd: Resource<types::Descriptor>,
path_flags: types::PathFlags,
path: String,
oflags: OpenFlags,
flags: DescriptorFlags,
) -> FsResult<Resource<types::Descriptor>> {
match self.target(&fd, &path) {
Some((actor, path)) => self.open(actor, path, oflags, flags).await,
None => {
let child_path = self.state.dirs.get(&fd.rep()).map(|base| join(base, &path));
let opened = HostDescriptor::open_at(&mut self.inner, fd, path_flags, path, oflags, flags).await?;
if let Some(path) = child_path {
if HostDescriptor::get_type(&mut self.inner, Resource::new_borrow(opened.rep())).await? == DescriptorType::Directory {
self.state.dirs.insert(opened.rep(), path);
}
}
Ok(opened)
}
}
}
fn drop(&mut self, fd: Resource<types::Descriptor>) -> wasmtime::Result<()> {
if self.virt(&fd).is_some() {
if let Virt::File(f) = self.inner.table.delete(Resource::<Virt>::new_own(fd.rep()))?
&& f.dirty.load(Ordering::SeqCst)
{
self.write_back_later(&f);
}
return Ok(());
}
self.state.dirs.remove(&fd.rep());
HostDescriptor::drop(&mut self.inner, fd)
}
async fn readlink_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<String> {
if self.target(&fd, &path).is_some() {
return Err(ErrorCode::Invalid.into());
}
HostDescriptor::readlink_at(&mut self.inner, fd, path).await
}
async fn remove_directory_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
match self.target(&fd, &path) {
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
None => HostDescriptor::remove_directory_at(&mut self.inner, fd, path).await,
}
}
async fn rename_at(&mut self, fd: Resource<types::Descriptor>, old_path: String, new_fd: Resource<types::Descriptor>, new_path: String) -> FsResult<()> {
match (self.target(&fd, &old_path), self.target(&new_fd, &new_path)) {
(None, None) => HostDescriptor::rename_at(&mut self.inner, fd, old_path, new_fd, new_path).await,
(Some((a, from)), Some((b, to))) if a == b => {
self.asker().change(&a, json!({"type": "fs_rename", "path": from, "to": to})).await
}
_ => Err(ErrorCode::Unsupported.into()),
}
}
async fn symlink_at(&mut self, fd: Resource<types::Descriptor>, src_path: String, dest_path: String) -> FsResult<()> {
if self.target(&fd, &dest_path).is_some() {
return Err(ErrorCode::Unsupported.into());
}
HostDescriptor::symlink_at(&mut self.inner, fd, src_path, dest_path).await
}
async fn unlink_file_at(&mut self, fd: Resource<types::Descriptor>, path: String) -> FsResult<()> {
match self.target(&fd, &path) {
Some((actor, path)) => self.asker().change(&actor, json!({"type": "fs_remove", "path": path})).await,
None => HostDescriptor::unlink_file_at(&mut self.inner, fd, path).await,
}
}
fn read_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynInputStream>> {
match self.file(&fd) {
Some(f) => {
let f = f?;
let data = f.data.lock().unwrap();
let start = usize::try_from(offset).unwrap_or(usize::MAX).min(data.len());
let stream: DynInputStream = Box::new(MemoryInputPipe::new(data[start..].to_vec()));
std::mem::drop(data);
Ok(self.inner.table.push(stream)?)
}
None => HostDescriptor::read_via_stream(&mut self.inner, fd, offset),
}
}
fn write_via_stream(&mut self, fd: Resource<types::Descriptor>, offset: types::Filesize) -> FsResult<Resource<DynOutputStream>> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: Some(offset as usize) });
Ok(self.inner.table.push(stream)?)
}
None => HostDescriptor::write_via_stream(&mut self.inner, fd, offset),
}
}
fn append_via_stream(&mut self, fd: Resource<types::Descriptor>) -> FsResult<Resource<DynOutputStream>> {
match self.file(&fd) {
Some(f) => {
let f = f?;
if !f.write {
return Err(ErrorCode::NotPermitted.into());
}
let stream: DynOutputStream = Box::new(VWriter { file: f, pos: None });
Ok(self.inner.table.push(stream)?)
}
None => HostDescriptor::append_via_stream(&mut self.inner, fd),
}
}
async fn is_same_object(&mut self, a: Resource<types::Descriptor>, b: Resource<types::Descriptor>) -> wasmtime::Result<bool> {
let key = |v: Virt| match v {
Virt::Dir { actor, path } => (actor, path),
Virt::File(f) => (f.actor, f.path),
};
match (self.virt(&a), self.virt(&b)) {
(None, None) => HostDescriptor::is_same_object(&mut self.inner, a, b).await,
(Some(x), Some(y)) => Ok(key(x) == key(y)),
_ => Ok(false),
}
}
async fn metadata_hash(&mut self, fd: Resource<types::Descriptor>) -> FsResult<types::MetadataHashValue> {
match self.virt(&fd) {
Some(Virt::Dir { actor, path }) => Ok(hash(&actor, &path)),
Some(Virt::File(f)) => Ok(hash(&f.actor, &f.path)),
None => HostDescriptor::metadata_hash(&mut self.inner, fd).await,
}
}
async fn metadata_hash_at(&mut self, fd: Resource<types::Descriptor>, path_flags: types::PathFlags, path: String) -> FsResult<types::MetadataHashValue> {
match self.target(&fd, &path) {
Some((actor, path)) => Ok(hash(&actor, &path)),
None => HostDescriptor::metadata_hash_at(&mut self.inner, fd, path_flags, path).await,
}
}
}
impl HostDirectoryEntryStream for VfsView<'_> {
async fn read_directory_entry(&mut self, stream: Resource<types::DirectoryEntryStream>) -> FsResult<Option<DirectoryEntry>> {
if let Some(dir) = self.inner.table.get_any_mut(stream.rep()).ok().and_then(|a| a.downcast_mut::<VDir>()) {
return Ok(dir.0.next());
}
HostDirectoryEntryStream::read_directory_entry(&mut self.inner, stream).await
}
fn drop(&mut self, stream: Resource<types::DirectoryEntryStream>) -> wasmtime::Result<()> {
let ours = self.inner.table.get_any_mut(stream.rep()).ok().is_some_and(|a| a.is::<VDir>());
if ours {
self.inner.table.delete(Resource::<VDir>::new_own(stream.rep()))?;
return Ok(());
}
HostDirectoryEntryStream::drop(&mut self.inner, stream)
}
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
fn base64_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (*b as u32) << (16 - 8 * i));
for i in 0..4 {
out.push(if i <= chunk.len() { B64[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' });
}
}
out
}
fn base64_decode(text: &str) -> Option<Vec<u8>> {
let (mut out, mut n, mut bits) = (Vec::new(), 0u32, 0);
for c in text.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
n = (n << 6 | B64.iter().position(|b| *b == c)? as u32) & 0xFF_FFFF;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((n >> bits) as u8);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn actor_fs_paths_and_discovery_docs() {
assert_eq!(actor_fs_path("/actor/brain/fs"), Some(("brain".to_string(), "/".to_string())));
assert_eq!(actor_fs_path("/actor/brain/fs/docs/readme.md"), Some(("brain".to_string(), "/docs/readme.md".to_string())));
assert_eq!(actor_fs_path("/actor/brain/public/fs/file"), None);
assert_eq!(actor_root("/actor/brain"), Some("brain"));
assert_eq!(actor_root("/actor/brain/fs"), None);
assert!(declares_actor_fs("//! brain\n//! Actor-served files include fs_read.\nfn main() {}"));
assert!(!declares_actor_fs("//! actor without fs support\nfn main() {}"));
}
#[test]
fn base64_round_trip() {
for s in ["", "a", "ab", "abc", "abcd", "\u{0}\u{ff}binary"] {
let enc = base64_encode(s.as_bytes());
assert_eq!(base64_decode(&enc).unwrap(), s.as_bytes());
}
assert_eq!(base64_encode(b"hello"), "aGVsbG8=");
}
#[test]
fn join_resolves_dots() {
assert_eq!(join("/a/b", "../c/./d"), "/a/c/d");
assert_eq!(join("/", "x/fs"), "/x/fs");
assert_eq!(join("/", ".."), "/");
}
}
+256
View File
@@ -0,0 +1,256 @@
//! 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())),
]
);
}
}
+12 -4
View File
@@ -1,11 +1,12 @@
//! On-disk layout of a Sausage world. The folder is the source of truth:
//! `sausage run` boots every actor that it finds in `actor/`.
//! `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 (broker)
//! 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
@@ -22,8 +23,9 @@ 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.
pub const NATIVE: &[&str] = &["broker", "console", "cargo"];
/// IDs of the actors that the host implements natively. `fs` sends the
/// requests for actor-served files (`vfs.rs`).
pub const NATIVE: &[&str] = &["broker", "console", "cargo", "fs"];
#[derive(Clone)]
pub struct World {
@@ -42,6 +44,12 @@ pub struct ActorMeta {
/// 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 {
+36
View File
@@ -39,6 +39,8 @@ fn main() {
}
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;
@@ -73,6 +75,40 @@ fn main() {
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,
+85
View File
@@ -0,0 +1,85 @@
//! 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());
}
/// Start a headless host on a fresh world with stdin at EOF; returns once it
/// printed its ready line (printed after the signal handlers are installed).
fn start_headless(label: &str) -> (std::process::Child, std::io::Lines<std::io::BufReader<std::process::ChildStdout>>) {
use std::io::{BufRead, BufReader};
let dir = empty_dir(label);
let world = dir.join("world");
sausage::World::create(&world).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_sausage"))
.args(["run", "--headless"])
.arg(&world)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let line = lines.next().expect("host exited before it started").unwrap();
assert!(line.contains("headless"), "unexpected first line: {line}");
(child, lines)
}
/// With stdin at EOF (as soon as the host starts), a headless host stays up.
#[test]
fn headless_ignores_stdin_eof() {
let (mut child, _lines) = start_headless("headless-eof");
std::thread::sleep(std::time::Duration::from_millis(500));
let alive = child.try_wait().unwrap().is_none();
let _ = child.kill();
let _ = child.wait();
assert!(alive, "headless host exited on stdin EOF");
}
/// SIGTERM right after the ready line stops the actors and exits with 0.
#[test]
fn headless_stops_cleanly_on_sigterm_right_after_ready() {
let (mut child, lines) = start_headless("headless-term");
let pid = child.id().to_string();
assert!(Command::new("kill").args(["-TERM", &pid]).status().unwrap().success());
let rest: Vec<String> = lines.map(|l| l.unwrap()).collect();
let status = child.wait().unwrap();
assert!(status.success(), "exit status {status:?}, output {rest:?}");
assert!(rest.iter().any(|l| l.contains("Stopping actors")), "output {rest:?}");
}
@@ -0,0 +1,6 @@
[package]
name = "cargo-test-fail"
version = "0.1.0"
edition = "2024"
[workspace]
@@ -0,0 +1,9 @@
fn main() {}
#[cfg(test)]
mod tests {
#[test]
fn deliberate_unit_test_failure() {
assert_eq!(2 + 2, 5);
}
}
+8
View File
@@ -1 +1,9 @@
fn main() {}
#[cfg(test)]
mod tests {
#[test]
fn unit_tests_run_on_the_host_target() {
assert_eq!(2 + 2, 4);
}
}
+450 -14
View File
@@ -108,15 +108,15 @@ async fn routes_replies_publishes_and_reports_undeliverable() {
async fn broker_does_not_answer_messages_with_reply_to() {
let world = create_world("broker-reply-to");
let (host, mut mailbox) = start(&world).await;
let scanned = request(&host, &mut mailbox, json!({"type": "scan"})).await;
assert_eq!(scanned.body["type"], "scanned");
let untapped = request(&host, &mut mailbox, json!({"type": "untap"})).await;
assert_eq!(untapped.body["type"], "untapped");
host.broker.send(
"console",
Some("broker"),
None,
Some("upstream-message"),
json!({"type": "scan"}),
json!({"type": "untap"}),
);
assert!(
tokio::time::timeout(Duration::from_secs(1), mailbox.recv())
@@ -216,6 +216,101 @@ async fn actors_can_read_but_not_write_other_actor_data() {
host.stop().await;
}
#[tokio::test]
async fn actors_serve_files_under_fs() {
// "writer" serves its fs/ from memory (toolbox fs_read / fs_write / fs_remove).
let (_, host, mut mailbox) = filesystem_world("actor-fs").await;
let file = "/actor/writer/fs/notes/a.txt";
let write = json!({"type": "write", "path": file, "text": "hello"});
ask_type(&host, &mut mailbox, "reader", write, "ok").await;
// The write reached the owner as one fs_write message.
let direct = json!({"type": "fs_read", "path": "/notes/a.txt"});
let served = ask_type(&host, &mut mailbox, "writer", direct, "fs_file").await;
assert_eq!(served.body["text"], "hello");
let read = json!({"type": "read", "path": file});
assert_eq!(ask_type(&host, &mut mailbox, "reader", read, "content").await.body["text"], "hello");
let list = json!({"type": "list", "path": "/actor/writer/fs/notes"});
assert_eq!(ask_type(&host, &mut mailbox, "reader", list, "entries").await.body["names"], json!(["a.txt"]));
let root = json!({"type": "list", "path": "/actor/writer/fs"});
assert_eq!(ask_type(&host, &mut mailbox, "reader", root, "entries").await.body["names"], json!(["notes"]));
// fs_error and undeliverable become errors of the file operation.
for path in ["/actor/writer/fs/missing.txt", "/actor/nobody/fs/x"] {
ask_type(&host, &mut mailbox, "reader", json!({"type": "read", "path": path}), "error").await;
}
// An actor that does not answer (here: it is busy reading itself) fails after the timeout.
let id = host.broker.send("console", Some("reader"), None, None, json!({"type": "read", "path": "/actor/reader/fs/x"}));
let timed_out = receive_reply(&mut mailbox, &id, Duration::from_secs(20)).await;
assert_eq!(timed_out.body["type"], "error");
host.stop().await;
}
#[tokio::test]
async fn actor_fs_is_discoverable_only_when_its_source_docs_advertise_it() {
let world = create_world("actor-fs-discovery");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let advertised = passing_source(&world, "advertised-fs");
std::fs::create_dir_all(advertised.join("src")).unwrap();
std::fs::write(
advertised.join("src/main.rs"),
"//! test actor\n//! Actor-served files: implements fs_read and fs_dir.\n",
)
.unwrap();
spawn_type(&host, &mut mailbox, "served", &wasm, &advertised).await;
let undocumented = passing_source(&world, "undocumented-fs");
std::fs::create_dir_all(undocumented.join("src")).unwrap();
std::fs::write(undocumented.join("src/main.rs"), "//! test actor without a virtual filesystem\n").unwrap();
spawn_type(&host, &mut mailbox, "plain", &wasm, &undocumented).await;
let reader_source = passing_source(&world, "fs-reader");
spawn_type(&host, &mut mailbox, "reader", &wasm, &reader_source).await;
let served_entries = ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "list", "path": "/actor/served"}),
"entries",
)
.await;
assert!(served_entries.body["names"].as_array().unwrap().iter().any(|name| name.as_str() == Some("fs")));
let plain_entries = ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "list", "path": "/actor/plain"}),
"entries",
)
.await;
assert!(!plain_entries.body["names"].as_array().unwrap().iter().any(|name| name.as_str() == Some("fs")));
let path = "/actor/served/fs/readme.txt";
ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "write", "path": path, "text": "virtual document"}),
"ok",
)
.await;
let document = ask_type(
&host,
&mut mailbox,
"reader",
json!({"type": "read", "path": path}),
"content",
)
.await;
assert_eq!(document.body["text"], "virtual document");
host.stop().await;
}
#[tokio::test]
async fn files_are_plain_posix_files() {
let (_, host, mut mailbox) = filesystem_world("posix").await;
@@ -465,9 +560,69 @@ async fn explicit_rollback_keeps_current_data() {
}
#[tokio::test]
async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() {
async fn remove_shuts_an_actor_down_and_deletes_its_folder() {
let world = create_world("remove");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let source = passing_source(&world, "source");
spawn_type(&host, &mut mailbox, "doomed", &wasm, &source).await;
let write = json!({"type": "write", "path": "/data/state", "text": "x"});
ask_type(&host, &mut mailbox, "doomed", write, "ok").await;
ask_type(&host, &mut mailbox, "doomed", json!({"type": "arm-shutdown"}), "armed").await;
// Normal shutdown: the actor gets "shutdown" and its last message is routed.
let id = host.broker.send("console", Some("broker"), None, None, json!({"type": "remove", "actor": "doomed"}));
let (mut removed, mut last_words) = (false, false);
tokio::time::timeout(Duration::from_secs(10), async {
while !removed || !last_words {
let message = mailbox.recv().await.expect("console mailbox closed");
if message.reply_to.as_deref() == Some(id.as_str()) {
assert_eq!(message.body, json!({"type": "removed", "actor": "doomed"}));
removed = true;
} else if message.reply_to.as_deref() == Some("broker/shutdown") {
assert_eq!(message.body["type"], "shutdown-reply");
last_words = true;
}
}
})
.await
.expect("remove did not answer or did not shut the actor down");
assert!(!world.actor_dir("doomed").exists());
let gone = ask(&host, &mut mailbox, "doomed", json!({"type": "echo", "value": "hi"})).await;
assert_eq!(gone.body["type"], "undeliverable");
let again = request(&host, &mut mailbox, json!({"type": "remove", "actor": "doomed"})).await;
assert_eq!(again.body["type"], "error");
let broker = request(&host, &mut mailbox, json!({"type": "remove", "actor": "broker"})).await;
assert_eq!(broker.body["type"], "error");
assert!(world.actor_dir("broker").exists());
// The ID is free again: a new spawn starts at version 1 with empty data.
assert_eq!(spawn_type(&host, &mut mailbox, "doomed", &wasm, &source).await.body["version"], 1);
let read = json!({"type": "read", "path": "/data/state"});
ask_type(&host, &mut mailbox, "doomed", read, "error").await;
host.stop().await;
}
#[tokio::test]
async fn unknown_broker_request_lists_the_known_ones() {
let world = create_world("unknown-request");
let (host, mut mailbox) = start(&world).await;
let unknown = request(&host, &mut mailbox, json!({"type": "frobnicate"})).await;
assert_eq!(unknown.body["type"], "error");
let reason = unknown.body["reason"].as_str().unwrap();
assert!(reason.contains("remove") && reason.contains("tap") && reason.contains("watch"), "{reason}");
// `scan` is retired: the host watches the world folder instead.
let scan = request(&host, &mut mailbox, json!({"type": "scan"})).await;
assert_eq!(scan.body["type"], "error");
host.stop().await;
}
#[tokio::test]
async fn cargo_actor_runs_unit_tests_by_default_and_can_skip_them() {
let world = create_world("cargo-actor");
let (host, mut mailbox) = start(&world).await;
let valid_source = cargo_source("cargo-valid");
let valid_id = host.broker.send(
"console",
@@ -476,12 +631,42 @@ async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() {
None,
json!({"type": "build", "source": valid_source.display().to_string()}),
);
let built = receive_reply(&mut mailbox, &valid_id, Duration::from_secs(120)).await;
assert_eq!(built.body["type"], "built");
let built = receive_reply(&mut mailbox, &valid_id, Duration::from_secs(180)).await;
assert_eq!(built.body["type"], "built", "{built:?}");
assert_eq!(built.body["tests"]["passed"], 1);
assert_eq!(built.body["tests"]["failed"], 0);
assert!(built.body["tests"]["output"].as_str().unwrap().contains("unit_tests_run_on_the_host_target"));
let wasm = built.body["wasm"].as_str().unwrap();
assert!(wasm.ends_with(".wasm"));
assert!(host.world.resolve("console", wasm).unwrap().is_file());
let failing_test_source = cargo_source("cargo-test-fail");
let failed_test_id = host.broker.send(
"console",
Some("cargo"),
None,
None,
json!({"type": "build", "source": failing_test_source.display().to_string()}),
);
let failed_test = receive_reply(&mut mailbox, &failed_test_id, Duration::from_secs(120)).await;
assert_eq!(failed_test.body["type"], "build_failed", "{failed_test:?}");
assert_eq!(failed_test.body["tests"]["passed"], 0);
assert_eq!(failed_test.body["tests"]["failed"], 1);
assert!(failed_test.body["tests"]["output"].as_str().unwrap().contains("deliberate_unit_test_failure"));
let skipped_id = host.broker.send(
"console",
Some("cargo"),
None,
None,
json!({"type": "build", "source": failing_test_source.display().to_string(), "skip_tests": true}),
);
let skipped = receive_reply(&mut mailbox, &skipped_id, Duration::from_secs(120)).await;
assert_eq!(skipped.body["type"], "built", "{skipped:?}");
assert_eq!(skipped.body["tests"]["passed"], 0);
assert_eq!(skipped.body["tests"]["failed"], 0);
assert_eq!(skipped.body["tests"]["output"], "skipped (skip_tests=true)");
let invalid_source = cargo_source("cargo-fail");
let invalid_id = host.broker.send(
"console",
@@ -490,14 +675,10 @@ async fn cargo_actor_builds_valid_crates_and_reports_compiler_errors() {
None,
json!({"type": "build", "source": invalid_source.display().to_string()}),
);
let failed = receive_reply(&mut mailbox, &invalid_id, Duration::from_secs(120)).await;
assert_eq!(failed.body["type"], "build_failed");
assert!(
failed.body["errors"]
.as_str()
.unwrap()
.contains("missing_function")
);
let invalid = receive_reply(&mut mailbox, &invalid_id, Duration::from_secs(120)).await;
assert_eq!(invalid.body["type"], "build_failed", "{invalid:?}");
assert_eq!(invalid.body["tests"]["failed"], 1);
assert!(invalid.body["tests"]["output"].as_str().unwrap().contains("missing_function"));
host.stop().await;
}
@@ -712,3 +893,258 @@ async fn running_actors_have_sockets_and_candidates_do_not() {
assert_eq!(answer.body, json!({"type": "tcp", "line": "echo: hi"}));
host.stop().await;
}
/// Every message that arrives until the mailbox is quiet for `quiet`.
async fn drain(mailbox: &mut mpsc::Receiver<Envelope>, quiet: Duration) -> Vec<Envelope> {
let mut messages = Vec::new();
while let Ok(Some(message)) = tokio::time::timeout(quiet, mailbox.recv()).await {
messages.push(message);
}
messages
}
#[tokio::test]
async fn tap_copies_every_routed_message_once() {
let world = create_world("tap");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let source = passing_source(&world, "source");
spawn_type(&host, &mut mailbox, "tapped", &wasm, &source).await;
let subscribe = json!({"type": "subscribe", "actor": "tapped"});
assert_eq!(request(&host, &mut mailbox, subscribe).await.body["type"], "subscribed");
assert_eq!(request(&host, &mut mailbox, json!({"type": "tap"})).await.body["type"], "tapping");
let echo = json!({"type": "echo", "value": "hi"});
let echo_id = host.broker.send("console", Some("tapped"), None, None, echo.clone());
let publish = json!({"type": "publish", "value": "news"});
host.broker.send("console", Some("tapped"), None, None, publish);
let messages = drain(&mut mailbox, Duration::from_secs(2)).await;
let copies: Vec<&Value> = messages
.iter()
.filter(|m| m.from == "broker" && m.body["type"] == "tapped")
.map(|m| &m.body)
.collect();
// 4 copies (2 requests, 1 reply, 1 publish) + the reply + the publish itself.
assert_eq!((copies.len(), messages.len()), (4, 6), "{messages:?}");
for copy in &copies {
assert!(copy["ts"].as_u64().unwrap() > 0, "{copy}");
}
let count = |want: Value| copies.iter().filter(|c| c["message"] == want).count();
let request_copy = json!({"id": echo_id, "from": "console", "to": "tapped", "reply_to": null, "body": echo});
assert_eq!(count(request_copy), 1, "{copies:?}");
let reply_copies = copies
.iter()
.filter(|c| c["message"]["reply_to"] == echo_id.as_str() && c["message"]["from"] == "tapped" && c["message"]["to"] == "console")
.count();
assert_eq!(reply_copies, 1, "{copies:?}");
let publish_copy = copies.iter().find(|c| c["message"]["body"]["type"] == "published").expect("publish not tapped");
assert_eq!(publish_copy["message"]["from"], "tapped");
assert_eq!(publish_copy["message"]["to"], Value::Null);
assert_eq!(request(&host, &mut mailbox, json!({"type": "untap"})).await.body["type"], "untapped");
let echo_id = host.broker.send("console", Some("tapped"), None, None, json!({"type": "echo", "value": "quiet"}));
let messages = drain(&mut mailbox, Duration::from_secs(1)).await;
assert_eq!(messages.len(), 1, "{messages:?}");
assert_eq!(messages[0].reply_to.as_deref(), Some(echo_id.as_str()));
host.stop().await;
}
#[tokio::test]
async fn tap_survives_a_restart() {
let world = create_world("tap-restart");
let (host, mut mailbox) = start(&world).await;
assert_eq!(request(&host, &mut mailbox, json!({"type": "tap"})).await.body["type"], "tapping");
host.stop().await;
assert!(world.read_meta("console").tapped);
let (host, mut mailbox) = start(&world).await;
let id = host.broker.send("console", Some("nobody"), None, None, json!({"type": "hello"}));
let copy = tokio::time::timeout(Duration::from_secs(10), async {
loop {
let m = mailbox.recv().await.expect("console mailbox closed");
if m.body["type"] == "tapped" && m.body["message"]["id"] == id.as_str() {
return m;
}
}
})
.await
.expect("no tapped copy after the restart");
assert_eq!(copy.body["message"]["to"], "nobody");
host.stop().await;
}
#[tokio::test]
async fn a_full_tapper_mailbox_drops_copies_without_blocking() {
let world = create_world("tap-full");
let (host, mut mailbox) = start(&world).await;
// "ghost" taps and never reads its mailbox.
let mut ghost = host.mailbox("ghost");
let mut sink = host.mailbox("sink");
host.broker.send("ghost", Some("broker"), None, None, json!({"type": "tap"}));
tokio::time::timeout(Duration::from_secs(10), async {
while !world.read_meta("ghost").tapped {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("tap was not saved");
let total = sausage::broker::MAILBOX_SIZE + 500;
for n in 0..total {
host.broker.send("console", Some("sink"), None, None, json!({"type": "n", "n": n}));
assert_eq!(sink.try_recv().unwrap().body["n"], n);
}
assert!(mailbox.try_recv().is_err(), "a dropped copy reported something to the sender");
let mut held = 0;
while ghost.try_recv().is_ok() {
held += 1;
}
assert_eq!(held, sausage::broker::MAILBOX_SIZE);
host.broker.send("console", Some("sink"), None, None, json!({"type": "after"}));
assert_eq!(ghost.try_recv().unwrap().body["type"], "tapped");
host.stop().await;
}
/// Ask `actor` again and again (it may not exist yet) until `want` holds for
/// the answer.
async fn ask_until(
host: &Host,
mailbox: &mut mpsc::Receiver<Envelope>,
actor: &str,
body: Value,
want: impl Fn(&Value) -> bool,
) -> Envelope {
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let reply = ask(host, mailbox, actor, body.clone()).await;
if want(&reply.body) {
return reply;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
})
.await
.unwrap_or_else(|_| panic!("{actor} never gave the wanted answer"))
}
/// The `fs_changed` bodies among `messages` (all from `fs`).
fn fs_events(messages: Vec<Envelope>) -> Vec<Value> {
messages
.into_iter()
.filter(|m| m.body["type"] == "fs_changed")
.map(|m| {
assert_eq!(m.from, "fs");
m.body
})
.collect()
}
#[tokio::test]
async fn a_new_actor_wasm_on_disk_becomes_an_actor() {
let world = create_world("watch-spawn");
let (host, mut mailbox) = start(&world).await;
// Any writer counts: here the test itself, like a shell on the host.
let tests = world.source_dir("dropped").join("tests");
std::fs::create_dir_all(&tests).unwrap();
let test = json!({"steps": [{"send": {"type": "echo", "value": "candidate"}, "expect": {"type": "echo", "value": "candidate"}}]});
std::fs::write(tests.join("candidate.json"), test.to_string()).unwrap();
std::fs::copy(toolbox_wasm(), world.wasm_path("dropped")).unwrap();
let echo = json!({"type": "echo", "value": "hi"});
let reply = ask_until(&host, &mut mailbox, "dropped", echo, |b| b["type"] == "echo").await;
assert_eq!(reply.from, "dropped");
assert_eq!(world.read_meta("dropped").version, Some(1));
let meta = std::fs::read_to_string(world.version_dir("dropped", 1).join("meta.json")).unwrap();
assert_eq!(serde_json::from_str::<Value>(&meta).unwrap()["requested_by"], "scan");
host.stop().await;
}
#[tokio::test]
async fn a_changed_actor_wasm_on_disk_is_an_upgrade() {
let world = create_world("watch-upgrade");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let source = passing_source(&world, "source");
spawn_type(&host, &mut mailbox, "changing", &wasm, &source).await;
// The same program plus a custom section: a different file.
let mut changed = std::fs::read(&wasm).unwrap();
changed.extend_from_slice(&[0, 4, 1, b'x', b'v', b'2']);
std::fs::write(world.wasm_path("changing"), changed).unwrap();
let version = json!({"type": "version"});
ask_until(&host, &mut mailbox, "changing", version, |b| b["version"] == "2").await;
assert_eq!(world.read_meta("changing").version, Some(2));
assert_eq!(world.versions("changing"), vec![1, 2]);
host.stop().await;
}
#[tokio::test]
async fn watchers_get_one_event_per_change_from_any_writer() {
let (world, host, mut mailbox) = filesystem_world("watch-events").await;
let watch = json!({"type": "watch", "path": "/actor/writer/public/"});
let watching = request(&host, &mut mailbox, watch).await;
assert_eq!(watching.body, json!({"type": "watching", "path": "/actor/writer/public"}));
assert!(world.read_meta("console").watches.contains("/actor/writer/public"));
drain(&mut mailbox, Duration::from_millis(300)).await;
let quiet = Duration::from_millis(600);
let event = |path: &str, op: &str| json!({"type": "fs_changed", "path": format!("/actor/writer/public/{path}"), "op": op});
// An actor creates a file: one create, not create + write.
let write = json!({"type": "write", "path": "/public/a.txt", "text": "a"});
ask_type(&host, &mut mailbox, "writer", write, "ok").await;
assert_eq!(fs_events(drain(&mut mailbox, quiet).await), vec![event("a.txt", "create")]);
// The host (a shell, git, ...) changes it.
std::fs::write(world.public_dir("writer").join("a.txt"), "b").unwrap();
assert_eq!(fs_events(drain(&mut mailbox, quiet).await), vec![event("a.txt", "write")]);
// A rename is one event with `to`.
let rename = json!({"type": "rename", "path": "/public/a.txt", "to": "/public/b.txt"});
ask_type(&host, &mut mailbox, "writer", rename, "ok").await;
let mut renamed = event("a.txt", "rename");
renamed["to"] = json!("/actor/writer/public/b.txt");
assert_eq!(fs_events(drain(&mut mailbox, quiet).await), vec![renamed]);
std::fs::remove_file(world.public_dir("writer").join("b.txt")).unwrap();
assert_eq!(fs_events(drain(&mut mailbox, quiet).await), vec![event("b.txt", "remove")]);
// Nothing outside the watched path.
let outside = json!({"type": "write", "path": "/data/x", "text": "x"});
ask_type(&host, &mut mailbox, "writer", outside, "ok").await;
assert_eq!(fs_events(drain(&mut mailbox, quiet).await), Vec::<Value>::new());
let unwatch = json!({"type": "unwatch", "path": "/actor/writer/public"});
let unwatched = request(&host, &mut mailbox, unwatch).await;
assert_eq!(unwatched.body, json!({"type": "unwatched", "path": "/actor/writer/public"}));
assert!(world.read_meta("console").watches.is_empty());
let write = json!({"type": "write", "path": "/public/c.txt", "text": "c"});
ask_type(&host, &mut mailbox, "writer", write, "ok").await;
assert_eq!(fs_events(drain(&mut mailbox, quiet).await), Vec::<Value>::new());
host.stop().await;
}
#[tokio::test]
async fn a_watcher_overflow_sends_one_rescan_and_runs_a_full_pass() {
let world = create_world("watch-overflow");
let wasm = toolbox_wasm().clone();
let (host, mut mailbox) = start(&world).await;
let mut bystander = host.mailbox("bystander");
let watch = json!({"type": "watch", "path": "/actor/elsewhere"});
assert_eq!(request(&host, &mut mailbox, watch).await.body["type"], "watching");
// A dropped actor that the full pass finds (before the watcher's quiet time).
let tests = world.source_dir("planted").join("tests");
std::fs::create_dir_all(&tests).unwrap();
let test = json!({"steps": [{"send": {"type": "echo", "value": "candidate"}, "expect": {"type": "echo", "value": "candidate"}}]});
std::fs::write(tests.join("candidate.json"), test.to_string()).unwrap();
std::fs::copy(&wasm, world.wasm_path("planted")).unwrap();
let report = host.broker.clone().overflowed().await;
assert!(report.iter().any(|r| r["type"] == "spawned" && r["actor"] == "planted"), "{report:?}");
let rescan = json!({"type": "fs_changed", "path": "/actor", "op": "rescan"});
assert_eq!(fs_events(drain(&mut mailbox, Duration::from_millis(600)).await), vec![rescan]);
assert!(bystander.try_recv().is_err(), "an actor without watches got the rescan");
host.stop().await;
}
Generated
+16
View File
@@ -1,5 +1,20 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1791754810,
"narHash": "sha256-cN0Mgk+sQtCJcGWs9H4M0CnLRAuC4I8aCwiBTcBJs6E=",
"owner": "ipetkov",
"repo": "crane",
"rev": "bd63bf1d0a0f6dcacd7549c714a671ee63b0db6a",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
@@ -36,6 +51,7 @@
},
"root": {
"inputs": {
"crane": "crane",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
+108 -22
View File
@@ -6,9 +6,10 @@
flake-utils.url = "github:numtide/flake-utils";
rust-overlay.url = "github:oxalica/rust-overlay";
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
crane.url = "github:ipetkov/crane";
};
outputs = { nixpkgs, flake-utils, rust-overlay, ... }:
outputs = { nixpkgs, flake-utils, rust-overlay, crane, ... }:
flake-utils.lib.eachSystem flake-utils.lib.allSystems (system:
let
pkgs = import nixpkgs {
@@ -20,34 +21,53 @@
targets = [ "wasm32-wasip2" ];
extensions = [ "rust-src" ];
};
rustPlatform = pkgs.makeRustPlatform { cargo = rust; rustc = rust; };
sausage = rustPlatform.buildRustPackage {
# The runtime cargo actor 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.
runtimePath = pkgs.lib.makeBinPath [ rust pkgs.stdenv.cc ];
craneLib = (crane.mkLib pkgs).overrideToolchain rust;
common = {
pname = "sausage";
version = "0.1.0";
src = ./.;
cargoLock.lockFile = ./Cargo.lock;
cargoBuildFlags = [ "-p" "sausage" ];
};
# The test packages for `checks.tests`.
testPackages = "-p sausage -p sausage-actor -p agent";
cargoArtifacts = craneLib.buildDepsOnly (common // {
doCheck = false;
# The dependencies of the host binary and of the tests.
buildPhaseCargoCommand = ''
cargoWithProfile build --locked -p sausage
cargoWithProfile test --locked --no-run ${testPackages}
'';
});
sausage = craneLib.buildPackage (common // {
inherit cargoArtifacts;
doNotRemoveReferencesToRustToolchain = true;
cargoExtraArgs = "--locked -p sausage";
doCheck = false;
nativeBuildInputs = [ pkgs.makeWrapper ];
# The `cargo` actor runs cargo at runtime. Build scripts need a C linker.
postInstall = ''
wrapProgram $out/bin/sausage --prefix PATH : ${pkgs.lib.makeBinPath [ rust pkgs.stdenv.cc ]}
wrapProgram $out/bin/sausage --prefix PATH : ${runtimePath}
'';
meta.mainProgram = "sausage";
};
});
# `nix run .#loop -- <world>` runs the newest `sausage run <world>`
# from the Gitea repo, again and again: after a crash or a broker
# (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.
# `--internal` (or `.#loop-internal`) uses Gitea's SSH port on the LAN.
publicUrl = "git+ssh://containeruser@gitea.surma.technology:2222/surma/sausage";
internalUrl = "git+ssh://containeruser@10.0.0.2:2222/surma/sausage";
loop = name: url: pkgs.writeShellScriptBin name ''
url="${url}"
if [ "''${1:-}" = "--internal" ]; then url="${internalUrl}"; shift; fi
if [ $# -eq 0 ]; then set -- world; fi
# 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";
loop = pkgs.writeShellScriptBin "sausage-loop" ''
url="''${SAUSAGE_FLAKE:-${repoUrl}}"
# No folder: bare `sausage` (creates ./world first if needed).
if [ $# -gt 0 ]; then set -- run "$@"; fi
while true; do
nix run --refresh "$url#default" -- run "$@"
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
@@ -61,19 +81,85 @@
# `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.
checks.tests = sausage.overrideAttrs (old: {
pname = "sausage-tests";
doCheck = true;
cargoTestFlags = [ "-p" "sausage" "-p" "sausage-actor" "-p" "agent" ];
# 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 // {
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
'';
});
# 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 the native tests and the wasm32-wasip2 build.
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"
for actor in broker console cargo fs; do
mkdir -p "$world/actor/$actor/public" "$world/actor/$actor/data"
done
source="$world/actor/console/public/probe"
mkdir -p "$source/src" "$source/tests"
cat > "$source/Cargo.toml" <<'TOML'
[package]
name = "runtime-probe"
version = "0.1.0"
edition = "2024"
TOML
printf 'fn main() {}\n' > "$source/src/main.rs"
cat > "$source/tests/probe.rs" <<'RUST'
#[test]
fn native_test_harness_runs() {
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 {"type":"build","source":"/actor/console/public/probe"}' >&"$hostInput"
built=0
while IFS= read -r -t 300 line <&"$hostOutput"; do
echo "$line"
case "$line" in
*'"type":"build_failed"'*)
echo "packaged cargo actor build failed with an empty caller PATH" >&2
exit 1
;;
*'"type":"built"'*)
case "$line" in
*'"passed":1'*) built=1 ;;
*) echo "native test did not pass" >&2; exit 1 ;;
esac
break
;;
esac
done
if [ "$built" -ne 1 ]; then
echo "packaged host did not build the probe" >&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 "sausage-loop" publicUrl}/bin/sausage-loop"; };
apps.loop-internal = { type = "app"; program = "${loop "sausage-loop-internal" internalUrl}/bin/sausage-loop-internal"; };
apps.loop = { type = "app"; program = "${loop}/bin/sausage-loop"; };
devShells.default = pkgs.mkShell {
packages = [ rust pkgs.wasmtime pkgs.wasm-tools ];