Commit 1a8a8c610d

mlugg <mlugg@mlugg.co.uk>
2025-09-09 23:45:39
tests: split up and enhance stack trace tests
Previously, the `test-stack-traces` step was essentially just testing error traces, and even there we didn't have much coverage. This commit solves that by splitting the "stack trace" tests into two separate harnesses: the "stack trace" tests are for actual stack traces (i.e. involving stack unwinding), while the "error trace" tests are specifically for error return traces. The "stack trace" tests will test different configurations of: * `-lc` * `-fPIE` * `-fomit-frame-pointer` * `-fllvm` * unwind tables (currently disabled) * strip debug info (currently disabled) The main goal there is to test *stack unwinding* under different conditions. Meanwhile, the "error trace" tests will test different configurations of `-O` and `-fllvm`; the main goal here, aside from checking that error traces themselves do not miscompile, is to check whether debug info is still working even in optimized builds. Of course, aggressive optimizations *can* thwart debug info no matter what, so as before, there is a way to disable cases for specific targets / optimize modes. The program which converts stack traces into a more validatable format by removing things like addresses (previously `check-stack-trace.zig`, now `convert-stack-trace.zig`) has been rewritten and simplified. Also, thanks to various fixes in this branch, several workarounds have become unnecessary: for instance, we don't need to ignore the function name printed in stack traces in release modes, because `std.debug.Dwarf` now uses the correct DIE for inlined functions! Neither `test-stack-traces` nor `test-error-traces` does general foreign architecture testing, because it seems that (at least for now) external executors often aren't particularly good at handling stack tracing correctly (looking at you, Wine). Generally, they just test the native target (this matches the old behavior of `test-stack-traces`). However, there is one exception: when on an x86_64 or aarch64 host, we will also test the 32-bit version (x86 or arm) if the OS supports it, because such executables can be trivially tested without an external executor. Oh, also, I wrote a bunch of stack trace tests. Previously there was, erm, *one* test in `test-stack-traces` which wasn't for error traces. Now there are a good few!
1 parent cedd9de
lib/std/debug/SelfInfo.zig
@@ -355,7 +355,7 @@ pub const DwarfUnwindContext = struct {
         context.reg_context.eh_frame = cie.version != 4;
         context.reg_context.is_macho = native_os.isDarwin();
 
-        const row = try context.vm.runTo(gpa, context.pc - load_offset, cie, fde, @sizeOf(usize), native_endian);
+        const row = try context.vm.runTo(gpa, pc_vaddr, cie, fde, @sizeOf(usize), native_endian);
         context.cfa = switch (row.cfa.rule) {
             .val_offset => |offset| blk: {
                 const register = row.cfa.register orelse return error.InvalidCFARule;
test/src/check-stack-trace.zig
@@ -1,88 +0,0 @@
-const builtin = @import("builtin");
-const std = @import("std");
-const mem = std.mem;
-const fs = std.fs;
-
-pub fn main() !void {
-    var arena_instance = std.heap.ArenaAllocator.init(std.heap.page_allocator);
-    defer arena_instance.deinit();
-    const arena = arena_instance.allocator();
-
-    const args = try std.process.argsAlloc(arena);
-
-    const input_path = args[1];
-    const optimize_mode_text = args[2];
-
-    const input_bytes = try std.fs.cwd().readFileAlloc(input_path, arena, .limited(5 * 1024 * 1024));
-    const optimize_mode = std.meta.stringToEnum(std.builtin.OptimizeMode, optimize_mode_text).?;
-
-    var stderr = input_bytes;
-
-    // process result
-    // - keep only basename of source file path
-    // - replace address with symbolic string
-    // - replace function name with symbolic string when optimize_mode != .Debug
-    // - skip empty lines
-    const got: []const u8 = got_result: {
-        var buf = std.array_list.Managed(u8).init(arena);
-        defer buf.deinit();
-        if (stderr.len != 0 and stderr[stderr.len - 1] == '\n') stderr = stderr[0 .. stderr.len - 1];
-        var it = mem.splitScalar(u8, stderr, '\n');
-        process_lines: while (it.next()) |line| {
-            if (line.len == 0) continue;
-
-            // offset search past `[drive]:` on windows
-            var pos: usize = if (builtin.os.tag == .windows) 2 else 0;
-            // locate delims/anchor
-            const delims = [_][]const u8{ ":", ":", ":", " in ", "(", ")" };
-            var marks = [_]usize{0} ** delims.len;
-            for (delims, 0..) |delim, i| {
-                marks[i] = mem.indexOfPos(u8, line, pos, delim) orelse {
-                    // unexpected pattern: emit raw line and cont
-                    try buf.appendSlice(line);
-                    try buf.appendSlice("\n");
-                    continue :process_lines;
-                };
-                pos = marks[i] + delim.len;
-            }
-            // locate source basename
-            pos = mem.lastIndexOfScalar(u8, line[0..marks[0]], fs.path.sep) orelse {
-                // unexpected pattern: emit raw line and cont
-                try buf.appendSlice(line);
-                try buf.appendSlice("\n");
-                continue :process_lines;
-            };
-            // end processing if source basename changes
-            if (!mem.eql(u8, "source.zig", line[pos + 1 .. marks[0]])) break;
-            // emit substituted line
-            try buf.appendSlice(line[pos + 1 .. marks[2] + delims[2].len]);
-            try buf.appendSlice(" [address]");
-            if (optimize_mode == .Debug) {
-                try buf.appendSlice(line[marks[3] .. marks[4] + delims[4].len]);
-
-                const file_name = line[marks[4] + delims[4].len .. marks[5]];
-                // The LLVM backend currently uses the object file name in the debug info here.
-                // This actually violates the DWARF specification (DWARF5 § 3.1.1, lines 24-27).
-                // The self-hosted backend uses the root Zig source file of the module (in compilance with the spec).
-                if (std.mem.eql(u8, file_name, "test") or
-                    std.mem.eql(u8, file_name, "test_zcu.obj") or
-                    std.mem.endsWith(u8, file_name, ".zig"))
-                {
-                    try buf.appendSlice("[main_file]");
-                } else {
-                    // Something unexpected; include it verbatim.
-                    try buf.appendSlice(file_name);
-                }
-
-                try buf.appendSlice(line[marks[5]..]);
-            } else {
-                try buf.appendSlice(line[marks[3] .. marks[3] + delims[3].len]);
-                try buf.appendSlice("[function]");
-            }
-            try buf.appendSlice("\n");
-        }
-        break :got_result try buf.toOwnedSlice();
-    };
-
-    try std.fs.File.stdout().writeAll(got);
-}
test/src/convert-stack-trace.zig
@@ -0,0 +1,104 @@
+//! Accepts a stack trace in a file (whose path is given as argv[1]), and removes all
+//! non-reproducible information from it, including addresses, module names, and file
+//! paths. All module names are removed, file paths become just their basename, and
+//! addresses are replaced with a fixed string. So, lines like this:
+//!
+//!   /something/foo.zig:1:5: 0x12345678 in bar (main.o)
+//!       doThing();
+//!              ^
+//!   ???:?:?: 0x12345678 in qux (other.o)
+//!   ???:?:?: 0x12345678 in ??? (???)
+//!
+//! ...are turned into lines like this:
+//!
+//!   foo.zig:1:5: [address] in bar
+//!       doThing();
+//!              ^
+//!   ???:?:?: [address] in qux
+//!   ???:?:?: [address] in ???
+//!
+//! Additionally, lines reporting unwind errors are removed:
+//!
+//!   Unwind error at address `/proc/self/exe:0x1016533` (unwind info unavailable), remaining frames may be incorrect
+//!
+//! With these transformations, the test harness can safely do string comparisons.
+
+pub fn main() !void {
+    var arena_instance: std.heap.ArenaAllocator = .init(std.heap.page_allocator);
+    defer arena_instance.deinit();
+    const arena = arena_instance.allocator();
+
+    const args = try std.process.argsAlloc(arena);
+    if (args.len != 2) std.process.fatal("usage: convert-stack-trace path/to/test/output", .{});
+
+    var read_buf: [1024]u8 = undefined;
+    var write_buf: [1024]u8 = undefined;
+
+    const in_file = try std.fs.cwd().openFile(args[1], .{});
+    defer in_file.close();
+
+    const out_file: std.fs.File = .stdout();
+
+    var in_fr = in_file.reader(&read_buf);
+    var out_fw = out_file.writer(&write_buf);
+
+    const w = &out_fw.interface;
+
+    while (in_fr.interface.takeDelimiterInclusive('\n')) |in_line| {
+        if (std.mem.startsWith(u8, in_line, "Unwind error at address `")) {
+            // Remove these lines from the output.
+            continue;
+        }
+
+        const src_col_end = std.mem.indexOf(u8, in_line, ": 0x") orelse {
+            try w.writeAll(in_line);
+            continue;
+        };
+        const src_row_end = std.mem.lastIndexOfScalar(u8, in_line[0..src_col_end], ':') orelse {
+            try w.writeAll(in_line);
+            continue;
+        };
+        const src_path_end = std.mem.lastIndexOfScalar(u8, in_line[0..src_row_end], ':') orelse {
+            try w.writeAll(in_line);
+            continue;
+        };
+
+        const addr_end = std.mem.indexOfPos(u8, in_line, src_col_end, " in ") orelse {
+            try w.writeAll(in_line);
+            continue;
+        };
+        const symbol_end = std.mem.indexOfPos(u8, in_line, addr_end, " (") orelse {
+            try w.writeAll(in_line);
+            continue;
+        };
+        if (!std.mem.endsWith(u8, std.mem.trimEnd(u8, in_line, "\n"), ")")) {
+            try w.writeAll(in_line);
+            continue;
+        }
+
+        // Where '_' is a placeholder for an arbitrary string, we now know the line looks like:
+        //
+        //   _:_:_: 0x_ in _ (_)
+        //
+        // That seems good enough to assume it's a stack trace frame! We'll rewrite it to:
+        //
+        //   _:_:_: [address] in _
+        //
+        // ...with that first '_' being replaced by its basename.
+
+        const src_path = in_line[0..src_path_end];
+        const basename_start = if (std.mem.lastIndexOfAny(u8, src_path, "/\\")) |i| i + 1 else 0;
+        const symbol_start = addr_end + " in ".len;
+        try w.writeAll(in_line[basename_start..src_col_end]);
+        try w.writeAll(": [address] in ");
+        try w.writeAll(in_line[symbol_start..symbol_end]);
+        try w.writeByte('\n');
+    } else |err| switch (err) {
+        error.EndOfStream => {},
+        else => |e| return e,
+    }
+
+    try w.flush();
+}
+
+const std = @import("std");
test/src/ErrorTrace.zig
@@ -0,0 +1,126 @@
+b: *std.Build,
+step: *Step,
+test_filters: []const []const u8,
+targets: []const std.Build.ResolvedTarget,
+optimize_modes: []const OptimizeMode,
+convert_exe: *std.Build.Step.Compile,
+
+pub const Case = struct {
+    name: []const u8,
+    source: []const u8,
+    expect_error: []const u8,
+    expect_trace: []const u8,
+    /// On these arch/OS pairs we will not test the error trace on optimized LLVM builds because the
+    /// optimizations break the error trace. We will test the binary with error tracing disabled,
+    /// just to ensure that the expected error is still returned from `main`.
+    disable_trace_optimized: []const DisableConfig = &.{},
+
+    pub const DisableConfig = struct { std.Target.Cpu.Arch, std.Target.Os.Tag };
+    pub const Backend = enum { llvm, selfhosted };
+};
+
+pub fn addCase(self: *ErrorTrace, case: Case) void {
+    for (self.targets) |*target| {
+        const triple: ?[]const u8 = if (target.query.isNative()) null else t: {
+            break :t target.query.zigTriple(self.b.graph.arena) catch @panic("OOM");
+        };
+        for (self.optimize_modes) |optimize| {
+            self.addCaseConfig(case, target, triple, optimize, .llvm);
+        }
+        if (shouldTestNonLlvm(&target.result)) {
+            for (self.optimize_modes) |optimize| {
+                self.addCaseConfig(case, target, triple, optimize, .selfhosted);
+            }
+        }
+    }
+}
+
+fn shouldTestNonLlvm(target: *const std.Target) bool {
+    return switch (target.cpu.arch) {
+        .x86_64 => switch (target.ofmt) {
+            .elf => true,
+            else => false,
+        },
+        else => false,
+    };
+}
+
+fn addCaseConfig(
+    self: *ErrorTrace,
+    case: Case,
+    target: *const std.Build.ResolvedTarget,
+    triple: ?[]const u8,
+    optimize: OptimizeMode,
+    backend: Case.Backend,
+) void {
+    const b = self.b;
+
+    const error_tracing: bool = tracing: {
+        if (optimize == .Debug) break :tracing true;
+        if (backend != .llvm) break :tracing true;
+        for (case.disable_trace_optimized) |disable| {
+            const d_arch, const d_os = disable;
+            if (target.result.cpu.arch == d_arch and target.result.os.tag == d_os) {
+                // This particular configuration cannot do error tracing in optimized LLVM builds.
+                break :tracing false;
+            }
+        }
+        break :tracing true;
+    };
+
+    const annotated_case_name = b.fmt("check {s} ({s}{s}{s} {s})", .{
+        case.name,
+        triple orelse "",
+        if (triple != null) " " else "",
+        @tagName(optimize),
+        @tagName(backend),
+    });
+    if (self.test_filters.len > 0) {
+        for (self.test_filters) |test_filter| {
+            if (mem.indexOf(u8, annotated_case_name, test_filter)) |_| break;
+        } else return;
+    }
+
+    const write_files = b.addWriteFiles();
+    const source_zig = write_files.add("source.zig", case.source);
+    const exe = b.addExecutable(.{
+        .name = "test",
+        .root_module = b.createModule(.{
+            .root_source_file = source_zig,
+            .optimize = optimize,
+            .target = target.*,
+            .error_tracing = error_tracing,
+            .strip = false,
+        }),
+        .use_llvm = switch (backend) {
+            .llvm => true,
+            .selfhosted => false,
+        },
+    });
+    exe.bundle_ubsan_rt = false;
+
+    const run = b.addRunArtifact(exe);
+    run.removeEnvironmentVariable("CLICOLOR_FORCE");
+    run.setEnvironmentVariable("NO_COLOR", "1");
+    run.expectExitCode(1);
+    run.expectStdOutEqual("");
+
+    const expected_stderr = switch (error_tracing) {
+        true => b.fmt("error: {s}\n{s}\n", .{ case.expect_error, case.expect_trace }),
+        false => b.fmt("error: {s}\n", .{case.expect_error}),
+    };
+
+    const check_run = b.addRunArtifact(self.convert_exe);
+    check_run.setName(annotated_case_name);
+    check_run.addFileArg(run.captureStdErr(.{}));
+    check_run.expectStdOutEqual(expected_stderr);
+
+    self.step.dependOn(&check_run.step);
+}
+
+const ErrorTrace = @This();
+const std = @import("std");
+const builtin = @import("builtin");
+const Step = std.Build.Step;
+const OptimizeMode = std.builtin.OptimizeMode;
+const mem = std.mem;
test/src/StackTrace.zig
@@ -1,75 +1,164 @@
 b: *std.Build,
 step: *Step,
-test_index: usize,
 test_filters: []const []const u8,
-optimize_modes: []const OptimizeMode,
-check_exe: *std.Build.Step.Compile,
+targets: []const std.Build.ResolvedTarget,
+convert_exe: *std.Build.Step.Compile,
 
 const Config = struct {
     name: []const u8,
     source: []const u8,
-    Debug: ?PerMode = null,
-    ReleaseSmall: ?PerMode = null,
-    ReleaseSafe: ?PerMode = null,
-    ReleaseFast: ?PerMode = null,
-
-    const PerMode = struct {
-        expect: []const u8,
-        exclude_arch: []const std.Target.Cpu.Arch = &.{},
-        exclude_os: []const std.Target.Os.Tag = &.{},
-        error_tracing: ?bool = null,
-    };
+    /// Whether this test case expects to have unwind tables / frame pointers.
+    unwind: enum {
+        /// This case assumes that some unwind strategy, safe or unsafe, is available.
+        any,
+        /// This case assumes that no unwinding strategy is available.
+        none,
+        /// This case assumes that a safe unwind strategy, like DWARF unwinding, is available.
+        safe,
+        /// This case assumes that at most, unsafe FP unwinding is available.
+        no_safe,
+    },
+    /// If `true`, the expected exit code is that of the default panic handler, rather than 0.
+    expect_panic: bool,
+    /// When debug info is not stripped, stdout is expected to **contain** (not equal!) this string.
+    expect: []const u8,
+    /// When debug info *is* stripped, stdout is expected to **contain** (not equal!) this string.
+    expect_strip: []const u8,
 };
 
 pub fn addCase(self: *StackTrace, config: Config) void {
-    self.addCaseInner(config, true);
-    if (shouldTestNonLlvm(&self.b.graph.host.result)) {
-        self.addCaseInner(config, false);
+    for (self.targets) |*target| {
+        addCaseTarget(
+            self,
+            config,
+            target,
+            if (target.query.isNative()) null else t: {
+                break :t target.query.zigTriple(self.b.graph.arena) catch @panic("OOM");
+            },
+        );
     }
 }
-
-fn addCaseInner(self: *StackTrace, config: Config, use_llvm: bool) void {
-    if (config.Debug) |per_mode|
-        self.addExpect(config.name, config.source, .Debug, use_llvm, per_mode);
-
-    if (config.ReleaseSmall) |per_mode|
-        self.addExpect(config.name, config.source, .ReleaseSmall, use_llvm, per_mode);
-
-    if (config.ReleaseFast) |per_mode|
-        self.addExpect(config.name, config.source, .ReleaseFast, use_llvm, per_mode);
-
-    if (config.ReleaseSafe) |per_mode|
-        self.addExpect(config.name, config.source, .ReleaseSafe, use_llvm, per_mode);
-}
-
-fn shouldTestNonLlvm(target: *const std.Target) bool {
-    return switch (target.cpu.arch) {
-        .x86_64 => switch (target.ofmt) {
-            .elf => !target.os.tag.isBSD(),
+fn addCaseTarget(
+    self: *StackTrace,
+    config: Config,
+    target: *const std.Build.ResolvedTarget,
+    triple: ?[]const u8,
+) void {
+    const both_backends = switch (target.result.cpu.arch) {
+        .x86_64 => switch (target.result.ofmt) {
+            .elf => true,
             else => false,
         },
         else => false,
     };
+    const both_pie = switch (target.result.os.tag) {
+        .fuchsia, .openbsd => false,
+        else => true,
+    };
+    const both_libc = switch (target.result.os.tag) {
+        .freebsd, .netbsd => false,
+        else => !target.result.requiresLibC(),
+    };
+
+    // On aarch64-macos, FP unwinding is blessed by Apple to always be reliable, and std.debug knows this.
+    const fp_unwind_is_safe = target.result.cpu.arch == .aarch64 and target.result.os.tag.isDarwin();
+
+    const use_llvm_vals: []const bool = if (both_backends) &.{ true, false } else &.{true};
+    const pie_vals: []const ?bool = if (both_pie) &.{ true, false } else &.{null};
+    const link_libc_vals: []const ?bool = if (both_libc) &.{ true, false } else &.{null};
+    const strip_debug_vals: []const bool = &.{ true, false };
+
+    const UnwindInfo = packed struct(u2) {
+        tables: bool,
+        fp: bool,
+        const none: @This() = .{ .tables = false, .fp = false };
+        const both: @This() = .{ .tables = true, .fp = true };
+        const only_tables: @This() = .{ .tables = true, .fp = false };
+        const only_fp: @This() = .{ .tables = false, .fp = true };
+    };
+    const unwind_info_vals: []const UnwindInfo = switch (config.unwind) {
+        .none => &.{.none},
+        .any => &.{ .only_tables, .only_fp, .both },
+        .safe => if (fp_unwind_is_safe) &.{ .only_tables, .only_fp, .both } else &.{ .only_tables, .both },
+        .no_safe => if (fp_unwind_is_safe) &.{.none} else &.{ .none, .only_fp },
+    };
+
+    for (use_llvm_vals) |use_llvm| {
+        for (pie_vals) |pie| {
+            for (link_libc_vals) |link_libc| {
+                for (strip_debug_vals) |strip_debug| {
+                    for (unwind_info_vals) |unwind_info| {
+                        self.addCaseInstance(
+                            target,
+                            triple,
+                            config.name,
+                            config.source,
+                            use_llvm,
+                            pie,
+                            link_libc,
+                            strip_debug,
+                            !unwind_info.tables,
+                            !unwind_info.fp,
+                            config.expect_panic,
+                            if (strip_debug) config.expect_strip else config.expect,
+                        );
+                    }
+                }
+            }
+        }
+    }
 }
 
-fn addExpect(
+fn addCaseInstance(
     self: *StackTrace,
+    target: *const std.Build.ResolvedTarget,
+    triple: ?[]const u8,
     name: []const u8,
     source: []const u8,
-    optimize_mode: OptimizeMode,
     use_llvm: bool,
-    mode_config: Config.PerMode,
+    pie: ?bool,
+    link_libc: ?bool,
+    strip_debug: bool,
+    strip_unwind: bool,
+    omit_frame_pointer: bool,
+    expect_panic: bool,
+    expect_stderr: []const u8,
 ) void {
-    for (mode_config.exclude_arch) |tag| if (tag == builtin.cpu.arch) return;
-    for (mode_config.exclude_os) |tag| if (tag == builtin.os.tag) return;
-
     const b = self.b;
-    const annotated_case_name = b.fmt("check {s} ({s} {s})", .{
-        name, @tagName(optimize_mode), if (use_llvm) "llvm" else "selfhosted",
+
+    if (strip_debug) {
+        // To enable this coverage, one of two things needs to happen:
+        // * The compiler needs to gain the ability to strip only debug info (not symbols)
+        // * `std.Build.Step.ObjCopy` needs to be un-regressed
+        return;
+    }
+
+    if (strip_unwind) {
+        // To enable this coverage, `std.Build.Step.ObjCopy` needs to be un-regressed and gain the
+        // ability to remove individual sections. `-fno-unwind-tables` is insufficient because it
+        // does not prevent `.debug_frame` from being emitted. If we could, we would remove the
+        // following sections:
+        // * `.eh_frame`, `.eh_frame_hdr`, `.debug_frame` (Linux)
+        // * `__TEXT,__eh_frame`, `__TEXT,__unwind_info` (macOS)
+        return;
+    }
+
+    const annotated_case_name = b.fmt("check {s} ({s}{s}{s}{s}{s}{s}{s}{s})", .{
+        name,
+        triple orelse "",
+        if (triple != null) " " else "",
+        if (use_llvm) "llvm" else "selfhosted",
+        if (pie == true) " pie" else "",
+        if (link_libc == true) " libc" else "",
+        if (strip_debug) " strip" else "",
+        if (strip_unwind) " no_unwind" else "",
+        if (omit_frame_pointer) " no_fp" else "",
     });
-    for (self.test_filters) |test_filter| {
-        if (mem.indexOf(u8, annotated_case_name, test_filter)) |_| break;
-    } else if (self.test_filters.len > 0) return;
+    if (self.test_filters.len > 0) {
+        for (self.test_filters) |test_filter| {
+            if (mem.indexOf(u8, annotated_case_name, test_filter)) |_| break;
+        } else return;
+    }
 
     const write_files = b.addWriteFiles();
     const source_zig = write_files.add("source.zig", source);
@@ -77,27 +166,34 @@ fn addExpect(
         .name = "test",
         .root_module = b.createModule(.{
             .root_source_file = source_zig,
-            .optimize = optimize_mode,
-            .target = b.graph.host,
-            .error_tracing = mode_config.error_tracing,
+            .optimize = .Debug,
+            .target = target.*,
+            .omit_frame_pointer = omit_frame_pointer,
+            .link_libc = link_libc,
+            .unwind_tables = if (strip_unwind) .none else null,
+            // make panics single-threaded so that they don't include a thread ID
+            .single_threaded = expect_panic,
         }),
         .use_llvm = use_llvm,
     });
+    exe.pie = pie;
     exe.bundle_ubsan_rt = false;
 
     const run = b.addRunArtifact(exe);
     run.removeEnvironmentVariable("CLICOLOR_FORCE");
     run.setEnvironmentVariable("NO_COLOR", "1");
-    run.expectExitCode(1);
+    run.addCheck(.{ .expect_term = term: {
+        if (!expect_panic) break :term .{ .Exited = 0 };
+        if (target.result.os.tag == .windows) break :term .{ .Exited = 3 };
+        break :term .{ .Signal = 6 };
+    } });
     run.expectStdOutEqual("");
 
-    const check_run = b.addRunArtifact(self.check_exe);
+    const check_run = b.addRunArtifact(self.convert_exe);
     check_run.setName(annotated_case_name);
     check_run.addFileArg(run.captureStdErr(.{}));
-    check_run.addArgs(&.{
-        @tagName(optimize_mode),
-    });
-    check_run.expectStdOutEqual(mode_config.expect);
+    check_run.expectExitCode(0);
+    check_run.addCheck(.{ .expect_stdout_match = expect_stderr });
 
     self.step.dependOn(&check_run.step);
 }
test/error_traces.zig
@@ -0,0 +1,430 @@
+pub fn addCases(cases: *@import("tests.zig").ErrorTracesContext) void {
+    cases.addCase(.{
+        .name = "return",
+        .source =
+        \\pub fn main() !void {
+        \\    return error.TheSkyIsFalling;
+        \\}
+        ,
+        .expect_error = "TheSkyIsFalling",
+        .expect_trace =
+        \\source.zig:2:5: [address] in main
+        \\    return error.TheSkyIsFalling;
+        \\    ^
+        ,
+    });
+
+    cases.addCase(.{
+        .name = "try return",
+        .source =
+        \\fn foo() !void {
+        \\    return error.TheSkyIsFalling;
+        \\}
+        \\
+        \\pub fn main() !void {
+        \\    try foo();
+        \\}
+        ,
+        .expect_error = "TheSkyIsFalling",
+        .expect_trace =
+        \\source.zig:2:5: [address] in foo
+        \\    return error.TheSkyIsFalling;
+        \\    ^
+        \\source.zig:6:5: [address] in main
+        \\    try foo();
+        \\    ^
+        ,
+        .disable_trace_optimized = &.{
+            .{ .x86_64, .windows },
+            .{ .x86, .windows },
+        },
+    });
+    cases.addCase(.{
+        .name = "non-error return pops error trace",
+        .source =
+        \\fn bar() !void {
+        \\    return error.UhOh;
+        \\}
+        \\
+        \\fn foo() !void {
+        \\    bar() catch {
+        \\        return; // non-error result: success
+        \\    };
+        \\}
+        \\
+        \\pub fn main() !void {
+        \\    try foo();
+        \\    return error.UnrelatedError;
+        \\}
+        ,
+        .expect_error = "UnrelatedError",
+        .expect_trace =
+        \\source.zig:13:5: [address] in main
+        \\    return error.UnrelatedError;
+        \\    ^
+        ,
+    });
+
+    cases.addCase(.{
+        .name = "continue in while loop",
+        .source =
+        \\fn foo() !void {
+        \\    return error.UhOh;
+        \\}
+        \\
+        \\pub fn main() !void {
+        \\    var i: usize = 0;
+        \\    while (i < 3) : (i += 1) {
+        \\        foo() catch continue;
+        \\    }
+        \\    return error.UnrelatedError;
+        \\}
+        ,
+        .expect_error = "UnrelatedError",
+        .expect_trace =
+        \\source.zig:10:5: [address] in main
+        \\    return error.UnrelatedError;
+        \\    ^
+        ,
+        .disable_trace_optimized = &.{
+            .{ .x86_64, .linux },
+            .{ .x86, .linux },
+            .{ .x86_64, .windows },
+            .{ .x86, .windows },
+        },
+    });
+
+    cases.addCase(.{
+        .name = "try return + handled catch/if-else",
+        .source =
+        \\fn foo() !void {
+        \\    return error.TheSkyIsFalling;
+        \\}
+        \\
+        \\pub fn main() !void {
+        \\    foo() catch {}; // should not affect error trace
+        \\    if (foo()) |_| {} else |_| {
+        \\        // should also not affect error trace
+        \\    }
+        \\    try foo();
+        \\}
+        ,
+        .expect_error = "TheSkyIsFalling",
+        .expect_trace =
+        \\source.zig:2:5: [address] in foo
+        \\    return error.TheSkyIsFalling;
+        \\    ^
+        \\source.zig:10:5: [address] in main
+        \\    try foo();
+        \\    ^
+        ,
+        .disable_trace_optimized = &.{
+            .{ .x86_64, .windows },
+            .{ .x86, .windows },
+        },
+    });
+
+    cases.addCase(.{
+        .name = "break from inline loop pops error return trace",
+        .source =
+        \\fn foo() !void { return error.FooBar; }
+        \\
+        \\pub fn main() !void {
+        \\    comptime var i: usize = 0;
+        \\    b: inline while (i < 5) : (i += 1) {
+        \\        foo() catch {
+        \\            break :b; // non-error break, success
+        \\        };
+        \\    }
+        \\    // foo() was successfully handled, should not appear in trace
+        \\
+        \\    return error.BadTime;
+        \\}
+        ,
+        .expect_error = "BadTime",
+        .expect_trace =
+        \\source.zig:12:5: [address] in main
+        \\    return error.BadTime;
+        \\    ^
+        ,
+    });
+
+    cases.addCase(.{
+        .name = "catch and re-throw error",
+        .source =
+        \\fn foo() !void {
+        \\    return error.TheSkyIsFalling;
+        \\}
+        \\
+        \\pub fn main() !void {
+        \\    return foo() catch error.AndMyCarIsOutOfGas;
+        \\}
+        ,
+        .expect_error = "AndMyCarIsOutOfGas",
+        .expect_trace =
+        \\source.zig:2:5: [address] in foo
+        \\    return error.TheSkyIsFalling;
+        \\    ^
+        \\source.zig:6:5: [address] in main
+        \\    return foo() catch error.AndMyCarIsOutOfGas;
+        \\    ^
+        ,
+        .disable_trace_optimized = &.{
+            .{ .x86_64, .windows },
+            .{ .x86, .windows },
+        },
+    });
+
+    cases.addCase(.{
+        .name = "errors stored in var do not contribute to error trace",
+        .source =
+        \\fn foo() !void {
+        \\    return error.TheSkyIsFalling;
+        \\}
+        \\
+        \\pub fn main() !void {
+        \\    // Once an error is stored in a variable, it is popped from the trace
+        \\    var x = foo();
+        \\    x = {};
+        \\
+        \\    // As a result, this error trace will still be clean
+        \\    return error.SomethingUnrelatedWentWrong;
+        \\}
+        ,
+        .expect_error = "SomethingUnrelatedWentWrong",
+        .expect_trace =
+        \\source.zig:11:5: [address] in main
+        \\    return error.SomethingUnrelatedWentWrong;
+        \\    ^
+        ,
+    });
+
+    cases.addCase(.{
+        .name = "error stored in const has trace preserved for duration of block",
+        .source =
+        \\fn foo() !void { return error.TheSkyIsFalling; }
+        \\fn bar() !void { return error.InternalError; }
+        \\fn baz() !void { return error.UnexpectedReality; }
+        \\
+        \\pub fn main() !void {
+        \\    const x = foo();
+        \\    const y = b: {
+        \\        if (true)
+        \\            break :b bar();
+        \\
+        \\        break :b {};
+        \\    };
+        \\    x catch {};
+        \\    y catch {};
+        \\    // foo()/bar() error traces not popped until end of block
+        \\
+        \\    {
+        \\        const z = baz();
+        \\        z catch {};
+        \\        // baz() error trace still alive here
+        \\    }
+        \\    // baz() error trace popped, foo(), bar() still alive
+        \\    return error.StillUnresolved;
+        \\}
+        ,
+        .expect_error = "StillUnresolved",
+        .expect_trace =
+        \\source.zig:1:18: [address] in foo
+        \\fn foo() !void { return error.TheSkyIsFalling; }
+        \\                 ^
+        \\source.zig:2:18: [address] in bar
+        \\fn bar() !void { return error.InternalError; }
+        \\                 ^
+        \\source.zig:23:5: [address] in main
+        \\    return error.StillUnresolved;
+        \\    ^
+        ,
+        .disable_trace_optimized = &.{
+            .{ .x86_64, .windows },
+            .{ .x86, .windows },
+        },
+    });
+
+    cases.addCase(.{
+        .name = "error passed to function has its trace preserved for duration of the call",
+        .source =
+        \\pub fn expectError(expected_error: anyerror, actual_error: anyerror!void) !void {
+        \\    actual_error catch |err| {
+        \\        if (err == expected_error) return {};
+        \\    };
+        \\    return error.TestExpectedError;
+        \\}
+        \\
+        \\fn alwaysErrors() !void { return error.ThisErrorShouldNotAppearInAnyTrace; }
+        \\fn foo() !void { return error.Foo; }
+        \\
+        \\pub fn main() !void {
+        \\    try expectError(error.ThisErrorShouldNotAppearInAnyTrace, alwaysErrors());
+        \\    try expectError(error.ThisErrorShouldNotAppearInAnyTrace, alwaysErrors());
+        \\    try expectError(error.Foo, foo());
+        \\
+        \\    // Only the error trace for this failing check should appear:
+        \\    try expectError(error.Bar, foo());
+        \\}
+        ,
+        .expect_error = "TestExpectedError",
+        .expect_trace =
+        \\source.zig:9:18: [address] in foo
+        \\fn foo() !void { return error.Foo; }
+        \\                 ^
+        \\source.zig:5:5: [address] in expectError
+        \\    return error.TestExpectedError;
+        \\    ^
+        \\source.zig:17:5: [address] in main
+        \\    try expectError(error.Bar, foo());
+        \\    ^
+        ,
+        .disable_trace_optimized = &.{
+            .{ .x86_64, .windows },
+            .{ .x86, .windows },
+        },
+    });
+
+    cases.addCase(.{
+        .name = "try return from within catch",
+        .source =
+        \\fn foo() !void {
+        \\    return error.TheSkyIsFalling;
+        \\}
+        \\
+        \\fn bar() !void {
+        \\    return error.AndMyCarIsOutOfGas;
+        \\}
+        \\
+        \\pub fn main() !void {
+        \\    foo() catch { // error trace should include foo()
+        \\        try bar();
+        \\    };
+        \\}
+        ,
+        .expect_error = "AndMyCarIsOutOfGas",
+        .expect_trace =
+        \\source.zig:2:5: [address] in foo
+        \\    return error.TheSkyIsFalling;
+        \\    ^
+        \\source.zig:6:5: [address] in bar
+        \\    return error.AndMyCarIsOutOfGas;
+        \\    ^
+        \\source.zig:11:9: [address] in main
+        \\        try bar();
+        \\        ^
+        ,
+        .disable_trace_optimized = &.{
+            .{ .x86_64, .windows },
+            .{ .x86, .windows },
+        },
+    });
+
+    cases.addCase(.{
+        .name = "try return from within if-else",
+        .source =
+        \\fn foo() !void {
+        \\    return error.TheSkyIsFalling;
+        \\}
+        \\
+        \\fn bar() !void {
+        \\    return error.AndMyCarIsOutOfGas;
+        \\}
+        \\
+        \\pub fn main() !void {
+        \\    if (foo()) |_| {} else |_| { // error trace should include foo()
+        \\        try bar();
+        \\    }
+        \\}
+        ,
+        .expect_error = "AndMyCarIsOutOfGas",
+        .expect_trace =
+        \\source.zig:2:5: [address] in foo
+        \\    return error.TheSkyIsFalling;
+        \\    ^
+        \\source.zig:6:5: [address] in bar
+        \\    return error.AndMyCarIsOutOfGas;
+        \\    ^
+        \\source.zig:11:9: [address] in main
+        \\        try bar();
+        \\        ^
+        ,
+        .disable_trace_optimized = &.{
+            .{ .x86_64, .windows },
+            .{ .x86, .windows },
+        },
+    });
+
+    cases.addCase(.{
+        .name = "try try return return",
+        .source =
+        \\fn foo() !void {
+        \\    try bar();
+        \\}
+        \\
+        \\fn bar() !void {
+        \\    return make_error();
+        \\}
+        \\
+        \\fn make_error() !void {
+        \\    return error.TheSkyIsFalling;
+        \\}
+        \\
+        \\pub fn main() !void {
+        \\    try foo();
+        \\}
+        ,
+        .expect_error = "TheSkyIsFalling",
+        .expect_trace =
+        \\source.zig:10:5: [address] in make_error
+        \\    return error.TheSkyIsFalling;
+        \\    ^
+        \\source.zig:6:5: [address] in bar
+        \\    return make_error();
+        \\    ^
+        \\source.zig:2:5: [address] in foo
+        \\    try bar();
+        \\    ^
+        \\source.zig:14:5: [address] in main
+        \\    try foo();
+        \\    ^
+        ,
+        .disable_trace_optimized = &.{
+            .{ .x86_64, .windows },
+            .{ .x86, .windows },
+        },
+    });
+
+    cases.addCase(.{
+        .name = "error union switch with call operand",
+        .source =
+        \\pub fn main() !void {
+        \\    try foo();
+        \\    return error.TheSkyIsFalling;
+        \\}
+        \\
+        \\noinline fn failure() error{ Fatal, NonFatal }!void {
+        \\    return error.NonFatal;
+        \\}
+        \\
+        \\fn foo() error{Fatal}!void {
+        \\    return failure() catch |err| switch (err) {
+        \\        error.Fatal => return error.Fatal,
+        \\        error.NonFatal => return,
+        \\    };
+        \\}
+        ,
+        .expect_error = "TheSkyIsFalling",
+        .expect_trace =
+        \\source.zig:3:5: [address] in main
+        \\    return error.TheSkyIsFalling;
+        \\    ^
+        ,
+        .disable_trace_optimized = &.{
+            .{ .x86_64, .linux },
+            .{ .x86, .linux },
+            .{ .x86_64, .windows },
+            .{ .x86, .windows },
+        },
+    });
+}
test/stack_traces.zig
@@ -1,878 +1,224 @@
-const std = @import("std");
-const os = std.os;
-const tests = @import("tests.zig");
-
-pub fn addCases(cases: *tests.StackTracesContext) void {
+pub fn addCases(cases: *@import("tests.zig").StackTracesContext) void {
     cases.addCase(.{
-        .name = "return",
+        .name = "simple panic",
         .source =
-        \\pub fn main() !void {
-        \\    return error.TheSkyIsFalling;
+        \\pub fn main() void {
+        \\    foo();
         \\}
-        ,
-        .Debug = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\source.zig:2:5: [address] in main ([main_file])
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-                .linux, // defeated by aggressive inlining
-            },
-            .expect =
-            \\error: TheSkyIsFalling
-            \\source.zig:2:5: [address] in [function]
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\
-            ,
-        },
-    });
-
-    cases.addCase(.{
-        .name = "try return",
-        .source =
-        \\fn foo() !void {
-        \\    return error.TheSkyIsFalling;
+        \\fn foo() void {
+        \\    @panic("oh no");
         \\}
         \\
-        \\pub fn main() !void {
-        \\    try foo();
-        \\}
         ,
-        .Debug = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\source.zig:2:5: [address] in foo ([main_file])
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\source.zig:6:5: [address] in main ([main_file])
-            \\    try foo();
-            \\    ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-            },
-            .expect =
-            \\error: TheSkyIsFalling
-            \\source.zig:2:5: [address] in [function]
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\source.zig:6:5: [address] in [function]
-            \\    try foo();
-            \\    ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\
-            ,
-        },
-    });
-    cases.addCase(.{
-        .name = "non-error return pops error trace",
-        .source =
-        \\fn bar() !void {
-        \\    return error.UhOh;
-        \\}
+        .unwind = .any,
+        .expect_panic = true,
+        .expect =
+        \\panic: oh no
+        \\source.zig:5:5: [address] in foo
+        \\    @panic("oh no");
+        \\    ^
+        \\source.zig:2:8: [address] in main
+        \\    foo();
+        \\       ^
         \\
-        \\fn foo() !void {
-        \\    bar() catch {
-        \\        return; // non-error result: success
-        \\    };
-        \\}
+        ,
+        .expect_strip =
+        \\panic: oh no
+        \\???:?:?: [address] in source.foo
+        \\???:?:?: [address] in source.main
         \\
-        \\pub fn main() !void {
-        \\    try foo();
-        \\    return error.UnrelatedError;
-        \\}
         ,
-        .Debug = .{
-            .expect =
-            \\error: UnrelatedError
-            \\source.zig:13:5: [address] in main ([main_file])
-            \\    return error.UnrelatedError;
-            \\    ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-                .linux, // defeated by aggressive inlining
-            },
-            .expect =
-            \\error: UnrelatedError
-            \\source.zig:13:5: [address] in [function]
-            \\    return error.UnrelatedError;
-            \\    ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: UnrelatedError
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: UnrelatedError
-            \\
-            ,
-        },
     });
 
     cases.addCase(.{
-        .name = "continue in while loop",
+        .name = "simple panic with no unwind strategy",
         .source =
-        \\fn foo() !void {
-        \\    return error.UhOh;
+        \\pub fn main() void {
+        \\    foo();
         \\}
-        \\
-        \\pub fn main() !void {
-        \\    var i: usize = 0;
-        \\    while (i < 3) : (i += 1) {
-        \\        foo() catch continue;
-        \\    }
-        \\    return error.UnrelatedError;
+        \\fn foo() void {
+        \\    @panic("oh no");
         \\}
+        \\
         ,
-        .Debug = .{
-            .expect =
-            \\error: UnrelatedError
-            \\source.zig:10:5: [address] in main ([main_file])
-            \\    return error.UnrelatedError;
-            \\    ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-                .linux, // defeated by aggressive inlining
-            },
-            .expect =
-            \\error: UnrelatedError
-            \\source.zig:10:5: [address] in [function]
-            \\    return error.UnrelatedError;
-            \\    ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: UnrelatedError
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: UnrelatedError
-            \\
-            ,
-        },
+        .unwind = .none,
+        .expect_panic = true,
+        .expect = "panic: oh no",
+        .expect_strip = "panic: oh no",
     });
 
     cases.addCase(.{
-        .name = "try return + handled catch/if-else",
+        .name = "dump current trace",
         .source =
-        \\fn foo() !void {
-        \\    return error.TheSkyIsFalling;
+        \\pub fn main() void {
+        \\    foo(bar());
         \\}
-        \\
-        \\pub fn main() !void {
-        \\    foo() catch {}; // should not affect error trace
-        \\    if (foo()) |_| {} else |_| {
-        \\        // should also not affect error trace
-        \\    }
-        \\    try foo();
+        \\fn bar() void {
+        \\    qux(123);
         \\}
+        \\fn foo(_: void) void {}
+        \\fn qux(x: u32) void {
+        \\    std.debug.dumpCurrentStackTrace(.{});
+        \\    _ = x;
+        \\}
+        \\const std = @import("std");
+        \\
         ,
-        .Debug = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\source.zig:2:5: [address] in foo ([main_file])
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\source.zig:10:5: [address] in main ([main_file])
-            \\    try foo();
-            \\    ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-                .linux, // defeated by aggressive inlining
-            },
-            .expect =
-            \\error: TheSkyIsFalling
-            \\source.zig:2:5: [address] in [function]
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\source.zig:10:5: [address] in [function]
-            \\    try foo();
-            \\    ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\
-            ,
-        },
-    });
-
-    cases.addCase(.{
-        .name = "break from inline loop pops error return trace",
-        .source =
-        \\fn foo() !void { return error.FooBar; }
+        .unwind = .safe,
+        .expect_panic = false,
+        .expect =
+        \\source.zig:9:36: [address] in qux
+        \\    std.debug.dumpCurrentStackTrace(.{});
+        \\                                   ^
+        \\source.zig:5:8: [address] in bar
+        \\    qux(123);
+        \\       ^
+        \\source.zig:2:12: [address] in main
+        \\    foo(bar());
+        \\           ^
         \\
-        \\pub fn main() !void {
-        \\    comptime var i: usize = 0;
-        \\    b: inline while (i < 5) : (i += 1) {
-        \\        foo() catch {
-        \\            break :b; // non-error break, success
-        \\        };
-        \\    }
-        \\    // foo() was successfully handled, should not appear in trace
+        ,
+        .expect_strip =
+        \\???:?:?: [address] in source.qux
+        \\???:?:?: [address] in source.bar
+        \\???:?:?: [address] in source.main
         \\
-        \\    return error.BadTime;
-        \\}
         ,
-        .Debug = .{
-            .expect =
-            \\error: BadTime
-            \\source.zig:12:5: [address] in main ([main_file])
-            \\    return error.BadTime;
-            \\    ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-                .linux, // defeated by aggressive inlining
-            },
-            .expect =
-            \\error: BadTime
-            \\source.zig:12:5: [address] in [function]
-            \\    return error.BadTime;
-            \\    ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: BadTime
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: BadTime
-            \\
-            ,
-        },
     });
 
     cases.addCase(.{
-        .name = "catch and re-throw error",
+        .name = "dump current trace with no unwind strategy",
         .source =
-        \\fn foo() !void {
-        \\    return error.TheSkyIsFalling;
+        \\pub fn main() void {
+        \\    foo(bar());
         \\}
-        \\
-        \\pub fn main() !void {
-        \\    return foo() catch error.AndMyCarIsOutOfGas;
+        \\fn bar() void {
+        \\    qux(123);
         \\}
-        ,
-        .Debug = .{
-            .expect =
-            \\error: AndMyCarIsOutOfGas
-            \\source.zig:2:5: [address] in foo ([main_file])
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\source.zig:6:5: [address] in main ([main_file])
-            \\    return foo() catch error.AndMyCarIsOutOfGas;
-            \\    ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-                .linux, // defeated by aggressive inlining
-            },
-            .expect =
-            \\error: AndMyCarIsOutOfGas
-            \\source.zig:2:5: [address] in [function]
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\source.zig:6:5: [address] in [function]
-            \\    return foo() catch error.AndMyCarIsOutOfGas;
-            \\    ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: AndMyCarIsOutOfGas
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: AndMyCarIsOutOfGas
-            \\
-            ,
-        },
-    });
-
-    cases.addCase(.{
-        .name = "errors stored in var do not contribute to error trace",
-        .source =
-        \\fn foo() !void {
-        \\    return error.TheSkyIsFalling;
+        \\fn foo(_: void) void {}
+        \\fn qux(x: u32) void {
+        \\    std.debug.print("pre\n", .{});
+        \\    std.debug.dumpCurrentStackTrace(.{});
+        \\    std.debug.print("post\n", .{});
+        \\    _ = x;
         \\}
+        \\const std = @import("std");
         \\
-        \\pub fn main() !void {
-        \\    // Once an error is stored in a variable, it is popped from the trace
-        \\    var x = foo();
-        \\    x = {};
-        \\
-        \\    // As a result, this error trace will still be clean
-        \\    return error.SomethingUnrelatedWentWrong;
-        \\}
         ,
-        .Debug = .{
-            .expect =
-            \\error: SomethingUnrelatedWentWrong
-            \\source.zig:11:5: [address] in main ([main_file])
-            \\    return error.SomethingUnrelatedWentWrong;
-            \\    ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-                .linux, // defeated by aggressive inlining
-            },
-            .expect =
-            \\error: SomethingUnrelatedWentWrong
-            \\source.zig:11:5: [address] in [function]
-            \\    return error.SomethingUnrelatedWentWrong;
-            \\    ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: SomethingUnrelatedWentWrong
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: SomethingUnrelatedWentWrong
-            \\
-            ,
-        },
+        .unwind = .no_safe,
+        .expect_panic = false,
+        .expect = "pre\npost\n",
+        .expect_strip = "pre\npost\n",
     });
 
     cases.addCase(.{
-        .name = "error stored in const has trace preserved for duration of block",
+        .name = "dump captured trace",
         .source =
-        \\fn foo() !void { return error.TheSkyIsFalling; }
-        \\fn bar() !void { return error.InternalError; }
-        \\fn baz() !void { return error.UnexpectedReality; }
-        \\
-        \\pub fn main() !void {
-        \\    const x = foo();
-        \\    const y = b: {
-        \\        if (true)
-        \\            break :b bar();
-        \\
-        \\        break :b {};
-        \\    };
-        \\    x catch {};
-        \\    y catch {};
-        \\    // foo()/bar() error traces not popped until end of block
-        \\
-        \\    {
-        \\        const z = baz();
-        \\        z catch {};
-        \\        // baz() error trace still alive here
-        \\    }
-        \\    // baz() error trace popped, foo(), bar() still alive
-        \\    return error.StillUnresolved;
+        \\pub fn main() void {
+        \\    var stack_trace_buf: [8]usize = undefined;
+        \\    dumpIt(&captureIt(&stack_trace_buf));
         \\}
-        ,
-        .Debug = .{
-            .expect =
-            \\error: StillUnresolved
-            \\source.zig:1:18: [address] in foo ([main_file])
-            \\fn foo() !void { return error.TheSkyIsFalling; }
-            \\                 ^
-            \\source.zig:2:18: [address] in bar ([main_file])
-            \\fn bar() !void { return error.InternalError; }
-            \\                 ^
-            \\source.zig:23:5: [address] in main ([main_file])
-            \\    return error.StillUnresolved;
-            \\    ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-                .linux, // defeated by aggressive inlining
-            },
-            .expect =
-            \\error: StillUnresolved
-            \\source.zig:1:18: [address] in [function]
-            \\fn foo() !void { return error.TheSkyIsFalling; }
-            \\                 ^
-            \\source.zig:2:18: [address] in [function]
-            \\fn bar() !void { return error.InternalError; }
-            \\                 ^
-            \\source.zig:23:5: [address] in [function]
-            \\    return error.StillUnresolved;
-            \\    ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: StillUnresolved
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: StillUnresolved
-            \\
-            ,
-        },
-    });
-
-    cases.addCase(.{
-        .name = "error passed to function has its trace preserved for duration of the call",
-        .source =
-        \\pub fn expectError(expected_error: anyerror, actual_error: anyerror!void) !void {
-        \\    actual_error catch |err| {
-        \\        if (err == expected_error) return {};
-        \\    };
-        \\    return error.TestExpectedError;
+        \\fn captureIt(buf: []usize) std.builtin.StackTrace {
+        \\    return captureItInner(buf);
         \\}
+        \\fn dumpIt(st: *const std.builtin.StackTrace) void {
+        \\    std.debug.dumpStackTrace(st);
+        \\}
+        \\fn captureItInner(buf: []usize) std.builtin.StackTrace {
+        \\    return std.debug.captureCurrentStackTrace(.{}, buf);
+        \\}
+        \\const std = @import("std");
         \\
-        \\fn alwaysErrors() !void { return error.ThisErrorShouldNotAppearInAnyTrace; }
-        \\fn foo() !void { return error.Foo; }
+        ,
+        .unwind = .safe,
+        .expect_panic = false,
+        .expect =
+        \\source.zig:12:46: [address] in captureItInner
+        \\    return std.debug.captureCurrentStackTrace(.{}, buf);
+        \\                                             ^
+        \\source.zig:6:26: [address] in captureIt
+        \\    return captureItInner(buf);
+        \\                         ^
+        \\source.zig:3:22: [address] in main
+        \\    dumpIt(&captureIt(&stack_trace_buf));
+        \\                     ^
         \\
-        \\pub fn main() !void {
-        \\    try expectError(error.ThisErrorShouldNotAppearInAnyTrace, alwaysErrors());
-        \\    try expectError(error.ThisErrorShouldNotAppearInAnyTrace, alwaysErrors());
-        \\    try expectError(error.Foo, foo());
+        ,
+        .expect_strip =
+        \\???:?:?: [address] in source.captureItInner
+        \\???:?:?: [address] in source.captureIt
+        \\???:?:?: [address] in source.main
         \\
-        \\    // Only the error trace for this failing check should appear:
-        \\    try expectError(error.Bar, foo());
-        \\}
         ,
-        .Debug = .{
-            .expect =
-            \\error: TestExpectedError
-            \\source.zig:9:18: [address] in foo ([main_file])
-            \\fn foo() !void { return error.Foo; }
-            \\                 ^
-            \\source.zig:5:5: [address] in expectError ([main_file])
-            \\    return error.TestExpectedError;
-            \\    ^
-            \\source.zig:17:5: [address] in main ([main_file])
-            \\    try expectError(error.Bar, foo());
-            \\    ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-            },
-            .expect =
-            \\error: TestExpectedError
-            \\source.zig:9:18: [address] in [function]
-            \\fn foo() !void { return error.Foo; }
-            \\                 ^
-            \\source.zig:5:5: [address] in [function]
-            \\    return error.TestExpectedError;
-            \\    ^
-            \\source.zig:17:5: [address] in [function]
-            \\    try expectError(error.Bar, foo());
-            \\    ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: TestExpectedError
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: TestExpectedError
-            \\
-            ,
-        },
     });
 
     cases.addCase(.{
-        .name = "try return from within catch",
+        .name = "dump captured trace with no unwind strategy",
         .source =
-        \\fn foo() !void {
-        \\    return error.TheSkyIsFalling;
+        \\pub fn main() void {
+        \\    var stack_trace_buf: [8]usize = undefined;
+        \\    dumpIt(&captureIt(&stack_trace_buf));
         \\}
-        \\
-        \\fn bar() !void {
-        \\    return error.AndMyCarIsOutOfGas;
+        \\fn captureIt(buf: []usize) std.builtin.StackTrace {
+        \\    return captureItInner(buf);
         \\}
-        \\
-        \\pub fn main() !void {
-        \\    foo() catch { // error trace should include foo()
-        \\        try bar();
-        \\    };
+        \\fn dumpIt(st: *const std.builtin.StackTrace) void {
+        \\    std.debug.dumpStackTrace(st);
         \\}
-        ,
-        .Debug = .{
-            .expect =
-            \\error: AndMyCarIsOutOfGas
-            \\source.zig:2:5: [address] in foo ([main_file])
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\source.zig:6:5: [address] in bar ([main_file])
-            \\    return error.AndMyCarIsOutOfGas;
-            \\    ^
-            \\source.zig:11:9: [address] in main ([main_file])
-            \\        try bar();
-            \\        ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-            },
-            .expect =
-            \\error: AndMyCarIsOutOfGas
-            \\source.zig:2:5: [address] in [function]
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\source.zig:6:5: [address] in [function]
-            \\    return error.AndMyCarIsOutOfGas;
-            \\    ^
-            \\source.zig:11:9: [address] in [function]
-            \\        try bar();
-            \\        ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: AndMyCarIsOutOfGas
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: AndMyCarIsOutOfGas
-            \\
-            ,
-        },
-    });
-
-    cases.addCase(.{
-        .name = "try return from within if-else",
-        .source =
-        \\fn foo() !void {
-        \\    return error.TheSkyIsFalling;
-        \\}
-        \\
-        \\fn bar() !void {
-        \\    return error.AndMyCarIsOutOfGas;
+        \\fn captureItInner(buf: []usize) std.builtin.StackTrace {
+        \\    return std.debug.captureCurrentStackTrace(.{}, buf);
         \\}
+        \\const std = @import("std");
         \\
-        \\pub fn main() !void {
-        \\    if (foo()) |_| {} else |_| { // error trace should include foo()
-        \\        try bar();
-        \\    }
-        \\}
         ,
-        .Debug = .{
-            .expect =
-            \\error: AndMyCarIsOutOfGas
-            \\source.zig:2:5: [address] in foo ([main_file])
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\source.zig:6:5: [address] in bar ([main_file])
-            \\    return error.AndMyCarIsOutOfGas;
-            \\    ^
-            \\source.zig:11:9: [address] in main ([main_file])
-            \\        try bar();
-            \\        ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-            },
-            .expect =
-            \\error: AndMyCarIsOutOfGas
-            \\source.zig:2:5: [address] in [function]
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\source.zig:6:5: [address] in [function]
-            \\    return error.AndMyCarIsOutOfGas;
-            \\    ^
-            \\source.zig:11:9: [address] in [function]
-            \\        try bar();
-            \\        ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: AndMyCarIsOutOfGas
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: AndMyCarIsOutOfGas
-            \\
-            ,
-        },
+        .unwind = .no_safe,
+        .expect_panic = false,
+        .expect = "(empty stack trace)\n",
+        .expect_strip = "(empty stack trace)\n",
     });
 
     cases.addCase(.{
-        .name = "try try return return",
+        .name = "dump captured trace on thread",
         .source =
-        \\fn foo() !void {
-        \\    try bar();
+        \\pub fn main() !void {
+        \\    var stack_trace_buf: [8]usize = undefined;
+        \\    const t = try std.Thread.spawn(.{}, threadMain, .{&stack_trace_buf});
+        \\    t.join();
         \\}
-        \\
-        \\fn bar() !void {
-        \\    return make_error();
+        \\fn threadMain(stack_trace_buf: []usize) void {
+        \\    dumpIt(&captureIt(stack_trace_buf));
         \\}
-        \\
-        \\fn make_error() !void {
-        \\    return error.TheSkyIsFalling;
+        \\fn captureIt(buf: []usize) std.builtin.StackTrace {
+        \\    return captureItInner(buf);
         \\}
-        \\
-        \\pub fn main() !void {
-        \\    try foo();
+        \\fn dumpIt(st: *const std.builtin.StackTrace) void {
+        \\    std.debug.dumpStackTrace(st);
+        \\}
+        \\fn captureItInner(buf: []usize) std.builtin.StackTrace {
+        \\    return std.debug.captureCurrentStackTrace(.{}, buf);
         \\}
-        ,
-        .Debug = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\source.zig:10:5: [address] in make_error ([main_file])
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\source.zig:6:5: [address] in bar ([main_file])
-            \\    return make_error();
-            \\    ^
-            \\source.zig:2:5: [address] in foo ([main_file])
-            \\    try bar();
-            \\    ^
-            \\source.zig:14:5: [address] in main ([main_file])
-            \\    try foo();
-            \\    ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .windows, // TODO
-            },
-            .expect =
-            \\error: TheSkyIsFalling
-            \\source.zig:10:5: [address] in [function]
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\source.zig:6:5: [address] in [function]
-            \\    return make_error();
-            \\    ^
-            \\source.zig:2:5: [address] in [function]
-            \\    try bar();
-            \\    ^
-            \\source.zig:14:5: [address] in [function]
-            \\    try foo();
-            \\    ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\
-            ,
-        },
-    });
-
-    cases.addCase(.{
-        .name = "dumpCurrentStackTrace",
-        .source =
         \\const std = @import("std");
         \\
-        \\fn bar() void {
-        \\    std.debug.dumpCurrentStackTrace(@returnAddress());
-        \\}
-        \\fn foo() void {
-        \\    bar();
-        \\}
-        \\pub fn main() u8 {
-        \\    foo();
-        \\    return 1;
-        \\}
         ,
-        .Debug = .{
-            // std.debug.sys_can_stack_trace
-            .exclude_arch = &.{
-                .loongarch32,
-                .loongarch64,
-                .mips,
-                .mipsel,
-                .mips64,
-                .mips64el,
-                .s390x,
-            },
-            .exclude_os = &.{
-                .freebsd,
-                .openbsd, // integer overflow
-                .windows, // TODO intermittent failures
-            },
-            .expect =
-            \\source.zig:7:8: [address] in foo ([main_file])
-            \\    bar();
-            \\       ^
-            \\source.zig:10:8: [address] in main ([main_file])
-            \\    foo();
-            \\       ^
-            \\
-            ,
-        },
-    });
-    cases.addCase(.{
-        .name = "error union switch with call operand",
-        .source =
-        \\pub fn main() !void {
-        \\    try foo();
-        \\    return error.TheSkyIsFalling;
-        \\}
+        .unwind = .safe,
+        .expect_panic = false,
+        .expect =
+        \\source.zig:16:46: [address] in captureItInner
+        \\    return std.debug.captureCurrentStackTrace(.{}, buf);
+        \\                                             ^
+        \\source.zig:10:26: [address] in captureIt
+        \\    return captureItInner(buf);
+        \\                         ^
+        \\source.zig:7:22: [address] in threadMain
+        \\    dumpIt(&captureIt(stack_trace_buf));
+        \\                     ^
         \\
-        \\noinline fn failure() error{ Fatal, NonFatal }!void {
-        \\    return error.NonFatal;
-        \\}
+        ,
+        .expect_strip =
+        \\???:?:?: [address] in source.captureItInner
+        \\???:?:?: [address] in source.captureIt
+        \\???:?:?: [address] in source.threadMain
         \\
-        \\fn foo() error{Fatal}!void {
-        \\    return failure() catch |err| switch (err) {
-        \\        error.Fatal => return error.Fatal,
-        \\        error.NonFatal => return,
-        \\    };
-        \\}
         ,
-        .Debug = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\source.zig:3:5: [address] in main ([main_file])
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\
-            ,
-        },
-        .ReleaseSafe = .{
-            .exclude_os = &.{
-                .freebsd,
-                .windows, // TODO
-                .linux, // defeated by aggressive inlining
-                .macos, // Broken in LLVM 20.
-            },
-            .expect =
-            \\error: TheSkyIsFalling
-            \\source.zig:3:5: [address] in [function]
-            \\    return error.TheSkyIsFalling;
-            \\    ^
-            \\
-            ,
-            .error_tracing = true,
-        },
-        .ReleaseFast = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\
-            ,
-        },
-        .ReleaseSmall = .{
-            .expect =
-            \\error: TheSkyIsFalling
-            \\
-            ,
-        },
     });
 }
test/tests.zig
@@ -6,11 +6,13 @@ const OptimizeMode = std.builtin.OptimizeMode;
 const Step = std.Build.Step;
 
 // Cases
+const error_traces = @import("error_traces.zig");
 const stack_traces = @import("stack_traces.zig");
 const llvm_ir = @import("llvm_ir.zig");
 const libc = @import("libc.zig");
 
 // Implementations
+pub const ErrorTracesContext = @import("src/ErrorTrace.zig");
 pub const StackTracesContext = @import("src/StackTrace.zig");
 pub const DebuggerContext = @import("src/Debugger.zig");
 pub const LlvmIrContext = @import("src/LlvmIr.zig");
@@ -1857,28 +1859,53 @@ const c_abi_targets = blk: {
     };
 };
 
+/// For stack trace tests, we only test native, because external executors are pretty unreliable at
+/// stack tracing. However, if there's a 32-bit equivalent target which the host can trivially run,
+/// we may as well at least test that!
+fn nativeAndCompatible32bit(b: *std.Build, skip_non_native: bool) []const std.Build.ResolvedTarget {
+    const host = b.graph.host.result;
+    const only_native = (&b.graph.host)[0..1];
+    if (skip_non_native) return only_native;
+    const arch32: std.Target.Cpu.Arch = switch (host.cpu.arch) {
+        .x86_64 => .x86,
+        .aarch64 => .arm,
+        .aarch64_be => .armeb,
+        else => return only_native,
+    };
+    switch (host.os.tag) {
+        .windows => if (arch32.isArm()) return only_native,
+        .macos, .freebsd => if (arch32 == .x86) return only_native,
+        .linux, .netbsd => {},
+        else => return only_native,
+    }
+    return b.graph.arena.dupe(std.Build.ResolvedTarget, &.{
+        b.graph.host,
+        b.resolveTargetQuery(.{ .cpu_arch = arch32, .os_tag = host.os.tag }),
+    }) catch @panic("OOM");
+}
+
 pub fn addStackTraceTests(
     b: *std.Build,
     test_filters: []const []const u8,
-    optimize_modes: []const OptimizeMode,
+    skip_non_native: bool,
 ) *Step {
-    const check_exe = b.addExecutable(.{
-        .name = "check-stack-trace",
+    const convert_exe = b.addExecutable(.{
+        .name = "convert-stack-trace",
         .root_module = b.createModule(.{
-            .root_source_file = b.path("test/src/check-stack-trace.zig"),
+            .root_source_file = b.path("test/src/convert-stack-trace.zig"),
             .target = b.graph.host,
             .optimize = .Debug,
         }),
     });
 
     const cases = b.allocator.create(StackTracesContext) catch @panic("OOM");
+
     cases.* = .{
         .b = b,
         .step = b.step("test-stack-traces", "Run the stack trace tests"),
-        .test_index = 0,
         .test_filters = test_filters,
-        .optimize_modes = optimize_modes,
-        .check_exe = check_exe,
+        .targets = nativeAndCompatible32bit(b, skip_non_native),
+        .convert_exe = convert_exe,
     };
 
     stack_traces.addCases(cases);
@@ -1886,6 +1913,36 @@ pub fn addStackTraceTests(
     return cases.step;
 }
 
+pub fn addErrorTraceTests(
+    b: *std.Build,
+    test_filters: []const []const u8,
+    optimize_modes: []const OptimizeMode,
+    skip_non_native: bool,
+) *Step {
+    const convert_exe = b.addExecutable(.{
+        .name = "convert-stack-trace",
+        .root_module = b.createModule(.{
+            .root_source_file = b.path("test/src/convert-stack-trace.zig"),
+            .target = b.graph.host,
+            .optimize = .Debug,
+        }),
+    });
+
+    const cases = b.allocator.create(ErrorTracesContext) catch @panic("OOM");
+    cases.* = .{
+        .b = b,
+        .step = b.step("test-error-traces", "Run the error trace tests"),
+        .test_filters = test_filters,
+        .targets = nativeAndCompatible32bit(b, skip_non_native),
+        .optimize_modes = optimize_modes,
+        .convert_exe = convert_exe,
+    };
+
+    error_traces.addCases(cases);
+
+    return cases.step;
+}
+
 fn compilerHasPackageManager(b: *std.Build) bool {
     // We can only use dependencies if the compiler was built with support for package management.
     // (zig2 doesn't support it, but we still need to construct a build graph to build stage3.)
build.zig
@@ -563,7 +563,8 @@ pub fn build(b: *std.Build) !void {
         .skip_release = skip_release,
     }));
     test_step.dependOn(tests.addLinkTests(b, enable_macos_sdk, enable_ios_sdk, enable_symlinks_windows));
-    test_step.dependOn(tests.addStackTraceTests(b, test_filters, optimization_modes));
+    test_step.dependOn(tests.addStackTraceTests(b, test_filters, skip_non_native));
+    test_step.dependOn(tests.addErrorTraceTests(b, test_filters, optimization_modes, skip_non_native));
     test_step.dependOn(tests.addCliTests(b));
     if (tests.addDebuggerTests(b, .{
         .test_filters = test_filters,