Skip to content
zit Artifact [29f0efc333]
Login

Artifact [29f0efc333]

Artifact 29f0efc3338c14eb87085cd87591d01d78ae1feac8b3f6ab7f5a2533ac01a7db:


//! Deterministic working-tree crash simulator (H2, native harness design — no
//! RFC clause touches the working tree). A decorator `std.Io` that sits in front
//! of a healthy backend io and, at a single seeded operation index, "crashes":
//! it kills the run's forward progress the way a power loss does — the crashing
//! mutation applies at most a torn prefix, and every mutating op after it is a
//! no-op error, so whatever the checkout layer's own rollback tries to do next
//! moves no byte. The seed is the whole universe: the same seed reproduces the
//! same cut and the same on-disk aftermath exactly.
//!
//! The crash-point enumeration technique — walk the operation sequence and cut
//! at every reachable boundary, then assert recovery from each cut — is
//! TigerBeetle's storage-fault-injection lineage (the VOPR's disk model),
//! adapted here from block storage to the POSIX directory surface `update.zig`
//! and `checkout.zig` write through. It mirrors sim/faulty_file.zig's
//! discipline: every fault is a draw from the run PRNG, corruption is a pure
//! function of the payload so a retry never self-heals, and the healthy default
//! (no `crash_at_op`) is a perfect disk that DST phase 2 recovers over.
//!
//! Mechanism: the decorator keeps the backend's `userdata` and copies the
//! backend `VTable`, overriding only the mutating entries. The non-mutating
//! entries stay the backend's own function pointers, so reads/opens/stats never
//! lie — a crash tears WRITES, never the ability to observe what survived.
//! The active decorator is a single-run install (one sweep at a time, single
//! threaded, exactly like update.zig's `test_fault`); the overrides reach it
//! through `active`.

const std = @import("std");

const Io = std.Io;
const Dir = std.Io.Dir;
const File = std.Io.File;

/// The one decorator wired for the current run. Set by `install`, cleared by
/// `uninstall`; the vtable overrides read it. Single-threaded by construction
/// (the DST sweep drives one operation at a time), the same shape update.zig's
/// `test_fault` global uses to reach a file-private fault seam.
var active: ?*FaultyDir = null;

/// Crash schedule for one run. `crash_at_op == null` is a perfect disk (the
/// healthy default DST phase 2 recovers over); a set value crashes as the
/// `crash_at_op`-th mutating operation begins.
pub const CrashCfg = struct {
    /// One-indexed mutating-operation number to crash at, or null for never.
    /// Counting covers every write/sync/create/delete/rename the run performs,
    /// so a value in `[1, total_ops]` selects a reachable cut.
    crash_at_op: ?u64 = null,
    /// When true, the crashing WRITE first persists a random strict prefix of
    /// its bytes (a torn write) before failing — the byte-granular power-loss
    /// tear that leaves a file matching neither its pre- nor post-image. When
    /// false the crash is operation-granular (the write does not apply at all).
    torn_write: bool = false,
    /// When true, the torn prefix is additionally bit-flipped with a
    /// payload-derived mask (pure function of the offset, never self-heals),
    /// mirroring faulty_file.zig's `corruptionMask`.
    corrupt_torn: bool = false,
};

pub const FaultyDir = struct {
    /// The healthy io every non-crashing op delegates to.
    backend: Io,
    /// The backend's own vtable, kept so the overrides can call through it.
    backend_vtable: *const Io.VTable,
    /// A copy of `backend_vtable` with the mutating entries pointed at the
    /// overrides below. `io()` hands out `&self.vtable`, so `self` must outlive
    /// every use (the caller pins it as a `var`).
    vtable: Io.VTable,
    random: std.Random,
    cfg: CrashCfg,
    /// Mutating operations begun so far (monotonic; the crash index is compared
    /// against it).
    op_index: u64 = 0,
    /// Latched once the cut fires; every later mutating op is a frozen no-op.
    crashed: bool = false,
    /// The operation index the crash landed at (0 until it fires) — the value a
    /// failure report prints so the cut is auditable.
    crash_op_index: u64 = 0,

    /// The injected failure. `AccessDenied` is a member of every mutating error
    /// set on this path (write/sync/create/delete/rename), so one constant maps
    /// cleanly into all of them; update.zig collapses any of them to its own
    /// `error.Storage`, exactly as a real device fault would.
    const injected = error.AccessDenied;

    pub fn init(backend: Io, random: std.Random, cfg: CrashCfg) FaultyDir {
        var v = backend.vtable.*;
        v.fileWritePositional = wrapWrite;
        v.fileSync = wrapSync;
        v.dirCreateFile = wrapCreateFile;
        v.dirCreateFileAtomic = wrapCreateFileAtomic;
        v.dirCreateDir = wrapCreateDir;
        v.dirDeleteFile = wrapDeleteFile;
        v.dirDeleteDir = wrapDeleteDir;
        v.dirRename = wrapRename;
        return .{
            .backend = backend,
            .backend_vtable = backend.vtable,
            .vtable = v,
            .random = random,
            .cfg = cfg,
        };
    }

    /// The decorated io. Shares the backend's `userdata`, so the un-overridden
    /// backend entries receive exactly the context they expect.
    pub fn io(self: *FaultyDir) Io {
        return .{ .userdata = self.backend.userdata, .vtable = &self.vtable };
    }

    pub fn install(self: *FaultyDir) void {
        std.debug.assert(active == null); // one decorator at a time
        active = self;
    }

    pub fn uninstall() void {
        active = null;
    }

    const Decision = enum { proceed, crash_now, frozen };

    fn gate(self: *FaultyDir) Decision {
        if (self.crashed) return .frozen;
        self.op_index += 1;
        if (self.cfg.crash_at_op) |k| {
            if (self.op_index >= k) {
                self.crashed = true;
                self.crash_op_index = self.op_index;
                return .crash_now;
            }
        }
        return .proceed;
    }

    fn wrapWrite(
        ud: ?*anyopaque,
        file: File,
        header: []const u8,
        data: []const []const u8,
        splat: usize,
        offset: u64,
    ) File.WritePositionalError!usize {
        const self = active.?;
        switch (self.gate()) {
            .proceed => return self.backend_vtable.fileWritePositional(ud, file, header, data, splat, offset),
            .frozen => return injected,
            .crash_now => {
                if (self.cfg.torn_write) self.applyTorn(ud, file, data, offset);
                return injected;
            },
        }
    }

    /// Persist a strict prefix of the crashing write (the torn tail a power loss
    /// leaves in the page cache), optionally payload-corrupted. Best-effort: a
    /// backend hiccup here only means a slightly shorter tear, never a false
    /// invariant verdict.
    fn applyTorn(self: *FaultyDir, ud: ?*anyopaque, file: File, data: []const []const u8, offset: u64) void {
        for (data) |buf| {
            if (buf.len < 2) continue;
            const k = 1 + self.random.uintLessThan(usize, buf.len - 1); // 1..=len-1
            if (self.cfg.corrupt_torn) {
                var scratch: [512]u8 = undefined;
                const n = @min(k, scratch.len);
                @memcpy(scratch[0..n], buf[0..n]);
                var i: usize = 0;
                while (i < n) : (i += 1) scratch[i] ^= corruptionMask(offset + i);
                _ = self.backend_vtable.fileWritePositional(ud, file, &.{}, &.{scratch[0..n]}, 1, offset) catch {};
            } else {
                _ = self.backend_vtable.fileWritePositional(ud, file, &.{}, &.{buf[0..k]}, 1, offset) catch {};
            }
            return;
        }
    }

    fn wrapSync(ud: ?*anyopaque, file: File) File.SyncError!void {
        const self = active.?;
        switch (self.gate()) {
            .proceed => return self.backend_vtable.fileSync(ud, file),
            .frozen, .crash_now => return injected,
        }
    }

    fn wrapCreateFile(ud: ?*anyopaque, dir: Dir, sub_path: []const u8, opts: Dir.CreateFileOptions) File.OpenError!File {
        const self = active.?;
        switch (self.gate()) {
            .proceed => return self.backend_vtable.dirCreateFile(ud, dir, sub_path, opts),
            .frozen, .crash_now => return injected,
        }
    }

    fn wrapCreateFileAtomic(ud: ?*anyopaque, dir: Dir, sub_path: []const u8, opts: Dir.CreateFileAtomicOptions) Dir.CreateFileAtomicError!File.Atomic {
        const self = active.?;
        switch (self.gate()) {
            .proceed => return self.backend_vtable.dirCreateFileAtomic(ud, dir, sub_path, opts),
            .frozen, .crash_now => return injected,
        }
    }

    fn wrapCreateDir(ud: ?*anyopaque, dir: Dir, sub_path: []const u8, perm: Dir.Permissions) Dir.CreateDirError!void {
        const self = active.?;
        switch (self.gate()) {
            .proceed => return self.backend_vtable.dirCreateDir(ud, dir, sub_path, perm),
            .frozen, .crash_now => return injected,
        }
    }

    fn wrapDeleteFile(ud: ?*anyopaque, dir: Dir, sub_path: []const u8) Dir.DeleteFileError!void {
        const self = active.?;
        switch (self.gate()) {
            .proceed => return self.backend_vtable.dirDeleteFile(ud, dir, sub_path),
            .frozen, .crash_now => return injected,
        }
    }

    fn wrapDeleteDir(ud: ?*anyopaque, dir: Dir, sub_path: []const u8) Dir.DeleteDirError!void {
        const self = active.?;
        switch (self.gate()) {
            .proceed => return self.backend_vtable.dirDeleteDir(ud, dir, sub_path),
            .frozen, .crash_now => return injected,
        }
    }

    fn wrapRename(ud: ?*anyopaque, old_dir: Dir, old_sub_path: []const u8, new_dir: Dir, new_sub_path: []const u8) Dir.RenameError!void {
        const self = active.?;
        switch (self.gate()) {
            .proceed => return self.backend_vtable.dirRename(ud, old_dir, old_sub_path, new_dir, new_sub_path),
            .frozen, .crash_now => return injected,
        }
    }
};

/// Payload-derived, pure function of the byte offset (faulty_file.zig §B): every
/// retry corrupts identically and the mask is always nonzero, so it always flips.
fn corruptionMask(off: u64) u8 {
    const h = std.hash.Wyhash.hash(0, std.mem.asBytes(&off));
    return @truncate(1 | (h & 7));
}

test "faulty_dir: a healthy schedule delegates every op unchanged" {
    const t = std.testing;
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();

    var prng = std.Random.DefaultPrng.init(1);
    var faulty = FaultyDir.init(t.io, prng.random(), .{}); // no crash
    faulty.install();
    defer FaultyDir.uninstall();
    const io = faulty.io();

    var file = try tmp.dir.createFile(io, "f", .{ .read = false, .truncate = true, .resolve_beneath = true });
    try file.writePositionalAll(io, "hello world", 0);
    try file.sync(io);
    file.close(io);
    try t.expect(!faulty.crashed);

    const back = try tmp.dir.readFileAlloc(t.io, "f", t.allocator, .limited(64));
    defer t.allocator.free(back);
    try t.expectEqualStrings("hello world", back);
}

test "faulty_dir: the crash op errors and freezes every later mutation" {
    const t = std.testing;
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();

    var prng = std.Random.DefaultPrng.init(2);
    // Crash at the first mutating op: the very first createFile fails, and the
    // decorator latches so the follow-up delete is a no-op error too.
    var faulty = FaultyDir.init(t.io, prng.random(), .{ .crash_at_op = 1 });
    faulty.install();
    defer FaultyDir.uninstall();
    const io = faulty.io();

    try t.expectError(error.AccessDenied, tmp.dir.createFile(io, "f", .{ .truncate = true, .resolve_beneath = true }));
    try t.expect(faulty.crashed);
    try t.expectEqual(@as(u64, 1), faulty.crash_op_index);
    // Frozen: a subsequent delete cannot move a byte.
    try t.expectError(error.AccessDenied, tmp.dir.deleteFile(io, "anything"));
}

test "faulty_dir: a torn write leaves a strict prefix, matching neither image" {
    const t = std.testing;
    var tmp = t.tmpDir(.{});
    defer tmp.cleanup();

    // First lay down the pre-image with a healthy decorator.
    {
        var prng0 = std.Random.DefaultPrng.init(0);
        var healthy = FaultyDir.init(t.io, prng0.random(), .{});
        healthy.install();
        defer FaultyDir.uninstall();
        const hio = healthy.io();
        var f = try tmp.dir.createFile(hio, "f", .{ .truncate = true, .resolve_beneath = true });
        try f.writePositionalAll(hio, "AAAAAAAAAAAAAAAA", 0);
        try f.sync(hio);
        f.close(hio);
    }

    // Now crash mid-write of the post-image: the file already exists, so open +
    // positional overwrite tears a prefix of the new bytes over the old.
    var prng = std.Random.DefaultPrng.init(7);
    var faulty = FaultyDir.init(t.io, prng.random(), .{ .crash_at_op = 1, .torn_write = true });
    faulty.install();
    defer FaultyDir.uninstall();
    const io = faulty.io();

    var f = try tmp.dir.openFile(io, "f", .{ .mode = .write_only, .resolve_beneath = true });
    try t.expectError(error.AccessDenied, f.writePositionalAll(io, "BBBBBBBBBBBBBBBB", 0));
    f.close(io);

    const back = try tmp.dir.readFileAlloc(t.io, "f", t.allocator, .limited(64));
    defer t.allocator.free(back);
    // A strict, non-empty prefix of B's overwrote A's: neither all-A nor all-B.
    try t.expect(back.len == 16);
    try t.expect(std.mem.indexOfScalar(u8, back, 'B') != null); // some new bytes landed
    try t.expect(std.mem.indexOfScalar(u8, back, 'A') != null); // but not all of them
}