Compare commits

..

3 Commits

View File

@@ -9,6 +9,35 @@ pub const Level = enum {
info, info,
warn, warn,
@"error", @"error",
fatal,
/// Returns the fixed-width, uppercase label used as the log-line
/// prefix for `level` (e.g. `"INFO "` for `.info`), derived from
/// the enum's own field name via `@tagName`.
/// All labels are padded to the width of the longest level name,
/// so prefixes line up in a fixed-width terminal/file.
fn prefix(level: Level) []const u8
{
return switch (level)
{
inline else => |l| comptime comptimeTag(@tagName(l)),
};
}
fn comptimeTag(comptime name: []const u8) []const u8
{
const inner_width = 5;
const total_width = inner_width + 2; // account for "[" and "]"
comptime var buf: [total_width]u8 = .{' '} ** total_width;
buf[0] = '[';
inline for (name, 0..) |c, i| buf[i + 1] = std.ascii.toUpper(c);
buf[name.len + 1] = ']';
const result = buf;
return &result;
}
}; };
/// Configuration passed to `Logger.init`. /// Configuration passed to `Logger.init`.
@@ -25,78 +54,107 @@ pub const Level = enum {
/// or a log file). The logger takes no ownership of it beyond the /// or a log file). The logger takes no ownership of it beyond the
/// lifetime of the wrapping `std.Io.File.Writer`. /// lifetime of the wrapping `std.Io.File.Writer`.
pub const InitOptions = struct { pub const InitOptions = struct {
pub const ErrorCallback = Logger.ErrorCallback;
level: Level = .info, level: Level = .info,
buffer: usize = 1024, buffer: usize = 1024,
on_error: ErrorCallback = null,
file: std.Io.File, file: std.Io.File,
io: std.Io, io: std.Io,
}; };
/// A minimal, thread-safe, buffered logger. /// Movesafe, threadsafe, buffered file logger.
/// ///
/// Writes are buffered and only flushed automatically on `warn`, /// All mutable writer state is stored on the heap behind a stable pointer.
/// `@"error"`, or `deinit`. /// The `Logger` struct itself can be copied or moved freely.
///
/// `level` sets the minimum severity that will be written; messages
/// below this level are discarded.
///
/// Writes are buffered and only flushed automatically on `warn`, `@"error"`,
/// when calling `deinit` or due to drain-on-overflow of the write buffer.
/// `debug` and `info` messages may sit in the buffer until it fills, /// `debug` and `info` messages may sit in the buffer until it fills,
/// a higher-severity message is logged, `flush` or `deinit` is called. /// a higher-severity message is logged, `flush` or `deinit` is called.
/// ///
/// Must be initialized with `init` before use and cleaned up with /// Unless an `ErrorCallback` is specified all errors in the logging functions will be swallowed.
/// `deinit`. Not copyable once initialized, as `mutex` and `writer`
/// hold state tied to the original instance.
pub const Logger = struct { pub const Logger = struct {
pub const ErrorCallback = ?*const fn (err: anyerror) void;
level: Level = .info, level: Level = .info,
buffer: []u8 = undefined, on_error: ErrorCallback = null,
writer: std.Io.File.Writer = undefined,
mutex: std.Io.Mutex = undefined, io: std.Io,
alloc: std.mem.Allocator = undefined, state: *WriterState,
io: std.Io = undefined, allocator: std.mem.Allocator,
const Self = @This(); const Self = @This();
/// Heap-allocated home for the write buffer, the `File.Writer`
/// built on top of it, and the mutex guarding both. `File.Writer`
/// holds a pointer into `buffer`, so all three must live at a
/// stable address that survives the `Logger` value itself being
/// copied or moved.
const WriterState = struct {
buffer: []u8,
mutex: std.Io.Mutex,
writer: std.Io.File.Writer,
};
/// Creates and returns an initialized `Logger`. /// Creates and returns an initialized `Logger`.
/// ///
/// Allocates `options.buffer` bytes from `allocator` for internal /// Allocates `options.buffer` bytes from `allocator` for internal
/// write buffering; ownership of this allocation belongs to the /// write buffering, along with a `WriterState` to hold that buffer
/// returned `Logger` and is freed in `deinit`. /// and its `File.Writer` at a stable heap address. Ownership of
/// both allocations belongs to the returned `Logger` and are freed
/// in `deinit`.
/// ///
/// `allocator` is stored on the result and reused for cleanup, so /// `allocator` is stored on the result and reused for cleanup, so
/// it must remain valid for the logger's lifetime. /// it must remain valid for the logger's lifetime.
/// ///
/// Safe to move or copy the returned value freely before first /// Returns an error if either allocation fails.
/// use, since the write buffer lives on the heap rather than
/// inside the `Logger` struct itself. Once a method has been
/// called on it, the logger must stay at a fixed address (e.g.
/// behind a pointer or in a `var` that isn't reassigned by value)
/// for the remainder of its life.
///
/// Returns an error if the buffer allocation fails.
pub fn init(allocator: std.mem.Allocator, options: InitOptions) !Self pub fn init(allocator: std.mem.Allocator, options: InitOptions) !Self
{ {
const buffer = try allocator.alloc(u8, options.buffer); const buffer = try allocator.alloc(u8, options.buffer);
errdefer allocator.free(buffer);
const state = try allocator.create(WriterState);
state.* = .{
.buffer = buffer,
.mutex = .init,
.writer = options.file.writer(options.io, buffer),
};
return Self{ return Self{
.level = options.level, .level = options.level,
.buffer = buffer, .on_error = options.on_error,
.writer = options.file.writer(options.io, buffer),
.mutex = .init,
.alloc = allocator,
.io = options.io, .io = options.io,
.state = state,
.allocator = allocator,
}; };
} }
/// Flushes any buffered output and releases the logger's buffer. /// Flushes any buffered output and releases the logger's buffer
/// and `WriterState`.
/// ///
/// Safe to call once initialization via `init` has succeeded. /// Safe to call once initialization via `init` has succeeded.
/// Flush errors are silently ignored, since there is no caller /// Flush errors are silently ignored, since there is no caller
/// left to meaningfully report them to at teardown time. /// left to meaningfully report them to at teardown time.
/// ///
/// Must not be called more than once, as the buffer is freed /// Must not be called more than once, as the underlying
/// unconditionally. /// allocations are freed unconditionally.
pub fn deinit(logger: *Self) void pub fn deinit(logger: *Self) void
{ {
logger.mutex.lock(logger.io) catch {}; if (logger.state.mutex.lock(logger.io)) |_|
logger.writer.interface.flush() catch {}; {
logger.mutex.unlock(logger.io); logger.state.writer.interface.flush() catch {};
logger.state.mutex.unlock(logger.io);
}
else |_| {}
logger.alloc.free(logger.buffer); logger.allocator.free(logger.state.buffer);
logger.allocator.destroy(logger.state);
} }
// //
@@ -118,22 +176,54 @@ pub const Logger = struct {
{ {
if (@intFromEnum(level) < @intFromEnum(logger.level)) return; if (@intFromEnum(level) < @intFromEnum(logger.level)) return;
try logger.mutex.lock(logger.io); if (level == .fatal) return logger.fatalLog(fmt, args);
defer logger.mutex.unlock(logger.io);
try logger.writer.interface.print(fmt, args); try logger.state.mutex.lock(logger.io);
defer logger.state.mutex.unlock(logger.io);
if (level == .warn or level == .@"error") { try logger.state.writer.interface.print(
try logger.writer.interface.flush(); "{s} " ++ fmt,
.{Level.prefix(level)} ++ args
);
try logger.state.writer.interface.writeByte('\n');
switch (level)
{
.warn, .@"error" => { try logger.state.writer.interface.flush(); },
else => return,
} }
} }
/// Best-effort write-then-panic path for `.fatal` messages.
///
/// Ignores lock/write/flush failures rather than propagating them,
/// a failure to persist the fatal message must never prevent the
/// panic itself. Marked cold since this is checked on every `log`
/// call but taken essentially never.
fn fatalLog(logger: *Self, comptime fmt: []const u8, args: anytype) noreturn
{
@branchHint(.cold);
logger.state.mutex.lock(logger.io) catch {};
logger.state.writer.interface.print(
"{s} " ++ fmt,
.{Level.prefix(.fatal)} ++ args
) catch {};
logger.state.writer.interface.writeByte('\n') catch {};
logger.state.writer.interface.flush() catch {};
std.debug.panic(fmt, args);
}
// //
pub fn debug(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .debug) catch {}; } pub fn debug(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .debug) catch |e| if (logger.on_error) |h| h(e); }
pub fn info(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .info) catch {}; } pub fn info(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .info) catch |e| if (logger.on_error) |h| h(e); }
pub fn warn(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .warn) catch {}; } pub fn warn(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .warn) catch |e| if (logger.on_error) |h| h(e); }
pub fn @"error"(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .@"error") catch {}; } pub fn @"error"(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .@"error") catch |e| if (logger.on_error) |h| h(e); }
pub fn fatal(logger: *Self, comptime fmt: []const u8, args: anytype) noreturn { logger.log(fmt, args, .fatal) catch unreachable; unreachable; }
/// Forces any buffered output to be written to the underlying /// Forces any buffered output to be written to the underlying
/// writer immediately. /// writer immediately.
@@ -141,10 +231,10 @@ pub const Logger = struct {
/// Returns an error if the underlying writer fails to flush. /// Returns an error if the underlying writer fails to flush.
pub fn flush(logger: *Self) !void pub fn flush(logger: *Self) !void
{ {
try logger.mutex.lock(logger.io); try logger.state.mutex.lock(logger.io);
defer logger.mutex.unlock(logger.io); defer logger.state.mutex.unlock(logger.io);
try logger.writer.interface.flush(); try logger.state.writer.interface.flush();
} }
}; };
@@ -178,9 +268,9 @@ test "filtering, buffering and automatic flushing"
const dir = try std.Io.Dir.openDirAbsolute(io, path, .{}); const dir = try std.Io.Dir.openDirAbsolute(io, path, .{});
defer defer
{ {
dir.deleteFile(io, fname) catch unreachable; dir.deleteFile(io, fname) catch {};
dir.close(io); dir.close(io);
std.Io.Dir.deleteDirAbsolute(io, path) catch unreachable; std.Io.Dir.deleteDirAbsolute(io, path) catch {};
} }
const file = try dir.createFile(io, fname, .{ .lock = .exclusive }); const file = try dir.createFile(io, fname, .{ .lock = .exclusive });
@@ -206,10 +296,10 @@ test "filtering, buffering and automatic flushing"
{ {
const contents = try dir.readFile(io, fname, &read_buf); const contents = try dir.readFile(io, fname, &read_buf);
try std.testing.expectEqualStrings("info=1", contents); try std.testing.expectEqualStrings("[INFO] info=1\n", contents);
} }
try file.setLength(io, 0); try file.setLength(io, 0);
try log.writer.seekTo(0); try log.state.writer.seekTo(0);
@memset(read_buf[0..], 0); @memset(read_buf[0..], 0);
// The file should include 'warn=1' as warnings and errors automatically trigger a flush to disk. // The file should include 'warn=1' as warnings and errors automatically trigger a flush to disk.
@@ -217,10 +307,10 @@ test "filtering, buffering and automatic flushing"
{ {
const contents = try dir.readFile(io, fname, &read_buf); const contents = try dir.readFile(io, fname, &read_buf);
try std.testing.expectEqualStrings("warn=1", contents); try std.testing.expectEqualStrings("[WARN] warn=1\n", contents);
} }
try file.setLength(io, 0); try file.setLength(io, 0);
try log.writer.seekTo(0); try log.state.writer.seekTo(0);
@memset(read_buf[0..], 0); @memset(read_buf[0..], 0);
// The file should include 'info=x...' as it's bigger than the allocated 256 buffer, forcing it to automatically drain on overflow. // The file should include 'info=x...' as it's bigger than the allocated 256 buffer, forcing it to automatically drain on overflow.
@@ -228,9 +318,10 @@ test "filtering, buffering and automatic flushing"
{ {
const contents = try dir.readFile(io, fname, &read_buf); const contents = try dir.readFile(io, fname, &read_buf);
try std.testing.expectEqualStrings("info=" ++ ([_]u8{'x'} ** 300), contents); // Note: This doesn't expect a newline, as it only drains the actual overflow, not the newline written into the buffer afterwards!
try std.testing.expectEqualStrings("[INFO] info=" ++ ([_]u8{'x'} ** 300), contents);
} }
try file.setLength(io, 0); try file.setLength(io, 0);
try log.writer.seekTo(0); try log.state.writer.seekTo(0);
@memset(read_buf[0..], 0); @memset(read_buf[0..], 0);
} }