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
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.
StandardOutis 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.