diff --git a/docs/README.md b/docs/README.md index 843ec29..988d909 100644 --- a/docs/README.md +++ b/docs/README.md @@ -82,6 +82,12 @@ Start with the north star: - **[resilience.md](resilience.md) — resilience.** A design note (not built yet) on fault isolation + live restart — the reincarnation-server + capability model that makes "if I break it, I can restart it" real. danos's core motivation. +- **[zig-self-hosting.md](zig-self-hosting.md) — running Zig on danos.** A design note + (not built yet) on making danos a real Zig target (`-target x86_64-danos`) and + eventually running the compiler on it. The key realisation: Zig 0.16 reduces an OS + port to **one seam** (`std.os.danos`), so we build `runtime.os` (→ that seam) plus a + thin `runtime.fs`, retire the `posix` shim, and follow a phased path to + `zig build-exe hello.zig` running on danos — **not** Linux-ABI emulation. Cutting across all of these: diff --git a/docs/zig-self-hosting.md b/docs/zig-self-hosting.md new file mode 100644 index 0000000..aa6c1b0 --- /dev/null +++ b/docs/zig-self-hosting.md @@ -0,0 +1,334 @@ +# Running Zig on danos: the self-hosting roadmap + +A design note (not built yet) on the path to making danos a **real Zig target** — a +target you can name (`-target x86_64-danos`) and, eventually, run the Zig compiler +itself on. It is forward-looking, like [vision.md](vision.md): it sets a direction +and the decisions that follow from it, so the code we write now bends toward it +instead of away. + +This note deliberately does **not** cover a text editor or terminal. Those are +easier (single-process, I/O-bound) and fall out of the early phases here almost for +free; the hard, shaping problem is the standard-library surface, so that is what +this roadmap is about. + +The analysis behind it was done against **Zig 0.16** (the pinned toolchain). Zig's +standard library moves between releases — especially the parts described here — so +treat upstream references as "the shape in 0.16.x," and expect to re-check them on a +toolchain bump. + +## The win condition + +danos runs the Zig compiler when a bare + +``` +zig build-exe hello.zig +``` + +completes **on danos** and produces a runnable danos binary. Note the milestone is +`build-exe`, not `zig build`: the `zig build` runner spawns child processes (the +build steps), which needs a whole process-control surface danos does not have yet. +A single `build-exe` needs none of that (see Phase 3). Reaching `build-exe` is +"self-hosting"; reaching `zig build` is a later, separate lift. + +### Non-goals + +- **No Linux syscall/ABI emulation.** danos will not implement the Linux `syscall` + interface so that stock `x86_64-linux` binaries run. That is a permanent + compatibility treadmill and it inverts the microkernel design — explicitly out. +- **No musl port yet.** A musl libc port is a reasonable *later* effort (it unlocks + the C ecosystem), but it is not on the critical path to Zig-on-danos, and it is + deferred. The roadmap below is arranged so the work still pays off if musl ever + happens (see "The same surface, twice"). +- **Editor/terminal are out of scope for this note** (they are downstream of Phase 1). + +**On FFI.** Foreign-function interop splits the same way as the doors below. Zig-level +and C-ABI-*exposing* FFI (`extern`, `callconv(.c)`, C-ABI structs) work on a real target +immediately — and the `std.os.danos` seam is C-ABI-shaped by construction, so it is +FFI-friendly from the start. *Consuming* C libraries (`@cImport`, linking archives) is +the part that needs a libc + headers, i.e. the deferred musl door. So an eventual FFI +need reinforces keeping that door open; it does not change the plan. + +## The realization that shapes everything: 0.16 gives us *one* seam + +The instinct "to target Zig we'd have to reimplement all the `std` namespaces" was +how older Zig worked. Zig 0.16 (post-"writergate") is far kinder: + +- **`std.fs` is essentially gone.** It is now path helpers plus deprecated aliases; + there is no `std.fs.File`, `std.fs.Dir`, or `std.fs.cwd()`. File and directory + work goes through **`std.Io`** — a single runtime **vtable** (`Io.zig`) of + function pointers handed to `main` as `std.process.Init.io`. `std.Io.File` and + `std.Io.Dir` are thin forwarders to that vtable. `Io.zig` and the `fs` shim carry + **zero** per-OS branches. +- **`std.posix` is one generic body** parameterised over a single `system` module. + With no libc, `system` resolves **per target OS**: `.linux => std.os.linux`, + `.plan9 => std.os.plan9`, and so on. The generic `std.posix.read`/`write`/`open` + bodies are just `system.read(...)` plus an errno switch — *identical for every + OS*. The only variable is what `system` binds to. +- **`std.os.`** (e.g. `std/os/linux.zig`) is therefore the real porting seam: a + low-level, C-ABI-shaped module of `read/write/open/close/lseek/mmap/clock/exit/…` + plus an `errno` enum and the constant tables (`O_*`, `CLOCK_*`, `S_*`). + +Put together: **to port danos we write `std.os.danos` once** — the ~30-operation +seam — and the whole `std.posix` / `std.fs` / `std.Io` tower above it lights up +generically, because none of it branches on the OS. That is a dramatically smaller +and more contained target than "reimplement the namespaces." + +## Three doors, and why we take the first + +| Door | What it is | Verdict | +|------|-----------|---------| +| **1. Implement the std seam** (`std.os.danos`) | Write the ~30-op `system` module over danos's native ABI + VFS; the generic std tower lights up. | **Take this.** The only door that touches neither C nor the Linux ABI. | +| **2. Port musl** | Port musl libc to danos, link Zig against it. | Defer. Good later for the *C* ecosystem; barely helps *Zig* (std only uses libc on the libc-linked path). | +| **3. Emulate the Linux ABI** | Implement Linux syscalls so stock linux binaries run. | Reject. Bottomless compatibility treadmill; against the design. | + +### The same surface, twice + +Doors 1 and 2 are the **same native surface at different layers**. `std.posix.read` +is `system.read(...)` + an errno switch *regardless of OS* — the only question is +whether `system` is **`std.os.danos` (Zig)** or **musl (C)**. Either way, the set of +danos-facing operations you must implement is the *same* ~30 ops, all bottoming out +in danos's native syscalls + the VFS/FAT server. + +So the runtime work below is **not throwaway** if musl ever happens: you are building +the danos-native implementations of that surface either way. Door 1 just packages +them as Zig; a future musl re-uses the identical kernel/VFS operations underneath. The +two symmetries worth keeping in mind: doors 1 and 2 converge at the **top** (identical +POSIX surface); doors 2 and 3 converge at the **bottom** (unmodified musl needs the +Linux syscall ABI). Door 1 is the only one that avoids both C and Linux. + +### A fork is table stakes — for any door + +`std.Target.Os.Tag` is a **closed enum** baked into the compiler binary *and* into +the `std` linked with every program; `-target x86_64-danos` resolves through it. So +adding `danos` as a name requires patching and rebuilding the compiler — even the +musl door needs this. "Fork Zig" is therefore not an extra cost unique to door 1; it +is the price of admission for *any* real target. What door 1 adds on top is small and +localised (below). + +## The architecture decision: `runtime.os` + `runtime.fs`, and retire `posix` + +danos already has the right split ([the private-ABI boundary](../README.md)): the +kernel exposes a minimal syscall ABI ([syscall.md](syscall.md)); the **`runtime`** +library is the stable, danos-native application ABI. What this roadmap adds: + +- **`runtime.os` — the seam.** A C-ABI-shaped module of the ~30 operations + (`read/write/open/close/lseek/mmap/munmap/clock/exit/…`) + an errno enum + the + constant tables, each backed by danos's native syscalls and the VFS. **Structure it + to mirror `std/os/linux.zig`.** This is the load-bearing, *non-throwaway* artifact: + when we fork Zig, `runtime.os` is copy-pasted (near-verbatim) into `std.os.danos`. +- **`runtime.fs` — the thin native file API** danos programs use *today*, layered + over `runtime.os`. It is also the concrete backing for the `std.Io` vtable's + file-write entry once we're a real target, which is why program stdout, diagnostics, + and file writes should all be *decided once at that seam* rather than as bespoke + per-call helpers (see "How this informs decisions now"). + +**Do not hand-mirror the high-level std namespaces.** `std.fs`/`std.Io`/`std.process` +are generic and OS-agnostic; once `std.os.danos` exists and we fork, upstream *gives* +them to danos for free. Hand-writing `runtime.std.fs` to imitate them would be +redundant the day the fork works, and it would chase a moving target (0.16's `std.Io` +is large and still shifting). Build the seam well; take the tower for free. + +**Why not a library called `std`?** Because `@import("std")` resolves to the +compiler-provided standard library; a user module named `std` would *shadow* it for +anything that imports it that way. That is the real reason the seam lives *inside* a +forked std as `std/os/danos.zig`, not as a `runtime.std` library — and why danos's end +state (`@import("std")` just working, and knowing danos) is the most natively Zig it can +be. `runtime.os` is only the interim staging ground: developed against the stock +toolchain so Phase 1 need not wait on the fork, then promoted near-verbatim into the +fork's `std/os/danos.zig`. + +### Retire `library/posix` + +The `posix` compatibility layer (`unistd`, `stdio`) was the right instinct too early. +Its whole value is POSIX *spellings* for POSIX software — and danos has no POSIX +software; every current caller is danos-native code that could use `runtime.fs` +directly. The real POSIX story arrives later and from elsewhere (musl, or upstream +`std`'s own posix over `std.os.danos`), which supersedes a hand-rolled shim. So it is +premature abstraction that adds a "which layer do I use?" fork with no payoff yet. + +Its footprint is tiny: **five** call sites, all `unistd` file operations — +`system/services/fat/fat.zig` (`mount`), the `vfs-test` and `fat-test` clients, and +(from the boot-log work) `init.zig` and `log-flush.zig`. `stdio.zig` is dead — nothing +imports it. The plan: build `runtime.fs`, migrate those five to it, delete +`library/posix/`, and drop the `posix` module from `build.zig`'s `addUserBinary`. + +## Where danos stands: coverage vs. the gaps + +What the seam needs, and what danos already provides: + +| std need | danos today | Gap | +|----------|-------------|-----| +| open / read / write / close / lseek | VFS (via the current `unistd`, → `runtime.fs`) | none — repackage | +| directory read (`getdents`) | VFS `readdir` | none — repackage | +| mmap / munmap | native syscalls ([abi.zig](../system/abi.zig)) | none | +| page allocator | over `mmap`, via `root.os.heap.page_allocator` override | ~30-line hook | +| monotonic clock | `clock` syscall | none | +| args / argv | SysV entry stack ([sysv.md](sysv.md)), `runtime.process.Init` | none | +| stdout / stderr | `debug_write` today | wire fd 1/2 to a console **byte** stream | +| **mkdir / unlink / rename / truncate** | — | **missing** in the VFS protocol *and* FAT engine | +| **stat fields** | `{size, kind}` only | needs **mtime + inode + mode** (cache validity) | +| **wall-clock / realtime** | monotonic only | needs an RTC/time service | +| **environment variables** | `Init` has no env field | missing (can start empty) | +| **cwd / chdir** | paths are absolute or bare | missing (no cwd anchor) | +| **entropy / random** | — | missing (needed behind `vtable.random`) | +| process spawn + exit status | `system_spawn` starts a *named ramdisk binary*; `ExitReason` is a *category* | no exec-of-path, no numeric `WEXITSTATUS` | +| threads | one thread per process | avoided via `-fsingle-threaded` (below) | +| symlinks | `NodeKind` has the tag; unimplemented | low priority | + +The clustering is clear: reads and memory are basically done; the real work is +**filesystem mutation + richer stat + wall-clock**, and a few small seam pieces +(page-allocator hook, stdio bytes, entropy). Process spawning and threads are +side-stepped entirely for a single `build-exe`. + +## The roadmap + +### Phase 0 — Make `danos` a real target + +**Host, target, self-host — keep the three roles straight.** The *host* is where the +compiler runs (your mac + linux dev machines); the *target* is what it emits (`danos`); +and eventually danos becomes a host too (self-hosting — the win condition). So the move +is: fork the compiler, build it **for** your dev hosts, and teach it to **cross-compile +to** danos. You already do this — danos is cross-compiled `freestanding` from your dev +host today; Phase 0 swaps that `freestanding` target for a real `x86_64-danos` one, which +is what unlocks the native `std`. + +**Why a compiler fork, not just a `--zig-lib-dir` override.** `std.Target.Os.Tag` is a +*closed enum compiled into the compiler binary*, so `-target x86_64-danos` will not even +parse unless the compiler itself knows the tag. Overriding the std lib directory alone +cannot add a target — and there is no libc-only shortcut (a future musl needs the same +patch). The only alternative, staying on `freestanding` + hand-shims, is exactly the +non-native feel we are leaving: `@import("std")` there is stubbed, not real. + +**The fork.** Clone `ziglang/zig` at the pinned 0.16 tag; build it with a stock +same-version `zig` (`zig build` in the tree — a standard, LLVM-pulling, roughly one-time +build); point danos's `build.zig`/CI at the resulting binary. Four localised patches: + +- add `danos` to `std.Target.Os.Tag`, in the "no version range" group alongside + plan9/serenity; +- add `danos` to the freestanding/other **no-op `_start` list** in `std`'s `start.zig`, + so std does *not* emit its own System-V `_start` — danos keeps owning the entry shim + and `Init`/argv construction it already builds ([sysv.md](sysv.md)); +- wire the `system` selector `.danos => std.os.danos` in `std.posix`; +- add `std/os/danos.zig` — **the seam itself**, promoted near-verbatim from the + `runtime.os` developed first in Phase 1 (against the stock toolchain, so the fork is + not a prerequisite for starting). + +This is the fork treadmill we accept once. Keep the patch set tiny and `else`-friendly, +pin to one 0.16.x, and rebase on point releases. + +### Phase 1 — `runtime.os` read-side + allocator + stdio + cwd; retire `posix` + +Author `runtime.os` (→ `std.os.danos`): the `errno` enum, the constant tables, and +the C-convention `read / write / open / openat / close / lseek / mmap / munmap / +exit`, each returning result-or-`-errno`. Most backing already exists (VFS + native +mmap + clock). + +- Provide `page_allocator` via `root.os.heap.page_allocator` (a thin override over + danos `mmap`). This sits **outside** the `std.Io` vtable, so it is wired separately. +- Wire fd 0/1/2 to a console **byte** stream (today output only reaches `debug_write`; + input is structured `InputEvent` IPC — a byte tty is a new, small thing in both + directions). +- Add a `getcwd`/`chdir` anchor so `std.fs.cwd()`-style resolution has something to + resolve against. +- Build `runtime.fs` over `runtime.os`; migrate the five `posix` callers to it; delete + `library/posix/` and drop its build module. + +After Phase 1, the surface an editor or terminal needs (open/read/write/close/lseek/ +readdir/isatty/args/exit) exists. Those are downstream and out of scope here. + +### Phase 2 — Filesystem mutation + real stat (the compiler's cache tower) + +danos's biggest genuine gap, and the correctness-critical one: + +- Add **mkdir / unlink / rename / truncate** to *both* the VFS wire protocol + ([protocol.zig](../system/services/vfs/protocol.zig)) and the FAT engine + ([engine.zig](../system/services/fat/engine.zig)), then expose them via `runtime.os`. +- Extend `stat` beyond `{size, kind}` to carry **mtime + inode + mode** — `std`'s file + stat needs them for build-cache validity — which in turn needs **wall-clock** time + (danos is monotonic-only today; an RTC/time service is the dependency). + +Because `std.fs`/`std.Io` have no per-OS branches, finishing this in `runtime.os` +lights up the whole file tower for the compiler at once. Environment can stay an empty +map until the kernel populates a non-empty `envp`. + +### Phase 3 — Single-threaded, self-linked compiler bring-up + +Build the compiler with **two load-bearing flags**: + +- **`-fsingle-threaded`** removes `std.Thread` entirely — `Thread.spawn` is a hard + compile error under it, and `std.Io`'s threaded backend runs inline. danos being + one-thread-per-process is therefore **not** a blocker. Parallel codegen is a + throughput optimisation, not a correctness requirement. +- **`-fno-llvm -fno-lld`** keeps codegen and linking **in-process** (the self-hosted + x86-64 backend + self-linker), so a single `build-exe` **never forks a child**. That + is what lets us defer the entire spawn/exec/wait surface. + +Then supply the few remaining seam pieces: `now` (wrap the danos clock), an entropy +source behind `vtable.random` (`randomSecure` can alias it initially — low volume, for +temp-file names and hashmap seeds), and the Phase-2 mkdir/rename/unlink for cache dir +trees and atomic temp-then-rename output. + +**Explicitly deferred** (not on the `build-exe` path): child-process spawn/exec (only +`zig build` and external tools need it), `std.Thread`, `fsync` (FAT is write-through +today), symlinks, and musl. + +## Risks and gotchas + +- **The std-fork rebase treadmill is the main ongoing cost.** A new OS tag touches the + same broad file set plan9/serenity touch (hundreds of `native_os` sites, plus + "unsupported OS" `@compileError` dead-ends a new tag must be routed around), and the + entire `std.Io` layer is new in 0.16 and still moving. Stay pinned to one 0.16.x, + keep additions localised and `else`-friendly. Watch the closed-enum gotcha: adding + `danos` to `Os.Tag` can break existing *exhaustive* switches that lack an `else`, so + expect to touch switch sites beyond the ones you implement. +- **Single-threaded is load-bearing.** The "no `std.Thread`" simplification rests + entirely on `-fsingle-threaded`. If a dependency or flag flips threading back on, you + inherit an unescapable compile error (no root-hook exists) — the only outs are a full + thread-impl fork or linking libc for pthreads. Keep `single_threaded` asserted end to + end. +- **In-process linking is load-bearing.** Reaching the compiler without fork/exec + depends on `-fno-llvm -fno-lld`. The moment you shell out to LLD/`ld`, you need the + full `spawn`/`wait` surface — the hardest microkernel piece — and danos's + `system_spawn` only starts a *named ramdisk binary*, not exec of an arbitrary path. + Verify the self-hosted backend covers the target output before assuming child + processes are optional. +- **The shim cannot host the compiler.** danos's current `runtime`/`posix` is fine for + danos's *own* native programs, but the compiler `import`s *upstream* `std`, which on + a non-target hits the void `system` stub. So the compiler forces the real target + (Phase 0's fork). Do not over-invest in extending the hand-shim for compiler + purposes; put that effort into `runtime.os` + the VFS/FAT operations, which both the + fork *and* a future musl consume. +- **`"w"`/`O_CREAT` does not truncate — a silent-corruption bug on this road.** The FAT + engine's `writeFile` only *grows* `node.size`, so overwriting a shorter file leaves + trailing garbage. Harmless for the boot log today, but for a compiler it means + **corrupt `.o`/cache files that look like nondeterministic compiler bugs.** Land + `truncate` (Phase 2) before the compiler ever writes cache. +- **Exit status is categorical, not numeric.** `process_exit_reason` returns an + `ExitReason` *category*, not a numeric code (`WEXITSTATUS`). Fine while spawn is + stubbed; the day `zig build` or external tools arrive, plan a kernel exit-record + extension — do not let it surprise you. + +## How this informs decisions now + +Two current decisions fall out of this roadmap: + +1. **The `runtime.fs` / `std.Io` question resolves at the vtable seam.** Because 0.16 + routes *all* output through the `std.Io` vtable's file-write entry, and stdout/stderr + are just `File`s with well-known handles, build `runtime.fs` (and the console stdout) + as the concrete backing for that entry — not as a bespoke `std.Io.Writer`-only shim. + Decide it once, at the seam, and program stdout, diagnostics, and file writes all + flow through the same danos VFS/console path. +2. **The boot-log `truncate` caveat graduates from cosmetic to a real bug.** It is the + same `writeFile`-only-grows gap; on the self-hosting road it corrupts build output. + Fix it in Phase 2, not "someday." + +## Related + +- [vision.md](vision.md) — the north star this serves. +- [syscall.md](syscall.md) — the kernel↔runtime ABI `runtime.os` is built on. +- [sysv.md](sysv.md) — the entry stack (`argc/argv/envp/auxv`) danos already constructs. +- [ipc.md](ipc.md) — the IPC the VFS/FAT operations travel over. +- [danos-file-system-hierarchy-FSH.md](danos-file-system-hierarchy-FSH.md) — the + filesystem layout the file surface serves. +- [coding-standards.md](coding-standards.md) — danos naming (why the compat spellings + are confined, and now retired).