Started as "fix the ASAN leak in schfs_objtest" and the test turned out to be
the messenger. The allocation map was taken through a palloc callback and there
was no way to return it: no schfs_space_release, and a comment in
schfs_space_init reading "caller frees what was taken" for an API the caller
did not have.
Three separate leaks from that one gap:
* The kernel leaked the entire map on every unmount -- up to 2 MB, at 8 KiB
per GiB of volume -- because kmount_unmount flushed, cleared `mounted` and
walked away. This is the one that matters; the others are test-only.
* schfs_space_init stranded a page on its own error path. npages was raised
only after *both* of a pair of allocations succeeded, so a failure on the
second left the first outside the count -- unreachable to any cleanup that
could later exist.
* schfs_objtest leaked 2121728 bytes in 518 allocations, which is what made
the whole SchFS suite exit non-zero regardless of its assertions.
The map now carries its own pfree rather than taking one at release time, so it
can only ever be freed by the allocator that filled it -- the kernel's pages
come from kalloc and a host tool's from calloc, and the two are not
interchangeable. schfs_vol_open and schfs_vol_format release any map the volume
is already holding before building another, which is what stops a re-open
leaking; struct schfs_vol must therefore be zeroed before first use, and the
header now says so.
The test's own leak was the last one found and the simplest: it does
memset(&VOL, 0, sizeof(VOL)) before reopening, which wiped the map's pointers
before vol_open could release them. Eight of those, each now releasing first.
schfs_objtest asserts the balance -- 520 pages taken, 520 given back -- because
a suite that leaks cannot detect a leak: the filesystem's own allocations would
arrive as noise inside the harness's. The next one fails the run instead.
make schfs-check now exits 0. The 31 UBSAN misalignment reports are still
there and are still a Gate 0 item.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
459 lines
19 KiB
C
459 lines
19 KiB
C
// SPDX-License-Identifier: Apache-2.0
|
|
//
|
|
// 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 "types.h"
|
|
#include "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);
|
|
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)
|
|
{
|
|
schfs_space_free(&v->space, blk);
|
|
}
|