SchOS/docs/plan/1.0.md
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

442 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# SchOS 1.0
A finish line, so that the list of open work stops being infinite.
---
## Why this document exists
[schapi.md](schapi.md) §3 says the stability freeze starts at 1.0 and covers
whatever is marked `stable` at that moment. **1.0 was never defined**, which
means there has been no finish line — and with no finish line every gap is
equally mandatory, the list only grows, and the project feels larger every week
regardless of how much gets done.
That feeling is structural, not a judgement about progress. This document fixes
the structure by drawing a line and putting most of the open work on the far
side of it.
> **Anything not named in this document is not part of 1.0.** It is not
> cancelled, not forgotten, and not up for reconsideration until 1.0 ships.
---
## What 1.0 is
> **A machine one person can sit down at and use for an afternoon without
> falling off an edge.**
Concretely — boots on real hardware, roots on SchFS, runs a shell and the real
toybox userland, is on the network, and puts a windowed terminal on the screen.
It is deliberately a **small complete system** rather than a rich incomplete
one. SchOS 1.0 is not Linux, does not run third-party applications, and has no
package manager. It is a system that works, end to end, with nothing in it that
lies about what it did.
### The acceptance test
One unattended run, asserted from the host, the way everything else here is
tested. When this passes, it is 1.0:
```
boot on real hardware from a USB stick
→ SchFS root mounts, generation counter advances
→ schinit starts the compositor
→ a terminal window appears and is asserted by pixel
→ in it: ls, cat, a file written, a network fetch
→ reboot
→ the file is still there and the checksum matches
```
Nothing in that sentence is a feature list. Every clause is something that is
currently either broken, missing, or unproven.
---
## The freeze, and the register of unfinished work
**No new capability is started until this register is empty.**
The failure this exists to stop is not laziness and not scope creep — it is
*abandonment by promotion*. A thing gets built to the point where it works, the
next thing becomes more interesting, and the first one is left in a corner:
present in the tree, not finished, not deleted, and not visible in any list.
Enough of those and the tree stops matching its own documentation, which is the
state this register was written to end.
Two rules, and they are the same rule seen from either end:
> **Nothing enters the register except by being finished or being deleted.**
>
> **Adding a row requires closing one.** Two things in flight. Never three.
A row leaves this table in one of exactly two ways. Either it is **finished** —
and finished means a description exists in [../architecture/](../architecture/),
not that the code works — or it is **deleted**, and deleted means gone from the
tree, not marked for deletion. A directory that has said "to be deleted" for
months is the corner, not the cure; `projects/schwm/` and `projects/schde/`
proved that and are now actually gone.
### Built, and not connected to anything
The galling ones. The work exists, is tested, and nothing uses it.
| | Where it stopped |
|---|---|
| **The text layer** | `kernel/dev/text.c` and `kernel/dev/ttf.c` are 850 lines that rasterise TrueType and draw at any pixel position. The console still indexes `font_data[256][16]`. The only caller is a self-test. |
| **A font from disk** | Same layer, same reason. The console's 8×16 CP437 grid is compiled in. |
| **Socket activation** | `schinitd` parses `[Socket name]` and remembers the path. Nothing holds the socket. **This is the entire reason for copying launchd**, and it is the half that was not built. |
| **`display_ops.wait_vblank`** | In the vtable, implemented by the VBE driver, left null by virtio-gpu. A compositor that needs it cannot rely on it. |
### Started, and stopped part-way
| | Where it stopped |
|---|---|
| **`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, 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 done too — `ldlsd` runs the same loader on SchOS and gets a real driver's `probe()` to return. L3c done — a Linux driver now drives real hardware through `/dev/pci` and reads the card's own MAC off its EEPROM. L3d, the packet path, is what remains. |
| **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. |
**The row LDLS spends.** `hybrid.md` closed above — H3 built, H4 withdrawn —
and [ldls.md](ldls.md) takes the slot it freed. Worth being honest that this is
the largest thing ever to enter this register, and that it is **explicitly not
part of 1.0**: nothing in the acceptance test needs a foreign driver. It is
here because the mechanisms it uses are finished and idle, and because the
alternative to it is writing a driver for every device that exists.
**The row the debugger spent.** Adding one requires closing one, and *A released
PCI device* closed when H2 landed. It is worth saying plainly that the debugger
is not a capability of the OS the way every other row here is: nothing in it
ships to a user or appears in the acceptance test. The case for it is that
**Gate 0 is a list of bugs to find in a kernel that could not be asked
anything** — so it is the instrument the rest of this register is surveyed with,
and it earns its slot by making every other row cheaper to close.
**Closed since this register was written.** *A released PCI device* — release is
now symmetric with claim (bus mastering cleared before the DMA is freed, a
refusal when the device is the only display), a supervised job starts the
replacement, and it is described in
[../architecture/devices.md](../architecture/devices.md) under "Taking it back".
### Named in a "what it does not do" and still true
Not corners — these were written down honestly on the day and stayed written
down. They are here so the register is the whole picture rather than the
flattering part of it.
| | |
|---|---|
| `/proc/<pid>/exe` is a regular file | so `readlink()` fails and `ps -o comm` prints `[init]` |
| `getrusage` | does not distinguish `RUSAGE_CHILDREN` from `RUSAGE_SELF` |
| No slab allocator | every subsystem's fixed-size table is a consequence |
| No `getsockname`/`getpeername` | and `accept()` does not report an AF_INET peer |
| No routing table | first interface wins, which is a rule and not routing |
| No descriptor garbage collector | a socket passed over itself and never received leaks the pair |
### Frozen — scoped, unstarted, and not to be started
Neither cancelled nor in progress. **Do not open these.**
| | |
|---|---|
| `projects/schinst/` | the installer, the installation environment, `.sip` |
| `projects/schfs-linux/` | SchFS as a filesystem for Linux |
| [roadmap.md](roadmap.md) §4 | the interactive crash screen |
| [roadmap.md](roadmap.md) §6 | SchFS beyond SchOS |
| [schapi.md](schapi.md) | the API layering, designed and not built |
Each of those is well argued and none of them is on the path to the acceptance
test above. That is the whole test for whether something belongs in this section:
**not "is it worth doing" but "does 1.0 fail without it".**
---
## The four gates
In order. **Do not start a gate before the one before it closes**, and closing
means a description exists in [../architecture/](../architecture/) — see
[the rule](../README.md#the-rule-that-keeps-this-from-rotting).
### Gate 0 — Fix what is already wrong
**This comes first, before any new capability.** The desktop work is the
temptation and it is the wrong thing to do next: a compositor built on a kernel
with an unexplained hang and a socket layer that reports success for things it
did not do will fail in ways indistinguishable from its own bugs. That is the
same argument that put `netdev_selftest()` before the TCP/IP stack, and it was
right then.
**Things that are actively wrong:**
- [x] ~~**The `ls -lR` hang.**~~ **Not reproducible.** Instrumented and hunted;
see below. Left here rather than deleted because the absence of a result
is the result.
- [x] ~~**`setsockopt` returns 0 without setting anything.**~~ Six options now
genuinely reach lwIP's pcb and everything else is `ENOPROTOOPT`. Round-trip
checks in `cmd/socktest`, which a permissive stub cannot pass: it has to
read back a value it was set *and* refuse an unknown option.
- [x] ~~**`accept4` silently drops its flags.**~~ It is its own table entry now
rather than an alias for `accept` — an alias would have had to read a
fourth argument that `accept` never passes, inventing a `SOCK_NONBLOCK`
out of whatever the caller last left in that register.
- [x] ~~**Timestamps on everything created after boot read 1970.**~~ Both
`know()` (SchFS) and `now_sec()` (v1) returned `uptime()` under comments
saying the kernel had no wall clock. It has had one since `time_init()`
started reading the RTC; the comments outlived the condition. Two errors,
not one — `uptime()` counts 10 ms ticks and they were used as seconds, so
the clocks also ran 100× fast.
- [ ] **`getrusage` does not distinguish `RUSAGE_CHILDREN`** from `RUSAGE_SELF`.
- [ ] **`/proc/<pid>/exe` is a regular file, not a symlink**, so `readlink()`
fails and `ps -o comm` prints `[init]`. Needs symlink support in procfs.
- [ ] **OVMF memory reporting**, if the region-list fix did not fully close it.
**Work already done and not connected:**
These are the galling ones — the replacement exists, is tested, and is unused.
- [ ] **Port the console onto the text layer and delete the cell grid.**
`text.c` and `ttf.c` both exist and work. The console still indexes
`font_data[256][16]`.
- [ ] **Load a font from disk** and point the console at it.
- [x] ~~**Interrupt-driven receive.**~~ The e1000's handler calls
`netdev_rx_signal()`, which ends the network thread's wait early; the frame
is still *pulled* by the thread, because an interrupt handler can neither
sleep on the buffer cache nor take the lwIP lock. The wait stays on the
tick so lwIP's timers keep their floor. Proven rather than assumed:
`/proc/netif` gained `wake_rx`/`wake_tick`, and across three pings
`rx_frames` and `wake_rx` both went 2 -> 6.
**Structural, because everything after this compounds on it:**
- [ ] **A slab or pool allocator.** `kalloc` hands out one page at a time, and
every subsystem written since has invented its own fixed-size table to
work around it — `NPROC`, `NSOCK`, `NDEVICE`, `NSHM`, `MAXJOBS`. This is
the single change that most improves everything else in the kernel, and
it gets more expensive with every table added.
- [x] ~~**SchFS on-disk format version policy.**~~ Done. `SCHFS_VERSION` for
frame changes, plus `compat`/`ro_compat`/`incompat` feature flags in what
was `reserved0` — so existing images keep every field offset and read
unchanged. `uber_check()` now distinguishes *damaged* (step over it; that
is what the ring is for) from *too new* (stop), because falling back to an
older uberblock mounts a stale filesystem and reports success. Proven on a
forged image: the old code mounted the stale root and exited 0.
Still owed: an actual upgrade step, which is only writable once there are
two formats. See [../architecture/schfs.md](../architecture/schfs.md).
**Found while doing the above, and fixed:**
- [x] ~~Every `AF_INET` socket failed with `ENFILE` until DHCP bound.~~
`inet_new()` required an *address* before it would hand out a socket, and
reported the refusal as "file table overflow". Creating a socket is not
the same as having somewhere to send; a bind or connect with no route
fails on its own with an error that says so. `net_is_ready()` (lwIP up) is
now distinct from `net_is_up()` (DHCP bound).
- [x] ~~`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` above made the refusal look
real. A test asserting a failure has to say *which* failure.
**The suite itself:**
Both of these were found by running `make schfs-check` rather than by reading
it, and both predate the current work — confirmed by stashing and re-running.
They are here rather than filed as nice-to-haves because a suite that is red for
known reasons stops being read, and then it is red for unknown reasons too.
- [x] ~~**`make schfs-check` fails at baseline.**~~ Fixed, and it was not only a
test bug. The allocation map was taken through a `palloc` callback and
there was **no way to give it back** — no `schfs_space_release`, and a
comment in `schfs_space_init` reading *"caller frees what was taken"* for
an API the caller did not have.
So the kernel leaked the whole map on **every unmount**, up to 2 MB a
time, and `schfs_space_init`'s own error path stranded a page it had
already taken because `npages` was only raised after *both* allocations
succeeded. The test's 518 leaked pages were the symptom that pointed at
it.
The map now carries its own `pfree` so it can only be freed by the
allocator that filled it, `schfs_vol_open`/`schfs_vol_format` release any
map the volume already holds, and unmount releases. `schfs_objtest`
asserts the balance — 520 pages taken, 520 given back — so the next leak
fails the suite instead of being absorbed by it.
- [x] ~~**31 UndefinedBehaviorSanitizer errors.**~~ Fixed, plus two the test
never reached. One root cause: a leaf's value area holds variable-length
records packed end to end, so a record begins wherever the last one ended
— a multiple of 8 only by luck — and the code read them by casting a
pointer into the block, which tells the compiler they are aligned when
they are not.
The seven record types that live at arbitrary offsets now carry
`SCHFS_PACKED`. **No on-disk layout changed**, and that was measured
before it was applied rather than assumed: every one already had explicit
`pad` fields, so sizes and member offsets are identical with and without
the annotation. Seven `_Static_assert`s now pin those sizes, because a
record that silently grows misreads every existing volume from that point
on and looks like corruption rather than like a struct that changed.
`schfs_node_hdr`, `schfs_item`, `schfs_kptr` and `schfs_uberblock` are
deliberately left alone — the node header is exactly 32 bytes, so
everything after it is already 8-aligned, and annotating them would cost
byte-wise access on the tree's hottest paths to fix a problem they do not
have.
Verified by reading an image written *before* the change: full
`schfs_fsck --check` passes on structure, checksums, key ordering, data
blocks and derived free space.
Also caught on the way: `mkschfs` passed a null pointer to `qsort` for an
empty directory. Every implementation ignores it when the count is zero;
the standard does not say they have to.
**The identity item:**
- [ ] **Boot from a USB disk.** The xHCI stack, bulk-only transport, SCSI and
the block device all work; a superblock reads back through `bread()`. What
is missing is that the root device is compiled in rather than selectable.
Without this the acceptance test above cannot run at all.
#### The `ls -lR` hang, and why it is closed
Hunted with the instrumentation it asked for, under a workload heavier than the
one that originally triggered it — ten iterations of `ls -lR /bin`, interleaved
with writes and `cd`, 33 execs and 13378 tree descents. Measured across it:
```
inode_peak 9 / 200 unchanged
file_peak 9 / 512 unchanged
buf_pinned_peak 1 / 256 unchanged
schfs0 locked 0 tx_owner_pid 0 tx_depth 0
```
Nothing climbed, nothing wedged, and the repaired `holdingsleep` assertion never
fired. **That eliminates this file's own stated hypothesis** — "a lock or
reference-count leak somewhere in the `*at()` paths" — and it is very likely the
bug was 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.
It is closed rather than chased further, and that is a deliberate application of
the two-things-in-flight rule. The instrumentation is permanent: if it ever
happens again, `/proc/fsstat` names the table, or shows `locked 1` with
`tx_owner_pid 0` and the volume lock is orphaned. That is a much better position
than the one it was reported from.
Two real defects were found on the way and fixed, neither proven to be the
cause: `holdingsleep()` never compared the owning pid, which disarmed all seven
`panic()` assertions built on it; and SchFS's `tx_mine()` compared a bare
`struct proc *`, which a recycled ptable slot makes ambiguous.
### Gate 1 — The foundations
[foundations.md](foundations.md), items 1–4. Descriptor passing, a shareable
memory object, `AF_UNIX` in the filesystem namespace.
Nothing above needs these; everything below stops without them.
- [x] ~~**Item 1 — `sendmsg`/`recvmsg` with `SCM_RIGHTS`.**~~ Built and tested;
fourteen checks in `cmd/socktest`, each moving data through the passed
descriptor rather than inspecting it. Described in
[../architecture/networking.md](../architecture/networking.md#descriptor-passing).
This was also mechanism 1 of [hybrid.md](hybrid.md) — one primitive for
both, which is the whole reason those are one plan.
- [ ] **Item 2 — a shareable memory object** (`memfd_create`, and
`mmap(MAP_SHARED)` on it genuinely working between two processes). This is
what a Wayland buffer still cannot cross on.
- [ ] **Item 3 — `AF_UNIX` sockets in the filesystem namespace.**
### Gate 2 — Init as a role — **done**
Milestones I0–I3, all built. PID 1 forks a supervisor and spawns jobs on its
behalf; the job table lives in `/proc/schinit`; a heartbeat runs both ways; and
`proc_reincarnate()` re-execs PID 1 in place rather than panicking. Both failure
paths are exercised by `sysrq` keys rather than reasoned about.
Before the desktop rather than after, because a compositor being brought up for
the first time crashes constantly, and something that restarts it and says why is
worth far more during that work than at the end of it.
The description is now [../architecture/init.md](../architecture/init.md); the
plan document it replaced has been deleted, per
[the rule](../README.md#the-rule-that-keeps-this-from-rotting).
### Gate 3 — The desktop
[wayland.md](wayland.md), W1–W3, and the terminal.
**W4 and W5 are not in 1.0.** Multi-window focus policy and a panel are
post-1.0. One terminal window that works is the bar.
---
## Not in 1.0
The point of this list is that it is *written down*, so it can stop occupying
attention. Nothing here is cancelled.
| | Why it waits |
|---|---|
| **The rest of the hybrid kernel** ([hybrid.md](hybrid.md)) | Still not a completeness requirement — but the mechanisms and the first eviction happened early, because the alternative to a driver compatibility layer is writing a driver for every device that exists, and that is not a plan. What is deferred is the foreign driver host, not the interface it needs. |
| **Dynamic linking, Mesa, SDL3** | Everything at 1.0 is statically linked. This is the single biggest post-1.0 unlock and it is a whole project. |
| **A page cache** | `memfd` covers the desktop's needs. General writable `MAP_SHARED` of a file is a real project with no 1.0 consumer. |
| **`.sip` and the installer** | 1.0 installs by writing a USB stick. That is how most systems start and it is honest. |
| **SchFS snapshots, FUSE, Linux module, sparse files, online fsck** | The format version (Gate 0) is the prerequisite for all of them, and it is the only part on a clock. |
| **The interactive crash screen** | PSOGM renders, prints a full report and emits a QR code. That is enough. |
| **CFF/OTF fonts, kerning, text layout** | TrueType and fixed advances are enough for a terminal. |
| **Routing, `getsockname`/`getpeername`, IPv6** | One interface, one default route, works. |
| **Multiple outputs, damage tracking, subsurfaces, xkbcommon** | One screen, full repaints, one keymap. |
| **Audio** | Does not exist in any form and is not required to use a machine. |
| **Panel, notifications, settings, file manager** | A terminal is a desktop. |
| **SchAPI itself** | Deliberately. It should be designed *from* the compositor's experience, not before it. |
---
## The rule for this list
**Adding to Gate 0–3 requires deleting something else from it.**
Not a productivity trick — a forcing function. The reason this project feels
overwhelming is that ~20 fronts have been open simultaneously, and no amount of
discipline makes that workable. The fix is structural.
And one working limit, worth holding to literally:
> **Two things in flight. Never three.**
One in Gate 0 and one being written up. Everything else is closed — in the
document, and out of your head.
---
## How you will know it is going well
Not by velocity. By this:
**Every closed item leaves a description behind.** When the `ls -lR` hang is
fixed, `history/kernel.md` gains an entry saying what it actually was. When the
console moves onto the text layer, `architecture/graphics.md` loses a line from
its *What it does not do* section.
Those "does not do" sections shrinking is the real progress bar, and it is the
only one that does not lie.
---
## A note worth keeping
This document was written because the work started to feel overwhelming, and
that is worth recording rather than tidying away — because the feeling was
about the *shape* of the work and not its quality, and the two are easy to
confuse when you are inside it.
The evidence at the time: SMP working on real hardware after five bugs stacked
behind each other; a copy-on-write filesystem whose crash test cuts the power one
write later, 119 times, and demands a clean mount after each; a TCP/IP stack
asserted from both ends; an xHCI driver written from the specification; and a
documentation tree that records why things are strange so they do not get
un-fixed by accident.
None of that is the output of a project in trouble. It was a project with too
many tabs open.