Commit graph SchOS/include
Author SHA1 Message Date
Nexoniarz
f46fb41966 test: the network is checked by the suite now, and /proc/netif says what it did
The TCP/IP stack landed with its end-to-end test performed by hand: start
a listener, boot, type nettest, read the output. That is evidence once
and nothing afterwards, and it is exactly the kind of check that quietly
stops being run.

bootcheck -Net does the whole exchange. A listener runs on this host on a
port picked by binding one and letting go, the guest is told to connect
out to 10.0.2.2 at that port, and both ends are asserted: the guest
reports what it received, the listener reports what arrived. One side
alone proves much less than it looks -- a stack that completes a
handshake and then drops every byte satisfies any check made from inside
the guest.

The listener is a background job, a separate process, because this
harness has one hard rule: nothing may block the writer feeding QEMU's
stdin. A socket accept is precisely the wait that deadlocked two earlier
attempts at reading QEMU's output. It answers the protocol nettest
speaks and then closes -- the close is load-bearing, since nettest reads
until end of file and a listener that stayed open would hang the guest
rather than fail it.

The NIC is spelled out (-netdev user + -device e1000) rather than left to
QEMU's default. Other runs can take the default and still answer "did a
driver bind"; a run that asserts on an address should be reading its own
configuration.

/proc/netif is new, and the address is why. Until now the only report was
a line on the console at boot, which userland cannot read and a test can
only find by scraping the transcript. It gives interface, MAC, state,
address, netmask, gateway, MTU and frame counts. Named netif rather than
net on purpose: /proc/net is a *directory* on Linux, and a plain file
there would give ENOTDIR to anything reaching for /proc/net/dev, which is
worse than not having it. The counters are kept at the lwIP seam rather
than in the driver, because a frame the driver received and the netif
dropped never reached the stack.

Verified: 38 [ok], no failures. Eleven of them are new -- lwIP up, DHCP
obtaining 10.0.2.15 from SLIRP (a number nothing in the guest knows in
advance, so it is the exchange completing rather than a guess), the four
/proc/netif fields, frames counted in both directions, the handshake, the
reply read back, and the listener confirming HELLO-FROM-SCHOS arrived.

One bug found by writing it: $netPort was chosen after the command list
was built, so the guest ran `nettest 10.0.2.2` with no port and printed
its usage. An unset PowerShell variable interpolates to nothing rather
than failing, which is worth remembering next time a command in this
file looks right and behaves oddly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:11:49 +02:00
Nexoniarz
c0806d1b64 net: a TCP/IP stack, and AF_INET on the sockets that were already there
lwIP 2.2.0, fetched by ports/lwip and compiled straight into the kernel.
Not written from scratch, and the reason is not the packet formats: it is
congestion control, retransmission, window management and twenty years of
corner cases that only show up against a real peer. Not FreeBSD's
netinet either, though the licence would have allowed it -- that stack is
not a library, it reaches into mbufs, epoch(9), netisr, VNET and uma(9),
so adopting it means porting FreeBSD's kernel infrastructure first. lwIP
was written for exactly this situation: a small kernel with a raw
Ethernet driver and a port layer of a few hundred lines.

It is kernel code, so nothing is installed into the sysroot and there is
no second build system: the top-level Makefile compiles the files it
wants against our port headers. The file list is explicit rather than a
wildcard, because lwIP ships IPv6, 6LoWPAN, PPP, SLIP, DNS-SD and a dozen
apps in the same tree, and building what is not configured yields either
dead code or a link error naming a port function nobody meant to write.
Its sources are unmodified and its warnings are silenced for its files
only -- patching them would mean maintaining a fork.

The port layer is ours: a lock, a clock, the netif that hands frames to
e1000, lwipopts.h, and a shim/ directory of tiny headers so lwIP's
<string.h> and friends resolve to the kernel's functions instead of the
host's, whose prototypes disagree and would otherwise be found on the
default include path.

NO_SYS=1, so lwIP has no threads of its own and one kernel thread feeds
it. A thread rather than the interrupt handler, because a call into lwIP
can reach the driver, the driver can sleep, and an interrupt handler
cannot -- so the interrupt still only clears the cause register and the
thread polls. kthread_create is new and generally useful: a process with
a kernel-only page directory, no files, no cwd, no parent, dispatched
from forkret, which can therefore sleep and be preempted like anything
else. DHCP obtains an address at boot and the console says which one.

AF_INET went in exactly where phase 2 said it would. socknew dispatches
on the domain, kernel/net/inet.c implements the internet side on lwIP's
raw API, and not one syscall changed shape: bind, connect, listen,
accept, read and write reach the same descriptors they always did. TCP
and UDP both work. cmd/nettest opens a connection, writes, and reads the
answer back.

Two things are owed and named here so they are not discovered later:
recvfrom does not yet report the source address, and the socket paths
poll on the timer tick instead of sleeping on the socket. Both are
follow-ups, not redesigns.

NOTICE carries lwIP's BSD-3 text. Clause 3 is no-endorsement, not the
old advertising clause, so it sits with Apache-2.0 without difficulty.

Verified: the tree builds clean, and the headless boot check passes with
27 [ok] and no failures with the stack compiled in and the thread
running -- which is the part that would break first if netinit deadlocked
or the thread starved anything. An end-to-end connection test belongs in
that suite and is the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:02:48 +02:00
Nexoniarz
03cc316001 schfs: the commit barrier, reflink, and attributes with an index over them
The last three phases of the filesystem, which were written together and
touch the same files throughout -- kmount.c, objw.c and vfs.c are each
rewritten by all three -- so they land as one commit rather than three
that could not be built separately.

Durability. A transaction that reaches the platter out of order is worse
than one that never reached it: the uberblock can name a tree whose
blocks are still in the drive's write cache. bdirty/bflush give the
buffer layer an explicit barrier, ideflush issues ATA FLUSH CACHE and
usb_storage_flush the SCSI SYNCHRONIZE CACHE that means the same thing,
and a commit now waits for the data before it writes the root that
points at it. The uberblock groups are read newest-first with fallback,
so a volume whose newest roots are torn mounts at an older one and says
which generations were lost.

Reflink. Extents carry a refcount, so two objects can name the same
blocks and neither frees them while the other holds them. Exposed as
FICLONE -- Linux's ioctl value, so `cp --reflink` works unmodified --
and as cmd/reflink for the case where there is no cp to hand.

Attributes, and the one genuinely new idea here. attr.c stores named
values on an object, reachable through setxattr/getxattr/removexattr at
Linux's syscall numbers. On top of that, schfs_index declares an index
over an attribute name and schfs_query answers "which objects carry this
value" from the B-tree rather than by walking. A query against an
unindexed attribute is refused with an error, never silently answered by
a full scan: a query that quietly becomes a scan is worse than one that
admits it cannot help. cmd/schattr drives both.

Three bugs fixed on the way, each found by the numbers being wrong
rather than by inspection:

  - statfs's f_fsid was declared int64[2]. Linux's fsid_t is two *ints*,
    so every field after it landed one slot late: userland read f_frsize
    out of f_namelen, got 255, and df reported a 32 MB root filesystem
    as 1992 KB. It hid for as long as it did because the fields used to
    be invented constants, so wrong numbers looked like the usual ones.
  - struct inode's nlink was a short against SchFS's uint32, so a file
    past 32767 names came back negative -- and iput frees an object
    whose count reaches zero. It is an int now, signed on purpose, so an
    underflow lands somewhere a check can see rather than wrapping.
  - filewrite split every write to fit v1's log. That limit belongs to
    the journal, not to writing, and SchFS makes one transaction of
    whatever it is handed: a 1 MB write was becoming 170 commits and
    170 flushes for nothing. fs_ops carries max_write now, and SchFS
    sets no limit.

Also here: a real mount table, so `mount -t schfs /dev/hdb /mnt` shows
up in /proc/mounts -- df looks a path up there before it calls statfs at
all, so a mount missing from that list was invisible to it -- several
volumes mounted at once, snapshots mounted by name, and /proc/fsstat
with an rdtsc-based counter, which is the only instrument fine enough to
see inside a single syscall.

Verified: `make schfs-check` passes in full, including the sweep that
crashes the volume at each of 119 points mid-transaction and requires
every one to mount with exactly its committed data, and the case where
both newest uberblocks are rubbish and the volume still comes back. The
headless boot check passes with 27 [ok] and no failures, on a SchFS root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:02:02 +02:00
Nexoniarz
ad2b321d56 dev: raw disks are 64-bit, so a real drive stops wrapping at 4 GB
struct file already carried a uint64 offset and bread already took a
uint64 block number. The narrowing was one boundary in the middle:
devsw's read and write took a 32-bit uint, so fileread passed a 64-bit
f->off into a 32-bit parameter and the top half was discarded.

The effect was not a refusal at 4 GB but silent wraparound past it. A
read at 5 GB returned the bytes at 1 GB, reporting success. disk.c had a
DISK_MAX_BYTES constant documenting the limit, and nothing anywhere
referenced it -- the cap was described but never enforced, which is the
worst of the three options.

The signature is now uint64 across all nine files that carry it, and
diskread/diskwrite no longer narrow the block number on the way to
bread. Widening this is a prerequisite for NVMe and for any real disk:
namespaces start well above 4 GB and the failure mode reads valid data
from the wrong place.

Verified end to end rather than by inspection. A 6 GB sparse image with
"PAST4GB-MARKER-OK" written at byte 5368709120, attached as /dev/hdb to a
ramdisk-root kernel so device 1 was free:

  # dd if=/dev/hdb bs=4096 skip=1310720 count=1 | od -c | head -2
  0000000   P   A   S   T   4   G   B   -   M   A   R   K   E   R   -   O

Under the old code that offset truncates to 1 GB, where the image is
zeroes. The whole path is exercised: f->off, devsw, diskread, bread and
ide.c's LBA48.

Worth recording for next time: `make ramroot` stages the ESP itself, and
running `make esp` afterwards rebuilds the kernel without RAMROOT and
silently undoes it. The tell is a 855 KB kernel where a 25 MB one is
expected.

110 [ok], no failures, on the default build.
2026-08-11 12:15:38 +02:00
Nexoniarz
c4dd76cc66 boot/uefi: the memory map the kernel gets is the one the firmware agreed to
Two bugs on the ExitBootServices path, both invisible under OVMF and both
aimed squarely at real firmware.

The retry did not re-translate. If ExitBootServices returns an error the
loader re-reads the memory map and tries again -- correct, and the
specification expects it -- but the e820 table handed to the kernel was
still the one built from the *previous* map. The retry exists precisely
because the map changed, so the kernel was being given a description of
memory the firmware had already rearranged. QEMU never takes this path,
which is why it survived. Translation is now a function called on both.

Truncation was silent. phystop comes from the largest usable region, so
dropping the tail of the map can leave the kernel believing a large
machine has a fraction of its RAM -- and nothing said so, because the
loop simply stopped at the cap. It now warns while there is still a
console to warn on; after ExitBootServices there is nowhere to report
anything.

SCHBOOT_E820_MAX goes from 32 to 40. The map sits at BASE+0x208 and the
next field is at BASE+0x600, so 1016 bytes are reserved and 42 entries of
24 bytes fit. Real firmware emits far more descriptors than QEMU's; the
headroom was already allocated and unused.

Coalescing adjacent same-type runs is what keeps any of this in budget,
and it works because boot services memory becomes usable at this point --
that is most of the fragmentation in a UEFI map.

110 [ok], no failures, and the map still reads 7 regions under OVMF.
2026-08-11 11:42:22 +02:00
Nexoniarz
0500e4382d boot: one hand-off contract, defined once
Groundwork for the unified loader: before the two paths can share code,
they have to agree on what they are producing, and until now they only
agreed by coincidence.

Every field of the loader/kernel hand-off was #defined in whichever file
happened to need it -- kernel/core/main.c, kernel/dev/vga.c,
kernel/arch/x86_64/e820.c, boot/bios/cdboot.S, boot/bios/stage2main.c and
boot/uefi/main.c, six copies of the same addresses. schboot.h existed for
exactly this and held only the entry magic and the RSDP slot, with a
comment describing the rest of the block it did not define.

The whole block lives there now: the VBE info fields, the verbose and
media flags (with the real-mode segment:offset spelling cdboot.S needs),
the e820 count, map and cap, and the RSDP. Every one of those files
includes it instead of restating it.

This is not a tidy-up. It is the same hazard as defining the entry magic
twice, only quieter -- nothing breaks until one copy moves, and the two
loaders diverging is precisely what produced the EFER.SCE and RSDP bugs,
where a fix on one path silently did not exist on the other.

Verified on both firmwares: BIOS boots to schinit with the same e820,
ACPI and VGA lines as before; UEFI reaches cpu0 with 1018 MB in 7 regions
and its own 1280x800 mode. `make iso` still builds. bootcheck 108 [ok].

Next: the loaders become starters that gather firmware-specific facts and
hand to one common stage. That needs the BIOS side to climb to long mode
first, since common code can only be compiled for one CPU mode -- which
is the risky part, and is why the contract went first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:37:36 +02:00
Nexoniarz
b34779111a schfs: several snapshots at once, and unmount gives the slot back
Two loose ends from the mount-by-name commit, both closed.

A slot is a device number, so supporting more than one snapshot is a
small array rather than a redesign: SCHFS_SNAP_DEV through
SCHFS_SNAP_DEV + SCHFS_SNAP_SLOTS - 1, each with its own volume and its
own scratch pages, allocated on first use. Four of them -- "one at a
time" is an odd thing to have to explain, and the cost is two pages per
slot. Not many, because each one is a device number and those are a
small fixed space.

The leak mattered more. umount removed the mount table entry and left the
slot marked in use, so a snapshot could be unmounted and then never
mounted again until reboot. sys_umount2 releases it now, keyed off the
device it already has in hand.

Verified in the running system: two snapshots mounted at once, both
readable, both unmounted, and a third mount afterwards succeeds and reads
correctly -- which is the case that failed before.

schfs-check passes; bootcheck 108 [ok].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:30:28 +02:00
Nexoniarz
3919f1446e schfs: mount a snapshot by name, read-only
`mount -t schfs @golden /mnt` mounts a snapshot. The source argument
finally means something: sys_mount ignored it outright, because there was
one volume and nothing to choose between. `@name` rather than a path
because a snapshot is not a device -- there is no node in /dev for it and
there should not be, since it lives inside the volume already mounted.

A second volume rather than a parameter threaded through all 23 schfs_k*
entry points. Only the read side can ever address it, so the write path
keeps talking to the live volume and needed no change; the four read
entry points grew _dev variants and pick the volume from the device the
inode already carries. One fs_ops table serves both, because every entry
takes an inode and dispatches on it -- a second table would be the same
pointers under a different name.

It needs its own device number: a snapshot's object ids are the *same
numbers* as the live tree's, so one device would make the two views alias
in the inode cache and hand a reader whichever was cached first.

Three things this turned up, none of them new but all of them reachable
for the first time:

  - sys_mount refused with `dev == SCHFS_DEV`, meaning "do not mount SchFS
    onto SchFS". A snapshot's mount point is necessarily on the live root,
    so that refused the entire feature. The real condition is mounting a
    device onto *itself*, which is `dev == fsdev`.
  - createat allocates an inode and then panics if dirlink fails --
    reasonable about a writable filesystem, a crash on a read-only one.
    The refusal now happens before anything is allocated. Unlink and
    rename already handled a refusal properly.
  - a refused create reported ENOENT, so writing to a snapshot said "No
    such file or directory" -- the sentence a shell prints for every
    create failure. It reports EROFS now.

Verified in the running system against an image built with
--snapshot golden: the mount succeeds, ls lists the snapshot's tree,
/mnt2/etc/motd reads back through it, and a write is refused with
"Read-only file system".

One at a time, and no umount path for it yet: mount_del_by_root removes
the mount table entry, but ksnap_unmount is only called on the failure
paths. schfs-check passes; bootcheck 108 [ok].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:10:59 +02:00
Nexoniarz
6e2e9e8d05 schfs: snapshots
A snapshot is a tree root that is kept. The B-tree is copy-on-write, so
the blocks a committed root reaches are already immutable -- nothing
overwrites a block a committed tree can reach -- and retaining one costs
a record: (SNAP_OBJECTID, KEY_SNAP, id) -> struct schfs_snap.

The record names the root as it was *before* the record was added.
Inserting it rewrites the path under copy-on-write and produces a new
root, so a snapshot that included its own creation would name a block
nobody had written yet.

What makes it survive is the derivation. There is no free-space map on
disk; it is rebuilt at mount by walking the tree, and that walk now
covers every snapshot root as well as the live one. Without it a block
reachable only from a snapshot looks free, gets handed out, and
overwrites the data the snapshot exists to keep -- silently, surfacing
only when the snapshot is read. A snapshot's own tree may hold older
snapshot records; those are deliberately not followed, or deleting a
snapshot could be undone by an older one's memory of it.

Runtime frees are suspended while a snapshot exists. Knowing which freed
blocks a snapshot still needs requires per-extent reference counting,
which is a design and not a flag; until that exists the safe answer is to
free nothing and let the next mount's derivation reclaim what is
genuinely unreachable. Allocation is unaffected and stays correct -- it
works from the in-use bitmap the derivation filled. The running
free-blocks *counter* does drift from the truth while a snapshot is
present, which is cosmetic and is reported by the test rather than
asserted away.

Tested in schfs_objtest: after a snapshot, the live tree shows new
contents while the snapshot still shows the old ones; a file deleted from
the live tree is still readable through the snapshot; deleting the
snapshot removes the record and the space comes back at the next mount.

schfs-check passes including the round-trip; bootcheck 108 [ok].

Not yet: snapshots are not reachable by name from userspace -- there is
no mount-a-snapshot path, so this is the format and the mechanism, not
the feature a user sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:10:24 +02:00
Nexoniarz
527e6eac42 Raise the disk ceiling: 64-bit blocks, no FSSIZE panic, LBA48
SchFS is a 64-bit format addressing 64 ZB. The stack under it was not,
and the lowest limit was three orders of magnitude below the one anybody
would have guessed:

  ide.c   panicked on any block >= FSSIZE -- 150000 blocks, 614 MB
  ide.c   computed the sector in an int, and addressed it with LBA28
  bio.c   took a 32-bit block number, capping any device at 16 TB
  schfs   refused blocks past 0xffffffff, mirroring the cache

The FSSIZE check is the one that mattered. FSSIZE describes the size of a
v1 filesystem *image*; it had no business bounding a physical device, and
it meant a disk larger than half a gigabyte panicked the kernel rather
than being read.

struct buf's blockno is uint64 now, and so is bread's. idestart computes
the sector in 64 bits and picks LBA48 when it does not fit in 28 -- per
request, so everything that fits stays on the LBA28 path every existing
image has been read through. The 48-bit registers are written high half
first, because each is a two-deep FIFO and the other order leaves the
drive addressing whatever the low bytes alone describe.

Verified with a 2 GB disk carrying a marker at 1 GiB -- block 262144,
well past the old panic at 150000. The guest reads it back:
"MARKER-PAST-THE-OLD-LIMIT". schfs-check passes including the round-trip;
bootcheck 108 [ok].

Two limits deliberately left, both noted rather than guessed at: raw
/dev/hdN access still goes through devsw's 32-bit offset, so a raw device
is addressable to 4 GB; and usb_storage computes a 32-bit LBA, which is
what SCSI READ(10) carries -- READ(16) is a different command, not a
wider variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:50:31 +02:00
Nexoniarz
8b7b8df74f usb-hid: bind an absolute pointer, and plumb EV_ABS through
Groundwork for usb-tablet, which is the fix for the guest cursor and the
host's never being in the same place: a PS/2 mouse reports how far it
moved, two pointers accumulating deltas independently drift apart, and no
amount of care in the compositor can make them agree. An absolute device
reports where it is, so they coincide by construction.

  - the HID driver no longer matches the boot subclass only. usb-tablet
    declares subclass 0 and was refused at the door; hid_probe decides
    what an interface actually is, and still declines what it cannot make
    sense of.
  - a non-boot HID interface is taken to be an absolute pointer and is
    not asked for boot protocol, which it does not have -- that request
    failing is what stopped it binding.
  - tablet_report decodes the fixed 6-byte layout (buttons, 16-bit X,
    16-bit Y, wheel) and scales the logical range to pixels there,
    because it is the only place that knows the range.
  - input_mouse_abs reports EV_ABS/ABS_X/ABS_Y so a reader can tell an
    absolute position from a delta, and mouse_set_abs places the pointer
    rather than nudging it.
  - Schcom treats EV_ABS as authoritative and EV_REL as a nudge.

The device binds and identifies correctly: "usb-hid: tablet (absolute) on
slot 1, endpoint 0x81 every 4 ms".

It is NOT finished, and QEMU_INPUT is empty by default because of it. No
reports have yet been observed arriving on the tablet's interrupt
endpoint, so making it the default pointer would trade a cursor that
drifts for one that does not move at all. Opt in with

  make qemu QEMU_INPUT='-device qemu-xhci,id=xhci -device usb-tablet,bus=xhci.0'

Next step is to find out why nothing arrives: whether the first transfer
is queued but never completes, whether a completion needs re-arming for
this endpoint, or whether QEMU is not routing motion to the tablet at all
(`info mice`).

bootcheck 108 [ok], schcom-test passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:27:01 +02:00
Nexoniarz
eafdddefbc mouse: clamp the pointer to the screen that exists, not to 800x600
kernel/dev/mouse.c held the pointer inside literal 800x600 bounds. That
was correct while 800x600 was the only mode the loader could set, which
it was until the resolution became a boot-time choice. At 1280x800 the
pointer simply stopped two thirds of the way across and would not go
further -- which reads as the cursor drifting away from the host's rather
than as a clamp.

vga.c grows vga_get_width/vga_get_height for it. The mode is chosen at
boot now, so nothing above the framebuffer may hardcode its size, and
these are the first two callers that needed to ask.

Verified that the system still builds and boots at 1280x800, and that
bootcheck is 108 [ok] and schcom-test passes. I did not manage to
photograph the pointer past the old limit in the time available -- there
is no reader for /dev/mouse in the image and the screendump attempt did
not resolve, so the fix rests on inspection rather than on a picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 05:46:12 +02:00
Nexoniarz
66152ad2f2 Give userspace a raw disk: /dev/hda and /dev/hdb
Nothing outside the kernel could touch a disk. sys_mount does not even
look at the device it is handed -- it mounts the one SchFS volume found
at boot and ignores `source` -- so a program had no way to read a
partition table, format a volume or write a boot sector. An installer is
a program that needs all three, so this is the first piece of it.

A character device in devsw rather than a separate block layer, because
that is where the offset already lives: fileread/filewrite pass f->off
and advance it by what comes back, which is everything a seekable device
needs. The minor is the IDE device number, the same numbering iderw()
uses; two schemes for the same disks would be a bug waiting to happen.

Writes bypass every filesystem above them, which is inherent to a raw
device and why an installer must target a disk it has not mounted.

The nodes are created unconditionally, like every other entry in /dev.
That matters more than it looks: with no node, `echo x > /dev/hdb`
silently creates an ordinary file of that name and appears to work. It
did, and it agreed with itself on read-back; only the host's copy of the
disk showed nothing had been written.

st_rdev was never filled, so every node in /dev reported "0, 0" under
ls -l. Harmless while nothing read it, wrong as soon as something has to
tell two disks apart. Filled with the full Linux dev_t encoding rather
than (major << 8 | minor), which only works while both stay small.

Verified against the host as an independent oracle, both directions: the
guest wrote to /dev/hdb and the host saw those bytes at offset 0; the
host wrote at offset 4096 and the guest read them back with dd skip=.
ls -l now shows 1,1 for console, 2,0 for fb0 and 8,1 for hdb.

Known limit, commented in disk.c: devsw passes the offset as a 32-bit
uint, so a raw disk is addressable to 4 GB. That must be widened before
anyone installs onto a real disk.

bootcheck 108 [ok] with -Fb -Tty -Usb -Sock -Evdev -Ps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:19:40 +02:00
Nexoniarz
9735279970 acpi: take the RSDP from the loader when firmware gives it to us
find_rsdp() scanned the EBDA and 0xE0000-0x100000. That is a BIOS
convention, and UEFI firmware is under no obligation to leave a copy
there -- OVMF does not. So the kernel found no RSDP, no MADT and no
IOAPIC, and nothing routed IRQ 4. Serial output is polled, so the machine
booted all the way to a shell prompt and then ignored every key, which
reads as a broken console rather than a missing table.

The UEFI loader reads the RSDP out of the EFI configuration table,
preferring ACPI 2.0 for its XSDT, and leaves it at SCHBOOT_RSDP_ADDR --
the same low-memory hand-off block the e820 map already uses, now
described in include/schboot.h so the two sides cannot disagree.

The kernel validates it rather than trusting it: the BIOS path never
writes that slot, so there it holds whatever was in memory, and following
a wild pointer faults before there is anything to report the fault with.
Signature and checksum do that. It deliberately does not require 16-byte
alignment -- that rule is about searching the BIOS area, and OVMF's RSDP
sits at 0x3f77e014, so demanding it threw away the one address this code
exists to use.

UEFI now reports "acpi: 1 cpu, lapic at ..., ioapic id 0" and the ISO
boots to an interactive shell: uname, ls /bin (241), writing and reading
a file, ps, /proc/schfs -- all driven over serial.

BIOS unchanged: bootcheck 108 [ok], same ACPI line as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 22:02:57 +02:00
Nexoniarz
b768f5ded9 Map device registers with ioremap instead of assuming a low BAR
Every driver reached its registers with P2V(bar.base), which assumes the
BAR sits inside the 0-4 GB the direct map covers. Under SeaBIOS it always
does. Under OVMF it does not: firmware may place a 64-bit BAR anywhere,
and it puts virtio-gpu's at about 768 GB. P2V produced an address with no
translation behind it, so the driver's first register write took a page
fault, then a double fault, then a triple fault and a reboot loop --
which, coming from a driver bind, reads as broken hardware rather than a
missing mapping.

ioremap() carries each region on a 1 GB page in PDPT entries 4-511, which
were unused: the direct map occupies 0-3, and kpml4[0], kpml4[KERNBASE]
and every process's PML4 all share that one table, so a mapping appears
in every address space at once with no page table to allocate. The pages
are cache-disabled and write-through for the same reason the top GB of
the direct map is -- these are registers, and a write sitting in a cache
line is a write the device never sees.

A physical address below 4 GB returns the direct-map address unchanged,
so the BIOS path, where every BAR is low, is byte for byte what it was.

virtio-gpu now initialises under OVMF (1280x800, queue 64) instead of
triple faulting. e1000 and xhci had the identical latent bug; xhci was
already refusing to bind above 4 GB with a comment saying the mapping did
not exist yet, and now it does.

BIOS unchanged: bootcheck 108 [ok] with -Fb -Tty -Usb -Sock -Evdev -Ps,
e1000 and xhci both binding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 21:31:01 +02:00
Nexoniarz
0750e7cbf4 Free every usable memory region, not one contiguous run
e820_init() took the usable run *containing the kernel* and treated its
end as the top of RAM, and kinit1() walked a single range from the end
of the kernel image to there. That is the shape of a BIOS map. It is
not the shape of a UEFI one: OVMF places an ACPI NVS region at 8 MB,
so the kernel's run ended there, the map failed the PHYSTOP_MIN check,
and the kernel fell back to asserting 224 MB it had not measured --
then freed straight through the reserved hole anyway.

Keep the usable regions as a list as well as a top address. kinit1()
now frees each one, clipped to the window main() passes in, so the
holes are skipped rather than either freed or fatal. PHYSTOP still
means what it always did -- the top of what kalloc may hand out, and
the bound kfree() rejects pages above -- so no call site changes.

phystop comes from the largest region rather than the highest. The DMA
arena is carved off the top of PHYSTOP and must be physically
contiguous, and the largest region is the only one certain to have
room; a smaller region above it is left unfreed rather than raising
the bound past a hole, because kfree()'s check is worth more than the
pages it costs. Report the usable total separately from the top
address, since with a fragmented map they are different numbers and
printing one as the other is how 8 MB went unnoticed.

Verified on both paths. BIOS: unchanged at 1023 MB in 2 regions,
bootcheck 27/27. UEFI under OVMF: 1018 MB in 7 regions, up from the
8 MB that triggered the fallback, booting through vga, e1000, the RTC
and on to cpu0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:03:51 +02:00
Nexoniarz
56139bd0e6 boot/uefi: a working UEFI loader, with one known limitation
Loads the kernel from the ESP, places its ELF segments, records the
framebuffer, translates the UEFI memory map into E820 entries, exits boot
services, builds the page tables the kernel expects and jumps. Verified
under OVMF: the kernel starts, brings up PCI, VGA, e1000 and the RTC, and
reaches "cpu0: starting".

UEFI structures are written from the specification rather than importing
gnu-efi or EDK II -- a large dependency for two hundred lines of use, and
a licensing question the project does not need to answer. clang emits
PE/COFF directly, so this needs no new toolchain.

The kernel gained a second entry point. The multiboot entry is 32-bit
code that climbs into long mode itself, and firmware that already put us
there would have to drop back down only to climb out again -- a mode
switch whose failure mode is a triple fault with no console. So entry.S
publishes its 64-bit entry the same way it publishes the multiboot
header: a magic to scan for, SCH64BOO, with include/schboot.h owning the
constants so the two cannot disagree. That header goes *after* the
multiboot header, never before: header_addr says the multiboot header
lives at mboot_load_addr, and the BIOS loader computes the kernel's file
offset from it.

The kernel loads its own GDT at that entry rather than the loader doing
it. Arriving in long mode means arriving on the firmware's descriptors,
and the first thing depending on the kernel's own selectors faulted --
"unexpected trap 13 ... cs=38", a long way from the cause.

KNOWN LIMITATION, diagnosed and not yet fixed: e820_init takes the usable
run *containing the kernel*, which under OVMF ends at 8 MB -- the
firmware places an ACPI NVS region at 0x800000, so the run is genuinely
fragmented. The map is truthful and untruncated (21 entries); the
assumption of one contiguous run is BIOS-shaped. The fix is for the
kernel to free each usable region rather than one range, which touches
kalloc and deserves its own change rather than the tail of this one.

BIOS boot is unaffected: 27 checks, no failures.
2026-08-09 17:13:09 +02:00
Nexoniarz
9b6106eab2 Fix the boot logo source, and put /etc under version control
LOGO_SRC named "Assets/SchOS Small Logo.png", which no longer exists --
the small logo was replaced by the full logo, the same mark slightly
redrawn. `make logo` had been failing since. Points at the full logo now;
not the text logo, which is the wordmark and would be illegible scaled to
128 px. Verified by photographing the splash: the mark renders as the
circle with Sch over OS.

/etc lived only in sysroot/, which is generated and gitignored, so
/etc/passwd, /etc/group, /etc/profile, /etc/kshrc and /etc/motd would all
have been missing from a fresh clone -- and the machine boots to a shell
that reads them. They are tracked under etc/ now and installed by
etc-install alongside the schinit manifests.

/etc/motd still said "Schunix": the rename swept tracked files, and this
one was excluded from version control at the time. Fixed.

Noted while checking: sysroot/bin/sh still contains the symbol
__schunix_tp, so the staged mksh predates the rename of the musl TLS
patch to __schos_tp. Harmless -- it is an internal symbol -- but it
confirms the staged ports are older than the patches that produce them,
and they cannot be rebuilt on a Windows host. It resolves the next time
ports are built on Linux.
2026-08-09 16:51:04 +02:00
Nexoniarz
baede13847 schinit: a launchd-shaped process 1
Milestone 1 of three: manifests, RunAtLoad, KeepAlive with throttling,
and reaping. Replaces the hardcoded fork-a-shell loop in cmd/init, which
stays in the tree for now.

Jobs are sectioned key=value files in /etc/schinit/*.job, kept in the
repo under etc/ and installed into the sysroot -- sysroot/ is generated
and gitignored, so anything written only there is lost on a fresh clone,
which is already true of /etc/passwd and should not become true of the
boot configuration. Unknown keys and sections are ignored rather than
fatal: a manifest written for a newer schinit must not stop this one
booting the machine.

Socket sections are parsed and recorded but not yet held. That is
milestone 2 and it is the whole reason for copying launchd rather than
sysvinit -- the supervisor holds the listener and starts the job on first
connect, which inverts the dependency graph and makes runlevels a
non-concept. The key exists now so manifests need not change later.

getdents64 is exposed to userland for this. The kernel has had it since
the Linux ABI landed, but nothing declared it in user.h or lib/usys.S, so
first-party programs had no way to list a directory at all.

Two things found by testing rather than reasoning:

  - setsid() gives a job its own session, and a session has no
    controlling terminal. The console then delivers input only to the
    foreground process group it tracks, so the shell came up, echoed
    every keystroke through the line discipline, and executed none of
    them -- indistinguishable from a hung shell. ControllingTerminal=yes
    claims it with TIOCSCTTY/TIOCSPGRP; off by default, since a daemon
    has no business seizing the console.
  - the first bootstrap() dropped /dev/input/event0-1 and most of /proc,
    because it was transcribed from the part of the old init that had
    been read rather than all of it. -Evdev caught it.

build/usbstick.img is built by `all` now: without it QEMU refuses the
-drive and the entire -Usb run produces no output, which reads as a
broken USB driver.

Seven suites green: base 27, Tty 51, Ps 32, Sock 64, Fb 36, Usb 31,
Evdev 29. make schfs-check passes.
2026-08-09 16:36:13 +02:00
Nexoniarz
08a1a9033a boot: separate BIOS from UEFI
boot/bios/ now holds the MBR, the El Torito stub and the real-mode stage
2; boot/uefi/ is empty and documented.

Separate directories rather than #ifdefs, because the two share almost
nothing. A BIOS loader lives in 16-bit real mode, reaches the firmware
through software interrupts, and walks itself up into long mode. A UEFI
loader is a 64-bit PE that the firmware has already placed in long mode
with paging on, calling services through a table of function pointers.
What they do share is the hand-off contract, which boot/README.md now
writes down: ELF segments placed, long mode, a 24-byte-entry memory map
at E820_MAP_ADDR, framebuffer geometry at VBE_INFO_ADDR.

That contract is the point of documenting it now. UEFI's GetMemoryMap
returns EFI_MEMORY_DESCRIPTORs, and the right move is to translate them
into the entries kernel/arch/x86_64/e820.c already parses rather than
teach the kernel a second format.

Build output paths under $(BUILD)/boot are unchanged; only sources moved.
2026-08-09 16:00:03 +02:00
Nexoniarz
686d5cf006 init survives stray signals; commands are 4-34x faster
Two bugs reported from actual use, plus a test harness that had stopped
testing.

init could be killed by Ctrl-C, taking the machine with it. SIGINT goes
to the foreground process group, init is pgid 1, and any window where
tty_fg_pgrp is 1 -- before a shell claims the terminal, after one exits,
or between the two while the key is held -- lands it on init. init has
no handler, so killed was set, exit() ran, and exit() panics outright on
initproc. Spamming Ctrl-C at a frozen program is exactly the input that
finds that window. POSIX and Linux both discard a default-action signal
for pid 1; init_ignores() now does the same, SIGKILL included, because
init is not killable from userspace on any Unix.

Everything was slow because BSIZE was 1024 while a SchFS block is 4096,
so every block cost four IDE requests, each with its own interrupt and
sleep/wakeup. exec() of the 800 KB toybox binary meant ~800 round trips.
The giveaway: `seq 1 200`, which touches no file and prints nothing,
cost the same 2.0s as `cat /bin/toybox`, and `ls | wc -l` cost exactly
double -- both dominated by loading the same binary twice.

BSIZE is 4096 now, one request per SchFS block, and NBUF is 256 blocks
(1 MB) rather than 90 KB -- smaller than the binary every command execs,
so nothing was ever still resident. ideinit() issues SET MULTIPLE MODE,
which is what makes a 4096-byte transfer legal: READ/WRITE MULTIPLE move
the drive's multiple count per interrupt and nothing had ever set it,
which is the real reason idestart() carried a sector_per_block > 7 panic.

  seq 1 200          2.02s -> 0.06s
  ls /bin            2.76s -> 0.68s
  ls /bin | wc -l    4.68s -> 0.73s

And the harness. bootcheck called Find-Qemu from a param() default,
which is bound before any function in the body exists -- so it died on
its first line and printed an error containing neither "FAIL" nor
"Exception", the two things being grepped for. It had not run since it
was made host-portable. Worse, an earlier index-based edit had deleted
the Expect blocks for -Tty, -Usb, -Sock, -Ps and -Evdev, so five of the
seven modes asserted nothing at all. Restored, and the USB run gets its
own 16 MB build/usbstick.img: it had been pointed at build/fs.img, which
nothing builds since SchFS became root, so QEMU refused to start.

242 checks across seven modes, all passing, and make schfs-check green.
2026-08-09 15:54:05 +02:00
Nexoniarz
e04ca28812 Rename Schunix to SchOS; the kernel becomes SchIFT
Removes the "-unix" element and with it any trademark exposure to The
Open Group.

The split follows the ecosystem layout: SchOS is the system, and the
kernel has its own name. Kernel-identity strings are the only places
that take SchIFT -- uname(2) sysname, /proc/version, and the two licence
sentences that say "kernel" outright. That is what every Unix does here:
`uname -s` on Linux reports the kernel, not the distribution. Everything
a user reads as the product -- panic screen, boot menu, error prefixes,
docs, LICENSE, NOTICE -- is SchOS.

schunix.img is schos.img, the logo assets and the musl TLS patch are
renamed, and __SCHUNIX__ is __SCHOS__ (the define and the patch that
consumes it moved together).

Two things the rename broke, both found by the bootcheck suite:

  - shmtest wrote "SCHUNIX-MMAP-OK" with a hardcoded length of 15 and
    compared it back by hand-counted offsets (m[3]=='U', m[14]=='K').
    The shorter marker left the write running two bytes past the literal
    and the compare reading past the data, failing a file-backed mmap
    that was working correctly. Now written and compared through one
    constant with sizeof.
  - bootcheck asserted a 31-byte file after appending to
    "hello-from-schunix"; the shorter string makes it 29.

Both are the same mistake: a string literal whose length is written out
somewhere else. Full suite green -- base, Tty, Ps, Sock, Fb, Usb, Evdev.
2026-08-09 14:09:59 +02:00
Nexoniarz
d0f7acf9eb Initial commit: Schunix as it stands before the SchOS rename
An x86-64 operating system built from xv6, now largely rewritten: SMP,
a copy-on-write root filesystem (SchFS), PCI/USB/virtio drivers, a TTF
rasteriser, and a Linux-numbered syscall ABI.

No history before this point -- the tree was never under version control.
This commit exists so the rename that follows has something to undo to.
2026-08-09 13:47:01 +02:00