SchOS/cmd/schinit/main.c
Nexoniarz c80b7bf3b2 init: PID 1 becomes a role -- schinit splits, schinitd supervises
docs/plan/init.md milestones I0 and I1.

**The split.** schinit is PID 1 and does four things: bootstrap the
filesystems, fork schinitd, fork jobs on schinitd's behalf, reap children. No
manifest parsing, no filesystem walking after boot, no allocation. It is the
one program on the machine with no supervisor, so it is written to be too
boring to fail.

schinitd is PID 2 and holds everything else -- /etc/schinit, respawn policy,
throttling -- and is restartable, which is where the bugs are allowed to be.

**Jobs are forked by PID 1, not by schinitd.** That is the load-bearing
detail. schinitd decides and asks; PID 1 acts. So every job is a child of PID
1 at all times, and a supervisor that dies orphans nothing, reparents nothing
and restarts nothing.

**/proc/schinit** is the job table, written by PID 1 whenever it changes. The
kernel holds the text and does not interpret it: a procfs generator runs in
the kernel, and walking PID 1's memory to answer a read would put a userspace
program's format into the kernel, where it would rot.

Verified the way the plan asks for -- `kill 3`, then look:

    schinitd: adopting os.schos.console-shell
    schinitd: adopting os.schos.tty2-shell

/proc/schinit byte-identical across the restart, same pids, same cookies, same
start times, and the shell being typed into never noticed.

The first attempt got I1 wrong in the instructive way: PID 1 published the
table and schinitd never read it back, 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 half of it; coming back and believing it is the
other half.

The protocol is include/schinit.h: fixed-size messages, versioned in the
hello, entirely internal. Fixed-size so PID 1 never allocates, and a
supervisor that has gone mad cannot make it run a parser.

Still to come: I2 (heartbeat both ways, so a hang is as detectable as a death)
and I3 (proc_reincarnate, the actual law change).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 13:57:27 +02:00

521 lines
17 KiB
C

// SPDX-License-Identifier: Apache-2.0
//
// schinit -- PID 1.
//
// This program used to be the whole init system: it parsed manifests, walked
// /etc/schinit, decided respawn policy, and supervised every job. All of that
// now lives in schinitd (PID 2), and what is left here is deliberately, almost
// aggressively small.
//
// The reason is docs/plan/init.md, and it is worth restating because the
// smallness looks like an accident otherwise:
//
// **PID 1 is the one program on the machine with no supervisor.** Nothing
// restarts it, nothing notices it hang, and until proc_reincarnate() lands
// its death is a panic. A program in that position should not contain a
// parser, should not walk a filesystem, and should not allocate. So it does
// none of those things. Its entire job is to be too boring to fail.
//
// What it does:
//
// - bootstrap the filesystems, because something must and it happens first
// - fork schinitd and restart it if it dies
// - fork jobs *on schinitd's behalf*, so every job is a child of PID 1
// - reap children and say so
//
// That third one is the load-bearing one. schinitd decides and asks; PID 1
// acts. So schinitd crashing orphans nothing, reparents nothing and restarts
// nothing -- it comes back, reads /proc/schinit, and finds the world exactly
// as it left it. A supervisor that forked its own jobs could not have that.
#include "types.h"
#include "user.h"
#include "fcntl.h"
#include "posix.h"
#include "socket.h"
#include "schinit.h"
#define SUPERVISOR "/bin/schinitd"
// Jobs PID 1 is holding for schinitd. This is a table of pids and labels, not
// a table of policy: PID 1 does not know what a job *is*, only that it forked
// one and what to call it in /proc/schinit.
#define MAXJOBS 32
struct child {
int pid; // 0 = free slot
unsigned int cookie; // schinitd's handle for it
char label[SCHINIT_MAXLABEL];
int started; // uptime() ticks
};
static struct child children[MAXJOBS];
static int sock = -1; // to schinitd
static int supervisor_pid;
static unsigned int generation = 1; // bumped by proc_reincarnate() later
static int
slen(const char *s)
{
int n = 0;
while (s[n]) n++;
return n;
}
static void
scopy(char *dst, const char *src, int cap)
{
int i = 0;
while (src[i] && i < cap - 1) { dst[i] = src[i]; i++; }
dst[i] = '\0';
}
static int
seq(const char *a, const char *b)
{
while (*a && *a == *b) { a++; b++; }
return *a == *b;
}
// Case-insensitive, so a manifest may say KeepAlive, keepalive or KEEPALIVE.
// Keys are for humans and this is not a language.
static int
sieq(const char *a, const char *b)
{
for (;;) {
char ca = *a, cb = *b;
if (ca >= 'A' && ca <= 'Z') ca += 32;
if (cb >= 'A' && cb <= 'Z') cb += 32;
if (ca != cb) return 0;
if (!ca) return 1;
a++; b++;
}
}
static int
ends_with(const char *s, const char *suffix)
{
int ls = slen(s), lf = slen(suffix);
if (lf > ls) return 0;
return seq(s + ls - lf, suffix);
}
static void
out(const char *s)
{
write(1, (void *)s, slen(s));
}
static void
outn(int v)
{
char buf[12];
int i = 11;
int neg = v < 0;
if (neg) v = -v;
buf[i--] = '\0';
if (v == 0) buf[i--] = '0';
while (v > 0) { buf[i--] = (char)('0' + v % 10); v /= 10; }
if (neg) buf[i--] = '-';
out(&buf[i + 1]);
}
// ---------------------------------------------------------------------------
// Jobs
// ---------------------------------------------------------------------------
// Two formatters, because publish() needs them and pulling in a printf would
// put a format-string parser inside PID 1.
static int
fmt_str(char *dst, const char *s)
{
int n = 0;
while (s[n]) { dst[n] = s[n]; n++; }
return n;
}
static int
fmt_num(char *dst, int v)
{
char tmp[12];
int i = 0, n = 0;
if (v < 0) { dst[n++] = '-'; v = -v; }
if (v == 0) { dst[n++] = '0'; return n; }
while (v > 0) { tmp[i++] = '0' + (v % 10); v /= 10; }
while (i-- > 0) dst[n++] = tmp[i];
return n;
}
static void
bootstrap(void)
{
// /dev before the console, because the console now lives in it.
//
// The old order was the other way round -- open a console first so every
// message below had somewhere to go -- and the fallback below is what keeps
// that guarantee. If the mount fails there is no /dev/console and no way to
// say so, so the original path stays: a node called `console` in the root
// directory, which is where this used to put one.
//
// /dev is a filesystem now, not a pile of nodes. Every major and minor it
// contains is the kernel's -- devsw[] in kernel/dev/cdev.c is what they
// dispatch on -- and this program used to hold a second copy of that
// mapping with nothing checking the two agreed.
//
// It was also slow in a way that only showed on a fresh image: each mknod
// was one SchFS transaction, and the twenty of them cost 2600 ms of the
// first boot before this program printed its first line. On a second boot
// the nodes already existed and the same code took 0 ms, so the cost was
// invisible to anyone not building images -- which is to say, invisible to
// everyone except whoever works on this.
mkdir("/dev");
mount("dev", "/dev", "devtmpfs", 0, 0);
if (open("/dev/console", O_RDWR) < 0) {
// No devtmpfs. Fall back to a real node on the root filesystem, which
// is what every boot did before this.
if (open("console", O_RDWR) < 0) {
mknod("console", 1, 1);
open("console", O_RDWR);
}
}
dup(0);
dup(0);
// /proc is a real filesystem (kernel/fs/procfs.c), not a handful of nodes:
// it has to be, because one directory per process cannot be created ahead
// of time. That is what makes ps and top work.
//
// The thirteen mknod() calls that used to follow this mount are gone. They
// ran *after* it, so every one of them was resolving a path into a
// read-only synthetic filesystem and failing -- dead weight kept as a
// fallback for a mount that has not failed in a long time, exactly as the
// note in procfs.c said it was.
mkdir("/proc");
mount("proc", "/proc", "proc", 0, 0);
// Mount points, and nothing mounts on them yet. They are created by the
// image builder (see sysroot-install), so these are a fallback for an image
// that does not have them -- a mkdir that fails because the directory is
// already there costs a path walk and no writes, where creating one costs a
// whole SchFS transaction.
mkdir("/sys");
mkdir("/run");
}
// ---------------------------------------------------------------------------
// The job table, for /proc/schinit
// ---------------------------------------------------------------------------
// Publish what PID 1 is holding.
//
// docs/plan/init.md part 2: the state lives outside whichever process is
// supervising, so a restarted supervisor can read the world back instead of
// rebuilding it. It is written by PID 1 because PID 1 is the process that
// cannot lose it.
//
// One line per running job: cookie, pid, start tick, label. Written whenever
// the table changes rather than on read, because a procfs generator runs in
// the kernel and the kernel has no business walking this program's memory.
static void
publish(void)
{
char buf[MAXJOBS * (SCHINIT_MAXLABEL + 40) + 64];
int n = 0;
// Hand-rolled rather than snprintf, for the same reason as everything else
// here: no allocation, no format parser, no libc surprise in PID 1.
n += fmt_str(buf + n, "generation ");
n += fmt_num(buf + n, (int)generation);
n += fmt_str(buf + n, "\nsupervisor ");
n += fmt_num(buf + n, supervisor_pid);
n += fmt_str(buf + n, "\n");
for (int i = 0; i < MAXJOBS; i++) {
if (!children[i].pid)
continue;
n += fmt_num(buf + n, (int)children[i].cookie);
n += fmt_str(buf + n, " ");
n += fmt_num(buf + n, children[i].pid);
n += fmt_str(buf + n, " ");
n += fmt_num(buf + n, children[i].started);
n += fmt_str(buf + n, " ");
n += fmt_str(buf + n, children[i].label);
n += fmt_str(buf + n, "\n");
}
int fd = open("/proc/schinit", O_WRONLY);
if (fd < 0)
return; // no procfs yet, or it is read-only
write(fd, buf, n);
close(fd);
}
// ---------------------------------------------------------------------------
// Spawning, on request
// ---------------------------------------------------------------------------
static void
redirect(const char *path, int fd, int flags)
{
if (!path || !path[0])
return;
close(fd);
if (open((char *)path, flags) != fd) {
// Could not put it on the right descriptor. Say nothing -- this runs
// in the child, between fork and exec, and the console may be exactly
// the thing that failed to open.
}
}
// Fork and exec one job. Returns the pid, or a negative number.
//
// Everything this needs arrives in the message. It does not read a file, does
// not look at a manifest, and does not consult any policy: schinitd worked all
// of that out and sent the answer.
static int
spawn(struct schinit_spawn *s)
{
// Rebuild argv and envp from the flat buffer. Pointers cannot cross a
// process boundary, so the message carries offsets and they are turned
// back into pointers here, on this stack, where they are valid.
char *argv[SCHINIT_MAXARGS + 1];
char *envp[SCHINIT_MAXENVV + 1];
unsigned int na = s->nargs, ne = s->nenvs;
if (na > SCHINIT_MAXARGS) na = SCHINIT_MAXARGS;
if (ne > SCHINIT_MAXENVV) ne = SCHINIT_MAXENVV;
// A hostile or broken offset must not walk off the buffer. The strings are
// NUL-terminated inside strbuf and the last byte is forced to NUL below,
// so a bad offset yields a short string rather than a read past the end.
s->strbuf[SCHINIT_STRBUF - 1] = 0;
s->program[SCHINIT_MAXPATH - 1] = 0;
s->label[SCHINIT_MAXLABEL - 1] = 0;
for (unsigned int i = 0; i < na; i++) {
unsigned short off = s->args[i];
argv[i] = (off < SCHINIT_STRBUF) ? &s->strbuf[off] : (char *)"";
}
argv[na] = 0;
for (unsigned int i = 0; i < ne; i++) {
unsigned short off = s->envs[i];
envp[i] = (off < SCHINIT_STRBUF) ? &s->strbuf[off] : (char *)"";
}
envp[ne] = 0;
if (!argv[0])
argv[0] = s->program;
int pid = fork();
if (pid < 0)
return -1;
if (pid == 0) {
// A session of its own, so terminal input reaches it. A session with
// no controlling terminal is not delivered terminal input at all --
// without this the shell echoes and executes nothing.
setsid();
redirect(s->stdin_path, 0, O_RDWR);
redirect(s->stdout_path, 1, O_RDWR);
redirect(s->stderr_path, 2, O_RDWR);
if (s->own_tty)
ioctl(0, TIOCSCTTY, 0);
// The socket to schinitd is PID 1's and nobody else's.
close(sock);
execve(s->program, argv, ne ? envp : 0);
out("schinit: cannot exec ");
out(s->program);
out("\n");
exit();
}
return pid;
}
// ---------------------------------------------------------------------------
// The supervisor
// ---------------------------------------------------------------------------
static int
start_supervisor(void)
{
int sv[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, sv) < 0) {
out("schinit: socketpair failed\n");
return -1;
}
int pid = fork();
if (pid < 0) {
close(sv[0]);
close(sv[1]);
return -1;
}
if (pid == 0) {
close(sv[0]);
// A fixed descriptor rather than an argument: schinitd is exec'd, so
// there is nowhere to pass a number, and a well-known one is one less
// thing for the two of them to disagree about.
if (sv[1] != SCHINIT_SOCK_FD) {
close(SCHINIT_SOCK_FD);
dup2(sv[1], SCHINIT_SOCK_FD);
close(sv[1]);
}
char *argv[] = { (char *)"schinitd", 0 };
execve((char *)SUPERVISOR, argv, 0);
out("schinit: cannot exec " SUPERVISOR "\n");
exit();
}
close(sv[1]);
sock = sv[0];
supervisor_pid = pid;
return pid;
}
static void
send_reply(unsigned int op, unsigned int cookie, int pid, int status)
{
struct schinit_reply r;
r.op = op;
r.cookie = cookie;
r.pid = pid;
r.status = status;
// A supervisor that has stopped reading must not wedge PID 1. The socket
// buffer is far larger than anything this sends in a burst, and if the
// write fails the supervisor is already lost -- it will be restarted, and
// it will read /proc/schinit rather than this message.
write(sock, &r, sizeof(r));
}
// One message from schinitd. Returns 0 if the socket has closed.
static int
serve(void)
{
union schinit_msg m;
int n = read(sock, &m, sizeof(m));
if (n <= 0)
return 0; // supervisor gone
switch (m.op) {
case SCHINIT_HELLO: {
struct schinit_hello h;
h.op = SCHINIT_WELCOME;
h.proto = SCHINIT_PROTO;
h.generation = generation;
h.reserved = 0;
write(sock, &h, sizeof(h));
break;
}
case SCHINIT_SPAWN: {
int pid = spawn(&m.spawn);
if (pid > 0) {
for (int i = 0; i < MAXJOBS; i++) {
if (children[i].pid)
continue;
children[i].pid = pid;
children[i].cookie = m.spawn.cookie;
children[i].started = uptime();
scopy(children[i].label, m.spawn.label, SCHINIT_MAXLABEL);
break;
}
publish();
}
send_reply(SCHINIT_SPAWNED, m.spawn.cookie, pid, 0);
break;
}
default:
// An op PID 1 does not know. Ignore it rather than guess: this is the
// only process that cannot afford to act on a message it misread.
break;
}
return 1;
}
// ---------------------------------------------------------------------------
int
main(void)
{
bootstrap();
out("schinit: SchOS process 1\n");
if (start_supervisor() < 0) {
// No supervisor. Rather than idle usefully-looking, fall back to a
// shell so the machine is still usable and the failure is visible.
out("schinit: no supervisor; falling back to a shell\n");
if (fork() == 0) {
setsid();
char *argv[] = { (char *)"sh", 0 };
execve((char *)"/bin/sh", argv, 0);
exit();
}
}
publish();
out("schinit: ready (pid 1)\n");
for (;;) {
// Two things can happen -- a request arrives, or a child exits -- and
// both of them block. poll() with a timeout plus WNOHANG turns that
// into one loop with no signal handler, which is the simplest thing
// that works and therefore the right thing for this program.
struct pollfd pf;
pf.fd = sock;
pf.events = POLLIN;
pf.revents = 0;
if (sock >= 0 && poll(&pf, 1, 200) > 0 && (pf.revents & POLLIN)) {
if (!serve()) {
close(sock);
sock = -1;
}
} else if (sock < 0) {
sleep(10);
}
int status;
int pid;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
if (pid == supervisor_pid) {
out("schinit: supervisor died; restarting\n");
if (sock >= 0) { close(sock); sock = -1; }
supervisor_pid = 0;
// The jobs are untouched. They are children of this process,
// not of the supervisor, so nothing was orphaned and nothing
// needs restarting -- which is the entire reason jobs are
// forked here. The new supervisor reads /proc/schinit and
// picks up where the old one stopped.
sleep(100); // one second, so a crash loop is slow
start_supervisor();
publish();
continue;
}
for (int i = 0; i < MAXJOBS; i++) {
if (children[i].pid != pid)
continue;
unsigned int cookie = children[i].cookie;
children[i].pid = 0;
children[i].label[0] = 0;
publish();
if (sock >= 0)
send_reply(SCHINIT_EXITED, cookie, pid, status);
break;
}
// A pid in no slot is an orphan the kernel reparented here. It has
// been reaped, which was the whole obligation.
}
}
}