diff --git a/.claude/memory/schunix-boot-splash-logo.md b/.claude/memory/schunix-boot-splash-logo.md index a344ab9..33ab778 100644 --- a/.claude/memory/schunix-boot-splash-logo.md +++ b/.claude/memory/schunix-boot-splash-logo.md @@ -16,7 +16,7 @@ one group. Verified rendering correctly on 2026-08-08. counter-intuitive part and it is a timing fact, not a preference: the kernel does not reach `vga_init()` until roughly the last 300ms of a ~2.8s boot, so a kernel-drawn logo appeared for about one frame before `console_takeover()` wiped -it. Drawn from `splash()` in `boot/bios/stage2main.c` it is up for the whole boot. +it. Drawn from `splash()` in `boot/stage2main.c` it is up for the whole boot. `kernel/dev/vga.c` keeps the same drawing code against the same mask (`vga_draw_logo`, `vga_logo_centered`) for things that want the mark *after* boot — the crash screen, eventually `schinit`. Don't "fix" the duplication by @@ -35,7 +35,7 @@ for the one rule. 4 bits rather than 8 because the header is included by the **`STAGE2_SECTORS` was encoded in five places, not the three `docs/layout.md` claimed.** Raising it 24 → 48 to fit the 8 KiB mask proved it: the `dd seek=25` offset stayed behind and the kernel got written straight over stage2's tail. -The five were `boot/bios/bootasm.S`, `boot/bios/stage2main.c`, the `dd seek=`, and *twice* +The five were `boot/bootasm.S`, `boot/stage2main.c`, the `dd seek=`, and *twice* inside `tools/mkiso.sh` (its size assertion and `-boot-load-size`). It now lives only in the root Makefile, which passes `-DSTAGE2_SECTORS` and exports it to mkiso; the `#define`s left in the boot sources are `#ifndef` fallbacks. Hard diff --git a/.claude/memory/schunix-iso-boot.md b/.claude/memory/schunix-iso-boot.md index 59aa477..3cc9014 100644 --- a/.claude/memory/schunix-iso-boot.md +++ b/.claude/memory/schunix-iso-boot.md @@ -18,7 +18,7 @@ protected mode before it reads the kernel. That rules out both El Torito emulation modes (hard-disk emulation exists only through the BIOS) and floppy emulation (2.88 MB cap). So: -- `boot/bios/cdboot.S` — a separate stage 1 for optical media. `tools/mkiso.sh` +- `boot/cdboot.S` — a separate stage 1 for optical media. `tools/mkiso.sh` passes `-boot-load-size 25`, so the BIOS loads cdboot *and* all 24 sectors of stage2 before anything of ours runs, putting stage2 at 0x7E00 exactly where `bootasm.S` would have. cdboot only records the medium and jumps. diff --git a/Makefile b/Makefile index 53ddbe5..7a801de 100644 --- a/Makefile +++ b/Makefile @@ -86,7 +86,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 fbinfo shmtest socktest inputtest +CMDS = init schinit fbinfo shmtest socktest inputtest CMDBINS = $(addprefix $(BUILD)/cmd/, $(CMDS)) # ----------------------------------------------------------------------- @@ -325,7 +325,10 @@ endif # ----------------------------------------------------------------------- # Top-level targets # ----------------------------------------------------------------------- -all: schos.img +# usbstick.img is built here rather than on demand because bootcheck -Usb +# cannot start QEMU without it, and a missing test image looks exactly like a +# broken USB driver: QEMU refuses the -drive and the whole run produces nothing. +all: schos.img $(BUILD)/usbstick.img schos.img: $(BUILD)/boot/bootblock $(BUILD)/boot/stage2 $(BUILD)/kernel/kernel $(BUILD)/schfs.img dd if=/dev/zero of=schos.img count=10000 @@ -684,7 +687,19 @@ $(MKFS): tools/mkfs.c include/fs.h include/stat.h include/param.h # zero -- and it is stated out loud rather than silently skipped, because a # port that quietly does not rebuild is exactly the kind of thing that wastes # an afternoon later. -sysroot-install: $(addprefix $(SYSROOT)/bin/, $(CMDS)) +# schinit's job manifests. Kept under etc/ in the repo and installed here, +# because sysroot/ is generated and gitignored -- anything only ever written +# straight into it is lost on a fresh clone, which is already true of +# /etc/passwd and /etc/motd and should not become true of the boot +# configuration as well. +SCHINIT_JOBS = $(wildcard etc/schinit/*.job) etc/schinit/README + +etc-install: $(SCHINIT_JOBS) + @mkdir -p $(SYSROOT)/etc/schinit + @cp $(SCHINIT_JOBS) $(SYSROOT)/etc/schinit/ + @echo "installed $(words $(SCHINIT_JOBS)) schinit manifest file(s)" + +sysroot-install: $(addprefix $(SYSROOT)/bin/, $(CMDS)) etc-install ifdef WINHOST @echo "ports: skipped (Windows host) -- musl/mksh/toybox are used as already staged in $(SYSROOT)" @test -f $(SYSROOT)/bin/sh || { echo "ports: $(SYSROOT)/bin/sh is missing and cannot be built here; build ports on a Linux host once" >&2; exit 1; } diff --git a/cmd/schinit/main.c b/cmd/schinit/main.c new file mode 100644 index 0000000..1955ffc --- /dev/null +++ b/cmd/schinit/main.c @@ -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; +} diff --git a/etc/schinit/README b/etc/schinit/README new file mode 100644 index 0000000..32950e2 --- /dev/null +++ b/etc/schinit/README @@ -0,0 +1,27 @@ +Job manifests for schinit, SchOS process 1. + +Every *.job file in this directory is loaded at boot. Format is sectioned +key = value; # starts a comment, keys are case-insensitive. + + Label a reverse-DNS name, as launchd uses. Identity, not a path. + Program the binary to execute + Args argv, space separated. argv[0] is conventionally the + basename, so `Args = sh` for `/bin/sh`. + RunAtLoad start it when schinit loads + KeepAlive restart it when it exits + Disabled load it but do not start it + ThrottleInterval seconds between respawns (default 1) + StandardIn \ + StandardOut > paths to open as fd 0, 1, 2 + StandardError / + + [Socket ] + Path a unix socket schinit will hold on the job's behalf and + start the job on first connect + +Socket sections are parsed but not yet acted on -- that is milestone 2, and +demand launching is the whole reason this is shaped after launchd rather than +sysvinit. The key exists now so manifests do not have to change later. + +Unknown keys and unknown sections are ignored rather than fatal: a manifest +written for a newer schinit must not stop this one booting the machine. diff --git a/etc/schinit/console-shell.job b/etc/schinit/console-shell.job new file mode 100644 index 0000000..cdf8374 --- /dev/null +++ b/etc/schinit/console-shell.job @@ -0,0 +1,25 @@ +# The interactive shell on the system console. +# +# In launchd terms this is a RunAtLoad + KeepAlive daemon: started when +# schinit loads, and restarted whenever it exits -- which for a shell is +# every time someone types `exit`. + +Label = os.schos.console-shell +Program = /bin/sh +Args = sh +RunAtLoad = yes +KeepAlive = yes + +# This job reads the console, so it has to own it: schinit puts every job in +# its own session, and a session with no controlling terminal is not delivered +# terminal input at all. Without this the shell echoes and executes nothing. +ControllingTerminal = yes + +StandardIn = /dev/console +StandardOut = /dev/console +StandardError = /dev/console + +# Seconds between respawns. Guards against a shell that dies instantly +# taking the machine with it; launchd calls this ThrottleInterval and +# defaults to 10. One is enough for a shell someone just exited on purpose. +ThrottleInterval = 1 diff --git a/include/user.h b/include/user.h index 7983a2d..3b5aec2 100644 --- a/include/user.h +++ b/include/user.h @@ -95,6 +95,24 @@ int getppid(void); int rt_sigaction(int, void*, void*, uint64); int mount(const char *src, const char *tgt, const char *fstype, uint64 flags, void *data); +// Directory reading. The kernel has had getdents64 since the Linux ABI landed, +// but nothing declared it here, so first-party programs had no way to list a +// directory at all -- which is what schinit needs to find its job files. +// +// Records are packed: step by d_reclen, never by sizeof, because d_name is a +// flexible array and each record is only as long as its name needs. +struct dirent64 { + uint64 d_ino; + int64 d_off; + ushort d_reclen; + uchar d_type; + char d_name[]; +}; +#define DT_DIR 4 +#define DT_REG 8 + +int getdents64(int fd, void *buf, int count); + // Sockets. `struct sockaddr` and the AF_*/SOCK_* constants are in socket.h; // include it as well as this header to use them. struct sockaddr; diff --git a/lib/initcode.S b/lib/initcode.S index a8ba620..293c2ad 100644 --- a/lib/initcode.S +++ b/lib/initcode.S @@ -16,8 +16,11 @@ exit: syscall jmp exit +// The kernel's very first exec. /bin/init remains as the older, hardcoded +// fork-a-shell process 1; /bin/schinit is the supervisor that replaced it and +// reads its jobs from /etc/schinit. init: - .string "/bin/init" + .string "/bin/schinit" .p2align 3 argv: diff --git a/lib/usys.S b/lib/usys.S index e1d92d6..4cddbda 100644 --- a/lib/usys.S +++ b/lib/usys.S @@ -108,3 +108,4 @@ SYSCALL(socketpair) SYSCALL(setsockopt) SYSCALL(getsockopt) SYSCALL(mount) +SYSCALL(getdents64) diff --git a/tools/windows/bootcheck.ps1 b/tools/windows/bootcheck.ps1 index aed0b9e..8ad3c6f 100644 --- a/tools/windows/bootcheck.ps1 +++ b/tools/windows/bootcheck.ps1 @@ -150,6 +150,13 @@ $commands += 'kill -TERM 1' $commands += 'echo INIT-SURVIVED' $commands += 'ps | head -3' +# KeepAlive. Exiting the shell must bring a new one back -- that is the +# supervision loop doing its job, and it is the only part of schinit that +# cannot be checked by reading its startup output. +$commands += 'echo BEFORE-EXIT' +$commands += 'exit' +$commands += 'echo SHELL-RESPAWNED' + if ($Fb) { # /dev/fb0. The geometry has to come back from the mode the bootloader # actually programmed -- three places in the kernel used to assume @@ -441,6 +448,10 @@ Expect 'vga: display controller' 'the display driver found its aperture' # point would appear at all -- but check explicitly, so a failure names the # cause instead of showing up as "the shell stopped answering". Expect 'INIT-SURVIVED' 'init survives SIGINT, SIGKILL and SIGTERM' +Expect 'schinit: ready \(pid 1\)' 'schinit came up as process 1' +Expect 'job.? loaded' 'schinit read its manifests from /etc/schinit' +Expect 'starting os\.schos\.' 'a job started by Label, not by hardcoded path' +Expect 'SHELL-RESPAWNED' 'KeepAlive restarted the shell after it exited' Expect '(?m)^\s*1\s' 'pid 1 is still in the process table afterwards' Expect 'schfs: mounted' 'the kernel mounted SchFS as the root filesystem'