Disk Fault Model
Marionette's simulated disk is an in-memory, deterministic fault model. It is
not an emulator for a particular filesystem or block device. Storage code
should normally use std.Io through env.io(); mar.Disk is the lower-level
capability underneath that backend.
Portable v1
The current contract is portable_v1, numeric version 1. Its constants are
mar.disk_semantic_contract and mar.disk_semantic_version. Each simulated
disk records a disk.model event containing the contract, version, sector
size, tear model, reorder model, and lifecycle policy.
The contract guarantees:
- A successful write is visible immediately but remains pending until the file is synced.
- A torn pending write lands a prefix of whole sectors, never a byte prefix.
- A successful reorder roll reverses all surviving pending writes for that
crash; each is classified as
reordered. - Crash faults affect only pending writes and metadata. Unrelated durable state remains exact.
corruptSectordestructively changes existing logical media. A corrupt-read fault changes only the returned bytes.setLength,delete, andrenamefirst commit pending writes for the affected source path, then perform the lifecycle change.- Crash metadata is resolved newest to oldest. When rollback would collide with a later surviving create, the later entry wins.
- A crash and its trace/process notification are atomic. Allocation or trace failure restores the pre-crash media, liveness, random stream, and trace so the same crash can be retried exactly.
Lifecycle operations deliberately do not stand in for durability boundaries.
Only successful sync and syncDir calls support a portable crash-survival
claim.
Capabilities
One mar.SimDisk provides two views over the same state:
mar.Diskexposes reads, writes, sync, metadata, and file lifecycle operations to the application.mar.DiskControllets the harness configure faults, corrupt sectors, and crash or restart the disk.
Application code should receive env.io() or, for direct model tests,
env.disk. Harness-only operations are available through
world.simulate(...).control.disk.
Logical file paths never address the host filesystem. They contain non-empty
/-separated components and reject ., .., empty components, backslashes,
NUL, and host absolute or drive roots. . names the logical root directory
for syncDir.
The World owns the clock, random stream, global event index, and trace. Disk
code must not use wall-clock time, host randomness, pointer identity, hash-map
iteration order, or host filesystem state as model inputs.
Operations and ordering
Every operation receives a deterministic id. Ready operations are ordered by
(ready_at, op_id). Operations are synchronous to application code: a call
may advance simulated time before returning.
Successful writes are pending and visible to later reads. File sync commits
pending bytes. syncDir commits creates, deletes, and renames for that logical
directory. A newly created file becomes durable by syncing the file and then
its parent directory. A cross-directory rename requires syncing both parents.
setLength extension is zero-filled by the disk authority. It is one
lifecycle operation, not a series of independently fallible sector writes.
Faults
DiskFaultOptions configures deterministic rates for:
- read and write I/O errors;
- corrupt reads;
- lost pending writes;
- sector-prefix torn pending writes;
- reordered pending writes; and
- lost pending metadata.
The harness may also call corruptSector for an explicitly destructive,
path-and-sector-scoped fault.
control.disk.crashAfterOps(n) arms a structural crash. After n more data
or metadata operations complete, the next operation crashes the disk before
doing work and fails like any post-crash operation. Re-arming replaces the
budget; any crash disarms it. If the workload never reaches the boundary, the
crash never fires.
Crash marks the disk down and stops live logical processes. Disk restart only restores the disk authority; applications restart explicitly through their registered process lifecycle.
Recovery windows
A durability boundary separates durable truth from pending state:
- file bytes cross the boundary at successful file
sync; - creates, deletes, and renames cross it at successful parent
syncDir.
Synced data must recover exactly. A pending write may be absent or present
exactly as written; recovery accepting damaged data is never valid. Ordinary
crash profiles cannot damage durable truth. Doing that requires the explicit
corruptSector control or a corrupt-read profile.
The conservative single-node destructive-fault budget is one named sector on one path per recovery window. Tests that exceed it are destructive negative tests and should say so. Product-specific recovery checkers enforce these budgets; the generic disk API does not interpret application record formats.
The KV example demonstrates the contract: a synced record must survive, while an unsynced record may be absent or exact. Its checker rejects both loss of durable truth and recovery of a damaged record.
Trace contract
Disk code records escaped scalar fields through World.recordFields. It
records lengths and outcomes, not user payloads. The principal events are:
disk.modeldisk.read,disk.read_some, anddisk.writedisk.syncanddisk.sync_dirdisk.stat,disk.set_length,disk.delete, anddisk.renamedisk.faultdisk.crash_writeanddisk.crash_metadatadisk.crashanddisk.restart
Fault rolls are tied to an operation id or explicit crash. They are separate
from the time-evolved fault_evolution.boundary contract used by network and
process dynamics.
Status and fault names are stable literals such as ok, not_found,
io_error, corrupt, read_error, crash_lost_write, and
crash_torn_write. Payload hashes, if ever added for debugging, must name a
stable algorithm.
Limits
The model does not provide full filesystem behavior, transparent interception of direct host filesystem calls, or arbitrary random media chaos. Checksums, record validation, and the definition of a valid recovered application state belong to application code.