headless: register signal handlers synchronously, split tests, drop deployment examples and wording
This commit was merged in pull request #2.
This commit is contained in:
+1
-1
@@ -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 |
|
| `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) |
|
| `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 <world>` 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/...`.
|
Paths are `/actor/...`, or the sender's `/public/...` or `/data/...`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
same commands are `nix run`, `nix run -- init`, `nix run -- run` and
|
||||||
`nix run .#loop`.
|
`nix run .#loop`.
|
||||||
|
|
||||||
### As a service (headless)
|
### Headless
|
||||||
|
|
||||||
Without a terminal (systemd, a container), stdin is `/dev/null` and EOF would
|
`--headless` (or `SAUSAGE_HEADLESS=1`) turns the console input off: the host
|
||||||
stop the world. `--headless` (or `SAUSAGE_HEADLESS=1`) turns the console
|
never reads stdin, so end of input does not stop the world. It runs until
|
||||||
input off: the host runs until SIGTERM/SIGINT (clean stop, exit code 0) or a
|
SIGTERM/SIGINT (clean stop, exit code 0) or a broker `exit` request (exit
|
||||||
broker `exit` request (exit code 75, so the loop starts the newest version).
|
code 75, so the loop starts the newest version). Create the world first
|
||||||
Create the world first (`init`), then run the loop:
|
(`init`), then pass the flag through the loop:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix run git+https://gitea.surma.technology/surma/sausage#loop -- --headless my-world
|
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
|
||||||
|
|||||||
+16
-10
@@ -5,6 +5,7 @@ use anyhow::{Context, Result, bail};
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use include_dir::{Dir, include_dir};
|
use include_dir::{Dir, include_dir};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
use tokio::signal::unix::Signal;
|
||||||
|
|
||||||
use sausage::world::{ActorMeta, World};
|
use sausage::world::{ActorMeta, World};
|
||||||
use sausage::{Host, cargo_actor, console};
|
use sausage::{Host, cargo_actor, console};
|
||||||
@@ -17,9 +18,9 @@ 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
|
/// Do not read stdin, so end of input does not stop the world. It runs
|
||||||
/// does not stop the world. It runs until SIGTERM (or SIGINT), or until
|
/// until SIGTERM (or SIGINT), or until an actor sends
|
||||||
/// an actor sends `@broker {"type":"exit"}`. Also set by SAUSAGE_HEADLESS=1.
|
/// `@broker {"type":"exit"}`. Also set by SAUSAGE_HEADLESS=1.
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
headless: bool,
|
headless: bool,
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -96,11 +97,16 @@ async fn init(dir: PathBuf) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves on SIGTERM or SIGINT.
|
/// Install the SIGTERM and SIGINT handlers now (not when first polled).
|
||||||
async fn termination() {
|
fn termination_signals() -> Result<(Signal, Signal)> {
|
||||||
use tokio::signal::unix::{SignalKind, signal};
|
use tokio::signal::unix::{SignalKind, signal};
|
||||||
let mut term = signal(SignalKind::terminate()).expect("install SIGTERM handler");
|
let term = signal(SignalKind::terminate()).context("install SIGTERM handler")?;
|
||||||
let mut int = signal(SignalKind::interrupt()).expect("install SIGINT 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! {
|
tokio::select! {
|
||||||
_ = term.recv() => {}
|
_ = term.recv() => {}
|
||||||
_ = int.recv() => {}
|
_ = int.recv() => {}
|
||||||
@@ -108,9 +114,9 @@ async fn termination() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run(dir: PathBuf, headless: bool) -> Result<()> {
|
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.
|
// 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?;
|
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();
|
||||||
@@ -118,7 +124,7 @@ async fn run(dir: PathBuf, headless: bool) -> Result<()> {
|
|||||||
Some(signals) => {
|
Some(signals) => {
|
||||||
println!("Sausage headless: running until SIGTERM or an exit request.");
|
println!("Sausage headless: running until SIGTERM or an exit request.");
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = signals => false,
|
_ = termination(signals) => false,
|
||||||
_ = broker.exit_requested() => true,
|
_ = broker.exit_requested() => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-8
@@ -40,12 +40,11 @@ fn bare_command_without_a_world_starts_init() {
|
|||||||
assert!(!dir.join("world").exists());
|
assert!(!dir.join("world").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `--headless` never reads stdin: with stdin at EOF the world stays up, and
|
/// Start a headless host on a fresh world with stdin at EOF; returns once it
|
||||||
/// SIGTERM stops it cleanly (exit code 0).
|
/// printed its ready line (printed after the signal handlers are installed).
|
||||||
#[test]
|
fn start_headless(label: &str) -> (std::process::Child, std::io::Lines<std::io::BufReader<std::process::ChildStdout>>) {
|
||||||
fn headless_ignores_stdin_eof_and_stops_cleanly_on_sigterm() {
|
|
||||||
use std::io::{BufRead, BufReader};
|
use std::io::{BufRead, BufReader};
|
||||||
let dir = empty_dir("headless");
|
let dir = empty_dir(label);
|
||||||
let world = dir.join("world");
|
let world = dir.join("world");
|
||||||
sausage::World::create(&world).unwrap();
|
sausage::World::create(&world).unwrap();
|
||||||
let mut child = Command::new(env!("CARGO_BIN_EXE_sausage"))
|
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()
|
.spawn()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
|
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();
|
let line = lines.next().expect("host exited before it started").unwrap();
|
||||||
assert!(line.contains("headless"), "unexpected first line: {line}");
|
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));
|
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();
|
let pid = child.id().to_string();
|
||||||
assert!(Command::new("kill").args(["-TERM", &pid]).status().unwrap().success());
|
assert!(Command::new("kill").args(["-TERM", &pid]).status().unwrap().success());
|
||||||
let rest: Vec<String> = lines.map(|l| l.unwrap()).collect();
|
let rest: Vec<String> = lines.map(|l| l.unwrap()).collect();
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user