--- name: schfs-phase3-writes description: "SchFS phase 3 is done — full write API, in-RAM allocation map derived from the tree; the only gap left is VFS integration" metadata: node_type: memory type: project originSessionId: a665e14b-86bd-4b41-a228-51377f3e9225 modified: 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//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.