libghostty: add log callback configuration (#12227)

In C ABI builds, the Zig std.log default writes to stderr which is not
appropriate for a library. Override std_options.logFn with a custom sink
that dispatches to an embedder-provided callback, or silently discards
when none is registered.

Add GHOSTTY_SYS_OPT_LOG to ghostty_sys_set() following the existing
decode_png pattern. The callback receives the log level as a
GhosttySysLogLevel enum, scope and message as separate byte slices,
giving embedders full control over formatting and routing.

Export ghostty_sys_log_stderr as a built-in convenience callback that
writes to stderr using std.debug.lockStderrWriter for thread-safe
output. Embedders who want the old behavior can install it at startup
with a single ghostty_sys_set call.
This commit is contained in:
Mitchell Hashimoto
2026-04-10 11:03:18 -07:00
committed by GitHub
4 changed files with 377 additions and 3 deletions

View File

@@ -64,6 +64,45 @@ typedef struct {
size_t data_len;
} GhosttySysImage;
/**
* Log severity levels for the log callback.
*/
typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_SYS_LOG_LEVEL_ERROR = 0,
GHOSTTY_SYS_LOG_LEVEL_WARNING = 1,
GHOSTTY_SYS_LOG_LEVEL_INFO = 2,
GHOSTTY_SYS_LOG_LEVEL_DEBUG = 3,
GHOSTTY_SYS_LOG_LEVEL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySysLogLevel;
/**
* Callback type for logging.
*
* When installed, internal library log messages are delivered through
* this callback instead of being discarded. The embedder is responsible
* for formatting and routing log output.
*
* @p scope is the log scope name as UTF-8 bytes (e.g. "osc", "kitty").
* When the log is unscoped (default scope), @p scope_len is 0.
*
* All pointer arguments are only valid for the duration of the callback.
* The callback must be safe to call from any thread.
*
* @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA
* @param level The severity level of the log message
* @param scope Pointer to the scope name bytes
* @param scope_len Length of the scope name in bytes
* @param message Pointer to the log message bytes
* @param message_len Length of the log message in bytes
*/
typedef void (*GhosttySysLogFn)(
void* userdata,
GhosttySysLogLevel level,
const uint8_t* scope,
size_t scope_len,
const uint8_t* message,
size_t message_len);
/**
* Callback type for PNG decoding.
*
@@ -106,6 +145,26 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: GhosttySysDecodePngFn (function pointer, or NULL)
*/
GHOSTTY_SYS_OPT_DECODE_PNG = 1,
/**
* Set the log callback.
*
* When set, internal library log messages are delivered to this
* callback. When cleared (NULL value), log messages are silently
* discarded.
*
* Use ghostty_sys_log_stderr as a convenience callback that
* writes formatted messages to stderr.
*
* Which log levels are emitted depends on the build mode of the
* library and is not configurable at runtime. Debug builds emit
* all levels (debug and above). Release builds emit info and
* above; debug-level messages are compiled out entirely and will
* never reach the callback.
*
* Input type: GhosttySysLogFn (function pointer, or NULL)
*/
GHOSTTY_SYS_OPT_LOG = 2,
GHOSTTY_SYS_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySysOption;
@@ -125,6 +184,23 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_API GhosttyResult ghostty_sys_set(GhosttySysOption option,
const void* value);
/**
* Built-in log callback that writes to stderr.
*
* Formats each message as "[level](scope): message\n".
* Can be passed directly to ghostty_sys_set():
*
* @code
* ghostty_sys_set(GHOSTTY_SYS_OPT_LOG, &ghostty_sys_log_stderr);
* @endcode
*/
GHOSTTY_API void ghostty_sys_log_stderr(void* userdata,
GhosttySysLogLevel level,
const uint8_t* scope,
size_t scope_len,
const uint8_t* message,
size_t message_len);
#ifdef __cplusplus
}
#endif

View File

@@ -189,6 +189,7 @@ comptime {
@export(&c.size_report_encode, .{ .name = "ghostty_size_report_encode" });
@export(&c.style_default, .{ .name = "ghostty_style_default" });
@export(&c.style_is_default, .{ .name = "ghostty_style_is_default" });
@export(&c.sys_log_stderr, .{ .name = "ghostty_sys_log_stderr" });
@export(&c.sys_set, .{ .name = "ghostty_sys_set" });
@export(&c.cell_get, .{ .name = "ghostty_cell_get" });
@export(&c.row_get, .{ .name = "ghostty_row_get" });
@@ -290,9 +291,12 @@ pub const std_options: std.Options = options: {
.logFn = @import("os/wasm/log.zig").log,
};
// For everything else we currently use defaults. Longer term I'm
// SURE this isn't right (e.g. we definitely want to customize the log
// function for the C lib at least).
// For C ABI builds, use a custom log function that dispatches to an
// embedder-provided callback (or silently discards when none is set).
if (terminal.options.c_abi) break :options .{
.logFn = @import("terminal/c/sys.zig").logFn,
};
break :options .{};
};

View File

@@ -147,6 +147,7 @@ pub const row_get = row.get;
pub const style_default = style.default_style;
pub const style_is_default = style.style_is_default;
pub const sys_log_stderr = sys.logStderr;
pub const sys_set = sys.set;
pub const terminal_new = terminal.new;

View File

@@ -1,4 +1,5 @@
const std = @import("std");
const builtin = @import("builtin");
const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
const terminal_sys = @import("../sys.zig");
@@ -21,15 +22,44 @@ pub const DecodePngFn = *const fn (
*Image,
) callconv(lib.calling_conv) bool;
/// C: GhosttySysLogLevel
pub const LogLevel = enum(c_int) {
@"error" = 0,
warning = 1,
info = 2,
debug = 3,
pub fn fromStd(level: std.log.Level) LogLevel {
return switch (level) {
.err => .@"error",
.warn => .warning,
.info => .info,
.debug => .debug,
};
}
};
/// C: GhosttySysLogFn
pub const LogFn = *const fn (
?*anyopaque,
LogLevel,
[*]const u8,
usize,
[*]const u8,
usize,
) callconv(lib.calling_conv) void;
/// C: GhosttySysOption
pub const Option = enum(c_int) {
userdata = 0,
decode_png = 1,
log = 2,
pub fn InType(comptime self: Option) type {
return switch (self) {
.userdata => ?*const anyopaque,
.decode_png => ?DecodePngFn,
.log => ?LogFn,
};
}
};
@@ -39,6 +69,7 @@ pub const Option = enum(c_int) {
const Global = struct {
userdata: ?*anyopaque = null,
decode_png: ?DecodePngFn = null,
log: ?LogFn = null,
};
/// Global state for the C sys interface.
@@ -94,10 +125,131 @@ fn setTyped(
global.decode_png = value;
terminal_sys.decode_png = if (value != null) &decodePngWrapper else null;
},
.log => global.log = value,
}
return .success;
}
/// Dispatch a log message to the installed C callback, if any.
fn emitLog(level: LogLevel, scope: []const u8, message: []const u8) void {
const func = global.log orelse return;
func(
global.userdata,
level,
scope.ptr,
scope.len,
message.ptr,
message.len,
);
}
/// Emits logs in chunks. Almost all logs will be less than the chunk size
/// but this allows emitting larger logs without heap allocation.
const LogEmitter = struct {
c_level: LogLevel,
scope_text: []const u8,
buf: [2048]u8 = undefined,
pos: usize = 0,
fn write(self: *@This(), bytes: []const u8) error{}!usize {
var remaining = bytes;
while (remaining.len > 0) {
const space = self.buf.len - self.pos;
if (space == 0) {
self.flush();
continue;
}
const n = @min(remaining.len, space);
@memcpy(self.buf[self.pos..][0..n], remaining[0..n]);
self.pos += n;
remaining = remaining[n..];
}
return bytes.len;
}
fn flush(self: *@This()) void {
if (self.pos == 0) return;
emitLog(
self.c_level,
self.scope_text,
self.buf[0..self.pos],
);
self.pos = 0;
}
};
/// Custom std.log sink for C ABI builds.
///
/// When a log callback is installed via ghostty_sys_set(), messages are
/// dispatched through it. When no callback is installed, messages are
/// silently discarded. Large messages that exceed the stack buffer are
/// delivered across multiple callback invocations.
pub fn logFn(
comptime level: std.log.Level,
comptime scope: @TypeOf(.EnumLiteral),
comptime format: []const u8,
args: anytype,
) void {
if (global.log == null) return;
const scope_text: []const u8 = if (scope == .default) "" else @tagName(scope);
const c_level = LogLevel.fromStd(level);
var ctx: LogEmitter = .{
.c_level = c_level,
.scope_text = scope_text,
};
const writer: std.io.GenericWriter(
*LogEmitter,
error{},
LogEmitter.write,
) = .{ .context = &ctx };
nosuspend writer.print(format, args) catch {};
ctx.flush();
}
/// Built-in log callback that writes to stderr.
///
/// 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).
pub fn logStderr(
_: ?*anyopaque,
level: LogLevel,
scope_ptr: [*]const u8,
scope_len: usize,
message_ptr: [*]const u8,
message_len: usize,
) callconv(lib.calling_conv) void {
if (comptime builtin.target.cpu.arch.isWasm()) return;
const scope = scope_ptr[0..scope_len];
const message = message_ptr[0..message_len];
const level_text = switch (level) {
.@"error" => "error",
.warning => "warning",
.info => "info",
.debug => "debug",
};
var buffer: [64]u8 = undefined;
const writer = std.debug.lockStderrWriter(&buffer);
defer std.debug.unlockStderrWriter();
nosuspend {
if (scope.len > 0) {
writer.print("[{s}]({s}): {s}\n", .{ level_text, scope, message }) catch {};
} else {
writer.print("[{s}]: {s}\n", .{ level_text, message }) catch {};
}
}
}
test "set decode_png with null clears" {
// Start from a known state.
global.decode_png = null;
@@ -126,6 +278,147 @@ test "set decode_png installs wrapper" {
try std.testing.expect(terminal_sys.decode_png == null);
}
test "set log with null clears" {
global.log = null;
try std.testing.expectEqual(Result.success, set(.log, null));
try std.testing.expect(global.log == null);
}
test "set log installs callback" {
const S = struct {
var called: bool = false;
fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, _: [*]const u8, _: usize) callconv(lib.calling_conv) void {
called = true;
}
};
try std.testing.expectEqual(Result.success, set(.log, @ptrCast(&S.logCb)));
try std.testing.expect(global.log != null);
emitLog(.info, "test", "hello");
try std.testing.expect(S.called);
// Clear it again.
S.called = false;
try std.testing.expectEqual(Result.success, set(.log, null));
try std.testing.expect(global.log == null);
emitLog(.info, "test", "should not arrive");
try std.testing.expect(!S.called);
}
test "logFn small message single chunk" {
const S = struct {
var call_count: usize = 0;
var total_len: usize = 0;
fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, msg: [*]const u8, msg_len: usize) callconv(lib.calling_conv) void {
_ = msg;
call_count += 1;
total_len += msg_len;
}
};
S.call_count = 0;
S.total_len = 0;
global.log = @ptrCast(&S.logCb);
defer {
global.log = null;
}
logFn(.info, .default, "hello", .{});
try std.testing.expectEqual(@as(usize, 1), S.call_count);
try std.testing.expectEqual(@as(usize, 5), S.total_len);
}
test "logFn message exceeding chunk size is split" {
const S = struct {
var call_count: usize = 0;
var total_len: usize = 0;
fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, msg: [*]const u8, msg_len: usize) callconv(lib.calling_conv) void {
_ = msg;
call_count += 1;
total_len += msg_len;
// Each chunk must not exceed the buffer size.
std.debug.assert(msg_len <= 2048);
}
};
S.call_count = 0;
S.total_len = 0;
global.log = @ptrCast(&S.logCb);
defer {
global.log = null;
}
// Format a message larger than the 2048-byte buffer.
// 'A' repeated 3000 times via a fill format.
const fill: [3000]u8 = .{0x41} ** 3000;
logFn(.info, .default, "{s}", .{@as([]const u8, &fill)});
try std.testing.expect(S.call_count >= 2);
try std.testing.expectEqual(@as(usize, 3000), S.total_len);
}
test "logFn message exactly at chunk boundary" {
const S = struct {
var call_count: usize = 0;
var total_len: usize = 0;
fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, msg: [*]const u8, msg_len: usize) callconv(lib.calling_conv) void {
_ = msg;
call_count += 1;
total_len += msg_len;
std.debug.assert(msg_len <= 2048);
}
};
S.call_count = 0;
S.total_len = 0;
global.log = @ptrCast(&S.logCb);
defer {
global.log = null;
}
// Exactly 2048 bytes — should emit one full chunk, no remainder.
const fill: [2048]u8 = .{0x42} ** 2048;
logFn(.info, .default, "{s}", .{@as([]const u8, &fill)});
try std.testing.expectEqual(@as(usize, 1), S.call_count);
try std.testing.expectEqual(@as(usize, 2048), S.total_len);
}
test "logFn message exactly double chunk size" {
const S = struct {
var call_count: usize = 0;
var total_len: usize = 0;
fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, msg: [*]const u8, msg_len: usize) callconv(lib.calling_conv) void {
_ = msg;
call_count += 1;
total_len += msg_len;
std.debug.assert(msg_len <= 2048);
}
};
S.call_count = 0;
S.total_len = 0;
global.log = @ptrCast(&S.logCb);
defer {
global.log = null;
}
// Exactly 4096 bytes — should emit exactly two full chunks.
const fill: [4096]u8 = .{0x43} ** 4096;
logFn(.info, .default, "{s}", .{@as([]const u8, &fill)});
try std.testing.expectEqual(@as(usize, 2), S.call_count);
try std.testing.expectEqual(@as(usize, 4096), S.total_len);
}
test "set userdata" {
var data: u32 = 42;
try std.testing.expectEqual(Result.success, set(.userdata, @ptrCast(&data)));