SchOS/docs/architecture/init.md
Nexoniarz 57aff40228 docs: four directories, and a rule for when a thing leaves the roadmap
docs/ was ten files where a plan, a description, a guide and a bug report all
sat next to each other. It is now architecture/ (what SchOS is), plan/ (what it
will be), guides/ (how to work on it) and history/ (why it is like this), with
an index that states the one rule holding it together: when something is
finished it moves from plan/ to architecture/ and is rewritten as a description
of the thing rather than a record of building it.

A ticked checkbox says the work happened. It does not say what the work
*produced*, and six months later the checkbox is all that is left. The network
stack proved the point: finished, verified end to end, and for a month the only
description of it anywhere was a checkbox four levels of indentation deep.

So roadmap.md went from 534 lines to 181 -- nothing deleted, the done work
graduated. Seven subsystems that were finished and undescribed now have
descriptions: boot, kernel, syscalls, filesystem, networking, graphics, usb,
init. Every one ends with "What it does not do", which is the section that pays;
a document listing only strengths is marketing, and the reader who needs it most
is the one about to trip over a limitation nobody wrote down.

schfs.md still opened with "phases 1 through 3 1/2 are done, phase 4 is next"
long after all five had landed. It is a description now, with the phase record
and the hard-won bugs moved to history/.

Three corrections found on the way, all load-bearing:

  * projects/schcom/README.md claimed SCM_RIGHTS was done. There is no
    sendmsg/recvmsg in the kernel at all, and an entire GUI plan rested on it.
  * The root README said "No TCP/IP" months after the stack landed.
  * history/kernel.md sec.7 was marked "in progress" on a problem that process
    groups solved.

Also adds plan/1.0.md, which draws a finish line so the open list stops being
infinite, and marks schwm/ and schde/ superseded: in Wayland the compositor is
the window manager, so that split dissolves rather than being abandoned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 17:13:35 +02:00

6.4 KiB

schinit — PID 1

Shaped after launchd rather than sysvinit. The reason is not the configuration format.


Why launchd

Demand launching. The supervisor holds the listening socket or watches the path, and starts a job when something actually arrives.

That inverts the dependency graph. A job stops needing to declare what must be running before it, because the thing it depends on answers on first use — and runlevels stop being a concept at all.

SchOS has the two pieces that makes possible: AF_UNIX sockets and a real /proc. This milestone is the supervisor; demand launching is the next one. Socket sections are parsed and remembered but not yet held.

Constraints on the program itself

cmd/schinit/main.c links against lib/, which is raw syscall stubs. There is no libc here — no malloc, no printf, no strlen. Everything is static storage and hand-rolled, which is also why the limits are constants rather than growable:

MAXJOBS 32
MAXARGS 16
manifest size 4 KB, read into one static buffer

Bootstrap

Before any job runs, PID 1 creates the device nodes and mounts that a job needs in order to have somewhere to send its output. This cannot itself be expressed as a job without a bootstrapping problem, which is why it is a function and not a manifest.

Console first, as fd 0/1/2, so every message after it has somewhere to go. Then the /dev nodes — minor numbers under major 1 follow Linux's mem-major layout where it costs nothing (null=2, zero=5, full=7, random=8, urandom=9) — then /dev/input/event0 and event1, the raw disks, and /proc.

Two details that look like carelessness and are not:

The nodes are created unconditionally, including for absent hardware. Opening /dev/hdb with no drive present fails with ENXIO, which is a better answer than a missing path. It matters more than it looks: with no node, echo x > /dev/hdb silently creates an ordinary file of that name and appears to succeed.

The mknod'd /proc files still happen and are then covered by the mount. A failure to mount degrades to the old behaviour rather than losing /proc entirely.

Job manifests

Every *.job file in /etc/schinit is loaded at boot, found with getdents64 rather than a hardcoded list — so dropping a file in the directory is all it takes to add a job.

Sectioned key = value, # starts a comment, keys are case-insensitive (a manifest may say KeepAlive, keepalive or KEEPALIVE; keys are for humans and this is not a language).

Key
Label A reverse-DNS name, as launchd uses. Identity, not a path.
Program The binary to execute
Args argv, space separated. argv[0] is conventionally the basename, so Args = sh for /bin/sh
RunAtLoad Start it when schinit loads
KeepAlive Restart it when it exits
Disabled Load it but do not start it
ThrottleInterval Seconds between respawns (default 1)
ControllingTerminal Claim the console
StandardIn / StandardOut / StandardError Paths to open as fd 0, 1, 2
[Socket <name>] → Path Parsed, recorded, not yet honoured

Unknown keys and unknown sections are ignored rather than fatal. A manifest written for a newer schinit must not stop this one booting the machine.

Starting a job

Each job gets its own session (setsid), so it cannot reach back and signal the rest of the system through a shared controlling terminal.

That is exactly what makes ControllingTerminal necessary, and the interaction is the least obvious thing in the program:

A fresh session has no controlling terminal, and the console only delivers input to the foreground process group it is tracking. Without ControllingTerminal = yes, the shell comes up, echoes every keystroke through the line discipline, and executes none of them — which looks like a hung shell and is really a terminal that is not listening to it.

It is off by default. A daemon has no business seizing the console, and launchd's model is that the console is something a job asks for.

exec.c enables the Linux ABI automatically for musl binaries, so a job must not call enable_linux_abi() itself — it would renumber the syscalls the child still has to make.

Supervision

One loop around wait().

It reaps whatever exited, including processes reparented here when their own parent died. Those are not jobs, job_by_pid returns 0 for them, and that is the whole handling they need.

A KeepAlive job is restarted, subject to ThrottleInterval — a job that exits immediately and forever would otherwise spin the machine. launchd defaults to 10 seconds; 1 is enough here, where the common case is a shell someone just exited on purpose and expects back.

When wait() returns -1 there is nothing left to wait for, which should not happen while any job is alive. It sleeps rather than spinning, so a mistake there costs an idle machine and not a hot CPU.

The fallback

With no manifests found, schinit says so and starts /bin/sh on the console anyway.

An unbootable-looking machine because a manifest was missing would be a poor trade for purity.

PID 1 cannot be killed

Enforced in the kernel, not here. init_ignores() discards any signal whose disposition is the default action when the target is PID 1, including SIGKILL — see kernel.md for why Ctrl-C is the input that finds that window. A handler init did install is still delivered.


What it does not do

  • Socket activation is not implemented. The manifest key is parsed and the path remembered; nothing holds the socket, so demand launching — the entire reason for copying launchd — does not happen yet.
  • No schinitctl. Jobs cannot be started, stopped, reloaded or inspected at runtime. The only interface is rebooting with a different manifest.
  • The job table is invisible. Nothing reports what is loaded, what is running, or how many times something has respawned.
  • No dependency ordering of any kind, deliberately — demand launching is meant to make it unnecessary, and until that exists there is nothing in its place either.
  • No log capture. StandardOut is a path, so a job's output goes wherever the manifest says and nothing aggregates it.
  • A crash in schinit is fatal to the machine, because exit() panics on initproc. Fixing that is ../plan/init.md.
  • 32 jobs, 16 arguments, 4 KB manifests, all compile-time.