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%).
This commit is contained in:
Mitchell Hashimoto
2026-08-09 10:19:59 -07:00
parent 0aa71d02ed
commit f36854345e
3 changed files with 110 additions and 0 deletions

View File

@@ -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

View File

@@ -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;

60
src/os/stderr.zig Normal file
View File

@@ -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("");
}