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 }