SchOS/docs/architecture/networking.md
Nexoniarz 3034efd300 net: a frame wakes the stack, instead of waiting for the clock
The network thread waited on the timer and nothing else, so a frame that landed
just after a poll sat in the ring for up to 10 ms -- the largest fixed latency in
the system. That was deliberate when the stack was new (a receive path woken by
an interrupt is much harder to debug than one that is asked) and there is now a
working stack to compare against, which is exactly when it becomes safe to do.

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

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

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

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

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

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

14 KiB

Networking

ARP, IPv4, ICMP, UDP, TCP and DHCP, over a driver-agnostic interface class, reached through the same socket syscalls as Unix domain sockets.

Verified end to end rather than by inspection: bootcheck -Net starts a listener and a UDP responder on the host, tells the guest to talk to them, and asserts on both ends — one side alone cannot tell a working stack from one that completes a handshake and drops every byte.


The four layers

        socket() bind() connect() send() recv()        syscalls, one set
    ┌────────────────┴───────────────────┐
    │  AF_UNIX              AF_INET      │   socknew() dispatches on domain
    │  kernel/net/unix.c    kernel/net/inet.c
    └────────────────┬───────────────────┘
                     │  lwIP raw API
    ┌────────────────┴───────────────────┐
    │  lwIP 2.2.0, NO_SYS=1              │   ports/lwip, compiled into the kernel
    │  port layer: kernel/net/lwip/      │   ~400 lines: a lock, a clock, a netif
    └────────────────┬───────────────────┘
                     │  struct netdev_ops
    ┌────────────────┴───────────────────┐
    │  e1000, and any future NIC driver  │
    └────────────────────────────────────┘

The netdev class

A driver publishes a struct netdev_ops and is then finished with the network stack forever: it never mentions lwIP, and the stack never mentions it.

struct netdev_ops {
  int (*transmit)(struct device *, const void *frame, int len);
  int (*receive) (struct device *, void *out, int cap);
  const unsigned char *(*mac)(struct device *);
  int (*link_up) (struct device *);        // optional
};

transmit returning -1 is congestion, not failure — the ring is full and the caller is expected to retry. receive never blocks; it returns 0 when nothing is waiting, and the caller decides how to wait.

This class was added late, and the reason it had to be is instructive: the stack had reached straight into the driver. net.c called e1000_present(), the lwIP glue was named netif_e1000.c, and the e1000 shipped with .ops = 0 and a comment saying so. A second NIC driver would have meant editing the protocol stack to add itself. lwIP now gets one netif per bound device, /proc/netif lists them all, and the e1000 drives up to four cards rather than refusing the second — verified with two attached, each with its own MAC and its own DHCP lease.

netdev_selftest()

echo n > /proc/sysrq sends a hand-built ARP request and waits for the reply.

It proves the whole driver path in one exchange — descriptor formats, the DD handshake, tail bookkeeping, DMA addresses, the receive ring hand-back, and the MAC filter, because an ARP reply is addressed to this station and does not arrive if the filter is wrong.

It was written before there was any protocol stack, deliberately: a stack on an unproven driver fails in ways indistinguishable from its own bugs. It belongs to the class rather than to the e1000, because it is the check every new NIC driver will want first.

lwIP

lwIP 2.2.0, fetched by ports/lwip and compiled into the kernel — it is the one port that installs nothing into the sysroot, because it is kernel code.

NO_SYS=1, so lwIP has no threads, mailboxes or semaphores of its own. The port layer is kernel/net/lwip/: an architecture header, a lock, a clock, an Ethernet netif, and a set of shim headers standing in for the libc lwIP expects.

Why lwIP and not a BSD stack

The licence was not the deciding factor — FreeBSD's sys/netinet is BSD-3 too. It is that FreeBSD's stack is not a library. tcp_input.c alone is 122 KB and reaches into mbufs, epoch(9), netisr, VNET, uma(9), sockbufs and inpcb hash tables. Taking it means porting FreeBSD's kernel infrastructure first and finding the TCP somewhere underneath — the same conclusion already reached about FreeBSD's USB stack.

NetBSD was ruled out on licence: its tcp_input.c and tcp_output.c carry the WIDE Project's 4-clause advertising clause, which Berkeley's rescission does not reach. See ../licensing.md §3.

lwIP was written for exactly the case SchOS is in: a small operating system with a raw Ethernet driver and no infrastructure to speak of.

The reason not to write one, kept because it is the reason to resist later too: it is not the packet formats that are hard. It is congestion control, retransmission, window management and twenty years of corner cases that only appear against a real peer.

The network thread

kernel/net/net.c. One kernel thread, started by netinit(), feeds lwIP received frames and runs its timers.

A thread rather than the interrupt handler, and the reason is the lock: a call into lwIP can reach the driver, the driver can sleep, and an interrupt handler cannot. So the frame is always pulled, by this thread, through .receive.

The interrupt's only job is to end the thread's wait early. netdev_rx_signal() is what a driver calls from its handler; it delivers no frame and reads no ring. Without it a frame that arrived just after a poll sat there until the next clock tick — up to 10 ms, and the largest fixed latency in the system.

The wait stays on the timer tick rather than moving to a channel of its own, and that is the design rather than an accident. 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.

The flag lives under tickslock for the same reason it does not get a 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.

/proc/netif reports wake_rx and wake_tick so this is observable rather than assumed — if wake_rx stays at zero while frames arrive, the interrupt is not reaching the stack and the thread is running on the clock exactly as it used to. Measured across three pings: rx_frames and wake_rx both went 2 → 6.

netinit() runs after sinit() because kthread_create needs a usable process table, and it returns immediately: everything happens in that thread, which cannot run until the scheduler is entered.

DHCP obtains an address at boot. /proc/netif reports each interface's address, netmask, gateway, MAC and frame counts, taken at the lwIP seam.

It is /proc/netif and not /proc/net deliberately: on Linux /proc/net is a directory of many files, and a single file by that name would be a lie about a well-known path.

Sockets

One set of syscalls, at their real Linux numbers. socknew() dispatches on the domain, and no syscall changes shape between the two families.

AF_UNIX

kernel/net/unix.c. SOCK_STREAM, socketpair, shutdown.

A connected pair is two buffers, not one: writing appends to the peer's buffer and reading drains your own, which is what makes a socket bidirectional where a pipe is not, without needing two of anything.

One lock covers the whole table. A pipe gets away with a lock per pipe because every operation touches one object; a socket operation touches two, and two-object locking needs an ordering rule every path obeys or it deadlocks. Worse, the wakeup races are subtle — a reader testing peer_closed under its own lock while the closer sets it under the peer's has no interlock at all.

Names live in a table here, not in the filesystem. Binding creates no directory entry. The table gives the behaviour that matters — a client finds a server by a name agreed in advance — and what is lost is ls -l showing an s, and unlink() of a socket path working. Both want a VFS change rather than a socket change.

AF_INET

kernel/net/inet.c, on lwIP's raw API rather than lwIP's own sockets layer, which would need NO_SYS=0 and a second socket abstraction alongside this kernel's own.

Everything is protected by the lwIP lock and nothing else, and that is not a shortcut: lwIP's callbacks run in the network thread with that lock held and are the only writers to a socket's receive buffer. A syscall reading it takes the same lock. There is no second lock because there is no second writer.

How a blocking socket waits

Worth reading before touching, because the obvious implementation does not work. A blocking call cannot sleep(&socket, &lwip_lock): sleep() wants a spinlock and the lwIP lock is a sleeplock — it has to be, because a call into lwIP can reach the driver. And a waiter cannot hold that lock while sleeping, or the network thread could never deliver the thing being waited for.

So the wait is on a counter with a spinlock of its own:

  reader:  lwip_lock; check condition; s = net_seq(); lwip_unlock; net_wait(s)
  waker:   (inside a callback, lwIP lock held) net_wake()

No wakeup can be lost. s is read while the reader still holds the lwIP lock, so anything changing the condition must do so afterwards and therefore bumps the counter after the read; net_wait re-checks under its own spinlock and does not sleep.

One counter and one channel for all inet sockets rather than one each. At this scale waking sixteen sockets costs a handful of instructions in the ones with nothing to do, and a per-socket channel would need careful teardown in close() against callbacks that can still arrive.

Before this, a blocked socket woke on every timer tick and looked — costing a wakeup per socket per 10 ms and answering up to 10 ms late even when the data was already there. tcp_sent had to be registered for it as well, or a write blocked on a full send buffer slept with nobody left to wake it.

ICMP, and ping sockets

socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP) — Linux's ping socket, which gives an unprivileged process the one thing raw sockets were used for. The caller reads and writes the ICMP message alone; the kernel owns the IP header.

Built on lwIP's raw API: raw_new(IP_PROTO_ICMP), with the receive callback stripping the variable-length IP header before queueing the message on the same datagram queue UDP uses.

Only echo replies are consumed, and that restriction is load-bearing. raw_input runs before lwIP's own icmp_input, so a callback returning "eaten" for every ICMP message would swallow the echo requests too and this machine would silently stop answering pings from outside. Returning 0 leaves the packet for lwIP, which is what should happen to every ICMP message nobody asked for.

There is no per-socket echo-id filtering. Linux rewrites the id and matches on it; here a reply goes to every open ICMP socket, which is invisible with one ping running and means a second one may see a sequence number it never sent.

Loopback

127.0.0.1 is a real destination: LWIP_HAVE_LOOPIF creates the interface and lwip_init() brings it up. 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 there is no thread to drain the loopback queue, so the network thread calls netif_poll_all() alongside sys_check_timeouts().

Datagrams

UDP has a datagram queue, not a byte ring. It shared TCP's ring at first, and two datagrams arriving together came back as one — which is the bug that had to be fixed before recvfrom could report a source address at all.


What it does not do

  • ping is a first-party program, cmd/ping, not toybox's. toybox's reads replies with recvmsg(2) and asks for the TTL through an IP_RECVTTL control message, and neither exists — recvmsg is item 1 of ../plan/foundations.md, where it is wanted for SCM_RIGHTS anyway. Ours has no -I, no flood mode and no TTL display.
  • No resolver reachable from userland. lwIP's DNS client is compiled but nothing exposes it, so ping takes a dotted quad. localhost is special-cased in the program rather than resolved.
  • No routing table. With two interfaces up, the first is the default route and the second is reachable only by an address on its own subnet. That is a rule rather than an accident, and it is not routing.
  • No getsockname/getpeername, and accept() does not report the peer's address for an AF_INET socket. A server that wants to log who connected cannot.
  • setsockopt covers six options, and refuses the rest with ENOPROTOOPT rather than pretending: 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. Notably absent: SO_RCVTIMEO/SO_SNDTIMEO.
  • SOCK_DGRAM over AF_UNIX does not exist — stream only.
  • AF_UNIX names are not filesystem paths, so nothing can stat() or unlink() a socket.
  • One NIC driver exists. The class is proven with two e1000 cards, not with two different drivers.
  • No IPv6, no DNS resolver in the kernel (lwIP's is compiled but unused by anything), and no firewall or packet filter of any kind.