CI / build-and-test (push) Failing after 38s
Prompted by a direct question about data-integrity trustworthiness: the crash-safety design (self-checking journal records, atomic-rename compaction, Section 4.3/4.4) had never actually been tested against a real crash -- only reasoned about statically and tested against an already-corrupted file (test_pack_overlay.c, a different scenario). Added tests/test_crash_consistency.c: real fork()+SIGKILL fault injection against an actual child process, not a hand-truncated file standing in for a crash, with empirically-calibrated kill-delay sampling (measured via throwaway scripts, not guessed) so trials land genuine mid-operation interruptions rather than always completing first: - Journal-write crash injection (25 trials): kills a child mid-burst of individually-journaled, individually-fsynced writes; verifies a strict clean-prefix recovery (every completed write present and correct, every write after the kill cleanly absent, no gaps). 20-22/25 trials per run land a genuine interruption; zero corruption found. - Compaction crash injection (40 trials): kills a child mid-vfs_sync; verifies every write durably journaled *before* vfs_sync was called survives regardless of whether compaction itself completed. 40/40 trials per run land a genuine interruption; zero corruption found. A real finding from building this test, not from the code under test: the first draft reported 128 failures, every one a bug in the test itself -- it couldn't distinguish "the child was killed before ever attempting this write" from "the write completed and was then lost," since SIGKILL can't be caught to report progress. Fixed with an independent progress side-channel (plain write()+fsync() on a separate file, entirely outside packfs) as ground truth. Documented in CLAUDE.md's new "Known reliability characteristics" section, including this finding, because a claim is only as trustworthy as what's measuring it. Also investigated whether this sandbox's TSan block is actually unfixable, rather than re-asserting the known limitation: tried personality(ADDR_NO_RANDOMIZE) directly (EPERM), setarch -R (fails identically), searched TSAN_OPTIONS for a bypass (none exists), and checked capabilities/seccomp/unshare --user (zero effective caps, active seccomp filter, namespace escape also blocked). Confirmed categorical and documented as such in CLAUDE.md/CONTRIBUTING.md, rather than left as an unexamined "it doesn't work here." Also closed a real documentation gap SECURITY.md had: the pack integrity checksum's non-cryptographic nature (FNV-1a64, already documented in the context of the compaction-dedup bug) had never been explicitly connected to its *other* use, load-time pack integrity validation -- added, since it's a real, relevant caveat for anyone relying on that checksum against a deliberate adversary rather than accidental corruption. Verified: clean make all + make test (all 7 binaries), full ASan/UBSan sweep of all 7 binaries with zero real findings, and the new test run standalone 4+ times (this session) with stable, consistent results. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqJpkdJ6Njnt1pw3CbghzB
402 lines
16 KiB
C
402 lines
16 KiB
C
/*
|
|
* test_crash_consistency.c — real fault injection for Section 4.3/4.4's
|
|
* crash-safety claims (self-checking journal records, atomic-rename
|
|
* compaction), via an actual fork()+SIGKILL of a child mid-operation and
|
|
* a fresh reopen afterward, not a hand-truncated file standing in for a
|
|
* crash. The two scenarios below (journal writes, compaction) were the
|
|
* two concrete claims CLAUDE.md/concept.md make about crash behavior;
|
|
* before this file, neither had ever actually been exercised by killing
|
|
* a real process mid-write — only by static reasoning about the design
|
|
* and by test_pack_overlay.c's separate (unrelated) test of loading an
|
|
* already-corrupted pack file.
|
|
*
|
|
* Timing constants below are empirically calibrated for this project's
|
|
* own build (see the comment above each scenario), not arbitrary: the
|
|
* goal is to sample kill delays across the real duration of the
|
|
* operation under test, confirmed per-run by reporting how many trials
|
|
* actually landed a genuine mid-operation kill (WIFSIGNALED) versus how
|
|
* many the child outran (WIFEXITED) — a run reporting zero interrupted
|
|
* trials would mean this test verified nothing and should be treated as
|
|
* a test bug, not a pass; see the CHECK on INTERRUPTED_MIN below.
|
|
*/
|
|
|
|
#include <fcntl.h>
|
|
#include <signal.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/wait.h>
|
|
#include <time.h>
|
|
#include <unistd.h>
|
|
|
|
#include "packfs.h"
|
|
#include "test_harness.h"
|
|
|
|
static void sleep_sec(double s) {
|
|
struct timespec ts;
|
|
ts.tv_sec = (time_t)s;
|
|
ts.tv_nsec = (long)((s - (double)ts.tv_sec) * 1e9);
|
|
nanosleep(&ts, NULL);
|
|
}
|
|
|
|
/* fork the given child function, let it run for kill_delay seconds, then
|
|
* SIGKILL it. Returns 1 if the child was genuinely still running (killed
|
|
* by the signal), 0 if it had already exited on its own. */
|
|
static int run_and_kill(void (*child_fn)(void *), void *arg, double kill_delay) {
|
|
pid_t pid = fork();
|
|
if (pid < 0) { perror("fork"); exit(1); }
|
|
if (pid == 0) {
|
|
child_fn(arg);
|
|
_exit(99); /* child_fn must _exit itself; this is a safety net */
|
|
}
|
|
sleep_sec(kill_delay);
|
|
kill(pid, SIGKILL);
|
|
int status = 0;
|
|
waitpid(pid, &status, 0);
|
|
return WIFSIGNALED(status) && WTERMSIG(status) == SIGKILL;
|
|
}
|
|
|
|
/* ================= scenario A: kill mid-burst of journaled writes ===== */
|
|
/*
|
|
* Calibrated: ~6ms per individual journaled create+write+close on this
|
|
* build/environment (fopen+fwrite+fflush+fsync per record, Section 4.4).
|
|
* BURST=60 writes takes ~0.36s; sampling kill delays across [0, 0.40s]
|
|
* covers the whole burst including its very start and its tail.
|
|
*/
|
|
#define BURST_N 60
|
|
#define BURST_TRIALS 25
|
|
#define BURST_MAX_DELAY 0.40
|
|
|
|
typedef struct { char pack_path[512]; } BurstArg;
|
|
|
|
static void mk_burst_content(char *buf, size_t n, int i) {
|
|
snprintf(buf, n, "burst-content-%06d", i);
|
|
}
|
|
|
|
static void burst_child(void *arg_) {
|
|
BurstArg *arg = (BurstArg *)arg_;
|
|
Vfs *v = vfs_new();
|
|
Backend *mem = backend_mem_new();
|
|
int oerr = 0;
|
|
Backend *ov = backend_overlay_new(arg->pack_path, mem, &oerr);
|
|
if (!ov) _exit(2);
|
|
if (vfs_mount(v, "/", ov) != VFS_OK) _exit(3);
|
|
|
|
char name[64], content[64];
|
|
for (int i = 0; i < BURST_N; i++) {
|
|
snprintf(name, sizeof(name), "/f%05d.txt", i);
|
|
mk_burst_content(content, sizeof(content), i);
|
|
int err = 0;
|
|
VfsFile *f = vfs_open(v, name, VFS_O_WRONLY | VFS_O_CREAT, &err);
|
|
if (!f) _exit(4);
|
|
if (vfs_write(f, content, strlen(content)) != (pfs_isize)strlen(content)) _exit(5);
|
|
if (vfs_close(f) != VFS_OK) _exit(6);
|
|
}
|
|
_exit(0); /* all BURST_N writes durably committed */
|
|
}
|
|
|
|
static void test_journal_burst_crash(void) {
|
|
BurstArg arg;
|
|
snprintf(arg.pack_path, sizeof(arg.pack_path), "/tmp/packfs_test_crash_burst_%d.img", (int)getpid());
|
|
char jpath[600];
|
|
snprintf(jpath, sizeof(jpath), "%s.jnl", arg.pack_path);
|
|
|
|
int interrupted_count = 0;
|
|
for (int trial = 0; trial < BURST_TRIALS; trial++) {
|
|
unlink(arg.pack_path);
|
|
unlink(jpath);
|
|
double delay = (BURST_MAX_DELAY * (double)trial) / (double)(BURST_TRIALS - 1);
|
|
int interrupted = run_and_kill(burst_child, &arg, delay);
|
|
if (interrupted) interrupted_count++;
|
|
|
|
Vfs *v2 = vfs_new();
|
|
Backend *mem2 = backend_mem_new();
|
|
int oerr2 = 0;
|
|
Backend *ov2 = backend_overlay_new(arg.pack_path, mem2, &oerr2);
|
|
CHECK(ov2 != NULL); /* a crash mid-journal must never make reopening fail */
|
|
if (!ov2) { vfs_free(v2); backend_free(mem2); continue; }
|
|
CHECK_EQ_INT(vfs_mount(v2, "/", ov2), VFS_OK);
|
|
|
|
/* clean-prefix invariant: every present file has exactly correct
|
|
* content, and there is no gap (file i missing, file i+1 present) */
|
|
int last_present = -1;
|
|
char name[64], expect[64], buf[64];
|
|
for (int i = 0; i < BURST_N; i++) {
|
|
snprintf(name, sizeof(name), "/f%05d.txt", i);
|
|
int err = 0;
|
|
VfsFile *f = vfs_open(v2, name, VFS_O_RDONLY, &err);
|
|
if (!f) continue;
|
|
mk_burst_content(expect, sizeof(expect), i);
|
|
memset(buf, 0, sizeof(buf));
|
|
pfs_isize n = vfs_read(f, buf, sizeof(buf));
|
|
vfs_close(f);
|
|
if (n != (pfs_isize)strlen(expect) || memcmp(buf, expect, strlen(expect)) != 0) {
|
|
fprintf(stderr, "FAIL trial %d: %s has wrong/corrupt content after crash (delay=%.4f)\n",
|
|
trial, name, delay);
|
|
pfs_test_failures++;
|
|
}
|
|
if (i != last_present + 1) {
|
|
fprintf(stderr, "FAIL trial %d: gap in journal replay prefix at %s (delay=%.4f)\n",
|
|
trial, name, delay);
|
|
pfs_test_failures++;
|
|
}
|
|
last_present = i;
|
|
}
|
|
|
|
vfs_unmount(v2, "/");
|
|
backend_free(ov2);
|
|
backend_free(mem2);
|
|
vfs_free(v2);
|
|
}
|
|
|
|
unlink(arg.pack_path);
|
|
unlink(jpath);
|
|
fprintf(stderr, "journal-burst crash test: %d/%d trials genuinely interrupted mid-burst\n",
|
|
interrupted_count, BURST_TRIALS);
|
|
/* if this is ever 0, the delay schedule no longer matches this
|
|
* machine's write latency and the test isn't exercising the crash
|
|
* path at all -- that is itself a failure, not a quiet pass. */
|
|
CHECK(interrupted_count >= BURST_TRIALS / 4);
|
|
}
|
|
|
|
/* ================= scenario B: kill mid-compaction ===================== */
|
|
/*
|
|
* Calibrated: a 100-file, 2KB-payload durable baseline (built once,
|
|
* untimed, then compacted once to get a clean golden pack.img) plus a
|
|
* per-trial 10-file durable "extras" batch takes ~0.058s to journal and
|
|
* the following compaction takes ~0.012s on this build/environment.
|
|
* Sampling kill delays across [0, 0.08s] covers extras-journaling,
|
|
* compaction start, mid-compaction, and post-rename.
|
|
*
|
|
* The invariant under test is not "pre-state XOR post-state" -- it's
|
|
* simpler and is the actual documented guarantee (concept.md's crash-
|
|
* safety constraints, CLAUDE.md's "if writing pack.img.tmp or its fsync
|
|
* fails, compaction aborts and the existing pack + journal are
|
|
* untouched"): anything durably journaled (fsynced) *before* vfs_sync is
|
|
* even called must survive a crash during that vfs_sync call, no matter
|
|
* where in it the crash lands -- either via journal replay (if
|
|
* compaction didn't finish) or by being included in the fresh pack (if
|
|
* it did).
|
|
*/
|
|
#define GOLDEN_N 100
|
|
#define EXTRA_N 10
|
|
#define PAYLOAD_SIZE 2048
|
|
#define COMPACT_TRIALS 40
|
|
#define COMPACT_MAX_DELAY 0.08
|
|
|
|
typedef struct { char pack_path[512]; char golden_path[512]; char progress_path[512]; } CompactArg;
|
|
|
|
static void mk_payload(char *buf, size_t n, const char *tag, int i) {
|
|
int len = snprintf(buf, n, "%s-%06d-", tag, i);
|
|
for (size_t j = (size_t)len; j < n; j++) buf[j] = (char)('a' + (int)(j % 26));
|
|
}
|
|
|
|
static int copy_file(const char *from, const char *to) {
|
|
FILE *in = fopen(from, "rb");
|
|
if (!in) return -1;
|
|
FILE *out = fopen(to, "wb");
|
|
if (!out) { fclose(in); return -1; }
|
|
char buf[65536];
|
|
size_t n;
|
|
while ((n = fread(buf, 1, sizeof(buf), in)) > 0) fwrite(buf, 1, n, out);
|
|
fclose(in);
|
|
fclose(out);
|
|
return 0;
|
|
}
|
|
|
|
/* Records "N extras durably completed" via a plain POSIX write+fsync on a
|
|
* dedicated file, entirely independent of packfs. This is the test's
|
|
* ground truth for how far the child actually got before SIGKILL landed
|
|
* -- without it, an extra that the child simply never reached in time
|
|
* (expected, not a bug) is indistinguishable from one that completed and
|
|
* was then lost (a real bug), since SIGKILL cannot be caught to report
|
|
* progress any other way. The one imprecision this leaves: the tiny gap
|
|
* between vfs_close() returning (extra i durably in the journal) and
|
|
* this progress write's own fsync landing can make the progress count
|
|
* undercount by one extra in the worst case -- which only makes the
|
|
* check *less* strict at the margin (skips verifying the most recent
|
|
* extra), never produces a false failure. */
|
|
static void record_progress(const char *progress_path, int n_done) {
|
|
int fd = open(progress_path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
|
|
if (fd < 0) return;
|
|
char buf[16];
|
|
int len = snprintf(buf, sizeof(buf), "%d", n_done);
|
|
write(fd, buf, (size_t)len);
|
|
fsync(fd);
|
|
close(fd);
|
|
}
|
|
|
|
static int read_progress(const char *progress_path) {
|
|
FILE *fp = fopen(progress_path, "r");
|
|
if (!fp) return 0;
|
|
int n = 0;
|
|
if (fscanf(fp, "%d", &n) != 1) n = 0;
|
|
fclose(fp);
|
|
return n;
|
|
}
|
|
|
|
static void compact_child(void *arg_) {
|
|
CompactArg *arg = (CompactArg *)arg_;
|
|
record_progress(arg->progress_path, 0);
|
|
Vfs *v = vfs_new();
|
|
Backend *mem = backend_mem_new();
|
|
int oerr = 0;
|
|
Backend *ov = backend_overlay_new(arg->pack_path, mem, &oerr);
|
|
if (!ov) _exit(2);
|
|
if (vfs_mount(v, "/", ov) != VFS_OK) _exit(3);
|
|
|
|
char payload[PAYLOAD_SIZE];
|
|
for (int i = 0; i < EXTRA_N; i++) {
|
|
char name[64];
|
|
snprintf(name, sizeof(name), "/extra%04d.dat", i);
|
|
mk_payload(payload, sizeof(payload), "extra", i);
|
|
int err = 0;
|
|
VfsFile *f = vfs_open(v, name, VFS_O_WRONLY | VFS_O_CREAT, &err);
|
|
if (!f) _exit(4);
|
|
if (vfs_write(f, payload, sizeof(payload)) != (pfs_isize)sizeof(payload)) _exit(5);
|
|
if (vfs_close(f) != VFS_OK) _exit(6);
|
|
record_progress(arg->progress_path, i + 1); /* extra i is now durable */
|
|
}
|
|
/* every extra above is already durable (each fsynced individually);
|
|
* this is the operation actually being crash-tested */
|
|
if (vfs_sync(v, "/") != VFS_OK) _exit(7);
|
|
_exit(0);
|
|
}
|
|
|
|
static void build_golden_baseline(CompactArg *arg) {
|
|
unlink(arg->pack_path);
|
|
char jpath[600];
|
|
snprintf(jpath, sizeof(jpath), "%s.jnl", arg->pack_path);
|
|
unlink(jpath);
|
|
|
|
Vfs *v = vfs_new();
|
|
Backend *mem = backend_mem_new();
|
|
int oerr = 0;
|
|
Backend *ov = backend_overlay_new(arg->pack_path, mem, &oerr);
|
|
CHECK(ov != NULL);
|
|
CHECK_EQ_INT(vfs_mount(v, "/", ov), VFS_OK);
|
|
|
|
char payload[PAYLOAD_SIZE];
|
|
for (int i = 0; i < GOLDEN_N; i++) {
|
|
char name[64];
|
|
snprintf(name, sizeof(name), "/base%05d.dat", i);
|
|
mk_payload(payload, sizeof(payload), "base", i);
|
|
int err = 0;
|
|
VfsFile *f = vfs_open(v, name, VFS_O_WRONLY | VFS_O_CREAT, &err);
|
|
CHECK(f != NULL);
|
|
CHECK_EQ_INT(vfs_write(f, payload, sizeof(payload)), (pfs_isize)sizeof(payload));
|
|
CHECK_EQ_INT(vfs_close(f), VFS_OK);
|
|
}
|
|
CHECK_EQ_INT(vfs_sync(v, "/"), VFS_OK); /* clean, untimed compaction */
|
|
|
|
vfs_unmount(v, "/");
|
|
backend_free(ov);
|
|
backend_free(mem);
|
|
vfs_free(v);
|
|
unlink(jpath); /* golden state: compacted pack, no pending journal */
|
|
|
|
CHECK_EQ_INT(copy_file(arg->pack_path, arg->golden_path), 0);
|
|
}
|
|
|
|
static void test_compaction_crash(void) {
|
|
CompactArg arg;
|
|
snprintf(arg.pack_path, sizeof(arg.pack_path), "/tmp/packfs_test_crash_compact_%d.img", (int)getpid());
|
|
snprintf(arg.golden_path, sizeof(arg.golden_path), "/tmp/packfs_test_crash_golden_%d.img", (int)getpid());
|
|
snprintf(arg.progress_path, sizeof(arg.progress_path), "/tmp/packfs_test_crash_progress_%d.txt", (int)getpid());
|
|
char jpath[600];
|
|
snprintf(jpath, sizeof(jpath), "%s.jnl", arg.pack_path);
|
|
|
|
build_golden_baseline(&arg);
|
|
|
|
int interrupted_count = 0;
|
|
for (int trial = 0; trial < COMPACT_TRIALS; trial++) {
|
|
CHECK_EQ_INT(copy_file(arg.golden_path, arg.pack_path), 0);
|
|
unlink(jpath);
|
|
|
|
double delay = (COMPACT_MAX_DELAY * (double)trial) / (double)(COMPACT_TRIALS - 1);
|
|
int interrupted = run_and_kill(compact_child, &arg, delay);
|
|
if (interrupted) interrupted_count++;
|
|
|
|
Vfs *v2 = vfs_new();
|
|
Backend *mem2 = backend_mem_new();
|
|
int oerr2 = 0;
|
|
Backend *ov2 = backend_overlay_new(arg.pack_path, mem2, &oerr2);
|
|
CHECK(ov2 != NULL); /* a crash mid-compaction must never make reopening fail */
|
|
if (!ov2) { vfs_free(v2); backend_free(mem2); continue; }
|
|
CHECK_EQ_INT(vfs_mount(v2, "/", ov2), VFS_OK);
|
|
|
|
/* the pre-existing golden baseline must always survive */
|
|
char name[64], expect[PAYLOAD_SIZE], buf[PAYLOAD_SIZE];
|
|
for (int i = 0; i < GOLDEN_N; i++) {
|
|
snprintf(name, sizeof(name), "/base%05d.dat", i);
|
|
mk_payload(expect, sizeof(expect), "base", i);
|
|
int err = 0;
|
|
VfsFile *f = vfs_open(v2, name, VFS_O_RDONLY, &err);
|
|
if (!f) {
|
|
fprintf(stderr, "FAIL trial %d: baseline file %s LOST after compaction crash (delay=%.4f)\n",
|
|
trial, name, delay);
|
|
pfs_test_failures++;
|
|
continue;
|
|
}
|
|
memset(buf, 0, sizeof(buf));
|
|
pfs_isize n = vfs_read(f, buf, sizeof(buf));
|
|
vfs_close(f);
|
|
if (n != (pfs_isize)sizeof(buf) || memcmp(buf, expect, sizeof(buf)) != 0) {
|
|
fprintf(stderr, "FAIL trial %d: baseline file %s CORRUPTED after compaction crash (delay=%.4f)\n",
|
|
trial, name, delay);
|
|
pfs_test_failures++;
|
|
}
|
|
}
|
|
|
|
/* Every "extra" write the child actually completed (vfs_close
|
|
* returned VFS_OK) before it was killed was already durably
|
|
* journaled at that point -- it must survive regardless of
|
|
* whether the subsequent vfs_sync (compaction) itself completed.
|
|
* completed_extras, from the independent progress side-channel,
|
|
* is the ground truth for how many extras the child actually
|
|
* finished; extras beyond that were never attempted and their
|
|
* absence is expected, not a bug. */
|
|
int completed_extras = read_progress(arg.progress_path);
|
|
CHECK(completed_extras >= 0 && completed_extras <= EXTRA_N);
|
|
for (int i = 0; i < completed_extras; i++) {
|
|
snprintf(name, sizeof(name), "/extra%04d.dat", i);
|
|
mk_payload(expect, sizeof(expect), "extra", i);
|
|
int err = 0;
|
|
VfsFile *f = vfs_open(v2, name, VFS_O_RDONLY, &err);
|
|
if (!f) {
|
|
fprintf(stderr, "FAIL trial %d: durably-completed %s LOST after compaction crash "
|
|
"(delay=%.4f, completed_extras=%d)\n", trial, name, delay, completed_extras);
|
|
pfs_test_failures++;
|
|
continue;
|
|
}
|
|
memset(buf, 0, sizeof(buf));
|
|
pfs_isize n = vfs_read(f, buf, sizeof(buf));
|
|
vfs_close(f);
|
|
if (n != (pfs_isize)sizeof(buf) || memcmp(buf, expect, sizeof(buf)) != 0) {
|
|
fprintf(stderr, "FAIL trial %d: durably-completed %s CORRUPTED after compaction crash "
|
|
"(delay=%.4f, completed_extras=%d)\n", trial, name, delay, completed_extras);
|
|
pfs_test_failures++;
|
|
}
|
|
}
|
|
|
|
vfs_unmount(v2, "/");
|
|
backend_free(ov2);
|
|
backend_free(mem2);
|
|
vfs_free(v2);
|
|
}
|
|
|
|
unlink(arg.pack_path);
|
|
unlink(arg.golden_path);
|
|
unlink(arg.progress_path);
|
|
unlink(jpath);
|
|
fprintf(stderr, "compaction crash test: %d/%d trials genuinely interrupted mid-compaction\n",
|
|
interrupted_count, COMPACT_TRIALS);
|
|
CHECK(interrupted_count >= COMPACT_TRIALS / 4);
|
|
}
|
|
|
|
int main(void) {
|
|
test_journal_burst_crash();
|
|
test_compaction_crash();
|
|
TEST_MAIN_END();
|
|
}
|