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
This commit is contained in:
Nexoniarz 2026-09-13 13:57:27 +02:00
commit c80b7bf3b2
9 changed files with 1095 additions and 416 deletions

View file

@ -1,38 +1,58 @@
// SPDX-License-Identifier: Apache-2.0
//
// schinit — SchOS process 1.
// schinit -- PID 1.
//
// Shaped after launchd rather than sysvinit, and the reason is not the config
// format. It is *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.
// 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.
//
// This is milestone 1: manifests, RunAtLoad, KeepAlive with backoff, and
// reaping. Sockets are parsed and remembered but not yet held; that is
// milestone 2, and the manifest format already has a place to say it.
// The reason is docs/plan/init.md, and it is worth restating because the
// smallness looks like an accident otherwise:
//
// Constraints worth knowing before editing. This program 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 below are constants rather than growable.
// **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" // TIOCSCTTY, TIOCSPGRP
#include "posix.h"
#include "socket.h"
#include "schinit.h"
#define MAXJOBS 32
#define MAXARGS 16
#define MAXLABEL 64
#define MAXPATH 128
#define MAXLINE 256
#define JOBDIR "/etc/schinit"
#define SUPERVISOR "/bin/schinitd"
// ---------------------------------------------------------------------------
// Small string helpers. No libc.
// ---------------------------------------------------------------------------
// 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)
@ -104,341 +124,28 @@ outn(int v)
// Jobs
// ---------------------------------------------------------------------------
#define MAXENVV 16 // Environment lines honoured per job
struct job {
char label[MAXLABEL];
char program[MAXPATH];
char *argv[MAXARGS + 1];
char argstore[MAXLINE];
// Environment, one "KEY=VALUE" per Environment line, repeatable. Stored
// as pointers into envstore so the job owns its own strings -- the parse
// buffer is gone by the time anything execs.
char *envp[MAXENVV + 1];
char envstore[MAXLINE * 2];
int envlen;
int envn;
char stdin_path[MAXPATH];
char stdout_path[MAXPATH];
char stderr_path[MAXPATH];
int run_at_load;
int keep_alive;
int disabled;
int throttle; // seconds between respawns
int own_tty; // claim the console as controlling terminal
// A socket the job wants schinit to hold. Parsed now, honoured in
// milestone 2 -- recorded here so the manifest format does not have to
// change when it is.
char sock_path[MAXPATH];
int pid; // 0 when not running
int last_start; // uptime() ticks, for throttling
int starts;
};
static struct job jobs[MAXJOBS];
static int njobs;
static void
job_defaults(struct job *j)
{
j->label[0] = j->program[0] = '\0';
j->stdin_path[0] = j->stdout_path[0] = j->stderr_path[0] = '\0';
j->sock_path[0] = '\0';
j->argstore[0] = '\0';
j->argv[0] = 0;
j->run_at_load = 0;
j->keep_alive = 0;
j->disabled = 0;
j->throttle = 1;
j->own_tty = 0;
j->pid = 0;
j->last_start = 0;
j->starts = 0;
}
// Two formatters, because publish() needs them and pulling in a printf would
// put a format-string parser inside PID 1.
static int
truthy(const char *v)
fmt_str(char *dst, const char *s)
{
return sieq(v, "yes") || sieq(v, "true") || sieq(v, "1") || sieq(v, "on");
}
static int
parse_int(const char *s)
{
int v = 0;
while (*s >= '0' && *s <= '9') { v = v * 10 + (*s - '0'); s++; }
return v;
}
// Split Args into argv, in place within the job's own storage. argv[0] is the
// program's name as it should see it, which is conventionally the basename and
// not the full path -- so a manifest says `Args = sh` for `/bin/sh`.
static void
split_args(struct job *j, const char *value)
{
scopy(j->argstore, value, MAXLINE);
int n = 0;
char *p = j->argstore;
while (*p && n < MAXARGS) {
while (*p == ' ' || *p == '\t') p++;
if (!*p) break;
j->argv[n++] = p;
while (*p && *p != ' ' && *p != '\t') p++;
if (*p) *p++ = '\0';
}
j->argv[n] = 0;
}
// ---------------------------------------------------------------------------
// Manifest parsing
//
// Sectioned key = value. Comments start with #. A [Socket name] section marks
// everything after it as belonging to that socket, which is why Path is read
// into sock_path rather than treated as a job key.
// ---------------------------------------------------------------------------
// One "KEY=VALUE" onto the job's environment. Repeatable, so a manifest reads
//
// Environment = TERM=linux
// Environment = PATH=/bin:/usr/bin
//
// rather than inventing a separator that a value might contain.
static void
add_env(struct job *j, const char *kv)
{
if (j->envn >= MAXENVV)
return;
int n = slen(kv);
if (j->envlen + n + 1 > (int)sizeof(j->envstore))
return;
char *dst = j->envstore + j->envlen;
scopy(dst, kv, n + 1);
j->envp[j->envn++] = dst;
j->envp[j->envn] = 0;
j->envlen += n + 1;
}
static char *
trim(char *s)
{
while (*s == ' ' || *s == '\t') s++;
int n = slen(s);
while (n > 0 && (s[n-1] == ' ' || s[n-1] == '\t' ||
s[n-1] == '\r' || s[n-1] == '\n'))
s[--n] = '\0';
return s;
while (s[n]) { dst[n] = s[n]; n++; }
return n;
}
static int
load_job(const char *path)
fmt_num(char *dst, int v)
{
if (njobs >= MAXJOBS) {
out("schinit: too many jobs, ignoring "); out(path); out("\n");
return -1;
}
int fd = open((char *)path, O_RDONLY);
if (fd < 0) return -1;
// Whole file into one static buffer. A job manifest that does not fit in
// 4 KB is a manifest doing something it should not.
static char buf[4096];
int n = read(fd, buf, sizeof(buf) - 1);
close(fd);
if (n <= 0) return -1;
buf[n] = '\0';
struct job *j = &jobs[njobs];
job_defaults(j);
int in_socket = 0;
char *p = buf;
while (*p) {
char *line = p;
while (*p && *p != '\n') p++;
if (*p) *p++ = '\0';
// Strip comments before trimming, so trailing comments work too.
for (char *c = line; *c; c++)
if (*c == '#') { *c = '\0'; break; }
line = trim(line);
if (!*line) continue;
if (line[0] == '[') {
// [Socket name] -- the only section kind for now. Anything else is
// ignored rather than fatal: an unknown section from a newer
// schinit should not stop this one booting the machine.
in_socket = (line[1] == 'S' || line[1] == 's');
continue;
}
char *eq = line;
while (*eq && *eq != '=') eq++;
if (!*eq) continue; // not key = value; skip
*eq = '\0';
char *key = trim(line);
char *val = trim(eq + 1);
if (in_socket) {
if (sieq(key, "Path")) scopy(j->sock_path, val, MAXPATH);
continue;
}
if (sieq(key, "Label")) scopy(j->label, val, MAXLABEL);
else if (sieq(key, "Program")) scopy(j->program, val, MAXPATH);
else if (sieq(key, "Args")) split_args(j, val);
else if (sieq(key, "RunAtLoad")) j->run_at_load = truthy(val);
else if (sieq(key, "KeepAlive")) j->keep_alive = truthy(val);
else if (sieq(key, "Disabled")) j->disabled = truthy(val);
else if (sieq(key, "ThrottleInterval")) j->throttle = parse_int(val);
else if (sieq(key, "ControllingTerminal")) j->own_tty = truthy(val);
else if (sieq(key, "Environment")) add_env(j, val);
else if (sieq(key, "StandardIn")) scopy(j->stdin_path, val, MAXPATH);
else if (sieq(key, "StandardOut")) scopy(j->stdout_path, val, MAXPATH);
else if (sieq(key, "StandardError")) scopy(j->stderr_path, val, MAXPATH);
// Unknown keys are ignored on purpose, same reasoning as sections.
}
if (!j->program[0]) {
out("schinit: "); out(path); out(": no Program, ignoring\n");
return -1;
}
if (!j->label[0])
scopy(j->label, j->program, MAXLABEL);
if (!j->argv[0]) {
// No Args given: pass the program path as argv[0], which is what a
// program expects when nothing better is said.
scopy(j->argstore, j->program, MAXLINE);
j->argv[0] = j->argstore;
j->argv[1] = 0;
}
if (j->throttle <= 0)
j->throttle = 1;
njobs++;
return 0;
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;
}
// Read every *.job in JOBDIR. getdents64 rather than a hardcoded list, so
// dropping a file in the directory is all it takes to add a job.
static void
load_jobs(void)
{
int fd = open(JOBDIR, O_RDONLY);
if (fd < 0) {
out("schinit: no " JOBDIR "\n");
return;
}
static char dbuf[4096];
for (;;) {
int n = getdents64(fd, dbuf, sizeof(dbuf));
if (n <= 0) break;
int off = 0;
while (off < n) {
struct dirent64 *d = (struct dirent64 *)(dbuf + off);
// Step by d_reclen: records are packed and variable length.
if (d->d_reclen == 0) break;
off += d->d_reclen;
if (d->d_name[0] == '.') continue;
if (!ends_with(d->d_name, ".job")) continue;
char path[MAXPATH];
scopy(path, JOBDIR "/", MAXPATH);
int at = slen(path);
scopy(path + at, d->d_name, MAXPATH - at);
load_job(path);
}
}
close(fd);
}
// ---------------------------------------------------------------------------
// Starting and supervising
// ---------------------------------------------------------------------------
static void
redirect(const char *path, int fd, int flags)
{
if (!path[0]) return;
int f = open((char *)path, flags);
if (f < 0) return;
if (f != fd) { dup2(f, fd); close(f); }
}
static void
start_job(struct job *j)
{
int pid = fork();
if (pid < 0) {
out("schinit: fork failed for "); out(j->label); out("\n");
return;
}
if (pid == 0) {
// Its own session, so a job cannot reach back and signal the rest of
// the system through a shared controlling terminal.
setsid();
redirect(j->stdin_path, 0, O_RDWR);
redirect(j->stdout_path, 1, O_RDWR);
redirect(j->stderr_path, 2, O_RDWR);
// A job that reads the console has to *own* it, and setsid() is what
// makes that necessary: a fresh session has no controlling terminal,
// and the console only delivers input to the foreground process group
// it is tracking. Without this the shell came up, echoed every
// keystroke through the line discipline, and executed none of them --
// which looks like a hung shell and is really a terminal that is not
// listening to it.
//
// Off by default. A daemon has no business seizing the console, and
// launchd's model is that the console is a thing a job asks for.
if (j->own_tty) {
int pg = getpid(); // after setsid(), pgid == pid
ioctl(0, TIOCSCTTY, 0);
ioctl(0, TIOCSPGRP, &pg);
}
// exec.c enables the Linux ABI automatically for musl binaries. Do NOT
// call enable_linux_abi() here: it would renumber the syscalls this
// child still has to make.
execve(j->program, j->argv, j->envn ? j->envp : 0);
out("schinit: cannot exec "); out(j->program);
out(" for "); out(j->label); out("\n");
exit();
}
j->pid = pid;
j->last_start = uptime();
j->starts++;
}
static struct job *
job_by_pid(int pid)
{
for (int i = 0; i < njobs; i++)
if (jobs[i].pid == pid)
return &jobs[i];
return 0;
}
// ---------------------------------------------------------------------------
// Bootstrap: the device nodes and mounts that must exist before anything runs.
//
// Not jobs. A job needs /dev/console to have somewhere to send its output, so
// this cannot itself be expressed as one without a bootstrapping problem.
// ---------------------------------------------------------------------------
static void
bootstrap(void)
{
@ -496,87 +203,319 @@ bootstrap(void)
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)
{
if (getpid() != 1)
exit();
bootstrap();
out("schinit: SchOS process 1\n");
load_jobs();
if (njobs == 0) {
// Nothing to supervise. Rather than idle usefully-looking, say so and
// fall back to a shell -- an unbootable-looking machine because a
// manifest was missing would be a poor trade for purity.
out("schinit: no jobs found; falling back to /bin/sh\n");
struct job *j = &jobs[njobs++];
job_defaults(j);
scopy(j->label, "os.schos.fallback-shell", MAXLABEL);
scopy(j->program, "/bin/sh", MAXPATH);
split_args(j, "sh");
scopy(j->stdin_path, "/dev/console", MAXPATH);
scopy(j->stdout_path, "/dev/console", MAXPATH);
scopy(j->stderr_path, "/dev/console", MAXPATH);
j->run_at_load = 1;
j->keep_alive = 1;
j->own_tty = 1;
}
out("schinit: "); outn(njobs); out(" job");
if (njobs != 1) out("s");
out(" loaded\n");
for (int i = 0; i < njobs; i++) {
struct job *j = &jobs[i];
if (j->disabled || !j->run_at_load) continue;
out("schinit: starting "); out(j->label); out("\n");
start_job(j);
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");
// Supervision loop.
//
// wait() reaps whatever exited, including processes reparented here when
// their own parent died -- those are not jobs and job_by_pid returns 0 for
// them, which is the whole handling they need.
for (;;) {
int pid = wait();
if (pid < 0) {
// Nothing left to wait for. Should not happen while any job is
// alive; sleep rather than spin so a mistake here costs a idle
// machine and not a hot CPU.
sleep(100);
continue;
// 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);
}
struct job *j = job_by_pid(pid);
if (!j) continue; // an adopted orphan; already reaped
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;
}
j->pid = 0;
if (!j->keep_alive) {
out("schinit: "); out(j->label); out(" exited\n");
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.
}
// Throttle. A job that exits immediately and forever would otherwise
// spin the machine; launchd's ThrottleInterval exists for exactly this
// and defaults to 10 seconds. One is enough here, where the common
// case is a shell someone just exited on purpose and expects back.
int since = uptime() - j->last_start;
int ticks = j->throttle * 100; // uptime() counts 10ms ticks
if (since < ticks)
sleep(ticks - since);
start_job(j);
}
return 0;
}

607
cmd/schinitd/main.c Normal file
View file

@ -0,0 +1,607 @@
// SPDX-License-Identifier: Apache-2.0
//
// schinitd -- the supervisor. PID 2, and restartable.
//
// Everything that used to make PID 1 complicated lives here: reading
// /etc/schinit, parsing manifests, deciding what runs, respawn policy and
// throttling. docs/plan/init.md calls this "where the bugs will be", which is
// exactly why it is a separate program -- a bug here is a restart, not a panic.
//
// The one rule that makes the split worth having:
//
// **This program never forks a job.** It decides, and asks PID 1 to act. So
// every job on the machine is a child of PID 1 at all times, and this
// process dying 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 children could not be restarted without
// either losing them to reparenting or racing the new copy to adopt them --
// the split-brain failure docs/plan/init.md rejects the two-init scheme for.
#include "types.h"
#include "user.h"
#include "fcntl.h"
#include "posix.h"
#include "socket.h"
#include "schinit.h"
#define JOBDIR "/etc/schinit"
// Sizes. These mirror the protocol's, deliberately: a job that parses here but
// will not fit in a spawn message is a job that fails at the last moment, and
// keeping the two the same makes that impossible rather than unlikely.
#define MAXJOBS 32
#define MAXARGS SCHINIT_MAXARGS
#define MAXENVV SCHINIT_MAXENVV
#define MAXLABEL SCHINIT_MAXLABEL
#define MAXPATH SCHINIT_MAXPATH
#define MAXLINE 256
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
// ---------------------------------------------------------------------------
struct job {
char label[MAXLABEL];
char program[MAXPATH];
char *argv[MAXARGS + 1];
char argstore[MAXLINE];
// Environment, one "KEY=VALUE" per Environment line, repeatable. Stored
// as pointers into envstore so the job owns its own strings -- the parse
// buffer is gone by the time anything execs.
char *envp[MAXENVV + 1];
char envstore[MAXLINE * 2];
int envlen;
int envn;
char stdin_path[MAXPATH];
char stdout_path[MAXPATH];
char stderr_path[MAXPATH];
int run_at_load;
int keep_alive;
int disabled;
int throttle; // seconds between respawns
int own_tty; // claim the console as controlling terminal
// A socket the job wants schinit to hold. Parsed now, honoured in
// milestone 2 -- recorded here so the manifest format does not have to
// change when it is.
char sock_path[MAXPATH];
int pid; // 0 when not running
unsigned int cookie; // PID 1's handle for this job while it runs
int last_start; // uptime() ticks, for throttling
int starts;
};
static struct job jobs[MAXJOBS];
static int njobs;
static void
job_defaults(struct job *j)
{
j->label[0] = j->program[0] = '\0';
j->stdin_path[0] = j->stdout_path[0] = j->stderr_path[0] = '\0';
j->sock_path[0] = '\0';
j->argstore[0] = '\0';
j->argv[0] = 0;
j->run_at_load = 0;
j->keep_alive = 0;
j->disabled = 0;
j->throttle = 1;
j->own_tty = 0;
j->pid = 0;
j->last_start = 0;
j->starts = 0;
}
static int
truthy(const char *v)
{
return sieq(v, "yes") || sieq(v, "true") || sieq(v, "1") || sieq(v, "on");
}
static int
parse_int(const char *s)
{
int v = 0;
while (*s >= '0' && *s <= '9') { v = v * 10 + (*s - '0'); s++; }
return v;
}
// Split Args into argv, in place within the job's own storage. argv[0] is the
// program's name as it should see it, which is conventionally the basename and
// not the full path -- so a manifest says `Args = sh` for `/bin/sh`.
static void
split_args(struct job *j, const char *value)
{
scopy(j->argstore, value, MAXLINE);
int n = 0;
char *p = j->argstore;
while (*p && n < MAXARGS) {
while (*p == ' ' || *p == '\t') p++;
if (!*p) break;
j->argv[n++] = p;
while (*p && *p != ' ' && *p != '\t') p++;
if (*p) *p++ = '\0';
}
j->argv[n] = 0;
}
// ---------------------------------------------------------------------------
// Manifest parsing
//
// Sectioned key = value. Comments start with #. A [Socket name] section marks
// everything after it as belonging to that socket, which is why Path is read
// into sock_path rather than treated as a job key.
// ---------------------------------------------------------------------------
// One "KEY=VALUE" onto the job's environment. Repeatable, so a manifest reads
//
// Environment = TERM=linux
// Environment = PATH=/bin:/usr/bin
//
// rather than inventing a separator that a value might contain.
static void
add_env(struct job *j, const char *kv)
{
if (j->envn >= MAXENVV)
return;
int n = slen(kv);
if (j->envlen + n + 1 > (int)sizeof(j->envstore))
return;
char *dst = j->envstore + j->envlen;
scopy(dst, kv, n + 1);
j->envp[j->envn++] = dst;
j->envp[j->envn] = 0;
j->envlen += n + 1;
}
static char *
trim(char *s)
{
while (*s == ' ' || *s == '\t') s++;
int n = slen(s);
while (n > 0 && (s[n-1] == ' ' || s[n-1] == '\t' ||
s[n-1] == '\r' || s[n-1] == '\n'))
s[--n] = '\0';
return s;
}
static int
load_job(const char *path)
{
if (njobs >= MAXJOBS) {
out("schinit: too many jobs, ignoring "); out(path); out("\n");
return -1;
}
int fd = open((char *)path, O_RDONLY);
if (fd < 0) return -1;
// Whole file into one static buffer. A job manifest that does not fit in
// 4 KB is a manifest doing something it should not.
static char buf[4096];
int n = read(fd, buf, sizeof(buf) - 1);
close(fd);
if (n <= 0) return -1;
buf[n] = '\0';
struct job *j = &jobs[njobs];
job_defaults(j);
int in_socket = 0;
char *p = buf;
while (*p) {
char *line = p;
while (*p && *p != '\n') p++;
if (*p) *p++ = '\0';
// Strip comments before trimming, so trailing comments work too.
for (char *c = line; *c; c++)
if (*c == '#') { *c = '\0'; break; }
line = trim(line);
if (!*line) continue;
if (line[0] == '[') {
// [Socket name] -- the only section kind for now. Anything else is
// ignored rather than fatal: an unknown section from a newer
// schinit should not stop this one booting the machine.
in_socket = (line[1] == 'S' || line[1] == 's');
continue;
}
char *eq = line;
while (*eq && *eq != '=') eq++;
if (!*eq) continue; // not key = value; skip
*eq = '\0';
char *key = trim(line);
char *val = trim(eq + 1);
if (in_socket) {
if (sieq(key, "Path")) scopy(j->sock_path, val, MAXPATH);
continue;
}
if (sieq(key, "Label")) scopy(j->label, val, MAXLABEL);
else if (sieq(key, "Program")) scopy(j->program, val, MAXPATH);
else if (sieq(key, "Args")) split_args(j, val);
else if (sieq(key, "RunAtLoad")) j->run_at_load = truthy(val);
else if (sieq(key, "KeepAlive")) j->keep_alive = truthy(val);
else if (sieq(key, "Disabled")) j->disabled = truthy(val);
else if (sieq(key, "ThrottleInterval")) j->throttle = parse_int(val);
else if (sieq(key, "ControllingTerminal")) j->own_tty = truthy(val);
else if (sieq(key, "Environment")) add_env(j, val);
else if (sieq(key, "StandardIn")) scopy(j->stdin_path, val, MAXPATH);
else if (sieq(key, "StandardOut")) scopy(j->stdout_path, val, MAXPATH);
else if (sieq(key, "StandardError")) scopy(j->stderr_path, val, MAXPATH);
// Unknown keys are ignored on purpose, same reasoning as sections.
}
if (!j->program[0]) {
out("schinit: "); out(path); out(": no Program, ignoring\n");
return -1;
}
if (!j->label[0])
scopy(j->label, j->program, MAXLABEL);
if (!j->argv[0]) {
// No Args given: pass the program path as argv[0], which is what a
// program expects when nothing better is said.
scopy(j->argstore, j->program, MAXLINE);
j->argv[0] = j->argstore;
j->argv[1] = 0;
}
if (j->throttle <= 0)
j->throttle = 1;
njobs++;
return 0;
}
// Read every *.job in JOBDIR. getdents64 rather than a hardcoded list, so
// dropping a file in the directory is all it takes to add a job.
static void
load_jobs(void)
{
int fd = open(JOBDIR, O_RDONLY);
if (fd < 0) {
out("schinit: no " JOBDIR "\n");
return;
}
static char dbuf[4096];
for (;;) {
int n = getdents64(fd, dbuf, sizeof(dbuf));
if (n <= 0) break;
int off = 0;
while (off < n) {
struct dirent64 *d = (struct dirent64 *)(dbuf + off);
// Step by d_reclen: records are packed and variable length.
if (d->d_reclen == 0) break;
off += d->d_reclen;
if (d->d_name[0] == '.') continue;
if (!ends_with(d->d_name, ".job")) continue;
char path[MAXPATH];
scopy(path, JOBDIR "/", MAXPATH);
int at = slen(path);
scopy(path + at, d->d_name, MAXPATH - at);
load_job(path);
}
}
close(fd);
}
// ---------------------------------------------------------------------------
// Talking to PID 1
// ---------------------------------------------------------------------------
static int sock = SCHINIT_SOCK_FD;
// Flatten a job into a spawn request. Strings are copied into one buffer and
// referred to by offset, because pointers do not survive the trip.
static int
pack(struct schinit_spawn *s, struct job *j, unsigned int cookie)
{
int used = 0;
for (unsigned int z = 0; z < sizeof(*s); z++) ((char *)s)[z] = 0;
s->op = SCHINIT_SPAWN;
s->cookie = cookie;
scopy(s->label, j->label, SCHINIT_MAXLABEL);
scopy(s->program, j->program, SCHINIT_MAXPATH);
scopy(s->stdin_path, j->stdin_path, SCHINIT_MAXPATH);
scopy(s->stdout_path, j->stdout_path, SCHINIT_MAXPATH);
scopy(s->stderr_path, j->stderr_path, SCHINIT_MAXPATH);
s->own_tty = j->own_tty;
for (int i = 0; i < MAXARGS && j->argv[i]; i++) {
int n = slen(j->argv[i]) + 1;
if (used + n > SCHINIT_STRBUF)
return -1; // too much text; refuse rather than trim
s->args[s->nargs++] = (unsigned short)used;
scopy(&s->strbuf[used], j->argv[i], n);
used += n;
}
for (int i = 0; i < j->envn; i++) {
int n = slen(j->envp[i]) + 1;
if (used + n > SCHINIT_STRBUF)
return -1;
s->envs[s->nenvs++] = (unsigned short)used;
scopy(&s->strbuf[used], j->envp[i], n);
used += n;
}
return 0;
}
// Ask PID 1 to start a job. The reply may be preceded by exit notifications
// for other jobs, so this reads until it sees the answer to *this* cookie and
// handles anything else on the way past.
static void note_exit(unsigned int cookie, int pid, int status);
static unsigned int next_cookie = 1;
static int
ask_spawn(struct job *j)
{
struct schinit_spawn s;
unsigned int cookie = next_cookie++;
if (pack(&s, j, cookie) < 0) {
out("schinitd: "); out(j->label); out(": job too large to send\n");
return -1;
}
if (write(sock, &s, sizeof(s)) != sizeof(s))
return -1;
for (;;) {
struct schinit_reply r;
if (read(sock, &r, sizeof(r)) != sizeof(r))
return -1; // PID 1 is gone, which cannot happen
if (r.op == SCHINIT_SPAWNED && r.cookie == cookie) {
if (r.pid > 0) {
j->pid = r.pid;
j->cookie = cookie;
j->last_start = uptime();
j->starts++;
}
return r.pid;
}
if (r.op == SCHINIT_EXITED)
note_exit(r.cookie, r.pid, r.status);
}
}
static struct job *
job_by_cookie(unsigned int cookie)
{
for (int i = 0; i < njobs; i++)
if (jobs[i].cookie == cookie && jobs[i].pid)
return &jobs[i];
return 0;
}
static void
note_exit(unsigned int cookie, int pid, int status)
{
(void)status;
struct job *j = job_by_cookie(cookie);
if (!j)
return; // not ours, or already noticed
j->pid = 0;
j->cookie = 0;
if (!j->keep_alive)
return;
// Throttle. A job that exits immediately and forever would otherwise spin
// the machine; launchd's ThrottleInterval exists for exactly this.
int ticks = j->throttle * 100;
int since = uptime() - j->last_start;
if (since < ticks)
sleep(ticks - since);
out("schinitd: restarting "); out(j->label); out("\n");
ask_spawn(j);
}
// ---------------------------------------------------------------------------
// Adopting what is already running
// ---------------------------------------------------------------------------
// Read /proc/schinit and claim the jobs PID 1 is already holding.
//
// This is the half of docs/plan/init.md part 2 that makes the other half worth
// anything. PID 1 publishes the table; without this, a restarted supervisor
// reads its manifests, sees nothing running *as far as it knows*, and starts
// everything a second time. The console shell then exists twice, both reading
// the same terminal, and every keystroke goes to whichever woke first -- the
// split-brain failure the plan rejects the two-init scheme for, arrived at by
// a different route.
//
// The state is outside the process precisely so that coming back is a *read*
// rather than a guess.
static unsigned int
adopt(void)
{
char buf[2048];
int fd = open("/proc/schinit", O_RDONLY);
unsigned int highest = 0;
if (fd < 0)
return 0;
int n = read(fd, buf, sizeof(buf) - 1);
close(fd);
if (n <= 0)
return 0;
buf[n] = 0;
// Lines are "cookie pid started label". The two header lines do not match
// that shape and fall out of the parse on their own.
char *p = buf;
while (*p) {
char *line = p;
while (*p && *p != '\n') p++;
if (*p == '\n') *p++ = 0;
unsigned int cookie = 0;
int pid = 0, started = 0, f = 0;
char *q = line;
// cookie
if (*q < '0' || *q > '9') continue;
while (*q >= '0' && *q <= '9') { cookie = cookie * 10 + (*q++ - '0'); f++; }
while (*q == ' ') q++;
while (*q >= '0' && *q <= '9') { pid = pid * 10 + (*q++ - '0'); }
while (*q == ' ') q++;
while (*q >= '0' && *q <= '9') { started = started * 10 + (*q++ - '0'); }
while (*q == ' ') q++;
if (!f || !pid || !*q) continue;
for (int i = 0; i < njobs; i++) {
if (!seq(jobs[i].label, q))
continue;
jobs[i].pid = pid;
jobs[i].cookie = cookie;
jobs[i].last_start = started;
out("schinitd: adopting "); out(jobs[i].label); out("\n");
break;
}
if (cookie > highest)
highest = cookie;
}
return highest;
}
// ---------------------------------------------------------------------------
int
main(void)
{
struct schinit_hello h;
// Version handshake before anything else. A mismatch is a refusal rather
// than a best-effort interpretation -- see the header for why.
h.op = SCHINIT_HELLO;
h.proto = SCHINIT_PROTO;
h.generation = 0;
h.reserved = 0;
if (write(sock, &h, sizeof(h)) != sizeof(h)) {
out("schinitd: no channel to pid 1\n");
exit();
}
if (read(sock, &h, sizeof(h)) != sizeof(h) || h.op != SCHINIT_WELCOME) {
out("schinitd: pid 1 did not answer\n");
exit();
}
if (h.proto != SCHINIT_PROTO) {
out("schinitd: protocol mismatch with pid 1\n");
exit();
}
load_jobs();
out("schinitd: "); outn(njobs); out(" job(s) loaded, generation ");
outn((int)h.generation); out("\n");
// Anything PID 1 is already holding is ours again, not something to start.
unsigned int highest = adopt();
next_cookie = highest + 1;
for (int i = 0; i < njobs; i++) {
struct job *j = &jobs[i];
if (j->disabled || !j->run_at_load)
continue;
if (j->pid)
continue; // adopted; it never stopped running
out("schinitd: starting "); out(j->label); out("\n");
ask_spawn(j);
}
out("schinitd: ready\n");
// Everything from here is PID 1 telling us a child exited. There is no
// wait() in this program and there cannot be: the children are not ours.
for (;;) {
struct schinit_reply r;
int n = read(sock, &r, sizeof(r));
if (n != sizeof(r)) {
// PID 1 closed the channel. It is the only process that cannot
// have gone away, so this means we are being replaced.
out("schinitd: channel closed\n");
exit();
}
if (r.op == SCHINIT_EXITED)
note_exit(r.cookie, r.pid, r.status);
}
}