Commit graph SchOS/kernel/dev
Author SHA1 Message Date
Nexoniarz
5618164754 font: the console draws outlines now, and a byte could not have held Ą
"Ą Ę Ś Ł Ż" drew as "?????" and the obvious diagnosis -- the font has no
glyphs for them -- was wrong twice over. `struct vtcell.ch` was **one byte**
holding a CP437 index, and the console mapped UTF-8 down to it on the way in.
U+0104 does not fit in a byte. No font could have fixed that; the cell had to
get wider first.

So: `ch` is a uint32 codepoint, the mapping on the way in is gone, and the cell
draws through kernel/dev/ttf.c against JetBrains Mono linked into the image.
Cell size comes from the font's measured advance instead of a hardcoded 8x16.
The grid costs 864 KB of BSS instead of 432 KB, which is the honest price of
holding a codepoint per cell for six terminals at 240x75.

The rasteriser needed three fixes before it could be trusted with this.

**21 KB of locals on a 4 KB kernel stack.** `ttf_render` held an outline (512
points as three arrays) and a raster (1024 edges) as automatics. KSTACKSIZE is
4096. They walked off the bottom of the stack into whatever was underneath,
which surfaced as user processes faulting on addresses they never touched and
then a panic inside freevm() on an overwritten page directory. Nothing pointed
anywhere near a font.

**And then again, recursively.** load_outline() held a 4736-byte outline as a
local *and recursed* for composite glyphs. Composite glyphs are exactly Ą, Ę,
Ś, Ł, Ż -- so the second crash arrived the moment the first fix let Unicode
reach the screen, looking like a fresh bug. Now a static outline per recursion
level with a depth cap of 4 (the font's deepest real nesting is 2), and the
leaf parse split into load_simple() so its 512-byte flag array is not
multiplied by the depth. Worst chain measured from the object file: ~2.3 KB.

Neither showed in tools/ttftest, which has an 8 MB stack. That is the standing
hazard of one source file built for host and kernel: the host is most forgiving
about exactly the resource the kernel is strictest about.

**Component transforms were being skipped**, on the stated theory that a wrong
transform is worse than none. It is not. `‘` in this font is the comma glyph
with a scale of -1 -- a 180-degree rotation -- and the offset that ships with
it only lands correctly after the rotation. Without it the mark went twelve
pixels into a ten-pixel cell and was clipped away entirely, so the character
read as missing from the font rather than as misplaced. F2Dot14 2x2 now, with
SCALED_COMPONENT_OFFSET honoured.

The glyph cache was ASCII-only, so every accented letter re-rasterised on every
repaint: a screenful of Polish did hundreds of bezier flattenings per scroll
where the same screenful of English did none. 95 reserved ASCII slots plus 224
shared slots keyed by codepoint, 64 KB pool. Verified 13680 cached renders
byte-identical to direct ones across collisions and a pool flush.

tools/ttftest speaks UTF-8 now. Iterating bytes meant it could only ever ask
for ASCII, and ASCII is entirely simple glyphs -- it could not reach the code
path that mattered.

Also: resolvconf's success line moves behind /proc/bootmode, same rule as
vgpud. A quiet boot opened with "resolvconf: nameserver 10.0.2.3" where the
one line was supposed to be "Welcome to SchOS!". Failures stay loud.

Verified on screen, not just on the host: Ą Ę Ś Ł Ż ć ń ó ź, Ω λ π Œ Ð Æ ß,
∞ ‘x’ → ≤ all render, panic=0 across four boots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 00:35:34 +02:00
Nexoniarz
ca28978995 dma: the 4 MB cap was the refusal, not the arena
vgpud: wanted 7365 KB of DMA memory for a 1920x982 framebuffer
    vgpud: not enough contiguous DMA memory
    schinitd: restarting os.schos.vgpud        (and again, and again, until
    schinit: supervisor stopped answering       schinit replaced schinitd)

Every number in that diagnostic was about the arena, and the arena had 61 MB
free. The refusal came from do_dma()'s per-request cap, which was 4 MB --
exactly one framebuffer at 1280x800, chosen when that was the only thing
anybody asked for.

virtio-gpu's display size is the size of the **host's window**. Drag it out and
the guest is asked for 1920x982x4 in one contiguous piece, and no amount of
free arena helps. 32 MB now, which is 2560x1600 with room over. The cap is not
there to ration memory -- the arena does that and says so properly -- it is
there so a wild size cannot ask for everything by accident.

DMA_ARENA_BYTES 16 -> 64 MB alongside, for the reason its own comment already
gave and then got the arithmetic wrong on: either side of the handover *both*
drivers hold a framebuffer, so the requirement is two at the largest mode, not
one. 16 MB is two at 1280x800 and nothing bigger. Verified booting at 256M and
512M as well as 1G.

And vgpud no longer dies when it cannot have what it asked for. It claims the
framebuffer before creating the resource, and if the full size cannot be had it
steps down by thirds to 640x480 before giving up -- and gives up with
SCHINIT_EXIT_NORESTART rather than die(), because a driver that will fail
identically on every restart should not be restarted. That loop is what made
schinit replace the supervisor mid-boot.

    vgpud: the display is 1920x1080 but only 1280x720 of DMA could be had

is what it says when it steps down. With the cap raised it does not need to:
1920x1080 now allocates its full 8100 KB, 0 restarts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 22:52:20 +02:00
Nexoniarz
ff7afa746e tty: arrow keys, backspace, and four terminals nobody was home on
Three things, all in the first minute of using a shell.

**Arrow keys corrupted the two keystrokes after them.** They arrive as single
keycodes -- KEY_UP is 0xE2 -- and were pushed into the input buffer exactly
like that. No terminal works that way, and 0xE2 is the worst possible byte to
have chosen: it is a UTF-8 lead byte for a three-byte character, so a reader
decoding UTF-8 swallowed the next two keys waiting for continuations that never
arrived. consoleintr() sends the DEC/xterm sequences now -- ESC[A, ESC[D,
ESC[5~ and the rest -- which every terminfo entry and every line editor already
knows. Not echoed: in canonical mode echoing them would move the cursor for
real, which was the other half of what "weird glitches" looked like.

**Then the end of the line vanished as you typed into it**, with the buffer
perfectly correct. One line in vt_putc(): backspace erased the cell it moved
onto. That is a line-discipline erase, not the BS control character -- a
program rubbing a character out sends `\b \b`, and the separation matters
because programs also use plain `\b` to go back. zsh does, on every mid-line
edit:

    echo alpha  \b\b  zha  \b\b

back two, rewrite the tail, back two again to park the cursor. Against a
destructive backspace that last pair deleted the `ha` it had just drawn.

`\b` moves and nothing else now, 0x7f is ignored as on a real terminal, and
consputc() spells its own erase out as `\b \b` as it already did for serial.
ESC[@, ESC[P and ESC[X went in alongside, because TERM=linux declares them.

**VT3 to VT6 were blank** because nothing ran on them. NVT is 6 and the kernel
made all six; only two had a shell, so Alt+F3 switched to a terminal that was
working perfectly and had nobody to talk to. Four more manifests.

Six shells starting at once is enough concurrency to expose an intermittent
fault in one of them -- one boot in five, a wild jump into .data that runs
until it writes to 0, which nothing catches because SchOS never sets EFER.NXE.
Three boots with two shells, none. Recorded rather than fixed: it is not caused
by the manifests, only made likelier by them.

Also: QEMU_NET forwards host 8080/8081/8000 to guest 80/8080/8000, so the httpd
is reachable from the machine running QEMU instead of being sealed behind NAT.
Verified end to end. And the greeting prints once again -- a replaced
supervisor was announcing a boot that had already happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 22:43:54 +02:00
Nexoniarz
75ccf9841c errno: -1 is EPERM, and four places were saying it by accident
Ctrl-C killed the shell. consoleread() breaks off the read in progress when
SIGINT arrives and returned -1 to do it -- and -1 is -EPERM by the time it
reaches a program, so every program that handles SIGINT by carrying on saw a
permanent error instead of an interrupted call:

    zsh: error on TTY read: operation not permitted
    schinitd: restarting os.schos.console-shell

and the next Ctrl-C did it to the replacement. EINTR now, which is what a
signal-interrupted read is required to give. Verified: Ctrl-C an interruptible
command, shell survives, zero restarts.

Same mistake, same file family: link, unlink, rename and renameat returned -1
on every error path. Callers branch on errno -- `if (unlink(p) < 0 && errno !=
ENOENT)` is the standard shape, and against EPERM it always complains -- which
is why zsh reported failures for things that had worked. They return ENOENT,
EEXIST, EXDEV, EISDIR, ENOTEMPTY and EFAULT now. About ninety other `return -1`s
remain in sys_fs.c; these four are done because these four were what a program
tripped over. /dev/mem's unknown minors return ENODEV rather than -1 too.

/dev/urandom was a linear congruential generator seeded with a **constant**, so
every boot produced the same bytes in the same order -- not weak, the same. A
key, a nonce, a temporary filename: shared with every SchOS machine that had
ever run. Seeded at boot from RDRAND where the CPU has it, from the cycle
counter and the wall clock where it does not, and re-stirred per byte. Still an
LCG and still not a CSPRNG; what changed is the part that was dangerous rather
than merely poor.

**Not fixed, and recorded as such:** rename() still will not replace an existing
target, so zsh's history save fails on every exit after the first. An attempt to
unlink the target first was reverted -- the directory is an HTree, dirunlink
followed by dirlink of the same name did not behave, and it panicked in
filewrite with the old file already gone. Losing both files is worse than
refusing, so it refuses until the HTree's slot reuse is understood.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 22:12:28 +02:00
Nexoniarz
617a4aec0a boot: one line when it works, all of it when it does not
A boot printed about thirty lines before reaching a prompt. Every one was
useful once, and printed on every boot for ever they train the person in front
of the machine to stop reading their own console -- which is expensive on the
day one of them changes.

    SchOS UEFI loader
    Press F8 for boot options... booting

    Welcome to SchOS!  (F8 at boot for the full log, or dmesg once you are in)

    root@schos / »

**Nothing was deleted, it was routed.** consputc() already wrote to the klog
ring before anywhere else, so a line can be kept in full while being held back
from the two places a person looks. kinfo() is cprintf() with that routing, and
`dmesg` still has the whole boot with timestamps afterwards. The rule for which
is which is not how interesting a line is -- everything was interesting to
somebody once -- but whether you would want to see it on a machine that is
*working*. `pci: 7 devices` is yes the first time and no every time after;
`e1000: no DMA memory for the rings` is yes for ever and stays a cprintf.

The verbose boot the F8 menu already offered now means something much bigger,
which is the other half of this: it is the only way to see any of it live.

Userspace follows through /proc/bootmode, one word. schinit, schinitd and vgpud
hold back their progress lines and never their failures -- a supervisor that
goes quiet when a job dies is worse than one that chatters.

Two things found by testing rather than by reading:

  - schinit read /proc/bootmode *before* bootstrap() mounted /proc, so it got
    "quiet" every time and the verbose boot was quiet in the one process that
    runs first. It reads after the mount now.
  - resolvconf announced "already exists; -f to replace it" on every boot after
    the first, which is a line about having done nothing. It is quiet when the
    file already says what it would have written, and speaks only when the two
    disagree -- which means the nameserver changed and the machine is using the
    old one.

Verified both ways: quiet boot is six lines, a forced-verbose build prints all
thirty plus the `verbose boot:` steps, and /proc/kmsg holds every line in both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 21:19:50 +02:00
Nexoniarz
b08432b2c3 userland: 268 applets, an editor, names that resolve, and a web server
Four complaints, and only one of them was the thing it looked like.

**"toybox is too small."** Half true, and the fix cost no licensing at all:
236 of toybox's applets are what defconfig builds, and the rest live in
toys/pending/ -- awk, bc, diff, more, expr, tr, hexdump, stty among them. Same
0BSD tarball, switched off by a config default. 26 of them are on now, 236 ->
268. Not everything: strace wants ptrace, syslogd wants BSD's facilitynames[],
git wants OpenSSL, and those three break the link.

The toybox build also works again on this machine. Its scripts start
#!/bin/bash, which does not exist on NixOS, so the binary in the tree was one
nobody could reproduce; the shebangs are rewritten to /usr/bin/env bash on the
way in. Pending applets are enabled by editing .config directly, because
`make oldconfig` is the obvious way and undoes it -- every pending symbol is
`default n`.

**BusyBox was asked for and is not here.** It is GPL-2.0-only, and `make
ramroot` links the root image *into the kernel binary*, so it would put GPLv2
inside an Apache-2.0 executable -- which is the exact case ports/Makefile's
NOTPORTS filter and docs/licensing.md §4 exist for. nano is the same answer one
version later. Both recorded in licensing.md with what was done instead, since
"no" without an alternative is how a rule gets quietly broken later.

**An editor that is not vi.** kilo, BSD-2-Clause, one 1300-line file, no
dependencies beyond termios and TIOCGWINSZ, both of which zsh already needed.
Installed as /bin/edit as well as /bin/kilo, because somebody with a file to
fix will type `edit`.

**"wget doesn't work."** Not a userland problem, and BusyBox's wget would have
failed identically: nothing on this system could turn a name into an address,
so every program taking a hostname failed in its own way and each looked like
a separate bug. There is still no DNS client in SchOS and there does not need
to be -- musl has a complete resolver and it works over ordinary UDP sockets.
The one missing fact was which nameserver, which the kernel has had all along
from DHCP and never published. /proc/resolv.conf publishes it in resolv.conf's
exact syntax; cmd/resolvconf decides whether it becomes /etc/resolv.conf, and
will not overwrite a hand-written one without -f.

  # wget -O - http://example.com/ | head -1
  <!doctype html><html lang="en"><head><title>Example Domain</title>

**Something to host with.** cmd/httpd: GET and HEAD, static files, index.html
for a directory and a listing otherwise, a child per connection. No CGI, no
keep-alive, no TLS, no config file. The path check is the only
security-relevant code in it and it refuses rather than normalises -- no
component may be `..`, and percent-decoding happens first, because %2e%2e is
`..` and a check before decoding does not see it. Both forms answer 403.

And the disk is 100 MB, from 32. It is sparse, so the image is 21 MB on the
host. RAMDISK_BLOCKS went 6000 -> 8000 with it: the ramdisk was at 86% and the
next thing added would have broken `make ramroot`, which nobody would have
noticed until they used it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 19:24:55 +02:00
Nexoniarz
bc39b73969 debugger: D5, the network transport -- and the plan closes
schdbgd bridges TCP to the debugger, which is the one way in that does not
need you to be sitting at the machine. It is a bridge and nothing else: a line
in becomes a line written to /dev/dbgro and the answer comes back, so `nc` is a
complete client and there is no clever client to be broken on the day you need
this.

    # schdbgd &
    schdbgd: debugger on port 4444, loopback only
    $ nc 127.0.0.1 4444
    dma
    arena  0xffff80003da41000-0xffff80003ea41000  16384 KB total, ...

A kernel debugger plus a TCP socket is a remote root hole, so three things were
decided rather than discovered:

  - It does not start by itself. schdbgd.job ships Disabled. SchOS has no
    kernel command line, so the boot flag the plan asked for is the job
    manifest, which is the same thing in this system's own idiom.
  - It binds loopback; -a names an address and says so in a paragraph.
  - It cannot reach a view that changes state, enforced in the kernel.

That last one is /dev/dbgro: /dev/dbg on minor 1. The minor is the one piece of
per-open state a character device already sees, so which node you opened is the
permission -- nothing to latch, nothing to remember between the write and the
read, no way across. Which views are refused comes from dbg_register_rw() on
the registration itself, because a central list of dangerous views goes stale
in the direction where a new view is quietly treated as safe. Today that is
`watch <addr>`; `watch` with no argument is still the listing and still
answered.

Also: socket() now accepts IPPROTO_TCP with SOCK_STREAM and IPPROTO_UDP with
SOCK_DGRAM. They name the default rather than departing from it, and toybox's
nc writes them out -- so TCP was "Protocol not supported" on a system whose
whole network is TCP.

docs/plan/debugger.md retires into docs/architecture/debugging.md; 16 files
repointed. Every plan in docs/plan/ that was open is now closed, so the next
thing is Gate 0 itself: the survey, with the instrument built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 15:33:53 +02:00
Nexoniarz
071499a378 ldls: a second driver, and everything it found
e1000 -- Linux's, unmodified -- probes, opens, gets an address by DHCP and
carries ping traffic in a SchOS process. The kernel was driving that NIC and
gave it up, which makes it the first real handover as well as the second
driver.

None of what it needed was a missing feature. Every one was a call that
succeeded, returned zero and did nothing:

  _find_next_bit          "bit 0 is set"          -- for_each_cpu never ended
  netif_carrier_off       carrier already up      -- TCTL.EN never set
  __x86_indirect_thunk_r8 the jump discarded      -- 255 rx buffers, none made
  dma_map_page_attrs      bus address 0           -- NIC aimed at physical 0

so the rule from memcpy is now general: a stub returning zero is unsafe
wherever zero is a meaningful answer.

The loader grew ko_fixup_percpu(). The stack guard turned out to be one
per-CPU variable among several; `current` cannot be defused because the driver
dereferences it. Taking the %gs off instead leaves the same opcode reading the
same width from storage the host supplies -- a per-CPU variable in a
single-threaded host is just a variable. Eight bytes become seven and a nop,
and the displacement is not always last, so there is now just enough of an x86
decoder in ko.c to know how long the instruction is.

ldls/kpi/thunk.c is fifteen lines of assembly and the highest-value file here:
retpolines are control flow, and a stub cannot stand in for a jump.

The kernel side:

  - ioperm.c, the TSS I/O permission bitmap. A driver's outl is an instruction,
    not a call, and e1000's reset path picks it deliberately. IOPL=3 would hand
    over the PIC and the disk with it, so it is per-port. Writing it found that
    the I/O map base had been written at offset 64 rather than 102, leaving the
    CPU checking ports 0-831 against the TSS's own RSP0.
  - claim masks the device's IRQ line: 450,000 a second, otherwise.
  - net_detach(), the missing half of net_attach_late(). An interface left in
    the stack keeps its route and sends every frame to a driver that refuses
    it, which looks exactly like a userspace driver that does not work.
  - e1000_release() gives up the interface, not just the hardware.

8139too still passes 4/4; ldls-check still passes 27; four CPUs, no panic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 15:20:07 +02:00
Nexoniarz
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
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
415df0fd4d debugger: reachable from the console, and named on the crash screen
Two things, both about the debugger being available at the moment you
actually need it rather than once a shell exists.

**Ctrl-G on the console runs every view.** No filesystem, no /dev, no
shell -- it is the last thing main() sets up before entering the scheduler,
so it answers from early in boot, which is exactly when boot problems
happen. It sits beside the older Ctrl-P, and the difference between them is
the interesting part: procdump() takes no lock at all, deliberately,
because a lock is a way to hang while reporting a hang. The views cannot
follow that rule -- a free list read torn is worse than not read -- so they
take locks, and a lock must never be taken from an interrupt handler that
may have interrupted its holder. The key therefore *requests* and a kernel
thread *performs*. A scheduler too wedged to run a thread will not answer
Ctrl-G; Ctrl-P still will, and that division is the honest one.

**PSOGM says where the report went.** Its closing line used to be an
addr2line command, which the call chain no longer needs -- it prints names
now. What replaced it is the thing nobody would guess:

    This report is in /proc/kmsg after a restart.
    schdbg, or Ctrl-G on the console, asks more.
    PSOGM  --  Purple Screen of Guru Meditation

"Restart and read it" beats "photograph this screen" by a wide margin, and
until the log outlived the kernel it was not true. Both panic.c and
graphics.md had claimed in writing that the screen was the only artefact of
a crash; neither says so now. The QR code stays, because it is still the
only channel that works when the machine will not come back at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 01:29:30 +02:00
Nexoniarz
113595f80b debugger: who owns this page, and who just wrote to it
docs/plan/debugger.md D3 and D4, which finish the plan apart from the
network transport.

D3 -- `whose <addr>`. The views each speak for one subsystem, which is what
keeps them cheap and current, and it is exactly why none of them can see
the bug that matters: a page on the DMA arena's free list looks fine from
dma.c, the same page on kalloc's free list looks fine from kalloc.c, and
that it is on both is a fact no single subsystem holds.

    # schdbg whose 0x3e239000
    phys 0x000000003e239000   kernel va 0xffff80003e239000
      dma     arena, handed out
      pcidma  slot 2, 0 KB into a 4000 KB block given to pid 4 (vgpud)
      mapped  pid 4 (vgpud) at 0x000000004000d000  rwu  vma[5] no_free

      consistent: 1 owner, 1 mapping(s)

Claimants register the way views do -- one line in the file that owns the
data -- so the kernel image, the log region, the arena, the page allocator,
the pciuser DMA blocks and every page table each answer for themselves.
`mapped` is the one no allocator can answer: there is no reverse map, so
vm_walk_user() goes forward over every mapped page of every live process.

Two verdicts are shouted and everything else is reported as fact. More than
one owner means the second allocator to hand the page out is handing out
memory the first is still using -- the H2 bug, which would have printed as
three lines. Free-but-still-mapped means a page will be given away from
under the page tables that point at it. Deliberately *not* a finding: no
owner at all, because kalloc keeps a free list and no record of what it has
handed out, so an ordinary allocated page has nobody to speak for it and
saying otherwise would be the query crying wolf.

D4 -- watchpoints, on the four debug registers that had been sitting unused
since this kernel was written. Armed against the same framebuffer page and
then killing the driver:

    watch 0: 0xffff80003e239000 written, 8 byte(s), by stosq+0x1f, pid 12 vgpud
             #0  memset+0xba
             #1  dma_alloc+0x2e2
             #2  do_dma+0x22a
             #3  pciuser_ioctl+0x13b
             #4  sys_ioctl+0x3c7
             #5  syscall+0xf6

The chain is the answer, not the rip. It reports and continues -- a data
watchpoint is a trap, delivered after the store completes, so returning
carries on with the program none the wiser, which is what lets this be used
on a machine that is still running.

Debug registers are per-CPU and there is no general IPI here, so the table
carries a generation number and each CPU reloads from its timer interrupt
when it notices the number has moved: one comparison per tick, one tick of
latency at worst. `watch` prints which CPUs have caught up, because "this
watchpoint is watching" and "this watchpoint is watching one CPU" must not
look the same.

Also fixed while testing: watch_init() was called from mpmain(), which runs
on every processor, so the command appeared in the list once per CPU.
dbg_register() now refuses a duplicate name outright, so the next init that
runs per-CPU fails loudly in the list rather than quietly doubling it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 01:17:57 +02:00
Nexoniarz
1dd4ba0186 debugger: the kernel can be asked a question
docs/plan/debugger.md, milestones D0, D1 and D2. What they replace is the
ninety-second loop -- add a cprintf, rebuild, boot, read the serial log --
which answers exactly the one question you thought of before the rebuild.

The rule the shape follows: **it observes, it does not control.** No
stepping, no software breakpoints, no stopping the world. Half the bugs
this kernel produces are timing -- an interrupt storm, a level-triggered
line nobody acknowledges, a driver that faults only on its second start --
and every one of them disappears when you halt the machine to look. And
the layering rule, which is the circularity rule the display already has:
a debugger that needs lwIP cannot debug lwIP.

D0 -- symbols. tools/ksyms.pl turns the first link pass into a table the
second pass carries. Two passes is enough because .ksym is placed after
.text and .rodata, so carrying the table cannot move anything recorded in
it; the build lists both passes' text symbols and refuses to link if they
ever disagree, because a symbol table that is subtly wrong gives confident
false answers. ksym.c reads it through weak symbols, so pass one -- which
has no table -- still links. A crash chain now reads panic+0x5f <-
proc_sysrq_cmd+0x99 <- ... <- syscall+0xf6.

D1 -- a log that outlives the kernel. Every character through consputc()
lands in a byte ring with a timestamp per line, readable as /proc/kmsg.
It sits in a region carved before the page allocator gets anything, with a
magic header, so a kernel finds the previous kernel's log still there --
including the whole PSOGM report. panic.c's own comment said "there is no
core dump and no log to go back to"; that is no longer true.

  The obvious place for it is the top of RAM beside the DMA arena. That
  was the first attempt and the magic came back as ASCII on the reboot,
  because the top of usable memory is exactly where the firmware makes its
  own allocations. It lives above the kernel image instead, which is a
  stable address across boots of the same build.

D2 -- the introspection registry. A subsystem publishes its state with one
line at the bottom of the file that owns the data:

  dbg_register("dma", dma_dump, "the DMA arena's free extents");

Seven views -- dma, mem, proc, vma, claims, pci, sock -- reachable three
ways: sysrq 'g' (always, no process and no filesystem), /dev/dbg (echo a
line, cat the answer), and schdbg. The protocol is lines of text because
the same bytes have to work where nothing can allocate or frame a record,
and because a protocol you cannot drive by hand is one you cannot debug.
struct sbuf moved out of kernel/dev/procfs.c into include/sbuf.h rather
than being written a second time.

Several views exist because of a specific bug. `vma` is the table that
decides whether exit() frees a page, and the DMA ownership bug that made
this worth building was a block that was never in it.

Also fixed, found by building with clang for the first time in a while:
struct iovec was defined in both posix.h and socket.h, and every file that
included both was a redefinition. GCC 15 defaults to C23, which permits an
identical struct redeclaration, so the gcc path never noticed -- while
clang defaults to gnu17 and rejected it, breaking the one toolchain
shell.nix exists to provide and the UEFI loader requires. Both are now
guarded.

D3 (`whose <addr>`, the cross-subsystem ownership query) and D4 (hardware
watchpoints on DR0-DR3) are not built and are described honestly as
missing in the architecture doc's "What it does not do".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 00:58:45 +02:00
Nexoniarz
d1f50c4da3 hybrid: the driver is killed, and the driver comes back
H2 of docs/plan/hybrid.md -- the milestone the plan itself calls the one
that proves the whole idea, and the one that cannot be skipped.

    # cat /proc/schinit
    1 4 11 os.schos.vgpud
    # kill 4
    schinitd: restarting os.schos.vgpud
    vgpud: claimed
    vgpud: pid 8 drew a test pattern from userspace

The test pattern ends in a twelve-block tally of the drawing process's
pid, because a second run that redraws an identical picture is
indistinguishable from a first run that never stopped. Read off the
virtio-gpu scanout across four generations it says 4, 8, 10, 12, and the
DMA arena hands back the same physical address every time.

What it took:

* A halt the kernel can apply to a device it does not understand.
  Clearing PCI_CMD_BUS_MASTER is the only one there is, and it has to
  happen before the DMA goes back to the arena -- a dead driver's device
  keeps reading a framebuffer the kernel is about to give to somebody
  else. Claim sets the bit, release clears it; pcitest shows it in a bm
  column, with a third state so a failed read is never printed as off.

* A refusal. vgpu_release() asks vga_fb_restore() whether there is a
  framebuffer to put the console back on, and declines to hand over the
  device if there is not. The rule that the kernel keeps a minimal
  driver for everything on the path to a shell is worth writing down
  only if something enforces it.

* The DMA arena not being owned by two allocators at once. This is the
  bug that stopped H2: freevm() frees every mapped page below PHYSTOP
  and the arena is the top 16 MB of exactly that range, so a DMA block
  mapped into a driver went onto the page allocator's free list when the
  driver died, while pciuser_release_all() handed the same pages back to
  the arena. The restarted vgpud asked for its framebuffer, got pages
  kalloc() had already given out -- including its own page tables -- and
  dma_alloc()'s zeroing wiped them, so it died inside the ioctl on the
  return from a syscall whose stack had stopped existing. do_dma() now
  records a VMA with no_free set, the way shared memory does.

* One exit status. KeepAlive must restart a driver that crashed and must
  not restart one that has looked at the machine and found its device
  absent: on hardware with no virtio-gpu that was fifteen spawns in
  forty-five seconds, each printing a line to the console, for ever.
  SCHINIT_EXIT_NORESTART is 78, sysexits.h's EX_CONFIG. It is not a new
  manifest key -- the wait status has always travelled from PID 1 to the
  supervisor, and note_exit() simply stopped discarding it.

The milestone's third clause, "the compositor reattaches", is not done
and is not claimed: there is no compositor running. That is recorded in
the plan as a Schcom milestone rather than counted here.

Closes the register row "A released PCI device" in docs/plan/1.0.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-14 00:05:43 +02:00
Nexoniarz
d3c37f82b6 docs: the tree moved and the documentation did not
The site and several documents still described the system as it was before
virtual terminals, the new schinit, the hybrid work and descriptor passing.

docs/architecture/init.md described the wrong program. It documented a single
schinit that parses manifests and supervises jobs, which has not been true since
PID 1 was split: PID 1 forks a supervisor, spawns jobs on its behalf and reaps;
the job table lives in /proc/schinit; a heartbeat runs both ways; and
proc_reincarnate() re-execs PID 1 in place rather than panicking. Rewritten
around what exists, keeping the reasoning that is still load-bearing -- the five
failures of the sibling-init scheme, the escalation ladder, and the three bugs in
the reincarnation path that each had to happen before they were obvious.

docs/plan/init.md is deleted, which is the tree's own rule for finished work and
had not been applied. Fourteen source comments referenced it by part number, so
they are repointed at the architecture doc by section name rather than left
dangling -- a deleted document nobody notices is worse than a stale one.

docs/architecture/devices.md gained the section it has been missing since
/dev/pci existed: what a driver needs from a kernel, why mmap is checked against
the caller's claims, why DMA is a call and not a mapping, and why the write back
after an interrupt is not optional. Also that a released device does not come
back by itself, which is true and was nowhere.

docs/architecture/networking.md gained a descriptor-passing section, and
docs/architecture/README.md's stack diagram gained the two layers and the third
seam it was missing. "Two seams hold the whole thing together" has not been true
since /dev/pci.

docs/plan/README.md said the hybrid work was deliberately last, on the argument
that an interface with no consumer is wrong in ways nobody can see. The argument
was right; the conclusion was not, and the document now says why it changed --
the alternative to a driver compatibility layer is writing a driver for every
device that exists, and the original objection is answered rather than ignored
because SchOS's own drivers are the consumers.

The site: a Hybrid section with the eviction transcript, cards for virtual
terminals, schinit and drivers outside the kernel, and the numbers remeasured
rather than estimated -- 46,961 lines, 207 syscalls. The boot log in app.js is
from a boot taken today, and the lines are trimmed to what the console can show
without clipping, each still a verbatim prefix of real output.

Two small rendering fixes found by looking at the pages rather than assuming:
.console pre's min-height is now scoped to the hero, so a static transcript sizes
to its content instead of reserving eight lines it does not have; and the docs
reader's contents panel strips inline marks from headings, so a heading written
`proc_reincarnate()` no longer carries its backticks into the sidebar.

Every intra-doc link checked; none broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 20:05:06 +02:00
Nexoniarz
ea68ef4b54 hybrid: the GPU driver now runs in userspace
docs/plan/hybrid.md's first eviction. cmd/vgpud is the virtio-gpu driver as a
process: it finds the device by vendor and device id, asks the kernel for it,
and does the whole virtio 1.0 bring-up from outside -- reset, feature
negotiation, a virtqueue in DMA memory, GET_DISPLAY_INFO, RESOURCE_CREATE_2D,
ATTACH_BACKING, SET_SCANOUT, TRANSFER and FLUSH.

    vgpud: virtio-gpu at 00:03.0, currently held by virtio-gpu
    virtio-gpu: released to userspace; screen is the VBE framebuffer
    vgpud: claimed
    vgpud: display is 1280x800
    vgpud: scanout is ours, framebuffer at physical 0x3eceb000
    vgpud: drew a test pattern from userspace

Verified by screendumping the virtio-gpu head: the pattern is there, with a
checkerboard that would shear if the stride were wrong. The console carries on
in VBE on the other head at the same time, which is the fallback rule from the
plan working rather than being described.

No syscall knows this is a GPU. The kernel handed over a PCI device and the
process decided what it was -- which is the property that makes this a general
mechanism, and the reason the evictions come before anything foreign.

Four things the first real driver needed that the test program had not:

  - PCI config space (PCIIOC_CFG). A virtio device says where its registers
    are in vendor capabilities, which live in config space, so a driver that
    cannot read it cannot find its own registers.
  - a way for the incumbent to let go: struct pci_driver gained a release
    hook. A driver without one keeps its device, which is always safe and for
    anything on the path to a shell is also correct.
  - the outgoing driver freeing its DMA. Releasing the device but keeping its
    4 MB framebuffer is half a handover -- the incoming driver asks for one and
    is told there is no room.
  - a 16 MB DMA arena. 4 MB was exactly one framebuffer at this resolution,
    and either side of a handover there are two.

And one bug it reproduced on its first working run: vgpud polls the used ring,
so it registered no interrupt handler, and a virtio device interrupts on every
completion. The storm detector said so immediately --

    irq 11: 77780 interrupts/second -- a device on this line is not
    acknowledging it.  handler 'e1000' is attached

-- naming the line, the rate and the neighbour it was drowning. One line fixes
it (VRING_AVAIL_F_NO_INTERRUPT before the queue is enabled), and that detector
was worth every minute it cost.

include/virtio.h moved out of the driver directory. Its own comment said it
should when a second reader appeared; one has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 18:27:41 +02:00
Nexoniarz
72074a960e hybrid: interrupts to userspace, and a crashed driver that stays crashed
docs/plan/hybrid.md mechanism 3. A process asks for a device's interrupts with
PCIIOC_IRQ and then reads them, which is how Linux's uio does it and for the
same reason: a descriptor composes with poll, and a driver that stops reading
falls behind rather than losing events. read() returns a count rather than a
flag, so a driver that was busy sees it missed three and acts once.

Proven against an e1000e by asserting a cause through its ICS register and
reading the interrupt back out in userspace:

    interrupts routed here (line 10)
    interrupt delivered, count = 1
    ICR = 0x80000004

0x80000004 is the cause the test set plus INT_ASSERTED -- the device's own
register, read by userspace as its acknowledgement.

**The masking is the design, not a detail.** A PCI line is level-triggered and
stays asserted until the cause is cleared at the device, which is a different
register on every chip and so is something only the driver can do. The kernel
masks the line on arrival and userspace's write() unmasks it. Signalling EOI on
a still-asserted line re-enters the handler immediately and for ever; this
kernel did exactly that by accident at 72,000 interrupts a second, and what it
looked like from outside was a clock running seven times slow (§13).

Which decides what happens when a driver dies mid-interrupt: the line is left
**masked**. The instinct is to unmask -- do not leave the device dead -- and it
is wrong, because unmasking an asserted line with no handler is a crashed
driver taking the machine down with it. Masked is safe and not permanent: the
next PCIIOC_IRQ arms it. Both halves verified by crashing a driver on purpose
and then starting another one.

A line already carrying kernel handlers is refused outright. Masking is
per-line, so handing out a shared one would silence whichever kernel driver is
also there, with nothing logged. MSI is the real answer and pci_msi_enable() is
already in the tree waiting for a caller.

Also fixed, found by running the crash twice: DMA buffers were never returned
when a process exited. The arena is 4 MB and contiguous, so a driver that
restarts a few times exhausted it -- "out of DMA memory, 3584 free" -- which is
a poor property for an interface whose whole promise is that a driver can crash
and come back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 18:11:00 +02:00
Nexoniarz
24f79194ab hybrid: /dev/pci -- a driver can live outside the kernel now
docs/plan/hybrid.md mechanisms 2 and 4: MMIO and DMA to a process.

A process can enumerate the bus, claim a device exclusively, map its BARs and
get memory the device can be pointed at. Proven end to end against an e1000e
-- the exact device class we cannot drive and would want a foreign driver for:

    device 7: 8086:10d3 (network)
      claimed
      BAR0 at 0x81060000, 128 KB
      regs[0..3] = 0x00140261 0x00000000 0x00080283 0x00000000
      MMIO reaches the device
      DMA 8 KB at virtual 0x40001000, physical 0x3f8eb000

Those are the e1000e's CTRL and STATUS with full-duplex and link-up set, which
is how you tell a real mapping from one reading 0xFFFFFFFF off an unmapped
address. Claiming a device the kernel drives is refused -- two writers of one
register set looks like hardware that has started lying -- and everything a
process held is released when it exits, because a driver that crashed must not
keep its hardware away from its replacement.

mmap's offset is the BAR's physical address, checked against the BARs of
devices *this process has claimed*. Without that check the device is /dev/mem
with extra steps and a process could map the kernel's own memory. Mappings are
write-through: a cached write to a doorbell arrives whenever the cache feels
like it, which from a driver's side is indistinguishable from hardware that
ignores you.

The interface is about PCI devices, not about any driver model, and its first
users will be SchOS's own drivers when virtio-gpu is evicted. That ordering is
the design rather than an accident: an interface whose first consumers are
native drivers is a general mechanism something foreign can use later, instead
of a hole shaped like something foreign.

Also: PROT_* and MAP_* moved to include/posix.h. They were defined only in
sys_mem.c, so cmd/fbinfo carried a private copy and this wanted a third --
two copies of a number the kernel compares against, where a mismatch shows up
as a mapping with the wrong permissions rather than an error.

No IOMMU, so this isolates a driver against its own bugs and not against its
own DMA. The plan says so at length and it is still true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 15:34:28 +02:00
Nexoniarz
7d44eb75a3 console: put the kernel log on screen from the first line
Everything the kernel printed went to the serial port and nowhere else until
the first program started and called console_takeover(). On a machine with a
UART that is fine. On one without -- a laptop, a Chromebox, anything built
this century -- the screen showed the loader's progress bar and then nothing
at all, for the whole of boot, on exactly the machines where something was
going wrong.

A report of "it shows Loading SchOS and freezes" cannot be acted on. The same
boot with a log on it says which line it stopped after, and that is usually
the whole diagnosis.

The loader has already put a framebuffer on the screen and described it in the
boot block, so there was a perfectly good console sitting unused for the
entire early boot. kmain attaches it on its first line now.

Not gated on the F8 verbose menu, which was the first instinct and is wrong:
F8 is no use to someone whose machine resets before they can read anything.
This is what Linux does too -- the log is the default and `quiet splash` is
the thing you ask for. The splash survives until the kernel's first message,
and a boot that succeeds clears the lot when the shell starts, so the only
people who ever see this are the people who need to.

vt_init is idempotent now, because the early attach brings the terminals up
before main() reaches its usual call and the second one must not wipe six
screens of boot messages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 14:59:14 +02:00
Nexoniarz
f9e68779ee ide: idewait spun for a minute on any machine with no legacy drive
The bound was 20,000,000 iterations, on the reasoning that a real drive
answers in microseconds so only an empty channel could reach the end. True,
and it misses what an iteration costs: every read here is a port access, and
under a hypervisor a port access is a VM exit at a microsecond or two.

Twenty million of those is a minute of the machine apparently doing nothing,
on every boot, on any machine with nothing on the primary IDE channel -- which
is every machine built in the last decade, where storage is AHCI or NVMe and
the legacy ports answer nobody. Booting the ISO looked like a hang for exactly
that reason: the line after "time:" took longer than anyone waits.

Bounded by time now, one second: four orders of magnitude more than a drive
needs, and short enough that an absent one costs nothing. "ide: no drive on
the primary channel" prints immediately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 14:43:35 +02:00
Nexoniarz
0def474272 init: a hang is as detectable as a death, and PID 1 comes back
docs/plan/init.md milestones I2 and I3. The rule change.

**I2 -- the heartbeat.** The failure the two-init scheme could not see is that
an init usually does not die, it *hangs*: wait() never returns for a process
that is merely stuck, so a supervisor watching a wedged init reports
everything normal for ever. PID 1 now pings every two seconds and expects an
answer within ten; silence means stuck, and stuck is worth acting on.

Exercised rather than reasoned about, as the plan asks: `sysrq w` wedges
schinitd by making the scheduler pass over it for ever. It stays alive, keeps
its descriptors and appears in every listing -- which is the point.

Two things that only showed up by running it:

  - kill() sets a flag the target checks when it next runs, and the premise
    here is that it is not running. Waiting for a corpse that may never be
    produced turns the recovery path into a second hang. PID 1 replaces the
    supervisor *without waiting*; the old one's socket is closed, so the moment
    it runs again its first write fails and it exits.
  - the replaced supervisor must notice the hangup. poll() reports POLLHUP and
    never POLLIN again, so a loop that only looks for POLLIN sits there
    complaining that PID 1 has stopped pinging it.

**I3 -- proc_reincarnate().** exit() on initproc panicked. Now the kernel
tears down nothing, keeps pid 1, and re-execs /bin/schinit in place with a
generation counter. The pid stays 1, so every orphan the kernel reparented is
still correctly parented; the children survive; the open files survive, which
is why this is the first line of exit() and not the last; and the job table
survives in /proc/schinit, which is what it is there for.

The escalation ladder: generations 2-3 carry on, generation 4 ignores
/etc/schinit and starts a console shell only -- a manifest that kills init
must not kill it for ever -- and generation 5 stops, with PSOGM rendering
INIT_RESURRECTION_FAILED, a call chain and a QR code.

Three bugs found by running it, each a lesson about exec():

  - exec() returns 0 on success; it does not fail to return. The new image
    starts when the kernel returns to userspace through the rewritten
    trapframe. Returning from exit() is the success path.
  - exit() was reached because `killed` was set, and the trap path checks it
    again on the way out. Leaving it set exits the new image before its first
    instruction: five generations in a few microseconds, a resurrection loop
    that looks exactly like the crash it was meant to survive.
  - exec() keeps open files, which is what gives the new image a console --
    and also keeps the previous life's socket, holding the old supervisor's
    channel up so it never sees a hangup.

Verified: `sysrq i`, and the shell being typed into keeps its pid across the
death and rebirth of PID 1, with /proc/schinit byte-identical either side.

schfs-check passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 14:27:22 +02:00
Nexoniarz
d53602faf3 gfx: stop redrawing what already looks right
The console got slower the day TIOCGWINSZ started telling the truth.

`ESC[J` -- erase to end of screen -- arrives after every shell prompt, and zsh
redraws the prompt on every keystroke. While the terminal claimed to be 80x24
that erased 1920 cells; once it reported the real 160x50 it erased 8000, and
every one was written to the framebuffer whether or not it already looked
exactly like that.

Measured first, which killed the obvious theory: hoisting the bounds checks
out of gfx_draw_pixel changed a full-screen clear by 1%. The cost is not the
per-pixel function call, it is the framebuffer -- about 128 MB/s here, so four
megabytes is 32 ms of writing, and on a real window with QEMU rendering each
frame it is worse than that.

So write less rather than write faster. The grid already knows what is on
screen: erase_run compares before it draws and skips any row span already
blank in exactly those colours, and vt_clear does the same for the whole
screen.

    100x erase-to-end-of-screen, before   0.101s
    100x erase-to-end-of-screen, after    0.017s

Six times, with -display none where the framebuffer is ordinary RAM. This is
what the backing store is for: comparing is free, writing is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 13:57:26 +02:00
Nexoniarz
bbc512a51c time: read the clock from the TSC, not from counting interrupts
A timer interrupt that arrives while interrupts are disabled is not delayed,
it is lost -- the LAPIC in periodic mode holds one pending interrupt and drops
the rest. So anything the kernel does with interrupts off for longer than
10 ms makes `ticks` run slow, and it never catches up.

That has now presented twice as "uptime is wrong": once as an interrupt storm
taking 83% of the CPU, once as heavy console output on a display that is
expensive to draw to. Both times the tick was the messenger, and both times it
looked like a timekeeping bug.

monotonic() reads the TSC instead. It is a counter in the silicon that
advances whether or not this kernel is paying attention, it is already
measured against the PIT at boot, and reading it is one instruction and no
lock. wallclock(), /proc/uptime, sysinfo, getrusage and times() all go through
it now.

`ticks` keeps the job it is good at -- the scheduler preempts on it, sleep()
counts it -- where losing one means a slightly longer sleep rather than a
wrong answer about what time it is.

Lost ticks are no longer harmful, which makes them invisible, which is worse.
So /proc/interrupts now says

    timer: 3383 ticks taken, 3387 expected

and names the gap when it grows, because a machine dropping timer interrupts
is spending too long with interrupts disabled, and that is worth knowing
before it turns into a clock that lies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 13:57:26 +02:00
Nexoniarz
65906639da tty: AltGr+F1..F6 by default, and AltGr+-/+ to step
The sweet spot was available the whole time and I ruled it out by writing
"AltGr+anything" when the true statement is "AltGr+*letter*".

A Polish layout wants AltGr with a letter -- ą ć ę ł ń ó ś ź ż. AltGr with a
function key produces nothing on any layout, no window manager binds it, and
no application asks for it. It is the one combination the host, applications
and the keyboard layout all leave alone, and the previous three commits talked
themselves out of it on a technicality that was mine, not the hardware's.

Verified rather than argued, with QEMU sendkey and screendump hashes:

    AltGr+F2   -> tty2
    AltGr+F1   -> back, pixel-identical to the start
    AltGr+a    -> reaches zsh, so ą still types

That distinction is only expressible because Right Alt became its own modifier
bit two commits ago. Sharing ALT is harmless for typing -- neither is used to
look up a character -- and made this impossible to say.

Also added, since it was asked for in the same breath: minus and equals step
to the previous and next terminal, on whatever the chord's modifiers are. They
skip terminals that are not in use -- the same `opened` count VT_GETSTATE
reports -- because six terminals with two shells on them means four blank
screens in between, and cycling past those is worse than not having the key.

    AltGr+=  -> tty2, again -> wraps to tty1   (only two in use)
    AltGr+-  -> tty2

chvt -c still moves all of it, and `chvt` reports both halves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 13:22:01 +02:00
Nexoniarz
7c0f7c7d01 tty: the switch chord is a setting, because only the host knows the answer
Third guess at a chord, and the third one would have been wrong too: a display
manager that grabs Ctrl+Alt+F<n> matches loosely, so Ctrl+Alt+Shift+F2 never
reaches the guest either. Extra modifiers do not get past a loose grab.

The real problem is that this is not knowable from inside. A key the host
swallowed is indistinguishable from a key nobody pressed, so the guest cannot
probe for a free combination, and there is no combination that is free
everywhere:

  the HOST takes    Alt+F4, Alt+F2, Ctrl+Alt+F1..F6 (loosely matched),
                    Ctrl+Alt+digit (QEMU's own console tabs)
  APPLICATIONS want Alt+anything (Meta -- zsh binds Meta-digit as
                    digit-argument), Ctrl+letter, Ctrl+Shift+digit
                    (Ctrl+Shift+2 types ^@), bare F-keys
  the LAYOUT wants  AltGr+letter

So it is a setting. VT_SETCHORD/VT_GETCHORD, and in userspace:

    chvt -c ctrl+shift+F       Ctrl+Shift+F1..F6   (the new default)
    chvt -c ctrl+alt+shift+F
    chvt -c alt+1
    chvt -c off                no chord; chvt N is then the only way
    chvt                       report the active terminal and the chord

`off` is a legitimate setting rather than a failure mode. On a machine whose
host grabs everything worth binding, `chvt N` is the honest answer.

The default moved to Ctrl+Shift+F1..F6: display managers bind Ctrl+Alt+F<n>,
not Ctrl+Shift+F<n>, and Ctrl+Shift with a *function* key is not a character
on any layout -- unlike Ctrl+Shift+digit, which is.

Caught while writing it: VTC_RALT was 0x08 while kbd.h's RALT is (1<<7), since
(1<<3) is CAPSLOCK. A chord set from userspace with AltGr would simply never
have matched, and the only symptom would have been a key that does nothing.
A _Static_assert now holds the two headers together.

Verified: Ctrl+Alt+F2 leaves the screen byte-identical, Ctrl+Shift+F2 switches,
Ctrl+Shift+F1 returns to a pixel-identical tty1, and chvt -c round-trips
through alt+1 and off and back. schfs-check passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 13:13:38 +02:00
Nexoniarz
fe2f5e1bf8 tty: Ctrl+Alt+Shift+F1..F6, and VT_SETMODE actually works
Alt+1..6 was a worse choice than the Alt+F-keys it replaced, and the reason
is the one that matters: anything the kernel swallows is taken from every
program that will ever run here, permanently.

Alt+<digit> is Meta-digit. zsh's own line editor binds it as digit-argument --
Alt+1 Alt+2 x repeats x twelve times -- and every readline editor does the
same. Yesterday's chord broke that system-wide, which is exactly the failure
mode of picking a chord applications want.

Working the whole space instead of one axis of it:

  the HOST takes    Alt+F4, Alt+F2, Ctrl+Alt+F1..F6
  APPLICATIONS want Alt+anything (Meta), Ctrl+letter, Ctrl+Shift+digit
                    (Ctrl+Shift+2 types ^@), bare F-keys
  the LAYOUT wants  AltGr+letter -- Polish diacritics

Ctrl+Alt+Shift+F1..F6 is in none of those sets. Display managers bind the same
chord without Shift, so adding it steps out of the host's way, and no terminal
application binds a triple-modified function key.

Which chords actually reach a guest was measured rather than assumed: a
temporary probe in kbdgetc logged every modified keypress and QEMU's sendkey
fired each candidate at it. Everything reached the guest, Ctrl+Alt+F2
included -- so that conflict is the host's display manager, not QEMU. The same
probe corrected an earlier claim: `sendkey altgr-2` produces no event at all,
so the previous commit's "AltGr+2 did not fire" was never actually tested.

And the escape hatch is real now. VT_SETMODE was accepted and ignored, so a
program could ask to own the chord, be told yes, and still not get it.
VT_PROCESS genuinely stops the kernel switching. Together with the EVIOCGRAB
path -- which kbdgetc already checks before the chord is recognised -- there
are three honest layers: nothing, VT_PROCESS, exclusive grab.

What VT_PROCESS still does not do is the VT_RELDISP handshake; struct vt_mode
carries relsig/acqsig for when Schcom needs to release the GPU before the
screen changes hands.

Verified by screendump hash: Alt+2 reaches zsh as Meta, Ctrl+Alt+F2 and
Ctrl+Shift+F2 leave the screen untouched, Ctrl+Alt+Shift+F2 switches.
schfs-check passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 13:05:43 +02:00
Nexoniarz
91a6d440d6 tty: Alt+1..6 switches terminals, and AltGr is left alone
Alt+F1..F6 was wrong in three separate ways, and the third is the one that
matters most here.

  Alt+F4            the host window manager closes the QEMU window
  Alt+F2            GNOME and KDE open a run dialog
  Ctrl+Alt+F1..F6   the host switches its *own* virtual terminals
  AltGr+anything    Polish diacritics -- a c e l n o s x z with their marks --
                    and on some setups AltGr is delivered as Ctrl+Alt
  Ctrl+Shift+key    widely bound inside applications
  Scroll Lock       not present on many keyboards

Alt+1..6 survives all of it: the number row, so no function key is involved,
and left Alt, so AltGr is untouched.

Right Alt is tracked as its own modifier bit now. It used to share ALT, which
is harmless for typing -- neither is used to look up a character -- and wrong
for chords, because it made AltGr indistinguishable from Alt.

And the test is an equality rather than a mask:

    if ((shift & (CTL | SHIFT | ALT | RALT)) == ALT && ...)

`shift & ALT` fired on Ctrl+Alt+1 and AltGr+1 as well, so a chord aimed at the
host -- or an ordinary Polish keystroke -- would have switched terminals
behind your back. That was a real bug in what shipped yesterday.

Verified with QEMU's sendkey, comparing screendump hashes:

    start (tty1)   86cee2284878
    Ctrl+Alt+2     86cee2284878   identical -- did not fire
    AltGr+2        86cee2284878   identical -- did not fire
    Alt+2          114562804a78   switched
    Alt+1          86cee2284878   back, pixel-identical to the start

schfs-check passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 12:56:34 +02:00
Nexoniarz
a5b0d5a0ef tty: the grid was 80x24 on a 160x50 screen, and wrapping was eager
Three symptoms, two causes, both in the terminal layer.

**The grid and TIOCGWINSZ disagreed about the size.** vt_init() sizes every
grid from gfx_cols()/gfx_rows(), and it runs at main.c:75 -- while the display
driver attaches its scanout from the PCI bus walk at main.c:99. So the grids
were the 80x24 fallback, and TIOCGWINSZ, which computed its own answer live,
said 160x50.

That one disagreement produced all three things visible in the report: text
stopped two thirds of the way down the screen, `ls` laid out 160 columns into
an 80-column grid so words split across the wrap ("vers" / "ion"), and zsh's
cursor tracking was wrong on every line.

Fixed in two places, because either alone would leave the trap set:
gfx_attach_scanout() now calls vt_resize(), so the grids learn the real screen
the moment there is one; and TIOCGWINSZ reports v->cols/v->rows rather than
measuring again. One number, from the terminal being asked.

**Wrapping was eager.** A glyph printed in the last column moved the cursor to
the next line immediately. Real terminals set a pending-wrap flag and wrap when
the *next* glyph arrives -- which is why a line exactly as wide as the screen
takes one row and not two, and why a program can write the bottom-right cell
without scrolling.

zsh's partial-line marker depends on that rule directly: it writes exactly
COLUMNS-1 spaces and expects to still be on the line. With eager wrap it left a
reverse-video '#' above every prompt, which is the hashtag in the report. Both
are gone.

Verified by screendump: `ls /proc` now fits one line with "version" intact, no
markers, and output fills the screen to the last row. schfs-check passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 12:49:13 +02:00
Nexoniarz
1c1b0c9570 tty: six virtual terminals, and the plumbing that makes them terminals
/dev/tty1..tty6, Alt+F1..F6 or `chvt N`. Each is an independent terminal --
its own screen, cursor, line discipline, foreground process group and
session. Two shells on two terminals are unrelated processes that share a
keyboard and a screen.

What made it possible is a backing store. The console drew straight into the
framebuffer and scrolled by moving pixels, which works exactly as long as
nothing else wants the screen: there was nowhere to put terminal 2 while
terminal 1 was visible, and nothing to repaint terminal 1 from. Each terminal
owns a grid of cells now and the framebuffer shows whichever grid is active,
so vt_switch() is set-index, repaint, return -- nothing is saved on the way
out because the outgoing grid has been current all along.

The rule the layer is built on: a terminal does not know whether it is
visible. Writes land in the grid, the cursor moves, the grid scrolls, all
unconditionally; drawing is one question asked at the bottom of vt.c. Every
alternative spreads "am I on screen" through the escape parser, and then a
terminal switched away from mid-sequence comes back wrong.

Everything that was a single global is per-terminal now: tty_fg_pgrp (deleted),
termios, the input ring, the escape parser, the UTF-8 decoder, and the cursor
-- which also stops being pixels in gfx.c and becomes cells in the terminal.
TIOCSCTTY was `return 0` and now records proc->ctty, which is what /dev/tty
resolves through; /dev/tty was an alias for /dev/console with the same minor,
and they are different questions.

kbd.c names Alt+F1..F6 as KEY_ALT_F1+n, above 0xFF on purpose -- every byte
value is already a character or a UTF-8 continuation, and a chord colliding
with one would be typed into the terminal instead of switching away from it.
What the chord means is decided in console.c, where the terminals are.

VT_ACTIVATE / VT_WAITACTIVE / VT_GETSTATE / VT_OPENQRY, Linux's numbers.
The argument to ACTIVATE is by value, not a pointer -- same shape as TCFLSH.

Two bugs found by screenshotting the result rather than reading the log:

  - zsh emits 0x0F to leave alt-charset mode, because that is what the `linux`
    terminfo says rmacs is, and the console drew CP437 glyph 0x0F: a sun, in
    the prompt, on every line. A terminal never draws a C0 control character.
  - SGR 7 was unimplemented, so zsh's partial-line marker rendered as a bare
    '#' instead of a reversed block. Reverse video swaps the colours at draw
    time, not at store time, so turning it off restores what was underneath.

Verified by screendump: tty1 and tty2 show different screens, and switching
back repaints tty1's earlier content from its grid. schfs-check passes.

docs/architecture/terminals.md, including what it does not do -- no
scrollback, no reflow, no VT_PROCESS mode for a display server to veto a
switch, which Schcom will need before it owns Alt+F7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 00:08:00 +02:00
Nexoniarz
9d4cdb44fc kernel: the clock ran 7x slow because the GPU never acknowledged its interrupt
fastfetch reported 3 seconds of uptime after 15 real ones, `time` claimed
3.12s for a 20-second command, and ping's one-second interval was closer to
six. One cause, not three: `ticks` was advancing at ~14.5 Hz instead of 100.

It was not the timer. virtio-gpu-pci shares IRQ 11 with the e1000 and
registered no interrupt handler at all. PCI INTx is level-triggered, so the
device held the line asserted, the kernel signalled EOI, and it fired again
immediately -- 72,000 interrupts a second, 83% of CPU0, starving the tick.
The e1000's handler read ICR as zero half a million times, correctly
answering "not mine", which is what made it look like a network-free problem.

Reading virtio's ISR byte clears it and drops the line. That is the fix.

irq_dispatch now measures each line's rate against the TSC -- not ticks,
which is the first casualty of a storm -- and names the attached handlers
once above 20000/s. It reproduces the original fault as a single line.

Found along the way, and fixed because they were real:

  - lapicinit loaded TICR with xv6's uncalibrated 10000000, whose own comment
    admitted it was never measured. calibrate_timers() now measures the LAPIC
    and the TSC against the 8254 PIT. (It came back at 999.9 MHz, so this was
    not the bug -- but the next machine is not obliged to be 1 GHz.)
  - microdelay() was an empty function body. Every microdelay(1000) in the
    xHCI reset path and the USB hub power-on sequence returned instantly.
  - HZ is a constant in param.h now, not the literal 100 written out at a
    dozen ticks-to-seconds conversions. USER_HZ is separate and deliberately
    does not follow it: musl answers sysconf(_SC_CLK_TCK) with 100 always, so
    times() and /proc/stat scale at the syscall boundary the way Linux does.
  - TIOCGWINSZ answered a hardcoded 24x80. gfx_cols()/gfx_rows() have known
    the real geometry all along; at 1024x768 the console is 128x48, so every
    program that lays out a screen was formatting for the wrong one.
  - TCFLSH fell through to the catch-all `return 0`, claiming to discard
    input without discarding any.

uptime now advances 5.01 seconds per 5 real seconds. schfs-check passes.

Full investigation, including the two wrong hypotheses and the measurement
flaw that protected them, in docs/history/kernel.md section 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 21:34:36 +02:00
Nexoniarz
fc0a7c6f4e fs: timestamps come from the clock, and /proc/fsstat says how full the tables are
Everything created after boot was stamped 1970. know() in SchFS and now_sec() in
the v1 filesystem both returned uptime(), under comments saying the kernel had no
wall clock -- which stopped being true when time_init() started reading the RTC.
The comments outlived the condition they described.

Two errors, and the second is the less obvious: uptime() counts 10 ms *ticks* and
they were being used as *seconds*, so a node created 0.6 s into boot read back as
00:01:00 and the clocks ran a hundred times too fast. Both now go through
wallclock(), which exists precisely so nothing can disagree with gettimeofday
about the time. Confirmed against the RTC line from the same boot.

The occupancy counters are the instrumentation docs/plan/roadmap.md asked for
while hunting the ls -lR hang: inode, file and pinned-buffer counts with a
high-water mark each, maintained at the 0->1 and 1->0 transitions rather than by
scanning, so they cost one increment on a path that already holds the table's
lock.

The peaks are the diagnostic. A live count only describes the moment you looked,
and a reference leak is invisible in it right up until the table is full -- at
which point iget() panics and filealloc() returns 0, which is where the trail
used to go cold.

What they measured: across ten iterations of ls -lR with writes interleaved, 33
execs and 13378 tree descents, inode_peak stayed at 9/200, file_peak at 9/512 and
buf_pinned_peak at 1/256, with the SchFS bracket unlocked throughout. That
eliminates the roadmap's own hypothesis -- "a lock or reference-count leak
somewhere in the *at() paths" -- and the hang did not reproduce. It is very
likely fixed in passing by the *at() dirfd rewrite, the getdents cookie rewrite
or SchFS becoming the root, all of which landed after it was reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-12 17:14:14 +02:00
Nexoniarz
817d666068 gfx: the console and the crash screen stop drawing on a driver by name
Same disease as the network stack, one subsystem over. kernel/dev/vga.c
was three things wearing one name: the owner of the framebuffer, a
bitmap drawing library, and the text console. Only the first is a
driver, and because they shared a file they shared a prefix -- so
panic.c drew the crash screen with vga_fill_rect, console.c printed with
vga_putc, /dev/fb0 handed out vga_fb_phys, and every one of those files
named a display driver it had no business knowing about.

Split three ways:

  - kernel/drivers/gpu/vga/vga_fb.c owns the VBE framebuffer -- the mode
    the loader programmed and handed over, the aperture, and the display
    class vtable. It is a driver and now lives with the driver that
    finds the card on the bus.
  - kernel/dev/gfx.c draws: pixels, shapes, glyphs, the cursor, scroll,
    the ANSI erase operations. It names no driver. The framebuffer
    arrives through gfx_attach_scanout(), so a driver publishes what it
    owns and this file draws on it without knowing what it is.
  - include/gfx.h is the interface, and says why it is deliberately not
    the display class: that one is about frames -- acquire, present,
    vblank -- and the crash screen has to work when nothing else does.

main() no longer calls a display driver. platform_register_devices()
takes the framebuffer address and brings up whatever owns it, which is
where driver names belong; kernel/drivers/registry.c is the file whose
entire job is naming every driver in the kernel.

virtio-gpu got the e1000 treatment as well: instances in an array, the
instance hung on dev->priv by an `attached` hook, and every op reading
it rather than a file-static. It refused the second adapter outright
before, which is not hypothetical -- integrated plus discrete is an
ordinary laptop. The VGA driver deliberately has no such hook, and says
so: there is one framebuffer the loader programmed however many
VGA-class controllers the bus turns up, and a second one publishing the
same scanout is the truth rather than a bug.

Private headers moved next to their drivers: include/virtio.h and
include/xhci.h each had exactly one includer. include/ is for headers
the rest of the kernel needs.

Deleted rather than moved: consoleinit() programmed the sixteen ANSI
colours into the VGA DAC and disabled text-mode blink, by writing ports
0x3C0-0x3C9 from the console. The comment three lines above the colour
table already said this does nothing in a 32bpp linear mode -- there is
no palette to index -- so it was both dead and in the wrong file.

Verified, and this is a refactor so the evidence is that nothing moved:
  - bootcheck -Fb -Net: 59 [ok], no failures. That covers the console
    through every command, /dev/fb0's geometry, writing pixels through
    write() and mapping the framebuffer through mmap.
  - make crashshot: the crash screen renders exactly as before --
    shapes, glyphs, logo and QR code -- which is the whole of gfx.c
    exercised at once, in a panic, with the scanout attached by a driver.
  - make schcom-test: five pixel assertions from the host oracle still
    pass, so the display class and the compositor above it are unmoved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:04:10 +02:00
Nexoniarz
20cc673a41 net: a network *class*, so the stack stops knowing what an e1000 is
The TCP/IP stack reached straight into the first driver anyone wrote.
net.c called e1000_present(), the lwIP glue was named netif_e1000.c and
called e1000_transmit(), and include/defs.h declared the driver's
internals to the whole kernel. The second NIC driver would therefore
have arrived with a near-identical copy of the glue file and a patch to
the protocol stack, which is the arrangement include/device.h was
written to prevent -- the e1000 even said so, with `.ops = 0, // no
class vtable yet; there is no net class`.

There is now. A driver publishes a struct netdev_ops -- transmit,
receive, mac, link_up -- and is finished: it never mentions lwIP, and
nothing above it learns its name. The display class was already this
shape; this is the same shape for networking, and the two now read the
same way in kernel/dev/device.c.

What that made possible immediately:

  - One netif per bound device instead of one per driver. lwIP's glue is
    netif_eth.c, it takes a struct device, and it holds up to NETIF_MAX
    interfaces. net.c attaches every network device it finds and starts
    DHCP on each, because which interface the network answers on is not
    ours to decide.
  - /proc/netif lists all of them, one block of fields each, with the
    interface name and the driver that claimed it.
  - The ARP self-test moved from the e1000 to the class. Nothing in it
    was chip-specific, and it is the first thing anyone will want to run
    against the next driver. Still `echo n > /proc/sysrq`.
  - Private headers live with their driver: e1000.h was in include/,
    where exactly one file could use it. The driver build now adds the
    driver's own directory to the include path.

The e1000 also drives more than one card. Its probe() refused the second
outright, because there was a single static instance -- so a machine
with two of them drove one and silently ignored the other. Instances are
an array now, and the instance is hung on dev->priv by a new optional
`attached` hook that the bus calls once the device exists. probe() runs
before device_add(), so there was previously nowhere to put it; that is
the whole reason the hook exists rather than a line inside probe.

QEMU gets an explicit NIC on every target (QEMU_NET). The default device
happens to be an e1000 on this machine type today, which is exactly the
sort of thing that stays true until a QEMU release changes it and the
network stops being exercised without anyone noticing.

Verified:
  - bootcheck -Net passes, 48 [ok], no failures -- including the moved
    self-test and two new checks that /proc/netif names both the
    interface and the driver behind it, which together say the stack
    went through the class rather than through a driver it was compiled
    to know.
  - Two NICs attached by hand (-device e1000 twice, second with its own
    MAC): eth0 and eth1 both attach, each reports its own MAC in
    /proc/netif, and each gets its own DHCP lease. Before this commit
    the same machine produced one interface.
  - make schfs-check still passes in full.

One trap worth recording: moving a header broke the build for anyone
with stale .d files -- make stopped with "No rule to make target
'include/e1000.h'". CFLAGS gains -MP, which emits a phony target per
header so a moved or deleted one can never wedge the build again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 16:42:34 +02:00
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
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
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
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
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