The live root was a fixed 96 MB image in the kernel: 60 MB of zeros on the ISO, twice, and room for 60 MB of packages whatever the machine had. Now the image is only what the root holds, plus slack, measured by building it once large. Past its end the ramdisk goes on to half of the memory free at mount, as pages taken the first time each block is written; an unwritten block reads as zeros and costs nothing. SchFS keeps no free-space map on disk, so growing a volume is a larger total before open builds the map: grow_to, set for the ramdisk root. 8 GB machine: a 4 GB root, Xorg installed into it in 77 s with 7.6 GB still free. The ISO is 172 MB instead of 344. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
461 lines
19 KiB
C
461 lines
19 KiB
C
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
//
|
|
// SchFS volume layer: the uberblock ring.
|
|
//
|
|
// A ring of SCHFS_UBER_COUNT slots is written round-robin, each stamped with a
|
|
// transaction number and a checksum. Mounting reads every slot and adopts the
|
|
// highest-numbered one that validates.
|
|
//
|
|
// That single rule replaces journalling entirely. A commit writes one block; it
|
|
// either lands whole or it does not. If it does not, its checksum fails, the
|
|
// previous slot is still the highest valid one, and the volume comes up at the
|
|
// previous transaction with no replay and no fsck. Rotating through many slots
|
|
// rather than overwriting one means a run of bad sectors under the current slot
|
|
// cannot take the volume with it.
|
|
|
|
#include "schfs_int.h"
|
|
|
|
#ifdef SCHFS_HOSTED
|
|
#include <string.h>
|
|
#else
|
|
#include "core/types.h"
|
|
#include "core/defs.h"
|
|
#endif
|
|
|
|
// Checksum an uberblock with its own crc field zeroed, the same convention the
|
|
// tree nodes use.
|
|
static uint32_t
|
|
uber_crc(struct schfs_uberblock *u)
|
|
{
|
|
uint32_t saved = u->crc;
|
|
u->crc = 0;
|
|
uint32_t c = schfs_crc32c(0, u, sizeof(*u));
|
|
u->crc = saved;
|
|
return c;
|
|
}
|
|
|
|
// What this uberblock is, which is not the same question as whether it is good.
|
|
//
|
|
// This used to be one predicate returning "valid" or not, and a version
|
|
// mismatch answered the same as a failed checksum. That conflates two states
|
|
// the caller has to treat in opposite ways:
|
|
//
|
|
// A damaged uberblock means TRY THE NEXT ONE. That is what the ring is for,
|
|
// and falling back to an older generation is exactly right -- it is crash
|
|
// recovery, and the older root is a consistent filesystem.
|
|
//
|
|
// An uberblock this driver is too old to understand means STOP. Falling back
|
|
// mounts a *stale* filesystem: every commit made since the volume was
|
|
// upgraded is silently discarded, and the mount succeeds, so nobody finds
|
|
// out. That is data loss reported as success, which is the worst outcome
|
|
// available here.
|
|
//
|
|
// The order of the checks is the order of the questions. Is it ours (magic);
|
|
// is it intact (crc); can we read it (version, features); does it make sense
|
|
// (root in range). Compatibility is asked before sanity on purpose -- the
|
|
// fields a sanity check reads are only meaningfully placed if the format is
|
|
// one we know.
|
|
enum uber_state {
|
|
UBER_BAD = 0, // not SchFS, or damaged -- keep looking
|
|
UBER_OK, // ours, and fully usable
|
|
UBER_RO, // ours, sound, but carries ro_compat bits we lack
|
|
UBER_TOO_NEW, // ours, sound, and beyond this driver -- stop the scan
|
|
};
|
|
|
|
static enum uber_state
|
|
uber_check(struct schfs_uberblock *u)
|
|
{
|
|
if (u->magic != SCHFS_MAGIC)
|
|
return UBER_BAD;
|
|
if (uber_crc(u) != u->crc)
|
|
return UBER_BAD;
|
|
|
|
// From here the block is certainly ours and certainly landed intact, so
|
|
// anything wrong with it is a statement about the *format*, not the media.
|
|
if (u->version > SCHFS_VERSION)
|
|
return UBER_TOO_NEW;
|
|
// A different block size is not damage either: every offset would be read
|
|
// wrong, which is precisely what an incompatible format means.
|
|
if (u->blocksize != SCHFS_BLKSIZE)
|
|
return UBER_TOO_NEW;
|
|
if (u->feature_incompat & ~SCHFS_FEAT_INCOMPAT_SUPP)
|
|
return UBER_TOO_NEW;
|
|
|
|
// Self-consistency: a root inside the ring or past the end of the device is
|
|
// a corrupt uberblock that happens to checksum, not a usable one.
|
|
if (u->root < SCHFS_FIRST_BLOCK || u->root >= u->total_blocks)
|
|
return UBER_BAD;
|
|
|
|
// Read-only is a real mount, so it is decided last -- after everything that
|
|
// would have refused outright.
|
|
if (u->feature_ro_compat & ~SCHFS_FEAT_RO_COMPAT_SUPP)
|
|
return UBER_RO;
|
|
|
|
// feature_compat is deliberately not consulted. Unknown bits there are
|
|
// unknown on purpose; that is what the class means.
|
|
return UBER_OK;
|
|
}
|
|
|
|
static void
|
|
vol_from_uber(struct schfs_vol *v, struct schfs_uberblock *u, int slot)
|
|
{
|
|
v->tree.dev = &v->dev;
|
|
v->tree.root = u->root;
|
|
v->tree.root_crc = u->root_crc;
|
|
v->tree.root_gen = u->root_generation;
|
|
// The tree's `generation` is the transaction group being *built*, one past
|
|
// the newest committed one. Setting it to the committed root's generation
|
|
// instead would tell the tree that every block of the mounted filesystem
|
|
// belongs to the open transaction, and it would then rewrite the live tree
|
|
// in place — losing the very thing copy-on-write exists to preserve.
|
|
v->tree.generation = u->generation + 1;
|
|
|
|
v->total_blocks = u->total_blocks;
|
|
v->free_blocks = u->free_blocks;
|
|
v->next_objectid = u->next_objectid;
|
|
v->root_objectid = u->root_objectid;
|
|
v->generation = u->generation;
|
|
v->uber_slot = slot;
|
|
|
|
memmove(v->uuid, u->uuid, sizeof(v->uuid));
|
|
memmove(v->label, u->label, sizeof(v->label));
|
|
v->label[SCHFS_LABEL_MAX - 1] = '\0';
|
|
}
|
|
|
|
int
|
|
schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev,
|
|
struct schfs_ctx *x, void *(*palloc)(void),
|
|
void (*pfree)(void *))
|
|
{
|
|
struct schfs_uberblock *u = (struct schfs_uberblock *)x->a;
|
|
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
|
|
uint64_t too_new = 0; // newest generation this driver cannot read
|
|
int ro_forced = 0; // the adopted root carries ro_compat bits
|
|
|
|
v->dev = *dev;
|
|
v->ctx = *x;
|
|
v->tree.dev = &v->dev;
|
|
v->recovered = 0;
|
|
|
|
// Walk the ring newest-first, and keep walking.
|
|
//
|
|
// 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
|
|
}
|
|
{
|
|
enum uber_state st = uber_check(u);
|
|
if (st == UBER_TOO_NEW) {
|
|
// Recorded, not mounted, and not skipped past. Counted on
|
|
// the first sweep only, like io_errors: later sweeps walk
|
|
// the same slots with a lower ceiling.
|
|
if (ceiling == ~0ULL && u->generation > too_new)
|
|
too_new = u->generation;
|
|
continue;
|
|
}
|
|
if (st == UBER_BAD)
|
|
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
|
|
|
|
// Refuse rather than mount something older than a root we could not
|
|
// read. The candidate below is a perfectly good filesystem; it is just
|
|
// not the *current* one, and mounting it would discard every commit
|
|
// made since the volume was written by a newer driver -- silently, and
|
|
// with the mount reporting success.
|
|
if (too_new > best_gen)
|
|
return -3;
|
|
|
|
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;
|
|
{
|
|
enum uber_state st = uber_check(u);
|
|
if (st == UBER_TOO_NEW)
|
|
return -3; /* rewritten under us by a newer driver */
|
|
if (st == UBER_BAD)
|
|
goto next; /* changed under us, or a flaky device */
|
|
ro_forced = (st == UBER_RO);
|
|
}
|
|
|
|
vol_from_uber(v, u, best_slot);
|
|
if (v->grow_to > v->total_blocks)
|
|
v->total_blocks = v->grow_to;
|
|
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.
|
|
// A volume that has been opened before still holds the
|
|
// previous map, and schfs_space_init memsets it away -- so
|
|
// without this every re-open leaks one. schfs_objtest reopens
|
|
// the same volume nine times.
|
|
//
|
|
// Safe on a first open because a struct schfs_vol must be
|
|
// zeroed before first use, which is what release reads: a
|
|
// zeroed map has no pfree and no pages, so it does nothing.
|
|
schfs_space_release(&v->space);
|
|
int sr = schfs_space_init(&v->space, v->total_blocks, palloc, pfree);
|
|
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;
|
|
// A volume carrying ro_compat bits we do not implement stays
|
|
// read-only even though the map is now good: writing would leave
|
|
// whatever structure those bits describe stale, and stale is worse
|
|
// than absent because nothing would notice.
|
|
v->readonly = ro_forced;
|
|
}
|
|
// 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;
|
|
}
|
|
|
|
// 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 (too_new)
|
|
return -3;
|
|
if (io_errors == SCHFS_UBER_COUNT)
|
|
return -2;
|
|
return seen ? -2 : -1;
|
|
}
|
|
|
|
int
|
|
schfs_vol_commit(struct schfs_vol *v)
|
|
{
|
|
struct schfs_uberblock *u = (struct schfs_uberblock *)v->ctx.a;
|
|
|
|
v->generation++;
|
|
v->uber_slot = (v->uber_slot + 1) % SCHFS_UBER_COUNT;
|
|
|
|
memset(u, 0, sizeof(*u));
|
|
u->magic = SCHFS_MAGIC;
|
|
u->version = SCHFS_VERSION;
|
|
u->blocksize = SCHFS_BLKSIZE;
|
|
u->generation = v->generation;
|
|
u->root = v->tree.root;
|
|
u->root_crc = v->tree.root_crc;
|
|
u->root_generation = v->tree.root_gen;
|
|
u->total_blocks = v->total_blocks;
|
|
// Blocks this group released count as free *as of this uberblock*: nothing
|
|
// the root below reaches touches them. They are only still marked in use in
|
|
// memory because the group being superseded is what a crash would land on
|
|
// until this very block is written.
|
|
u->free_blocks = v->space.nblocks
|
|
? v->space.free_blocks + v->space.pending
|
|
: v->free_blocks;
|
|
u->next_objectid = v->next_objectid;
|
|
u->root_objectid = v->root_objectid;
|
|
memmove(u->uuid, v->uuid, sizeof(u->uuid));
|
|
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
|
|
// again; releasing them before the write would let a crash come back to a
|
|
// tree whose blocks had already been reused.
|
|
if (v->space.nblocks) {
|
|
schfs_space_commit(&v->space);
|
|
v->free_blocks = v->space.free_blocks;
|
|
}
|
|
|
|
// Everything written from here on belongs to the next transaction group, so
|
|
// the blocks just published become read-only to the tree again and the
|
|
// copy-on-write rule resumes. Advancing this is the whole reason a commit
|
|
// is a barrier rather than just another block write.
|
|
v->tree.generation = v->generation + 1;
|
|
return 0;
|
|
}
|
|
|
|
int
|
|
schfs_vol_format(struct schfs_vol *v, struct schfs_bdev *dev,
|
|
uint64_t total_blocks, const char *label,
|
|
struct schfs_ctx *x, void *(*palloc)(void),
|
|
void (*pfree)(void *))
|
|
{
|
|
if (total_blocks <= SCHFS_FIRST_BLOCK)
|
|
return -3;
|
|
|
|
// Same reasoning as the release in schfs_vol_open: formatting over a
|
|
// volume that was previously open would otherwise drop its map on the
|
|
// floor, and the memset below is what makes it unrecoverable.
|
|
schfs_space_release(&v->space);
|
|
memset(v, 0, sizeof(*v));
|
|
v->dev = *dev;
|
|
v->ctx = *x;
|
|
v->tree.dev = &v->dev;
|
|
v->total_blocks = total_blocks;
|
|
v->root_objectid = SCHFS_ROOT_OBJECTID;
|
|
|
|
// Nothing is allocated on a fresh volume beyond the boot block and the
|
|
// ring, so the map starts empty rather than being scanned into existence.
|
|
{
|
|
int sr = schfs_space_init(&v->space, total_blocks, palloc, pfree);
|
|
if (sr != 0)
|
|
return sr;
|
|
}
|
|
v->free_blocks = v->space.free_blocks;
|
|
// The root's object id is consumed by the root itself, so allocation must
|
|
// start *past* it. Starting at SCHFS_FIRST_OBJECTID instead handed the
|
|
// first directory created the same id as the root: that directory then
|
|
// silently became the root, so it contained itself and any tree walk
|
|
// recursed until the stack ran out.
|
|
v->next_objectid = SCHFS_ROOT_OBJECTID + 1;
|
|
v->generation = 0; /* nothing committed yet */
|
|
v->tree.generation = 1; /* ...so this is the group being built */
|
|
v->uber_slot = -1;
|
|
v->readonly = 0;
|
|
|
|
if (label) {
|
|
unsigned i = 0;
|
|
for (; i < SCHFS_LABEL_MAX - 1 && label[i]; i++)
|
|
v->label[i] = label[i];
|
|
v->label[i] = '\0';
|
|
}
|
|
|
|
// A UUID from the block count and a fixed salt. Not random — mkfs is
|
|
// deliberately reproducible, so two runs over the same tree produce
|
|
// byte-identical images and a diff means a real change.
|
|
{
|
|
uint32_t a = schfs_crc32c(0, &total_blocks, sizeof(total_blocks));
|
|
uint32_t b = schfs_crc32c(a, v->label, SCHFS_LABEL_MAX);
|
|
for (int i = 0; i < 16; i++)
|
|
v->uuid[i] = (uint8_t)((i & 1 ? a : b) >> ((i % 4) * 8));
|
|
}
|
|
|
|
// Zero the whole ring so a stale uberblock from a previous filesystem on
|
|
// this device cannot outrank ours and be mounted instead.
|
|
memset(x->a, 0, SCHFS_BLKSIZE);
|
|
for (int i = 0; i < SCHFS_UBER_COUNT; i++)
|
|
if (v->dev.bwrite(v->dev.ctx, SCHFS_UBER_START + i, x->a) != 0)
|
|
return -2;
|
|
|
|
if (schfs_btree_create(&v->tree, &v->dev, x) != 0)
|
|
return -2;
|
|
|
|
return schfs_vol_commit(v);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Allocation */
|
|
/* */
|
|
/* Thin adapters over the space manager, so callers wiring up a */
|
|
/* schfs_bdev have something with the right shape to point at. The */
|
|
/* readonly check lives here rather than in space.c because "this */
|
|
/* volume may not be written" is a property of the mount, not of the */
|
|
/* bitmap. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
uint64_t
|
|
schfs_vol_alloc(struct schfs_vol *v)
|
|
{
|
|
if (v->readonly)
|
|
return 0;
|
|
return schfs_space_alloc(&v->space);
|
|
}
|
|
|
|
uint64_t
|
|
schfs_vol_alloc_run(struct schfs_vol *v, uint64_t n)
|
|
{
|
|
if (v->readonly)
|
|
return 0;
|
|
return schfs_space_alloc_run(&v->space, n);
|
|
}
|
|
|
|
void
|
|
schfs_vol_free(struct schfs_vol *v, uint64_t blk, uint64_t gen)
|
|
{
|
|
schfs_space_free(&v->space, blk, gen);
|
|
}
|