diff --git a/src/logging/log.zig b/src/logging/log.zig index d322728..a6a2b4c 100644 --- a/src/logging/log.zig +++ b/src/logging/log.zig @@ -9,6 +9,7 @@ pub const Level = enum { info, warn, @"error", + fatal, }; /// Configuration passed to `Logger.init`. @@ -25,78 +26,102 @@ pub const Level = enum { /// or a log file). The logger takes no ownership of it beyond the /// lifetime of the wrapping `std.Io.File.Writer`. pub const InitOptions = struct { - level: Level = .info, - buffer: usize = 1024, - file: std.Io.File, - io: std.Io, + pub const ErrorCallback = Logger.ErrorCallback; + + level: Level = .info, + buffer: usize = 1024, + on_error: ErrorCallback = null, + file: std.Io.File, + io: std.Io, }; -/// A minimal, thread-safe, buffered logger. +/// Move‑safe, thread‑safe, buffered file logger. /// -/// Writes are buffered and only flushed automatically on `warn`, -/// `@"error"`, or `deinit`. +/// All mutable writer state is stored on the heap behind a stable pointer. +/// 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, /// a higher-severity message is logged, `flush` or `deinit` is called. /// -/// Must be initialized with `init` before use and cleaned up with -/// `deinit`. Not copyable once initialized, as `mutex` and `writer` -/// hold state tied to the original instance. +/// Unless an `ErrorCallback` is specified all errors in the logging functions will be swallowed. pub const Logger = struct { - level: Level = .info, - buffer: []u8 = undefined, - writer: std.Io.File.Writer = undefined, - mutex: std.Io.Mutex = undefined, - alloc: std.mem.Allocator = undefined, - io: std.Io = undefined, + pub const ErrorCallback = ?*const fn (err: anyerror) void; + + level: Level = .info, + on_error: ErrorCallback = null, + + io: std.Io = undefined, + state: *WriterState = undefined, + mutex: std.Io.Mutex = undefined, + allocator: std.mem.Allocator = undefined, + const Self = @This(); + /// Heap-allocated home for the write buffer and the `File.Writer` + /// built on top of it. `File.Writer` holds a pointer into + /// `buffer`, so both must live at a stable address that survives + /// the `Logger` value itself being copied or moved. + const WriterState = struct { + buffer: []u8, + writer: std.Io.File.Writer, + }; + /// Creates and returns an initialized `Logger`. /// /// Allocates `options.buffer` bytes from `allocator` for internal - /// write buffering; ownership of this allocation belongs to the - /// returned `Logger` and is freed in `deinit`. + /// write buffering, along with a `WriterState` to hold that buffer + /// 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 /// it must remain valid for the logger's lifetime. /// - /// Safe to move or copy the returned value freely before first - /// 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. + /// Returns an error if either allocation fails. pub fn init(allocator: std.mem.Allocator, options: InitOptions) !Self { const buffer = try allocator.alloc(u8, options.buffer); + errdefer allocator.free(buffer); - return Self{ - .level = options.level, + const state = try allocator.create(WriterState); + state.* = .{ .buffer = buffer, .writer = options.file.writer(options.io, buffer), - .mutex = .init, - .alloc = allocator, - .io = options.io, + }; + + return Self{ + .level = options.level, + .on_error = options.on_error, + .io = options.io, + .state = state, + .mutex = .init, + .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. /// Flush errors are silently ignored, since there is no caller /// left to meaningfully report them to at teardown time. /// - /// Must not be called more than once, as the buffer is freed - /// unconditionally. + /// Must not be called more than once, as the underlying + /// allocations are freed unconditionally. pub fn deinit(logger: *Self) void { logger.mutex.lock(logger.io) catch {}; - logger.writer.interface.flush() catch {}; + logger.state.writer.interface.flush() catch {}; logger.mutex.unlock(logger.io); - logger.alloc.free(logger.buffer); + logger.allocator.free(logger.state.buffer); + logger.allocator.destroy(logger.state); } // @@ -121,19 +146,23 @@ pub const Logger = struct { try logger.mutex.lock(logger.io); defer logger.mutex.unlock(logger.io); - try logger.writer.interface.print(fmt, args); + try logger.state.writer.interface.print(fmt, args); + try logger.state.writer.interface.writeByte('\n'); - if (level == .warn or level == .@"error") { - try logger.writer.interface.flush(); + switch (level) + { + .warn, .@"error" => { try logger.state.writer.interface.flush(); }, + .fatal => { std.debug.panic(fmt, args); }, + else => return } } // - pub fn debug(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .debug) catch {}; } - pub fn info(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .info) catch {}; } - pub fn warn(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .warn) catch {}; } - pub fn @"error"(logger: *Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .@"error") 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 |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 |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 |e| if (logger.on_error) |h| h(e); } /// Forces any buffered output to be written to the underlying /// writer immediately. @@ -144,7 +173,7 @@ pub const Logger = struct { try logger.mutex.lock(logger.io); defer logger.mutex.unlock(logger.io); - try logger.writer.interface.flush(); + try logger.state.writer.interface.flush(); } }; @@ -178,9 +207,9 @@ test "filtering, buffering and automatic flushing" const dir = try std.Io.Dir.openDirAbsolute(io, path, .{}); defer { - dir.deleteFile(io, fname) catch unreachable; + dir.deleteFile(io, fname) catch {}; 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 }); @@ -206,10 +235,10 @@ test "filtering, buffering and automatic flushing" { const contents = try dir.readFile(io, fname, &read_buf); - try std.testing.expectEqualStrings("info=1", contents); + try std.testing.expectEqualStrings("info=1\n", contents); } try file.setLength(io, 0); - try log.writer.seekTo(0); + try log.state.writer.seekTo(0); @memset(read_buf[0..], 0); // The file should include 'warn=1' as warnings and errors automatically trigger a flush to disk. @@ -217,10 +246,10 @@ test "filtering, buffering and automatic flushing" { const contents = try dir.readFile(io, fname, &read_buf); - try std.testing.expectEqualStrings("warn=1", contents); + try std.testing.expectEqualStrings("warn=1\n", contents); } try file.setLength(io, 0); - try log.writer.seekTo(0); + try log.state.writer.seekTo(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. @@ -228,9 +257,10 @@ test "filtering, buffering and automatic flushing" { const contents = try dir.readFile(io, fname, &read_buf); + // 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=" ++ ([_]u8{'x'} ** 300), contents); } try file.setLength(io, 0); - try log.writer.seekTo(0); + try log.state.writer.seekTo(0); @memset(read_buf[0..], 0); }