summaryrefslogtreecommitdiff
path: root/src/crash.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-03 13:10:59 -0300
committerGabriel Schneider <[email protected]>2026-09-03 13:10:59 -0300
commitffc8c1f6f19a5dc48e98f99938b438baf9a97165 (patch)
tree81ede7b26e6a1713597a236973014c1b64934d00 /src/crash.zig
parent3f2d6f43199d0e230490396deb50f8dc49c7b8b0 (diff)
downloadpardes-ffc8c1f6f19a5dc48e98f99938b438baf9a97165.tar.gz
pardes-ffc8c1f6f19a5dc48e98f99938b438baf9a97165.zip
crash: a panic writes itself down beside the init file, where stderr cannot lose it
Every crash this program has ever had went to stderr and nowhere else, and stderr is the one place it cannot keep anything. In the tty shell stderr IS the screen, so the trace lands on the grid the terminal is being reset out of; the SDL and AppKit shells have no terminal at all; a --detach session's goes wherever its launcher left it. src/crash.zig appends a record to <config dir>/crashes first: one line naming the build (version, commit, UTC, os-arch, pid) and under it the panic message and the frames behind it. RETURN ADDRESSES and not the symbolised trace, which is measured rather than chosen. `std.debug.writeCurrentStackTrace` called from a panic handler BEFORE defaultPanic wedges the process at 0% CPU: symbolising reads DWARF, that read can itself panic, and the staging which turns a nested panic into "aborting due to recursive panic" is defaultPanic's own and private. Reproduced in a standalone build with this program's std_options_debug_io and inside a test binary. `captureCurrentStackTrace` only walks frames, so the addresses go in the file and `addr2line -e` finishes the job; stderr still gets the symbolised trace from defaultPanic, unchanged. The AppKit shell gets a panic handler of its own here too: the macOS build roots at macos.zig, so main.zig's had never run there — in the shell with the least useful stderr of the four. The config directory is COPIED rather than borrowed, because that host's lives in an arena its own errdefer frees. One record at a time, so two panicking threads cannot interleave into one buffer. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_016Q4RATpafkwahrovHQLKRf
Diffstat (limited to 'src/crash.zig')
-rw-r--r--src/crash.zig226
1 files changed, 226 insertions, 0 deletions
diff --git a/src/crash.zig b/src/crash.zig
new file mode 100644
index 00000000..8de2abed
--- /dev/null
+++ b/src/crash.zig
@@ -0,0 +1,226 @@
+//! The crash file: what a panic leaves behind for the human who has to report it.
+//!
+//! stderr is where a panic has always gone, and stderr is the worst place this
+//! program has. In the tty shell stderr IS the screen — the trace lands on the
+//! grid the terminal is being reset out of, and the next redraw or the next
+//! `clear` takes it with it. The GUI and the AppKit shells have no terminal at
+//! all, so it goes to a console nobody has open. A detached session's goes
+//! wherever the launcher left it, which is usually /dev/null. So a record is
+//! appended to `<config dir>/crashes` as well, under one line naming the build
+//! that produced it: a bug report needs the version and the commit, and a user
+//! who runs pardes already knows where its config lives.
+//!
+//! The record is the metadata line, the panic message, and the RETURN
+//! ADDRESSES — not the symbolised trace stderr gets. `record` says why in as
+//! many words: symbolising from out here hangs the process, measured.
+//!
+//! Panics only. A SIGSEGV never reaches a panic handler (main.zig `debug`
+//! hooks that path separately) and unwinding one needs the signal's saved cpu
+//! context, which is a different job than this one.
+
+const std = @import("std");
+const builtin = @import("builtin");
+const pardes = @import("pardes.zig");
+
+/// Beside `init`, so `Config` opens the directory that holds both.
+pub const name = "crashes";
+
+/// The config directory `user_config.load` resolved, COPIED rather than
+/// borrowed: main.zig hands over an arena slice that outlives the process, but
+/// the AppKit host's lives in a `config_arena` its own `errdefer` frees on a
+/// failed init and its teardown frees at quit — and a panic after either would
+/// have built a path out of freed memory and then created a directory at it.
+/// A panic handler is the one caller that cannot check whether its input is
+/// still alive, so it does not borrow.
+var dir_buf: [1024]u8 = undefined;
+var dir_len: usize = 0;
+
+/// Called where the launcher sets `Options.config_dir`. Ignores a path too long
+/// to hold, which is a crash file that never appears rather than a truncated
+/// path pointing somewhere real.
+pub fn setDir(path: []const u8) void {
+ if (path.len == 0 or path.len > dir_buf.len) return;
+ @memcpy(dir_buf[0..path.len], path);
+ dir_len = path.len;
+}
+
+/// Built at comptime out of the two halves main.zig's `--version` prints, and
+/// for the same reason: there is nothing here to format at runtime. A build
+/// from a tarball says `pardes 0.0.1` rather than inventing a revision.
+const build_id = if (pardes.commit) |c|
+ "pardes " ++ pardes.version ++ " (" ++ c ++ ")"
+else
+ "pardes " ++ pardes.version;
+
+/// The whole record is formatted here before a byte of it is written, because
+/// the fd is opened `O_APPEND` and one `write(2)` of the lot is what keeps two
+/// crashing processes from interleaving their records line by line. Static
+/// rather than a local: a panic handler may be running on a thread stack of a
+/// few pages.
+var scratch: [8 << 10]u8 = undefined;
+
+/// Deep enough to reach `main` through any of this program's loops, and the
+/// only sizing decision here: the addresses are 8 bytes each and cost a line.
+var addr_buf: [64]usize = undefined;
+
+/// One at a time, and never inside itself. `defaultPanic` gets both properties
+/// from a private `panic_stage` and from the stderr lock, and neither is
+/// reachable from out here: two threads panicking at once would interleave
+/// `@memcpy`s into one `scratch` and each write out the mixture, and a panic
+/// raised INSIDE this function — the walk below is the plausible place — would
+/// re-enter it with the fd still open. Both end here instead, and the loser
+/// falls through to `defaultPanic`, which still says everything on stderr.
+var recording: std.atomic.Value(bool) = .init(false);
+
+/// Append one crash record, or silently do nothing. Called FROM the panic
+/// handler, so it takes no lock of this program's, allocates nothing of its
+/// own, and does its own file I/O: a static buffer, one `open`, one `write`.
+/// Every failure is swallowed — a crash file that could not be written must not
+/// become the crash, and stderr is about to get the message either way.
+pub fn record(msg: []const u8, first_trace_addr: ?usize) void {
+ if (dir_len == 0) return;
+ if (recording.swap(true, .seq_cst)) return;
+ // Released on the way out, which matters to nobody at panic time — the
+ // process is about to abort — and lets a test call this twice.
+ defer recording.store(false, .seq_cst);
+ const config_dir = dir_buf[0..dir_len];
+ var path_buf: [1024:0]u8 = undefined;
+ if (config_dir.len + 1 + name.len >= path_buf.len) return;
+
+ // The directory is where the config WOULD be, not where it is: a user who
+ // never wrote an init file has no config directory, and the first thing
+ // they have to report is this. Parents are assumed and EEXIST is the
+ // normal answer, exactly as dump.zig bets for its own directory.
+ @memcpy(path_buf[0..config_dir.len], config_dir);
+ path_buf[config_dir.len] = 0;
+ _ = std.c.mkdir(path_buf[0..config_dir.len :0], 0o755);
+
+ path_buf[config_dir.len] = '/';
+ @memcpy(path_buf[config_dir.len + 1 ..][0..name.len], name);
+ path_buf[config_dir.len + 1 + name.len] = 0;
+ const path = path_buf[0 .. config_dir.len + 1 + name.len :0];
+
+ const fd = std.c.open(path, .{
+ .ACCMODE = .WRONLY,
+ .CREAT = true,
+ .APPEND = true,
+ }, @as(std.c.mode_t, 0o600));
+ if (fd < 0) return;
+ defer _ = std.c.close(fd);
+
+ var w: std.Io.Writer = .fixed(&scratch);
+
+ // The metadata line. Same clock and the same civil-time arithmetic as
+ // dump.zig's filename, printed as UTC because a crash file is read by
+ // whoever the reporter sends it to and their zone is not the reporter's.
+ var ts: std.c.timespec = undefined;
+ _ = std.c.clock_gettime(.REALTIME, &ts);
+ const es: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, ts.sec)) };
+ const yd = es.getEpochDay().calculateYearDay();
+ const md = yd.calculateMonthDay();
+ const ds = es.getDaySeconds();
+ w.print("\n" ++ build_id ++ " {d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z {s}-{s} pid {d}\n", .{
+ yd.year,
+ md.month.numeric(),
+ @as(u8, md.day_index) + 1,
+ ds.getHoursIntoDay(),
+ ds.getMinutesIntoHour(),
+ ds.getSecondsIntoMinute(),
+ @tagName(builtin.os.tag),
+ @tagName(builtin.cpu.arch),
+ // Unsigned: `{d}` prints a leading '+' for a positive SIGNED int, which
+ // is the same note main.zig makes where it names a `--detach` session
+ // after this pid.
+ @as(u32, @intCast(std.c.getpid())),
+ }) catch {};
+
+ // ...and under it the message stderr is about to print, then the return
+ // addresses behind it.
+ //
+ // ADDRESSES AND NOT THE SYMBOLISED TRACE, which is the one thing here that
+ // is not simply "the same bytes stderr gets". `std.debug.writeCurrentStack
+ // Trace` — the call `defaultPanic` makes a moment later, into a writer of
+ // its own — HANGS THE PROCESS when it is made from a panic handler before
+ // `defaultPanic` has run. Symbolising reads DWARF, and on a machine with
+ // debug info to fetch that read can itself panic; `defaultPanic` survives
+ // that because its private `panic_stage` turns the nested panic into
+ // "aborting due to recursive panic" and an abort. Outside it there is no
+ // such staging, and the recursion ends in a futex nobody will post: a
+ // panicking pardes stopped dead instead of dying. Measured, not guessed —
+ // with the stack-trace call the process wedged at 0% CPU with libc's debug
+ // info half-open, and it did it identically inside a test binary and in a
+ // standalone build with this program's `std_options_debug_io`.
+ //
+ // `captureCurrentStackTrace` only walks frames, so it is safe here, and the
+ // addresses resolve with `addr2line -e <the pardes binary>` against the
+ // build the line above names. stderr still gets the symbolised trace from
+ // `defaultPanic`, unchanged.
+ w.print("panic: {s}\n", .{msg}) catch {};
+ const trace = std.debug.captureCurrentStackTrace(.{
+ .first_address = first_trace_addr orelse @returnAddress(),
+ .allow_unsafe_unwind = true,
+ }, &addr_buf);
+ for (trace.return_addresses) |a| w.print(" 0x{x}\n", .{a}) catch {};
+
+ const written = w.buffered();
+ var off: usize = 0;
+ while (off < written.len) {
+ const n = std.c.write(fd, written.ptr + off, written.len - off);
+ if (n <= 0) break; // including EINTR: the record is best effort
+ off += @intCast(n);
+ }
+}
+
+test "a crash record names the build, appends, and carries the panic message" {
+ const io = std.testing.io;
+ const gpa = std.testing.allocator;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var base_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const base_len = try tmp.dir.realPath(io, &base_buf);
+
+ // A directory that does NOT exist yet, which is the case that matters: the
+ // user who has never written an init file is the likeliest reporter.
+ const config_dir = try std.fs.path.join(gpa, &.{ base_buf[0..base_len], "pardes" });
+ defer gpa.free(config_dir);
+ const saved_len = dir_len;
+ defer dir_len = saved_len;
+ setDir(config_dir);
+
+ record("first boom", null);
+ record("second boom", null);
+
+ const path = try std.fs.path.join(gpa, &.{ config_dir, name });
+ defer gpa.free(path);
+ const bytes = try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(1 << 20));
+ defer gpa.free(bytes);
+
+ // The metadata line, then the message, for each of the two panics: the
+ // file is appended to, never rewritten.
+ try std.testing.expect(std.mem.count(u8, bytes, build_id ++ " ") == 2);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "panic: first boom\n") != null);
+ const second = std.mem.indexOf(u8, bytes, "panic: second boom\n").?;
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "panic: first boom\n").? < second);
+ // ...and the platform and a UTC stamp on that line, which is the half a
+ // version string cannot give: `1970-` would mean the clock read failed.
+ const stamp = std.mem.indexOf(u8, bytes, @tagName(builtin.os.tag) ++ "-" ++ @tagName(builtin.cpu.arch)).?;
+ try std.testing.expect(stamp < second);
+ try std.testing.expect(std.mem.indexOf(u8, bytes, "1970-") == null);
+
+ // ...and at least one return address under the FIRST message, which is the
+ // only part of this that can quietly produce nothing: an unwinder that
+ // refuses the strategy hands back an empty trace and the loop writes no
+ // lines at all. Asserted between the two records so it cannot be satisfied
+ // by the second one's.
+ const first_msg = std.mem.indexOf(u8, bytes, "panic: first boom\n").?;
+ const addr = std.mem.indexOfPos(u8, bytes, first_msg, "\n 0x").?;
+ try std.testing.expect(addr < second);
+
+ // No directory, no file: a core that never resolved a config directory
+ // panics exactly as it did before this existed.
+ dir_len = 0;
+ record("unrecorded", null);
+ const again = try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(1 << 20));
+ defer gpa.free(again);
+ try std.testing.expectEqual(bytes.len, again.len);
+}