Commit graph SchOS/docs/plan/1.0.md
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
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
b012060699 zsh: one word in a cross-compile cache, and every $(...) hung
`X=$(echo hi)` never returned. It has been in docs/history/kernel.md section 7
as "mksh's job control still doesn't reliably progress ... root cause not yet
isolated" with three suspects, and it was none of them, and it was not the
kernel at all.

ports/zsh/Makefile carries a block of `zsh_cv_*` answers for the checks
autoconf cannot run when cross-compiling. One of them was

    zsh_cv_sys_killesrch=no

which reads like a reassuring property and is in fact the answer to the
question configure asks: *does kill(pid, 0) return ESRCH correctly?* `no` makes
configure emit BROKEN_KILL_ESRCH, and Src/zsh_system.h answers that with

    #ifdef BROKEN_KILL_ESRCH
    #undef ESRCH
    #define ESRCH EINVAL
    #endif

zsh's waitforpid() ends its wait on `errno != ESRCH` (Src/jobs.c:1569). With
ESRCH redefined to EINVAL (22) and the kernel correctly returning ESRCH (3),
that condition was true for ever: the shell sat in sigsuspend waiting for a
child it had already reaped. An instrumented zsh says the whole thing in one
line -- kill returned -1, errno is 3, and the constant it is compared against
is 22:

    WFPkill -1 3 22

**Autoconf's own cross-compilation default for that check is `yes`.** Deleting
the line would have been correct. Writing it down as "a fact about SchOS" is
what got it wrong, and the block's comment now says to read each name as the
question rather than as a property.

Verified: `X=$(echo inline)`, `$(/bin/echo external)`, backticks, nested
`$(echo $(echo deep))`, a slow child, and `$(ls /bin | wc -l)` all return.
mksh -- as /bin/sh -- was never affected.

Two corrections to yesterday's note, both confident and both wrong:

  - "Not zsh, mksh hangs the same way" was false. mksh installs as /bin/sh,
    so that test was a `command not found` whose output scrolled past. Had the
    binary been named right, this would have localised to zsh's build in one
    step instead of a day.
  - cmd/sigtest had already proven the kernel's SIGCHLD/sigsuspend contract,
    and a musl-linked probe had already shown kill(pid,0) returning -1 with
    errno == ESRCH == 3 and zsh's own loop condition evaluating false. Both
    results were right; both were read as "so it must be subtler in the
    kernel". What they actually pointed at was the one place the probe and the
    shell differed -- how each was compiled.

The lesson is narrow enough to state: when a probe that reproduces a program's
exact syscall sequence behaves correctly and the program does not, stop looking
under the syscalls and look at the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 00:29:39 +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
374ed80096 plan: bring the 1.0 register up to date, and file the SMP corruption in Gate 0
The register steers everything and it had drifted. The text-layer row said the
console still indexes font_data[256][16]; it has not for three commits. Half of
that row is closed -- the console draws real TrueType through font_ops -- and
the half that is not (deleting the cell grid) is now its own item rather than
hiding behind the finished one.

The font-from-disk row is restated rather than closed: the typeface is linked
into the kernel on purpose, because a panic during boot has to be readable, so
what is open is a *replaceable* font and not having one at all.

And Gate 0 gains the row it most needs. User processes are corrupted on SMP --
zero faults across three boots at -smp 1, one/three/one at -smp 2, and it takes
schinitd as well as shells. That is exactly the situation Gate 0 exists to
prevent carrying into the compositor: a desktop built on a kernel that randomly
corrupts processes fails indistinguishably from its own bugs. What has been
ruled out is recorded with it, so the next attempt does not repeat the search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 21:53:08 +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
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
888c48fd96 ldls: ping, over a Linux driver
docs/plan/ldls.md L3d, and with it L3 -- the milestone the plan calls the one
that proves the whole idea and the one that cannot be skipped.

    # ldlsd /lib/modules/8139too.ko /lib/modules/btf &
      kpi: the driver read a MAC off the hardware: 52:54:00🆎cd:ef
      ndo_open returned 0
      carrying frames. the interface is ldls0.

    # cat /proc/netif
    interface ldls0   state up   address 10.9.0.15   gateway 10.9.0.2

    # ping 10.9.0.2 4
    64 bytes from 10.9.0.2: icmp_seq=1 time=30 ms
    64 bytes from 10.9.0.2: icmp_seq=2 time=20 ms
    64 bytes from 10.9.0.2: icmp_seq=3 time=20 ms
    64 bytes from 10.9.0.2: icmp_seq=4 time=20 ms

10.9.0.0/24 is reachable only through ldls0, and ldls0 is 8139too running in a
SchOS process. The address was obtained by DHCP over the same driver first.
lwIP -> netdev_ops -> /dev/netdev -> ldlsd -> ndo_start_xmit -> a BAR mapped by
/dev/pci -> the wire, and the whole way back. Nothing in the kernel knows a
Linux driver is involved; nothing in the driver knows it is not in Linux.

It polls rather than taking interrupts. /dev/pci delivers them by blocking
read(), which needs a thread this program does not have, so the driver's NAPI
poll is called from a loop instead -- the same work in a context LDLS can
provide, at the cost of latency and a spinning process.

Four things Linux's alloc_etherdev does that allocating does not, each found by
the driver faulting on it: dev->dev_addr is a pointer aimed at perm_addr;
dev->mc and dev->uc are list heads and are empty only when they point at
themselves; dev->_tx is an array of netdev_queue, because netif_start_queue
clears a bit in _tx[0].state; and __LINK_STATE_START is set by dev_open before
ndo_open is called.

Two did not announce themselves, and they are the ones worth recording.

netif_running(dev) gates 8139too's receive loop. Without it the chip received
the frame, the driver acknowledged the interrupt and left the loop, and nothing
came out -- while every counter said packets were arriving. Found by reading
the chip's own CBR register through the BAR and seeing the hardware had written
600 bytes the driver never collected.

memcpy was still a logging stub. The driver copied each frame into its sk_buff,
the copy did not happen, and the stack was handed exactly the right length of
zeroes; DHCP never completed and nothing reported an error. A stub that returns
zero is not safe for a function whose whole job is a side effect, and the
memory primitives are the worst case because they fail silently and plausibly.
ldls/kpi/string.c exists because of it.

Still one driver, one kernel version, tried once, against an emulated card --
which is what the plan has always promised and is worth repeating next to a
working ping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 11:14:18 +02:00
Nexoniarz
42f98130c4 ldls: a Linux driver, driving real hardware
docs/plan/ldls.md milestone L3c. ldls/kpi/ implements 41 of 8139too's 94
symbols rather than logging them: register access to a mapped BAR, the PCI
calls to /dev/pci, allocation to mmap and PCIIOC_DMA, register_netdev to
/dev/netdev.

    # ldlsd /lib/modules/8139too.ko /lib/modules/btf
      found   10ec:8139 at 00:05.0
      bar 1    0x0000000081060000  256 mem
      calling probe, with 41 of 94 symbols implemented
      kpi: mapped BAR 1 -- phys 0x0000000081060000, 256 bytes
      kpi: the driver read a MAC off the hardware: 52:54:00🆎cd:ef
      netuser: ldls0 is a process now, 52:54:0🆎cd:ef
      probe returned 0 after 0 stub call(s)

52:54:00🆎cd:ef is what the machine was configured with. Nothing invented
it: the driver bit-banged the card's EEPROM, serially, through a BAR mapped by
/dev/pci, and handed back what it found. That it matches means the loader, the
relocations, the BTF offsets, the BAR mapping and the register accessors are
all correct at once, which is why it was the thing to aim at. Zero stub calls:
every symbol probe reaches is implemented.

Two bugs, one of them the kernel's.

Mine first: pci_iomap checked a failed mmap for -1 only, while SchOS returns
-errno. It returned -13 as a pointer, the driver took that as its register
base, and the first write landed at 0x4e -- thirteen below where it meant to
be.

Underneath it, the real one. sys_mmap rounds a length up to a page, the only
granularity an MMU has, and pciuser_mmio_ok() compared that against the BAR's
own size. rtl8139's registers are 256 bytes, the request became 4096, and the
answer was -EACCES for a device the process legitimately held. A BAR smaller
than a page could never be mapped at all, and nothing had noticed because the
only consumer until now was virtio-gpu, whose BARs are large. The check is now
against the pages the BAR lies in, and the widening is named where it happens:
a sub-page BAR hands over the whole page, forced by the hardware rather than
chosen.

That is exactly what hybrid.md said a foreign driver would be good for --
"what it asks for is what a foreign driver would ask for too" -- arriving as a
concrete bug rather than a prediction.

kpi_io_ok() now bounds every ioread/iowrite against the mapping pci_iomap
returned. A stray access would otherwise be a page fault with no context, and
with no IOMMU a hosted driver's wild writes have to be caught early or not at
all. It is also what turned the fault above into two lines naming the offsets.

Not done, and said so in the output rather than only in the doc: no frame has
moved. ndo_open is never called and there is no transmit or receive path, so
ldlsd releases the interface instead of leaving it up -- a link that reports
itself up with nothing draining its queue is a black hole, and the stack would
send DHCP into it for ever. Down is the true answer until packets move. That
is L3d, now listed separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 09:59:14 +02:00
Nexoniarz
3fd1cc5279 ldls: the same loader, on SchOS
docs/plan/ldls.md milestone L3b. ldls/ko.c, btf.c and stub.c were written
freestanding so they would build unchanged on both sides; cmd/ldlsd collects
that promise -- the same three files, linked against lib/ instead of a libc,
in an ordinary SchOS process.

    # ldlsd /lib/modules/8139too.ko /lib/modules/btf
      module  8139too      license GPL      wants 94 symbols
      defused 6 %gs stack-guard reference(s)
      calling init_module
        1  _printk(...)
            | 8139too: 8139too Fast Ethernet driver 0.9.28
        2  __pci_register_driver(...)
      btf     122279 types
      layout  pci_driver.probe +16   pci_dev 2680 bytes
      driver  "8139too"    devices it claims: 10ec:8139 ... and 18 more
      calling probe
        1  alloc_etherdev_mqs(0x368, 0x1)
      probe returned -12 after 1 stub call(s)

Byte for byte the host harness's behaviour, which is what the shared source
was for. Two earlier predictions came good with no work: a process's mmap area
starts at 0x40000000, inside the window R_X86_64_32S needs, and the kernel
never sets EFER.NXE so every mapping is executable. The second is a gap rather
than a feature and is now recorded as one -- LDLS is relying on the absence of
a protection an OS should have.

One thing did not just work. The first run faulted before probe's first call,
err 5 -- present, read, user -- at a kernel address, with the rip resolving to

    mov %gs:0x0(%rip),%rax      # rtl8139_init_one+0x26

Modules are built -mstack-protector-guard-reg=gs, so the canary comes through
the GS segment, and that is an ABI between the kernel and its own per-CPU area
which a host that is not that kernel cannot provide. ko_defuse_gs() rewrites
those references to xor %reg,%reg.

There are two forms and the second is easy to miss: the prologue loads the
guard with mov (8b) and the epilogue compares it with sub (2b) before a jne to
__stack_chk_fail. xor is right for both -- it zeroes the saved canary and sets
ZF, so the epilogue's branch falls through exactly as a passing check does. Six
sites in 8139too.

It checks every site before patching any and refuses the whole operation on
anything it does not recognise. That refusal is what found the sub form, by
declining to guess -- a loader that half-understands an instruction should not
be editing it.

make ldls-stage/ldls-unstage put a module and its kernel's BTF into the local
sysroot and take them out again. Not part of `all`, and .gitignore refuses
*.ko, so an image only carries somebody else's GPL code between two commands
you typed on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 09:10:01 +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
2d9a0ec6e3 ldls: being the PCI core, with offsets nobody wrote down
docs/plan/ldls.md milestone L2, less the half that needs /dev/pci.

In Linux, __pci_register_driver ends at the PCI core, which matches a
driver's id_table against the bus and calls probe(). There is no PCI core
here, so LDLS keeps the pointer that call was given and plays the part.

To do that it must know where pci_driver.probe is, and it asks the kernel
rather than being told by us. ldls/btf.c reads /sys/kernel/btf/vmlinux --
122,279 types, every struct layout and every function prototype, as generated
data describing the kernel that is installed. Implemented from
Documentation/bpf/btf.rst, which is a published specification.

    $ ldls-types /sys/kernel/btf/vmlinux --need
      pci_driver   probe      offset    16  size 8
      pci_dev      resource   offset  1000  size 1088
      resource     start      offset     0  size 8
      all present

Cross-checked against pahole on the same file, field for field. No offset in
this is ours, which is rule 3's uncomfortable row disappearing rather than
being argued about.

And then the driver:

      driver name "8139too"
      devices it claims: 10ec:8139 ... and 16 more
      calling probe(pci_dev 10ec:8139, ...)
        1  alloc_etherdev_mqs(0x368, 1, 1)
        2  pci_enable_device / 3 pci_request_regions / 4 pci_set_master
        5  pci_iomap(pdev, 1, 0)
        6..544  ioread8 / iowrite8 -- 0x88, 0x8a, 0x8c, 0x8e, over and over
      545  dev_addr_mod / 547 netif_napi_add_weight_locked
      549  timer_init_key / 550 register_netdev / 551 netdev_info
      probe returned 0 after 552 stub call(s)

10ec:8139 is the real RTL8139 id. It mapped BAR 1, the one marked
IORESOURCE_MEM. Those 538 alternating byte accesses stepping 0x88/0x8a/0x8c/
0x8e are serial EEPROM clocking -- the driver bit-banging the chip for its MAC
address and handing the result to dev_addr_mod. Then NAPI, a watchdog timer,
register_netdev, its "found it" line, and zero.

Fifteen distinct symbols carry that entire probe, and three of them are 540 of
the 552 calls. L1 said 44 symbols carry 110 modules through init; L2 says 15
carry this one through probe. Neither is a number anyone would have guessed
and both came from running the thing.

Recorded as not done, because it is not: probe receives a pci_dev whose shape
is right and whose contents are invented. The BARs are made up, pci_iomap
returns a zeroed page, and 8139too read a MAC address of zeroes and was
perfectly happy about it. The "all through /dev/pci" half of L2 belongs to the
SchOS side. Also recorded: BTF has to come from somewhere, and SchOS has no
/sys/kernel/btf/vmlinux -- the user brings it with the module, from the kernel
the module was built for.

make ldls-check is 27 checks, four of them L2's, and it says so out loud when
it skips them on a machine with no BTF rather than passing silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 08:14:57 +02:00
Nexoniarz
7a7c7611e0 ldls: the stubs, and the driver's own voice
docs/plan/ldls.md milestone L1. Every undefined symbol resolves to a
trampoline -- twenty generated bytes that load the symbol's index and jump to
a shared thunk, which saves the argument registers and reports the call. Then
init_module is simply called.

    $ ldls-run 8139too.ko
      module  8139too
      license GPL
      wants   94 symbols
      calling init_module at 0x416fc020

        1  _printk(0x416fb6c0, 0, 0)
          | 8139too: 8139too Fast Ethernet driver 0.9.28
        2  __pci_register_driver(0x416fb400, 0x416fff80, 0x416fbdbb)

      init_module returned 0 after 2 stub call(s)

That second line is the driver's own banner, printed through a _printk that
is ours, from a string in the module's own .rodata that a relocation put
there. There is no stronger evidence that a load worked than the loaded thing
speaking.

Stubs return zero rather than stopping, which departs from what the milestone
originally said and does so deliberately: stopping at the first call tells you
one name, carrying on tells you the sequence, and the sequence is the thing a
symbol table cannot give you. A driver that then dereferences the zero faults,
and that is a result too -- it says how far it got before it needed the kernel
to have really done something.

The first run failed on neither of those. It failed on a relocation:

    ldls-run: relocate: a signed 32-bit relocation does not fit

A module is compiled -mcmodel=kernel, so the compiler emits R_X86_64_32S for
anything it believes lives in the top 2 GB, and an ordinary mmap at 0x7f...
has nowhere to put it. Linux gives modules a region within 2 GB of the kernel
text; the harness uses MAP_32BIT to reach the same window from the other end.
On SchOS this costs nothing -- a process's mmap area starts at 0x40000000 and
is already inside it -- but it is a real constraint on where the host may load
a module, and a loader that did not check its own arithmetic would have found
it much later and much more confusingly.

Swept over 500 stock modules: 449 ran init to completion, 22 faulted on a
stub's zero, 29 have no init_module, and zero hit a loader error.

Recorded honestly in "what it will not do": running init is not working. Every
symbol is a stub, so what has been shown is that drivers ask for things in a
readable order, not that anything answers. Also recorded: every undefined
symbol in a module is NOTYPE, so the stubs cannot tell jiffies from
pci_iomap and both get a trampoline -- BTF distinguishes VAR from FUNC and
that is where the fix belongs rather than in a guess.

make ldls-check is 23 checks now, five of them L1's, all against
ldls/test/refmod.c which is ours and now has an init_module of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 08:02:29 +02:00
Nexoniarz
1be5f3a704 ldls: the loader, and what it measured
docs/plan/ldls.md milestone L0. ldls/ko.c indexes the sections of an ELF64
relocatable object, places the allocatable ones, resolves undefined symbols
against a caller's table and applies the relocations. Nothing is executed.

It is freestanding -- no libc, no allocation, no I/O -- so the same source
builds as a host tool now and as the SchOS-side host later. That is the
arrangement mkschfs and the kernel already share, and for the same reason:
the failures at this layer are format failures, and one found on the host
costs a second instead of a boot.

    $ ldls-inspect 8139too.ko
      module    8139too
      license   GPL
      loads to  33988 bytes
      relocations: 1980
        R_X86_64_64 363, PC32 991, PLT32 428, 32S 169, PC64 29
      undefined symbols: 94
      stops at: unresolved: __fentry__

The licence is printed before anything is loaded, because it is the one
property that decides whether the object may be on the machine at all and it
is a plain string in .modinfo. Rule 1 is easier to keep when the answer is on
screen.

94 symbols is the measured API surface of 8139too on 6.18.46 -- the list,
exactly, rather than an estimate -- and it is L1's work queue. It is smaller
than it looks: __fentry__, __stack_chk_fail, __ref_stack_chk_guard,
__x86_return_thunk and __fortify_panic are compiler and instrumentation
artefacts that stub out in a line each.

Swept over every module in a stock 6.18.46 tree: 2,440 objects, all of them
laid out and relocated to completion, none with a relocation type the loader
does not handle. That sweep is also what retired the fixed section table --
nvidia-modeset has 7,069 sections and 74,305 relocations, against a median of
50 -- so the caller supplies the table and there is no limit to run into
rather than a generous one that fails later.

Loading is not running, and the doc says so: not one symbol is implemented,
and the distance between "relocates" and "transmits a packet" is the rest of
the plan.

No .ko is in this tree and .gitignore now refuses one outright. make
ldls-check runs 18 checks against ldls/test/refmod.c, which is ours, written
to contain one of each thing the loader has to get right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 07:45:31 +02:00
Nexoniarz
5263150d3d ldls: the plan for running someone else's drivers
docs/plan/ldls.md. SchOS cannot write a driver for every device that exists
and no project can, so the only route to the hardware support an established
system has is to run Linux's. hybrid.md built the mechanisms for this and said
so; vgpud proved them. This is what they were for.

Five rules, and everything else is an implementation of them:

  1. Nothing GPL ever enters an image we build. The driver is the user's,
     obtained by them, loaded on their machine.
  2. It runs in a process, never in the kernel. Legally a module linked into
     our kernel is the strongest derivative-work argument that exists, and
     practically this is foreign code that must not be able to take the
     machine. FreeBSD has shipped from this position for a decade.
  3. Facts, not expression. A symbol's signature and a struct's offsets are
     facts a compiled driver depends on; comments, macro bodies and inline
     bodies are expression and are never carried over. Provenance recorded per
     file. Flagged in the doc as wanting real legal advice before shipping.
  4. One kernel version at a time, pinned and named, because Linux says in
     writing that it has no stable in-kernel ABI.
  5. The unresolved-symbol list is the specification. Load a real module and
     let the loader say what is missing rather than guessing the surface.

Four pieces: an ELF relocatable loader (ours, clean, no licence question), the
KPI shim (the actual work), /dev/pci as the back end (built), and one new
kernel interface -- a netdev whose backend is a process, which is the exact
analogue of display_ops and is four vtable entries wide.

First target is 8139too rather than r8169, and the reason is decisive: QEMU
emulates rtl8139 and does not emulate r8169, so the obvious choice cannot be
tested at all. 8139too is also smaller, needs no firmware, and uses the thin
mii helpers instead of dragging in phylib. r8169 is what proves it was general,
afterwards and much more cheaply.

The "what it will not do" section carries the honest list, including the two
that are not yet answered: no EXPORT_SYMBOL_GPL story, and no IOMMU -- which
for foreign drivers is a materially worse problem than for our own, and is the
strongest argument for one this project has produced.

hybrid.md is closed and leaves the register; ldls.md takes the slot, which is
the freeze rule working rather than being bent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 01:51:30 +02:00
Nexoniarz
907d0e6c8a hybrid: H4 withdrawn -- USB is not a peripheral
Not deferred and not blocked on the per-device ownership interface the last
commit described. Withdrawn, because the premise was wrong.

USB is the bus the peripherals arrive on. Nearly every machine has a port in
some form, down to a bad micro-B on a board with nothing else, and the ones
that have none are not made worse by the stack being there. That makes it
structurally the same kind of thing as PCI, and this plan has never proposed
evicting PCI.

The eviction rule already said so and was read too quickly: "anything
discovered at runtime ... goes out" is about the things USB discovers, not
about USB. The keyboard and the root disk that arrive over it are on the path
to a shell, and the stack that finds them is part of that path.

The obstacles recorded in the previous commit are real, but they are not the
reason. Even with a minimal in-kernel fallback written and a per-device
ownership interface designed, the good USB driver and the minimal one would be
the same driver -- and an eviction that leaves an identical copy behind has
moved nothing.

hybrid.md is now closed and leaves the register in docs/plan/1.0.md. What the
four mechanisms were built for next is the foreign driver host.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 01:45:58 +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
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
c0ed23cc56 freeze: one register for everything half-built, and the corners cleared
The failure this is against is not scope creep. It is abandonment by promotion:
a thing gets built to the point where it works, the next thing becomes more
interesting, and the first is left in a corner -- in the tree, not finished, not
deleted, and not in any list. Enough of those and the tree stops matching its
own documentation.

docs/plan/1.0.md now holds the register, and it is the only place anything
half-built is listed. Four sections, because the kinds are genuinely different:
built and connected to nothing (the text layer, a font from disk, socket
activation, wait_vblank); started and stopped part-way (foundations 2-5, hybrid
H2, Schcom v0, a released PCI device that nothing brings back); named in a "what
it does not do" and still true; and frozen -- scoped, unstarted, not to be
started. The rule is that nothing enters except by being finished or deleted,
and that adding a row requires closing one.

1.0.md already said "two things in flight, never three". It was right and it was
not being followed.

Then the corners themselves, because a register written over a tree full of them
is just a longer list:

  - cmd/init is deleted. Nothing had execed /bin/init since initcode.S changed
    to /bin/schinit; it was built and installed on every image as an unreachable
    fallback, and an unreachable fallback rots rather than saves.
  - projects/schwm/ and projects/schde/ are deleted. Both said "to be deleted"
    in their own first paragraph, which proves only that a directory marked for
    deletion is not deleted by being marked. README-only, no code lost, and the
    arguments each made are moved into wayland.md where they are still true.
  - projects/schcom/schcom and schcom-demo were tracked binaries. Untracked, and
    .gitignore'd.

Three code comments and one document described a boot that has not happened
since 7d44eb7. cons_serial_only starts set, but kmain clears it on its first
lines now, so the splash does not survive the boot and the kernel log is on
screen from the start. That reversal was earned by a Chromebox with no serial
port freezing at boot showing nothing at all, and the comments still argued the
losing side of it.

The site:

  - a download page. One ISO, what it has actually been run on -- one machine,
    under test -- Ventoy and dd, what the first boot looks like step by step,
    and the build if you would rather. The support table names one Chromebox
    rather than listing hardware, because one Chromebox is the entire bare-metal
    record and a list would be fiction.
  - Assets/usb.svg, drawn to the same rule as the other marks: two tones,
    geometry only, the accent spent in one place, and that place is the activity
    light because it is the part that tells you something is happening.
  - Download is in both navs and is the hero's primary action; a closing band
    ends the front page on it.
  - the uberblock-slots and drivers-outside-the-kernel figures are gone. Three
    numbers left, including the fastfetch one, which stays because it is funny.

Two things caught by rendering the pages instead of assuming: .hero p was
beating .filemeta on specificity and the file line was coming out at body size,
and the dd command was one line long enough that the card clipped it at `conv=`
-- a destructive command that can scroll half of itself out of view is a good
way to take somebody's disk. It is two lines now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 20:28:05 +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
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
25530e7eed schfs: the allocation map can be given back, so unmount stops leaking it
Started as "fix the ASAN leak in schfs_objtest" and the test turned out to be
the messenger. The allocation map was taken through a palloc callback and there
was no way to return it: no schfs_space_release, and a comment in
schfs_space_init reading "caller frees what was taken" for an API the caller
did not have.

Three separate leaks from that one gap:

  * The kernel leaked the entire map on every unmount -- up to 2 MB, at 8 KiB
    per GiB of volume -- because kmount_unmount flushed, cleared `mounted` and
    walked away. This is the one that matters; the others are test-only.
  * schfs_space_init stranded a page on its own error path. npages was raised
    only after *both* of a pair of allocations succeeded, so a failure on the
    second left the first outside the count -- unreachable to any cleanup that
    could later exist.
  * schfs_objtest leaked 2121728 bytes in 518 allocations, which is what made
    the whole SchFS suite exit non-zero regardless of its assertions.

The map now carries its own pfree rather than taking one at release time, so it
can only ever be freed by the allocator that filled it -- the kernel's pages
come from kalloc and a host tool's from calloc, and the two are not
interchangeable. schfs_vol_open and schfs_vol_format release any map the volume
is already holding before building another, which is what stops a re-open
leaking; struct schfs_vol must therefore be zeroed before first use, and the
header now says so.

The test's own leak was the last one found and the simplest: it does
memset(&VOL, 0, sizeof(VOL)) before reopening, which wiped the map's pointers
before vol_open could release them. Eight of those, each now releasing first.

schfs_objtest asserts the balance -- 520 pages taken, 520 given back -- because
a suite that leaks cannot detect a leak: the filesystem's own allocations would
arrive as noise inside the harness's. The next one fails the run instead.

make schfs-check now exits 0. The 31 UBSAN misalignment reports are still
there and are still a Gate 0 item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 17:49:57 +02:00
Nexoniarz
3e2f3cd1ec docs: the SchFS suite is red, and Gate 0 says why
Two failures found by running `make schfs-check` rather than by reading it, both
predating the current work -- confirmed by stashing the session's changes and
re-running to byte-identical output.

  * AddressSanitizer: 2121728 bytes leaked in 518 allocations, every one from
    md_palloc in tools/schfs_objtest.c, the test's own page allocator. The
    target exits non-zero, so the whole suite fails whatever the assertions say.
    The cost is not the memory -- it is that a real leak in the filesystem would
    now arrive as noise inside 2 MB of expected noise.

  * UndefinedBehaviorSanitizer: 31 misaligned accesses across obj.c, objw.c,
    attr.c and space.c, one root cause. Records are read by casting a pointer
    into a tree leaf, and a record's offset within a leaf is not a multiple of
    8. This 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 then look like
    filesystem corruption rather than codegen.

Recorded as Gate 0 items rather than as nice-to-haves for one reason: a suite
that is red for known reasons stops being read, and then it is red for unknown
reasons too. guides/testing.md now says so up front, so nobody reads the [ok]
lines and ignores the exit code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 17:30:01 +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
Nexoniarz
57aff40228 docs: four directories, and a rule for when a thing leaves the roadmap
docs/ was ten files where a plan, a description, a guide and a bug report all
sat next to each other. It is now architecture/ (what SchOS is), plan/ (what it
will be), guides/ (how to work on it) and history/ (why it is like this), with
an index that states the one rule holding it together: when something is
finished it moves from plan/ to architecture/ and is rewritten as a description
of the thing rather than a record of building it.

A ticked checkbox says the work happened. It does not say what the work
*produced*, and six months later the checkbox is all that is left. The network
stack proved the point: finished, verified end to end, and for a month the only
description of it anywhere was a checkbox four levels of indentation deep.

So roadmap.md went from 534 lines to 181 -- nothing deleted, the done work
graduated. Seven subsystems that were finished and undescribed now have
descriptions: boot, kernel, syscalls, filesystem, networking, graphics, usb,
init. Every one ends with "What it does not do", which is the section that pays;
a document listing only strengths is marketing, and the reader who needs it most
is the one about to trip over a limitation nobody wrote down.

schfs.md still opened with "phases 1 through 3 1/2 are done, phase 4 is next"
long after all five had landed. It is a description now, with the phase record
and the hard-won bugs moved to history/.

Three corrections found on the way, all load-bearing:

  * projects/schcom/README.md claimed SCM_RIGHTS was done. There is no
    sendmsg/recvmsg in the kernel at all, and an entire GUI plan rested on it.
  * The root README said "No TCP/IP" months after the stack landed.
  * history/kernel.md sec.7 was marked "in progress" on a problem that process
    groups solved.

Also adds plan/1.0.md, which draws a finish line so the open list stops being
infinite, and marks schwm/ and schde/ superseded: in Wayland the compositor is
the window manager, so that split dissolves rather than being abandoned.

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