Transaction groups commit from a schfsd thread every 5 seconds, on sync and fsync, on unmount and shutdown, and early when freed-but-pending blocks crowd the free ones -- the contract ext4 and ZFS have. A crash still lands on the last group whole, and loses at most those seconds. Committing before every write(2) returned cost two cache flushes and an uberblock a call: 4 KB writes go from 2.8 to 6.6 MB/s on AHCI, and from 66 KB/s to 3.9 MB/s on QEMU's IDE disk. Filling the disk made the root volume unmountable, on the last commit as well: a tree update that ran out of blocks halfway was published by the next commit. 1% of the volume (1 to 64 MiB) is now a metadata reserve, as btrfs and ZFS keep one; data stops at it with ENOSPC as a short write, new names at half of it, and deletes may use the rest. A write that runs out part way records what it wrote in the inode rather than leaving blocks past an end of file nothing moved. Deleting a large file goes in 4 MiB steps from the end with a commit allowed between them, as ext4 restarts its transaction through a long truncate. Deleting 80 MB on a full disk ran out of room halfway and leaked the rest. The VFS's dirunlink called the object layer's whole unlink, which freed the object with its last name, so a file removed while open lost its data under the reader. It removes the name only now, and the object is freed on the last iput. An object still open when the machine went down is found by the mount's space scan and freed, like ext4's orphan list. The allocation bitmap has a second level of pointer pages, so the mount ceiling is 64 TiB instead of 64 GiB, and the freed-this-group bitmap has pages only where frees happened: a 1 TiB volume takes 32 MiB. A freed block written after the newest snapshot is reused at once (ZFS's birth-time rule, so bfree carries the block's generation); only older ones are kept while a snapshot exists. Deleting one snapshot no longer stops retaining for the others, which let their blocks be handed out again. Checked under QEMU: crash after sync, after 8 s and within 5 s; fill to ENOSPC, rm, remount; unlink while open; an orphan freed at the next mount; a 1 TiB volume. schfs-check passes, with a new test for the snapshot rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S1giNvrHTrPMPw7V6x1kQ4
1176 lines
48 KiB
C
1176 lines
48 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, uint64_t g) { (void)ctx; schfs_vol_free(&VOL, b, g); }
|
|
static long md_pages_taken, md_pages_given;
|
|
static void *md_palloc(void) { md_pages_taken++; return calloc(1, SCHFS_BLKSIZE); }
|
|
// The matching half. Without it every volume this test opened leaked its whole
|
|
// allocation map -- 2121728 bytes across 518 allocations by the end, which made
|
|
// the target exit non-zero and drowned any real leak the filesystem might have
|
|
// had in expected noise.
|
|
static void md_pfree(void *p) { md_pages_given++; free(p); }
|
|
|
|
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.
|
|
schfs_space_release(&VOL.space); /* before the memset strands it */
|
|
memset(&VOL, 0, sizeof(VOL));
|
|
if (schfs_vol_open(&VOL, &bd2, &CTX, md_palloc, md_pfree) != 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, md_pfree) != 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; }
|
|
// Running out part way is a short write of what fitted, as
|
|
// write(2) is, and the next one is ENOSPC.
|
|
if (w >= 0 && w < (long)sizeof(chunk)) {
|
|
off += (uint64_t)w;
|
|
saw_enospc = schfs_file_write(&VOL, f, off, chunk,
|
|
sizeof(chunk), DATABLK) == -3;
|
|
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");
|
|
|
|
// Data written after the snapshot is in no snapshot, so deleting it
|
|
// gives its blocks back now rather than at the next mount. The old
|
|
// tree nodes the churn copies are kept -- the snapshot may share them
|
|
// -- so the check is that the 64 KB of data came back, not that every
|
|
// block did.
|
|
{
|
|
static char fresh[64 * 1024];
|
|
memset(fresh, 'f', sizeof fresh);
|
|
uint64_t before_free = VOL.space.free_blocks;
|
|
uint64_t f = mkfile(VOL.root_objectid, "fresh");
|
|
schfs_file_write(&VOL, f, 0, fresh, sizeof fresh, DATABLK);
|
|
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
|
|
schfs_dir_del(&VOL, VOL.root_objectid, "fresh", 5);
|
|
schfs_object_free(&VOL, f);
|
|
if (schfs_vol_commit(&VOL) != 0) { printf(" FAIL: commit\n"); failures++; }
|
|
uint64_t lost = before_free > VOL.space.free_blocks
|
|
? before_free - VOL.space.free_blocks : 0;
|
|
ok(lost < sizeof fresh / SCHFS_BLKSIZE,
|
|
"data written after a snapshot is freed while the snapshot exists");
|
|
if (lost >= sizeof fresh / SCHFS_BLKSIZE)
|
|
printf(" (%llu blocks not given back)\n",
|
|
(unsigned long long)lost);
|
|
|
|
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,
|
|
"and the snapshot still reads back after that churn");
|
|
}
|
|
|
|
// 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);
|
|
schfs_space_release(&VOL.space); /* before the memset strands it */
|
|
memset(&VOL, 0, sizeof(VOL));
|
|
int r = schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree);
|
|
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);
|
|
|
|
schfs_space_release(&VOL.space); /* before the memset strands it */
|
|
memset(&VOL, 0, sizeof(VOL));
|
|
mkbdev(&bd3);
|
|
if (schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree) != 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.
|
|
schfs_space_release(&VOL.space); /* before the memset strands it */
|
|
memset(&VOL, 0, sizeof(VOL));
|
|
mkbdev(&bd3);
|
|
if (schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree) != 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);
|
|
schfs_space_release(&VOL.space); /* before the memset strands it */
|
|
memset(&VOL, 0, sizeof(VOL));
|
|
mkbdev(&bd3);
|
|
if (schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree) != 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);
|
|
|
|
schfs_space_release(&VOL.space); /* before the memset strands it */
|
|
memset(&VOL, 0, sizeof(VOL));
|
|
mkbdev(&bd3);
|
|
int rr = schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree);
|
|
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);
|
|
schfs_space_release(&VOL.space); /* before the memset strands it */
|
|
memset(&VOL, 0, sizeof(VOL));
|
|
mkbdev(&bd3);
|
|
ok(schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree) != 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);
|
|
schfs_space_release(&VOL.space); /* before the memset strands it */
|
|
memset(&VOL, 0, sizeof(VOL));
|
|
mkbdev(&bd3);
|
|
schfs_vol_open(&VOL, &bd3, &CTX, md_palloc, md_pfree);
|
|
free(saved);
|
|
}
|
|
|
|
// The last volume opened is still holding its map. Nothing else will take
|
|
// it, and a test that leaks cannot detect a leak: the filesystem's own
|
|
// allocations would arrive as noise inside this one.
|
|
schfs_space_release(&VOL.space);
|
|
|
|
if (md_pages_taken != md_pages_given) {
|
|
printf(" [FAIL] %ld allocation-map pages taken, %ld given back\n",
|
|
md_pages_taken, md_pages_given);
|
|
failures++;
|
|
} else {
|
|
printf(" [ok] every allocation-map page was given back (%ld)\n",
|
|
md_pages_taken);
|
|
}
|
|
|
|
printf("\n%s\n", failures ? "FAILED" : "all checks passed");
|
|
return failures ? 1 : 0;
|
|
}
|