Reduction and Explanation
Marionette 0.7.1 builds on 0.7's pinned-build replay capsules. A failure can now be checked at an explicit safe point, reduced to essential semantic groups, explained with causal references, and saved as a CI artifact directory.
Properties and action groups
A check name is its stable property ID. Choose one ID per invariant and preserve
it as the workload evolves. The failure fingerprint compares the complete
kind, error_name, and check_name; its 64-bit digest is a convenient label,
not the acceptance predicate. Give different properties distinct IDs rather
than relying on a generic scenario error to distinguish unrelated bugs.
const checks = [_]mar.StateCheck(Case){
.{ .name = "service.at_most_once", .phase = .always, .check = safe },
};
fn scenario(case: *Case) !void {
try case.action("background", background);
try case.action("request", request);
try case.action("retry", retry);
try case.checkpoint("after.retry");
}
checkpoint IDs and action IDs use the semantic site grammar from
Decision Tapes. A checkpoint evaluates .checkpoint and
.always checks in declaration order. .always also runs after initialization
and after a successful scenario; .both covers those two lifecycle boundaries
only. The first failed property stays fatal even if the scenario catches its
error, repairs the state, or later returns a different error. Cleanup still runs.
Checks inspect state at caller-selected boundaries; they do not implicitly drain
tasks or run after every scheduler step.
An action is enabled in normal exploration. Its action.<id> boolean decision
controls whether the callback executes during replay or reduction. Put required
setup outside optional groups. A site repeated in a loop belongs to one group;
use distinct semantic IDs when operations should be removable separately.
Reduce a failure
var reduced = try mar.reduceSimCase(.{
.allocator = allocator,
.seed = seed,
.simulate = mar.World.SimulateOptions{},
.init = App.init,
.scenario = scenario,
.checks = &checks,
.watchdog = mar.WatchdogOptions{},
}, .{ .max_attempts = 256 });
defer reduced.deinit();
const report = reduced.report(); // borrowed until reduced.deinit()
const bytes = try mar.ReplayCapsule.encode(allocator, report, identity);
defer allocator.free(bytes);
The reducer first requires a reproducible failure with a complete tape. It uses bounded delta debugging to omit groups of nonzero semantic sites. Omitted scalar choices become zero/false; byte choices become zero-filled buffers. Omitted actions therefore skip their callbacks. Other choices reuse the original selection at the same site occurrence when the alternatives still match; new or incompatible sites use generated values. This is candidate exploration, not exact replay. Each candidate records a fresh tape and must pass an exact second execution and preserve the original full failure fingerprint before acceptance.
attempts counts candidate runs (each includes replay). remaining_groups
counts retained original groups, not trace events or bytes. one_minimal means
no remaining single group can be omitted under this transformation; it does not
promise the globally smallest workload. If the attempt budget runs out, the best
verified result is returned with one_minimal = false. Use the normal watchdog
configuration to bound each candidate; an attempt count alone cannot stop a
non-returning scenario. Incomplete watchdog failures and mismatches cannot be
reduction baselines. Infrastructure errors abort reduction and release owned
results.
The minimized capsule contains the actual decisions, including disabled actions. Replay it with the original harness and checks: no external removal mask or modified workload is needed. Retain the pinned executable and build/SUT identity.
To reduce a retained capsule in a fresh process, pass the original harness and the capsule's identity:
var reduced = try mar.reduceCapsule(.{
.allocator = allocator,
.init = App.init,
.scenario = scenario,
.checks = &checks,
}, &capsule, identity, .{ .max_attempts = 256 });
defer reduced.deinit();
reduceCapsule rejects an incompatible identity, replays the capsule exactly as
the baseline, and runs candidates under the capsule's runtime and simulation
options, including its seed and watchdog. The result is the same as reducing
the original config at that seed. Campaigns reduce new
failures this way automatically.
Run the complete example:
zig build run-example -- reduce-idempotency --seed 1234 --trace --expect-failure
It removes telemetry, retains the request and duplicate that cause the bug, and
prints the reduced property failure. The example is also part of zig build test.
Causal events and operation spans
EventId is the existing world-global trace index. Recorder.event(name, fields,
cause) returns an optional ID; a disabled production recorder returns null.
A cause must reference an earlier event in the same world. The cause field is
reserved by this API. IDs come from events, never pointers or host thread IDs.
const accepted = try recorder.event("service.accepted", &.{}, null);
var operation = try recorder.beginOperation("request.commit", accepted);
_ = try operation.event("service.persisted", &.{});
try operation.end();
operation.begin starts the span; span events and operation.end reference its
begin event through cause. The explicit token remains associated with the
operation across task switches and can be used as the cause of another span.
Tokens borrow their recorder and must not outlive the world. Move tokens rather
than copying them. Ending a token twice, or appending to an
ended token, returns OperationEnded. On a failed operation, an unmatched begin
is useful diagnostic evidence; tracing does not automatically end or roll back
application operations.
Deadlocks retain sorted scheduler.wait_state diagnostics. If blocked tasks form
a cycle through modeled task-completion waits, scheduler.deadlock_cycle emits
one compact closed path, such as tasks=0,1,0. Timed waits are excluded. Arbitrary
futex, group, and I/O waits do not have unique known owners; they remain wait-state
diagnostics rather than speculative cycles.
CI artifact directories
Pass artifacts to runSimCase (or its single-case expectation wrappers):
.artifacts = mar.ArtifactOptions{
.io = host_io,
.parent = artifact_parent,
.name = "case-1234",
.identity = identity,
},
The runner writes after completing both executions, outside simulator/watchdog
execution. It uses only caller-supplied host std.Io and std.Io.Dir capabilities.
By default only failures are saved; include_passes includes passing runs.
The name must be one relative ASCII component using letters, digits, ., -,
and _, excluding . and ... The parent must exist. Reusing a directory
returns ArtifactAlreadyExists instead of overwriting a previous failure.
Directories contain:
trace.txtand, for mismatches,second-trace.txt;replay.jsonwhen the report is complete and reproducible;manifest.json, with build/SUT identity, expanded static simulator and runner options, failure identity, fingerprint, divergence diagnostics, and capsule availability. Runtime fault/process configuration remains visible in the trace.
The manifest is written last, to a temporary file renamed into place, so a
directory with manifest.json finished writing; an interrupted write leaves no
manifest. Consumers should still require a valid complete manifest. Incomplete or divergent
failures still produce diagnostic artifacts and explain why no capsule exists.
Host I/O errors return ArtifactIoFailed; encoding errors return
ArtifactEncodingFailed. Artifact I/O is not part of deterministic trace replay.
Campaigns name and write these directories automatically, one
per distinct failure.
Save a minimized result with mar.writeRunArtifacts(allocator, reduced.report(),
artifact_options). The ReductionResult owns both the original report and the
best accepted report; ordinary reports retain their existing ownership rules.
Managed process restart/reopen
const process = try sim.manageProcess(Service, node, Service.open);
try sim.killProcess(node);
try sim.restartProcess(node);
const reopened = process.state() orelse return error.ProcessUnavailable;
Service.open(env: mar.Env) !Service creates volatile state and reopens durable
resources through the revived node's environment. A public Service.deinit
releases that state. The world owns the stable ManagedProcess(Service) object;
do not destroy it or retain a pointer from state() across a kill or restart.
Kill stops process tasks and handles before freeing application state. Restart
publishes a complete fresh service only after initialization succeeds; failed
reopen leaves no live state and can be retried. Duplicate registration is rejected.
The runner finishes managed processes after application cleanup and before
resource checks and trace capture, so cleanup is replay-verified. Direct world
users can call sim.finishManagedProcesses() at their own capture boundary;
world teardown also releases remaining managed state. Existing manually registered
ProcessLifecycle callbacks retain their default cleanup policy. Fault options
and automatic restart dynamics remain explicit through the existing controls.