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; ifdir/logexists 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 1000services by default. - Unlike daemontools/runit’s polling loops, s6-svscan “never polls; it
only wakes up on notifications” unless you deliberately pass
-t rescan(-t5000emulates daemontools’ svscan,-t14000emulates runit’s runsvdir — the man page’s own numbers). - Signal behavior is scripted:
SIGHUPrescans and prunes (the closest thing to “reload config”),SIGTERMtears the whole tree down without losing logs,SIGQUITdoes the same but kills loggers too; any signal can be overridden by an executable.s6-svscan/SIGFOOfile.
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:
| File | Role |
|---|---|
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-newpidns | tuning 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):
- If the default state is up (no
./down), spawn./run. - When
./rundies, send adevent and spawn./finishif 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. - 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-tallyrecords 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):
| Argument | Value |
|---|---|
$1 | ./run’s exit code — or 256 if ./run was killed by a signal (note: 256, not runit’s -1) |
$2 | the number of the signal that killed ./run, or an undefined number if it was not signaled |
$3 | the service directory name (same string given to ./run) |
$4 | the 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:
| Mechanism | Default | Configurable via |
|---|---|---|
stop signal on s6-svc -d / -r | SIGTERM, 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 ms | none (never force-killed) | timeout-kill file (milliseconds) |
scope of the final SIGKILL | the service pid only | flag-timeout-killpg ⇒ whole process group |
./finish budget | 5000 ms, then SIGKILL | timeout-finish (0 = unlimited) |
| immediate hard kill | s6-svc -k (SIGKILL now), -K to the whole process group | — |
reload signal for s6-svc -l | SIGHUP | reload-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-fdexists, s6-supervise listens on a pipe from the service and, on notification, updatessupervise/statusand sends aUevent on./event. The daemon can notify itself, or a run script can forks6-notifyoncheckto 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 -Ublock until readiness. s6-svstatprints the service state, and its programmatic fields are the exit-status record:-o up,readydistinguishes started-but-not- ready from finish-script-running;-o exitcodeprints the last./runexit code (-1if killed by a signal or currently up);-o signal/-o signumprint the killing signal name/number (NA/-1otherwise). The man page’s own examples:-1 0 NAfor “down, last exit was 0”;-1 -1 SIGTERMfor “down, last death was SIGTERM” — e.g. afters6-svc -d.- Death tally: with
max-death-tally(default 100, maximum 4096 events) s6-supervise records each death;s6-svdtprints them asTAI64N timestamp+ the wordsignal(with the signal name, or number with-s) orexitcode(with the code);s6-svdt-clearwipes 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 theResult=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.
Related Topics
- runit.md — the closest design relative; compare finish
argument conventions (
-1vs256) and stop-timeout philosophy. - systemd.md — exit-code-conditioned restarts and default timeouts vs. s6’s opt-in escalation.
- openrc.md — OpenRC’s
supervisor=s6integration 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
- s6: the s6-supervise program — run/finish loop, finish arguments, finish-exit-125 semantics, timeout-finish, signals: https://skarnet.org/software/s6/s6-supervise.html
- s6: service directories —
down,notification-fd,timeout-kill,flag-timeout-killpg,timeout-finish,down-signal,reload-signal,max-death-tally: https://skarnet.org/software/s6/servicedir.html - s6: the s6-svscan program — supervision tree, reaper, scanner, signal behavior: https://skarnet.org/software/s6/s6-svscan.html
- s6: the s6-svc program — control commands,
-d/-k/-O/-D/-U, down-signal + SIGCONT: https://skarnet.org/software/s6/s6-svc.html - s6: the s6-svstat program — status fields, exitcode/signal semantics: https://skarnet.org/software/s6/s6-svstat.html
- s6: the s6-svdt program — death tally output: https://skarnet.org/software/s6/s6-svdt.html
- s6: the s6-notifyoncheck program — readiness polling: https://skarnet.org/software/s6/s6-notifyoncheck.html
- s6: the s6-log program — logging scripts and rotation: https://skarnet.org/software/s6/s6-log.html
- s6 index (suite overview, version 2.15.1.0, related projects incl. 66 and anopa): https://skarnet.org/software/s6/index.html
- s6: an overview — concept of process supervision: https://skarnet.org/software/s6/overview.html