Determinism
Determinism is Marionette's core product. Given the same Marionette version, Zig version, target platform, user code, simulation options, and seed, the same simulation must produce the same declared result and byte-identical Marionette trace. If it does not, the library has failed.
The Rule
Simulated code must route non-deterministic behavior through Marionette interfaces:
- Application clocks, sleeps, random bytes, tasks, file operations, and socket
operations through
Env.io(); harness time throughControlor the explicitly low-levelWorld.clock(). - Application random algorithms through
std.Random.IoSourceoverEnv.io(); simulator-internal choices through the seededWorldhelpers. - Allocation through
Env.allocator()or another caller-provided allocator. - Disk through Marionette's
Diskcapability. - Network through Marionette
Endpointhandles or node-scoped simulatedstd.Io.netobtained fromsim.envForNode(node).io(). - Cooperative scheduling through Marionette's task scheduler.
Do not call host sources directly from simulated code.
Banned Sources
These are banned in simulated code unless they live in a narrowly allowlisted composition root or diagnostic harness:
- Constructing an independent host backend such as
std.Io.Threaded,std.Io.Evented,std.Io.Dispatch,std.Io.Kqueue, orstd.Io.Uring. - Host thread APIs such as
std.Thread.* - Raw host access through
std.os,std.posix, C FFI, inline assembly, or equivalent application imports. - Host process and machine queries that do not take an
std.Io, such as user lookup, base address, and total system memory. - Global debug/log sinks, ambient allocator singletons, and independently seeded PRNGs whose seed came from host state.
- Pointer identity as a source of ordering or hashing.
- Hash map iteration order unless explicitly sorted or otherwise stabilized.
Marionette ships an AST-based tidy linter for the obvious direct-call cases.
It ignores comments and string literals, and it catches simple const aliases
such as const os = std.os;. It does not yet perform full semantic import
resolution, inspect arbitrary C imports, or prove which std.Io value a caller
passes.
What Env.io() Covers
Zig 0.16 moved both clocks and entropy behind std.Io, so Marionette does not
need parallel clock and random interfaces. The simulation backend currently
covers:
.real,.awake, and.bootclock reads, clock resolution, sleeps, deadlines, and timer wakeups. Unsupported CPU-process and CPU-thread clocks have deterministic fail-closed behavior: zero timestamps, unavailable resolution, and no-op sleeps.std.Io.random,std.Io.randomSecure, and thereforestd.Random.IoSource. Draws use the world's seeded stream and recordio.randomevents.- Async/concurrent tasks, groups, cancellation, futex waits/wakes, and queued operations through the deterministic scheduler.
- The modeled file/directory subset implemented over
SimDiskand the modeled IP listen/connect/accept/read/write/close, shutdown, and DNS subset.
The same application calls use host behavior when Production supplies a host
std.Io. This is capability substitution: std.Io is not a promise that
Marionette implements every operation. Unsupported simulation operations fail
closed. Current gaps include atomic file creation, directory deletion,
links/realpaths/ownership/permissions, file mmap and standard file locks;
process spawn/replace/current-directory/executable operations; Unix sockets,
socket pairs, raw bind/send and zero-copy network writes, interface-name
queries; CPU clocks; and TTY/progress facilities.
What Env.io() Cannot Cover Alone
std.Io controls effects performed through its vtable. It cannot make inputs
or effects deterministic when code obtains them elsewhere:
- Process arguments, environment variables, preopened directories, user
configuration, and inherited handles are inputs assembled by the composition
root. They must be captured or injected explicitly. Zig's
Dir.cwd()and standard-file values are inert handles whose operations are still mediated by the suppliedstd.Io; an already-opened foreign handle is nevertheless external input. - Allocation is a separate interface. Passing an allocator is sufficient for
ordinary code, while
Env.allocator()adds deterministic simulated OOMs; pointer values must still never enter behavior or traces. - A caller can retain another host
std.Io, construct its own PRNG, call raw OS/C APIs, use threads, consult process/machine state, or communicate through an external library that bypassesstd.Io. Tidy catches common syntax, not all semantic aliases or foreign calls. - Global debug logging, or standard streams used with a global/foreign I/O backend, bypass the environment's trace contract.
- Target, Zig/Marionette version, build options, undefined behavior, and algorithmic sources such as pointer-based ordering remain outside the I/O boundary and are part of the replay contract or must be eliminated.
The practical rule is therefore stronger than “take an std.Io”: simulated
application code receives only env.io() and explicit data/handles, and does
not retain or construct another authority. Marionette is an interface boundary,
not syscall interception or a security sandbox.
Enforcement Layers
Marionette enforces determinism in four layers.
- API design.
The intended path should be the easiest path. Users should have no reason
to reach for a host backend when Env.io(), Control, or World.clock()
is already in hand.
- Build-integrated linter.
The marionette-tidy executable parses Zig source with std.zig.Ast,
scans for banned direct call paths, and can be wired into zig build test.
Its defaults ban independent host I/O backends, raw OS access, host threads,
ambient allocators, global debug/log sinks, and host-only process queries.
Projects can add their own exact or prefix bans through addTidyStep.
const marionette_build = @import("marionette");
const tidy = marionette_build.addTidyStep(b, .{
.paths = &.{ "src", "examples", "tests" },
.extra_patterns = &.{
.{ .needle = "std.heap.page_allocator", .reason = "pass an allocator explicitly" },
},
});
test_step.dependOn(&tidy.step);
The imported build API locates its own dependency and gives the executable a dependency-owned path to Marionette's tidy entry point. Scan paths remain relative to the consuming project.
- Twice-and-compare runtime detector.
mar.run runs a scenario twice with the same seed and compares
byte-for-byte traces. A mismatch means non-determinism leaked.
- Documentation.
The rules and their reasons should be written down while the API is built, not reverse-engineered later.
Single-Threaded Simulation
Simulated components are single-threaded. This is intentional.
Real threads introduce OS scheduling into the behavior under test. That makes portable deterministic replay much harder and pushes the project toward a different product category.
If production code needs parallelism, the Marionette-friendly options are:
- Run multiple
Worldinstances independently. - Isolate parallel pieces behind deterministic interfaces.
- Test coordination logic in simulation and cover the remaining low-level concurrency with other tools.
If the main thing you need is adversarial scheduling of concurrent data structures, you probably want a Shuttle-style tool rather than Marionette.
No Syscall Interception
Marionette does not fake time with LD_PRELOAD, syscall interception, or
runtime patching.
The premise is that Zig code can be written against explicit interfaces. The benefit is clarity and zero production overhead. The cost is discipline: users must route effects through the interfaces Marionette can control.
Trace Discipline
The trace is the observable record used by determinism tests.
Good trace events should be:
- Stable across platforms.
- Independent of pointer addresses.
- Independent of hash map iteration order.
- Specific enough to explain what the simulated service did.
- Small enough to compare cheaply.
Do not record wall-clock timestamps, memory addresses, thread ids, or unordered container dumps.
The Phase 0 trace format is specified in Trace Format.
Randomness and Misuse Conventions
Two conventions keep seed streams and harness contracts uniform across the simulator:
- Disabled faults consume no randomness and emit no trace. A fault hook
with a zero rate (
.never(),numerator == 0) returns without drawing from the world's random stream and without recording a trace event. The disk model'srollFaultandEnv.buggifyboth follow this rule. Toggling a fault off therefore never shifts unrelated draws through call sites that would have rolled zero; enabling a fault does shift downstream draws, which is inherent to injecting it. A convention change here shifts seed streams, so any future revision must land inside a release boundary. - Misaligned
runFordurations are harness misuse and assert.World,SimClock,SimControl, and the network control all assert that arunForduration is a whole number of ticks instead of returningerror.InvalidDuration. Errors are reserved for conditions a correct harness can encounter at runtime; a misaligned duration is a bug in the test.