lib/tripwire/src/failpoint.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const builtin = @import("builtin");
3 const assert = std.debug.assert;
4 const testing = std.testing;
5
6 fn TripwireType(comptime Error: type) type {
7 return struct {
8 err: Error,
9 reached: usize = 0,
10 min: usize = 0,
11 tripped: bool = false,
12 };
13 }
14
15 /// Creates a failpoint module at compile time for the failpoint enum `P` and
16 /// the error specification `E`: the namespace that holds one group of named
17 /// check sites.
18 ///
19 /// - `P` is an enum whose fields name the check sites.
20 /// - `E` is one of four things: an error set type such as `anyerror` or
21 /// `error{...}`, an error union type such as `E!T`, a function type whose
22 /// return is an error union such as `fn (...) E!T`, or a function value that
23 /// declares such a return. An error value such as `error.OutOfMemory` does
24 /// not work as `E`, because the error set case hands `E` straight back and
25 /// the result has to be a type.
26 ///
27 /// Every caller naming the same `P` and `E` shares one state map, which belongs
28 /// to the returned type, so no instance of the returned type carries the state.
29 /// Nothing synchronizes the state across threads.
30 pub fn module(
31 comptime P: type,
32 comptime E: anytype,
33 ) type {
34 return struct {
35 /// The enum type whose tags name the check sites of this failpoint
36 /// module, named here so a caller can write out the type that other
37 /// functions take.
38 pub const FailPoint: type = P;
39
40 /// The error set read out of the error specification at compile time,
41 /// which `check` returns so a caller can match an enclosing function's
42 /// error set against it.
43 pub const Error: type = err: {
44 const T = if (@TypeOf(E) == type) E else @TypeOf(E);
45 break :err switch (@typeInfo(T)) {
46 .error_set => E,
47 .error_union => |info| info.error_set,
48 .@"fn" => |info| @typeInfo(info.return_type.?).error_union.error_set,
49 else => @compileError("E must be an error set or function type"),
50 };
51 };
52
53 /// The compile-time flag that turns the checks on, true in a test build
54 /// and false everywhere else.
55 pub const enabled = builtin.is_test;
56
57 comptime {
58 assert(@typeInfo(FailPoint) == .@"enum");
59 assert(@typeInfo(Error) == .error_set);
60 }
61
62 var tripwires: TripwireMap = .{};
63
64 const TripwireMap: type = std.EnumMap(FailPoint, Tripwire);
65 const Tripwire: type = TripwireType(Error);
66
67 /// Evaluates the check site named by `point` inside an operation where
68 /// a simulated error should arrive, so a test can drive the error path
69 /// that a successful run leaves alone.
70 ///
71 /// - Where `enabled` is false, meaning `builtin.is_test == false`, the
72 /// call is an inlined no-op that returns `void`.
73 /// - In a test build, the call returns `void` when `point` has no
74 /// configuration or while the visit count stands at or below the
75 /// threshold. Once the visit count passes the threshold, the site is
76 /// marked tripped and the configured error is returned. The error
77 /// comes back on every later visit as well, as long as the visit
78 /// count stays inside `usize` range.
79 pub fn check(point: FailPoint) callconv(callingConvention()) Error!void {
80 if (comptime !enabled) return;
81 return checkConstrained(point, Error);
82 }
83
84 /// Evaluates the check site named by `point` and returns an error typed
85 /// as `ConstrainedError`, letting the check sit inside a function whose
86 /// declared error set is wider than the module's own.
87 ///
88 /// - Where `enabled` is false, the call is an inlined no-op that
89 /// returns `void`.
90 /// - In a test build, it follows the same visit count and threshold
91 /// rules as `check`.
92 /// - The typing requirement in a test build comes from the body: it
93 /// runs `return tripwire.err;`, and that value has type `Error`. Zig
94 /// coerces an error set only into a set that holds it, so
95 /// `ConstrainedError` has to be `Error` or a superset of `Error`.
96 pub fn checkConstrained(
97 point: FailPoint,
98 comptime ConstrainedError: type,
99 ) callconv(callingConvention()) ConstrainedError!void {
100 if (comptime !enabled) return;
101 const tripwire = tripwires.getPtr(point) orelse return;
102 tripwire.reached += 1;
103 if (tripwire.reached <= tripwire.min) return;
104 tripwire.tripped = true;
105 return tripwire.err;
106 }
107
108 /// Provides the one-line setup for a test that wants the site to fail
109 /// the first time an operation reaches it, configuring `point` to
110 /// deliver `err` on its first visit, which is a threshold of 0. The
111 /// call replaces whatever configuration `point` already had, putting
112 /// the visit count back to 0 and the trip flag back to false. In a test
113 /// build, later calls to `check(point)` follow the visit count rules
114 /// written on `check`.
115 pub fn errorAlways(point: FailPoint, err: Error) void {
116 errorAfter(point, err, 0);
117 }
118
119 /// Lets earlier visits through when an operation reaches the same site
120 /// more than once before reaching the state the test targets, for
121 /// example while parsing earlier records or running earlier loop
122 /// iterations. The function configures `point` to pass its first `min`
123 /// visits and to deliver `err` on visit `min + 1` and on every visit
124 /// after that. This registration replaces whatever configuration
125 /// `point` already had, putting the visit count back to 0 and the trip
126 /// flag back to false. In a test build, later calls to `check(point)`
127 /// follow the visit count rules written on `check`.
128 pub fn errorAfter(point: FailPoint, err: Error, min: usize) void {
129 tripwires.put(point, .{ .err = err, .min = min });
130 }
131
132 /// Settles whether a test reached its configured sites, because a
133 /// passing test proves its error path only once the site is known to
134 /// have been reached. The function verifies that every configured site
135 /// in the failpoint module was visited past its threshold and tripped.
136 ///
137 /// - With no site configured, it succeeds at once.
138 /// - With any configured site still untripped, it returns
139 /// `error.UntrippedError`.
140 /// - Under `.reset`, it clears every configured site, including on the
141 /// run that returns the error.
142 /// - Under `.retain`, it keeps the registrations and the counters, so
143 /// the trip flags stay set and a later call passes with no further
144 /// visits in between.
145 ///
146 /// The call proves that the configured error was delivered at the check
147 /// site. Whether the caller caught the error, ran its cleanup, or held
148 /// its domain invariants is left to the test's own assertions.
149 pub fn end(reset_mode: enum { reset, retain }) error{UntrippedError}!void {
150 var untripped = false;
151 var iter = tripwires.iterator();
152 while (iter.next()) |entry| {
153 if (!entry.value.tripped) {
154 untripped = true;
155 }
156 }
157
158 switch (reset_mode) {
159 .reset => reset(),
160 .retain => {},
161 }
162
163 if (untripped) return error.UntrippedError;
164 }
165
166 /// Clears every failpoint registration and the counters behind them. A
167 /// test calls it before it starts and again on the way out, because
168 /// that state belongs to the type and outlives any one test.
169 pub fn reset() void {
170 tripwires = .{};
171 }
172
173 fn callingConvention() std.builtin.CallingConvention {
174 return if (!enabled) .@"inline" else .auto;
175 }
176 };
177 }
178
179 const TestFailPoint = enum {
180 anonymous_read,
181 anonymous_write,
182 };
183
184 fn test_error_function() error{ Foo, Bar }!void {
185 return error.Foo;
186 }
187
188 test {
189 const io = module(TestFailPoint, anyerror);
190 io.reset();
191 defer io.reset();
192
193 try io.end(.reset);
194 try io.check(.anonymous_read);
195
196 io.errorAlways(.anonymous_read, error.OutOfMemory);
197 try testing.expectError(error.OutOfMemory, io.check(.anonymous_read));
198 try testing.expectError(error.OutOfMemory, io.check(.anonymous_read));
199 try io.end(.reset);
200 }
201
202 test "module as error set" {
203 const io = module(enum { error_set_read, error_set_write }, @TypeOf(test_error_function));
204 io.reset();
205 defer io.reset();
206 try io.end(.reset);
207 }
208
209 test "errorAfter" {
210 const io = module(enum { after_read, after_write }, anyerror);
211 io.reset();
212 defer io.reset();
213 io.errorAfter(.after_read, error.OutOfMemory, 2);
214
215 try io.check(.after_read);
216 try io.check(.after_read);
217
218 try testing.expectError(error.OutOfMemory, io.check(.after_read));
219 try testing.expectError(error.OutOfMemory, io.check(.after_read));
220
221 try io.end(.reset);
222 }
223
224 test "errorAfter reports untripped expectations" {
225 const io = module(enum { untripped_read }, anyerror);
226 io.reset();
227 defer io.reset();
228 io.errorAfter(.untripped_read, error.OutOfMemory, 2);
229 try io.check(.untripped_read);
230 try testing.expectError(error.UntrippedError, io.end(.reset));
231 }