Add --headless mode (no stdin read, runs until SIGTERM/exit) + systemd/NixOS examples
ci / check (push) Successful in 2m44s

This commit is contained in:
Sausage Agent
2026-10-18 15:24:20 +00:00
parent c204e1ef0c
commit 01a821ab72
7 changed files with 144 additions and 9 deletions
+3 -1
View File
@@ -132,7 +132,9 @@ the request: the request's `type` in past tense (`spawned`, `upgraded`,
| `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 `/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/...`. Paths are `/actor/...`, or the sender's `/public/...` or `/data/...`.
+15
View File
@@ -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 same commands are `nix run`, `nix run -- init`, `nix run -- run` and
`nix run .#loop`. `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: In the console:
```text ```text
+2 -1
View File
@@ -3,7 +3,8 @@
//! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends //! - `@<actor> <json>` sends a JSON body. `@<actor> <text>` sends
//! `{"type": "text", "text": ...}`. //! `{"type": "text", "text": ...}`.
//! - A line without `@` goes to `agent` as 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; use std::sync::Arc;
+37 -7
View File
@@ -17,6 +17,11 @@ static AGENT_SOURCE: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent");
#[derive(Parser)] #[derive(Parser)]
#[command(version, about)] #[command(version, about)]
struct Cli { 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(subcommand)]
command: Option<Cmd>, command: Option<Cmd>,
} }
@@ -91,13 +96,36 @@ async fn init(dir: PathBuf) -> Result<()> {
Ok(()) 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?; let host = Host::start(&dir).await?;
tokio::spawn(console::run_mailbox(host.mailbox(console::ID))); tokio::spawn(console::run_mailbox(host.mailbox(console::ID)));
let broker = host.broker.clone(); let broker = host.broker.clone();
let exit = tokio::select! { let exit = match signals {
_ = console::run_input(host.broker.clone()) => false, Some(signals) => {
_ = broker.exit_requested() => true, 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 …"); println!("Stopping actors …");
host.stop().await; host.stop().await;
@@ -113,16 +141,18 @@ async fn run(dir: PathBuf) -> Result<()> {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { 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::Init { dir }) => init(dir).await,
Some(Cmd::Run { dir }) => run(dir).await, Some(Cmd::Run { dir }) => run(dir, headless).await,
None => { None => {
let dir = PathBuf::from("world"); let dir = PathBuf::from("world");
if !dir.join("actor").is_dir() { if !dir.join("actor").is_dir() {
println!("No world in ./world yet, creating it (same as `sausage init`)."); println!("No world in ./world yet, creating it (same as `sausage init`).");
init(dir.clone()).await?; init(dir.clone()).await?;
} }
run(dir).await run(dir, headless).await
} }
} }
} }
+31
View File
@@ -39,3 +39,34 @@ fn bare_command_without_a_world_starts_init() {
// It asks before it creates anything. // It asks before it creates anything.
assert!(!dir.join("world").exists()); 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<String> = 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:?}");
}
+33
View File
@@ -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;
};
};
};
}
+23
View File
@@ -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