Files
browser/src/crash_handler.zig
T
Karl Seguin e2299e2c05 dev: speedup unit tests
Directly improve a couple slow tests (crash_handler actually generating crash
dumps on some systems, e.g. mine). The runWebApiTest prefers to poll for work
when possible rather than a blind sleep -> check loop. (e.g. if we have
websocket connections, prefer an http tick).

For me, it's 22s -> 17s.
2026-09-18 15:17:39 +08:00

593 lines
24 KiB
Zig

// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const builtin = @import("builtin");
const abort = std.process.abort;
// tracks how deep within a panic we're panicling
var panic_level: usize = 0;
// Locked to avoid interleaving panic messages from multiple threads.
var panic_mutex: std.Io.Mutex = .init;
// overwrite's Zig default panic handler
pub fn panic(msg: []const u8, _: ?*std.builtin.StackTrace, begin_addr: ?usize) noreturn {
@branchHint(.cold);
crash(msg, .{ .source = "global" }, begin_addr orelse @returnAddress());
}
pub noinline fn crash(
reason: []const u8,
args: anytype,
begin_addr: usize,
) noreturn {
@branchHint(.cold);
nosuspend switch (panic_level) {
0 => {
panic_level = panic_level + 1;
{
panic_mutex.lockUncancelable(lp.io);
defer panic_mutex.unlock(lp.io);
var writer_w = std.Io.File.stderr().writerStreaming(lp.io, &.{});
const writer = &writer_w.interface;
writer.writeAll(
\\
\\Lightpanda has crashed. Please report the issue:
\\https://github.com/lightpanda-io/browser/issues
\\or let us know on discord: https://discord.gg/g24PtgD6
\\
) catch abort();
writer.print("\nreason: {s}\n", .{reason}) catch abort();
writer.print("OS: {s}\n", .{@tagName(builtin.os.tag)}) catch abort();
writer.print("mode: {s}\n", .{@tagName(builtin.mode)}) catch abort();
writer.print("version: {s}\n", .{lp.build_config.version}) catch abort();
inline for (@typeInfo(@TypeOf(args)).@"struct".fields) |f| {
writer.writeAll(f.name ++ ": ") catch break;
lp.log.writeValue(.pretty, @field(args, f.name), writer) catch abort();
writer.writeByte('\n') catch abort();
}
std.debug.writeCurrentStackTrace(.{ .first_address = begin_addr }, .{ .writer = writer, .mode = .no_color }) catch abort();
}
report(reason, begin_addr, args) catch {};
},
1 => {
panic_level = 2;
var stderr_w = std.Io.File.stderr().writerStreaming(lp.io, &.{});
const stderr = &stderr_w.interface;
stderr.writeAll("panicked during a panic. Aborting.\n") catch abort();
},
else => {},
};
abort();
}
fn report(reason: []const u8, begin_addr: usize, args: anytype) !void {
if (comptime lp.IS_DEBUG) {
return;
}
if (@import("telemetry/telemetry.zig").isDisabled()) {
return;
}
var curl_path: [2048]u8 = undefined;
const curl_path_len = curlPath(&curl_path) orelse return;
var url_buffer: [4096]u8 = undefined;
const url = blk: {
var writer: std.Io.Writer = .fixed(&url_buffer);
try writer.print("https://crash.lightpanda.io/c?v={s}&r=", .{lp.build_config.version_encoded});
for (reason) |b| {
switch (b) {
'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_' => try writer.writeByte(b),
' ' => try writer.writeByte('+'),
else => try writer.writeByte('!'), // some weird character, that we shouldn't have, but that'll we'll replace with a weird (bur url-safe) character
}
}
try writer.writeByte(0);
break :blk writer.buffered();
};
var body_buffer: [8192]u8 = undefined;
const body = blk: {
var writer: std.Io.Writer = .fixed(body_buffer[0..8191]); // reserve 1 space
inline for (@typeInfo(@TypeOf(args)).@"struct".fields) |f| {
// remove url value from the crash report.
if (comptime std.mem.eql(u8, f.name, "url")) {
writer.writeAll("url: REDACTED\n") catch break;
continue;
}
writer.writeAll(f.name ++ ": ") catch break;
lp.log.writeValue(.pretty, @field(args, f.name), &writer) catch {};
writer.writeByte('\n') catch {};
}
std.debug.writeCurrentStackTrace(.{ .first_address = begin_addr }, .{ .writer = &writer, .mode = .no_color }) catch {};
const written = writer.buffered();
if (written.len == 0) {
break :blk "???";
}
// Overwrite the last character with our null terminator
// body_buffer always has to be > written
body_buffer[written.len] = 0;
break :blk body_buffer[0 .. written.len + 1];
};
var argv = [_:null]?[*:0]const u8{
curl_path[0..curl_path_len :0],
"-fsSL",
"-H",
"Content-Type: application/octet-stream",
"--data-binary",
body[0 .. body.len - 1 :0],
url[0 .. url.len - 1 :0],
};
const result = std.c.fork();
switch (result) {
0 => {
_ = std.c.close(0);
_ = std.c.close(1);
_ = std.c.close(2);
_ = std.c.execve(argv[0].?, &argv, std.c.environ);
std.c.exit(0);
},
else => return,
}
}
fn curlPath(buf: []u8) ?usize {
const cwd = std.Io.Dir.cwd();
if (std.c.getenv("PATH")) |path_z| {
var it = std.mem.tokenizeScalar(u8, std.mem.span(path_z), std.fs.path.delimiter);
var fba = std.heap.FixedBufferAllocator.init(buf);
const allocator = fba.allocator();
while (it.next()) |p| {
defer fba.reset();
const full_path = std.fs.path.joinZ(allocator, &.{ p, "curl" }) catch continue;
cwd.access(lp.io, full_path, .{}) catch continue;
return full_path.len;
}
}
// A supervisor that replaces the environment rather than extending it
// leaves us with no PATH at all, and every crash report with it.
for ([_][]const u8{ "/usr/bin/curl", "/bin/curl", "/usr/local/bin/curl" }) |candidate| {
if (candidate.len >= buf.len) continue;
@memcpy(buf[0..candidate.len], candidate);
buf[candidate.len] = 0;
cwd.access(lp.io, buf[0..candidate.len :0], .{}) catch continue;
return candidate.len;
}
return null;
}
const fatal_signals = [_]std.posix.SIG{ .SEGV, .BUS, .ILL, .FPE };
const max_backtrace_frames = 32;
// A frame further than a whole thread stack from its caller is not a frame.
const max_frame_distance = 8 << 20;
// Initialized before threads start; owned until process exit.
var signal_output_fd: std.c.fd_t = -1;
var signal_handlers_attached = false;
// Best-effort record of a fatal signal, written before the process dies of
// it. Unlike panics, the interrupted thread may hold any lock, so this path
// never touches the panic mutex, lp.io, the unwinder, the allocator or
// telemetry: fixed-buffer scalar formatting, nonblocking output, re-raise.
//
// V8's WebAssembly trap handler is not enabled; enabling it would require
// giving it first chance at SIGSEGV/SIGBUS here.
pub fn attachSignalHandlers() void {
if (builtin.os.tag != .linux or signal_handlers_attached) return;
signal_handlers_attached = true;
signal_output_fd = openSignalOutput();
var mask = std.posix.sigemptyset();
std.posix.sigaddset(&mask, .PIPE);
const act: std.posix.Sigaction = .{
.handler = .{ .sigaction = handleFatalSignal },
.mask = mask,
.flags = std.posix.SA.SIGINFO | std.posix.SA.RESETHAND | std.posix.SA.NODEFER,
};
for (fatal_signals) |sig| std.posix.sigaction(sig, &act, null);
}
fn openSignalOutput() std.c.fd_t {
if (builtin.os.tag != .linux) return -1;
const S = std.os.linux.S;
const raw_flags = std.c.fcntl(2, std.posix.F.GETFL);
if (raw_flags < 0) return -1;
const flags: std.posix.O = @bitCast(@as(u32, @intCast(raw_flags)));
if (flags.ACCMODE == .RDONLY) return -1;
var original: std.os.linux.Statx = undefined;
if (!statFd(2, &original)) return -1;
const is_regular = S.ISREG(original.mode);
if (!is_regular and !S.ISFIFO(original.mode) and !S.ISCHR(original.mode)) return -1;
// Unlike dup(), procfs gives us independent O_NONBLOCK flags. A regular
// file additionally needs O_APPEND: the new description starts at offset
// zero and would otherwise overwrite the head of the log.
const fd = std.c.open("/proc/self/fd/2", .{
.ACCMODE = .WRONLY,
.NONBLOCK = true,
.CLOEXEC = true,
.APPEND = is_regular,
});
if (fd < 0) return -1;
var reopened: std.os.linux.Statx = undefined;
if (!statFd(fd, &reopened) or reopened.dev_major != original.dev_major or reopened.dev_minor != original.dev_minor or reopened.ino != original.ino) {
_ = std.c.close(fd);
return -1;
}
return fd;
}
fn statFd(fd: std.c.fd_t, stat: *std.os.linux.Statx) bool {
const linux = std.os.linux;
if (linux.statx(fd, "", linux.AT.EMPTY_PATH, .{ .TYPE = true, .INO = true }, stat) != 0) {
return false;
}
return stat.mask.TYPE and stat.mask.INO;
}
fn handleFatalSignal(sig: std.posix.SIG, info: *const std.posix.siginfo_t, ctx_ptr: ?*anyopaque) callconv(.c) noreturn {
// A secondary fault must not re-enter any reporting machinery.
const default: std.posix.Sigaction = .{ .handler = .{ .handler = std.posix.SIG.DFL }, .mask = std.posix.sigemptyset(), .flags = 0 };
for (fatal_signals) |fatal| _ = std.c.sigaction(fatal, &default, null);
const opt_context: ?std.debug.cpu_context.Native = if (ctx_ptr == null) null else std.debug.cpu_context.fromPosixSignalContext(ctx_ptr);
const context: ?*const std.debug.cpu_context.Native = if (opt_context) |*ctx| ctx else null;
var buffer: [512]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buffer);
writeSignalContext(&writer, sig, info, context) catch {};
writeRecord(writer.buffered());
// Written separately because walking the frame chain reads memory the
// fault may already have invalidated: the record above has to survive a
// second fault in here.
if (context) |ctx| {
writeBacktrace(ctx);
}
_ = std.c.raise(sig);
std.c._exit(@intCast(128 + @intFromEnum(sig)));
}
fn writeRecord(record: []const u8) void {
if (record.len == 0) {
return;
}
if (signal_output_fd >= 0) {
_ = std.c.write(signal_output_fd, record.ptr, record.len);
} else {
// Per-call flags leave inherited stderr flags unchanged. Non-sockets
// fail with ENOTSOCK: omit the record rather than risk blocking.
_ = std.c.send(2, record.ptr, record.len, std.c.MSG.DONTWAIT | std.c.MSG.NOSIGNAL);
}
}
fn writeSignalContext(writer: *std.Io.Writer, sig: std.posix.SIG, info: *const std.posix.siginfo_t, context: ?*const std.debug.cpu_context.Native) !void {
try writer.print("\nLightpanda fatal signal: {t} ({d})\nversion: {s}\nOS: {s}\nmode: {s}\ncode: {d}\n", .{
sig, @intFromEnum(sig), lp.build_config.version, @tagName(builtin.os.tag), @tagName(builtin.mode), info.code,
});
if (faultAddress(info)) |address| {
try writer.print("address: 0x{x}\n", .{address});
} else {
try writer.writeAll("address: unavailable\n");
}
// Runtime address of a known symbol for offline ASLR adjustment.
try writer.print("crash_handler.handleFatalSignal: 0x{x}\n", .{@intFromPtr(&handleFatalSignal)});
if (context) |ctx| {
try writer.print("pc: 0x{x}\nfp: 0x{x}\n", .{ ctx.getPc(), ctx.getFp() });
if (stackPointer(ctx)) |sp| try writer.print("sp: 0x{x}\n", .{sp});
switch (builtin.cpu.arch) {
.aarch64 => try writer.print("lr: 0x{x}\n", .{ctx.x[30]}),
else => {},
}
}
}
fn writeBacktrace(ctx: *const std.debug.cpu_context.Native) void {
if (comptime builtin.omit_frame_pointer) {
return;
}
var buffer: [640]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buffer);
writer.print("backtrace: 0x{x}", .{ctx.getPc()}) catch return;
// Each frame must sit above the last, close enough to be a real frame.
var floor = stackPointer(ctx) orelse ctx.getFp();
var fp = ctx.getFp();
for (0..max_backtrace_frames) |_| {
if (fp < floor or fp - floor > max_frame_distance or fp % @alignOf(usize) != 0) {
break;
}
const frame: *const [2]usize = @ptrFromInt(fp);
const return_address = frame[1];
if (return_address == 0) {
break;
}
writer.print(" 0x{x}", .{return_address}) catch break;
floor = fp +| 1;
fp = frame[0];
}
writer.writeByte('\n') catch {};
writeRecord(writer.buffered());
}
fn stackPointer(ctx: *const std.debug.cpu_context.Native) ?usize {
return switch (builtin.cpu.arch) {
.aarch64 => ctx.sp,
.x86_64 => ctx.gprs.get(.rsp),
else => null,
};
}
fn faultAddress(info: *const std.posix.siginfo_t) ?usize {
// SI_USER/SI_TKILL/SI_KERNEL do not supply si_addr.
if (info.code <= 0 or info.code >= 128) return null;
return @intFromPtr(info.fields.sigfault.addr);
}
const testing = @import("testing.zig");
test "crash_handler: fatal signals preserve termination with unavailable stderr" {
if (builtin.os.tag != .linux) return error.SkipZigTest;
for (fatal_signals) |sig| {
for ([_]SignalTestMode{ .normal, .pipe, .tty, .closed, .broken_pipe, .locked_panic, .regular_file, .read_only_pipe }) |mode| {
try testSignal(sig, mode);
}
}
}
test "crash_handler: full stderr must not delay termination" {
if (builtin.os.tag != .linux) return error.SkipZigTest;
for (fatal_signals) |sig| {
try testSignal(sig, .full_pipe);
try testSignal(sig, .full_socket);
}
}
test "crash_handler: hardware fault reports the interrupted context" {
if (builtin.os.tag != .linux) return error.SkipZigTest;
try testSignal(.SEGV, .hardware);
}
test "crash_handler: fatal signal after fork from a non-main thread" {
if (builtin.os.tag != .linux) return error.SkipZigTest;
const Worker = struct {
fn run(result: *?anyerror) void {
testSignal(.SEGV, .hardware) catch |err| {
result.* = err;
};
}
};
var result: ?anyerror = null;
const thread = try std.Thread.spawn(.{}, Worker.run, .{&result});
thread.join();
if (result) |err| return err;
}
test "crash_handler: unknown signal addresses are not read" {
if (builtin.os.tag != .linux) return error.SkipZigTest;
var info: std.posix.siginfo_t = undefined;
for ([_]c_int{ 0, -1, -6, 128, 0x10001 }) |code| {
info.code = code;
try testing.expectEqual(@as(?usize, null), faultAddress(&info));
}
}
const SignalTestMode = enum { normal, pipe, tty, closed, broken_pipe, locked_panic, hardware, full_pipe, full_socket, regular_file, read_only_pipe };
extern "c" fn posix_openpt(oflag: c_int) c_int;
extern "c" fn grantpt(fd: c_int) c_int;
extern "c" fn unlockpt(fd: c_int) c_int;
extern "c" fn ptsname_r(fd: c_int, buf: [*]u8, buflen: usize) c_int;
// fds[0] is the master the parent reads, fds[1] the slave the child gets as
// its stderr: the same shape as pipe() and socketpair().
fn openPty(fds: *[2]std.c.fd_t) c_int {
const oflag: c_int = @bitCast(@as(u32, @bitCast(std.posix.O{ .ACCMODE = .RDWR, .NOCTTY = true })));
const master = posix_openpt(oflag);
if (master < 0) return -1;
if (grantpt(master) != 0 or unlockpt(master) != 0) {
_ = std.c.close(master);
return -1;
}
var name: [128]u8 = undefined;
if (ptsname_r(master, &name, name.len) != 0) {
_ = std.c.close(master);
return -1;
}
const slave = std.c.open(@ptrCast(&name), .{ .ACCMODE = .WRONLY, .NOCTTY = true });
if (slave < 0) {
_ = std.c.close(master);
return -1;
}
fds.* = .{ master, slave };
return 0;
}
fn testSignal(sig: std.posix.SIG, mode: SignalTestMode) !void {
const guard = if (mode == .hardware) try std.posix.mmap(null, std.heap.pageSize(), .{}, .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, -1, 0) else null;
defer if (guard) |memory| std.posix.munmap(memory);
const file = if (mode == .regular_file) std.c.memfd_create("fatal-signal-test", std.c.MFD.CLOEXEC) else -1;
if (mode == .regular_file) {
try testing.expectEqual(true, file >= 0);
// O_APPEND or not is the whole question: a reopened description starts
// at offset zero and would land on top of this.
try testing.expectEqual(@as(isize, prior_log.len), std.c.write(file, prior_log, prior_log.len));
}
defer if (file >= 0) {
_ = std.c.close(file);
};
var fds: [2]std.c.fd_t = undefined;
const full = mode == .full_pipe or mode == .full_socket;
const is_pipe = mode == .pipe or mode == .full_pipe or mode == .read_only_pipe;
const result = if (mode == .tty)
openPty(&fds)
else if (is_pipe)
std.c.pipe(&fds)
else
std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &fds);
// A sandbox without /dev/ptmx leaves nothing to test here.
if (mode == .tty and result != 0) return;
try testing.expectEqual(@as(c_int, 0), result);
defer _ = std.c.close(fds[0]);
const pid = std.c.fork();
if (pid == -1) {
_ = std.c.close(fds[1]);
return error.ForkFailed;
}
if (pid == 0) {
// Keep a regression from hanging the runner or writing a core.
_ = std.os.linux.prctl(@intFromEnum(std.os.linux.PR.SET_DUMPABLE), 0, 0, 0, 0);
const default: std.posix.Sigaction = .{ .handler = .{ .handler = std.posix.SIG.DFL }, .mask = std.posix.sigemptyset(), .flags = 0 };
std.posix.sigaction(.ALRM, &default, null);
std.posix.sigaction(.PIPE, &default, null);
_ = std.c.alarm(3);
_ = std.c.dup2(if (mode == .read_only_pipe) fds[0] else fds[1], 2);
_ = std.c.close(fds[0]);
_ = std.c.close(fds[1]);
if (mode == .closed) _ = std.c.close(2);
if (file >= 0) {
_ = std.c.dup2(file, 2);
_ = std.c.close(file);
}
if (mode == .broken_pipe) {
var broken: [2]std.c.fd_t = undefined;
if (std.c.pipe(&broken) != 0) std.c._exit(1);
_ = std.c.close(broken[0]);
_ = std.c.dup2(broken[1], 2);
_ = std.c.close(broken[1]);
}
if (full) {
const flags = std.c.fcntl(2, std.posix.F.GETFL);
const nonblock: c_int = @bitCast(@as(u32, @bitCast(std.posix.O{ .NONBLOCK = true })));
if (std.c.fcntl(2, std.posix.F.SETFL, flags | nonblock) < 0) std.c._exit(1);
const fill = [_]u8{'x'} ** 1024;
for ([_]usize{ fill.len, 1 }) |len| {
while (true) {
const written = std.c.write(2, &fill, len);
if (written > 0) continue;
if (std.posix.errno(written) != .AGAIN) std.c._exit(1);
break;
}
}
if (std.c.fcntl(2, std.posix.F.SETFL, flags) < 0) std.c._exit(1);
}
if (mode == .locked_panic) panic_mutex.lockUncancelable(lp.io);
const flags_before = std.c.fcntl(2, std.posix.F.GETFL);
attachSignalHandlers();
const first_output = signal_output_fd;
attachSignalHandlers();
if (signal_output_fd != first_output) std.c._exit(1);
if (std.c.fcntl(2, std.posix.F.GETFL) != flags_before) std.c._exit(1);
if (signal_output_fd >= 0) {
const output_flags: std.posix.O = @bitCast(@as(u32, @intCast(std.c.fcntl(signal_output_fd, std.posix.F.GETFL))));
if (!output_flags.NONBLOCK) std.c._exit(1);
if (output_flags.APPEND != (mode == .regular_file)) std.c._exit(1);
if (std.c.fcntl(signal_output_fd, std.posix.F.GETFD) & std.posix.FD_CLOEXEC == 0) std.c._exit(1);
}
if (guard) |memory| @as(*volatile u8, @ptrCast(memory.ptr)).* = 1;
_ = std.c.raise(sig);
std.c._exit(1);
}
_ = std.c.close(fds[1]);
var status: c_int = 0;
if (full) try testing.expectEqual(pid, std.c.waitpid(pid, &status, 0));
var output: [4096]u8 = undefined;
var len: usize = 0;
while (len < output.len) {
const count = std.c.read(fds[0], output[len..].ptr, output.len - len);
if (count <= 0) break;
len += @intCast(count);
}
if (!full) try testing.expectEqual(pid, std.c.waitpid(pid, &status, 0));
const raw: u32 = @bitCast(status);
errdefer std.debug.print("signal={t} mode={t} status=0x{x}\n", .{ sig, mode, raw });
if (comptime builtin.sanitize_thread) {
// ThreadSanitizer's sigaction wrapper keeps the signal blocked for the
// duration of the handler whatever SA_NODEFER says, so the re-raise
// only ever goes pending and the handler's fallback exit is what ends
// the process. Everything before that point is unaffected.
try testing.expectEqual(true, std.posix.W.IFEXITED(raw));
try testing.expectEqual(@as(u8, @intCast(128 + @intFromEnum(sig))), std.posix.W.EXITSTATUS(raw));
} else {
try testing.expectEqual(true, std.posix.W.IFSIGNALED(raw));
try testing.expectEqual(sig, std.posix.W.TERMSIG(raw));
}
var text = output[0..len];
if (mode == .regular_file) {
// The record went to the file, not to the socketpair.
try testing.expectEqual(@as(usize, 0), len);
try testing.expectEqual(@as(std.c.off_t, 0), std.c.lseek(file, 0, std.c.SEEK.SET));
const count = std.c.read(file, &output, output.len);
try testing.expectEqual(true, count > 0);
text = output[0..@intCast(count)];
try testing.expectEqual(true, std.mem.startsWith(u8, text, prior_log));
}
var unwrapped: [output.len]u8 = undefined;
if (mode == .tty) {
// ONLCR turns every \n into \r\n on the way through the line discipline.
const replaced = std.mem.replace(u8, text, "\r\n", "\n", &unwrapped);
text = unwrapped[0 .. text.len - replaced];
}
if (mode == .read_only_pipe or mode == .closed or mode == .broken_pipe) try testing.expectEqual(@as(usize, 0), text.len);
if (mode == .normal or mode == .locked_panic or mode == .hardware or mode == .pipe or mode == .tty or mode == .regular_file) {
errdefer std.debug.print("signal={t} mode={t}\n{s}\n", .{ sig, mode, text });
try testing.expectEqual(true, std.mem.containsAtLeast(u8, text, 1, "Lightpanda fatal signal:"));
try testing.expectEqual(true, std.mem.containsAtLeast(u8, text, 1, "\npc: 0x"));
try testing.expectEqual(true, std.mem.containsAtLeast(u8, text, 1, "\ncrash_handler.handleFatalSignal: 0x"));
try testing.expectEqual(true, std.mem.containsAtLeast(u8, text, 1, "\nbacktrace: 0x"));
if (mode == .hardware) {
var address_buffer: [64]u8 = undefined;
const address = try std.fmt.bufPrint(&address_buffer, "address: 0x{x}\n", .{@intFromPtr(guard.?.ptr)});
try testing.expectEqual(true, std.mem.containsAtLeast(u8, text, 1, address));
// The faulting pc alone is not a backtrace: the walk has to have
// followed at least one link out of the frame that faulted.
const line = text[std.mem.indexOf(u8, text, "\nbacktrace: ").? + 1 ..];
try testing.expectEqual(true, std.mem.count(u8, line[0..std.mem.indexOfScalar(u8, line, '\n').?], " 0x") >= 2);
} else {
try testing.expectEqual(true, std.mem.containsAtLeast(u8, text, 1, "address: unavailable\n"));
}
}
}
const prior_log = "a line that was already in the log\n";