danos/docs/zig-self-hosting.md

21 KiB

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: 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.<tag> (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): the kernel exposes a minimal syscall ABI (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) 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), runtime.process.Init none
stdout / stderr debug_write today wire fd 1/2 to a console byte stream
mkdir / unlink / rename / truncate done — engine + VFS + runtime.fs (Phase 2)
stat fields {size, kind, mtime} mode / inode still missing (cache validity)
wall-clock / realtime done — wall_clock syscall (CMOS RTC, Phase 2d)
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);
  • wire the system selector .danos => std.os.danos in std.posix;
  • add std/os/danos.zigthe 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) and the FAT engine (engine.zig), then expose them via runtime.os.
  • Extend stat beyond {size, kind} to carry mtime + inode + modestd'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.

Status — Phase 2 complete. truncate (O_TRUNC, closing the boot-log stale-tail bug), mkdir, unlink, and rename are all wired through the FAT engine, the VFS protocol + router, and runtime.fs (makeDirectory / remove / rename) — host-tested and QEMU-tested (fat-mutations + fat-rename make a directory, write+read a file in it, rename it, then remove it through the mount). removeFile and rename are LFN-aware; rename is same-directory + 8.3 (cross-directory and long-name- preserving rename are noted limitations). Wall-clock is now a kernel syscall (wall_clock, a CMOS-RTC read anchored to the monotonic clock), and the FAT engine stamps and reports mtimestat / runtime.fs.Attributes carry a real modification time (the fat-mtime case reads it back within seconds of the host clock). The remaining stat fields, mode/inode, are deferred (not needed until the compiler's cache layer wants them). Everything past here is gated on Phase 0 (the fork): the runtime.os seam, cwd, stdio-as-fds, and the compiler bring-up.

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 imports 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 Files 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 is now fixed (Phase 2a). It was the same writeFile-only-grows gap that on the self-hosting road would corrupt build output; engine.truncate + an O_TRUNC open flag now free the old chain so a shorter rewrite leaves no stale tail, and the boot-log flush opens with it.