Skip to content

Commit 82efec6

Browse files
committed
test_runner: enable testing panics in mainTerminal
The user can use std.testing.spawnExpectPanic() in a test to spawn a child process, which must panic or the test fails. Internally, - 1. is_panic_parentproc is set from the cli arguments for simple reproduction of both test spawn and panic behavior, - 2. panic_msg is set as threadlocal, if comptime-detectable capabilities exist, to enable multithreaded processing and user-customized messages, - 3. error.SpawnZigTest is returned to the test_runner.zig - 4. the test_runner spawns a child_process on correct usage - 5. the child_process expected to panic executes only one test block This means, that only one @Panic is possible within a test block and that no follow-up code after the @Panic in the test block can be run. This commit does not add the panic test capability to the server yet, since there are open design questions how many processes should be spawned at the same time and how to manage time quotas to prevent unnecessary slowdowns. Supersedes ziglang#14351. Work on ziglang#1356.
1 parent 1631dc9 commit 82efec6

File tree

5 files changed

+254
-15
lines changed

5 files changed

+254
-15
lines changed

lib/std/child_process.zig

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,9 @@ pub const ChildProcess = struct {
251251
}
252252

253253
/// Blocks until child process terminates and then cleans up all resources.
254+
/// In case of error, the caller is responsible to clean up the ressources
255+
/// via calling `self.cleanupStreams()`.
256+
/// TODO: This describes the current state. Is this intended?
254257
pub fn wait(self: *ChildProcess) !Term {
255258
const term = if (builtin.os.tag == .windows)
256259
try self.waitWindows()
@@ -323,7 +326,7 @@ pub const ChildProcess = struct {
323326
};
324327

325328
/// Spawns a child process, waits for it, collecting stdout and stderr, and then returns.
326-
/// If it succeeds, the caller owns result.stdout and result.stderr memory.
329+
/// If spawning succeeds, then the caller owns result.stdout and result.stderr memory.
327330
pub fn exec(args: struct {
328331
allocator: mem.Allocator,
329332
argv: []const []const u8,
@@ -426,7 +429,7 @@ pub const ChildProcess = struct {
426429
self.term = self.cleanupAfterWait(status);
427430
}
428431

429-
fn cleanupStreams(self: *ChildProcess) void {
432+
pub fn cleanupStreams(self: *ChildProcess) void {
430433
if (self.stdin) |*stdin| {
431434
stdin.close();
432435
self.stdin = null;

lib/std/std.zig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,9 @@ pub const options = struct {
292292
options_override.side_channels_mitigations
293293
else
294294
crypto.default_side_channels_mitigations;
295+
296+
/// Default thread-local storage panic message size used for panic tests.
297+
pub const testing_max_panic_msg_size = 100;
295298
};
296299

297300
// This forces the start.zig file to be imported, and the comptime logic inside that

lib/std/testing.zig

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,37 @@ test "expectEqualDeep composite type" {
935935
}
936936
}
937937

938+
pub const can_panic_test = builtin.is_test and !builtin.single_threaded and std.process.can_spawn;
939+
940+
/// Static storage to support user-generated panic messages
941+
/// Parent process writes these, returns to the test execution loop and spawns,
942+
/// child process ignores these.
943+
const TestFn_iT = if (can_panic_test) ?[std.options.testing_max_panic_msg_size:0]u8 else void;
944+
pub threadlocal var panic_msg: TestFn_iT = if (can_panic_test) null else {};
945+
946+
/// Distinguishes between parent and child, if panics are tested for.
947+
/// TODO: is_panic_parentproc and panic_msg feels like it belongs into test api to
948+
/// allow implementations providing their own way to prevent the necessity to use tls.
949+
pub var is_panic_parentproc: if (can_panic_test) bool else void = if (can_panic_test) true else {};
950+
951+
/// To be used for panic tests after test block declaration.
952+
pub fn spawnExpectPanic(msg: []const u8) error{ SpawnZigTest, SkipZigTest }!void {
953+
std.debug.assert(can_panic_test); // Caller is responsible to check.
954+
if (is_panic_parentproc) {
955+
if (panic_msg == null) {
956+
panic_msg = .{undefined} ** std.options.testing_max_panic_msg_size;
957+
@memcpy(panic_msg.?[0..msg.len], msg); // Message must be persistent, not stack-local.
958+
panic_msg.?[msg.len] = 0; // 0-sentinel for the len without separate field
959+
return error.SpawnZigTest; // Test will be run in separate process
960+
} else {
961+
@panic("std.testing.panic_msg must be only used in spawnExpectPanic");
962+
}
963+
} else {
964+
std.debug.assert(panic_msg == null);
965+
// panic runner continues running the test block
966+
}
967+
}
968+
938969
fn printIndicatorLine(source: []const u8, indicator_index: usize) void {
939970
const line_begin_index = if (std.mem.lastIndexOfScalar(u8, source[0..indicator_index], '\n')) |line_begin|
940971
line_begin + 1

lib/std/zig/Server.zig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ pub const Message = struct {
4242
/// - 0 means not async
4343
/// * expected_panic_msg: [tests_len]u32,
4444
/// - null-terminated string_bytes index
45-
/// - 0 means does not expect pani
45+
/// - 0 means does not expect panic
4646
/// * string_bytes: [string_bytes_len]u8,
4747
pub const TestMetadata = extern struct {
4848
string_bytes_len: u32,

lib/test_runner.zig

Lines changed: 214 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ var log_err_count: usize = 0;
1212
var cmdline_buffer: [4096]u8 = undefined;
1313
var fba = std.heap.FixedBufferAllocator.init(&cmdline_buffer);
1414

15+
const Mode = enum {
16+
listen,
17+
terminal,
18+
panic_test,
19+
};
20+
21+
fn callError(args: [][:0]u8) noreturn {
22+
std.debug.print("invalid cli arguments:\n", .{});
23+
for (args) |arg| {
24+
std.debug.print("{s} ", .{arg});
25+
}
26+
std.debug.print("\n", .{});
27+
@panic("call error");
28+
}
29+
1530
pub fn main() void {
1631
if (builtin.zig_backend == .stage2_aarch64) {
1732
return mainSimple() catch @panic("test failure");
@@ -20,20 +35,33 @@ pub fn main() void {
2035
const args = std.process.argsAlloc(fba.allocator()) catch
2136
@panic("unable to parse command line args");
2237

23-
var listen = false;
38+
var i: u32 = 1;
39+
var test_i: ?u64 = null;
40+
var mode: Mode = .terminal;
2441

25-
for (args[1..]) |arg| {
26-
if (std.mem.eql(u8, arg, "--listen=-")) {
27-
listen = true;
42+
while (i < args.len) : (i += 1) {
43+
if (std.mem.eql(u8, args[i], "--listen=-")) {
44+
mode = .listen;
45+
} else if (std.mem.eql(u8, args[i], "--test_panic_index")) {
46+
i += 1;
47+
if (i < args.len) {
48+
test_i = std.fmt.parseInt(u64, args[i], 10) catch {
49+
callError(args);
50+
};
51+
mode = .panic_test;
52+
std.testing.is_panic_parentproc = false;
53+
} else {
54+
callError(args);
55+
}
2856
} else {
29-
@panic("unrecognized command line argument");
57+
callError(args);
3058
}
3159
}
3260

33-
if (listen) {
34-
return mainServer() catch @panic("internal test runner failure");
35-
} else {
36-
return mainTerminal();
61+
switch (mode) {
62+
.listen => return mainServer() catch @panic("internal test runner failure"),
63+
.terminal => return mainTerminal(args),
64+
.panic_test => return panicTest(test_i.?),
3765
}
3866
}
3967

@@ -130,7 +158,18 @@ fn mainServer() !void {
130158
}
131159
}
132160

133-
fn mainTerminal() void {
161+
// TODO
162+
// - [ ] has test_i:
163+
// * spawn and compare specific function
164+
// * compare result: if returning from execution => @panic("FoundNoPanicInTest");
165+
// - [ ] not test_i:
166+
// * iterate through all functions
167+
// * compare result: compare execution result with special case for panic msg "FoundNoPanicInTest"
168+
169+
fn mainTerminal(args: [][:0]const u8) void {
170+
var test_i_buf: [20]u8 = undefined;
171+
// TODO make environment buffer size configurable and use a sane default
172+
// Tradeoff: waste stack space or allocate on every panic test
134173
const test_fn_list = builtin.test_functions;
135174
var ok_count: usize = 0;
136175
var skip_count: usize = 0;
@@ -146,7 +185,6 @@ fn mainTerminal() void {
146185
// TODO this is on the next line (using `undefined` above) because otherwise zig incorrectly
147186
// ignores the alignment of the slice.
148187
async_frame_buffer = &[_]u8{};
149-
150188
var leaks: usize = 0;
151189
for (test_fn_list, 0..) |test_fn, i| {
152190
std.testing.allocator_instance = .{};
@@ -189,9 +227,134 @@ fn mainTerminal() void {
189227
progress.log("SKIP\n", .{});
190228
test_node.end();
191229
},
230+
error.SpawnZigTest => {
231+
progress.log("error.SpawnZigTest\n", .{});
232+
if (!std.testing.can_panic_test)
233+
@panic("Found error.SpawnZigTest without panic test capabilities.");
234+
if (std.testing.panic_msg == null)
235+
@panic("Panic test expects `panic_msg` to be set. Use std.testing.spawnExpectPanic().");
236+
237+
const test_i_written = std.fmt.bufPrint(&test_i_buf, "{d}", .{i}) catch unreachable;
238+
var child_proc = std.ChildProcess.init(
239+
&.{ args[0], "--test_panic_index", test_i_written },
240+
std.testing.allocator,
241+
);
242+
progress.log("spawning '{s} {s} {s}'\n", .{ args[0], "--test_panic_index", test_i_written });
243+
244+
child_proc.stdin_behavior = .Ignore;
245+
child_proc.stdout_behavior = .Pipe;
246+
child_proc.stderr_behavior = .Pipe;
247+
child_proc.spawn() catch |spawn_err| {
248+
progress.log("FAIL spawn ({s})\n", .{@errorName(spawn_err)});
249+
fail_count += 1;
250+
test_node.end();
251+
continue;
252+
};
253+
254+
var stdout = std.ArrayList(u8).init(std.testing.allocator);
255+
defer stdout.deinit();
256+
var stderr = std.ArrayList(u8).init(std.testing.allocator);
257+
defer stderr.deinit();
258+
// child_process.zig: max_output_bytes: usize = 50 * 1024,
259+
child_proc.collectOutput(&stdout, &stderr, 50 * 1024) catch |collect_err| {
260+
progress.log("FAIL collect ({s})\n", .{@errorName(collect_err)});
261+
fail_count += 1;
262+
test_node.end();
263+
continue;
264+
};
265+
const term = child_proc.wait() catch |wait_err| {
266+
child_proc.cleanupStreams();
267+
progress.log("FAIL wait_error (exit_status: {d})\n", .{@errorName(wait_err)});
268+
fail_count += 1;
269+
test_node.end();
270+
continue;
271+
};
272+
switch (term) {
273+
.Exited => |code| {
274+
progress.log("FAIL term exited, status: {})\nstdout: ({s})\nstderr: ({s})\n", .{ code, stdout.items, stderr.items });
275+
fail_count += 1;
276+
test_node.end();
277+
continue;
278+
},
279+
.Signal => |code| {
280+
progress.log("Signal: {d}\n", .{code});
281+
// assert: panic message format: 'XYZ thread thread_id panic: msg'
282+
// Any signal can be returned on panic, if a custom signal
283+
// or panic handler was installed as part of the unit test.
284+
var pos_eol: usize = 0;
285+
var found_eol: bool = false;
286+
while (pos_eol < stderr.items.len) : (pos_eol += 1) {
287+
if (stderr.items[pos_eol] == '\n') {
288+
found_eol = true;
289+
break;
290+
}
291+
}
292+
293+
if (!found_eol) {
294+
progress.log("FAIL no end of line in panic format\nstdout: ({s})\nstderr: ({s})\n", .{ stdout.items, stderr.items });
295+
fail_count += 1;
296+
test_node.end();
297+
continue;
298+
}
299+
300+
var it = std.mem.tokenize(u8, stderr.items[0..pos_eol], " ");
301+
var parsed_panic_msg = false;
302+
while (it.next()) |word| { // 'thread thread_id panic: msg'
303+
if (!std.mem.eql(u8, word, "thread")) continue;
304+
const thread_id = it.next();
305+
if (thread_id == null) continue;
306+
_ = std.fmt.parseInt(u64, thread_id.?, 10) catch continue;
307+
const panic_txt = it.next();
308+
if (panic_txt == null) continue;
309+
if (!std.mem.eql(u8, panic_txt.?, "panic:")) continue;
310+
const panic_msg = it.next();
311+
if (panic_msg == null) continue;
312+
const panic_msg_start = it.index - panic_msg.?.len;
313+
const len_exp_panic_msg = std.mem.len(@as([*:0]u8, std.testing.panic_msg.?[0..]));
314+
const expected_panic_msg = std.testing.panic_msg.?[0..len_exp_panic_msg];
315+
const panic_msg_end = panic_msg_start + expected_panic_msg.len;
316+
if (panic_msg_end > pos_eol) break;
317+
318+
parsed_panic_msg = true;
319+
const current_panic_msg = stderr.items[panic_msg_start..panic_msg_end];
320+
321+
if (!std.mem.eql(u8, "SKIP (async test)", current_panic_msg) and !std.mem.eql(u8, expected_panic_msg, current_panic_msg)) {
322+
progress.log("FAIL expected_panic_msg: '{s}', got: '{s}'\n", .{ expected_panic_msg, current_panic_msg });
323+
std.testing.panic_msg = null;
324+
fail_count += 1;
325+
test_node.end();
326+
break;
327+
}
328+
std.testing.panic_msg = null;
329+
ok_count += 1;
330+
test_node.end();
331+
if (!have_tty) std.debug.print("OK\n", .{});
332+
break;
333+
}
334+
if (!parsed_panic_msg) {
335+
progress.log("FAIL invalid panic_msg format expect 'XYZ thread thread_id panic: msg'\nstdout: ({s})\nstderr: ({s})\n", .{ stdout.items, stderr.items });
336+
fail_count += 1;
337+
test_node.end();
338+
continue;
339+
}
340+
},
341+
.Stopped => |code| {
342+
fail_count += 1;
343+
progress.log("FAIL stopped, status: ({d})\nstdout: ({s})\nstderr: ({s})\n", .{ code, stdout.items, stderr.items });
344+
test_node.end();
345+
continue;
346+
},
347+
.Unknown => |code| {
348+
fail_count += 1;
349+
progress.log("FAIL unknown, status: ({d})\nstdout: ({s})\nstderr: ({s})\n", .{ code, stdout.items, stderr.items });
350+
test_node.end();
351+
continue;
352+
},
353+
}
354+
},
192355
else => {
193356
fail_count += 1;
194-
progress.log("FAIL ({s})\n", .{@errorName(err)});
357+
progress.log("FAIL unexpected error ({s})\n", .{@errorName(err)});
195358
if (@errorReturnTrace()) |trace| {
196359
std.debug.dumpStackTrace(trace.*);
197360
}
@@ -216,6 +379,45 @@ fn mainTerminal() void {
216379
}
217380
}
218381

382+
fn panicTest(test_i: u64) void {
383+
const test_fn_list = builtin.test_functions;
384+
var async_frame_buffer: []align(std.Target.stack_align) u8 = undefined;
385+
// TODO this is on the next line (using `undefined` above) because otherwise zig incorrectly
386+
// ignores the alignment of the slice.
387+
async_frame_buffer = &[_]u8{};
388+
{
389+
std.testing.allocator_instance = .{};
390+
// custom panic handler to restore to save state and prevent memory
391+
// leakage is out of scope, so ignore memory leaks
392+
defer {
393+
if (std.testing.allocator_instance.deinit() == .leak) {
394+
@panic("internal test runner memory leak");
395+
}
396+
}
397+
std.testing.log_level = .warn;
398+
const result = if (test_fn_list[test_i].async_frame_size) |size| switch (std.options.io_mode) {
399+
.evented => blk: {
400+
if (async_frame_buffer.len < size) {
401+
std.heap.page_allocator.free(async_frame_buffer);
402+
async_frame_buffer = std.heap.page_allocator.alignedAlloc(u8, std.Target.stack_align, size) catch @panic("out of memory");
403+
}
404+
const casted_fn = @ptrCast(fn () callconv(.Async) anyerror!void, test_fn_list[test_i].func);
405+
break :blk await @asyncCall(async_frame_buffer, {}, casted_fn, .{});
406+
},
407+
.blocking => @panic("SKIP (async test)"),
408+
} else test_fn_list[test_i].func();
409+
410+
if (result) {
411+
std.os.exit(0);
412+
} else |err| {
413+
std.debug.print("FAIL unexpected error ({s})\n", .{@errorName(err)});
414+
if (@errorReturnTrace()) |trace| {
415+
std.debug.dumpStackTrace(trace.*);
416+
}
417+
}
418+
}
419+
}
420+
219421
pub fn log(
220422
comptime message_level: std.log.Level,
221423
comptime scope: @Type(.EnumLiteral),

0 commit comments

Comments
 (0)