libghostty: reduce binary size ~16% (macOS), ~22% (Linux) (#13715)

This shrinks the binary size of libghostty-vt by **16% on aarch64 macOS
and 22% on x86_64 Linux**. It also shrinks the in-memory footprint by
~256KB per thread + ~20KB per app. All benchmarks remain the same, no
speedups or slowdowns.

Each commit message explains an individual tactic used, but to
summarize:

1. **No stack traces in release panic handlers (~160KB).** This requires
the Zig stack unwind and symbolication logic. I don't think this makes
sense in an embedded library because the embedder should handle this.

2. **An alternate `std.Io` implementation called `TinyIo` (~100KB to
200KB).** See later... since this is the big one.

3. **Disable recursive Parser.Action logging (~35KB).** We now only log
the top-level fields of a Parser.Action, which lowers the amount of
`std.fmt` codegen significantly.

All sizes above are aarch64 macOS and x86_64 Linux ReleaseFast
libghostty builds.

## TinyIo

I think the main complexity introduction here is our alternate `std.Io`
implementation `TinyIo`. This is an IO implementation that implements IO
operations we need through direct syscalls and does not support
concurrency or any other options like network, progress, etc.

Why? Because of the way `std.Io` works through vtable dispatch, the
linker and dead code removal can't prune ANY of the function pointers.
So our binary has full implementations of all the networking,
concurrency, etc. related code even though we don't use it.

This has a runtime effect too: even though we put `std.Io.Threaded` in
single-threaded mode, it still allocates ~256KB of TLS _per thread_, and
its raw struct state is ~18KB (versus 80 _bytes_ for `TinyIo`).

For future maintenance: I exhaustively implemented the vtable rather
than use the failing vtable from Zig stdlib so any Zig changes to add
new fields to this error so we can determine if we want to support it or
not.
This commit is contained in:
Mitchell Hashimoto
2026-08-09 14:23:15 -07:00
committed by GitHub
11 changed files with 1744 additions and 51 deletions

1522
src/lib/TinyIo.zig Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ const types = @import("types.zig");
const unionpkg = @import("union.zig");
pub const allocator = @import("allocator.zig");
pub const TinyIo = @import("TinyIo.zig");
pub const Buffer = types.Buffer;
pub const Enum = enumpkg.Enum;
pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum;

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/`
@@ -37,6 +38,17 @@ const terminal = @import("terminal/main.zig");
/// Additional functionality will be added here over time as needed.
pub const sys = terminal.sys;
/// A tiny, blocking `std.Io` implementation optimized for binary size.
///
/// Constructing a `Terminal` requires a `std.Io` for features that touch
/// the filesystem (e.g. Kitty graphics file transmission). Embedders that
/// don't have their own `Io` can use `TinyIo` (e.g.
/// `(TinyIo.init).io()`) instead of `std.Io.Threaded` to avoid linking
/// Threaded's full vtable (networking, process spawning, async
/// machinery, etc.), which is worth roughly 110KB of binary size. See
/// the TinyIo docs for the exact tradeoffs.
pub const TinyIo = @import("lib/TinyIo.zig");
pub const apc = terminal.apc;
pub const dcs = terminal.dcs;
pub const osc = terminal.osc;
@@ -414,6 +426,79 @@ pub const std_options: std.Options = opts: {
break :opts options;
};
/// True for builds where we keep the full std debug machinery (stack
/// traces on panic, std.debug.print, etc.). These builds are for
/// development, where the roughly 160KB of binary size it costs is
/// worth it.
const debug_machinery: bool = builtin.is_test or switch (builtin.mode) {
.Debug, .ReleaseSafe => true,
.ReleaseFast, .ReleaseSmall => false,
};
/// 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 (debug_machinery)
std.debug.FullPanic(std.debug.defaultPanic)
else
std.debug.FullPanic(tinyPanicImpl);
/// Guards release builds against accidentally reintroducing the std
/// debug Io machinery.
///
/// `std.Options.debug_io` defaults to `std.Io.Threaded`, and anything
/// that reaches it (std.debug.print, std.debug.lockStderr, the default
/// std.log handler, etc.) pins Threaded's entire vtable into the binary:
/// roughly 110KB of unreachable code.
///
/// This verifies nothing ever touches it.
pub const std_options_debug_io: std.Io = if (debug_machinery)
std.Io.Threaded.global_single_threaded.io()
else
@compileError(
\\The std debug Io machinery (std.debug.print, std.debug.lockStderr,
\\std.log's default handler, ...) is disabled in libghostty-vt release
\\builds because it costs ~110KB of binary size. Use std.log (routed
\\through our logFn), os/stderr.zig for raw diagnostic writes, or
\\gate the code on debug builds.
);
/// 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("");
}

View File

@@ -166,12 +166,18 @@ pub const Action = union(enum) {
// of invisible characters we don't want to handle right
// now.
// All others do the default behavior
// All others do the default behavior. Note the max
// depth of 1: this is only used for logging so we
// only want the top-level fields. Larger depths
// instantiate a recursive formatter for every nested
// type of every action payload (e.g. the full
// osc.Command union), which costs tens of kilobytes
// of binary size.
else => try writer.printValue(
"any",
.{},
@field(self, u_field.name),
3,
1,
),
}
}
@@ -386,10 +392,7 @@ inline fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
// We only allow colon or mixed separators for the 'm' command.
if (c != 'm' and self.params_sep.count() > 0) {
@branchHint(.cold);
log.warn(
"CSI colon or mixed separators only allowed for 'm' command, got: {f}",
.{result},
);
warnCsiSepMismatch(result.csi_dispatch);
break :csi_dispatch null;
}
@@ -406,6 +409,20 @@ inline fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
};
}
/// Log a warning for a CSI dispatch with colon/mixed separators on a
/// non-'m' command.
///
/// This is noinline on purpose so that this unlikely (cold) behavior
/// doesn't bloat the hot dispatch path which has been measured to actually
/// affect both binary size and performance due to icache busts.
noinline fn warnCsiSepMismatch(csi: Action.CSI) void {
@branchHint(.cold);
log.warn(
"CSI colon or mixed separators only allowed for 'm' command, got: {f}",
.{csi},
);
}
pub inline fn clear(self: *Parser) void {
self.intermediates_idx = 0;
self.params_idx = 0;

View File

@@ -719,33 +719,6 @@ test "decoder option and empty source" {
try testing.expectEqual(null, terminal);
}
test "snapshot decoder defers terminal I/O allocation until READY" {
var failing = testing.FailingAllocator.init(testing.allocator, .{
// The decoder wrapper is the only allocation performed by new_buf.
// Fail the following allocation, which creates terminal-owned I/O.
.fail_index = 1,
});
const failing_zig = failing.allocator();
const failing_c: CAllocator = .fromZig(&failing_zig);
var decoder: Decoder = null;
try testing.expectEqual(Result.success, decoder_new_buf(
&failing_c,
&decoder,
null,
0,
));
defer decoder_free(decoder);
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.out_of_memory, decoder_ready(
decoder,
&terminal,
));
try testing.expectEqual(null, terminal);
try testing.expectEqual(@as(usize, 0), decoder.?.source.offset());
}
test "snapshot C API full round trip restores continuation" {
var source: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(

View File

@@ -3,6 +3,7 @@ const builtin = @import("builtin");
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
const terminal_sys = @import("../sys.zig");
const stderr = @import("../../os/stderr.zig");
const Result = @import("result.zig").Result;
/// C: GhosttySysImage
@@ -211,8 +212,12 @@ pub fn logFn(
/// Formats each message as "[level](scope): message\n". Can be passed
/// directly to ghostty_sys_set(GHOSTTY_SYS_OPT_LOG, &ghostty_sys_log_stderr).
///
/// Uses std.debug.lockStderrWriter for thread-safe, mutex-protected output.
/// On freestanding/wasm targets this is a no-op (no stderr available).
/// Each log line is emitted with a single raw write to stderr, which keeps
/// concurrent log lines from interleaving. We intentionally avoid
/// `std.debug.lockStderr` because it routes through `std.Options.debug_io`
/// and would keep the entire `std.Io.Threaded` vtable alive in the binary
/// (see `os/stderr.zig`). On freestanding/wasm targets this is a no-op
/// (no stderr available).
pub fn logStderr(
_: ?*anyopaque,
level: LogLevel,
@@ -233,16 +238,32 @@ pub fn logStderr(
.debug => "debug",
};
var buffer: [64]u8 = undefined;
var locked_stderr = std.debug.lockStderr(&buffer);
defer std.debug.unlockStderr();
nosuspend {
if (scope.len > 0) {
locked_stderr.file_writer.interface.print("[{s}]({s}): {s}\n", .{ level_text, scope, message }) catch {};
} else {
locked_stderr.file_writer.interface.print("[{s}]: {s}\n", .{ level_text, message }) catch {};
}
// Large enough for a full logFn chunk plus the level/scope prefix.
var buffer: [LogEmitter.buffer_size + 128]u8 = undefined;
const line: ?[]const u8 = if (scope.len > 0)
std.fmt.bufPrint(&buffer, "[{s}]({s}): {s}\n", .{ level_text, scope, message }) catch null
else
std.fmt.bufPrint(&buffer, "[{s}]: {s}\n", .{ level_text, message }) catch null;
if (line) |v| {
stderr.write(v);
return;
}
// The line didn't fit in our buffer (an embedder called us directly
// with a very large message). Fall back to writing the pieces
// separately; interleaving with other threads is possible here but
// this is a best-effort diagnostic path.
stderr.write("[");
stderr.write(level_text);
stderr.write("]");
if (scope.len > 0) {
stderr.write("(");
stderr.write(scope);
stderr.write(")");
}
stderr.write(": ");
stderr.write(message);
stderr.write("\n");
}
test "set decode_png with null clears" {

View File

@@ -51,17 +51,25 @@ pub const Io = struct {
impl: Impl,
/// Platform-specific storage backing the public `std.Io` value.
const Impl = if (builtin.os.tag != .freestanding)
///
/// Where supported (POSIX) we use TinyIo, which is stateless and
/// supports exactly the operations the terminal needs at a fraction
/// of the code size (see lib/TinyIo.zig). On Windows we use
/// `std.Io.Threaded` since TinyIo doesn't implement the NT
/// operations. On the remaining targets (e.g. freestanding wasm)
/// TinyIo degrades to `std.Io.failing`, which is correct: they have
/// no filesystem.
const Impl = if (builtin.os.tag == .windows)
*std.Io.Threaded
else
void;
lib.TinyIo;
/// Allocation failures possible while constructing an I/O owner.
pub const Error = error{OutOfMemory};
/// Allocate the native I/O implementation when the platform requires it.
pub fn init(alloc: std.mem.Allocator) Error!Io {
if (comptime builtin.os.tag == .freestanding) return .{ .impl = {} };
if (comptime Impl == lib.TinyIo) return .{ .impl = .init };
const ptr = alloc.create(std.Io.Threaded) catch
return error.OutOfMemory;
@@ -71,15 +79,15 @@ pub const Io = struct {
/// Return the value passed to native terminal construction and decoding.
pub fn io(self: Io) std.Io {
if (comptime builtin.os.tag == .freestanding) {
return std.Io.failing;
}
return self.impl.io();
}
/// Release an I/O implementation that has not already been transferred.
pub fn deinit(self: Io, alloc: std.mem.Allocator) void {
if (comptime builtin.os.tag != .freestanding) {
// Note: this must not name `std.Io.Threaded` in the condition
// because resolving that type trips its container-level comptime
// checks on targets it doesn't support (e.g. wasm32-freestanding).
if (comptime Impl != lib.TinyIo) {
self.impl.deinit();
alloc.destroy(self.impl);
}

View File

@@ -14,6 +14,7 @@ pub const calling_conv: std.builtin.CallingConvention = .c;
/// Forwarded decls from lib that are used.
pub const alloc = lib.allocator;
pub const TinyIo = lib.TinyIo;
pub const Buffer = lib.Buffer;
pub const Enum = lib.Enum;
pub const TaggedUnion = lib.TaggedUnion;

View File

@@ -80,6 +80,9 @@ rin = "rin"
ower = "ower"
# OpenType table names
loca = "loca"
# TinyIo
tio = "tio"
WRONLY = "WRONLY"
[type.po]
extend-glob = ["*.po"]