mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-17 04:52:47 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 .{};
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user