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
This commit is contained in:
Nexoniarz 2026-09-12 17:13:35 +02:00
commit 57aff40228
59 changed files with 3981 additions and 1124 deletions

155
docs/architecture/init.md Normal file
View file

@ -0,0 +1,155 @@
# `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](kernel.md#signals) 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](../plan/init.md).
- **32 jobs, 16 arguments, 4 KB manifests**, all compile-time.