diff --git a/PROTOCOL.md b/PROTOCOL.md index 56a36c1..752ac19 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -83,7 +83,7 @@ message. 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`), or `{"type": "error", "reason": "..."}`. +`scanned`, `exiting`), or `{"type": "error", "reason": "..."}`. | `type` | Fields | Effect | | --- | --- | --- | @@ -93,6 +93,7 @@ the request: the request's `type` in past tense (`spawned`, `upgraded`, | `stop`, `start` | `actor` | stop or start an actor | | `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) | Paths are `/actor/...`, or the sender's `/public/...` or `/data/...`. diff --git a/README.md b/README.md index c7ccf45..3aeba05 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,16 @@ the base URL, the model, and the token. It reads `SAUSAGE_API`, values go to `world/actor/agent/data/config.json`. Both commands take another folder as an argument, for example `nix run -- 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) +``` + +The loop runs `nix run --refresh git+ssh://…/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. + In the console: ```text diff --git a/cli/src/broker.rs b/cli/src/broker.rs index f51dccf..918640a 100644 --- a/cli/src/broker.rs +++ b/cli/src/broker.rs @@ -1,6 +1,6 @@ //! The broker: message routing, mailboxes, subscriptions, the version store, //! and the requests to `broker` (spawn, upgrade, rollback, stop, start, -//! subscribe, unsubscribe, scan). `supervisor.rs` runs each WASM actor. +//! subscribe, unsubscribe, scan, exit). `supervisor.rs` runs each WASM actor. //! //! The broker accepts only `.wasm` files. It does not know any source language. //! The world folder is the source of truth: `scan` boots every @@ -58,6 +58,8 @@ pub struct Broker { metas: Mutex>, op_locks: Mutex>>>, seq: AtomicU64, + /// Set by the `exit` request: the host process should stop. + exit: tokio::sync::Notify, } impl Broker { @@ -71,9 +73,17 @@ impl Broker { metas: Mutex::new(metas), op_locks: Mutex::new(HashMap::new()), seq: AtomicU64::new(1), + exit: tokio::sync::Notify::new(), })) } + /// Wait until an actor sends `{"type": "exit"}` to `broker`. The `sausage` + /// binary then stops all actors and exits, so that a wrapper such as + /// `nix run .#loop` can start a fresh (updated) host. + pub async fn exit_requested(&self) { + self.exit.notified().await; + } + pub fn meta(&self, id: &str) -> Option { self.metas.lock().unwrap().get(id).cloned() } @@ -427,7 +437,13 @@ impl Broker { Ok(json!({"type": format!("{ty}d"), "actor": publisher})) } "scan" => Ok(json!({"type": "scanned", "changes": self.scan().await})), - other => bail!("unknown broker request {other:?}. Known: spawn, upgrade, rollback, stop, start, subscribe, unsubscribe, scan"), + "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"), } } diff --git a/cli/src/main.rs b/cli/src/main.rs index 7def291..989e147 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -92,9 +92,20 @@ async fn init(dir: PathBuf) -> Result<()> { async fn run(dir: PathBuf) -> Result<()> { let host = Host::start(&dir).await?; tokio::spawn(console::run_mailbox(host.mailbox(console::ID))); - console::run_input(host.broker.clone()).await; + let broker = host.broker.clone(); + let exit = tokio::select! { + _ = console::run_input(host.broker.clone()) => false, + _ = broker.exit_requested() => true, + }; println!("Stopping actors …"); host.stop().await; + if exit { + // The console still blocks on a stdin read that cannot be cancelled, + // so the runtime would not shut down on its own. Exit code 75 tells + // `nix run .#loop` to start the host again (0 = /quit = stop the loop). + println!("Exiting, as an actor asked the broker to."); + std::process::exit(75); + } Ok(()) } diff --git a/cli/tests/host.rs b/cli/tests/host.rs index eb57ed9..c417034 100644 --- a/cli/tests/host.rs +++ b/cli/tests/host.rs @@ -127,6 +127,18 @@ async fn broker_does_not_answer_messages_with_reply_to() { host.stop().await; } +#[tokio::test] +async fn exit_request_is_answered_and_signals_the_host() { + let world = create_world("broker-exit"); + let (host, mut mailbox) = start(&world).await; + let exiting = request(&host, &mut mailbox, json!({"type": "exit"})).await; + assert_eq!(exiting.body["type"], "exiting"); + tokio::time::timeout(Duration::from_secs(1), host.broker.exit_requested()) + .await + .expect("exit request did not reach the host"); + host.stop().await; +} + #[tokio::test] async fn filesystem_permissions_and_public_sharing() { let (_, host, mut mailbox) = filesystem_world("filesystem-permissions").await; diff --git a/flake.nix b/flake.nix index 11f11a1..0c65791 100644 --- a/flake.nix +++ b/flake.nix @@ -35,11 +35,32 @@ ''; meta.mainProgram = "sausage"; }; + # `nix run .#loop -- ` runs the newest `sausage run ` + # from the Gitea 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 + while true; do + nix run --refresh "$url#default" -- run "$@" + code=$? + if [ "$code" -eq 0 ]; then exit 0; fi + echo "sausage-loop: sausage exited with $code, starting the newest version in 2 s …" >&2 + sleep 2 + done + ''; in { packages.default = sausage; 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"; }; devShells.default = pkgs.mkShell { packages = [ rust pkgs.wasmtime pkgs.wasm-tools ];