# 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](os-development/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` (done) The `posix` compatibility layer (`unistd`, `stdio`) was the right instinct too early. Its whole value was POSIX *spellings* for POSIX software — and danos has no POSIX software; every caller was 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 was premature abstraction that added a "which layer do I use?" fork with no payoff. Its footprint was 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` was dead — nothing imported it. The plan — build `runtime.fs`, migrate those five to it, delete `library/posix/`, and drop the `posix` module from `build.zig`'s `addUserBinary` — has since been carried out: `library/` today holds only `mmio`, `runtime`, and `xkeyboard-config`. ## 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 `runtime.fs`; the `unistd` shim is retired) | 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](os-development/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 | native threads — `thread_spawn`/futex/`thread_join` (`runtime.Thread`) | `std.Thread` seam unwritten; 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](os-development/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 ([vfs-protocol.zig](../library/protocol/vfs/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`. **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 **mtime** — `stat` / `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. The unwritten `std.Thread` seam is therefore **not** a blocker (danos has native threads now — `thread_spawn`/futex — but the seam need not cover them yet). 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` 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` not truncating was a silent-corruption bug on this road — fixed in Phase 2.** The FAT engine's `writeFile` only *grows* `node.size`, so overwriting a shorter file used to leave trailing garbage — for a compiler that means **corrupt `.o`/cache files that look like nondeterministic compiler bugs.** `engine.truncate` + the O_TRUNC open flag (wired through the VFS protocol and `runtime.fs`) closed this 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 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. ## Related - [vision.md](vision.md) — the north star this serves. - [syscall.md](os-development/syscall.md) — the kernel↔runtime ABI `runtime.os` is built on. - [sysv.md](os-development/sysv.md) — the entry stack (`argc/argv/envp/auxv`) danos already constructs. - [ipc.md](device-driver-development/ipc.md) — the IPC the VFS/FAT operations travel over. - [danos-file-system-hierarchy-FSH.md](file-system-development/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).