SchOS/cmd/schinit/main.c
Nexoniarz 14f0e4d0e2 kernel: processes have an environment now, and zsh is the shell
Porting zsh found four defects in the kernel and one in the filesystem. None
of them were about zsh; all of them had been there the whole time.

**exec threw the environment away.** kernel/fs/exec.c wrote a single zero for
envp with the comment "(no environment)" -- not yet, but ever, for every
process. musl has been passing a perfectly good envp to execve (Linux 59, which
SchOS answers) and it was never read. So $TERM was unset, $PATH was whatever
each program invented, and a variable exported in a shell did not survive the
exec meant to carry it. exec() takes an envp, sys_exec reads argument three,
and schinit manifests grew an Environment key. The userland two-argument
exec(path, argv) stays as a wrapper that passes null explicitly -- the raw stub
is execve now, so a short caller cannot leave %rdx holding a stray pointer.

**Which overflowed the kernel stack.** Three pointer arrays, ~1.8 KB, on a
4096-byte stack, under exec -> loaduvm -> readi -> SchFS -> btree. Pid 1 went
down on the first exec. The canary caught it and named the process, which is
what it is for. They live in one kalloc'd page now.

**waitpid returned a bare -1** for both "no children" and "interrupted". musl
reads [-4095,-1] as -errno, so userspace saw EPERM; zsh printed "wait failed:
operation not permitted" and abandoned the rest of /etc/zsh/zshrc. -ECHILD and
-EINTR now. Third time a bare -1 has cost real time -- on a system with no
permission model, EPERM should be read as "a syscall returned -1".

**NVMA was 64.** musl's allocator takes a mapping per size class and does not
release them eagerly, so compinit ran out of slots and got -1, which is EPERM,
which malloc turns into NULL, which zsh reports as "fatal error: out of memory"
-- on a machine with 1000 MB free. 256 slots, and -ENOMEM.

**And a small write to SchFS costs 58 ms.** 200 writes to one open file take
11.75 s; the same to /dev/null take 0.14 s. That is the design stated at the
top of kmount.c -- one transaction group per operation, and a commit is two
flushes and an uberblock -- not a bug. But compdump writes its digest with
thousands of separate prints, so it takes minutes and looks like a hang.
compinit -D, which skips the dump, finishes in 3.6 s. This one is a decision
rather than a fix, so it is written up and left: /etc/zsh/zshrc keeps compinit
switched off and explains exactly why, with the measurements.

zsh runs as /bin/zsh and is the console shell. ZLE works -- arrow keys recall
history through terminfo, $COLUMNS reads the real 160 because TIOCGWINSZ was
fixed, colour and prompt expansion work, startup is 0.14 s. mksh keeps /bin/sh,
so schinit's no-jobs fallback is a different program from the one that could
have failed.

Also fixed while here: AT_CLKTCK was the literal 100 and is USER_HZ; the
extract rule in four ports could move a source tree inside itself when make
re-ran it, which is how zsh's configure came to write modfile=zsh-5.9/... into
config.modules and fail three steps later.

schfs-check passes. Full writeup in docs/history/kernel.md section 14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 23:26:50 +02:00

582 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
// ---------------------------------------------------------------------------
#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;
}
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);
}
// ---------------------------------------------------------------------------
// 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)
{
// /dev before the console, because the console now lives in it.
//
// The old order was the other way round -- open a console first so every
// message below had somewhere to go -- and the fallback below is what keeps
// that guarantee. If the mount fails there is no /dev/console and no way to
// say so, so the original path stays: a node called `console` in the root
// directory, which is where this used to put one.
//
// /dev is a filesystem now, not a pile of nodes. Every major and minor it
// contains is the kernel's -- devsw[] in kernel/dev/cdev.c is what they
// dispatch on -- and this program used to hold a second copy of that
// mapping with nothing checking the two agreed.
//
// It was also slow in a way that only showed on a fresh image: each mknod
// was one SchFS transaction, and the twenty of them cost 2600 ms of the
// first boot before this program printed its first line. On a second boot
// the nodes already existed and the same code took 0 ms, so the cost was
// invisible to anyone not building images -- which is to say, invisible to
// everyone except whoever works on this.
mkdir("/dev");
mount("dev", "/dev", "devtmpfs", 0, 0);
if (open("/dev/console", O_RDWR) < 0) {
// No devtmpfs. Fall back to a real node on the root filesystem, which
// is what every boot did before this.
if (open("console", O_RDWR) < 0) {
mknod("console", 1, 1);
open("console", O_RDWR);
}
}
dup(0);
dup(0);
// /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 thirteen mknod() calls that used to follow this mount are gone. They
// ran *after* it, so every one of them was resolving a path into a
// read-only synthetic filesystem and failing -- dead weight kept as a
// fallback for a mount that has not failed in a long time, exactly as the
// note in procfs.c said it was.
mkdir("/proc");
mount("proc", "/proc", "proc", 0, 0);
// Mount points, and nothing mounts on them yet. They are created by the
// image builder (see sysroot-install), so these are a fallback for an image
// that does not have them -- a mkdir that fails because the directory is
// already there costs a path walk and no writes, where creating one costs a
// whole SchFS transaction.
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;
}