blivet
A correct, full-featured Unix daemon library and CLI for Rust.
Daemonizing a process correctly is deceptively hard: the double-fork dance,
session detachment, signal and fd hygiene, and -- the part most libraries skip
-- telling the launcher whether the daemon actually came up. Unlike daemon(3)
or thin wrappers, blivet reports post-fork startup failures back to the
launcher over a notification pipe, with sysexits.h exit codes. The
shell, systemd, or a supervisor sees a real success or failure -- not a
detached process that may have already died during init.
use ;
let mut config = new;
config.pidfile;
let mut ctx = daemonize?; // safe: checks single-threaded, then double-forks
ctx.notify_parent?; // tell the launcher we're up; it exits 0
// daemon runs here
Why blivet
- Correct by default. Mandatory 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. - 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. - 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, or the standalone
daemonizebinary (cargo install blivet) that wraps any program.
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;
chown_paths(), thendrop_privileges(), to drop to an unprivileged user;- 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 pidfile and signals readiness. A fuller setup
adds a lock file, log redirection, and a working directory. As with
daemonize(1), the defaults are standard daemon behavior -- stdout/stderr go to
/dev/null and the working directory becomes / -- 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; this is the shape of it.
DaemonConfig -- a builder of infallible &mut self setters; validation is
deferred to validate(), which daemonize() runs for you. Settings: pidfile,
lockfile, stdout/stderr (+ append), chdir, umask, user/group,
foreground, close_fds, cleanup_on_drop, and env. Defaults worth knowing:
working directory /, stdout/stderr /dev/null, and close_fds and
cleanup_on_drop both true.
DaemonContext -- returned by a successful daemonize(); owns the lockfile
and notification pipe. The methods you reach for most: notify_parent(),
chown_paths() / 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 |
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 |
PermissionDenied |
77 | Not root, or setuid/setgid failed |
ExecFailed |
71 | CLI: exec of target program failed |
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) |
-E |
--env NAME=VAL |
Set environment variable (repeatable) |
-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 pitchfork" 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.