Compare commits

..

21 Commits

Author SHA1 Message Date
f1b07147b1 enhanced logger: add fixed-width [LEVEL] prefixes, introduce fatalLog for .fatal messages, improve alignment and error handling, made fatal noreturn 2026-08-08 00:01:39 +02:00
963f1611fb updated logger: integrate mutex into WriterState (move-safety), fix lock/unlock logic in deinit 2026-08-07 23:22:01 +02:00
bea0b6668a enhanced logger: move-safety, add fatal log level, refine buffering and error handling 2026-08-07 23:11:32 +02:00
31c74fc960 logger tests; verify buffering, flushing, and overflow behavior 2026-08-06 10:49:05 +02:00
d56010b064 refactor build system: separate test module and streamline test dependencies 2026-08-06 10:48:43 +02:00
7ae1907189 refined build configuration 2026-08-04 13:51:51 +02:00
2a6ade9bf7 unify configuration path resolution between dev and prod (use zig build run -- --path "config/config.toml") 2026-08-04 13:17:00 +02:00
7c97ad554e Introduce logging module with a minimal, thread-safe logger implementation 2026-08-04 11:18:38 +02:00
a8ff9d95bb Introduce signals module for signal handling 2026-08-03 22:36:13 +02:00
2688cacfa7 Refactor memory utilities: unify validation logic, adjust return types, and clean up main.zig formatting. 2026-08-03 12:43:34 +02:00
0a7ad2662c Introduce deinitIfExists utility and replace deinitIfLive for optional resource cleanup in main.zig. 2026-08-02 23:41:15 +02:00
88d312e713 Refactor deinitIfLive: adjust return type and parameter naming; update allocator usage in main.zig 2026-08-02 23:13:19 +02:00
d70a051a57 Introduce deinitIfLive utility and integrate enhanced memory management 2026-08-02 22:59:02 +02:00
b1bdfccd1e Enhance build options and refactor memory management for configuration parsing 2026-08-02 18:16:58 +02:00
84c4158aaa Update configuration handling: refine path resolution and allocator usage in main.zig 2026-08-02 18:01:28 +02:00
250829e922 Refactor configuration handling and integrate command-line argument parsing with args.zig 2026-08-01 23:28:06 +02:00
f902086e14 Clean up formatting and remove redundant debug output in main.zig 2026-08-01 22:04:47 +02:00
569700d6bd Update configuration paths and enhance allocator handling in main.zig 2026-08-01 22:01:48 +02:00
a7aa1517ca Add Config structure, TOML parser, and tests for zocket configuration 2026-08-01 14:23:07 +02:00
aa3b77eeda Add initial zocket/config.toml and define Zig dependency for TOML parsing 2026-07-31 23:09:10 +02:00
7d85821643 Add initial Zig build system and project structure 2026-07-31 18:30:16 +02:00
11 changed files with 1089 additions and 76 deletions

84
.gitignore vendored
View File

@@ -1,83 +1,15 @@
# ---> Zig zig-cache/
.zig-cache/ .zig-cache/
zig-out/ zig-out/
# ---> JetBrains zig-pkg
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff build/
.idea/**/workspace.xml build-*/
.idea/**/tasks.xml docgen_tmp/
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
# AWS User-specific .zbscanhelper.zig
.idea/**/aws.xml
# Generated files .idea/
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# Gradle and Maven with auto-import
# When using Gradle or Maven with auto-import, you should exclude module files,
# since they will be recreated, and may cause churn. Uncomment if using
# auto-import.
# .idea/artifacts
# .idea/compiler.xml
# .idea/jarRepositories.xml
# .idea/modules.xml
# .idea/*.iml
# .idea/modules
# *.iml
# *.ipr
# CMake
cmake-build-*/
# Mongo Explorer plugin
.idea/**/mongoSettings.xml
# File-based project format
*.iws
# IntelliJ
out/
# mpeltonen/sbt-idea plugin
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Cursive Clojure plugin
.idea/replstate.xml
# SonarLint plugin
.idea/sonarlint/
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
# Editor-based Rest Client
.idea/httpRequests
# Android studio 3.1+ serialized cache file
.idea/caches/build_file_checksums.ser
config/*.md

96
build.zig Normal file
View File

@@ -0,0 +1,96 @@
const std = @import("std");
const arch = @import("builtin").cpu.arch;
pub fn build(b: *std.Build) void
{
const target = b.resolveTargetQuery(.{
.cpu_arch = arch,
.os_tag = .linux,
.abi = .musl
});
const optimize = b.standardOptimizeOption(.{});
const version = getGitVersion(b);
const options = b.addOptions();
options.addOption([]const u8, "version", version);
// deps
const toml_dep = b.dependency("toml", .{
.target = target,
.optimize = optimize,
});
const args_dep = b.dependency("args", .{
.target = target,
.optimize = optimize,
});
//
const mod = b.addModule("zocket", .{
.root_source_file = b.path("src/root.zig"),
.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("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(.{
.name = "zocket",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "zocket", .module = mod },
},
}),
});
exe.root_module.addOptions("build_options", options);
//
b.installArtifact(exe);
const run_step = b.step("run", "Run the app");
const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| {
run_cmd.addArgs(args);
}
const mod_tests = b.addTest(.{
.root_module = test_mod,
});
const run_mod_tests = b.addRunArtifact(mod_tests);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_mod_tests.step);
}
fn getGitVersion(b: *std.Build) []const u8
{
const result = b.runAllowFail(
&.{ "git", "describe", "--tags", "--always", "--dirty" },
undefined,
.inherit,
) catch return "0.0.0-unknown";
return b.dupe(std.mem.trim(u8, result, " \n\r\t"));
}

23
build.zig.zon Normal file
View File

@@ -0,0 +1,23 @@
.{
.name = .zocket,
.version = "0.0.0",
.fingerprint = 0x22ea3973fd74773f,
.minimum_zig_version = "0.16.0",
.dependencies = .{
.toml = .{
.url = "git+https://github.com/sam701/zig-toml?ref=zig-0.16#8685923e32e8b8a795eb2715684236975a70faed",
.hash = "toml-0.3.0-bV14BRmKAQAWR0FT0KUKFwVJTImaFhWjSe6HfSMtNZOH",
},
.args = .{
.url = "https://github.com/muhammad-fiaz/args.zig/archive/refs/tags/0.0.8.tar.gz",
.hash = "args-0.0.8-8hzgbsBECgD3C0uPaFNcK9UFTZ210rWU4XIhopR8snoA",
},
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
"LICENSE",
"README.md",
},
}

137
config/config.toml Normal file
View File

@@ -0,0 +1,137 @@
[node]
id = "node-01"
data_dir = "/var/lib/zocket" # /$data_dir/$id/*
private_key = "/var/lib/zocket/node-01/identity.key" # ed25519, auto-generated on first run if missing, default = /$data_dir/$id/identity.key
# DEFAULT ASSUMPTION: ephemeral.
# A fresh keypair each restart is fine and expected as nothing is persisted to disk on purpose
# (see mesh.discoverability + mesh.trust below), everything mesh-wide is rediscovered via
# gossip/broadcast after (re)connecting to a seed peer in mesh.peers.
# EXCEPTION:
# If another node pins THIS node by pubkey in its own static mesh.peers[] entry, this
# key must be persisted (mount on a volume) as a pinned pubkey is a promise of a stable identity,
# and an ephemeral key breaks that pin on every restart.
# Only relevant for statically pinned, long-lived "hub" style nodes; auto-enrolled/broadcast-mode peers never need this.
[log]
level = "info" # debug | info | warn | error
format = "json" # json | text
output = "stdout" # stdout | /path/to/file.log
# ----
[listen.client]
enabled = true
bind = "0.0.0.0:8080"
[listen.client.tls]
enabled = false
cert = "/etc/zocket/client.crt"
key = "/etc/zocket/client.key"
[listen.client.auth]
mode = "token" # none | token
# If mode is set to "token", then the client_id is DERIVED from the matched
# identity's id below, never self-declared by the client.
# If mode is set to "none", any client may self-declare their client_id.
# On collision with an already-connected client_id, the NEW connection is rejected
# (first-connected wins) to prevent a second client from silently taking over another's routing identity.
[[listen.client.auth.identities]]
id = "discord-bot"
token = "sdfb08zbd98bzbh9r98gh98enb"
[[listen.client.auth.identities]]
id = "minecraft-mod"
token = "sv8sovz987w98ruw9fwbgfubwf"
[listen.client.access_control]
mode = "allowlist" # allowlist | blocklist | disabled
list = ["10.0.0.0/24", "127.0.0.1"] # IP | CIDR
[listen.client.limits]
max_message_bytes = 65536
max_frame_bytes = 65536
idle_timeout_secs = 300
ping_interval_secs = 30
max_missed_pongs = 2
max_connections = 256
[listen.client.limits.rate_limit]
enabled = true
messages_per_sec = 50
burst = 100
# ----
[listen.peer]
enabled = true
bind = "0.0.0.0:8181"
[listen.peer.tls]
enabled = false
cert = "/etc/zocket/peer.crt"
key = "/etc/zocket/peer.key"
require_client_cert = false
[listen.peer.auth]
mode = "pubkey" # pubkey
# Peer links can inject routes and broadcasts mesh-wide, so identity is always cryptographically verified, regardless of access_control.
# Every connecting peer proves possession of the private key matching a pubkey via a signed nonce challenge - no shared secret ever transits the wire.
# For a KNOWN node_id, the pubkey must match mesh.peers[].pubkey exactly, or the connection is rejected outright.
# For a claimed node_id that isn't yet known, see mesh.discoverability = "broadcast" below.
[listen.peer.access_control]
mode = "allowlist" # allowlist | blocklist | disabled
list = ["10.0.0.0/24", "127.0.0.1"] # IP | CIDR
[listen.peer.limits]
max_message_bytes = 1048576
idle_timeout_secs = 60
ping_interval_secs = 15
# ----
[mesh]
enabled = true
topology = "full" # full | static-partial | disabled
routing_mode = "gossip" # gossip | reactive | static
[mesh.discoverability]
mode = "static" # static | broadcast
# static: mesh.peers below is the complete, manually-maintained set of instances.
# broadcast: mesh.peers is just the initial seed; unknown nodes may connect and prove
# possession of a pubkey; they're auto-trusted and gossiped mesh-wide immediately.
# listen.peer.access_control is the real gate in this mode; scope it tightly.
[[mesh.peers]]
id = "node-02"
addr = "10.0.0.2:8181"
pubkey = "rijgeb8u845un34tun34tou3nt3ot3untz3otn3to"
reconnect = true
backoff_min_ms = 500
backoff_max_ms = 30000
[mesh.trust]
gossip_new_peers = true # propagate newly-enrolled peer identities to the rest of the mesh
[mesh.gossip]
seen_cache_ttl_secs = 300
route_ttl_secs = 0 # 0 = no expiry, rely on explicit ROUTE_REMOVE
[mesh.broadcast]
enabled = true
# Broadcasting (target = "*") is peer-only and never exposed to clients on listen.client.
# Floods across peer links using the same seen_cache dedup as gossip route announcements.
# only used if routing_mode = "reactive"
[mesh.reactive]
query_ttl_hops = 4
query_timeout_ms = 2000
negative_cache_secs = 30
# ----
# prometheus format
[metrics]
enabled = true
bind = "127.0.0.1:9090"

30
src/config/config.zig Normal file
View File

@@ -0,0 +1,30 @@
const Config = @import("models.zig").Config;
const std = @import("std");
const toml = @import("toml");
pub fn parse(io: std.Io, allocator: std.mem.Allocator, path: []const u8) !toml.Parsed(Config)
{
var parser = toml.Parser(Config).init(allocator);
defer parser.deinit();
return parser.parseFile(io,path);
}
test "parse zocket config"
{
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const allocator = std.testing.allocator;
const parsed = try parse(io, allocator, "config/config.toml");
defer parsed.deinit();
const cfg = parsed.value;
try std.testing.expectEqualStrings("node-01", cfg.node.id);
try std.testing.expect(cfg.listen.client.enabled);
try std.testing.expectEqual(@as(usize, 1), cfg.mesh.peers.len);
try std.testing.expectEqualStrings("node-02", cfg.mesh.peers[0].id);
}

209
src/config/models.zig Normal file
View File

@@ -0,0 +1,209 @@
const std = @import("std");
const toml = @import("toml");
//
pub const Config = struct {
node: Node,
log: Log = .{},
listen: Listen = .{},
mesh: Mesh = .{},
metrics: Metrics = .{},
};
//
/// Per-instance identity. Intentionally has no defaults - id/data_dir/private_key
/// are meaningless without a concrete deployment behind them.
pub const Node = struct {
id: []const u8,
data_dir: []const u8,
private_key: []const u8, // ed25519; auto-generated on first run if missing
};
pub const LogLevel = enum {
debug,
info,
warn,
@"error",
};
pub const LogFormat = enum {
json,
text,
};
pub const Log = struct {
level: LogLevel = .info,
format: LogFormat = .json,
output: []const u8 = "stdout", // or "/path/to/file.log"
};
pub const Listen = struct {
client: ListenClient = .{},
peer: ListenPeer = .{},
};
pub const ListenClient = struct {
enabled: bool = true,
bind: []const u8 = "0.0.0.0:8080",
tls: ClientTls = .{},
auth: ClientAuth = .{},
access_control: AccessControl = .{},
limits: ClientLimits = .{},
};
pub const ClientTls = struct {
enabled: bool = false,
cert: []const u8 = "/etc/zocket/client.crt",
key: []const u8 = "/etc/zocket/client.key",
};
pub const ClientAuthMode = enum {
none, // clients self-declare their client_id
token, // client_id is derived from the matched identity below
};
pub const ClientAuth = struct {
mode: ClientAuthMode = .token,
identities: []Identity = &[_]Identity{},
};
/// A single client credential. No defaults as each identity is a distinct
/// (id, token) pair that must be explicitly provisioned.
pub const Identity = struct {
id: []const u8,
token: []const u8,
};
/// Shared by listen.client.access_control and listen.peer.access_control.
pub const AccessControlMode = enum {
allowlist,
blocklist,
disabled,
};
pub const AccessControl = struct {
mode: AccessControlMode = .allowlist,
list: [][]const u8 = &[_][]const u8{}, // IP | CIDR entries
};
pub const ClientLimits = struct {
max_message_bytes: i64 = 65536,
max_frame_bytes: i64 = 65536,
idle_timeout_secs: i64 = 300,
ping_interval_secs: i64 = 30,
max_missed_pongs: i64 = 2,
max_connections: i64 = 256,
rate_limit: RateLimit = .{},
};
pub const RateLimit = struct {
enabled: bool = true,
messages_per_sec: i64 = 50,
burst: i64 = 100,
};
pub const ListenPeer = struct {
enabled: bool = true,
bind: []const u8 = "0.0.0.0:8181",
tls: PeerTls = .{},
auth: PeerAuth = .{},
access_control: AccessControl = .{},
limits: PeerLimits = .{},
};
pub const PeerTls = struct {
enabled: bool = false,
cert: []const u8 = "/etc/zocket/peer.crt",
key: []const u8 = "/etc/zocket/peer.key",
require_client_cert: bool = false,
};
pub const PeerAuthMode = enum {
pubkey, // only supported mode: signed-nonce challenge, never a shared secret
};
pub const PeerAuth = struct {
mode: PeerAuthMode = .pubkey,
};
pub const PeerLimits = struct {
max_message_bytes: i64 = 1048576,
idle_timeout_secs: i64 = 60,
ping_interval_secs: i64 = 15,
};
pub const MeshTopology = enum {
full,
@"static-partial",
disabled,
};
pub const RoutingMode = enum {
gossip,
reactive,
static,
};
pub const Mesh = struct {
enabled: bool = true,
topology: MeshTopology = .full,
routing_mode: RoutingMode = .gossip,
discoverability: Discoverability = .{},
peers: []MeshPeer = &[_]MeshPeer{},
trust: Trust = .{},
gossip: Gossip = .{},
broadcast: Broadcast = .{},
reactive: Reactive = .{},
};
pub const DiscoverabilityMode = enum {
// mesh.peers is the complete, manually-maintained set of instances.
static,
// mesh.peers is just the initial seed; unknown nodes may connect and
// prove possession of a pubkey, then get auto-trusted and gossiped
// mesh-wide. listen.peer.access_control is the real gate in this mode.
broadcast,
};
pub const Discoverability = struct {
mode: DiscoverabilityMode = .static,
};
/// A statically-pinned peer. No defaults for id/addr/pubkey as pinning a
/// peer's identity is the entire point, so these must be explicit.
pub const MeshPeer = struct {
id: []const u8,
addr: []const u8,
pubkey: []const u8,
reconnect: bool = true,
backoff_min_ms: i64 = 500,
backoff_max_ms: i64 = 30000,
};
pub const Trust = struct {
gossip_new_peers: bool = true, // propagate newly-enrolled peer identities mesh-wide
};
pub const Gossip = struct {
seen_cache_ttl_secs: i64 = 300,
route_ttl_secs: i64 = 0, // 0 = no expiry, rely on explicit ROUTE_REMOVE
};
pub const Broadcast = struct {
// Peer-only (target = "*"); never exposed to clients on listen.client.
enabled: bool = true,
};
pub const Reactive = struct {
// Only used when mesh.routing_mode = .reactive.
query_ttl_hops: i64 = 4,
query_timeout_ms: i64 = 2000,
negative_cache_secs: i64 = 30,
};
pub const Metrics = struct {
enabled: bool = true,
bind: []const u8 = "127.0.0.1:9090", // prometheus format
};

327
src/logging/log.zig Normal file
View File

@@ -0,0 +1,327 @@
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 {
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 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`.
///
/// `level` sets the minimum severity that will be written; messages
/// below this level are discarded.
///
/// `buffer` is the size, in bytes, of the internal write buffer
/// allocated for `writer`. Larger buffers reduce the number of
/// underlying writes at the cost of more memory and higher latency
/// before data is flushed.
///
/// `writer` is the underlying file the logger writes to (e.g. stderr
/// 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 {
pub const ErrorCallback = Logger.ErrorCallback;
level: Level = .info,
buffer: usize = 1024,
on_error: ErrorCallback = null,
file: std.Io.File,
io: std.Io,
};
/// Movesafe, threadsafe, buffered file logger.
///
/// 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.
///
/// Unless an `ErrorCallback` is specified all errors in the logging functions will be 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.
const WriterState = struct {
buffer: []u8,
mutex: std.Io.Mutex,
writer: std.Io.File.Writer,
};
/// Creates and returns an initialized `Logger`.
///
/// 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`.
///
/// `allocator` is stored on the result and reused for cleanup, so
/// it must remain valid for the logger's lifetime.
///
/// 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.* = .{
.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`.
///
/// 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 underlying
/// allocations are freed unconditionally.
pub fn deinit(logger: *Self) void
{
if (logger.state.mutex.lock(logger.io)) |_|
{
logger.state.writer.interface.flush() catch {};
logger.state.mutex.unlock(logger.io);
}
else |_| {}
logger.allocator.free(logger.state.buffer);
logger.allocator.destroy(logger.state);
}
//
/// 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: *Self, comptime fmt: []const u8, args: anytype, level: Level) !void
{
if (@intFromEnum(level) < @intFromEnum(logger.level)) return;
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.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: *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 |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); }
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
/// writer immediately.
///
/// 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.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);
var 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=1' 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 should include 'warn=1' as warnings and errors automatically trigger a flush to disk.
log.warn("warn={d}", .{1});
{
const contents = try dir.readFile(io, fname, &read_buf);
try std.testing.expectEqualStrings("[WARN] warn=1\n", contents);
}
try file.setLength(io, 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.
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);
}

86
src/main.zig Normal file
View File

@@ -0,0 +1,86 @@
//! 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 std = @import("std");
const builtin = @import("builtin");
const options = @import("build_options");
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
{
const debug = builtin.mode == .Debug;
const io = init.io;
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer if (gpa.deinit() == .leak) std.process.exit(1);
const allocator = if (debug) gpa.allocator() else std.heap.smp_allocator;
var tmp_arena: ?std.heap.ArenaAllocator = .init(allocator);
errdefer _ = zocket.memory.deinitIfExists(&tmp_arena);
const tmp_allocator = tmp_arena.?.allocator();
var parser = try args.ArgumentParser.init(tmp_allocator, .{
.name = "zocket",
.version = options.version,
.description = "A lightweight Zig WebSocket server and mesh routing daemon.",
});
try parser.addOption("path", .{
.short = 'p',
.help = "Configuration-File Path (*.toml)",
});
var result = try parser.parseProcess(init);
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" });
};
const path = try allocator.dupe(u8, tmp_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
{
const parsed = zocket.config.parse(io, allocator, path) catch |err|
{
const msg: []const u8 = switch (err) {
error.FileNotFound => "File not found",
error.AccessDenied => "Permission denied reading",
else => "Unexpected error opening",
};
std.log.err("{s}: '{s}' ({any})", .{ msg, path, err });
std.process.exit(1);
};
defer parsed.deinit();
allocator.free(path);
const configuration = parsed.value;
std.debug.print("{any}", .{configuration});
}

103
src/memory/util.zig Normal file
View 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);
}

17
src/root.zig Normal file
View File

@@ -0,0 +1,17 @@
pub const deps = struct {
pub const args = @import("args");
};
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 {
std.testing.refAllDecls(@This());
}

53
src/signals/models.zig Normal file
View 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;
}
};