Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

s6 as a Supervisor — Supervision Trees, ./finish Exit Codes, and Signal Escalation

s6 (skarnet.org, current release 2.15.1.0 at retrieval time) is a process-supervision suite in the daemontools/runit lineage. Like runit it restarts a supervised process unconditionally — but unlike runit it adds the pieces runit lacks: the ./finish script’s own exit code is meaningful (exit 125 declares a permanent failure and stops restarts), stop escalation is configurable per service (timeout-kill, down-signal), readiness notification is first-class (notification-fd), and a death tally is recorded per service (s6-svdt). This page covers the supervision tree, the run/finish lifecycle, exit statuses, and the signal/timeout knobs.

runit’s take on the same philosophy is in runit.md; the policy-driven alternative is systemd.md; OpenRC can even drive an s6 tree directly (openrc.md). Cross-checks with ../../../concurrency/supervision-trees.md and the init-systems comparison page are recommended.

All claims below were verified against the official s6 documentation at skarnet.org — s6-supervise.html, s6-svscan.html, servicedir.html, s6-svc.html, s6-svstat.html, s6-svdt.html, s6-notifyoncheck.html, s6-log.html, and the suite index (retrieved 2026-10).

The supervision tree: s6-svscan → s6-supervise (SM-010)

s6 organizes supervision as a two-level tree rooted at a scan directory. s6-svscan(8) runs on the scan directory and “starts and monitors a collection of s6-supervise processes, each of these processes monitoring a single service”:

  • For each subdirectory (skipping dot-names) the scanner spawns an s6-supervise; if dir/log exists it supervises both and maintains a never-closing pipe from the service’s stdout to the logger’s stdin.
  • s6-svscan is designed never to die: on an unsolvable system-call failure it executes .s6-svscan/crash; when told to exit it executes .s6-svscan/finish. Up to -C 1000 services by default.
  • Unlike daemontools/runit’s polling loops, s6-svscan “never polls; it only wakes up on notifications” unless you deliberately pass -t rescan (-t5000 emulates daemontools’ svscan, -t14000 emulates runit’s runsvdir — the man page’s own numbers).
  • Signal behavior is scripted: SIGHUP rescans and prunes (the closest thing to “reload config”), SIGTERM tears the whole tree down without losing logs, SIGQUIT does the same but kills loggers too; any signal can be overridden by an executable .s6-svscan/SIGFOO file.

Zombie reaping is an explicit duty

The s6-svscan page is unusually explicit about the reaper, which matters when s6-svscan runs as process 1 (e.g. via s6-linux-init): upon SIGCHLD it runs a reaper that “acknowledges (via some wait() function), without blocking, every terminated child of s6-svscan, even ones it does not know it has. This is especially important when s6-svscan is run as process 1.” A dead s6-supervise is restarted one second later. At the leaf, each s6-supervise reaps its own ./run and ./finish. So every level of the tree reaps: see process-control.md for the underlying orphan/re-parenting mechanics.

s6-supervise(8) itself is a fully asynchronous state machine per service: it exits 100 if another supervisor already monitors the directory, spawns ./run, and — “s6-supervise always spawns its child in a new session, as a session leader” — a deliberate guard so a misbehaved service cannot signal-bomb the whole supervision tree through its process group.

./run, ./finish, and the restart loop (SM-010 / SM-011)

The service directory contract (servicedir.html) is:

FileRole
run (executable)the long-lived daemon; spawned by s6-supervise with one argument (the service directory name). “That process must not ‘background itself’”
finish (optional)spawned when ./run dies; short-lived cleanup
down (optional, empty)default state is down: s6-supervise will not autostart until s6-svc -u
notification-fd (optional)descriptor number the daemon writes its readiness notification to
timeout-kill, down-signal, reload-signal, timeout-finish, max-death-tally, flag-timeout-killpg, lock-fd, flag-newpidnstuning knobs, detailed below
log/ (optional service dir)logger service; stdout of run/finish is piped into log/run
supervise/, event/internal state + notification fifodir (must be writable)

The documented loop (s6-supervise.html):

  1. If the default state is up (no ./down), spawn ./run.
  2. When ./run dies, send a d event and spawn ./finish if it exists — “When ./finish dies … it restarts ./run unless it has been told not to.” Default state = restart always, unconditionally on the run process’s exit code or signal — same design property as runit.
  3. Anti-busyloop: “There is a minimum 1-second delay between two ./run spawns … if it is not ready when it dies, s6-supervise will always pause for 1 second before spawning it again” (a service that had been ready for > 1 s restarts immediately). Like runit, there is no restart limit or backoff in the core; max-death-tally records deaths for external throttling instead.

./finish arguments and the exit-125 contract (SM-011)

s6’s finish script is where exit codes and signals become policy. It receives four arguments (servicedir.html):

ArgumentValue
$1./run’s exit code — or 256 if ./run was killed by a signal (note: 256, not runit’s -1)
$2the number of the signal that killed ./run, or an undefined number if it was not signaled
$3the service directory name (same string given to ./run)
$4the process group id of the defunct ./run — for cleaning up daemons that leave children behind: the page’s own example is if test "$1" -gt 255 ; then kill -9 -- -"$4" ; fi

And the exit code of ./finish itself is the one exit-code-based decision s6 makes: “If the finish script exits 125, then s6-supervise interprets this as a permanent failure for the service, and does not restart it, as if an s6-svc -O command had been sent.” (-O = “mark the service to run once at most”). That is the entire documented finish-exit-code vocabulary — any other exit code restarts normally. timeout-finish bounds the script: default 5000 ms, then SIGKILL (0 = run forever).

#!/bin/sh
# service/finish — classify the death of ./run
# $1 = exit code, or 256 if killed by a signal
# $2 = signal number (undefined if not signaled)
# $3 = service directory name, $4 = pgid of the defunct ./run
if [ "$1" -gt 255 ] 2>/dev/null; then
    echo "killed by signal $2" | logger -t mydaemon
    # documented cleanup idiom for daemons that leave children behind:
    # kill -9 -- -"$4"
else
    echo "exited with code $1" | logger -t mydaemon
fi
# cleanup work goes here
exit 0   # any exit code except 125 keeps the restart loop alive

./down semantics

With a ./down file the service’s default state is down — s6-supervise “will not automatically start it until it receives a s6-svc -u command”. Unlike runit, s6-svc can manage the file: -D downs the service and creates ./down so it stays down across supervisor restarts; -U ups it and removes ./down. This makes “autostart or not” a first-class, tool-managed state rather than a filesystem accident.

Stop signals, timeouts, and escalation (SM-011)

There is no global stop timeout: if you do not configure timeout-kill, a s6-svc -d service is never forcibly killed. (servicedir.html: “If timeout-kill does not exist, or contains 0 or an invalid value, then the service is never forcibly killed (unless, of course, an s6-svc -k command is sent).”) The full stop protocol:

MechanismDefaultConfigurable via
stop signal on s6-svc -d / -rSIGTERM, always followed by SIGCONT “to make sure even stopped processes receive the signal”down-signal file (signal name or number)
forced kill after timeout-kill msnone (never force-killed)timeout-kill file (milliseconds)
scope of the final SIGKILLthe service pid onlyflag-timeout-killpg ⇒ whole process group
./finish budget5000 ms, then SIGKILLtimeout-finish (0 = unlimited)
immediate hard kills6-svc -k (SIGKILL now), -K to the whole process group—
reload signal for s6-svc -lSIGHUPreload-signal file

s6-svc -r implements “restart” by sending the down-signal and letting s6-supervise bring the service back up; -x makes s6-supervise exit after the service goes down one last time (the last finish invocation then runs with stdin/stdout on /dev/null). Compare the philosophy with systemd’s 90 s TimeoutStopSec default: s6 ships no implicit escalation timeout — the admin encodes it explicitly, e.g. a timeout-kill containing 10000.

Readiness notification and status reporting (SM-011)

  • If notification-fd exists, s6-supervise listens on a pipe from the service and, on notification, updates supervise/status and sends a U event on ./event. The daemon can notify itself, or a run script can fork s6-notifyoncheck to poll ./data/check (up to 7 failing attempts by default) and feed the result into the notification mechanism — the documented option for daemons with no native notification support. s6-svc -wU / s6-svwait -U block until readiness.
  • s6-svstat prints the service state, and its programmatic fields are the exit-status record: -o up,ready distinguishes started-but-not- ready from finish-script-running; -o exitcode prints the last ./run exit code (-1 if killed by a signal or currently up); -o signal/-o signum print the killing signal name/number (NA/-1 otherwise). The man page’s own examples: -1 0 NA for “down, last exit was 0”; -1 -1 SIGTERM for “down, last death was SIGTERM” — e.g. after s6-svc -d.
  • Death tally: with max-death-tally (default 100, maximum 4096 events) s6-supervise records each death; s6-svdt prints them as TAI64N timestamp + the word signal (with the signal name, or number with -s) or exitcode (with the code); s6-svdt-clear wipes the tally. This is s6’s built-in answer to “why does it keep dying” — runit has nothing equivalent, and systemd approximates it with journal history plus the Result= property.

Example service directory

/etc/s6/mydaemon/
├── run                 # execline/sh script exec'ing the daemon (foreground!)
├── finish              # cleanup; exit 125 = permanent failure
├── log/run             # s6-log pipeline: mydaemon stdout -> s6-log
├── down                # (optional) do not autostart
├── notification-fd     # contents: "3"
├── timeout-kill        # contents: "10000"  (10s to die after SIGTERM)
├── down-signal         # contents: "TERM"
└── max-death-tally     # contents: "1000"
#!/bin/execlineb -P
# run — stdout is piped to the logger automatically; mirror stderr into it,
# drop privileges, then exec the daemon in the foreground
# (the servicedir page's own redirection idiom is fdmove -c 2 1)
fdmove -c 2 1
s6-setuidgid mydaemon
exec mydaemon --foreground

Logging is a first-class service: log/run typically execs s6-log, “a reliable logging program with automated log rotation … It does its best to ensure there is never any log loss”, driven by a logging script (dirs, size/time rotation, T timestamping, processors). Because the logger is just another supervised service, a crashed logger is restarted like any other service.

Ecosystem note: 66 and anopa

The official s6 documentation’s related-resources list points to 66 (https://web.obarun.org/software/66/latest/) and anopa (https://jjacky.com/anopa) as service managers built on top of the s6 supervision primitives, alongside s6-rc (declared-state service management) and s6-linux-init (s6-svscan as PID 1 with a catch-all logger). Their supervision, signal, and restart behavior reduces to the s6-supervise mechanics above; consult their own documentation before relying on their extras.

  • runit.md — the closest design relative; compare finish argument conventions (-1 vs 256) and stop-timeout philosophy.
  • systemd.md — exit-code-conditioned restarts and default timeouts vs. s6’s opt-in escalation.
  • openrc.md — OpenRC’s supervisor=s6 integration knobs.
  • comparison.md and index.md — cross-manager comparisons.
  • supervision-trees.md — the general tree-of-supervisors pattern s6 embodies.
  • signals.md — SIGTERM/SIGCONT/SIGKILL semantics used by the stop protocol.

References