diff --git a/PROTOCOL.md b/PROTOCOL.md index ee732e8..c5d26bf 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -134,7 +134,7 @@ the request: the request's `type` in past tense (`spawned`, `upgraded`, | `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`) 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. +**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 ` 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/...`. diff --git a/README.md b/README.md index 7b1fcc0..5ab86b0 100644 --- a/README.md +++ b/README.md @@ -48,21 +48,18 @@ 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) +### 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: +`--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 ``` -[`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/main.rs b/cli/src/main.rs index dc64dcf..03d874c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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}; @@ -17,9 +18,9 @@ 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. + /// 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)] @@ -96,11 +97,16 @@ async fn init(dir: PathBuf) -> Result<()> { Ok(()) } -/// Resolves on SIGTERM or SIGINT. -async fn termination() { +/// Install the SIGTERM and SIGINT handlers now (not when first polled). +fn termination_signals() -> Result<(Signal, Signal)> { 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"); + 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() => {} @@ -108,9 +114,9 @@ async fn termination() { } async fn run(dir: PathBuf, headless: bool) -> Result<()> { - // Install the signal handlers before the host starts, so a signal that + // Register 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 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(); @@ -118,7 +124,7 @@ async fn run(dir: PathBuf, headless: bool) -> Result<()> { Some(signals) => { println!("Sausage headless: running until SIGTERM or an exit request."); tokio::select! { - _ = signals => false, + _ = termination(signals) => false, _ = broker.exit_requested() => true, } } diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index 1fe3eda..f1e966e 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -40,12 +40,11 @@ fn bare_command_without_a_world_starts_init() { 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() { +/// 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>) { use std::io::{BufRead, BufReader}; - let dir = empty_dir("headless"); + 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")) @@ -57,12 +56,26 @@ fn headless_ignores_stdin_eof_and_stops_cleanly_on_sigterm() { .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. + (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)); - assert!(child.try_wait().unwrap().is_none(), "headless host exited on stdin EOF"); + 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 = lines.map(|l| l.unwrap()).collect(); diff --git a/examples/systemd/sausage.nix b/examples/systemd/sausage.nix deleted file mode 100644 index eee3b78..0000000 --- a/examples/systemd/sausage.nix +++ /dev/null @@ -1,33 +0,0 @@ -# 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 deleted file mode 100644 index 7e3c280..0000000 --- a/examples/systemd/sausage.service +++ /dev/null @@ -1,23 +0,0 @@ -# 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