schinit: a launchd-shaped process 1

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

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

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

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

Two things found by testing rather than reasoning:

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

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

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

View file

@ -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

View file

@ -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.

View file

@ -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; }

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

@ -0,0 +1,557 @@
// SPDX-License-Identifier: Apache-2.0
//
// schinit — SchOS process 1.
//
// Shaped after launchd rather than sysvinit, and the reason is not the config
// format. It is *demand launching*: the supervisor holds the listening socket
// or watches the path, and starts a job when something actually arrives. That
// inverts the dependency graph — a job stops needing to declare what must be
// running before it, because the thing it depends on answers on first use, and
// runlevels stop being a concept at all.
//
// This is milestone 1: manifests, RunAtLoad, KeepAlive with backoff, and
// reaping. Sockets are parsed and remembered but not yet held; that is
// milestone 2, and the manifest format already has a place to say it.
//
// Constraints worth knowing before editing. This program links against lib/,
// which is raw syscall stubs — there is no libc here. No malloc, no printf, no
// strlen. Everything is static storage and hand-rolled, which is also why the
// limits below are constants rather than growable.
#include "types.h"
#include "user.h"
#include "fcntl.h"
#include "posix.h" // TIOCSCTTY, TIOCSPGRP
#define MAXJOBS 32
#define MAXARGS 16
#define MAXLABEL 64
#define MAXPATH 128
#define MAXLINE 256
#define JOBDIR "/etc/schinit"
// ---------------------------------------------------------------------------
// Small string helpers. No libc.
// ---------------------------------------------------------------------------
static int
slen(const char *s)
{
int n = 0;
while (s[n]) n++;
return n;
}
static void
scopy(char *dst, const char *src, int cap)
{
int i = 0;
while (src[i] && i < cap - 1) { dst[i] = src[i]; i++; }
dst[i] = '\0';
}
static int
seq(const char *a, const char *b)
{
while (*a && *a == *b) { a++; b++; }
return *a == *b;
}
// Case-insensitive, so a manifest may say KeepAlive, keepalive or KEEPALIVE.
// Keys are for humans and this is not a language.
static int
sieq(const char *a, const char *b)
{
for (;;) {
char ca = *a, cb = *b;
if (ca >= 'A' && ca <= 'Z') ca += 32;
if (cb >= 'A' && cb <= 'Z') cb += 32;
if (ca != cb) return 0;
if (!ca) return 1;
a++; b++;
}
}
static int
ends_with(const char *s, const char *suffix)
{
int ls = slen(s), lf = slen(suffix);
if (lf > ls) return 0;
return seq(s + ls - lf, suffix);
}
static void
out(const char *s)
{
write(1, (void *)s, slen(s));
}
static void
outn(int v)
{
char buf[12];
int i = 11;
int neg = v < 0;
if (neg) v = -v;
buf[i--] = '\0';
if (v == 0) buf[i--] = '0';
while (v > 0) { buf[i--] = (char)('0' + v % 10); v /= 10; }
if (neg) buf[i--] = '-';
out(&buf[i + 1]);
}
// ---------------------------------------------------------------------------
// Jobs
// ---------------------------------------------------------------------------
struct job {
char label[MAXLABEL];
char program[MAXPATH];
char *argv[MAXARGS + 1];
char argstore[MAXLINE];
char stdin_path[MAXPATH];
char stdout_path[MAXPATH];
char stderr_path[MAXPATH];
int run_at_load;
int keep_alive;
int disabled;
int throttle; // seconds between respawns
int own_tty; // claim the console as controlling terminal
// A socket the job wants schinit to hold. Parsed now, honoured in
// milestone 2 -- recorded here so the manifest format does not have to
// change when it is.
char sock_path[MAXPATH];
int pid; // 0 when not running
int last_start; // uptime() ticks, for throttling
int starts;
};
static struct job jobs[MAXJOBS];
static int njobs;
static void
job_defaults(struct job *j)
{
j->label[0] = j->program[0] = '\0';
j->stdin_path[0] = j->stdout_path[0] = j->stderr_path[0] = '\0';
j->sock_path[0] = '\0';
j->argstore[0] = '\0';
j->argv[0] = 0;
j->run_at_load = 0;
j->keep_alive = 0;
j->disabled = 0;
j->throttle = 1;
j->own_tty = 0;
j->pid = 0;
j->last_start = 0;
j->starts = 0;
}
static int
truthy(const char *v)
{
return sieq(v, "yes") || sieq(v, "true") || sieq(v, "1") || sieq(v, "on");
}
static int
parse_int(const char *s)
{
int v = 0;
while (*s >= '0' && *s <= '9') { v = v * 10 + (*s - '0'); s++; }
return v;
}
// Split Args into argv, in place within the job's own storage. argv[0] is the
// program's name as it should see it, which is conventionally the basename and
// not the full path -- so a manifest says `Args = sh` for `/bin/sh`.
static void
split_args(struct job *j, const char *value)
{
scopy(j->argstore, value, MAXLINE);
int n = 0;
char *p = j->argstore;
while (*p && n < MAXARGS) {
while (*p == ' ' || *p == '\t') p++;
if (!*p) break;
j->argv[n++] = p;
while (*p && *p != ' ' && *p != '\t') p++;
if (*p) *p++ = '\0';
}
j->argv[n] = 0;
}
// ---------------------------------------------------------------------------
// Manifest parsing
//
// Sectioned key = value. Comments start with #. A [Socket name] section marks
// everything after it as belonging to that socket, which is why Path is read
// into sock_path rather than treated as a job key.
// ---------------------------------------------------------------------------
static char *
trim(char *s)
{
while (*s == ' ' || *s == '\t') s++;
int n = slen(s);
while (n > 0 && (s[n-1] == ' ' || s[n-1] == '\t' ||
s[n-1] == '\r' || s[n-1] == '\n'))
s[--n] = '\0';
return s;
}
static int
load_job(const char *path)
{
if (njobs >= MAXJOBS) {
out("schinit: too many jobs, ignoring "); out(path); out("\n");
return -1;
}
int fd = open((char *)path, O_RDONLY);
if (fd < 0) return -1;
// Whole file into one static buffer. A job manifest that does not fit in
// 4 KB is a manifest doing something it should not.
static char buf[4096];
int n = read(fd, buf, sizeof(buf) - 1);
close(fd);
if (n <= 0) return -1;
buf[n] = '\0';
struct job *j = &jobs[njobs];
job_defaults(j);
int in_socket = 0;
char *p = buf;
while (*p) {
char *line = p;
while (*p && *p != '\n') p++;
if (*p) *p++ = '\0';
// Strip comments before trimming, so trailing comments work too.
for (char *c = line; *c; c++)
if (*c == '#') { *c = '\0'; break; }
line = trim(line);
if (!*line) continue;
if (line[0] == '[') {
// [Socket name] -- the only section kind for now. Anything else is
// ignored rather than fatal: an unknown section from a newer
// schinit should not stop this one booting the machine.
in_socket = (line[1] == 'S' || line[1] == 's');
continue;
}
char *eq = line;
while (*eq && *eq != '=') eq++;
if (!*eq) continue; // not key = value; skip
*eq = '\0';
char *key = trim(line);
char *val = trim(eq + 1);
if (in_socket) {
if (sieq(key, "Path")) scopy(j->sock_path, val, MAXPATH);
continue;
}
if (sieq(key, "Label")) scopy(j->label, val, MAXLABEL);
else if (sieq(key, "Program")) scopy(j->program, val, MAXPATH);
else if (sieq(key, "Args")) split_args(j, val);
else if (sieq(key, "RunAtLoad")) j->run_at_load = truthy(val);
else if (sieq(key, "KeepAlive")) j->keep_alive = truthy(val);
else if (sieq(key, "Disabled")) j->disabled = truthy(val);
else if (sieq(key, "ThrottleInterval")) j->throttle = parse_int(val);
else if (sieq(key, "ControllingTerminal")) j->own_tty = truthy(val);
else if (sieq(key, "StandardIn")) scopy(j->stdin_path, val, MAXPATH);
else if (sieq(key, "StandardOut")) scopy(j->stdout_path, val, MAXPATH);
else if (sieq(key, "StandardError")) scopy(j->stderr_path, val, MAXPATH);
// Unknown keys are ignored on purpose, same reasoning as sections.
}
if (!j->program[0]) {
out("schinit: "); out(path); out(": no Program, ignoring\n");
return -1;
}
if (!j->label[0])
scopy(j->label, j->program, MAXLABEL);
if (!j->argv[0]) {
// No Args given: pass the program path as argv[0], which is what a
// program expects when nothing better is said.
scopy(j->argstore, j->program, MAXLINE);
j->argv[0] = j->argstore;
j->argv[1] = 0;
}
if (j->throttle <= 0)
j->throttle = 1;
njobs++;
return 0;
}
// Read every *.job in JOBDIR. getdents64 rather than a hardcoded list, so
// dropping a file in the directory is all it takes to add a job.
static void
load_jobs(void)
{
int fd = open(JOBDIR, O_RDONLY);
if (fd < 0) {
out("schinit: no " JOBDIR "\n");
return;
}
static char dbuf[4096];
for (;;) {
int n = getdents64(fd, dbuf, sizeof(dbuf));
if (n <= 0) break;
int off = 0;
while (off < n) {
struct dirent64 *d = (struct dirent64 *)(dbuf + off);
// Step by d_reclen: records are packed and variable length.
if (d->d_reclen == 0) break;
off += d->d_reclen;
if (d->d_name[0] == '.') continue;
if (!ends_with(d->d_name, ".job")) continue;
char path[MAXPATH];
scopy(path, JOBDIR "/", MAXPATH);
int at = slen(path);
scopy(path + at, d->d_name, MAXPATH - at);
load_job(path);
}
}
close(fd);
}
// ---------------------------------------------------------------------------
// Starting and supervising
// ---------------------------------------------------------------------------
static void
redirect(const char *path, int fd, int flags)
{
if (!path[0]) return;
int f = open((char *)path, flags);
if (f < 0) return;
if (f != fd) { dup2(f, fd); close(f); }
}
static void
start_job(struct job *j)
{
int pid = fork();
if (pid < 0) {
out("schinit: fork failed for "); out(j->label); out("\n");
return;
}
if (pid == 0) {
// Its own session, so a job cannot reach back and signal the rest of
// the system through a shared controlling terminal.
setsid();
redirect(j->stdin_path, 0, O_RDWR);
redirect(j->stdout_path, 1, O_RDWR);
redirect(j->stderr_path, 2, O_RDWR);
// A job that reads the console has to *own* it, and setsid() is what
// makes that necessary: a fresh session has no controlling terminal,
// and the console only delivers input to the foreground process group
// it is tracking. Without this the shell came up, echoed every
// keystroke through the line discipline, and executed none of them --
// which looks like a hung shell and is really a terminal that is not
// listening to it.
//
// Off by default. A daemon has no business seizing the console, and
// launchd's model is that the console is a thing a job asks for.
if (j->own_tty) {
int pg = getpid(); // after setsid(), pgid == pid
ioctl(0, TIOCSCTTY, 0);
ioctl(0, TIOCSPGRP, &pg);
}
// exec.c enables the Linux ABI automatically for musl binaries. Do NOT
// call enable_linux_abi() here: it would renumber the syscalls this
// child still has to make.
exec(j->program, j->argv);
out("schinit: cannot exec "); out(j->program);
out(" for "); out(j->label); out("\n");
exit();
}
j->pid = pid;
j->last_start = uptime();
j->starts++;
}
static struct job *
job_by_pid(int pid)
{
for (int i = 0; i < njobs; i++)
if (jobs[i].pid == pid)
return &jobs[i];
return 0;
}
// ---------------------------------------------------------------------------
// Bootstrap: the device nodes and mounts that must exist before anything runs.
//
// Not jobs. A job needs /dev/console to have somewhere to send its output, so
// this cannot itself be expressed as one without a bootstrapping problem.
// ---------------------------------------------------------------------------
static void
bootstrap(void)
{
// Console first, as fd 0/1/2, so every message below has somewhere to go.
if (open("console", O_RDWR) < 0) {
mknod("console", 1, 1);
open("console", O_RDWR);
}
dup(0);
dup(0);
// Minor numbers under major 1 follow Linux's mem-major layout where it
// costs nothing (null=2, zero=5, full=7, random=8, urandom=9).
mkdir("/dev");
mknod("/dev/console", 1, 1);
mknod("/dev/null", 1, 2);
mknod("/dev/keyboard", 1, 3);
mknod("/dev/zero", 1, 5);
mknod("/dev/full", 1, 7);
mknod("/dev/random", 1, 8);
mknod("/dev/urandom", 1, 9);
mknod("/dev/fb0", 2, 0);
mknod("/dev/mouse", 3, 1);
mknod("/dev/tty", 1, 1); // alias for console -- must pass TCGETS
mknod("/dev/ttyS0", 4, 64);
mknod("/dev/stdin", 1, 1);
mknod("/dev/stdout", 1, 1);
mknod("/dev/stderr", 1, 1);
// Raw input events, Linux evdev layout (kernel/dev/input.c). /dev/keyboard
// and /dev/mouse above are the old character-and-position interfaces;
// these are the ones with key releases and button transitions, which is
// what a game or a compositor needs and neither of the others express.
mkdir("/dev/input");
mknod("/dev/input/event0", 7, 0); // keyboard
mknod("/dev/input/event1", 7, 1); // mouse
// /proc is a real filesystem (kernel/fs/procfs.c), not a handful of nodes:
// it has to be, because one directory per process cannot be created ahead
// of time. That is what makes ps and top work.
//
// The mknod'd global files still happen and are simply covered by the
// mount, so a failure there degrades to the old behaviour rather than
// losing /proc entirely.
mkdir("/proc");
mount("proc", "/proc", "proc", 0, 0);
mknod("/proc/meminfo", 6, 1);
mknod("/proc/uptime", 6, 2);
mknod("/proc/cpuinfo", 6, 3);
mknod("/proc/version", 6, 4);
mknod("/proc/stat", 6, 5);
mknod("/proc/loadavg", 6, 6);
mknod("/proc/mounts", 6, 7);
mknod("/proc/filesystems", 6, 8);
mknod("/proc/schfs", 6, 9);
mknod("/proc/sysrq", 6, 10); // echo c > here to crash on purpose
mknod("/proc/pci", 6, 11);
mknod("/proc/interrupts", 6, 12);
mknod("/proc/devices", 6, 13);
mkdir("/sys");
mkdir("/run");
}
// ---------------------------------------------------------------------------
int
main(void)
{
if (getpid() != 1)
exit();
bootstrap();
out("schinit: SchOS process 1\n");
load_jobs();
if (njobs == 0) {
// Nothing to supervise. Rather than idle usefully-looking, say so and
// fall back to a shell -- an unbootable-looking machine because a
// manifest was missing would be a poor trade for purity.
out("schinit: no jobs found; falling back to /bin/sh\n");
struct job *j = &jobs[njobs++];
job_defaults(j);
scopy(j->label, "os.schos.fallback-shell", MAXLABEL);
scopy(j->program, "/bin/sh", MAXPATH);
split_args(j, "sh");
scopy(j->stdin_path, "/dev/console", MAXPATH);
scopy(j->stdout_path, "/dev/console", MAXPATH);
scopy(j->stderr_path, "/dev/console", MAXPATH);
j->run_at_load = 1;
j->keep_alive = 1;
j->own_tty = 1;
}
out("schinit: "); outn(njobs); out(" job");
if (njobs != 1) out("s");
out(" loaded\n");
for (int i = 0; i < njobs; i++) {
struct job *j = &jobs[i];
if (j->disabled || !j->run_at_load) continue;
out("schinit: starting "); out(j->label); out("\n");
start_job(j);
}
out("schinit: ready (pid 1)\n");
// Supervision loop.
//
// wait() reaps whatever exited, including processes reparented here when
// their own parent died -- those are not jobs and job_by_pid returns 0 for
// them, which is the whole handling they need.
for (;;) {
int pid = wait();
if (pid < 0) {
// Nothing left to wait for. Should not happen while any job is
// alive; sleep rather than spin so a mistake here costs a idle
// machine and not a hot CPU.
sleep(100);
continue;
}
struct job *j = job_by_pid(pid);
if (!j) continue; // an adopted orphan; already reaped
j->pid = 0;
if (!j->keep_alive) {
out("schinit: "); out(j->label); out(" exited\n");
continue;
}
// Throttle. A job that exits immediately and forever would otherwise
// spin the machine; launchd's ThrottleInterval exists for exactly this
// and defaults to 10 seconds. One is enough here, where the common
// case is a shell someone just exited on purpose and expects back.
int since = uptime() - j->last_start;
int ticks = j->throttle * 100; // uptime() counts 10ms ticks
if (since < ticks)
sleep(ticks - since);
start_job(j);
}
return 0;
}

27
etc/schinit/README Normal file
View file

@ -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 <name>]
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.

View file

@ -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

View file

@ -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;

View file

@ -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:

View file

@ -108,3 +108,4 @@ SYSCALL(socketpair)
SYSCALL(setsockopt)
SYSCALL(getsockopt)
SYSCALL(mount)
SYSCALL(getdents64)

View file

@ -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'