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
This commit is contained in:
Nexoniarz 2026-09-14 08:31:06 +02:00
commit 2a441af3c6
14 changed files with 953 additions and 5 deletions

View file

@ -95,7 +95,7 @@ The galling ones. The work exists, is tested, and nothing uses it.
|---|---|
| **`foundations.md`** | Item 1 built. Items 2 (a shareable memory object), 3 (`AF_UNIX` in the filesystem namespace), 4 and 5 open. **Wayland stops at item 2.** |
| ~~**`hybrid.md`**~~ | **Closed.** All four mechanisms built; H1, H2 and H3 done. H4 — the USB eviction — is *withdrawn* rather than outstanding: USB is the bus peripherals arrive on, not a peripheral, and an eviction that has to leave an identical driver behind has moved nothing. See [hybrid.md](hybrid.md), "Decided: USB stays in the kernel". |
| **`ldls.md`** | L0, L1 and L2 done — the loader handles 2,440 stock modules; 449 of 500 run `init_module`; and `8139too`'s `probe()` runs to completion and returns 0, reached through struct offsets read from the kernel's own BTF. L3 (a packet, and the `/dev/pci` back end) open. The hardware is still invented. |
| **`ldls.md`** | L0, L1, L2 and L3a done — the loader handles 2,440 stock modules; 449 of 500 run `init_module`; `8139too`'s `probe()` runs to completion; and `/dev/netdev` lets a process be an interface, with `ping` working over one. L3b (the SchOS-side host) and L3c (the fifteen symbols, for real) open. No Linux driver has yet touched hardware. |
| **Schcom v0** | 715 lines that composite, over a wire format that is superseded. Frozen: it runs, nothing more is added, and it is kept only because it is the one thing in the tree that has ever put a client's pixels on screen. |
| **`debugger.md`** | D0 through D4 built and described in [../architecture/debugging.md](../architecture/debugging.md). Only the network transport is open — a userspace daemon bridging TCP to `/dev/dbg`, which is deliberately last. |

View file

@ -181,7 +181,10 @@ One device, one process, claimed and released, on the `/dev/pci` discipline.
| **L0** | **The loader.** Parse a real `.ko`, lay it out, relocate it, and **print every unresolved symbol**. Nothing executes. The output is the measured API surface. | **done** — `ldls/ko.c`, `make ldls-check` |
| **L1** | **Stubs.** Every symbol resolves, to a function that logs its name. `init_module` runs, which yields the *call order* — the second thing you need and the other thing you cannot guess. | **done** — `ldls/stub.c`, `ldls-run` |
| **L2** | **The hardware.** `probe()` receives a working `struct pci_dev`, maps its BARs, allocates coherent DMA and registers an interrupt, all through `/dev/pci`. The driver reaches its device. | **done**, except `/dev/pci` — see below |
| **L3** | **A packet.** The process-backed netdev, wired to the driver's transmit and receive paths. **`ping` over a Linux driver.** This is the milestone that proves the whole idea and the one that cannot be skipped. | |
| **L3a** | **The class bridge.** `/dev/netdev`: a process claims it and becomes an interface. `ping` over a *process*. | **done** — `kernel/dev/netuser.c`, `cmd/netecho` |
| **L3b** | The SchOS-side host: `ldls-run`'s loader and BTF work, as a program on SchOS, reaching hardware through `/dev/pci`. | |
| **L3c** | The fifteen symbols, implemented rather than stubbed, against a real rtl8139. | |
| **L3** | **A packet.** The two joined: **`ping` over a Linux driver.** This is the milestone that proves the whole idea and the one that cannot be skipped. | |
| **L4** | **Kill it.** The host process dies, the kernel clears bus mastering and reclaims the DMA, a new host starts and the interface comes back. | free — H2 built all of this |
L4 is listed for completeness rather than as work. `pciuser_stop()`,
@ -466,6 +469,84 @@ the driver accepts, built from offsets nobody wrote down.
---
## L3a: a network, in a process
L3 is one sentence in the milestone table and three pieces of work. This is the
first, and it is the one the rest plugs into: **`/dev/netdev`, an interface
whose backend is a process.**
The kernel side is `netdev_ops` and nothing else — `transmit`, `receive`,
`mac`, `link_up`, the same four entries `e1000` publishes. 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. It is `/dev/pci`'s shape
with the arrows reversed: that hands a device *to* a process, this lets a
process *be* one.
`cmd/netecho` is the proof, and it is deliberately **not** a driver — it has no
hardware and claims none. It answers every frame itself: DHCP so the interface
gets an address at all, ARP so the stack can find it, ICMP so it can be pinged.
```
# netecho &
netuser: ldls0 is a process now, 2:4c:44:4c:53:1
net: ldls0 attached as interface 1, asking for an address
netecho: this process is now an interface. 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
64 bytes from 10.9.0.1: icmp_seq=1 time=30 ms
64 bytes from 10.9.0.1: icmp_seq=2 time=10 ms
64 bytes from 10.9.0.1: icmp_seq=3 time=10 ms
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 on the other side of
`netdev_ops` is not a driver — which is the entire property the bridge exists
to have.
### It dies the way it should
The same test, continued:
```
# kill 8
# schdbg netuser
interface ldls0 DOWN, nobody holds it
# ping 10.9.0.1 1
ping: sendto: -5
0 packets transmitted, 0 received
# netecho &
# ping 10.9.0.1 2
2 packets transmitted, 2 received, 0% packet loss
```
`link_up()` answers false the moment nobody holds the descriptor, so the stack
refuses to send rather than swallowing packets into a driver that is not there
— and when a new process claims it, the *same* interface comes back, keeping
its address. That is H2's property applied to a network interface, and it came
free: `netuser_release_all()` sits beside `pciuser_release_all()` in `exit()`
for exactly the same reason.
### One new thing the kernel needed
`netinit()` starts DHCP on the devices that exist when it runs. An interface
created later — which is how an LDLS-hosted driver arrives — missed that
entirely and would have come up with no address and no way to get one. So
`net_attach_late()` exists, in `kernel/net/net.c` because that is the only file
allowed to include lwIP's headers.
---
## What it will not do
- **It is not a security boundary, and here that is worse than usual.** There
@ -516,6 +597,18 @@ the driver accepts, built from offsets nobody wrote down.
the module, from the kernel the module was built for. Both are theirs,
neither is shipped, and a kernel built without `CONFIG_DEBUG_INFO_BTF` has
neither.
- **L3a proves the bridge, not the driver.** `ping` works over a *process*;
nothing in that test loads a module, and `netecho` is a fake network rather
than a NIC. That is the point of doing it 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.
- **One interface, one process.** `/dev/netdev` is a single device and a second
claimant gets `-EBUSY`. Two hosted NICs would need it to be indexed like
`/dev/input/eventN`, which is a change of shape rather than of size.
- **The interface is never removed.** Releasing it leaves the device registered
with the link down, because nothing in lwIP supports taking an interface out
of its list. From the stack's point of view that is the same thing; from
`/proc/netif`'s it is a `ldls0` that stays listed after its process is gone.
- **`probe()` is reached with invented hardware.** The BARs are numbers the
harness made up and `pci_iomap` returns a zeroed page. A driver that reads a
status register gets whatever was there, which is why 8139too read a MAC