A panic no longer ends in a picture. The report is drawn as before, and
then GMMOS runs under it: the real kernel stays dead -- the other
processors are halted, and nothing of it is called that locks, sleeps,
allocates or waits for an interrupt -- and GMMOS keeps its own clock
(the TSC), its own keyboard (micro drivers, polled), and the screen.
On the report: a live status line counting down the three-minute
power-off, which any key cancels, and the keys.
Space the last 300 lines of this boot's log as QR codes, a page per
symbol, numbered; + and - trade module size for capacity
L the log on screen, scrolled
T a terminal: why, log, grep, ps, cpus, pci, mem (page tables
walked first, so a bad address cannot fault), uptime, restart,
poweroff
R R restart; P P power off -- twice, so a stray key does neither
The first micro driver is PS/2, polled: kbd.c with everything but the
keys taken out. The probe asks the keyboard to echo, so a controller
with nothing behind it is not offered as one. Polling also sidesteps the
IRQ 1 polarity quirk, which is about interrupt routing.
A panic inside GMMOS (or a second one in the report) is reported and
then only blinks; it does not re-enter GMMOS.
Checked under QEMU: every screen; two QR pages read off a screenshot
with zbar decode to the log text exactly; restart reboots; left alone it
still powers off at three minutes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
A laptop's touchpad is on the chipset's I2C bus, found only through ACPI
(PNP0C50 / ACPI0C50), and SchOS had no I2C at all, so the touchpad of
every recent laptop did nothing.
kernel/acpi/i2c_hid.c drives the DesignWare I2C controller every x86
chipset uses (AMDI0010 on Ryzen) by polling, speaks HID over I2C to the
device the _DSM points at, and feeds the reports through the USB HID
parser. On AMD the interrupt pin's level is read every tick, so a report
is read exactly when one is waiting; elsewhere the input register is polled.
The touchpad is driven in mouse mode, which it starts in after a reset:
motion, clicks and taps become /dev/input/event1 like any mouse. The HID
parser now prefers a descriptor's relative pointer over an absolute one
for this, and takes descriptors from transports other than USB.
Untested on hardware: QEMU emulates no I2C touchpad.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
SCFF, the SchOS Compressed File Format (include/fs/scff.h), is a block
image cut into 128 KB chunks, each its own zstd frame, behind an index.
tools/mkscff writes it from fs-ram.img on every core (-19, 11 s) and
checks each chunk decompresses to itself. The kernel carries the .scff
as its ramdisk and unpacks a chunk the first time a block in it is
read; zero blocks share one page. The kernel and the ISO are the
compressed size, and the root costs memory only for what is touched.
See docs/architecture/scff.md.
zstd 1.5.7 comes from ports/zstd like uACPI: the decoder is compiled
into the kernel at -O2, with a static context and a 64 KB stack of its
own, and mkscff builds the whole library from the same release.
world now has xorg-server, the fbdev and evdev drivers, xinit, Window
Maker, xterm and DejaVu, and /root/.xinitrc runs wmaker. That root is
358 MB, 102 MB as SCFF.
The ISO no longer carries the root a third time as an ISO 9660 tree,
which nothing read. schos.iso: 1389 MB -> 318 MB. El Torito keeps its
own copy of the EFI image, the layout real machines have booted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
The live root was a fixed 96 MB image in the kernel: 60 MB of zeros on
the ISO, twice, and room for 60 MB of packages whatever the machine had.
Now the image is only what the root holds, plus slack, measured by
building it once large. Past its end the ramdisk goes on to half of the
memory free at mount, as pages taken the first time each block is
written; an unwritten block reads as zeros and costs nothing. SchFS
keeps no free-space map on disk, so growing a volume is a larger total
before open builds the map: grow_to, set for the ramdisk root.
8 GB machine: a 4 GB root, Xorg installed into it in 77 s with 7.6 GB
still free. The ISO is 172 MB instead of 344.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
make qemu and make iso went on building sysroot/ as the root while only
make aroot built the apk one. Both now come from the apk root: a 1 GB
schfs.img for make qemu, and a 96 MB ramdisk for the ISO, 35 MB of it
the base system and the rest room for apk add on the live system.
sysroot/ is what schos-base is packaged from, not a root of its own.
hwdata leaves the ramdisk exclude list: it is Alpine's package now, and
a root missing files apk's database lists is one apk has to repair.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
make aroot builds a SchFS root from Alpine's minirootfs, the packages
ports/alpine/world lists, and SchOS itself as the signed apk package
schos-base, in its own repository inside the root. apk add installs from
Alpine; apk upgrade updates both. Booted: zsh is Alpine's, apk update
sees 28669 packages, apk add htop works, and so does xorg-server.
world.lock pins every package with its sha256; a build fetches only what
dist/alpine/ lacks and never looks for newer versions. make alpine-lock
is the one step that does.
Alpine's zsh, busybox commands and hwdata replace SchOS's copies; SchOS
keeps its own tools, toybox's other commands, and static mksh, which
schinit's recovery shell now tries first.
Also: the dependency-file include matched Alpine's usr/lib/sysctl.d, a
directory, and stopped every make. See docs/plan/alpine.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
There were none: fcntl answered F_SETLK with EINVAL and flock was not a
system call. Alpine's apk, which runs unmodified on SchOS through
Alpine's own musl, locks its database before reading it and stopped
there.
kernel/fs/lock.c keeps one table of advisory locks keyed by (dev, inum):
flock() for the whole file, owned by the open file description;
F_SETLK/F_SETLKW/F_GETLK for byte ranges, owned by the process and
released when it closes any descriptor on the file or exits; and the
F_OFD_* forms, ranges owned by the description. flock and record locks
do not see each other, as on Linux. Waiters sleep until any release and
are interrupted by a signal.
cmd/locktest checks each between a parent and a child, including range
splitting, F_GETLK's report, and a blocked waiter being woken.
shell.nix gains pkg-config, meson, ninja, m4, bison, flex and xz, for
the X ports.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
Xorg on a framebuffer -- the fbdev video driver, the evdev input driver,
libpciaccess -- asks the kernel a fixed set of questions before it draws
anything, and gives up on the first wrong answer. These are those
answers.
evdev: EVIOCGVERSION, GID, GNAME, GPHYS, GUNIQ, GPROP, GBIT, GKEY, GABS,
GREP and the accepted setters. Xorg and libinput decide what a device
*is* from the capability bitmaps; /dev/input/eventN answered only
EVIOCGRAB, which reads as no device at all. Each device's bitmaps are
its defaults plus whatever it has reported, so a USB tablet makes the
pointer absolute.
fbdev: FBIOPUT_VSCREENINFO succeeds for the mode already set, as on
Linux -- the fbdev driver sets it every time it starts -- and still
refuses any other. FBIOPAN_DISPLAY to the origin; FBIOBLANK.
The console: KDSETMODE(KD_GRAPHICS) stops it drawing on a terminal,
KDSKBMODE(K_OFF) stops keys reaching its tty, and VT_PROCESS now does
the switch handshake: leaving the terminal sends relsig and waits for
VT_RELDISP, arriving sends acqsig. A terminal whose owner has died goes
back to the kernel whole, so a crashed X cannot leave the console dead.
/sys: boot_vga, a writable enable, and graphics/fb0 under the display
device, which is how the fbdev driver pairs a card with /dev/fb0.
ports/linux-headers: <linux/*.h> into musl's prefix, from sabotage-
linux's kernel-headers 4.19.88-2. Its checksum is recorded from the
first download; upstream publishes none.
cmd/xready makes each call and checks the answer, including the switch
handshake on tty2 and an owner that dies holding it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
The set libwayland's event loop is made of, in kernel/sys/sys_event.c:
four kinds of descriptor with no inode, FD_EVENT, that read() drains
and poll() reports on. They were table entries returning ENOSYS that
read as done.
epoll registers (descriptor, file) pairs, so a closed descriptor
leaves the set; level- and edge-triggered, EPOLLONESHOT. Waiting is
poll()'s, a tick at a time. The old eventfd and signalfd numbers (284,
282) were aliased to the new calls and read flags from a garbage
register; they are their own entries now, with epoll_create and
epoll_pwait.
cmd/evtest checks each, then all four in one set. The plans are
brought up to date: everything Wayland was waiting on is built except
setsockopt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
A program that names an interpreter in PT_INTERP gets it loaded too, at
the bottom of the mmap area, and started instead -- with AT_PHDR,
AT_PHENT, AT_PHNUM, AT_BASE and AT_ENTRY to find the program by. ET_DYN
images are placed at a base: the interpreter there, a PIE program where
a fixed one would go. Static musl programs get AT_PHDR now as well,
which is how musl finds their TLS template.
mmap(MAP_FIXED) of a file handed back zeroed pages whatever the
descriptor said, and kept no record of them, so fork() did not copy
them. A dynamic linker maps every library segment after the first that
way. Fixed mappings now read the file and are recorded wherever no
mapping already covers them.
musl is built shared as well as static, and libc.so ships as
/lib/ld-musl-x86_64.so.1 -- libc and loader in one file. Every port
still links -static on purpose.
cmd/dynhello is the test: against libc.so and a library of its own,
checking TLS, the heap, and a fork whose child still has both.
mprotect is accepted and not enforced yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
From a clean tree the first make failed with "no rule to make target
build/lwip/core/init.o": a pattern rule is only used when its
prerequisite exists or can be made, and before the unpack lwIP's .c
files were neither. A second make worked, which is why it went unseen.
The unpack now claims the .c files. And sysroot-install waits for it,
because its own make -C ports unpacked lwIP too, and two unzips under
-j collided.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
-isohybrid-gpt-basdat does nothing without an MBR template, so the ISO
had no partition table at all: written to a stick, it was a CD
filesystem at sector 0. AMI on a B550 (and OVMF) read El Torito off any
block device and booted it anyway. An HP all-in-one from 2012 looks for
an EFI system partition on a USB disk, as the spec says to, found none,
and listed the stick under Legacy only.
The FAT image now goes in a second time as MBR partition 2, type 0xEF,
and is FAT16 by cluster count rather than FAT12, the type old firmware
reads worst. Booted under OVMF both as a CD and as a USB disk. The ISO
also rebuilds when mkiso.sh changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
Sixty-five headers sat side by side in include/, so `#include "irq.h"`
said nothing about where irq.h was or what it belonged to, and errno.h,
stat.h and fcntl.h at the top shadowed the system's for any host tool
built with -I./include. They are in folders now -- arch/ core/ fs/
dev/ gfx/ net/ uapi/ (and ldls/ for the Linux driver layer) -- and
every include names the folder, as Linux's do: "core/proc.h".
Done mechanically and checked the hard way: the kernel built from this
tree disassembles identically to the one built from the tree before
it. The full image boots, and userland is the same set of programs.
Paths in docs and comments follow; docs/guides/layout.md has a table
of what is where, and tools/fix-includes.py rewrites old-style
includes from the folders in the tree, for code written before this.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
Nothing ever saved the FPU and SSE registers on a context switch.
musl's memcpy, the compiler's struct copies and any arithmetic on
doubles keep live values in %xmm, so two processes sharing a CPU wrote
over each other's: two shells counting in a loop on one processor took
a NULL+8 page fault within seconds, at a different rip every time. It
reproduces on kernels from before this week too -- it had always been
there, and every crash nobody could explain was a candidate.
sched() now fxsaves the process leaving the CPU and scheduler()
fxrstors the one arriving. A new process starts from the ABI's state
(FCW 0x37F, MXCSR 0x1F80), fork copies the parent's live registers,
exec resets them, and a signal handler's are put back by rt_sigreturn
so the interrupted code finds what it had.
The kernel was free to use %xmm too -- GCC vectorised struct copies
and memset into 153 instructions of it -- so an interrupt could
clobber the registers of the program it interrupted with no switch at
all. Kernel, lwIP and uACPI objects are built -mgeneral-regs-only now
and the image has none; cmd/ keeps SSE.
Checked under QEMU: three concurrent loops three times over, on 1 CPU
and on 4, no faults; before, every run faulted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
hwreport writes one folder to the first read-write FAT mount, or mounts
the first USB stick that takes vfat itself and unmounts it afterwards:
summary.txt (machine, firmware, CPU, memory, every PCI and USB device
with its driver or "-- no driver"), dmesg.txt, lspci.txt in the shape of
lspci -nnk -xxx, lsusb.txt, dmi.txt, memmap.txt, the ACPI tables as raw
.dat files iasl -d reads, and copies of /proc. Serials, the UUID and the
MSDM/SLIC tables (a Windows licence key) are left out.
It reads /sys, which is new: sysfs at Linux's paths and in Linux's
formats -- /sys/bus/pci/devices with uevent, config and resource,
/sys/bus/usb/devices, /sys/firmware/acpi/tables, dmi/tables, memmap
and efi, /sys/class/dmi/id. toybox's lspci and lsusb work unmodified
against it. The USB core now keeps a table of attached devices for it.
The machine's name comes from SMBIOS (kernel/dev/dmi.c). The UEFI loader
hands over the entry point from the configuration table, the kernel
falls back to the 0xF0000 scan, and the log gets Linux's "DMI:" line.
Two bugs found on the way:
- The loader handed over at most 40 memory-map entries, in firmware
order. A laptop's UEFI map is 60-150 descriptors with RAM above 4 GB
listed last, so that is what was dropped. The entries now have their
own area at 0x31000 with room for 256, are sorted before merging, and
the kernel's 32 usable regions keep the largest when there are more.
- MemTotal and sysinfo's totalram were PHYSTOP, an address, which counts
the PCI hole: a 6 GB machine reported 8. Now the usable RAM, summed.
usb.ids moves to /usr/share/hwdata, where lsusb looks for it, and
pci.ids joins it (`make pci-ids`, BSD-3-Clause like usb.ids).
Checked under QEMU q35: the report written to a FAT32 stick, fsck.fat
clean, DSDT.dat disassembled by iasl on the host, lspci -k and lsusb
showing names, 6 GB of RAM seen as 6137 MB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
The filesystem every other system can read: a USB stick, an SD card, the
EFI system partition. It is here first for the hardware report, which has
to leave a machine on something that can be read on another one.
mount -t vfat /dev/usb0p1 /mnt (msdos and fat work too; -o ro)
Written from Microsoft's specification, and matched to Linux vfat where
the specification leaves a choice: 8.3 names with the NT lowercase bits,
long-name entries otherwise, Linux's alias rule (REPORTS, then BIGNUM~1),
case-insensitive lookup, UTC times, the boot-sector dirty bit, every FAT
copy and FSInfo kept up to date. It uses its own sector cache over the
new blkdev_xfer, since a partition need not be a whole number of 4 KB
blocks, and every operation that changes something reaches the disk
before it returns. Inode numbers come from the entry position, or the
first cluster for a directory, plus a small table for entries that moved.
Around it:
- /dev lists every disk and partition the block layer found (major 8,
minor = block device), and raw reads and writes on them go by sector.
- The ESP and MBR FAT partitions are registered, typed so the root probe
passes over them. MBR entries with a bad status byte are skipped, so a
superfloppy's boot code is not read as a partition table.
- statfs, sync, fsync, umount (EBUSY while in use), shutdown, reboot and
the power button all know about FAT.
- create undoes and reports a failed ialloc or dirlink (ENOSPC, EINVAL,
EPERM) instead of panicking; fs_ops gains refuse_types and no_hardlinks.
- filewrite returns a short write instead of panicking on it.
- utimensat is implemented. As a stub returning 0 it made toybox touch
believe the file existed, so `touch newfile` created nothing on any
filesystem.
Tested in QEMU against mkfs.vfat images (FAT12 floppy, FAT16 on MBR,
FAT32 superfloppy and GPT ESP) by writing, renaming, deleting, filling to
ENOSPC and truncating there, then checking with fsck.fat -n and mtools on
the host: all clean, contents match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
The firmware's AML is now interpreted. uACPI 6.1.1 (MIT) is fetched by
ports/uacpi, checksum-verified, and compiled into the kernel unmodified,
the same way lwIP is. kernel/acpi/host.c is its host layer: memory,
mapping, port and PCI config I/O, time, mutexes and events that spin
before the scheduler exists, the SCI, and an "acpi" thread for the
work the SCI defers.
PCI interrupts go where the board says. pci_init() now routes every
device's INTx through the root bridge's _PRT before drivers bind. It
follows link devices' _CRS, uses a bridge's own _PRT where the namespace
has one, and swizzles through bridges that don't. Before this, INTx
waited on the 8259's line number, which is the wrong pin in APIC mode.
irq_register() takes a trigger mode rather than a level/low flag,
because q35's links and most SCIs are level, active-high. The MADT's
interrupt source overrides are now parsed, and are what places the SCI.
The power button syncs the filesystems and powers off. shutdown() and
reboot() go through the firmware's S5 and reset register first, and
only then fall back to the emulators' ports, which a real board does
not answer.
Tested under QEMU: q35 with MSI off routes xHCI to GSI 22,
level/active-high, and it takes real interrupts with nothing polled.
system_powerdown powers off q35 and i440fx (QEMU exits 0), and q35
with 4 CPUs boots from NVMe with USB hot-plug working alongside.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
A native driver written from the NVMe 1.4 base specification. It resets
the controller the firmware left running, then sets up an admin queue and
one I/O queue pair. Completion is polled, with interrupts disabled at the
queue, for the same reason AHCI polls: without AML, interrupt routing on
real hardware cannot be trusted. Each active namespace with 512-byte to
4 KB sectors and no metadata is registered as nvme<C>n<N>, the name
Linux uses, and its GPT is read like any other disk's.
Tested under QEMU q35: disk.img on NVMe as the only disk boots through
OVMF, roots on nvme0n1p2, and writes read back after sync. A
4 KB-sector namespace registers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
q35 stopped with "no root filesystem" because its disk is SATA, like
every PC since 2008, and SchOS only had IDE. And SchFS could only sit
on a whole raw device, so nothing could boot from a disk that also
carried an ESP.
- include/blkdev.h, kernel/dev/blkdev.c: a registry of block devices
found at probe time, numbered from 4. A disk's GPT (or MBR) is read
when it registers, and each partition becomes a device whose block 0
is the partition's first sector. SchFS needs no format change to live
in a partition; a Linux SchFS driver will see the same bytes on
/dev/sdX2. SchFS has its own GPT type:
47138FA9-B79C-47A6-9F7D-AC1F7F572D5E.
- AHCI driver, written from the AHCI 1.3.1 spec: BIOS handoff,
COMRESET when the link is down, IDENTIFY, 48-bit DMA read/write,
FLUSH. Completion is polled, so the root disk does not depend on
interrupt routing.
- USB sticks also register as usb0, so their partitions are found.
- The root probe tries registered devices between USB and IDE,
SchFS-typed partitions first.
- make disk / tools/posix/mkdisk.sh: a GPT image with a 64 MB ESP and
a SchFS partition, ready to dd to a stick or a disk.
Tested in QEMU q35: the old two-disk setup roots on sata1; disk.img as
the only SATA disk roots on sata0p2; disk.img as a USB stick (firmware
boots from it) roots on usb0p2. Writes survive sync in each case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HH7xukt4aaiGj1nQEAoQf1
Nexoniarz is the sole author and SchOS was never published under
Apache-2.0, so this is a straight relicence. LICENSE is the FSF GPLv2 text,
every first-party SPDX header moves to GPL-2.0-or-later, and NOTICE,
README.md, docs/licensing.md and the site say so. projects/schfs-linux
follows the rest of the tree.
The GPL gives copyleft: a fork has to ship its source. It also lifts the
old "Linux drivers, never" rule, since GPL-2.0-only code combines with
GPL-2.0-or-later. "Or later" leaves room for GPLv3, but one binary still
has to pick one version, so docs/licensing.md now keeps Apache-2.0 and
GPLv3 code out of the kernel to keep Linux code possible there. BSD
4-clause code goes from "avoid" to "no", because the advertising clause
is GPL-incompatible.
LDLS still ships no .ko. That boundary is now project policy rather than
a legal requirement, and ldls.md, devices.md and licensing.md section 6
say so. refmod declares license=GPL, and ldls-check passes (27 checks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two NICs, and unlike the RTL8111 last commit, QEMU emulates both -- so both
are tested rather than reasoned about.
**RTL8139** is its own driver and not a mode of the 8169, because it is its own
chip. No descriptor rings: the receiver is one circular buffer the chip writes
packets into back to back behind four-byte headers, and the transmitter is four
fixed slots. Putting both programming models behind one set of function
pointers is how a mode flag ends up threaded through every path.
Three details are the whole driver. CAPR runs sixteen bytes behind the true
read position -- a quirk of the part, not an off-by-one. WRAP means the chip
runs a packet past the end of the buffer rather than splitting it, so the 8 KB
buffer is allocated as 8 KB + 16 + one maximum frame; getting that wrong is a
chip writing past its allocation at line rate. And a length field of 0xFFF0
means "still writing this one", so treating it as a length copies a partial
packet and loses the alignment for everything after it.
**The e1000 now matches the PCIe 8257x parts.** Its header refused them, with
a note saying they "differ in NVM access and initialisation, and claiming a
device this driver has not been shown to initialise is worse than not claiming
it". That was right, and the second half is answerable: QEMU emulates the
82574L, so it can be *shown* to initialise.
What differs turned out to be one register. EERD moved its address field from
bits 8-15 to bits 2-15 and its done bit from 4 to 1; the rings, RCTL, TCTL and
ICR are identical. So there is a per-instance flag for the NVM layout and seven
more ids -- not a second copy of four hundred lines of ring code. The wrong
pair of constants fails silently, reading the wrong word or waiting for a bit
that never sets, and the MAC comes out as zeroes; that is why it is named in
the header rather than papered over.
Still refused: the ICH/PCH parts (82562, 82577-82579, I217-I219), which put the
PHY behind a MAC-PHY interconnect and need a different bring-up nothing here
can test.
Tested, each booted alone: rtl8139 (10ec:8139) and 82574L (8086:10d3) both bind,
read a real MAC, complete DHCP and ping 4 of 4 with no loss. The classic
82540EM does the same, which is the regression that mattered for the NVM
change. Both supported display configurations: one boot each, no traps, no
panics, socktest and memfdtest green.
Also recorded: the Chromebox CN62 carries an RTL8111, not an Intel part, so
last commit's driver covers it too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The laptop's NIC is an RTL8111H, and the request was to add it to LDLS.
Measuring it is what changed the answer, so the measurement is the commit
message: r8169.ko (6.18.52) has **183 undefined symbols, 130 of them not
implemented by ldls/kpi/, and 35 of those 130 are phylib** --
phy_connect_direct, phy_start, mdiobus_read, genphy_soft_reset and the rest.
phylib is a subsystem with a link state machine, not a set of leaf calls, and
LDLS's usual answer for an unimplemented symbol -- a stub that reports itself
and returns zero -- is exactly wrong for it. phy_start returning zero means the
link never comes up and there is nothing to look at. The 8139too that ldls.md
uses as its model has no phylib dependency, which is why that one was
tractable and this one is not.
So: a native driver. It reaches the PHY through the chip's own MII window --
PHYAR is one register that is a whole MII bus -- and skips the subsystem. Less
work than the port, no GPL entanglement, no firmware blob, no coupling to one
Linux version, and it covers the whole RTL8111/8168 family because all of it
answers to 10EC:8168. The register layout is a fact about the hardware, taken
the way ldls.md section 3 says facts may be taken; the expression is this
tree's, in the same shape as the e1000 beside it.
**It has not run on hardware, and that is not a hedge.** QEMU emulates rtl8139
and nothing else from Realtek, and the 8139 is a different programming model --
a circular receive buffer rather than descriptors -- so it is deliberately not
in the match table. What is verified: it builds, its presence changes nothing
on a machine without the device, and with an rtl8139 on the bus it correctly
leaves it unclaimed. What is not: every register write, on a real part.
The probe therefore says everything a first bug report needs, with cprintf
rather than kinfo so it reaches the screen of a machine with no serial port:
r8169: xid 5c0, 1c:1b:b5:xx:xx:xx, irq 11, link up (bmsr 796d)
xid is the hardware version from the top of TxConfig -- the PCI device id has
been 8168 for twenty years and says nothing. bmsr is MII register 1: bit 2 is
link, bit 5 is autonegotiation complete. Those four facts separate "the chip is
not there" from "the PHY sees nothing" from "the rings are wrong".
Two constants were wrong in the first draft and are recorded because both would
have worked quietly. RxConfig had 0x7700 for "unlimited": MXDMA is bits 8-10
and RXFTH is bits 13-15, so that was one bit short of a full burst and a
half-full FIFO threshold. And CPCR bit 3 was being set under a comment calling
it "PCI multiple read/write" -- bit 3 is PCIDAC, 64-bit addressing, and bit 4
is what the comment described; neither is wanted, so the write is gone.
Both supported configurations: one boot each, no traps, no panics, socktest and
memfdtest green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
schdbgd ships Disabled, and its manifest explains why: a debugger that can read
the process table should not run on a machine whose owner never asked for one.
That default is right and has one bad consequence -- **the machines that most
need debugging are the ones you cannot get a shell on to enable it.** A laptop
with no serial port that stops before it can draw has nowhere to say why, and
`schinitd enable` needs a prompt that does not exist.
So `make DEBUG=1` adds a sixth entry to the F8 menu. Picking it toggles;
booting carries the choice through SCHBOOT_DEBUG_FLAG; the kernel publishes
/proc/bootdebug; schinitd starts the one job that flag may un-disable, with
the manifest's Args now naming 0.0.0.0 -- a debugger reachable only from the
machine being debugged is no use when that machine is the one that will not
talk to you.
Four decisions, since this is a network-reachable debugger:
compiled out, not hidden A release build cannot set the flag because the
code that would set it is not in the binary. A
menu item that is present and refuses is an
invitation to make it not refuse.
consent at the machine Somebody standing at the keyboard during boot is
the strongest consent this system can ask for and
the weakest one that is any use on hardware that
will not boot.
one job, named in schinitd Not a manifest key like EnableIfDebug: the general
version of "the boot menu starts things that ship
off" is worse than the specific one, and this way
what the flag can start is one readable line.
the kernel only publishes Nothing in the kernel behaves differently, so a
debug kernel and a release kernel are the same
kernel and a bug cannot be the flag's fault.
/proc/bootdebug is its own file rather than a second line in /proc/bootmode,
because bootmode's whole promise is that reading it is a string compare and
not a parser.
**Also: `tone` stopped lying.** On a machine with no sound card it reported
"the device will not take 16-bit samples", which sends you looking at the
format code instead of at the empty PCI slot -- /dev/dsp exists whether or not
hardware is behind it, and the ioctl is where the absence shows up. ENODEV is
now told apart from a format refusal.
Verified end to end: F8 -> item 6 -> boot -> schinitd says it is starting
schdbgd -> a client outside the VM connected to the forwarded port and ran
`dma`, getting the real arena dump back. The release build has no such entry
in the binary at all, and boots clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A program that wants to make a sound opens /dev/dsp, writes PCM, and closes
it. That is what UNIX audio has always been and it now exists here:
tone 440 2
Three pieces, in the order a sample passes through them. **/dev/dsp** is a
character device where write() is audio and a handful of OSS ioctls say what
the samples are. **AudioEngine** is everything above a driver that is not
specific to a chip -- the format, whether there is room, and what a write does
when there is not. **A driver** is bound by class, exactly as a display driver
is: device_find_class(DEVCLASS_AUDIO).
OSS numbering rather than something new, because every UNIX audio program ever
written already knows it and the only thing a different set would achieve is
making ports harder. They are read/write ioctls, which is the part that
matters: a program asks for 44100, the device sets 48000 and writes 48000
back, the program resamples. One that ignores the answer plays 9% fast, and
that is its bug rather than the driver's.
**The engine is deliberately one stream.** Mixing several programs is a real
feature with a real design behind it, and building it before anything had
played a single sound would be exactly what docs/plan/1.0.md keeps a register
for: work that exists and is connected to nothing. The second program that
wants to play at the same time is what should cause it to be written.
**audio_wait() sleeps on the timer tick, not on the device.** That looks like
the lazy choice and is not: a ring that drains is a ring with room, so asking
again in ten milliseconds always makes progress, whereas sleeping on the card
makes audio depend on an interrupt arriving. On a machine whose line is
misrouted that is not a stutter, it is a writer that never wakes. The handler
still accounts for finished buffers -- deleting it would cost efficiency, not
correctness -- and it retires descriptors through the same `tail` pointer
queue() uses, so the two cannot disagree about what is in flight.
**AC'97 first because HDA is two unproven things at once.** It is the simplest
audio device that exists on real machines and in QEMU both. Intel HDA is what
the laptop actually has and is a much larger driver; writing it against an
interface nothing had ever played through would have meant debugging the
driver and the interface simultaneously. HDA arrives as a second
struct audio_ops and nothing above it changes -- which is the entire point of
the class.
Two things the wiring needed: PCI class 0x04 now maps to DEVCLASS_AUDIO (the
driver bound and /proc/devices showed it, but audio_get() found nothing,
because the class byte had never been translated), and include/x86.h grew
inw() -- AC'97's mixer is all 16-bit registers and reading one a byte at a
time returns the low half twice on some codecs.
**Tested by measuring what came out, not by not crashing.** QEMU's wav backend
writes the guest's audio to a file; `tone 440 2` produced 1.97 seconds of
44.1 kHz stereo with peak amplitude 14177 against the generator's table
maximum of 14178, and 880 zero crossings per second -- 440 Hz exactly. wav is
on every QEMU target rather than a host backend, because a build machine may
have no sound card and it is the only backend whose output can be checked.
Both supported configurations: one boot each, no traps, no panics, socktest
and memfdtest green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"but Virtio GPU is still non functional. it only shows logo" -- and that was
a fair reading of it. A second monitor showing a mark for ever is
indistinguishable from a second monitor that does not work.
The mark was the right answer to the question hybrid.md asked: H2 and H3 needed
a screenshot that proved a *userspace process* had drawn something, and that
the driver came back after being killed. Both are closed. What was left was a
display whose entire output was a logo, on a machine whose console was sitting
on the other head.
So vgpud mirrors the console onto it.
**A copy with a diff.** /dev/fb0 maps the console's framebuffer; the virtio
backing store is already mapped, because vgpud allocated it. The backing
therefore holds, at all times, exactly what was last sent to the device --
which means it *is* the previous frame, and "what changed" needs no third
buffer. Ten times a second: compare rows, skip the ones that match, copy the
ones that do not, transfer the one rectangle spanning the rows that changed.
Hence present() becoming present_rect(). Moving one line of text through a
full-screen transfer of a 1280x800 resource is most of a megabyte of copy for
a few hundred bytes of difference, and this loop runs whether or not anything
is happening.
Mapping /dev/fb0 does not take the screen from the console: the pages are the
ones the console draws into, and fb_mapped() only arranges a redraw when the fd
is closed. This one never is.
Sizes differ, so a head larger than the console centres it on the theme ground
the mark already draws, and a smaller head shows the top-left corner -- which
is where a terminal puts everything worth seeing. Scaling is the alternative
and it is the wrong one; text resampled to a non-integer ratio is text you
cannot read. The blocking accept() became poll() with the frame interval as its
timeout, because holding the device is no longer the only thing to do.
Not the endpoint: a compositor will want this head for itself, and when there
is one this goes away. Until then the honest thing for a working display to
show is the system that is running on it.
**And a bug older than any of it.** schmode reported "cannot reach vgpud on
/run/vgpud -- it is not running" about a process that was running perfectly
well. Every boot after the first, on any image whose root is writable, and
nothing to do with this change -- it reproduces on the previous commit, 3 runs
in 4.
bind() creates a node at the path, and /run is a directory on the root
filesystem rather than a tmpfs. The node outlived the process that bound it and
the boot it was bound in, so the next boot's vgpud claimed the device, drew,
held it -- and then failed bind() on a name that already existed. A driver
nobody could talk to, and an error message naming the wrong cause.
It unlinks before binding now, which is what a Unix daemon does. The usual
objection, that this lets a second instance steal a live socket, does not apply
here: two vgpuds cannot coexist, because the first holds the PCI claim and the
second is refused the device long before it reaches bind(). The claim is the
mutual exclusion; the socket node never was.
Verified by screendumping both heads over QMP: with the console at 1280x800 the
two are byte-identical, and after `schmode 1024 768` the virtio head carries
the console at the new mode. Three consecutive boots of one image all reach a
working control socket, where the previous commit managed one. Both supported
configurations: one boot each, no traps, no panics, socktest and memfdtest
green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gate 1, item 2, and the thing a Wayland buffer crosses on.
memfd_create(2) is the same segment machinery shmget() already had -- pages,
refcounts, no_free VMAs, the fork and exit accounting -- reached by
**descriptor** rather than by System V key. That difference is the entire
point and the reason it exists on Linux at all: two processes share memory by
passing the fd over a socket, so neither has to agree on a path or race to
invent a key. It is also why SCM_RIGHTS had to be item 1.
docs/plan/foundations.md proposed exactly this and it was right: what memfd
adds on top of shmget is a segment that starts empty and is sized by
ftruncate(), and a file that owns one reference to it. FD_MEMFD, and
read/write/lseek/fstat all learn about it -- a descriptor that refuses read()
is a surprise nobody needs.
Three things are refused rather than faked, each the honest version of
something this kernel cannot do:
- MAP_PRIVATE on a memfd. Every caller is sharing on purpose; a private
mapping of an anonymous object is an expensive MAP_ANONYMOUS.
- Mapping past the end. Linux faults those pages in; there is no handler
here that could.
- Shrinking while mapped. Linux truncates the pages out from under the
mapper; here a freed page would stay mapped to memory somebody else now
owns.
Writable MAP_SHARED of a *real file* is still ENODEV. That needs a page cache,
and an anonymous object has no such contract to break -- which is precisely
why memfd is the narrow answer.
**cmd/memfdtest**, seventeen checks, written so a permissive implementation
cannot pass: an mmap that quietly returned private zeroed pages satisfies every
return code and fails the one assertion that matters, because the reader is a
different process. It ends with the combination the plan singled out -- a memfd
sent over a socketpair to a receiver that closes every inherited copy first,
maps only the descriptor that arrived, reads what the sender wrote and writes
back where the sender can see it.
Two things were found by writing that test, both of which had been refusing
everything that was not FD_INODE:
- sys_fstat() never reached filestat(), so the memfd branch there was
unreachable from the syscall that needs it. It also refused pipes and
sockets, and returned -1 (which musl reads as EPERM) rather than an errno.
- sys_lseek() the same, and SEEK_END needs a length that is not in an inode.
Verified: 40 consecutive runs leave MemFree at exactly the value it started
at -- 936656 kB before and after -- so nothing leaks across creation, dual
mapping, descriptor passing and exit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gate 0's last structural row. kernel/core/kmem.c: slabs are pages from
kalloc() with a header at the front, and free objects hold the free list in
their own first eight bytes.
That last detail is the whole design. **An object carries no header and
freeing performs no search**: the page base of any object *is* its slab, and
the slab names its cache. It is also exactly why an object cannot exceed a
page, which matters below.
Two interfaces. kmem_cache_create/alloc/free for a subsystem with one kind of
thing -- it gets its own line in /proc/slab, which is most of why naming it is
worth the argument -- and kmalloc/kmfree over power-of-two classes from 16 to
2048 for code with no single type to build a cache around. Each cache keeps
partial and full lists; a slab that empties goes straight back to kalloc()
rather than being kept, so /proc/slab never overstates what is held.
`make kmem-test` asserts the bookkeeping on the build machine. Same arrangement
as schfs-objtest, and for the same reason: slab lists, free-list threading, the
page-to-cache lookup and whether pages actually come back are all things that
can be wrong without a kernel to be wrong in. 26 checks, including one that a
permissive allocator cannot pass -- write a distinct pattern through 512
objects and read them all back, because two objects sharing storage shows up
there and nowhere else.
One bug the test found immediately: kmem_max_obj() returned the raw page
remainder, which kmem_cache_create() then rounded *up* past its own limit. The
largest legal size was reported as illegal.
**It does not do what the register said it would.** The claim was that every
fixed table exists to work around kalloc. Measured:
struct proc 10656 NPROC 128 larger than a page
struct socket 4928 NSOCK 32 larger than a page
struct buf 4272 NBUF 256 larger than a page
struct inode 280 NINODE 200 a cache, bounded on purpose
struct device 120 NDEVICE 32 3.8 KB, never hit
struct file 48 NFILE 512 24 KB, never hit
The three largest cannot go in a page-backed slab whatever it does. NINODE is
a cache and is meant to be bounded. NFILE and NDEVICE are small enough that
their tables are rounding errors and their ceilings have never been reached.
Freeing the big ones needs a contiguous multi-page allocator, which is a
different thing and is not written. That is now in the register and in
docs/architecture/kernel.md rather than left as a claim nobody checked.
What it does fix is real, and was measured rather than assumed:
- **A pipe is 640 bytes and came from a whole 4096-byte page.** Every `|` in
a shell command burned three and a half kilobytes it never touched. Six
pipes per page now, and /proc/slab shows the page going back when they
close.
- **procread() built its answer in a 2048-byte array on the kernel stack**,
under a comment that had warned about exactly that since it was written --
it stayed a local because a whole page for 2 KB felt worse, and kmalloc is
the answer that was missing. Its frame is 64 bytes now instead of ~2100.
- procfs's other generator took a page from kalloc for the same 2 KB.
Verified on a boot: /proc/slab tracks pipe and kmalloc-2048 through real
pipelines, allocs and frees balance, and $(), ps, time, readlink, sigtest and
rename are all still green with no traps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Went looking for why `X=$(echo hi)` hangs every shell. Did not find it. Found
two other things, and built the instrument that rules out the largest suspect.
**The signal return trampoline was ABI-blind.** deliver_signals() manufactures
a return address for the handler -- seven bytes on the user stack reading
`mov eax, 15 ; syscall`. Fifteen is *Linux's* rt_sigreturn, which is right for
anything musl-linked, and that is every port and every shell. For a first-party
binary it is native syscall 15, which is `open`. So a handler in any cmd/
program ran, returned into the trampoline, called open() with whatever the
handler had left in its registers, got -ENOENT, and carried on executing the
stack bytes after the trampoline.
pid 14 sigtest: trap 14 err 4 rip 0x46ca7 addr 0xfffffffffffffffe
rax=fffffffffffffffe ... rsp=46ca0
ustack: 46e30 46ca0 50f0000000fb8 0 0 0
rip is rsp+7 -- one past a seven-byte trampoline -- and the third word of the
stack is the trampoline's own bytes. It emits the number for the process's ABI
now; syscall_map is the thing that says which numbering this process speaks.
**SIG_BLOCK, SIG_UNBLOCK and SIG_SETMASK were not defined anywhere.**
sys_sigprocmask branched on bare 0, 1 and 2 with the names in comments, so
anything outside the kernel had to spell the number and hope it had the right
one. Defined in posix.h, used in the kernel.
**cmd/sigtest** asserts the SIGCHLD/sigsuspend contract every shell is built
on, and it is written so a permissive stub cannot pass: a blocked SIGCHLD stays
pending, sigsuspend delivers an *already pending* signal without spinning, the
handler runs exactly once, the mask is restored, the child is still reapable,
and a signal arriving mid-wait also wakes it. Eight checks, all green. The
kernel's half of that contract is now proven rather than assumed, which is what
lets the remaining bug be pursued somewhere else.
Along with it, `rt_sigsuspend` in lib/usys.S -- the syscall existed and the
first-party library simply never exposed it.
On the hang itself: it is not job control, not SMP, not zsh (mksh does it too),
and not any value the kernel returns -- a full syscall trace is in
docs/history/kernel.md section 7, which also records what the contradiction
narrows to. That section had said "root cause not yet isolated" with three
suspects; all three are now eliminated, and the note says what to do next
instead of what to guess.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`∞`, `→`, `≤` and `'` could not be typed. `ą` and `ę` could. That pairing is
the whole diagnosis: KBD_UP was 0xE2 and its neighbours filled 0xE0..0xE9,
which are exactly the UTF-8 lead bytes for three-byte characters. Every
character in U+0800..U+FFFF -- most punctuation, every arrow, every maths
symbol, the whole of CJK -- arrived as a value the console read as an arrow key
and helpfully turned into `ESC [ A`. Two-byte sequences lead with 0xC4 and
nothing claimed 0xC4, so Polish came through and the rest did not.
The chord codes already knew this rule and said so in a comment. The navigation
keys predate them and were never brought in line. Stated once, in kbd.h: a key
produces either a Unicode codepoint or a value above 0x10FFFF, and nothing in
between means anything. KEY_* -> KBD_*, which also settles a silent collision
-- include/input.h defines its own KEY_HOME, KEY_UP and KEY_END as evdev
numbers, and kbd.c includes both headers, so three macros were being quietly
redefined in the one file that used them.
Then the layouts, which is what the codepoint-sized keycodes were for.
kernel/dev/keymap.c: us, pl, de. A layout is four tables -- plain, Shift,
AltGr, Shift+AltGr -- of uint32 codepoints. uint32 and not uchar is the reason
the file exists: `ó` fits in a byte and `ą` does not, and half a Polish
alphabet is not a Polish layout.
Two things deliberately are *not* tables:
- **Ctrl is derived from the plain codepoint.** The table it replaces was
indexed by scancode, which quietly assumed US -- on QWERTZ the Y and Z keys
swap, so Ctrl+Z would have suspended a job only on some layouts.
- **Caps Lock is a case toggle on the result**, so AltGr+a with Caps Lock is
`Ą`. No second table could say that, because Caps Lock combines with AltGr
rather than replacing it. Latin Extended-A alternates upper/lower by
codepoint but not all the same way round -- Ł is U+0141 (odd) and Ą is
U+0104 (even) -- so Polish lands in both halves and the parity matters.
kbdgetc() encodes to UTF-8 and returns the bytes one call at a time.
consoleintr()'s contract is bytes: the serial line hands it UTF-8 already and
it could not tell an encoded codepoint from a byte of the same value.
The layouts live in the kernel. Linux's loadkeys compiles a text keymap and
uploads it a key at a time through KDSKBENT, which puts the data in userspace
-- and a machine whose /usr has not mounted then has no layout at all. Here the
kernel always has a working keyboard and `loadkeys` only names which one.
KB_GETLAYOUT/KB_SETLAYOUT/KB_LISTLAYOUT; /etc/vconsole.conf and an
os.schos.keymap job for the boot-time choice. No dead keys, so French and
Spanish are absent rather than wrong.
/etc/locale.conf and LANG=C.UTF-8 in profile and zshenv. Only the charset half
of that is real -- musl decodes UTF-8 whatever the locale says and ships no
locale data -- but an unset LANG reads as "ASCII only" to everything that asks,
which is its own way of producing `?`.
And two symlink bugs found while chasing
zsh: locking failed for /root/.zsh_history: operation not permitted
on every boot. symlink() is the classic atomic lock -- create the link, and if
it exists someone else holds it -- and zsh distinguishes EEXIST ("contended,
retry") from anything else ("this cannot work, give up"). sys_symlink returned
a bare -1, which is -EPERM, so every zsh on the machine took the second branch
on a filesystem that supports symlinks perfectly well. Same errno class as
commit 75ccf98.
The second one the first was hiding: **divesymlink() has been in fs.c since the
fork and nothing ever called it.** A symlink behaved as a small file containing
its own target, so `cat link` printed the target's *name*. open() and openat()
follow the final component now (O_NOFOLLOW gives ELOOP), and stat() and lstat()
stop being the same function, since which one follows is the only difference
between them. Mid-path symlinks are still not resolved -- that belongs in
namex(), which would re-enter lookup holding a directory lock.
Verified on the emulated keyboard, not just over serial: AltGr+a/e/s gives
ąęś, Shift+AltGr gives ĄĘŚ, Caps Lock+AltGr gives ĄĘŚ, de types zy for the
physical y/z keys and @€µ on AltGr. `∞ 'x' → ≤ • — …` typed over serial now
survives. Three fresh boots: 0 lock failures, 0 faults.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Ą Ę Ś Ł Ż" drew as "?????" and the obvious diagnosis -- the font has no
glyphs for them -- was wrong twice over. `struct vtcell.ch` was **one byte**
holding a CP437 index, and the console mapped UTF-8 down to it on the way in.
U+0104 does not fit in a byte. No font could have fixed that; the cell had to
get wider first.
So: `ch` is a uint32 codepoint, the mapping on the way in is gone, and the cell
draws through kernel/dev/ttf.c against JetBrains Mono linked into the image.
Cell size comes from the font's measured advance instead of a hardcoded 8x16.
The grid costs 864 KB of BSS instead of 432 KB, which is the honest price of
holding a codepoint per cell for six terminals at 240x75.
The rasteriser needed three fixes before it could be trusted with this.
**21 KB of locals on a 4 KB kernel stack.** `ttf_render` held an outline (512
points as three arrays) and a raster (1024 edges) as automatics. KSTACKSIZE is
4096. They walked off the bottom of the stack into whatever was underneath,
which surfaced as user processes faulting on addresses they never touched and
then a panic inside freevm() on an overwritten page directory. Nothing pointed
anywhere near a font.
**And then again, recursively.** load_outline() held a 4736-byte outline as a
local *and recursed* for composite glyphs. Composite glyphs are exactly Ą, Ę,
Ś, Ł, Ż -- so the second crash arrived the moment the first fix let Unicode
reach the screen, looking like a fresh bug. Now a static outline per recursion
level with a depth cap of 4 (the font's deepest real nesting is 2), and the
leaf parse split into load_simple() so its 512-byte flag array is not
multiplied by the depth. Worst chain measured from the object file: ~2.3 KB.
Neither showed in tools/ttftest, which has an 8 MB stack. That is the standing
hazard of one source file built for host and kernel: the host is most forgiving
about exactly the resource the kernel is strictest about.
**Component transforms were being skipped**, on the stated theory that a wrong
transform is worse than none. It is not. `‘` in this font is the comma glyph
with a scale of -1 -- a 180-degree rotation -- and the offset that ships with
it only lands correctly after the rotation. Without it the mark went twelve
pixels into a ten-pixel cell and was clipped away entirely, so the character
read as missing from the font rather than as misplaced. F2Dot14 2x2 now, with
SCALED_COMPONENT_OFFSET honoured.
The glyph cache was ASCII-only, so every accented letter re-rasterised on every
repaint: a screenful of Polish did hundreds of bezier flattenings per scroll
where the same screenful of English did none. 95 reserved ASCII slots plus 224
shared slots keyed by codepoint, 64 KB pool. Verified 13680 cached renders
byte-identical to direct ones across collisions and a pool flush.
tools/ttftest speaks UTF-8 now. Iterating bytes meant it could only ever ask
for ASCII, and ASCII is entirely simple glyphs -- it could not reach the code
path that mattered.
Also: resolvconf's success line moves behind /proc/bootmode, same rule as
vgpud. A quiet boot opened with "resolvconf: nameserver 10.0.2.3" where the
one line was supposed to be "Welcome to SchOS!". Failures stay loud.
Verified on screen, not just on the host: Ą Ę Ś Ł Ż ć ń ó ź, Ω λ π Œ Ð Æ ß,
∞ ‘x’ → ≤ all render, panic=0 across four boots.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things, all in the first minute of using a shell.
**Arrow keys corrupted the two keystrokes after them.** They arrive as single
keycodes -- KEY_UP is 0xE2 -- and were pushed into the input buffer exactly
like that. No terminal works that way, and 0xE2 is the worst possible byte to
have chosen: it is a UTF-8 lead byte for a three-byte character, so a reader
decoding UTF-8 swallowed the next two keys waiting for continuations that never
arrived. consoleintr() sends the DEC/xterm sequences now -- ESC[A, ESC[D,
ESC[5~ and the rest -- which every terminfo entry and every line editor already
knows. Not echoed: in canonical mode echoing them would move the cursor for
real, which was the other half of what "weird glitches" looked like.
**Then the end of the line vanished as you typed into it**, with the buffer
perfectly correct. One line in vt_putc(): backspace erased the cell it moved
onto. That is a line-discipline erase, not the BS control character -- a
program rubbing a character out sends `\b \b`, and the separation matters
because programs also use plain `\b` to go back. zsh does, on every mid-line
edit:
echo alpha \b\b zha \b\b
back two, rewrite the tail, back two again to park the cursor. Against a
destructive backspace that last pair deleted the `ha` it had just drawn.
`\b` moves and nothing else now, 0x7f is ignored as on a real terminal, and
consputc() spells its own erase out as `\b \b` as it already did for serial.
ESC[@, ESC[P and ESC[X went in alongside, because TERM=linux declares them.
**VT3 to VT6 were blank** because nothing ran on them. NVT is 6 and the kernel
made all six; only two had a shell, so Alt+F3 switched to a terminal that was
working perfectly and had nobody to talk to. Four more manifests.
Six shells starting at once is enough concurrency to expose an intermittent
fault in one of them -- one boot in five, a wild jump into .data that runs
until it writes to 0, which nothing catches because SchOS never sets EFER.NXE.
Three boots with two shells, none. Recorded rather than fixed: it is not caused
by the manifests, only made likelier by them.
Also: QEMU_NET forwards host 8080/8081/8000 to guest 80/8080/8000, so the httpd
is reachable from the machine running QEMU instead of being sealed behind NAT.
Verified end to end. And the greeting prints once again -- a replaced
supervisor was announcing a boot that had already happened.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
Ctrl-C killed the shell. consoleread() breaks off the read in progress when
SIGINT arrives and returned -1 to do it -- and -1 is -EPERM by the time it
reaches a program, so every program that handles SIGINT by carrying on saw a
permanent error instead of an interrupted call:
zsh: error on TTY read: operation not permitted
schinitd: restarting os.schos.console-shell
and the next Ctrl-C did it to the replacement. EINTR now, which is what a
signal-interrupted read is required to give. Verified: Ctrl-C an interruptible
command, shell survives, zero restarts.
Same mistake, same file family: link, unlink, rename and renameat returned -1
on every error path. Callers branch on errno -- `if (unlink(p) < 0 && errno !=
ENOENT)` is the standard shape, and against EPERM it always complains -- which
is why zsh reported failures for things that had worked. They return ENOENT,
EEXIST, EXDEV, EISDIR, ENOTEMPTY and EFAULT now. About ninety other `return -1`s
remain in sys_fs.c; these four are done because these four were what a program
tripped over. /dev/mem's unknown minors return ENODEV rather than -1 too.
/dev/urandom was a linear congruential generator seeded with a **constant**, so
every boot produced the same bytes in the same order -- not weak, the same. A
key, a nonce, a temporary filename: shared with every SchOS machine that had
ever run. Seeded at boot from RDRAND where the CPU has it, from the cycle
counter and the wall clock where it does not, and re-stirred per byte. Still an
LCG and still not a CSPRNG; what changed is the part that was dangerous rather
than merely poor.
**Not fixed, and recorded as such:** rename() still will not replace an existing
target, so zsh's history save fails on every exit after the first. An attempt to
unlink the target first was reverted -- the directory is an HTree, dirunlink
followed by dirlink of the same name did not behave, and it panicked in
filewrite with the old file already gone. Losing both files is worse than
refusing, so it refuses until the HTree's slot reuse is understood.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
`make qemu` gives the machine two display devices, which is deliberate --
stdvga carries the splash and text console, virtio-gpu is what the display
class binds and what a compositor is developed against. What the comment above
QEMU_DISPLAY did not say is that this means **two QEMU displays**, and a window
shows one at a time.
Once vgpud runs they show completely different things: the console and your
shell are on stdvga, and virtio-gpu has whatever vgpud drew, which today is a
test pattern. So a window sitting on the virtio-gpu display shows colour bars
and no prompt, and one that opened before vgpud drew shows black -- and neither
is a failure, though both look exactly like one.
`make qemu-console` boots with one display and no GPU to hand over: vgpud finds
nothing to claim and exits, and the single window is the console. Mostly it is
a way to tell "I am looking at the other screen" apart from "the screen is
broken" in the ten seconds before assuming the second one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
`make` had been failing since the e1000 work and I did not see it, because I
was checking build output with `grep error` -- which matches neither "Błąd" nor
mkfs's "image full". Checking the exit status instead found all of this in a
minute.
**usbstick.img was full.** `make ldls-stage` puts a module and its BTF in
sysroot/lib/modules, and a vmlinux BTF is 5 MB on its own; the 16 MB test image
could not hold it. The size cannot move -- tools/windows/bootcheck.ps1 asserts
READ CAPACITY returns exactly 32768 sectors -- so the exclusions grew instead:
lib and usr as well as bin. That image exists to prove the USB driver can read
one block, so its contents were never the point. It also stopped a GPL module
riding into a test artifact, which RAMDISK_EXCLUDE does for the ramdisk and
nothing was doing for this one.
**toybox's httpd was shadowing ours.** /bin/httpd answered `-p` with toybox's
"Unknown option 'p'", because whichever install rule ran second won the name.
TOYBOX_SKIP existed for exactly this and held one entry, `ping`, found the same
hard way. It is now generated from cmd/ -- a list you have to remember is a
list that is wrong the next time cmd/ grows, and it already was: `chvt` had
been silently toybox's, whose VT ioctls are Linux's and not ours.
**ldlsd would not link with clang.** `*out = in;` on a 96-byte struct is a call
to memcpy as far as the C standard is concerned; gcc unrolled it and clang
emitted the call, so a freestanding program linked for months and then did not
on a machine where nothing about it had changed but the compiler. lib/glue.c
now provides memcpy, memmove, memset and memcmp -- the four an implementation
may call without being asked.
**OVMF was never found on NixOS.** `$(dir $(QEMU))share` is the *bin*
directory plus "share", so it looked in /usr/bin/share. The absolute paths
after it covered that up on distributions that keep firmware in /usr; on one
that keeps nothing there it meant the firmware was undiscoverable on a machine
that had it, and the only way to build was to copy the file in by hand. It
resolves the prefix properly now and looks in <prefix>/share/qemu too.
Verified by exit status, from a clean tree and a no-op: both 0. ldls-check
passes 27. The guest boots, serves /srv to the host and answers 403 to
%2e%2e/etc/passwd.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
Four complaints, and only one of them was the thing it looked like.
**"toybox is too small."** Half true, and the fix cost no licensing at all:
236 of toybox's applets are what defconfig builds, and the rest live in
toys/pending/ -- awk, bc, diff, more, expr, tr, hexdump, stty among them. Same
0BSD tarball, switched off by a config default. 26 of them are on now, 236 ->
268. Not everything: strace wants ptrace, syslogd wants BSD's facilitynames[],
git wants OpenSSL, and those three break the link.
The toybox build also works again on this machine. Its scripts start
#!/bin/bash, which does not exist on NixOS, so the binary in the tree was one
nobody could reproduce; the shebangs are rewritten to /usr/bin/env bash on the
way in. Pending applets are enabled by editing .config directly, because
`make oldconfig` is the obvious way and undoes it -- every pending symbol is
`default n`.
**BusyBox was asked for and is not here.** It is GPL-2.0-only, and `make
ramroot` links the root image *into the kernel binary*, so it would put GPLv2
inside an Apache-2.0 executable -- which is the exact case ports/Makefile's
NOTPORTS filter and docs/licensing.md §4 exist for. nano is the same answer one
version later. Both recorded in licensing.md with what was done instead, since
"no" without an alternative is how a rule gets quietly broken later.
**An editor that is not vi.** kilo, BSD-2-Clause, one 1300-line file, no
dependencies beyond termios and TIOCGWINSZ, both of which zsh already needed.
Installed as /bin/edit as well as /bin/kilo, because somebody with a file to
fix will type `edit`.
**"wget doesn't work."** Not a userland problem, and BusyBox's wget would have
failed identically: nothing on this system could turn a name into an address,
so every program taking a hostname failed in its own way and each looked like
a separate bug. There is still no DNS client in SchOS and there does not need
to be -- musl has a complete resolver and it works over ordinary UDP sockets.
The one missing fact was which nameserver, which the kernel has had all along
from DHCP and never published. /proc/resolv.conf publishes it in resolv.conf's
exact syntax; cmd/resolvconf decides whether it becomes /etc/resolv.conf, and
will not overwrite a hand-written one without -f.
# wget -O - http://example.com/ | head -1
<!doctype html><html lang="en"><head><title>Example Domain</title>
**Something to host with.** cmd/httpd: GET and HEAD, static files, index.html
for a directory and a listing otherwise, a child per connection. No CGI, no
keep-alive, no TLS, no config file. The path check is the only
security-relevant code in it and it refuses rather than normalises -- no
component may be `..`, and percent-decoding happens first, because %2e%2e is
`..` and a check before decoding does not see it. Both forms answer 403.
And the disk is 100 MB, from 32. It is sparse, so the image is 21 MB on the
host. RAMDISK_BLOCKS went 6000 -> 8000 with it: the ramdisk was at 86% and the
next thing added would have broken `make ramroot`, which nobody would have
noticed until they used it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
schdbgd bridges TCP to the debugger, which is the one way in that does not
need you to be sitting at the machine. It is a bridge and nothing else: a line
in becomes a line written to /dev/dbgro and the answer comes back, so `nc` is a
complete client and there is no clever client to be broken on the day you need
this.
# schdbgd &
schdbgd: debugger on port 4444, loopback only
$ nc 127.0.0.1 4444
dma
arena 0xffff80003da41000-0xffff80003ea41000 16384 KB total, ...
A kernel debugger plus a TCP socket is a remote root hole, so three things were
decided rather than discovered:
- It does not start by itself. schdbgd.job ships Disabled. SchOS has no
kernel command line, so the boot flag the plan asked for is the job
manifest, which is the same thing in this system's own idiom.
- It binds loopback; -a names an address and says so in a paragraph.
- It cannot reach a view that changes state, enforced in the kernel.
That last one is /dev/dbgro: /dev/dbg on minor 1. The minor is the one piece of
per-open state a character device already sees, so which node you opened is the
permission -- nothing to latch, nothing to remember between the write and the
read, no way across. Which views are refused comes from dbg_register_rw() on
the registration itself, because a central list of dangerous views goes stale
in the direction where a new view is quietly treated as safe. Today that is
`watch <addr>`; `watch` with no argument is still the listing and still
answered.
Also: socket() now accepts IPPROTO_TCP with SOCK_STREAM and IPPROTO_UDP with
SOCK_DGRAM. They name the default rather than departing from it, and toybox's
nc writes them out -- so TCP was "Protocol not supported" on a system whose
whole network is TCP.
docs/plan/debugger.md retires into docs/architecture/debugging.md; 16 files
repointed. Every plan in docs/plan/ that was open is now closed, so the next
thing is Gate 0 itself: the survey, with the instrument built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
e1000 -- Linux's, unmodified -- probes, opens, gets an address by DHCP and
carries ping traffic in a SchOS process. The kernel was driving that NIC and
gave it up, which makes it the first real handover as well as the second
driver.
None of what it needed was a missing feature. Every one was a call that
succeeded, returned zero and did nothing:
_find_next_bit "bit 0 is set" -- for_each_cpu never ended
netif_carrier_off carrier already up -- TCTL.EN never set
__x86_indirect_thunk_r8 the jump discarded -- 255 rx buffers, none made
dma_map_page_attrs bus address 0 -- NIC aimed at physical 0
so the rule from memcpy is now general: a stub returning zero is unsafe
wherever zero is a meaningful answer.
The loader grew ko_fixup_percpu(). The stack guard turned out to be one
per-CPU variable among several; `current` cannot be defused because the driver
dereferences it. Taking the %gs off instead leaves the same opcode reading the
same width from storage the host supplies -- a per-CPU variable in a
single-threaded host is just a variable. Eight bytes become seven and a nop,
and the displacement is not always last, so there is now just enough of an x86
decoder in ko.c to know how long the instruction is.
ldls/kpi/thunk.c is fifteen lines of assembly and the highest-value file here:
retpolines are control flow, and a stub cannot stand in for a jump.
The kernel side:
- ioperm.c, the TSS I/O permission bitmap. A driver's outl is an instruction,
not a call, and e1000's reset path picks it deliberately. IOPL=3 would hand
over the PIC and the disk with it, so it is per-port. Writing it found that
the I/O map base had been written at offset 64 rather than 102, leaving the
CPU checking ports 0-831 against the TSS's own RSP0.
- claim masks the device's IRQ line: 450,000 a second, otherwise.
- net_detach(), the missing half of net_attach_late(). An interface left in
the stack keeps its route and sends every frame to a driver that refuses
it, which looks exactly like a userspace driver that does not work.
- e1000_release() gives up the interface, not just the hardware.
8139too still passes 4/4; ldls-check still passes 27; four CPUs, no panic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/ldls.md L3d, and with it L3 -- the milestone the plan calls the one
that proves the whole idea and the one that cannot be skipped.
# ldlsd /lib/modules/8139too.ko /lib/modules/btf &
kpi: the driver read a MAC off the hardware: 52:54:00🆎cd:ef
ndo_open returned 0
carrying frames. the interface is ldls0.
# cat /proc/netif
interface ldls0 state up address 10.9.0.15 gateway 10.9.0.2
# ping 10.9.0.2 4
64 bytes from 10.9.0.2: icmp_seq=1 time=30 ms
64 bytes from 10.9.0.2: icmp_seq=2 time=20 ms
64 bytes from 10.9.0.2: icmp_seq=3 time=20 ms
64 bytes from 10.9.0.2: icmp_seq=4 time=20 ms
10.9.0.0/24 is reachable only through ldls0, and ldls0 is 8139too running in a
SchOS process. The address was obtained by DHCP over the same driver first.
lwIP -> netdev_ops -> /dev/netdev -> ldlsd -> ndo_start_xmit -> a BAR mapped by
/dev/pci -> the wire, and the whole way back. Nothing in the kernel knows a
Linux driver is involved; nothing in the driver knows it is not in Linux.
It polls rather than taking interrupts. /dev/pci delivers them by blocking
read(), which needs a thread this program does not have, so the driver's NAPI
poll is called from a loop instead -- the same work in a context LDLS can
provide, at the cost of latency and a spinning process.
Four things Linux's alloc_etherdev does that allocating does not, each found by
the driver faulting on it: dev->dev_addr is a pointer aimed at perm_addr;
dev->mc and dev->uc are list heads and are empty only when they point at
themselves; dev->_tx is an array of netdev_queue, because netif_start_queue
clears a bit in _tx[0].state; and __LINK_STATE_START is set by dev_open before
ndo_open is called.
Two did not announce themselves, and they are the ones worth recording.
netif_running(dev) gates 8139too's receive loop. Without it the chip received
the frame, the driver acknowledged the interrupt and left the loop, and nothing
came out -- while every counter said packets were arriving. Found by reading
the chip's own CBR register through the BAR and seeing the hardware had written
600 bytes the driver never collected.
memcpy was still a logging stub. The driver copied each frame into its sk_buff,
the copy did not happen, and the stack was handed exactly the right length of
zeroes; DHCP never completed and nothing reported an error. A stub that returns
zero is not safe for a function whose whole job is a side effect, and the
memory primitives are the worst case because they fail silently and plausibly.
ldls/kpi/string.c exists because of it.
Still one driver, one kernel version, tried once, against an emulated card --
which is what the plan has always promised and is worth repeating next to a
working ping.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/ldls.md milestone L3c. ldls/kpi/ implements 41 of 8139too's 94
symbols rather than logging them: register access to a mapped BAR, the PCI
calls to /dev/pci, allocation to mmap and PCIIOC_DMA, register_netdev to
/dev/netdev.
# ldlsd /lib/modules/8139too.ko /lib/modules/btf
found 10ec:8139 at 00:05.0
bar 1 0x0000000081060000 256 mem
calling probe, with 41 of 94 symbols implemented
kpi: mapped BAR 1 -- phys 0x0000000081060000, 256 bytes
kpi: the driver read a MAC off the hardware: 52:54:00🆎cd:ef
netuser: ldls0 is a process now, 52:54:0🆎cd:ef
probe returned 0 after 0 stub call(s)
52:54:00🆎cd:ef is what the machine was configured with. Nothing invented
it: the driver bit-banged the card's EEPROM, serially, through a BAR mapped by
/dev/pci, and handed back what it found. That it matches means the loader, the
relocations, the BTF offsets, the BAR mapping and the register accessors are
all correct at once, which is why it was the thing to aim at. Zero stub calls:
every symbol probe reaches is implemented.
Two bugs, one of them the kernel's.
Mine first: pci_iomap checked a failed mmap for -1 only, while SchOS returns
-errno. It returned -13 as a pointer, the driver took that as its register
base, and the first write landed at 0x4e -- thirteen below where it meant to
be.
Underneath it, the real one. sys_mmap rounds a length up to a page, the only
granularity an MMU has, and pciuser_mmio_ok() compared that against the BAR's
own size. rtl8139's registers are 256 bytes, the request became 4096, and the
answer was -EACCES for a device the process legitimately held. A BAR smaller
than a page could never be mapped at all, and nothing had noticed because the
only consumer until now was virtio-gpu, whose BARs are large. The check is now
against the pages the BAR lies in, and the widening is named where it happens:
a sub-page BAR hands over the whole page, forced by the hardware rather than
chosen.
That is exactly what hybrid.md said a foreign driver would be good for --
"what it asks for is what a foreign driver would ask for too" -- arriving as a
concrete bug rather than a prediction.
kpi_io_ok() now bounds every ioread/iowrite against the mapping pci_iomap
returned. A stray access would otherwise be a page fault with no context, and
with no IOMMU a hosted driver's wild writes have to be caught early or not at
all. It is also what turned the fault above into two lines naming the offsets.
Not done, and said so in the output rather than only in the doc: no frame has
moved. ndo_open is never called and there is no transmit or receive path, so
ldlsd releases the interface instead of leaving it up -- a link that reports
itself up with nothing draining its queue is a black hole, and the stack would
send DHCP into it for ever. Down is the true answer until packets move. That
is L3d, now listed separately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/ldls.md milestone L3b. ldls/ko.c, btf.c and stub.c were written
freestanding so they would build unchanged on both sides; cmd/ldlsd collects
that promise -- the same three files, linked against lib/ instead of a libc,
in an ordinary SchOS process.
# ldlsd /lib/modules/8139too.ko /lib/modules/btf
module 8139too license GPL wants 94 symbols
defused 6 %gs stack-guard reference(s)
calling init_module
1 _printk(...)
| 8139too: 8139too Fast Ethernet driver 0.9.28
2 __pci_register_driver(...)
btf 122279 types
layout pci_driver.probe +16 pci_dev 2680 bytes
driver "8139too" devices it claims: 10ec:8139 ... and 18 more
calling probe
1 alloc_etherdev_mqs(0x368, 0x1)
probe returned -12 after 1 stub call(s)
Byte for byte the host harness's behaviour, which is what the shared source
was for. Two earlier predictions came good with no work: a process's mmap area
starts at 0x40000000, inside the window R_X86_64_32S needs, and the kernel
never sets EFER.NXE so every mapping is executable. The second is a gap rather
than a feature and is now recorded as one -- LDLS is relying on the absence of
a protection an OS should have.
One thing did not just work. The first run faulted before probe's first call,
err 5 -- present, read, user -- at a kernel address, with the rip resolving to
mov %gs:0x0(%rip),%rax # rtl8139_init_one+0x26
Modules are built -mstack-protector-guard-reg=gs, so the canary comes through
the GS segment, and that is an ABI between the kernel and its own per-CPU area
which a host that is not that kernel cannot provide. ko_defuse_gs() rewrites
those references to xor %reg,%reg.
There are two forms and the second is easy to miss: the prologue loads the
guard with mov (8b) and the epilogue compares it with sub (2b) before a jne to
__stack_chk_fail. xor is right for both -- it zeroes the saved canary and sets
ZF, so the epilogue's branch falls through exactly as a passing check does. Six
sites in 8139too.
It checks every site before patching any and refuses the whole operation on
anything it does not recognise. That refusal is what found the sub form, by
declining to guess -- a loader that half-understands an instruction should not
be editing it.
make ldls-stage/ldls-unstage put a module and its kernel's BTF into the local
sysroot and take them out again. Not part of `all`, and .gitignore refuses
*.ko, so an image only carries somebody else's GPL code between two commands
you typed on purpose.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/ldls.md L3a -- the class bridge, and the first of the three pieces
L3 turned out to be.
/dev/netdev is /dev/pci with the arrows reversed. That hands a device to a
process; this lets a process be one. The kernel side is netdev_ops and nothing
else -- transmit, receive, mac, link_up, the same four entries e1000
publishes -- so a process claims the device, supplies a MAC, and from then on
read() gives it frames the stack wants sent and write() hands it frames that
arrived.
cmd/netecho proves it and is deliberately not a driver: no hardware, none
claimed, every frame answered by the program itself. DHCP so the interface
gets an address at all, ARP so the stack can find it, ICMP so it can be
pinged. All built by hand from RFCs 826, 791, 792 and 2131.
# netecho &
netuser: ldls0 is a process now, 2:4c:44:4c:53:1
netecho: I am 10.9.0.1 and will offer 10.9.0.2
netecho: answered DHCP, offering an address
# cat /proc/netif
interface ldls0 driver netuser address 10.9.0.2 gateway 10.9.0.1
# ping 10.9.0.1 3
netecho: answered an ARP
netecho: answered a ping
3 packets transmitted, 3 received, 0% packet loss
The interface got its address by DHCP from a process and then answered ping
from one. lwIP never learns that the thing behind netdev_ops is not a driver,
which is the whole property the bridge exists to have.
It dies the way it should, too. kill the process and link_up() answers false
immediately, so the stack refuses to send -- "ping: sendto: -5" -- rather than
swallowing packets into a driver that is not there. Start another and the
*same* interface comes back, keeping its address. That came free:
netuser_release_all() sits beside pciuser_release_all() in exit() for exactly
the same reason, and the reason is that a process holding a device is one that
may crash.
One thing the kernel needed: netinit() starts DHCP on the devices that exist
when it runs, so an interface created later -- which is how an LDLS-hosted
driver arrives -- would have come up with no address and no way to get one.
net_attach_late() fixes that, and lives in kernel/net/net.c because that is
the only file allowed to include lwIP's headers.
Recorded as not done, because it is not: this proves the bridge, not a driver.
Nothing in that test loads a module. That is why it was done first -- if ping
had not worked here the bridge would be wrong, and now a failure with a real
driver can only be the driver's side. L3b is the SchOS-side host, L3c is the
fifteen symbols for real, and L3 is the two joined.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/ldls.md milestone L2, less the half that needs /dev/pci.
In Linux, __pci_register_driver ends at the PCI core, which matches a
driver's id_table against the bus and calls probe(). There is no PCI core
here, so LDLS keeps the pointer that call was given and plays the part.
To do that it must know where pci_driver.probe is, and it asks the kernel
rather than being told by us. ldls/btf.c reads /sys/kernel/btf/vmlinux --
122,279 types, every struct layout and every function prototype, as generated
data describing the kernel that is installed. Implemented from
Documentation/bpf/btf.rst, which is a published specification.
$ ldls-types /sys/kernel/btf/vmlinux --need
pci_driver probe offset 16 size 8
pci_dev resource offset 1000 size 1088
resource start offset 0 size 8
all present
Cross-checked against pahole on the same file, field for field. No offset in
this is ours, which is rule 3's uncomfortable row disappearing rather than
being argued about.
And then the driver:
driver name "8139too"
devices it claims: 10ec:8139 ... and 16 more
calling probe(pci_dev 10ec:8139, ...)
1 alloc_etherdev_mqs(0x368, 1, 1)
2 pci_enable_device / 3 pci_request_regions / 4 pci_set_master
5 pci_iomap(pdev, 1, 0)
6..544 ioread8 / iowrite8 -- 0x88, 0x8a, 0x8c, 0x8e, over and over
545 dev_addr_mod / 547 netif_napi_add_weight_locked
549 timer_init_key / 550 register_netdev / 551 netdev_info
probe returned 0 after 552 stub call(s)
10ec:8139 is the real RTL8139 id. It mapped BAR 1, the one marked
IORESOURCE_MEM. Those 538 alternating byte accesses stepping 0x88/0x8a/0x8c/
0x8e are serial EEPROM clocking -- the driver bit-banging the chip for its MAC
address and handing the result to dev_addr_mod. Then NAPI, a watchdog timer,
register_netdev, its "found it" line, and zero.
Fifteen distinct symbols carry that entire probe, and three of them are 540 of
the 552 calls. L1 said 44 symbols carry 110 modules through init; L2 says 15
carry this one through probe. Neither is a number anyone would have guessed
and both came from running the thing.
Recorded as not done, because it is not: probe receives a pci_dev whose shape
is right and whose contents are invented. The BARs are made up, pci_iomap
returns a zeroed page, and 8139too read a MAC address of zeroes and was
perfectly happy about it. The "all through /dev/pci" half of L2 belongs to the
SchOS side. Also recorded: BTF has to come from somewhere, and SchOS has no
/sys/kernel/btf/vmlinux -- the user brings it with the module, from the kernel
the module was built for.
make ldls-check is 27 checks, four of them L2's, and it says so out loud when
it skips them on a machine with no BTF rather than passing silently.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/ldls.md milestone L1. Every undefined symbol resolves to a
trampoline -- twenty generated bytes that load the symbol's index and jump to
a shared thunk, which saves the argument registers and reports the call. Then
init_module is simply called.
$ ldls-run 8139too.ko
module 8139too
license GPL
wants 94 symbols
calling init_module at 0x416fc020
1 _printk(0x416fb6c0, 0, 0)
| 8139too: 8139too Fast Ethernet driver 0.9.28
2 __pci_register_driver(0x416fb400, 0x416fff80, 0x416fbdbb)
init_module returned 0 after 2 stub call(s)
That second line is the driver's own banner, printed through a _printk that
is ours, from a string in the module's own .rodata that a relocation put
there. There is no stronger evidence that a load worked than the loaded thing
speaking.
Stubs return zero rather than stopping, which departs from what the milestone
originally said and does so deliberately: stopping at the first call tells you
one name, carrying on tells you the sequence, and the sequence is the thing a
symbol table cannot give you. A driver that then dereferences the zero faults,
and that is a result too -- it says how far it got before it needed the kernel
to have really done something.
The first run failed on neither of those. It failed on a relocation:
ldls-run: relocate: a signed 32-bit relocation does not fit
A module is compiled -mcmodel=kernel, so the compiler emits R_X86_64_32S for
anything it believes lives in the top 2 GB, and an ordinary mmap at 0x7f...
has nowhere to put it. Linux gives modules a region within 2 GB of the kernel
text; the harness uses MAP_32BIT to reach the same window from the other end.
On SchOS this costs nothing -- a process's mmap area starts at 0x40000000 and
is already inside it -- but it is a real constraint on where the host may load
a module, and a loader that did not check its own arithmetic would have found
it much later and much more confusingly.
Swept over 500 stock modules: 449 ran init to completion, 22 faulted on a
stub's zero, 29 have no init_module, and zero hit a loader error.
Recorded honestly in "what it will not do": running init is not working. Every
symbol is a stub, so what has been shown is that drivers ask for things in a
readable order, not that anything answers. Also recorded: every undefined
symbol in a module is NOTYPE, so the stubs cannot tell jiffies from
pci_iomap and both get a trampoline -- BTF distinguishes VAR from FUNC and
that is where the fix belongs rather than in a guess.
make ldls-check is 23 checks now, five of them L1's, all against
ldls/test/refmod.c which is ours and now has an init_module of its own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/ldls.md milestone L0. ldls/ko.c indexes the sections of an ELF64
relocatable object, places the allocatable ones, resolves undefined symbols
against a caller's table and applies the relocations. Nothing is executed.
It is freestanding -- no libc, no allocation, no I/O -- so the same source
builds as a host tool now and as the SchOS-side host later. That is the
arrangement mkschfs and the kernel already share, and for the same reason:
the failures at this layer are format failures, and one found on the host
costs a second instead of a boot.
$ ldls-inspect 8139too.ko
module 8139too
license GPL
loads to 33988 bytes
relocations: 1980
R_X86_64_64 363, PC32 991, PLT32 428, 32S 169, PC64 29
undefined symbols: 94
stops at: unresolved: __fentry__
The licence is printed before anything is loaded, because it is the one
property that decides whether the object may be on the machine at all and it
is a plain string in .modinfo. Rule 1 is easier to keep when the answer is on
screen.
94 symbols is the measured API surface of 8139too on 6.18.46 -- the list,
exactly, rather than an estimate -- and it is L1's work queue. It is smaller
than it looks: __fentry__, __stack_chk_fail, __ref_stack_chk_guard,
__x86_return_thunk and __fortify_panic are compiler and instrumentation
artefacts that stub out in a line each.
Swept over every module in a stock 6.18.46 tree: 2,440 objects, all of them
laid out and relocated to completion, none with a relocation type the loader
does not handle. That sweep is also what retired the fixed section table --
nvidia-modeset has 7,069 sections and 74,305 relocations, against a median of
50 -- so the caller supplies the table and there is no limit to run into
rather than a generous one that fails later.
Loading is not running, and the doc says so: not one symbol is implemented,
and the distance between "relocates" and "transmits a packet" is the rest of
the plan.
No .ko is in this tree and .gitignore now refuses one outright. make
ldls-check runs 18 checks against ldls/test/refmod.c, which is ours, written
to contain one of each thing the loader has to get right.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
H3 of docs/plan/hybrid.md -- "a modeset API, which is the thing an
out-of-kernel GPU driver makes affordable and an in-kernel one never got".
# schmode modes
1280x800 *
1280x720
1024x768
800x600
640x480
# schmode 1024 768
1024x768 ok
Captured off the virtio-gpu head, the frames really come back 1280x800,
1024x768, 640x480 and 1280x800 again, with the checkerboard band showing no
shear at any of them.
Nothing in the kernel changed for this. No syscall on the path has heard of
a scanout: schmode writes a line to a socket and vgpud sends five commands
to the device. The comparison is the point -- the same five commands in the
kernel would have been the same five commands *and a reason to be nervous*,
because a wrong one there takes the machine instead of one process. That is
why the in-kernel driver never grew one: not difficulty, exposure.
The framebuffer is allocated once, at the largest mode, and every smaller
mode reuses it. PCIIOC_DMA has no free -- a driver's blocks come back when
it exits and not before -- so a modeset that allocated would spend sixteen
slots and then the arena, and the failure would look like a GPU bug rather
than like counting. A larger mode is refused and says why.
vgpud used to end in for(;;) sleep(100) under a comment arguing that
building a control socket before there is a compositor to judge it is the
mistake this plan warns about. That was right about a *compositor protocol*
and wrong as a reason to have no socket: a modeset has a consumer that
exists, somebody changing the mode and looking at the screen, and schmode
is small enough to judge an interface this small. What is deliberately not
on that socket is everything a compositor would need -- no buffer
submission, no damage, no vblank -- which still waits, for the original
reason.
H4, the USB eviction, is now the only milestone left on the plan and is by
some distance the largest thing on the register.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/debugger.md D3 and D4, which finish the plan apart from the
network transport.
D3 -- `whose <addr>`. The views each speak for one subsystem, which is what
keeps them cheap and current, and it is exactly why none of them can see
the bug that matters: a page on the DMA arena's free list looks fine from
dma.c, the same page on kalloc's free list looks fine from kalloc.c, and
that it is on both is a fact no single subsystem holds.
# schdbg whose 0x3e239000
phys 0x000000003e239000 kernel va 0xffff80003e239000
dma arena, handed out
pcidma slot 2, 0 KB into a 4000 KB block given to pid 4 (vgpud)
mapped pid 4 (vgpud) at 0x000000004000d000 rwu vma[5] no_free
consistent: 1 owner, 1 mapping(s)
Claimants register the way views do -- one line in the file that owns the
data -- so the kernel image, the log region, the arena, the page allocator,
the pciuser DMA blocks and every page table each answer for themselves.
`mapped` is the one no allocator can answer: there is no reverse map, so
vm_walk_user() goes forward over every mapped page of every live process.
Two verdicts are shouted and everything else is reported as fact. More than
one owner means the second allocator to hand the page out is handing out
memory the first is still using -- the H2 bug, which would have printed as
three lines. Free-but-still-mapped means a page will be given away from
under the page tables that point at it. Deliberately *not* a finding: no
owner at all, because kalloc keeps a free list and no record of what it has
handed out, so an ordinary allocated page has nobody to speak for it and
saying otherwise would be the query crying wolf.
D4 -- watchpoints, on the four debug registers that had been sitting unused
since this kernel was written. Armed against the same framebuffer page and
then killing the driver:
watch 0: 0xffff80003e239000 written, 8 byte(s), by stosq+0x1f, pid 12 vgpud
#0 memset+0xba
#1 dma_alloc+0x2e2
#2 do_dma+0x22a
#3 pciuser_ioctl+0x13b
#4 sys_ioctl+0x3c7
#5 syscall+0xf6
The chain is the answer, not the rip. It reports and continues -- a data
watchpoint is a trap, delivered after the store completes, so returning
carries on with the program none the wiser, which is what lets this be used
on a machine that is still running.
Debug registers are per-CPU and there is no general IPI here, so the table
carries a generation number and each CPU reloads from its timer interrupt
when it notices the number has moved: one comparison per tick, one tick of
latency at worst. `watch` prints which CPUs have caught up, because "this
watchpoint is watching" and "this watchpoint is watching one CPU" must not
look the same.
Also fixed while testing: watch_init() was called from mpmain(), which runs
on every processor, so the command appeared in the list once per CPU.
dbg_register() now refuses a duplicate name outright, so the next init that
runs per-CPU fails loudly in the list rather than quietly doubling it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/debugger.md, milestones D0, D1 and D2. What they replace is the
ninety-second loop -- add a cprintf, rebuild, boot, read the serial log --
which answers exactly the one question you thought of before the rebuild.
The rule the shape follows: **it observes, it does not control.** No
stepping, no software breakpoints, no stopping the world. Half the bugs
this kernel produces are timing -- an interrupt storm, a level-triggered
line nobody acknowledges, a driver that faults only on its second start --
and every one of them disappears when you halt the machine to look. And
the layering rule, which is the circularity rule the display already has:
a debugger that needs lwIP cannot debug lwIP.
D0 -- symbols. tools/ksyms.pl turns the first link pass into a table the
second pass carries. Two passes is enough because .ksym is placed after
.text and .rodata, so carrying the table cannot move anything recorded in
it; the build lists both passes' text symbols and refuses to link if they
ever disagree, because a symbol table that is subtly wrong gives confident
false answers. ksym.c reads it through weak symbols, so pass one -- which
has no table -- still links. A crash chain now reads panic+0x5f <-
proc_sysrq_cmd+0x99 <- ... <- syscall+0xf6.
D1 -- a log that outlives the kernel. Every character through consputc()
lands in a byte ring with a timestamp per line, readable as /proc/kmsg.
It sits in a region carved before the page allocator gets anything, with a
magic header, so a kernel finds the previous kernel's log still there --
including the whole PSOGM report. panic.c's own comment said "there is no
core dump and no log to go back to"; that is no longer true.
The obvious place for it is the top of RAM beside the DMA arena. That
was the first attempt and the magic came back as ASCII on the reboot,
because the top of usable memory is exactly where the firmware makes its
own allocations. It lives above the kernel image instead, which is a
stable address across boots of the same build.
D2 -- the introspection registry. A subsystem publishes its state with one
line at the bottom of the file that owns the data:
dbg_register("dma", dma_dump, "the DMA arena's free extents");
Seven views -- dma, mem, proc, vma, claims, pci, sock -- reachable three
ways: sysrq 'g' (always, no process and no filesystem), /dev/dbg (echo a
line, cat the answer), and schdbg. The protocol is lines of text because
the same bytes have to work where nothing can allocate or frame a record,
and because a protocol you cannot drive by hand is one you cannot debug.
struct sbuf moved out of kernel/dev/procfs.c into include/sbuf.h rather
than being written a second time.
Several views exist because of a specific bug. `vma` is the table that
decides whether exit() frees a page, and the DMA ownership bug that made
this worth building was a block that was never in it.
Also fixed, found by building with clang for the first time in a while:
struct iovec was defined in both posix.h and socket.h, and every file that
included both was a redefinition. GCC 15 defaults to C23, which permits an
identical struct redeclaration, so the gcc path never noticed -- while
clang defaults to gnu17 and rejected it, breaking the one toolchain
shell.nix exists to provide and the UEFI loader requires. Both are now
guarded.
D3 (`whose <addr>`, the cross-subsystem ownership query) and D4 (hardware
watchpoints on DR0-DR3) are not built and are described honestly as
missing in the architecture doc's "What it does not do".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
The failure this is against is not scope creep. It is abandonment by promotion:
a thing gets built to the point where it works, the next thing becomes more
interesting, and the first is left in a corner -- in the tree, not finished, not
deleted, and not in any list. Enough of those and the tree stops matching its
own documentation.
docs/plan/1.0.md now holds the register, and it is the only place anything
half-built is listed. Four sections, because the kinds are genuinely different:
built and connected to nothing (the text layer, a font from disk, socket
activation, wait_vblank); started and stopped part-way (foundations 2-5, hybrid
H2, Schcom v0, a released PCI device that nothing brings back); named in a "what
it does not do" and still true; and frozen -- scoped, unstarted, not to be
started. The rule is that nothing enters except by being finished or deleted,
and that adding a row requires closing one.
1.0.md already said "two things in flight, never three". It was right and it was
not being followed.
Then the corners themselves, because a register written over a tree full of them
is just a longer list:
- cmd/init is deleted. Nothing had execed /bin/init since initcode.S changed
to /bin/schinit; it was built and installed on every image as an unreachable
fallback, and an unreachable fallback rots rather than saves.
- projects/schwm/ and projects/schde/ are deleted. Both said "to be deleted"
in their own first paragraph, which proves only that a directory marked for
deletion is not deleted by being marked. README-only, no code lost, and the
arguments each made are moved into wayland.md where they are still true.
- projects/schcom/schcom and schcom-demo were tracked binaries. Untracked, and
.gitignore'd.
Three code comments and one document described a boot that has not happened
since 7d44eb7. cons_serial_only starts set, but kmain clears it on its first
lines now, so the splash does not survive the boot and the kernel log is on
screen from the start. That reversal was earned by a Chromebox with no serial
port freezing at boot showing nothing at all, and the comments still argued the
losing side of it.
The site:
- a download page. One ISO, what it has actually been run on -- one machine,
under test -- Ventoy and dd, what the first boot looks like step by step,
and the build if you would rather. The support table names one Chromebox
rather than listing hardware, because one Chromebox is the entire bare-metal
record and a list would be fiction.
- Assets/usb.svg, drawn to the same rule as the other marks: two tones,
geometry only, the accent spent in one place, and that place is the activity
light because it is the part that tells you something is happening.
- Download is in both navs and is the hero's primary action; a closing band
ends the front page on it.
- the uberblock-slots and drivers-outside-the-kernel figures are gone. Three
numbers left, including the fastfetch one, which stays because it is funny.
Two things caught by rendering the pages instead of assuming: .hero p was
beating .filemeta on specificity and the file line was coming out at body size,
and the dd command was one line long enough that the card clipped it at `conv=`
-- a destructive command that can scroll half of itself out of view is a good
way to take somebody's disk. It is two lines now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/hybrid.md's first eviction. cmd/vgpud is the virtio-gpu driver as a
process: it finds the device by vendor and device id, asks the kernel for it,
and does the whole virtio 1.0 bring-up from outside -- reset, feature
negotiation, a virtqueue in DMA memory, GET_DISPLAY_INFO, RESOURCE_CREATE_2D,
ATTACH_BACKING, SET_SCANOUT, TRANSFER and FLUSH.
vgpud: virtio-gpu at 00:03.0, currently held by virtio-gpu
virtio-gpu: released to userspace; screen is the VBE framebuffer
vgpud: claimed
vgpud: display is 1280x800
vgpud: scanout is ours, framebuffer at physical 0x3eceb000
vgpud: drew a test pattern from userspace
Verified by screendumping the virtio-gpu head: the pattern is there, with a
checkerboard that would shear if the stride were wrong. The console carries on
in VBE on the other head at the same time, which is the fallback rule from the
plan working rather than being described.
No syscall knows this is a GPU. The kernel handed over a PCI device and the
process decided what it was -- which is the property that makes this a general
mechanism, and the reason the evictions come before anything foreign.
Four things the first real driver needed that the test program had not:
- PCI config space (PCIIOC_CFG). A virtio device says where its registers
are in vendor capabilities, which live in config space, so a driver that
cannot read it cannot find its own registers.
- a way for the incumbent to let go: struct pci_driver gained a release
hook. A driver without one keeps its device, which is always safe and for
anything on the path to a shell is also correct.
- the outgoing driver freeing its DMA. Releasing the device but keeping its
4 MB framebuffer is half a handover -- the incoming driver asks for one and
is told there is no room.
- a 16 MB DMA arena. 4 MB was exactly one framebuffer at this resolution,
and either side of a handover there are two.
And one bug it reproduced on its first working run: vgpud polls the used ring,
so it registered no interrupt handler, and a virtio device interrupts on every
completion. The storm detector said so immediately --
irq 11: 77780 interrupts/second -- a device on this line is not
acknowledging it. handler 'e1000' is attached
-- naming the line, the rate and the neighbour it was drowning. One line fixes
it (VRING_AVAIL_F_NO_INTERRUPT before the queue is enabled), and that detector
was worth every minute it cost.
include/virtio.h moved out of the driver directory. Its own comment said it
should when a second reader appeared; one has.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/hybrid.md mechanisms 2 and 4: MMIO and DMA to a process.
A process can enumerate the bus, claim a device exclusively, map its BARs and
get memory the device can be pointed at. Proven end to end against an e1000e
-- the exact device class we cannot drive and would want a foreign driver for:
device 7: 8086:10d3 (network)
claimed
BAR0 at 0x81060000, 128 KB
regs[0..3] = 0x00140261 0x00000000 0x00080283 0x00000000
MMIO reaches the device
DMA 8 KB at virtual 0x40001000, physical 0x3f8eb000
Those are the e1000e's CTRL and STATUS with full-duplex and link-up set, which
is how you tell a real mapping from one reading 0xFFFFFFFF off an unmapped
address. Claiming a device the kernel drives is refused -- two writers of one
register set looks like hardware that has started lying -- and everything a
process held is released when it exits, because a driver that crashed must not
keep its hardware away from its replacement.
mmap's offset is the BAR's physical address, checked against the BARs of
devices *this process has claimed*. Without that check the device is /dev/mem
with extra steps and a process could map the kernel's own memory. Mappings are
write-through: a cached write to a doorbell arrives whenever the cache feels
like it, which from a driver's side is indistinguishable from hardware that
ignores you.
The interface is about PCI devices, not about any driver model, and its first
users will be SchOS's own drivers when virtio-gpu is evicted. That ordering is
the design rather than an accident: an interface whose first consumers are
native drivers is a general mechanism something foreign can use later, instead
of a hole shaped like something foreign.
Also: PROT_* and MAP_* moved to include/posix.h. They were defined only in
sys_mem.c, so cmd/fbinfo carried a private copy and this wanted a third --
two copies of a number the kernel compares against, where a mismatch shows up
as a mapping with the wrong permissions rather than an error.
No IOMMU, so this isolates a driver against its own bugs and not against its
own DMA. The plan says so at length and it is still true.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2