Nothing outside the kernel could touch a disk. sys_mount does not even look at the device it is handed -- it mounts the one SchFS volume found at boot and ignores `source` -- so a program had no way to read a partition table, format a volume or write a boot sector. An installer is a program that needs all three, so this is the first piece of it. A character device in devsw rather than a separate block layer, because that is where the offset already lives: fileread/filewrite pass f->off and advance it by what comes back, which is everything a seekable device needs. The minor is the IDE device number, the same numbering iderw() uses; two schemes for the same disks would be a bug waiting to happen. Writes bypass every filesystem above them, which is inherent to a raw device and why an installer must target a disk it has not mounted. The nodes are created unconditionally, like every other entry in /dev. That matters more than it looks: with no node, `echo x > /dev/hdb` silently creates an ordinary file of that name and appears to work. It did, and it agreed with itself on read-back; only the host's copy of the disk showed nothing had been written. st_rdev was never filled, so every node in /dev reported "0, 0" under ls -l. Harmless while nothing read it, wrong as soon as something has to tell two disks apart. Filled with the full Linux dev_t encoding rather than (major << 8 | minor), which only works while both stay small. Verified against the host as an independent oracle, both directions: the guest wrote to /dev/hdb and the host saw those bytes at offset 0; the host wrote at offset 4096 and the guest read them back with dd skip=. ls -l now shows 1,1 for console, 2,0 for fb0 and 8,1 for hdb. Known limit, commented in disk.c: devsw passes the offset as a 32-bit uint, so a raw disk is addressable to 4 GB. That must be widened before anyone installs onto a real disk. bootcheck 108 [ok] with -Fb -Tty -Usb -Sock -Evdev -Ps. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
569 lines
18 KiB
C
569 lines
18 KiB
C
// 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;
|
|
}
|