SchOS/docs/architecture/schfs.md
Nexoniarz 7c8cf51941 schfs: records at arbitrary offsets are packed, and the compiler is told
A leaf's value area holds variable-length records end to end, so a record begins
wherever the one before it finished -- a multiple of 8 only by luck. The code
reads them by casting a pointer into the block, which tells the compiler they
are aligned when they are not.

That works on x86, which is why it has never shown as a bug, and it is undefined
in C. A compiler entitled to assume 8-byte alignment may emit an instruction
that requires it, and the failure would arrive at a different optimisation level
or on a different architecture looking like filesystem corruption rather than
like codegen. UndefinedBehaviorSanitizer reported 31 of them across obj.c,
objw.c, attr.c and space.c.

Seven types now carry SCHFS_PACKED: dirent_rec, extent, extref, index_rec,
attr_rec, snap and inode. Two of those -- attr_rec and inode -- were never
reported, because schfs_objtest does not reach an unaligned instance of either.
They were latent rather than absent, and casting into a leaf is the same lie
whether or not a test has caught it yet.

NO ON-DISK LAYOUT CHANGED, and that was measured before the annotation was
applied rather than assumed: every one of these already carries explicit `pad`
fields and therefore has no implicit padding, so sizes and member offsets are
identical with and without it. Seven _Static_asserts now pin the 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 the items and key pointers after
it are 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: schfs_fsck --check
passes on structure, checksums, key ordering, data blocks and derived free
space, and the kernel mounts its own root unchanged.

Also here: 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.

make schfs-check now runs with no sanitizer output at all.

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

18 KiB
Raw Blame History

SchFS

The root filesystem. A copy-on-write B-tree with an uberblock ring, 64-bit object ids, checksummed metadata and extents.

It is the root. forkret() mounts it before PID 1 has a working directory and panics if it is not there. All five design phases are complete: the B-tree, read-only mount, the write path and transaction groups, the commit barrier and uberblock fallback, extent refcounts and reflink, and attributes with an index and a query.

The phases themselves, and the bugs they produced, are in ../history/schfs.md. The VFS it plugs into is filesystem.md.


The argument in one line

A crash leaves either the old tree or the new one, so there is nothing to repair — and free space is derived from the tree rather than stored beside it, so there is no second structure to disagree with the first.

/proc/schfs shows the generation counter advancing as writes commit.


What was taken, from where

ZFS — never be inconsistent. Copy-on-write: no block that is part of the live tree is ever overwritten. An update writes new blocks, then atomically flips a single root pointer. A crash therefore lands on either the old tree or the new one — never a half-written mix. Consequence: no fsck, ever. Paired with end-to-end checksums where a block's checksum is stored in its parent pointer, not in the block itself, which makes the whole tree self-validating and catches misdirected writes and bit rot that a self-stored checksum cannot.

btrfs — one data structure, reused everywhere. btrfs has essentially one engine: a copy-on-write B-tree. Inodes, directory entries, extent maps, free space, and snapshots are all just different key types in that same tree. For a small team this is the single most important idea in the whole design: you write and debug one tricky data structure, then get five subsystems from it. Also takes extents with reference counts, which is what makes snapshots and reflink copies fall out almost for free.

BeFS — the part that feels like a database, at a size one person can build. BeFS is a 64-bit journaled filesystem whose distinguishing feature is B+tree indexes over file attributes, with a query interface on top. That is the "SQL-like" capability, and unlike WinFS it actually shipped. Critically, BeFS is also the right scale model: a small team, a readable codebase, and a book (Giampaolo, Practical File System Design, free online) that describes it end to end.

What was deliberately not taken

  • No ZFS storage pools / RAID-Z / vdevs. Multi-disk redundancy is a large subsystem that solves a problem SchOS does not have yet.
  • No btrfs-style rebalancing or online format conversion. Enormous complexity, and it is the historical source of btrfs's worst bugs.
  • No actual SQL. No parser, no query planner, no relational engine. See Attributes, indexes and the query below — the useful part of "database filesystem" is the index, not the language. WinFS spent 2003-2006 and a large team on the other interpretation and shipped nothing.
  • No deduplication. ZFS's dedup is famous for needing more RAM than anyone expects. Extent refcounts give reflink/snapshot benefits without it.

On-disk shape

+--------------------+  block 0        boot block (untouched by the FS)
+--------------------+  blocks 1..N    uberblock array (128 slots, rotated)
+--------------------+
|  CoW B-tree        |  one tree, many key types:
|                    |    (ino, INODE,  0)      -> inode record
|                    |    (ino, DIRENT, hash)   -> directory entry
|                    |    (ino, EXTENT, offset) -> extent mapping
|                    |    (ino, ATTR,   nameid) -> extended attribute
|                    |    (idx, INDEX,  key)    -> attribute index entry
+--------------------+
|  free space        |  extent-based, tracked as tree keys, not a bitmap
+--------------------+

Uberblock array (from ZFS). 128 slots written round-robin, each carrying a transaction number and a checksum. Mount scans all slots and takes the highest-numbered one that validates. This is what makes crash recovery "pick the newest good root" instead of "replay a journal."

Everything is one B-tree (from btrfs). Keyed by (objectid, type, offset), 24 bytes. Adding a feature usually means adding a key type, not a new on-disk structure.

Checksums live in the parent (from ZFS). Each internal node stores, for every child pointer, that child's CRC32c. A block that is read is verified against the pointer that led to it. v1 already chose CRC32c and already has checksum fields in dinode and superblock — same algorithm, better placement.

Widths — every v1 limit that has to die

v1 limit v2
dirent.inum is ushort 65,535 files, total uint64 object ids
dinode.size is uint 4 GB max file uint64
uint block numbers, BSIZE 1024 4 TB volume uint64 blocks, 4096-byte
DIRSIZ 14 14-char filenames variable, up to 255
fixed hash table per dir every dir ≥113 KB, capped at 119 KB B+tree, pay per entry
addrs[NDIRECT+3] block-at-a-time extents with refcounts

Block size moves 1024 → 4096 to match modern media and make extents worthwhile.

Attributes, indexes, and the query

What you want from "works like SQL" is: ask a question about a lot of files and get an answer fast, without walking every directory. That is an index, not a query language. BeFS's design:

  • Files carry typed attributes (string, int, time, …).
  • An attribute can be indexed: a dedicated B+tree keyed by attribute value, mapping value → object ids.
  • A query is a predicate over indexed attributes, evaluated by intersecting index ranges.

In SchFS this is just another key type — (idx, INDEX, key) — in the tree that already exists. That is the whole reason the tree was built the btrfs way: the "database filesystem" feature becomes a few hundred lines on top of machinery already written and debugged, rather than a separate engine.

A concrete query() syscall and a find-like tool come after that, and are small by comparison.

Limits

Three different things get called "the limit", and they are worth keeping apart: what the on-disk format can express, what this implementation can currently mount, and what the rest of SchOS can carry. The smallest one is what you actually hit.

The format

limit where it comes from
Volume 64 ZiB (2⁶⁴ × 4 KiB blocks) uint64 block numbers
File 16 EiB (2⁶⁴ bytes) schfs_inode.size
Files per volume 1.8 × 10¹⁹ uint64 object ids
Hard links per file 4.3 × 10⁹ uint32 nlink
Filename 255 bytes SCHFS_NAME_MAX
Entries per directory no fixed limit one tree key per name hash
Extent 64 ZiB in one record uint64 nblocks
Tree depth 8 levels SCHFS_MAX_LEVEL
Block size 4096 bytes, fixed SCHFS_BLKSIZE; a mismatch is refused at mount
Transaction groups 1.8 × 10¹⁹ uint64 generation, never reused

Directory depth is unbounded on disk; path resolution is what limits it.

This implementation

limit where it comes from
Volume, mounted 64 GiB the in-RAM allocation bitmap: SCHFS_SPACE_PAGES (512) pages × 32768 blocks
Volumes mounted at once 4 SCHFS_VOL_SLOTS; slot 0 is the root
Snapshots mounted at once 4 SCHFS_SNAP_SLOTS
Volume, via the buffer cache 4 TiB bio.c addresses blocks with a uint, and one SchFS block spans four of them
RAM per mount 8 KiB per GiB of volume two bitmaps, one bit per block each
Inline value in a leaf 1016 bytes SCHFS_MAX_INLINE = a quarter of a node
Colliding names in one directory ~60, sharing a hash the packed list for one hash must fit in one inline value
Directory entries at 8-byte names ~42 per key, unlimited keys as above
Records per leaf / children per node 127 / 84 4 KiB minus a 32-byte header
Mount time one pass over the metadata the allocation map is derived, not stored

The 64 GiB mount ceiling is the one that bites first, and it is one constant: raising SCHFS_SPACE_PAGES to 4096 makes it 512 GiB at 64 KiB of pointers per mount. It is set where it is because the page-pointer vector lives inside struct schfs_vol, which the kernel holds as static storage.

The rest of SchOS

These are VFS and driver limits rather than SchFS ones:

  • MAXPATH caps a path at 128 bytes.
  • NMOUNT allows 8 mounts.
  • The IDE driver handles the primary channel only: 2 ATA devices.
  • The IDE driver uses LBA48 past 128 GiB, so the disk is not the limit there.
  • A single read/write call is capped at 4 GiB (n is a uint); the file itself is not.

The two that used to be here — a 16-bit inode number in struct dirent and a 32-bit inum/size in struct inode — are gone. getdents64 is a per- filesystem operation now, so SchFS emits its own records with 64-bit inode numbers and names up to 255 bytes, and the inode carries 64-bit numbers and sizes throughout.


Format version, and what a bump means

Two mechanisms, because one is not enough. SCHFS_VERSION changes only when the frame changes — the uberblock, the node header, the key, the things any reader must understand before it can parse anything at all. Everything else is a feature flag, in one of three classes:

Class An unknown bit means
compat Ignore it. Mount read-write.
ro_compat Mount read-only. A writer would maintain a structure this driver does not know about, and writing would leave it stale.
incompat Refuse. The volume cannot be parsed correctly without it.

Choosing the class is the whole design decision, and the test is one question: what happens if a driver that does not know this feature writes to the volume? Nothing → compat. Something on disk goes stale → ro_compat. Something becomes unreadable → incompat.

The flags occupy what was reserved0, so every field after them keeps its offset and existing images — which have zeros there — read identically. schfs_vol_commit memsets the uberblock before filling it in, which is why volumes written before the field existed already say "no features". That trick is available exactly once; the next addition costs a version bump.

Four _Static_asserts pin the uberblock to one block and pin version, the feature words and the field after them to fixed offsets, because those are on-disk facts rather than C conveniences.

Refusing is not falling back

The uberblock ring exists so that a damaged newest uberblock can be stepped over — that is crash recovery, and the older root is a consistent filesystem. A newest uberblock that is sound but too new is the opposite case, and treating the two alike is how a mount quietly loses data:

Falling back mounts a stale filesystem. Every commit made since the volume was upgraded is discarded, the mount reports success, and nobody finds out.

So uber_check() answers with a state rather than a yes/no — BAD / OK / RO / TOO_NEW — and the scan stops on TOO_NEW instead of continuing to the next candidate. The order of its checks is the order of the questions: is it ours (magic), is it intact (crc), can we read it (version, features), does it make sense (root in range). Compatibility is asked before sanity deliberately — the fields a sanity check reads are only meaningfully placed if the format is one we know.

Demonstrated rather than asserted, on a forged image whose two uberblocks are (version 1, generation 1, root 129) and (version 2, generation 2, root 130): the old code mounted root 129 and exited 0, discarding the live filesystem; the current code refuses and says why.


Records are packed, and the compiler is told

A leaf's value area holds variable-length records end to end, so a record starts wherever the previous one finished — 8-aligned only by luck. The seven types that live there carry SCHFS_PACKED, because casting a pointer into a block and calling it a struct schfs_dirent_rec * is otherwise a lie to the compiler: the type promises alignment the address does not have.

The annotation changes no on-disk layout. Every one of those structs already carries explicit pad fields and so has no implicit padding; sizes and offsets are identical with and without it, and seven _Static_asserts hold them there. What changes is the generated code — byte-wise access where alignment cannot be proven, which is the truth of the situation.

The block-level structures are deliberately excluded. schfs_node_hdr is exactly 32 bytes, so the items and key pointers after it are already aligned, and packing them would cost byte-wise access on the tree's hottest paths for nothing.


One source, two compilers

Everything except kmount.c is compiled both into the kernel and into the host tools.

The writer and the reader cannot drift apart, because there is one implementation of the format and nothing to disagree with.

That is why mkschfs cannot produce an image the kernel misreads, and why format bugs are found on the host where a test takes a second instead of a boot.


The source

include/schfs.h              on-disk format + compile-time layout assertions
kernel/fs/schfs/btree.{c,h}  the copy-on-write B-tree and its cursor
kernel/fs/schfs/schfs_int.h  volume + object layer interface
kernel/fs/schfs/space.c      allocation bitmap, derived from the tree at mount
kernel/fs/schfs/vol.c        uberblock ring: open, format, commit
kernel/fs/schfs/obj.c        read side: inodes, dirents, extents, path lookup
kernel/fs/schfs/objw.c       write side: create, link, unlink, rename, write
kernel/fs/schfs/kmount.c     kernel: block device, mount slots, transaction bracket
kernel/fs/schfs/vfs.c        the fs_ops table: SchFS behind open/read/write
tools/mkschfs.c              build an image from a directory tree
tools/schfs_fsck.c           verify, list, extract
include/fsstat.h             counters behind /proc/fsstat: where the time goes
tools/schfs_test.c           B-tree torture harness
tools/schfs_objtest.c        object layer: files, directories, space accounting
tools/posix/schfs_roundtrip.sh     end-to-end filesystem test
tools/posix/mutate.sh              mutation-tests the suites themselves
tools/windows/bootcheck.ps1          boots under QEMU and drives the shell

Everything except kmount.c is compiled both into the kernel and into the host tools, so the writer and the reader cannot drift apart: there is one implementation of the format and nothing to disagree with.

The test suites

make schfs-check         all three suites
make schfs-test          B-tree only
make schfs-objtest       object layer only
make schfs-image-test    round-trip only

make schfs-check after touching anything under kernel/fs/schfs.

The round-trip's oracle is the host's own filesystem: an image is built from a directory tree, extracted with a separate program, and diff -r'd against the original. It also asserts hard-link counts (which diff cannot see), checks that rebuilding produces byte-identical output, and runs the whole thing a second time with the name hash cut to 6 bits so the packed-dirent collision path — which a 64-bit hash would never reach — is actually exercised.

schfs_objtest runs against a memory-backed volume, so a full create/write/delete/remount cycle takes milliseconds. Its most important check is the least glamorous: after deleting everything, free space must be back to exactly what it was on an empty volume. Every other test in the suite still passes on a filesystem that leaks a block on every delete.

Its section 10 is crash recovery, and it exists because the whole consistency argument for this format — "mount takes the newest uberblock that validates, so a crash lands on either the old tree or the new one" — had no end-to-end test at all. schfs_test injected crashes into the B-tree; nothing did it to a volume. The crash is a longjmp out of a block write, which is the only faithful model: returning an error tests error handling instead, and letting execution continue with the write swallowed has the filesystem reading back blocks it believes it just wrote, which no real failure does. It runs the same transaction 119 times, cutting the power one write later each time, and after every one the volume must mount, check out structurally, and still hold the data committed before it.

tools/posix/mutate.sh injects six deliberate bugs and fails if any of them goes undetected. It is how the suites are kept honest; see ../history/schfs.md.

Verifying on real hardware

make ramroot             root from RAM, so device 1 is free
make build/schfs.img
make qemu-schfs          then, at the shell:  cat /proc/schfs

.\run.ps1 -SchFS does the same on Windows, and tools\bootcheck.ps1 -SchFS does it unattended and asserts on the transcript.


What it does not do

  • No snapshots. The B-tree is copy-on-write, which is most of the machinery — nothing takes or names a snapshot. This is the missing feature the existing design already pays for.
  • No journal replay beyond what copy-on-write gives for free.
  • No sparse files. A hole is not representable; writing at a large offset allocates everything before it.
  • No online fsck. schfs_fsck is a host tool and the volume must not be mounted.
  • No upgrade path. The version policy below refuses a volume it is too old for; nothing converts one format to another, because there is only one format. The first bump is when that has to be written.
  • No Linux driver. FUSE first, then possibly a kernel module, and not before the format is pinned.
  • No deduplication, deliberately — ZFS's is famous for needing more RAM than anyone expects, and extent refcounts give the reflink and snapshot benefits without it.
  • No multi-disk anything — no pools, no RAID-Z, no vdevs.
  • No rebalancing or online format conversion, deliberately: enormous complexity, and historically the source of btrfs's worst bugs.