aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/libghostty-vt/src/tripwire.zig
blob: 1829c348fa86e18811804ae36c9864c38c541921 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
//! A library for injecting failures into Zig code for the express
//! purpose of testing error handling paths.
//!
//! Improper `errdefer` is one of the highest sources of bugs in Zig code.
//! Many `errdefer` points are hard to exercise in unit tests and rare
//! to encounter in production, so they often hide bugs. Worse, error
//! scenarios are most likely to put your code in an unexpected state
//! that can result in future assertion failures or memory safety issues.
//!
//! This module aims to solve this problem by providing a way to inject
//! errors at specific points in your code during unit tests, allowing you
//! to test every possible error path.
//!
//! # Usage
//!
//! To use this package, create a `tripwire.module` for each failable
//! function you want to test. The enum must be hand-curated to be the
//! set of fail points, and the error set comes directly from the function
//! itself.
//!
//! Pepper your function with `try tw.check` calls wherever you want to
//! have a testable failure point. You don't need every "try" to have
//! an associated tripwire check, only the ones you care about testing.
//! Usually, this is going to be the points where you want to test
//! errdefer logic above it.
//!
//! In unit tests, add `try tw.errorAlways` or related calls to
//! configure expected failures. Then, call your function. Finally, always
//! call `try tw.end(.reset)` to verify your expectations were met and
//! to reset the tripwire module for future tests.
//!
//! ```
//! const tw = tripwire.module(enum { alloc_buf, open_file }, myFunction);
//!
//! fn myFunction() tw.Error!void {
//!     try tw.check(.alloc_buf);
//!     const buf = try allocator.alloc(u8, 1024);
//!     errdefer allocator.free(buf);
//!
//!     try tw.check(.open_file);
//!     const file = try std.Io.Dir.cwd().openFile(io, "foo.txt", .{});
//!     // ...
//! }
//!
//! test "myFunction fails on alloc" {
//!     tw.errorAlways(.alloc_buf, error.OutOfMemory);
//!     try std.testing.expectError(error.OutOfMemory, myFunction());
//!     try tw.end(.reset);
//! }
//! ```
//!
//! ## Transitive Function Calls
//!
//! To test transitive calls, there are two schools of thought:
//!
//!   1. Put a failure point above the transitive call in the caller
//!      and assume the child function error handling works correctly.
//!
//!   2. Create another tripwire module for the child function and
//!      trigger failures there. This is recommended if the child function
//!      can't really be called in isolation (e.g. its an auxiliary function
//!      to a public API).
//!
//! Either works, its situationally dependent which is better.

const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;
const testing = std.testing;

const log = std.log.scoped(.tripwire);

// Future ideas:
//
//   - Assert that the errors are actually tripped. e.g. you set a
//     errorAlways on a point, and want to verify it was tripped.
//   - Assert that every point is covered by at least one test. We
//     can probably use global state for this.
//   - Error only after a certain number of calls to a point.
//   - Error only on a range of calls (first 5 times, 2-7th time, etc.)
//
// I don't want to implement these until they're actually needed by
// some part of our codebase, but I want to list them here in case they
// do become useful.

/// A tripwire module that can be used to inject failures at specific points.
///
/// Outside of unit tests, this module is free and completely optimized away.
/// It takes up zero binary or runtime space and all function calls are
/// optimized out.
///
/// To use this module, add `check` (or related) calls prior to every
/// `try` operation that you want to be able to fail arbitrarily. Then,
/// in your unit tests, call the `error` family of functions to configure
/// when errors should be injected.
///
/// P is an enum type representing the failure points in the module.
/// E is the error set of possible errors that can be returned from the
/// failure points. You can use `anyerror` here but note you may have to
/// use `checkConstrained` to narrow down the error type when you call
/// it in your function (so your function can compile).
///
/// E may also be an error union type, in which case the error set of that
/// union is used as the error set for the tripwire module.
/// If E is a function, then the error set of the return value of that
/// function is used as the error set for the tripwire module.
pub fn module(
    comptime P: type,
    comptime E: anytype,
) type {
    return struct {
        /// The points this module can fail at.
        pub const FailPoint = P;

        /// The error set used for failures at the failure points.
        pub const Error = err: {
            const T = if (@TypeOf(E) == type) E else @TypeOf(E);
            break :err switch (@typeInfo(T)) {
                .error_set => E,
                .error_union => |info| info.error_set,
                .@"fn" => |info| @typeInfo(info.return_type.?).error_union.error_set,
                else => @compileError("E must be an error set or function type"),
            };
        };

        /// Whether our module is enabled or not. In the future we may
        /// want to make this a comptime parameter to the module.
        pub const enabled = builtin.is_test;

        comptime {
            assert(@typeInfo(FailPoint) == .@"enum");
            assert(@typeInfo(Error) == .error_set);
        }

        /// The configured tripwires for this module.
        var tripwires: TripwireMap = .{};
        const TripwireMap = std.EnumMap(FailPoint, Tripwire);
        const Tripwire = struct {
            /// Error to return when tripped
            err: Error,

            /// The amount of times this tripwire has been reached. This
            /// is NOT the number of times it has tripped, since we may
            /// have mins for that.
            reached: usize = 0,

            /// The minimum number of times this must be reached before
            /// tripping. After this point, it trips every time. This is
            /// a "before" check so if this is "1" then it'll trip the
            /// second time it's reached.
            min: usize = 0,

            /// True if this has been tripped at least once.
            tripped: bool = false,
        };

        /// Check for a failure at the given failure point. These should
        /// be placed directly before the `try` operation that may fail.
        pub fn check(point: FailPoint) callconv(callingConvention()) Error!void {
            if (comptime !enabled) return;
            return checkConstrained(point, Error);
        }

        /// Same as check but allows specifying a custom error type for the
        /// return value. This must be a subset of the module's Error type
        /// and will produce a runtime error if the configured tripwire
        /// error can't be cast to the ConstrainedError type.
        pub fn checkConstrained(
            point: FailPoint,
            comptime ConstrainedError: type,
        ) callconv(callingConvention()) ConstrainedError!void {
            if (comptime !enabled) return;
            const tripwire = tripwires.getPtr(point) orelse return;
            tripwire.reached += 1;
            if (tripwire.reached <= tripwire.min) return;
            tripwire.tripped = true;
            return tripwire.err;
        }

        /// Mark a failure point to always trip with the given error.
        pub fn errorAlways(point: FailPoint, err: Error) void {
            errorAfter(point, err, 0);
        }

        /// Mark a failure point to trip with the given error after
        /// the failure point is reached at least `min` times. A value of
        /// zero is equivalent to `errorAlways`.
        pub fn errorAfter(point: FailPoint, err: Error, min: usize) void {
            tripwires.put(point, .{ .err = err, .min = min });
        }

        /// Ends the tripwire session. This will raise an error if there
        /// were untripped error expectations. The reset mode specifies
        /// whether expectations are reset too. Expectations are always reset,
        /// even if this returns an error.
        pub fn end(reset_mode: enum { reset, retain }) error{UntrippedError}!void {
            var untripped: bool = false;
            var iter = tripwires.iterator();
            while (iter.next()) |entry| {
                if (!entry.value.tripped) {
                    log.warn("untripped point={s}", .{@tagName(entry.key)});
                    untripped = true;
                }
            }

            switch (reset_mode) {
                .reset => reset(),
                .retain => {},
            }

            if (untripped) return error.UntrippedError;
        }

        /// Unset all the tripwires. You should usually call `end` instead.
        pub fn reset() void {
            tripwires = .{};
        }

        /// Our calling convention is inline if our tripwire module is
        /// NOT enabled, so that all calls to `check` are optimized away.
        fn callingConvention() std.builtin.CallingConvention {
            return if (!enabled) .@"inline" else .auto;
        }
    };
}

test {
    const io = module(enum {
        read,
        write,
    }, anyerror);

    // Reset should work
    try io.end(.reset);

    // By default, its pass-through
    try io.check(.read);

    // Always trip
    io.errorAlways(.read, error.OutOfMemory);
    try testing.expectError(
        error.OutOfMemory,
        io.check(.read),
    );
    // Happens again
    try testing.expectError(
        error.OutOfMemory,
        io.check(.read),
    );
    try io.end(.reset);
}

test "module as error set" {
    const io = module(enum { read, write }, @TypeOf((struct {
        fn func() error{ Foo, Bar }!void {
            return error.Foo;
        }
    }).func));
    try io.end(.reset);
}

test "errorAfter" {
    const io = module(enum { read, write }, anyerror);
    // Trip after 2 calls (on the 3rd call)
    io.errorAfter(.read, error.OutOfMemory, 2);

    // First two calls succeed
    try io.check(.read);
    try io.check(.read);

    // Third call and on trips
    try testing.expectError(error.OutOfMemory, io.check(.read));
    try testing.expectError(error.OutOfMemory, io.check(.read));

    try io.end(.reset);
}

test "errorAfter untripped error if min not reached" {
    const io = module(enum { read }, anyerror);
    io.errorAfter(.read, error.OutOfMemory, 2);
    // Only call once, not enough to trip
    try io.check(.read);
    // end should fail because tripwire was set but never tripped
    try testing.expectError(
        error.UntrippedError,
        io.end(.reset),
    );
}