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

194 lines
9.9 KiB
Markdown

---
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/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.