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
178 lines
7.7 KiB
Markdown
178 lines
7.7 KiB
Markdown
# PID 1 becomes a role
|
||
|
||
What `schinit` is today is [../architecture/init.md](../architecture/init.md).
|
||
This is what it becomes, and it is the one item on this plan that deliberately
|
||
breaks a Unix rule.
|
||
|
||
---
|
||
|
||
## The law being changed
|
||
|
||
Unix's rule is *PID 1 is special and must never die*. Every init since 1975 has
|
||
honoured it by being careful.
|
||
|
||
> **SchOS's rule instead: PID 1 is a role. The kernel guarantees the role is
|
||
> filled. The role's state lives outside whichever process currently holds it.**
|
||
|
||
That is a real change and it is worth making, because "must never die" is not a
|
||
mechanism — it is a hope, and the thing it hopes about is the one program on the
|
||
machine with no supervisor.
|
||
|
||
---
|
||
|
||
## The proposal this replaces
|
||
|
||
The original sketch was: init 1 starts init 2; if init 1 is killed, init 2 starts
|
||
init 3 and revives init 1.
|
||
|
||
The instinct is right — init needs something watching it — and one half of the
|
||
idea survives below. The specific arrangement has five problems, and four of them
|
||
are structural rather than fixable. They are recorded because each one is a trap
|
||
that any redundant-supervisor design falls into:
|
||
|
||
**It defends against the attack that already fails.** Userspace cannot kill PID 1
|
||
on this kernel. `init_ignores()` (`kernel/proc/proc.c:620`) drops `SIGKILL` to
|
||
`initproc`. The threat is already handled.
|
||
|
||
**Init 2 is the same binary as init 1.** If init 1 died of its own bug — a null
|
||
dereference parsing a manifest, an exec of a truncated binary — init 2 reaches
|
||
the same line and dies the same way, and *faster*, because it just watched the
|
||
input that caused it.
|
||
|
||
**The chain has no bound.** Init 2 makes init 3, which by symmetry watches init 2
|
||
and makes init 4. Without a cap it is a fork bomb whose author is the supervisor;
|
||
with a cap, the cap is the real design and the chain is decoration.
|
||
|
||
**Two supervisors is split brain.** Both hold the job table. Both see a job's pid
|
||
exit. Both restart it. Now the console shell exists twice, both reading
|
||
`/dev/console`, and every keystroke goes to whichever woke first. This is the
|
||
standard failure of redundant supervisors, and it is worse than no redundancy
|
||
because it corrupts the state it was added to protect.
|
||
|
||
**It only detects death.** The common way an init breaks is not dying — it is
|
||
**hanging**: deadlocked, spinning, or blocked forever on a disk that stopped
|
||
answering. `wait()` never returns for a process that is merely stuck, so init 2
|
||
watches a wedged init 1 forever and reports everything normal. The scheme is
|
||
armed against the rare failure and blind to the likely one.
|
||
|
||
---
|
||
|
||
## What gets built instead
|
||
|
||
Four parts. The first three are userspace; the fourth is the kernel change.
|
||
|
||
### Part 1 — split by role, not by copy
|
||
|
||
**`schinit` (PID 1)** becomes small enough to be obviously correct and then stops
|
||
changing: reap children, hold the job table in memory, supervise exactly one
|
||
child. No manifest parsing. No filesystem walking after boot. No allocation. Its
|
||
entire job is to be too boring to fail.
|
||
|
||
**`schinitd` (PID 2)** gets everything else — manifests, socket activation,
|
||
respawn policy, backoff, and the eventual `schinitctl`. It is where the bugs will
|
||
be, and it is restartable.
|
||
|
||
The detail that makes this work, and that the two-copy scheme cannot have:
|
||
|
||
> **Jobs are forked by PID 1, not by `schinitd`.** `schinitd` decides and asks;
|
||
> PID 1 acts. So every job on the machine is a child of PID 1 at all times, and
|
||
> `schinitd` crashing orphans nothing, reparents nothing and restarts nothing. It
|
||
> comes back, reads the state, and finds the world exactly as it left it.
|
||
|
||
This keeps the good half of the original idea — two processes, one watching the
|
||
other — with the roles made *different*, so the second is not a second copy of
|
||
the first one's bug.
|
||
|
||
### Part 2 — state outside the process
|
||
|
||
**`/proc/schinit`**: the serialised job table, written by PID 1 whenever it
|
||
changes, readable by anyone. A resurrected supervisor reads it back and knows
|
||
what is running without having started it.
|
||
|
||
procfs rather than a file on disk, for four reasons: it survives a read-only
|
||
root, it survives a full disk, it costs no I/O, and it is already the pattern
|
||
this kernel uses for exactly this kind of question.
|
||
|
||
The side benefit closes an item the architecture doc lists as missing — the job
|
||
table is currently invisible. `cat /proc/schinit` is the observation.
|
||
|
||
### Part 3 — a heartbeat, in both directions
|
||
|
||
The failure the original scheme cannot see.
|
||
|
||
PID 1 and `schinitd` hold a `socketpair`; each must be heard from within a
|
||
deadline. Silence from `schinitd` means PID 1 kills and restarts it. Silence from
|
||
PID 1 is the case part 4 exists for.
|
||
|
||
**A hang is now as detectable as a death**, which is the whole point.
|
||
|
||
### Part 4 — `proc_reincarnate()`
|
||
|
||
The kernel change, and the actual law change.
|
||
|
||
Today `exit()` on `initproc` calls `panic("init exiting")`
|
||
(`kernel/proc/proc.c:277`). Instead: tear down the address space, keep the
|
||
`struct proc`, keep **pid 1**, and re-exec `/sbin/schinit` in place with a
|
||
generation counter passed in.
|
||
|
||
No init 2, no init 3, no reparenting problem — **because pid 1 never stops being
|
||
pid 1**. Every orphan the kernel already reparented to `initproc` is still
|
||
correctly parented, `initproc` is still the pointer it always was, the children
|
||
survive, and the state is in `/proc/schinit`.
|
||
|
||
This is strictly better than resurrection-by-sibling for every case, and it is
|
||
the smaller change.
|
||
|
||
#### The escalation ladder
|
||
|
||
What makes it safe to have at all:
|
||
|
||
| Generation | What happens |
|
||
|---|---|
|
||
| 1 | Normal boot. |
|
||
| 2–3 | Re-exec in place. Log it, keep the jobs, carry on. |
|
||
| 4 | Re-exec, but ignore `/etc/schinit` and start only a console shell. **A manifest that kills init must not kill it forever.** |
|
||
| 5 | Stop. `panic()` with stop code `INIT_RESURRECTION_FAILED`. |
|
||
|
||
The last row is the honest one. A machine that cannot hold PID 1 up five times is
|
||
not a machine that should keep trying, and PSOGM already renders a stop code, a
|
||
call chain and a QR code of the whole report — which is a far better end than a
|
||
silent loop. The crash screen is the correct place for this to arrive.
|
||
|
||
---
|
||
|
||
## Milestones
|
||
|
||
| | |
|
||
|---|---|
|
||
| **I0** | Split the program. PID 1 supervises one child; `schinitd` does everything else. Jobs still forked by PID 1. |
|
||
| **I1** | `/proc/schinit`, written and read back. Kill `schinitd` by hand and watch it return without disturbing a single job. |
|
||
| **I2** | The heartbeat both ways, with a `sysrq` key that wedges `schinitd` on purpose so the timeout path is exercised rather than assumed. |
|
||
| **I3** | `proc_reincarnate()` and the ladder, with a `sysrq` key that kills PID 1 on purpose. |
|
||
|
||
---
|
||
|
||
## What it costs
|
||
|
||
**PID 1 and PID 2 must agree on a protocol**, which is a compatibility surface
|
||
where there was none — created by a plan whose own [schapi.md](schapi.md) §3
|
||
warns about exactly that. Keep it tiny, version it in its first message, and keep
|
||
it entirely internal. It is not SchAPI and nothing else may speak it.
|
||
|
||
**`proc_reincarnate()` is a genuinely dangerous function.** It runs in the
|
||
context of a process that is dying, and it must not take a lock that process may
|
||
already hold. It belongs in `proc.c` next to `exit()`, written with the care of
|
||
the panic path, and tested by a `sysrq` key rather than by reasoning.
|
||
|
||
---
|
||
|
||
## What it will still not do
|
||
|
||
- **It does not make init correct**, only survivable. A `schinitd` that starts
|
||
the wrong jobs starts them again after every restart.
|
||
- **It does not protect the jobs from each other.** A job that fills the disk or
|
||
exhausts the process table still takes the machine down, and the supervisor
|
||
surviving does not help.
|
||
- **A hang in PID 1 itself is only caught by `schinitd` noticing**, and if both
|
||
are wedged simultaneously nothing catches it. There is no hardware watchdog.
|
||
- **Five generations is a guess**, not a measured number.
|