diff --git a/PROTOCOL.md b/PROTOCOL.md index dfaeaf2..ee732e8 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -132,7 +132,9 @@ the request: the request's `type` in past tense (`spawned`, `upgraded`, | `subscribe`, `unsubscribe` | `actor` | receive (or not) what `actor` publishes | | `tap`, `untap` | | receive (or not) a copy of every message the broker routes | | `watch`, `unwatch` | `path` | receive (or not) `fs_changed` for every change of `path` or anything under it; answer `{"type": "watching" \| "unwatched", "path"}` with the path as an `/actor/...` path | -| `exit` | | answer `exiting`, stop every actor, and end the `sausage` process (a wrapper like `nix run .#loop` starts it again, updated) | +| `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`) is for services: the host does not read stdin, so EOF (no terminal, `/dev/null`) does not stop the world, and the console prompt is off. 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/...`. diff --git a/README.md b/README.md index 8d588d1..7b1fcc0 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,21 @@ 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`. +### As a service (headless) + +Without a terminal (systemd, a container), stdin is `/dev/null` and EOF would +stop the world. `--headless` (or `SAUSAGE_HEADLESS=1`) turns the console +input off: the host 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 run the loop: + +```sh +nix run git+https://gitea.surma.technology/surma/sausage#loop -- --headless my-world +``` + +[`examples/systemd/`](examples/systemd/) has a plain systemd unit and a NixOS +module that run exactly this. + In the console: ```text diff --git a/cli/src/console.rs b/cli/src/console.rs index dbf779f..70ff338 100644 --- a/cli/src/console.rs +++ b/cli/src/console.rs @@ -3,7 +3,8 @@ //! - `@ ` sends a JSON body. `@ ` 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; diff --git a/cli/src/main.rs b/cli/src/main.rs index 77f3ee3..dc64dcf 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -17,6 +17,11 @@ static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent"); #[derive(Parser)] #[command(version, about)] struct Cli { + /// Run without a terminal (for a service): do not read stdin, so EOF + /// 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: Option, } @@ -91,13 +96,36 @@ async fn init(dir: PathBuf) -> Result<()> { Ok(()) } -async fn run(dir: PathBuf) -> Result<()> { +/// Resolves on SIGTERM or SIGINT. +async fn termination() { + use tokio::signal::unix::{SignalKind, signal}; + let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler"); + let mut int = signal(SignalKind::interrupt()).expect("install SIGINT handler"); + tokio::select! { + _ = term.recv() => {} + _ = int.recv() => {} + } +} + +async fn run(dir: PathBuf, headless: bool) -> Result<()> { + // Install the signal handlers before the host starts, so a signal that + // arrives early is never the default (kill) action. + let signals = headless.then(|| tokio::spawn(termination())); let host = Host::start(&dir).await?; tokio::spawn(console::run_mailbox(host.mailbox(console::ID))); let broker = host.broker.clone(); - let exit = tokio::select! { - _ = console::run_input(host.broker.clone()) => false, - _ = broker.exit_requested() => true, + let exit = match signals { + Some(signals) => { + println!("Sausage headless: running until SIGTERM or an exit request."); + tokio::select! { + _ = 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; @@ -113,16 +141,18 @@ async fn run(dir: PathBuf) -> Result<()> { #[tokio::main] async fn main() -> Result<()> { - match Cli::parse().command { + 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).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).await + run(dir, headless).await } } } diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index 1d93cc2..1fe3eda 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -39,3 +39,34 @@ fn bare_command_without_a_world_starts_init() { // It asks before it creates anything. assert!(!dir.join("world").exists()); } + +/// `--headless` never reads stdin: with stdin at EOF the world stays up, and +/// SIGTERM stops it cleanly (exit code 0). +#[test] +fn headless_ignores_stdin_eof_and_stops_cleanly_on_sigterm() { + use std::io::{BufRead, BufReader}; + let dir = empty_dir("headless"); + 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(); + // The host prints this once it runs and the signal handlers are installed. + let line = lines.next().expect("host exited before it started").unwrap(); + assert!(line.contains("headless"), "unexpected first line: {line}"); + // The host reaches EOF on stdin immediately; it must still be running. + std::thread::sleep(std::time::Duration::from_millis(500)); + assert!(child.try_wait().unwrap().is_none(), "headless host exited on stdin EOF"); + let pid = child.id().to_string(); + assert!(Command::new("kill").args(["-TERM", &pid]).status().unwrap().success()); + let rest: Vec = 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:?}"); +} diff --git a/examples/systemd/sausage.nix b/examples/systemd/sausage.nix new file mode 100644 index 0000000..eee3b78 --- /dev/null +++ b/examples/systemd/sausage.nix @@ -0,0 +1,33 @@ +# NixOS module: runs a Sausage world as a service (see README, "As a service"). +# Create the world once: sudo -u sausage nix run git+https://gitea.surma.technology/surma/sausage -- init /var/lib/sausage/world +{ config, lib, pkgs, ... }: +let cfg = config.services.sausage; in +{ + options.services.sausage = { + enable = lib.mkEnableOption "the Sausage world"; + world = lib.mkOption { type = lib.types.str; default = "/var/lib/sausage/world"; }; + flake = lib.mkOption { + type = lib.types.str; + default = "git+https://gitea.surma.technology/surma/sausage"; + }; + }; + config = lib.mkIf cfg.enable { + users.users.sausage = { isSystemUser = true; group = "sausage"; home = "/var/lib/sausage"; createHome = true; }; + users.groups.sausage = { }; + systemd.services.sausage = { + description = "Sausage world"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + path = [ pkgs.nix pkgs.git ]; + serviceConfig = { + User = "sausage"; + WorkingDirectory = "/var/lib/sausage"; + ExecStart = "${pkgs.nix}/bin/nix run ${cfg.flake}#loop -- --headless ${cfg.world}"; + Restart = "on-failure"; + RestartSec = 5; + TimeoutStopSec = 30; + }; + }; + }; +} diff --git a/examples/systemd/sausage.service b/examples/systemd/sausage.service new file mode 100644 index 0000000..7e3c280 --- /dev/null +++ b/examples/systemd/sausage.service @@ -0,0 +1,23 @@ +# Sausage as a systemd service. Create the world once as the service user: +# nix run git+https://gitea.surma.technology/surma/sausage -- init /var/lib/sausage/world +# Then: install as /etc/systemd/system/sausage.service, `systemctl enable --now sausage`. +# Adjust User and the path to `nix`. +[Unit] +Description=Sausage world +After=network-online.target +Wants=network-online.target + +[Service] +User=sausage +WorkingDirectory=/var/lib/sausage +# The loop restarts the host (newest version) after a crash or a broker `exit`. +# --headless: no console, stdin EOF does not stop the world. +ExecStart=/run/current-system/sw/bin/nix run git+https://gitea.surma.technology/surma/sausage#loop -- --headless /var/lib/sausage/world +Restart=on-failure +RestartSec=5 +# SIGTERM stops the host cleanly (exit code 0). +KillMode=control-group +TimeoutStopSec=30 + +[Install] +WantedBy=multi-user.target