SchOS/tools/schfs_objtest.c
Nexoniarz 03cc316001 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>
2026-08-15 16:02:02 +02:00

1106 lines
44 KiB
C

// SchFS object-layer test: files, directories and space accounting.
//
// schfs_test covers the B-tree. This covers everything built on it — create,
// write at an offset, truncate, unlink, rename, hard links — and, most
// importantly, that space comes back. A filesystem that loses a block on every
// delete passes every functional test ever written and is still useless.
//
// Everything runs against a memory-backed volume, so a full create/write/
// delete/remount cycle takes milliseconds. The kernel gets this code only once
// it passes here, because the same failure inside a VM costs a rebuild and a
// boot to see once.
//
// Build and run: make schfs-objtest
#define SCHFS_HOSTED 1
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <setjmp.h> /* the crash in section 10 is a longjmp out of a write */
#include "../kernel/fs/schfs/btree.c"
#include "../kernel/fs/schfs/space.c"
#include "../kernel/fs/schfs/vol.c"
#include "../kernel/fs/schfs/obj.c"
#include "../kernel/fs/schfs/objw.c"
#include "../kernel/fs/schfs/attr.c"
/* ------------------------------------------------------------------ */
/* Memory-backed device */
/* ------------------------------------------------------------------ */
#define NBLOCKS 4096
static unsigned char *DISK;
static struct schfs_vol VOL;
static char CTX_A[SCHFS_BLKSIZE];
static char CTX_B[SCHFS_BLKSIZE];
static char CTX_C[SCHFS_BLKSIZE];
static struct schfs_ctx CTX = { CTX_A, CTX_B, CTX_C };
static char CURBLK[SCHFS_BLKSIZE];
static char DATABLK[SCHFS_BLKSIZE];
static int
md_read(void *ctx, uint64_t b, void *buf)
{
(void)ctx;
if (b >= NBLOCKS) return -1;
memcpy(buf, DISK + b * SCHFS_BLKSIZE, SCHFS_BLKSIZE);
return 0;
}
// Simulated power loss.
//
// md_fail_after counts writes down; when it reaches zero the write does not
// happen and control leaves the filesystem entirely, through the longjmp. That
// is what a power cut is: the disk keeps whatever reached it and *nothing else
// runs*. The obvious cheaper models are both wrong in ways that test the wrong
// thing -- returning an error tests error handling, and silently swallowing the
// write while letting execution continue leaves the code reading back blocks it
// believes it just wrote, which no real failure does.
static long md_fail_after = -1;
static int md_crash_armed;
static jmp_buf md_crash_jmp;
static long md_writes;
static int
md_write(void *ctx, uint64_t b, const void *buf)
{
(void)ctx;
if (b >= NBLOCKS) return -1;
md_writes++;
if (md_crash_armed && md_fail_after >= 0 && md_fail_after-- == 0)
longjmp(md_crash_jmp, 1);
memcpy(DISK + b * SCHFS_BLKSIZE, buf, SCHFS_BLKSIZE);
return 0;
}
static uint64_t md_alloc(void *ctx) { (void)ctx; return schfs_vol_alloc(&VOL); }
static uint64_t md_run(void *ctx, uint64_t n) { (void)ctx; return schfs_vol_alloc_run(&VOL, n); }
static void md_free(void *ctx, uint64_t b) { (void)ctx; schfs_vol_free(&VOL, b); }
static void *md_palloc(void) { return calloc(1, SCHFS_BLKSIZE); }
static void
mkbdev(struct schfs_bdev *bd)
{
// Zero first. Every field has to be set even when the answer is "no such
// hook", because callers declare these on the stack: leaving the optional
// bflush uninitialised handed schfs_vol_commit a garbage function pointer
// to test and call, which is a segfault at the first commit.
memset(bd, 0, sizeof(*bd));
bd->ctx = 0;
bd->bread = md_read;
bd->bwrite = md_write;
bd->balloc = md_alloc;
bd->bfree = md_free;
bd->balloc_run = md_run;
}
/* ------------------------------------------------------------------ */
static int failures;
static void
ok(int cond, const char *what)
{
if (cond) {
printf(" [ok] %s\n", what);
} else {
printf(" FAIL: %s\n", what);
failures++;
}
}
static uint32_t rng = 987654321;
static uint32_t
rnd(void)
{
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5;
return rng;
}
// Read a whole file into buf, returning its length or -1.
static long
readall(uint64_t ino, char *buf, unsigned cap)
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
if (schfs_inode_get(&VOL, &c, ino, &in) != 0)
return -1;
if (in.size > cap)
return -1;
unsigned off = 0;
while (off < in.size) {
long got = schfs_file_read(&VOL, &c, ino, off, buf + off,
(unsigned)(in.size - off), DATABLK);
if (got <= 0)
return -1;
off += (unsigned)got;
}
return (long)off;
}
static uint64_t
lookup(const char *path)
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
return schfs_path_lookup(&VOL, &c, path);
}
static uint32_t
nlink_of(uint64_t ino)
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
if (schfs_inode_get(&VOL, &c, ino, &in) != 0)
return 0;
return in.nlink;
}
static uint64_t
size_of(uint64_t ino)
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_inode in;
if (schfs_inode_get(&VOL, &c, ino, &in) != 0)
return ~0ULL;
return in.size;
}
static int
count_extents_of(uint64_t ino)
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_key start;
schfs_mkkey(&start, ino, SCHFS_KEY_EXTENT, 0);
if (schfs_cursor_seek(&c, &start) != 0)
return 0;
int n = 0;
for (;;) {
const struct schfs_key *k = schfs_cursor_key(&c);
if (!k || k->objectid != ino || k->type != SCHFS_KEY_EXTENT)
break;
n++;
if (schfs_cursor_next(&c) != 0)
break;
}
return n;
}
// Publish, then reopen the volume from its uberblock — which rebuilds the
// allocation map by walking the tree. Returns the free count the writer had, so
// a caller can hold it against the one the mount derived: a bitmap that has
// drifted from its own counter shows up here and nowhere else.
//
// Reopening also ages every block into a previous transaction group, which is
// what makes the copy-on-write paths run at all. Within one group the tree and
// the data both rewrite in place, so a test that never commits exercises the
// cheap half of the filesystem and none of the careful half.
static uint64_t
remount(void)
{
if (schfs_vol_commit(&VOL) != 0) {
printf(" FAIL: commit before remount\n");
failures++;
return ~0ULL;
}
uint64_t before = VOL.free_blocks;
struct schfs_bdev bd2;
mkbdev(&bd2);
// Straight back into VOL, not into a local that is then copied over it: a
// schfs_vol points at its own dev field, so a copy of one leaves the tree
// talking to whichever struct it was built in.
memset(&VOL, 0, sizeof(VOL));
if (schfs_vol_open(&VOL, &bd2, &CTX, md_palloc) != 0) {
printf(" FAIL: remount\n");
failures++;
return ~0ULL;
}
return before;
}
/* ------------------------------------------------------------------ */
#define ROOT (VOL.root_objectid)
static uint64_t
mkfile(uint64_t dir, const char *name)
{
uint64_t ino = 0;
int r = schfs_create(&VOL, dir, name, (unsigned)strlen(name),
SCHFS_T_FILE, 0644, 0, 0, 1000, &ino);
if (r != 0) {
printf(" FAIL: create %s -> %d\n", name, r);
failures++;
return 0;
}
return ino;
}
static uint64_t
mkdirent(uint64_t dir, const char *name)
{
uint64_t ino = 0;
int r = schfs_create(&VOL, dir, name, (unsigned)strlen(name),
SCHFS_T_DIR, 0755, 0, 0, 1000, &ino);
if (r != 0) {
printf(" FAIL: mkdir %s -> %d\n", name, r);
failures++;
return 0;
}
return ino;
}
int
main(void)
{
struct schfs_bdev bd;
DISK = calloc(NBLOCKS, SCHFS_BLKSIZE);
if (!DISK) { perror("calloc"); return 1; }
mkbdev(&bd);
printf("SchFS object-layer test\n\n");
if (schfs_vol_format(&VOL, &bd, NBLOCKS, "objtest", &CTX, md_palloc) != 0) {
fprintf(stderr, "FAIL: format\n");
return 1;
}
// The root directory is an object like any other; nothing points at it
// except the uberblock's root_objectid.
{
struct schfs_inode root;
memset(&root, 0, sizeof(root));
root.type = SCHFS_T_DIR;
root.mode = 0755;
root.nlink = 2;
if (schfs_inode_put(&VOL, ROOT, &root) != 0) {
fprintf(stderr, "FAIL: root inode\n");
return 1;
}
}
if (schfs_vol_commit(&VOL) != 0) { fprintf(stderr, "FAIL: commit\n"); return 1; }
uint64_t baseline_free = VOL.free_blocks;
/* ---- 1. create, write, read back ---- */
{
uint64_t d = mkdirent(ROOT, "dir");
uint64_t f = mkfile(d, "hello");
const char *msg = "the quick brown fox";
long w = schfs_file_write(&VOL, f, 0, msg, (unsigned)strlen(msg), DATABLK);
ok(w == (long)strlen(msg), "write returns the byte count");
char buf[128];
long got = readall(f, buf, sizeof(buf));
ok(got == (long)strlen(msg) && memcmp(buf, msg, (size_t)got) == 0,
"a short file reads back exactly");
ok(lookup("/dir/hello") == f, "path lookup finds it");
ok(nlink_of(d) == 2, "a new directory has nlink 2");
ok(nlink_of(ROOT) == 3, "creating a subdirectory bumps the parent");
}
/* ---- 2. a large file becomes few extents, not one per block ---- */
{
uint64_t f = mkfile(ROOT, "big");
static char src[64 * 1024];
for (unsigned i = 0; i < sizeof(src); i++)
src[i] = (char)(i * 7 + (i >> 8));
// Written in small pieces on purpose: this is the shape that used to
// produce one extent record per block, and one copy-on-write per call.
unsigned off = 0;
while (off < sizeof(src)) {
unsigned n = 100 + rnd() % 900;
if (off + n > sizeof(src)) n = (unsigned)sizeof(src) - off;
long w = schfs_file_write(&VOL, f, off, src + off, n, DATABLK);
if (w != (long)n) { printf(" FAIL: partial write %ld\n", w); failures++; break; }
off += n;
}
static char back[64 * 1024];
long got = readall(f, back, sizeof(back));
ok(got == (long)sizeof(src) && memcmp(back, src, sizeof(src)) == 0,
"64 KB written in small pieces reads back identical");
int ne = count_extents_of(f);
ok(ne <= 4, "sequential writes merge into a handful of extents");
if (ne > 4)
printf(" (%d extents for 16 blocks)\n", ne);
}
/* ---- 3. overwriting the middle, and holes ---- */
{
uint64_t f = mkfile(ROOT, "patch");
static char src[16 * 1024];
memset(src, 'a', sizeof(src));
schfs_file_write(&VOL, f, 0, src, sizeof(src), DATABLK);
// Straddles a block boundary, so it exercises the split path on both
// sides of the extent it lands in.
char patch[600];
memset(patch, 'Z', sizeof(patch));
schfs_file_write(&VOL, f, 4000, patch, sizeof(patch), DATABLK);
memcpy(src + 4000, patch, sizeof(patch));
static char back[16 * 1024];
long got = readall(f, back, sizeof(back));
ok(got == (long)sizeof(src) && memcmp(back, src, sizeof(src)) == 0,
"an overwrite across a block boundary lands exactly");
// Now the same thing across a commit. Everything above happened inside
// one transaction group, where a data block may be rewritten in place;
// once it is committed it belongs to a tree a crash can still land on,
// so an overwrite has to copy the block and split the extent record
// around it. That is the path that actually risks losing data, and only
// a commit makes it run.
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
char patch2[3000];
memset(patch2, 'K', sizeof(patch2));
long w = schfs_file_write(&VOL, f, 9000, patch2, sizeof(patch2), DATABLK);
ok(w == (long)sizeof(patch2), "an overwrite after a commit succeeds");
memcpy(src + 9000, patch2, sizeof(patch2));
got = readall(f, back, sizeof(back));
ok(got == (long)sizeof(src) && memcmp(back, src, sizeof(src)) == 0,
"copy-on-write of a committed block keeps the rest of the run intact");
// And the block it replaced must really be gone, not still in the map.
uint64_t was = remount();
ok(was == VOL.free_blocks,
"free space after a copy-on-write overwrite survives a remount");
got = readall(lookup("/patch"), back, sizeof(back));
ok(got == (long)sizeof(src) && memcmp(back, src, sizeof(src)) == 0,
"and the patched file still reads correctly after remounting");
uint64_t h = mkfile(ROOT, "sparse");
schfs_file_write(&VOL, h, 40000, "end", 3, DATABLK);
ok(size_of(h) == 40003, "writing past the end extends the file");
static char hb[64 * 1024];
got = readall(h, hb, sizeof(hb));
int zeros = (got == 40003);
for (long i = 0; i < 40000 && zeros; i++)
if (hb[i] != 0) zeros = 0;
ok(zeros && memcmp(hb + 40000, "end", 3) == 0,
"the gap left behind is a hole and reads as zeros");
}
/* ---- 4. truncate ---- */
{
uint64_t f = mkfile(ROOT, "trunc");
static char src[20 * 1024];
memset(src, 'q', sizeof(src));
schfs_file_write(&VOL, f, 0, src, sizeof(src), DATABLK);
ok(schfs_file_truncate(&VOL, f, 100, DATABLK) == 0, "truncate succeeds");
ok(size_of(f) == 100, "truncate sets the size");
char back[256];
long got = readall(f, back, sizeof(back));
int good = (got == 100);
for (int i = 0; i < 100 && good; i++)
if (back[i] != 'q') good = 0;
ok(good, "what survives a truncate is the original head");
// Growing again must read as zeros, not as whatever the released
// blocks used to hold.
ok(schfs_file_truncate(&VOL, f, 8192, DATABLK) == 0, "truncate can grow");
static char big[8192];
got = readall(f, big, sizeof(big));
int zeroed = (got == 8192);
for (int i = 100; i < 8192 && zeroed; i++)
if (big[i] != 0) zeroed = 0;
ok(zeroed, "growing past the old end reads as zeros, not stale data");
}
/* ---- 5. links, unlink, rename, rmdir ---- */
{
uint64_t f = mkfile(ROOT, "orig");
schfs_file_write(&VOL, f, 0, "content", 7, DATABLK);
ok(schfs_link(&VOL, ROOT, "alias", 5, f) == 0, "hard link succeeds");
ok(nlink_of(f) == 2, "hard link bumps nlink");
ok(lookup("/alias") == f, "both names reach the same object");
ok(schfs_unlink(&VOL, ROOT, "orig", 4) == 0, "unlink one name");
ok(nlink_of(f) == 1, "nlink drops but the object survives");
char buf[32];
ok(readall(f, buf, sizeof(buf)) == 7, "the surviving name still reads");
ok(schfs_rename(&VOL, ROOT, "alias", 5, ROOT, "renamed", 7) == 0,
"rename within a directory");
ok(lookup("/alias") == 0 && lookup("/renamed") == f,
"the old name is gone and the new one resolves");
uint64_t d = lookup("/dir");
ok(schfs_unlink(&VOL, ROOT, "dir", 3) == -5,
"rmdir refuses a directory that still has entries");
ok(schfs_unlink(&VOL, d, "hello", 5) == 0, "emptying it first");
ok(schfs_unlink(&VOL, ROOT, "dir", 3) == 0, "then rmdir succeeds");
ok(nlink_of(ROOT) == 2, "removing a subdirectory drops the parent's count");
// Moving a directory under itself would detach the whole subtree.
uint64_t a = mkdirent(ROOT, "a");
uint64_t b = mkdirent(a, "b");
(void)b;
ok(schfs_rename(&VOL, ROOT, "a", 1, lookup("/a/b"), "loop", 4) != 0,
"renaming a directory into its own subtree is refused");
}
/* ---- 6. it all survives a commit and a remount ---- */
{
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
static char back_before[64 * 1024];
long before = readall(lookup("/big"), back_before, sizeof(back_before));
uint64_t was_free = remount();
ok(was_free != ~0ULL, "the volume reopens from its uberblock");
static char back_after[64 * 1024];
long after = readall(lookup("/big"), back_after, sizeof(back_after));
ok(before == after && before > 0 &&
memcmp(back_before, back_after, (size_t)before) == 0,
"file contents are identical after a remount");
ok(lookup("/renamed") != 0 && lookup("/dir") == 0,
"the namespace is exactly as it was left");
ok(VOL.free_blocks == was_free,
"free space derived at mount matches what the writer had");
if (VOL.free_blocks != was_free)
printf(" (mount says %llu, writer said %llu)\n",
(unsigned long long)VOL.free_blocks,
(unsigned long long)was_free);
}
/* ---- 7. deleting everything gives the space back ----
*
* The one test that catches a leak. Every earlier check would still pass on
* a filesystem that never reused a block; this one does not.
*/
{
const char *names[] = { "big", "patch", "sparse", "trunc", "renamed" };
for (unsigned i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
int r = schfs_unlink(&VOL, ROOT, names[i], (unsigned)strlen(names[i]));
if (r != 0) { printf(" FAIL: unlink %s -> %d\n", names[i], r); failures++; }
}
int r = schfs_unlink(&VOL, lookup("/a"), "b", 1);
if (r == 0) r = schfs_unlink(&VOL, ROOT, "a", 1);
if (r != 0) { printf(" FAIL: rmdir a/b -> %d\n", r); failures++; }
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
ok(VOL.free_blocks == baseline_free,
"an emptied filesystem is back to its starting free-space count");
if (VOL.free_blocks != baseline_free)
printf(" (%llu free now, %llu when empty: %lld blocks lost)\n",
(unsigned long long)VOL.free_blocks,
(unsigned long long)baseline_free,
(long long)baseline_free - (long long)VOL.free_blocks);
ok(schfs_btree_check(&VOL.tree) == 0,
"the tree is still structurally sound after all of that");
}
/* ---- 8. fill, empty, repeat: the space has to be genuinely reusable ----
*
* The free *count* coming back is not the same as the free *map* coming
* back, and a remount hides the difference -- it throws the map away and
* derives a correct one from the tree. Only spending the space again, on
* the same mount, holds the two against each other. A map that leaks a
* little on every delete stores less on each round until nothing fits.
*/
{
static char chunk[32 * 1024];
memset(chunk, 'x', sizeof(chunk));
uint64_t first_round = 0;
int good = 1;
for (int round = 0; round < 3 && good; round++) {
uint64_t f = mkfile(ROOT, "hog");
uint64_t off = 0;
int saw_enospc = 0;
for (int i = 0; i < 10000; i++) {
long w = schfs_file_write(&VOL, f, off, chunk,
sizeof(chunk), DATABLK);
if (w == -3) { saw_enospc = 1; break; }
if (w != (long)sizeof(chunk)) {
printf(" FAIL: write returned %ld\n", w);
good = 0;
break;
}
off += sizeof(chunk);
}
if (round == 0) {
ok(saw_enospc,
"filling the volume reports out of space rather than corrupting");
first_round = off;
} else if (off + 128 * 1024 < first_round) {
printf(" FAIL: round %d stored %llu KB, round 0 stored %llu KB\n",
round, (unsigned long long)(off / 1024),
(unsigned long long)(first_round / 1024));
good = 0;
}
int r = schfs_unlink(&VOL, ROOT, "hog", 3);
if (r != 0) {
printf(" FAIL: could not delete on a full volume (%d)\n", r);
good = 0;
}
if (schfs_vol_commit(&VOL) != 0) {
printf(" FAIL: commit\n");
good = 0;
}
}
ok(good, "filling and emptying three times reuses the same space");
ok(schfs_btree_check(&VOL.tree) == 0,
"the tree survives being filled to the brim");
}
/* ---- 8b. snapshots ---- */
{
// A snapshot is only worth anything if the data it names survives the
// live tree changing underneath it. So: write, snapshot, overwrite,
// and then read *through the snapshot's root* and expect the old bytes.
int r;
uint64_t ino = mkfile(VOL.root_objectid, "snapfile");
ok(ino != 0, "created a file to snapshot");
const char before[] = "BEFORE-THE-SNAPSHOT";
schfs_file_write(&VOL, ino, 0, before, sizeof before, DATABLK);
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
// The snapshot names the committed root, which is the state just
// written -- not the root this call produces.
r = schfs_snap_create(&VOL, 1, "first", 5);
ok(r == 0, "took a snapshot");
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
struct schfs_snap sn;
ok(schfs_snap_get(&VOL, &c, 1, &sn) == 0, "the snapshot record reads back");
// Change the live tree: overwrite the file completely.
const char after[] = "AFTER-THE-SNAPSHOT!";
schfs_file_write(&VOL, ino, 0, after, sizeof after, DATABLK);
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
char buf[64];
memset(buf, 0, sizeof buf);
schfs_cursor_init(&c, &VOL.tree, CURBLK);
schfs_file_read(&VOL, &c, ino, 0, buf, sizeof after, DATABLK);
ok(memcmp(buf, after, sizeof after) == 0,
"the live tree shows the new contents");
// Read through the retained root. Same volume, same block device, a
// tree pointed at the old root -- which is all a snapshot is.
struct schfs_vol snapvol = VOL;
snapvol.tree.root = sn.root;
snapvol.tree.root_crc = sn.root_crc;
snapvol.tree.root_gen = sn.root_generation;
memset(buf, 0, sizeof buf);
schfs_cursor_init(&c, &snapvol.tree, CURBLK);
long got = schfs_file_read(&snapvol, &c, ino, 0, buf, sizeof before, DATABLK);
ok(got == (long)sizeof before && memcmp(buf, before, sizeof before) == 0,
"the snapshot still shows the OLD contents");
if (memcmp(buf, before, sizeof before) != 0)
printf(" (snapshot read back \"%s\")\n", buf);
// And the blocks it holds must not be considered free, or they get
// handed out and the snapshot rots silently.
// Exact agreement is not the invariant once a snapshot exists, and the
// number is the only thing that drifts. Allocation works from the
// in-use bitmap, which the derivation filled by walking the snapshot
// too, so a snapshot's blocks are never handed out. The running
// counter is not corrected for them, so it and the derivation disagree
// until the next mount. Cosmetic, and reported rather than asserted
// away.
uint64_t claimed = remount();
if (claimed != VOL.free_blocks)
printf(" (note: counter %llu, derived %llu -- the counter "
"drifts while a snapshot exists)\n",
(unsigned long long)claimed,
(unsigned long long)VOL.free_blocks);
// Deleting the file from the live tree must not disturb the snapshot.
schfs_dir_del(&VOL, VOL.root_objectid, "snapfile", 8);
schfs_object_free(&VOL, ino);
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
memset(buf, 0, sizeof buf);
snapvol = VOL;
snapvol.tree.root = sn.root;
snapvol.tree.root_crc = sn.root_crc;
snapvol.tree.root_gen = sn.root_generation;
schfs_cursor_init(&c, &snapvol.tree, CURBLK);
got = schfs_file_read(&snapvol, &c, ino, 0, buf, sizeof before, DATABLK);
ok(got == (long)sizeof before && memcmp(buf, before, sizeof before) == 0,
"a file deleted from the live tree is still in the snapshot");
// Drop it again, so what it was holding becomes reclaimable and the
// volume goes back to exact accounting for the checks that follow.
ok(schfs_snap_delete(&VOL, 1) == 0, "the snapshot can be deleted");
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
schfs_cursor_init(&c, &VOL.tree, CURBLK);
ok(schfs_snap_get(&VOL, &c, 1, &sn) != 0,
"a deleted snapshot is gone");
// Deleting it makes what it held unreachable, and the derivation at
// the next mount is what reclaims it -- there is no refcount to
// decrement. So the derived figure must be at least what the stale
// counter believed; check 9 below then proves exact agreement is back.
claimed = remount();
ok(VOL.free_blocks >= claimed,
"space the snapshot held is reclaimed at the next mount");
}
/* ---- 9. the map and its counter still agree ---- */
{
uint64_t claimed = remount();
ok(claimed == VOL.free_blocks,
"free space counted in memory matches free space derived from the tree");
if (claimed != VOL.free_blocks)
printf(" (counter said %llu, the tree says %llu)\n",
(unsigned long long)claimed,
(unsigned long long)VOL.free_blocks);
}
/* ---- 9b. reflink ---- */
//
// The thing that makes a reflink worth having is that it costs no space,
// and the thing that makes it dangerous is that both files are writable
// afterwards. So both are checked: the clone must be free, and a write to
// either side must not be visible on the other.
printf("\n reflink\n");
{
uint64_t before = VOL.free_blocks;
uint64_t a = mkfile(ROOT, "clone-src");
char big[16 * 1024];
for (unsigned i = 0; i < sizeof(big); i++)
big[i] = (char)(i * 7 + 1);
schfs_file_write(&VOL, a, 0, big, sizeof(big), DATABLK);
remount();
uint64_t used_by_a = before - VOL.free_blocks;
ok(used_by_a > 0, "the source file occupies space");
uint64_t b = mkfile(ROOT, "clone-dst");
uint64_t pre = VOL.free_blocks;
ok(schfs_file_reflink(&VOL, a, b) == 0, "reflink succeeds");
remount();
// A handful of metadata blocks is expected; a full copy is not.
uint64_t cost = pre - VOL.free_blocks;
ok(cost * 4 < used_by_a,
"the clone costs far less than the data it shares");
if (cost * 4 >= used_by_a)
printf(" (clone cost %llu blocks, data is %llu)\n",
(unsigned long long)cost, (unsigned long long)used_by_a);
ok(size_of(b) == sizeof(big), "the clone has the source's size");
char rb[sizeof(big)];
ok(readall(b, rb, sizeof(rb)) == (long)sizeof(big) &&
memcmp(rb, big, sizeof(big)) == 0,
"the clone reads back exactly what the source held");
// Writing to the clone must not disturb the source. This is the check
// that fails if the write path rewrites a shared run in place.
const char *poke = "CLONE-WAS-WRITTEN";
schfs_file_write(&VOL, b, 0, poke, 17, DATABLK);
remount();
char ra[sizeof(big)];
ok(readall(a, ra, sizeof(ra)) == (long)sizeof(big) &&
memcmp(ra, big, sizeof(big)) == 0,
"writing the clone leaves the source untouched");
ok(readall(b, rb, sizeof(rb)) == (long)sizeof(big) &&
memcmp(rb, poke, 17) == 0 &&
memcmp(rb + 17, big + 17, sizeof(big) - 17) == 0,
"and the clone holds the new bytes with the rest unchanged");
// And the other direction, on a fresh pair: writing the *source*.
uint64_t c2 = mkfile(ROOT, "clone-dst2");
ok(schfs_file_reflink(&VOL, a, c2) == 0, "a second clone of the same file");
remount();
schfs_file_write(&VOL, a, 0, "SOURCE-WAS-WRITTEN", 18, DATABLK);
remount();
ok(readall(c2, rb, sizeof(rb)) == (long)sizeof(big) &&
memcmp(rb, big, sizeof(big)) == 0,
"writing the source leaves the clone untouched");
// Deleting every holder must give all the space back. A refcount that
// is decremented once too often frees blocks another file still uses;
// one decremented too rarely leaks them. Only this catches either.
schfs_unlink(&VOL, ROOT, "clone-src", 9);
schfs_unlink(&VOL, ROOT, "clone-dst", 9);
schfs_unlink(&VOL, ROOT, "clone-dst2", 10);
remount();
ok(VOL.free_blocks == before,
"deleting every holder returns exactly the space they shared");
if (VOL.free_blocks != before)
printf(" (started %llu, ended %llu)\n",
(unsigned long long)before,
(unsigned long long)VOL.free_blocks);
ok(schfs_btree_check(&VOL.tree) == 0, "the tree is sound after all that");
}
/* ---- 9c. attributes, and the index over them ---- */
//
// Two things are being checked, and only the second is interesting. That
// attributes store and reload is table stakes. That a *query* returns
// exactly the right objects -- including after a value changes, which has
// to remove the old index entry as well as add the new one -- is the part
// that makes the feature worth having and the part that quietly breaks.
printf("\n attributes and query\n");
{
uint64_t d = mkdirent(ROOT, "photos");
uint64_t f1 = mkfile(d, "beach.jpg");
uint64_t f2 = mkfile(d, "hills.jpg");
uint64_t f3 = mkfile(d, "notes.txt");
ok(schfs_attr_set(&VOL, f1, "tag", 3, "holiday", 7,
SCHFS_ATTR_STRING) == 0, "set an attribute");
char buf[64];
unsigned vl = 0;
uint16_t ty = 0;
ok(schfs_attr_get(&VOL, f1, "tag", 3, buf, sizeof(buf), &vl, &ty) == 1 &&
vl == 7 && memcmp(buf, "holiday", 7) == 0 &&
ty == SCHFS_ATTR_STRING,
"it reads back with its value and type");
ok(schfs_attr_get(&VOL, f2, "tag", 3, buf, sizeof(buf), &vl, &ty) == 0,
"an object without it says so");
remount();
ok(schfs_attr_get(&VOL, f1, "tag", 3, buf, sizeof(buf), &vl, &ty) == 1 &&
memcmp(buf, "holiday", 7) == 0,
"and it survives a remount");
// Two attributes on one object, and one replaced.
schfs_attr_set(&VOL, f1, "rating", 6, "5", 1, SCHFS_ATTR_INT);
schfs_attr_set(&VOL, f1, "tag", 3, "work", 4, SCHFS_ATTR_STRING);
remount();
ok(schfs_attr_get(&VOL, f1, "tag", 3, buf, sizeof(buf), &vl, &ty) == 1 &&
vl == 4 && memcmp(buf, "work", 4) == 0,
"replacing a value replaces it rather than appending");
ok(schfs_attr_get(&VOL, f1, "rating", 6, buf, sizeof(buf), &vl, &ty) == 1 &&
vl == 1 && buf[0] == '5',
"and the other attribute is untouched");
ok(schfs_attr_del(&VOL, f1, "rating", 6) == 0 &&
schfs_attr_get(&VOL, f1, "rating", 6, buf, sizeof(buf), &vl, &ty) == 0,
"removing one leaves the rest");
ok(schfs_attr_get(&VOL, f1, "tag", 3, buf, sizeof(buf), &vl, &ty) == 1,
"specifically, the one not removed");
/* --- the index --- */
uint64_t hits[8];
ok(schfs_query_equal(&VOL, "tag", 3, "holiday", 7, hits, 8) < 0,
"querying an attribute with no index is refused, not answered slowly");
ok(schfs_index_create(&VOL, "tag", 3) == 0, "declare an index on `tag`");
// Written *after* the index exists, which is the only thing it covers.
schfs_attr_set(&VOL, f1, "tag", 3, "holiday", 7, SCHFS_ATTR_STRING);
schfs_attr_set(&VOL, f2, "tag", 3, "holiday", 7, SCHFS_ATTR_STRING);
schfs_attr_set(&VOL, f3, "tag", 3, "work", 4, SCHFS_ATTR_STRING);
remount();
int n = schfs_query_equal(&VOL, "tag", 3, "holiday", 7, hits, 8);
ok(n == 2, "a query returns exactly the objects carrying that value");
if (n != 2)
printf(" (got %d)\n", n);
ok(n == 2 && ((hits[0] == f1 && hits[1] == f2) ||
(hits[0] == f2 && hits[1] == f1)),
"and they are the right two");
n = schfs_query_equal(&VOL, "tag", 3, "work", 4, hits, 8);
ok(n == 1 && hits[0] == f3, "a different value returns a different set");
n = schfs_query_equal(&VOL, "tag", 3, "nonesuch", 8, hits, 8);
ok(n == 0, "a value nothing carries returns nothing");
// Changing a value must take the object *out* of the old set. An index
// that only ever gains entries answers confidently and wrongly.
schfs_attr_set(&VOL, f2, "tag", 3, "work", 4, SCHFS_ATTR_STRING);
remount();
n = schfs_query_equal(&VOL, "tag", 3, "holiday", 7, hits, 8);
ok(n == 1 && hits[0] == f1,
"changing a value removes the object from the old value's set");
n = schfs_query_equal(&VOL, "tag", 3, "work", 4, hits, 8);
ok(n == 2, "and adds it to the new one");
// And removing the attribute entirely.
schfs_attr_del(&VOL, f1, "tag", 3);
remount();
n = schfs_query_equal(&VOL, "tag", 3, "holiday", 7, hits, 8);
ok(n == 0, "removing the attribute removes it from the index too");
ok(schfs_btree_check(&VOL.tree) == 0, "the tree is sound after all that");
}
/* ---- 10. crash recovery ---- */
//
// The whole consistency argument for this filesystem is one sentence:
// mount takes the newest uberblock that validates, so a crash lands on
// either the old tree or the new one and never between them. Every test
// above this point runs on a volume that was never interrupted, so not one
// of them touches that sentence -- schfs_test proves it for the B-tree in
// isolation, and nothing proved it for a whole volume until here.
printf("\n crash recovery\n");
{
// A known-good starting point, and a note of where it is.
{
uint64_t d = mkdirent(ROOT, "survivor");
uint64_t f = mkfile(d, "keep");
schfs_file_write(&VOL, f, 0, "before the crash", 16, DATABLK);
}
remount();
uint64_t good_gen = VOL.generation;
unsigned char *saved = malloc(NBLOCKS * SCHFS_BLKSIZE);
memcpy(saved, DISK, NBLOCKS * SCHFS_BLKSIZE);
/* --- 10a. cut the power in the middle of a transaction --- */
//
// Enough writes to be well into the tree and nowhere near the commit,
// which is the case that matters: the uberblock never lands, so the
// previous one is still newest and every block this group touched is
// one no committed root points at.
md_writes = 0;
md_writes = 0;
if (setjmp(md_crash_jmp) == 0) {
md_crash_armed = 1;
md_fail_after = 12;
uint64_t d = mkdirent(ROOT, "doomed");
for (int i = 0; i < 40; i++) {
char nm[16];
snprintf(nm, sizeof(nm), "f%d", i);
uint64_t f = mkfile(d, nm);
if (f) schfs_file_write(&VOL, f, 0, "junk", 4, DATABLK);
}
schfs_vol_commit(&VOL);
printf(" FAIL: the simulated crash never fired\n");
failures++;
}
md_crash_armed = 0;
md_fail_after = -1;
struct schfs_bdev bd3;
mkbdev(&bd3);
memset(&VOL, 0, sizeof(VOL));
int r = schfs_vol_open(&VOL, &bd3, &CTX, md_palloc);
ok(r == 0, "the volume mounts after a crash mid-transaction");
ok(VOL.generation == good_gen,
"it comes back at the last committed generation, not a partial one");
ok(VOL.recovered == 0,
"and needs no fallback to do it: the newest uberblock is the good one");
/* --- 10b. the pre-crash filesystem is exactly as it was --- */
{
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
uint64_t d = 0, f = 0;
uint16_t ty = 0;
ok(schfs_dir_lookup(&VOL, &c, ROOT, "survivor", 8, &d, &ty) == 0,
"what was committed before the crash is still there");
schfs_cursor_init(&c, &VOL.tree, CURBLK);
ok(d && schfs_dir_lookup(&VOL, &c, d, "keep", 4, &f, &ty) == 0,
"and so is its content");
char buf[32];
memset(buf, 0, sizeof(buf));
schfs_cursor_init(&c, &VOL.tree, CURBLK);
long got = f ? schfs_file_read(&VOL, &c, f, 0, buf, 16, DATABLK) : -1;
ok(got == 16 && memcmp(buf, "before the crash", 16) == 0,
"the file reads back byte for byte");
schfs_cursor_init(&c, &VOL.tree, CURBLK);
uint64_t gone = 0;
ok(schfs_dir_lookup(&VOL, &c, ROOT, "doomed", 6, &gone, &ty) != 0,
"nothing the interrupted transaction did is visible");
}
ok(schfs_btree_check(&VOL.tree) == 0,
"the tree is structurally sound after the crash");
/* --- 10b'. crash at every write point, not one --- */
//
// One crash point proves the mechanism exists. It does not prove there
// is no window -- and a window is exactly what this kind of bug is: a
// handful of writes somewhere in the middle after which the volume
// comes back subtly wrong rather than obviously broken. So: run the
// same transaction again and again, cutting the power one write later
// each time, and check the whole story every time.
{
int points = 0, bad = 0;
for (long cut = 1; cut < 120; cut++) {
memcpy(DISK, saved, NBLOCKS * SCHFS_BLKSIZE);
memset(&VOL, 0, sizeof(VOL));
mkbdev(&bd3);
if (schfs_vol_open(&VOL, &bd3, &CTX, md_palloc) != 0) {
bad++; break;
}
int fired = 0;
if (setjmp(md_crash_jmp) == 0) {
md_crash_armed = 1;
md_fail_after = cut;
uint64_t d = mkdirent(ROOT, "doomed");
for (int i = 0; i < 40; i++) {
char nm[16];
snprintf(nm, sizeof(nm), "f%d", i);
uint64_t f = mkfile(d, nm);
if (f) schfs_file_write(&VOL, f, 0, "junk", 4, DATABLK);
}
schfs_vol_commit(&VOL);
} else {
fired = 1;
}
md_crash_armed = 0;
md_fail_after = -1;
if (!fired)
break; /* ran out of writes to cut at */
points++;
// Whatever the disk looks like now, all three of these must
// hold: it mounts, it is a whole tree, and the data committed
// before the crash is exactly as it was.
memset(&VOL, 0, sizeof(VOL));
mkbdev(&bd3);
if (schfs_vol_open(&VOL, &bd3, &CTX, md_palloc) != 0) {
printf(" cut %ld: does not mount\n", cut);
bad++; continue;
}
if (schfs_btree_check(&VOL.tree) != 0) {
printf(" cut %ld: tree is not sound\n", cut);
bad++; continue;
}
struct schfs_cursor c;
schfs_cursor_init(&c, &VOL.tree, CURBLK);
uint64_t d = 0, f = 0;
uint16_t ty = 0;
if (schfs_dir_lookup(&VOL, &c, ROOT, "survivor", 8, &d, &ty) != 0) {
printf(" cut %ld: lost a committed directory\n", cut);
bad++; continue;
}
schfs_cursor_init(&c, &VOL.tree, CURBLK);
if (schfs_dir_lookup(&VOL, &c, d, "keep", 4, &f, &ty) != 0) {
printf(" cut %ld: lost a committed file\n", cut);
bad++; continue;
}
char buf[32];
memset(buf, 0, sizeof(buf));
schfs_cursor_init(&c, &VOL.tree, CURBLK);
if (schfs_file_read(&VOL, &c, f, 0, buf, 16, DATABLK) != 16 ||
memcmp(buf, "before the crash", 16) != 0) {
printf(" cut %ld: committed file came back changed\n", cut);
bad++; continue;
}
}
char msg[96];
snprintf(msg, sizeof(msg),
"%d crash points, every one mounts intact with its "
"committed data", points);
ok(bad == 0 && points > 20, msg);
}
/* --- 10c. a newest uberblock naming a tree that never landed --- */
//
// This is the failure the commit barrier exists to prevent, and it is
// still reachable through a misdirected write or a bad sector under
// the root. Before the fallback existed the mount simply refused, with
// 127 intact older roots sitting on the disk untouched.
memcpy(DISK, saved, NBLOCKS * SCHFS_BLKSIZE);
memset(&VOL, 0, sizeof(VOL));
mkbdev(&bd3);
if (schfs_vol_open(&VOL, &bd3, &CTX, md_palloc) != 0) {
printf(" FAIL: reopen for the misdirected-write case\n");
failures++;
} else {
uint64_t base_gen = VOL.generation;
// Two more commits, then destroy the block each of their roots
// points at -- so the two newest uberblocks are both intact and
// both name rubbish.
uint64_t roots[2];
for (int i = 0; i < 2; i++) {
char nm[16];
snprintf(nm, sizeof(nm), "later%d", i);
mkfile(ROOT, nm);
schfs_vol_commit(&VOL);
roots[i] = VOL.tree.root;
}
for (int i = 0; i < 2; i++)
memset(DISK + roots[i] * SCHFS_BLKSIZE, 0xA5, SCHFS_BLKSIZE);
memset(&VOL, 0, sizeof(VOL));
mkbdev(&bd3);
int rr = schfs_vol_open(&VOL, &bd3, &CTX, md_palloc);
ok(rr == 0, "a volume whose two newest roots are rubbish still mounts");
// Strictly older, not "exactly two older". Copy-on-write shares
// every unchanged node between generations, so a block that is part
// of the newest tree is very often part of several older ones too --
// destroying it takes them all with it. How far back the ring has to
// reach is therefore not something a test can pin down, and an
// earlier version of this asserted exactly two and was simply wrong
// once the filesystem had enough history to share nodes.
//
// What must hold is that it landed on neither corrupted generation
// and said so.
ok(VOL.generation < base_gen,
"it falls back past both of them to an older readable root");
ok(VOL.recovered >= 2,
"and says that at least those two groups were lost");
ok(schfs_btree_check(&VOL.tree) == 0,
"what it recovered is structurally sound");
}
/* --- 10d. every uberblock destroyed is a failure, not a guess --- */
memcpy(DISK, saved, NBLOCKS * SCHFS_BLKSIZE);
memset(DISK + SCHFS_UBER_START * SCHFS_BLKSIZE, 0,
SCHFS_UBER_COUNT * SCHFS_BLKSIZE);
memset(&VOL, 0, sizeof(VOL));
mkbdev(&bd3);
ok(schfs_vol_open(&VOL, &bd3, &CTX, md_palloc) != 0,
"a volume with no valid uberblock at all is refused, not invented");
// Put the good image back so anything after this runs on a sane volume.
memcpy(DISK, saved, NBLOCKS * SCHFS_BLKSIZE);
memset(&VOL, 0, sizeof(VOL));
mkbdev(&bd3);
schfs_vol_open(&VOL, &bd3, &CTX, md_palloc);
free(saved);
}
printf("\n%s\n", failures ? "FAILED" : "all checks passed");
return failures ? 1 : 0;
}