Running Scenarios
runSimCase is the single scenario runner. It records a semantic decision tape
during the first execution, exact-replays it during the second, and compares
byte-identical traces. Scenario and check errors are returned as data;
allocation or trace-infrastructure errors remain Zig errors.
Define A Case
const Case = mar.SimCase(Service);
fn init(sim: mar.Sim) !Service {
return Service.init(sim.env.io(), sim.env.recorder());
}
fn scenario(case: *Case) !void {
try case.app.request();
try case.control().network.partition(&.{0}, &.{1});
try case.control().tick();
}
fn safe(case: *const Case) !void {
if (!case.app.safe()) return error.InvariantBroken;
}
Run it with typed simulation options:
const checks = [_]mar.StateCheck(Case){
.{ .name = "service remains safe", .check = safe },
};
var report = try mar.runSimCase(.{
.allocator = allocator,
.seed = seed,
.name = "partition",
.simulate = mar.World.SimulateOptions{
.network = .{ .nodes = 2 },
},
.init = init,
.scenario = scenario,
.checks = &checks,
});
defer report.deinit();
The runner owns world and application cleanup. If the app type defines
deinit, it runs before each world is destroyed and remains part of replay
comparison.
Seed Schedules
Use seed_schedule to reset the deterministic random stream at exact traced
random-call boundaries without changing simulated time:
const schedule = [_]mar.SeedCutover{
.{
.at = .{ .sim_time_ns = 10, .microstep = 0 },
.seed = 0xA11CE,
},
.{
.at = .{ .sim_time_ns = 10, .microstep = 3 },
.seed = 0xB0B,
},
};
var report = try mar.runSimCase(.{
.allocator = allocator,
.seed = 0xC0FFEE,
.seed_schedule = @as(mar.SeedSchedule, &schedule),
// simulate, init, and scenario omitted
});
defer report.deinit();
Cutovers must be strictly increasing by (sim_time_ns, microstep) or the
runner returns error.InvalidSeedSchedule. A microstep counts one successfully
traced random API call at that simulated timestamp, including one
Io.random call regardless of its buffer length. If execution passes a point
without drawing randomness there, the cutover takes effect before the next
later draw. Schedules are reproduced in traces and failure summaries.
This is a same-build positional control, not a durable replay format. Code that adds or removes an earlier random call can shift later microsteps.
Outcomes
Set .check_resources = true in the runner configuration to check simulated
handles after a successful scenario, named checks, and App.deinit, before
world teardown. The default is false, allowing scenarios to finish with
services running. Leaks produce resource_leak with error name ResourceLeak.
Existing scenario/check failures take precedence. This also works with the
watchdog and expectation/fuzz helpers.
For an explicit checkpoint, call try sim.control.checkResources() after
application cleanup. It checks all processes, records diagnostics, and returns
error.ResourceLeak without closing resources. An error propagated from a
scenario or named check keeps its usual scenario_error or check_failed
classification.
Checks cover live simulated files, directories, listeners, and client/accepted sockets. Pending accepts belong to their listener; closed sockets awaiting internal retirement are excluded. Process kill, disk crash, and the disk model's deletion semantics retire handles, so those no longer appear. This checks current modeled ownership, not every historical missing close. Host descriptors, tasks/futures, memory, typed endpoints, and arbitrary user resources are outside this check. Allocation-site stacks are not captured.
RunReport is either passed or failed.
Failure kinds are:
resource_leak;scenario_error;check_failed;scheduler_deadlock;scheduler_error;non_yielding;livelock;replay_diverged;determinism_mismatch;first_run_failed;second_run_failed.
RunFailure.writeSummary writes a compact replay line. A passed or failed
report owns the first execution's decision tape; a replay divergence also owns
the expected and actual decision boundaries. takeDecisionTape transfers tape
ownership. All remaining metadata, traces, and tape storage live until
RunReport.deinit.
See Decision Tapes for entry semantics and direct replay.
Test Helpers
Use expectSimPass for normal cases and expectSimFailure for planted bugs.
An optional failure field can require any combination of kind, error name,
and check name:
try mar.expectSimFailure(.{
// allocator, simulation, initializer, and scenario omitted
.failure = mar.FailureExpectation{
.kind = .check_failed,
.error_name = "InvariantBroken",
.check_name = "service remains safe",
},
});
Omitted constraints accept any value. A mismatch returns
error.UnexpectedRunFailure. Use runSimCase directly when a test must inspect
the full failure or trace.
expectSimFuzz requires a nonzero seeds field. Each derived seed is replayed
twice. Long campaigns belong in the nightly seed sweep; focused unit tests
should use small counts.
Liveness Watchdog
Cooperative tasks normally yield at simulated I/O boundaries. To contain code that never reaches one, opt into an isolated worker process:
.watchdog = mar.WatchdogOptions{
.stall_timeout_ns = 5 * std.time.ns_per_s,
.run_timeout_ns = 30 * std.time.ns_per_s,
.trace_capacity = 4 * 1024 * 1024,
.result_capacity = 16 * 1024 * 1024,
},
The stall bound classifies worker code with no observed progress as
non_yielding, whether it runs directly in the scenario (task=main) or in a
cooperative task. The total-time and partial-trace bounds classify continuing
activity as livelock. Completed trace events are copied from shared memory
and a final watchdog.non_yielding or watchdog.livelock event identifies the
classification.
The watchdog is available on Linux, macOS, FreeBSD, NetBSD, OpenBSD,
DragonFly BSD, and illumos. Its deadlines use host monotonic time and do not
enter simulated time or deterministic choices. It uses fork, so enable it
from a single-threaded harness process before starting unrelated host threads.
Without .watchdog, execution and allocator behavior are unchanged.
Completed workers publish their full decision tape and use exact replay.
Terminated workers set tape_complete = false and retain same-seed trace-prefix
replay; an unexpected worker exit is worker_crashed and must reproduce its
exact trace. Incomplete tapes cannot be encoded as replay capsules. The
result_capacity bounds the encoded completed result, including decisions;
overflow returns WatchdogTraceTooLarge. See
Decision Tapes And Replay Capsules for persistent replay.
CLI
Run an included scenario with:
zig build run-example -- kv-store --seed 12648430 --summary
Use --trace for the full trace and --expect-failure for planted bugs. Run
without a valid scenario to print the current scenario list.