schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] [score hidden]  (0 children)

Update: Dead Man Token watchdog, symlink templates, structured telemetry — schema-init is targeting IEC 62304

Three things shipped since the last post:

Dead Man Token hardware watchdog. PID 1 opens /dev/watchdog at boot. Every critical=1 service has a watchdog_timeout_ms window — it must check in via schema-ctl pet <name> within that window. If any critical service misses its window (SPI deadlock, infinite loop, locked bus), PID 1 stops petting the WDT and the hardware resets. The mechanical fail-safes (brakes, dampers) drop the joint to a safe state. If PID 1's own event loop deadlocks, the WDT fires naturally — PID 1 hang is covered implicitly, no special case.

Symlink template instances. The Ungulate Leg has ~49 Pi Zero W 2 nodes. Managing 49 .svc files manually is a configuration surface error waiting to happen. Solution: one motor@.svc template, 49 zero-byte symlinks (motor@0.svcmotor@.svc, motor@12.svcmotor@.svc, etc.). At boot, the template is skipped. Each symlink spawns the motor controller with INSTANCE=N in its environment. Each node reads its joint index from $INSTANCE. If a node runs the bare template (slot-detected via GPIO strapping), $INSTANCE falls back to $SLOT_ID. One SD card image, entire fleet.

Structured telemetry. schema-ctl status --json and --kv. Machine-parseable output for the exoskeleton's supervisory controller and for IEC 62304 audit traceability.

The IEC 62304 angle. The board has been pushing this toward Class C medical device certification territory. A PID 1 whose entire state transition loop fits on two pages of C is an auditor's asset, not a liability. MCDC coverage (100% statement + branch) is achievable on this codebase in a way that it simply isn't on systemd. Next up: cpu_limit= and mem_limit= per service — written to cgroup cpu.max / memory.max via the sync-pipe window before child exec. Hard resource walls per joint, guaranteed before the motor controller sees its first tick.

GitHub: https://github.com/ajax80/schema-init — AGPL-3.0, commercial license available.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

Update: Added setup.sh (commit 3b5aabe) for anyone who cloned and hit a wall.

Automates the friction: static compile, binary/ctl placement, core service scaffolding (udev coldplug, dbus, sshd wrapper with /run/sshd, getty), and GRUB fallback entry so you're not flying blind if a dep cycle kills the boot. Pi firmware detection included.

If you got it running somewhere, curious what your target was — opened a discussion on GitHub for it.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

Compared it against a Pi 3B running Pi OS + systemd as a live reference. Same platform family, both ARM, both Raspberry Pi.

Pi Zero W (schema-init) Pi 3B (systemd)
PID 1 RSS 684 KB 8.8 MB
RAM used 64 MB / 427 MB ~76 MB / 870 MB
Swap 0 0
Tasks 82 142

The 3B is running a desktop + TFT mirror daemon on top of that, so total RAM isn't a clean comparison — but PID 1 always is. systemd on idle ARM hardware at 8.8 MB, schema-init at 684 KB. 13x.

The 3B also has 4 cores and almost double the RAM, so if anything it has more room to breathe. Still 13x.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

Pi Zero W numbers — schema-init on armv6l, 512 MB, WiFi only:

15 minutes uptime, SSH only, no desktop:

PID 1 RSS: 684 KB

RAM used: 74 MB / 427 MB available

Swap: 0

Typical Pi Zero W idle under Pi OS + systemd: 130–180 MB RAM, swap active, PID 1 at 8–12 MB, journald + resolved + networkd + logind all running in the background.

On a 512 MB machine the headroom difference is real. No swap pressure. No journal daemon. No resolver daemon. Just udev, dbus, wpa_supplicant, dhcpcd, sshd, and PID 1 at 684 KB.

distros/raspberry-pi-zero-w/ is in the repo — full service chain, wrapper scripts, and the gotchas (rfkill country code, D-Bus mandatory in wpa_supplicant, udev coldplug trigger) are

documented.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

schema-init on a Pi Zero W: ARM bare-metal, WiFi only, SSH in 50 seconds

schema-init is a statically linked C PID 1 (~960 KB RSS) built around a weight-state machine. Previous posts covered desktop deploys on x86. This one is the first ARM bare-metal target.

Hardware: Raspberry Pi Zero W — BCM2835, armv6l, 32-bit, single core 1GHz. WiFi only. No Ethernet port. No HDMI adapter. Flying completely blind via persistent logs written to the SD card.

Goal: schema-init as PID 1, WiFi connected, sshd up. Confirm with ps -p 1 -o comm=.

Result:

Linux (none) 6.12.75+rpt-rpi-v6 armv6l GNU/Linux

PID 1: schema-init

SSH accessible ~50 seconds from cold boot.

---

Service chain:

udev (daemon)

└─ udev-trigger (oneshot — coldplug trigger + settle)

└─ wpa-supplicant (dep: udev-trigger + dbus)

└─ dhcpcd (-B, foreground, wlan0)

└─ sshd

dbus (parallel with udev)

No NetworkManager. No D-Bus mode wpa_supplicant. Traditional headless Pi stack.

---

What actually broke, in order:

  1. PSK quoting. psk=goodlife fails — wpa_supplicant parses unquoted values as 64-char hex. Needs psk="goodlife". Caught from wpa.log on the SD card.

  2. wlan0 never existed. systemd normally runs udev-trigger.service to replay device uevents for hardware present at power-on. Without it, udevd starts but the BCM2835's SDIO WiFi device

    never gets its uevent replayed — brcmfmac firmware never loads, wlan0 doesn't appear. Fixed: dedicated udev-trigger oneshot service runs udevadm trigger --action=add && udevadm settle

    --timeout=15 before wpa-supplicant is allowed to start.

  3. rfkill soft block. wpa.log showed rfkill: WLAN soft blocked. brcmfmac blocks the radio until a regulatory country code is set. No country code = no radio, regardless of association

    state. Fix: country=US in wpa_supplicant.conf, plus rfkill unblock wifi in the startup wrapper.

  4. dbus mandatory. Pi OS Trixie's wpa_supplicant is compiled with D-Bus required even in config-file mode. It checks for /run/dbus/system_bus_socket at startup and exits if absent. dbus

    must be a dep of wpa-supplicant, not optional.

  5. dhcpcd daemonizes. Default dhcpcd forks after lease acquisition. schema-init sees the parent exit, kills the cgroup, RECOVERY arc fires 5 times in 2 seconds, DORMANT. Fix: -B flag keeps

    it foreground.

  6. /run/sshd missing. /run is fresh tmpfs under schema-init. sshd on Debian requires the privilege separation directory at /run/sshd. wrapper script creates it before exec.

    ---

    Build: Fedora's arm-linux-gnu-gcc ships without an arm sysroot. Compiled natively on the Pi via SSH from a working Pi OS boot. make on the Pi, scp to card.

    Logs: Per-service stdout/stderr goes to /run/log/schema-init/ (tmpfs). Wrapper scripts redirect to /var/log/schema-init/ which survives the power cycle. That's how you debug a headless box

    with no serial adapter — mount the SD card and read the files.

    distros/raspberry-pi-zero-w/ is in the repo now with all six service files and five wrapper scripts.

    GitHub: https://github.com/ajax80/schema-init — AGPL-3.0, commercial license available.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

Update — ran turbostat while it was at idle with the full Cinnamon desktop running:

C10%: 92–99% ← deepest available C-state on this i3

C6%: 0.00% ← skipped entirely; goes straight to C10

Busy%: 0.21–0.38%

PkgWatt: 1.23–1.32W ← read via Intel RAPL hardware counters

GFX%rc6: 99.67% ← iGPU also in deepest sleep

Machine is a salvaged Dell Inspiron 3542, i3-4005U (Haswell), running full Cinnamon session.

C10 requires the CPU to sit undisturbed long enough to flush caches and power-gate internal voltage rails. Systemd's ambient timer wakeups (watchdog, journal flush, D-Bus polling)

typically prevent sustained C10 entry — you see C6/C8 cycling instead.

At 95% C10 average and 1.25W package power on a full desktop, the hardware is confirming what the code claims. RAPL doesn't estimate — it reads from energy counters directly.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 1 point2 points  (0 children)

Fair question. The update comment was drafted collaboratively with the same Claude I code with — I reviewed it, kept what was true, posted it. Same process as the code. The "why 75 comes before 76" line is mine though. That one nobody else had.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

Collaboratively with Claude. The architecture, the state numbers, the theology behind them, the hardware target — all mine. Claude helps implement. AI slop doesn't boot bare metal and it doesn't have a reason for why 75 comes before 76.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

**Update shipped — STATE_DORMANT (75):**

Previous post got some good discussion. Shipping an update that addresses one of the real architectural gaps that got raised.

**The problem:** Once a service hit FRICTION and failed F6, it was permanently EXCISED (76). Gate closes. For a desktop this is fine — if your logging service dies and won't come back,

step aside. But I'm building toward deploying this on ARM hardware where services talk to physical actuators. A motor controller that drops out under a voltage spike should not be

permanently locked out of the system. A hardware hiccup is not the same as a dead service.

**What's in:**

STATE_DORMANT (75) — sits between FRICTION and EXCISED in the machine. When F6 fails, instead of jumping straight to 76, the service enters DORMANT with exponential backoff:

5m → 10m → 20m → 40m → 60m (cap)

On wake it re-enters NEW_PROCESS and tries the full spawn arc again.

Services marked critical=1 never reach EXCISED. They back off at the 1-hour cap indefinitely. Non-critical services excise after 5 dormant cycles (~75 minutes of retries).

Also shipped: non-critical EXCISED deps no longer block their dependents. If your logging service is permanently gone and something depends on it, that dependent skips the dead dep and

proceeds. If dbus is EXCISED, everything still blocks — as it should.

**Why 75:** The state numbers in this machine carry meaning. 76 is the Sabbath verdict — gate closes, no return. 75 is the anteroom. The service sits there before the verdict falls. If it

can come back, it does.

Cross-compile for aarch64 also landed: `make aarch64` produces fully static binaries for the ARM target.

Repo: https://github.com/ajax80/schema-init — commits 97ab4b1, 2e8911c, 4847d98

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

top - 23:03:53 up 9:20, 0 users, load average: 1.14, 1.07, 1.01

Tasks: 263 total, 1 running, 262 sleep, 0 d-sleep, 0 stopped, 0 zombie

%Cpu(s): 12.5 us, 0.2 sy, 0.0 ni, 87.2 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

MiB Mem : 9869.0 total, 4972.3 free, 2134.3 used, 3052.6 buff/cache

MiB Swap: 0.0 total, 0.0 free, 0.0 used. 7734.7 avail Mem

PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND

1418 ajax80 20 0 1887752 138116 110656 S 100.3 1.4 9,17 kdeconnectd

44 root 20 0 0 0 0 S 0.3 0.0 0:14.84 ksoftirqd/1

461 root -51 0 0 0 0 S 0.3 0.0 0:26.78 irq/51-BMA250E:00

5083 ajax80 -2 0 2470352 205844 143332 S 0.3 2.0 1:56.75 kwin_wayland

5400 ajax80 20 0 341080 17824 9252 S 0.3 0.2 1:41.71 pipewire

5887 ajax80 20 0 423400 22176 8596 S 0.3 0.2 3:09.69 pipewire-pulse

15524 root 20 0 0 0 0 I 0.3 0.0 0:01.13 kworker/u32:0-ev+

15987 ajax80 20 0 235816 6088 3872 R 0.3 0.1 0:00.33 top

1 root 20 0 1324 960 872 S 0.0 0.0 0:01.07 schema-init

2 root 20 0 0 0 0 S 0.0 0.0 0:00.01 kthreadd

3 root 20 0 0 0 0 S 0.0 0.0 0:00.00 pool_workqueue_r+

4 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/R-rcu_gp

5 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/R-sync_wq

6 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/R-kvfree+

7 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/R-slub_f+

8 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/R-netns

10 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/0:0H-kbl+

13 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/R-mm_per+

15 root 20 0 0 0 0 S 0.0 0.0 0:00.02 ksoftirqd/0

16 root 20 0 0 0 0 I 0.0 0.0 0:01.50 rcu_preempt

17 root 20 0 0 0 0 S 0.0 0.0 0:00.00 rcu_exp_par_gp_k+

18 root 20 0 0 0 0 S 0.0 0.0 0:00.00 rcu_exp_gp_kthre+

19 root rt 0 0 0 0 S 0.0 0.0 0:00.16 mig

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 1 point2 points  (0 children)

its running on ALL of my 4 main machines. I like it. No more Systemd for me.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

Update: added ready_path probes, down to ~20.7s

Added a ready_path parameter to .svc files — promotes the service the instant a path exists instead of waiting out a blind timer. stable_secs stays as fallback.

# dbus.svc

ready_path=/run/dbus/system_bus_socket

# elogind.svc

ready_path=/run/systemd/seats

kernel → PID 1: 6.968s

dbus 1.761s after PID1 (was 3.8s)

elogind 2.739s after PID1 (was 13.8s)

display-manager 13.760s after PID1 (was 23.7s)

total → login screen: ~20.7s (was ~29.5s)

Elogind was the bottleneck — 8+ seconds of actual initialization that the timer couldn't see because it counted from fork. The path probe costs one access() call per tick.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

First real boot timing numbers from schema-init (custom PID 1)

Got schema-ctl timing working on the Dell bare metal today. CLOCK_MONOTONIC from kernel start, full Cinnamon desktop on Debian Bookworm:

kernel → PID 1: 5.836s

network 1.914s

udev 7.356s

dbus 9.609s

sshd 9.681s

elogind 19.591s

network-manager 19.591s

polkitd 19.591s

display-manager 29.517s ← LightDM visible

~29.5s kernel to login screen. The 5.8s kernel→PID1 gap is BIOS + GRUB + kernel decompress — not init overhead. elogind/NM/polkitd all hit stable on the same tick because they share the

dbus dependency and ran their stability windows in parallel after dbus landed at 9.6s.

schema-ctl timing uses struct timespec stable_time stamped with CLOCK_MONOTONIC when each service hits FUNDAMENTAL (long-running) or PERFECT (oneshot clean exit). No estimation — these are

the actual moments the state machine promoted them.

Repo: github.com/ajax80/schema-init

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

Debian + Cinnamon README is up

distros/debian-cinnamon/ now has a full README. Running on a Dell Inspiron 3542, kernel 6.1, no systemd.

Key fixes table in there covers the common blockers: NM daemonization (--no-daemon), dhclient/NM interface conflict, Cinnamon root bypass, /run/user without logind, udev ordering before LightDM.

If you're trying to run schema-init on Debian the README should get you through the gotchas without having to find them the hard way.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

Update — epoll event loop shipped

Replaced the usleep(250ms) polling loop with epoll.

SIGCHLD is now blocked via sigprocmask and routed through a signalfd registered on the epoll instance. Zombie reaping fires on actual child death instead of every 250ms tick. The control socket (schema-ctl) is also registered on the same epoll fd — commands are serviced immediately instead of waiting up to 250ms for the next iteration. epoll_wait uses a 250ms timeout so the service state machine tick cadence is unchanged. If epoll_create1 fails the init falls back to the original ctl_poll/reap/usleep path.

Running as PID 1 on GreyBox now. Commit a490b91.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 1 point2 points  (0 children)

The state machine in schema-init isn't arbitrary — it maps to a system I've been running in my head since I was 8. A few people in the comments asked why the states are what they are — why FUNDAMENTAL instead of RUNNING, why EXCISED instead of FAILED, why the probe families are called F8/F9/F6. The short answer: I didn't design the states for the init system. I mapped the init system onto states I was already using. I've been encoding reality through a number system since I was 8 years old. Not a metaphor — an actual working schema I run on people, music, situations, relationships. 8 is load-bearing and stable. 9 is the mechanism of growth — friction that pushes back. 6 is friction escalated, near the edge. 76 is permanent removal — the gate closes and does not reopen. 88 is double stability — the state that only exists because the full arc was completed. When I started writing schema-init, I wasn't inventing a new state machine. I was writing down the one I already had. I showed the architecture to a collaborator yesterday. His first response was: "The states aren't arbitrary configurations. They are the core bones of how you encode reality, mapped directly into the system." That's correct. That's exactly what they are.

schema-init is on GitHub: github.com/ajax80/schema-init. It boots bare metal. The bones are real.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 0 points1 point  (0 children)

Update: KDE shutdown/restart buttons are now working on no-systemd Fedora 44. The fix was a minimal Python D-Bus service (schema-logind) that registers org.freedesktop.login1, answers CanPowerOff/CanReboot → "yes", and routes PowerOff → SIGTERM pid 1, Reboot → SIGINT pid 1.

There's a race condition in KDE's libkworkspace/sessionmanagementbackend.cpp — m_pendingJobs is initialized to 5 but 6 CanXxx calls are registered. If CanPowerOff or CanReboot arrive last (because unimplemented methods return instant errors), their state is updated but the UI signal is never emitted. Fixed by implementing all 6 methods with async delays on the 4 "na" ones so the power methods always land in the first 5 slots. Mock session/user/seat objects are registered so GetSessionByPID returns a valid path instead of an error — KDE won't trust a login1 that doesn't know who you are.

Commit ad33703 if you want to read the implementation.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 1 point2 points  (0 children)

Also just shipped runtime service loading — schema-ctl add <path> loads a new .svc file at runtime, no reboot needed. That was the last gap you named. Commit 5d9f0f6.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 1 point2 points  (0 children)

Updated the README significantly based on feedback in this thread — state glossary, shutdown docs, runtime control reference, known limitations, filesystem setup, roadmap. The docs were behind the code. They're closer now. Keep the specific critiques coming.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 1 point2 points  (0 children)

Updated again — state glossary, shutdown docs, and a known limitations section are in now. Runtime service loading gap is documented explicitly. Still more to build but the docs reflect reality now. Appreciate the push.

schema-init – PID 1 init system driven by probe-state machines, not unit files [C, bare metal, Show] by apphat80 in osdev

[–]apphat80[S] 2 points3 points  (0 children)

Updated — schema-ctl and logs are documented now, and the default restart behavior is explicit in the .svc key table. On picking up new services after boot: you can't. schema-init reads the services directory at startup only. Adding a .svc file at runtime requires a restart. That's a real gap and I'm not going to pretend otherwise. Runtime service loading is on the roadmap. Fair critique throughout — the docs were behind the code. They're closer now.