A process supervisor daemon written in Go, that is largely inspired by supervisord. It efficiently manages child processes with dependency support, config-based lifecycle management, and log rotation.
- Features
- Installation
- Quick Start
- Command-Line Options
- Configuration
- Process States
- Signals
- Reloading configuration
- Recovering from a crash
- Process groups
- Running as PID 1
- Stopping processes
- Restart behavior
- Dependency Management
- Health checks
- Log Rotation
- Best practices
- Examples
- Architecture
- Development
- License
- Contributing
- Process Management: Start, stop, restart, and monitor child processes
- Dependency Management: Launch processes once the programs they depend on are running, are ready to serve, or have finished their work
- Health Checks: Probe a program to tell whether it is ready to serve, and hold its dependents back until it is
- Configuration-Based: Configure process lifetime and behavior via YAML config files
- Log Rotation: Automatic log file rotation based on file size with configurable retention periods
- CLI Tool: Command-line interface for managing processes
- Process States: Track process states
- STOPPED
- STARTING
- RUNNING
- BACKOFF
- STOPPING
- EXITED
- FATAL
- Auto-restart Policies: Configure restart behavior (always, never, unexpected)
Each release publishes one tar.gz per architecture, holding both binaries, the
sample config, and a checksums.txt to verify the download against. Linux only,
amd64 and arm64.
VERSION=0.9.0
ARCH=amd64 # or arm64
curl -sSfL -O https://github.com/ademidoff/supavisor/releases/download/v${VERSION}/supavisor_${VERSION}_linux_${ARCH}.tar.gz
curl -sSfL -O https://github.com/ademidoff/supavisor/releases/download/v${VERSION}/checksums.txt
sha256sum --ignore-missing -c checksums.txt
tar -xzf supavisor_${VERSION}_linux_${ARCH}.tar.gz
sudo install -m 0755 supavisor sctl /usr/local/bin/The binaries are built with CGO_ENABLED=0, so they carry no libc dependency
and run on any distribution.
git clone https://github.com/ademidoff/supavisor
cd supavisor
make buildA source build reports its version as dev; only released binaries carry a
version, commit and build date.
- Create a configuration file (e.g.,
supavisor.yml):
supavisor:
logfile: /var/log/supavisor/supavisor.log
pidfile: /var/run/supavisor.pid
socket: /tmp/supavisor.sock
programs:
database:
command: /usr/bin/postgres
autostart: true
autorestart: unexpected
stdout_logfile: /var/log/database/stdout.log
stdout_logfile_maxbytes: 50MB
stdout_logfile_backups: 10
webapp:
command: /usr/bin/python app.py
directory: /opt/webapp
autostart: true
autorestart: always
startsecs: 10
depends_on:
- database
stdout_logfile: /var/log/webapp/stdout.log
stdout_logfile_maxbytes: 10MB
stdout_logfile_backups: 5
stdout_logfile_maxage: 7- Start the supavisor daemon:
# Run in foreground
./supavisor -c supavisor.yml
# Run in background
./supavisor -c supavisor.yml &
# Or use nohup for persistent background execution
nohup ./supavisor -c supavisor.yml &Note:
- When a logfile is configured, all logs are written to the log file only (no console output)
- When no logfile is configured, logs are written to stdout (useful for container environments)
- To run without a logfile, comment out or omit the
logfilesetting in the config - Supavisor holds an exclusive lock on its PID file for as long as it runs, which is what prevents a second instance from starting. Starting one reports the PID of the daemon that already holds the lock.
- The kernel releases the lock when the daemon exits, including on a crash, so a
PID or socket file left behind by a crash is not an obstacle: supavisor takes
the lock, reuses the paths and starts normally. There is nothing to remove by
hand, and supavisor can run under
systemd'sRestart=or a container restart policy without needing intervention to come back up. - On shutdown supavisor removes its PID file and socket only if those paths still refer to the files it created, so a slow shutdown can never delete the files of a daemon that has already replaced it.
- Processes that outlive a supavisor crash are stopped when supavisor next starts. See Recovering from a crash.
- Use the CLI tool to manage processes:
# Check status
./sctl status
# Look at one process, including why it is not running
./sctl status webapp
# Start a process
./sctl start webapp
# Stop a process
./sctl stop webapp
# Restart a process
./sctl restart webapp
# Reload configuration
./sctl reload
# Shutdown supavisor
./sctl shutdown./supavisor [options]Options:
-c, -config <path>: Path to configuration file (default:/etc/supavisor/supavisor.yml)-logfile <path>: Override log file path from config (optional)-version: Print version, commit and build date, then exit
./sctl [options] <command> [process-name]Options must be given before the command; sctl -s /run/supavisor.sock status
works, sctl status -s /run/supavisor.sock is rejected.
Options:
-s, -socket <path>: Path to supavisor socket (default:/tmp/supavisor.sock)-o, -output <format>:table(default) orjson, see Machine-readable output-version: Print version, commit and build date, then exit
Access to the socket is access to the daemon: it can stop supervised processes,
and start causes configured commands to run as the user supavisor runs as. The
socket is therefore created 0660, owned by socket_group when one is set.
Commands:
status: Show status of all processesstatus <name>: Show one process in detail, including why it is not runningstart <name>: Start a specific processstop <name>: Stop a specific processrestart <name>: Restart a specific processreload: Re-read the configuration and apply what changedshutdown: Shutdown supavisor daemon
The table is for people. Its aligned columns, the N/A it prints for numbers
that mean nothing yet and the - it prints for absent health are presentation
choices, and they are not a stable interface. -o json prints the response the
daemon actually sent:
$ sctl -o json status
{
"data": {
"processes": [
{
"desired": "RUNNING",
"exit_code": 0,
"health": "HEALTHY",
"name": "db",
"pid": 4211,
"restart_count": 0,
"state": "RUNNING",
"uptime": "2m 10s"
}
]
},
"success": true
}The shape is the same for every command, so data carries the processes for
status and the applied diff for reload, and the envelope is printed whether
or not the command succeeded. A failure is reported as JSON rather than on
stderr, with the exit code still carrying the outcome:
$ sctl -o json start nosuchprog
{
"message": "process nosuchprog not found",
"success": false
}
$ echo $?
1sctl -o json status <name> keeps the same envelope as the whole-list form, with
one entry in processes, so parsing does not depend on how the command was
invoked.
Two things to read carefully, because the table hides them behind placeholders:
pidis whatever the daemon last knew. For a program that is not running it is the PID of the run that ended, where the table printsN/A. Read it together withstate.healthisNONEwhen no probe is configured, which the table shows as-. It does not mean unhealthy.
In addition to the main config file passed via -c, supavisor will look for a
sibling drop-in directory named after the main file. For
/etc/supavisor/supavisor.yml the directory is /etc/supavisor/supavisor.d/.
- Every
*.ymland*.yamlfile in the drop-in directory is loaded in lexical order and merged with the main file. - Fragment files may only define the
programs:section. Asupavisor:section in a fragment is rejected; daemon-level settings must live in the main file. - A program name defined in two files is a hard error — supavisor refuses to start and names both source files.
depends_oncan reference programs defined in any file; dependencies are resolved across the merged program set.
If the drop-in directory does not exist, supavisor simply loads the main file.
Unknown keys are rejected. A misspelled setting such as autorestrt: always fails
at startup and names the offending key, rather than being ignored and leaving the
program running on defaults nobody chose.
Settings whose zero value is meaningful distinguish "set to zero" from "not set":
max_restarts: 0 means never retry, startsecs: 0 means treat the process as
started immediately, and stopwaitsecs: 0 means kill without waiting. Omitting a
setting takes its default.
logfile: Path to supavisor's own log file (optional)- When specified, all logs are written to this file only (no console output)
- When not specified, logs are written to stdout (if running in a terminal)
- Can be overridden with the
-logfilecommand-line flag
pidfile: Path to PID file (default:/var/run/supavisor.pid)socket: Path to Unix domain socket for CLI communication (default:/tmp/supavisor.sock). Unix sockets cap the path at about 104 bytes; a longer one is rejected at startup rather than failing withbind: invalid argument.socket_group: Group given ownership of the control socket, by name or numeric gid (optional). The socket is created with mode0660, so by default only the user running supavisor can usesctl. Set this to an administrators' group to let its members manage processes without being that user.log_format: Log format -text(default) orjsonlog_level: Log level -debug,info(default),warn, orerror
Each program is defined under programs with its name as the key:
command: Command to run (required). This is not run through a shell: there is no expansion, globbing or substitution. Quoting and backslash escapes are supported, socommand: /bin/app --msg "hello world"and--path a\ bboth work. To use shell features, invoke a shell explicitly:command: /bin/sh -c "a && b".directory: Working directory for the processautostart: Start process automatically on supavisor startup (default: true)autorestart: Restart policy -always,never, orunexpected(default: unexpected)startsecs: Seconds to wait before considering start successful (default: 1)stopsignal: Signal sent to stop the process -TERM(default),INT,QUIT,HUP,USR1,USR2orKILL. TheSIGprefix is optional. See When a program needsstopsignal: INT.stopwaitsecs: Seconds to wait for the process to exit onstopsignalbefore it is killed (default: 10)max_restarts: Maximum number of consecutive restarts before giving up (default: 3). See Restart behavior for how the counter is reset.depends_on: Programs this one waits for, either as a list of names or as a mapping that says what each one has to reach:started,healthyorcompleted. See Dependency Management.health_check: How to tell whether this program is ready to serve, rather than merely running. See Health checks.stdout_logfile: Path to stdout log file. If omitted, the process's stdout is discarded (connected to/dev/null).stderr_logfile: Path to stderr log file. If omitted, the process's stderr is discarded (connected to/dev/null).stdout_logfile_maxbytes: Maximum size before rotation (supports KB, MB, GB suffixes, default: 50MB). An unparseable size is a startup error, not a silent fallback to the default.stdout_logfile_backups: Number of rotated logs to keep (default: 10)stdout_logfile_maxage: Days to keep rotated logs (0 = no limit, default: 0)stderr_logfile_maxbytes: Maximum size before rotation (default: 50MB)stderr_logfile_backups: Number of rotated logs to keep (default: 10)stderr_logfile_maxage: Days to keep rotated logs (default: 0)environment: Map of environment variables (e.g.,APP_ENV: production)priority: Order among programs that are ready to start at the same moment, lower first (default: 999). Dependencies still decide what may start; priority only settles ties between programs that are all ready.
The user setting is not implemented. Rather than accept it and run the program
as supavisor's own user anyway, a config that sets it is rejected at startup. Run
supavisor itself as the intended user instead.
STOPPED: Process was stopped by supavisor (e.g., viasctl stop)STARTING: Process is starting upRUNNING: Process is running normallyBACKOFF: Process failed to start, waiting before retrySTOPPING: Process is being stopped (transitional state)EXITED: Process exited on its own (completed normally or crashed)FATAL: Process failed to start after all retries
sctl status lists every configured program, including ones that have never been
started; those are reported as STOPPED.
sctl stop always ends in STOPPED, including for a process that had already
exited or reached FATAL on its own. Stopping a process that is waiting out a
restart backoff cancels that pending restart.
The HEALTH column of sctl status reports separately on programs that declare a
health_check; see Health checks.
A state on its own does not say whether anything is wrong: a STOPPED program may
be one nobody has asked for, or one that wants to run and cannot. The DESIRED
column separates them, and sctl status <name> says why:
$ sctl status
NAME STATE DESIRED HEALTH PID EXIT_CODE RESTARTS UPTIME
---- ----- ------- ------ --- --------- -------- ------
api STOPPED RUNNING - N/A 0 0 N/A
db RUNNING RUNNING UNHEALTHY 89734 0 0 8s
doomed FATAL RUNNING - N/A 1 1 N/A
idle STOPPED STOPPED - N/A 0 0 N/A
$ sctl status api
Name: api
State: STOPPED
Desired: RUNNING
Health: -
PID: N/A
Exit code: 0
Restarts: 0
Uptime: N/A
Reason: dependency db is running but its health check is UNHEALTHY
Reason appears only when there is something to explain, and covers every way a
program can be not running while it is still wanted:
| State | Reason |
|---|---|
STOPPED |
dependency db is running but its health check is UNHEALTHY |
FATAL |
gave up after 1 restart |
EXITED |
exited with status 3; autorestart is never |
EXITED |
exited cleanly; autorestart is unexpected |
The two EXITED forms name the policy that decided not to bring the program back,
which is the setting to change if that was not what you wanted. A program that is
running has no Reason line, and neither has one that is simply not wanted. The
same string is available to API clients in the reason field of the status
response.
Supavisor handles:
SIGTERM/SIGINT: stop all processes and exit. A second one, sent while that shutdown is still in progress, exits immediately rather than being ignored, so a program that refuses to stop cannot leave the daemon unkillable by anything short ofSIGKILL.SIGHUP: reload the configuration, equivalent tosctl reload.
sctl reload re-reads the config file and its drop-in fragments and applies the
difference:
- Programs that are unchanged keep running and are not disturbed.
- Programs that were removed are stopped and forgotten.
- Programs that were added are started if they are set to autostart.
- Programs whose definition changed are stopped and started again on the new one.
A program that was deliberately stopped stays stopped, even if its definition changed: reload applies configuration, it does not override what an operator asked for.
If the new configuration is invalid, the reload is refused and nothing changes,
so a typo cannot take running programs down. Daemon-level settings (pidfile,
socket, socket_group, logfile, log_format, log_level) are bound at
startup; changing one is reported by name and needs a restart.
Reload reports what it applied, naming only the categories that have something in them:
$ sctl reload
configuration reloaded
changed: victoriametrics, vmalert
$ sctl reload
configuration reloaded, nothing changed
This matters to anything driving supavisor over the socket rather than by hand. A
program that owns a drop-in directory, writes fragments into it and then reloads
can check whether its own name is in changed, because a program whose definition
changed is stopped and replaced — including when it is the one that asked for the
reload. The reload itself still completes; what the caller loses is the chance to
see it finish.
If supavisor is killed rather than shut down, the processes it manages keep running: they are reparented to init and nothing supervises them any more. Starting supavisor again would otherwise add a second copy of every program.
Supavisor therefore records the processes it owns in a state file next to its PID
file (/var/run/supavisor.pid is tracked by /var/run/supavisor.state), and on
startup stops anything from that file that is still running before starting
anything of its own. Survivors are sent SIGTERM, and SIGKILL if they have not
exited within five seconds. Recovery needs no operator intervention.
Supavisor stops these processes rather than adopting them because they are no longer its children: it cannot wait on them, so it could neither collect their exit status nor notice them exiting.
A recorded PID is only acted on if the process running under it is still the one supavisor started. PIDs get reused, so each record also stores the process's start time; if it does not match, the PID now belongs to something unrelated and is left alone.
Records are also tied to the boot they were made on. A start time on Linux is measured in ticks since boot, so it repeats every boot: without this, a state file that outlived a restart could match a PID against a process that merely started at the same offset of the current boot. Records from an earlier boot are discarded rather than acted on.
Both checks need platform support, which supavisor implements for Linux and macOS. Anywhere else, recorded processes are left running rather than risking killing the wrong one.
The state file is removed on a clean shutdown, so it only ever has contents to act on after a crash.
Each managed process is started in a process group of its own, and everything it spawns inherits that group. Stopping a process signals the whole group rather than just the command supavisor launched, so a wrapper script does not leave the real workload running:
programs:
worker:
# 'sh' is the direct child, but the python process is what matters
command: /bin/sh -c "exec_setup && python worker.py"After a process exits, anything still left in its group is killed, so a program that backgrounds work and returns does not leak it.
One consequence: because managed processes are in their own groups, Ctrl+C in a
terminal running supavisor in the foreground reaches supavisor only. Supavisor
then stops its processes itself, in its own order.
Supavisor is safe to run as PID 1 in a container, with no tini or dumb-init
in front of it.
PID 1 inherits every orphan on the system. When a supervised program spawns a
child and exits first, that child is reparented to PID 1, and when it eventually
exits somebody has to call wait() for it or it stays a zombie forever. Nothing
else will: its original parent is gone. A supervisor that only waits for its own
children accumulates them for as long as the container runs.
Supavisor therefore does all of its waiting in one place. A single reaper calls
wait4(-1), hands each status to whichever program owns that PID, and discards
the rest, which is exactly the orphan case. It follows that nothing else may
wait: os/exec's own Wait is a second waiter, and two waiters race for the
same statuses, so an exit code goes missing every so often and a program that
exited cleanly looks like it crashed. That is the reason the process package
never calls cmd.Wait().
None of this needs configuring, and it costs nothing when supavisor is not PID 1: with a real init above it, orphans reparent there instead and the reaper simply never sees them.
An entrypoint script has to hand PID 1 over rather than keep it:
#!/bin/sh
# ... setup ...
exec supavisor -c /etc/supavisor/supavisor.ymlWithout the exec the script stays PID 1 and supavisor runs as its child. Docker
then sends SIGTERM to the script on docker stop, and a shell does not pass it
on: supavisor never learns it is meant to stop, and every program is killed
outright when the container is torn down. None of the shutdown described in
Stopping processes happens — no stopsignal, no
stopwaitsecs, no dependency order. A database configured for a fast shutdown
gets a kill instead, and recovers on next boot.
Measured, stopping the same container both ways:
with or without exec |
supavisor sees SIGTERM |
shuts down cleanly |
|---|---|---|
with exec |
yes | yes, down to Supavisor daemon stopped |
without exec |
no | no, last line is a program reaching RUNNING |
Reaping is not the reason, which is what makes this easy to miss: a shell left
as PID 1 does reap orphans, both ash and bash, so nothing accumulates and the
container looks healthy. The damage only appears when you stop it.
Both halves of that are reproducible rather than asserted. probes/pid1-zombies.sh
runs supavisor as PID 1 in a container and counts what it leaves behind, and
probes/wait4-race/main.go demonstrates the exit codes that go missing if a second
waiter is added. See probes/README.md.
sctl stop, sctl restart and daemon shutdown all follow the same sequence:
stopsignal(defaultTERM) is sent to the process group.- Supavisor waits up to
stopwaitsecs(default 10) for the group to exit. - Anything still alive is sent
SIGKILL.
SIGTERM is the right default, because it is the signal a daemon is
conventionally asked to shut down with. But a program only shuts down cleanly on
a signal it actually handles, and some programs only handle SIGINT. Sending
those SIGTERM skips their cleanup entirely and they are killed stopwaitsecs
later.
Set stopsignal: INT when the program is one of these:
- Written to be stopped with Ctrl+C. A program developed by running it in a
terminal usually grows a
SIGINThandler and nothing forSIGTERM, because Ctrl+C was the only way it was ever stopped. - A Python program relying on
KeyboardInterrupt.SIGINTraisesKeyboardInterrupt, sotry/finally,withblocks andatexithandlers all run.SIGTERMhas no default handler in Python at all: the process is terminated with nothing run. A program that catchesKeyboardInterrupt, or relies onatexit, but never callssignal.signal(signal.SIGTERM, ...)needsstopsignal: INT. - A Node.js program registering only
process.on('SIGINT', ...). Same shape: the default disposition forSIGTERMterminates the process without running the handler. - A program that treats the two asymmetrically, using
SIGINTto drain gracefully andSIGTERMto stop at once. Some queue workers and batch jobs do this deliberately so an operator can pick. - Anything that stopped cleanly under an older supavisor. Stopping used to be
hard-coded to
SIGINT;stopsignal: INTrestores the previous behavior for a program that regressed.
The same reasoning applies to the other values. QUIT is worth knowing about
because some servers, nginx among them, document SIGQUIT as their graceful
shutdown and SIGTERM as a fast one.
A program that ignores its stop signal always takes the full stopwaitsecs and
is then killed. In supavisor's log that looks like:
msg="Signaling the process group for graceful shutdown" process=importer signal=terminated
msg="Graceful shutdown timeout, sending SIGKILL to the process group" process=importer
msg="Force killed" process=importer
A program that handles the signal logs this instead, and promptly:
msg="Signaling the process group for graceful shutdown" process=importer signal=terminated
msg="Process exited gracefully" process=importer
Timing shows it too: time sctl stop <name> taking about stopwaitsecs rather
than returning straight away means the signal was ignored. You can also check the
program directly, outside supavisor:
# Run it by hand, then from another terminal:
kill -TERM <pid> # does it clean up and exit?
kill -INT <pid> # does this work where TERM did not?Putting it together:
programs:
importer:
command: /usr/bin/python3 /opt/importer/run.py
stopsignal: INT # cleanup hangs off KeyboardInterrupt
stopwaitsecs: 30 # let the in-flight batch finishIf a program handles neither signal there is nothing to configure: it will always
be killed. Lower its stopwaitsecs so shutdown is not held up waiting for a
graceful exit that is never going to happen.
When a process exits on its own and its autorestart policy calls for a restart,
supavisor waits before starting it again, doubling the delay on each consecutive
attempt: 1s, 2s, 4s, 8s, 16s, then 30s for every attempt after that.
The consecutive-restart counter is compared against max_restarts; exceeding it
puts the process in FATAL. The counter is reset to zero whenever a run lasts at
least 60 seconds, so max_restarts bounds crash loops rather than the total number
of restarts over the lifetime of the daemon. A process that is restarted once a
week will not eventually reach FATAL.
Supavisor works from a desired state for each program rather than from a fixed
startup sequence. autostart sets the initial desired state, sctl start and
sctl stop change it, and a reconcile loop continuously moves each program
towards whatever its desired state currently is.
Dependencies fall out of that: a program is started once every entry in its
depends_on has reached the condition it is waited on for, and until then it
simply stays STOPPED and is reconsidered. Nothing is scheduled in advance, so a
dependency that takes a long time to come up, or that is started by hand much
later, does not strand the programs behind it:
programs:
db:
command: /usr/bin/postgres
autostart: false # started by hand, whenever
api:
command: /usr/bin/api
depends_on: [db] # starts on its own once db is RUNNINGdepends_on also accepts a mapping, which says what each dependency has to reach:
programs:
db:
command: /usr/bin/postgres
health_check:
exec: pg_isready -q
api:
command: /usr/bin/api
depends_on:
db:
condition: healthy # wait for the check to pass, not just for the process
logs:
command: /usr/bin/tailer
depends_on:
db: # no condition means started, as the list form doescondition: started(the default) waits for the dependency to beRUNNING, which is whatdepends_onhas always meant.condition: healthyalso waits for the dependency'shealth_checkto pass. The dependency must declare one, or the configuration is rejected at startup rather than leaving the dependent waiting for something that can never happen.condition: completedwaits for the dependency to finish its work, which is what a migration or an init task is depended on for. See Waiting for a one-off to finish.
Both forms can be mixed across programs, and the list form is unchanged: adding a
health_check to a program does not start gating dependents that only asked for it
to be running.
started waits for the process and healthy waits for readiness. Neither says
"wait for the work to be done", which is what an init task, a migration, a schema
load or a seed job is actually depended on for. That is condition: completed:
programs:
migrate:
command: /usr/bin/migrate --up
autorestart: never
api:
command: /usr/bin/api
depends_on:
migrate:
condition: completedapi starts once migrate has exited with status 0, and keeps running once it has.
- Exit status 0 is what satisfies it. That is the same line
autorestart: unexpecteddraws, so the two agree on what a successful run is. - It is latched. The condition stays satisfied while the task sits in
EXITED, so a dependent that is started an hour later, or that crashes and comes back, still starts. The latch belongs to the running daemon: a restarted one runs the task again, or, if the task hasautostart: false, holds the dependents back until it is started by hand. - Running the task again clears it, so
sctl restart migratere-runs the work and latches again when it succeeds. Dependents that are already up are left alone, in the same way a dependency crashing does not stop them. - A task that never completes is retried under its own
autorestartpolicy and eventually reachesFATAL. Dependents are not left waiting in silence: starting one is refused immediately, and the task responsible is named. sctl starton the task itself reports what the work did rather than only whether the program stayed up. A task that finishes insidestartsecsnever reachesRUNNING, and exiting 0 is reported as the success it is.- Reload replacing the task clears the latch and runs the new definition, since the completion belonged to the definition that was replaced. Dependents that are already running stay running.
autorestart: unexpected is the natural pairing: retry an unsuccessful run, settle
on a successful one. autorestart: always on a task something waits to complete is
rejected at startup, naming both programs — a program that is always restarted is
never left in EXITED, so the wait could never end.
Other behavior:
- When a dependency stops or crashes, it is restarted according to its own
autorestartpolicy. Dependent processes keep running: supavisor does not stop or restart them. - Restarts within a run are the
autorestartpolicy's business, not the loop's. A program that exited on its own, or that gave up aftermax_restarts, is left alone rather than being started again by reconciliation. sctl startwaits for the outcome and reports it. If the program cannot start because something it depends on will never come up, it says so immediately and names the program actually responsible, however far down the chain it is.- A program that is being held back logs why, once per distinct reason rather than
on every pass of the reconcile loop. A dependency that stays down for an hour
costs one line, and the line changes as the reason does. The same explanation is
available at any time from
sctl status <name>. - A desired state persists: stopping a program keeps it stopped, and a program waiting on a dependency starts as soon as that dependency is up, with no second command needed.
Shutdown runs the same relationships in reverse: programs are stopped from the
outermost dependents inwards, so nothing is pulled out from under something still
using it. Programs that do not depend on each other are stopped at the same time,
so total shutdown time is bounded by the slowest tier rather than by the sum of
every program's stopwaitsecs.
Circular dependencies are detected and rejected during configuration validation.
RUNNING means the process is alive: it stayed up for startsecs and its PID still
answers. That is liveness, not readiness. A database forks early and then spends
time on recovery before it accepts connections, and a service can be up long before
it binds its socket or finishes its migrations. A dependent started in that window
fails to connect and exits, and whether the stack recovers comes down to its restart
policy.
startsecs cannot close that gap, because it is a fixed sleep: too short on a cold
start, which is exactly when the race bites, and paid in full on every restart when
it is long enough. A health check observes readiness instead of guessing at it:
programs:
db:
command: /usr/bin/postgres
health_check:
exec: pg_isready -q -h 127.0.0.1
interval: 2s
timeout: 5s
retries: 3
start_period: 60sExactly one of these is required:
exec: run a command; exit status 0 means ready. It runs from the program'sdirectoryand with the program'senvironment, so a check likepg_isreadysees the same settings the program was given. Likecommand, it is not run through a shell; use/bin/sh -c '...'if you need one.tcp: connect tohost:port; a completed connection means ready.http: issue aGET; any status below 400 means ready.
interval: how often to probe (default: 2s). The first probe runs immediately rather than after one interval, so a program that is ready straight away does not pay the interval as startup latency. A probe never overlaps itself: the next one is due only after the previous attempt has finished.timeout: how long one attempt may take before it counts as a failure (default: 5s)retries: consecutive failures before the program is reportedUNHEALTHY(default: 3)start_period: a window after the process starts during which failures do not count, for a program that is known to need time to initialize (default: 0). It applies only until the first successful check; after that, failures count normally.
Durations are written as Go durations, such as 500ms, 2s or 1m. An unparseable
one is a startup error rather than a silent fallback to the default, as is a probe
that sets none or more than one of exec, tcp and http.
Health is reported alongside the process state, in the HEALTH column of
sctl status and in the health field of the status API:
NAME STATE HEALTH PID EXIT_CODE RESTARTS UPTIME
---- ----- ------ --- --------- -------- ------
db RUNNING HEALTHY 4211 0 0 2m 10s
api RUNNING - 4230 0 0 1m 44s
-: no health check is configured, or the program is not running, where readiness would mean nothingSTARTING: a configured check that has not passed yetHEALTHY: the last attempt passedUNHEALTHY:retriesattempts in a row have failed
A failing probe does not stop or restart the program. Supavisor reports what it
observes, and restarts stay tied to the process actually exiting, so a flapping
probe cannot take a working process down. What health decides is whether dependents
that asked for condition: healthy may start, and a dependency that goes unhealthy
after they are already up does not stop them, in the same way a dependency that
crashes does not.
Probes belong to a run: they start with the process and stop when it exits or is
stopped, and the health goes back to -.
Processes do not write to their log files directly. Supavisor gives each stream a pipe, reads it line by line, and writes to the log file itself. This is what makes rotation reliable: the log descriptor belongs to supavisor, so renaming the file and opening a new one actually redirects subsequent output. A process holding its own descriptor would keep writing to the renamed file no matter what the supervisor did to the directory entry.
When a line would take the file past its configured maximum size:
- Existing backups are rotated (
.1->.2,.2->.3, etc.) - Current log is moved to
.1 - A new file is created and output continues into it
- Backups beyond the configured count are removed
- Backups older than
maxagedays are removed
Because rotation happens on line boundaries, a log file can exceed its maximum by at most one line. A run of output longer than 64KB with no newline in it is written out in pieces rather than buffered.
Backup pruning runs when a log is opened and at each rotation, so a process that never produces enough output to rotate keeps its existing backups until it is next restarted.
Notes:
- If a stream has no
stdout_logfile/stderr_logfileconfigured, it is connected to/dev/nulland nothing is captured. - If both streams point at the same file, they share one pipe, so their output interleaves in the order it was written.
- Two different programs may not share a log file. Supavisor owns each log descriptor so that rotation works, so sharing one would have two writers rotating the same files and destroying each other's output. This is rejected at startup.
- Because output flows through supavisor, a process that logs faster than the disk can absorb will eventually block on write, and a process that outlives a supavisor crash will see its output descriptor close.
A one-off program — one that does a piece of work and exits, such as a migration, a bootstrap script or an init playbook — is a perfectly good thing to supervise, and a perfectly good thing to depend on. What matters is which condition the dependents use, because two of the three cannot express what a one-off offers.
condition: started is satisfied while the dependency is RUNNING, and a one-off is
RUNNING only for as long as its work takes. A dependent gated on that either starts
inside that window — concurrently with the task it was meant to follow, not after it
— or, if it comes to be started after the work has finished, does not start at all:
the task has exited and is not coming back.
condition: healthy is no better. Probes belong to a run, so a one-off's health
returns to - when it exits; unless a probe happened to pass while the process was
still alive, the dependent is waiting for a state that can no longer occur. Whether
it does is a matter of timing between the check interval and the exit, which is not
something to build on.
condition: completed is the one to use. It waits for the work rather than for the
process, and it stays satisfied afterwards, so a dependent restarted long after the
task finished still starts:
programs:
migrate:
command: /usr/bin/migrate --up
# Retry a failed run; a successful one settles in EXITED and stays there.
autorestart: unexpected
priority: 1
db:
command: /usr/bin/postgres
priority: 5
health_check:
exec: pg_isready -q
api:
command: /usr/bin/api
priority: 10
depends_on:
db:
condition: healthy
migrate:
condition: completedpriority is not a substitute for any of this: it orders the launch of programs that
are ready to start, and does not wait for one to finish. Use it to get work moving
early, and depends_on to express what must not run until it is done.
supavisor: {}
programs:
myapp:
command: /usr/bin/myapp
autostart: true
autorestart: always
stdout_logfile: /var/log/myapp/stdout.logsupavisor: {}
programs:
database:
command: /usr/bin/postgres
autostart: true
autorestart: unexpected
webapp:
command: /usr/bin/python app.py
depends_on:
- database
autostart: true
autorestart: alwayssupavisor: {}
programs:
database:
command: /usr/bin/postgres
autostart: true
autorestart: unexpected
health_check:
exec: pg_isready -q -h 127.0.0.1
interval: 2s
start_period: 60s
webapp:
command: /usr/bin/python app.py
depends_on:
database:
condition: healthy
autostart: true
autorestart: alwayssupavisor: {}
programs:
worker:
command: /usr/bin/worker
stdout_logfile: /var/log/worker/stdout.log
stdout_logfile_maxbytes: 100MB
stdout_logfile_backups: 10
stdout_logfile_maxage: 30supavisor: {}
programs:
myapp:
command: /usr/bin/myapp
environment:
APP_ENV: production
APP_PORT: "8080"
PATH: /usr/bin:/usr/local/bin:/opt/bin
DEBUG: "false"cmd/supavisor: Main daemon entry pointcmd/sctl: CLI tool for managing processesinternal/config: Configuration file parsinginternal/process: Process lifecycle managementinternal/dependency: Dependency resolution engineinternal/logrotate: Log rotation and retentioninternal/server: Core supavisor daemoninternal/api: API types for IPC communication
# Run the full suite with the race detector, verbose output
make test
# Same, plus coverage: prints the total and writes coverage.out
make cover
# Same, then open the annotated per-line report in a browser
make cover-html
# Bypass Go's test result cache and genuinely re-run everything
make cover GOTESTFLAGS=-count=1Go caches test results, so a repeated make cover over unchanged source
replays the previous verdict in about a second. That is convenient locally but
wrong for CI: several tests here are timing-dependent, and a replayed result
means the race detector never gets a fresh attempt. CI therefore passes
GOTESTFLAGS=-count=1.
CI keeps testing and coverage apart. The Tests workflow
(.github/workflows/test.yml) runs make lint and then make test on every
push to a branch other than main; the Coverage workflow runs make cover on
main. Each event therefore runs the suite exactly once — a branch push runs
Tests, a merge runs Coverage — rather than testing the same commits again on
the way in.
Tests also runs on pull requests, but only those from a fork: a fork's pushes
never reach this repository, so without it an outside contribution would arrive
untested. A pull request from a branch of this repository is skipped there,
having already been tested by the push that created it.
make cover passes -coverpkg=./... so that code exercised across package
boundaries is credited to the package that defines it. Without it, helpers in
internal/config and internal/process that are driven by the internal/server
tests are reported as uncovered.
The cmd/supavisor and cmd/sctl packages show low coverage because they are
binary entry points. Covering them meaningfully requires building an
instrumented binary with go build -cover and setting GOCOVERDIR, rather than
more unit tests.
The Coverage workflow (.github/workflows/coverage.yml) runs on every push to
main, which in practice means on every merge. It publishes three files to
GitHub Pages, built by
.github/scripts/coverage-site.sh:
index.html: the total, a per-file table sorted least-covered first, and a link to the annotated reportreport.html:go tool cover -htmloutput, annotated line by linecoverage.json: a shields.io endpoint descriptor backing the badge at the top of this README
Branches are deliberately left out. Coverage is published rather than reviewed,
so it describes what is on main; a branch is tested by the Tests workflow
instead, which runs the same suite without the coverage instrumentation.
The per-file numbers are computed from coverage.out rather than read from
go tool cover -func, which reports per function. Under -coverpkg=./... every
test binary emits a record for every block it can see, so the same block appears
once per binary; the script deduplicates on the block key and sums the execution
counts before deciding whether a block was covered. The resulting total is
checked against go tool cover -func on each change.
Enabling this on a fork requires setting Settings > Pages > Source to GitHub Actions.
Releases are cut by the Release workflow (.github/workflows/release.yml),
which only the repository owner can run: from the Actions tab, run Release
against the branch to release and give it a version tag such as v0.9.0.
The job validates the tag format, refuses a tag that already exists, and runs
the test suite before tagging the commit, then hands off to
GoReleaser (.goreleaser.yaml). GoReleaser builds
linux/amd64 and linux/arm64, stamps each binary with the tag, commit and
commit date, packs both binaries into one tar.gz per architecture, and uploads
the archives and checksums.txt to a GitHub release. The release notes are the
commit subjects since the previous tag, minus docs:, test: and chore:.
The release is left as a draft: review the artifacts and notes on the Releases page, then publish it by hand. Nothing is visible to users until you do. Note that the tag is pushed regardless — a draft does not defer that.
Because the tests run before the tag is created, a failing build leaves no tag
behind. If GoReleaser itself fails the tag has already been pushed, and it must
be deleted (git push --delete origin v0.9.0) before that version can be
retried.
The build is reproducible: -trimpath keeps local paths out of the binaries and
the commit timestamp is used instead of the build time, so rebuilding a tag
yields byte-identical archives.
To exercise the release locally without publishing anything:
goreleaser check
goreleaser release --snapshot --clean --skip=publishMIT
Contributions are welcome! Please open an issue or submit a pull request.