Skip to documentation
SLOP

tiny.tripwire

Reference tiny.tripwire

Overview · API · Code relationships · Verification · Audit

Overview

The excerpt below, from BufferPublication.stageAndPublish, shows one operation that takes a private copy of a payload, holds it, and then publishes it, with an error path open between the copy and the publish. The worked example near the end of this page gives the operation in full.

zig
pub fn stageAndPublish(self: *BufferPublication, payload: []const u8) !void {    if (self.published != null) return error.AlreadyPublished;    const staged = try self.allocator.dupe(u8, payload);    errdefer self.allocator.free(staged);    try Points.check(.before_publish);    self.published = staged;}

When a unit test runs that operation with Points unconfigured and the allocation succeeding, check returns void, so the errdefer cleanup never runs and the error recovery goes unverified. This unverified error recovery is the obstacle the package answers. A place in the code under test where a call to check is written, named by one tag of an enum, is a check site, and the package lets a test choose the error a check site delivers while the test runs. One evaluation of a configured site is a visit. The caller decides when a site delivers, by setting how many visits pass first. Once the test body has run, the caller confirms that the site delivered its error.

Named check sites and the threshold counter

A codebase declares an enum whose tags name its check sites, the failpoint enum, and passes it to module, which republishes it as FailPoint.

zig
const Points = tripwire.module(enum {    before_publish,    after_flush,}, anyerror);

An operation can reach the same site more than once before it reaches the state the test is after, for example while it parses earlier records or runs earlier loop iterations. How many visits a site lets through before it delivers its error (min) is the threshold. errorAfter(point, err, min) lets min visits pass and delivers err on visit min + 1 and on every visit after. Inside check, a site with no configuration returns void at once and touches no state. A configured site adds 1 to its visit count, tripwire.reached, inside usize range. While the visit count stands at or below the threshold, check returns void. Once the visit count passes the threshold, the site trips: it sets tripped to true, the flag end reads, and hands back err. A tripped site keeps returning err on every later call, as long as the visit count stays inside usize range. errorAlways(point, err) calls errorAfter(point, err, 0), which leaves the threshold at zero and trips the site on its first visit.

Execution trace

Configuration Call Result
unconfigured 1 void
errorAlways(pt, err) 1 err
errorAlways(pt, err) 2 err
errorAfter(pt, err, 2) 1 void
errorAfter(pt, err, 2) 2 void
errorAfter(pt, err, 2) 3 err
errorAfter(pt, err, 2) 4 err

The record one configured site carries, holding the error to deliver, its threshold, its visit count, and whether the error has gone out (the type Tripwire), is a failpoint. A call to end has three outcomes:

Configuration lifecycle and verification

The one map of site names to failpoints that the generated type owns (TripwireMap, a std.EnumMap) is the state map. Calling errorAfter or errorAlways on a site that is already configured replaces its record in the state map, putting reached back to 0 and tripped back to false. reset() empties the state map.

The argument to end choosing whether it empties the state map after it verifies, one of .reset or .retain, is the reset mode:

Calling end proves that configured errors were delivered: each active site took more visits than its threshold and gave back the error it holds. Whether the caller handled the error, released its resources, or held its domain invariants is left to the test's own assertions, such as checking that private staged state stayed unpublished and that memory came back.

Compilation and runtime mechanics

A compilation whose root is a test, the case builtin.is_test reports, is a test build. The compile-time flag that turns the checks on is enabled, and it equals builtin.is_test, so in a test build a check performs its state map lookup, raises its counter, and delivers the error. Any other build has builtin.is_test == false, which fixes enabled at false during compilation and makes callingConvention() yield .@"inline". Each check begins with the guard if (comptime !enabled) return;, so the compiler folds the call into its caller and drops the rest of the body as an inlined no-op. An expression written as an argument to check is evaluated by the caller at runtime before the call, unless a general compiler optimization removes it. Whether the compilation is a test (builtin.is_test) is a separate question from the optimization mode (-O Debug, -O ReleaseFast). errorAfter, reset, and end stay in the failpoint module's namespace and remain callable where enabled is false, and there they work on a state map that check leaves unread. The counter uses platform usize addition, written tripwire.reached += 1, which detects overflow in a safety-checked build. The persistent failure behavior holds as long as reached stays inside usize range.

Namespace and type system contracts

A call to module(P, E) returns a struct type, the failpoint module, holding the site names, the resolved error set, the state map, and the functions that configure, evaluate, and verify the sites.

zig
var tripwires: TripwireMap = .{};

That map belongs to the generated type, so every caller referencing the same (P, E) specialization shares the one state map, and no struct instance holds it. The state map is a std.EnumMap held under no mutex, no atomic primitive, and no thread-local storage. Calling check, errorAfter, reset, or end from more than one thread at once races on that map, and the result is undefined behavior. A test with concurrency serializes its failpoint operations or keeps them on one thread.

The second argument to module, written E, is the error specification, and the error set read out of it at compile time, the type check returns (Error), is the resolved error set. The error specification E accepts four forms:

  1. An error set type, written anyerror or error{OutOfMemory, DiskFull}.
  2. An error union type, written anyerror!void or error{DiskFull}!u32.
  3. A function type returning an error union, written @TypeOf(myFunc).
  4. A function value, written myFunc, whose declared return type carries the error union.

An individual error value such as error.DiskFull is refused as E, because the error set case hands the value back and Error has to be a type. Where enabled is false, checkConstrained(point, ConstrainedError) becomes an inlined no-op that returns void. In a test build, once its body is instantiated, checkConstrained executes return tripwire.err;, and tripwire.err has type Error, so returning it requires Error to coerce to ConstrainedError. Zig coerces an error set only into a set that holds it, so in a test build ConstrainedError has to be Error or a superset of Error. A ConstrainedError smaller than Error, say error{DiskFull} where the failpoint module was built with anyerror, fails compilation.

Worked caller example

The example stages private memory, checks a failpoint before publishing, cleans up with errdefer, and asserts its postconditions apart from end.

zig
const std = @import("std");const tripwire = @import("tripwire");pub const Points = tripwire.module(enum {    before_publish,}, anyerror);pub const BufferPublication = struct {    allocator: std.mem.Allocator,    published: ?[]u8 = null,    pub fn stageAndPublish(self: *@This(), payload: []const u8) !void {        if (self.published != null) return error.AlreadyPublished;        const staged = try self.allocator.dupe(u8, payload);        errdefer self.allocator.free(staged);        try Points.check(.before_publish);        self.published = staged;    }    pub fn deinit(self: *@This()) void {        if (self.published) |buffer| {            self.allocator.free(buffer);            self.published = null;        }    }};test "staged buffer allocation and cleanup under fault injection" {    const testing = std.testing;    Points.reset();    defer Points.reset();    Points.errorAlways(.before_publish, error.DiskFull);    var publication: BufferPublication = .{ .allocator = testing.allocator };    defer publication.deinit();    // 1. Confirm that the injected error propagates:    try testing.expectError(error.DiskFull, publication.stageAndPublish("payload"));    // 2. Independently verify domain postconditions:    //    Private staged memory was freed by errdefer (verified by testing.allocator),    //    and published state remains unmutated:    try testing.expect(publication.published == null);    // 3. Verify probe execution:    try Points.end(.reset);}

Boundaries and limitations

The package tests explicit, localized failure points:

Work on injecting faults reaches the machine at different layers:

References:

Definitions

Actions

Public operations.

Namespaces

Public namespaces.

Code relationships

Direct static dependencies extracted from parsed source by semantic graph analysis.

Uses: None
Used by: None

Verification

No verification records are cataloged for this module in this build.

Audit

EvidenceValue
Sourcelib/tripwire/src/root.zig
Definitions1 of 2 documented
Members0 of 0 documented
Public names3 API, 3 indexed
Version26.7.0
Revisiondaab053ee433