schinit: a launchd-shaped process 1

Milestone 1 of three: manifests, RunAtLoad, KeepAlive with throttling,
and reaping. Replaces the hardcoded fork-a-shell loop in cmd/init, which
stays in the tree for now.

Jobs are sectioned key=value files in /etc/schinit/*.job, kept in the
repo under etc/ and installed into the sysroot -- sysroot/ is generated
and gitignored, so anything written only there is lost on a fresh clone,
which is already true of /etc/passwd and should not become true of the
boot configuration. Unknown keys and sections are ignored rather than
fatal: a manifest written for a newer schinit must not stop this one
booting the machine.

Socket sections are parsed and recorded but not yet held. That is
milestone 2 and it is the whole reason for copying launchd rather than
sysvinit -- the supervisor holds the listener and starts the job on first
connect, which inverts the dependency graph and makes runlevels a
non-concept. The key exists now so manifests need not change later.

getdents64 is exposed to userland for this. The kernel has had it since
the Linux ABI landed, but nothing declared it in user.h or lib/usys.S, so
first-party programs had no way to list a directory at all.

Two things found by testing rather than reasoning:

  - setsid() gives a job its own session, and a session has no
    controlling terminal. The console then delivers input only to the
    foreground process group it tracks, so the shell came up, echoed
    every keystroke through the line discipline, and executed none of
    them -- indistinguishable from a hung shell. ControllingTerminal=yes
    claims it with TIOCSCTTY/TIOCSPGRP; off by default, since a daemon
    has no business seizing the console.
  - the first bootstrap() dropped /dev/input/event0-1 and most of /proc,
    because it was transcribed from the part of the old init that had
    been read rather than all of it. -Evdev caught it.

build/usbstick.img is built by `all` now: without it QEMU refuses the
-drive and the entire -Usb run produces no output, which reads as a
broken USB driver.

Seven suites green: base 27, Tty 51, Ps 32, Sock 64, Fb 36, Usb 31,
Evdev 29. make schfs-check passes.
This commit is contained in:
Nexoniarz 2026-08-09 16:36:13 +02:00
commit baede13847
10 changed files with 664 additions and 7 deletions

557
cmd/schinit/main.c Normal file
View file

@ -0,0 +1,557 @@
// SPDX-License-Identifier: Apache-2.0
//
// schinit — SchOS process 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 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.
//
// 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.
#include "types.h"
#include "user.h"
#include "fcntl.h"
#include "posix.h" // TIOCSCTTY, TIOCSPGRP
#define MAXJOBS 32
#define MAXARGS 16
#define MAXLABEL 64
#define MAXPATH 128
#define MAXLINE 256
#define JOBDIR "/etc/schinit"
// ---------------------------------------------------------------------------
// Small string helpers. No libc.
// ---------------------------------------------------------------------------
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];
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;
}
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.
// ---------------------------------------------------------------------------
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, "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);
}
// ---------------------------------------------------------------------------
// 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.
exec(j->program, j->argv);
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)
{
// Console first, as fd 0/1/2, so every message below has somewhere to go.
if (open("console", O_RDWR) < 0) {
mknod("console", 1, 1);
open("console", O_RDWR);
}
dup(0);
dup(0);
// 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).
mkdir("/dev");
mknod("/dev/console", 1, 1);
mknod("/dev/null", 1, 2);
mknod("/dev/keyboard", 1, 3);
mknod("/dev/zero", 1, 5);
mknod("/dev/full", 1, 7);
mknod("/dev/random", 1, 8);
mknod("/dev/urandom", 1, 9);
mknod("/dev/fb0", 2, 0);
mknod("/dev/mouse", 3, 1);
mknod("/dev/tty", 1, 1); // alias for console -- must pass TCGETS
mknod("/dev/ttyS0", 4, 64);
mknod("/dev/stdin", 1, 1);
mknod("/dev/stdout", 1, 1);
mknod("/dev/stderr", 1, 1);
// Raw input events, Linux evdev layout (kernel/dev/input.c). /dev/keyboard
// and /dev/mouse above are the old character-and-position interfaces;
// these are the ones with key releases and button transitions, which is
// what a game or a compositor needs and neither of the others express.
mkdir("/dev/input");
mknod("/dev/input/event0", 7, 0); // keyboard
mknod("/dev/input/event1", 7, 1); // mouse
// /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 mknod'd global files still happen and are simply covered by the
// mount, so a failure there degrades to the old behaviour rather than
// losing /proc entirely.
mkdir("/proc");
mount("proc", "/proc", "proc", 0, 0);
mknod("/proc/meminfo", 6, 1);
mknod("/proc/uptime", 6, 2);
mknod("/proc/cpuinfo", 6, 3);
mknod("/proc/version", 6, 4);
mknod("/proc/stat", 6, 5);
mknod("/proc/loadavg", 6, 6);
mknod("/proc/mounts", 6, 7);
mknod("/proc/filesystems", 6, 8);
mknod("/proc/schfs", 6, 9);
mknod("/proc/sysrq", 6, 10); // echo c > here to crash on purpose
mknod("/proc/pci", 6, 11);
mknod("/proc/interrupts", 6, 12);
mknod("/proc/devices", 6, 13);
mkdir("/sys");
mkdir("/run");
}
// ---------------------------------------------------------------------------
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);
}
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;
}
struct job *j = job_by_pid(pid);
if (!j) continue; // an adopted orphan; already reaped
j->pid = 0;
if (!j->keep_alive) {
out("schinit: "); out(j->label); out(" exited\n");
continue;
}
// 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;
}