// 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 // Raw disks (kernel/dev/disk.c). The minor is the IDE device number, so // hda is device 0 and hdb device 1 -- the same numbering iderw() uses. // // Created unconditionally, like everything else here: opening one when no // drive is present fails with ENXIO, which is a better answer than a // missing path. It also matters that these exist even when absent -- with // no node, `echo x > /dev/hdb` silently creates an ordinary file of that // name and appears to succeed, which is a confusing way to discover the // node was never made. mknod("/dev/hda", 8, 0); mknod("/dev/hdb", 8, 1); // /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; }