From aa6943da378a9b5b985d14449baa284a738e51f5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Apr 2026 10:17:40 -0700 Subject: [PATCH] libghostty: add log callback configuration 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. --- include/ghostty/vt/sys.h | 76 ++++++++++ src/lib_vt.zig | 10 +- src/terminal/c/main.zig | 1 + src/terminal/c/sys.zig | 293 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 377 insertions(+), 3 deletions(-) diff --git a/include/ghostty/vt/sys.h b/include/ghostty/vt/sys.h index e3d6e2cc7..ae9059692 100644 --- a/include/ghostty/vt/sys.h +++ b/include/ghostty/vt/sys.h @@ -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 diff --git a/src/lib_vt.zig b/src/lib_vt.zig index ff11177da..b8b9658fb 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -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 .{}; }; diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index e7a7db68a..8bd98a169 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -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; diff --git a/src/terminal/c/sys.zig b/src/terminal/c/sys.zig index 9677c8794..c4b2b17f2 100644 --- a/src/terminal/c/sys.zig +++ b/src/terminal/c/sys.zig @@ -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)));