Compare commits
19 Commits
250829e922
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 69be6470b3 | |||
| 5c1ad319bc | |||
| 34cad1c078 | |||
| 0d3e97423a | |||
| f1b07147b1 | |||
| 963f1611fb | |||
| bea0b6668a | |||
| 31c74fc960 | |||
| d56010b064 | |||
| 7ae1907189 | |||
| 2a6ade9bf7 | |||
| 7c97ad554e | |||
| a8ff9d95bb | |||
| 2688cacfa7 | |||
| 0a7ad2662c | |||
| 88d312e713 | |||
| d70a051a57 | |||
| b1bdfccd1e | |||
| 84c4158aaa |
27
build.zig
27
build.zig
@@ -1,8 +1,13 @@
|
|||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const arch = @import("builtin").cpu.arch;
|
||||||
|
|
||||||
pub fn build(b: *std.Build) void
|
pub fn build(b: *std.Build) void
|
||||||
{
|
{
|
||||||
const target = b.standardTargetOptions(.{});
|
const target = b.resolveTargetQuery(.{
|
||||||
|
.cpu_arch = arch,
|
||||||
|
.os_tag = .linux,
|
||||||
|
.abi = .musl
|
||||||
|
});
|
||||||
const optimize = b.standardOptimizeOption(.{});
|
const optimize = b.standardOptimizeOption(.{});
|
||||||
|
|
||||||
const version = getGitVersion(b);
|
const version = getGitVersion(b);
|
||||||
@@ -29,9 +34,18 @@ pub fn build(b: *std.Build) void
|
|||||||
.target = target,
|
.target = target,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const test_mod = b.createModule(.{
|
||||||
|
.root_source_file = b.path("src/root.zig"),
|
||||||
|
.target = target,
|
||||||
|
.link_libc = true,
|
||||||
|
});
|
||||||
|
|
||||||
mod.addImport("toml", toml_dep.module("toml"));
|
mod.addImport("toml", toml_dep.module("toml"));
|
||||||
mod.addImport("args", args_dep.module("args"));
|
mod.addImport("args", args_dep.module("args"));
|
||||||
|
|
||||||
|
test_mod.addImport("toml", toml_dep.module("toml"));
|
||||||
|
test_mod.addImport("args", args_dep.module("args"));
|
||||||
|
|
||||||
const exe = b.addExecutable(.{
|
const exe = b.addExecutable(.{
|
||||||
.name = "zocket",
|
.name = "zocket",
|
||||||
.root_module = b.createModule(.{
|
.root_module = b.createModule(.{
|
||||||
@@ -61,20 +75,13 @@ pub fn build(b: *std.Build) void
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mod_tests = b.addTest(.{
|
const mod_tests = b.addTest(.{
|
||||||
.root_module = mod,
|
.root_module = test_mod,
|
||||||
});
|
});
|
||||||
|
|
||||||
const run_mod_tests = b.addRunArtifact(mod_tests);
|
const run_mod_tests = b.addRunArtifact(mod_tests);
|
||||||
|
|
||||||
const exe_tests = b.addTest(.{
|
|
||||||
.root_module = exe.root_module,
|
|
||||||
});
|
|
||||||
|
|
||||||
const run_exe_tests = b.addRunArtifact(exe_tests);
|
|
||||||
|
|
||||||
const test_step = b.step("test", "Run tests");
|
const test_step = b.step("test", "Run tests");
|
||||||
test_step.dependOn(&run_mod_tests.step);
|
test_step.dependOn(&run_mod_tests.step);
|
||||||
test_step.dependOn(&run_exe_tests.step);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn getGitVersion(b: *std.Build) []const u8
|
fn getGitVersion(b: *std.Build) []const u8
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ test "parse zocket config"
|
|||||||
|
|
||||||
const allocator = std.testing.allocator;
|
const allocator = std.testing.allocator;
|
||||||
|
|
||||||
const parsed = try parse(io, allocator, "zocket/config.toml");
|
const parsed = try parse(io, allocator, "config/config.toml");
|
||||||
defer parsed.deinit();
|
defer parsed.deinit();
|
||||||
|
|
||||||
const cfg = parsed.value;
|
const cfg = parsed.value;
|
||||||
|
|||||||
505
src/logging/log.zig
Normal file
505
src/logging/log.zig
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
/// Severity levels for log messages, ordered from least to most severe.
|
||||||
|
///
|
||||||
|
/// A `Logger`'s configured `level` acts as a filter: messages logged
|
||||||
|
/// at a lower severity than the logger's level are silently dropped.
|
||||||
|
pub const Level = enum(u8) {
|
||||||
|
debug,
|
||||||
|
info,
|
||||||
|
warn,
|
||||||
|
@"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 width = blk: {
|
||||||
|
var max = 0;
|
||||||
|
for (@typeInfo(Level).@"enum".fields) |f| max = @max(max, f.name.len);
|
||||||
|
break :blk max;
|
||||||
|
} + 2; // account for "[" and "]"
|
||||||
|
|
||||||
|
comptime var buf: [width]u8 = .{' '} ** 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 used to create an initial `Logger` handle.
|
||||||
|
///
|
||||||
|
/// `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 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;
|
||||||
|
|
||||||
|
level: Level = .info,
|
||||||
|
buffer: usize = 1024,
|
||||||
|
on_error: ?ErrorCallback = null,
|
||||||
|
file: std.Io.File,
|
||||||
|
io: std.Io,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Thread-safe, move-safe, copy-safe (through `.clone`), buffered file logger
|
||||||
|
/// with shared writer state.
|
||||||
|
///
|
||||||
|
/// 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` 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.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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 = struct {
|
||||||
|
ctx: ?*anyopaque,
|
||||||
|
func: *const fn (ctx: ?*anyopaque, err: anyerror) void,
|
||||||
|
|
||||||
|
pub fn call(cb: ErrorCallback, e: anyerror) void { cb.func(cb.ctx, e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
level: std.atomic.Value(Level) = .init(.info),
|
||||||
|
on_error: ?ErrorCallback = null,
|
||||||
|
state: *WriterState,
|
||||||
|
|
||||||
|
|
||||||
|
const Self = @This();
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
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 an initialized owning logger handle.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
{
|
||||||
|
const buffer = try allocator.alloc(u8, options.buffer);
|
||||||
|
errdefer allocator.free(buffer);
|
||||||
|
|
||||||
|
const state = try allocator.create(WriterState);
|
||||||
|
state.* = .{
|
||||||
|
.allocator = allocator,
|
||||||
|
.io = options.io,
|
||||||
|
.buffer = buffer,
|
||||||
|
.mutex = .init,
|
||||||
|
.writer = options.file.writer(options.io, buffer),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Self{
|
||||||
|
.level = .init(options.level),
|
||||||
|
.on_error = options.on_error,
|
||||||
|
.state = state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases this logger handle's ownership of the shared writer state.
|
||||||
|
///
|
||||||
|
/// 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`.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
|
{
|
||||||
|
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)) |_|
|
||||||
|
{
|
||||||
|
if (state.writer.interface.flush()) |_| { state.mutex.unlock(state.io); }
|
||||||
|
else |e|
|
||||||
|
{
|
||||||
|
state.mutex.unlock(state.io);
|
||||||
|
if (logger.on_error) |h| h.call(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else |e| if (logger.on_error) |h| h.call(e);
|
||||||
|
|
||||||
|
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 .{
|
||||||
|
.level = .init(logger.level.load(.monotonic)),
|
||||||
|
.on_error = logger.on_error,
|
||||||
|
.state = logger.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new owning handle like `clone`, but with a different
|
||||||
|
/// severity filter.
|
||||||
|
pub fn cloneWith(logger: *const Self, level: Level) Self
|
||||||
|
{
|
||||||
|
var c = logger.clone();
|
||||||
|
c.level.store(level, .monotonic);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
/// Formats and writes a log message at the given `level`.
|
||||||
|
///
|
||||||
|
/// Messages below `logger.level` are dropped before anything is
|
||||||
|
/// written or locked. `warn` and `@"error"` messages are flushed
|
||||||
|
/// immediately so they reach the destination even if the process
|
||||||
|
/// crashes shortly after; `debug` and `info` messages are left
|
||||||
|
/// buffered for efficiency.
|
||||||
|
///
|
||||||
|
/// No buffer size/overflow checks as the writer automatically
|
||||||
|
/// drains on overflow.
|
||||||
|
///
|
||||||
|
/// Returns an error if formatting or writing to the underlying
|
||||||
|
/// writer fails.
|
||||||
|
fn log(logger: *const Self, comptime fmt: []const u8, args: anytype, level: Level) !void
|
||||||
|
{
|
||||||
|
if (@intFromEnum(level) < @intFromEnum(logger.level.load(.monotonic))) return;
|
||||||
|
|
||||||
|
if (level == .fatal) return logger.fatalLog(fmt, args);
|
||||||
|
|
||||||
|
try logger.state.mutex.lock(logger.state.io);
|
||||||
|
defer logger.state.mutex.unlock(logger.state.io);
|
||||||
|
|
||||||
|
try logger.state.writer.interface.print(
|
||||||
|
"{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: *const Self, comptime fmt: []const u8, args: anytype) noreturn
|
||||||
|
{
|
||||||
|
@branchHint(.cold);
|
||||||
|
|
||||||
|
const f = "{s} " ++ fmt;
|
||||||
|
const a = .{Level.prefix(.fatal)} ++ args;
|
||||||
|
|
||||||
|
if (logger.state.mutex.tryLock())
|
||||||
|
{
|
||||||
|
logger.state.writer.interface.print(f, a) catch {};
|
||||||
|
logger.state.writer.interface.writeByte('\n') catch {};
|
||||||
|
logger.state.writer.interface.flush() catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
std.debug.panic(f, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
pub fn debug(logger: *const Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .debug) catch |e| if (logger.on_error) |h| h.call(e); }
|
||||||
|
pub fn info(logger: *const Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .info) catch |e| if (logger.on_error) |h| h.call(e); }
|
||||||
|
pub fn warn(logger: *const Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .warn) catch |e| if (logger.on_error) |h| h.call(e); }
|
||||||
|
pub fn @"error"(logger: *const Self, comptime fmt: []const u8, args: anytype) void { logger.log(fmt, args, .@"error") catch |e| if (logger.on_error) |h| h.call(e); }
|
||||||
|
pub fn fatal(logger: *const Self, comptime fmt: []const u8, args: anytype) noreturn { logger.log(fmt, args, .fatal) catch unreachable; unreachable; }
|
||||||
|
|
||||||
|
// alias
|
||||||
|
pub const err = @"error";
|
||||||
|
|
||||||
|
/// Forces any buffered output to be written to the underlying
|
||||||
|
/// writer immediately.
|
||||||
|
///
|
||||||
|
/// Returns an error if the underlying writer fails to flush.
|
||||||
|
pub fn flush(logger: *const Self) !void
|
||||||
|
{
|
||||||
|
try logger.state.mutex.lock(logger.state.io);
|
||||||
|
defer logger.state.mutex.unlock(logger.state.io);
|
||||||
|
|
||||||
|
try logger.state.writer.interface.flush();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
extern fn mkdtemp(template: [*:0]u8) ?[*:0]u8;
|
||||||
|
|
||||||
|
fn mktmpdir(buffer: *[64:0]u8, pattern: []const u8) ![]u8
|
||||||
|
{
|
||||||
|
if (pattern.len >= buffer.len) return error.PatternTooLong;
|
||||||
|
|
||||||
|
@memcpy(buffer[0..pattern.len], pattern);
|
||||||
|
buffer[pattern.len] = 0;
|
||||||
|
|
||||||
|
const result = mkdtemp(buffer) orelse return error.MkDtempFailed;
|
||||||
|
|
||||||
|
return std.mem.span(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "filtering, buffering and automatic flushing"
|
||||||
|
{
|
||||||
|
const io = std.testing.io;
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const fname = "logger.test";
|
||||||
|
|
||||||
|
var path_buf: [64:0]u8 = undefined;
|
||||||
|
var read_buf: [512]u8 = undefined;
|
||||||
|
|
||||||
|
const path = try mktmpdir(&path_buf,"/tmp/zocket-test-XXXXXX");
|
||||||
|
const dir = try std.Io.Dir.openDirAbsolute(io, path, .{});
|
||||||
|
defer
|
||||||
|
{
|
||||||
|
dir.deleteFile(io, fname) catch {};
|
||||||
|
dir.close(io);
|
||||||
|
std.Io.Dir.deleteDirAbsolute(io, path) catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = try dir.createFile(io, fname, .{ .lock = .exclusive });
|
||||||
|
defer file.close(io);
|
||||||
|
|
||||||
|
const log = try Logger.init(allocator, .{
|
||||||
|
.io = io, .buffer = 256, .file = file
|
||||||
|
});
|
||||||
|
defer log.deinit();
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
// The file is expected to be empty, as this does not force the internal buffer to auto-flush.
|
||||||
|
log.info("info={d}", .{1});
|
||||||
|
|
||||||
|
{
|
||||||
|
const contents = try dir.readFile(io, fname, &read_buf);
|
||||||
|
try std.testing.expect(contents.len == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file should include '[INFO] info=1\n' after flushing to disk.
|
||||||
|
try log.flush();
|
||||||
|
|
||||||
|
{
|
||||||
|
const contents = try dir.readFile(io, fname, &read_buf);
|
||||||
|
try std.testing.expectEqualStrings("[INFO] info=1\n", contents);
|
||||||
|
}
|
||||||
|
try file.setLength(io, 0);
|
||||||
|
try log.state.writer.seekTo(0);
|
||||||
|
@memset(read_buf[0..], 0);
|
||||||
|
|
||||||
|
// The file is expected to be empty, as debug is below the default level (info).
|
||||||
|
log.debug("debug={d}", .{1});
|
||||||
|
try log.flush();
|
||||||
|
|
||||||
|
{
|
||||||
|
const contents = try dir.readFile(io, fname, &read_buf);
|
||||||
|
try std.testing.expect(contents.len == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file should include '[ERROR] error=1\n' as warnings and errors automatically trigger a flush to disk.
|
||||||
|
log.err("error={d}", .{1});
|
||||||
|
|
||||||
|
{
|
||||||
|
const contents = try dir.readFile(io, fname, &read_buf);
|
||||||
|
try std.testing.expectEqualStrings("[ERROR] error=1\n", contents);
|
||||||
|
}
|
||||||
|
try file.setLength(io, 0);
|
||||||
|
try log.state.writer.seekTo(0);
|
||||||
|
@memset(read_buf[0..], 0);
|
||||||
|
|
||||||
|
// The file should include '[INFO] info=x...' as it's bigger than the allocated 256 buffer, forcing it to automatically drain on overflow.
|
||||||
|
log.info("info={s}", .{[_]u8{'x'} ** 300});
|
||||||
|
|
||||||
|
{
|
||||||
|
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] info=" ++ ([_]u8{'x'} ** 300), contents);
|
||||||
|
}
|
||||||
|
try file.setLength(io, 0);
|
||||||
|
try log.state.writer.seekTo(0);
|
||||||
|
@memset(read_buf[0..], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "init, shared state & individual handles, deinit"
|
||||||
|
{
|
||||||
|
const io = std.testing.io;
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
const fname = "logger-memory.test";
|
||||||
|
|
||||||
|
var path_buf: [64:0]u8 = undefined;
|
||||||
|
var read_buf: [512]u8 = undefined;
|
||||||
|
|
||||||
|
const path = try mktmpdir(&path_buf, "/tmp/zocket-test-XXXXXX");
|
||||||
|
const dir = try std.Io.Dir.openDirAbsolute(io, path, .{});
|
||||||
|
defer
|
||||||
|
{
|
||||||
|
dir.deleteFile(io, fname) catch {};
|
||||||
|
dir.close(io);
|
||||||
|
std.Io.Dir.deleteDirAbsolute(io, path) catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = try dir.createFile(io, fname, .{ .lock = .exclusive });
|
||||||
|
defer file.close(io);
|
||||||
|
|
||||||
|
// A failed init doesn't leak memory, partial allocations are freed.
|
||||||
|
{
|
||||||
|
var failing = std.testing.FailingAllocator.init(allocator, .{});
|
||||||
|
|
||||||
|
// First allocation (the buffer) fails.
|
||||||
|
failing.fail_index = failing.alloc_index;
|
||||||
|
try std.testing.expectError(error.OutOfMemory, Logger.init(failing.allocator(), .{
|
||||||
|
.io = io, .buffer = 128, .file = file,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Second allocation (the WriterState) fails.
|
||||||
|
failing.fail_index = failing.alloc_index + 1;
|
||||||
|
try std.testing.expectError(error.OutOfMemory, Logger.init(failing.allocator(), .{
|
||||||
|
.io = io, .buffer = 128, .file = file,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = try Logger.init(allocator, .{
|
||||||
|
.io = io, .buffer = 128, .file = file,
|
||||||
|
});
|
||||||
|
|
||||||
|
// init acquires exactly one ownership reference.
|
||||||
|
try std.testing.expectEqual(1, log.state.refs.load(.monotonic));
|
||||||
|
|
||||||
|
const clone_a = log.clone();
|
||||||
|
const clone_b = log.cloneWith(.debug);
|
||||||
|
try std.testing.expectEqual(3, log.state.refs.load(.monotonic));
|
||||||
|
|
||||||
|
// Clones share one state, buffer, writer, and mutex.
|
||||||
|
try std.testing.expectEqual(log.state, clone_a.state);
|
||||||
|
try std.testing.expectEqual(log.state, clone_b.state);
|
||||||
|
|
||||||
|
// Handles are move-safe, the state is heap-stable, so an owning handle may be relocated and retains full ownership.
|
||||||
|
const state_ptr = clone_b.state;
|
||||||
|
|
||||||
|
var slot: ?Logger = null;
|
||||||
|
slot = clone_b;
|
||||||
|
var moved = slot.?;
|
||||||
|
slot = null;
|
||||||
|
|
||||||
|
try std.testing.expectEqual(state_ptr, moved.state);
|
||||||
|
try std.testing.expectEqual(3, moved.state.refs.load(.monotonic)); // Moves don't retain.
|
||||||
|
|
||||||
|
// Buffered writes from two different handles accumulate in the one shared buffer.
|
||||||
|
log.info("from-original", .{});
|
||||||
|
clone_a.info("from-clone", .{});
|
||||||
|
|
||||||
|
// Deinitializing a non-final handle neither flushes nor frees, the file stays empty and the buffered data survives.
|
||||||
|
log.deinit();
|
||||||
|
|
||||||
|
{
|
||||||
|
try std.testing.expectEqual(2, clone_a.state.refs.load(.monotonic));
|
||||||
|
|
||||||
|
const contents = try dir.readFile(io, fname, &read_buf);
|
||||||
|
try std.testing.expectEqual(0, contents.len);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surviving handles keep full use of the shared state after the original handle is gone.
|
||||||
|
clone_a.warn("after-original-deinit", .{});
|
||||||
|
|
||||||
|
{
|
||||||
|
const contents = try dir.readFile(io, fname, &read_buf);
|
||||||
|
try std.testing.expectEqualStrings(
|
||||||
|
"[INFO] from-original\n" ++
|
||||||
|
"[INFO] from-clone\n" ++
|
||||||
|
"[WARN] after-original-deinit\n",
|
||||||
|
contents,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
clone_a.deinit();
|
||||||
|
|
||||||
|
// The moved handle writes into the same still-alive shared buffer.
|
||||||
|
moved.info("still-buffered", .{});
|
||||||
|
|
||||||
|
// The final deinit flushes pending output and frees the buffer and WriterState.
|
||||||
|
moved.deinit();
|
||||||
|
|
||||||
|
{
|
||||||
|
const contents = try dir.readFile(io, fname, &read_buf);
|
||||||
|
try std.testing.expectEqualStrings(
|
||||||
|
"[INFO] from-original\n" ++
|
||||||
|
"[INFO] from-clone\n" ++
|
||||||
|
"[WARN] after-original-deinit\n" ++
|
||||||
|
"[INFO] still-buffered\n",
|
||||||
|
contents,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/main.zig
79
src/main.zig
@@ -1,3 +1,8 @@
|
|||||||
|
//! Entry point for the zocket daemon.
|
||||||
|
//!
|
||||||
|
//! Handles allocator selection (debug-checked vs. release), CLI argument
|
||||||
|
//! parsing, and configuration path resolution before handing off to `run`.
|
||||||
|
|
||||||
const zocket = @import("zocket");
|
const zocket = @import("zocket");
|
||||||
|
|
||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
@@ -7,52 +12,59 @@ const options = @import("build_options");
|
|||||||
|
|
||||||
const args = zocket.deps.args;
|
const args = zocket.deps.args;
|
||||||
|
|
||||||
|
/// Resolves the config path, then delegates to `run`.
|
||||||
|
///
|
||||||
|
/// Allocator strategy:
|
||||||
|
/// - Debug builds use `DebugAllocator`, which tracks every allocation and
|
||||||
|
/// panics/exits on leaks or double-frees.
|
||||||
|
/// - Release builds use `smp_allocator`.
|
||||||
pub fn main(init: std.process.Init) !void
|
pub fn main(init: std.process.Init) !void
|
||||||
{
|
{
|
||||||
const io = init.io;
|
const debug = builtin.mode == .Debug;
|
||||||
|
const io = init.io;
|
||||||
|
|
||||||
if (builtin.mode == .Debug)
|
var gpa: std.heap.DebugAllocator(.{}) = .init;
|
||||||
{
|
defer if (gpa.deinit() == .leak) std.process.exit(1);
|
||||||
var arena: std.heap.DebugAllocator(.{}) = .init;
|
|
||||||
defer _ = arena.deinit();
|
|
||||||
|
|
||||||
try run(io, arena.allocator(), "config/config.toml");
|
const allocator = if (debug) gpa.allocator() else std.heap.smp_allocator;
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var arena = std.heap.ArenaAllocator.init(std.heap.smp_allocator);
|
|
||||||
defer arena.deinit();
|
|
||||||
const allocator = arena.allocator();
|
|
||||||
|
|
||||||
var tmpArena = std.heap.ArenaAllocator.init(std.heap.smp_allocator);
|
var tmp_arena: ?std.heap.ArenaAllocator = .init(allocator);
|
||||||
const tmpAllocator = tmpArena.allocator();
|
errdefer _ = zocket.memory.deinitIfExists(&tmp_arena);
|
||||||
|
|
||||||
var parser = try args.ArgumentParser.init(tmpAllocator, .{
|
const tmp_allocator = tmp_arena.?.allocator();
|
||||||
.name = "zocket",
|
|
||||||
.version = options.version,
|
|
||||||
.description = "A lightweight Zig WebSocket server and mesh routing daemon.",
|
|
||||||
});
|
|
||||||
|
|
||||||
try parser.addOption("path", .{
|
var parser = try args.ArgumentParser.init(tmp_allocator, .{
|
||||||
.short = 'p',
|
.name = "zocket",
|
||||||
.help = "Configuration-File Path (*.toml)",
|
.version = options.version,
|
||||||
});
|
.description = "A lightweight Zig WebSocket server and mesh routing daemon.",
|
||||||
|
});
|
||||||
|
|
||||||
var result = try parser.parseProcess(init);
|
try parser.addOption("path", .{
|
||||||
|
.short = 'p',
|
||||||
|
.help = "Configuration-File Path (*.toml)",
|
||||||
|
});
|
||||||
|
|
||||||
const tmpPath = result.getString("path") orelse blk: {
|
var result = try parser.parseProcess(init);
|
||||||
const exe = try std.process.executableDirPathAlloc(io, tmpAllocator);
|
|
||||||
break :blk try std.Io.Dir.path.join(tmpAllocator, &.{ exe, "config/config.toml" });
|
|
||||||
};
|
|
||||||
|
|
||||||
const path = try allocator.dupe(u8, tmpPath);
|
const tmp_path = result.getString("path") orelse path: {
|
||||||
|
const exe = try std.process.executableDirPathAlloc(io, tmp_allocator);
|
||||||
|
break :path try std.Io.Dir.path.join(tmp_allocator, &.{ exe, "config/config.toml" });
|
||||||
|
};
|
||||||
|
|
||||||
tmpArena.deinit();
|
const path = try allocator.dupe(u8, tmp_path);
|
||||||
|
|
||||||
try run(io, allocator, path);
|
_ = zocket.memory.deinitIfExists(&tmp_arena);
|
||||||
}
|
|
||||||
|
try run(io, allocator, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Loads and parses the configuration file at `path`, then hands off to
|
||||||
|
/// the rest of the application.
|
||||||
|
///
|
||||||
|
/// Owns `path` and frees it once it's no longer needed; Callers must not free `path` themselves!
|
||||||
|
///
|
||||||
|
/// On parse failure, logs a human-readable reason and terminates the
|
||||||
|
/// process immediately via `std.process.exit`.
|
||||||
fn run(io: std.Io, allocator: std.mem.Allocator, path: []const u8) !void
|
fn run(io: std.Io, allocator: std.mem.Allocator, path: []const u8) !void
|
||||||
{
|
{
|
||||||
const parsed = zocket.config.parse(io, allocator, path) catch |err|
|
const parsed = zocket.config.parse(io, allocator, path) catch |err|
|
||||||
@@ -62,10 +74,11 @@ fn run(io: std.Io, allocator: std.mem.Allocator, path: []const u8) !void
|
|||||||
error.AccessDenied => "Permission denied reading",
|
error.AccessDenied => "Permission denied reading",
|
||||||
else => "Unexpected error opening",
|
else => "Unexpected error opening",
|
||||||
};
|
};
|
||||||
std.log.err("{s}: '{s}' ({any})\n", .{ msg, path, err });
|
std.log.err("{s}: '{s}' ({any})", .{ msg, path, err });
|
||||||
std.process.exit(1);
|
std.process.exit(1);
|
||||||
};
|
};
|
||||||
defer parsed.deinit();
|
defer parsed.deinit();
|
||||||
|
allocator.free(path);
|
||||||
|
|
||||||
const configuration = parsed.value;
|
const configuration = parsed.value;
|
||||||
|
|
||||||
|
|||||||
103
src/memory/util.zig
Normal file
103
src/memory/util.zig
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
/// Deinitializes `resource` (allocator owner) only if it is still live.
|
||||||
|
///
|
||||||
|
/// Pass a pointer to the allocator owner, such as an `ArenaAllocator`.
|
||||||
|
///
|
||||||
|
/// If `live` is `true`, calls `resource.deinit()` and then sets
|
||||||
|
/// `live` to `false`, preventing a later cleanup path from attempting
|
||||||
|
/// to deinitialize the same resource again.
|
||||||
|
///
|
||||||
|
/// The return value of `resource.deinit()` is returned from the function.
|
||||||
|
pub fn deinitIfLive(resource: anytype, live: *bool) ?LiveDeinitReturn(@TypeOf(resource))
|
||||||
|
{
|
||||||
|
if (!live.*) return null;
|
||||||
|
|
||||||
|
live.* = false;
|
||||||
|
return resource.deinit();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deinitializes the resources stored in `resource`
|
||||||
|
/// (allocator owner) only if still exists.
|
||||||
|
///
|
||||||
|
/// Pass a pointer to an optional allocator owner, such as
|
||||||
|
/// `*?std.heap.ArenaAllocator`.
|
||||||
|
///
|
||||||
|
/// If `resource.*` is non-null, calls `deinit()` on the payload
|
||||||
|
/// and then sets `resource.* = null`, preventing a later cleanup
|
||||||
|
/// path from attempting to deinitialize the same resource again.
|
||||||
|
///
|
||||||
|
/// The return value of `resource.*.?.deinit()` is returned
|
||||||
|
/// from the function.
|
||||||
|
pub fn deinitIfExists(resource: anytype) ?ExistsDeinitReturn(@TypeOf(resource))
|
||||||
|
{
|
||||||
|
if (resource.*) |*payload|
|
||||||
|
{
|
||||||
|
const result = payload.deinit();
|
||||||
|
resource.* = null;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
fn Payload(comptime Pointer: type) type
|
||||||
|
{
|
||||||
|
const Child = ValidatedChild(Pointer);
|
||||||
|
|
||||||
|
if (@typeInfo(Child) != .optional)
|
||||||
|
@compileError("Expected a pointer to an optional resource (*?T)!");
|
||||||
|
|
||||||
|
return @typeInfo(Child).optional.child;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ValidatedChild(comptime Pointer: type) type
|
||||||
|
{
|
||||||
|
if (@typeInfo(Pointer) != .pointer)
|
||||||
|
@compileError("Expected a pointer!");
|
||||||
|
|
||||||
|
if (@typeInfo(Pointer).pointer.size != .one)
|
||||||
|
@compileError("Expected a single-item pointer!");
|
||||||
|
|
||||||
|
return @typeInfo(Pointer).pointer.child;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn LiveDeinitReturn(comptime Pointer: type) type {
|
||||||
|
const Child = ValidatedChild(Pointer);
|
||||||
|
return @typeInfo(@TypeOf(Child.deinit)).@"fn".return_type.?;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ExistsDeinitReturn(comptime Pointer: type) type {
|
||||||
|
return @typeInfo(@TypeOf(Payload(Pointer).deinit)).@"fn".return_type.?;
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
test "deinitIfLive deinitializes an ArenaAllocator once" {
|
||||||
|
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||||
|
var live = true;
|
||||||
|
|
||||||
|
const alloc = arena.allocator();
|
||||||
|
_ = try alloc.alloc(u8, 1);
|
||||||
|
|
||||||
|
_ = deinitIfLive(&arena, &live);
|
||||||
|
try std.testing.expect(!live);
|
||||||
|
|
||||||
|
// Must not deinitialize it again.
|
||||||
|
try std.testing.expect(deinitIfLive(&arena, &live) == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "deinitIfExists deinitializes an optional ArenaAllocator once" {
|
||||||
|
var arena: ?std.heap.ArenaAllocator = .init(std.testing.allocator);
|
||||||
|
|
||||||
|
const alloc = arena.?.allocator();
|
||||||
|
_ = try alloc.alloc(u8, 1);
|
||||||
|
|
||||||
|
_ = deinitIfExists(&arena);
|
||||||
|
try std.testing.expect(arena == null);
|
||||||
|
|
||||||
|
// Must not deinitialize it again.
|
||||||
|
try std.testing.expect(deinitIfExists(&arena) == null);
|
||||||
|
}
|
||||||
11
src/root.zig
11
src/root.zig
@@ -3,8 +3,15 @@ pub const deps = struct {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
pub const config = @import("config/config.zig");
|
pub const config = @import("config/config.zig");
|
||||||
|
pub const memory = @import("memory/util.zig");
|
||||||
|
pub const signals = @import("signals/models.zig");
|
||||||
|
pub const logging = @import("logging/log.zig");
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
test {
|
test {
|
||||||
_ = config;
|
std.testing.refAllDecls(@This());
|
||||||
}
|
}
|
||||||
|
|||||||
53
src/signals/models.zig
Normal file
53
src/signals/models.zig
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
const posix = std.posix;
|
||||||
|
const linux = std.os.linux;
|
||||||
|
|
||||||
|
pub const Event = enum {
|
||||||
|
none, // Sentinel
|
||||||
|
shutdown, // SIGTERM, SIGINT
|
||||||
|
reload, // SIGHUP
|
||||||
|
|
||||||
|
pub fn fromSignal(sig: i32) ?Event
|
||||||
|
{
|
||||||
|
const signal: posix.SIG = @enumFromInt(@as(u32, @intCast(sig)));
|
||||||
|
|
||||||
|
return switch (signal) {
|
||||||
|
.TERM,
|
||||||
|
.INT => .shutdown,
|
||||||
|
.HUP => .reload,
|
||||||
|
else => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SignalFd = struct {
|
||||||
|
fd: posix.fd_t,
|
||||||
|
|
||||||
|
pub fn init() !SignalFd
|
||||||
|
{
|
||||||
|
var mask = linux.sigemptyset();
|
||||||
|
|
||||||
|
linux.sigaddset(&mask, posix.SIG.TERM);
|
||||||
|
linux.sigaddset(&mask, posix.SIG.INT);
|
||||||
|
linux.sigaddset(&mask, posix.SIG.HUP);
|
||||||
|
|
||||||
|
posix.sigprocmask(posix.SIG.BLOCK, &mask, null);
|
||||||
|
|
||||||
|
const fd = try posix.signalfd(-1, &mask, linux.SFD.CLOEXEC);
|
||||||
|
|
||||||
|
return .{ .fd = fd };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: SignalFd) void { _ = std.os.linux.close(self.fd); }
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
pub fn read(self: SignalFd) !Event
|
||||||
|
{
|
||||||
|
var info: linux.signalfd_siginfo = undefined;
|
||||||
|
|
||||||
|
_ = try posix.read(self.fd, std.mem.asBytes(&info));
|
||||||
|
|
||||||
|
return Event.fromSignal(@intCast(info.signo)) orelse .none;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user