//! 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 `/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 ` 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); }