diff --git a/src/logging/log.zig b/src/logging/log.zig index 48f317b..9f58c3b 100644 --- a/src/logging/log.zig +++ b/src/logging/log.zig @@ -40,15 +40,16 @@ pub const Level = enum { } }; -/// Configuration passed to `Logger.init`. +/// Configuration used to create an initial `Logger` handle. /// -/// `level` sets the minimum severity that will be written; messages -/// below this level are discarded. +/// `level` sets the initial handle's minimum severity. A logger made +/// with `clone` receives an independent copy of this setting and may +/// change it without affecting other logger handles that share the +/// same writer. /// -/// `buffer` is the size, in bytes, allocated for the internal write -/// buffer. Larger buffers reduce the number of underlying -/// writes at the cost of more memory and higher latency -/// before data is flushed. +/// `buffer` is the size, in bytes, allocated for the shared internal +/// write buffer. Larger buffers can reduce underlying writes at the +/// cost of memory use and latency before buffered messages are flushed. pub const InitOptions = struct { pub const ErrorCallback = Logger.ErrorCallback; @@ -59,54 +60,72 @@ pub const InitOptions = struct { io: std.Io, }; -/// Move‑safe, thread‑safe, buffered file logger. +/// Thread-safe, move-safe, copy-safe (through `.clone`), buffered file logger +/// with shared writer state. /// -/// All mutable writer state is stored on the heap behind a stable pointer. -/// The `Logger` struct itself can be copied or moved freely. +/// A `Logger` is an owning handle to shared writer state. Handles made +/// with `clone` share one buffer, `File.Writer`, I/O context, and mutex, +/// so writes from all clones are serialized and cannot interleave. /// -/// `level` sets the minimum severity that will be written; messages -/// below this level are discarded. +/// `level` and `on_error` belong to each individual logger handle. +/// Consequently, clones may use different severity filters and error +/// callbacks while writing to the same destination. /// -/// 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. +/// Use `clone` to create another owning logger handle. Do not duplicate +/// a `Logger` through assignment, aggregate initialization, or +/// `@memcpy`; those operations do not retain the shared state. Every +/// logger returned by `init` or `clone` must be passed to `deinit` +/// exactly once. /// -/// Unless an `ErrorCallback` is specified all errors in the logging functions will be swallowed. +/// Writes are buffered and automatically flushed for `warn`, `@"error"`, +/// and `fatal` messages; they are also flushed when the final owning +/// handle is deinitialized or when the writer drains due to buffer +/// overflow. `debug` and `info` messages may remain buffered until then, +/// or until `flush` is called. +/// +/// Fatal logging is best-effort: the logger attempts to lock, write, and +/// flush the fatal message, but ignores failures so logging failures can +/// never prevent the subsequent panic. +/// +/// Unless an `ErrorCallback` is specified, errors from the public +/// logging methods are swallowed. pub const Logger = struct { pub const ErrorCallback = ?*const fn (err: anyerror) void; level: Level = .info, on_error: ErrorCallback = null, - - io: std.Io, state: *WriterState, - allocator: std.mem.Allocator, 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. + /// Heap-allocated state shared by all `Logger` clones. + /// + /// This stores the buffer, writer, I/O context, and mutex together at + /// a stable address. `File.Writer` retains a pointer into `buffer`, so + /// neither may move for as long as any owning logger handle remains. + /// + /// `refs` counts owning handles created by `init` and `clone`. The last + /// handle released by `deinit` flushes the writer and frees this state. const WriterState = struct { - buffer: []u8, - mutex: std.Io.Mutex, - writer: std.Io.File.Writer, + refs: std.atomic.Value(usize) = .init(1), + + io: std.Io, + allocator: std.mem.Allocator, + buffer: []u8, + mutex: std.Io.Mutex, + writer: std.Io.File.Writer, }; - /// Creates and returns an initialized `Logger`. + /// Creates an initialized owning logger handle. /// - /// Allocates `options.buffer` bytes from `allocator` for internal - /// 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`. + /// Allocates `options.buffer` bytes for the shared write buffer and a + /// `WriterState` containing the buffer's writer, I/O context, mutex, + /// allocator, and initial ownership reference. /// - /// `allocator` is stored on the result and reused for cleanup, so - /// it must remain valid for the logger's lifetime. + /// The returned logger owns one reference to the shared state. It must + /// be passed to `deinit` exactly once, unless ownership is explicitly + /// transferred to another part of the program. /// /// Returns an error if either allocation fails. pub fn init(allocator: std.mem.Allocator, options: InitOptions) !Self @@ -116,42 +135,57 @@ pub const Logger = struct { const state = try allocator.create(WriterState); state.* = .{ - .buffer = buffer, - .mutex = .init, - .writer = options.file.writer(options.io, buffer), + .allocator = allocator, + .io = options.io, + .buffer = buffer, + .mutex = .init, + .writer = options.file.writer(options.io, buffer), }; return Self{ .level = options.level, .on_error = options.on_error, - - .io = options.io, .state = state, - .allocator = allocator, }; } - /// Flushes any buffered output and releases the logger's buffer - /// and `WriterState`. + /// Releases this logger handle's ownership of the shared writer state. /// - /// Safe to call once initialization via `init` has succeeded. - /// Flush and lock errors are reported via `on_error` if set, - /// but otherwise ignored, since there is no caller left to - /// meaningfully return them to at teardown time. + /// If other logger handles created with `clone` remain, this only + /// decrements the shared reference count. The final owning handle + /// flushes buffered output and frees the shared buffer and `WriterState`. /// - /// Must not be called more than once, as the underlying - /// allocations are freed unconditionally. + /// Flush and lock errors during final cleanup are reported through this + /// handle's `on_error` callback when one is configured; otherwise they + /// are ignored. + /// + /// Each logger returned by `init` or `clone` must be deinitialized + /// exactly once. pub fn deinit(logger: *Self) void { - if (logger.state.mutex.lock(logger.io)) |_| + const state = logger.state; + + // Another owning logger remains responsible for the shared state. + if (state.refs.fetchSub(1, .acq_rel) != 1) return; + + if (state.mutex.lock(state.io)) |_| { - logger.state.writer.interface.flush() catch |e| if (logger.on_error) |h| h(e); - logger.state.mutex.unlock(logger.io); + state.writer.interface.flush() catch |e| if (logger.on_error) |h| h(e); + state.mutex.unlock(state.io); } else |e| { if (logger.on_error) |h| h(e); } - logger.allocator.free(logger.state.buffer); - logger.allocator.destroy(logger.state); + state.allocator.free(state.buffer); + state.allocator.destroy(state); + } + + /// Returns another owning logger handle that shares the same buffered + /// writer and mutex, while retaining this handle's current level and + /// error callback by value. + pub fn clone(logger: *const Self) Self + { + _ = logger.state.refs.fetchAdd(1, .monotonic); + return logger.*; } // @@ -175,8 +209,8 @@ pub const Logger = struct { if (level == .fatal) return logger.fatalLog(fmt, args); - try logger.state.mutex.lock(logger.io); - defer logger.state.mutex.unlock(logger.io); + try logger.state.mutex.lock(logger.state.io); + defer logger.state.mutex.unlock(logger.state.io); try logger.state.writer.interface.print( "{s} " ++ fmt, @@ -201,7 +235,7 @@ pub const Logger = struct { { @branchHint(.cold); - logger.state.mutex.lock(logger.io) catch {}; + logger.state.mutex.lock(logger.state.io) catch {}; logger.state.writer.interface.print( "{s} " ++ fmt, @@ -228,8 +262,8 @@ pub const Logger = struct { /// Returns an error if the underlying writer fails to flush. pub fn flush(logger: *Self) !void { - try logger.state.mutex.lock(logger.io); - defer logger.state.mutex.unlock(logger.io); + try logger.state.mutex.lock(logger.state.io); + defer logger.state.mutex.unlock(logger.state.io); try logger.state.writer.interface.flush(); }