SchOS/.claude/memory/schfs-phase3-writes.md
Nexoniarz a1c965da1e include: one folder per subsystem, and includes that name it
Sixty-five headers sat side by side in include/, so `#include "irq.h"`
said nothing about where irq.h was or what it belonged to, and errno.h,
stat.h and fcntl.h at the top shadowed the system's for any host tool
built with -I./include. They are in folders now -- arch/ core/ fs/
dev/ gfx/ net/ uapi/ (and ldls/ for the Linux driver layer) -- and
every include names the folder, as Linux's do: "core/proc.h".

Done mechanically and checked the hard way: the kernel built from this
tree disassembles identically to the one built from the tree before
it. The full image boots, and userland is the same set of programs.
Paths in docs and comments follow; docs/guides/layout.md has a table
of what is where, and tools/fix-includes.py rewrites old-style
includes from the folders in the tree, for code written before this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
2026-10-23 15:05:12 +02:00

9.9 KiB

name description metadata
schfs-phase3-writes SchFS phase 3 is done — full write API, in-RAM allocation map derived from the tree; the only gap left is VFS integration
node_type type originSessionId modified
memory project a665e14b-86bd-4b41-a228-51377f3e9225 2026-08-03T13:59:08.496Z

Phase 3 done (2026-08-03), including VFS integration. SchFS is a complete, mountable, read-write filesystem. Follows schfs-phase2-mount.

mkdir /mnt && mount -t schfs /dev/hdb /mnt

then ordinary ls/cat/>/>>/mkdir/cp/rm/rmdir all work on it.

The VFS now has a real dispatch layer: struct fs_ops in include/fs/file.h, one table per filesystem, chosen by iget() from the device number and hung off the inode. v1's implementations kept their bodies and are named v1_*; SchFS's live in kernel/fs/schfs/vfs.c. Adding a filesystem is a new table, not another branch in fifteen functions. getdents64 is an fs_op for the same reason — v1's struct dirent (16-bit inum, 14-byte name) cannot represent a SchFS entry, so each filesystem emits the record stream itself.

Mounting is a substitution during path resolution (mount_add/mount_cross in fs.c), recorded nowhere on disk. .. at a mounted root has to be redirected out explicitly, or the mount becomes a subtree you can enter and never leave.

Two design decisions that contradict the older design doc — the doc is now right, older notes are not:

  • Free space is NOT tracked as tree keys. Allocating from a tree-resident map mutates the tree, which allocates, which changes the map. Instead the map is derived at mount by walking the tree (kernel/fs/schfs/space.c) and lives only in RAM. It cannot disagree with the filesystem because it is computed from it. This is what caps a mounted volume at 64 GiB (SCHFS_SPACE_PAGES).
  • Data blocks carry a generation too (schfs_extent.gen, 64-bit). Same rule as tree nodes: a run stamped with the group being built may be overwritten in place. Without it every 100-byte write copied a 4 KB block and split an extent.

Testing. make schfs-check now runs three suites — add schfs-objtest (tools/schfs_objtest.c, memory-backed, covers files/dirs/space accounting) to the two that existed. sh tools/mutate.sh injects nine bugs; all nine must be caught.

The two tests that actually earn their keep, both found by mutation testing after everything else passed:

  • Filling and emptying the volume three times on one mount and checking each round stores the same amount. A leaked bitmap heals on remount, so a remount-based check cannot see it; only spending the space again can.
  • Overwriting a block after a commit. Within one transaction group both the tree and the data rewrite in place, so a test that never commits never runs the copy-on-write path at all.

struct schfs_vol is not copyable — tree.dev points at its own dev field. Open into the storage it will live in.

Migrating root off the xv6 legacy FS (2026-08-09)

Nexoniarz: kill the legacy FS, keep SchFS ("SchFS is goat"). Agreed — and it is a migration, not a rewrite: SchFS is 4,204 lines across 7 files against kernel/fs/fs.c's 1,830, and it already builds images, mounts, and dispatches through struct fs_ops. Every xv6 legacy problem (DIRSIZ 14, the 16-byte dirent, the HTree byte layout, ushort inum) lives below a filesystem that has already been replaced but never promoted.

The one line holding xv6 in charge:

kernel/fs/fs.c:1642:  ip = (*spath == '/') ? iget(ROOTDEV, ROOTINO) : idup(start);

Blocker 1, now cleared: mkschfs would not build on Windows. It needed sys/sysmacros.h (major/minor) and lstat/readlink/S_ISLNK, none of which mingw has. Stubbed under __MINGW32__ — honest here because Windows has no mknod and find sysroot -type l returns nothing, so neither device nodes nor symlinks can appear in the tree being imaged. If symlinks are ever staged (procfs wants /proc/<pid>/exe to be one) this host can no longer build a faithful image and the stub must become a real reparse-point read. make build/schfs.img now works: 586 of 8000 blocks, 2344 KB, generation 2.

Blocker 2, confirmed and not started: booting with schfs.img as the root disk panics fsinit: invalid file system — v1 fsinit() probes ROOTDEV for a v1 superblock and halts. So the order is:

  1. fsinit/namei reach the root through fs_ops instead of iget(ROOTDEV, ROOTINO).
  2. A ramroot-equivalent SchFS image, so root can be swapped without a disk.
  3. Point ROOTDEV at the SchFS volume; keep v1 mountable until it is proven.
  4. Delete fs.c.

Do this with a fresh session. Swapping the root filesystem is the change most able to leave an unbootable image, and step 1 wants room to boot, fail and retry.

Sizing step 1 (2026-08-09) -- it is small

ops_for(dev) in kernel/fs/fs.c:278 already dispatches the whole VFS:

if (dev == SCHFS_DEV)  return &schfs_ops;
if (dev == PROCFS_DEV) return &procfs_ops;
return &v1_ops;                 // legacy, only because it is the default

struct fs_ops (include/fs/file.h:35) is a complete vtable -- iload, istore, ifree, read, write, truncate, dirops -- and SchFS implements all of it. So fs.c is no longer "the filesystem"; it is the fallback branch. Killing it is three edits, not a rewrite:

  1. fsinit() stops requiring a v1 superblock on ROOTDEV; call schfs_kmount().
  2. ROOTDEV becomes SCHFS_DEV so ops_for returns schfs_ops for /.
  3. namei's iget(ROOTDEV, ROOTINO) uses schfs_kroot() for the root inode.

Then v1_ops has no caller and fs.c can go. Keep a build flag or a second disk to boot v1 until SchFS has carried / through a full bootcheck run.

Nexoniarz calls the legacy one "ShitFS v1" -- worth knowing that "SchFS v1" in older notes means the xv6-derived filesystem, not early SchFS.

The recycle bin, removed 2026-08-09

sys_delete (syscall 51, NO_LINUX) set an on-disk inode flag showmode = 0, and fileread/filewrite then refused every operation with a printf. A "deleted" file still existed, still had its name, still held its blocks, and answered read() with -1 -- and since nothing was freed, deleting filled the disk faster. Both gates removed; the syscall entry and the on-disk field stay only because pulling them touches the inode layout, and both die with fs.c.

SchFS is the root filesystem -- default since 2026-08-09

make builds it, make qemu boots it, tools/bootcheck.ps1 tests it. There is no opt-in switch any more: schfsroot, -DSCHFS_ROOT and the -SchFS / -SchFSRoot bootcheck switches are all gone. schunix.img depends on build/schfs.img, and a kernel handed anything else on device 1 panics in forkret().

Full suite green -- base, -Tty, -Ps, -Sock, -Fb, -Usb, -Evdev.

Three edits carried / over:

  1. kernel/proc/proc.c forkret: schfs_kmount(ROOTDEV) + schfs_kmounted() check, replacing fsinit(ROOTDEV). Mounted there and not in main() because mounting reads blocks, bread() sleeps, and sleeping needs a process context. schfs_kmount returns void -- it reports failure by leaving the volume unmounted, because elsewhere it is a probe.
  2. kernel/fs/fs.c namei: root is iget(SCHFS_DEV, schfs_kroot()), so ops_for() routes / through schfs_ops. SCHFS_DEV is 64 -- a synthetic number for ops dispatch, not the disk the volume lives on.
  3. kernel/fs/log.c: begin_op/end_op return immediately, the rest is #if 0. The v1 log made multi-block v1 updates atomic; SchFS gets that from CoW. They stay as no-ops because sys_fs.c calls them in dozens of places.

The bug that cost the most, and would cost it again: sinit() sets pid 1's cwd with namei("/") in main(), before forkret mounts anything. The old v1 root survived that because iget(ROOTDEV, ROOTINO) is two compile-time constants that touch no disk -- the inode fills in lazily at the first ilock. SchFS reads its root object id off the uberblock, so pid 1 got a cwd built from an unmounted volume. Absolute paths never consult cwd, so exec("/bin/init") worked and everything looked healthy; the first relative lookup is init's open("console"), which failed, so init had no fd 0/1/2, so every shell it spawned exited at once and init respawned it forever. Silent boot, no init: ready, indistinguishable from a hang. Fixed by re-pointing myproc()->cwd after the mount.

Also: the kernel execs /bin/init (lib/initcode.S), not /init. Probing the wrong path made lookup look broken when it was not.

Still to do -- the v1 format code is compiled but unreachable from /. fs.c cannot simply be deleted: iget/ilock/iput/namei/readi, the whole VFS layer, live in it too. Only the v1 on-disk format goes -- fsinit, the superblock, bmap/balloc, v1_ops, and the third-party HTree/extent code -- plus log.c, tools/mkfs.c and build/fs.img. The MIT attribution in NOTICE stays regardless (schunix-licensing). fs.img is still built: the -Usb suite uses it as the USB stick.

Two harness bugs found doing this, both of which faked OS failures

  • bootcheck slept a flat 4s between commands. ls /bin | wc -l overran it, so the next command was typed while the shell was busy -- the tty echoed it, no reader took it, and it landed concatenated onto the following one. The transcript read cat /proc/pcicat /proc/schfs and three PCI checks failed, looking exactly like a broken PCI driver. Now it waits for the prompt by polling the logfile QEMU is writing. (It still must not read QEMU's stdout -- that deadlocks, see the script header.)
  • The USB stick was $hdb, so the root volume was attached twice, once as a disk and once over USB. That failed the storage checks and the xHCI interrupt check. The stick is pinned to build/fs.img now and -Usb is green.

The general lesson, twice over: a red check is not evidence until the harness is ruled out. Both of these looked like driver bugs.