/proc/interrupts on q35 said "LOSING 526" ticks: 5 s of boot with
interrupts off. Timing each step of kmain() found two waits:
- AHCI sent a COMRESET to every empty port and waited a second for a
link. DET = 0 is "no device", so only DET = 1 is worth a reset now
(after 20 ms of grace for a drive that is still spinning up).
- ideinit() read 0xFF from a bus with no IDE controller, took the BSY
bit for a busy drive, and waited out its 1 s timeout. A status of
0xFF means no controller and is answered at once.
That is every PC with SATA disks. q35 now loses 33 ticks, under the
flag; i440fx still roots on IDE and loses none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HH7xukt4aaiGj1nQEAoQf1
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
PCI drivers took their interrupt from INTERRUPT_LINE. On real hardware
that is the 8259's number, not the IOAPIC pin the device is wired to
(that is in ACPI _PRT, which needs AML), so nothing ever arrived. xHCI
is entirely interrupt-driven, so the USB keyboard and a USB root would
have hung even on a clean boot. Under QEMU the two numbers agree,
which is why it never showed.
- pci_irq_attach()/pci_irq_detach(): MSI, then MSI-X if the driver
allows it, then INTx. The handler is registered before MSI is
enabled, so no edge is lost. xHCI, e1000, r8169 and rtl8139 use it.
- Lines 24-29 are MSI vectors. The LAPIC error vector moves from 19,
which is a PCI pin on Intel chipsets, to 30. Nothing is placed at or
above vector 64 (T_SYSCALL).
- A storming line is masked and its handlers are polled instead of
being left to eat the CPU. A level line that has had a handler for 2 s
and never fired is polled every tick by a kernel thread.
Tested in QEMU q35 with the root on a USB stick: xHCI and e1000e on
MSI; then MSI disabled and INTx deliberately misrouted, where xHCI got
zero interrupts and the root still mounted through the poller. i440fx
INTx and the rtl8139 hand-over are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HH7xukt4aaiGj1nQEAoQf1
Every one of these works under QEMU and stops a real machine:
- MADT processors that are "online capable" but not enabled were
counted as CPUs. AMD firmware lists absent cores that way (so does
QEMU with maxcpus), and startothers() then waited for ever for an
AP that does not exist. Only enabled entries count now, x2APIC
(type 9) entries are read too, and the wait is bounded to a second;
an AP that misses it is parked with INIT and the boot goes on.
- The PIT calibration loop had no bound. Recent boards clock-gate the
8254: the output never rises (a hang) or floats high (TSC "0 MHz"
and every clock thousands of times fast). The loop is bounded and
checked, and falls back to the ACPI PM timer, then CPUID 0x15/0x16.
Under QEMU pit=off the PM timer gives the same figures the PIT did.
- Firmware that hands over in x2APIC mode left lapic[] reading
garbage: cpunum() panicked or the timer never started. Each CPU is
switched back to xAPIC first (x2APIC -> disabled -> xAPIC).
- The IOAPIC was always at 0xFEC00000 and interrupts were aimed at
APIC ID "0". Both now come from the firmware's tables.
- INIT-to-STARTUP was 100 us ("Bochs is slow"); it is the 10 ms the
MP specification asks for.
- mouseinit() drained the 8042 with an unbounded loop. With no
controller the port reads 0xFF and the loop never ends. It now
notices there is no controller and skips the mouse.
Tested in QEMU: i440fx and q35, -smp 4, -smp 2,maxcpus=4, pit=off.
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>
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>
`jmp mpenter`.
x86-64 requires rsp to be 16-byte aligned immediately before a `call`, so a
function starts with rsp ending in 8 -- the return address -- and its
`push %rbp; mov %rsp,%rbp` prologue leaves %rbp 16-byte aligned. Compilers
build on that: an aligned vector store to a frame slot is `movaps`, and
`movaps` to a misaligned address is #GP.
entry64mp loaded the AP's stack pointer, which is page-aligned, and *jumped*.
Nothing pushed a return address, so %rbp came out eight bytes off -- on every
frame, on every application processor, for the life of the machine.
It cost nothing for as long as no function an AP ran needed an aligned vector
store. Then the console's glyph path started returning a small struct by value
(the cell-as-a-surface change), clang spilled it with movaps, and CPU 1 took a
#GP the instant it announced itself on a verbose boot:
unexpected trap 13 from cpu 1 rip ffff800000120a3b <draw_glyph_px+0x1cb>
120a37: 0f 10 45 90 movups -0x70(%rbp), %xmm0
120a3b: 0f 29 45 b0 movaps %xmm0, -0x50(%rbp) <- #GP
movups on the line above loads fine; movaps stores and faults. The
displacement is a multiple of 16, so what is misaligned is %rbp itself, which
is a fact about how the function was entered.
`call mpenter`, not `jmp`. Eight bytes for an ABI the whole toolchain assumes.
**It was hiding behind a second bug.** The first report was not a #GP at all:
Stop code ACQUIRE
#2 cprintf+0xae
#3 trap+0x2c4
trap() reported the fault with cprintf(), cprintf() takes the console lock,
and the faulting code was already holding it -- on a verbose boot every
character is drawn under that lock. acquire() panicked about a lock its own
call chain owned, and the trap number, the faulting address and the registers
were all lost. A kernel-mode trap ends in panic() a dozen lines later anyway,
so the console goes into panic mode *before* the report rather than during it.
**And a third, in the same boot.** The console reported "JetBrains Mono, 8x16
cells" -- a name from one font and metrics from another. The built-in bitmap
became a provider like any other in the previous commit, so draw_glyph_px()
installs it lazily on the first glyph; on a verbose boot that happens before
gfx_font_init() runs, which then returned early because a font was already
installed. The console spent the whole boot in the fallback while claiming not
to be. "A font is installed" and "a typeface was chosen" had been the same test
and quietly stopped being; they are two flags now.
Verified: verbose boot reaches a shell twice running with no traps and no
panics, and reports 10x21 cells -- the real typeface -- where it reported 8x16
before. Both supported configurations quiet: one boot each, no traps, no
panics, socktest and memfdtest green.
Written up as section 22 of docs/history/kernel.md, because the interesting
part is not the missing eight bytes but that three separate faults had to be
peeled off in order before the first one could be seen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gate 0's last item, "delete the cell grid" -- done as far as it should be, and
the rest of it retired, which is the part worth explaining.
include/text.h made the case against the grid in four charges: it cannot do
variable-width glyphs, cannot position text off a multiple of eight, cannot
anti-alias, and cannot hold a codepoint above 255. Every one of those was
about the *drawing layer*, and every one is now false -- glyphs carry metrics,
PSOGM positions text at arbitrary pixels, the TrueType path blends 8-bit
coverage, and vtcell.ch is a uint32.
What is left is a grid in vt.c, and deleting that would not modernise the
console; it would stop it being a terminal. VT100 escapes address columns,
TIOCGWINSZ reports rows and columns, and every program that draws a box
assumes both. So the item is checked with its remainder rewritten to say what
was actually wrong, rather than ticked as if the original wording had been
carried out.
**What was actually wrong was the duplicate.** gfx.c walked font_data and
blitted glyphs its own way; text.c did the same job into a struct surface, for
no caller but a sysrq self-test. Two implementations of bearings, coverage and
clipping, which had to agree about a cell size only one of them was told had
changed -- and they did not agree, which is why every line of PSOGM once drew
the next character's bitmap underneath itself.
There is one now, and the mechanism is the pleasing part: the console hands
text_draw_glyph() **the cell as a surface of its own** -- same pixels, same
stride, origin moved and bounds set to that cell. Clipping a tall ascender to
its line is then the same bounds check that clips to the edge of a screen, and
there is no second notion of clipping in the tree. `surface_sub()` is six
lines and replaced fourteen of hand-written comparisons.
The bitmap is reached through font_builtin() like any other provider, so the
early console and the real one are one path with different metrics, and the
`if (!cfont)` branch that walked the array is gone. gfx.c no longer includes
font.h; text.c does, being the only file left that reads it -- which also
retires the note in text.c explaining why it could not.
struct surface gained a bpp, because gfx_draw_pixel handles 16, 24 and 32 and
whatever replaces it has to as well. Zero means 32: a UEFI GOP has no other
format and the loader publishes 32 unconditionally, so callers that do not
care say nothing.
One behaviour change, and it is the documented one: a codepoint the font
cannot draw is now a substituted glyph rather than a blank cell. text.h argues
for that directly -- "a missing glyph that draws nothing makes text silently
shorter and the fault impossible to see".
Verified by screendump: the console renders correctly, PSOGM renders
correctly, and `echo t > /proc/sysrq` still draws its specimen through the
surface path with the back buffer at 32bpp. Both supported configurations: one
boot each, no traps, no panics, socktest and memfdtest green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gate 0's last-but-one open item. Drop a TrueType file at \schos\console.ttf on
the ESP and the console is drawn with it; with no file, the face linked into
the kernel image, which is what an early panic needs and is not a degraded
mode.
**The loader reads it, and that is the decision worth writing down.** The
kernel opening the file once the root filesystem is mounted is the obvious
design. It is also wrong: the console has been drawing since the kernel's
first line, so a font arriving later changes the cell size, which changes the
column count, which re-lays out every terminal -- and blanks them. The kernel
log would vanish off the screen at the moment the root filesystem mounted,
which is exactly the log a machine being brought up for the first time is
being read for. Since the next thing on the list is real hardware, losing that
was not a trade worth making.
So the choice happens before anything is drawn. Three details make it cheap:
- **EfiReservedMemoryType**, not EfiLoaderData. The E820 handoff turns
loader data into usable memory, on the argument that the loader's own
pages are the kernel's the moment it runs -- true of the loader, not true
of a buffer it wants the kernel to keep reading from.
- **Below 4 GB**, because the kernel reaches this through its direct map and
that is how far the direct map goes. A font above it would need an ioremap
the console cannot do before there is a console.
- **Used in place.** No copy, which matters because the kernel has no
contiguous allocator to copy 112 KB into -- the only one is the DMA arena,
and that belongs to devices.
Sized without EFI_FILE_INFO and its GUID: seek past the end and ask where that
landed, which the specification requires to clamp.
Every failure falls back and keeps going -- no file, implausible size, no
memory, short read, unparseable file. The last says so in the log, because a
font that silently did not load looks exactly like a font that did.
Verified by booting with JetBrainsMono-ExtraBold.ttf on the ESP and
screendumping the console: visibly the bold face, against the Regular of a
boot with no file. Three boots each way, no traps and no panics, socktest and
memfdtest green in all six.
Nothing ships a console.ttf: the file would be the file already in the kernel,
and two copies of a font are two things that can disagree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things, both asked for.
**The crash report no longer says which monitors it reached.** "The report is
on the virtio-gpu display too" was a line about the crash screen printed on the
crash screen. The report says what happened and where to read it again; that is
not part of it.
**And the second head now shows the F8 menu and the boot.** It used to be black
until vgpud started several seconds into userspace -- through the splash,
through the menu, through every boot message -- which from the outside is
indistinguishable from a display that does not work. Three stages now cover the
whole boot and hand over to each other without coordinating:
*The loader.* LocateProtocol() answers with the first GOP and offers no way to
ask for the rest, so the rest are found by handle and fed the primary's
framebuffer as a Blt source at the points where a screen is finished: the menu,
the resolution chooser, system information, the splash, each segment of the
kernel load. Blt rather than a second framebuffer to draw into, because a
virtio GOP does not have one -- the same fact that made a virtio-only machine
reboot-loop two commits ago.
*The kernel.* gfx remembers which rows were drawn, as one span rather than a
list of rectangles, because console output is lines and a span is what lines
make. display_mirror_console() copies that span across: a line of boot text is
one cell-row transfer, not a screen. Primed once after pci_init(), because a
display that has just appeared has a black scanout and the console it should be
showing was drawn by the loader into a framebuffer it knows nothing about.
*Then vgpud*, from the same console framebuffer, unchanged.
**Two bugs found building it, both worth the retelling.**
The mirror ran per character from inside vt_feed -- which is inside the console
lock -- and vga_disp_acquire() announces its back buffer with a cprintf. A CPU
taking a lock it already holds, with a call chain that said exactly that. It
runs at the end of consolewrite() and cprintf() now, outside the lock, which is
also the better batching: one transfer per write instead of one per character.
And "is this the console's own screen" was asked of the pointer acquire()
returned. vga_fb hands back a back buffer it copies onto the console's
framebuffer afterwards, so on a one-screen machine the answer was "a different
screen" -- and the console was mirrored onto itself through two megabytes of
intermediate, allocated for the purpose. display_ops grew scanout_phys, which
is a fact about the hardware rather than a policy a driver can get subtly
wrong, and it is asked first: it has no side effects and acquire() has two.
Verified with two heads under QEMU, screendumping the virtio head every 400 ms
from power-on: splash, splash, splash, then the console, and it stays. The F8
menu appears on it; choosing 1024x768 there brings both heads up at 1024x768
byte-identical. A panic still reaches both. Both supported configurations: one
boot each, no traps, no panics, socktest and memfdtest green.
One gap remains and is documented rather than hidden: while the kernel's driver
resets the device during probe, QEMU shows "Display output is not active" for a
fraction of a second. A device being taken over has no scanout until the new
driver sets one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"it also doesn't show crash screen on virtio gpu or just other gpu than default
vga" -- true, and on the virtio-gpu it was worse than nothing. The head kept
showing the console from a second before the panic: one screen saying the
kernel is dead, the other showing a shell prompt.
panic() painted into the console framebuffer and stopped there. Two cases were
missing, and they are not the same problem.
**The kernel still drives it.** display_acquire() hands back a buffer; the
report is copied in and presented. Ordinary, and a no-op when the display
class's buffer *is* the console's, so a one-screen machine does not do the work
twice.
**A process drives it.** The hard one, and the one hybrid.md owed an answer
for. The kernel let go of the device deliberately, and the process holding it
will never run again -- there is no scheduling after a panic -- so there is
nobody left to ask. It is not a race that can be won by being careful.
So the userspace driver says where it is scanning out *in advance*:
PCIIOC_PANICFB, registered by vgpud after bring-up and after every modeset. On
a panic the kernel paints the report into that memory and asks the kernel
driver the device was taken from to put it on screen one last time --
`panic_present` in struct pci_driver, which virtio-gpu implements by resetting
the device out from under a driver that is still nominally running. That is
safe here and nowhere else, because nothing is going to run again.
The panic path allocates nothing. It cannot: a corrupted heap is one of the
ways a machine gets here, and an allocator that deadlocks takes the crash
screen *and* the serial log's last chance to be read. So the driver keeps its
BAR mappings across the handover, rebuilds one queue from 20 KB of static
memory, and bounds every spin -- including the reset acknowledge, which is an
unbounded `while` in probe() and correct there.
**The registration is checked like the promise it is.** The range must lie
inside a block that process got from PCIIOC_DMA on that device. Without that,
a claimed device would be a way to nominate any physical address in the machine
-- page tables, kernel text -- to be overwritten with four megabytes of crash
report at the one moment nothing is left to notice. That is a worse bug than
the one this fixes. It goes away with the claim and with the process.
What it buys is worth stating plainly: a device can be evicted to userspace
without the kernel losing its ability to tell you the machine died. The
alternative was a driver boundary that is also a blind spot.
Verified both ways under QEMU with two heads, `echo c > /proc/sysrq`, and a
screendump of each over QMP:
vgpud holding the device both heads byte-identical, serial says
"The report is on the virtio-gpu display too."
kernel holding it same, via display_acquire/display_present
(/proc/pci shows 1af4:1050 [virtio-gpu])
Both supported configurations: one boot each, no traps, no panics, socktest and
memfdtest green.
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>
Guru and SchCotie are gone. Shift is the only mark, and the crash screen draws
it where the face used to be.
Guru was an icon -- a pictogram with a face, denoting one state -- that lived
on the crash screen and nowhere else. Its argument was identity: `:(` is
recognisably Microsoft's, and a crash screen is the single screenshot most
likely to leave the machine, so it is the worst place to be wearing somebody
else's face. That part holds, and it is kept in the branding README because it
is the argument against any second mark. What does not follow is that the
answer is a *new* mark. Shift already signs the system, it is already what the
machine drew on the splash it started with, and a reader who has seen it twice
before the crash recognises it faster than a face they have never seen.
SchCotie was a mascot, and never existed: the file said so itself under "what
this does not cover". A brief for a character nobody is about to draw is not
worth carrying.
So `draw_guru()` and its SVG-space geometry leave panic.c, the two `guru.svg`
files are deleted, and PSOGM calls gfx_draw_logo() -- the same include/logo.h
mask the loader splash and the boot console composite. At its native 128 px:
there is no resampler in the crash path, and the face was scalable in a place
that only ever drew it at one size.
The name stays. "Purple Screen of Guru Meditation" is the Amiga nod, which
panic.c's own header already called out as a separate thing from the mark.
**And taking the screenshot to check it caught a regression from the TTF
console work.** `gfx_draw_char()` -- the pixel-positioned path, which only
PSOGM uses -- still walked `font_data[c][row]` for `row < CHAR_HEIGHT`.
font_data has exactly 16 rows per glyph; CHAR_HEIGHT is the *TrueType* cell
height now, about 21. So every line of the crash report drew five rows of the
next character's bitmap underneath itself, and it looked like each line had
been printed twice. The grid path had been updated when the cell size stopped
being a constant; this one had not, because nothing routine draws text in pixel
coordinates. Both go through one draw_glyph_px() now, and the bitmap's own
geometry is spelled BITMAP_W/BITMAP_H so it cannot be mistaken for a cell size
again.
Two stale claims corrected while in there, in panic.c's header and in
graphics.md: PSOGM has not dimmed-and-floated over the old screen for a while
-- it clears to full purple, and the file's own code comment says why -- and
there is no longer "a face that is obviously a cartoon".
The website's crash section shows the mark in flat white rather than its own
violets, which is what the kernel actually draws: PSOGM composites a coverage
mask in one colour, and those violets sit close enough to #4B1D9A to
half-disappear against it.
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>
vgpud: wanted 7365 KB of DMA memory for a 1920x982 framebuffer
vgpud: not enough contiguous DMA memory
schinitd: restarting os.schos.vgpud (and again, and again, until
schinit: supervisor stopped answering schinit replaced schinitd)
Every number in that diagnostic was about the arena, and the arena had 61 MB
free. The refusal came from do_dma()'s per-request cap, which was 4 MB --
exactly one framebuffer at 1280x800, chosen when that was the only thing
anybody asked for.
virtio-gpu's display size is the size of the **host's window**. Drag it out and
the guest is asked for 1920x982x4 in one contiguous piece, and no amount of
free arena helps. 32 MB now, which is 2560x1600 with room over. The cap is not
there to ration memory -- the arena does that and says so properly -- it is
there so a wild size cannot ask for everything by accident.
DMA_ARENA_BYTES 16 -> 64 MB alongside, for the reason its own comment already
gave and then got the arithmetic wrong on: either side of the handover *both*
drivers hold a framebuffer, so the requirement is two at the largest mode, not
one. 16 MB is two at 1280x800 and nothing bigger. Verified booting at 256M and
512M as well as 1G.
And vgpud no longer dies when it cannot have what it asked for. It claims the
framebuffer before creating the resource, and if the full size cannot be had it
steps down by thirds to 640x480 before giving up -- and gives up with
SCHINIT_EXIT_NORESTART rather than die(), because a driver that will fail
identically on every restart should not be restarted. That loop is what made
schinit replace the supervisor mid-boot.
vgpud: the display is 1920x1080 but only 1280x720 of DMA could be had
is what it says when it steps down. With the cap raised it does not need to:
1920x1080 now allocates its full 8100 KB, 0 restarts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
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
A boot printed about thirty lines before reaching a prompt. Every one was
useful once, and printed on every boot for ever they train the person in front
of the machine to stop reading their own console -- which is expensive on the
day one of them changes.
SchOS UEFI loader
Press F8 for boot options... booting
Welcome to SchOS! (F8 at boot for the full log, or dmesg once you are in)
root@schos / »
**Nothing was deleted, it was routed.** consputc() already wrote to the klog
ring before anywhere else, so a line can be kept in full while being held back
from the two places a person looks. kinfo() is cprintf() with that routing, and
`dmesg` still has the whole boot with timestamps afterwards. The rule for which
is which is not how interesting a line is -- everything was interesting to
somebody once -- but whether you would want to see it on a machine that is
*working*. `pci: 7 devices` is yes the first time and no every time after;
`e1000: no DMA memory for the rings` is yes for ever and stays a cprintf.
The verbose boot the F8 menu already offered now means something much bigger,
which is the other half of this: it is the only way to see any of it live.
Userspace follows through /proc/bootmode, one word. schinit, schinitd and vgpud
hold back their progress lines and never their failures -- a supervisor that
goes quiet when a job dies is worse than one that chatters.
Two things found by testing rather than by reading:
- schinit read /proc/bootmode *before* bootstrap() mounted /proc, so it got
"quiet" every time and the verbose boot was quiet in the one process that
runs first. It reads after the mount now.
- resolvconf announced "already exists; -f to replace it" on every boot after
the first, which is a line about having done nothing. It is quiet when the
file already says what it would have written, and speaks only when the two
disagree -- which means the nameserver changed and the machine is using the
old one.
Verified both ways: quiet boot is six lines, a forced-verbose build prints all
thirty plus the `verbose boot:` steps, and /proc/kmsg holds every line in both.
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 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 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
Two things, both about the debugger being available at the moment you
actually need it rather than once a shell exists.
**Ctrl-G on the console runs every view.** No filesystem, no /dev, no
shell -- it is the last thing main() sets up before entering the scheduler,
so it answers from early in boot, which is exactly when boot problems
happen. It sits beside the older Ctrl-P, and the difference between them is
the interesting part: procdump() takes no lock at all, deliberately,
because a lock is a way to hang while reporting a hang. The views cannot
follow that rule -- a free list read torn is worse than not read -- so they
take locks, and a lock must never be taken from an interrupt handler that
may have interrupted its holder. The key therefore *requests* and a kernel
thread *performs*. A scheduler too wedged to run a thread will not answer
Ctrl-G; Ctrl-P still will, and that division is the honest one.
**PSOGM says where the report went.** Its closing line used to be an
addr2line command, which the call chain no longer needs -- it prints names
now. What replaced it is the thing nobody would guess:
This report is in /proc/kmsg after a restart.
schdbg, or Ctrl-G on the console, asks more.
PSOGM -- Purple Screen of Guru Meditation
"Restart and read it" beats "photograph this screen" by a wide margin, and
until the log outlived the kernel it was not true. Both panic.c and
graphics.md had claimed in writing that the screen was the only artefact of
a crash; neither says so now. The QR code stays, because it is still the
only channel that works when the machine will not come back at all.
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
H2 of docs/plan/hybrid.md -- the milestone the plan itself calls the one
that proves the whole idea, and the one that cannot be skipped.
# cat /proc/schinit
1 4 11 os.schos.vgpud
# kill 4
schinitd: restarting os.schos.vgpud
vgpud: claimed
vgpud: pid 8 drew a test pattern from userspace
The test pattern ends in a twelve-block tally of the drawing process's
pid, because a second run that redraws an identical picture is
indistinguishable from a first run that never stopped. Read off the
virtio-gpu scanout across four generations it says 4, 8, 10, 12, and the
DMA arena hands back the same physical address every time.
What it took:
* A halt the kernel can apply to a device it does not understand.
Clearing PCI_CMD_BUS_MASTER is the only one there is, and it has to
happen before the DMA goes back to the arena -- a dead driver's device
keeps reading a framebuffer the kernel is about to give to somebody
else. Claim sets the bit, release clears it; pcitest shows it in a bm
column, with a third state so a failed read is never printed as off.
* A refusal. vgpu_release() asks vga_fb_restore() whether there is a
framebuffer to put the console back on, and declines to hand over the
device if there is not. The rule that the kernel keeps a minimal
driver for everything on the path to a shell is worth writing down
only if something enforces it.
* The DMA arena not being owned by two allocators at once. This is the
bug that stopped H2: freevm() frees every mapped page below PHYSTOP
and the arena is the top 16 MB of exactly that range, so a DMA block
mapped into a driver went onto the page allocator's free list when the
driver died, while pciuser_release_all() handed the same pages back to
the arena. The restarted vgpud asked for its framebuffer, got pages
kalloc() had already given out -- including its own page tables -- and
dma_alloc()'s zeroing wiped them, so it died inside the ioctl on the
return from a syscall whose stack had stopped existing. do_dma() now
records a VMA with no_free set, the way shared memory does.
* One exit status. KeepAlive must restart a driver that crashed and must
not restart one that has looked at the machine and found its device
absent: on hardware with no virtio-gpu that was fifteen spawns in
forty-five seconds, each printing a line to the console, for ever.
SCHINIT_EXIT_NORESTART is 78, sysexits.h's EX_CONFIG. It is not a new
manifest key -- the wait status has always travelled from PID 1 to the
supervisor, and note_exit() simply stopped discarding it.
The milestone's third clause, "the compositor reattaches", is not done
and is not claimed: there is no compositor running. That is recorded in
the plan as a Schcom milestone rather than counted here.
Closes the register row "A released PCI device" in docs/plan/1.0.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
The site and several documents still described the system as it was before
virtual terminals, the new schinit, the hybrid work and descriptor passing.
docs/architecture/init.md described the wrong program. It documented a single
schinit that parses manifests and supervises jobs, which has not been true since
PID 1 was split: PID 1 forks a supervisor, spawns jobs on its behalf and reaps;
the job table lives in /proc/schinit; a heartbeat runs both ways; and
proc_reincarnate() re-execs PID 1 in place rather than panicking. Rewritten
around what exists, keeping the reasoning that is still load-bearing -- the five
failures of the sibling-init scheme, the escalation ladder, and the three bugs in
the reincarnation path that each had to happen before they were obvious.
docs/plan/init.md is deleted, which is the tree's own rule for finished work and
had not been applied. Fourteen source comments referenced it by part number, so
they are repointed at the architecture doc by section name rather than left
dangling -- a deleted document nobody notices is worse than a stale one.
docs/architecture/devices.md gained the section it has been missing since
/dev/pci existed: what a driver needs from a kernel, why mmap is checked against
the caller's claims, why DMA is a call and not a mapping, and why the write back
after an interrupt is not optional. Also that a released device does not come
back by itself, which is true and was nowhere.
docs/architecture/networking.md gained a descriptor-passing section, and
docs/architecture/README.md's stack diagram gained the two layers and the third
seam it was missing. "Two seams hold the whole thing together" has not been true
since /dev/pci.
docs/plan/README.md said the hybrid work was deliberately last, on the argument
that an interface with no consumer is wrong in ways nobody can see. The argument
was right; the conclusion was not, and the document now says why it changed --
the alternative to a driver compatibility layer is writing a driver for every
device that exists, and the original objection is answered rather than ignored
because SchOS's own drivers are the consumers.
The site: a Hybrid section with the eviction transcript, cards for virtual
terminals, schinit and drivers outside the kernel, and the numbers remeasured
rather than estimated -- 46,961 lines, 207 syscalls. The boot log in app.js is
from a boot taken today, and the lines are trimmed to what the console can show
without clipping, each still a verbatim prefix of real output.
Two small rendering fixes found by looking at the pages rather than assuming:
.console pre's min-height is now scoped to the hero, so a static transcript sizes
to its content instead of reserving eight lines it does not have; and the docs
reader's contents panel strips inline marks from headings, so a heading written
`proc_reincarnate()` no longer carries its backticks into the sidebar.
Every intra-doc link checked; none broken.
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 mechanism 3. A process asks for a device's interrupts with
PCIIOC_IRQ and then reads them, which is how Linux's uio does it and for the
same reason: a descriptor composes with poll, and a driver that stops reading
falls behind rather than losing events. read() returns a count rather than a
flag, so a driver that was busy sees it missed three and acts once.
Proven against an e1000e by asserting a cause through its ICS register and
reading the interrupt back out in userspace:
interrupts routed here (line 10)
interrupt delivered, count = 1
ICR = 0x80000004
0x80000004 is the cause the test set plus INT_ASSERTED -- the device's own
register, read by userspace as its acknowledgement.
**The masking is the design, not a detail.** A PCI line is level-triggered and
stays asserted until the cause is cleared at the device, which is a different
register on every chip and so is something only the driver can do. The kernel
masks the line on arrival and userspace's write() unmasks it. Signalling EOI on
a still-asserted line re-enters the handler immediately and for ever; this
kernel did exactly that by accident at 72,000 interrupts a second, and what it
looked like from outside was a clock running seven times slow (§13).
Which decides what happens when a driver dies mid-interrupt: the line is left
**masked**. The instinct is to unmask -- do not leave the device dead -- and it
is wrong, because unmasking an asserted line with no handler is a crashed
driver taking the machine down with it. Masked is safe and not permanent: the
next PCIIOC_IRQ arms it. Both halves verified by crashing a driver on purpose
and then starting another one.
A line already carrying kernel handlers is refused outright. Masking is
per-line, so handing out a shared one would silence whichever kernel driver is
also there, with nothing logged. MSI is the real answer and pci_msi_enable() is
already in the tree waiting for a caller.
Also fixed, found by running the crash twice: DMA buffers were never returned
when a process exited. The arena is 4 MB and contiguous, so a driver that
restarts a few times exhausted it -- "out of DMA memory, 3584 free" -- which is
a poor property for an interface whose whole promise is that a driver can crash
and come back.
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
Everything the kernel printed went to the serial port and nowhere else until
the first program started and called console_takeover(). On a machine with a
UART that is fine. On one without -- a laptop, a Chromebox, anything built
this century -- the screen showed the loader's progress bar and then nothing
at all, for the whole of boot, on exactly the machines where something was
going wrong.
A report of "it shows Loading SchOS and freezes" cannot be acted on. The same
boot with a log on it says which line it stopped after, and that is usually
the whole diagnosis.
The loader has already put a framebuffer on the screen and described it in the
boot block, so there was a perfectly good console sitting unused for the
entire early boot. kmain attaches it on its first line now.
Not gated on the F8 verbose menu, which was the first instinct and is wrong:
F8 is no use to someone whose machine resets before they can read anything.
This is what Linux does too -- the log is the default and `quiet splash` is
the thing you ask for. The splash survives until the kernel's first message,
and a boot that succeeds clears the lot when the shell starts, so the only
people who ever see this are the people who need to.
vt_init is idempotent now, because the early attach brings the terminals up
before main() reaches its usual call and the second one must not wipe six
screens of boot messages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
The bound was 20,000,000 iterations, on the reasoning that a real drive
answers in microseconds so only an empty channel could reach the end. True,
and it misses what an iteration costs: every read here is a port access, and
under a hypervisor a port access is a VM exit at a microsecond or two.
Twenty million of those is a minute of the machine apparently doing nothing,
on every boot, on any machine with nothing on the primary IDE channel -- which
is every machine built in the last decade, where storage is AHCI or NVMe and
the legacy ports answer nobody. Booting the ISO looked like a hang for exactly
that reason: the line after "time:" took longer than anyone waits.
Bounded by time now, one second: four orders of magnitude more than a drive
needs, and short enough that an absent one costs nothing. "ide: no drive on
the primary channel" prints immediately.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/init.md milestones I2 and I3. The rule change.
**I2 -- the heartbeat.** The failure the two-init scheme could not see is that
an init usually does not die, it *hangs*: wait() never returns for a process
that is merely stuck, so a supervisor watching a wedged init reports
everything normal for ever. PID 1 now pings every two seconds and expects an
answer within ten; silence means stuck, and stuck is worth acting on.
Exercised rather than reasoned about, as the plan asks: `sysrq w` wedges
schinitd by making the scheduler pass over it for ever. It stays alive, keeps
its descriptors and appears in every listing -- which is the point.
Two things that only showed up by running it:
- kill() sets a flag the target checks when it next runs, and the premise
here is that it is not running. Waiting for a corpse that may never be
produced turns the recovery path into a second hang. PID 1 replaces the
supervisor *without waiting*; the old one's socket is closed, so the moment
it runs again its first write fails and it exits.
- the replaced supervisor must notice the hangup. poll() reports POLLHUP and
never POLLIN again, so a loop that only looks for POLLIN sits there
complaining that PID 1 has stopped pinging it.
**I3 -- proc_reincarnate().** exit() on initproc panicked. Now the kernel
tears down nothing, keeps pid 1, and re-execs /bin/schinit in place with a
generation counter. The pid stays 1, so every orphan the kernel reparented is
still correctly parented; the children survive; the open files survive, which
is why this is the first line of exit() and not the last; and the job table
survives in /proc/schinit, which is what it is there for.
The escalation ladder: generations 2-3 carry on, generation 4 ignores
/etc/schinit and starts a console shell only -- a manifest that kills init
must not kill it for ever -- and generation 5 stops, with PSOGM rendering
INIT_RESURRECTION_FAILED, a call chain and a QR code.
Three bugs found by running it, each a lesson about exec():
- exec() returns 0 on success; it does not fail to return. The new image
starts when the kernel returns to userspace through the rewritten
trapframe. Returning from exit() is the success path.
- exit() was reached because `killed` was set, and the trap path checks it
again on the way out. Leaving it set exits the new image before its first
instruction: five generations in a few microseconds, a resurrection loop
that looks exactly like the crash it was meant to survive.
- exec() keeps open files, which is what gives the new image a console --
and also keeps the previous life's socket, holding the old supervisor's
channel up so it never sees a hangup.
Verified: `sysrq i`, and the shell being typed into keeps its pid across the
death and rebirth of PID 1, with /proc/schinit byte-identical either side.
schfs-check passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
The console got slower the day TIOCGWINSZ started telling the truth.
`ESC[J` -- erase to end of screen -- arrives after every shell prompt, and zsh
redraws the prompt on every keystroke. While the terminal claimed to be 80x24
that erased 1920 cells; once it reported the real 160x50 it erased 8000, and
every one was written to the framebuffer whether or not it already looked
exactly like that.
Measured first, which killed the obvious theory: hoisting the bounds checks
out of gfx_draw_pixel changed a full-screen clear by 1%. The cost is not the
per-pixel function call, it is the framebuffer -- about 128 MB/s here, so four
megabytes is 32 ms of writing, and on a real window with QEMU rendering each
frame it is worse than that.
So write less rather than write faster. The grid already knows what is on
screen: erase_run compares before it draws and skips any row span already
blank in exactly those colours, and vt_clear does the same for the whole
screen.
100x erase-to-end-of-screen, before 0.101s
100x erase-to-end-of-screen, after 0.017s
Six times, with -display none where the framebuffer is ordinary RAM. This is
what the backing store is for: comparing is free, writing is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
A timer interrupt that arrives while interrupts are disabled is not delayed,
it is lost -- the LAPIC in periodic mode holds one pending interrupt and drops
the rest. So anything the kernel does with interrupts off for longer than
10 ms makes `ticks` run slow, and it never catches up.
That has now presented twice as "uptime is wrong": once as an interrupt storm
taking 83% of the CPU, once as heavy console output on a display that is
expensive to draw to. Both times the tick was the messenger, and both times it
looked like a timekeeping bug.
monotonic() reads the TSC instead. It is a counter in the silicon that
advances whether or not this kernel is paying attention, it is already
measured against the PIT at boot, and reading it is one instruction and no
lock. wallclock(), /proc/uptime, sysinfo, getrusage and times() all go through
it now.
`ticks` keeps the job it is good at -- the scheduler preempts on it, sleep()
counts it -- where losing one means a slightly longer sleep rather than a
wrong answer about what time it is.
Lost ticks are no longer harmful, which makes them invisible, which is worse.
So /proc/interrupts now says
timer: 3383 ticks taken, 3387 expected
and names the gap when it grows, because a machine dropping timer interrupts
is spending too long with interrupts disabled, and that is worth knowing
before it turns into a clock that lies.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
The sweet spot was available the whole time and I ruled it out by writing
"AltGr+anything" when the true statement is "AltGr+*letter*".
A Polish layout wants AltGr with a letter -- ą ć ę ł ń ó ś ź ż. AltGr with a
function key produces nothing on any layout, no window manager binds it, and
no application asks for it. It is the one combination the host, applications
and the keyboard layout all leave alone, and the previous three commits talked
themselves out of it on a technicality that was mine, not the hardware's.
Verified rather than argued, with QEMU sendkey and screendump hashes:
AltGr+F2 -> tty2
AltGr+F1 -> back, pixel-identical to the start
AltGr+a -> reaches zsh, so ą still types
That distinction is only expressible because Right Alt became its own modifier
bit two commits ago. Sharing ALT is harmless for typing -- neither is used to
look up a character -- and made this impossible to say.
Also added, since it was asked for in the same breath: minus and equals step
to the previous and next terminal, on whatever the chord's modifiers are. They
skip terminals that are not in use -- the same `opened` count VT_GETSTATE
reports -- because six terminals with two shells on them means four blank
screens in between, and cycling past those is worse than not having the key.
AltGr+= -> tty2, again -> wraps to tty1 (only two in use)
AltGr+- -> tty2
chvt -c still moves all of it, and `chvt` reports both halves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
Third guess at a chord, and the third one would have been wrong too: a display
manager that grabs Ctrl+Alt+F<n> matches loosely, so Ctrl+Alt+Shift+F2 never
reaches the guest either. Extra modifiers do not get past a loose grab.
The real problem is that this is not knowable from inside. A key the host
swallowed is indistinguishable from a key nobody pressed, so the guest cannot
probe for a free combination, and there is no combination that is free
everywhere:
the HOST takes Alt+F4, Alt+F2, Ctrl+Alt+F1..F6 (loosely matched),
Ctrl+Alt+digit (QEMU's own console tabs)
APPLICATIONS want Alt+anything (Meta -- zsh binds Meta-digit as
digit-argument), Ctrl+letter, Ctrl+Shift+digit
(Ctrl+Shift+2 types ^@), bare F-keys
the LAYOUT wants AltGr+letter
So it is a setting. VT_SETCHORD/VT_GETCHORD, and in userspace:
chvt -c ctrl+shift+F Ctrl+Shift+F1..F6 (the new default)
chvt -c ctrl+alt+shift+F
chvt -c alt+1
chvt -c off no chord; chvt N is then the only way
chvt report the active terminal and the chord
`off` is a legitimate setting rather than a failure mode. On a machine whose
host grabs everything worth binding, `chvt N` is the honest answer.
The default moved to Ctrl+Shift+F1..F6: display managers bind Ctrl+Alt+F<n>,
not Ctrl+Shift+F<n>, and Ctrl+Shift with a *function* key is not a character
on any layout -- unlike Ctrl+Shift+digit, which is.
Caught while writing it: VTC_RALT was 0x08 while kbd.h's RALT is (1<<7), since
(1<<3) is CAPSLOCK. A chord set from userspace with AltGr would simply never
have matched, and the only symptom would have been a key that does nothing.
A _Static_assert now holds the two headers together.
Verified: Ctrl+Alt+F2 leaves the screen byte-identical, Ctrl+Shift+F2 switches,
Ctrl+Shift+F1 returns to a pixel-identical tty1, and chvt -c round-trips
through alt+1 and off and back. schfs-check passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
Alt+1..6 was a worse choice than the Alt+F-keys it replaced, and the reason
is the one that matters: anything the kernel swallows is taken from every
program that will ever run here, permanently.
Alt+<digit> is Meta-digit. zsh's own line editor binds it as digit-argument --
Alt+1 Alt+2 x repeats x twelve times -- and every readline editor does the
same. Yesterday's chord broke that system-wide, which is exactly the failure
mode of picking a chord applications want.
Working the whole space instead of one axis of it:
the HOST takes Alt+F4, Alt+F2, Ctrl+Alt+F1..F6
APPLICATIONS want Alt+anything (Meta), Ctrl+letter, Ctrl+Shift+digit
(Ctrl+Shift+2 types ^@), bare F-keys
the LAYOUT wants AltGr+letter -- Polish diacritics
Ctrl+Alt+Shift+F1..F6 is in none of those sets. Display managers bind the same
chord without Shift, so adding it steps out of the host's way, and no terminal
application binds a triple-modified function key.
Which chords actually reach a guest was measured rather than assumed: a
temporary probe in kbdgetc logged every modified keypress and QEMU's sendkey
fired each candidate at it. Everything reached the guest, Ctrl+Alt+F2
included -- so that conflict is the host's display manager, not QEMU. The same
probe corrected an earlier claim: `sendkey altgr-2` produces no event at all,
so the previous commit's "AltGr+2 did not fire" was never actually tested.
And the escape hatch is real now. VT_SETMODE was accepted and ignored, so a
program could ask to own the chord, be told yes, and still not get it.
VT_PROCESS genuinely stops the kernel switching. Together with the EVIOCGRAB
path -- which kbdgetc already checks before the chord is recognised -- there
are three honest layers: nothing, VT_PROCESS, exclusive grab.
What VT_PROCESS still does not do is the VT_RELDISP handshake; struct vt_mode
carries relsig/acqsig for when Schcom needs to release the GPU before the
screen changes hands.
Verified by screendump hash: Alt+2 reaches zsh as Meta, Ctrl+Alt+F2 and
Ctrl+Shift+F2 leave the screen untouched, Ctrl+Alt+Shift+F2 switches.
schfs-check passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
Alt+F1..F6 was wrong in three separate ways, and the third is the one that
matters most here.
Alt+F4 the host window manager closes the QEMU window
Alt+F2 GNOME and KDE open a run dialog
Ctrl+Alt+F1..F6 the host switches its *own* virtual terminals
AltGr+anything Polish diacritics -- a c e l n o s x z with their marks --
and on some setups AltGr is delivered as Ctrl+Alt
Ctrl+Shift+key widely bound inside applications
Scroll Lock not present on many keyboards
Alt+1..6 survives all of it: the number row, so no function key is involved,
and left Alt, so AltGr is untouched.
Right Alt is tracked as its own modifier bit now. It used to share ALT, which
is harmless for typing -- neither is used to look up a character -- and wrong
for chords, because it made AltGr indistinguishable from Alt.
And the test is an equality rather than a mask:
if ((shift & (CTL | SHIFT | ALT | RALT)) == ALT && ...)
`shift & ALT` fired on Ctrl+Alt+1 and AltGr+1 as well, so a chord aimed at the
host -- or an ordinary Polish keystroke -- would have switched terminals
behind your back. That was a real bug in what shipped yesterday.
Verified with QEMU's sendkey, comparing screendump hashes:
start (tty1) 86cee2284878
Ctrl+Alt+2 86cee2284878 identical -- did not fire
AltGr+2 86cee2284878 identical -- did not fire
Alt+2 114562804a78 switched
Alt+1 86cee2284878 back, pixel-identical to the start
schfs-check passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
Three symptoms, two causes, both in the terminal layer.
**The grid and TIOCGWINSZ disagreed about the size.** vt_init() sizes every
grid from gfx_cols()/gfx_rows(), and it runs at main.c:75 -- while the display
driver attaches its scanout from the PCI bus walk at main.c:99. So the grids
were the 80x24 fallback, and TIOCGWINSZ, which computed its own answer live,
said 160x50.
That one disagreement produced all three things visible in the report: text
stopped two thirds of the way down the screen, `ls` laid out 160 columns into
an 80-column grid so words split across the wrap ("vers" / "ion"), and zsh's
cursor tracking was wrong on every line.
Fixed in two places, because either alone would leave the trap set:
gfx_attach_scanout() now calls vt_resize(), so the grids learn the real screen
the moment there is one; and TIOCGWINSZ reports v->cols/v->rows rather than
measuring again. One number, from the terminal being asked.
**Wrapping was eager.** A glyph printed in the last column moved the cursor to
the next line immediately. Real terminals set a pending-wrap flag and wrap when
the *next* glyph arrives -- which is why a line exactly as wide as the screen
takes one row and not two, and why a program can write the bottom-right cell
without scrolling.
zsh's partial-line marker depends on that rule directly: it writes exactly
COLUMNS-1 spaces and expects to still be on the line. With eager wrap it left a
reverse-video '#' above every prompt, which is the hashtag in the report. Both
are gone.
Verified by screendump: `ls /proc` now fits one line with "version" intact, no
markers, and output fills the screen to the last row. schfs-check passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
/dev/tty1..tty6, Alt+F1..F6 or `chvt N`. Each is an independent terminal --
its own screen, cursor, line discipline, foreground process group and
session. Two shells on two terminals are unrelated processes that share a
keyboard and a screen.
What made it possible is a backing store. The console drew straight into the
framebuffer and scrolled by moving pixels, which works exactly as long as
nothing else wants the screen: there was nowhere to put terminal 2 while
terminal 1 was visible, and nothing to repaint terminal 1 from. Each terminal
owns a grid of cells now and the framebuffer shows whichever grid is active,
so vt_switch() is set-index, repaint, return -- nothing is saved on the way
out because the outgoing grid has been current all along.
The rule the layer is built on: a terminal does not know whether it is
visible. Writes land in the grid, the cursor moves, the grid scrolls, all
unconditionally; drawing is one question asked at the bottom of vt.c. Every
alternative spreads "am I on screen" through the escape parser, and then a
terminal switched away from mid-sequence comes back wrong.
Everything that was a single global is per-terminal now: tty_fg_pgrp (deleted),
termios, the input ring, the escape parser, the UTF-8 decoder, and the cursor
-- which also stops being pixels in gfx.c and becomes cells in the terminal.
TIOCSCTTY was `return 0` and now records proc->ctty, which is what /dev/tty
resolves through; /dev/tty was an alias for /dev/console with the same minor,
and they are different questions.
kbd.c names Alt+F1..F6 as KEY_ALT_F1+n, above 0xFF on purpose -- every byte
value is already a character or a UTF-8 continuation, and a chord colliding
with one would be typed into the terminal instead of switching away from it.
What the chord means is decided in console.c, where the terminals are.
VT_ACTIVATE / VT_WAITACTIVE / VT_GETSTATE / VT_OPENQRY, Linux's numbers.
The argument to ACTIVATE is by value, not a pointer -- same shape as TCFLSH.
Two bugs found by screenshotting the result rather than reading the log:
- zsh emits 0x0F to leave alt-charset mode, because that is what the `linux`
terminfo says rmacs is, and the console drew CP437 glyph 0x0F: a sun, in
the prompt, on every line. A terminal never draws a C0 control character.
- SGR 7 was unimplemented, so zsh's partial-line marker rendered as a bare
'#' instead of a reversed block. Reverse video swaps the colours at draw
time, not at store time, so turning it off restores what was underneath.
Verified by screendump: tty1 and tty2 show different screens, and switching
back repaints tty1's earlier content from its grid. schfs-check passes.
docs/architecture/terminals.md, including what it does not do -- no
scrollback, no reflow, no VT_PROCESS mode for a display server to veto a
switch, which Schcom will need before it owns Alt+F7.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
fastfetch reported 3 seconds of uptime after 15 real ones, `time` claimed
3.12s for a 20-second command, and ping's one-second interval was closer to
six. One cause, not three: `ticks` was advancing at ~14.5 Hz instead of 100.
It was not the timer. virtio-gpu-pci shares IRQ 11 with the e1000 and
registered no interrupt handler at all. PCI INTx is level-triggered, so the
device held the line asserted, the kernel signalled EOI, and it fired again
immediately -- 72,000 interrupts a second, 83% of CPU0, starving the tick.
The e1000's handler read ICR as zero half a million times, correctly
answering "not mine", which is what made it look like a network-free problem.
Reading virtio's ISR byte clears it and drops the line. That is the fix.
irq_dispatch now measures each line's rate against the TSC -- not ticks,
which is the first casualty of a storm -- and names the attached handlers
once above 20000/s. It reproduces the original fault as a single line.
Found along the way, and fixed because they were real:
- lapicinit loaded TICR with xv6's uncalibrated 10000000, whose own comment
admitted it was never measured. calibrate_timers() now measures the LAPIC
and the TSC against the 8254 PIT. (It came back at 999.9 MHz, so this was
not the bug -- but the next machine is not obliged to be 1 GHz.)
- microdelay() was an empty function body. Every microdelay(1000) in the
xHCI reset path and the USB hub power-on sequence returned instantly.
- HZ is a constant in param.h now, not the literal 100 written out at a
dozen ticks-to-seconds conversions. USER_HZ is separate and deliberately
does not follow it: musl answers sysconf(_SC_CLK_TCK) with 100 always, so
times() and /proc/stat scale at the syscall boundary the way Linux does.
- TIOCGWINSZ answered a hardcoded 24x80. gfx_cols()/gfx_rows() have known
the real geometry all along; at 1024x768 the console is 128x48, so every
program that lays out a screen was formatting for the wrong one.
- TCFLSH fell through to the catch-all `return 0`, claiming to discard
input without discarding any.
uptime now advances 5.01 seconds per 5 real seconds. schfs-check passes.
Full investigation, including the two wrong hypotheses and the measurement
flaw that protected them, in docs/history/kernel.md section 13.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
Everything created after boot was stamped 1970. know() in SchFS and now_sec() in
the v1 filesystem both returned uptime(), under comments saying the kernel had no
wall clock -- which stopped being true when time_init() started reading the RTC.
The comments outlived the condition they described.
Two errors, and the second is the less obvious: uptime() counts 10 ms *ticks* and
they were being used as *seconds*, so a node created 0.6 s into boot read back as
00:01:00 and the clocks ran a hundred times too fast. Both now go through
wallclock(), which exists precisely so nothing can disagree with gettimeofday
about the time. Confirmed against the RTC line from the same boot.
The occupancy counters are the instrumentation docs/plan/roadmap.md asked for
while hunting the ls -lR hang: inode, file and pinned-buffer counts with a
high-water mark each, maintained at the 0->1 and 1->0 transitions rather than by
scanning, so they cost one increment on a path that already holds the table's
lock.
The peaks are the diagnostic. A live count only describes the moment you looked,
and a reference leak is invisible in it right up until the table is full -- at
which point iget() panics and filealloc() returns 0, which is where the trail
used to go cold.
What they measured: across ten iterations of ls -lR with writes interleaved, 33
execs and 13378 tree descents, inode_peak stayed at 9/200, file_peak at 9/512 and
buf_pinned_peak at 1/256, with the SchFS bracket unlocked throughout. That
eliminates the roadmap's own hypothesis -- "a lock or reference-count leak
somewhere in the *at() paths" -- and the hang did not reproduce. It is very
likely fixed in passing by the *at() dirfd rewrite, the getdents cookie rewrite
or SchFS becoming the root, all of which landed after it was reported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
Same disease as the network stack, one subsystem over. kernel/dev/vga.c
was three things wearing one name: the owner of the framebuffer, a
bitmap drawing library, and the text console. Only the first is a
driver, and because they shared a file they shared a prefix -- so
panic.c drew the crash screen with vga_fill_rect, console.c printed with
vga_putc, /dev/fb0 handed out vga_fb_phys, and every one of those files
named a display driver it had no business knowing about.
Split three ways:
- kernel/drivers/gpu/vga/vga_fb.c owns the VBE framebuffer -- the mode
the loader programmed and handed over, the aperture, and the display
class vtable. It is a driver and now lives with the driver that
finds the card on the bus.
- kernel/dev/gfx.c draws: pixels, shapes, glyphs, the cursor, scroll,
the ANSI erase operations. It names no driver. The framebuffer
arrives through gfx_attach_scanout(), so a driver publishes what it
owns and this file draws on it without knowing what it is.
- include/gfx.h is the interface, and says why it is deliberately not
the display class: that one is about frames -- acquire, present,
vblank -- and the crash screen has to work when nothing else does.
main() no longer calls a display driver. platform_register_devices()
takes the framebuffer address and brings up whatever owns it, which is
where driver names belong; kernel/drivers/registry.c is the file whose
entire job is naming every driver in the kernel.
virtio-gpu got the e1000 treatment as well: instances in an array, the
instance hung on dev->priv by an `attached` hook, and every op reading
it rather than a file-static. It refused the second adapter outright
before, which is not hypothetical -- integrated plus discrete is an
ordinary laptop. The VGA driver deliberately has no such hook, and says
so: there is one framebuffer the loader programmed however many
VGA-class controllers the bus turns up, and a second one publishing the
same scanout is the truth rather than a bug.
Private headers moved next to their drivers: include/virtio.h and
include/xhci.h each had exactly one includer. include/ is for headers
the rest of the kernel needs.
Deleted rather than moved: consoleinit() programmed the sixteen ANSI
colours into the VGA DAC and disabled text-mode blink, by writing ports
0x3C0-0x3C9 from the console. The comment three lines above the colour
table already said this does nothing in a 32bpp linear mode -- there is
no palette to index -- so it was both dead and in the wrong file.
Verified, and this is a refactor so the evidence is that nothing moved:
- bootcheck -Fb -Net: 59 [ok], no failures. That covers the console
through every command, /dev/fb0's geometry, writing pixels through
write() and mapping the framebuffer through mmap.
- make crashshot: the crash screen renders exactly as before --
shapes, glyphs, logo and QR code -- which is the whole of gfx.c
exercised at once, in a panic, with the scanout attached by a driver.
- make schcom-test: five pixel assertions from the host oracle still
pass, so the display class and the compositor above it are unmoved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The TCP/IP stack reached straight into the first driver anyone wrote.
net.c called e1000_present(), the lwIP glue was named netif_e1000.c and
called e1000_transmit(), and include/defs.h declared the driver's
internals to the whole kernel. The second NIC driver would therefore
have arrived with a near-identical copy of the glue file and a patch to
the protocol stack, which is the arrangement include/device.h was
written to prevent -- the e1000 even said so, with `.ops = 0, // no
class vtable yet; there is no net class`.
There is now. A driver publishes a struct netdev_ops -- transmit,
receive, mac, link_up -- and is finished: it never mentions lwIP, and
nothing above it learns its name. The display class was already this
shape; this is the same shape for networking, and the two now read the
same way in kernel/dev/device.c.
What that made possible immediately:
- One netif per bound device instead of one per driver. lwIP's glue is
netif_eth.c, it takes a struct device, and it holds up to NETIF_MAX
interfaces. net.c attaches every network device it finds and starts
DHCP on each, because which interface the network answers on is not
ours to decide.
- /proc/netif lists all of them, one block of fields each, with the
interface name and the driver that claimed it.
- The ARP self-test moved from the e1000 to the class. Nothing in it
was chip-specific, and it is the first thing anyone will want to run
against the next driver. Still `echo n > /proc/sysrq`.
- Private headers live with their driver: e1000.h was in include/,
where exactly one file could use it. The driver build now adds the
driver's own directory to the include path.
The e1000 also drives more than one card. Its probe() refused the second
outright, because there was a single static instance -- so a machine
with two of them drove one and silently ignored the other. Instances are
an array now, and the instance is hung on dev->priv by a new optional
`attached` hook that the bus calls once the device exists. probe() runs
before device_add(), so there was previously nowhere to put it; that is
the whole reason the hook exists rather than a line inside probe.
QEMU gets an explicit NIC on every target (QEMU_NET). The default device
happens to be an e1000 on this machine type today, which is exactly the
sort of thing that stays true until a QEMU release changes it and the
network stops being exercised without anyone noticing.
Verified:
- bootcheck -Net passes, 48 [ok], no failures -- including the moved
self-test and two new checks that /proc/netif names both the
interface and the driver behind it, which together say the stack
went through the class rather than through a driver it was compiled
to know.
- Two NICs attached by hand (-device e1000 twice, second with its own
MAC): eth0 and eth1 both attach, each reports its own MAC in
/proc/netif, and each gets its own DHCP lease. Before this commit
the same machine produced one interface.
- make schfs-check still passes in full.
One trap worth recording: moving a header broke the build for anyone
with stale .d files -- make stopped with "No rule to make target
'include/e1000.h'". CFLAGS gains -MP, which emits a phony target per
header so a moved or deleted one can never wedge the build again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The TCP/IP stack landed with its end-to-end test performed by hand: start
a listener, boot, type nettest, read the output. That is evidence once
and nothing afterwards, and it is exactly the kind of check that quietly
stops being run.
bootcheck -Net does the whole exchange. A listener runs on this host on a
port picked by binding one and letting go, the guest is told to connect
out to 10.0.2.2 at that port, and both ends are asserted: the guest
reports what it received, the listener reports what arrived. One side
alone proves much less than it looks -- a stack that completes a
handshake and then drops every byte satisfies any check made from inside
the guest.
The listener is a background job, a separate process, because this
harness has one hard rule: nothing may block the writer feeding QEMU's
stdin. A socket accept is precisely the wait that deadlocked two earlier
attempts at reading QEMU's output. It answers the protocol nettest
speaks and then closes -- the close is load-bearing, since nettest reads
until end of file and a listener that stayed open would hang the guest
rather than fail it.
The NIC is spelled out (-netdev user + -device e1000) rather than left to
QEMU's default. Other runs can take the default and still answer "did a
driver bind"; a run that asserts on an address should be reading its own
configuration.
/proc/netif is new, and the address is why. Until now the only report was
a line on the console at boot, which userland cannot read and a test can
only find by scraping the transcript. It gives interface, MAC, state,
address, netmask, gateway, MTU and frame counts. Named netif rather than
net on purpose: /proc/net is a *directory* on Linux, and a plain file
there would give ENOTDIR to anything reaching for /proc/net/dev, which is
worse than not having it. The counters are kept at the lwIP seam rather
than in the driver, because a frame the driver received and the netif
dropped never reached the stack.
Verified: 38 [ok], no failures. Eleven of them are new -- lwIP up, DHCP
obtaining 10.0.2.15 from SLIRP (a number nothing in the guest knows in
advance, so it is the exchange completing rather than a guess), the four
/proc/netif fields, frames counted in both directions, the handshake, the
reply read back, and the listener confirming HELLO-FROM-SCHOS arrived.
One bug found by writing it: $netPort was chosen after the command list
was built, so the guest ran `nettest 10.0.2.2` with no port and printed
its usage. An unset PowerShell variable interpolates to nothing rather
than failing, which is worth remembering next time a command in this
file looks right and behaves oddly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last three phases of the filesystem, which were written together and
touch the same files throughout -- kmount.c, objw.c and vfs.c are each
rewritten by all three -- so they land as one commit rather than three
that could not be built separately.
Durability. A transaction that reaches the platter out of order is worse
than one that never reached it: the uberblock can name a tree whose
blocks are still in the drive's write cache. bdirty/bflush give the
buffer layer an explicit barrier, ideflush issues ATA FLUSH CACHE and
usb_storage_flush the SCSI SYNCHRONIZE CACHE that means the same thing,
and a commit now waits for the data before it writes the root that
points at it. The uberblock groups are read newest-first with fallback,
so a volume whose newest roots are torn mounts at an older one and says
which generations were lost.
Reflink. Extents carry a refcount, so two objects can name the same
blocks and neither frees them while the other holds them. Exposed as
FICLONE -- Linux's ioctl value, so `cp --reflink` works unmodified --
and as cmd/reflink for the case where there is no cp to hand.
Attributes, and the one genuinely new idea here. attr.c stores named
values on an object, reachable through setxattr/getxattr/removexattr at
Linux's syscall numbers. On top of that, schfs_index declares an index
over an attribute name and schfs_query answers "which objects carry this
value" from the B-tree rather than by walking. A query against an
unindexed attribute is refused with an error, never silently answered by
a full scan: a query that quietly becomes a scan is worse than one that
admits it cannot help. cmd/schattr drives both.
Three bugs fixed on the way, each found by the numbers being wrong
rather than by inspection:
- statfs's f_fsid was declared int64[2]. Linux's fsid_t is two *ints*,
so every field after it landed one slot late: userland read f_frsize
out of f_namelen, got 255, and df reported a 32 MB root filesystem
as 1992 KB. It hid for as long as it did because the fields used to
be invented constants, so wrong numbers looked like the usual ones.
- struct inode's nlink was a short against SchFS's uint32, so a file
past 32767 names came back negative -- and iput frees an object
whose count reaches zero. It is an int now, signed on purpose, so an
underflow lands somewhere a check can see rather than wrapping.
- filewrite split every write to fit v1's log. That limit belongs to
the journal, not to writing, and SchFS makes one transaction of
whatever it is handed: a 1 MB write was becoming 170 commits and
170 flushes for nothing. fs_ops carries max_write now, and SchFS
sets no limit.
Also here: a real mount table, so `mount -t schfs /dev/hdb /mnt` shows
up in /proc/mounts -- df looks a path up there before it calls statfs at
all, so a mount missing from that list was invisible to it -- several
volumes mounted at once, snapshots mounted by name, and /proc/fsstat
with an rdtsc-based counter, which is the only instrument fine enough to
see inside a single syscall.
Verified: `make schfs-check` passes in full, including the sweep that
crashes the volume at each of 119 points mid-transaction and requires
every one to mount with exactly its committed data, and the case where
both newest uberblocks are rubbish and the volume still comes back. The
headless boot check passes with 27 [ok] and no failures, on a SchFS root.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>