diff --git a/Makefile b/Makefile index 61fd00e..5461ce9 100644 --- a/Makefile +++ b/Makefile @@ -107,7 +107,7 @@ KERN_NOENTRY = $(filter-out $(BUILD)/kernel/arch/x86_64/entry.o, $(KERNOBJS)) # Third-party software does NOT go here — see ports/. # ----------------------------------------------------------------------- ULIB = $(addprefix $(BUILD)/lib/, start.o glue.o usys.o) -CMDS = init schinit fbinfo shmtest socktest inputtest nettest reflink schattr ping chvt +CMDS = init schinit schinitd fbinfo shmtest socktest inputtest nettest reflink schattr ping chvt CMDBINS = $(addprefix $(BUILD)/cmd/, $(CMDS)) # ----------------------------------------------------------------------- diff --git a/cmd/schinit/main.c b/cmd/schinit/main.c index 6114f7b..1cc0d6b 100644 --- a/cmd/schinit/main.c +++ b/cmd/schinit/main.c @@ -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; } diff --git a/cmd/schinitd/main.c b/cmd/schinitd/main.c new file mode 100644 index 0000000..661f556 --- /dev/null +++ b/cmd/schinitd/main.c @@ -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); + } +} diff --git a/docs/plan/init.md b/docs/plan/init.md index af546d1..e6b5f66 100644 --- a/docs/plan/init.md +++ b/docs/plan/init.md @@ -143,10 +143,31 @@ silent loop. The crash screen is the correct place for this to arrive. ## Milestones +**I0 and I1 are built.** `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. | +| **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. | diff --git a/include/gfx.h b/include/gfx.h index c0f17cf..41e8da5 100644 --- a/include/gfx.h +++ b/include/gfx.h @@ -76,6 +76,8 @@ int gfx_rows(void); // For the text layer in kernel/dev/vt.c: one cell with its own colours, and // a screen scroll that leaves the cursor alone. void gfx_draw_cell(int col, int row, uchar glyph, uint32 fg, uint32 bg); +// A filled rectangle in pixels, with the per-pixel checks hoisted out. +void gfx_fill(int x, int y, int w, int h, uint32 color); void gfx_scroll_up(uint32 bg); void gfx_cursor_get(int *col, int *row); void gfx_cursor_set(int col, int row); diff --git a/include/posix.h b/include/posix.h index fce5e13..f44a6cd 100644 --- a/include/posix.h +++ b/include/posix.h @@ -350,6 +350,9 @@ struct statfs { #define FALLOC_FL_KEEP_SIZE 1 // poll(2) event bits (Linux values) +// waitpid() options. The kernel tests `options & 1`; this is that bit, named. +#define WNOHANG 0x001 + #define POLLIN 0x001 #define POLLPRI 0x002 #define POLLOUT 0x004 diff --git a/include/schinit.h b/include/schinit.h new file mode 100644 index 0000000..b344bd4 --- /dev/null +++ b/include/schinit.h @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The protocol between PID 1 and the supervisor. +// +// docs/plan/init.md §"What it costs" warns about this file specifically: two +// processes that must agree is a compatibility surface where there was none, +// and docs/plan/schapi.md §3 is about how those go wrong. So the rules are +// written here rather than assumed. +// +// **Fixed-size messages.** Every message is exactly one struct, and PID 1 +// reads sizeof(struct) or nothing. No length prefix to get wrong, no +// partial-parse state to be caught in, and -- the reason that matters -- +// **PID 1 never allocates**. A supervisor that has gone mad cannot make PID 1 +// allocate, block on a malloc, or run a parser. +// +// **Versioned in the first message.** SCHINIT_PROTO travels in the hello. A +// mismatch is a refusal, not a best-effort interpretation. +// +// **Entirely internal.** This is not SchAPI. Nothing but schinit and +// schinitd may speak it, and it may change shape whenever both change +// together, which is what keeping it in one header is for. +// +// The asymmetry is the design, not an accident: the supervisor *asks*, PID 1 +// *acts*. Every job on the machine is therefore a child of PID 1 at all times, +// so a supervisor that dies orphans nothing and reparents nothing. + +#ifndef SCHINIT_H +#define SCHINIT_H + +#define SCHINIT_PROTO 1 + +#define SCHINIT_MAXARGS 16 +#define SCHINIT_MAXENVV 16 +#define SCHINIT_MAXLABEL 64 +#define SCHINIT_MAXPATH 128 +// One flat buffer for argv and envp strings, copied whole. Pointers cannot +// cross a process boundary, so the spec carries text and PID 1 rebuilds the +// arrays on its own stack after exec'ing -- which is the only place they are +// ever valid. +#define SCHINIT_STRBUF 1024 + +// supervisor -> PID 1 +#define SCHINIT_HELLO 1 // struct schinit_hello +#define SCHINIT_SPAWN 2 // struct schinit_spawn +#define SCHINIT_SIGNAL 3 // struct schinit_signal + +// PID 1 -> supervisor +#define SCHINIT_WELCOME 128 // struct schinit_hello, echoed with PID 1's version +#define SCHINIT_SPAWNED 129 // struct schinit_reply -- pid, or negative on failure +#define SCHINIT_EXITED 130 // struct schinit_reply -- a child has been reaped + +struct schinit_hello { + unsigned int op; + unsigned int proto; + unsigned int generation; // how many times PID 1 has been re-exec'd + unsigned int reserved; +}; + +// A job, flattened. `args` and `envs` index into `strbuf`, which holds +// NUL-terminated strings back to back. +struct schinit_spawn { + unsigned int op; + unsigned int cookie; // echoed in the reply, so the supervisor + // can match a spawn to the job it asked for + char label[SCHINIT_MAXLABEL]; + char program[SCHINIT_MAXPATH]; + + char stdin_path[SCHINIT_MAXPATH]; + char stdout_path[SCHINIT_MAXPATH]; + char stderr_path[SCHINIT_MAXPATH]; + + unsigned int own_tty; // claim the terminal as controlling + unsigned int nargs; + unsigned int nenvs; + unsigned short args[SCHINIT_MAXARGS]; // offsets into strbuf + unsigned short envs[SCHINIT_MAXENVV]; + char strbuf[SCHINIT_STRBUF]; +}; + +struct schinit_reply { + unsigned int op; + unsigned int cookie; + int pid; // SPAWNED: the child, or negative errno + int status; // EXITED: wait status +}; + +// The largest message, which is what a reader must have room for. PID 1 reads +// into one of these on its own stack and never anywhere else. +union schinit_msg { + unsigned int op; + struct schinit_hello hello; + struct schinit_spawn spawn; + struct schinit_reply reply; +}; + +// The descriptor the supervisor inherits. Fixed rather than passed, because +// the supervisor is exec'd by PID 1 and there is nowhere to pass it -- and a +// well-known number is one less thing for the two to disagree about. +#define SCHINIT_SOCK_FD 3 + +#endif // SCHINIT_H diff --git a/include/user.h b/include/user.h index e23c707..55718ce 100644 --- a/include/user.h +++ b/include/user.h @@ -65,6 +65,11 @@ int geteuid(void); int nanosleep(void*); int mprotect(void*, uint64, int); int ioctl(int, uint64, void*); +// Declared at file scope, not inside the prototype: struct pollfd lives in +// posix.h, and a tag first seen in a parameter list is scoped to that list -- +// so the prototype would describe a different, incompatible type. +struct pollfd; +int poll(struct pollfd*, int, int); // Attributes on a file, and the index over them. Only SchFS carries these; // anything else answers EOPNOTSUPP. diff --git a/lib/usys.S b/lib/usys.S index 990a20b..d696293 100644 --- a/lib/usys.S +++ b/lib/usys.S @@ -55,6 +55,7 @@ SYSCALL(sigprocmask) SYSCALL(sigwait) SYSCALL(alarm) SYSCALL(waitpid) +SYSCALL(poll) SYSCALL(setsid) SYSCALL(mmap) SYSCALL(munmap)