From f36854345e50fe382f37a6ef5859f9013787a23b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 9 Aug 2026 10:19:59 -0700 Subject: [PATCH] libghostty: skip stack traces in release panic handlers The default Zig panic handler unwinds the stack and symbolicates it, which drags in ~160KB worth of helper machinery. For an embedded library this isn't great because the embedder's environment should be providing this as long as libghostty is compiled with symbols or has a way to symbolize. Change ReleaseFast/ReleaseSmall libghostty-vt builds to use a custom panic handler. Debug/ReleaseSafe keep the full Zig handlers. This shrinks libghostty-vt on macOS by ~160KB (~9%). --- src/lib_vt.zig | 48 +++++++++++++++++++++++++++++++++++++ src/os/main.zig | 2 ++ src/os/stderr.zig | 60 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 src/os/stderr.zig diff --git a/src/lib_vt.zig b/src/lib_vt.zig index c9edd20b0..68f275b26 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -11,6 +11,7 @@ const lib = @This(); const std = @import("std"); const builtin = @import("builtin"); +const stderr = @import("os/stderr.zig"); // The public API below reproduces a lot of terminal/main.zig but // is separate because (1) we need our root file to be in `src/` @@ -414,6 +415,53 @@ pub const std_options: std.Options = opts: { break :opts options; }; +/// The panic handler for when this file is the root module. +/// +/// In ReleaseFast and ReleaseSmall builds we print the panic message to +/// stderr and trap, but do not attempt to unwind the stack to print a +/// stack trace. +pub const panic: type = if (builtin.is_test or switch (builtin.mode) { + .Debug, .ReleaseSafe => true, + .ReleaseFast, .ReleaseSmall => false, +}) + std.debug.FullPanic(std.debug.defaultPanic) +else + std.debug.FullPanic(tinyPanicImpl); + +/// Prints the panic message to stderr (best-effort) and traps. +/// +/// This intentionally avoids `std.debug.lockStderr`, which routes through +/// `std.Options.debug_io` and would keep the entire `std.Io.Threaded` +/// vtable alive in the binary which takes up hundreds of KB. +/// +/// This is safe to call from any thread (and even from signal handlers): +/// it takes no locks, performs no allocation, and touches no shared +/// mutable state. The message is emitted with a single raw write so that +/// concurrent stderr output doesn't interleave with it. +fn tinyPanicImpl(msg: []const u8, ra: ?usize) noreturn { + @branchHint(.cold); + _ = ra; + + // 256 bytes is enough for most messages, so try that first + // so that we can try to write in a single syscall. + var buf: [256]u8 = undefined; + if (std.fmt.bufPrint( + &buf, + "panic: {s}\n", + .{msg}, + )) |line| { + stderr.write(line); + } else |_| { + stderr.write("panic: "); + stderr.write(msg); + stderr.write("\n"); + } + + // Trap forces a standard crash that embedder-provided debuggers + // or environments can catch. + @trap(); +} + test { // Zig 0.16.0 has made test logging more strict. Now, *anything* that gets // printed to stderr results in a "failed command" message, even if the diff --git a/src/os/main.zig b/src/os/main.zig index 7f9e12cf9..e948144a0 100644 --- a/src/os/main.zig +++ b/src/os/main.zig @@ -28,6 +28,7 @@ pub const xdg = @import("xdg.zig"); pub const windows = @import("windows.zig"); pub const macos = @import("macos.zig"); pub const shell = @import("shell.zig"); +pub const stderr = @import("stderr.zig"); pub const uri = @import("uri.zig"); // Functions and types @@ -57,6 +58,7 @@ pub const getKernelInfo = kernel_info.getKernelInfo; test { _ = file; + _ = stderr; _ = i18n; _ = path; _ = uri; diff --git a/src/os/stderr.zig b/src/os/stderr.zig new file mode 100644 index 000000000..201bc438b --- /dev/null +++ b/src/os/stderr.zig @@ -0,0 +1,60 @@ +//! Raw, best-effort stderr writing that bypasses `std.Io` entirely. +//! +//! `std.debug.lockStderr` (and everything layered on top of it) routes +//! through `std.Options.debug_io`, whose default implementation is +//! `std.Io.Threaded`. This keeps ~70KB of unreachable code in binaries. + +const std = @import("std"); +const builtin = @import("builtin"); + +/// Write bytes to stderr using the most primitive mechanism available +/// for the target. This is best-effort: errors are ignored, since this +/// is only used for diagnostics (logging and panic messages). +/// +/// Freestanding targets (e.g. wasm) have no stderr, so this is a no-op +/// there. +pub fn write(bytes: []const u8) void { + switch (builtin.os.tag) { + .freestanding, .other => {}, + + .windows => { + const windows = std.os.windows; + const handle = windows.peb().ProcessParameters.hStdError; + var iosb: windows.IO_STATUS_BLOCK = undefined; + _ = windows.ntdll.NtWriteFile( + handle, + null, // event + null, // APC routine + null, // APC context + &iosb, + bytes.ptr, + @intCast(bytes.len), + null, // byte offset + null, // key + ); + }, + + else => { + const posix = std.posix; + var i: usize = 0; + while (i < bytes.len) { + const rc = posix.system.write( + posix.STDERR_FILENO, + bytes[i..].ptr, + bytes.len - i, + ); + switch (posix.errno(rc)) { + .SUCCESS => i += @as(usize, @intCast(rc)), + .INTR => continue, + else => return, + } + } + }, + } +} + +test write { + // Smoke test: must not crash. We can't assert on the output without + // capturing stderr, which isn't worth the complexity here. + write(""); +}