docs/plan/init.md milestones I2 and I3. The rule change.
**I2 -- the heartbeat.** The failure the two-init scheme could not see is that
an init usually does not die, it *hangs*: wait() never returns for a process
that is merely stuck, so a supervisor watching a wedged init reports
everything normal for ever. PID 1 now pings every two seconds and expects an
answer within ten; silence means stuck, and stuck is worth acting on.
Exercised rather than reasoned about, as the plan asks: `sysrq w` wedges
schinitd by making the scheduler pass over it for ever. It stays alive, keeps
its descriptors and appears in every listing -- which is the point.
Two things that only showed up by running it:
- kill() sets a flag the target checks when it next runs, and the premise
here is that it is not running. Waiting for a corpse that may never be
produced turns the recovery path into a second hang. PID 1 replaces the
supervisor *without waiting*; the old one's socket is closed, so the moment
it runs again its first write fails and it exits.
- the replaced supervisor must notice the hangup. poll() reports POLLHUP and
never POLLIN again, so a loop that only looks for POLLIN sits there
complaining that PID 1 has stopped pinging it.
**I3 -- proc_reincarnate().** exit() on initproc panicked. Now the kernel
tears down nothing, keeps pid 1, and re-execs /bin/schinit in place with a
generation counter. The pid stays 1, so every orphan the kernel reparented is
still correctly parented; the children survive; the open files survive, which
is why this is the first line of exit() and not the last; and the job table
survives in /proc/schinit, which is what it is there for.
The escalation ladder: generations 2-3 carry on, generation 4 ignores
/etc/schinit and starts a console shell only -- a manifest that kills init
must not kill it for ever -- and generation 5 stops, with PSOGM rendering
INIT_RESURRECTION_FAILED, a call chain and a QR code.
Three bugs found by running it, each a lesson about exec():
- exec() returns 0 on success; it does not fail to return. The new image
starts when the kernel returns to userspace through the rewritten
trapframe. Returning from exit() is the success path.
- exit() was reached because `killed` was set, and the trap path checks it
again on the way out. Leaving it set exits the new image before its first
instruction: five generations in a few microseconds, a resurrection loop
that looks exactly like the crash it was meant to survive.
- exec() keeps open files, which is what gives the new image a console --
and also keeps the previous life's socket, holding the old supervisor's
channel up so it never sees a hangup.
Verified: `sysrq i`, and the shell being typed into keeps its pid across the
death and rebirth of PID 1, with /proc/schinit byte-identical either side.
schfs-check passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
212 lines
9 KiB
Markdown
212 lines
9 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
|
||
|
||
**All four milestones are built.** `sysrq w` wedges the supervisor and PID 1
|
||
replaces it; `sysrq i` kills PID 1 and the kernel refills the role:
|
||
|
||
```
|
||
init: pid 1 exited with status 9 -- reincarnating, generation 2
|
||
schinit: reclaimed 2 running job(s) from /proc/schinit
|
||
schinit: SchOS process 1, generation 2 (reincarnated)
|
||
schinitd: adopting os.schos.console-shell
|
||
schinitd: adopting os.schos.tty2-shell
|
||
```
|
||
|
||
The shell being typed into keeps its pid across the death and rebirth of PID 1.
|
||
|
||
**I0 and I1 first.** `schinit` is 520 lines with no parser and no
|
||
allocation; `schinitd` holds the manifests. `kill 3` produces:
|
||
|
||
```
|
||
schinitd: 2 job(s) loaded, generation 1
|
||
schinitd: adopting os.schos.console-shell
|
||
schinitd: adopting os.schos.tty2-shell
|
||
schinitd: ready
|
||
```
|
||
|
||
— and `/proc/schinit` is byte-identical across the restart, same pids, same
|
||
cookies, same start times. The shell the user was typing into never noticed.
|
||
|
||
The first attempt at I1 got this wrong in the instructive way: PID 1 published
|
||
the table and `schinitd` never read it, so the restarted supervisor started
|
||
everything a *second* time. Two console shells on one terminal, which is the
|
||
split-brain this plan rejects the two-init scheme for, reached by a different
|
||
road. Publishing state is only half of it; the other half is coming back and
|
||
believing it.
|
||
|
||
|
||
| | |
|
||
|---|---|
|
||
| **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.
|