SchOS/include/schinit.h
Nexoniarz c80b7bf3b2 init: PID 1 becomes a role -- schinit splits, schinitd supervises
docs/plan/init.md milestones I0 and I1.

**The split.** schinit is PID 1 and does four things: bootstrap the
filesystems, fork schinitd, fork jobs on schinitd's behalf, reap children. No
manifest parsing, no filesystem walking after boot, no allocation. It is the
one program on the machine with no supervisor, so it is written to be too
boring to fail.

schinitd is PID 2 and holds everything else -- /etc/schinit, respawn policy,
throttling -- and is restartable, which is where the bugs are allowed to be.

**Jobs are forked by PID 1, not by schinitd.** That is the load-bearing
detail. schinitd decides and asks; PID 1 acts. So every job is a child of PID
1 at all times, and a supervisor that dies orphans nothing, reparents nothing
and restarts nothing.

**/proc/schinit** is the job table, written by PID 1 whenever it changes. The
kernel holds the text and does not interpret it: a procfs generator runs in
the kernel, and walking PID 1's memory to answer a read would put a userspace
program's format into the kernel, where it would rot.

Verified the way the plan asks for -- `kill 3`, then look:

    schinitd: adopting os.schos.console-shell
    schinitd: adopting os.schos.tty2-shell

/proc/schinit byte-identical across the restart, same pids, same cookies, same
start times, and the shell being typed into never noticed.

The first attempt got I1 wrong in the instructive way: PID 1 published the
table and schinitd never read it back, so the restarted supervisor started
everything a second time -- two console shells on one terminal, which is the
split-brain this plan rejects the two-init scheme for, reached by a different
road. Publishing state is half of it; coming back and believing it is the
other half.

The protocol is include/schinit.h: fixed-size messages, versioned in the
hello, entirely internal. Fixed-size so PID 1 never allocates, and a
supervisor that has gone mad cannot make it run a parser.

Still to come: I2 (heartbeat both ways, so a hang is as detectable as a death)
and I3 (proc_reincarnate, the actual law change).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 13:57:27 +02:00

101 lines
3.8 KiB
C

// SPDX-License-Identifier: Apache-2.0
//
// The protocol between PID 1 and the supervisor.
//
// docs/plan/init.md §"What it costs" warns about this file specifically: two
// processes that must agree is a compatibility surface where there was none,
// and docs/plan/schapi.md §3 is about how those go wrong. So the rules are
// written here rather than assumed.
//
// **Fixed-size messages.** Every message is exactly one struct, and PID 1
// reads sizeof(struct) or nothing. No length prefix to get wrong, no
// partial-parse state to be caught in, and -- the reason that matters --
// **PID 1 never allocates**. A supervisor that has gone mad cannot make PID 1
// allocate, block on a malloc, or run a parser.
//
// **Versioned in the first message.** SCHINIT_PROTO travels in the hello. A
// mismatch is a refusal, not a best-effort interpretation.
//
// **Entirely internal.** This is not SchAPI. Nothing but schinit and
// schinitd may speak it, and it may change shape whenever both change
// together, which is what keeping it in one header is for.
//
// The asymmetry is the design, not an accident: the supervisor *asks*, PID 1
// *acts*. Every job on the machine is therefore a child of PID 1 at all times,
// so a supervisor that dies orphans nothing and reparents nothing.
#ifndef SCHINIT_H
#define SCHINIT_H
#define SCHINIT_PROTO 1
#define SCHINIT_MAXARGS 16
#define SCHINIT_MAXENVV 16
#define SCHINIT_MAXLABEL 64
#define SCHINIT_MAXPATH 128
// One flat buffer for argv and envp strings, copied whole. Pointers cannot
// cross a process boundary, so the spec carries text and PID 1 rebuilds the
// arrays on its own stack after exec'ing -- which is the only place they are
// ever valid.
#define SCHINIT_STRBUF 1024
// supervisor -> PID 1
#define SCHINIT_HELLO 1 // struct schinit_hello
#define SCHINIT_SPAWN 2 // struct schinit_spawn
#define SCHINIT_SIGNAL 3 // struct schinit_signal
// PID 1 -> supervisor
#define SCHINIT_WELCOME 128 // struct schinit_hello, echoed with PID 1's version
#define SCHINIT_SPAWNED 129 // struct schinit_reply -- pid, or negative on failure
#define SCHINIT_EXITED 130 // struct schinit_reply -- a child has been reaped
struct schinit_hello {
unsigned int op;
unsigned int proto;
unsigned int generation; // how many times PID 1 has been re-exec'd
unsigned int reserved;
};
// A job, flattened. `args` and `envs` index into `strbuf`, which holds
// NUL-terminated strings back to back.
struct schinit_spawn {
unsigned int op;
unsigned int cookie; // echoed in the reply, so the supervisor
// can match a spawn to the job it asked for
char label[SCHINIT_MAXLABEL];
char program[SCHINIT_MAXPATH];
char stdin_path[SCHINIT_MAXPATH];
char stdout_path[SCHINIT_MAXPATH];
char stderr_path[SCHINIT_MAXPATH];
unsigned int own_tty; // claim the terminal as controlling
unsigned int nargs;
unsigned int nenvs;
unsigned short args[SCHINIT_MAXARGS]; // offsets into strbuf
unsigned short envs[SCHINIT_MAXENVV];
char strbuf[SCHINIT_STRBUF];
};
struct schinit_reply {
unsigned int op;
unsigned int cookie;
int pid; // SPAWNED: the child, or negative errno
int status; // EXITED: wait status
};
// The largest message, which is what a reader must have room for. PID 1 reads
// into one of these on its own stack and never anywhere else.
union schinit_msg {
unsigned int op;
struct schinit_hello hello;
struct schinit_spawn spawn;
struct schinit_reply reply;
};
// The descriptor the supervisor inherits. Fixed rather than passed, because
// the supervisor is exec'd by PID 1 and there is nowhere to pass it -- and a
// well-known number is one less thing for the two to disagree about.
#define SCHINIT_SOCK_FD 3
#endif // SCHINIT_H