headless: register signal handlers synchronously, split tests, drop deployment examples and wording
ci / check (pull_request) Successful in 1m36s
ci / check (push) Successful in 11s

This commit was merged in pull request #2.
This commit is contained in:
Sausage Agent
2026-10-18 15:31:41 +00:00
parent 01a821ab72
commit a72e42a8c9
6 changed files with 44 additions and 84 deletions
+16 -10
View File
@@ -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,
}
}
+21 -8
View File
@@ -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<std::io::BufReader<std::process::ChildStdout>>) {
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<String> = lines.map(|l| l.unwrap()).collect();