diff --git a/PROTOCOL.md b/PROTOCOL.md index dfaeaf2..c5d26bf 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`) 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 8d588d1..5ab86b0 100644 --- a/README.md +++ b/README.md @@ -48,6 +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`. +### 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: ```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..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,6 +18,11 @@ static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent"); #[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: Option, } @@ -91,13 +97,41 @@ async fn init(dir: PathBuf) -> Result<()> { 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! { - _ = 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! { + _ = 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; @@ -113,16 +147,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..f1e966e 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -39,3 +39,47 @@ fn bare_command_without_a_world_starts_init() { // 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>) { + 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 = 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:?}"); +}