SchOS/.claude/memory/schfs-phase3-writes.md
Nexoniarz 3182da3965 Carry project memory in the repo, with tools/memory-sync.sh
Claude Code keeps per-project memory at <home>/.claude/projects/<key>/,
where <key> is the project's absolute path with every separator and the
drive colon turned into a dash. That makes it both rename-fragile and
machine-specific, and this project has hit each of those once:

  - D:\DEV\Schunix -> D:\DEV\SchOS changed the key, and 43 memories were
    invisible until moved across by hand.
  - Development happens from Windows and from Linux, and each looks in a
    different place for the same checkout.

Committing .claude/memory/ fixes both -- git carries it across renames
and between hosts -- and `import` puts it wherever the local machine
expects. .claude/settings.local.json stays ignored: those are per-machine
permissions.

The colon maps to a dash rather than being dropped. Deleting it gives
D-DEV-SchOS, which matches nothing and silently finds zero memories; that
was the first attempt. On Git Bash the key comes from `cygpath -w`,
because Claude Code is a Windows program and sees D:\DEV\SchOS, not
/d/DEV/SchOS.
2026-08-09 14:27:22 +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/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/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.