From 31e4588e9950c9ca8c599d2d49ee55328c22eb1b Mon Sep 17 00:00:00 2001 From: foxnne Date: Sat, 10 Oct 2026 13:33:58 -0500 Subject: [PATCH] app: a crash report says whose code crashed fizzy's panic handler, std's segfault handler (now on in every build mode) and a SIGABRT/SIGTRAP handler write one report per crash to stderr and /crashes/: who crashed (fizzy or which plugin), the loaded modules with their build ids, and each frame as module+offset. Plugin dylibs enter a fixed module table before their register runs and leave it before dlclose; ranges and build ids are read from the mapped headers at load. ELF builds now carry a build id. Part of plans/RESTART_AND_CRASHES_PLAN.md, step 3. Co-Authored-By: Claude Opus 5.5 --- app/crash/crash.zig | 356 +++++++++++++++++++++++++++++++++++++ app/crash/image.zig | 179 +++++++++++++++++++ app/root.zig | 4 + app/store/PluginLoader.zig | 7 + build/app.zig | 14 ++ build/exe.zig | 3 + build/sdk.zig | 11 ++ sdk/plugin_sdk.zig | 3 + src/Entry.zig | 15 +- src/editor/Editor.zig | 2 + 10 files changed, 593 insertions(+), 1 deletion(-) create mode 100644 app/crash/crash.zig create mode 100644 app/crash/image.zig diff --git a/app/crash/crash.zig b/app/crash/crash.zig new file mode 100644 index 000000000..84949fdce --- /dev/null +++ b/app/crash/crash.zig @@ -0,0 +1,356 @@ +//! Crash reports: when the app panics or faults, a report saying whose code it was. +//! +//! Every image the app runs code from is in a fixed table of modules: the executable (with the +//! plugins linked into it) from `init`, and each plugin dylib from its load to its unload. A +//! crash walks the stack, and each frame becomes a module and an offset into it; the innermost +//! frame in a known module names who crashed. The report goes to stderr and to a file in +//! `/crashes/`, named when the run starts, so writing it allocates nothing and opens one +//! file. +//! +//! What reaches it: +//! - a panic in the app's own code (`panic`, the root file's panic handler); +//! - a fault anywhere in the process (`handleSegfault`, std's segfault handler, which the app +//! switches on in every build mode): SIGSEGV, SIGBUS, SIGILL, SIGFPE, or an access violation; +//! - an abort or trap anywhere (SIGABRT, SIGTRAP; POSIX only). A plugin's panic lands here: its +//! dylib has its own std, whose panic handler prints the message and aborts. The message then +//! reaches stderr but not the report; carrying it is the dylib entry's `panic` forwarding to +//! the host's `panic` through an exported symbol, a follow-up. +//! +//! After the report, each hands on to what would have run without it: std's own handler, which +//! prints the symbolized stack trace, or the signal's default action. +//! +//! Std-only, so its tests run on their own (`fizzy-crash-tests`). +const std = @import("std"); +const builtin = @import("builtin"); +pub const image = @import("image.zig"); + +/// Where crash reports can be written: native targets std can catch faults on. +pub const supported = builtin.cpu.arch != .wasm32 and std.debug.have_segfault_handling_support; + +pub const Module = struct { + name_buf: [32]u8 = undefined, + name_len: u8 = 0, + version_buf: [24]u8 = undefined, + version_len: u8 = 0, + image: image.Image, + + pub fn init(name_: []const u8, version_: []const u8, img: image.Image) Module { + var m: Module = .{ .image = img }; + m.name_len = @intCast(@min(name_.len, m.name_buf.len)); + @memcpy(m.name_buf[0..m.name_len], name_[0..m.name_len]); + m.version_len = @intCast(@min(version_.len, m.version_buf.len)); + @memcpy(m.version_buf[0..m.version_len], version_[0..m.version_len]); + return m; + } + + pub fn name(m: *const Module) []const u8 { + return m.name_buf[0..m.name_len]; + } + + pub fn version(m: *const Module) []const u8 { + return m.version_buf[0..m.version_len]; + } +}; + +/// What crashed, as the first line of a report says it. +pub const What = union(enum) { + panic: []const u8, + signal: struct { name: []const u8, address: ?usize = null }, +}; + +/// The run a report describes. Every string outlives the run. +pub const Run = struct { + app: []const u8, + version: []const u8, + sdk: []const u8, +}; + +/// Writes a report: who crashed and how, the run, the modules, and each frame as a module and an +/// offset. `frames` are return addresses, as `std.debug.captureCurrentStackTrace` gives them; +/// each is written less one, so it lands inside the call. `nameOf` names an address no module +/// holds (a system library), when the platform can. +pub fn writeReport( + w: *std.Io.Writer, + run: Run, + what: What, + modules: []const Module, + frames: []const usize, + nameOf: ?*const fn (usize, []u8) ?[]const u8, +) std.Io.Writer.Error!void { + try w.print("{s} crashed", .{run.app}); + for (frames) |ret| if (moduleOf(modules, ret -| 1)) |i| { + try w.print(" in {s} {s}", .{ modules[i].name(), modules[i].version() }); + break; + }; + switch (what) { + .panic => |msg| try w.print(": panic: {s}\n", .{msg}), + .signal => |s| if (s.address) |a| + try w.print(": {s} at address 0x{x}\n", .{ s.name, a }) + else + try w.print(": {s}\n", .{s.name}), + } + try w.print("\n{s} {s}, sdk {s}, {t} {t} {t}\n\nmodules:\n", .{ + run.app, run.version, run.sdk, builtin.os.tag, builtin.cpu.arch, builtin.mode, + }); + for (modules, 0..) |*m, i| { + try w.print(" {d} {s} {s} 0x{x} 0x{x} ", .{ i, m.name(), m.version(), m.image.base, m.image.end - m.image.base }); + if (m.image.buildId().len == 0) try w.writeAll("-") else for (m.image.buildId()) |b| try w.print("{x:0>2}", .{b}); + try w.writeByte('\n'); + } + try w.writeAll("frames:\n"); + var name_buf: [256]u8 = undefined; + for (frames) |ret| { + const pc = ret -| 1; + if (moduleOf(modules, pc)) |i| { + try w.print(" {s}+0x{x}\n", .{ modules[i].name(), pc - modules[i].image.base }); + } else { + try w.print(" 0x{x}", .{pc}); + if (nameOf) |f| if (f(pc, &name_buf)) |n| try w.print(" {s}", .{n}); + try w.writeByte('\n'); + } + } + if (frames.len == 0) try w.writeAll(" (none: the stack could not be walked)\n"); +} + +fn moduleOf(modules: []const Module, address: usize) ?usize { + for (modules, 0..) |*m, i| if (m.image.contains(address)) return i; + return null; +} + +// The table. Only the UI thread adds and removes; a crash on any thread reads. A slot is written +// whole before it is marked used, and marked unused before it can be reused. +const max_modules = 64; +var table: [max_modules]Module = undefined; +var used: [max_modules]std.atomic.Value(bool) = @splat(.init(false)); + +/// Adds the image holding `address` (any code or data in it) as `name` at `version`. +pub fn addModule(name: []const u8, version: []const u8, address: usize) void { + if (!supported) return; + const img = image.containing(address) orelse return; + for (&used, 0..) |*u, i| if (!u.load(.acquire)) { + table[i] = .init(name, version, img); + u.store(true, .release); + return; + }; +} + +/// Removes the image holding `address`, before it is unmapped. +pub fn removeModule(address: usize) void { + if (!supported) return; + for (&used, 0..) |*u, i| if (u.load(.acquire) and table[i].image.contains(address)) u.store(false, .release); +} + +var run_info: ?Run = null; +/// The report's file, once `writeTo` has named it. +var path_buf: [std.fs.max_path_bytes:0]u8 = undefined; +var path_len: usize = 0; +var path_w: if (builtin.os.tag == .windows) [std.fs.max_path_bytes:0]u16 else void = undefined; + +/// Starts reporting for this run: enters the executable as module 0 and catches aborts and +/// traps. Call once, first thing. std's segfault handler is the root's to switch on +/// (`std_options.enable_segfault_handler`) and to route here (`root.debug.handleSegfault`). +/// Until `writeTo`, a report goes to stderr only. +pub fn init(run: Run) void { + if (!supported) return; + run_info = run; + addModule(run.app, run.version, @intFromPtr(&init)); + if (builtin.os.tag != .windows) { + const act: std.posix.Sigaction = .{ + .handler = .{ .sigaction = onAbortOrTrap }, + .mask = std.posix.sigemptyset(), + .flags = std.posix.SA.SIGINFO | std.posix.SA.ONSTACK | std.posix.SA.RESETHAND, + }; + std.posix.sigaction(.ABRT, &act, null); + std.posix.sigaction(.TRAP, &act, null); + } +} + +/// Names this run's report file, `/crashes/-.txt`, creating the folder. +/// Nothing is written there unless the run crashes. +pub fn writeTo(io: std.Io, config_folder: []const u8) void { + if (!supported) return; + const pid = if (builtin.os.tag == .windows) win.GetCurrentProcessId() else std.c.getpid(); + const launched = std.Io.Timestamp.now(io, .real).toMilliseconds(); + const sep = std.fs.path.sep; + const path = std.fmt.bufPrintZ(&path_buf, "{s}{c}crashes{c}{d}-{d}.txt", .{ config_folder, sep, sep, launched, pid }) catch return; + const dir = std.fs.path.dirname(path).?; + std.Io.Dir.cwd().createDirPath(io, dir) catch |err| { + std.log.warn("crash reports: can't create {s}: {t}", .{ dir, err }); + return; + }; + if (builtin.os.tag == .windows) { + const n = std.unicode.wtf8ToWtf16Le(&path_w, path) catch return; + if (n >= path_w.len) return; + path_w[n] = 0; + } + path_len = path.len; +} + +/// The root's panic handler (`std.debug.FullPanic(crash.panic)`): the report, then std's. +pub fn panic(msg: []const u8, first_trace_addr: ?usize) noreturn { + @branchHint(.cold); + report(.{ .panic = msg }, .{ .first_address = first_trace_addr orelse @returnAddress() }); + std.debug.defaultPanic(msg, first_trace_addr); +} + +/// The root's `debug.handleSegfault`: the report, then std's. +pub fn handleSegfault(address: ?usize, name: []const u8, ctx: ?std.debug.CpuContextPtr) noreturn { + report(.{ .signal = .{ .name = name, .address = address } }, .{ .context = ctx }); + std.debug.defaultHandleSegfault(address, name, ctx); +} + +fn onAbortOrTrap(sig: std.posix.SIG, _: *const std.posix.siginfo_t, ctx_ptr: ?*anyopaque) callconv(.c) void { + const ctx = std.debug.cpu_context.fromPosixSignalContext(ctx_ptr); + const name = if (sig == .ABRT) "Abort" else "Trace/breakpoint trap"; + report(.{ .signal = .{ .name = name } }, .{ .context = if (ctx) |*c| c else null }); + // RESETHAND put the default action back; raised again, it runs once this handler returns. + _ = std.c.raise(sig); +} + +const Where = struct { first_address: ?usize = null, context: ?std.debug.CpuContextPtr = null }; + +var reported: std.atomic.Value(bool) = .init(false); +var report_buf: [32 * 1024]u8 = undefined; +var frame_buf: [128]usize = undefined; + +/// One report per run: a crash while reporting, or the abort std's panic ends in, adds none. +fn report(what: What, where: Where) void { + if (reported.swap(true, .acq_rel)) return; + const run = run_info orelse return; + const trace = std.debug.captureCurrentStackTrace(.{ + .first_address = where.first_address, + .context = where.context, + .allow_unsafe_unwind = true, + }, &frame_buf); + + var modules: [max_modules]Module = undefined; + var n: usize = 0; + for (&used, 0..) |*u, i| if (u.load(.acquire)) { + modules[n] = table[i]; + n += 1; + }; + + var w: std.Io.Writer = .fixed(&report_buf); + writeReport(&w, run, what, modules[0..n], trace.return_addresses, imageName) catch {}; + const text = w.buffered(); + const path = path_buf[0..path_len]; + if (path_len > 0 and writeFile(text)) { + writeStderr("\n"); + writeStderr(text); + writeStderr("crash report: "); + writeStderr(path); + writeStderr("\n\n"); + } else { + writeStderr("\n"); + writeStderr(text); + writeStderr("\n"); + } +} + +fn writeFile(text: []const u8) bool { + if (builtin.os.tag == .windows) { + const h = win.CreateFileW(&path_w, win.GENERIC_WRITE, 0, null, win.CREATE_ALWAYS, win.FILE_ATTRIBUTE_NORMAL, null); + if (h == win.INVALID_HANDLE_VALUE) return false; + defer _ = win.CloseHandle(h); + return win.writeAll(h, text); + } + const fd = std.c.open(path_buf[0..path_len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(std.c.mode_t, 0o644)); + if (fd < 0) return false; + defer _ = std.c.close(fd); + return posixWriteAll(fd, text); +} + +fn writeStderr(text: []const u8) void { + if (builtin.os.tag == .windows) { + _ = win.writeAll(win.GetStdHandle(win.STD_ERROR_HANDLE) orelse return, text); + } else _ = posixWriteAll(2, text); +} + +fn posixWriteAll(fd: std.c.fd_t, text: []const u8) bool { + var at: usize = 0; + while (at < text.len) { + const n = std.c.write(fd, text[at..].ptr, text.len - at); + if (n <= 0) return false; + at += @intCast(n); + } + return true; +} + +/// The file name of the image holding `address`, for a frame in no known module. +fn imageName(address: usize, buf: []u8) ?[]const u8 { + if (builtin.os.tag == .windows) return null; + var info: Dl_info = undefined; + if (dladdr(@ptrFromInt(address), &info) == 0) return null; + const path = std.mem.span(info.dli_fname orelse return null); + const base = std.fs.path.basename(path); + const n = @min(base.len, buf.len); + @memcpy(buf[0..n], base[0..n]); + return buf[0..n]; +} + +const Dl_info = extern struct { + dli_fname: ?[*:0]const u8, + dli_fbase: ?*anyopaque, + dli_sname: ?[*:0]const u8, + dli_saddr: ?*anyopaque, +}; +extern "c" fn dladdr(address: *const anyopaque, info: *Dl_info) c_int; + +const win = struct { + const HANDLE = *anyopaque; + const INVALID_HANDLE_VALUE: HANDLE = @ptrFromInt(std.math.maxInt(usize)); + const GENERIC_WRITE = 0x40000000; + const CREATE_ALWAYS = 2; + const FILE_ATTRIBUTE_NORMAL = 0x80; + const STD_ERROR_HANDLE: u32 = @bitCast(@as(i32, -12)); + extern "kernel32" fn CreateFileW(name: [*:0]const u16, access: u32, share: u32, security: ?*anyopaque, disposition: u32, flags: u32, template: ?HANDLE) callconv(.winapi) HANDLE; + extern "kernel32" fn WriteFile(h: HANDLE, buf: [*]const u8, len: u32, written: *u32, overlapped: ?*anyopaque) callconv(.winapi) i32; + extern "kernel32" fn CloseHandle(h: HANDLE) callconv(.winapi) i32; + extern "kernel32" fn GetStdHandle(which: u32) callconv(.winapi) ?HANDLE; + extern "kernel32" fn GetCurrentProcessId() callconv(.winapi) u32; + + fn writeAll(h: HANDLE, text: []const u8) bool { + var at: usize = 0; + while (at < text.len) { + var written: u32 = 0; + const chunk: u32 = @intCast(@min(text.len - at, std.math.maxInt(u32))); + if (WriteFile(h, text[at..].ptr, chunk, &written, null) == 0 or written == 0) return false; + at += written; + } + return true; + } +}; + +test { + // The handlers too, so every target's `zig build test` compiles them. + std.testing.refAllDecls(@This()); +} + +test "a report names the innermost frame's module, and writes each frame against its module" { + const app: Module = .init("fizzy", "0.4.2", .{ .base = 0x1000, .end = 0x9000 }); + var pixi: Module = .init("pixi", "0.1.40", .{ .base = 0x20000, .end = 0x28000 }); + pixi.image.build_id_buf[0..2].* = .{ 0xab, 0x01 }; + pixi.image.build_id_len = 2; + var buf: [2048]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + const unknown = struct { + fn f(_: usize, out: []u8) ?[]const u8 { + @memcpy(out[0..9], "libc.so.6"); + return out[0..9]; + } + }.f; + try writeReport(&w, .{ .app = "fizzy", .version = "0.4.2", .sdk = "0.2.22" }, .{ .panic = "index out of bounds" }, &.{ app, pixi }, &.{ 0x500001, 0x20101, 0x1201 }, unknown); + const text = w.buffered(); + try std.testing.expect(std.mem.startsWith(u8, text, "fizzy crashed in pixi 0.1.40: panic: index out of bounds\n")); + try std.testing.expect(std.mem.indexOf(u8, text, " 1 pixi 0.1.40 0x20000 0x8000 ab01\n") != null); + try std.testing.expect(std.mem.indexOf(u8, text, " 0 fizzy 0.4.2 0x1000 0x8000 -\n") != null); + try std.testing.expect(std.mem.endsWith(u8, text, "frames:\n 0x500000 libc.so.6\n pixi+0x100\n fizzy+0x200\n")); +} + +test "a fault with no frames still says what and where" { + var buf: [1024]u8 = undefined; + var w: std.Io.Writer = .fixed(&buf); + try writeReport(&w, .{ .app = "fizzy", .version = "0.4.2", .sdk = "0.2.22" }, .{ .signal = .{ .name = "Segmentation fault", .address = 0x10 } }, &.{}, &.{}, null); + try std.testing.expect(std.mem.startsWith(u8, w.buffered(), "fizzy crashed: Segmentation fault at address 0x10\n")); + try std.testing.expect(std.mem.endsWith(u8, w.buffered(), "frames:\n (none: the stack could not be walked)\n")); +} diff --git a/app/crash/image.zig b/app/crash/image.zig new file mode 100644 index 000000000..f441d5301 --- /dev/null +++ b/app/crash/image.zig @@ -0,0 +1,179 @@ +//! The loaded image (executable or dynamic library) holding an address: where it is mapped and +//! the build id that names the exact binary, read from the mapped headers themselves. +//! +//! The build id is what a crash report's offsets are resolved against: the Mach-O `LC_UUID` +//! (what a `.dSYM` carries), the ELF `NT_GNU_BUILD_ID` note, or a PE image's CodeView record (the +//! PDB's GUID and age). Read once, when the image is loaded, so a crash only compares addresses. +const std = @import("std"); +const builtin = @import("builtin"); + +pub const Image = struct { + /// Where the image is mapped: its header on Mach-O and PE, its lowest loaded segment on ELF. + /// An offset in a report is an address minus this. + base: usize, + /// One past the last mapped byte of its code. + end: usize, + build_id_buf: [20]u8 = @splat(0), + build_id_len: u8 = 0, + + pub fn contains(self: Image, address: usize) bool { + return address >= self.base and address < self.end; + } + + pub fn buildId(self: *const Image) []const u8 { + return self.build_id_buf[0..self.build_id_len]; + } + + fn setBuildId(self: *Image, bytes: []const u8) void { + const n = @min(bytes.len, self.build_id_buf.len); + @memcpy(self.build_id_buf[0..n], bytes[0..n]); + self.build_id_len = @intCast(n); + } +}; + +/// The image `address` lies in, or null where the platform can't say (or it lies in none). +pub fn containing(address: usize) ?Image { + return switch (builtin.os.tag) { + .macos, .ios, .tvos, .visionos, .watchos => macho(address), + .linux, .freebsd, .netbsd, .openbsd, .dragonfly => elf(address), + .windows => pe(address), + else => null, + }; +} + +fn macho(address: usize) ?Image { + const macho_ = std.macho; + const header: *const macho_.mach_header_64 = @ptrCast(@alignCast( + std.c._dyld_get_image_header_containing_address(@ptrFromInt(address)) orelse return null, + )); + var image: Image = .{ .base = @intFromPtr(header), .end = 0 }; + var at: usize = @intFromPtr(header) + @sizeOf(macho_.mach_header_64); + for (0..header.ncmds) |_| { + const lc: *const macho_.load_command = @ptrFromInt(at); + switch (lc.cmd) { + .SEGMENT_64 => { + const seg: *const macho_.segment_command_64 = @ptrFromInt(at); + // __TEXT starts at the header (file offset 0), so its size is the code's extent. + if (std.mem.eql(u8, std.mem.sliceTo(&seg.segname, 0), "__TEXT")) image.end = image.base + seg.vmsize; + }, + .UUID => image.setBuildId(&@as(*const macho_.uuid_command, @ptrFromInt(at)).uuid), + else => {}, + } + at += lc.cmdsize; + } + if (image.end == 0) return null; + return image; +} + +fn elf(address: usize) ?Image { + const Search = struct { + address: usize, + found: ?Image = null, + + fn visit(info: *std.c.dl_phdr_info, _: usize, data: ?*anyopaque) callconv(.c) c_int { + const s: *@This() = @ptrCast(@alignCast(data.?)); + const phdrs = info.phdr[0..info.phnum]; + var lo: usize = std.math.maxInt(usize); + var hi: usize = 0; + var hit = false; + for (phdrs) |p| if (p.type == .LOAD) { + const start = info.addr + p.vaddr; + const end = start + p.memsz; + lo = @min(lo, start); + hi = @max(hi, end); + if (s.address >= start and s.address < end) hit = true; + }; + if (!hit) return 0; + var image: Image = .{ .base = lo, .end = hi }; + for (phdrs) |p| if (p.type == .NOTE) { + if (gnuBuildId(@as([*]const u8, @ptrFromInt(info.addr + p.vaddr))[0..p.memsz])) |id| image.setBuildId(id); + }; + s.found = image; + return 1; + } + }; + var search: Search = .{ .address = address }; + _ = std.c.dl_iterate_phdr(Search.visit, &search); + return search.found; +} + +/// The `NT_GNU_BUILD_ID` note's bytes in a `PT_NOTE` segment, if it holds one. +fn gnuBuildId(notes: []const u8) ?[]const u8 { + var at: usize = 0; + while (at + 12 <= notes.len) { + const namesz = std.mem.readInt(u32, notes[at..][0..4], builtin.cpu.arch.endian()); + const descsz = std.mem.readInt(u32, notes[at + 4 ..][0..4], builtin.cpu.arch.endian()); + const kind = std.mem.readInt(u32, notes[at + 8 ..][0..4], builtin.cpu.arch.endian()); + const name_at = at + 12; + const desc_at = name_at + std.mem.alignForward(usize, namesz, 4); + const next = desc_at + std.mem.alignForward(usize, descsz, 4); + if (next > notes.len) return null; + if (kind == std.elf.NT_GNU_BUILD_ID and namesz == 4 and std.mem.eql(u8, notes[name_at..][0..4], "GNU\x00")) + return notes[desc_at..][0..descsz]; + at = next; + } + return null; +} + +const win = struct { + const GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT = 0x2; + const GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS = 0x4; + extern "kernel32" fn GetModuleHandleExW(flags: u32, name: ?*const anyopaque, module: *?*anyopaque) callconv(.winapi) i32; +}; + +fn pe(address: usize) ?Image { + var module: ?*anyopaque = null; + if (win.GetModuleHandleExW( + win.GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | win.GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + @ptrFromInt(address), + &module, + ) == 0) return null; + const base = @intFromPtr(module orelse return null); + const at = struct { + fn u16_(p: usize) u16 { + return std.mem.readInt(u16, @as(*const [2]u8, @ptrFromInt(p)), .little); + } + fn u32_(p: usize) u32 { + return std.mem.readInt(u32, @as(*const [4]u8, @ptrFromInt(p)), .little); + } + }; + const nt = base + at.u32_(base + 0x3c); // e_lfanew + if (at.u32_(nt) != 0x4550) return null; // "PE\0\0" + const optional = nt + 4 + 20; // past the signature and the COFF file header + if (at.u16_(optional) != 0x20b) return null; // PE32+ + var image: Image = .{ .base = base, .end = base + at.u32_(optional + 56) }; // SizeOfImage + // Data directory 6 is the debug directory; its CodeView entry is "RSDS", GUID, age. + const debug_rva = at.u32_(optional + 112 + 6 * 8); + const debug_size = at.u32_(optional + 112 + 6 * 8 + 4); + var entry: usize = 0; + while (entry + 28 <= debug_size) : (entry += 28) { + const d = base + debug_rva + entry; + if (at.u32_(d + 12) != 2) continue; // IMAGE_DEBUG_TYPE_CODEVIEW + const cv = base + at.u32_(d + 20); + if (at.u32_(cv) == 0x53445352) // "RSDS" + image.setBuildId(@as(*const [20]u8, @ptrFromInt(cv + 4))); + break; + } + return image; +} + +test "the image holding this code contains it, and names its build" { + if (builtin.os.tag != .macos and builtin.os.tag != .linux and builtin.os.tag != .windows) return error.SkipZigTest; + const here = @intFromPtr(&containing); + const image = containing(here) orelse return error.NoImage; + try std.testing.expect(image.contains(here)); + // Zig's Mach-O linker always writes an LC_UUID; ELF and PE only carry an id when asked to. + if (builtin.os.tag == .macos) try std.testing.expectEqual(16, image.buildId().len); +} + +test "a GNU build-id note is found among others" { + const w = struct { + fn u(v: u32) [4]u8 { + return std.mem.toBytes(v); + } + }.u; + const other = w(4) ++ w(8) ++ w(1) ++ "GNU\x00".* ++ [_]u8{0} ** 8; + const build_id = w(4) ++ w(5) ++ w(std.elf.NT_GNU_BUILD_ID) ++ "GNU\x00".* ++ "\xde\xad\xbe\xef\x01\x00\x00\x00".*; + try std.testing.expectEqualSlices(u8, "\xde\xad\xbe\xef\x01", gnuBuildId(&(other ++ build_id)).?); + try std.testing.expectEqual(null, gnuBuildId(&other)); +} diff --git a/app/root.zig b/app/root.zig index 883685b40..8e31a473d 100644 --- a/app/root.zig +++ b/app/root.zig @@ -32,6 +32,10 @@ pub const settings = struct { pub const PluginPane = @import("settings/PluginSettingsPane.zig"); }; +/// Crash reports: whose code crashed, from the panic and fault handlers, written to +/// `/crashes/`. +pub const crash = @import("crash"); + /// Recently opened folders, persisted as `recents.zon`. pub const Recents = @import("Recents.zig"); diff --git a/app/store/PluginLoader.zig b/app/store/PluginLoader.zig index 302bec10c..dd0b8f337 100644 --- a/app/store/PluginLoader.zig +++ b/app/store/PluginLoader.zig @@ -13,6 +13,7 @@ const Host = sdk.Host; const dylib_api = sdk.dylib; const dvui_context = sdk.dvui_context; const version = sdk.version; +const crash = @import("crash"); /// Zig 0.16.0's `std.DynLib` dropped Windows support; this thin wrapper restores it for /// Windows while delegating elsewhere. Shape matches `std.DynLib.{open, close, lookup}`. @@ -484,6 +485,12 @@ pub fn register(host: *Host, opened: Opened, pre: ?PreRegister) LoadError!Loaded ); } + // In the crash table from before its `register` runs, so a crash there is the plugin's. + var version_buf: [24]u8 = undefined; + const plugin_version = std.fmt.bufPrint(&version_buf, "{f}", .{o.version_info.plugin_version}) catch "?"; + crash.addModule(o.plugin_id, plugin_version, @intFromPtr(o.register_fn)); + errdefer crash.removeModule(@intFromPtr(o.register_fn)); + const status: dylib_api.RegisterStatus = @enumFromInt(o.register_fn(host)); switch (status) { .ok => {}, diff --git a/build/app.zig b/build/app.zig index b14bbc2af..bbf60b6f2 100644 --- a/build/app.zig +++ b/build/app.zig @@ -672,6 +672,19 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. .filters = test_filters, })); + // Crash reports: the report's text, and the image (range, build id) holding an address. + // std-only; it reads images and writes the report through libc. + try unit_test_artifacts.append(b.allocator, b.addTest(.{ + .name = "fizzy-crash-tests", + .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("app/crash/crash.zig"), + .link_libc = true, + }), + .filters = test_filters, + })); + // Keybinding parse/resolve core. Deliberately dvui-free (see Keymap.zig) — dvui's keybind map // can't express chords and is keyed by bind name, not command. Its key spelling is `tape`'s // (`sdk/tape/chord.zig`), the one dependency it has. @@ -1188,6 +1201,7 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. }); plugin_loader_module.addImport("dvui", dvui_testing_dep.module("dvui_testing")); plugin_loader_module.addImport("fizzy_sdk", sdk_module_test); + plugin_loader_module.addImport("crash", sdk.crashModule(b, target, optimize)); // How big a pane is: the split's drag, capture handoff and hit distance, and the pane // row's shares. Both need a real Window — every bug either has had was a dvui diff --git a/build/exe.zig b/build/exe.zig index 010402751..e74df7b7f 100644 --- a/build/exe.zig +++ b/build/exe.zig @@ -232,6 +232,9 @@ pub fn addFizzyExecutableForTarget( }), }); exe.root_module.strip = false; + // The id a crash report names this exact binary by (`app/crash/image.zig`); see + // `sdk/plugin_sdk.zig`'s `create`. + if (resolved_target.result.ofmt == .elf) exe.build_id = .fast; exe.root_module.addImport("assets", assets_module); exe.root_module.addImport("build_opts", sdk.buildOptsModule(build_opts)); diff --git a/build/sdk.zig b/build/sdk.zig index d8eaeacfc..836c58695 100644 --- a/build/sdk.zig +++ b/build/sdk.zig @@ -114,10 +114,21 @@ pub fn wireAppModule( // (`core_module.addImports`), so a `Tape` here is the `Tape` there. app_module.addImport("tape", core_module.import_table.get("tape").?); app_module.addImport("replay", core_module.import_table.get("replay").?); + app_module.addImport("crash", crashModule(b, target, optimize)); if (consumer) |c| c.addImport("app", app_module); return app_module; } +/// Crash reports (`app/crash/`): std-only, a module of its own so the plugin loader reaches it +/// from a test rooted at the loader as well as from the app. +pub fn crashModule(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode) *std.Build.Module { + return b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("app/crash/crash.zig"), + }); +} + /// One module per `Options` step, shared by every consumer. /// /// `addOptions` creates a *new* module each time it is called, and the generated `options.zig` diff --git a/sdk/plugin_sdk.zig b/sdk/plugin_sdk.zig index bcec6dc89..68403418d 100644 --- a/sdk/plugin_sdk.zig +++ b/sdk/plugin_sdk.zig @@ -551,6 +551,9 @@ pub fn create(b: *std.Build, opts: CreateOptions) PluginArtifact { }); lib.linker_allow_shlib_undefined = true; lib.root_module.export_symbol_names = &dylib_exports; + // The id a crash report names this exact binary by (fizzy's `app/crash/image.zig`). Mach-O + // always carries one (LC_UUID) and PE one with its PDB; an ELF image only when asked. + if (opts.target.result.ofmt == .elf) lib.build_id = .fast; // `FIZZY_PLUGIN_USE_LLVM=1` / `=0` forces the code generator for a plugin build; unset, Zig // chooses its default for the target. For measuring the rebuild // loop per backend (`scripts/plugin-loop/bench.py --backend`, `.github/workflows/plugin-loop.yml`): diff --git a/src/Entry.zig b/src/Entry.zig index b02b26141..b986a74ca 100644 --- a/src/Entry.zig +++ b/src/Entry.zig @@ -18,6 +18,7 @@ const update_notify = @import("app").update.update_notify; const singleton = @import("app").single_instance; const restart = @import("app").restart; const automation = @import("app").automation; +const crash = @import("app").crash; const paths = fizzy.core.paths; const Constants = @import("editor/Constants.zig"); const AppInfo = @import("app").AppInfo; @@ -142,6 +143,7 @@ pub const dvui_app: dvui.App = .{ }; pub fn main(main_init: std.process.Init) !u8 { + crash.init(.{ .app = AppInfo.current.name, .version = AppInfo.current.version, .sdk = sdk_version }); std.log.info("{s} version {s} ({s})", .{ AppInfo.current.display_name, AppInfo.current.version, @tagName(@import("builtin").mode) }); if (comptime auto_update.impl) { @@ -245,10 +247,21 @@ pub fn main(main_init: std.process.Init) !u8 { return 0; } -pub const panic = dvui.App.panic; +pub const panic = if (crash.supported) std.debug.FullPanic(crash.panic) else dvui.App.panic; pub const std_options: std.Options = .{ .logFn = logFn, + // In every build mode, not only the safe ones: a release crash is the one worth a report. + .enable_segfault_handler = crash.supported, }; +/// std's segfault handler hands a fault here (`std.debug.handleSegfault`). +pub const debug = struct { + pub const handleSegfault = crash.handleSegfault; +}; +/// The SDK this build hosts, as a crash report names it: version and boundary fingerprint. +const sdk_version = std.fmt.comptimePrint("{f} ({x})", .{ + fizzy.sdk.version.sdk_version, + fizzy.sdk.version.recorded_sdk_shape_fingerprint, +}); // Forwards every log call to dvui's usual sink (stderr on native, the browser console on // web) and also into `fizzy.OutputLog`, so fizzy's "Output" bottom panel can show it — except diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 9bdb8c4ae..8539d6642 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -375,6 +375,7 @@ pub fn init( } } const palette_folder = try std.fs.path.join(app.allocator, &.{ config_folder, "palettes" }); + @import("app").crash.writeTo(dvui.io, config_folder); var editor: Editor = .{ .app = .{ @@ -1811,6 +1812,7 @@ pub fn unloadPlugin(editor: *Editor, id: []const u8, opts: UnloadOptions) Unload // Unmap the image and free our bookkeeping for it. var loaded = editor.app.loaded_plugin_libs.orderedRemove(lib_index); + @import("app").crash.removeModule(@intFromPtr(loaded.set_globals)); loaded.lib.close(); editor.app.gpa.free(loaded.plugin_id); editor.app.gpa.free(loaded.path);