A service manager and PID 1 for Linux, written in Rust.
Pre-alpha. Nothing is stable, and the unit format may change between any two releases. See ROADMAP.md for what works and what each milestone was verified against.
An init system is the first program the kernel starts. It is process number 1, and it is responsible for every other process on the machine: starting them in the right order, restarting them when they die, and stopping them cleanly when the machine goes down. If it exits, the kernel panics and the machine is gone.
flowchart TD
K[Linux kernel] -->|starts as PID 1| O[oxinit]
O -->|starts, in order| A[database]
O -->|restarts when it dies| B[web server]
O -->|starts on a schedule| C[backup job]
O -->|starts on a connection| D[rarely-used service]
O -.->|stdout and stderr| L[oxlogd → /var/log/oxinit]
E[you] -->|oxctl| O
oxctl and oxlogd are separate programs that talk to oxinit over a socket.
That is deliberate: a bug in the log writer kills the log writer, and a bug in
PID 1 kills the machine.
You need Docker and nothing else. No Rust, no virtual machine, no kernel.
docker run --rm --name oxinit-demo -p 8080:8080 ghcr.io/youhide/oxinit:demoThe image tracks main and is 4.6 MB. To build and run it yourself instead:
cargo xtask fetch --arch x86_64 # a kernel and a busybox, into target/
cargo xtask demo # build the image and run itoxinit boots as PID 1 inside the container and starts a handful of small services. You will see it resolve them in order, report each one, and then sit there supervising.
The first few lines are oxinit saying what the container runtime will not let
it do — mount /run, set the hostname, write to the cgroup filesystem. That is
not a failure and not noise: a supervisor that pretended those worked would be
lying about the machine it is on.
Look at what it is running. In a second terminal:
docker exec oxinit-demo /bin/oxctl listRead a service's log. counter writes a line every two seconds, into its
own file rather than onto the console:
docker exec oxinit-demo /bin/oxctl logs counterWatch a service start because you asked for something. Nothing is listening on port 8080 — no process, that is, though the port is bound. The request is what starts the service:
curl localhost:8080Stop it the way a real system would. docker stop sends SIGTERM, which
is a request to shut down, not a kill:
docker stop oxinit-demooxinit stops every service in the reverse of the order it started them, waits
for each to actually be gone, and exits 0. Most container images have to be
killed after a ten-second grace period; that shows up as exit code 137.
The units the demo runs are in demo/, one small file each, and they
are meant to be read. The image is that small because oxinit is statically
linked against musl and the image is FROM scratch — no base image, no shared
libraries, nothing in it but the three binaries and the units.
Every init system in production use is written in C: systemd, OpenRC, runit, s6, dinit. The Rust attempts either stalled — rustysd is unmaintained — or explicitly declined to be PID 1, as initd did.
PID 1 is the process that cannot fail. If it exits, the kernel panics. It is the parent of every service on the machine, it owns the cgroup hierarchy, it holds the file descriptors for socket activation, and it determines the order in which everything shuts down. There is no supervisor above it to restart it.
That is a large blast radius for a language where a bounds error is a memory error. The correctness properties Rust enforces at compile time are worth more in PID 1 than anywhere else in the system.
- PID 1 stays small. Supervision only. The CLI, log shipping, and device management are separate processes talking over a unix socket.
- No panic in PID 1.
panic = "unwind", neverabort— an abort in PID 1 is a kernel panic. Every event-loop handler is wrapped incatch_unwind. Theoxinitcrate deniesunwrap,expect,panic, and slice indexing. If the loop cannot continue, it spawns/bin/shon the console. It exits in exactly one place: the end of an ordered shutdown in a container, where the container lives only as long as its PID 1 does. - Unsafe is quarantined. Syscalls go through
rustix. Whateverunsaferemains lives in one module, with a// SAFETY:comment per block stating the invariant. - Synchronous. One epoll loop, one thread, no async runtime in PID 1.
- Declarative units. TOML. No shell, no scripting, no runtime interpolation beyond a small documented set of specifiers.
# /etc/oxinit/units/sshd.toml
[unit]
description = "OpenSSH daemon"
after = ["network-online"]
requires = ["network-online"]
[service]
type = "notify" # simple | forking | oneshot | notify
exec = "/usr/sbin/sshd -D"
restart = "on-failure" # no | always | on-failure | on-abnormal
restart-sec = "5s"
stop-sec = "20s"
watchdog-sec = "30s"
user = "sshd"
[resources]
memory-max = "256M"
tasks-max = 512requires and after are both declared, and neither implies the other. "A
needs B running" and "A must start after B" are different statements, and
conflating them makes both impossible to express precisely.
after means the unit is not started until every unit it names has finished
activating — for a notify service, until READY=1 actually arrives. Boot is
a queue drained by the event loop, not a loop that issues starts in order.
Every key is specified in docs/UNIT_FORMAT.md. That document is the specification, not a description of the parser.
A socket unit binds an address before anything starts, and names the service those descriptors belong to:
# /etc/oxinit/units/sshd-socket.toml
[unit]
description = "SSH socket"
[socket]
listen = ["0.0.0.0:22", "[::]:22"]
service = "sshd"Nothing runs until something connects, and a restart does not refuse connections — the listening socket outlives the process serving them, so they queue in the kernel.
A timer unit starts a service on a schedule, and names it the same way:
# /etc/oxinit/units/backup-timer.toml
[unit]
description = "Nightly backup"
[timer]
service = "backup"
on-calendar = "03:30"on-boot and interval cover the monotonic cases — first firing, then every
firing after it, measured from the previous one. The calendar vocabulary is
deliberately closed: hourly, daily, weekly, HH:MM, HH:MM:SS, in UTC.
It is not a cron expression and will not become one. Calendar deadlines are
armed absolutely on a CLOCK_REALTIME timerfd, so "at 03:30" survives the
clock being corrected under it. A firing that arrives
while the service is still running is skipped rather than queued, and a failed
run does not stop the schedule.
Every service moves through the same states, and the edges are what the
restart policy and the readiness type decide between.
stateDiagram-v2
[*] --> Inactive
Inactive --> Activating: start requested,<br/>and everything it is `after` has finished
Activating --> Active: ready — depends on `type`
Activating --> Failed: exec failed, or `start-sec` expired
Activating --> Inactive: a `oneshot` exited 0
Active --> Deactivating: stop requested, or a watchdog miss
Active --> Failed: exited nonzero, or on a signal
Deactivating --> Inactive: its cgroup emptied in time
Deactivating --> Failed: `stop-sec` expired, killed by cgroup
Failed --> Restarting: the `restart` policy applies
Inactive --> Restarting: the `restart` policy applies
Restarting --> Activating: the backoff elapsed
"Ready" is the interesting one, and it is what type selects:
type |
Becomes Active when |
|---|---|
simple |
exec succeeded. No more accurate than that. |
oneshot |
The process exited with status 0 — and it goes Inactive, not Active. |
notify |
The service sent READY=1 on the notify socket. |
forking |
The initial process exited and the cgroup still has something in it. |
Backoff doubles per consecutive failure and resets once the service has stayed up longer than its own current delay — without that, a service that crashes once a day eventually takes hours to come back.
oxctl talks to PID 1 over /run/oxinit/control.sock. It is a separate
process: a bug in the CLI kills the CLI.
$ oxctl list
banner service inactive Boot banner
console service inactive Console shell
default target active Default target
echo service inactive Socket-activated echo service
echo-socket socket active Echo socket
limited service active Runs under a memory and task cap
probe service active sd_notify probe
$ oxctl status probe
probe (service)
description sd_notify probe
state active
pid 11
status probe up
memory 94208 bytes
tasks 1
status is what the service last sent as STATUS= over sd_notify. Memory
and tasks are read from memory.current and pids.current in the service's
cgroup, on demand — nothing is polled.
start, stop, restart and reload do what they say. Every answer is
immediate and says what was asked for, not what has finished: a stop ends
when the unit's cgroup empties, and the one socket that has to stay responsive
does not wait on the slowest thing on the machine.
A service declaring output = "log" writes into a pipe rather than onto the
console, and oxlogd turns that into /var/log/oxinit/<unit>.log:
$ oxctl logs chatty
1786287692.803983 chatty-out
1786287692.812454 chatty-err
1786287692.812548 chatty-last
stdout and stderr share the pipe, so the order the service wrote them in
survives. The timestamp is seconds and microseconds since the epoch, padded so
the files sort as text — rendering it as a date is the reader's job, and not
having a calendar in a log writer is worth more than pretty output. Rotation
is by size, because bounded disk per unit is the property that matters.
PID 1 reads none of it. It makes the pipe, gives the write end to the
child, and passes the read end to oxlogd over a unix socket. A service
writing a megabyte a second cannot make PID 1 do work, and a bug in a log
writer kills a log writer.
oxinit keeps its own copy of every read end, which is what makes an oxlogd
restart invisible: the pipes are never closed, the services writing into them
notice nothing, and the replacement is handed every descriptor again when it
connects.
oxctl logs reads the file directly rather than asking PID 1 for it. The
control socket has to stay responsive, and bulk data is what would stop it
being.
SIGTERM stops every unit in the reverse of its start order, waits for each
cgroup to empty, then syncs, remounts / read-only and calls reboot(2).
| Signal | Action |
|---|---|
SIGTERM |
Ordered shutdown, then power off. |
SIGINT |
Ordered shutdown, then reboot. Ctrl-Alt-Del. |
SIGPWR |
Ordered shutdown, then power off. |
SIGUSR1 |
Ordered shutdown, then halt without cutting power. |
SIGUSR2 |
Report every unit's state. Changes nothing. |
Shutdown is a state the supervisor is in, not a routine that runs to completion: every unit is asked to stop, the ordinary event loop keeps running, and each event is also a chance to notice that the last one has gone. A blocking routine would have to re-implement the reaper, the timers and the cgroup notifications it is waiting on, during the one part of the boot where getting it wrong strands the machine.
Milestones 0 through 14 are done, and the roadmap is closed. oxinit boots under QEMU and runs as a container's PID 1.
| M0 | Mounts, console, signalfd, reaping. Never exits. |
| M1 | TOML units, dependency graph, Kahn ordering, cycles refused at load. |
| M2 | State machine, restart policy with backoff, sd_notify, watchdog. |
| M3 | cgroup v2: placement between fork and exec, [resources], cgroup.kill, privilege drop. |
| M4 | Socket activation: LISTEN_FDS, LISTEN_PID, LISTEN_FDNAMES. |
| M5 | Ordered shutdown, the signal table, the control socket, oxctl. |
| M6 | Containers, and a console with a controlling terminal. |
| M7 | Logs: a pipe per service, oxlogd, rotation, oxctl logs. |
| M8 | Timer units: on-boot, interval, and a closed calendar vocabulary. |
| M9 | aarch64 as a tested target, not an assumed one. Both boot the same suite. |
| M10 | A real distribution userspace, with dynamically linked services. |
| M11 | after, requires and conflicts enforced; start-sec bounds activation. |
| M12 | CI: every suite above, on every push. |
| M13 | Coverage for seven behaviours the milestones claimed and nothing re-ran. |
| M14 | Calendar schedules on the wall clock; datagram sockets; the list closed. |
| M15 | The documents' invariants enforced by the build, not by discipline. |
| M16 | A demo, a released binary, and a README that starts at the beginning. |
One epoll loop multiplexes the signalfd, the timerfd, the notify socket, the
control socket, every socket unit's listening descriptor and every service
cgroup's cgroup.events. One thread. No async runtime.
Nothing is scheduled after M16. ROADMAP.md has the breakdown — what each milestone was verified against, what was deferred out of it, and which remaining ideas are deliberately not being taken.
Every unsafe block in the project lives in one file, and that is a property
of the build rather than of anyone's discipline: oxinit denies unsafe_code
with a single module relaxing it, and every crate that should contain none
carries forbid. CI also builds on the declared MSRV and checks the dependency
tree for advisories, because both were promises nothing was keeping.
Requires a Rust toolchain with the musl target, qemu-system-x86_64, cpio,
and a kernel image.
cargo xtask bootThis builds a static binary for x86_64-unknown-linux-musl, packs it into a
single-file cpio initramfs, and boots it:
qemu-system-x86_64 -kernel target/vmlinuz-x86_64 \
-initrd target/oxinit-x86_64.cpio.gz -nographic -append "console=ttyS0"Edit to boot takes a few seconds. Ctrl-A X exits QEMU. Setup details are in
CONTRIBUTING.md.
Two suites assert on that boot rather than asking you to watch it:
cargo xtask fetch --arch x86_64 # a kernel and a busybox, into target/
cargo xtask test-boot --arch all # boots x86_64 and aarch64, matches the log
cargo xtask container # runs it in Docker, checks the exit code
cargo xtask test-distro # boots a real Alpine userspaceAll four run in CI on every push, along with fmt, clippy against both musl
targets, and the host tests.
The library crates test on any host, with no VM and no kernel:
cargo test -p oxinit-unit -p oxinit-graph -p oxinit-serviceThat split is the point of the workspace. oxinit-unit, oxinit-graph and
oxinit-service have no OS dependency at all, and the parser, the dependency
semantics and the restart policy are where the logic errors are.
oxinit works as a container's PID 1. That job is reaping orphans, forwarding
signals, and supervising more than one process — which is most of what a
service manager already does, and the reason tini and dumb-init exist. The
difference is that oxinit supervises: restart policy, readiness, ordering, and
a stop that is ordered rather than a broadcast SIGKILL.
The image is the binary, the units, and whatever the services need:
FROM scratch
COPY oxinit /init
COPY units/ /etc/oxinit/units/
COPY myservice /usr/bin/myservice
ENTRYPOINT ["/init"]cargo xtask container --privilegedbuilds exactly that from this repository's own test units, runs it, and asserts on the log and on the exit code.
docker stop is an ordered shutdown. SIGTERM stops every unit in the
reverse of its start order, and oxinit then exits: 0 for a clean stop, 133
— systemd's convention, honoured by podman — where a machine would have
rebooted. Exiting is the whole contract in a container, since the container is
up for exactly as long as its PID 1 is. A supervisor that stopped everything
and stayed running is a container the runtime has to SIGKILL ten seconds
later, which is the exit code 137 you have seen.
The runtime's environment is left alone. Whatever it already mounted is
skipped rather than re-mounted or reported, and the stdio it exec'd oxinit with
is kept rather than redirected onto /dev/console — which is what a container
runtime reads its logs from.
[resources] needs a writable cgroupfs. By default a runtime mounts
/sys/fs/cgroup read-only, and oxinit says so once and carries on without
cgroups: no per-service limits, no type = "forking" readiness, and
cgroup.kill falls back to signalling the one pid it knows. A unit that
declared a limit oxinit cannot apply fails rather than running uncapped — the
unit asked to be capped, and running it uncapped is not a smaller version of
that. With --privileged, or under Kubernetes with the equivalent security
context, the hierarchy works and the limits land.
Note what those limits mean there: the runtime has already capped the whole
container, and [resources] divides that budget between the units inside it.
It cannot raise the container's own ceiling.
oxinit does not, and will not:
- Resolve DNS.
- Implement an NTP client.
- Manage network configuration.
- Run containers. It runs inside one, as PID 1; it does not start one.
- Boot the machine — it is not a bootloader, and it does not
switch_root. On a real machine oxinit is what a distribution's initramfs hands over to; in a container and intest-distroit is the initramfs. - Manage logins or sessions.
It also will not aim for compatibility with systemd unit files. Supporting a
subset of .service means adopting a specification that is defined by another
project's implementation rather than by a document, and the subset you implement
is never the one your distribution actually uses. Units are TOML, specified
here, and that is the whole surface.
Not incidentally — the mechanisms oxinit is built on have no portable equivalent:
| Job | Mechanism | Elsewhere |
|---|---|---|
| Track a service's processes | cgroup v2 | jails/rctl on FreeBSD; a different model |
| Signals as event loop input | signalfd |
kqueue + EVFILT_SIGNAL |
| Multiplexing | epoll |
kqueue |
| Deadlines | timerfd |
EVFILT_TIMER |
| Sender identity on a socket | SO_PASSCRED / SCM_CREDENTIALS |
LOCAL_PEERCRED / SCM_CREDS |
| Boot filesystems | devtmpfs, procfs, sysfs | devfs, no sysfs |
cgroup.kill and the populated key of cgroup.events are what make
type = "forking" tractable at all, and they have no direct analogue.
macOS is not a question of effort: launchd is PID 1, SIP prevents replacing
it, and there is no interface to substitute an init.
A FreeBSD port is possible in principle — init_path is settable — but it
would be a different program. It would, however, reuse oxinit-unit,
oxinit-graph, and oxinit-service, which have no OS dependency at all: the
unit format, the dependency semantics, the topological ordering, and the
restart policy are all portable and test on any host today. Only the system
layer is Linux-bound.
None of that is planned. Getting one platform right comes first.
Architecture support is a separate axis: x86_64 and aarch64, both musl, and
both actually booted — cargo xtask test-boot --arch all runs the same
twenty-six checks on each. oxinit compiled and ran on ARM64 unchanged, which
is the first evidence that going through rustix and keeping the unsafe in
one file bought what it was supposed to.
oxinit does implement two protocols that came from systemd:
sd_notify—NOTIFY_SOCKET,READY=1,STATUS=,WATCHDOG=1.- Socket activation —
LISTEN_FDS,LISTEN_PID,LISTEN_FDNAMES.
These are runtime protocols, not configuration formats. They are small, stable, documented wire contracts that a large amount of existing software already speaks. Implementing them lets unmodified daemons report readiness and receive pre-opened sockets. That is a different thing from adopting another project's config language, and the distinction is deliberate. See ARCHITECTURE.md.
ox — oxidation, meaning Rust — plus init. It states the implementation
language and the category of software, and it follows the naming convention the
category already uses: sysvinit, runit, dinit.
Pre-alpha, so the useful contributions right now are design review and the current milestone's work. See CONTRIBUTING.md for setup and the development loop, and ARCHITECTURE.md before proposing changes to the design.
Dual-licensed under either of:
- MIT License
- Apache License, Version 2.0
at your option. Contributions submitted for inclusion are dual-licensed on the same terms, with no additional conditions.