blivet
A correct, full-featured Unix daemon library and CLI for Rust.
Backgrounding a process is easy; doing it correctly is not. A daemon that
forks, detaches, then dies mid-init looks exactly like success -- the launcher
has already exited 0. blivet closes that gap: it reports readiness or failure
back over a notification pipe, with sysexits.h exit codes, so the launcher
sees a real result. And it gets the rest right by default -- signal and fd
hygiene, privilege handling, output redirects -- with no unsafe in your code.
use ;
Why blivet
- Parent notification. The launcher blocks until the daemon signals
readiness or reports an error -- no "did it start?" polling. Forget to call
notify_parent()and the launcher exits non-zero automatically. - Correct by default. Double-fork,
setsid, signal reset (including real-time signals on Linux; SIGPIPE stays ignored so pipe writes keep returning errors instead of killing the daemon), signal-mask clear, fd close, and/dev/nullredirect -- the things hand-rolled daemonizers forget. - Split-phase privileges.
daemonize()returns while still privileged, so you can bind port 80 orchrootbeforedrop_privileges(). - Safe by default. The checked entry points verify single-threadedness, so
you write no
unsafe; the crate's ownunsafe(libc FFI,fork,setenv) is isolated under#![deny(unsafe_code)]and documented. Opt-out variants are there when you manage the single-threaded contract yourself. - Library and CLI. A builder API in the familiar shape --
infallible setters, deferred validation,
?-friendly errors withsysexits.hcodes -- or the standalonedaemonizebinary (cargo install blivet) that wraps any program. The ergonomics of the simple wrappers, with the power they skip.
How it works
daemonize() double-forks, calls setsid to detach from the controlling
terminal, and returns a DaemonContext in the grandchild -- your daemon. The
launcher does not return; it blocks on a pipe to the grandchild, waiting for
that daemon to report in. In the daemon you then:
- run fallible init -- bind sockets, open files, connect to dependencies;
drop_privileges()to switch to an unprivileged user (it first chowns the pidfile/lockfile/log files to that user, while still root);- call
notify_parent(), which writes one readiness byte down the pipe.
launcher ── daemonize() ──► fork → setsid → fork ──► grandchild = your daemon
▲ │
└───────────── readiness / error (pipe) ─────────────┘
That byte releases the launcher, which exits 0. If the daemon instead dies or
calls report_error() before notifying, the launcher exits non-zero with the
matching sysexits.h code -- so it sees a real result, not a process that
detached and then crashed.
Steps 1-2 must run single-threaded: spawn threads, async runtimes, or accept
loops only after drop_privileges() returns. See Safety for why.
Install
The crate is blivet; the installed binary is daemonize. See
Library for the API, or CLI for command-line use.
Library
The minimal example above writes a locked pidfile and signals readiness, so a
second instance fails fast instead of clobbering it. A fuller setup adds a
separate lock file, log redirection, and a working directory. Like
daemonize(1), the defaults are standard daemon behavior -- stdout/stderr to
/dev/null, working directory / -- so use absolute paths and redirect any
output you want to keep:
use ;
Split-phase privilege dropping. Do privileged work (bind port 80, chroot,
set rlimits) between daemonize() and drop_privileges():
use ;
Foreground mode. For systemd, containers, or debugging: skip forking while
applying all other setup (umask, chdir, signal reset, …). Stdout/stderr stay
inherited unless explicitly redirected with .stdout()/.stderr():
config.foreground.close_fds; // keep supervisor-passed fds
See
examples/echo_server.rsfor a complete, runnable daemonized TCP echo server with signal-based shutdown and pidfile cleanup.
Entry points
daemonize(&config)-- the safe, recommended entry point. It verifies the process is single-threaded, then daemonizes. Available on Linux, macOS, FreeBSD, NetBSD, and OpenBSD (it reads the kernel thread count:/proc/self/statuson Linux,proc_pidinfoon macOS,sysctlon the BSDs). On any other target it is a#[deprecated]stub that panics; use the unchecked form below.unsafe { daemonize_unchecked(&config) }-- the escape hatch, on all Unix platforms. It skips the thread-count check, so you must guarantee the process is single-threaded.
DaemonContext::drop_privileges() mirrors this split: it is safe and checked
(panicking if a user switch is configured while multithreaded), with
unsafe { drop_privileges_unchecked() } as the opt-out. See Safety.
Safety
Forking a multithreaded process is unsound: mutexes held by other threads stay
locked forever in the child, deadlocking it. A second thread-unsafe step
follows -- drop_privileges() calls setenv (USER/HOME/LOGNAME) when
switching users -- so the single-threaded window runs from the fork through
drop_privileges() (see How it works). Spawn
threads, an async runtime, or an accept loop only after drop_privileges()
returns -- or after daemonize() returns if you don't switch users.
Both checked entry points read the kernel thread count and panic if violated:
daemonize() at the fork, and drop_privileges() at its setenv (only when a
user switch is configured -- the sole setenv path; a group-only switch is not
guarded). daemonize_unchecked() and drop_privileges_unchecked() are the
unsafe opt-outs for callers who manage the contract themselves, or who run on
a target without a thread-count source.
API reference
Full reference is on docs.rs; the essentials:
DaemonConfig -- a builder of infallible &mut self setters; validation is
deferred to validate(), which daemonize() runs for you. Defaults worth
knowing: working directory /, stdout/stderr /dev/null, close_fds and
cleanup_on_drop both true, and a configured pidfile doubles as the lockfile
-- a second instance fails with LockConflict (exit 69) rather than silently
overwriting the pidfile. Point lockfile() at a separate path, or call
no_lockfile() to write the pidfile unlocked.
DaemonContext -- returned by a successful daemonize(); owns the lockfile
and notification pipe. The methods you reach for most: notify_parent(),
drop_privileges(), cleanup() / cleanup_on_term_signals(), and
report_error() / report_error_msg() / notify_parent_or_report().
Errors & exit codes
DaemonizeError has sixteen variants covering validation, fork, setsid, lock,
permission, chown, exec, and parent-notify failures, plus a caller-supplied
Application variant. Each maps to a sysexits.h exit code via exit_code(),
so failures reach the shell with a meaningful status:
| Variant | Exit code | Meaning |
|---|---|---|
ValidationError |
64 | Bad config (paths, env keys, overlaps) |
ProgramNotFound |
66 | CLI: program missing or not executable (pre-fork check or exec ENOENT/EACCES) |
UserNotFound |
67 | User doesn't exist |
GroupNotFound |
67 | Group doesn't exist |
LockConflict |
69 | Lockfile held by another process |
LockfileError |
73 | Can't open lockfile |
PidfileError |
73 | Can't write pidfile |
OutputFileError |
73 | Can't open/redirect output file |
ChownError |
73 | Can't chown pidfile/lockfile/output file |
ForkFailed |
71 | fork() error |
SetsidFailed |
71 | setsid() error |
ChdirFailed |
71 | chdir() error |
SystemError |
71 | Post-fork syscall failed (/dev/null, sigaction, sigprocmask, getrlimit) |
PermissionDenied |
77 | Not root, or setuid/setgid failed |
ExecFailed |
71 | CLI: exec failed (other than ENOENT/EACCES) |
NotifyFailed |
71 | Can't write readiness byte to launcher |
PrivilegesNotDropped |
70 | user/group set but drop_privileges() never called |
Application |
caller's | App-level failure you report yourself |
Recipes
Pidfile cleanup on signals
When cleanup_on_drop is true (the default), the pidfile is removed when
DaemonContext is dropped. But Drop does not run when the process is killed
by a signal (SIGTERM, SIGKILL, …) -- which is how most daemons stop -- so
the pidfile would be left behind. The built-in fix installs async-signal-safe
handlers that remove the pidfile on SIGINT/SIGTERM, then re-raise so the
process still terminates normally:
use ;
Library-only. The
daemonizeCLI cannot do this: itexecs the target program, andexecresets custom signal handlers to their default disposition, so a CLI-launched program must clean up its own pidfile.
If you already run your own signal loop (e.g. for graceful shutdown), you don't
need the built-in handler: let the loop exit, then call cleanup() -- or just
let ctx drop. The signal-hook crate
is one way to drive that loop; blivet does not re-export it, so
cargo add signal-hook first:
use ;
use Arc;
use ;
Reporting your own failures
If startup work in the privileged init window fails (a socket bind, a database
connect), report it to the launcher with a sysexits.h code of your choosing
via report_error_msg -- no need to construct a DaemonizeError by hand:
let listener = match bind ;
Propagating exit codes
The sysexits.h codes only reach the shell if you use them. The idiomatic
fn main() -> Result<(), E> prints the error via Termination and exits 1,
ignoring exit_code(). To preserve the codes, drive exit_code() yourself:
use ;
CLI
The daemonize binary wraps any program as a daemon, applying the same setup as
the library:
# Simplest: daemonize a program
# Typical service: pidfile, log redirect, working dir, drop to an unprivileged user
The launcher blocks until the daemon successfully execs, then exits 0. On
failure (lockfile conflict, permission denied, exec error) it prints the error
to stderr and exits with a sysexits.h code -- the same codes the library
returns (see Errors & exit codes). When -u/-g are
given, the CLI transfers ownership of the pidfile, lockfile, and log files to the
target user/group before dropping privileges, so the daemon can keep writing to
them.
| Flag | Long | Description |
|---|---|---|
-p |
--pidfile PATH |
Write daemon PID to file |
-c |
--chdir PATH |
Working directory (default: /) |
-m |
--umask MODE |
Process umask in octal (e.g. 022) |
-o |
--stdout PATH |
Redirect stdout to file (also sets stderr if -e is not given) |
-e |
--stderr PATH |
Redirect stderr to file (default: stdout path; .stdout→.stderr, .out→.err) |
-a |
--append |
Append to stdout/stderr files instead of truncating |
-l |
--lock PATH |
Exclusive lockfile (default: pidfile path, if set) |
--no-lock |
Do not lock the pidfile (allows multiple instances) | |
-E |
--env NAME=VAL |
Set environment variable (repeatable; a bare NAME sets the empty string) |
-u |
--user NAME|UID |
Switch to user after daemonizing (requires root) |
-g |
--group NAME|GID |
Switch to group after daemonizing (requires root) |
-f |
--foreground |
Stay in foreground (no fork/setsid) |
-v |
--verbose |
Print diagnostic info before daemonizing |
Minimum supported Rust version
1.85
Why the name blivet?
A blivet is the "impossible fork" optical illusion, also known as the devil's fork, where the prongs are mysteriously detached from the base. Daemons are created by forking to detach from their parent terminal.
License
Licensed under either of Apache License, Version 2.0 or MIT License at your option.