Review findings: the plan doc claimed no implementation existed, had the domain dependency order wrong (kernel depends on protocol; device on kernel + protocol + csv), never placed the three shared contracts, and named a nonexistent new-driver-checklist.md. Its status now records the implemented phases (and the deliberate pci-bus-first pilot), the target shape carries the contract placements and the path-dependency-only constraint on the kernel package's out-of-root abi export, and the execution notes describe the post-pilot build for whichever session runs the remaining waves. README's repo layout gains build-support/ and the packages-note; driver-model, threading, system-requirements, and the two display plan docs stop citing root build.zig for recipe facts that now live in build-support.
8.4 KiB
Plan: packages — hierarchical builds for libraries and binaries
Status: in progress. Implemented on branch claude/build-packages-plan-174144:
phase 0 (build-support), phase 1 (all six library domains as packages, root as
the pilot consumer), and the first phase-2 binary package — pci-bus, a
deliberate deviation from wave A's two-small-services opener, because a driver
with per-binary extras (device-manager-protocol, pci-class) exercises the
template harder than a plain service. Every phase landed green (unit tests, the
QEMU suite at parity with main, boot-image file list unchanged). Remaining:
waves B–D of phase 2, then phase 3.
Why
build.zig was ~1,250 lines, growing by three hand-written stanzas per binary;
at a driver per device family that does not scale. More fundamentally: in one
monolithic build every binary compiles against library source, so a library
interface break is silently absorbed by whoever edits everything in one commit —
the interface never has to be honest. danos is about isolation; the build should
mirror it.
A package here is a build-time unit only — a directory owning a build.zig
(recipe: what it exports, how to test it) and a build.zig.zon (manifest: name
- dependencies). Binaries remain fully static freestanding ELFs; packages change
who declares what, not what links to what. Source code is untouched:
@importuses module names ("pci","service") exactly as today — only build files know where anything lives.
Target shape
build-support/ package: the danos build API (userBinary(), defaultImports(), targets)
library/kernel/ package "kernel": modules abi, ipc, service, memory, process, logging, time, ... (depends on protocol)
library/device/ package "device": modules driver, pci, usb-abi, model, ... (depends on kernel, protocol, csv)
library/protocol/ package "protocol": the wire protocols
library/client/ package "client" (depends on kernel, protocol)
library/csv/ package "csv"
library/xkeyboard-config/ package "xkeyboard-config"
system/services/<name>/ one package per binary: ~15-line build.zig + zon
system/drivers/<name>/ one package per binary
build.zig (root) orchestrator: dependency() per binary, image assembly, QEMU, test steps
The three shared contracts: boot-handoff stays a root module (only the
loader↔kernel pair speaks it); abi is exported by the kernel package from
../../system/abi.zig (the source stays with the kernel; userspace's one view
of it lives in the package, so every consumer names the same module instance);
device-abi is exported by device. Reaching outside the package root means the
kernel package is valid only as an in-repo path dependency — it could never be
fetched by hash — which is fine: path dependencies are the only way any of
these packages is consumed.
Rules:
- Dependencies are declared at domain level (a binary's zon names
kernel,device), imports stay module-level (@import("pci")).build-supportpre-wires the core set every binary uses (service, ipc, memory, process, logging); per-binary build files name only extras. - Modules export source, not artifacts — each consumer compiles libraries with its own flags, so per-binary optimization choices keep working; Zig's cache deduplicates.
- Zon paths are relative and that is accepted. Binaries sit exactly three
levels deep, so the
../../../prefix is a constant idiom; a library-domain move is a rare, already-breaking event fixed by one sed across manifests, and a stale path fails loudly before anything compiles. - Cross-cutting build changes live in
build-supportonly — that is the contract that keeps per-binary build files declarative.
What this buys
- Library interfaces become machine-checked: a consumer can only import what a domain exports, and each domain's zon declares what it needs (claim-before- touch, applied to source).
- Each library domain gets a standalone
zig build test— runtime-library stability testing in isolation. - Adding a binary = adding a directory (source + two small files), not editing three places in a 1,250-line file.
- Later:
lazyDependencylets an image target build only what it ships.
Phases
Each phase ends green: zig build test passes (88/88 QEMU) and the boot
image's file list is unchanged. Byte-identical binaries are expected but not
required (module reorganization can perturb symbol order); file list is the
hard gate.
Phase 0 — build-support. Extract addUserBinary/addThreadedUserBinary,
the freestanding target setup, and the default-import wiring into the
build-support package. Root build consumes it; nothing else moves. This is
the cross-cutting-change home, so it lands first.
Phase 1 — library domains become packages. In dependency order: protocol
and csv (the roots) → kernel (depends on protocol: file-system speaks
vfs-protocol) → device, client; xkeyboard-config stands alone. Each gets
build.zig + zon + a standalone test step (client's is empty until its modules
grow host tests — kept for uniformity, since the root aggregate depends on
every domain's test step). The root build swaps its createModule calls for
b.dependency("<domain>").module("<name>"). No binary moves in this phase
— the root build is the pilot consumer, which proves the packages without
touching 30 binaries.
Phase 2 — binaries become packages, in waves. The template was shaken out by the pci-bus pilot (see Status). Wave A: services. Wave B: the remaining drivers. Wave C: test fixtures. Root build shrinks to orchestration per wave.
Phase 3 — root cleanup. Split what remains of the root build into
build/images.zig, build/qemu.zig, imported by a short root build.zig.
Afterwards (outside this plan): the intel-uhd-graphics-750 driver is (re)created as a greenfield package — the "Adding a driver" checklist's build step (docs/device-driver-development/devices-csv.md, step 1) gets rewritten against the package template at that point.
Execution notes (for whichever session runs the remaining waves)
Anchors in the root build.zig as it stands after the pilot:
- The shared recipe lives in
build-support/build.zig:userBinary(domains form, what binary packages call),userBinaryFromImports(the underlying recipe root's stanzas still use), anddefaultImports— the ONE list of default modules; root and the packages both draw from it. Thestartroot shim anduser.ldare named through the kernel package (Dependency.path). programModule(<exe>).addImport(...)calls in root (searchprogramModule) are the per-binary extra imports — the data for each binary's future build.zig.system/drivers/pci-bus/build.zigis the template to copy.- The boot-tree array (search
"etc/init.csv"or.getEmittedBin()) is the image file list — the authoritative before/after comparison target. A converted binary's stanza becomesb.dependency("<name>", .{}).artifact("<name>")plus a zon entry. - The QEMU size-check tests hardcode source paths (search
virtio-gpu-protocol.zigin the root test list) — they move with their binaries' waves.
Verification per phase:
- Unit tests:
zig build test. - QEMU integration suite:
python3 test/qemu_test.py(docs/testing.md; the full suite, all cases must pass). - Image file list: the boot-tree array is the source of truth — snapshot it
(paths only) before phase 0 and diff after each phase;
zig build check-fat-imagemust also stay green.
Context a fresh session should read first: this doc, docs/testing.md,
docs/coding-standards.md (kebab-case names, no abbreviations), and the
addUserBinaryImpl body. Commit style: no Co-Authored-By trailers.
Risks / notes
- Zig version churn: the package API (
b.dependency, zon schema) has moved between releases; the work pins against the repo's current Zig and any upgrade lands separately, never mid-phase. - The QEMU size-check tests hardcode source paths (e.g. virtio-gpu protocol struct sizes in the root build) — phase 2 wave C must carry those along.
- Doc updates ride each phase: docs/README.md (repo layout + source map), docs/device-driver-development/devices-csv.md ("Adding a driver", step 1), and the docs that cite the build recipe (driver-model.md, threading.md, system-requirements.md) reference build shapes that keep changing.