- /dev/ptmx and /dev/pts/N: Unix98 pairs with a full line discipline,
window sizes, job control and hangup (kernel/dev/pty.c).
- The console speaks scroll regions, insert/delete line, SU/SD, RI/IND/NEL,
DECSC/DECRC; swallows charset designations and private CSI sequences;
and LF only returns the carriage under ONLCR.
- setsid() drops the controlling terminal, so a pane's pty becomes its ctty.
- pipe2() honours O_NONBLOCK and O_CLOEXEC (libevent's signal pipe).
- File modes are stored and enforced: chmod, fchmod(at), umask, creation
modes; /tmp is 1777.
- /proc/stat has real user/system/idle; /proc/<pid>/task; kthread flag.
- SO_PEERCRED, IOV_MAX 1024.
- /proc/strace: name a program and its syscalls go to the kernel log.
- World gains htop, btop, tmux, mc, ncdu, fastfetch, games, X toys, NetSurf.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
busybox wget hands TLS to ssl_client over a socketpair and then does
shutdown(SHUT_WR). That cut the AF_UNIX link both ways, so ssl_client's
reply was a SIGPIPE and every https:// download hung. SHUT_WR is now a
half-close: the peer reads EOF and can still answer. TCP sockets get
shutdown() at all (tcp_shutdown for the FIN); it used to do nothing.
- exec keeps ignored signals and the signal mask, as execve(2) says.
- /proc/<pid>/fd: a link per descriptor -- the path it was opened by,
socket:[n], pipe:[n], anon_inode:[eventpoll]. /proc/self too. The
global inode bit moves above pid << 8; at 0x10000 it was /proc/256.
- fstat on a socket or pipe answers S_IFSOCK / S_IFIFO instead of EINVAL.
- poll, select and epoll wake as soon as a pipe, socket or input device
changes, not on the next tick.
- Pipes are 64 KB of lazily allocated pages, copied in blocks, with
PIPE_BUF atomicity: 300 MB through `yes | head | wc` in 7 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
alarm() and setitimer() stored a countdown nothing counted down, so no
timer has ever fired; setitimer and getitimer were also registered with
no Linux number. alarmd now posts SIGALRM on the tick, while any process
has a timer armed. busybox's ping, /bin/ping on the Alpine root, sends
every request after the first from its SIGALRM handler.
It also opens SOCK_RAW, which was EPROTOTYPE. A raw ICMP socket is now a
ping socket whose reads start at the IP header. And a blocked socket
call gives up for a signal with a handler, not only for a kill: the
alarm woke recvfrom, and the wait put it straight back to sleep.
For Xorg: AF_UNIX abstract names, which read as the empty path and so as
EADDRINUSE ("server already running"); and getsockname and getpeername,
which did not exist, so libxcb sent no cookie and the server refused
every client.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
Measured against the host fetching the same 67 MB from Alpine's CDN at
11.5 MB/s, SchOS managed about 120 KB/s. Three causes:
- The receive window reopened when data entered the socket buffer,
before the reader drained it; lwIP then refused segments and the
sender waited on retransmission timers. It reopens on read now.
- The e1000 receive ring held 16 frames, so a full window arriving back
to back lost everything past the sixteenth. 256 now.
- Without window scaling the window stops at 64 KB, one window per
round trip. Scaling on, 256 KB window, 512 KB socket ring (a power of
two, which the free-running indices need), copied in blocks.
Now 5.8 MB/s for the same file, and apk update in about two seconds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
Sixty-five headers sat side by side in include/, so `#include "irq.h"`
said nothing about where irq.h was or what it belonged to, and errno.h,
stat.h and fcntl.h at the top shadowed the system's for any host tool
built with -I./include. They are in folders now -- arch/ core/ fs/
dev/ gfx/ net/ uapi/ (and ldls/ for the Linux driver layer) -- and
every include names the folder, as Linux's do: "core/proc.h".
Done mechanically and checked the hard way: the kernel built from this
tree disassembles identically to the one built from the tree before
it. The full image boots, and userland is the same set of programs.
Paths in docs and comments follow; docs/guides/layout.md has a table
of what is where, and tools/fix-includes.py rewrites old-style
includes from the folders in the tree, for code written before this.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
Nexoniarz is the sole author and SchOS was never published under
Apache-2.0, so this is a straight relicence. LICENSE is the FSF GPLv2 text,
every first-party SPDX header moves to GPL-2.0-or-later, and NOTICE,
README.md, docs/licensing.md and the site say so. projects/schfs-linux
follows the rest of the tree.
The GPL gives copyleft: a fork has to ship its source. It also lifts the
old "Linux drivers, never" rule, since GPL-2.0-only code combines with
GPL-2.0-or-later. "Or later" leaves room for GPLv3, but one binary still
has to pick one version, so docs/licensing.md now keeps Apache-2.0 and
GPLv3 code out of the kernel to keep Linux code possible there. BSD
4-clause code goes from "avoid" to "no", because the advertising clause
is GPL-incompatible.
LDLS still ships no .ko. That boundary is now project policy rather than
a legal requirement, and ldls.md, devices.md and licensing.md section 6
say so. refmod declares license=GPL, and ldls-check passes (27 checks).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Gate 1, item 3, and the last of foundations.md.
bind() creates a T_SOCK node at the path; connect() resolves the path and looks
for the socket bound to **that inode**. Matching on the inode rather than on
the path *string* is the part that matters: the name used to live only in the
socket table, so two spellings of one path missed each other, a relative path
never matched an absolute one, and there was nothing to stat() or unlink().
An inode is the thing a path resolves *to*, so connect() can walk the path
however it likes and arrive at the same socket.
# ls -l /tmp
srwxr-xr-x 1 root root 0 2026-09-22 08:04 socktest
The node is a marker, not the binding. It holds no data and the live
association stays in the socket table, so a socket file left by a crash is
what Linux leaves behind -- a dead file that connect() refuses until something
binds again -- and unlink()ing a live socket's path does not disturb anyone
already connected.
EADDRINUSE comes from the *directory* rather than from a scan of the table,
deliberately: a stale socket file has to refuse a rebind for the same reason a
running server's does, or two servers both believe they own it. The creation
lives in sys_bind() and not in unix.c, because that is the layer with both --
kernel/net/unix.c knows about sockets and has no business calling create().
T_SOCK is a compat addition to SchFS by the rule in schfs.h: a driver that does
not know SCHFS_T_SOCK maps it to a zero-length regular file, so nothing goes
stale and nothing becomes unreadable.
cmd/socktest section 15 asserts the plan's own wording -- bind, stat, see an
`s`, unlink -- plus the two a name table could never fail: a second bind to a
live path is refused, and connecting to a path that is a *regular file* is
refused rather than matched by name.
**And the semantics being real broke socktest on its second run**, correctly:
the socket files persisted and every bind() hit EADDRINUSE. Every real server
unlinks before binding; socktest now does too, which is the change the new
behaviour demands rather than a workaround for it.
Chasing that turned up something worse. `socktest` reported rc=1 with every
check passing, and so did every other first-party program:
- **lib/start.S called main and threw %eax away.** sys_exit() reads the
status from %rdi and nothing put it there, so every program exited with
whatever that register happened to hold. Every test suite ends in
`return failures ? 1 : 0` and none of it reached the shell.
- **exit(void) passed no status either**, for the same reason: the
declaration takes no argument, so %rdi was the caller's litter.
`if socktest; then` works now, and a failing command reports 1 -- checked both
ways, because a fix that makes everything return 0 would look identical from
the success side.
Also factored: unlink_path() out of sys_unlink, so a failed bind can take its
own node back out without going through the syscall entry, and create_sock()
as a named door rather than exporting create() with an arbitrary type.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
Ctrl-C on httpd took the whole machine down:
lwip assert: invalid socket state for recv callback at tcp.c:2024
#1 tcp_recv #2 inet_close #3 sockclose #4 fileclose #5 exit
tcp_listen() does not put a pcb into a listening state -- it frees the
connection pcb and returns a *different, smaller* structure, a
tcp_pcb_listen, which has no recv, sent, err or poll field at all. The two
are different types behind one `struct tcp_pcb *`, and the only callback a
listening one has is accept.
inet_close() cleared the callbacks identically for both, so on a listening
socket tcp_recv() wrote past the end of the allocation. lwIP asserts rather
than allowing that, and an assert here is a panic -- which means any program
that listened and then exited killed the kernel, because the close path runs
from exit(). A user process, ending normally, halting the system.
Nothing had ever done it: until cmd/httpd nothing in the tree listened on TCP
and exited. schdbgd would have found it a few days later.
inet_close() now branches on is->listening -- which was already there, one
field away, and the close path simply never asked. The listening case clears
accept and calls tcp_close (always ERR_OK for a LISTEN pcb, and tcp_abort
would be wrong on one: it reaches for connection state that does not exist).
The connected case also clears `sent`, which it had been leaving set.
Verified: Ctrl-C the listener, no panic, and a second httpd binds port 80
again -- which is the part that proves the pcb was actually freed. Six
requests after the change, all 200.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
Four complaints, and only one of them was the thing it looked like.
**"toybox is too small."** Half true, and the fix cost no licensing at all:
236 of toybox's applets are what defconfig builds, and the rest live in
toys/pending/ -- awk, bc, diff, more, expr, tr, hexdump, stty among them. Same
0BSD tarball, switched off by a config default. 26 of them are on now, 236 ->
268. Not everything: strace wants ptrace, syslogd wants BSD's facilitynames[],
git wants OpenSSL, and those three break the link.
The toybox build also works again on this machine. Its scripts start
#!/bin/bash, which does not exist on NixOS, so the binary in the tree was one
nobody could reproduce; the shebangs are rewritten to /usr/bin/env bash on the
way in. Pending applets are enabled by editing .config directly, because
`make oldconfig` is the obvious way and undoes it -- every pending symbol is
`default n`.
**BusyBox was asked for and is not here.** It is GPL-2.0-only, and `make
ramroot` links the root image *into the kernel binary*, so it would put GPLv2
inside an Apache-2.0 executable -- which is the exact case ports/Makefile's
NOTPORTS filter and docs/licensing.md §4 exist for. nano is the same answer one
version later. Both recorded in licensing.md with what was done instead, since
"no" without an alternative is how a rule gets quietly broken later.
**An editor that is not vi.** kilo, BSD-2-Clause, one 1300-line file, no
dependencies beyond termios and TIOCGWINSZ, both of which zsh already needed.
Installed as /bin/edit as well as /bin/kilo, because somebody with a file to
fix will type `edit`.
**"wget doesn't work."** Not a userland problem, and BusyBox's wget would have
failed identically: nothing on this system could turn a name into an address,
so every program taking a hostname failed in its own way and each looked like
a separate bug. There is still no DNS client in SchOS and there does not need
to be -- musl has a complete resolver and it works over ordinary UDP sockets.
The one missing fact was which nameserver, which the kernel has had all along
from DHCP and never published. /proc/resolv.conf publishes it in resolv.conf's
exact syntax; cmd/resolvconf decides whether it becomes /etc/resolv.conf, and
will not overwrite a hand-written one without -f.
# wget -O - http://example.com/ | head -1
<!doctype html><html lang="en"><head><title>Example Domain</title>
**Something to host with.** cmd/httpd: GET and HEAD, static files, index.html
for a directory and a listing otherwise, a child per connection. No CGI, no
keep-alive, no TLS, no config file. The path check is the only
security-relevant code in it and it refuses rather than normalises -- no
component may be `..`, and percent-decoding happens first, because %2e%2e is
`..` and a check before decoding does not see it. Both forms answer 403.
And the disk is 100 MB, from 32. It is sparse, so the image is 21 MB on the
host. RAMDISK_BLOCKS went 6000 -> 8000 with it: the ramdisk was at 86% and the
next thing added would have broken `make ramroot`, which nobody would have
noticed until they used it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
e1000 -- Linux's, unmodified -- probes, opens, gets an address by DHCP and
carries ping traffic in a SchOS process. The kernel was driving that NIC and
gave it up, which makes it the first real handover as well as the second
driver.
None of what it needed was a missing feature. Every one was a call that
succeeded, returned zero and did nothing:
_find_next_bit "bit 0 is set" -- for_each_cpu never ended
netif_carrier_off carrier already up -- TCTL.EN never set
__x86_indirect_thunk_r8 the jump discarded -- 255 rx buffers, none made
dma_map_page_attrs bus address 0 -- NIC aimed at physical 0
so the rule from memcpy is now general: a stub returning zero is unsafe
wherever zero is a meaningful answer.
The loader grew ko_fixup_percpu(). The stack guard turned out to be one
per-CPU variable among several; `current` cannot be defused because the driver
dereferences it. Taking the %gs off instead leaves the same opcode reading the
same width from storage the host supplies -- a per-CPU variable in a
single-threaded host is just a variable. Eight bytes become seven and a nop,
and the displacement is not always last, so there is now just enough of an x86
decoder in ko.c to know how long the instruction is.
ldls/kpi/thunk.c is fifteen lines of assembly and the highest-value file here:
retpolines are control flow, and a stub cannot stand in for a jump.
The kernel side:
- ioperm.c, the TSS I/O permission bitmap. A driver's outl is an instruction,
not a call, and e1000's reset path picks it deliberately. IOPL=3 would hand
over the PIC and the disk with it, so it is per-port. Writing it found that
the I/O map base had been written at offset 64 rather than 102, leaving the
CPU checking ports 0-831 against the TSS's own RSP0.
- claim masks the device's IRQ line: 450,000 a second, otherwise.
- net_detach(), the missing half of net_attach_late(). An interface left in
the stack keeps its route and sends every frame to a driver that refuses
it, which looks exactly like a userspace driver that does not work.
- e1000_release() gives up the interface, not just the hardware.
8139too still passes 4/4; ldls-check still passes 27; four CPUs, no panic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
docs/plan/ldls.md 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
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
sendmsg/recvmsg with SCM_RIGHTS. Item 1 of docs/plan/foundations.md and
mechanism 1 of docs/plan/hybrid.md, which are the same primitive seen from two
directions: Wayland's wl_shm_create_pool passes a file descriptor with no
integer-shaped alternative, and handing a driver process the device it is
allowed to touch is the same act. Both plans stopped here.
Linux's structures byte for byte -- msghdr, cmsghdr, the CMSG_ macros -- because
the programs that need this are not ours. libwayland and toybox both call
recvmsg with Linux's headers and would not notice a friendlier door.
The delivery rule is the whole design. A batch of descriptors is pinned to the
stream position of the first byte of the message that carried it, and that has
two halves: a reader arriving at that position is handed them, and a reader that
has not reached it is stopped SHORT of it. Leave out the second half and a
program asking for 4096 bytes swallows three messages and is handed one
message's descriptors, with nothing in the result saying which. socktest checks
exactly that: two one-byte messages, an eight-byte read, one byte back.
The kernel holds a reference to every descriptor in flight, so a sender may
close its own copy the moment sendmsg returns. Giving that reference back is the
part that leaks if it is not written down, and there are three ways a message
ends:
- received with room installed in the receiver's table
- received without room, or by
plain read(2) closed; MSG_CTRUNC for the first case
- never received, socket closed closed by sockclose
The middle row looks like a choice and is not: holding kernel references for a
receiver that has proved it is not looking is a leak with extra steps.
No garbage collector. A socket descriptor sent over that same socket and never
received holds a reference to the socket that holds the reference to it, and
neither close reaches zero. Linux carries several hundred lines for this.
Nothing on either plan passes a socket over a socket -- Wayland passes memory,
the driver work passes devices -- so it is a bounded leak that is documented
rather than a silent one.
Fourteen checks in cmd/socktest, and every one moves data through the descriptor
rather than inspecting it. The descriptor passed is always a pipe end, because a
pipe announces its own lifetime: a read of a pipe whose writers are all gone
returns 0, which turns "was the kernel's reference released" -- otherwise
invisible from userspace -- into a value a test can compare.
Two bugs fell out, neither in the new code:
- sockpair() never set the socket's domain, so every socketpair() socket
claimed domain 0. Invisible while the data paths asked only whether a socket
was AF_INET or AF_NETLINK, since zero answers "neither" correctly.
SCM_RIGHTS asks "is this AF_UNIX", and zero is wrong for that one.
- pipe_poll() reports POLLHUP but not POLLIN at end of file, so a non-blocking
read of a pipe whose writers had all gone returned EAGAIN forever instead of
0 -- a program that polls and reads spins waiting for an end that already
happened. sockreadable() always had the equivalent condition; the pipe path
did not. Found because the test helper for "is this descriptor closed" is a
non-blocking read of a pipe.
projects/schcom/README.md said SCM_RIGHTS was done for a long time while nothing
in the kernel implemented it, and an entire GUI plan was drawn on that line.
It is true now, which is not the same as it having been right, and the README
says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
"net: eth0 10.0.2.15/255.255.255.0 via 10.0.2.2" arrived on the display seconds
after the prompt did, so the first thing anyone saw after booting was the kernel
typing into their session. It lands wherever the cursor happens to be -- in the
middle of a command, a fastfetch, or later a compositor's output.
console_takeover() hands the framebuffer to userspace on the first exec, and
after that the screen belongs to whoever is using the machine. cons_serial_only
already encodes that rule for the boot path -- "the display is the product; the
serial line is the diagnostics" -- and it starts set for exactly this reason.
What was missing is the rule for messages that cannot be timed to happen before
userspace starts, because they are waiting on a network.
klog() is that: the serial port only, whatever cons_serial_only currently says.
A macro rather than a function, because cprintf's varargs cannot be forwarded --
there is no vcprintf, and adding one to duplicate the formatter would cost more
than it buys. cprintf holds cons.lock for the whole message, so the flag cannot
be observed half-changed by another CPU printing at the same time.
Nothing is lost. The line still goes out the UART, which is where bootcheck
reads it from and where you want it when something is wrong, and a person who
wants to know their address reads /proc/netif.
Verified by screendumping the framebuffer after a boot and two fastfetch runs:
no interruption on screen, still one occurrence on serial.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
fastfetch reported "getifaddrs(&ifAddrStruct) failed", and the right answer to
that is not a patch to fastfetch. Asking what interfaces a machine has is
something every networked program does, and on Linux there is exactly one way:
a NETLINK_ROUTE socket. musl implements getifaddrs() over it, so a libc call
that looks like a syscall is really a small protocol conversation -- and without
this side of it every caller fails the same way.
kernel/net/netlink.c is a responder rather than a general socket family. No
event stream, no multicast groups, nothing subscribes to link state: a request
arrives, the whole answer is generated into the socket's buffer, and reads drain
it. That shape is chosen, not a shortcut -- getifaddrs sends a dump and reads
until NLMSG_DONE with MSG_DONTWAIT, so generating at send time means a read
never blocks and the non-blocking flag is honoured for free.
RTM_GETLINK returns one RTM_NEWLINK per interface with IFLA_IFNAME and
IFLA_ADDRESS; RTM_GETADDR returns one RTM_NEWADDR per address with IFA_ADDRESS,
IFA_LOCAL and IFA_LABEL. Anything else gets a bare NLMSG_DONE, because the
caller's next move is a read either way and an unexpected error message is worse
than nothing to read.
The data is not netlink's. Interfaces come from the netdev class and addresses
from lwIP's netifs, exactly as /proc/netif reads them; nothing in the file knows
what an e1000 is. Interface indices matter more than they look -- getifaddrs
matches address records to link records by index, so the two dumps have to
agree. Loopback is 1, netdevs follow from 2.
Verified by reading it back through musl rather than by inspecting the bytes:
lo flags=0x49 fam=-1
eth0 flags=0x1043 fam=17 (AF_PACKET, the MAC)
lo flags=0x49 fam=2 addr=127.0.0.1 mask=255.0.0.0
eth0 flags=0x1043 fam=2 addr=10.0.2.15 mask=255.255.255.0
Correct flags, addresses and netmasks, and the link records pair with the
address records.
One thing this does not close: fastfetch's Local IP module defaults to
default-route-only, which calls ffNetifGetDefaultRouteIfName() and reads
/proc/net/route. SchOS has no routing table and no such file, so the module
still shows nothing -- now for a reason that is about routing rather than about
enumeration. That is the next file to provide.
socktest still passes; the AF_UNIX and AF_INET paths are untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
The network thread waited on the timer and nothing else, so a frame that landed
just after a poll sat in the ring for up to 10 ms -- the largest fixed latency in
the system. That was deliberate when the stack was new (a receive path woken by
an interrupt is much harder to debug than one that is asked) and there is now a
working stack to compare against, which is exactly when it becomes safe to do.
The frame is still *pulled* by the thread through .receive. It has to be: a call
into lwIP can reach the driver, the driver can sleep on the buffer cache, and an
interrupt handler may do neither. netdev_rx_signal() delivers nothing and reads
no ring -- it only ends the wait early.
The wait stays on the timer tick rather than moving to a channel of its own, and
that is the design. lwIP's timers must still run when the link is silent, so the
clock has to remain a floor under the wait, and sleep() cannot wait on two things
at once. Joining the tick's existing wakeup costs nothing and leaves one wait
with one predicate.
rx_signal lives under tickslock for the same reason it gets no lock of its own:
sleep() releases tickslock atomically, so a driver setting the flag and waking
under that lock cannot lose the wakeup by arriving between the predicate test and
the sleep. A private lock would not be held across the sleep, and that window
would be back. Calling it from interrupt context is safe on the same terms the
timer handler already relies on -- acquire() disables interrupts, so a CPU
holding tickslock cannot be interrupted into it.
The e1000 signals on RXT0 (a frame or burst has landed) and RXDMT0 (the ring has
fallen below its threshold, which is when waiting for the clock hurts most).
Measured rather than asserted: /proc/netif reports wake_rx and wake_tick, and
across three pings rx_frames and wake_rx both went 2 -> 6 -- every received frame
woke the thread through the interrupt. wake_tick keeps advancing as the idle
floor, which is what lwIP's timers need. socktest still passes and ping is still
0% loss.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
setsockopt was `return 0;` and getsockopt wrote a zero for whatever was asked.
The comment said it was so a server setting SO_REUSEADDR and treating failure as
fatal would not fail. That trade is the wrong way round: a caller told "no"
adapts, a caller told "yes" changes its behaviour on the strength of it -- sets
SO_RCVTIMEO and then blocks forever in a read it believes will time out, sets
TCP_NODELAY and reports latency numbers that measure Nagle. The failure is
silent, remote from the call, and indistinguishable from a bug in the stack.
Six options are honoured now and everything else is ENOPROTOOPT. SO_REUSEADDR,
SO_KEEPALIVE, SO_BROADCAST and TCP_NODELAY reach lwIP's pcb; SO_SNDBUF/SO_RCVBUF
are accepted and report the real size on read. SO_REUSEADDR is implemented rather
than refused precisely because of the original comment's concern -- it is the one
option a server really does set and really does check.
accept4 becomes its own table entry rather than an alias for accept. It has to
be: accept takes three arguments, so a shared handler reading a fourth register
reads whatever the caller last left there and invents a SOCK_NONBLOCK out of
stack litter. SOCK_CLOEXEC and SOCK_NONBLOCK are now honoured by socket(),
socketpair() and accept4() -- a dropped CLOEXEC leaks a listening socket across
every exec, and a dropped NONBLOCK has the caller block forever in what it
believes is a poll loop.
ICMP ping sockets: socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP), on lwIP's raw API,
with the callback stripping the variable-length IP header. Only echo replies are
eaten, and that restriction is load-bearing -- raw_input runs before lwIP's own
icmp_input, so consuming every ICMP message would swallow the echo *requests* and
this machine would silently stop answering pings from outside.
Loopback, so 127.0.0.1 is a real destination. LWIP_NETIF_LOOPBACK is the half
that is easy to miss: without it a packet addressed to an interface's own address
goes out of the wire and never comes back. With NO_SYS=1 nothing drains the
loopback queue, so the network thread calls netif_poll_all().
cmd/ping is first-party rather than toybox's, and the reason is a limitation not
a preference: toybox reads replies with recvmsg(2) and wants the TTL through an
IP_RECVTTL control message, and SchOS has neither. recvmsg is item 1 of
docs/plan/foundations.md, where it is wanted for SCM_RIGHTS anyway, and should
not be rushed to make one applet run.
Two bugs found on the way. Every AF_INET socket failed with ENFILE until DHCP
bound, because inet_new() demanded an *address* before handing out a socket and
reported it as "file table overflow"; net_is_ready() (lwIP up) is now distinct
from net_is_up() (DHCP bound). And that bug was hiding a bad test: socktest
asserted "AF_INET is refused, not faked", true when written, false since AF_INET
landed, and green the whole time because the suite runs before DHCP binds and the
ENFILE made the refusal look real. Fixing the kernel is what turned it red.
Verified: ping to 127.0.0.1, localhost, the gateway, 1.1.1.1 and 9.9.9.9, all
0% loss; socktest ALL CHECKS PASSED with ten new checks, including a setsockopt
round trip and an unknown option that must be refused -- a permissive stub cannot
pass both at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
The TCP/IP stack reached straight into the first driver anyone wrote.
net.c called e1000_present(), the lwIP glue was named netif_e1000.c and
called e1000_transmit(), and include/defs.h declared the driver's
internals to the whole kernel. The second NIC driver would therefore
have arrived with a near-identical copy of the glue file and a patch to
the protocol stack, which is the arrangement include/device.h was
written to prevent -- the e1000 even said so, with `.ops = 0, // no
class vtable yet; there is no net class`.
There is now. A driver publishes a struct netdev_ops -- transmit,
receive, mac, link_up -- and is finished: it never mentions lwIP, and
nothing above it learns its name. The display class was already this
shape; this is the same shape for networking, and the two now read the
same way in kernel/dev/device.c.
What that made possible immediately:
- One netif per bound device instead of one per driver. lwIP's glue is
netif_eth.c, it takes a struct device, and it holds up to NETIF_MAX
interfaces. net.c attaches every network device it finds and starts
DHCP on each, because which interface the network answers on is not
ours to decide.
- /proc/netif lists all of them, one block of fields each, with the
interface name and the driver that claimed it.
- The ARP self-test moved from the e1000 to the class. Nothing in it
was chip-specific, and it is the first thing anyone will want to run
against the next driver. Still `echo n > /proc/sysrq`.
- Private headers live with their driver: e1000.h was in include/,
where exactly one file could use it. The driver build now adds the
driver's own directory to the include path.
The e1000 also drives more than one card. Its probe() refused the second
outright, because there was a single static instance -- so a machine
with two of them drove one and silently ignored the other. Instances are
an array now, and the instance is hung on dev->priv by a new optional
`attached` hook that the bus calls once the device exists. probe() runs
before device_add(), so there was previously nowhere to put it; that is
the whole reason the hook exists rather than a line inside probe.
QEMU gets an explicit NIC on every target (QEMU_NET). The default device
happens to be an e1000 on this machine type today, which is exactly the
sort of thing that stays true until a QEMU release changes it and the
network stops being exercised without anyone noticing.
Verified:
- bootcheck -Net passes, 48 [ok], no failures -- including the moved
self-test and two new checks that /proc/netif names both the
interface and the driver behind it, which together say the stack
went through the class rather than through a driver it was compiled
to know.
- Two NICs attached by hand (-device e1000 twice, second with its own
MAC): eth0 and eth1 both attach, each reports its own MAC in
/proc/netif, and each gets its own DHCP lease. Before this commit
the same machine produced one interface.
- make schfs-check still passes in full.
One trap worth recording: moving a header broke the build for anyone
with stale .d files -- make stopped with "No rule to make target
'include/e1000.h'". CFLAGS gains -MP, which emits a phony target per
header so a moved or deleted one can never wedge the build again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The second of the two things the stack owed. recvfrom accepted the
address arguments and ignored them, so every caller was told nothing
about where a datagram came from -- which for UDP is most of the point:
a server that cannot see the sender cannot reply to it.
Fixing it properly meant fixing something worse that was sitting
underneath. UDP payloads went into the same byte ring TCP uses, with no
record of where one datagram ended and the next began, so two datagrams
that arrived together came back from one read as one. That is a bug
whose appearance depends on timing and whose blame lands on the sender.
Datagrams now carry a header alongside the bytes: source address, source
port, length. Sixteen of them per socket, in a ring beside the existing
one. Whole buffers would have been 16 sockets x 16 datagrams x an MTU of
static memory for something almost always empty; only the boundaries need
keeping, and those are eight bytes each. A datagram too big for the
buffer it is read into is truncated and the remainder discarded, rather
than left in the ring to be delivered as the head of the next one. One
that arrives with the queue full is dropped whole -- that is UDP's own
contract, and half a datagram in the ring would corrupt every one after.
An empty queue and a zero-length datagram are kept apart, because a
sender really can send zero bytes and the right answer to that is 0
rather than "nothing has arrived yet".
sendto grew the mirror image: a destination address on a datagram
socket, which is what a server replying to whoever just wrote to it
needs. On TCP a destination is ignored rather than refused -- the
connection decides where the bytes go, and programs pass one out of
habit. For a stream, recvfrom's source is read from the pcb: it is the
peer, and it is the same answer for every byte.
Verified: bootcheck -Net now covers UDP too, 45 [ok] and no failures.
The address check is real rather than cosmetic -- nettest deliberately
leaves its sockaddr uninitialised, so a kernel that filled in nothing
would print stack rubbish instead of 10.0.2.2 and the port the responder
is actually on. The boundaries are checked by byte count: the responder
sends 20 bytes and then 16, and the guest reports reading 20 and then
16. Treated as a stream the first read would have taken all 36.
Left for later, and deliberately: accept() still does not report the
peer's address, and getsockname/getpeername do not exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The AF_INET blocking paths -- connect, accept, read and write -- woke on
every timer tick and looked to see whether anything had changed. That is
a wakeup per blocked socket per 10 ms whether or not the network did
anything, and it answers late even when the data was already sitting in
the buffer.
What stood in the way was a lock mismatch, and it is written down in the
file now because it is the reason this is not simply sleep on the socket:
sleep() wants a spinlock, and the lwIP lock is a sleeplock -- it must be,
since a call into lwIP can reach the driver and the driver can sleep.
Nor can a waiter hold the lwIP lock while sleeping, or the network thread
could never deliver the thing it is waiting for.
So the wait is on a counter with a spinlock of its own. A waiter reads
the counter while it still holds the lwIP lock, right after checking its
condition, then releases and sleeps only if the counter has not moved.
The lwIP callbacks bump it and wake the channel. No wakeup can be lost:
anything that changes a condition does so after the reader's snapshot,
and therefore bumps the counter after it too, and the sleeper re-checks
under the spinlock before committing.
One counter and one channel for all sixteen sockets rather than one
each. Waking a socket with nothing to do costs a few instructions, and a
per-socket channel would have to be torn down in close() against
callbacks that can still arrive -- which is a use-after-free waiting to
be written.
tcp_sent is registered now, on both connected and accepted pcbs. It was
not needed while writes woke on the clock; with this change a write
blocked on a full send buffer would otherwise sleep with nobody left to
wake it, because an acknowledgement is the only thing that frees space.
Verified: bootcheck -Net passes, 38 [ok], no failures. It exercises the
path that matters -- nettest blocks in read() until the host answers, and
the host answers only after reading what the guest sent, so the wakeup
comes from a callback and not from a tick that would have fired anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The TCP/IP stack landed with its end-to-end test performed by hand: start
a listener, boot, type nettest, read the output. That is evidence once
and nothing afterwards, and it is exactly the kind of check that quietly
stops being run.
bootcheck -Net does the whole exchange. A listener runs on this host on a
port picked by binding one and letting go, the guest is told to connect
out to 10.0.2.2 at that port, and both ends are asserted: the guest
reports what it received, the listener reports what arrived. One side
alone proves much less than it looks -- a stack that completes a
handshake and then drops every byte satisfies any check made from inside
the guest.
The listener is a background job, a separate process, because this
harness has one hard rule: nothing may block the writer feeding QEMU's
stdin. A socket accept is precisely the wait that deadlocked two earlier
attempts at reading QEMU's output. It answers the protocol nettest
speaks and then closes -- the close is load-bearing, since nettest reads
until end of file and a listener that stayed open would hang the guest
rather than fail it.
The NIC is spelled out (-netdev user + -device e1000) rather than left to
QEMU's default. Other runs can take the default and still answer "did a
driver bind"; a run that asserts on an address should be reading its own
configuration.
/proc/netif is new, and the address is why. Until now the only report was
a line on the console at boot, which userland cannot read and a test can
only find by scraping the transcript. It gives interface, MAC, state,
address, netmask, gateway, MTU and frame counts. Named netif rather than
net on purpose: /proc/net is a *directory* on Linux, and a plain file
there would give ENOTDIR to anything reaching for /proc/net/dev, which is
worse than not having it. The counters are kept at the lwIP seam rather
than in the driver, because a frame the driver received and the netif
dropped never reached the stack.
Verified: 38 [ok], no failures. Eleven of them are new -- lwIP up, DHCP
obtaining 10.0.2.15 from SLIRP (a number nothing in the guest knows in
advance, so it is the exchange completing rather than a guess), the four
/proc/netif fields, frames counted in both directions, the handshake, the
reply read back, and the listener confirming HELLO-FROM-SCHOS arrived.
One bug found by writing it: $netPort was chosen after the command list
was built, so the guest ran `nettest 10.0.2.2` with no port and printed
its usage. An unset PowerShell variable interpolates to nothing rather
than failing, which is worth remembering next time a command in this
file looks right and behaves oddly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lwIP 2.2.0, fetched by ports/lwip and compiled straight into the kernel.
Not written from scratch, and the reason is not the packet formats: it is
congestion control, retransmission, window management and twenty years of
corner cases that only show up against a real peer. Not FreeBSD's
netinet either, though the licence would have allowed it -- that stack is
not a library, it reaches into mbufs, epoch(9), netisr, VNET and uma(9),
so adopting it means porting FreeBSD's kernel infrastructure first. lwIP
was written for exactly this situation: a small kernel with a raw
Ethernet driver and a port layer of a few hundred lines.
It is kernel code, so nothing is installed into the sysroot and there is
no second build system: the top-level Makefile compiles the files it
wants against our port headers. The file list is explicit rather than a
wildcard, because lwIP ships IPv6, 6LoWPAN, PPP, SLIP, DNS-SD and a dozen
apps in the same tree, and building what is not configured yields either
dead code or a link error naming a port function nobody meant to write.
Its sources are unmodified and its warnings are silenced for its files
only -- patching them would mean maintaining a fork.
The port layer is ours: a lock, a clock, the netif that hands frames to
e1000, lwipopts.h, and a shim/ directory of tiny headers so lwIP's
<string.h> and friends resolve to the kernel's functions instead of the
host's, whose prototypes disagree and would otherwise be found on the
default include path.
NO_SYS=1, so lwIP has no threads of its own and one kernel thread feeds
it. A thread rather than the interrupt handler, because a call into lwIP
can reach the driver, the driver can sleep, and an interrupt handler
cannot -- so the interrupt still only clears the cause register and the
thread polls. kthread_create is new and generally useful: a process with
a kernel-only page directory, no files, no cwd, no parent, dispatched
from forkret, which can therefore sleep and be preempted like anything
else. DHCP obtains an address at boot and the console says which one.
AF_INET went in exactly where phase 2 said it would. socknew dispatches
on the domain, kernel/net/inet.c implements the internet side on lwIP's
raw API, and not one syscall changed shape: bind, connect, listen,
accept, read and write reach the same descriptors they always did. TCP
and UDP both work. cmd/nettest opens a connection, writes, and reads the
answer back.
Two things are owed and named here so they are not discovered later:
recvfrom does not yet report the source address, and the socket paths
poll on the timer tick instead of sleeping on the socket. Both are
follow-ups, not redesigns.
NOTICE carries lwIP's BSD-3 text. Clause 3 is no-endorsement, not the
old advertising clause, so it sits with Apache-2.0 without difficulty.
Verified: the tree builds clean, and the headless boot check passes with
27 [ok] and no failures with the stack compiled in and the thread
running -- which is the part that would break first if netinit deadlocked
or the thread starved anything. An end-to-end connection test belongs in
that suite and is the next commit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.