Compare commits

...
Author SHA1 Message Date
Sausage Agent 34e2f35272 Add writable /self alias alongside /public and /data
ci / check (push) Successful in 15s
2026-10-22 13:35:13 +00:00
Sausage Agent f932ab9719 Mount the writable world root for every actor
ci / check (push) Successful in 3m9s
2026-10-22 13:29:38 +00:00
6 changed files with 114 additions and 43 deletions
+23 -8
View File
@@ -59,15 +59,27 @@ it exits. After 5 seconds, it kills the instance.
## Filesystem ## Filesystem
Each instance sees three folders: The world folder is the foundation of every instance's filesystem:
| Path | Access | Contents | | Path | Access | Contents |
| --- | --- | --- | | --- | --- | --- |
| `/data` | read-write by its actor; readable by every actor | the actor's state | | `/` | read-write by every actor | the whole world folder |
| `/public` | read-write | the files that the actor publishes | | `/self` | read-write | alias for the actor's own folder (`public/`, `data/`, `source/`, etc.) |
| `/actor` | read-only | the `actor/` folder of the world | | `/data` | read-write | alias for the actor's state folder |
| `/public` | read-write | alias for the actor's published files |
| `/actor` | read-write by every actor | 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. Every actor can read and write every file in the world, including other actors'
`/actor/<id>/data/` and `/actor/<id>/public/`. Nothing there is private.
Creating a folder in the world makes it visible to running actors immediately:
`/config`, `/docs`, `/www`, and `/skills` need no new mounts or host restart.
New worlds include an empty `/config` for global configuration by convention;
existing worlds can simply create it. Keep secrets out of global configuration.
Candidate instances see the same writable world root. Their `/self` alias
points to the scratch actor folder; `/public` and `/data` still point to its
corresponding children, and network access remains off;
absolute world paths are not isolated from the live world.
`/actor/<id>/` holds everything about an actor: `actor.wasm`, `actor.json` `/actor/<id>/` holds everything about an actor: `actor.wasm`, `actor.json`
(installed version, running, subscriptions, tapped, watches), `source/`, `public/`, `data/`, (installed version, running, subscriptions, tapped, watches), `source/`, `public/`, `data/`,
@@ -131,12 +143,15 @@ the request: the request's `type` in past tense (`spawned`, `upgraded`,
| `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` | | `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 | | `subscribe`, `unsubscribe` | `actor` | receive (or not) what `actor` publishes |
| `tap`, `untap` | | receive (or not) a copy of every message the broker routes | | `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 | | `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 absolute world 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) | | `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. **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/...`. Paths are absolute world paths, or the sender's `/self/...`, `/public/...` or
`/data/...` aliases. Native senders (such as the console) can also supply
absolute host paths; apart from `/actor` and these aliases,
these retain their host meaning.
**Taps.** After `tap`, the sender gets a copy of every message that the **Taps.** After `tap`, the sender gets a copy of every message that the
broker routes (direct messages, replies, and publishes, between any actors, broker routes (direct messages, replies, and publishes, between any actors,
@@ -176,7 +191,7 @@ change of `path` or a path under it (`/actor/a` covers `/actor/a/x`, not
- Events come from `fs`. Like tap copies, they are not routed, a full - 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. mailbox drops them, and they never block the writer. Never answer them.
- If the OS drops events, every watcher gets one - If the OS drops events, every watcher gets one
`{"type": "fs_changed", "path": "/actor", "op": "rescan"}`: anything may `{"type": "fs_changed", "path": "/", "op": "rescan"}`: anything may
have changed. have changed.
- A watch is saved in `actor.json` (`"watches": [...]`) and survives - A watch is saved in `actor.json` (`"watches": [...]`) and survives
restarts and upgrades. `unwatch` with the same path ends it. restarts and upgrades. `unwatch` with the same path ends it.
+7 -5
View File
@@ -255,7 +255,9 @@ impl Broker {
} }
InstanceConfig { InstanceConfig {
mounts: vec![ mounts: vec![
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: false }, Mount { guest: "/".into(), host: self.world.root.clone(), writable: true },
Mount { guest: "/actor".into(), host: self.world.actors_root(), writable: true },
Mount { guest: "/self".into(), host: own.clone(), writable: true },
Mount { guest: "/public".into(), host: own.join("public"), writable: true }, Mount { guest: "/public".into(), host: own.join("public"), writable: true },
Mount { guest: "/data".into(), host: own.join("data"), writable: true }, Mount { guest: "/data".into(), host: own.join("data"), writable: true },
], ],
@@ -445,7 +447,7 @@ impl Broker {
self.host_error("the file watcher dropped events; watchers get a rescan, actors a full pass"); self.host_error("the file watcher dropped events; watchers get a rescan, actors a full pass");
let watchers: Vec<String> = let watchers: Vec<String> =
self.metas.lock().unwrap().iter().filter(|(_, m)| !m.watches.is_empty()).map(|(id, _)| id.clone()).collect(); 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"})); self.inject(&watchers, vfs::ID, json!({"type": "fs_changed", "path": "/", "op": "rescan"}));
let report = self.scan().await; let report = self.scan().await;
for change in &report { for change in &report {
eprintln!("sausage: {change}"); eprintln!("sausage: {change}");
@@ -599,9 +601,9 @@ impl Broker {
let on = ty == "watch"; let on = ty == "watch";
let path_s = b["path"].as_str().ok_or_else(|| anyhow!("missing \"path\""))?; 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 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 = global.ok_or_else(|| anyhow!("{path_s} is not under the world root"))?;
let path = match path.trim_end_matches('/') { let path = match path.trim_end_matches('/') {
"" => "/actor".to_string(), "" => "/".to_string(),
p => p.to_string(), p => p.to_string(),
}; };
self.update_meta(&env.from, |m| { self.update_meta(&env.from, |m| {
@@ -649,7 +651,7 @@ fn same_file(a: &Path, b: &Path) -> bool {
/// Whether the namespace path `path` is `prefix` or inside it. /// Whether the namespace path `path` is `prefix` or inside it.
fn under(path: &str, prefix: &str) -> bool { fn under(path: &str, prefix: &str) -> bool {
path.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('/')) prefix == "/" || path.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
} }
pub fn truncate(s: &str, max: usize) -> String { pub fn truncate(s: &str, max: usize) -> String {
+2 -2
View File
@@ -390,8 +390,8 @@ impl preopens::Host for VfsView<'_> {
fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> { fn get_directories(&mut self) -> wasmtime::Result<Vec<(Resource<types::Descriptor>, String)>> {
let dirs = preopens::Host::get_directories(&mut self.inner)?; let dirs = preopens::Host::get_directories(&mut self.inner)?;
for (fd, name) in &dirs { for (fd, name) in &dirs {
if name == "/actor" { if name == "/actor" || name == "/" {
self.state.dirs.insert(fd.rep(), "/actor".to_string()); self.state.dirs.insert(fd.rep(), name.clone());
} }
} }
Ok(dirs) Ok(dirs)
+10 -8
View File
@@ -5,7 +5,7 @@
//! Events are coalesced per path: once a path is quiet for `QUIET` (or has //! Events are coalesced per path: once a path is quiet for `QUIET` (or has
//! kept changing for `MAX_DELAY`), it becomes one change //! kept changing for `MAX_DELAY`), it becomes one change
//! `{path, op: write | create | remove | rename, to?}` with a namespace path //! `{path, op: write | create | remove | rename, to?}` with a namespace path
//! (`/actor/...`). Then the broker //! (relative to the world root, such as `/actor/...` or `/config/...`). Then the broker
//! //!
//! - sends `fs_changed` to the actors that watch the path (`watch` request), //! - sends `fs_changed` to the actors that watch the path (`watch` request),
//! - starts or upgrades an actor whose `actor/<id>/actor.wasm` (or folder //! - starts or upgrades an actor whose `actor/<id>/actor.wasm` (or folder
@@ -75,7 +75,7 @@ impl Pending {
} }
async fn run(broker: Arc<Broker>, mut events: mpsc::UnboundedReceiver<notify::Result<notify::Event>>) { async fn run(broker: Arc<Broker>, mut events: mpsc::UnboundedReceiver<notify::Result<notify::Event>>) {
let root = broker.world.actors_root(); let root = broker.world.root.clone();
let mut pending: HashMap<String, Pending> = HashMap::new(); let mut pending: HashMap<String, Pending> = HashMap::new();
// Actor checks and full passes. Dropping the set (when the host stops) aborts them. // Actor checks and full passes. Dropping the set (when the host stops) aborts them.
let mut tasks = tokio::task::JoinSet::new(); let mut tasks = tokio::task::JoinSet::new();
@@ -122,11 +122,11 @@ async fn sleep_until(t: Option<Instant>) {
} }
} }
/// The namespace path (`/actor/...`) of a host path, unless it is outside /// The world namespace path of a host path, unless it is outside the
/// `actor/` or a temporary file of the host. /// world or a temporary file of the host.
fn namespace(root: &Path, path: &Path) -> Option<String> { fn namespace(root: &Path, path: &Path) -> Option<String> {
let rel = path.strip_prefix(root).ok()?; let rel = path.strip_prefix(root).ok()?;
let mut out = String::from("/actor"); let mut out = String::new();
for part in rel.components() { for part in rel.components() {
let name = part.as_os_str().to_str()?; let name = part.as_os_str().to_str()?;
if name.starts_with(TMP_PREFIX) { if name.starts_with(TMP_PREFIX) {
@@ -135,7 +135,7 @@ fn namespace(root: &Path, path: &Path) -> Option<String> {
out.push('/'); out.push('/');
out.push_str(name); out.push_str(name);
} }
Some(out) Some(if out.is_empty() { "/".to_string() } else { out })
} }
/// One change: namespace path, op, and the new path of a rename. /// One change: namespace path, op, and the new path of a rename.
@@ -219,7 +219,7 @@ mod tests {
#[test] #[test]
fn a_rename_is_one_change_and_temp_files_are_skipped() { fn a_rename_is_one_change_and_temp_files_are_skipped() {
let root = Path::new("/w/actor"); let root = Path::new("/w");
let now = Instant::now(); let now = Instant::now();
let mut pending = HashMap::new(); let mut pending = HashMap::new();
let ev = |kind, paths: &[&str]| { let ev = |kind, paths: &[&str]| {
@@ -236,7 +236,8 @@ mod tests {
ev(name(RenameMode::Both), &["/w/actor/a/public/x", "/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(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(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"]), ev(EventKind::Create(notify::event::CreateKind::File), &["/outside/.lock"]),
ev(EventKind::Create(notify::event::CreateKind::File), &["/w/config/x"]),
] { ] {
for (path, op, to) in changes(root, &e) { for (path, op, to) in changes(root, &e) {
add(&mut pending, path, op, to, now); add(&mut pending, path, op, to, now);
@@ -250,6 +251,7 @@ mod tests {
vec![ vec![
("/actor/a/actor.json".to_string(), "write", None), ("/actor/a/actor.json".to_string(), "write", None),
("/actor/a/public/x".to_string(), "rename", Some("/actor/a/public/y".to_string())), ("/actor/a/public/x".to_string(), "rename", Some("/actor/a/public/y".to_string())),
("/config/x".to_string(), "create", None),
] ]
); );
} }
+19 -9
View File
@@ -1,15 +1,17 @@
//! On-disk layout of a Sausage world. The folder is the source of truth: //! On-disk layout of a Sausage world. The folder is the source of truth:
//! `sausage run` boots every actor that it finds in `actor/`, and then //! `sausage run` boots every actor that it finds in `actor/`, and then
//! watches the folder (`watch.rs`). //! watches the folder (`watch.rs`). `/self` aliases the running actor's folder;
//! `/public` and `/data` remain aliases for its `public/` and `data/`.
//! //!
//! ```text //! ```text
//! <world>/ //! <world>/ mounted at /, readable and writable by every actor
//! config/ global configuration by convention (no secrets)
//! actor/<id>/ //! actor/<id>/
//! actor.wasm the current version //! actor.wasm the current version
//! actor.json version, running, subscriptions, tapped, watches (broker) //! actor.json version, running, subscriptions, tapped, watches (broker)
//! source/ source of the current version //! source/ source of the current version
//! public/ written by the actor, read-only for others //! public/ published files, readable and writable by everyone
//! data/ state, writable only by the actor //! data/ state, readable and writable by everyone
//! stderr.log stderr of the actor and notes of the broker //! stderr.log stderr of the actor and notes of the broker
//! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json //! versions/<n>/ every version that was installed: actor.wasm, source/, meta.json
//! ``` //! ```
@@ -131,6 +133,7 @@ impl World {
for id in NATIVE { for id in NATIVE {
world.ensure_actor_dirs(id)?; world.ensure_actor_dirs(id)?;
} }
std::fs::create_dir_all(world.root.join("config"))?;
Ok(world) Ok(world)
} }
@@ -191,8 +194,9 @@ impl World {
/// Map a path in an actor's namespace to a host path. /// Map a path in an actor's namespace to a host path.
/// ///
/// - `/actor/<id>/...` is the global filesystem. /// - Absolute paths are relative to the world root.
/// - `/public/...` and `/data/...` belong to `sender`. /// - `/self/...` aliases `sender`'s folder; `/public/...` and `/data/...`
/// alias its corresponding children.
/// - Native senders (the console) can also give absolute host paths. /// - Native senders (the console) can also give absolute host paths.
pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> { pub fn resolve(&self, sender: &str, path: &str) -> Result<PathBuf> {
let under = |base: PathBuf, rest: &str| -> Result<PathBuf> { let under = |base: PathBuf, rest: &str| -> Result<PathBuf> {
@@ -212,6 +216,9 @@ impl World {
if let Some(rest) = prefixed("/actor") { if let Some(rest) = prefixed("/actor") {
return under(self.actors_root(), rest); return under(self.actors_root(), rest);
} }
if let Some(rest) = prefixed("/self") {
return under(self.actor_dir(sender), rest);
}
if let Some(rest) = prefixed("/public") { if let Some(rest) = prefixed("/public") {
return under(self.public_dir(sender), rest); return under(self.public_dir(sender), rest);
} }
@@ -221,13 +228,16 @@ impl World {
if NATIVE.contains(&sender) && Path::new(path).is_absolute() { if NATIVE.contains(&sender) && Path::new(path).is_absolute() {
return Ok(PathBuf::from(path)); return Ok(PathBuf::from(path));
} }
bail!("cannot resolve path {path} for {sender}: use /actor/<id>/..., /public/... or /data/...") if Path::new(path).is_absolute() {
return under(self.root.clone(), path);
}
bail!("cannot resolve path {path} for {sender}: use an absolute world path")
} }
/// The path of a host file in the global filesystem, if it has one. /// The path of a host file in the global filesystem, if it has one.
pub fn global_path(&self, host: &Path) -> Option<String> { pub fn global_path(&self, host: &Path) -> Option<String> {
let rel = host.strip_prefix(self.actors_root()).ok()?; let rel = host.strip_prefix(&self.root).ok()?;
Some(format!("/actor/{}", rel.display())) Some(format!("/{}", rel.display()))
} }
} }
+53 -11
View File
@@ -140,20 +140,20 @@ async fn exit_request_is_answered_and_signals_the_host() {
} }
#[tokio::test] #[tokio::test]
async fn filesystem_permissions_and_public_sharing() { async fn filesystem_is_writable_and_public_aliases_share_files() {
let (_, host, mut mailbox) = filesystem_world("filesystem-permissions").await; let (_, host, mut mailbox) = filesystem_world("filesystem-permissions").await;
for path in [ for path in [
"/actor/reader/public/denied.txt", "/actor/reader/public/shared.txt",
"/actor/writer/public/denied.txt", "/actor/writer/public/shared.txt",
] { ] {
let denied = ask( let written = ask(
&host, &host,
&mut mailbox, &mut mailbox,
"writer", "writer",
json!({"type": "write", "path": path, "text": "denied"}), json!({"type": "write", "path": path, "text": "shared"}),
) )
.await; .await;
assert_eq!(denied.body["type"], "error", "write to {path} succeeded"); assert_eq!(written.body["type"], "ok", "write to {path} failed");
} }
let written = ask( let written = ask(
@@ -176,7 +176,7 @@ async fn filesystem_permissions_and_public_sharing() {
} }
#[tokio::test] #[tokio::test]
async fn actors_can_read_but_not_write_other_actor_data() { async fn actors_can_read_and_write_other_actor_data() {
let (_, host, mut mailbox) = filesystem_world("actor-data-sharing").await; let (_, host, mut mailbox) = filesystem_world("actor-data-sharing").await;
ask_type( ask_type(
&host, &host,
@@ -201,10 +201,10 @@ async fn actors_can_read_but_not_write_other_actor_data() {
&mut mailbox, &mut mailbox,
"reader", "reader",
json!({"type": "write", "path": "/actor/writer/data/x", "text": "changed"}), json!({"type": "write", "path": "/actor/writer/data/x", "text": "changed"}),
"error", "ok",
) )
.await; .await;
let unchanged = ask_type( let changed = ask_type(
&host, &host,
&mut mailbox, &mut mailbox,
"writer", "writer",
@@ -212,7 +212,49 @@ async fn actors_can_read_but_not_write_other_actor_data() {
"content", "content",
) )
.await; .await;
assert_eq!(unchanged.body["text"], "shared"); assert_eq!(changed.body["text"], "changed");
host.stop().await;
}
#[tokio::test]
async fn world_root_is_shared_writable_and_survives_restart() {
let (world, host, mut mailbox) = filesystem_world("world-root").await;
assert!(world.root.join("config").is_dir());
ask_type(&host, &mut mailbox, "writer", json!({"type": "write", "path": "/self/data/x", "text": "self-alias"}), "ok").await;
assert_eq!(ask_type(&host, &mut mailbox, "writer", json!({"type": "read", "path": "/data/x"}), "content").await.body["text"], "self-alias");
let file = "/config/x";
ask_type(&host, &mut mailbox, "writer", json!({"type": "write", "path": file, "text": "first"}), "ok").await;
let read = json!({"type": "read", "path": file});
assert_eq!(ask_type(&host, &mut mailbox, "reader", read.clone(), "content").await.body["text"], "first");
ask_type(&host, &mut mailbox, "reader", json!({"type": "write", "path": file, "text": "second"}), "ok").await;
assert_eq!(std::fs::read_to_string(world.root.join("config/x")).unwrap(), "second");
for actor in ["writer", "reader"] {
assert_eq!(world.resolve(actor, file).unwrap(), world.root.join("config/x"));
}
assert_eq!(world.global_path(&world.root.join("config/x")).as_deref(), Some(file));
// Watches use the same world namespace, not only /actor paths.
let watching = request(&host, &mut mailbox, json!({"type": "watch", "path": world.root.join("config").display().to_string()})).await;
assert_eq!(watching.body, json!({"type": "watching", "path": "/config"}));
drain(&mut mailbox, Duration::from_millis(300)).await;
std::fs::write(world.root.join("config/event"), "watched").unwrap();
assert_eq!(fs_events(drain(&mut mailbox, Duration::from_millis(600)).await),
vec![json!({"type": "fs_changed", "path": "/config/event", "op": "create"})]);
request(&host, &mut mailbox, json!({"type": "unwatch", "path": world.root.join("config").display().to_string()})).await;
// A new top-level folder appears in already running instances, without a mount change.
std::fs::create_dir(world.root.join("docs")).unwrap();
std::fs::write(world.root.join("docs/new"), "host-created").unwrap();
let docs = json!({"type": "read", "path": "/docs/new"});
assert_eq!(ask_type(&host, &mut mailbox, "reader", docs, "content").await.body["text"], "host-created");
ask_type(&host, &mut mailbox, "reader", json!({"type": "write", "path": "/docs/new", "text": "actor-updated"}), "ok").await;
assert_eq!(std::fs::read_to_string(world.root.join("docs/new")).unwrap(), "actor-updated");
host.stop().await;
let (host, mut mailbox) = start(&world).await;
for actor in ["writer", "reader"] {
assert_eq!(ask_type(&host, &mut mailbox, actor, read.clone(), "content").await.body["text"], "second");
}
host.stop().await; host.stop().await;
} }
@@ -1187,7 +1229,7 @@ async fn a_watcher_overflow_sends_one_rescan_and_runs_a_full_pass() {
let report = host.broker.clone().overflowed().await; let report = host.broker.clone().overflowed().await;
assert!(report.iter().any(|r| r["type"] == "spawned" && r["actor"] == "planted"), "{report:?}"); assert!(report.iter().any(|r| r["type"] == "spawned" && r["actor"] == "planted"), "{report:?}");
let rescan = json!({"type": "fs_changed", "path": "/actor", "op": "rescan"}); let rescan = json!({"type": "fs_changed", "path": "/", "op": "rescan"});
assert_eq!(fs_events(drain(&mut mailbox, Duration::from_millis(600)).await), vec![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"); assert!(bystander.try_recv().is_err(), "an actor without watches got the rescan");
host.stop().await; host.stop().await;