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
155 lines
6.4 KiB
Markdown
155 lines
6.4 KiB
Markdown
# `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.
|