Skip to content

New file format - #493

Merged
troglobit merged 28 commits into
masterfrom
file-format
Aug 1, 2026
Merged

New file format#493
troglobit merged 28 commits into
masterfrom
file-format

Conversation

@troglobit

Copy link
Copy Markdown
Collaborator

New Finit .conf format

Reference for the libconfuse-based format in src/conf.c, written
from the schema and the actual translators.

Every setting is listed with what it does and a snippet you can paste
into a .conf file.


How a file is chosen and parsed

Both formats use .conf. There is no marker line and no new extension;
conf_parse_file() decides per file by trying to parse it:

  1. Parse strictly with libconfuse. Success means block format.
  2. On a parse error, parse again accepting any unknown key. If that
    succeeds the file is block format with a typo, and the strict error
    is reported with file and line. The file is not handed to the legacy
    parser.
  3. If the lenient parse fails too, the file goes to the legacy
    one-liner parser.

One format per file. Mixing an environment { } block and a legacy
run [S] ... line in the same file makes the whole file fail both
parses, and it lands in the legacy parser, where the block lines are
junk.

Files are read from /etc/finit.conf first, then *.conf in
/lib/finit/system/, /run/finit/system/, /etc/finit.d/ and
/etc/finit.d/enabled/, in that order. /etc/finit.d/available/ is
never read; only what is enabled.


Grammar

Native libconfuse. Values are quoted strings, bare tokens, or integers;
lists use braces; sections may carry a title.

# shell comment
// C++ comment
/* C comment */

key      = "value"                 # string
number   = 20                      # integer
flag     = true                    # boolean
list     = { "one", "two" }        # string list
block title { key = "value" }      # titled section

path = "${HOME}/thing"             # environment expansion
include("/etc/finit.d/extra.conf") # native include

Bare tokens work where a string is expected, so memory.max = 65M and
restart = always both parse as strings.

Two conventions

Keys are kebab-case, never snake_case or CamelCase: restart-sec,
stop-timeout, reboot-watchdog.

List-valued keys are plural: conditions, conflicts, capabilities,
modules, extra-groups. Two imperatives keep their singular form
because they are verbs rather than nouns: mknod and include.

Aliases

Nine, no more. An alias may abbreviate the canonical name, or preserve
a legacy spelling; it may not simply rename.

Canonical Alias
description desc
conditions cond
capabilities caps
modules mod
envfile env
manual-start manual
remain-after-exit remain
stop-signal halt
stop-timeout kill

The - prefix

A leading - on a path means "carry on if it is not there", the same
mark systemd uses on EnvironmentFile= and ExecStart=:

service foo {
    envfile = "-/etc/default/foo"  # skip the file if missing
    command = "-/usr/sbin/foo"     # skip the whole stanza if missing
}

It is the only sigil that means "optional". Two others exist and mean
something else: ! on runlevel inverts the set, and ~ on a
condition propagates a reload. Both are covered where they apply.


service, task, run, sysv

Four block types share one schema, so every key below parses in all
four. The schema does not enforce which keys are meaningful where:
remain only does something for run and task, notify is
meaningless for sysv, and exec-start-ready is a no-op in run and
task because readiness only applies to daemons. None are rejected.

The section title is the identity, optionally name:id:

service sshd     { command = "/usr/sbin/sshd -D" }
service getty:1  { command = "/sbin/agetty tty1" }

command is the only required key. A block without it is skipped with
an error. Blocks run in the order they appear in the file, recovered by
sorting on each section's source line.

Identity and selection

Key Alias Type Meaning
description desc string Shown by initctl status, and during boot
command string Command and arguments; a leading - tolerates a missing binary
runlevel string Runlevels to run in, e.g. "2345", "S12345"; a leading ! inverts the set
conditions cond list Conditions that must all be asserted before the block runs
if string Skip the block entirely unless the statement holds
conflicts list Other services this one cannot coexist with
service sshd {
    description = "OpenSSH daemon"
    runlevel    = "2345"
    conditions  = { "net/route/default" }
    command     = "/usr/sbin/sshd -D"
}

runlevel = "!12345" means every runlevel except those, the set
starts full and the listed levels are cleared. It is a set of
characters, not a range, so S and the digits, and !1-9 does not
work.

Condition prefixes

~ before a condition propagates a reload of the upstream service to
this one, instead of merely pausing and resuming it. Any condition in
the list may carry it. Note that it sets one flag for the whole block,
so writing it on a single condition still affects all of them.

service svc_b {
    conditions = { "pid/svc_a" }          # barrier: just needs A running
    command    = "/sbin/svc_b"
}

service svc_c {
    conditions = { "~pid/svc_b" }         # follow B's reload
    reload-signal = "none"                # ... by restarting
    command    = "/sbin/svc_c"
}

There is no way to express "run when this condition is not
asserted". A + and - pair for asserted and deasserted conditions
has been discussed, and the list is kept clear of anything that is not
a condition so those operators have room.

Porting note: a legacy condition list may be led by !, which is not
a condition and not a negation. It is a flag on the block, and it maps
to two different keys here depending on the block it was in.

Legacy Block format
<!...> on a service or sysv reload-signal = "none"
<!...> on a run or task required = false
task pwrfail {
    conditions = { "sys/pwr/fail" }
    required   = false             # was <!sys/pwr/fail>
    command    = "initctl poweroff"
}

A ! left in a conditions list warns and is ignored, as does either
key on a block type it does not apply to.

Process environment

Key Alias Type Meaning
envfile env string File to source; a - prefix makes it optional
user string User to run as
group string Primary group
extra-groups list Supplementary groups
capabilities caps list Linux capabilities to retain
tty string Controlling terminal for the process
service dropbear {
    envfile      = "-/etc/conf.d/dropbear"
    user         = "sshd"
    group        = "sshd"
    extra-groups = { "wheel", "dialout" }
    capabilities = { "CAP_NET_BIND_SERVICE" }
    command      = "/usr/sbin/dropbear -R -F $DROPBEAR_OPTS"
}

user without group runs in the user's own group. group without
user implies root. Note that envfile names a file to source;
inline variables are the top-level environment {} block.

Supervision and readiness

Key Alias Type Meaning
type string Only "forking" is understood; anything else warns and is ignored
notify string Readiness protocol: none, pid, systemd, s6
pidfile bool/string true for the derived default, a bare name, or a path
pidfile-create bool Finit writes the file instead of the daemon
manual-start manual bool Do not start automatically; initctl start only
remain-after-exit remain bool run/task only; keep it in the service list after it exits
required bool run/task only; false means it does not hold up bootstrap
respawn bool Exiting is normal; restart at once rather than treating it as a failure
service nginx {
    type    = "forking"
    pidfile = "/run/nginx.pid"
    notify  = "pid"
    command = "/usr/sbin/nginx"
}

The daemon writes its own pidfile unless told otherwise, so the three
forms of pidfile name a path and nothing more:

service udevd {
    pidfile = true             # /var/run/<command basename>.pid
  # pidfile = "udevd"          # a bare name, /var/run/udevd.pid
  # pidfile = "/run/foo.pid"   # an explicit path
    command = "/lib/systemd/systemd-udevd"
}

pidfile-create = true is the exception, for a foreground daemon that
writes no pidfile of its own. It is the fragile path: Finit then
creates the file on start and removes it on stop. Naming the file and
choosing who writes it are separate decisions, so changing the path
never changes the owner.

Restart behaviour

Key Alias Type Meaning
restart string true, false, always or never
restart-max int How many restarts before giving up; default 10
restart-sec int Seconds to wait between restarts
oncrash string reboot or script
reload-signal string Signal that reloads the daemon, or none to restart it instead
stop-signal halt string Signal sent to stop the service, e.g. SIGTERM
stop-timeout kill int Seconds between the stop signal and SIGKILL, 1-300
runtime-dir string Directory under /run, created at start, owned by user, removed on stop
state-dir string Same under /var/lib, persists
cache-dir string Same under /var/cache, persists
logs-dir string Same under /var/log, persists
config-dir string Same under /etc, persists
service watchdogd {
    restart      = "always"
    restart-sec  = 5
    oncrash      = "reboot"
    stop-signal  = "SIGTERM"
    stop-timeout = 10
    command      = "/usr/sbin/watchdogd"
}

Finit reloads a daemon by sending it reload-signal, SIGHUP by
default, unless exec-reload is set, which takes precedence. With
reload-signal = "none" there is no signal to send and the service is
restarted instead. Only SIGHUP and none are accepted for now, in
any case and with or without the SIG prefix, because the legacy line
this translates into can carry nothing else.

remain-after-exit decides whether a finished run or task keeps
existing. By default it does not: the entry is removed, so it re-runs
whenever its runlevel is entered again, initctl cannot see it, and
exec-stop-post never fires. With the key set it stays in the list in
a done state, is not re-run on runlevel re-entry, and gets a real
teardown when stopped or when it leaves its runlevels.

This is systemd's RemainAfterExit=, and the two agree on the default.
Finit is the unusual one in having a vanishing model at all: SMF's
duration = transient, s6-rc oneshots and OpenRC services all stay
around once run, so no other init system needs the option.

task firewall {
    runlevel          = "2345"
    remain-after-exit = true
    exec-stop-post    = "/usr/sbin/teardown-firewall"
    command           = "/usr/sbin/setup-firewall"
}

It is ignored, with a warning, for a task whose only runlevel is S.
Bootstrap tasks are pruned as soon as they finish, so there is nothing
left to remain, and their exec-stop-post never runs.

required = false needs a word on what it does not do. Finit has
two separate notions of blocking: a run blocks the next stanza,
which is what makes it a run rather than a task, and separately
the bootstrap barrier waits for every run and task to finish before
leaving runlevel S. required speaks only to the second. A run with
required = false still runs in sequence; it simply stops holding up
bootstrap. Run/tasks conditioned on hook/svc/up or hook/system/up
are exempt from that barrier already and need no key.

restart is the policy and restart-max the count. true is the
default policy, restart up to restart-max; false and never mean
no restarts; always is unlimited and ignores restart-max. An
unrecognised word warns and is treated as true.

respawn is not part of that family

respawn takes a different path through the state machine, and the
difference is not "forever" but why the process exited. For a
service, exiting is a failure: Finit counts it, waits restart-sec,
and gives up at restart-max. For a respawn service, exiting is
normal work, so it restarts at once with no counter and no give-up.
That is the getty case, where a login ending is the expected outcome
and the next getty should appear immediately.

The two are not interchangeable. restart = "always" still counts and
still waits, and restart-sec = 0 does not mean "immediately", it
means the retry timer is never armed, so the service would not come
back at all.

systemd has no equivalent key. A getty there is Restart=always with
RestartSec=0 and StartLimitIntervalSec=0 to switch the rate
limiter off, which is the same behaviour assembled from three general
settings rather than named as one.

Today a respawn service ignores restart-max and oncrash entirely,
even when it exits non-zero every time, so a permanently broken getty
retries forever and never triggers oncrash. The intended direction
is for respawn to honour the restart settings on a failing exit
while keeping the immediate restart on a clean one: normal exits stay
normal, failures become failures. That split is also where
on-failure and smarter backoff would fit.

Lifecycle scripts

Six hooks, each with a -timeout of its own, in seconds, 0-3600.
Without one the service's stop-timeout is used.

Key Alias Fires
exec-start-pre before the service starts
exec-start-ready once it signals readiness; daemons only
exec-stop in place of the stop signal
exec-stop-post after it has stopped, including after a crash
exec-reload in place of SIGHUP
exec-cleanup when the service is removed from the configuration
service dnsmasq {
    exec-start-pre         = "/etc/dnsmasq/pre.sh"
    exec-start-pre-timeout = 10
    exec-stop-post         = "/etc/dnsmasq/post.sh"
    exec-reload            = "/etc/dnsmasq/reload.sh"
    command                = "/usr/sbin/dnsmasq -k"
}

The script must exist and be executable or it is skipped with a
warning. Mapped onto systemd, exec-start-pre is ExecStartPre=,
exec-start-ready is ExecStartPost=, and exec-stop-post is
ExecStopPost=; exec-cleanup has no counterpart.

Service output

log {} says where the service's own output goes. /dev/null and
/dev/console are spelled as paths, so there is one way to say it.

Key Alias Meaning
file Path, or /dev/null to discard, or /dev/console
priority Syslog facility and level, e.g. daemon.info
identity Syslog tag
service foo {
    log {
        file     = "/var/log/foo.log"
        priority = "daemon.info"
        identity = "foo"
    }
    command = "/usr/bin/foo"
}
service quiet  { log { file = "/dev/null" }  command = "/usr/bin/quiet" }
service chatty { log { }                     command = "/usr/bin/chatty" }

The empty block is how a service asks for syslog with defaults.
Careful when copying these: at file scope log {} is the rotation
block below, which has no file. There
is no log = true: libconfuse rejects a name declared as both a scalar
and a section.

Per-service cgroup and rlimit

Both are sections, covered below.


tty

Three shapes, distinguished by which key is present. A tty block with
none of device, command, notty or rescue is skipped with an
error.

Key Alias Type Meaning
device string Terminal for the built-in getty, e.g. @console
baud int Line speed; omit to keep the kernel setting
term string TERM value, device form only
command string External getty; a leading - tolerates a missing binary
notty bool No terminal at all, for board bring-up
rescue bool Rescue shell
runlevel string Runlevels to run in
conditions cond list Conditions that must be asserted
noclear bool Do not clear the screen before the prompt
nowait bool Skip the "press Enter to activate" prompt
nologin bool Run a shell directly instead of login

Built-in getty, selected by device:

tty console {
    device     = "@console"    # or /dev/ttyAMA0
    baud       = 115200        # omit to keep the kernel setting
    term       = "vt220"
    runlevel   = "12345"
    conditions = { "net/lo/up" }
    noclear    = true
    nowait     = true
    nologin    = true
}

External getty, selected by command:

tty agetty {
    command  = "/sbin/agetty -L ttyAMA0 115200 vt100"
    runlevel = "12345"
    nowait   = true
}

Board bring-up and rescue:

tty rescue {
    notty  = true
    rescue = true
}

term and baud apply only to the device form and are dropped for
an external getty. The booleans stay negative, matching agetty and
mingetty, which spell --noclear exactly this way and carry a whole
family of no flags between them. Two are worth knowing precisely:
nowait skips the "press Enter to activate" prompt, which Finit shows
by default where the gettys do not; and nologin runs a shell directly
rather than login, so it is stronger than agetty's --skip-login.


cgroup

Valid at top level, where it defines a group, and inside a service
block, where it joins one.

Common cgroup v2 keys are declared in the schema, so they are typed and
warning-free. Anything else is passed to the kernel verbatim, which is
what the legacy parser always did; the kernel rejects bad writes at
runtime. Consequence: a typo in a common key degrades to passthrough
rather than being caught.

Declared keys: cpu.weight, cpu.weight.nice, cpu.max,
cpu.max.burst, cpu.idle, cpuset.cpus, cpuset.mems,
memory.{min,low,high,max}, memory.swap.{high,max},
memory.oom.group, memory.zswap.max, io.{weight,max,latency},
pids.max.

cgroup system {
    cpu.weight      = 9800
    cpu.max         = "50000 100000"
    memory.max      = 65M
    hugetlb.2MB.max = 128M       # not declared, passed through
}

service acpid {
    cgroup system { cpu.weight = 250 }   # join, with an override
    command = "/usr/sbin/acpid -f"
}

service dropbear {
    cgroup user {}                       # join, no overrides
    command = "/usr/sbin/dropbear -F"
}

The keys are the cgroupfs filenames, so the config reads the same as
the kernel documentation. Dots are literal characters, not nesting.

A service joins exactly one group. If a block declares more than one
cgroup section the last wins and a warning names the one used.

The legacy cgroup.NAME context-switch line, meaning "every following
stanza joins NAME", has no block equivalent. Each block says which
group it joins.


rlimit

Valid at top level, where it applies to every block in the file, and
inside a service block, where it applies to that block only, layered on
the file-scope values.

Unlike cgroup keys the resource set is closed, it is the kernel ABI, so
the schema declares all of them and a typo is a hard parse error. Each
resource can be written bare, or with a soft. or hard. prefix. Bare
sets both.

Resources: as, core, cpu, data, fsize, locks, memlock,
msgqueue, nice, nofile, nproc, rss, rtprio, rttime,
sigpending, stack.

Values are a number, or unlimited (infinity also works).

rlimit {
    nofile      = 1024        # both soft and hard
    soft.nofile = 512
    hard.core   = unlimited
}

service bigmem {
    rlimit { memlock = unlimited }
    command = "/usr/bin/bigmem"
}

Templates

A file named name@.conf is a template. It is never started on its
own; enabling an instance creates name@INSTANCE.conf pointing at it,
and every %i is replaced with INSTANCE before the file is parsed.
Substitution happens over the whole file, so %i works in the section
title, in any value, and in the command line alike.

# /etc/finit.d/available/avahi-autoipd@.conf
service avahi-autoipd:%i {
    description = "ZeroConf for %i"
    envfile     = "-/etc/default/avahi-autoipd-%i"
    command     = "avahi-autoipd $AVAHI_AUTOIPD_ARGS %i"
    pidfile     = "/run/avahi-autoipd.%i.pid"
}
$ initctl enable avahi-autoipd@eth0.conf
$ initctl status avahi-autoipd:eth0

The instance name becomes the service :ID, so it is bounded by
MAX_ID_LEN. A bare name@.conf reaching the parser registers
nothing. Diagnostics are reported against the instance file, so a typo
in the template above appears as
/etc/finit.d/enabled/avahi-autoipd@eth0.conf:2: ....


environment

Global environment variables, free-form keys. env is an accepted
alias, matching the envfile/env pair on services.

environment {
    DEBUG = "1"
    PATH  = "/usr/sbin:/usr/bin:/sbin:/bin"
}

Read on every reload, not only at bootstrap. That matters because
conf_reset_env() clears all tracked variables at the start of each
reload, and entering the configured runlevel triggers one; gating the
block on bootstrap would drop every variable on the way out of runlevel
S. Matches doc/config/env.md.

Variables are global and shared by all services. For a service-local
environment use the per-service envfile key, which sources a file.


log

Rotation of Finit's own log, not service output. Always evaluated, not
bootstrap-gated. Shares its name with the per-service log {} block
deliberately; the two sit at different levels and more global log
settings are expected here.

log {
    size  = "200k"     # accepts k/M suffixes
    count = 5
}

Top-level directives

Everything below sits at file scope, outside any block.

Evaluated only at bootstrap

Key Alias Type Meaning
hostname string System hostname; /etc/hostname still wins
modules mod list Kernel modules to load
mknod list Device nodes to create
network string Script to bring up networking
rcsd string Override the finit.d directory
runparts string Directory of start scripts to run
runparts-progress bool Show progress for runparts
runparts-sysv bool Treat runparts scripts as SysV
runlevel int Runlevel to enter after bootstrap, 1-9 except 6
readiness string Only "none" is acted on
hostname = "anarchy"
runlevel = 2
modules  = { "loop", "dummy" }
mknod    = { "/dev/null c 1 3" }
network  = "/etc/network/start.sh"
runparts = "/etc/rc.d"

An out-of-range runlevel silently falls back to 2 rather than
erroring.

modules entries go to kmod_load(), which skips already-loaded
modules and shells out to modprobe; arguments after the module name
are passed along. mknod entries are concatenated onto a mknod
command and run interactively.

Evaluated on every reload

Key Alias Type Meaning
shutdown string Script to run on shutdown
reboot-delay int Seconds to wait before reboot, 0-60
reboot-watchdog bool Reboot via the watchdog instead of the SoC
service-interval int Seconds between service checks, 0-1440
shutdown         = "/etc/shutdown.sh"
reboot-delay     = 5
reboot-watchdog  = true
service-interval = 300

Out-of-range values are ignored, leaving the previous value in place.


Worked example: a complete finit.conf

# Bootstrap
hostname = "anarchy"
runlevel = 2
modules  = { "loop" }

environment {
    PATH = "/usr/sbin:/usr/bin:/sbin:/bin"
}

log {
    size  = "200k"
    count = 5
}

rlimit {
    nofile = 8192
}

cgroup system {
    cpu.weight = 9800
    memory.max = 512M
}

tty console {
    device   = "@console"
    runlevel = "12345"
    noclear  = true
}

run kmap {
    description = "Loading keymap"
    runlevel    = "S"
    envfile     = "/etc/conf.d/loadkmap"
    command     = "/usr/bin/loadkeys sv-latin1"
}

service syslogd {
    description = "Syslog daemon"
    runlevel    = "S12345"
    envfile     = "-/etc/default/rsyslog"
    command     = "/usr/sbin/rsyslogd -n $RSYSLOGD_OPTIONS"
}

service sshd {
    description = "OpenSSH daemon"
    runlevel    = "2345"
    conditions  = { "net/route/default" }
    pidfile     = "/run/sshd.pid"

    exec-start-pre         = "/etc/ssh/pre.sh"
    exec-start-pre-timeout = 10

    log { file = "/var/log/sshd.log"  priority = "daemon.info" }
    cgroup system { cpu.weight = 100 }
    command = "/usr/sbin/sshd -D"
}

Per-service directories

The five *-dir keys mirror systemd's RuntimeDirectory= family. The
value is a name resolved under a fixed base, absolute paths and ..
are refused. The directory is created before each start, mode 0755,
chowned to user/group, and its full path is exported to the process
as RUNTIME_DIRECTORY, STATE_DIRECTORY, CACHE_DIRECTORY,
LOGS_DIRECTORY, or CONFIGURATION_DIRECTORY. Only the runtime
directory is removed when the unit stops (after exec-stop-post); a
completed non-remain run/task counts as stopped.

Each takes a -mode suffix key (octal, leading zero, default 0755),
and runtime-dir-preserve = "no" | "restart" | "yes" maps systemd's
RuntimeDirectoryPreserve=. config-dir is created but never
chowned, like systemd. An existing dir with the right owner is left
alone; on mismatch the tree is chowned back recursively.

These are the first block-only settings: no legacy token exists, the
values are set on the svc after service_register() returns it.
systemd accepts multiple directories per setting; these are scalar for
now, widening later is syntax-compatible since libconfuse accepts a
bare value for a list option.

Gaps worth knowing

initctl show does not print the translated line. It is an alias for
cat, so for a block-format service it prints the block. The plan
assumed otherwise, which means the "declare the same service in both
formats and compare the output" check it describes cannot be done
today. The translated one-liner is visible only with finit.debug=on,
as a translated: line.

Emitted one-liners are verified by reading and by that debug output,
not by the test suite. Three have been wrong so far: cgroup:NAME,
which parse_cgroup() reads as a settings string so the group was
never joined; cgroup.NAME:settings, whose separator has to be a
comma; and a nowarn that landed on the wrong service after a
refactor. All three are fixed, and the last one passed the whole test
suite before the debug output caught it.

Cgroups are untested. cgroup_avail() is false inside the test
namespace, so the suite cannot assert that a group was joined.

On libconfuse 3.3 each free-form cgroup key logs one spurious warning.
Values are captured correctly, only the logging differs, and Finit's
error callback absorbs it. Gone in 3.4.

doc/config/service-opts.md still documents stop:'script' without a
timeout. Both exec-stop and exec-reload now take one, so that page
is out of date.


Future

A persist flag for run/tasks whose only runlevel is S. Those are
pruned once bootstrap finishes, so a user in a normal runlevel cannot
see that they ran at all, and remain-after-exit cannot help because
there is nothing left by then. persist would keep the entry alive
past the prune purely so it stays inspectable.

troglobit added 26 commits July 30, 2026 15:21
Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Every test left a stray `sleep 300` behind, reparented to PID 1, where
it lingered for up to five minutes after the test had finished.

wdstart() runs the watchdog in a subshell, so $! is the pid of the
subshell, not of the sleep it forks.  wdkill() killed the subshell and
orphaned the sleep.

Kill the child first, killing the subshell puts the sleep beyond the
reach of pkill -P.  Neither kill is sure to match, and wdkill() runs
from the EXIT trap under set -e, so both must tolerate failure.  Also
return early when wdpid is unset, for failures before wdstart() runs.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The one-liner parser is about to be joined by a second, block-based
format.  Give it a name that says which of the two it implements,
before any content changes make the diff hard to follow.

No functional change.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The one-liner format has grown crowded and very wide, and every new
service option makes it worse.

Add a second, block-based format, parsed with libconfuse:

    service sshd {
        description = "OpenSSH daemon"
        runlevel    = "2345"
        command     = "/usr/sbin/sshd -D $SSHD_OPTS"
    }

Both formats keep the .conf extension and are detected per file by
content.  Try-parse strictly with libconfuse; on a parse error,
re-parse leniently to tell a block file with a typo from a one-liner
file.  Only a one-liner file reaches the legacy parser, a typo is
reported with its file and line.

Each block is translated to the canonical one-liner and registered
through the existing entry points, so the two formats cannot drift.

The one-liner parser is frozen at the 4.x feature set, new options
land only in the block schema.  libconfuse 3.3 or later is required,
CFGF_KEYSTRVAL does not exist before it.

Covers service, task, run, sysv and tty blocks, the static directives,
and the cgroup, rlimit, set and log blocks.  Templating and the
documentation rewrite are still to come.

The regression test covers translation of a service block to the
one-liner, a block-format /etc/finit.conf booting with set {} applied
at bootstrap, both formats side by side, and rejection of a typo at
block and at root level.

A rejected file must not fall through to the legacy parser, which
registers a bogus unstartable service per line.  assert_num_children
cannot see that, the bogus service has no children either, so the
check is assert_num_services.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Relocate process-wide global variables from legacy parser that ended up
there because it used to be conf.c, but which is now now frozen at the
4.x feature set.  Each variable is moved to their respective "owner".

Give cgroup_current[] and cgroup_settings_current[] named bounds.  Their
extern declarations were unsized, so sizeof() on them stopped compiling
once the definitions moved to another translation unit.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
 - legacy.[ch]: strictly legacy .conf parser boilerplate only
 - conf.[ch]: .conf parser and generic configuration functionality

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
A template in the block format registered garbage.  conf_parse_file()
routed every file with an '@' in its name straight to the legacy
parser, which read the block line by line: the section header became a
service whose command was the section title, and each key = value line
below it became an environment variable.

    service serv:%i { ... }   ->  service 'serv:eth0' with argument '{'

Substitute %i over the whole file before parsing instead, so format
detection and both parsers see finished text.  A bare name@.conf is
still skipped, it is the template rather than an instance of one.

The legacy parser no longer opens the file or substitutes per line, it
is handed the instantiated buffer, so the template convention now has
one implementation instead of two.  conf_is_template() applies
basenm(), a directory with an '@' in its name is not a template.

libconfuse cannot name a buffer it parses before 3.4, so a typo in a
template would be reported against "[buf]".  Parse through fmemopen()
with the file name preset until the floor moves.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Three private ones had grown: fnread() in util.c, flen() behind
pid_cmdline()/pid_cgroup() in cgutil.c, and conf_read_template() in
conf.c.  Two of them were also wrong in ways the others were not.

fnread() formatted the path into a char[256] and stat()ed it before
opening, so a longer path was silently truncated and then read from
whichever file the truncation happened to name, and the size could
change between the look and the read.  flen() existed because neither
of those approaches works on procfs at all, where stat() reports zero
and the only way to learn the size is to read to EOF.

Add fslurp() to util.[ch], which every tool already links.  It opens
first and sizes the fd it holds, treats st_size as a hint, and reads
until EOF, so procfs and regular files take the same path.  Paths are
formatted by libite's vfopenf(), which allocates to fit.  Callers that
need the byte count, /proc/PID/cmdline embeds NUL, ask for it.

fnread() keeps its signature and becomes a bounded copy out of the
result, so its one caller is unaffected.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
A stop: or reload: script written with a timeout killed Finit at
config load:

    service stop:5,/bin/true service.sh -- Boom

parse_script() takes the timeout as a pointer and the caller decides
whether it wants one.  However, both stop: and reload: scripts so far
have no timeout, i.e., NULL.  Guard the branch that reads a leading
number.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Both were bounded by killdelay, the delay between the stop signal and
SIGKILL, because service_run_script() had nothing else to reach for.
That conflates two things: how long the daemon may take to die, and
how long its stop script may run.

Give each hook a timeout of its own, defaulting to killdelay when
unset, so the existing behaviour is what you get until you ask for
something else.  parse_script() already falls back that way for the
hooks that had one.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
'make check' refreshes the sysroot through the setup-chroot rule, but
running a test script by hand does not, so the test exercises whichever
finit was installed last and reports on code that is no longer there.
Both a passing and a failing run are then meaningless, and nothing says
so.

Compare the built binary against the installed one at startup and fail
with the command that fixes it.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The initial implementation was done using a naive translator of the
legacy one-liner format key by key, so it inherited encodings that the
block format exists to remove: a timeout packed into a script path, a
small comma-and-colon language inside the log string, sigils standing in
for booleans, and a log key (services) carrying three (!) types.

Settled naming against systemd, OpenRC, FreeBSD rc.subr, s6 and SMF.
Match systemd's semantics, not its naming.

    pid          -> pidfile, plus pidfile-create for the rare case
                    where Finit writes the file rather than the daemon
    environment  -> envfile, since it names a file to source, and the
                    top-level environment {} block sets variables
    pre/post/... -> exec-start-pre, exec-start-ready, exec-stop,
                    exec-stop-post, exec-reload, exec-cleanup, each
                    with its own -timeout instead of "SEC,script"
    halt, kill   -> stop-signal, stop-timeout
    restart      -> restart for the policy, restart-max for the count
    log          -> a block with file, priority and identity, where
                    /dev/null and /dev/console are spelled as paths
    group        -> group and extra-groups, no longer positional
    nowarn       -> a leading - on command, as on envfile

List-valued keys take plural names.  Aliases are desc, cond, mod,
caps, env, halt and kill; an alias may abbreviate the canonical name
or preserve a legacy spelling, nothing else.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
A condition list could be led by '!', which is not a condition and not
a negation.  It is a flag on the block, and it means two unrelated
things depending on which block it sits in: a service or sysv does not
handle SIGHUP and must be restarted to reload, while a run or task
must not hold up bootstrap.  Writing '<!>' with no condition at all is
legal, which gives away that it was never an operator.

Give each meaning its own key, valid only where it applies:

    service foo { reload-signal = "none" }   # restart to reload
    task    bar { required      = false  }   # do not hold up bootstrap

Using either on a block type it does not apply to warns, as does a '!'
left in a conditions list.  Both still translate to that same '!',
which is all a legacy line can carry, so reload-signal takes SIGHUP or
none for now; str2sig() already accepts any case and an optional SIG
prefix.

This also clears the way for the conditions list to grow real
operators, '+' and '-' for asserted and deasserted, without '!'
sitting among them meaning something else entirely.

The '~' prefix stays.  It belongs to the list: it marks a dependency
whose reload should propagate here.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Both keys prompt the question they should be answering.

'remain' decides whether a finished run or task keeps existing: without
it the entry is pruned, so the work re-runs on every runlevel entry,
initctl cannot see it, and its post script never fires.  With it the
entry stays, is not re-run, and gets a teardown when stopped or when it
leaves its runlevels.  That is systemd's RemainAfterExit, and 'remain'
is that name with the informative half cut off.

'manual' says how a service is started but not that it is about
starting at all.

    remain -> remain-after-exit
    manual -> manual-start

Both keep their old spelling as an alias, which they qualify for twice
over, as abbreviations of the canonical name and as the legacy
spellings.

While here, give sec_getbool() the alias argument its string and list
counterparts already take.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This is what 'initctl create' and 'initctl edit -c' put in front of a
user writing their first .conf file, so it is also the whole of the
"initctl emits the new format" work: neither command generates syntax,
they copy this file and open an editor on it.

The ASCII diagram naming eight positional fields goes with it.  A
block has no positions to explain.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
parse_cgroup() takes two arguments that are not cgroupfs files: the
leaf directory to place the service in, and whether to hand the subtree
over to it.  The block format could express neither.  'name' happened
to work, because a free-form key is emitted as name:VALUE and that is
what the parser looks for, but 'delegate' came out as delegate:true and
was filed as a cgroup setting, so it silently did nothing.

Declare both, and emit delegate as the bare flag the parser expects.
Neither means anything on a top-level group definition, so say so there
rather than emitting something that would be written to cgroupfs.

    service podman {
        cgroup containers { name = "podman"  delegate = true }
        ...
    }

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The syntax overview no longer describes a line-based format, since that
is not what the rest of the documentation shows.  It now covers the
grammar, the two naming conventions, the nine aliases, and the leading
'-' on a path, and it says plainly that both formats are still read and
told apart per file by content.  Without that, a reader with an
existing configuration is left wondering what happened to it.

service-opts.md was a list of modifiers to place between a directive
and its command, so it needed rewriting rather than translating: there
are no positions left to describe.  It is now grouped by what the
settings do.

conditions.md needed correcting.  It presented '!' as a condition
prefix alongside '~'.  It is neither a condition nor a negation, it is
a flag on the block that means one thing on a service and another on a
run or task, so it is spelled reload-signal and required here, and the
page maps the old form to both.

Two things the pages claimed are not true.  The kill delay range is
1-300, not 1-60, and stop and reload scripts are no longer run without
a timeout.

ChangeLog.md keeps its line-based examples.  Those sit in historical
release entries, and rewriting them in a syntax that did not exist at
the time would misdate the format.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The examples people copy from were still written in the line-based
format, so the block format was documented but nowhere demonstrated.

Two names in contrib were accidents of the old format, where the
service name falls out of the command basename: the Alpine and Void
keymap task was called zcat, and Debian's console/keyboard setup tasks
carried a .sh suffix.  They now carry the name their file implies.
Nothing referenced the old names.

The mdevd coldplug path keeps the name it has always had.  Its legacy
line spelled the name inside the cgroup argument, where it names the
cgroup leaf and not the service, so the barrier condition really is
<run/mdevd-coldplug/success> and not the <run/coldplug/success> the
comment above it promises.  Converted as-is so boot ordering does not
change; the discrepancy is now written down where it happens.

A list may not contain comments, the lexer sees the entries after the
'#' regardless:

    modules = {
    #	"fbcon",
    	"softdog"
    }

so the commented-out module candidates sit above the list instead.

setup-sysroot.sh removes 10-hotplug.conf from the test sysroot, so that
file is covered by parsing only, not by make check.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The block conversion changed the bodies of the reference sections but
left every "**Syntax:**" header spelling the line-based format, so each
page opened by teaching the format it then stopped using.  Six files
were missed entirely: runparts, files, capabilities, requirements,
runlevels, and switchroot.

runparts had no block spelling written down anywhere, though the parser
has read `runparts`, `runparts-progress`, and `runparts-sysv` all along.

tty gains a table per variant.  Its three syntax lines carried nine
positional fields between them, which no longer describes anything the
parser accepts.

Fixes #148

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
An ambient capability only reaches the effective set when euid is
non-zero, so a service that pairs `capabilities = { "^cap_..." }` with a
root user gets none of the restriction it asks for, and keeps the full
root set instead.  Finit read the list, applied it, and said nothing.  A
build without libcap dropped the list on the floor just as quietly.

Both now warn, naming the service:

    nginx: ambient capabilities ('^') have no effect as root, use a
    non-root user, or '%' and '!' entries

The ambient entries are read back from the parsed IAB value rather than
matched in the text, so inheritable ('%') and bounding ('!') entries stay
silent -- those work fine as root.

The warning repeats when the .conf files are re-read on runlevel change,
as parse warnings here already do.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Reference sections kept pointing at the line-based format they no longer
document.  `sysv` and `task` sent the reader to Services for "<COND>",
the cgroups chapter opened by listing three legacy directives and then
explained further down that only two of them exist here, and the logging
chapter still gave "log:prio:facility.level,tag:ident" as the full
syntax.

Some claims were wrong independent of the format:

  - a sysv is a supervised daemon, grouped with service in
    SVC_TYPE_DAEMON, not a variation on task
  - restart-max has no upper bound of 255, or any other
  - the built-in rescue fallback runs in 12345789, not 12345
  - conditional loading quotes system/10-hotplug.conf, not
    system/hotplug.conf
  - the key spells conflicts, not conflict
  - the built-in getty no longer wants TERM last, it is a key

`if` takes either a service name or, in angle brackets, a condition,
decided in svc_ifthen().  Only the examples showed this, so it is now
said.

Terminology follows the split index.md already draws: a block is the new
format, a stanza the line-based one.

src/rescue.conf was still line-based, missed because it sits in src/
rather than system/ or contrib/.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The block format spells conditions as bare strings everywhere else, so
requiring `if = "<usr/foo>"` left one sigil behind, carried over from
the line-based `if:` token.  A namespace separator already tells the two
apart: a value with a '/' is a condition, anything else is a service
name.

svc_ifthen() picks its mode from the start of the statement and applies
it to the whole, so a statement naming both kinds cannot be evaluated.
That is now an error, as are the old angle brackets, and either one
skips the block:

    /etc/finit.conf: mixed: if: cannot mix a service name with a
    condition in 'anchor,usr/enable-me', a statement must be all of
    one kind, skipping

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Settings that exist only in the block format have nowhere to go: the
legacy line cannot carry them, and service_register() returned an errno
that no caller ever read, so conf.c had no handle on the service it just
created.  Return the svc instead, NULL with errno set on failure, errno
zero when a block is skipped on purpose.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
rmrf() is needed outside tmpfiles.c.  The move also deduplicates the
nftw callback: the contents-only removal used by tmpfiles 'D' entries
is now rmcontents(), sharing the callback with rmrf().

mksubsys() did nothing at all when the user could not be resolved, no
directory and no message, and callers had no way to tell.  Now the
directory is always created, ownership is best effort, and an unknown
user is warned about.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
A service that drops privileges cannot create its own PID file in
/run, root owns it.  Finit can create the file with pidfile-create,
but the daemon still cannot touch it to confirm a SIGHUP.

Five new settings, block format only: runtime-dir, state-dir,
cache-dir, logs-dir, and config-dir.  The value is a directory name,
resolved under /run, /var/lib, /var/cache, /var/log, and /etc,
respectively.  The directory is created before the service starts,
mode 0755 owned by user/group, and the full path is exported to the
process as RUNTIME_DIRECTORY, STATE_DIRECTORY, CACHE_DIRECTORY,
LOGS_DIRECTORY, and CONFIGURATION_DIRECTORY.  Mode and ownership are
asserted at creation only, a daemon may tighten them afterwards.

The runtime directory is removed when the unit stops, after any
exec-stop-post script, like systemd with RuntimeDirectoryPreserve=no.
A completed run/task counts as stopped unless remain-after-exit keeps
it up.  The other four persist across restarts.

These are the first settings with no legacy token: they are validated
by service_set_dir() and stored on the svc that service_register()
now returns.  systemd accepts a list of directories per setting; this
is a single name for now, widening later is compatible since
libconfuse accepts a bare value for a list option.

The test sysroot gains libnss_files.so.2, which ldd cannot see, glibc
dlopen()s it.  Without it getpwnam() fails inside the chroot, so
user/group settings never resolved and directory ownership could not
be tested.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
Aaron Andersen points out in the #492 discussion that the *Directory
settings carry more contract than create-and-chown: per-directory
modes, specific ownership rules, and cleanup toggles.  Without them
config-dir was chowned to the service user, which systemd never does,
an existing directory with drifted ownership was left wrong, and the
runtime directory could not survive a restart.

Now matching systemd.exec(5), and where the man page is vague, the
code in setup_exec_directory():

  - each directory takes a matching -mode key, octal with the leading
    zero, default 0755.  The mode of the named directory is locked
    down again on every start, also when it already exists
  - config-dir is created but never chowned
  - the contents of an existing directory are left alone as long as
    the owner is right; on drift everything under it is chowned back
  - runtime-dir-preserve = no | restart | yes maps
    RuntimeDirectoryPreserve=.  A service still qualified to run when
    the runtime directory would be removed is restarting, not
    stopping, which is what svc_enabled() answers

The dir mechanics move to mksubsysd(), taking resolved ids, with
mksubsys() reduced to a name-resolving wrapper for the dbus plugin.
The child resolves uid/gid once for both directory setup and
privilege drop.

The symlink form, RuntimeDirectory=foo:bar, is not adopted.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
@troglobit
troglobit requested a review from aanderse July 30, 2026 13:24
The workflows install libuev and libite from source and everything
else from apt, but never libconfuse, so every build job on this
branch dies in configure:

    checking for libconfuse >= 3.3... no

Ubuntu ships libconfuse 3.3 with the static library included, which
covers both the static and regular builds.  Staying on 3.3 in CI is
deliberate: it exercises the fallback paths marked
"XXX: Workaround for libConfuse <3.4" that a from-source 3.4 would
leave untested.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
The line-based format accepts `service :80 ...`, deriving the name
from the command basename.  The block format has no counterpart, the
title carries both name and ID.  Implied by the format description,
but anyone converting such a line deserves to find it written down.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
@troglobit

Copy link
Copy Markdown
Collaborator Author

@aanderse if you could spare a minute or so, skimming through the PR description at least, that would be very appreciated! 🙇‍♂️

@aanderse aanderse left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is fantastic! i am really happy with what you did here 😁 and all these wonderful new options! somehow you keep adding so much great stuff while really hitting that perfect balance with simplicity 🤩

this format matches what i had with finix already so porting over wasn't that hard. happy to report my laptop is running fine on this branch as of tonight 🚀

PR description is fantastic and all i needed to migrate everything i had 👍

really looking forward to this being merged!

@troglobit

Copy link
Copy Markdown
Collaborator Author

this is fantastic! i am really happy with what you did here 😁 and all these wonderful new options! somehow you keep adding so much great stuff while really hitting that perfect balance with simplicity 🤩

Thank you for those kind words, I really appreciate that! 😊

this format matches what i had with finix already so porting over wasn't that hard. happy to report my laptop is running fine on this branch as of tonight 🚀

Wow, that was quick! 🤯

PR description is fantastic and all i needed to migrate everything i had 👍

I'll put together a migration guide later on, thank you for the idea!

really looking forward to this being merged!

Going in now!

@troglobit
troglobit merged commit 3062f49 into master Aug 1, 2026
5 checks passed
@troglobit
troglobit deleted the file-format branch August 1, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants