API
This document describes the current experimental API. The API is not stable yet.
Random
mar.Random is a thin wrapper around Zig's default PRNG that forces callers
to provide a seed.
var rng = mar.Random.init(42);
const random = rng.random();
const value = random.int(u64);
The same seed produces the same stream within a single Zig version.
Time Through std.Io
Simulated time is owned by the world. mar.SimClock advances only when the
caller explicitly advances the simulation; application code reads time with
std.Io.Clock.* over env.io(). Production code uses the same calls over the
host std.Io its composition root already owns.
std.Io.sleep(env.io(), duration, .awake) is the app-facing scheduler
operation. Inside a task it parks that task; from the scenario/main context it
drives runnable tasks and timer deadlines up to the requested time. Both paths
round to the world's tick resolution and evolve automatic process/network
faults at every crossed boundary. Sleeping through an I/O capability retained
from a killed node returns error.Canceled. In contrast, World.clock()
exposes the deliberately low-level SimClock for harness code that wants raw
clock mutation without scheduler or fault-evolution work.
All timestamps and durations are nanoseconds:
pub const Timestamp = u64;
pub const Duration = u64;
Env
Application code should receive explicit authorities from its caller instead of
constructing them itself. Storage-oriented code should usually take std.Io,
a root std.Io.Dir, and a narrow mar.Recorder; code that needs Marionette's
clock, random hooks, or other simulator capabilities can take mar.Env:
fn service(env: anytype) !void {
const io = env.io();
const now = std.Io.Clock.awake.now(io).nanoseconds;
var random_source: std.Random.IoSource = .{ .io = io };
const jitter = random_source.interface().intRangeLessThan(u64, 0, 1_000);
if (try env.buggify(.slow_path, .oneIn(10))) {
try std.Io.sleep(io, .fromNanoseconds(jitter), .awake);
}
_ = .{ now, jitter };
}
mar.Env is the concrete harness-facing capability bundle. env.io() is the
single authority for I/O, clocks, sleeps, and random bytes: host I/O in
production envs, and Marionette's current deterministic backend in simulation
envs. The allocator, modeled Disk, and tracer are explicit sibling
capabilities. env.recorder() returns a narrow structured recording
capability for code that should not depend on all of Env.
Production-shaped libraries should prefer taking the smallest capabilities they
need. For example, code that only needs I/O and trace events can accept
std.Io plus mar.Recorder:
fn put(io: std.Io, recorder: mar.Recorder, key: u64, value: u64) !void {
_ = io;
try recorder.record("kv.put key={} value={}", .{ key, value });
}
Simulation builds app and harness views together through World.simulate:
fn scenario(world: *mar.World) !void {
const sim = try world.simulate(.{});
try service(sim.env);
}
sim.env supplies the handles passed to application code. sim.control is
kept by the harness for simulator-only actions such as advancing time or
crashing disk.
simulate options also include task_stack_size for scheduler-backed
std.Io tasks (default 1 MiB). Raise it when a simulated SUT's call chains
run deep; on guard-page targets stacks cost address space, not resident
memory, and overflow faults at a 256 KiB guard region instead of corrupting
neighboring memory.
On POSIX guard-page targets, an overflow fault also produces a targeted
stderr diagnostic before the fault proceeds: the task id, owning process,
configured stack size, and the task_stack_size fix, after which the fault
chains to the previously installed signal handler (Zig's Debug handler still
prints the fault-site trace). This installs a process-global
SIGSEGV/SIGBUS handler on first task spawn; embedders that own their
signal dispositions can disable it with
simulate(.{ .fiber_overflow_diagnostics = false }). Faults outside fiber
guard regions chain through with no added output.
simulate options also include task_start_jitter_ns (default 0, off).
When nonzero, every scheduler-backed task draws a uniform initial delay in
[0, max] from the seed and becomes runnable only after that much virtual
time, so seed sweeps explore task start orderings, such as
connect-before-listen races, that the cooperative scheduler otherwise masks
structurally (virtual time advances only when every task blocks, so a task
with a suspension point before its first action always loses to one
without). Zero consumes no randomness and emits no trace; enabled draws are
trace-visible as scheduler.start_jitter events, so jittered runs replay
byte-identically from their seed.
env.buggify draws through the env's random capability only when the env was
built by simulation; production envs construct the same composition bundle with
production adapters such as mar.RealDisk.
World
mar.World owns deterministic simulation engine state:
- One
SimClock. - One seeded
Random. - One trace log.
Application code should receive explicit handles from the composition root, not
World directly.
Scenarios and harnesses use World to construct simulations, drive time, and
inspect trace bytes.
Each World constructs at most one simulation. A failed simulate attempt
rolls back its resources and leaves the world available for another attempt.
Create a world with an explicit allocator:
const ns_per_ms: mar.Duration = 1_000_000;
var world = try mar.World.init(std.testing.allocator, .{
.seed = 0xC0FFEE,
.tick_ns = ns_per_ms,
});
defer world.deinit();
Advance simulated time:
try world.tick();
try world.runFor(10 * ns_per_ms);
Record service-level trace events:
try world.record("request.accepted id={}", .{42});
Use structured fields when a value comes from user text, paths, or other runtime bytes that may contain spaces or separators:
try world.recordFields("disk.open", &.{
mar.traceField("path", .{ .text = "/tmp/a b" }),
mar.traceField("mode", .{ .literal = "read" }),
});
The text field is written as path=/tmp/a%20b; raw World.record remains
strict and returns error.InvalidTracePayload for ambiguous formatted values.
Read the trace:
const trace = world.traceBytes();
The returned trace slice is invalidated by later trace writes.
Phase 0 traces start with marionette.trace format=text version=1. Every
later World.record line is prefixed with a global event=<u64> index.
Random Choices In A World
world.unsafeUntracedRandom() returns a raw std.Random view over the
world's seeded PRNG. Raw draws are deterministic, but they are not
automatically traced. The unsafe name is intentional: simulator decisions
should usually use traced helpers.
Use traced helpers when the random choice should appear in the replay trace:
const value = try world.randomU64();
const enabled = try world.randomBool();
const index = try world.randomIntLessThan(u64, 1_000_000);
randomIntLessThan uses Zig's rejection-sampling bounded integer helper, so it
does not teach modulo bias.
Application code should draw through an std.Random.IoSource over env.io()
instead of receiving the whole World. The simulation backend records an
io.random event for each byte draw:
var source: std.Random.IoSource = .{ .io = env.io() };
const latency_ns = source.interface().intRangeLessThan(u64, 0, 1_000_000);
Allocation
env.allocator() returns the app-facing std.mem.Allocator. Production envs
return the backing allocator passed to Production.init, with no added
faults. Simulation envs return a deterministic allocation authority that
wraps the harness allocator with modeled failures and address-free tracing.
Configure faults at simulation setup:
const sim = try world.simulate(.{ .allocation = .{
.fail_after = 32,
} });
Or from scenario code through the control surface:
try control.allocation.setFaults(.{ .quota_bytes = 4096 });
try control.allocation.setFaults(.{ .buggify_rate = .percent(25) });
try control.allocation.setFaults(.{}); // heal
Fault semantics:
fail_aftercounts successful allocation and growth requests over the whole simulation, not since the lastsetFaultscall.fail_after = 0fails every subsequent growth request.quota_bytesbounds modeled live bytes. Growth requests that would exceed the quota fail; frees and shrinks return budget.buggify_ratedraws a seeded roll per growth request and fails the request when the roll fires. Shrinking operations and frees never fail.
control.allocation.stats() returns address-free counters: operation index,
successful allocations, live bytes, and total allocated and freed bytes.
Allocation faults model failure timing and resource pressure, not address determinism. The addresses returned by the backing allocator are not part of the deterministic contract and never appear in traces.
Every allocation operation is traced by default, including frees, resizes, and remaps: leak diagnosis needs the free events, and OOM diagnosis needs the operation sequence. Modeled app allocations are expected to be deliberate and scarce, so this is the readable default; if a real workload floods traces, a quieter profile can be added later without changing what the default records.
Modeled application allocations stay separate from Marionette's internal
bookkeeping: this surface cannot inject harness OOM, and a modeled app OOM
does not corrupt simulator state. The tidy linter rejects
std.heap.page_allocator in simulated code; pass an allocator explicitly
instead.
Disk
mar.Disk is the lower-level disk capability beneath Marionette's std.Io
backend. It is a concrete, storable handle with sector-oriented read,
write, and sync, plus path-level stat, EOF-aware readSome,
setLength, delete, and rename.
mar.SimDisk is the deterministic
in-memory simulator behind that handle: logical files, sector-aligned
reads/writes, sparse sectors, deterministic latency, operation ids, trace
events, replayable read/write/corruption faults, and crash/restart behavior
for pending writes. mar.RealDisk is the production adapter backed by a real
root directory. mar.Disk.unavailable() remains the honest null-object for
envs without storage.
Construct a world-owned simulator bundle, then hand app code either std.Io
for ordinary file code or the lower-level sector disk capability when a test
needs that explicit surface:
const sim = try world.simulate(.{ .disk = .{
.sector_size = 4096,
.min_latency_ns = 1_000_000,
.latency_jitter_ns = 2_000_000,
} });
const io = sim.env.io();
const disk = sim.env.disk; // low-level sector API
sim.env.io() is node 0's default process I/O. Multi-node std.Io.net
scenarios should use sim.envForNode(node).io() so listeners and clients keep
stable process identity across reconnects.
If DiskOptions.min_latency_ns is omitted, it defaults to the world's tick
duration. Passing a concrete value keeps that exact value and validates it
against the tick size.
Write and read logical paths:
try disk.write(.{
.path = "wal.log",
.offset = 0,
.bytes = sector_bytes,
});
try disk.read(.{
.path = "wal.log",
.offset = 0,
.buffer = sector_buffer,
});
try disk.sync(.{ .path = "wal.log" });
const stat = try disk.stat(.{ .path = "wal.log" });
const read_len = try disk.readSome(.{
.path = "wal.log",
.offset = 0,
.buffer = wal_buffer,
});
try disk.setLength(.{ .path = "wal.log", .len = 0 });
try disk.rename(.{ .old_path = "compact.tmp", .new_path = "data.db" });
try disk.delete(.{ .path = "wal.log" });
Construct a production capability bundle by scoping it to a root directory:
var production = try mar.Production.init(.{
.allocator = allocator,
.root_dir = root_dir,
.io = io,
.disk = .{ .sector_size = 4096 },
});
defer production.deinit();
const env = production.env();
Production owns the production capability adapters and exposes the same
Env shape that simulation returns. Its disk adapter accepts relative paths,
creates parent directories on write, reads missing or short files as
zero-filled sectors, and uses the same sector alignment checks as SimDisk.
Logical file paths use canonical rooted syntax: non-empty /-separated
components, no ., .., empty components, backslashes, NUL, or host absolute
roots. . is reserved for the root directory in syncDir.
That validator is a namespace boundary, not a full portable filename profile.
It guarantees rooted, non-traversing logical syntax and keeps host absolute
paths and current-working-directory behavior out of app code. It does not
guarantee identical behavior across host filesystems: case sensitivity,
Unicode normalization, Windows reserved names and alternate streams, trailing
dots/spaces, and path limits may still differ. Uppercase, Unicode, and
ordinary punctuation remain accepted by the logical syntax today. Complete
host filename parity requires a future opt-in portable filename profile or a
production std.Io wrapper that can enforce the same policy in simulation and
production.
The io argument is the production host I/O backend used to perform
filesystem calls and provide host randomness. production.env().io() returns
that same host std.Io. Simulation envs return Marionette's current
deterministic std.Io backend; sim.envForNode(node).io() returns the
process-scoped backend for a specific simulated node. The backend supports
deterministic clock/random operations, scheduler-backed Io.async /
Io.concurrent / await, scheduler-backed Io.Group, immediate non-blocking
Io.Queue operations, and an in-memory TCP stream subset today. Cooperative
cancellation is delivered at the supported futex, sleep, and network
suspension points. The backend also supports a directory-aware file subset over SimDisk:
create/open, access/statFile, positional and streaming read/write,
length/stat/setLength, sync, close, delete, rename, directory
create/open/stat/iteration, and process-coordinated blocking and non-blocking
advisory locks. Directory namespace state is shared by all simulated processes
through SimDisk. Streaming cursor state is per open file handle, advances
only by bytes actually transferred, and is left unchanged by failed streaming
operations. Full filesystem behavior, process operations, datagrams, DNS, and
real external network access still fail closed. See
std.Io Direction.
Simulated file stats report deterministic size, kind, and mutation-time
information. mtime updates on successful content mutations; access and change
timestamps remain zero because Marionette does not yet model them.
Simulated storage tests should prefer world.simulate(...).env.io() for code
that naturally uses std.Io.File. The Disk returned by
world.simulate(...).env.disk remains the low-level sector/file-lifecycle
surface for examples that intentionally test Marionette's disk model directly;
harness code keeps the matching DiskControl for faults, crash, restart, and
corruption.
Low-level disk-shaped code uses the attached Disk field and only the
app-facing operations:
const sim = try world.simulate(.{
.disk = .{ .sector_size = 4096 },
});
fn appendRecord(disk: mar.Disk, sector_bytes: []const u8) !void {
try disk.write(.{ .path = "wal.log", .offset = 0, .bytes = sector_bytes });
try disk.sync(.{ .path = "wal.log" });
try disk.syncDir(.{ .path = "." });
}
The Disk view exposes read, write, sync, syncDir, stat,
readSome, setLength, delete, and rename.
Simulator-control operations such as setFaults, crash, restart, and
corruptSector remain on mar.DiskControl, exposed through
sim.control.disk, and are kept by the harness or scenario state.
For sector-oriented read and write, offsets and lengths must be whole
multiples of sector_size. readSome and setLength are byte-oriented for
WAL iteration and file lifecycle code. Reads from unwritten sectors return
zero bytes; readSome returns the number of bytes copied and does not fill
past EOF. Logical paths are not host paths and are escaped through
World.recordFields in trace events:
disk.write op=0 path=wal.log offset=0 len=4096 status=ok latency_ns=1000000
disk.read op=1 path=wal.log offset=0 len=4096 status=ok latency_ns=1000000
disk.sync op=2 path=wal.log status=ok committed_writes=1 latency_ns=1000000
disk.sync_dir op=3 path=. status=ok committed_metadata=1 latency_ns=1000000
disk.stat op=4 path=wal.log status=ok size=4096 latency_ns=1000000
disk.read_some op=5 path=wal.log offset=0 requested_len=32 read_len=32 status=ok latency_ns=1000000
disk.set_length op=6 path=wal.log len=0 status=ok committed_writes=0 latency_ns=1000000
disk.rename op=7 path=compact.tmp new_path=data.db status=ok committed_writes=0 latency_ns=1000000
disk.delete op=8 path=wal.log status=ok committed_writes=0 latency_ns=1000000
File sync commits pending file contents. syncDir commits directory-entry
metadata for creates, deletes, and renames in that logical directory. Without
syncDir, a crash can keep file contents while losing the directory entry,
matching the classic parent-directory-fsync storage bug class. Cross-directory
renames require syncing both parent directories before the rename is fully
durable.
RealDisk.syncDir currently returns error.DirectorySyncUnsupported. Zig
0.16 does not expose a portable directory-sync operation through std.Io, so
the production adapter fails explicitly instead of reporting durability it did
not establish.
Faults are disabled by default. Enable them through mar.DiskControl:
const control = sim.control.disk;
try control.setFaults(.{
.read_error_rate = .oneIn(100),
.write_error_rate = .oneIn(100),
.corrupt_read_rate = .oneIn(1_000),
.crash_lost_write_rate = .oneIn(10),
.crash_torn_write_rate = .oneIn(10),
.crash_reordered_write_rate = .oneIn(10),
.crash_lost_metadata_rate = .oneIn(10),
});
Invalid rates return error.InvalidRate. Read and write errors return
error.ReadError and error.WriteError after deterministic latency. Fault
decisions are traced when their rate is non-zero:
disk.fault op=3 path=wal.log kind=write_error rate=1/100 roll=42 fired=false
disk.fault op=4 path=wal.log kind=read_error rate=1/100 roll=0 fired=true
disk.read op=4 path=wal.log offset=0 len=4096 status=io_error latency_ns=1000000
corrupt_read_rate corrupts only the returned buffer; it does not mutate the
durable in-memory model. Scenario code can inject persistent scripted sector
corruption with:
try control.corruptSector("wal.log", 0);
That simulator-control API records disk.fault ... kind=scripted_corruption;
later reads covering that sector return status=corrupt.
Writes are visible to later reads immediately, but they are pending until
sync. A crash processes pending writes according to the crash fault profile:
each pending write may land, be lost, be torn, or be applied out of issue
order. Synced writes are already committed and are not lost by crash.
try disk.write(.{ .path = "wal.log", .offset = 0, .bytes = sector_bytes });
try control.crash();
try control.restart();
While crashed, disk operations return error.DiskCrashed. Crash outcomes are
trace-visible. In simulation, control.crash() also kills every live logical
process after pending-write outcomes are applied; control.restart() brings
only the disk back up. Rerun registered application initializers with
sim.control.process.restart(node).
disk.fault op=3 path=wal.log kind=crash_lost_write rate=1/10 roll=7 fired=false
disk.fault op=3 path=wal.log kind=crash_torn_write rate=1/10 roll=0 fired=true
disk.crash_write op=3 path=wal.log offset=0 len=4096 result=torn
disk.crash pending_writes=1 landed=0 lost=0 torn=1 reordered=0
disk.restart status=ok
Process lifecycle is explicit on the Sim returned by World.simulate.
sim.registerProcess(node, lifecycle) registers the initializer, while
sim.control.process.kill(node) tears down one logical process and
sim.control.process.restart(node) reruns the registered initializer with
that node's Env. Invalid nodes return error.InvalidNode; restarting
without a registered lifecycle returns error.ProcessNotRegistered.
Per-node crash/restart dynamics live on the same process-control handle:
try sim.registerProcess(0, .{
.ptr = &state,
.on_kill = State.onKill,
.restart = State.restart,
});
try sim.control.process.setDynamics(0, .{
.crash_rate = .percent(1),
.restart_rate = .percent(10),
.crash_stability_min_ns = 10 * ns_per_ms,
.restart_stability_min_ns = 50 * ns_per_ms,
});
Process dynamics evolve only through sim.control.tick() or positive
sim.control.runFor(...) boundaries. Automatic crashes record
process.kill reason=auto_crash; automatic restarts rerun the registered
lifecycle and record process.restart automatic=true. Invalid rates return
error.InvalidRate; stability durations must be tick-aligned or
setDynamics returns error.InvalidDuration.
Liveness Transition
sim.transitionToLiveness(core) is a one-shot switch out of fault mode,
following the VOPR transition_to_liveness_mode shape. It zeroes every
probabilistic simulator fault rate (process crash/restart dynamics on every
node, network lossiness, clog and partition dynamics, disk fault rates, and
allocation faults), restores links, clogs, and node-down state between core
nodes, restarts the disk if it is crashed, and revives killed core processes
through their registered lifecycles. Non-core failures become permanent:
killed non-core processes stay down and severed non-core links stay severed,
because nothing reschedules them once the rates are zero.
try sim.transitionToLiveness(&.{ 0, 1 });
try sim.control.runFor(bounded_recovery_window_ns);
// Assert the core made progress.
The transition records liveness.transition core_count=... and
network.liveness_restore ..., and the zeroed rates record their usual
process.dynamics, network.lossiness, and related events, so same-seed
replay stays byte-identical. Calling it twice asserts as harness misuse. An
invalid core node returns error.InvalidNode and a killed core process
without a registered lifecycle returns error.ProcessNotRegistered; both
are checked before any state changes, so a failed call leaves the one-shot
transition unconsumed and retryable. Harness-armed deterministic faults are not
undone: an armed crashAfterOps budget stays armed, and app-level
Env.buggify rates are call-site values the harness zeroes itself.
Network
mar.Endpoint(Message) is an experimental, simulation-only message handle.
It tests protocol behavior above the wire under its documented delivery model.
Production socket code uses host std.Io.net; simulator-control faults remain on
control.network.
See Network Model for the design contract and current limits. See Network API Direction for the split between app-facing network authority and test-only simulator-control operations.
const Message = struct { value: u64 };
const sim = try world.simulate(.{ .network = .{
.nodes = 4,
.path_capacity = 64,
} });
const sender = try sim.endpoint(Message, 0);
const receiver = try sim.endpoint(Message, 1);
try sim.control.network.setLossiness(.{ .drop_rate = .percent(20) });
try sim.control.network.setLatency(.{
.min_latency_ns = 1_000_000,
.latency_jitter_ns = 2_000_000,
});
try sender.send(1, .{ .value = 42 });
while (try receiver.receive()) |envelope| {
_ = envelope.from;
_ = envelope.message;
}
Message is copied with ordinary Zig value semantics. Inline values are
copied; pointers, slices, and handles still reference their original storage.
Prefer value-only messages. Referenced storage must otherwise remain valid and
immutable for the simulation lifetime.
send records network.send or network.drop and does not wait for delivery.
Successful sends can be silently dropped by configured faults. receive
records network.deliver and may advance world time to the next delivery
anywhere on the same typed bus. A null result means that this endpoint has no
message at that bus scheduling boundary; it does not mean that the endpoint has
no later packet, is closed, or reached EOF. The surface does not model close,
deadlines, cancellation, acknowledgements, or backpressure.
Use simulated std.Io.net when the system under test must exercise its wire
format, framing, partial I/O, stream ordering, or connection lifecycle.
Latency values must align with the world's tick size because simulated delivery and fault-evolution boundaries are tick-aligned.
When a simulation owns time-evolved faults, advance time through simulation control:
try sim.control.tick();
try sim.control.runFor(10 * ns_per_ms);
This advances the backing world and evolves network fault state at
deterministic control boundaries. Long runFor calls may jump between
boundaries rather than iterating every tick in the interval.
Nodes are up by default. Mark one down or up with:
try sim.control.network.setNode(1, false);
try sim.control.network.setNode(1, true);
Directed links can be disabled and re-enabled:
try sim.control.network.setLink(0, 1, false);
try sim.control.network.setLink(0, 1, true);
Directed paths can also be clogged for a simulated duration:
try sim.control.network.clog(0, 1, 100 * ns_per_ms);
try sim.control.network.unclog(0, 1);
Partitions disable every directed link crossing between two groups:
const left = [_]mar.NodeId{0};
const right = [_]mar.NodeId{ 1, 2 };
try sim.control.network.partition(&left, &right);
try sim.control.network.heal();
Seeds
mar.parseSeed accepts decimal u64 seeds and 40-character Git hashes:
const seed = try mar.parseSeed("000000000000000000000000000000000000002a");
try std.testing.expectEqual(@as(u64, 42), seed);
Git hashes are parsed as u160 hexadecimal values and truncated to the low 64
bits. This is useful for CLI tools and CI jobs that want deterministic seed
variation by commit.
Trace Summary
mar.summarize(allocator, trace_bytes) builds an owned mar.Summary from a
Marionette trace. It is a debugging view, not a replay format.
var summary = try mar.summarize(allocator, trace);
defer summary.deinit();
try summary.writeSummary(writer);
The summary output is deterministic and line-oriented. It reports total event count, final simulated timestamp when present, replay context, subsystem and event counts, singleton events, network send/drop/delivery counts, drop reasons, and per-link network counts.
Trace Assertions
mar.expectTraceContains(trace, needle) asserts that a trace contains a
substring, printing the needle and the trace tail on failure. The trace is a
first-class Marionette artifact, so pinning contracts against its events is a
standard oracle shape:
try mar.expectTraceContains(outcome.trace, "network.drop");
try mar.expectTraceContains(outcome.trace, "reason=link_disabled");
runSimCase And run
mar.runSimCase(opts) is the primary stateful simulation runner. It
initializes fresh SimCase(App) state for each replay attempt, executes a
scenario twice with the same seed, runs named checks, and compares
byte-identical traces.
mar.run(allocator, options, scenario) is the lower-level world-only runner
for scenarios that do not need structured state.
fn scenario(world: *mar.World) !void {
try world.tick();
try world.record("scenario.done", .{});
}
var report = try mar.run(std.testing.allocator, .{ .seed = 0x1234 }, scenario);
defer report.deinit();
Runs can carry replay-visible tags and typed attributes:
const tags = [_][]const u8{ "example:replicated_register", "scenario:smoke" };
const attributes = [_]mar.RunAttribute{
mar.runAttribute("replicas", @as(u64, 3)),
mar.runAttribute("packet_loss_percent", @as(u8, 20)),
};
var report = try mar.run(std.testing.allocator, .{
.seed = 0x1234,
.name = "smoke",
.tags = &tags,
.attributes = &attributes,
}, scenario);
name, tags, and attributes are recorded into the trace before
scenario code runs and are included in failure summaries. Tags are loose
searchable labels. Attributes are stable scalar facts needed to reproduce the
run without forcing tools to parse presentation strings. Use
mar.runAttribute to build attributes; keys are written explicitly so exported
metadata names never silently track internal field renames. Runtime behavior
should read from the config, not from derived attributes.
Named simulation profiles package common run metadata, static simulator setup,
and runtime fault controls. A profile must still be expanded and applied
explicitly: simulateOptions() configures World.simulate, runTags() and
runAttributes() make the expanded values visible in traces and failure
summaries, and apply(control) sets runtime controls such as network loss,
latency, clogs, and partition dynamics.
fn swarmProfile() mar.SimProfile.Expanded {
return mar.SimProfile.swarm(.{
.tick_ns = tick_ns,
.network = .{
.nodes = replica_count + 1,
.service_nodes = replica_count,
.path_capacity = max_messages,
},
}).expand();
}
fn scenario(case: *Case) !void {
const profile = swarmProfile();
try profile.apply(case.control());
try case.app.write(.{ .version = 1, .value = 41, .retry_limit = 6 });
}
const profile = swarmProfile();
var report = try mar.runSimCase(.{
.allocator = std.testing.allocator,
.seed = 0x1234,
.name = "replicated-register-swarm",
.tags = profile.runTags(),
.attributes = profile.runAttributes(),
.simulate = profile.simulateOptions(),
.init = initReplicas,
.scenario = scenario,
.checks = &checks,
});
The built-in profile names are baseline, swarm, replay, and
performance. replay is intentionally just an exact carrier for explicit
values; pass the values from a failure summary back into the profile options
rather than relying on hidden generation. Network runtime controls are reported
as nonzero metadata only when a network topology is present. performance
defaults disk latency to zero and runtime faults to off.
World-only checks can be attached to the run options:
fn noBadState(world: *mar.World) !void {
if (std.mem.indexOf(u8, world.traceBytes(), "bad_state") != null) {
return error.BadState;
}
}
const checks = [_]mar.Check{
.{ .name = "no bad state", .check = noBadState },
};
var report = try mar.run(std.testing.allocator, .{
.seed = 0x1234,
.checks = &checks,
}, scenario);
defer report.deinit();
Simulation scenarios should usually use runSimCase and SimCase(App). The
app initializer receives mar.Sim directly, while scenarios and checks receive
the standard case wrapper:
const Case = mar.SimCase(Model);
const Model = struct {
env: mar.Env,
committed: bool = false,
};
fn initModel(sim: mar.Sim) Model {
return .{ .env = sim.env };
}
fn scenario(case: *Case) !void {
try case.control().tick();
case.app.committed = true;
try case.env().record("model.commit", .{});
}
fn committed(case: *const Case) !void {
if (!case.app.committed) return error.NotCommitted;
}
const state_checks = [_]mar.StateCheck(Case){
.{ .name = "committed", .check = committed },
};
var report = try mar.runSimCase(.{
.allocator = std.testing.allocator,
.seed = 0x1234,
.name = "model-smoke",
.simulate = .{},
.init = initModel,
.scenario = scenario,
.checks = &state_checks,
});
defer report.deinit();
runSimCase initializes fresh state for each replay attempt: it creates the
World, records run metadata, calls world.simulate(config.simulate), passes
the resulting mar.Sim to init, and then runs the scenario and checks over
*mar.SimCase(App). Use case.app for application state and case.control()
for simulator authority. case.env(), case.envForNode(...),
case.endpoint(...), and related helpers forward to the underlying mar.Sim.
case.io() and case.ioForNode(node) are the std.Io-facing equivalents for
single-node and node-scoped simulated I/O.
SimCase(App) automatically calls app.deinit() when App defines that
method. For state too custom for SimCase, drive mar.World directly through
mar.run or a hand-rolled harness.
Tests that only need pass/fail behavior can skip report handling:
try mar.expectSimPass(.{
.allocator = std.testing.allocator,
.seed = 0x1234,
.simulate = .{},
.init = initModel,
.scenario = scenario,
.checks = &state_checks,
});
try mar.expectSimFuzz(.{
.allocator = std.testing.allocator,
.seed = 0x1234,
.seeds = 1000,
.simulate = .{},
.init = initModel,
.scenario = scenario,
.checks = &state_checks,
});
Use mar.expectSimFailure when proving a simulation checker catches a
known-buggy scenario. Use the lower-level mar.run for world-only
scenarios.
The return value is mar.RunReport:
.passedcontains the owned trace from the first successful run..failedcontains a failure report with seed, options, event counts, traces, failure kind, error name when available, and check name when a check failed.
RunFailure.writeSummary(writer) writes the compact failure line used by
RunFailure.print(). Prefer writeSummary in tests so failure output stays
stable.
See Run for details.
Error Policy
Marionette uses a small error policy:
- Invariant violations use
std.debug.assert. - Resource failures return standard Zig errors.
- Expected disk and network faults use their narrow domain error sets.
Low-level World methods may fail because trace logging allocates. Disk and
network capabilities additionally return their declared model and validation
errors.
Examples of assertions:
tick_nsmust be greater than zero.runFor(duration)must use a duration that is an exact multiple of the world's tick size.- Simulated timestamp arithmetic must not overflow.
Examples of returned errors:
- Trace allocation failure.
- Trace formatting allocation failure.
TraceError, DiskError, and NetworkError keep those boundaries explicit;
the API avoids broad catch-all custom errors.
When mar.run catches a scenario error return, it preserves the partial trace
through the last completed event and includes that trace in the failure report.
Panics are harder because Zig's default panic path may abort before Marionette
can flush anything; users should prefer error-returning invariant checks for
simulated failures.
Build Support
Marionette's build.zig exports a helper for wiring marionette-tidy into a
consuming build. Importing that build API lets the helper locate its own
dependency, so the executable source resolves inside Marionette rather than
inside your package:
const marionette_build = @import("marionette");
const tidy = marionette_build.addTidyStep(b, .{
.paths = &.{ "src", "examples", "tests" },
});
test_step.dependOn(&tidy.step);
The helper builds the marionette-tidy executable and creates a run step that
exits non-zero when banned non-deterministic calls are found. Projects can add
their own exact or prefix bans and file-level or pattern-level allow entries:
const tidy = marionette_build.addTidyStep(b, .{
.paths = &.{ "src", "examples", "tests" },
.extra_patterns = &.{
.{
.needle = "std.heap.page_allocator",
.reason = "pass an allocator explicitly",
},
.{
.needle = "std.posix",
.reason = "route host effects through explicit interfaces",
.match = .prefix,
},
},
.extra_allowed = &.{
.{ .path = "src/platform.zig", .needle = "std.posix" },
},
});
The current linter is AST-based: it ignores comments and string literals,
supports exact and prefix dotted-path bans, and catches simple const aliases
such as const os = std.os. It does not yet perform full semantic import
resolution.