# 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.<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](../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 | 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](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`.

**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. 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 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](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).
