The folded header stops being a rule in a document and becomes the layout
on the wire. Verbs number from sixteen, leaving describe, enumerate,
subscribe and unsubscribe reserved and answered the same way by every
provider — none of them writes a line to do it. What each protocol used to
carry in a field of its own now travels in the header: a vfs node and a
display layer are the packet's target, and a reply opens with a status the
envelope stamps rather than one each protocol spelled for itself.
Display gains the most. One forty-byte request had served eleven verbs, so
attach_scanout smuggled stride through x, refresh through y and format
through colour, and every coordinate crossed as a bitcast. Per-operation
structs end all three: the fields have their own names and their own signs,
and the tile payload grows to 224 bytes because the prefix shrank. Scanout
loses a message maximum of 64 it had no business declaring — it answers
calls, and the floor for a call is 256 — and virtio-gpu stops hard-coding
that number at its harness.
Two changes are semantic rather than notational. A directory now ends at an
entry with no name, because the fixed part of a reply always travels and a
zero-length reply no longer exists to mean anything. And input joins the
service harness, the last loop in the tree that answered no ping and heard
no terminate; its subscriber table, its pruning and its fan-out are the
same code, and a shutdown now asks it to stop instead of killing it.
A new conformance case reads the registry's own listing and asks every
protocol it finds for its name, its version and its verb count, then offers
a verb nobody defines and requires -ENOSYS — the envelope's promise,
checked against providers rather than against itself. What it cannot reach
in that boot it names on the serial line instead of passing quietly.
Suite 110/110.
The registry consults the open rows it has been parsing since P2, so
reaching a contract now takes a grant as well as a binding. A caller
without one is answered exactly as it would be for a name nobody ever
bound: same status, same empty reply, same absent capability, byte for
byte, and no log line on either path — klog_read is ungated, so a line on
one and not the other would be the oracle the design set out to remove.
Refusal and absence being one answer is what lets a supervisor later
narrow, fake or park a child's namespace without the child learning what
it was denied.
The manifest gains a third permission for a shape the plan did not
foresee: attestation is one hop, but the driver tree is three deep — the
PS/2 keyboard and mouse are spawned by ps2-bus, which the device manager
spawned — so no row could name them and PS/2 input would simply stop.
A supervise grant lets a delegate vouch for what its children *reach*,
never for what they claim; the bind path is untouched, and the laundering
deputy is still refused.
The review found the receive side of a rule this track had already
written down. Every process holds a sendable handle to the registrar —
resolve installs one for anyone who asks — and ipc_reply_wait never asked
who owned the endpoint, so a stranger could dequeue there: take the
provider endpoints riding bind requests, and answer other clients' opens
in the registrar's name. Receiving is the owner's privilege, like binding
a signal or a timer; sending remains anyone's.
Suite 109/109.
A protocol is reached by name now, not by a compile-time integer. Init is
PID 1 and already knows which binary it started, so init serves /protocol
as a vfs backend: bind claims a contract with the provider's endpoint
attached, open answers with that endpoint as the reply's capability, and
readdir lists what is bound with the task and binary behind it. The kernel
reserves the prefix — nothing may mount over it, under it, or unmount it —
and ServiceId, ipc_register and ipc_lookup are gone, their syscall numbers
left vacant.
A bind is authorized by who the caller *is*: the kernel-stamped binary
together with the supervising task's identity, matched against
/system/configuration/protocol.csv. Identity, not spelling — spawn is
ungated, so an attacker can run any bundled binary, and a name-only rule
would have let it launder grants through an init of its own making. A name
a live process holds is refused to everyone else; a dead one's is released.
Three review rounds against a hostile ring-3 process found what 108 green
tests could not, because the suite contains no attacker. Publishing init's
supervision endpoint as the registry put PID 1's mailbox in every process's
hands, where two forged bytes reached the shutdown path: privileged traffic
is now believed only from the task that holds the contract it speaks for.
A capability arriving on a request outlived every path that ignored it,
one handle per call until the table was full — in init, and in the harness
ten services share — so the arriving capability is owned by the turn and
released unless a handler says otherwise. And the kernel let anyone holding
an endpoint handle aim signals, timers, exit notices and interrupts at it:
binding now requires having created it.
Suite 108/108. The new protocol-registry case asserts eleven properties,
each one an attack that must fail.
A new user-memory module owns every kernel touch of a user buffer:
copyFromUser, the new copyToUser, and the resolve behind both. The walk
accumulates the U/S and writable bits down all four levels with the MMU's
own AND rule — folding a 2 MiB leaf in before it resolves and refusing a
1 GiB leaf outright — so a copy honours what ring 3 itself would be
allowed, closing the presence-only trust model the IPC layer carried since
bring-up. It then confirms the frame is physmap-backed, because that is how
the copy reaches it: an mmio_map'd BAR passes the permission walk and would
otherwise fault ring 0 on an alias the physmap never mapped, on the IPC path
as much as the new one.
The nine stragglers that dereferenced user pointers raw now route through
it, so a bad pointer returns -EFAULT where it used to fault the kernel.
The write direction restructures its callees around kernel bounce buffers:
scheduler and devices-broker enumerate from a slot cursor (a task exiting
between chunks can neither duplicate nor lose an entry), klog_read drains
the ring in chunks, and fs_node stages headers and names contiguously.
fs_resolve copies out before installing the endpoint handle, so a faulting
copy cannot strand a capability; its out-capacity bound no longer adds an
unbounded ring-3 length to the base, which wrapped and trapped the kernel's
own overflow check. debug_write reads the caller's message once.
Suite 107/107 (new user-memory case: seven bad pointers refused, each
paired with a sound call that must still succeed).
/etc/init.csv and /etc/devices.csv become /system/configuration/*.csv (the
repo's etc/ moves to system/configuration/, mirroring the runtime tree),
/var/log becomes /system/logs, and /mnt/usb becomes /volumes/usb. The
kernel VFS gains a carve-out so FAT may serve exactly /system/configuration
and /system/logs beneath the initrd-backed /system while /system and /test
themselves stay unshadowable; FAT's single /var mount splits into those two
rewritten mounts. The kvfs readdir check learns /system's third child and
the ramdisk spawn sweep skips the configuration tree.
Suite 106/106.
VT-d and AMD-Vi are x86 hardware, but lived in the architecture-neutral
kernel tree and leaked further: the core's public Kind enum named both
vendors, and the ACPI parser read the VT-d version/capability registers
(raw volatile MMIO inside table discovery). Now the vendor backends
live in architecture/x86_64/ behind architecture.iommu — the core hands
over the discovery facts plus an injected environment (frame allocation
+ the log sink, the same pattern enablePaging uses) and receives the
hardware vtable back, so the backends never import kernel internals and
an ARM port supplies its SMMU with no core change. Discovery keeps
table facts only; the live-unit register check moved into VT-d detect
(version reading zero now stays fail-open). The unused kindOf() is
gone. Log shapes the harness pins (iommu online, DANOS-IOMMU-FAULT)
are unchanged; all five IOMMU QEMU cases pass.
initial_ramdisk's spawn-everything sweep counted the /etc data files
(devices.csv, init.csv) as spawnable programs — BadElf ever since the
boot tree started ferrying them — so it now counts only the /system and
/test trees. The init test waited for two raw user writes before
checking the last write for the heartbeat text; init's boot chatter
(heap ok, the /etc/init.csv lookup) satisfies the count long before the
first beat, so it now waits for the heartbeat itself. Both cases pass
again; these failures predate the build-packages work.
The second hardware backend. The IOMMU core, DMA-region capabilities, and
per-device enforcement are unchanged; this adds AMD-Vi (IVRS) as an
alternative to Intel VT-d (DMAR) under the same Backend vtable.
- parseIvrs records the IOMMU control-register base from the first IVHD;
the platform layer gains iommu_is_amd, and the core picks the backend by
vendor at init. VT-d and AMD-Vi are mutually exclusive on real hardware.
- iommu-amd.zig: a 2 MiB device table (every DTE zeroed = deny-all until a
device is claimed), AMD native-format page tables (4 KiB leaves), a
command buffer (INVALIDATE_DEVTAB_ENTRY / INVALIDATE_IOMMU_PAGES /
COMPLETION_WAIT) and an event log for faults. The DTE forwards
interrupts unmapped, so MSI passthrough works exactly as on VT-d.
- The boot log and the iommu self-test are now vendor-aware.
**UNTESTED on real AMD hardware** — danos is developed on Intel, so this
is validated only against QEMU's amd-iommu, and every log line and doc
says so. QEMU quirk handled: its amd-iommu does not observe the
COMPLETION_WAIT store form, but consumes the command ring synchronously on
the tail-register write, so invalidations are already applied by the time
we poll — the backend warns once and proceeds.
Cases: amd-iommu (detection + scratch-domain walker) and
amd-iommu-usb-storage (full storage stack through AMD device-table
translation with per-grant capabilities), both green. 106/106.
This completes the IOVA/IOMMU-enforcement track: per-device DMA domains on
both vendors, with buffers reachable only through delegated capabilities.
Replaces L1's shared blanket identity domain with a private translation
domain per claimed PCI function. A device now reaches only:
- the DMA pool: every dma_alloc'd region, mapped into every claimed
device's domain (poolAdd/poolRemove, driven from the dma_alloc and
dma_free syscalls). This keeps the cross-process buffer handoff
working (fat's bounce buffer reaches the xHC) while blocking the
kernel, page tables, process heaps, MMIO, and unallocated RAM.
- its own firmware reserved region (RMRR), seeded at confine time.
The pool is the honest interim: devices can still reach one another's
DMA buffers. The DMA-region capability layer (next) narrows it to
per-grant reachability.
dma_free unmaps from every domain and invalidates BEFORE the frames
return to the allocator, closing the stale-IOTLB use-after-free window.
Driver death tears down its domains (detach + free tables) before the
broker claims and DMA frames are released.
New iommu_fault_drain syscall (+ driver.iommuFaultDrain) forces pending
fault records to the log on demand. The new iommu-fault case proves it:
a claimed e1000e is programmed to DMA-fetch its TX ring from an unmapped
page; VT-d faults the access (bdf 00:03.0 addr 0x1000 reason 0x6) and the
system stays alive. 104/104.
First enforcement step of the IOVA track. A vendor-neutral IOMMU core
(iommu.zig) drives an Intel VT-d backend (iommu-intel.zig) to give DMA a
real translation layer instead of the fail-open free-for-all M16 left.
- Boot posture is now stated explicitly: "iommu online (Intel VT-d)"
with version/agaw/rmrr, or "none present - DMA fail-open (unisolated)".
- DMAR parsing extended to select the INCLUDE_PCI_ALL unit (real Intel
PCs put an iGPU-scoped unit first) and record single-path-endpoint
RMRRs; multi-hop scopes and extra DRHDs are counted and warned, never
silently dropped.
- Translation is enabled at boot into a blanket identity domain (all RAM
+ RMRRs, 2 MiB leaves). PCI functions are enumerated post-boot by the
ring-3 pci-bus driver, so a device is attached to the domain when its
driver claims it (confineDevice, with claim rollback if confinement
fails) and detached on driver death, before broker release and DMA
frame teardown. Unclaimed devices are non-present: their DMA faults.
- Interrupt remapping stays off, so MSI writes to 0xFEE00000 bypass
translation and the interrupt-driven xHC keeps working.
- devices-broker gains pciAddressOf (derives BDF from the config-space
ECAM offset), unclaim, and forEachPciFunction.
Faults are drained and logged rate-limited as DANOS-IOMMU-FAULT.
Cases: iommu extended (translation on, scratch-domain map/resolve/unmap,
zero idle faults); new iommu-usb-storage and iommu-usb-hid run the full
storage + input stacks through translated DMA with MSI intact. 103/103.
library/device/pci is now the complete generic floor a leaf PCI driver
needs, instead of just what virtio-gpu used:
- pci-class: capability IDs, MSI/MSI-X/power-management/PCI-Express
register layouts, extended-capability header decode (host-tested),
per-bit command constants, remaining header offsets.
- pci.Function: header accessors, disableBusMaster + interrupt-disable
helpers, findCapability, programMsi/disableMsi, MsiX vector-table
struct, ensurePowerStateD0, functionLevelReset (BAR save/restore),
extended-capability iterator.
Proven by the new pci-caps QEMU case: a pci-cap-test fixture claims an
extra e1000e (PM+MSI+PCIe+MSI-X, no danos driver) and readback-verifies
every surface, including the first driver-side use of msi_bind.
usb-xhci-bus converts from 8 ms event-ring polling to message-signalled
interrupts: plain MSI where offered (real Intel xHC), MSI-X entry 0
otherwise (qemu-xhci has no MSI capability), byte-identical polling as
fallback. The timer survives as a 250 ms port-reconcile/lost-edge tick —
real-hardware USB2 hub debounce still needs it. MSI setup runs BEFORE
controller bring-up: QEMU's xhci only registers the MSI-X vector as used
when IMAN.IE is written while MSI-X is already enabled; interrupts are
silently dropped otherwise (real hardware does not care about the order).
101/101 QEMU cases green; real-hardware smoke passed (mouse works,
boot 2026-07-23T174805Z, plain-MSI branch, vector 33).
The 11 QEMU-suite fixtures lived mixed into system/services/ with their
binaries bundled at /system/tests/<name>. Now the repo path is the boot
path, like every real service: test/system/services/<name>. fat-test
moves out of the fat server's directory into its own; display-demo
stays a boot service.
- kernel VFS: setInitialRamdisk derives one read-only initrd mount per
top-level tree named by the ramdisk entry paths (/system, /test),
registers ancestors generically with self-parented roots, and refuses
backend shadowing of any initrd tree
- EFI loader: the fallback walk also enumerates \test (optional — a
volume without fixtures still boots); manifest and capsule unchanged
- path literals: vfs-test self-open + create probe, process-test
process_enumerate matches, the args-echo argv[0] expectation; the
kvfs case now covers the /test root end to end
- docs: DFHS /test rows, tree diagrams, loader prose, and the location
convention gain the third home; fixed the input-source link
100/100 QEMU cases pass.
The adversarial review of the branch confirmed the big one: the shm/DMA
page-table walks and their pmm/heap calls ran outside the big kernel lock —
pre-existing, but fatal once the per-space cursors invited sibling threads to
race them (two concurrent creates could orphan a page table: one thread's
region silently unmapped, the frame leaked — a plausible root for the
long-standing intermittent AP ring-3 fault at the shm base). All three paths
now follow the mmap discipline: allocation, object build, record, and handle
under one lock hold with full rollback; the map itself per-page under brief
holds; dma_free's translate/unmap/free per-page likewise.
Contract edges from the same review: thread_spawn into a dying group returns
-ESRCH (was generic -1); process_kill during the condemned window answers
from the latch's stashed supervisor (0 or -EPERM, was -ESRCH once the leader
slot was reaped); exit derives the group reason from its own argument rather
than the racy exit_code global; a worker's thread_exit no longer overwrites a
concurrent group-kill stamp; checkGroupDead now asserts exactly-one
notification via the drained ring. Full suite: 100/100.
Eight new QEMU cases (thread-fault-group, kill-threaded-group,
kill-via-worker-tid, racing-triggers, exit-group, leader-thread-exit,
thread-exit-solo, shm-mapping-ref) driving seven new thread-test modes; a
shared checkGroupDead asserts the contract everywhere: one notification,
badged with the leader, reason on the leader's record, no member listed,
claims released first.
The shm-mapping-ref case flushed out the per-task DMA/shared-memory arena
cursor bug directly (a sibling's regions mapped over the worker's), so both
cursors moved to the AddressSpaceRef like the mmap/MMIO cursors before them
(threading M7 pattern). Runtime gains Thread.tryExitCurrent for the leader
-EPERM refusal path. Docs updated: threading.md's shared-fate gap is closed,
process-management.md and process-lifecycle.md describe the leader re-key,
plan status = implemented. Full suite: 100/100.
Every task carries its process leader's id (main task: own id; threads:
copied from the spawner; kernel tasks: 0, never followed). process_kill and
process_signal resolve any member id to the leader and authorize against the
leader's supervisor, making both capabilities per-process. ProcessDescriptor
gains the leader field. No fan-out yet (docs/shared-fate-plan.md M1).
Per docs/coding-standards.md (no Unix-abbreviation exception): syscalls
shared_memory_create/map/physical, kernel SharedMemoryObject + handlers,
runtime.shared_memory (library/runtime/shared-memory.zig), the
shared-memory-server/-client test services, the shared-memory QEMU case,
and docs incl. vdso.md's danos_shared_memory_*. 87/87 QEMU tests pass.
runtime.fs now routes every path through fs_resolve: kernel-served
/system nodes are read via fs_node (tokens, no open state); everything
under a userspace mount goes straight to the owning backend's endpoint
with the kernel-rewritten mount-relative path — one syscall of naming,
then the unchanged vfs-protocol rendezvous, public API untouched. mkdir/
unlink/rename resolve-then-forward (rename checks both paths land on
the SAME backend); mount is the fs_mount syscall.
The fat server mounts twice — /mnt/usb from the volume root and /var
from its /var subtree — so the logger now writes the FHS path
/var/log/<boot-stamp>/... and swapping the persistent medium later
touches only fat's two mount calls. With clients holding fat's node ids
directly, fat records each handle's owner, checks it, and sweeps a dead
client's handles via the published exit events (the old router's
pattern, now where the state actually lives).
The userspace vfs server and its router die; ServiceId.vfs=1 stays
reserved-retired; protocol.zig moves to system/vfs-protocol.zig (the
wire contract is backend-only now). vfs-test becomes the ring-3 proof
of the kernel VFS (own-binary ELF magic through /system, read-only
refusals, listing); vfs-client-death becomes the fat sweep test over
the full storage chain, with a ring-scanning check (the last-write
buffer is too racy under a chattering tree).
system/kernel/vfs.zig is the resolve+redirect router: fs_resolve (#46)
walks the kernel mount table; a path under the kernel-backed /system
mount (the initrd, seeded by setInitialRamdisk with a derived directory
table) yields a permanent stateless node token served by fs_node (#47)
— read/status/readdir with copy-out, initrd reads lock-free — while a
path under a userspace mount yields the backend's endpoint (installed
in the caller's table, DEDUPLICATED so 16 slots can't be exhausted by
repeated resolves) plus the rewritten mount-relative path; the caller
then speaks the unchanged vfs-protocol rendezvous directly. The kernel
never blocks on a userspace filesystem, holds no open-file state, and
refuses create-intent on the immutable /system.
fs_mount (#48) is the syscall form of the old router's op-6 cap-pass
(possession of the backend handle is the capability; an optional
rewrite prefix maps the mount into the backend's namespace — how /var
will reach the flash volume); fs_unmount (#49) removes one. A dead
backend's mount clears lazily on resolve.
Dormant this milestone: the userspace vfs still serves runtime.fs
unchanged; the kvfs QEMU case covers the kernel side (resolution, ELF
magic read-through, /system listing, read-only + unknown refusals)
until the M-G cutover exercises the syscalls end-to-end.
tests.zig wrote markers straight to serial, racing user-process records
rendered on other cores — visible as 16-byte UART-FIFO interleave once
the tagged renderer emitted several writes per record. Markers now go
through kernel log print (same serial sink, now under the log lock), the
renderer composes each line into one buffer and hits each sink once, and
the vfs-client-death needle drops the old self-written 'vfs: ' prefix.
processRunning compared literal names against task names that are now
full binary paths; the acpi-ps2/usb-report harness regexes assumed
unprefixed driver names in device-manager spawn/restart lines.
Retire the build-time ramdisk packer and the packed initial-ramdisk.img.
make-fat-image.py now lays every user binary out at its FHS path on the
boot volume (system/services, system/drivers, system/tests), and the EFI
loader walks \system at boot, packing what it finds into an in-RAM v2
initial_ramdisk whose entry names are full FHS paths. init rides the
table like every other binary: its dedicated handoff fields are gone and
the kernel spawns PID 1 via the same lookup as everyone else
(process.spawnBundled).
system_spawn resolves names by exact path first, then unique basename,
and normalizes argv[0] to the stored path — so task names (and, next,
the tagged log ring's attribution) are honest binary paths everywhere.
initrd v2 rejects the old magic so a stale image fails loudly.
Groundwork for per-process logging (/var/log/<boot-stamp>/<binary-path>.log)
and the kernel-VFS /system mount.
Both EDID moments the system has are now captured and carried to the
compositor's frame clock:
- EFI: the loader derives refresh from the preferred detailed timing
(pixel clock / total pixels) while GOP is still alive — the only moment
it is readable — and hands it through the boot handoff into the
display0 node's DisplayInfo (new refresh_hz field, 0 = unknown).
- GPU: the virtio-gpu driver derives the same figure from its own EDID
read and carries it in the attach_scanout announce (request.y).
updateFrameClock() re-derives the interval from the active backend's
info at bring-up and again on every backend change — the boot
framebuffer's clock dies with the GOP floor at upgrade, replaced by the
GPU's rate. Unknown rate defaults to 60 Hz; the result is clamped to
[30, 120] Hz so a mis-parsed EDID can neither starve nor flood the
compositor. Rate only, never phase: without vblank, presents still
free-run (docs/display-v2.md, 'Fenced is not vsync').
Observed in QEMU: OVMF exposes no EDID for the VGA adapter, so the GOP
floor logs 'frame clock 62 Hz (default)' (real firmware does expose it);
the virtio-gpu EDID advertises 75 Hz and the upgrade logs 'frame clock
76 Hz (panel EDID)'. The display kernel test asserts refresh_hz rides
the seeded node; all 7 display QEMU cases pass.
Two real bugs visible in a normal `zig build run-x86-64` boot (but not in the
display-demo test, which spawns no input service):
- Two cursors. The demo drew its own cursor layer while the display service
now draws one too (its mouse-listener thread). The demo's went through the
client IPC protocol and lagged; the service's is in-process and tracks
tightly — "one responds better than the other."
- Animation frozen until the mouse moves. The demo called a *blocking*
`mouse.next()` (ipc_reply_wait) inside its animation loop, so the sliding
box advanced only one frame per mouse event. In the display-demo test
there is no input service, so subscribeMouse() returned null and the loop
ran free on its 30ms timer — which is exactly why the test passed while
the real boot was broken.
The compositor now owns the cursor (docs/display.md), so the demo should draw
none and read no input: it becomes a pure client-animation proof whose loop is
independent of the mouse. Removes its cursor layer, mouse subscription, and the
blocking read.
Also harden displayDemoTest to spawn the `input` service alongside the demo
(matching real boot): a client that blocks its animation loop on a mouse read
would now stall before `display-demo: ok` and fail the test, instead of
passing because no input service happened to be present.
Verified: display / display-service / display-demo / display-cursor all green.
The display's first use of threads (docs/threading.md, docs/display.md). The
compositor stays the single owner of the framebuffer — only the main
service.run loop touches the backend and layer stack — and a dedicated
mouse-listener thread runs beside it:
- Listener: blocks on input.subscribeMouse(), accumulates relative dx/dy
into an absolute cursor position clamped to the screen, and hands it to
the compositor. It never touches the compositor, so no lock guards the
framebuffer; a parked next() lets the core halt.
- CursorChannel: a single-slot latest-value cell under a Thread.Mutex (the
renderer wants where the cursor is now, not a replay of deltas), with a
coalesced self-ipc.send poke that wakes the main loop — parked in
replyWait — as a message-notification. At most one poke is queued while
the last is undrained, so a fast mouse can't flood the endpoint.
- Render: the cursor is a top-z compositor layer; on the poke the main loop
moves it via configure + present (which damages old + new footprints).
The display binary opts into threads (addThreadedUserBinary), and
input-source gains a "mouse" mode that publishes pure motion to drive it.
Two kernel-level findings this surfaced, both fixed:
1. IPC handles do not cross threads. The handle table lives on the Task, so
the listener can't reuse the main loop's endpoint handle — it
ipc.lookup(.display)s its own handle to the same endpoint to poke through.
2. Concurrent IPC from two threads raced unlocked kernel state. The display
is the first process issuing IPC syscalls from two threads at once, which
exposed a data race (flaky #GP in installEntry): create_ipc_endpoint /
ipc_register / ipc_lookup allocate from the kernel heap and mutate the
global registry, endpoint refcounts, and handle tables without the big
kernel lock. They were safe only while a process couldn't race itself.
They now sync.enter() like call/reply_wait/send already did (the kernel
heap has no lock of its own yet — heap.zig: "a lock comes with
threads/SMP" — so the big lock keeps its callers serialized).
Test: -Dtest-case=display-cursor (smp:4) spawns the input service, the
threaded display, and input-source in mouse mode; asserts the display's
"cursor tracking mouse ok" marker once the cursor has tracked a run of motion
end to end. Verified green 6/6 under stress (the race hit ~1-in-4 before the
lock fix) and in the full 29-case QEMU guardrail suite; zig build test clean.
The "fs.base" spelling read like a field/submodule access, but no such
identifier exists — it meant the x86_64 FS segment base (the IA32_FS_BASE
MSR). Two problems fixed:
- Arch-neutrality: in generic docs, the runtime, and the plan, the mechanism
is now named by its arch-neutral concept — the "thread pointer" — matching
the already-renamed `thread_pointer` Task field, `set_thread_pointer`
syscall, and `architecture.setThreadPointer` fn. x86-specific spots keep
the precise names: `IA32_FS_BASE` (the MSR), `%fs:8`/`%fs:0`, variant-II.
- Stale identifiers: the M10 section in threading-plan.md still referenced
`fs_base` on Task and `architecture.setFsBase` — both renamed away in the
arch-neutral pass. Corrected to `thread_pointer` / `setThreadPointer`.
The x86-only `thread-tls` test (which really does write `%fs:8`) now says
"FS base" (no dot) consistently, matching the established form already in
tests.zig. The matched serial markers ("thread-tls: ok" / "thread-tls:
FAIL") are unchanged; only a non-load-bearing FAIL parenthetical was
reworded.
Verified: zig build clean, thread-tls passes.
Expand the abbreviations flagged in docs/coding-standards.md (names spelled
out in full unless an acronym) across the kernel, runtime, ABI, tests, and
docs:
aspace -> address_space (AspaceRef -> AddressSpaceRef, retainAspace ->
retainAddressSpace, loaded_aspace -> loaded_address_space, the
liveAspaceCount/aspaceDestroyCount test hooks, etc.)
vaddr -> virtual_address
paddr -> physical_address
The kernel test case and its serial markers are renamed to match:
aspace-refcount -> address-space-refcount (kernel dispatch string and
test/qemu_test.py case name kept in sync). Prose in docs uses the natural
"address space"/"virtual address"; backticked field/identifier references
use the code spelling.
Also expand the bare "AS" abbreviation in three ABI comments and reframe the
set_thread_pointer ABI/handler docs to lead with the arch-neutral concept
(user-space TLS thread pointer; x86_64 IA32_FS_BASE, aarch64 TPIDR_EL0)
rather than x86 FS-first, matching scheduler.zig's existing framing.
Foreign ABI names preserved: the ELF p_vaddr field and mmap/mmio remain.
Verified: zig build, zig build test, and the full 25-case QEMU guardrail
suite all green.
runtime.Thread.RwLock (reader-preferring, lock/tryLock/unlock +
lockShared/tryLockShared/unlockShared) and WaitGroup (start/finish/wait), both on
the existing Mutex/Condition.
A compile-time Futex seam gated on builtin.os.tag: the futex syscalls on danos, a
spin+yield mock off-target (Zig 0.16 has no std.Thread.Futex; wake is a no-op
since the state machines re-check). thread.zig is wired into zig build test, so
Mutex/RwLock/WaitGroup run as host unit tests with real std.Thread threads (test
blocks compile only under test, so std.Thread there is fine on freestanding).
thread-rwlock QEMU case: 2 writers set both halves of a value under the exclusive
lock while 3 readers check they match under the shared lock; zero half-write
observations across ~150k reads.
Marks Phase 2 (M7-M11) built. threading.md/threading-plan.md status updated.
Gate: host zig build test covers the sync primitives; thread-rwlock PASS (3x);
full Done gate 26/26 (whole thread-* suite + guardrail); build clean.
Each thread gets its own x86_64 thread pointer (FS base) for user-space TLS.
Task.fs_base is restored on every context switch only when it changes (same
conditional-load discipline as CR3; architecture.setFsBase -> wrmsr
IA32_FS_BASE). New set_thread_pointer=44 syscall sets the caller's fs_base and
loads it now. The kernel never touches FS, so no swapgs complication.
The runtime lays a small per-thread TLS block at the top of each thread's stack
(self-pointer at %fs:0 + scratch) and the thread trampoline calls
set_thread_pointer before any user code — so every spawned thread has a private,
switch-stable thread pointer, reclaimed with the stack.
thread-test tls mode: two threads write unique markers to their own %fs:8 and,
after both wrote, read back — a shared fs.base would clobber one (cross-talk).
Deferred: the Zig threadlocal *compiler* layer (ELF variant-II PT_TLS + linker
sections + template copy) — high-uncertainty, no consumer today; this lands the
load-bearing per-thread fs.base it builds on. See docs/threading-plan.md M10.
Gate thread-tls PASS (3x); full guardrail 25/25; build + host tests clean.
join no longer needs a per-thread IPC endpoint. New thread_join(tid) syscall
blocks the caller until the task with id tid exits; the exit paths call
wakeJoinersLocked. join only reclaims the joined thread's USER stack, which the
thread vacates the instant it enters the kernel to exit, so waking at exit time
(not reap time) is safe — no reaper/aspace juggling or user-memory write, and
equally std-shaped (like pthread_join). thread_spawn drops the exit-endpoint arg
(runtime passes no_cap). thread-test's join mode runs 40 spawn+join cycles that
would exhaust the 16-slot handle table under the old endpoint scheme.
Also harden the M8 reaper: its single per-core reap slot could be overwritten by
a second death on that core before draining (a fresh-task/SMP timing window), an
intermittent one-stack leak that made task-reap ~20% flaky. Replace it with a
per-core reap LIST plus a .reaping task state so a pending slot can't be reused
before its stack is freed. task-reap now 11/11 isolated.
Deferred: detached-thread user-stack reclaim (still at process exit, as in M3).
Gate thread-join PASS (3x); full guardrail 26/26; build + host tests clean.
A dead task's kernel stack was leaked (no reaper), so every process/thread death
bled kernel memory. Now exit()/exitUserLocked record the dying task in a per-core
reap_after_switch slot and switch away; the task that resumes on that core frees
the stack in switchTo's tail (on its own stack, lock still held so the slot can't
be reused). reapKillPendingLocked drains the slot on the timer tick as a safety
net for the fresh-task case (a fresh task enters via the trampoline, bypassing
switchTo's tail). A task killed while not running is freed directly in
destroyTaskLocked. live_stack_bytes is the observable.
Fixed a migration bug this exposed: the post-switchContext reap read the pc
parameter, but a migrated task carries a stale pc in its saved switchTo frame ->
it freed the wrong core's pending stack (a #GP under SMP). Re-fetch thisCpu()
after the switch.
Gate task-reap PASS (5x isolated, 2x in the 24-case batch); full guardrail 24/24
incl. fault-recovery/supervision/process-kill/smp/affinity; build + host green.
Move the mmap/mmio grant-arena cursors off Task into the per-address-space object
(scheduler aspace_refs, exposed via aspaceMmapNextPtr/aspaceDeviceMapNextPtr), so
sibling threads in one address space hand out disjoint grants. systemMmap reserves
a range under a brief lock then maps per page under a short-held lock (not the
whole grant): the big lock runs with interrupts disabled, so pinning it across a
multi-MiB memset+map froze other cores. Guard the runtime heap's rawAlloc/rawFree
with a Thread.Mutex, gated on !single_threaded so ordinary binaries compile it out.
thread-test gains an alloc mode: 4 threads x 500 alloc/fill/verify/free cycles;
any overlap between concurrent allocations is caught by the pattern check.
Also fix the affinity guardrail: its 3-billion-iteration busy-loop had
codegen-dependent wall-time (adding a function to tests.zig swung it ~4s -> ~63s
and timed it out). Reworked to wait on the wall clock instead.
Gate thread-alloc PASS (3x); full guardrail 23/23 green; build + host tests clean.
New thread_self=42 syscall backs runtime.Thread.getCurrentId (the calling
thread's kernel task id). thread-test gains an id mode: two workers read
getCurrentId and the main thread confirms all three ids are non-zero and
distinct. Per-thread threadlocal TLS is deferred by design (no consumer; it would
need context-switched fs.base for an unused feature), as are RwLock/WaitGroup.
Marks the threading feature built (M1-M6): threading.md + docs/README.md status
updated, all thread-* cases wired into qemu_test.py.
Gate thread-id PASS; full suite green: 21/21 (thread-spawn/join/futex/mutex/id,
aspace-refcount, + 15 guardrail cases), zig build clean, zig build test green.
runtime.Thread.Mutex is the classic three-state futex mutex (unlocked/locked/
contended): the fast path is a single CAS and only a contended lock enters the
kernel. Condition is a futex sequence counter (wait/timedWait/signal/broadcast,
spurious wakeups allowed, use in a predicate loop); a signal racing the unlock
bumps the seq so it is never missed. Semaphore is permits guarded by
Mutex+Condition. All mirror std.Thread's shapes, ported onto runtime.Thread.Futex.
thread-test gains a mutex mode: 2 producers + 2 consumers move 2000 unique items
through an 8-slot ring (small enough that both sides block); the consumed
checksum and tally match exactly, proving the lock and condvars correct under
real cross-core contention.
Deferred with rationale (see docs/threading-plan.md): migrating join to a futex
completion word needs kernel clear-on-exit (else use-after-free munmapping a live
stack); host unit tests need a mockable Futex seam.
Gate thread-mutex PASS (3x); 17 guardrail/thread cases green; build + host tests
clean.
New private syscalls futex_wait(addr, expected, timeout_ns)=40 and
futex_wake(addr, count)=41. A waiter is a .blocked task tagged with
Task.futex_addr (no queue linkage); futex_wait reads the user word under the big
lock and parks only if it still equals expected, so a concurrent wake can't slip
between the check and the block. futex_wake scans the task table and readies up
to count waiters in the same address space. A timed wait also sets wake_at so the
existing wakeExpired times it out; futex_addr staying non-zero (only futex_wake
clears it) distinguishes timeout from a real wake. Waiters park in-kernel, so an
idle core still halts (no busy-wait).
runtime.Thread.Futex mirrors std.Thread.Futex (wait/timedWait/wake). thread-test
gains a futex mode: a waiter parks, the main thread wakes it (serial order
waiting/waking/woke, asserted by the case regex), and timedWait reports a
timeout.
Gate thread-futex PASS (3x); 18 guardrail cases green incl. sleep/event/ipc
blocking paths; build + host tests clean.
thread_spawn takes a 4th arg, an exit-endpoint handle: spawnThreadSupervised
resolves and refcounts it under the spawn lock (like spawnProcessSupervised), so
a thread's death posts a child-exit notification carrying its tid. runtime
Thread.join blocks in replyWait on that (private) endpoint for its tid, then
munmaps the stack; detach relinquishes the join (stack reclaimed at process
exit, for now). New current_core=39 syscall + Thread.currentCore() lets a worker
observe which core it ran on.
The closure now lives at the top of the thread's own (private) stack instead of
the heap, so spawn/join never touch the not-yet-thread-safe runtime heap.
thread-test gains a join mode: 4 workers x 100k atomic increments, joined, with
counter == N*K and >1 core stamped (real parallelism), plus a detached worker.
Gate thread-join PASS (4x, non-flaky); 17 guardrail/M1/M2 cases green; build +
host tests clean.
A thread is a task sharing the caller's address space. New private syscalls
thread_spawn(entry, stack_top, arg)=37 and thread_exit=38: thread_spawn goes
through scheduler.spawnThread (retains the shared aspace), thread_exit ends the
task like a process exit(0) (terminateCurrent -> releaseAspace, so the space
survives while siblings hold it). The closure pointer reaches the new thread in
rdi via a new jump_to_user_arg asm path and a per-task user_arg (0 for a normal
process, whose _start ignores it) - so the runtime trampoline is a plain C-ABI
Zig function, no naked asm.
runtime.Thread (library/runtime/thread.zig) mirrors std.Thread.spawn: mmap a
stack, heap-allocate the args closure, hand the kernel the trampoline + closure.
addThreadedUserBinary opts a binary into single_threaded=false; thread-test is
the first, and proves a worker runs in the shared address space via a shared
global the main thread polls.
Gate thread-spawn PASS; 16 guardrail cases green (incl. args/init/process on the
new jump_to_user_arg path) + aspace-refcount; build + host tests clean.
Route address-space lifetime through a refcount keyed by the page-table root
(scheduler.zig aspace_refs): retainAspace on the spawnUserLocked success path,
releaseAspace from both teardown paths (exitUserLocked, destroyTaskLocked),
destroying the space only when the last task on it exits. Behaviour is identical
today (every space has exactly one task); this is the foundation shared-address-
space threads (docs/threading.md) build on.
Test-observable liveAspaceCount/aspaceDestroyCount + a new aspace-refcount kernel
self-test and QEMU case: spawn and reap 5 ring-3 probes, assert live spaces return
to baseline and destructions advance by exactly 5 (destroyed once each, no leak,
no double-free). Gate passes; 13 guardrail cases green; build + host tests clean.
pciScanTest put three [64]DeviceDescriptor arrays on the stack; at 328
bytes each that is a 66,096-byte frame, larger than the 64 KiB bootstrap
stack the boot context runs on, so the prologue overflowed the stack and
triple-faulted on entry before the test could print anything — the CPU
reset with no exception line. Reuse one descriptor buffer (frame ~21 KiB).
With the fault gone, the restart-drill checks proved flaky. They polled
process.write_buffer (which holds only the latest write-syscall message)
for the transient restarting/re-scan lines, which the concurrent
class-driver output overwrote before the starved low-priority poller could
observe them. The kernel broker count can't witness the restart either:
register is idempotent and the table has no unregister, so the count is
invariant across kill/prune/respawn.
Follow the sibling restart drills (usbReportTest/driverRestartTest): assert
the ordered drill in the harness regex over the full serial log (a
backreference requires the respawn to re-scan the same count), and keep only
race-free broker checks in the kernel — empty before the scan, populated
once it settles, never growing past N — sampled via scheduler.sleep at a
fixed cadence instead of a busy-poll.
The compositor now survives the virtio-gpu driver dying and re-attaches when device-manager
restarts it — the last piece of display v2.
Three parts:
- The driver hellos the device manager (role: bus). It never did, so the manager — which
spawns it supervised and expects a hello — was stopping it at the 3s hello deadline every
run (the gate markers just printed first). Now it is properly supervised: not stopped for
silence, and restarted on death.
- A kernel IPC fix so a call to a dead service errors instead of hanging. An endpoint records
its owner; when that task dies, its registered endpoints are marked dead (and any parked
senders woken with -EPEER), so ipc_call returns -EPEER rather than blocking on a reply that
will never come. Without this the compositor's first present after the driver died blocked
forever. General robustness — any client of any service benefits.
- The compositor re-attaches. Its .scanout calls now fail cleanly (caught), freezing the last
frame; when the restarted driver re-announces, attach_scanout detects the backend is already
virtio and logs "scanout re-attached", mapping the fresh shared surface and re-looking-up
.scanout. (The previous shm mapping leaks — no shm_unmap syscall yet — but its frames are the
dead driver's, reclaimed on exit.)
- device-manager gains a "test-scanout-restart" mode (like test-usb-restart) that kills the
virtio-gpu driver once after it hellos; the displayReattachTest kernel scenario drives it.
Gate: python3 test/qemu_test.py display-reattach — "scanout upgraded to virtio-gpu" then
"scanout re-attached", no CPU exception, passing 3/3. host tests, ipc/ipc-call/ipc-cap,
supervision, shm, display-service, display-demo, virtio-gpu, display-native, and
display-modeset all pass; default zig build clean. v2 (V1-V6) complete.
The compositor now boots on the GOP framebuffer and upgrades to the virtio-gpu driver
the moment it announces itself — the pluggable-scanout payoff.
The shared surface. The scanout resource is an shm region the driver creates
(shm_physical, a new syscall, hands it the guest-physical for attach_backing) and passes
to the compositor as a capability. The compositor maps it and composites straight into
it: on x86 DMA is cache-coherent, so the cacheable shared pages the CPU paints are exactly
what the device transfers-and-flushes — no copy, no explicit flush.
The handshake. After bring-up the driver looks up .display and sends attach_scanout with
the geometry + the surface capability. The compositor maps the surface, looks up the
driver's .scanout endpoint itself (the driver registered it — no need to pass it), switches
to backend.VirtioGpu, and re-composites the current frame. present() over the native
backend is a present request on .scanout -> transfer-to-host + resource flush. The first
native present is deferred to a one-shot timer: presenting inline from the announce handler
would deadlock, since the driver is still blocked on our reply and not yet serving .scanout.
After it lands, the compositor reads a pixel back from the shared surface to confirm the
frame reached the device's backing.
- shm_physical (syscall 36) + runtime.shm.physical.
- scanout-protocol (the compositor->driver present channel), separate from the
client-facing display protocol; the display protocol gains attach_scanout.
- backend.VirtioGpu joins backend.Gop in the tagged union; select() still boots GOP.
- the virtio-gpu driver's scanout backing is now shm (was DMA); it announces + serves
.scanout present requests (transfer-to-host + flush of the shared surface).
Also fixes a latent framebuffer-geometry corruption the display service hit only when it
enumerated the device tree alongside a busy device-manager: Gop.init now captures the
geometry into a small value the instant device_enumerate returns (rather than re-reading
the 328-byte descriptor across the later claim/mmio_map syscalls) and retries on a zero
geometry. The underlying device-table clobber is a separate kernel bug, tracked apart.
Gate: python3 test/qemu_test.py display-native (QEMU -device virtio-gpu-pci) — "display:
scanout upgraded to virtio-gpu" + "display: native present verified" + "display-demo: ok",
passing 3/3. host tests, display-service, display-demo, shm, and virtio-gpu still pass.
The first native scanout backend's driver half. The device manager matches the
virtio-gpu PCI function by its Display/Other class triple and spawns the driver,
which claims the device, confirms vendor 0x1AF4/device 0x1050 from config space,
enables memory-space + bus mastering, and walks the virtio vendor capabilities to
find the common-config and notify structures in its BAR.
From there it is the standard modern-virtio bring-up: reset, negotiate VERSION_1,
stand up the control virtqueue in coherent DMA, then drive the GPU end to end —
RESOURCE_CREATE_2D, ATTACH_BACKING (a coherent DMA buffer; V4 swaps in the shm-
shared surface), SET_SCANOUT, paint a known pattern, TRANSFER_TO_HOST_2D,
RESOURCE_FLUSH, and wait for the device's used-ring ack. Reading the backing back
proves it is CPU-visible RAM; the ack proves the device consumed the frame —
together the automated stand-in for "it's on screen", no screenshot.
- system/drivers/virtio-gpu/: the driver, plus virtio-gpu-protocol.zig (control
commands) and virtio-pci.zig (the 1.0 PCI transport + split-virtqueue), both with
host-tested struct sizes.
- device-manager matches the display/other class triple to "virtio-gpu"; the driver
self-confirms the vendor/device id, since the class alone cannot distinguish it.
- ServiceId.scanout (11): the driver registers it so the compositor finds it in V4.
Gate: python3 test/qemu_test.py virtio-gpu (QEMU -device virtio-gpu-pci) — the
device-manager stack discovers the function, the driver brings up a 640x480 scanout
and flushes a test pattern: "virtio-gpu: scanout 640x480 online" + "flush acked,
pixel check ok". host tests, display-service, shm, and device-list still pass.
Note: the pre-existing pci-scan case triple-faults on main (verified at 88ad432,
before this change); it is unrelated and tracked separately.
Generalize capability passing from endpoints to memory objects. The per-task
handle table now holds kind-tagged entries (scheduler.HandleObject{kind, ptr});
closeHandles and shareCapability dispatch by kind, so a shared-memory object
rides an ipc_call send_cap exactly like an endpoint and is refcount-freed only
when its last capability drops.
- shm_create(len) -> vaddr, handle: contiguous, zeroed, cacheable frames wrapped
in a refcounted ShmObject, mapped into the caller's shm arena (PML4[230]).
- shm_map(cap) -> vaddr: map the same physical pages into a receiver that got the
capability. mapUserSharedInto maps WB-cacheable + device_grant, so a sharer's
teardown never frees the shared frames — the object owns them.
- runtime.shm: create(len) -> Region{ptr, handle, len}, map(handle) -> ptr.
Gate: qemu_test.py shm — shm-client creates a region, writes a pattern, passes
its capability to shm-server, which maps it and reads the same bytes back
(shm: shared 4096 bytes ok). ipc/ipc-call/ipc-cap/supervision/dma/usermem/
display-service and host tests all still pass — the handle change broke no IPC.
Drive the compositor from a separate process, proving the pipeline end to end.
- runtime.display: a Layer handle (fill / blitTile / configure / damage /
destroy), createLayer, and a color(r,g,b) helper that caches the mode and
packs via protocol.pack. Coordinates are signed over the u32 wire fields
(@bitCast both ways), so a layer may sit or move partly off-screen.
- system/services/display-demo: the input-source analog for the compositor — a
full-screen wallpaper, a rectangle it slides back and forth (moved by
configure each frame, so the damage-driven present repaints old + new), and a
cursor. Presents in a loop paced by runtime.time. Wired into build + initrd.
Fix this surfaced: protocol.message_maximum was 4096, but the kernel caps every
IPC message at MESSAGE_MAXIMUM = 256, so replyWait rejected the oversized receive
buffer with -E2BIG and the serve loop had been spinning since D2 (invisibly, as
those gates matched init-time heartbeats). Set it to 256; blit_tile is now
explicitly a small-tile path (larger bitmaps are the deferred shm surface).
Gate: `python3 test/qemu_test.py display-demo` — the demo drives frames of
motion through the layer client API and logs `display-demo: ok`. Regression:
zig build test, display (D1), display-service (D2/D3), and default zig build.
Stand up /system/services/display: a ring-3 process that claims the framebuffer
D1 seeded, maps it write-combining as the front buffer, allocates a cacheable
back buffer, and presents composed frames. The GUI track's compositor, reached
by name over ServiceId.display (= 9).
- protocol.zig: the display wire protocol (info/create_layer/configure_layer/
destroy_layer/fill_rect/blit_tile/damage/present). `info` and a whole-screen
`present` are live; the layer ops fail-stub until D3.
- display.zig: enumerate -> claim -> mmio_map(WC) the LFB, mmap a cacheable back
buffer, clear it and present it (proving the double-buffer path), then serve.
- runtime.display + barrel exports (display, display_protocol): a cached
`.display` client with info()/present(), the runtime.block shape.
- init spawns "display" in boot_services; build.zig wires the protocol onto the
runtime, builds the exe, packs it into the initial-ramdisk, installs it.
mmap fix the back buffer forced: systemMmap was capped at 256 pages (1 MiB) by a
fixed kernel-stack scratch array. Rewrote it to map page-by-page with rollback
(no array) and raised the cap to 8192 pages (32 MiB) — enough for a 4K back
buffer. A real limitation met.
Gate: `python3 test/qemu_test.py display-service` matches the service's own
serial heartbeats (display: online WxH / presented frame 0), printed only after
the full claim -> WC-map -> back-buffer -> present chain. Regression-checked
usermem, heap, init, and D1's display.
Kick off the display service track (docs/display.md, docs/display-plan.md): a
user-space compositor that owns the framebuffer. GOP and the PCI display device
are two views of one controller; GOP dies at ExitBootServices, so the portable
base is the boot-handoff linear framebuffer.
D1 makes that framebuffer reachable from user space over the existing device
claim/mmio_map path rather than a bespoke syscall:
- device-abi: a `display` DeviceClass, a DisplayInfo{w,h,pitch,format} on the
descriptor, and a flags field on resources with a write-combining bit.
- devices-broker: seedDisplay() publishes the loader's framebuffer as a
root-level `display` node (one WC-flagged memory resource); kmain seeds it
after discovery. displayDevice()/displayClaimed() track the claim.
- paging/mmio_map: mapUserDeviceInto gains a write_combining bool — a WC-flagged
resource maps through PAT entry 4 instead of strong-uncacheable (an
uncacheable framebuffer blit is glacial).
- console: falls silent while a display service holds the framebuffer, and is
forced back on by the panic/exception paths.
Gate: the `display` kernel test asserts the seeded node's shape and that the
claim + mmio_map leaf is genuinely write-combining (PAT bit set, PCD/PWT clear).
Regression-checked discovery/ioport/claim-release/supervision/device-list/
device-manager with the +1 device in the table.
Kernel discovery interpreted the whole DSDT/SSDTs (~0.5 MB -> ~9700 nodes) solely to extract the _S5 sleep type for a kernel-side soft-off — ~1-2s of work on the single-core, pre-scheduler critical path, with nothing to overlap it. The ring-3 acpi service already parses the same blobs and owns S5 end-to-end (reads pm1a_cnt from the FADT, writes SLP_TYP itself; orderly-shutdown tests it). So drop the kernel parse entirely.
- all *static*-table parsing stays (MADT/HPET/FADT/MCFG): CPUs, timers, PCIe, and the power register map are still detected from ACPI, not legacy/compat addresses (timer still calibrates via HPET/PM-timer/CPUID, PIT only as last resort)
- kernel keeps reboot (FADT reset register + legacy fallbacks — no AML); soft-off is userspace-only now
- removed PowerInformation.s5/s3, the kernel AML namespace, aml_stats, amlDeviceCount, and the aml import; power.shutdown/enable/sleepS3 gone
- tests: poweroff case retired (S5 covered by orderly-shutdown); discovery drops its S5/AML checks; acpi-parse self-verifies against a device-count floor since there's no kernel count to match
Adds real (calendar) time, the foundation for filesystem timestamps. Monotonic
time (`clock`) says how long since boot; this says what time it actually is.
- cpu.zig (x86_64): readRtcUnixSeconds() reads the CMOS real-time clock (ports
0x70/0x71) — waits out an update-in-progress, reads twice until stable, handles
BCD-vs-binary and 12-vs-24-hour per status register B — and converts to Unix
epoch seconds (UTC).
- kernel/wall-clock.zig: reads the RTC once at boot and anchors it to the monotonic
clock, so a query is a cheap arithmetic offset — no per-call CMOS poll, no lock,
no SMP hazard on the shared ports. kmain calls init() once the monotonic clock is
final and logs the epoch.
- wall_clock() syscall (33) -> Unix epoch seconds, wrapped by runtime.system
.wallClock(). Wall-clock *seconds* are mechanism the kernel owns like the
monotonic clock; calendars/timezones are user-space policy (the stale comment on
systemClock that called wall-clock a "user-space service" is updated in spirit by
the new handler's doc).
- A `wall-clock` kernel test asserts the boot RTC read is a plausible current epoch.
Verified against the host: the guest read epoch 1783971244 while `date -u +%s` gave
1783971245 (one second of boot lag) — the CMOS read + epoch conversion are correct to
the second. zig build, zig build test, and smoke/clock/init are green.
Add a FAT12/16/32 filesystem the VFS mounts at /mnt/usb, reading and writing a
USB stick through the block device. Verified end to end under QEMU: the fat
server mounts the volume, the VFS routes /mnt/usb to it, and a client lists the
root and reads a file (the ELF magic of /mnt/usb/system/kernel).
- engine.zig: the FAT engine over a BlockDevice interface — mount (a bare FAT or,
as QEMU's VVFAT and most real sticks present it, an MBR-partitioned disk), FAT
chain walk (12/16/32), cluster allocation, directory traversal with long-name
read, and file read / write / create. Host-tested against a RAM-backed FAT16
image (create, cluster-spanning write, mid-file overwrite, read-back, list).
- on-disk.zig: the align(1) boot-sector / directory / long-name / FSInfo structs
and the cluster-count FAT-type detection.
- fat.zig: the server — wraps the .block device (a DMA bounce buffer) in a
BlockDevice, mounts the FAT, serves the vfs-protocol as a backend, and mounts
itself into the VFS at /mnt/usb. Spawned by init as a boot service.
- runtime.block: the block-device client (geometry / read / write by physical
address, so whole sectors never cross IPC).
- Raise the kernel service-name registry (maximum_services) 8 -> 16: it is
indexed directly by ServiceId, and fat = 8 was being rejected, so the fat
server exited before registering.
- VFS: an absolute path with no matching mount is now not-found rather than
silently created in the flat ramfs — so /mnt/usb fails cleanly until mounted.
Tests: fat-mount (the full stack: block -> FAT -> VFS mount -> list + file read)
passes; host units cover the engine and on-disk structs; the vfs, shutdown, and
USB regression suite stays green (10/10).
Flesh out the xHCI host-controller driver into a full transfer engine and build
the three USB class drivers on top, all verified end to end under QEMU.
- xHCI engine (usb-xhci-library.zig): controller reset, command/event rings with
cycle-bit bookkeeping (gated on a No-Op-command proof), device slots, Address
Device, control transfers, full chapter-9 enumeration, Configure Endpoint, and
interrupt/bulk transfers. Each interface is device_registered with its
(class,subclass,protocol) identity, unique per (port,interface).
- Bus<->class transfer protocol (usb-transfer-protocol.zig + runtime.usb): open /
control / interrupt-subscribe (async report pump on a poll timer) / bulk-by-
physical-address, so sector data never crosses the 256-byte IPC limit.
- USB HID keyboard + mouse (usb-hid/): decode boot-protocol reports and publish
to the input service. A USB usage is already the input protocol's keycode.
- USB mass storage (usb-storage/): Bulk-Only Transport + transparent SCSI,
serving a block device under the new .block service id (block-protocol).
- device-manager matches USB interfaces to class drivers (usbDriverForIdentity).
- usb-abi / usb-ids made importable modules; add HID and mass-storage class
requests, packTriple, and a usb_device DeviceClass.
- Fix test/qemu_test.py on macOS: the QMP unix-socket path was built from the
deep worktree path and exceeded the 104-byte sun_path limit, so QEMU exited
before booting. It now lives under a short temp path.
Tests: usb-report, usb-hid, usb-storage pass under python3 test/qemu_test.py;
host units (usb-abi, usb-ids, hid-report, bulk-only-transport, scsi) green.