mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-25 00:21:46 +00:00
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:
1522
src/lib/TinyIo.zig
Normal file
1522
src/lib/TinyIo.zig
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
60
src/os/stderr.zig
Normal 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("");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -80,6 +80,9 @@ rin = "rin"
|
||||
ower = "ower"
|
||||
# OpenType table names
|
||||
loca = "loca"
|
||||
# TinyIo
|
||||
tio = "tio"
|
||||
WRONLY = "WRONLY"
|
||||
|
||||
[type.po]
|
||||
extend-glob = ["*.po"]
|
||||
|
||||
Reference in New Issue
Block a user