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:
parent
817d666068
commit
57aff40228
59 changed files with 3981 additions and 1124 deletions
155
docs/architecture/init.md
Normal file
155
docs/architecture/init.md
Normal 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.
|
||||
Loading…
Reference in a new issue