schfs: a format version policy, and refusing is not falling back
uber_valid() answered one question -- valid or not -- and a version mismatch returned the same as a failed checksum. Those two states need opposite handling: A damaged uberblock means STEP OVER IT. That is what the 128-slot ring is for, and the older root is a consistent filesystem. This is crash recovery. A sound uberblock from a newer format means STOP. Falling back mounts a STALE filesystem: every commit made since the volume was upgraded is discarded, the mount reports success, and nobody finds out. Demonstrated rather than asserted. A forged image with two uberblocks -- (version 1, generation 1, root 129) and (version 2, generation 2, root 130) -- made the old code mount root 129 and exit 0, silently rolling the filesystem back to before the upgrade. uber_check() returns BAD / OK / RO / TOO_NEW, and asks its questions in order: 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. Plus feature flags in three classes, which is what stops a version number being a blunt instrument: compat (ignore unknown bits), ro_compat (mount read-only), incompat (refuse). The test for which class is one question -- what happens if a driver that does not know this feature writes to the volume? Nothing -> compat. Something goes stale -> ro_compat. Something is unreadable -> incompat. They occupy what was reserved0, so every later field keeps its offset and existing images -- which have zeros there -- read unchanged. schfs_vol_commit memsets the uberblock before filling it in, which is why v1 volumes already say "no features" without having been written by code that knew the field existed. That trick is available exactly once. Four _Static_asserts now pin the layout. Verified on forged images: version bump refuses, incompat refuses, blocksize mismatch refuses, ro_compat mounts the NEWEST root read-only rather than falling back. Also here, because they are the same file: SchFS's transaction owner is a pid alongside the pointer, since a bare struct proc * does not identify a process -- ptable slots are recycled, and an exited owner's pointer compares equal to whichever unrelated process lands in that slot next, at which point klock() skips the lock entirely and two callers share one volume's scratch pages. And schfs_lockinfo(), so /proc/fsstat can report a wedged volume instead of leaving every path asleep in acquiresleep with no way to ask why. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
parent
3eed58fe9b
commit
73acc40dbb
6 changed files with 250 additions and 31 deletions
|
|
@ -106,7 +106,16 @@ struct schfs_mount {
|
|||
struct sleeplock lock;
|
||||
|
||||
// The explicit transaction bracket, per volume for the same reason.
|
||||
//
|
||||
// The pid is carried alongside the pointer, and both must match, because a
|
||||
// `struct proc *` alone does not identify a process: ptable slots are
|
||||
// recycled, so an exited owner's pointer compares equal to whichever
|
||||
// unrelated process next lands in that slot. tx_mine() would then be true for
|
||||
// a process that holds nothing, klock() would skip the lock entirely, and two
|
||||
// callers would share one volume's scratch pages. A recycled slot always
|
||||
// carries a new pid, so the pair is unambiguous.
|
||||
struct proc *tx_owner;
|
||||
int tx_owner_pid;
|
||||
int tx_depth;
|
||||
int tx_dirty;
|
||||
};
|
||||
|
|
@ -124,13 +133,27 @@ static int ksnap_home[SCHFS_SNAP_SLOTS]; // which volume slot
|
|||
static char *ksnapcurblk[SCHFS_SNAP_SLOTS];
|
||||
static char *ksnapdatablk[SCHFS_SNAP_SLOTS];
|
||||
|
||||
// Seconds, for inode timestamps. The kernel has no wall clock wired up, so this
|
||||
// matches what fs.c does for the v1 filesystem rather than inventing a second,
|
||||
// differently-wrong answer.
|
||||
// Seconds since the epoch, for inode timestamps.
|
||||
//
|
||||
// This used to return uptime(), with a comment saying the kernel had no wall
|
||||
// clock. It has had one since time_init() started reading the RTC at boot, and
|
||||
// the stale comment outlived the condition it described -- which is how every
|
||||
// file created after boot ended up stamped 1970.
|
||||
//
|
||||
// Two errors, not one, and the second is the less obvious: uptime() counts
|
||||
// 10 ms *ticks*, and they were being used as *seconds*. A node created 60 ticks
|
||||
// (0.6 s) into boot therefore read back as 00:01:00, so the timestamps were not
|
||||
// merely offset, they ran a hundred times too fast.
|
||||
//
|
||||
// wallclock() is the one source (kernel/core/time.c), so this cannot disagree
|
||||
// with gettimeofday about what time it is -- which was the whole reason that
|
||||
// function exists.
|
||||
static uint64_t
|
||||
know(void)
|
||||
{
|
||||
return (uint64_t)uptime();
|
||||
uint64 sec;
|
||||
wallclock(&sec, 0);
|
||||
return (uint64_t)sec;
|
||||
}
|
||||
|
||||
// Explicit transaction brackets.
|
||||
|
|
@ -220,7 +243,9 @@ writable_for(uint dev)
|
|||
static int
|
||||
tx_mine(struct schfs_mount *m)
|
||||
{
|
||||
return m->tx_owner != 0 && m->tx_owner == myproc();
|
||||
struct proc *p = myproc();
|
||||
return m->tx_owner != 0 && p != 0 &&
|
||||
m->tx_owner == p && m->tx_owner_pid == p->pid;
|
||||
}
|
||||
|
||||
// Take a volume's lock unless this process already holds it through a bracket.
|
||||
|
|
@ -243,6 +268,26 @@ kunlock(uint dev)
|
|||
releasesleep(&m->lock);
|
||||
}
|
||||
|
||||
// The bracket's state, for /proc/fsstat. A wedged filesystem looks exactly like
|
||||
// a slow one from userland -- every path hangs in acquiresleep with no way to
|
||||
// tell whether anything owns the lock. This says so.
|
||||
//
|
||||
// Read without the lock on purpose: the one time it matters is when the lock
|
||||
// cannot be taken, and a reporting path that blocks on the thing it is
|
||||
// reporting on is useless.
|
||||
void
|
||||
schfs_lockinfo(int slot, int *locked, int *owner_pid, int *depth)
|
||||
{
|
||||
if (slot < 0 || slot >= SCHFS_VOL_SLOTS) {
|
||||
*locked = *owner_pid = *depth = -1;
|
||||
return;
|
||||
}
|
||||
struct schfs_mount *m = &kmnt[slot];
|
||||
*locked = m->mounted ? m->lock.locked : -1;
|
||||
*owner_pid = m->tx_owner_pid;
|
||||
*depth = m->tx_depth;
|
||||
}
|
||||
|
||||
void
|
||||
schfs_ktx_open(uint dev)
|
||||
{
|
||||
|
|
@ -253,9 +298,10 @@ schfs_ktx_open(uint dev)
|
|||
return;
|
||||
}
|
||||
acquiresleep(&m->lock);
|
||||
m->tx_owner = myproc();
|
||||
m->tx_depth = 1;
|
||||
m->tx_dirty = 0;
|
||||
m->tx_owner = myproc();
|
||||
m->tx_owner_pid = myproc() ? myproc()->pid : 0;
|
||||
m->tx_depth = 1;
|
||||
m->tx_dirty = 0;
|
||||
}
|
||||
|
||||
int
|
||||
|
|
@ -271,8 +317,9 @@ schfs_ktx_close(uint dev)
|
|||
|
||||
if (m->tx_dirty)
|
||||
r = schfs_vol_commit(&m->vol);
|
||||
m->tx_dirty = 0;
|
||||
m->tx_owner = 0;
|
||||
m->tx_dirty = 0;
|
||||
m->tx_owner = 0;
|
||||
m->tx_owner_pid = 0;
|
||||
releasesleep(&m->lock);
|
||||
return r;
|
||||
}
|
||||
|
|
@ -461,6 +508,15 @@ kmount_slot(int slot, uint bdev)
|
|||
cprintf("schfs: no memory for the allocation map of device %d\n", bdev);
|
||||
return -ENOMEM;
|
||||
}
|
||||
if (r == -3) {
|
||||
// Refused on purpose, and the wording matters: the volume is fine, this
|
||||
// driver is the thing that is out of date. Older uberblocks may well be
|
||||
// readable, and mounting one would quietly roll the filesystem back to
|
||||
// before the upgrade -- so it is not offered.
|
||||
cprintf("schfs: device %d was written by a newer SchFS than this one "
|
||||
"understands; refusing rather than mounting a stale root\n", bdev);
|
||||
return -ENOTSUP;
|
||||
}
|
||||
if (r != 0) {
|
||||
cprintf("schfs: device %d has a SchFS uberblock but an unreadable tree\n",
|
||||
bdev);
|
||||
|
|
|
|||
|
|
@ -34,24 +34,66 @@ uber_crc(struct schfs_uberblock *u)
|
|||
return c;
|
||||
}
|
||||
|
||||
static int
|
||||
uber_valid(struct schfs_uberblock *u)
|
||||
// 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 0;
|
||||
if (u->version != SCHFS_VERSION)
|
||||
return 0;
|
||||
// A volume made with a different block size would otherwise be read with
|
||||
// every offset wrong, which looks exactly like corruption.
|
||||
if (u->blocksize != SCHFS_BLKSIZE)
|
||||
return 0;
|
||||
return UBER_BAD;
|
||||
if (uber_crc(u) != u->crc)
|
||||
return 0;
|
||||
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 0;
|
||||
return 1;
|
||||
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
|
||||
|
|
@ -89,6 +131,8 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev,
|
|||
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;
|
||||
|
|
@ -116,8 +160,19 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev,
|
|||
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;
|
||||
{
|
||||
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) {
|
||||
|
|
@ -130,6 +185,15 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev,
|
|||
|
||||
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.
|
||||
|
|
@ -140,8 +204,14 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev,
|
|||
// 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))
|
||||
goto next; /* changed under us, or a flaky device */
|
||||
{
|
||||
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
|
||||
|
|
@ -178,7 +248,11 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev,
|
|||
if (schfs_space_scan(v) != 0)
|
||||
goto next;
|
||||
v->free_blocks = v->space.free_blocks;
|
||||
v->readonly = 0;
|
||||
// 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
|
||||
|
|
@ -194,6 +268,8 @@ schfs_vol_open(struct schfs_vol *v, struct schfs_bdev *dev,
|
|||
// 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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue