Commit graph SchOS/include
Author SHA1 Message Date
Nexoniarz
c84d06c5b1 gfx: one glyph blitter, and a terminal cell is a surface
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>
2026-09-23 17:57:16 +02:00
Nexoniarz
d85274dbd9 gfx: the console's typeface is replaceable, and the loader is what replaces it
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>
2026-09-23 17:47:45 +02:00
Nexoniarz
182cc76588 gfx: the second head shows the boot, not just what comes after it
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>
2026-09-22 21:47:35 +02:00
Nexoniarz
e72205cdc0 panic: the crash screen reaches every display, not just the console's
"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>
2026-09-22 16:53:16 +02:00
Nexoniarz
3f13989883 unix: a bound socket is a file
Gate 1, item 3, and the last of foundations.md.

bind() creates a T_SOCK node at the path; connect() resolves the path and looks
for the socket bound to **that inode**. Matching on the inode rather than on
the path *string* is the part that matters: the name used to live only in the
socket table, so two spellings of one path missed each other, a relative path
never matched an absolute one, and there was nothing to stat() or unlink().
An inode is the thing a path resolves *to*, so connect() can walk the path
however it likes and arrive at the same socket.

    # ls -l /tmp
    srwxr-xr-x 1 root root 0 2026-09-22 08:04 socktest

The node is a marker, not the binding. It holds no data and the live
association stays in the socket table, so a socket file left by a crash is
what Linux leaves behind -- a dead file that connect() refuses until something
binds again -- and unlink()ing a live socket's path does not disturb anyone
already connected.

EADDRINUSE comes from the *directory* rather than from a scan of the table,
deliberately: a stale socket file has to refuse a rebind for the same reason a
running server's does, or two servers both believe they own it. The creation
lives in sys_bind() and not in unix.c, because that is the layer with both --
kernel/net/unix.c knows about sockets and has no business calling create().

T_SOCK is a compat addition to SchFS by the rule in schfs.h: a driver that does
not know SCHFS_T_SOCK maps it to a zero-length regular file, so nothing goes
stale and nothing becomes unreadable.

cmd/socktest section 15 asserts the plan's own wording -- bind, stat, see an
`s`, unlink -- plus the two a name table could never fail: a second bind to a
live path is refused, and connecting to a path that is a *regular file* is
refused rather than matched by name.

**And the semantics being real broke socktest on its second run**, correctly:
the socket files persisted and every bind() hit EADDRINUSE. Every real server
unlinks before binding; socktest now does too, which is the change the new
behaviour demands rather than a workaround for it.

Chasing that turned up something worse. `socktest` reported rc=1 with every
check passing, and so did every other first-party program:

  - **lib/start.S called main and threw %eax away.** sys_exit() reads the
    status from %rdi and nothing put it there, so every program exited with
    whatever that register happened to hold. Every test suite ends in
    `return failures ? 1 : 0` and none of it reached the shell.
  - **exit(void) passed no status either**, for the same reason: the
    declaration takes no argument, so %rdi was the caller's litter.

`if socktest; then` works now, and a failing command reports 1 -- checked both
ways, because a fix that makes everything return 0 would look identical from
the success side.

Also factored: unlink_path() out of sys_unlink, so a failed bind can take its
own node back out without going through the syscall entry, and create_sock()
as a named door rather than exporting create() with an arbitrary type.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:06:10 +02:00
Nexoniarz
a79dfac32b memfd: a shared memory object you reach by descriptor
Gate 1, item 2, and the thing a Wayland buffer crosses on.

memfd_create(2) is the same segment machinery shmget() already had -- pages,
refcounts, no_free VMAs, the fork and exit accounting -- reached by
**descriptor** rather than by System V key. That difference is the entire
point and the reason it exists on Linux at all: two processes share memory by
passing the fd over a socket, so neither has to agree on a path or race to
invent a key. It is also why SCM_RIGHTS had to be item 1.

docs/plan/foundations.md proposed exactly this and it was right: what memfd
adds on top of shmget is a segment that starts empty and is sized by
ftruncate(), and a file that owns one reference to it. FD_MEMFD, and
read/write/lseek/fstat all learn about it -- a descriptor that refuses read()
is a surprise nobody needs.

Three things are refused rather than faked, each the honest version of
something this kernel cannot do:

  - MAP_PRIVATE on a memfd. Every caller is sharing on purpose; a private
    mapping of an anonymous object is an expensive MAP_ANONYMOUS.
  - Mapping past the end. Linux faults those pages in; there is no handler
    here that could.
  - Shrinking while mapped. Linux truncates the pages out from under the
    mapper; here a freed page would stay mapped to memory somebody else now
    owns.

Writable MAP_SHARED of a *real file* is still ENODEV. That needs a page cache,
and an anonymous object has no such contract to break -- which is precisely
why memfd is the narrow answer.

**cmd/memfdtest**, seventeen checks, written so a permissive implementation
cannot pass: an mmap that quietly returned private zeroed pages satisfies every
return code and fails the one assertion that matters, because the reader is a
different process. It ends with the combination the plan singled out -- a memfd
sent over a socketpair to a receiver that closes every inherited copy first,
maps only the descriptor that arrived, reads what the sender wrote and writes
back where the sender can see it.

Two things were found by writing that test, both of which had been refusing
everything that was not FD_INODE:

  - sys_fstat() never reached filestat(), so the memfd branch there was
    unreachable from the syscall that needs it. It also refused pipes and
    sockets, and returned -1 (which musl reads as EPERM) rather than an errno.
  - sys_lseek() the same, and SEEK_END needs a length that is not in an inode.

Verified: 40 consecutive runs leave MemFree at exactly the value it started
at -- 936656 kB before and after -- so nothing leaks across creation, dual
mapping, descriptor passing and exit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:47:31 +02:00
Nexoniarz
c98bdf0019 kmem: a slab allocator, and what the fixed tables actually cost
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>
2026-09-22 09:32:06 +02:00
Nexoniarz
a5ff83de46 proc: real CPU time, a tty number, and an exe that is a symlink
Four Gate 0 rows, and a fifth found while checking one of them.

**getrusage() did not distinguish RUSAGE_CHILDREN from RUSAGE_SELF**, which
undersells it: it ignored `who` entirely and reported the machine's *uptime*
as the caller's user time. Every process of every age answered with the same
number. Per-process CPU time is sampled at the timer tick now and split on the
interrupted privilege level; children's time is rolled up in wait() rather than
exit(), because RUSAGE_CHILDREN means *reaped* children and a running one has
not contributed yet.

**And wait4()'s fourth argument is a `struct rusage *` that was read off the
stack and thrown away.** That is the one shells actually use -- wait3() is the
same syscall with pid -1 -- so zsh's `time` reported

    16777216.01s user 1073.78s system 927983249% cpu

for every command. 2^24 seconds is uninitialised memory, and it did not vary
with the workload because it was never written. Now:

    busy loop   2.66s user  97% cpu   2.720 total
    sleep 2     0.02s user   0% cpu   2.248 total
    ls -lR /    0.70s user  65% cpu   1.070 total

**ru_stime is a structural zero and says so.** MSR_SFMASK includes FL_IF, so
SYSCALL clears interrupts and no tick can land while a process is inside a
syscall. Tick sampling cannot see kernel time at all here. Charging it needs
TSC deltas closed and reopened around every sched(), which is scheduler
accounting rather than sampling; the limitation is written down in
docs/architecture/kernel.md rather than papered over.

**/proc/<pid>/exe is a symlink.** It also needed a real st_size, and every
other procfs file deliberately reports zero -- readlink(2) bounds its copy by
it, so a symlink of size 0 resolves to the empty string, which looks like it
worked and is worse than the regular file was. Generating that one path in
ilock() costs one small render per stat() of one file.

**OVMF memory reporting is closed**: 1 GB reports 1017 MB usable, 2 GB reports
2041 MB. The firmware keeps about 7 MB either way, which is the right shape.

**And `ps` printed a header and nothing else**, which is not in any list
because nobody had run it. `ps` with no arguments shows only processes on
*this* terminal; /proc/<pid>/stat hardcoded tty_nr to 0, so nothing could ever
match. `ps -e` always worked, which is exactly what hid it. p->ctty was already
tracked -- procfs simply was not asked. Fields 14..17 were hardcoded zero too,
so TIME read 00:00:00 however long a process had run; they carry the new
counters now, and a shell that has burned 653 ticks reports 00:00:06.

Verified: utime climbs 18 -> 653 across a loop in the shell itself, `ps` lists
its own terminal, `ps -e` still lists everything, `readlink /proc/1/exe` gives
/bin/schinit, and sigtest/$()/rename/dmesg are all still green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:45:00 +02:00
Nexoniarz
7e88cea151 root: find the filesystem instead of being told at build time, and fix dmesg
`#define ROOTDEV 1` -- IDE disk 1 -- so a kernel could only ever root on the
disk it was *built* expecting. Booting the same image from a USB stick was
impossible by construction, and that was the one thing standing between this
kernel and its own acceptance test, which begins "boot on real hardware from a
USB stick".

rootdev_mount() probes. schfs_kmount() was already written for exactly this --
its own comment says "the uberblock scan doubles as the probe: no valid
uberblock means this is simply not a SchFS volume, which is not an error worth
reporting loudly" -- and a failed probe leaves the slot unmounted and reusable,
which is what makes a loop over candidates legal at all. So the change is one
function and one call site; the only real use of ROOTDEV was `schfs_kmount()`.

Order is preference, not confidence: a RAMDISK_ROOT build wins outright, then
USB, then IDE 1, then IDE 0. USB before the fixed disks because a stick that is
plugged in was plugged in on purpose. iderw() panics on an absent drive rather
than failing, so each candidate is checked with ide_disk_present() /
usb_storage_present() first -- which is what those predicates are for.

Verified with **no IDE disk attached at all**: boots, mounts the stick, writes
a file, and the file is still there on the next boot.

    [    0.000021] root: USB disk (device 3)

And when nothing matches, it names what it looked at, because "no root
filesystem" with no list is the least useful thing a kernel can say at the one
moment somebody needs to know which disk it expected:

    root: no SchFS volume on any candidate device. Looked at:
      device 3, USB disk: not present
      device 1, IDE disk 1: not present
      device 0, IDE disk 0: present, but no SchFS on it

**That message is also how dmesg turned out to be broken**, because it did not
appear in `dmesg` and should have. `dmesg` printed *nothing at all*, on a
system whose boot banner says "or dmesg once you are in" and whose entire
quiet-boot design is "route the detail into the kernel log and say how to read
it". The log was there the whole time -- `cat /proc/kmsg` has always worked --
but the one command anybody would reach for could not see it.

Two separate faults, each alone enough:

  - **sys_syslog() was `return 0`.** dmesg does not read /proc/kmsg; it calls
    klogctl(10) for the buffer size and klogctl(3) to read. 0 to the first one
    means "the log is zero bytes", and a stub that answers 0 is
    indistinguishable from a machine that logged nothing. Implemented for the
    actions that mean something here; the rest return -EINVAL rather than 0,
    because a console log level this kernel does not have is not a request that
    succeeded.

  - **With that fixed, dmesg was still empty.** It parses every line with
    `sscanf(msg, "<%u>[%llu.%llu] %n", ...)` and silently drops anything that
    does not match. The ring's `[     0.00] text` already matches everything
    after the priority -- scanf skips the leading spaces -- so the only missing
    thing was the `<6>`. klog_render_syslog() adds it on the way out rather
    than storing it: the ring is also what a person reads with `cat
    /proc/kmsg` and what PSOGM points at after a crash, and an ABI detail
    belongs at the boundary that has the ABI.

Also: the first version of the no-root diagnostic printed

    %-11s device 1596400

because cprintf() has no field-width specifier -- it prints "%-11s" literally
and then reads the next argument for the %d. Plain %s now, with the note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:24:34 +02:00
Nexoniarz
a053a96add signal: the return trampoline was Linux-only, and a test that proves the rest
Went looking for why `X=$(echo hi)` hangs every shell. Did not find it. Found
two other things, and built the instrument that rules out the largest suspect.

**The signal return trampoline was ABI-blind.** deliver_signals() manufactures
a return address for the handler -- seven bytes on the user stack reading
`mov eax, 15 ; syscall`. Fifteen is *Linux's* rt_sigreturn, which is right for
anything musl-linked, and that is every port and every shell. For a first-party
binary it is native syscall 15, which is `open`. So a handler in any cmd/
program ran, returned into the trampoline, called open() with whatever the
handler had left in its registers, got -ENOENT, and carried on executing the
stack bytes after the trampoline.

    pid 14 sigtest: trap 14 err 4 rip 0x46ca7 addr 0xfffffffffffffffe
      rax=fffffffffffffffe ... rsp=46ca0
      ustack: 46e30 46ca0 50f0000000fb8 0 0 0

rip is rsp+7 -- one past a seven-byte trampoline -- and the third word of the
stack is the trampoline's own bytes. It emits the number for the process's ABI
now; syscall_map is the thing that says which numbering this process speaks.

**SIG_BLOCK, SIG_UNBLOCK and SIG_SETMASK were not defined anywhere.**
sys_sigprocmask branched on bare 0, 1 and 2 with the names in comments, so
anything outside the kernel had to spell the number and hope it had the right
one. Defined in posix.h, used in the kernel.

**cmd/sigtest** asserts the SIGCHLD/sigsuspend contract every shell is built
on, and it is written so a permissive stub cannot pass: a blocked SIGCHLD stays
pending, sigsuspend delivers an *already pending* signal without spinning, the
handler runs exactly once, the mask is restored, the child is still reapable,
and a signal arriving mid-wait also wakes it. Eight checks, all green. The
kernel's half of that contract is now proven rather than assumed, which is what
lets the remaining bug be pursued somewhere else.

Along with it, `rt_sigsuspend` in lib/usys.S -- the syscall existed and the
first-party library simply never exposed it.

On the hang itself: it is not job control, not SMP, not zsh (mksh does it too),
and not any value the kernel returns -- a full syscall trace is in
docs/history/kernel.md section 7, which also records what the contradiction
narrows to. That section had said "root cause not yet isolated" with three
suspects; all three are now eliminated, and the note says what to do next
instead of what to guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 23:56:34 +02:00
Nexoniarz
3185685997 syscall: three globals on the entry path, shared by every CPU
One to four processes died per boot with a fault they could not have caused:
`rip 0x0` -- an instruction fetch at zero, which a process does not branch to
-- or a dereference of `0xffffffffffffffea`, which is `-EINVAL`. It hit shells
and schinitd alike and had been carried for a long time as "zsh sometimes jumps
into .data".

`syscall_entry` has to swap FS_BASE from the user's TLS to the kernel's before
it can touch any `%fs:` data, and `wrmsr` destroys rax, rcx and rdx. Those have
to be parked somewhere first -- not on the user stack, which is untrusted and
has not been switched away from yet. They were parked in three globals:

    // Single-CPU scratch for syscall_entry FS swap
    // (safe while IF cleared by SYSCALL/SFMASK)
    uint64 _sc_rax, _sc_rcx, _sc_rdx;

IF being clear answers for an interrupt arriving on *this* CPU. It says nothing
whatever about a second CPU executing the same three stores at the same
instant, and with more than one processor entering syscalls that is not a rare
interleaving -- it is the ordinary one.

**%rcx is the user return address.** SYSCALL puts the user's rip there and
SYSRET takes it back from there. So the CPU that lost the race returned its
process into *another process's* instruction stream, with %rax -- the syscall
number on the way in, the return value on the way out -- swapped alongside it.
Both symptoms fall straight out of that: a zero handed over and followed, and
an errno used as a pointer.

Per-CPU words at %gs:32..48 now. %gs is usable there, before the FS swap, for
exactly the reason RESTORE_KERNEL_FS can already read %gs:0: GS base is per-CPU
in hardware, seginit() sets it once, and userspace cannot change it --
sys_arch_prctl() implements ARCH_SET_FS and ARCH_GET_FS and rejects the rest.

Measured rather than argued. Both variants built once, `build/esp` saved for
each, the same pristine schfs.img restored before every boot, -smp 4:

    three globals     10 boots    10 faults, in five of the ten
    three per-CPU     18 boots     0 faults

A _Static_assert pins all seven per-CPU offsets, because trapasm.S spells them
as literals and cannot include a header -- a slot that moves would leave a
register quietly holding somebody else's value, which is the hardest possible
thing to find.

**This is the second bug of this exact shape in this file.** The first was
`kernel_fs_base`, a single global every CPU wrote during its own seginit(), so
the last writer won and every CPU restored one CPU's per-CPU block;
RESTORE_KERNEL_FS reads %gs:0 for that reason. The lesson generalises and is
now written down in both places: **on the entry path, a global is a bug.**

Closes the row added to Gate 0 four commits ago. It was the right row to put
first -- a compositor built on this would have failed in ways indistinguishable
from its own bugs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 22:56:45 +02:00
Nexoniarz
d58b1cda17 kstack: 3736 of 4096 bytes, measured, and the AP got a page it did not own
Painting every kernel stack at allocation and scanning it on each trap puts the
high-water mark at **3736 of 4096 bytes** -- 91%, in netd, inside lwIP's input
path. 360 bytes of headroom on a kernel that takes interrupts on the current
stack is not headroom: a timer landing at that depth pushes a trapframe and
runs trap() straight off the bottom of the page and into whatever kalloc handed
out next, which is very often another process's kernel stack.

The canary at kstack[0] did not catch this and could not have. It is checked on
trap entry and exit, and a frame allocated in one `sub $N, %rsp` steps clean
over the bottom eight bytes and writes below them without ever touching it. It
catches a slow walk past the end, not one deep call.

8192 now, and the stacks are static -- one per ptable slot, NPROC * KSTACKSIZE
= 1 MB of BSS. Static rather than a bigger kalloc because kalloc() hands out
exactly one page and there is no contiguous multi-page allocator to ask. A
process's slot is fixed for its lifetime, so the stack belonging to that slot is
the natural answer: no allocation to fail, no free to forget, and the kalloc
failure path in allocproc() goes away with it. The page was being paid for out
of kalloc before, one at a time, and undercounted.

**And the AP scheduler stack was already wrong.** startothers() did
`stack = kalloc()` for one page and then handed the AP `stack + KSTACKSIZE` as
its stack top. Exactly right for as long as KSTACKSIZE happened to equal
PGSIZE, and a whole page past the allocation the moment it did not -- which
this commit would have made true. That is not a throwaway boot stack either:
the AP runs mpmain() and then scheduler() on it for the rest of the machine's
life, and takes interrupts on it whenever no process is current. Static too,
NCPU of them.

**This does not fix the intermittent zsh crash**, and the measurement above was
taken while looking for it. It is worth saying plainly: 1-3 shells per boot
still die, so the hypothesis was wrong and the stack was a separate latent bug
that the search turned up.

What the search did establish is worth more than the guess was:

  - **It is SMP-only.** Three boots at -smp 1: zero faults. Three at -smp 2:
    one, three, one.
  - **It is not only zsh.** schinitd took one too.
  - **The faults name their cause if you print enough.** `rip 0x0` is a process
    handed a zero and following it, not a process that branched anywhere. And
    schinitd's `addr 0xffffffffffffffea` is -22, which is -EINVAL: a call
    returned an error and the caller used it as a pointer.

So trap.c dumps registers, segments, and the top of the user stack for a fault
at rip below a page or a faulting address near zero, the way it already did for
a GP fault. Neither of the two facts above was visible from the one-line
report, and both came out of the first dump that included them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 21:05:15 +02:00
Nexoniarz
4e3e345b41 kbd: three layouts, and the arrow keys were sitting on top of UTF-8
`∞`, `→`, `≤` 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>
2026-09-20 01:09:32 +02:00
Nexoniarz
5618164754 font: the console draws outlines now, and a byte could not have held Ą
"Ą Ę Ś Ł Ż" 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>
2026-09-20 00:35:34 +02:00
Nexoniarz
ca28978995 dma: the 4 MB cap was the refusal, not the arena
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
2026-09-14 22:52:20 +02:00
Nexoniarz
75ccf9841c errno: -1 is EPERM, and four places were saying it by accident
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
2026-09-14 22:12:28 +02:00
Nexoniarz
eacb13ce35 dma: best fit, and say which number is the problem
The arena hands out contiguous memory -- that is the only reason it exists --
so the number that matters is not how much is free but how big the largest
piece is. First fit spends that number cheaply: a 64 KB ring taken from the
middle of a 16 MB arena leaves two 8 MB extents, and a framebuffer wanting 9 MB
then cannot be had on an arena three quarters empty.

dma_alloc() is best fit now: the tightest extent that holds the request, which
leaves the large ones whole for the requests that have no alternative. O(n)
over a table with a handful of entries, and it cannot fragment worse than first
fit for any request.

And the failure says both numbers, because they point at different fixes and
the old message only had one of them:

    dma_alloc: no room for 9216000 bytes -- 12591104 free,
               largest contiguous piece 8495104 (`schdbg dma` for the map)

"12 MB free" alone reads as out-of-memory on a machine that has plenty; the gap
between the two is the whole story. A fragmented arena is cured by whoever is
holding the block in the middle letting go, a full one by DMA_ARENA_BYTES.

Also fixes a real bug found while reading it: when the extent table was full,
the tail-split path did `continue` while still holding the lock and having
already committed the extent to the caller -- so it carried on scanning, and
could return a second block or fall through to the failure path having handed
one out. It now returns the block it committed, and says so.

**This does not close the report it came from.** vgpud failing to get a
framebuffer was not reproduced here: at 1280x800 the arena has 8.1 MB
contiguous against a 4 MB request. What is fixed is that the next occurrence
explains itself instead of saying "not enough DMA memory" on an empty arena.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 21:28:07 +02:00
Nexoniarz
617a4aec0a boot: one line when it works, all of it when it does not
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
2026-09-14 21:19:50 +02:00
Nexoniarz
b08432b2c3 userland: 268 applets, an editor, names that resolve, and a web server
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
2026-09-14 19:24:55 +02:00
Nexoniarz
bc39b73969 debugger: D5, the network transport -- and the plan closes
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
2026-09-14 15:33:53 +02:00
Nexoniarz
071499a378 ldls: a second driver, and everything it found
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
2026-09-14 15:20:07 +02:00
Nexoniarz
f38eb8a7fd ldls: L4 was free after all, and the plan closes
L4 was listed as "free -- H2 built all of this" and never actually tested,
which is a claim rather than a result. Tested now, and it holds:

    ldlsd running     ping 2/2; holds index 7 and DMA slots 3 and 4
    killed            interface DOWN, device released, bus mastering
                      cleared, DMA reclaimed -- back to vgpud's 3 blocks
    ping              fails honestly: sendto: -5
    a second ldlsd    ping 3/3, and the arena hands back the same addresses

pciuser_release_all() and netuser_release_all() sit beside each other in
exit() for the same reason, and that they apply unchanged to a foreign driver
is the whole point of having built them against our own.

With that, every milestone on ldls.md is done, so it leaves docs/plan/ for
docs/architecture/ -- the rule docs/plan/README.md states and the one that
keeps the two directories meaning different things. The new document describes
what is, not what was planned: the five rules, the four pieces, what it costs
(polling, no stack protector, locks that are no-ops), that it can be killed,
and a "what it does not do" that leads with one driver, one kernel version,
tried once.

Sixteen source files pointed at docs/plan/ldls.md and would have been dangling
the moment it moved. All repointed, and the milestone references -- which the
architecture doc deliberately does not have -- rewritten to name sections
instead, so a reader lands somewhere real.

The register row closes with it. hybrid.md freed a slot, ldls.md took it, and
it is now free again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 13:37:04 +02:00
Nexoniarz
2a441af3c6 ldls: a network, in a process
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
2026-09-14 08:31:06 +02:00
Nexoniarz
ef3e191c6f hybrid: a modeset, from a process
H3 of docs/plan/hybrid.md -- "a modeset API, which is the thing an
out-of-kernel GPU driver makes affordable and an in-kernel one never got".

    # schmode modes
    1280x800  *
    1280x720
    1024x768
    800x600
    640x480
    # schmode 1024 768
    1024x768 ok

Captured off the virtio-gpu head, the frames really come back 1280x800,
1024x768, 640x480 and 1280x800 again, with the checkerboard band showing no
shear at any of them.

Nothing in the kernel changed for this. No syscall on the path has heard of
a scanout: schmode writes a line to a socket and vgpud sends five commands
to the device. The comparison is the point -- the same five commands in the
kernel would have been the same five commands *and a reason to be nervous*,
because a wrong one there takes the machine instead of one process. That is
why the in-kernel driver never grew one: not difficulty, exposure.

The framebuffer is allocated once, at the largest mode, and every smaller
mode reuses it. PCIIOC_DMA has no free -- a driver's blocks come back when
it exits and not before -- so a modeset that allocated would spend sixteen
slots and then the arena, and the failure would look like a GPU bug rather
than like counting. A larger mode is refused and says why.

vgpud used to end in for(;;) sleep(100) under a comment arguing that
building a control socket before there is a compositor to judge it is the
mistake this plan warns about. That was right about a *compositor protocol*
and wrong as a reason to have no socket: a modeset has a consumer that
exists, somebody changing the mode and looking at the screen, and schmode
is small enough to judge an interface this small. What is deliberately not
on that socket is everything a compositor would need -- no buffer
submission, no damage, no vblank -- which still waits, for the original
reason.

H4, the USB eviction, is now the only milestone left on the plan and is by
some distance the largest thing on the register.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 01:36:05 +02:00
Nexoniarz
415df0fd4d debugger: reachable from the console, and named on the crash screen
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
2026-09-14 01:29:30 +02:00
Nexoniarz
113595f80b debugger: who owns this page, and who just wrote to it
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
2026-09-14 01:17:57 +02:00
Nexoniarz
1dd4ba0186 debugger: the kernel can be asked a question
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
2026-09-14 00:58:45 +02:00
Nexoniarz
d1f50c4da3 hybrid: the driver is killed, and the driver comes back
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
2026-09-14 00:05:43 +02:00
Nexoniarz
d3c37f82b6 docs: the tree moved and the documentation did not
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
2026-09-13 20:05:06 +02:00
Nexoniarz
9e6d8dd4e3 sockets: a descriptor can cross between processes
sendmsg/recvmsg with SCM_RIGHTS. Item 1 of docs/plan/foundations.md and
mechanism 1 of docs/plan/hybrid.md, which are the same primitive seen from two
directions: Wayland's wl_shm_create_pool passes a file descriptor with no
integer-shaped alternative, and handing a driver process the device it is
allowed to touch is the same act. Both plans stopped here.

Linux's structures byte for byte -- msghdr, cmsghdr, the CMSG_ macros -- because
the programs that need this are not ours. libwayland and toybox both call
recvmsg with Linux's headers and would not notice a friendlier door.

The delivery rule is the whole design. A batch of descriptors is pinned to the
stream position of the first byte of the message that carried it, and that has
two halves: a reader arriving at that position is handed them, and a reader that
has not reached it is stopped SHORT of it. Leave out the second half and a
program asking for 4096 bytes swallows three messages and is handed one
message's descriptors, with nothing in the result saying which. socktest checks
exactly that: two one-byte messages, an eight-byte read, one byte back.

The kernel holds a reference to every descriptor in flight, so a sender may
close its own copy the moment sendmsg returns. Giving that reference back is the
part that leaks if it is not written down, and there are three ways a message
ends:

  - received with room            installed in the receiver's table
  - received without room, or by
    plain read(2)                 closed; MSG_CTRUNC for the first case
  - never received, socket closed closed by sockclose

The middle row looks like a choice and is not: holding kernel references for a
receiver that has proved it is not looking is a leak with extra steps.

No garbage collector. A socket descriptor sent over that same socket and never
received holds a reference to the socket that holds the reference to it, and
neither close reaches zero. Linux carries several hundred lines for this.
Nothing on either plan passes a socket over a socket -- Wayland passes memory,
the driver work passes devices -- so it is a bounded leak that is documented
rather than a silent one.

Fourteen checks in cmd/socktest, and every one moves data through the descriptor
rather than inspecting it. The descriptor passed is always a pipe end, because a
pipe announces its own lifetime: a read of a pipe whose writers are all gone
returns 0, which turns "was the kernel's reference released" -- otherwise
invisible from userspace -- into a value a test can compare.

Two bugs fell out, neither in the new code:

  - sockpair() never set the socket's domain, so every socketpair() socket
    claimed domain 0. Invisible while the data paths asked only whether a socket
    was AF_INET or AF_NETLINK, since zero answers "neither" correctly.
    SCM_RIGHTS asks "is this AF_UNIX", and zero is wrong for that one.

  - pipe_poll() reports POLLHUP but not POLLIN at end of file, so a non-blocking
    read of a pipe whose writers had all gone returned EAGAIN forever instead of
    0 -- a program that polls and reads spins waiting for an end that already
    happened. sockreadable() always had the equivalent condition; the pipe path
    did not. Found because the test helper for "is this descriptor closed" is a
    non-blocking read of a pipe.

projects/schcom/README.md said SCM_RIGHTS was done for a long time while nothing
in the kernel implemented it, and an entire GUI plan was drawn on that line.
It is true now, which is not the same as it having been right, and the README
says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 19:49:37 +02:00
Nexoniarz
ea68ef4b54 hybrid: the GPU driver now runs in userspace
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
2026-09-13 18:27:41 +02:00
Nexoniarz
72074a960e hybrid: interrupts to userspace, and a crashed driver that stays crashed
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
2026-09-13 18:11:00 +02:00
Nexoniarz
24f79194ab hybrid: /dev/pci -- a driver can live outside the kernel now
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
2026-09-13 15:34:28 +02:00
Nexoniarz
0def474272 init: a hang is as detectable as a death, and PID 1 comes back
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
2026-09-13 14:27:22 +02:00
Nexoniarz
c80b7bf3b2 init: PID 1 becomes a role -- schinit splits, schinitd supervises
docs/plan/init.md milestones I0 and I1.

**The split.** schinit is PID 1 and does four things: bootstrap the
filesystems, fork schinitd, fork jobs on schinitd's behalf, reap children. No
manifest parsing, no filesystem walking after boot, no allocation. It is the
one program on the machine with no supervisor, so it is written to be too
boring to fail.

schinitd is PID 2 and holds everything else -- /etc/schinit, respawn policy,
throttling -- and is restartable, which is where the bugs are allowed to be.

**Jobs are forked by PID 1, not by schinitd.** That is the load-bearing
detail. schinitd decides and asks; PID 1 acts. So every job is a child of PID
1 at all times, and a supervisor that dies orphans nothing, reparents nothing
and restarts nothing.

**/proc/schinit** is the job table, written by PID 1 whenever it changes. The
kernel holds the text and does not interpret it: a procfs generator runs in
the kernel, and walking PID 1's memory to answer a read would put a userspace
program's format into the kernel, where it would rot.

Verified the way the plan asks for -- `kill 3`, then look:

    schinitd: adopting os.schos.console-shell
    schinitd: adopting os.schos.tty2-shell

/proc/schinit byte-identical across the restart, same pids, same cookies, same
start times, and the shell being typed into never noticed.

The first attempt got I1 wrong in the instructive way: PID 1 published the
table and schinitd never read it back, so the restarted supervisor started
everything a second time -- two console shells on one terminal, which is the
split-brain this plan rejects the two-init scheme for, reached by a different
road. Publishing state is half of it; coming back and believing it is the
other half.

The protocol is include/schinit.h: fixed-size messages, versioned in the
hello, entirely internal. Fixed-size so PID 1 never allocates, and a
supervisor that has gone mad cannot make it run a parser.

Still to come: I2 (heartbeat both ways, so a hang is as detectable as a death)
and I3 (proc_reincarnate, the actual law change).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 13:57:27 +02:00
Nexoniarz
bbc512a51c time: read the clock from the TSC, not from counting interrupts
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
2026-09-13 13:57:26 +02:00
Nexoniarz
65906639da tty: AltGr+F1..F6 by default, and AltGr+-/+ to step
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
2026-09-13 13:22:01 +02:00
Nexoniarz
7c0f7c7d01 tty: the switch chord is a setting, because only the host knows the answer
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
2026-09-13 13:13:38 +02:00
Nexoniarz
fe2f5e1bf8 tty: Ctrl+Alt+Shift+F1..F6, and VT_SETMODE actually works
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
2026-09-13 13:05:43 +02:00
Nexoniarz
91a6d440d6 tty: Alt+1..6 switches terminals, and AltGr is left alone
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
2026-09-13 12:56:34 +02:00
Nexoniarz
a5b0d5a0ef tty: the grid was 80x24 on a 160x50 screen, and wrapping was eager
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
2026-09-13 12:49:13 +02:00
Nexoniarz
1c1b0c9570 tty: six virtual terminals, and the plumbing that makes them terminals
/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
2026-09-13 00:08:00 +02:00
Nexoniarz
14f0e4d0e2 kernel: processes have an environment now, and zsh is the shell
Porting zsh found four defects in the kernel and one in the filesystem. None
of them were about zsh; all of them had been there the whole time.

**exec threw the environment away.** kernel/fs/exec.c wrote a single zero for
envp with the comment "(no environment)" -- not yet, but ever, for every
process. musl has been passing a perfectly good envp to execve (Linux 59, which
SchOS answers) and it was never read. So $TERM was unset, $PATH was whatever
each program invented, and a variable exported in a shell did not survive the
exec meant to carry it. exec() takes an envp, sys_exec reads argument three,
and schinit manifests grew an Environment key. The userland two-argument
exec(path, argv) stays as a wrapper that passes null explicitly -- the raw stub
is execve now, so a short caller cannot leave %rdx holding a stray pointer.

**Which overflowed the kernel stack.** Three pointer arrays, ~1.8 KB, on a
4096-byte stack, under exec -> loaduvm -> readi -> SchFS -> btree. Pid 1 went
down on the first exec. The canary caught it and named the process, which is
what it is for. They live in one kalloc'd page now.

**waitpid returned a bare -1** for both "no children" and "interrupted". musl
reads [-4095,-1] as -errno, so userspace saw EPERM; zsh printed "wait failed:
operation not permitted" and abandoned the rest of /etc/zsh/zshrc. -ECHILD and
-EINTR now. Third time a bare -1 has cost real time -- on a system with no
permission model, EPERM should be read as "a syscall returned -1".

**NVMA was 64.** musl's allocator takes a mapping per size class and does not
release them eagerly, so compinit ran out of slots and got -1, which is EPERM,
which malloc turns into NULL, which zsh reports as "fatal error: out of memory"
-- on a machine with 1000 MB free. 256 slots, and -ENOMEM.

**And a small write to SchFS costs 58 ms.** 200 writes to one open file take
11.75 s; the same to /dev/null take 0.14 s. That is the design stated at the
top of kmount.c -- one transaction group per operation, and a commit is two
flushes and an uberblock -- not a bug. But compdump writes its digest with
thousands of separate prints, so it takes minutes and looks like a hang.
compinit -D, which skips the dump, finishes in 3.6 s. This one is a decision
rather than a fix, so it is written up and left: /etc/zsh/zshrc keeps compinit
switched off and explains exactly why, with the measurements.

zsh runs as /bin/zsh and is the console shell. ZLE works -- arrow keys recall
history through terminfo, $COLUMNS reads the real 160 because TIOCGWINSZ was
fixed, colour and prompt expansion work, startup is 0.14 s. mksh keeps /bin/sh,
so schinit's no-jobs fallback is a different program from the one that could
have failed.

Also fixed while here: AT_CLKTCK was the literal 100 and is USER_HZ; the
extract rule in four ports could move a source tree inside itself when make
re-ran it, which is how zsh's configure came to write modfile=zsh-5.9/... into
config.modules and fail three steps later.

schfs-check passes. Full writeup in docs/history/kernel.md section 14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 23:26:50 +02:00
Nexoniarz
9d4cdb44fc kernel: the clock ran 7x slow because the GPU never acknowledged its interrupt
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
2026-09-12 21:34:36 +02:00
Nexoniarz
c874811679 fs: devtmpfs, so /dev is generated rather than written to a disk
A fresh boot spent 2600 ms in schinit's bootstrap() before printing its first
line, and the whole of it was device nodes. Measured rather than guessed: an
instrumented bootstrap reported 2600 ms on a fresh image and 0 ms on the next
boot, because the nodes already existed by then and mknod failed fast. The cost
was therefore invisible to anyone not building images -- which is to say,
invisible to everyone except whoever works on this.

Each node was a real inode on the root filesystem, and SchFS commits every
mutation as its own transaction group: a copy-on-write rewrite of the path to
the root, then an uberblock behind a cache flush. Twenty of those is seconds.

The duplication mattered more than the time. Major and minor numbers are the
kernel's -- devsw[] in kernel/dev/cdev.c is what they dispatch on -- and schinit
held a second copy of that mapping with nothing checking the two agreed. Adding
a character device meant editing a userspace program, and a wrong minor produced
a node that opened successfully and did nothing.

kernel/fs/devfs.c puts the table next to the thing it describes and serves /dev
behind the same fs_ops vtable procfs uses. The namespace is all it provides:
fileread and filewrite see T_DEV and go through devsw themselves, so a read
never reaches it. It is read-only, and dirlink and ialloc answer EROFS rather
than pretending -- a node exists because the kernel has the device, or it does
not exist.

This also makes /proc/filesystems true. It has advertised "nodev devtmpfs" for
as long as it has existed, against a kernel that had no such filesystem.

Two things found while measuring what remained:

  * The thirteen mknod() calls for /proc ran *after* the procfs mount, so every
    one was resolving a path into a read-only synthetic filesystem and failing.
    procfs.c's own note called them a fallback to be cleaned up later; they have
    been dead for longer than that.
  * The four mkdir() calls for mount points were the rest of it. They are made
    by the image builder now (sysroot-install), and the ones in init are a
    fallback -- a mkdir that fails because the directory exists costs a path
    walk and no writes, where creating one costs a transaction.

bootstrap() is now 0 ms on a fresh image, down from 2600.

init opens /dev/console after the mount rather than mknod'ing a node called
"console" into the root directory, which is what used to leave a stray /console
in `ls /`. The old path is kept as a fallback: if the mount fails there is no
/dev/console and no way to report it.

socktest passes, /proc still lists per-process directories, ps -e still
enumerates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 20:18:30 +02:00
Nexoniarz
d97dd9ed90 net: the DHCP lease goes to the log, not over the user's shell
"net: eth0 10.0.2.15/255.255.255.0 via 10.0.2.2" arrived on the display seconds
after the prompt did, so the first thing anyone saw after booting was the kernel
typing into their session. It lands wherever the cursor happens to be -- in the
middle of a command, a fastfetch, or later a compositor's output.

console_takeover() hands the framebuffer to userspace on the first exec, and
after that the screen belongs to whoever is using the machine. cons_serial_only
already encodes that rule for the boot path -- "the display is the product; the
serial line is the diagnostics" -- and it starts set for exactly this reason.
What was missing is the rule for messages that cannot be timed to happen before
userspace starts, because they are waiting on a network.

klog() is that: the serial port only, whatever cons_serial_only currently says.
A macro rather than a function, because cprintf's varargs cannot be forwarded --
there is no vcprintf, and adding one to duplicate the formatter would cost more
than it buys. cprintf holds cons.lock for the whole message, so the flag cannot
be observed half-changed by another CPU printing at the same time.

Nothing is lost. The line still goes out the UART, which is where bootcheck
reads it from and where you want it when something is wrong, and a person who
wants to know their address reads /proc/netif.

Verified by screendumping the framebuffer after a boot and two fastfetch runs:
no interruption on screen, still one occurrence on serial.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 19:59:05 +02:00
Nexoniarz
f395c39704 net: AF_NETLINK, so getifaddrs() works for everything and not just one program
fastfetch reported "getifaddrs(&ifAddrStruct) failed", and the right answer to
that is not a patch to fastfetch. Asking what interfaces a machine has is
something every networked program does, and on Linux there is exactly one way:
a NETLINK_ROUTE socket. musl implements getifaddrs() over it, so a libc call
that looks like a syscall is really a small protocol conversation -- and without
this side of it every caller fails the same way.

kernel/net/netlink.c is a responder rather than a general socket family. No
event stream, no multicast groups, nothing subscribes to link state: a request
arrives, the whole answer is generated into the socket's buffer, and reads drain
it. That shape is chosen, not a shortcut -- getifaddrs sends a dump and reads
until NLMSG_DONE with MSG_DONTWAIT, so generating at send time means a read
never blocks and the non-blocking flag is honoured for free.

RTM_GETLINK returns one RTM_NEWLINK per interface with IFLA_IFNAME and
IFLA_ADDRESS; RTM_GETADDR returns one RTM_NEWADDR per address with IFA_ADDRESS,
IFA_LOCAL and IFA_LABEL. Anything else gets a bare NLMSG_DONE, because the
caller's next move is a read either way and an unexpected error message is worse
than nothing to read.

The data is not netlink's. Interfaces come from the netdev class and addresses
from lwIP's netifs, exactly as /proc/netif reads them; nothing in the file knows
what an e1000 is. Interface indices matter more than they look -- getifaddrs
matches address records to link records by index, so the two dumps have to
agree. Loopback is 1, netdevs follow from 2.

Verified by reading it back through musl rather than by inspecting the bytes:

    lo     flags=0x49    fam=-1
    eth0   flags=0x1043  fam=17                    (AF_PACKET, the MAC)
    lo     flags=0x49    fam=2  addr=127.0.0.1  mask=255.0.0.0
    eth0   flags=0x1043  fam=2  addr=10.0.2.15  mask=255.255.255.0

Correct flags, addresses and netmasks, and the link records pair with the
address records.

One thing this does not close: fastfetch's Local IP module defaults to
default-route-only, which calls ffNetifGetDefaultRouteIfName() and reads
/proc/net/route. SchOS has no routing table and no such file, so the module
still shows nothing -- now for a reason that is about routing rather than about
enumeration. That is the next file to provide.

socktest still passes; the AF_UNIX and AF_INET paths are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 19:23:34 +02:00
Nexoniarz
d73985633b docs: a branding folder, and the three marks are not interchangeable
Shift is the logo, Guru is the icon, SchCotie is the mascot, and the difference
between them is not stylistic. An icon works once, at one size, in one state. A
mascot has to be able to *do* things -- confused, pleased, asleep, seen from
behind -- which is why it costs more and is drawn last. Written down because
this is exactly the kind of decision that gets re-derived badly.

Assets/ becomes docs/branding/, which also fixes two things:

  * Filenames had spaces in them, so every reference needed quoting. They are
    lowercase and hyphenated now.
  * Assets/ held hand-written SVG and generated PNG in one directory, which is
    the one rule docs/guides/layout.md states.

The guide records what was previously only in people's heads:

  * The constraint everything answers to -- include/logo.h is a 128x128
    single-channel coverage mask, so a mark has to survive as a solid shape in
    one colour and be recognisable at 24 px. Colour is the last decision, never
    the first.
  * Guru's purple is load-bearing. PSOGM_BG #4B1D9A is deliberately not the
    accent #7C5CFF, and theme.h's rule is that the full-bleed purple belongs
    entirely to failure. If Guru appears in accent violet on a sticker, the deep
    purple stops meaning "the machine stopped" -- and it is the only colour in
    the system that means anything.
  * Why shift-mask.svg is a separate file from shift.svg. mklogo.py reads ink as
    (255 - luminance) * alpha, so tones survive as coverage and the relationship
    inverts: darkest here is brightest on screen. Copying the violet fills would
    light the object from below.
  * Why a raster is committed beside its SVG. inkscape is not a build
    prerequisite, so requiring a vector rasteriser would break the build on most
    hosts for a file that changes about once a year.
  * That the BSD daemon is copyrighted and SchCotie must be drawn from the
    concept rather than from Beastie. A daemon is a Greek daimon and a Unix
    background process; neither is ownable. The pitchfork, the sneakers and that
    face are.

Format rule: SVG, or PNG at high resolution with no lossy compression, nothing
else. SVG is the source of truth for every mark and a raster is generated from
it, never edited.

include/logo.h regenerates byte-identical from the new path -- only the source
comment differs. The two old wordmark SVGs were deleted outside this session and
are left deleted; their PNGs are kept as superseded-*.png for reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 18:19:09 +02:00
Nexoniarz
7c8cf51941 schfs: records at arbitrary offsets are packed, and the compiler is told
A leaf's value area holds variable-length records end to end, so a record begins
wherever the one before it finished -- a multiple of 8 only by luck. The code
reads them by casting a pointer into the block, which tells the compiler they
are aligned when they are not.

That works on x86, which is why it has never shown as a bug, and it is undefined
in C. A compiler entitled to assume 8-byte alignment may emit an instruction
that requires it, and the failure would arrive at a different optimisation level
or on a different architecture looking like filesystem corruption rather than
like codegen. UndefinedBehaviorSanitizer reported 31 of them across obj.c,
objw.c, attr.c and space.c.

Seven types now carry SCHFS_PACKED: dirent_rec, extent, extref, index_rec,
attr_rec, snap and inode. Two of those -- attr_rec and inode -- were never
reported, because schfs_objtest does not reach an unaligned instance of either.
They were latent rather than absent, and casting into a leaf is the same lie
whether or not a test has caught it yet.

NO ON-DISK LAYOUT CHANGED, and that was measured before the annotation was
applied rather than assumed: every one of these already carries explicit `pad`
fields and therefore has no implicit padding, so sizes and member offsets are
identical with and without it. Seven _Static_asserts now pin the sizes, because
a record that silently grows misreads every existing volume from that point on
and looks like corruption rather than like a struct that changed.

schfs_node_hdr, schfs_item, schfs_kptr and schfs_uberblock are deliberately left
alone. The node header is exactly 32 bytes, so the items and key pointers after
it are already 8-aligned, and annotating them would cost byte-wise access on the
tree's hottest paths to fix a problem they do not have.

Verified by reading an image written before the change: schfs_fsck --check
passes on structure, checksums, key ordering, data blocks and derived free
space, and the kernel mounts its own root unchanged.

Also here: mkschfs passed a null pointer to qsort for an empty directory. Every
implementation ignores it when the count is zero; the standard does not say they
have to.

make schfs-check now runs with no sanitizer output at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 17:56:36 +02:00
Nexoniarz
3034efd300 net: a frame wakes the stack, instead of waiting for the clock
The network thread waited on the timer and nothing else, so a frame that landed
just after a poll sat in the ring for up to 10 ms -- the largest fixed latency in
the system. That was deliberate when the stack was new (a receive path woken by
an interrupt is much harder to debug than one that is asked) and there is now a
working stack to compare against, which is exactly when it becomes safe to do.

The frame is still *pulled* by the thread through .receive. It has to be: a call
into lwIP can reach the driver, the driver can sleep on the buffer cache, and an
interrupt handler may do neither. netdev_rx_signal() delivers nothing and reads
no ring -- it only ends the wait early.

The wait stays on the timer tick rather than moving to a channel of its own, and
that is the design. lwIP's timers must still run when the link is silent, so the
clock has to remain a floor under the wait, and sleep() cannot wait on two things
at once. Joining the tick's existing wakeup costs nothing and leaves one wait
with one predicate.

rx_signal lives under tickslock for the same reason it gets no lock of its own:
sleep() releases tickslock atomically, so a driver setting the flag and waking
under that lock cannot lose the wakeup by arriving between the predicate test and
the sleep. A private lock would not be held across the sleep, and that window
would be back. Calling it from interrupt context is safe on the same terms the
timer handler already relies on -- acquire() disables interrupts, so a CPU
holding tickslock cannot be interrupted into it.

The e1000 signals on RXT0 (a frame or burst has landed) and RXDMT0 (the ring has
fallen below its threshold, which is when waiting for the clock hurts most).

Measured rather than asserted: /proc/netif reports wake_rx and wake_tick, and
across three pings rx_frames and wake_rx both went 2 -> 6 -- every received frame
woke the thread through the interrupt. wake_tick keeps advancing as the idle
floor, which is what lwIP's timers need. socktest still passes and ping is still
0% loss.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 17:23:46 +02:00