Started as "fix the ASAN leak in schfs_objtest" and the test turned out to be
the messenger. The allocation map was taken through a palloc callback and there
was no way to return it: 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.
Three separate leaks from that one gap:
* The kernel leaked the entire map on every unmount -- up to 2 MB, at 8 KiB
per GiB of volume -- because kmount_unmount flushed, cleared `mounted` and
walked away. This is the one that matters; the others are test-only.
* schfs_space_init stranded a page on its own error path. npages was raised
only after *both* of a pair of allocations succeeded, so a failure on the
second left the first outside the count -- unreachable to any cleanup that
could later exist.
* schfs_objtest leaked 2121728 bytes in 518 allocations, which is what made
the whole SchFS suite exit non-zero regardless of its assertions.
The map now carries its own pfree rather than taking one at release time, so it
can only ever be freed by the allocator that filled it -- the kernel's pages
come from kalloc and a host tool's from calloc, and the two are not
interchangeable. schfs_vol_open and schfs_vol_format release any map the volume
is already holding before building another, which is what stops a re-open
leaking; struct schfs_vol must therefore be zeroed before first use, and the
header now says so.
The test's own leak was the last one found and the simplest: it does
memset(&VOL, 0, sizeof(VOL)) before reopening, which wiped the map's pointers
before vol_open could release them. Eight of those, each now releasing first.
schfs_objtest asserts the balance -- 520 pages taken, 520 given back -- because
a suite that leaks cannot detect a leak: the filesystem's own allocations would
arrive as noise inside the harness's. The next one fails the run instead.
make schfs-check now exits 0. The 31 UBSAN misalignment reports are still
there and are still a Gate 0 item.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
15 KiB
SchOS 1.0
A finish line, so that the list of open work stops being infinite.
Why this document exists
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 four gates
In order. Do not start a gate before the one before it closes, and closing means a description exists in ../architecture/ — see the rule.
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:
TheNot reproducible. Instrumented and hunted; see below. Left here rather than deleted because the absence of a result is the result.ls -lRhang.Six options now genuinely reach lwIP's pcb and everything else issetsockoptreturns 0 without setting anything.ENOPROTOOPT. Round-trip checks incmd/socktest, which a permissive stub cannot pass: it has to read back a value it was set and refuse an unknown option.It is its own table entry now rather than an alias foraccept4silently drops its flags.accept— an alias would have had to read a fourth argument thatacceptnever passes, inventing aSOCK_NONBLOCKout of whatever the caller last left in that register.Timestamps on everything created after boot read 1970.Bothknow()(SchFS) andnow_sec()(v1) returneduptime()under comments saying the kernel had no wall clock. It has had one sincetime_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.getrusagedoes not distinguishRUSAGE_CHILDRENfromRUSAGE_SELF./proc/<pid>/exeis a regular file, not a symlink, soreadlink()fails andps -o commprints[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.candttf.cboth exist and work. The console still indexesfont_data[256][16]. - Load a font from disk and point the console at it.
Interrupt-driven receive.The e1000's handler callsnetdev_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/netifgainedwake_rx/wake_tick, and across three pingsrx_framesandwake_rxboth went 2 -> 6.
Structural, because everything after this compounds on it:
- A slab or pool allocator.
kallochands 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. SchFS on-disk format version policy.Done.SCHFS_VERSIONfor frame changes, pluscompat/ro_compat/incompatfeature flags in what wasreserved0— 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.
Found while doing the above, and fixed:
EveryAF_INETsocket failed withENFILEuntil 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 fromnet_is_up()(DHCP bound).True when written, false since AF_INET landed — and green the whole time, because the suite runs before DHCP binds and thesocktestasserted "AF_INET is refused, not faked".ENFILEabove 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.
-
Fixed, and it was not only a test bug. The allocation map was taken through amake schfs-checkfails at baseline.palloccallback and there was no way to give it back — noschfs_space_release, and a comment inschfs_space_initreading "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. -
31 UndefinedBehaviorSanitizer errors, all one root cause: records are read straight out of a tree leaf by casting a pointer into the block, and a record's offset within a leaf is not a multiple of 8. Sites:
obj.c(9),objw.c(11),attr.c(7),space.c(5), acrossschfs_dirent_rec,schfs_extent,schfs_extref,schfs_index_recandschfs_snap.**This is a real hazard, not sanitizer pedantry.** Unaligned access happens to work on x86, which is why it has never shown as a bug — but it is undefined in C, and a compiler that knows a pointer is 8-aligned is entitled to emit an instruction that requires it. The failure would appear under a different optimisation level or on a different architecture, and would look like filesystem corruption rather than a codegen problem. The fix that keeps the format unchanged is to stop casting: copy the bytes into an aligned local with `memmove`, or declare the records packed so the compiler stops assuming. Padding the records to 8 bytes would also work and costs on-disk space — and is now a **format change**, which is what the version policy above is for.
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, items 1–4. Descriptor passing, a shareable
memory object, AF_UNIX in the filesystem namespace.
Nothing above needs these; everything below stops without them.
Gate 2 — Init as a role
init.md, milestones I0–I3.
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.
Gate 3 — The desktop
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 hybrid kernel (hybrid.md) | An architecture improvement, not a completeness requirement. It is also the work that most wants a demanding consumer first, and 1.0's compositor is exactly that consumer. |
| 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.