schfs: the commit barrier, reflink, and attributes with an index over them

The last three phases of the filesystem, which were written together and
touch the same files throughout -- kmount.c, objw.c and vfs.c are each
rewritten by all three -- so they land as one commit rather than three
that could not be built separately.

Durability. A transaction that reaches the platter out of order is worse
than one that never reached it: the uberblock can name a tree whose
blocks are still in the drive's write cache. bdirty/bflush give the
buffer layer an explicit barrier, ideflush issues ATA FLUSH CACHE and
usb_storage_flush the SCSI SYNCHRONIZE CACHE that means the same thing,
and a commit now waits for the data before it writes the root that
points at it. The uberblock groups are read newest-first with fallback,
so a volume whose newest roots are torn mounts at an older one and says
which generations were lost.

Reflink. Extents carry a refcount, so two objects can name the same
blocks and neither frees them while the other holds them. Exposed as
FICLONE -- Linux's ioctl value, so `cp --reflink` works unmodified --
and as cmd/reflink for the case where there is no cp to hand.

Attributes, and the one genuinely new idea here. attr.c stores named
values on an object, reachable through setxattr/getxattr/removexattr at
Linux's syscall numbers. On top of that, schfs_index declares an index
over an attribute name and schfs_query answers "which objects carry this
value" from the B-tree rather than by walking. A query against an
unindexed attribute is refused with an error, never silently answered by
a full scan: a query that quietly becomes a scan is worse than one that
admits it cannot help. cmd/schattr drives both.

Three bugs fixed on the way, each found by the numbers being wrong
rather than by inspection:

  - statfs's f_fsid was declared int64[2]. Linux's fsid_t is two *ints*,
    so every field after it landed one slot late: userland read f_frsize
    out of f_namelen, got 255, and df reported a 32 MB root filesystem
    as 1992 KB. It hid for as long as it did because the fields used to
    be invented constants, so wrong numbers looked like the usual ones.
  - struct inode's nlink was a short against SchFS's uint32, so a file
    past 32767 names came back negative -- and iput frees an object
    whose count reaches zero. It is an int now, signed on purpose, so an
    underflow lands somewhere a check can see rather than wrapping.
  - filewrite split every write to fit v1's log. That limit belongs to
    the journal, not to writing, and SchFS makes one transaction of
    whatever it is handed: a 1 MB write was becoming 170 commits and
    170 flushes for nothing. fs_ops carries max_write now, and SchFS
    sets no limit.

Also here: a real mount table, so `mount -t schfs /dev/hdb /mnt` shows
up in /proc/mounts -- df looks a path up there before it calls statfs at
all, so a mount missing from that list was invisible to it -- several
volumes mounted at once, snapshots mounted by name, and /proc/fsstat
with an rdtsc-based counter, which is the only instrument fine enough to
see inside a single syscall.

Verified: `make schfs-check` passes in full, including the sweep that
crashes the volume at each of 119 points mid-transaction and requires
every one to mount with exactly its committed data, and the case where
both newest uberblocks are rubbish and the volume still comes back. The
headless boot check passes with 27 [ok] and no failures, on a SchFS root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-08-15 16:01:22 +02:00
commit 03cc316001
39 changed files with 3942 additions and 550 deletions

View file

@ -85,70 +85,118 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev,
struct schfs_ctx *x, void *(*palloc)(void))
{
struct schfs_uberblock *u = (struct schfs_uberblock *)x->a;
int best_slot = -1;
uint64_t best_gen = 0;
int io_errors = 0;
int io_errors = 0;
int seen = 0; // valid uberblocks found, at any generation
uint64_t ceiling = ~0ULL; // consider only generations below this
int spaced = 0; // the allocation map is built and owned
v->dev = *dev;
v->ctx = *x;
v->tree.dev = &v->dev;
v->recovered = 0;
// Remember which slot won, not a copy of it.
// Walk the ring newest-first, and keep walking.
//
// An uberblock is a whole SCHFS_BLKSIZE block, and keeping one as a local
// put 4096 bytes on a kernel stack that is 4096 bytes in total — the canary
// in kalloc'd stacks caught it as "kstack overflow" on the very first boot.
// Re-reading the winner costs one block read, once, at mount.
for (int i = 0; i < SCHFS_UBER_COUNT; i++) {
if (v->dev.bread(v->dev.ctx, SCHFS_UBER_START + i, u) != 0) {
io_errors++;
continue; // an unreadable slot is not fatal: try the rest
// This used to take the highest valid generation, check its tree, and give
// up if that failed. But an uberblock passing its own checksum only proves
// the uberblock landed -- not the tree it names. That is exactly the state
// a power cut used to leave behind before commit had a barrier, and it is
// still what a misdirected write or a bad sector under the root produces.
//
// A ring of SCHFS_UBER_COUNT slots exists precisely so there is somewhere
// to fall back *to*. Refusing the mount while 127 older, entirely intact
// roots sat on the disk was throwing away the only thing the ring is for.
for (;;) {
int best_slot = -1;
uint64_t best_gen = 0;
for (int i = 0; i < SCHFS_UBER_COUNT; i++) {
if (v->dev.bread(v->dev.ctx, SCHFS_UBER_START + i, u) != 0) {
if (ceiling == ~0ULL)
io_errors++; // count each slot once, on the first sweep
continue; // an unreadable slot is not fatal: try the rest
}
if (!uber_valid(u))
continue;
if (u->generation >= ceiling)
continue; // already tried, and rejected
if (best_slot < 0 || u->generation > best_gen) {
best_gen = u->generation;
best_slot = i;
}
}
if (ceiling == ~0ULL)
seen = best_slot >= 0;
if (best_slot < 0)
break; // nothing older left to try
ceiling = best_gen; // next round considers strictly older ones
// Re-read the winner rather than keeping a copy of it.
//
// An uberblock is a whole SCHFS_BLKSIZE block, and keeping one as a
// local put 4096 bytes on a kernel stack that is 4096 bytes in total --
// the canary in kalloc'd stacks caught it as "kstack overflow" on the
// very first boot. Re-reading costs one block read per candidate.
if (v->dev.bread(v->dev.ctx, SCHFS_UBER_START + best_slot, u) != 0)
goto next;
if (!uber_valid(u))
continue;
if (best_slot < 0 || u->generation > best_gen) {
best_gen = u->generation;
best_slot = i;
goto next; /* changed under us, or a flaky device */
vol_from_uber(v, u, best_slot);
v->readonly = 1; // until an allocation map exists, below
if (v->dev.bread(v->dev.ctx, v->tree.root, x->a) != 0)
goto next;
{
struct schfs_node_hdr *h = (struct schfs_node_hdr *)x->a;
if (h->magic != SCHFS_NODE_MAGIC || h->blocknr != v->tree.root)
goto next;
}
// Rebuild the allocation map from the tree just adopted. Without a
// palloc the volume stays read-only, which is exactly right for a tool
// that only inspects an image: it can then never allocate, rather than
// allocating from a map that says everything is free.
//
// The scan is also the deepest check this root gets: it visits every
// metadata block and verifies each against the checksum in its parent.
// A root that survives it is not merely present, it is whole -- so a
// scan failure is a reason to try the next candidate, not to give up.
if (palloc) {
if (!spaced) {
// Sizing failures are the caller's problem, not this root's:
// a volume too large to map or a machine out of pages will
// fail identically on every slot in the ring.
int sr = schfs_space_init(&v->space, v->total_blocks, palloc);
if (sr != 0)
return sr;
spaced = 1;
} else {
schfs_space_reset(&v->space, v->total_blocks);
}
if (schfs_space_scan(v) != 0)
goto next;
v->free_blocks = v->space.free_blocks;
v->readonly = 0;
}
// Which attributes are indexed, so attr.c can answer without a lookup
// on every write. A volume with no indexes -- every volume until
// somebody asks for one -- ends up with an empty list.
schfs_index_reload(v);
return 0;
next:
v->recovered++; // this candidate was newer, and unusable
continue;
}
if (best_slot < 0)
return io_errors == SCHFS_UBER_COUNT ? -2 : -1;
if (v->dev.bread(v->dev.ctx, SCHFS_UBER_START + best_slot, u) != 0)
// Nothing usable. Distinguish "not a SchFS volume" from "a SchFS volume
// that cannot be read", because the first is an ordinary answer to a probe
// and the second is a damaged disk somebody needs to hear about.
if (io_errors == SCHFS_UBER_COUNT)
return -2;
if (!uber_valid(u))
return -2; /* changed under us, or a flaky device */
vol_from_uber(v, u, best_slot);
v->readonly = 1; // until an allocation map exists, below
// The newest uberblock passing its own checksum does not prove the tree it
// names is readable — a misdirected write could have landed on the root.
// Check now, while there is still the option of falling back, rather than
// discovering it on the first lookup.
if (v->dev.bread(v->dev.ctx, v->tree.root, x->a) != 0)
return -2;
{
struct schfs_node_hdr *h = (struct schfs_node_hdr *)x->a;
if (h->magic != SCHFS_NODE_MAGIC || h->blocknr != v->tree.root)
return -2;
}
// Rebuild the allocation map from the tree just adopted. Without a palloc
// the volume stays read-only, which is exactly right for a tool that only
// inspects an image: it can then never allocate, rather than allocating
// from a map that says everything is free.
if (palloc) {
int sr = schfs_space_init(&v->space, v->total_blocks, palloc);
if (sr != 0)
return sr;
if (schfs_space_scan(v) != 0)
return -2;
v->free_blocks = v->space.free_blocks;
v->readonly = 0;
}
return 0;
return seen ? -2 : -1;
}
int
@ -181,9 +229,30 @@ schfs_vol_commit(struct schfs_vol *v)
memmove(u->label, v->label, sizeof(u->label));
u->crc = uber_crc(u);
// The barrier the whole design rests on. Every block the new root reaches
// has been handed to bwrite by now, but "handed to bwrite" is not "on the
// medium": the kernel defers tree writes into the buffer cache, and the
// drive under it has a write cache of its own. Both are free to reorder.
//
// So: drain everything, *then* write the block that makes it live. A crash
// before this point leaves the previous uberblock newest and nothing is
// lost; a crash after it finds a tree that is fully present. Without the
// barrier there is a third outcome -- a newest uberblock naming a tree that
// never landed -- and it is unrecoverable, because the checksum on the
// uberblock is perfectly valid.
if (v->dev.bflush && v->dev.bflush(v->dev.ctx) != 0)
return -2;
if (v->dev.bwrite(v->dev.ctx, SCHFS_UBER_START + v->uber_slot, u) != 0)
return -2;
// And the uberblock itself, before this function claims the transaction is
// durable. Everything below acts on that claim: the blocks this group freed
// are handed back to the allocator, which is only safe once the group that
// still referenced them can no longer be what a crash comes back to.
if (v->dev.bflush && v->dev.bflush(v->dev.ctx) != 0)
return -2;
// Order matters here, and it is the whole crash-safety argument in three
// lines. Only now that the new uberblock is on the disk is the group this
// freed blocks in superseded, so only now may those blocks be handed out