From 9cc1ba3a31acf9d02b7bf3959fee8508794d9ea5 Mon Sep 17 00:00:00 2001 From: zzstoatzz Date: Thu, 2 Apr 2026 11:58:23 -0500 Subject: [PATCH] migrate to zig 0.16: Io primitives, updated deps, timer regression fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phase 1 of the 0.16 migration — threads still use std.Thread.spawn, but all synchronization primitives now use Io.Mutex/Io.Condition and all sleep/timestamp calls go through std.Io. - bump deps: zat v0.3.0-alpha.7, websocket edeca26, rocksdb 9be930b, pg dev - build.zig: Module API (link_libc on root_module), .ignore/.trimEnd renames - Dockerfile: zig 0.16.0-dev.3059 - main.zig: Io.Evented/Threaded backend selection, DebugAllocator, getenv helper - all modules: Io.Mutex/Io.Condition, io.sleep(), Io.Timestamp, Io.Duration - thread_pool: stays on std.Thread.spawn (CPU-bound keyed workers) - event_log: sleep-based flush loop (Io.Condition has no timedWait) - broadcaster: poll-based idle wakeup for consumer keepalive pings - thread_pool submit: poll-based stop check to prevent shutdown deadlock - lru/ring_buffer: Io.Mutex for cross-boundary access - api/xrpc: Io.Writer.fixed() replaces fixedBufferStream - collection_index: allocPrint for test paths (realpathAlloc removed) next: port thread spawns to io.concurrent for upstream/downstream websocket paths Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + Dockerfile | 6 +- build.zig | 12 +- build.zig.zon | 18 +-- src/api/admin.zig | 26 +++-- src/api/router.zig | 2 +- src/api/xrpc.zig | 39 +++---- src/backfill.zig | 30 ++--- src/broadcaster.zig | 165 +++++++++++++------------- src/cleaner.zig | 8 +- src/collection_index.zig | 14 +-- src/event_log.zig | 242 ++++++++++++++++++++------------------- src/frame_worker.zig | 16 ++- src/lru.zig | 47 ++++---- src/main.zig | 206 +++++++++++++++++++-------------- src/resync.zig | 51 +++++---- src/ring_buffer.zig | 48 ++++---- src/slurper.zig | 127 +++++++++++--------- src/subscriber.zig | 72 ++++++++---- src/thread_pool.zig | 91 ++++++++------- src/validator.zig | 98 +++++++++------- 21 files changed, 732 insertions(+), 587 deletions(-) diff --git a/.gitignore b/.gitignore index ade917d..f5053b9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .zig-cache/ zig-out/ +zig-pkg/ data/ .env .env.* diff --git a/Dockerfile b/Dockerfile index 928afc6..5e3f44e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ # multi-stage build: compile natively inside x86_64 linux container -# uses Debian (glibc) because zig 0.15's C++ codegen for musl +# uses Debian (glibc) because zig's C++ codegen for musl # produces illegal instructions in RocksDB's LRU cache FROM --platform=linux/amd64 debian:bookworm-slim AS builder RUN apt-get update && apt-get install -y --no-install-recommends curl xz-utils ca-certificates && rm -rf /var/lib/apt/lists/* -RUN curl -fSL https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz \ +RUN curl -fSL https://ziglang.org/builds/zig-x86_64-linux-0.16.0-dev.3059+42e33db9d.tar.xz \ | tar xJ -C /opt -ENV PATH=/opt/zig-x86_64-linux-0.15.2:$PATH +ENV PATH=/opt/zig-x86_64-linux-0.16.0-dev.3059+42e33db9d:$PATH WORKDIR /build # fetch dependencies first (cacheable — only changes when build.zig.zon changes) diff --git a/build.zig b/build.zig index 44c6f03..71aa86b 100644 --- a/build.zig +++ b/build.zig @@ -32,9 +32,9 @@ pub fn build(b: *std.Build) void { const build_options = b.addOptions(); build_options.addOption([]const u8, "git_sha", git_sha: { var code: u8 = 0; - const result = b.runAllowFail(&.{ "git", "rev-parse", "--short", "HEAD" }, &code, .Ignore); + const result = b.runAllowFail(&.{ "git", "rev-parse", "--short", "HEAD" }, &code, .ignore); if (result) |output| { - break :git_sha std.mem.trimRight(u8, output, "\n \t"); + break :git_sha std.mem.trimEnd(u8, output, "\n \t"); } else |_| { break :git_sha "unknown"; } @@ -51,12 +51,12 @@ pub fn build(b: *std.Build) void { .imports = imports, }); relay_mod.addImport("build_options", build_options.createModule()); + relay_mod.link_libc = true; + relay_mod.link_libcpp = true; const relay = b.addExecutable(.{ .name = "zlay", .root_module = relay_mod, }); - relay.linkLibC(); - relay.linkLibCpp(); b.installArtifact(relay); const run_relay = b.addRunArtifact(relay); @@ -87,11 +87,11 @@ pub fn build(b: *std.Build) void { .imports = imports, }); test_mod.addImport("build_options", build_options.createModule()); + test_mod.link_libc = true; + test_mod.link_libcpp = true; const t = b.addTest(.{ .root_module = test_mod, }); - t.linkLibC(); - t.linkLibCpp(); test_step.dependOn(&b.addRunArtifact(t).step); } } diff --git a/build.zig.zon b/build.zig.zon index 91843fd..2010d28 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,23 +2,23 @@ .name = .zlay, .version = "0.0.1", .fingerprint = 0x31343ede133f3e58, - .minimum_zig_version = "0.15.0", + .minimum_zig_version = "0.16.0", .dependencies = .{ .zat = .{ - .url = "https://tangled.org/zat.dev/zat/archive/v0.2.16.tar.gz", - .hash = "zat-0.2.16-5PuC7tjwBADbnwV5y8ztKUHhGHMJHh2HouvoYImnZ7y5", + .url = "https://tangled.org/zat.dev/zat/archive/v0.3.0-alpha.7.tar.gz", + .hash = "zat-0.3.0-alpha.7-5PuC7uNjBQDv28db31DEKkFn1tU5I4f1GfJs-RrG8_pS", }, .websocket = .{ - .url = "https://github.com/zzstoatzz/websocket.zig/archive/395d0f4.tar.gz", - .hash = "websocket-0.1.0-ZPISdVJ8AwD7U03ARGgHclzlYSd9GeU91_WDXjRyjYdh", + .url = "https://github.com/zzstoatzz/websocket.zig/archive/edeca26.tar.gz", + .hash = "websocket-0.1.0-ZPISdSmqAwCbwcFtrAQC_q9cegdw-iHyrjCftgfMz-Nf", }, .pg = .{ - .url = "git+https://github.com/karlseguin/pg.zig?ref=master#e58b318b7867ef065b3135983f829219c5eef891", - .hash = "pg-0.0.0-Wp_7gXFoBgD0fQ72WICKa-bxLga03AXXQ3BbIsjjohQ3", + .url = "git+https://github.com/zzstoatzz/pg.zig?ref=dev#fdc519c42d6df787c4ac5ad9f391baf961d07f9c", + .hash = "pg-0.0.0-Wp_7gaOBBgAdAUwxkZb82X57EovpFUGWp9IdOSCcgKiL", }, .rocksdb = .{ - .url = "https://github.com/Syndica/rocksdb-zig/archive/baceb67dc9c66e8ba40a83da3de3fd959b889e57.tar.gz", - .hash = "rocksdb-9.7.4-z_CUTg_HAABgQurZzAhpJL5Erij8ZnppTmy3g1LIM8br", + .url = "https://github.com/zzstoatzz/rocksdb-zig/archive/9be930b.tar.gz", + .hash = "rocksdb-9.7.4-z_CUTr_HAADkz2Rte8o6L0TSrQnXjZZuw2kzve5n88RQ", }, }, .paths = .{ diff --git a/src/api/admin.zig b/src/api/admin.zig index 9420042..6d6e8e6 100644 --- a/src/api/admin.zig +++ b/src/api/admin.zig @@ -4,6 +4,7 @@ //! includes host blocking/unblocking, account bans, and backfill control. const std = @import("std"); +const Io = std.Io; const h = @import("http.zig"); const router = @import("router.zig"); const websocket = @import("websocket"); @@ -19,7 +20,7 @@ const HttpContext = router.HttpContext; /// check admin auth via headers, send error response if not authorized. returns true if authorized. pub fn checkAdmin(conn: *h.Conn, headers: ?*const websocket.Handshake.KeyValue) bool { - const admin_pw = std.posix.getenv("RELAY_ADMIN_PASSWORD") orelse { + const admin_pw = getenv("RELAY_ADMIN_PASSWORD") orelse { h.respondJson(conn, .forbidden, "{\"error\":\"admin endpoint not configured\"}"); return false; }; @@ -105,16 +106,16 @@ pub fn handleAdminListHosts(conn: *h.Conn, headers: *const websocket.Handshake.K persist.allocator.free(hosts); } - var list: std.ArrayListUnmanaged(u8) = .{}; - defer list.deinit(persist.allocator); - const w = list.writer(persist.allocator); + var aw: Io.Writer.Allocating = .init(persist.allocator); + defer aw.deinit(); + const w = &aw.writer; w.writeAll("{\"hosts\":[") catch return; for (hosts, 0..) |host, i| { if (i > 0) w.writeByte(',') catch return; if (host.account_limit) |limit| { - std.fmt.format(w, "{{\"id\":{d},\"hostname\":\"{s}\",\"status\":\"{s}\",\"last_seq\":{d},\"failed_attempts\":{d},\"account_limit\":{d}}}", .{ + w.print("{{\"id\":{d},\"hostname\":\"{s}\",\"status\":\"{s}\",\"last_seq\":{d},\"failed_attempts\":{d},\"account_limit\":{d}}}", .{ host.id, host.hostname, host.status, @@ -123,7 +124,7 @@ pub fn handleAdminListHosts(conn: *h.Conn, headers: *const websocket.Handshake.K limit, }) catch return; } else { - std.fmt.format(w, "{{\"id\":{d},\"hostname\":\"{s}\",\"status\":\"{s}\",\"last_seq\":{d},\"failed_attempts\":{d},\"account_limit\":null}}", .{ + w.print("{{\"id\":{d},\"hostname\":\"{s}\",\"status\":\"{s}\",\"last_seq\":{d},\"failed_attempts\":{d},\"account_limit\":null}}", .{ host.id, host.hostname, host.status, @@ -133,8 +134,8 @@ pub fn handleAdminListHosts(conn: *h.Conn, headers: *const websocket.Handshake.K } } - std.fmt.format(w, "],\"active_workers\":{d}}}", .{ctx.slurper.workerCount()}) catch return; - h.respondJson(conn, .ok, list.items); + w.print("],\"active_workers\":{d}}}", .{ctx.slurper.workerCount()}) catch return; + h.respondJson(conn, .ok, aw.written()); } pub fn handleAdminBlockHost(conn: *h.Conn, body: []const u8, headers: *const websocket.Handshake.KeyValue, persist: *event_log_mod.DiskPersist) void { @@ -377,7 +378,9 @@ fn buildAccountFrame(allocator: std.mem.Allocator, did: []const u8) ?[]const u8 /// format current UTC time as ISO 8601 (YYYY-MM-DDTHH:MM:SSZ) fn formatTimestamp(buf: *[24]u8) []const u8 { - const ts: u64 = @intCast(std.time.timestamp()); + var tp: std.c.timespec = undefined; + _ = std.c.clock_gettime(.REALTIME, &tp); + const ts: u64 = @intCast(tp.sec); const es = std.time.epoch.EpochSeconds{ .secs = ts }; const day = es.getEpochDay(); const yd = day.calculateYearDay(); @@ -393,3 +396,8 @@ fn formatTimestamp(buf: *[24]u8) []const u8 { ds.getSecondsIntoMinute(), }) catch "1970-01-01T00:00:00Z"; } + +fn getenv(key: [*:0]const u8) ?[]const u8 { + const ptr = std.c.getenv(key) orelse return null; + return std.mem.sliceTo(ptr, 0); +} diff --git a/src/api/router.zig b/src/api/router.zig index 6ea7330..114b722 100644 --- a/src/api/router.zig +++ b/src/api/router.zig @@ -67,7 +67,7 @@ fn handleGet(conn: *websocket.Conn, path: []const u8, query: []const u8, headers h.respondJson(conn, .ok, "{\"status\":\"ok\"}"); } else if (std.mem.eql(u8, path, "/_stats")) { var stats_buf: [4096]u8 = undefined; - const body = broadcaster.formatStatsResponse(ctx.stats, &stats_buf); + const body = broadcaster.formatStatsResponse(ctx.stats, &stats_buf, ctx.bc.io); h.respondJson(conn, .ok, body); } else if (std.mem.eql(u8, path, "/xrpc/com.atproto.sync.listRepos")) { xrpc.handleListRepos(conn, query, ctx.persist); diff --git a/src/api/xrpc.zig b/src/api/xrpc.zig index 02c8b72..bc61a4e 100644 --- a/src/api/xrpc.zig +++ b/src/api/xrpc.zig @@ -5,6 +5,7 @@ //! listHosts, getHostStatus, requestCrawl const std = @import("std"); +const Io = std.Io; const h = @import("http.zig"); const event_log_mod = @import("../event_log.zig"); const collection_index_mod = @import("../collection_index.zig"); @@ -48,8 +49,7 @@ pub fn handleListRepos(conn: *h.Conn, query: []const u8, persist: *event_log_mod // build JSON response into a buffer var buf: [65536]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); - const w = fbs.writer(); + var w: Io.Writer = .fixed(&buf); var count: i64 = 0; var last_uid: i64 = 0; @@ -100,12 +100,12 @@ pub fn handleListRepos(conn: *h.Conn, query: []const u8, persist: *event_log_mod // include cursor if we got a full page if (count >= limit and count >= 2) { - std.fmt.format(w, ",\"cursor\":\"{d}\"", .{last_uid}) catch return; + w.print(",\"cursor\":\"{d}\"", .{last_uid}) catch return; } w.writeByte('}') catch return; - h.respondJson(conn, .ok, fbs.getWritten()); + h.respondJson(conn, .ok, w.buffered()); } pub fn handleGetRepoStatus(conn: *h.Conn, query: []const u8, persist: *event_log_mod.DiskPersist) void { @@ -144,8 +144,7 @@ pub fn handleGetRepoStatus(conn: *h.Conn, query: []const u8, persist: *event_log const status = if (!local_ok) local_status else upstream_status; var buf: [4096]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); - const w = fbs.writer(); + var w: Io.Writer = .fixed(&buf); w.writeAll("{\"did\":\"") catch return; w.writeAll(did) catch return; @@ -166,7 +165,7 @@ pub fn handleGetRepoStatus(conn: *h.Conn, query: []const u8, persist: *event_log } w.writeByte('}') catch return; - h.respondJson(conn, .ok, fbs.getWritten()); + h.respondJson(conn, .ok, w.buffered()); } pub fn handleGetRepo(conn: *h.Conn, query: []const u8, persist: *event_log_mod.DiskPersist) void { @@ -257,8 +256,7 @@ pub fn handleGetLatestCommit(conn: *h.Conn, query: []const u8, persist: *event_l } var buf: [4096]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); - const w = fbs.writer(); + var w: Io.Writer = .fixed(&buf); w.writeAll("{\"cid\":\"") catch return; w.writeAll(cid) catch return; @@ -266,7 +264,7 @@ pub fn handleGetLatestCommit(conn: *h.Conn, query: []const u8, persist: *event_l w.writeAll(rev) catch return; w.writeAll("\"}") catch return; - h.respondJson(conn, .ok, fbs.getWritten()); + h.respondJson(conn, .ok, w.buffered()); } pub fn handleListReposByCollection(conn: *h.Conn, query: []const u8, ci: *collection_index_mod.CollectionIndex) void { @@ -302,8 +300,7 @@ pub fn handleListReposByCollection(conn: *h.Conn, query: []const u8, ci: *collec // build JSON response var buf: [65536]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); - const w = fbs.writer(); + var w: Io.Writer = .fixed(&buf); w.writeAll("{\"repos\":[") catch return; for (0..ci_result.count) |i| { @@ -323,7 +320,7 @@ pub fn handleListReposByCollection(conn: *h.Conn, query: []const u8, ci: *collec } w.writeByte('}') catch return; - h.respondJson(conn, .ok, fbs.getWritten()); + h.respondJson(conn, .ok, w.buffered()); } pub fn handleListHosts(conn: *h.Conn, query: []const u8, persist: *event_log_mod.DiskPersist) void { @@ -358,8 +355,7 @@ pub fn handleListHosts(conn: *h.Conn, query: []const u8, persist: *event_log_mod defer result.deinit(); var buf: [65536]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); - const w = fbs.writer(); + var w: Io.Writer = .fixed(&buf); var count: i64 = 0; var last_id: i64 = 0; @@ -377,7 +373,7 @@ pub fn handleListHosts(conn: *h.Conn, query: []const u8, persist: *event_log_mod w.writeAll("{\"hostname\":\"") catch return; w.writeAll(hostname) catch return; w.writeAll("\"") catch return; - std.fmt.format(w, ",\"seq\":{d}", .{seq}) catch return; + w.print(",\"seq\":{d}", .{seq}) catch return; w.writeAll(",\"status\":\"") catch return; w.writeAll(status) catch return; w.writeAll("\"}") catch return; @@ -389,11 +385,11 @@ pub fn handleListHosts(conn: *h.Conn, query: []const u8, persist: *event_log_mod w.writeByte(']') catch return; if (count >= limit and count > 1) { - std.fmt.format(w, ",\"cursor\":\"{d}\"", .{last_id}) catch return; + w.print(",\"cursor\":\"{d}\"", .{last_id}) catch return; } w.writeByte('}') catch return; - h.respondJson(conn, .ok, fbs.getWritten()); + h.respondJson(conn, .ok, w.buffered()); } pub fn handleGetHostStatus(conn: *h.Conn, query: []const u8, persist: *event_log_mod.DiskPersist) void { @@ -440,18 +436,17 @@ pub fn handleGetHostStatus(conn: *h.Conn, query: []const u8, persist: *event_log } else 0; var buf: [4096]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); - const w = fbs.writer(); + var w: Io.Writer = .fixed(&buf); w.writeAll("{\"hostname\":\"") catch return; w.writeAll(host_name) catch return; w.writeAll("\"") catch return; - std.fmt.format(w, ",\"seq\":{d},\"accountCount\":{d}", .{ seq, account_count }) catch return; + w.print(",\"seq\":{d},\"accountCount\":{d}", .{ seq, account_count }) catch return; w.writeAll(",\"status\":\"") catch return; w.writeAll(status) catch return; w.writeAll("\"}") catch return; - h.respondJson(conn, .ok, fbs.getWritten()); + h.respondJson(conn, .ok, w.buffered()); } pub fn handleRequestCrawl(conn: *h.Conn, body: []const u8, slurper: *slurper_mod.Slurper) void { diff --git a/src/backfill.zig b/src/backfill.zig index 4b51d8a..7d85b12 100644 --- a/src/backfill.zig +++ b/src/backfill.zig @@ -8,6 +8,7 @@ //! triggered via POST /admin/backfill-collections, status via GET. const std = @import("std"); +const Io = std.Io; const http = std.http; const pg = @import("pg"); const collection_index_mod = @import("collection_index.zig"); @@ -27,11 +28,13 @@ pub const Backfiller = struct { running: std.atomic.Value(bool), thread: ?std.Thread, source: []const u8, + io: Io, pub fn init( allocator: Allocator, collection_index: *collection_index_mod.CollectionIndex, db: *pg.Pool, + io: Io, ) Backfiller { return .{ .allocator = allocator, @@ -40,6 +43,7 @@ pub const Backfiller = struct { .running = .{ .raw = false }, .thread = null, .source = "", + .io = io, }; } @@ -99,7 +103,7 @@ pub const Backfiller = struct { } fn discoverCollections(self: *Backfiller) ![][]const u8 { - var seen: std.StringHashMapUnmanaged(void) = .{}; + var seen: std.StringHashMapUnmanaged(void) = .empty; defer seen.deinit(self.allocator); // source 1: lexicon garden @@ -151,7 +155,7 @@ pub const Backfiller = struct { /// fetch NSIDs from https://lexicon.garden/llms.txt /// parses lines matching `- [``](` fn fetchLexiconGarden(self: *Backfiller) ![][]const u8 { - var client: http.Client = .{ .allocator = self.allocator }; + var client: http.Client = .{ .allocator = self.allocator, .io = self.io }; defer client.deinit(); var aw: std.Io.Writer.Allocating = .init(self.allocator); @@ -167,7 +171,7 @@ pub const Backfiller = struct { const body = aw.toArrayList().items; - var nsids: std.ArrayListUnmanaged([]const u8) = .{}; + var nsids: std.ArrayListUnmanaged([]const u8) = .empty; defer nsids.deinit(self.allocator); // parse lines like: - [`app.bsky.feed.post`]( @@ -217,7 +221,7 @@ pub const Backfiller = struct { } // reuse one HTTP client across all pages for this collection - var client: http.Client = .{ .allocator = self.allocator }; + var client: http.Client = .{ .allocator = self.allocator, .io = self.io }; defer client.deinit(); var page_count: usize = 0; @@ -253,7 +257,7 @@ pub const Backfiller = struct { cursor = self.allocator.dupe(u8, nc) catch break; // brief pause between pages - std.posix.nanosleep(0, 100 * std.time.ns_per_ms); + self.io.sleep(Io.Duration.fromMilliseconds(100), .awake) catch {}; } else { // no more pages — mark complete _ = self.db.exec( @@ -296,7 +300,7 @@ pub const Backfiller = struct { .next_cursor = null, }; - var dids: std.ArrayListUnmanaged([]const u8) = .{}; + var dids: std.ArrayListUnmanaged([]const u8) = .empty; defer dids.deinit(self.allocator); for (repos) |repo| { @@ -329,9 +333,9 @@ pub const Backfiller = struct { /// return status summary for the admin endpoint pub fn getStatus(self: *Backfiller, allocator: Allocator) ![]u8 { - var list: std.ArrayListUnmanaged(u8) = .{}; - defer list.deinit(allocator); - const w = list.writer(allocator); + var aw: Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + const w = &aw.writer; // query aggregate stats var total: i64 = 0; @@ -350,7 +354,7 @@ pub const Backfiller = struct { } } - std.fmt.format(w, "{{\"running\":{},\"total\":{d},\"completed\":{d},\"in_progress\":{d},\"total_imported\":{d},\"collections\":[", .{ + w.print("{{\"running\":{},\"total\":{d},\"completed\":{d},\"in_progress\":{d},\"total_imported\":{d},\"collections\":[", .{ self.isRunning(), total, completed, @@ -376,7 +380,7 @@ pub const Backfiller = struct { const count = dbrow.get(i64, 3); const is_completed = dbrow.get(bool, 4); - std.fmt.format(w, "{{\"collection\":\"{s}\",\"source\":\"{s}\",\"imported\":{d},\"completed\":{}", .{ + w.print("{{\"collection\":\"{s}\",\"source\":\"{s}\",\"imported\":{d},\"completed\":{}", .{ collection, source, count, @@ -384,13 +388,13 @@ pub const Backfiller = struct { }) catch {}; if (cursor_val.len > 0 and !is_completed) { - std.fmt.format(w, ",\"cursor\":\"{s}\"", .{cursor_val}) catch {}; + w.print(",\"cursor\":\"{s}\"", .{cursor_val}) catch {}; } w.writeByte('}') catch {}; } w.writeAll("]}") catch {}; - return try list.toOwnedSlice(allocator); + return try aw.toOwnedSlice(); } }; diff --git a/src/broadcaster.zig b/src/broadcaster.zig index 078849c..ea32646 100644 --- a/src/broadcaster.zig +++ b/src/broadcaster.zig @@ -8,6 +8,7 @@ //! - health/stats HTTP endpoints via websocket handshake routing const std = @import("std"); +const Io = std.Io; const websocket = @import("websocket"); const zat = @import("zat"); const ring_buffer = @import("ring_buffer.zig"); @@ -205,7 +206,7 @@ pub fn resequenceFrame(allocator: Allocator, data: []const u8, relay_seq: u64) ? }; // rebuild map entries with seq replaced - var new_entries: std.ArrayListUnmanaged(cbor.Value.MapEntry) = .{}; + var new_entries: std.ArrayListUnmanaged(cbor.Value.MapEntry) = .empty; var found_seq = false; for (old_entries) |entry| { if (std.mem.eql(u8, entry.key, "seq")) { @@ -245,16 +246,17 @@ pub const Consumer = struct { read_pos: usize = 0, buf_len: usize = 0, alive: std.atomic.Value(bool) = .{ .raw = true }, - mutex: std.Thread.Mutex = .{}, - cond: std.Thread.Condition = .{}, + mutex: Io.Mutex = Io.Mutex.init, + cond: Io.Condition = Io.Condition.init, thread: ?std.Thread = null, last_send_time: i128 = 0, + io: Io, /// push a shared frame to this consumer's send buffer. /// acquires a reference. returns false if full (consumer too slow). pub fn enqueue(self: *Consumer, frame: *SharedFrame) bool { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); if (self.buf_len == BUFFER_CAP) return false; @@ -262,7 +264,7 @@ pub const Consumer = struct { self.buf[self.write_pos] = frame; self.write_pos = (self.write_pos + 1) % BUFFER_CAP; self.buf_len += 1; - self.cond.signal(); + self.cond.signal(self.io); return true; } @@ -289,16 +291,19 @@ pub const Consumer = struct { } fn writeLoop(self: *Consumer) void { - self.last_send_time = std.time.nanoTimestamp(); + self.last_send_time = Io.Timestamp.now(self.io, .real).nanoseconds; while (self.alive.load(.acquire)) { var frame: ?*SharedFrame = null; { - self.mutex.lock(); - defer self.mutex.unlock(); - while (self.buf_len == 0 and self.alive.load(.acquire)) { - // wake every 100ms to check ping timer - self.cond.timedWait(&self.mutex, 100 * std.time.ns_per_ms) catch {}; + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); + if (self.buf_len == 0) { + // no data: poll briefly instead of blocking on cond + // (Io.Condition has no timedWait, so we poll to allow periodic ping checks) + self.mutex.unlock(self.io); + self.io.sleep(Io.Duration.fromMilliseconds(100), .awake) catch {}; + self.mutex.lockUncancelable(self.io); } frame = self.dequeue(); } @@ -308,7 +313,7 @@ pub const Consumer = struct { self.alive.store(false, .release); return; }; - self.last_send_time = std.time.nanoTimestamp(); + self.last_send_time = Io.Timestamp.now(self.io, .real).nanoseconds; } else { // no data — check if we should send a ping self.maybePing(); @@ -316,9 +321,9 @@ pub const Consumer = struct { } // drain remaining buffered frames while (true) { - self.mutex.lock(); + self.mutex.lockUncancelable(self.io); const frame = self.dequeue(); - self.mutex.unlock(); + self.mutex.unlock(self.io); if (frame) |f| { f.release(); } else break; @@ -326,7 +331,7 @@ pub const Consumer = struct { } fn maybePing(self: *Consumer) void { - const now = std.time.nanoTimestamp(); + const now = Io.Timestamp.now(self.io, .real).nanoseconds; const elapsed: u64 = @intCast(@max(0, now - self.last_send_time)); if (elapsed >= ping_interval_ns) { // send ping @@ -340,7 +345,7 @@ pub const Consumer = struct { pub fn shutdown(self: *Consumer) void { self.alive.store(false, .release); - self.cond.signal(); + self.cond.signal(self.io); if (self.thread) |t| t.join(); self.thread = null; } @@ -363,28 +368,30 @@ pub const HttpFallbackFn = *const fn ( pub const Broadcaster = struct { allocator: Allocator, - consumers: std.ArrayListUnmanaged(*Consumer) = .{}, - consumers_mutex: std.Thread.Mutex = .{}, - broadcast_order: std.Thread.Mutex = .{}, + consumers: std.ArrayListUnmanaged(*Consumer) = .empty, + consumers_mutex: Io.Mutex = Io.Mutex.init, + broadcast_order: Io.Mutex = Io.Mutex.init, history: FrameHistory, persist: ?*event_log_mod.DiskPersist = null, stats: Stats = .{}, error_frame: ?[]const u8 = null, http_fallback: ?HttpFallbackFn = null, http_fallback_ctx: ?*anyopaque = null, + io: Io, - pub fn init(allocator: Allocator) Broadcaster { + pub fn init(allocator: Allocator, io: Io) Broadcaster { return .{ .allocator = allocator, - .history = FrameHistory.init(allocator), - .stats = .{ .start_time = std.time.timestamp() }, + .history = FrameHistory.init(allocator, io), + .stats = .{ .start_time = timestamp(io) }, .error_frame = buildErrorFrame(allocator), + .io = io, }; } pub fn deinit(self: *Broadcaster) void { - self.consumers_mutex.lock(); - defer self.consumers_mutex.unlock(); + self.consumers_mutex.lockUncancelable(self.io); + defer self.consumers_mutex.unlock(self.io); for (self.consumers.items) |consumer| { consumer.shutdown(); self.allocator.destroy(consumer); @@ -399,14 +406,15 @@ pub const Broadcaster = struct { consumer.* = .{ .conn = conn, .allocator = self.allocator, + .io = self.io, }; consumer.thread = std.Thread.spawn(.{ .stack_size = @import("main.zig").default_stack_size }, Consumer.writeLoop, .{consumer}) catch { self.allocator.destroy(consumer); return error.ThreadSpawnFailed; }; - self.consumers_mutex.lock(); - defer self.consumers_mutex.unlock(); + self.consumers_mutex.lockUncancelable(self.io); + defer self.consumers_mutex.unlock(self.io); self.consumers.append(self.allocator, consumer) catch { consumer.shutdown(); self.allocator.destroy(consumer); @@ -419,8 +427,8 @@ pub const Broadcaster = struct { pub fn removeConsumer(self: *Broadcaster, consumer: *Consumer) void { { - self.consumers_mutex.lock(); - defer self.consumers_mutex.unlock(); + self.consumers_mutex.lockUncancelable(self.io); + defer self.consumers_mutex.unlock(self.io); for (self.consumers.items, 0..) |c, i| { if (c == consumer) { _ = self.consumers.swapRemove(i); @@ -446,8 +454,8 @@ pub const Broadcaster = struct { const frame = SharedFrame.create(self.allocator, data) catch return; defer frame.release(); // release broadcaster's reference - self.consumers_mutex.lock(); - defer self.consumers_mutex.unlock(); + self.consumers_mutex.lockUncancelable(self.io); + defer self.consumers_mutex.unlock(self.io); var frames_sent: u64 = 0; var i: usize = 0; @@ -482,7 +490,7 @@ pub const Broadcaster = struct { consumer.conn.writeBin(ef) catch {}; } consumer.alive.store(false, .release); - consumer.cond.signal(); + consumer.cond.signal(consumer.io); consumer.conn.close(.{}) catch {}; // clean up asynchronously to avoid joining thread while holding mutex const alloc = self.allocator; @@ -501,7 +509,7 @@ pub const Broadcaster = struct { pub fn replayTo(self: *Broadcaster, consumer: *Consumer, cursor: u64) void { // phase 1: disk replay from diskpersist if (self.persist) |dp| { - var entries: std.ArrayListUnmanaged(event_log_mod.PlaybackEntry) = .{}; + var entries: std.ArrayListUnmanaged(event_log_mod.PlaybackEntry) = .empty; defer { for (entries.items) |e| self.allocator.free(e.data); entries.deinit(self.allocator); @@ -537,8 +545,8 @@ pub const Broadcaster = struct { } fn replayFromMemory(self: *Broadcaster, consumer: *Consumer, cursor: u64) void { - self.history.mutex.lock(); - defer self.history.mutex.unlock(); + self.history.mutex.lockUncancelable(self.io); + defer self.history.mutex.unlock(self.io); var i: usize = 0; while (i < self.history.len) : (i += 1) { @@ -551,8 +559,8 @@ pub const Broadcaster = struct { } pub fn consumerCount(self: *Broadcaster) usize { - self.consumers_mutex.lock(); - defer self.consumers_mutex.unlock(); + self.consumers_mutex.lockUncancelable(self.io); + defer self.consumers_mutex.unlock(self.io); return self.consumers.items.len; } }; @@ -650,7 +658,7 @@ pub const Handler = struct { pub fn clientClose(self: *Handler, _: []const u8) !void { if (self.consumer) |c| { c.alive.store(false, .release); - c.cond.signal(); + c.cond.signal(c.io); } self.conn.close(.{}) catch {}; } @@ -689,20 +697,19 @@ pub const AttributionMetrics = struct { workers_count: usize = 0, }; -pub fn formatPrometheusMetrics(stats: *const Stats, cache_entries: usize, attribution: AttributionMetrics, data_dir: []const u8, buf: []u8) []const u8 { - const uptime: i64 = std.time.timestamp() - stats.start_time; - var fbs = std.io.fixedBufferStream(buf); - const w = fbs.writer(); +pub fn formatPrometheusMetrics(stats: *const Stats, cache_entries: usize, attribution: AttributionMetrics, data_dir: []const u8, buf: []u8, io: Io) []const u8 { + const uptime: i64 = timestamp(io) - stats.start_time; + var w: Io.Writer = .fixed(buf); // canary metric — proves what binary is running - std.fmt.format(w, + w.print( \\# TYPE relay_build_info gauge \\# HELP relay_build_info build provenance \\relay_build_info{{git_sha="{s}",optimize="{s}"}} 1 \\ , .{ build_options.git_sha, build_options.optimize }) catch {}; - std.fmt.format(w, + w.print( \\# TYPE relay_frames_received_total counter \\relay_frames_received_total {d} \\ @@ -831,10 +838,10 @@ pub fn formatPrometheusMetrics(stats: *const Stats, cache_entries: usize, attrib stats.host_authority_host_changed.load(.acquire), stats.host_authority_time_us.load(.acquire), stats.pool_queued_bytes.load(.acquire), - }) catch return fbs.getWritten(); + }) catch return w.buffered(); // validation failure breakdown by reason - std.fmt.format(w, + w.print( \\# TYPE relay_validation_failed counter \\# HELP relay_validation_failed validation failures by reason \\relay_validation_failed{{reason="bad_did"}} {d} @@ -853,10 +860,10 @@ pub fn formatPrometheusMetrics(stats: *const Stats, cache_entries: usize, attrib stats.failed_bad_structure.load(.acquire), stats.failed_host_authority.load(.acquire), stats.failed_future_rev.load(.acquire), - }) catch return fbs.getWritten(); + }) catch return w.buffered(); // memory attribution — internal capacities help identify what's consuming RSS - std.fmt.format(w, + w.print( \\# TYPE relay_validator_cache_map_cap gauge \\# HELP relay_validator_cache_map_cap hashmap backing capacity of signing key LRU \\relay_validator_cache_map_cap {d} @@ -892,26 +899,26 @@ pub fn formatPrometheusMetrics(stats: *const Stats, cache_entries: usize, attrib // linux-only process metrics from /proc if (comptime builtin.os.tag == .linux) { - appendProcMetrics(w); - appendDiskMetrics(w, data_dir); + appendProcMetrics(&w, io); + appendDiskMetrics(&w, data_dir); } - return fbs.getWritten(); + return w.buffered(); } -fn appendProcMetrics(w: anytype) void { +fn appendProcMetrics(w: *Io.Writer, io: Io) void { // RSS from /proc/self/statm (field[1] * page_size) - if (std.fs.openFileAbsolute("/proc/self/statm", .{})) |f| { - defer f.close(); + if (Io.Dir.openFileAbsolute(io, "/proc/self/statm", .{})) |f| { + defer f.close(io); var statm_buf: [256]u8 = undefined; - const n = f.readAll(&statm_buf) catch 0; + const n = f.readPositionalAll(io, &statm_buf, 0) catch 0; if (n > 0) { const line = statm_buf[0..n]; var iter = std.mem.splitScalar(u8, line, ' '); _ = iter.next(); // skip total pages if (iter.next()) |rss_pages| { if (std.fmt.parseInt(u64, rss_pages, 10)) |pages| { - std.fmt.format(w, + w.print( \\# TYPE relay_process_rss_bytes gauge \\relay_process_rss_bytes {d} \\ @@ -922,10 +929,10 @@ fn appendProcMetrics(w: anytype) void { } else |_| {} // thread count, VmHWM, RssAnon from /proc/self/status - if (std.fs.openFileAbsolute("/proc/self/status", .{})) |f| { - defer f.close(); + if (Io.Dir.openFileAbsolute(io, "/proc/self/status", .{})) |f| { + defer f.close(io); var status_buf: [4096]u8 = undefined; - const n = f.readAll(&status_buf) catch 0; + const n = f.readPositionalAll(io, &status_buf, 0) catch 0; if (n > 0) { const content = status_buf[0..n]; const fields = .{ @@ -940,7 +947,7 @@ fn appendProcMetrics(w: anytype) void { const end = std.mem.indexOfScalar(u8, trimmed, ' ') orelse (std.mem.indexOfScalar(u8, trimmed, '\n') orelse trimmed.len); if (std.fmt.parseInt(u64, trimmed[0..end], 10)) |val| { - std.fmt.format(w, + w.print( \\# TYPE {s} gauge \\{s} {d} \\ @@ -959,7 +966,7 @@ fn appendProcMetrics(w: anytype) void { const in_use: u64 = @as(u32, @bitCast(mi.uordblks)); const free_bytes: u64 = @as(u32, @bitCast(mi.fordblks)); const mmap_bytes: u64 = @as(u32, @bitCast(mi.hblkhd)); - std.fmt.format(w, + w.print( \\# TYPE relay_malloc_arena_bytes gauge \\relay_malloc_arena_bytes {d} \\ @@ -978,7 +985,7 @@ fn appendProcMetrics(w: anytype) void { const posix_vfs = @cImport(@cInclude("sys/statvfs.h")); const malloc_h = @cImport(@cInclude("malloc.h")); -fn appendDiskMetrics(w: anytype, data_dir: []const u8) void { +fn appendDiskMetrics(w: *Io.Writer, data_dir: []const u8) void { // statvfs needs a null-terminated path var path_buf: [4096]u8 = undefined; if (data_dir.len >= path_buf.len) return; @@ -992,7 +999,7 @@ fn appendDiskMetrics(w: anytype, data_dir: []const u8) void { const total = stat.f_blocks * block_size; const available = stat.f_bavail * block_size; - std.fmt.format(w, + w.print( \\# TYPE relay_disk_total_bytes gauge \\relay_disk_total_bytes {d} \\ @@ -1002,7 +1009,7 @@ fn appendDiskMetrics(w: anytype, data_dir: []const u8) void { , .{ total, available }) catch {}; } -pub fn formatStatsResponse(stats: *const Stats, buf: []u8) []const u8 { +pub fn formatStatsResponse(stats: *const Stats, buf: []u8, io: Io) []const u8 { return std.fmt.bufPrint(buf, \\{{"seq":{d},"relay_seq":{d},"consumers":{d},"connected_inbound":{d},"frames_in":{d},"frames_out":{d},"validated":{d},"failed":{d},"skipped":{d},"decode_errors":{d},"cache_hits":{d},"cache_misses":{d},"slow_consumers":{d},"chain_breaks":{d},"pool_backpressure":{d},"uptime_seconds":{d}}} , .{ @@ -1021,14 +1028,18 @@ pub fn formatStatsResponse(stats: *const Stats, buf: []u8) []const u8 { stats.slow_consumers.load(.acquire), stats.chain_breaks.load(.acquire), stats.pool_backpressure.load(.acquire), - std.time.timestamp() - stats.start_time, + timestamp(io) - stats.start_time, }) catch ""; } +fn timestamp(io: Io) i64 { + return @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_s)); +} + // --- tests --- test "broadcaster add and remove consumer" { - var b = Broadcaster.init(std.testing.allocator); + var b = Broadcaster.init(std.testing.allocator, std.testing.io); defer b.deinit(); try std.testing.expectEqual(@as(u64, 0), b.stats.seq.load(.acquire)); @@ -1036,7 +1047,7 @@ test "broadcaster add and remove consumer" { } test "broadcast updates stats and history" { - var b = Broadcaster.init(std.testing.allocator); + var b = Broadcaster.init(std.testing.allocator, std.testing.io); defer b.deinit(); b.broadcast(1, "frame1"); @@ -1051,7 +1062,7 @@ test "broadcast updates stats and history" { } test "frame history supports cursor replay" { - var b = Broadcaster.init(std.testing.allocator); + var b = Broadcaster.init(std.testing.allocator, std.testing.io); defer b.deinit(); for (1..6) |i| { @@ -1102,7 +1113,7 @@ test "error frame is valid CBOR" { } test "formatPrometheusMetrics produces valid output" { - var stats = Stats{ .start_time = std.time.timestamp() - 60 }; + var stats = Stats{ .start_time = timestamp(std.testing.io) - 60 }; stats.seq.store(99999, .release); stats.relay_seq.store(12345, .release); stats.consumer_count.store(3, .release); @@ -1115,7 +1126,7 @@ test "formatPrometheusMetrics produces valid output" { stats.cache_misses.store(100, .release); var buf: [65536]u8 = undefined; - const output = formatPrometheusMetrics(&stats, 42, .{}, "/tmp", &buf); + const output = formatPrometheusMetrics(&stats, 42, .{}, "/tmp", &buf, std.testing.io); try std.testing.expect(std.mem.indexOf(u8, output, "relay_frames_received_total 10000") != null); try std.testing.expect(std.mem.indexOf(u8, output, "relay_frames_broadcast_total 9000") != null); @@ -1131,13 +1142,13 @@ test "formatPrometheusMetrics produces valid output" { } test "formatStatsResponse produces valid JSON" { - var stats = Stats{ .start_time = std.time.timestamp() - 42 }; + var stats = Stats{ .start_time = timestamp(std.testing.io) - 42 }; stats.seq.store(100, .release); stats.frames_in.store(200, .release); stats.consumer_count.store(5, .release); var buf: [4096]u8 = undefined; - const response = formatStatsResponse(&stats, &buf); + const response = formatStatsResponse(&stats, &buf, std.testing.io); try std.testing.expect(std.mem.startsWith(u8, response, "{")); try std.testing.expect(std.mem.indexOf(u8, response, "\"seq\":100") != null); @@ -1272,7 +1283,7 @@ test "concurrent broadcast through ordering mutex produces monotonic sequences" // ordering lock, assign a seq (atomic increment, like persist), and // broadcast. the ring buffer history must be strictly monotonic. - var bc = Broadcaster.init(std.testing.allocator); + var bc = Broadcaster.init(std.testing.allocator, std.testing.io); defer bc.deinit(); const num_threads = 8; @@ -1281,13 +1292,13 @@ test "concurrent broadcast through ordering mutex produces monotonic sequences" var seq_counter = std.atomic.Value(u64).init(0); const Worker = struct { - fn run(broadcaster: *Broadcaster, counter: *std.atomic.Value(u64)) void { + fn run(bc_ptr: *Broadcaster, counter: *std.atomic.Value(u64)) void { for (0..frames_per_thread) |_| { - broadcaster.broadcast_order.lock(); - defer broadcaster.broadcast_order.unlock(); + bc_ptr.broadcast_order.lockUncancelable(bc_ptr.io); + defer bc_ptr.broadcast_order.unlock(bc_ptr.io); const seq = counter.fetchAdd(1, .monotonic) + 1; - broadcaster.broadcast(seq, "x"); + bc_ptr.broadcast(seq, "x"); } } }; diff --git a/src/cleaner.zig b/src/cleaner.zig index 0ed6330..4678f21 100644 --- a/src/cleaner.zig +++ b/src/cleaner.zig @@ -5,6 +5,7 @@ //! modeled on backfill.zig's Backfiller pattern. const std = @import("std"); +const Io = std.Io; const pg = @import("pg"); const collection_index_mod = @import("collection_index.zig"); @@ -13,6 +14,7 @@ const log = std.log.scoped(.cleaner); pub const Cleaner = struct { allocator: Allocator, + io: Io, collection_index: *collection_index_mod.CollectionIndex, db: *pg.Pool, running: std.atomic.Value(bool), @@ -22,11 +24,13 @@ pub const Cleaner = struct { pub fn init( allocator: Allocator, + io: Io, collection_index: *collection_index_mod.CollectionIndex, db: *pg.Pool, ) Cleaner { return .{ .allocator = allocator, + .io = io, .collection_index = collection_index, .db = db, .running = .{ .raw = false }, @@ -49,7 +53,7 @@ pub const Cleaner = struct { self.scanned.store(0, .release); self.removed.store(0, .release); - self.thread = try std.Thread.spawn(.{ .stack_size = @import("main.zig").default_stack_size }, run, .{self}); + self.thread = try std.Thread.spawn(.{}, run, .{self}); } fn run(self: *Cleaner) void { @@ -97,7 +101,7 @@ pub const Cleaner = struct { }); // brief pause between batches to avoid hammering postgres - std.posix.nanosleep(0, 50 * std.time.ns_per_ms); + self.io.sleep(Io.Duration.fromMilliseconds(50), .awake) catch {}; } const total = self.scanned.load(.monotonic); diff --git a/src/collection_index.zig b/src/collection_index.zig index 5c69be7..20205b2 100644 --- a/src/collection_index.zig +++ b/src/collection_index.zig @@ -276,7 +276,7 @@ pub const CollectionIndex = struct { pub fn listKnownCollections(self: *CollectionIndex, allocator: Allocator) ![][]const u8 { var err_str: ?rocksdb.Data = null; - var seen: std.StringHashMapUnmanaged(void) = .{}; + var seen: std.StringHashMapUnmanaged(void) = .empty; defer seen.deinit(allocator); // full scan of RBC — keys are collection\0did @@ -359,7 +359,7 @@ test "collection index: open and close" { var dir = std.testing.tmpDir(.{}); defer dir.cleanup(); - const path = try dir.dir.realpathAlloc(allocator, "."); + const path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{@as([]const u8, &dir.sub_path)}); defer allocator.free(path); var ci = CollectionIndex.open(allocator, path) catch |err| { @@ -375,7 +375,7 @@ test "collection index: basic put and get" { var dir = std.testing.tmpDir(.{}); defer dir.cleanup(); - const path = try dir.dir.realpathAlloc(allocator, "."); + const path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{@as([]const u8, &dir.sub_path)}); defer allocator.free(path); var ci = CollectionIndex.open(allocator, path) catch |err| { @@ -402,7 +402,7 @@ test "collection index: list and cursor" { var dir = std.testing.tmpDir(.{}); defer dir.cleanup(); - const path = try dir.dir.realpathAlloc(allocator, "."); + const path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{@as([]const u8, &dir.sub_path)}); defer allocator.free(path); var ci = CollectionIndex.open(allocator, path) catch |err| { @@ -434,7 +434,7 @@ test "collection index: removeAll" { var dir = std.testing.tmpDir(.{}); defer dir.cleanup(); - const path = try dir.dir.realpathAlloc(allocator, "."); + const path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{@as([]const u8, &dir.sub_path)}); defer allocator.free(path); var ci = CollectionIndex.open(allocator, path) catch |err| { @@ -462,7 +462,7 @@ test "collection index: listKnownCollections" { var dir = std.testing.tmpDir(.{}); defer dir.cleanup(); - const path = try dir.dir.realpathAlloc(allocator, "."); + const path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{@as([]const u8, &dir.sub_path)}); defer allocator.free(path); var ci = CollectionIndex.open(allocator, path) catch |err| { @@ -504,7 +504,7 @@ test "collection index: idempotent add" { var dir = std.testing.tmpDir(.{}); defer dir.cleanup(); - const path = try dir.dir.realpathAlloc(allocator, "."); + const path = try std.fmt.allocPrint(allocator, ".zig-cache/tmp/{s}", .{@as([]const u8, &dir.sub_path)}); defer allocator.free(path); var ci = CollectionIndex.open(allocator, path) catch |err| { diff --git a/src/event_log.zig b/src/event_log.zig index a08d610..738345e 100644 --- a/src/event_log.zig +++ b/src/event_log.zig @@ -11,6 +11,7 @@ //! see: indigo cmd/relay/stream/persist/diskpersist/diskpersist.go const std = @import("std"); +const Io = std.Io; const pg = @import("pg"); const lru = @import("lru.zig"); @@ -79,10 +80,11 @@ const PersistJob = struct { pub const DiskPersist = struct { allocator: Allocator, dir_path: []const u8, - dir: std.fs.Dir, + dir: Io.Dir, db: *pg.Pool, - current_file: ?std.fs.File = null, + current_file: ?Io.File = null, current_file_path: ?[]const u8 = null, + current_file_pos: u64 = 0, // sequence state cur_seq: u64 = 1, @@ -97,19 +99,20 @@ pub const DiskPersist = struct { did_cache: lru.LruCache(u64), // write buffer (flushed periodically or when threshold hit) - outbuf: std.ArrayListUnmanaged(u8) = .{}, - evtbuf: std.ArrayListUnmanaged(PersistJob) = .{}, - mutex: std.Thread.Mutex = .{}, + outbuf: std.ArrayListUnmanaged(u8) = .empty, + evtbuf: std.ArrayListUnmanaged(PersistJob) = .empty, + mutex: Io.Mutex = Io.Mutex.init, // flush thread flush_thread: ?std.Thread = null, alive: std.atomic.Value(bool) = .{ .raw = true }, - flush_cond: std.Thread.Condition = .{}, + + io: Io, /// current evtbuf entry count (for metrics — non-blocking, returns 0 if lock is contended) pub fn evtbufLen(self: *DiskPersist) usize { if (!self.mutex.tryLock()) return 0; - defer self.mutex.unlock(); + defer self.mutex.unlock(self.io); return self.evtbuf.items.len; } @@ -126,30 +129,27 @@ pub const DiskPersist = struct { /// evtbuf allocated capacity in jobs (for memory attribution — non-blocking) pub fn evtbufCap(self: *DiskPersist) usize { if (!self.mutex.tryLock()) return 0; - defer self.mutex.unlock(); + defer self.mutex.unlock(self.io); return self.evtbuf.capacity; } /// outbuf allocated capacity in bytes (for memory attribution — non-blocking) pub fn outbufCap(self: *DiskPersist) usize { if (!self.mutex.tryLock()) return 0; - defer self.mutex.unlock(); + defer self.mutex.unlock(self.io); return self.outbuf.capacity; } - pub fn init(allocator: Allocator, dir_path: []const u8, database_url: []const u8) !DiskPersist { + pub fn init(allocator: Allocator, dir_path: []const u8, database_url: []const u8, io: Io) !DiskPersist { // ensure directory exists - std.fs.cwd().makePath(dir_path) catch |err| switch (err) { - error.PathAlreadyExists => {}, - else => return err, - }; + try Io.Dir.cwd().createDirPath(io, dir_path); - var dir = try std.fs.cwd().openDir(dir_path, .{ .iterate = true }); - errdefer dir.close(); + var dir = try Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }); + errdefer dir.close(io); // connect to Postgres const uri = std.Uri.parse(database_url) catch return error.InvalidDatabaseUrl; - const pool = try pg.Pool.initUri(allocator, uri, .{ .size = 5 }); + const pool = try pg.Pool.initUri(allocator, io, uri, .{ .size = 5 }); errdefer pool.deinit(); // create tables (matching indigo's Go relay schema) @@ -242,7 +242,8 @@ pub const DiskPersist = struct { .dir_path = try allocator.dupe(u8, dir_path), .dir = dir, .db = pool, - .did_cache = lru.LruCache(u64).init(allocator, 500_000), + .did_cache = lru.LruCache(u64).init(allocator, 500_000, io), + .io = io, }; // recover from existing log files @@ -252,15 +253,14 @@ pub const DiskPersist = struct { } pub fn deinit(self: *DiskPersist) void { - // stop flush thread + // stop flush thread (loop checks alive after each sleep interval) self.alive.store(false, .release); - self.flush_cond.signal(); if (self.flush_thread) |t| t.join(); // flush remaining - self.mutex.lock(); + self.mutex.lockUncancelable(self.io); self.flushLocked() catch {}; - self.mutex.unlock(); + self.mutex.unlock(self.io); // free write buffer for (self.evtbuf.items) |job| self.allocator.free(job.data); @@ -269,9 +269,9 @@ pub const DiskPersist = struct { self.did_cache.deinit(); - if (self.current_file) |f| f.close(); + if (self.current_file) |f| f.close(self.io); if (self.current_file_path) |p| self.allocator.free(p); - self.dir.close(); + self.dir.close(self.io); self.db.deinit(); self.allocator.free(self.dir_path); } @@ -544,7 +544,7 @@ pub const DiskPersist = struct { /// list all active hosts pub fn listActiveHosts(self: *DiskPersist, allocator: Allocator) ![]Host { - var hosts: std.ArrayListUnmanaged(Host) = .{}; + var hosts: std.ArrayListUnmanaged(Host) = .empty; errdefer { for (hosts.items) |h| { allocator.free(h.hostname); @@ -575,7 +575,7 @@ pub const DiskPersist = struct { /// list all hosts (any status) for admin view pub fn listAllHosts(self: *DiskPersist, allocator: Allocator) ![]Host { - var hosts: std.ArrayListUnmanaged(Host) = .{}; + var hosts: std.ArrayListUnmanaged(Host) = .empty; errdefer { for (hosts.items) |h| { allocator.free(h.hostname); @@ -668,8 +668,8 @@ pub const DiskPersist = struct { header.encode(data[0..header_size]); @memcpy(data[header_size..], payload); - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); // assign seq const seq = self.cur_seq; @@ -690,13 +690,13 @@ pub const DiskPersist = struct { /// playback events with seq > since. calls cb for each event. pub fn playback(self: *DiskPersist, since: u64, allocator: Allocator, entries: *std.ArrayListUnmanaged(PlaybackEntry)) !void { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); const since_i: i64 = @intCast(since); // find the log file containing `since` - var start_files: std.ArrayListUnmanaged(LogFileRef) = .{}; + var start_files: std.ArrayListUnmanaged(LogFileRef) = .empty; defer start_files.deinit(allocator); if (since > 0) { @@ -733,9 +733,9 @@ pub const DiskPersist = struct { // read events from each file for (start_files.items) |ref| { - var file = self.dir.openFile(ref.path, .{}) catch continue; - defer file.close(); - try readEventsFrom(allocator, file, since, entries); + var file = self.dir.openFile(self.io, ref.path, .{}) catch continue; + defer file.close(self.io); + try readEventsFrom(allocator, file, self.io, since, entries); } } @@ -761,14 +761,14 @@ pub const DiskPersist = struct { /// garbage-collect log files older than the retention period pub fn gc(self: *DiskPersist) !void { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); const cutoff_interval = try std.fmt.allocPrint(self.allocator, "{d} hours", .{self.retention_hours}); defer self.allocator.free(cutoff_interval); // find expired refs - var expired: std.ArrayListUnmanaged(GcRef) = .{}; + var expired: std.ArrayListUnmanaged(GcRef) = .empty; defer { for (expired.items) |e| self.allocator.free(e.path); expired.deinit(self.allocator); @@ -801,7 +801,7 @@ pub const DiskPersist = struct { }; // delete file - self.dir.deleteFile(ref.path) catch |err| { + self.dir.deleteFile(self.io, ref.path) catch |err| { log.warn("gc: failed to delete {s}: {s}", .{ ref.path, @errorName(err) }); }; } @@ -819,7 +819,7 @@ pub const DiskPersist = struct { /// delete oldest event files until total directory size is under max_dir_bytes fn gcBySize(self: *DiskPersist) !void { // query all refs ordered by creation time (oldest first) - var refs: std.ArrayListUnmanaged(GcSizeRef) = .{}; + var refs: std.ArrayListUnmanaged(GcSizeRef) = .empty; defer { for (refs.items) |e| self.allocator.free(e.path); refs.deinit(self.allocator); @@ -834,7 +834,7 @@ pub const DiskPersist = struct { defer result.deinit(); while (result.nextUnsafe() catch null) |r| { const path = r.get([]const u8, 1); - const stat = self.dir.statFile(path) catch continue; + const stat = self.dir.statFile(self.io, path, .{}) catch continue; total_bytes += stat.size; try refs.append(self.allocator, .{ .id = r.get(i64, 0), @@ -865,7 +865,7 @@ pub const DiskPersist = struct { continue; }; - self.dir.deleteFile(ref.path) catch |err| { + self.dir.deleteFile(self.io, ref.path) catch |err| { log.warn("gc: failed to delete {s}: {s}", .{ ref.path, @errorName(err) }); continue; // don't subtract size if file delete failed }; @@ -884,11 +884,11 @@ pub const DiskPersist = struct { /// take down all events for a user (set flag + zero payload) pub fn takeDownUser(self: *DiskPersist, uid: u64) !void { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); // iterate all log files - var refs: std.ArrayListUnmanaged([]const u8) = .{}; + var refs: std.ArrayListUnmanaged([]const u8) = .empty; defer { for (refs.items) |p| self.allocator.free(p); refs.deinit(self.allocator); @@ -903,9 +903,9 @@ pub const DiskPersist = struct { } for (refs.items) |path| { - var file = self.dir.openFile(path, .{ .mode = .read_write }) catch continue; - defer file.close(); - mutateUserEventsInFile(file, uid) catch |err| { + var file = self.dir.openFile(self.io, path, .{ .mode = .read_write }) catch continue; + defer file.close(self.io); + mutateUserEventsInFile(file, self.io, uid) catch |err| { log.warn("takedown: failed to process {s}: {s}", .{ path, @errorName(err) }); }; } @@ -924,15 +924,15 @@ pub const DiskPersist = struct { const path = r.get([]const u8, 1); const seq_start: u64 = @intCast(r.get(i64, 2)); - var file = self.dir.openFile(path, .{ .mode = .read_write }) catch { + var file = self.dir.openFile(self.io, path, .{ .mode = .read_write }) catch { // file missing, start fresh try self.initLogFile(); return; }; // scan for last seq - const last_seq = scanForLastSeq(file) catch { - file.close(); + const last_seq = scanForLastSeq(file, self.io) catch { + file.close(self.io); try self.initLogFile(); return; }; @@ -944,9 +944,9 @@ pub const DiskPersist = struct { self.cur_seq = if (seq_start > 0) seq_start else 1; } - // seek to end for appending - const stat = try file.stat(); - try file.seekTo(stat.size); + // track position for appending (at end of file) + const file_stat = try file.stat(self.io); + self.current_file_pos = file_stat.size; self.current_file = file; self.current_file_path = try self.allocator.dupe(u8, path); @@ -963,10 +963,11 @@ pub const DiskPersist = struct { var name_buf: [64]u8 = undefined; const name = std.fmt.bufPrint(&name_buf, "evts-{d}", .{start_seq}) catch unreachable; - if (self.current_file) |f| f.close(); + if (self.current_file) |f| f.close(self.io); if (self.current_file_path) |p| self.allocator.free(p); - self.current_file = try self.dir.createFile(name, .{ .truncate = false }); + self.current_file = try self.dir.createFile(self.io, name, .{ .truncate = false, .read = true }); + self.current_file_pos = 0; self.current_file_path = try self.allocator.dupe(u8, name); // register in Postgres @@ -983,10 +984,11 @@ pub const DiskPersist = struct { // write buffered bytes to current file const file = self.current_file orelse return; - file.writeAll(self.outbuf.items) catch |err| { + file.writePositionalAll(self.io, self.outbuf.items, self.current_file_pos) catch |err| { log.err("flush: write failed: {s}", .{@errorName(err)}); return err; }; + self.current_file_pos += self.outbuf.items.len; // clear buffers self.outbuf.clearRetainingCapacity(); @@ -1007,13 +1009,11 @@ pub const DiskPersist = struct { fn flushLoop(self: *DiskPersist) void { while (self.alive.load(.acquire)) { - // wait for flush interval or signal - { - self.mutex.lock(); - defer self.mutex.unlock(); - self.flush_cond.timedWait(&self.mutex, default_flush_interval_ms * std.time.ns_per_ms) catch {}; - self.flushLocked() catch {}; - } + // sleep for flush interval (replaces timedWait — Io.Condition has no timed variant) + self.io.sleep(Io.Duration.fromMilliseconds(@intCast(default_flush_interval_ms)), .awake) catch {}; + self.mutex.lockUncancelable(self.io); + self.flushLocked() catch {}; + self.mutex.unlock(self.io); } } }; @@ -1045,36 +1045,38 @@ const GcSizeRef = struct { // --- file-level operations --- -fn readEventsFrom(allocator: Allocator, file: std.fs.File, since: u64, result: *std.ArrayListUnmanaged(PlaybackEntry)) !void { - const file_size = (try file.stat()).size; +fn readEventsFrom(allocator: Allocator, file: Io.File, io: Io, since: u64, result: *std.ArrayListUnmanaged(PlaybackEntry)) !void { + const file_size = (try file.stat(io)).size; // if since > 0, scan to the right position + var pos: u64 = 0; if (since > 0) { - seekToSeq(file, since, file_size) catch return; + pos = seekToSeq(file, io, since, file_size) catch return; } // read events - while (true) { + while (pos + header_size <= file_size) { var hdr_buf: [header_size]u8 = undefined; - const n = file.readAll(&hdr_buf) catch break; + const n = file.readPositionalAll(io, &hdr_buf, pos) catch break; if (n < header_size) break; + pos += header_size; const hdr = EvtHeader.decode(&hdr_buf); // skip taken down / rebased events if (hdr.flags & (EvtFlags.takedown | EvtFlags.rebased) != 0) { - file.seekBy(@intCast(hdr.len)) catch break; + pos += hdr.len; continue; } if (hdr.seq <= since) { - file.seekBy(@intCast(hdr.len)) catch break; + pos += hdr.len; continue; } // read payload const data = allocator.alloc(u8, hdr.len) catch break; - const read_n = file.readAll(data) catch { + const read_n = file.readPositionalAll(io, data, pos) catch { allocator.free(data); break; }; @@ -1082,6 +1084,7 @@ fn readEventsFrom(allocator: Allocator, file: std.fs.File, since: u64, result: * allocator.free(data); break; } + pos += hdr.len; result.append(allocator, .{ .seq = hdr.seq, @@ -1095,37 +1098,32 @@ fn readEventsFrom(allocator: Allocator, file: std.fs.File, since: u64, result: * } } -/// scan file headers to seek to the first event with seq > target -fn seekToSeq(file: std.fs.File, target: u64, file_size: u64) !void { - try file.seekTo(0); +/// scan file headers to find position of first event with seq > target +fn seekToSeq(file: Io.File, io: Io, target: u64, file_size: u64) !u64 { var pos: u64 = 0; while (pos + header_size <= file_size) { var hdr_buf: [header_size]u8 = undefined; - const n = try file.readAll(&hdr_buf); + const n = try file.readPositionalAll(io, &hdr_buf, pos); if (n < header_size) break; const hdr = EvtHeader.decode(&hdr_buf); if (hdr.seq > target) { - // seek back to start of this header - try file.seekTo(pos); - return; + return pos; } pos += header_size + hdr.len; - try file.seekTo(pos); } + return pos; } /// scan a file for the last sequence number -fn scanForLastSeq(file: std.fs.File) !?u64 { - try file.seekTo(0); - const file_size = (try file.stat()).size; +fn scanForLastSeq(file: Io.File, io: Io) !?u64 { + const file_size = (try file.stat(io)).size; var last_seq: ?u64 = null; var pos: u64 = 0; while (pos + header_size <= file_size) { var hdr_buf: [header_size]u8 = undefined; - try file.seekTo(pos); - const n = try file.readAll(&hdr_buf); + const n = try file.readPositionalAll(io, &hdr_buf, pos); if (n < header_size) break; const hdr = EvtHeader.decode(&hdr_buf); @@ -1136,14 +1134,13 @@ fn scanForLastSeq(file: std.fs.File) !?u64 { } /// set takedown flag + zero payload for all events belonging to uid -fn mutateUserEventsInFile(file: std.fs.File, uid: u64) !void { - const file_size = (try file.stat()).size; +fn mutateUserEventsInFile(file: Io.File, io: Io, uid: u64) !void { + const file_size = (try file.stat(io)).size; var pos: u64 = 0; while (pos + header_size <= file_size) { var hdr_buf: [header_size]u8 = undefined; - try file.seekTo(pos); - const n = try file.readAll(&hdr_buf); + const n = try file.readPositionalAll(io, &hdr_buf, pos); if (n < header_size) break; const hdr = EvtHeader.decode(&hdr_buf); @@ -1153,17 +1150,17 @@ fn mutateUserEventsInFile(file: std.fs.File, uid: u64) !void { const new_flags = hdr.flags | EvtFlags.takedown; var flags_buf: [4]u8 = undefined; std.mem.writeInt(u32, &flags_buf, new_flags, .little); - try file.seekTo(pos); - try file.writeAll(&flags_buf); + try file.writePositionalAll(io, &flags_buf, pos); // zero the payload const payload_start = pos + header_size; - try file.seekTo(payload_start); var zeros: [4096]u8 = [_]u8{0} ** 4096; var remaining: u64 = hdr.len; + var write_pos: u64 = payload_start; while (remaining > 0) { - const chunk = @min(remaining, zeros.len); - try file.writeAll(zeros[0..chunk]); + const chunk: usize = @min(remaining, zeros.len); + try file.writePositionalAll(io, zeros[0..chunk], write_pos); + write_pos += chunk; remaining -= chunk; } } @@ -1201,8 +1198,13 @@ test "header is little-endian" { try std.testing.expectEqual(@as(u8, 0x01), buf[9]); } +fn getenv(key: [*:0]const u8) ?[]const u8 { + const ptr = std.c.getenv(key) orelse return null; + return std.mem.sliceTo(ptr, 0); +} + fn requireDatabaseUrl() ![]const u8 { - return std.posix.getenv("DATABASE_URL") orelse return error.SkipZigTest; + return getenv("DATABASE_URL") orelse return error.SkipZigTest; } test "persist and playback" { @@ -1214,7 +1216,7 @@ test "persist and playback" { const dir_path = try tmpDirRealPath(std.testing.allocator, tmp); defer std.testing.allocator.free(dir_path); - var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url); + var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url, std.testing.io); defer dp.deinit(); // persist some events (sync flush, no background thread) @@ -1228,13 +1230,13 @@ test "persist and playback" { // flush manually { - dp.mutex.lock(); - defer dp.mutex.unlock(); + dp.mutex.lockUncancelable(dp.io); + defer dp.mutex.unlock(dp.io); try dp.flushLocked(); } // playback from cursor=0 → all events - var entries: std.ArrayListUnmanaged(PlaybackEntry) = .{}; + var entries: std.ArrayListUnmanaged(PlaybackEntry) = .empty; defer { for (entries.items) |e| std.testing.allocator.free(e.data); entries.deinit(std.testing.allocator); @@ -1257,20 +1259,20 @@ test "playback with cursor" { const dir_path = try tmpDirRealPath(std.testing.allocator, tmp); defer std.testing.allocator.free(dir_path); - var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url); + var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url, std.testing.io); defer dp.deinit(); _ = try dp.persist(.commit, 1, "a"); _ = try dp.persist(.commit, 2, "b"); _ = try dp.persist(.commit, 3, "c"); { - dp.mutex.lock(); - defer dp.mutex.unlock(); + dp.mutex.lockUncancelable(dp.io); + defer dp.mutex.unlock(dp.io); try dp.flushLocked(); } // playback from cursor=2 → only seq 3 - var entries: std.ArrayListUnmanaged(PlaybackEntry) = .{}; + var entries: std.ArrayListUnmanaged(PlaybackEntry) = .empty; defer { for (entries.items) |e| std.testing.allocator.free(e.data); entries.deinit(std.testing.allocator); @@ -1293,19 +1295,19 @@ test "seq recovery after reinit" { // write some events { - var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url); + var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url, std.testing.io); defer dp.deinit(); _ = try dp.persist(.commit, 1, "x"); _ = try dp.persist(.commit, 2, "y"); _ = try dp.persist(.account, 3, "z"); - dp.mutex.lock(); - defer dp.mutex.unlock(); + dp.mutex.lockUncancelable(dp.io); + defer dp.mutex.unlock(dp.io); try dp.flushLocked(); } // reinit — should recover seq { - var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url); + var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url, std.testing.io); defer dp.deinit(); try std.testing.expectEqual(@as(u64, 3), dp.lastSeq().?); const seq4 = try dp.persist(.commit, 1, "w"); @@ -1322,14 +1324,14 @@ test "takedown zeros payload" { const dir_path = try tmpDirRealPath(std.testing.allocator, tmp); defer std.testing.allocator.free(dir_path); - var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url); + var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url, std.testing.io); defer dp.deinit(); _ = try dp.persist(.commit, 42, "secret-data"); _ = try dp.persist(.commit, 99, "other-data"); { - dp.mutex.lock(); - defer dp.mutex.unlock(); + dp.mutex.lockUncancelable(dp.io); + defer dp.mutex.unlock(dp.io); try dp.flushLocked(); } @@ -1337,7 +1339,7 @@ test "takedown zeros payload" { try dp.takeDownUser(42); // playback should skip user 42's events - var entries: std.ArrayListUnmanaged(PlaybackEntry) = .{}; + var entries: std.ArrayListUnmanaged(PlaybackEntry) = .empty; defer { for (entries.items) |e| std.testing.allocator.free(e.data); entries.deinit(std.testing.allocator); @@ -1357,7 +1359,7 @@ test "uidForDid assigns and caches UIDs" { const dir_path = try tmpDirRealPath(std.testing.allocator, tmp); defer std.testing.allocator.free(dir_path); - var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url); + var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url, std.testing.io); defer dp.deinit(); // first call creates the account @@ -1385,14 +1387,14 @@ test "uidForDid survives reinit" { var uid1: u64 = undefined; { - var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url); + var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url, std.testing.io); defer dp.deinit(); uid1 = try dp.uidForDid("did:plc:carol"); } // reinit — UID should be the same from database { - var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url); + var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url, std.testing.io); defer dp.deinit(); const uid1_again = try dp.uidForDid("did:plc:carol"); try std.testing.expectEqual(uid1, uid1_again); @@ -1408,7 +1410,7 @@ test "takedown with real UIDs" { const dir_path = try tmpDirRealPath(std.testing.allocator, tmp); defer std.testing.allocator.free(dir_path); - var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url); + var dp = try DiskPersist.init(std.testing.allocator, dir_path, database_url, std.testing.io); defer dp.deinit(); const alice_uid = try dp.uidForDid("did:plc:alice"); @@ -1418,8 +1420,8 @@ test "takedown with real UIDs" { _ = try dp.persist(.commit, bob_uid, "bob-post"); _ = try dp.persist(.commit, alice_uid, "alice-post-2"); { - dp.mutex.lock(); - defer dp.mutex.unlock(); + dp.mutex.lockUncancelable(dp.io); + defer dp.mutex.unlock(dp.io); try dp.flushLocked(); } @@ -1427,7 +1429,7 @@ test "takedown with real UIDs" { try dp.takeDownUser(alice_uid); // playback should only have bob's event - var entries: std.ArrayListUnmanaged(PlaybackEntry) = .{}; + var entries: std.ArrayListUnmanaged(PlaybackEntry) = .empty; defer { for (entries.items) |e| std.testing.allocator.free(e.data); entries.deinit(std.testing.allocator); @@ -1440,7 +1442,7 @@ test "takedown with real UIDs" { } fn tmpDirRealPath(allocator: Allocator, tmp: std.testing.TmpDir) ![]const u8 { - var buf: [std.fs.max_path_bytes]u8 = undefined; - const real = try tmp.dir.realpath(".", &buf); - return try allocator.dupe(u8, real); + var buf: [Io.Dir.max_path_bytes]u8 = undefined; + const n = try tmp.dir.realPath(std.testing.io, &buf); + return try allocator.dupe(u8, buf[0..n]); } diff --git a/src/frame_worker.zig b/src/frame_worker.zig index 91d9648..54d4d25 100644 --- a/src/frame_worker.zig +++ b/src/frame_worker.zig @@ -8,6 +8,7 @@ //! CBOR decode is ~1-2μs, far cheaper than serializing decoded values across threads. const std = @import("std"); +const Io = std.Io; const zat = @import("zat"); const broadcaster = @import("broadcaster.zig"); const validator_mod = @import("validator.zig"); @@ -19,11 +20,16 @@ const thread_pool = @import("thread_pool.zig"); const Allocator = std.mem.Allocator; const log = std.log.scoped(.relay); +fn microTimestamp(io: Io) i64 { + return @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_us)); +} + pub const FrameWork = struct { data: []u8, // raw frame bytes (heap-duped by reader, freed by worker) host_id: u64, hostname: []const u8, // borrowed from subscriber (stable lifetime) allocator: Allocator, + io: Io, // shared references (all thread-safe, all outlive the work item) bc: *broadcaster.Broadcaster, validator: *validator_mod.Validator, @@ -92,9 +98,9 @@ pub fn processFrame(work: *FrameWork) void { if (result.is_new) _ = work.bc.stats.host_authority_is_new.fetchAdd(1, .monotonic); if (result.host_changed) _ = work.bc.stats.host_authority_host_changed.fetchAdd(1, .monotonic); _ = work.bc.stats.host_authority_checks.fetchAdd(1, .monotonic); - const ha_t0 = std.time.microTimestamp(); + const ha_t0 = microTimestamp(work.io); defer { - const elapsed: u64 = @intCast(@max(0, std.time.microTimestamp() - ha_t0)); + const elapsed: u64 = @intCast(@max(0, microTimestamp(work.io) - ha_t0)); _ = work.bc.stats.host_authority_time_us.fetchAdd(elapsed, .monotonic); } switch (work.validator.resolveHostAuthority(d, work.host_id)) { @@ -177,7 +183,7 @@ pub fn processFrame(work: *FrameWork) void { // future-rev rejection: drop commits with timestamps too far ahead if (zat.Tid.parse(incoming_rev)) |tid| { const rev_us: i64 = @intCast(tid.timestamp()); - const now_us = std.time.microTimestamp(); + const now_us = microTimestamp(work.io); const skew_us: i64 = work.validator.config.rev_clock_skew * 1_000_000; if (rev_us > now_us + skew_us) { log.info("host {s}: dropping future rev uid={d} rev={s}", .{ @@ -270,8 +276,8 @@ pub fn processFrame(work: *FrameWork) void { // persist and broadcast under ordering lock if (work.persist) |dp| { const relay_seq = blk: { - work.bc.broadcast_order.lock(); - defer work.bc.broadcast_order.unlock(); + work.bc.broadcast_order.lockUncancelable(work.io); + defer work.bc.broadcast_order.unlock(work.io); const seq = dp.persist(kind, uid, data) catch |err| { log.warn("persist failed: {s}", .{@errorName(err)}); diff --git a/src/lru.zig b/src/lru.zig index 4440704..be989b9 100644 --- a/src/lru.zig +++ b/src/lru.zig @@ -4,6 +4,7 @@ //! thread-safe (internal mutex). keys are duped on insert, freed on eviction. const std = @import("std"); +const Io = std.Io; const Allocator = std.mem.Allocator; pub fn LruCache(comptime V: type) type { @@ -17,18 +18,20 @@ pub fn LruCache(comptime V: type) type { next: ?*Node = null, }; - map: std.StringHashMapUnmanaged(*Node) = .{}, + map: std.StringHashMapUnmanaged(*Node) = .empty, head: ?*Node = null, // most recently used tail: ?*Node = null, // least recently used capacity: u32, len: u32 = 0, allocator: Allocator, - mutex: std.Thread.Mutex = .{}, + mutex: Io.Mutex = Io.Mutex.init, + io: Io, - pub fn init(allocator: Allocator, capacity: u32) Self { + pub fn init(allocator: Allocator, capacity: u32, io: Io) Self { return .{ .allocator = allocator, .capacity = capacity, + .io = io, }; } @@ -44,16 +47,16 @@ pub fn LruCache(comptime V: type) type { } pub fn get(self: *Self, key: []const u8) ?V { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); const node = self.map.get(key) orelse return null; self.moveToHead(node); return node.value; } pub fn put(self: *Self, key: []const u8, value: V) Allocator.Error!void { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); if (self.map.get(key)) |node| { // update existing @@ -88,8 +91,8 @@ pub fn LruCache(comptime V: type) type { } pub fn remove(self: *Self, key: []const u8) bool { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); const node = self.map.get(key) orelse return false; self.unlink(node); // fetchRemove uses the node's owned key for lookup, which is valid @@ -103,22 +106,22 @@ pub fn LruCache(comptime V: type) type { /// check if a key exists without promoting it pub fn contains(self: *Self, key: []const u8) bool { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); return self.map.contains(key); } /// entry count (non-blocking — returns 0 if lock is contended) pub fn count(self: *Self) u32 { if (!self.mutex.tryLock()) return 0; - defer self.mutex.unlock(); + defer self.mutex.unlock(self.io); return self.len; } /// internal hashmap capacity (non-blocking — returns 0 if lock is contended) pub fn mapCapacity(self: *Self) u32 { if (!self.mutex.tryLock()) return 0; - defer self.mutex.unlock(); + defer self.mutex.unlock(self.io); return self.map.capacity(); } @@ -165,7 +168,7 @@ pub fn LruCache(comptime V: type) type { const testing = std.testing; test "basic get and put" { - var cache = LruCache(u64).init(testing.allocator, 3); + var cache = LruCache(u64).init(testing.allocator, 3, testing.io); defer cache.deinit(); try cache.put("a", 1); @@ -179,7 +182,7 @@ test "basic get and put" { } test "eviction order" { - var cache = LruCache(u64).init(testing.allocator, 2); + var cache = LruCache(u64).init(testing.allocator, 2, testing.io); defer cache.deinit(); try cache.put("a", 1); @@ -194,7 +197,7 @@ test "eviction order" { } test "update moves to front" { - var cache = LruCache(u64).init(testing.allocator, 2); + var cache = LruCache(u64).init(testing.allocator, 2, testing.io); defer cache.deinit(); try cache.put("a", 1); @@ -213,7 +216,7 @@ test "update moves to front" { } test "put update existing key" { - var cache = LruCache(u64).init(testing.allocator, 2); + var cache = LruCache(u64).init(testing.allocator, 2, testing.io); defer cache.deinit(); try cache.put("a", 1); @@ -228,7 +231,7 @@ test "put update existing key" { } test "remove" { - var cache = LruCache(u64).init(testing.allocator, 3); + var cache = LruCache(u64).init(testing.allocator, 3, testing.io); defer cache.deinit(); try cache.put("a", 1); @@ -241,7 +244,7 @@ test "remove" { } test "capacity 1" { - var cache = LruCache(u64).init(testing.allocator, 1); + var cache = LruCache(u64).init(testing.allocator, 1, testing.io); defer cache.deinit(); try cache.put("a", 1); @@ -254,7 +257,7 @@ test "capacity 1" { } test "count" { - var cache = LruCache(u64).init(testing.allocator, 10); + var cache = LruCache(u64).init(testing.allocator, 10, testing.io); defer cache.deinit(); try testing.expectEqual(@as(u32, 0), cache.count()); @@ -267,7 +270,7 @@ test "count" { } test "contains" { - var cache = LruCache(u64).init(testing.allocator, 3); + var cache = LruCache(u64).init(testing.allocator, 3, testing.io); defer cache.deinit(); try cache.put("a", 1); @@ -277,7 +280,7 @@ test "contains" { test "struct values" { const Val = struct { x: u32, y: u32 }; - var cache = LruCache(Val).init(testing.allocator, 2); + var cache = LruCache(Val).init(testing.allocator, 2, testing.io); defer cache.deinit(); try cache.put("point1", .{ .x = 1, .y = 2 }); diff --git a/src/main.zig b/src/main.zig index ed76dea..9bd2a54 100644 --- a/src/main.zig +++ b/src/main.zig @@ -24,6 +24,7 @@ //! /_health — liveness probe (DB check) const std = @import("std"); +const Io = std.Io; const http = std.http; const websocket = @import("websocket"); const broadcaster = @import("broadcaster.zig"); @@ -40,17 +41,26 @@ const malloc_h = if (builtin.os.tag == .linux) @cImport(@cInclude("malloc.h")) e const log = std.log.scoped(.relay); -/// zig's default thread stack is 16 MB. with ~2,750 subscriber threads that's -/// 44 GB of virtual memory. 8 MB supports ReleaseSafe — tls.Client.init alone -/// needs ~134 KiB of stack, and deep call chains under inline-else cipher -/// dispatch need headroom. only touched pages count as RSS. pub const default_stack_size = 8 * 1024 * 1024; +// -- Io backend selection -- +// Evented = Io.Uring (linux) | Io.Dispatch (macOS) | Io.Kqueue (BSD) | void +// Falls back to Io.Threaded on platforms without fiber support. +const Backend = if (Io.Evented != void) Io.Evented else Io.Threaded; + +var backend: Backend = undefined; +var debug_threaded_io: Io.Threaded = undefined; + +/// override single-threaded debug_io default — required for std.debug.print safety +/// when multiple OS threads exist (frame worker pool, websocket server). +pub const std_options_debug_threaded_io: ?*Io.Threaded = &debug_threaded_io; + var shutdown_flag: std.atomic.Value(bool) = .{ .raw = false }; /// metrics-only server on the internal port const MetricsServer = struct { - server: std.net.Server, + server: Io.net.Server, + io: Io, stats: *broadcaster.Stats, validator: *validator_mod.Validator, data_dir: []const u8, @@ -60,80 +70,77 @@ const MetricsServer = struct { fn run(self: *MetricsServer) void { while (!shutdown_flag.load(.acquire)) { - const conn = self.server.accept() catch |err| { + const stream = self.server.accept(self.io) catch |err| { if (shutdown_flag.load(.acquire)) return; log.debug("metrics accept error: {s}", .{@errorName(err)}); continue; }; - // 5s read timeout — prevents stale connections from blocking the single-threaded server - const timeout = std.posix.timeval{ .sec = 5, .usec = 0 }; - std.posix.setsockopt(conn.stream.handle, std.posix.SOL.SOCKET, std.posix.SO.RCVTIMEO, std.mem.asBytes(&timeout)) catch {}; - handleMetricsConn(conn.stream, self.stats, self.validator, self.data_dir, self.persist, self.bc, self.slurper); + self.handleMetricsConn(stream); } } -}; -fn handleMetricsConn(stream: std.net.Stream, stats: *broadcaster.Stats, validator: *validator_mod.Validator, data_dir: []const u8, persist: *event_log_mod.DiskPersist, bc: *broadcaster.Broadcaster, slurp: *slurper_mod.Slurper) void { - defer stream.close(); - - var recv_buf: [4096]u8 = undefined; - var send_buf: [4096]u8 = undefined; - var connection_reader = stream.reader(&recv_buf); - var connection_writer = stream.writer(&send_buf); - var server = http.Server.init(connection_reader.interface(), &connection_writer.interface); - - var request = server.receiveHead() catch return; - const path = request.head.target; - - if (std.mem.eql(u8, path, "/_healthz")) { - // trivial liveness — constant-time, no dependencies - request.respond("{\"status\":\"ok\"}", .{ .status = .ok, .keep_alive = false, .extra_headers = &.{ - .{ .name = "content-type", .value = "application/json" }, - .{ .name = "server", .value = "zlay (atproto-relay)" }, - } }) catch {}; - } else if (std.mem.eql(u8, path, "/_health") or std.mem.eql(u8, path, "/_readyz")) { - const db_ok = if (persist.db.exec("SELECT 1", .{})) |_| true else |_| false; - const status: http.Status = if (db_ok) .ok else .internal_server_error; - const body = if (db_ok) "{\"status\":\"ok\"}" else "{\"status\":\"error\",\"msg\":\"database unavailable\"}"; - request.respond(body, .{ .status = status, .keep_alive = false, .extra_headers = &.{ - .{ .name = "content-type", .value = "application/json" }, - .{ .name = "server", .value = "zlay (atproto-relay)" }, - } }) catch {}; - } else if (std.mem.eql(u8, path, "/metrics")) { - const cache_entries = validator.cacheSize(); - const attribution = broadcaster.AttributionMetrics{ - .history_entries = bc.history.count(), - .evtbuf_entries = persist.evtbufLen(), - .did_cache_entries = persist.didCacheLen(), - .resolve_queue_len = validator.resolveQueueLen(), - .resolve_queued_set_count = validator.resolveQueuedSetCount(), - .validator_cache_map_cap = validator.cacheMapCapacity(), - .did_cache_map_cap = persist.didCacheMapCap(), - .queued_set_map_cap = validator.resolveQueuedSetCapacity(), - .evtbuf_cap = persist.evtbufCap(), - .outbuf_cap = persist.outbufCap(), - .workers_count = slurp.workerCount(), - }; + fn handleMetricsConn(self: *MetricsServer, stream: Io.net.Stream) void { + defer stream.close(self.io); + + var recv_buf: [4096]u8 = undefined; + var send_buf: [4096]u8 = undefined; + var connection_reader = stream.reader(self.io, &recv_buf); + var connection_writer = stream.writer(self.io, &send_buf); + var server = http.Server.init(&connection_reader.interface, &connection_writer.interface); + + var request = server.receiveHead() catch return; + const path = request.head.target; + + if (std.mem.eql(u8, path, "/_healthz")) { + // trivial liveness — constant-time, no dependencies + request.respond("{\"status\":\"ok\"}", .{ .status = .ok, .keep_alive = false, .extra_headers = &.{ + .{ .name = "content-type", .value = "application/json" }, + .{ .name = "server", .value = "zlay (atproto-relay)" }, + } }) catch {}; + } else if (std.mem.eql(u8, path, "/_health") or std.mem.eql(u8, path, "/_readyz")) { + const db_ok = if (self.persist.db.exec("SELECT 1", .{})) |_| true else |_| false; + const status: http.Status = if (db_ok) .ok else .internal_server_error; + const body = if (db_ok) "{\"status\":\"ok\"}" else "{\"status\":\"error\",\"msg\":\"database unavailable\"}"; + request.respond(body, .{ .status = status, .keep_alive = false, .extra_headers = &.{ + .{ .name = "content-type", .value = "application/json" }, + .{ .name = "server", .value = "zlay (atproto-relay)" }, + } }) catch {}; + } else if (std.mem.eql(u8, path, "/metrics")) { + const cache_entries = self.validator.cacheSize(); + const attribution = broadcaster.AttributionMetrics{ + .history_entries = self.bc.history.count(), + .evtbuf_entries = self.persist.evtbufLen(), + .did_cache_entries = self.persist.didCacheLen(), + .resolve_queue_len = self.validator.resolveQueueLen(), + .resolve_queued_set_count = self.validator.resolveQueuedSetCount(), + .validator_cache_map_cap = self.validator.cacheMapCapacity(), + .did_cache_map_cap = self.persist.didCacheMapCap(), + .queued_set_map_cap = self.validator.resolveQueuedSetCapacity(), + .evtbuf_cap = self.persist.evtbufCap(), + .outbuf_cap = self.persist.outbufCap(), + .workers_count = self.slurper.workerCount(), + }; - var metrics_buf: [65536]u8 = undefined; - const body = broadcaster.formatPrometheusMetrics(stats, cache_entries, attribution, data_dir, &metrics_buf); - request.respond(body, .{ .status = .ok, .keep_alive = false, .extra_headers = &.{ - .{ .name = "content-type", .value = "text/plain; version=0.0.4; charset=utf-8" }, - .{ .name = "server", .value = "zlay (atproto-relay)" }, - } }) catch {}; - } else { - request.respond("not found", .{ .status = .not_found, .keep_alive = false, .extra_headers = &.{ - .{ .name = "content-type", .value = "text/plain" }, - .{ .name = "server", .value = "zlay (atproto-relay)" }, - } }) catch {}; + var metrics_buf: [65536]u8 = undefined; + const body = broadcaster.formatPrometheusMetrics(self.stats, cache_entries, attribution, self.data_dir, &metrics_buf, self.io); + request.respond(body, .{ .status = .ok, .keep_alive = false, .extra_headers = &.{ + .{ .name = "content-type", .value = "text/plain; version=0.0.4; charset=utf-8" }, + .{ .name = "server", .value = "zlay (atproto-relay)" }, + } }) catch {}; + } else { + request.respond("not found", .{ .status = .not_found, .keep_alive = false, .extra_headers = &.{ + .{ .name = "content-type", .value = "text/plain" }, + .{ .name = "server", .value = "zlay (atproto-relay)" }, + } }) catch {}; + } } -} +}; pub fn main() !void { // exp-002: optional GPA wrapper for leak detection. // build with -Duse_gpa=true to enable. on clean shutdown (SIGTERM), // GPA logs every allocation that was never freed, with stack traces. - var gpa: std.heap.GeneralPurposeAllocator(.{ + var gpa: std.heap.DebugAllocator(.{ .stack_trace_frames = if (build_options.use_gpa) 8 else 0, }) = .init; defer if (build_options.use_gpa) { @@ -147,11 +154,24 @@ pub fn main() !void { }; const allocator = if (build_options.use_gpa) gpa.allocator() else std.heap.c_allocator; + // init debug io (for std.debug.print thread safety) + debug_threaded_io = Io.Threaded.init(allocator, .{}); + + // init primary runtime + if (Backend == Io.Threaded) { + backend = Io.Threaded.init(allocator, .{}); + } else { + try Backend.init(&backend, allocator, .{}); + } + const io = backend.io(); + + log.info("io backend: {s}", .{if (Backend == Io.Threaded) "Threaded" else "Evented"}); + // parse config from env const port = parseEnvInt(u16, "RELAY_PORT", 3000); const metrics_port = parseEnvInt(u16, "RELAY_METRICS_PORT", 3001); - const upstream = normalizeSeedHost(std.posix.getenv("RELAY_UPSTREAM") orelse "bsky.network"); - const data_dir = std.posix.getenv("RELAY_DATA_DIR") orelse "data/events"; + const upstream = normalizeSeedHost(getenv("RELAY_UPSTREAM") orelse "bsky.network"); + const data_dir = getenv("RELAY_DATA_DIR") orelse "data/events"; const retention_hours = parseEnvInt(u64, "RELAY_RETENTION_HOURS", 72); const max_events_gb = parseEnvInt(u64, "RELAY_MAX_EVENTS_GB", 100); const frame_workers = parseEnvInt(u16, "FRAME_WORKERS", 16); @@ -160,17 +180,17 @@ pub fn main() !void { // install signal handlers (including SIGPIPE ignore) installSignalHandlers(); - // init components - var bc = broadcaster.Broadcaster.init(allocator); + // init components — pass io to network-facing modules + var bc = broadcaster.Broadcaster.init(allocator, io); defer bc.deinit(); - var val = validator_mod.Validator.init(allocator, &bc.stats); + var val = validator_mod.Validator.init(allocator, &bc.stats, io); defer val.deinit(); try val.start(); // init disk persistence (indigo-compatible diskpersist format + Postgres index) - const database_url = std.posix.getenv("DATABASE_URL") orelse "postgres://relay:relay@localhost:5432/relay"; - var dp = event_log_mod.DiskPersist.init(allocator, data_dir, database_url) catch |err| { + const database_url = getenv("DATABASE_URL") orelse "postgres://relay:relay@localhost:5432/relay"; + var dp = event_log_mod.DiskPersist.init(allocator, data_dir, database_url, io) catch |err| { log.err("failed to init disk persist at {s}: {s}", .{ data_dir, @errorName(err) }); return err; }; @@ -190,7 +210,7 @@ pub fn main() !void { val.persist = &dp; // init collection index (RocksDB — inspired by lightrail/microcosm.blue) - const ci_dir = std.posix.getenv("COLLECTION_INDEX_DIR") orelse "data/collection-index"; + const ci_dir = getenv("COLLECTION_INDEX_DIR") orelse "data/collection-index"; var ci = collection_index_mod.CollectionIndex.open(allocator, ci_dir) catch |err| { log.err("failed to init collection index at {s}: {s}", .{ ci_dir, @errorName(err) }); return err; @@ -198,13 +218,13 @@ pub fn main() !void { defer ci.deinit(); // init backfiller (collection index backfill from source relay) - var backfiller = backfill_mod.Backfiller.init(allocator, &ci, dp.db); + var backfiller = backfill_mod.Backfiller.init(allocator, &ci, dp.db, io); // init cleaner (removes stale entries from collection index) - var cleaner = cleaner_mod.Cleaner.init(allocator, &ci, dp.db); + var cleaner = cleaner_mod.Cleaner.init(allocator, io, &ci, dp.db); // init resyncer (updates collection index on #sync events) - var resyncer = resync_mod.Resyncer.init(allocator, &ci); + var resyncer = resync_mod.Resyncer.init(allocator, io, &ci); try resyncer.start(); defer resyncer.deinit(); @@ -221,6 +241,7 @@ pub fn main() !void { .frame_workers = frame_workers, .frame_queue_capacity = frame_queue_capacity, }, + io, ); defer slurper.deinit(); slurper.collection_index = &ci; @@ -229,8 +250,8 @@ pub fn main() !void { // start: loads active hosts from DB, spawns subscriber threads try slurper.start(); - // start GC thread (runs every 10 minutes) - const gc_thread = try std.Thread.spawn(.{ .stack_size = default_stack_size }, gcLoop, .{&dp}); + // start GC loop (runs as background thread — does disk I/O + malloc_trim) + const gc_thread = try std.Thread.spawn(.{}, gcLoop, .{ &dp, io }); // wire HTTP fallback into broadcaster (all API endpoints served on WS port) var http_context = api.HttpContext{ @@ -248,12 +269,13 @@ pub fn main() !void { bc.http_fallback_ctx = @ptrCast(&http_context); // start metrics-only server (internal port) - const metrics_address = std.net.Address.initIp4(.{ 0, 0, 0, 0 }, metrics_port); + const metrics_address = Io.net.Ip4Address.unspecified(metrics_port); var metrics_srv = MetricsServer{ - .server = metrics_address.listen(.{ .reuse_address = true }) catch |err| { + .server = (Io.net.IpAddress{ .ip4 = metrics_address }).listen(io, .{ .reuse_address = true }) catch |err| { log.err("metrics server failed to listen on :{d}: {s}", .{ metrics_port, @errorName(err) }); return err; }, + .io = io, .stats = &bc.stats, .validator = &val, .data_dir = data_dir, @@ -261,7 +283,7 @@ pub fn main() !void { .bc = &bc, .slurper = &slurper, }; - const metrics_thread = try std.Thread.spawn(.{ .stack_size = default_stack_size }, MetricsServer.run, .{&metrics_srv}); + const metrics_thread = try std.Thread.spawn(.{}, MetricsServer.run, .{&metrics_srv}); // start downstream WebSocket server (also serves HTTP API via httpFallback) log.info("relay listening on :{d} (ws+http), :{d} (metrics)", .{ port, metrics_port }); @@ -280,7 +302,7 @@ pub fn main() !void { // wait for shutdown signal while (!shutdown_flag.load(.acquire)) { - std.posix.nanosleep(0, 100 * std.time.ns_per_ms); + io.sleep(Io.Duration.fromMilliseconds(100), .awake) catch break; } log.info("shutdown signal received, stopping...", .{}); @@ -292,8 +314,8 @@ pub fn main() !void { // wait for GC thread gc_thread.join(); - // close metrics listener socket to unblock accept(), then join - metrics_srv.server.stream.close(); + // close metrics listener to unblock accept(), then join + metrics_srv.server.deinit(io); metrics_thread.join(); log.info("relay stopped cleanly", .{}); @@ -301,14 +323,14 @@ pub fn main() !void { const builtin = @import("builtin"); -fn gcLoop(dp: *event_log_mod.DiskPersist) void { +fn gcLoop(dp: *event_log_mod.DiskPersist, io: Io) void { const gc_interval: u64 = 10 * 60; // 10 minutes in seconds while (!shutdown_flag.load(.acquire)) { // sleep in small increments to check shutdown var remaining: u64 = gc_interval; while (remaining > 0 and !shutdown_flag.load(.acquire)) { const chunk = @min(remaining, 1); - std.posix.nanosleep(chunk, 0); + io.sleep(Io.Duration.fromSeconds(@intCast(chunk)), .awake) catch return; remaining -= chunk; } if (shutdown_flag.load(.acquire)) return; @@ -325,7 +347,7 @@ fn gcLoop(dp: *event_log_mod.DiskPersist) void { } } -fn signalHandler(_: c_int) callconv(.c) void { +fn signalHandler(_: std.posix.SIG) callconv(.c) void { shutdown_flag.store(true, .release); } @@ -353,8 +375,14 @@ fn normalizeSeedHost(raw: []const u8) []const u8 { return raw; } -fn parseEnvInt(comptime T: type, key: []const u8, default: T) T { - const val = std.posix.getenv(key) orelse return default; +/// libc getenv — std.posix.getenv removed in 0.16 +fn getenv(key: [*:0]const u8) ?[]const u8 { + const ptr = std.c.getenv(key) orelse return null; + return std.mem.sliceTo(ptr, 0); +} + +fn parseEnvInt(comptime T: type, key: [*:0]const u8, default: T) T { + const val = getenv(key) orelse return default; return std.fmt.parseInt(T, val, 10) catch default; } diff --git a/src/resync.zig b/src/resync.zig index 8e5d494..4aa5e18 100644 --- a/src/resync.zig +++ b/src/resync.zig @@ -8,6 +8,7 @@ //! modeled on cleaner.zig — bounded queue, single background worker thread. const std = @import("std"); +const Io = std.Io; const http = std.http; const collection_index_mod = @import("collection_index.zig"); @@ -33,6 +34,7 @@ const ResyncItem = struct { pub const Resyncer = struct { allocator: Allocator, + io: Io, collection_index: *collection_index_mod.CollectionIndex, // bounded ring buffer queue @@ -40,8 +42,8 @@ pub const Resyncer = struct { head: usize, tail: usize, len: usize, - mutex: std.Thread.Mutex, - cond: std.Thread.Condition, + mutex: Io.Mutex, + cond: Io.Condition, running: std.atomic.Value(bool), thread: ?std.Thread, @@ -53,17 +55,19 @@ pub const Resyncer = struct { pub fn init( allocator: Allocator, + io: Io, collection_index: *collection_index_mod.CollectionIndex, ) Resyncer { return .{ .allocator = allocator, + .io = io, .collection_index = collection_index, .queue = undefined, .head = 0, .tail = 0, .len = 0, - .mutex = .{}, - .cond = .{}, + .mutex = Io.Mutex.init, + .cond = Io.Condition.init, .running = .{ .raw = false }, .thread = null, .processed = .{ .raw = 0 }, @@ -76,7 +80,7 @@ pub const Resyncer = struct { pub fn start(self: *Resyncer) !void { self.running.store(true, .release); self.thread = try std.Thread.spawn( - .{ .stack_size = @import("main.zig").default_stack_size }, + .{}, run, .{self}, ); @@ -86,8 +90,8 @@ pub const Resyncer = struct { pub fn enqueue(self: *Resyncer, did: []const u8, hostname: []const u8) void { if (did.len == 0 or did.len > 128 or hostname.len == 0 or hostname.len > 256) return; - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); if (self.len >= queue_capacity) { _ = self.dropped.fetchAdd(1, .monotonic); @@ -106,16 +110,16 @@ pub const Resyncer = struct { self.queue[self.tail] = item; self.tail = (self.tail + 1) % queue_capacity; self.len += 1; - self.cond.signal(); + self.cond.signal(self.io); } /// dequeue one item. blocks until available or shutdown. fn dequeue(self: *Resyncer) ?ResyncItem { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); while (self.len == 0 and self.running.load(.acquire)) { - self.cond.timedWait(&self.mutex, 1 * std.time.ns_per_s) catch {}; + self.cond.waitUncancelable(self.io, &self.mutex); } if (self.len == 0) return null; @@ -129,7 +133,7 @@ pub const Resyncer = struct { fn run(self: *Resyncer) void { log.info("resync worker started", .{}); - var client: http.Client = .{ .allocator = self.allocator }; + var client: http.Client = .{ .allocator = self.allocator, .io = self.io }; defer client.deinit(); while (self.running.load(.acquire)) { @@ -137,7 +141,7 @@ pub const Resyncer = struct { self.processItem(&client, &item); // brief pause between items - std.posix.nanosleep(0, 50 * std.time.ns_per_ms); + self.io.sleep(Io.Duration.fromMilliseconds(50), .awake) catch {}; } log.info("resync worker stopped (processed={d}, failed={d}, dropped={d})", .{ @@ -216,14 +220,14 @@ pub const Resyncer = struct { } pub fn queueDepth(self: *Resyncer) usize { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); return self.len; } pub fn stop(self: *Resyncer) void { self.running.store(false, .release); - self.cond.signal(); + self.cond.signal(self.io); } pub fn deinit(self: *Resyncer) void { @@ -243,13 +247,14 @@ test "Resyncer init and enqueue" { // just test the queue mechanics, not the HTTP/RocksDB parts var r: Resyncer = .{ .allocator = std.testing.allocator, + .io = std.testing.io, .collection_index = undefined, // not used in this test .queue = undefined, .head = 0, .tail = 0, .len = 0, - .mutex = .{}, - .cond = .{}, + .mutex = Io.Mutex.init, + .cond = Io.Condition.init, .running = .{ .raw = true }, .thread = null, .processed = .{ .raw = 0 }, @@ -270,13 +275,14 @@ test "Resyncer init and enqueue" { test "Resyncer drops when full" { var r: Resyncer = .{ .allocator = std.testing.allocator, + .io = std.testing.io, .collection_index = undefined, .queue = undefined, .head = 0, .tail = 0, .len = queue_capacity, // pretend full - .mutex = .{}, - .cond = .{}, + .mutex = Io.Mutex.init, + .cond = Io.Condition.init, .running = .{ .raw = true }, .thread = null, .processed = .{ .raw = 0 }, @@ -291,13 +297,14 @@ test "Resyncer drops when full" { test "Resyncer rejects oversized inputs" { var r: Resyncer = .{ .allocator = std.testing.allocator, + .io = std.testing.io, .collection_index = undefined, .queue = undefined, .head = 0, .tail = 0, .len = 0, - .mutex = .{}, - .cond = .{}, + .mutex = Io.Mutex.init, + .cond = Io.Condition.init, .running = .{ .raw = true }, .thread = null, .processed = .{ .raw = 0 }, diff --git a/src/ring_buffer.zig b/src/ring_buffer.zig index 98be57f..362bdb8 100644 --- a/src/ring_buffer.zig +++ b/src/ring_buffer.zig @@ -4,6 +4,7 @@ //! and global frame history (cursor replay). const std = @import("std"); +const Io = std.Io; const Allocator = std.mem.Allocator; pub const Frame = struct { @@ -23,12 +24,13 @@ pub fn RingBuffer(comptime capacity: usize) type { read_pos: usize = 0, // next read position (for pop) len: usize = 0, allocator: Allocator, - mutex: std.Thread.Mutex = .{}, + mutex: Io.Mutex = Io.Mutex.init, + io: Io, const Self = @This(); - pub fn init(allocator: Allocator) Self { - return .{ .allocator = allocator }; + pub fn init(allocator: Allocator, io: Io) Self { + return .{ .allocator = allocator, .io = io }; } pub fn deinit(self: *Self) void { @@ -46,8 +48,8 @@ pub fn RingBuffer(comptime capacity: usize) type { /// push a frame. if full, overwrites oldest. returns false if alloc failed. pub fn push(self: *Self, seq: u64, data: []const u8) bool { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); return self.pushUnlocked(seq, data); } @@ -73,8 +75,8 @@ pub fn RingBuffer(comptime capacity: usize) type { /// pop the oldest frame. caller owns the returned data. pub fn pop(self: *Self) ?Frame { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); return self.popUnlocked(); } @@ -90,24 +92,24 @@ pub fn RingBuffer(comptime capacity: usize) type { /// number of frames currently buffered (non-blocking — returns 0 if lock is contended) pub fn count(self: *Self) usize { if (!self.mutex.tryLock()) return 0; - defer self.mutex.unlock(); + defer self.mutex.unlock(self.io); return self.len; } /// check if buffer is full pub fn isFull(self: *Self) bool { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); return self.len == capacity; } /// get all frames with seq > cursor, ordered by seq. /// caller owns the returned slice AND frame data. pub fn framesSince(self: *Self, allocator: Allocator, cursor: u64) ![]const Frame { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); - var result: std.ArrayList(Frame) = .{}; + var result: std.ArrayList(Frame) = .empty; errdefer { for (result.items) |f| allocator.free(f.data); result.deinit(allocator); @@ -127,16 +129,16 @@ pub fn RingBuffer(comptime capacity: usize) type { /// oldest seq in the buffer, or null if empty pub fn oldestSeq(self: *Self) ?u64 { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); if (self.len == 0) return null; return self.entries[self.read_pos].seq; } /// newest seq in the buffer, or null if empty pub fn newestSeq(self: *Self) ?u64 { - self.mutex.lock(); - defer self.mutex.unlock(); + self.mutex.lockUncancelable(self.io); + defer self.mutex.unlock(self.io); if (self.len == 0) return null; const newest_idx = if (self.write_pos == 0) capacity - 1 else self.write_pos - 1; return self.entries[newest_idx].seq; @@ -147,7 +149,7 @@ pub fn RingBuffer(comptime capacity: usize) type { // === tests === test "push and pop" { - var buf = RingBuffer(4).init(std.testing.allocator); + var buf = RingBuffer(4).init(std.testing.allocator, std.testing.io); defer buf.deinit(); try std.testing.expect(buf.push(1, "hello")); @@ -167,7 +169,7 @@ test "push and pop" { } test "overwrite when full" { - var buf = RingBuffer(3).init(std.testing.allocator); + var buf = RingBuffer(3).init(std.testing.allocator, std.testing.io); defer buf.deinit(); try std.testing.expect(buf.push(1, "a")); @@ -185,7 +187,7 @@ test "overwrite when full" { } test "framesSince" { - var buf = RingBuffer(8).init(std.testing.allocator); + var buf = RingBuffer(8).init(std.testing.allocator, std.testing.io); defer buf.deinit(); for (1..6) |i| { @@ -204,7 +206,7 @@ test "framesSince" { } test "oldestSeq and newestSeq" { - var buf = RingBuffer(4).init(std.testing.allocator); + var buf = RingBuffer(4).init(std.testing.allocator, std.testing.io); defer buf.deinit(); try std.testing.expect(buf.oldestSeq() == null); @@ -219,7 +221,7 @@ test "oldestSeq and newestSeq" { } test "empty buffer operations" { - var buf = RingBuffer(4).init(std.testing.allocator); + var buf = RingBuffer(4).init(std.testing.allocator, std.testing.io); defer buf.deinit(); try std.testing.expectEqual(@as(usize, 0), buf.count()); @@ -232,7 +234,7 @@ test "empty buffer operations" { } test "wrap-around with pop and push" { - var buf = RingBuffer(3).init(std.testing.allocator); + var buf = RingBuffer(3).init(std.testing.allocator, std.testing.io); defer buf.deinit(); // fill diff --git a/src/slurper.zig b/src/slurper.zig index 756392e..c364993 100644 --- a/src/slurper.zig +++ b/src/slurper.zig @@ -11,6 +11,7 @@ //! thread-safe for N concurrent producers, so this just orchestrates. const std = @import("std"); +const Io = std.Io; const http = std.http; const broadcaster = @import("broadcaster.zig"); const validator_mod = @import("validator.zig"); @@ -111,43 +112,60 @@ pub fn validateHostname(allocator: Allocator, raw: []const u8) HostValidationErr /// SSRF protection: resolve hostname and reject private/reserved IP ranges. /// Go relay: ssrf.go PublicOnlyTransport — rejects 10/8, 172.16/12, 192.168/16, 127/8, link-local. fn rejectPrivateHost(allocator: Allocator, hostname: []const u8) HostValidationError!void { - const addr_list = std.net.getAddressList(allocator, hostname, 443) catch return error.HostUnreachable; - defer addr_list.deinit(); + // null-terminate hostname for getaddrinfo + const hostname_z = allocator.dupeZ(u8, hostname) catch return error.HostUnreachable; + defer allocator.free(hostname_z); + + var hints: std.c.addrinfo = .{ + .flags = .{}, + .family = std.c.AF.UNSPEC, + .socktype = std.c.SOCK.STREAM, + .protocol = 0, + .addrlen = 0, + .addr = null, + .canonname = null, + .next = null, + }; - if (addr_list.addrs.len == 0) return error.HostUnreachable; + var res: ?*std.c.addrinfo = null; + const rc = std.c.getaddrinfo(hostname_z, "443", &hints, &res); + if (@intFromEnum(rc) != 0 or res == null) return error.HostUnreachable; + defer std.c.freeaddrinfo(res.?); // check all resolved addresses — reject if ANY is private - for (addr_list.addrs) |addr| { - switch (addr.any.family) { - std.posix.AF.INET => { - const ip4 = addr.in.sa.addr; - const bytes: [4]u8 = @bitCast(ip4); - if (bytes[0] == 10 or // 10.0.0.0/8 - (bytes[0] == 172 and (bytes[1] & 0xf0) == 16) or // 172.16.0.0/12 - (bytes[0] == 192 and bytes[1] == 168) or // 192.168.0.0/16 - bytes[0] == 127 or // 127.0.0.0/8 - bytes[0] == 0 or // 0.0.0.0/8 - (bytes[0] == 169 and bytes[1] == 254)) // 169.254.0.0/16 link-local - { - log.warn("SSRF: {s} resolves to private IP {d}.{d}.{d}.{d}", .{ hostname, bytes[0], bytes[1], bytes[2], bytes[3] }); - return error.HostUnreachable; - } - }, - else => {}, // allow IPv6 for now (could add RFC 4193 check later) + var cur = res; + var found_any = false; + while (cur) |node| : (cur = node.next) { + found_any = true; + if (node.family == std.c.AF.INET) { + const sa: *const std.c.sockaddr.in = @ptrCast(@alignCast(node.addr.?)); + const bytes: [4]u8 = @bitCast(sa.addr); + if (bytes[0] == 10 or // 10.0.0.0/8 + (bytes[0] == 172 and (bytes[1] & 0xf0) == 16) or // 172.16.0.0/12 + (bytes[0] == 192 and bytes[1] == 168) or // 192.168.0.0/16 + bytes[0] == 127 or // 127.0.0.0/8 + bytes[0] == 0 or // 0.0.0.0/8 + (bytes[0] == 169 and bytes[1] == 254)) // 169.254.0.0/16 link-local + { + log.warn("SSRF: {s} resolves to private IP {d}.{d}.{d}.{d}", .{ hostname, bytes[0], bytes[1], bytes[2], bytes[3] }); + return error.HostUnreachable; + } } + // allow IPv6 for now (could add RFC 4193 check later) } + if (!found_any) return error.HostUnreachable; } /// check that a host is a real PDS by calling describeServer. /// also checks Server header for relay loop detection. /// Go relay: host_checker.go CheckHost + slurper.go Server header check. -fn checkHost(allocator: Allocator, hostname: []const u8) HostValidationError!void { +fn checkHost(allocator: Allocator, hostname: []const u8, io: Io) HostValidationError!void { // SSRF protection: reject private IPs before making any request rejectPrivateHost(allocator, hostname) catch |err| return err; var url_buf: [512]u8 = undefined; const url = std.fmt.bufPrint(&url_buf, "https://{s}/xrpc/com.atproto.server.describeServer", .{hostname}) catch return error.HostUnreachable; - var client: http.Client = .{ .allocator = allocator }; + var client: http.Client = .{ .allocator = allocator, .io = io }; defer client.deinit(); const uri = std.Uri.parse(url) catch return error.HostUnreachable; @@ -216,18 +234,20 @@ pub const Slurper = struct { ca_bundle: ?std.crypto.Certificate.Bundle = null, // active subscriber threads, keyed by host_id - workers: std.AutoHashMapUnmanaged(u64, WorkerEntry) = .{}, - workers_mutex: std.Thread.Mutex = .{}, + workers: std.AutoHashMapUnmanaged(u64, WorkerEntry) = .empty, + workers_mutex: Io.Mutex = Io.Mutex.init, // crawl request queue - crawl_queue: std.ArrayListUnmanaged([]const u8) = .{}, - crawl_mutex: std.Thread.Mutex = .{}, - crawl_cond: std.Thread.Condition = .{}, + crawl_queue: std.ArrayListUnmanaged([]const u8) = .empty, + crawl_mutex: Io.Mutex = Io.Mutex.init, + crawl_cond: Io.Condition = Io.Condition.init, // background threads startup_thread: ?std.Thread = null, crawl_thread: ?std.Thread = null, + io: Io, + pub fn init( allocator: Allocator, bc: *broadcaster.Broadcaster, @@ -235,6 +255,7 @@ pub const Slurper = struct { persist: *event_log_mod.DiskPersist, shutdown: *std.atomic.Value(bool), options: Options, + io: Io, ) Slurper { return .{ .allocator = allocator, @@ -243,6 +264,7 @@ pub const Slurper = struct { .persist = persist, .shutdown = shutdown, .options = options, + .io = io, }; } @@ -250,8 +272,8 @@ pub const Slurper = struct { /// Go relay: pull-hosts bootstraps from bsky.network's listHosts, then crawls each PDS directly. pub fn start(self: *Slurper) !void { // load CA bundle once — shared by all subscriber TLS connections - var bundle: std.crypto.Certificate.Bundle = .{}; - try bundle.rescan(self.allocator); + var bundle: std.crypto.Certificate.Bundle = .empty; + try bundle.rescan(self.allocator, self.io, Io.Timestamp.now(self.io, .real)); self.ca_bundle = bundle; log.info("loaded shared CA bundle", .{}); @@ -260,7 +282,7 @@ pub const Slurper = struct { .num_workers = self.options.frame_workers, .queue_capacity = self.options.frame_queue_capacity, .stack_size = @import("main.zig").default_stack_size, - }); + }, self.io); log.info("frame pool started: {d} workers, queue capacity {d}", .{ self.options.frame_workers, self.options.frame_queue_capacity }); // spawn worker startup in background so HTTP server + probes come up immediately. @@ -276,7 +298,7 @@ pub const Slurper = struct { var total: usize = 0; const limit = 500; - var client: http.Client = .{ .allocator = self.allocator }; + var client: http.Client = .{ .allocator = self.allocator, .io = self.io }; defer client.deinit(); while (true) { @@ -366,10 +388,10 @@ pub const Slurper = struct { /// add a crawl request (from requestCrawl endpoint) pub fn addCrawlRequest(self: *Slurper, hostname: []const u8) !void { const duped = try self.allocator.dupe(u8, hostname); - self.crawl_mutex.lock(); - defer self.crawl_mutex.unlock(); + self.crawl_mutex.lockUncancelable(self.io); + defer self.crawl_mutex.unlock(self.io); try self.crawl_queue.append(self.allocator, duped); - self.crawl_cond.signal(); + self.crawl_cond.signal(self.io); } /// validate and add a host: format check, domain ban, describeServer, then spawn. @@ -401,8 +423,8 @@ pub const Slurper = struct { // Go relay: crawl.go CheckIfSubscribed const host_info = try self.persist.getOrCreateHost(hostname); { - self.workers_mutex.lock(); - defer self.workers_mutex.unlock(); + self.workers_mutex.lockUncancelable(self.io); + defer self.workers_mutex.unlock(self.io); if (self.workers.contains(host_info.id)) { log.debug("host {s} already has a worker, skipping", .{hostname}); return; @@ -411,7 +433,7 @@ pub const Slurper = struct { // step 5: describeServer liveness check // Go relay: host_checker.go CheckHost (with SSRF protection) - checkHost(self.allocator, hostname) catch |err| { + checkHost(self.allocator, hostname, self.io) catch |err| { log.warn("host {s}: describeServer check failed: {s}", .{ hostname, @errorName(err) }); return; }; @@ -438,6 +460,7 @@ pub const Slurper = struct { sub.* = subscriber_mod.Subscriber.init( self.allocator, + self.io, self.bc, self.validator, self.persist, @@ -456,8 +479,8 @@ pub const Slurper = struct { const thread = try std.Thread.spawn(.{ .stack_size = @import("main.zig").default_stack_size }, runWorker, .{ self, host_id, sub }); - self.workers_mutex.lock(); - defer self.workers_mutex.unlock(); + self.workers_mutex.lockUncancelable(self.io); + defer self.workers_mutex.unlock(self.io); try self.workers.put(self.allocator, host_id, .{ .thread = thread, .subscriber = sub, @@ -470,8 +493,8 @@ pub const Slurper = struct { sub.run(); // subscriber returned — remove from active workers - self.workers_mutex.lock(); - defer self.workers_mutex.unlock(); + self.workers_mutex.lockUncancelable(self.io); + defer self.workers_mutex.unlock(self.io); _ = self.workers.remove(host_id); _ = self.bc.stats.connected_inbound.fetchSub(1, .monotonic); @@ -524,10 +547,10 @@ pub const Slurper = struct { while (!self.shutdown.load(.acquire)) { var hostname: ?[]const u8 = null; { - self.crawl_mutex.lock(); - defer self.crawl_mutex.unlock(); + self.crawl_mutex.lockUncancelable(self.io); + defer self.crawl_mutex.unlock(self.io); while (self.crawl_queue.items.len == 0 and !self.shutdown.load(.acquire)) { - self.crawl_cond.timedWait(&self.crawl_mutex, 1 * std.time.ns_per_s) catch {}; + self.crawl_cond.waitUncancelable(self.io, &self.crawl_mutex); } if (self.crawl_queue.items.len > 0) { hostname = self.crawl_queue.orderedRemove(0); @@ -545,16 +568,16 @@ pub const Slurper = struct { /// number of active workers pub fn workerCount(self: *Slurper) usize { - self.workers_mutex.lock(); - defer self.workers_mutex.unlock(); + self.workers_mutex.lockUncancelable(self.io); + defer self.workers_mutex.unlock(self.io); return self.workers.count(); } /// update rate limits for a running subscriber (called from admin API). /// if the host has a worker, recomputes and applies new limits immediately. pub fn updateHostLimits(self: *Slurper, host_id: u64, account_count: u64) void { - self.workers_mutex.lock(); - defer self.workers_mutex.unlock(); + self.workers_mutex.lockUncancelable(self.io); + defer self.workers_mutex.unlock(self.io); if (self.workers.get(host_id)) |entry| { const trusted = subscriber_mod.isTrustedHost(entry.subscriber.options.hostname); const limits = subscriber_mod.computeLimits(trusted, account_count); @@ -569,16 +592,16 @@ pub const Slurper = struct { pub fn deinit(self: *Slurper) void { // join background threads if (self.startup_thread) |t| t.join(); - self.crawl_cond.signal(); + self.crawl_cond.signal(self.io); if (self.crawl_thread) |t| t.join(); // collect threads to join (can't join while holding workers_mutex) - var threads_to_join: std.ArrayListUnmanaged(std.Thread) = .{}; + var threads_to_join: std.ArrayListUnmanaged(std.Thread) = .empty; defer threads_to_join.deinit(self.allocator); { - self.workers_mutex.lock(); - defer self.workers_mutex.unlock(); + self.workers_mutex.lockUncancelable(self.io); + defer self.workers_mutex.unlock(self.io); var it = self.workers.iterator(); while (it.next()) |entry| { threads_to_join.append(self.allocator, entry.value_ptr.thread) catch {}; diff --git a/src/subscriber.zig b/src/subscriber.zig index 6aec5a4..762c30e 100644 --- a/src/subscriber.zig +++ b/src/subscriber.zig @@ -17,6 +17,7 @@ const resync_mod = @import("resync.zig"); const frame_worker_mod = @import("frame_worker.zig"); const Allocator = std.mem.Allocator; +const Io = std.Io; const log = std.log.scoped(.relay); const max_consecutive_failures = 15; @@ -67,6 +68,22 @@ pub const Options = struct { ca_bundle: ?std.crypto.Certificate.Bundle = null, }; +fn timestamp(io: Io) i64 { + return @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_s)); +} + +fn milliTimestamp(io: Io) i64 { + return Io.Timestamp.now(io, .real).toMilliseconds(); +} + +fn microTimestamp(io: Io) i64 { + return Io.Timestamp.now(io, .real).toMicroseconds(); +} + +fn nanoTimestamp(io: Io) i96 { + return Io.Timestamp.now(io, .real).toNanoseconds(); +} + /// simple sliding window rate limiter — tracks event counts per second/hour/day. /// Sliding window rate limiter (same algorithm as Go relay's github.com/RussellLuo/slidingwindow). /// Uses millisecond timestamps for sub-second precision (critical for the 1-second window). @@ -125,9 +142,9 @@ const RateLimiter = struct { /// Block until all rate limit windows allow the event. /// Checks every 100ms, matching indigo's waitForLimiter behavior. /// Returns which tier (if any) caused a wait, for metrics. - fn waitForAllow(self: *RateLimiter, shutdown: *std.atomic.Value(bool)) Result { + fn waitForAllow(self: *RateLimiter, shutdown: *std.atomic.Value(bool), io: Io) Result { // fast path: no waiting needed - const now_ms = std.time.milliTimestamp(); + const now_ms = milliTimestamp(io); self.sec.advance(now_ms); self.hour.advance(now_ms); self.day.advance(now_ms); @@ -145,9 +162,9 @@ const RateLimiter = struct { // slow path: poll every 100ms until allowed (creates TCP backpressure) var waited: Result = .sec; while (!shutdown.load(.acquire)) { - std.posix.nanosleep(0, 100 * std.time.ns_per_ms); + io.sleep(Io.Duration.fromMilliseconds(100), .awake) catch {}; - const t = std.time.milliTimestamp(); + const t = milliTimestamp(io); self.sec.advance(t); self.hour.advance(t); self.day.advance(t); @@ -183,6 +200,7 @@ const RateLimiter = struct { pub const Subscriber = struct { allocator: Allocator, + io: Io, options: Options, bc: *broadcaster.Broadcaster, validator: *validator_mod.Validator, @@ -200,6 +218,7 @@ pub const Subscriber = struct { pub fn init( allocator: Allocator, + io: Io, bc: *broadcaster.Broadcaster, val: *validator_mod.Validator, persist: ?*event_log_mod.DiskPersist, @@ -210,6 +229,7 @@ pub const Subscriber = struct { const limits = computeLimits(trusted, options.account_count); return .{ .allocator = allocator, + .io = io, .options = options, .bc = bc, .validator = val, @@ -273,7 +293,7 @@ pub const Subscriber = struct { var remaining: u64 = backoff; while (remaining > 0 and !self.shouldStop()) { const chunk = @min(remaining, 1); - std.posix.nanosleep(chunk, 0); + self.io.sleep(Io.Duration.fromSeconds(@intCast(chunk)), .awake) catch {}; remaining -= chunk; } backoff = @min(backoff * 2, max_backoff); @@ -301,7 +321,7 @@ pub const Subscriber = struct { } const path = w.buffered(); - var client = try websocket.Client.init(self.allocator, .{ + var client = try websocket.Client.init(self.io, self.allocator, .{ .host = self.options.hostname, .port = 443, .tls = true, @@ -354,7 +374,7 @@ pub const Subscriber = struct { // sleep in 1s increments so we can check shutdown var elapsed: u32 = 0; while (elapsed < ping_interval_sec and !self.shouldStop()) { - std.posix.nanosleep(1, 0); + self.io.sleep(Io.Duration.fromSeconds(1), .awake) catch {}; elapsed += 1; } if (self.shouldStop()) return; @@ -379,6 +399,7 @@ const FrameHandler = struct { pub fn serverMessage(self: *FrameHandler, data: []const u8) !void { const sub = self.subscriber; + const io = sub.io; // lightweight header decode for cursor tracking + routing var arena = std.heap.ArenaAllocator.init(sub.allocator); @@ -430,7 +451,7 @@ const FrameHandler = struct { // time-based cursor flush (Go relay: every 4 seconds) { - const now = std.time.timestamp(); + const now = timestamp(io); if (now - sub.last_cursor_flush >= cursor_flush_interval_sec) { sub.flushCursor(); sub.last_cursor_flush = now; @@ -439,7 +460,7 @@ const FrameHandler = struct { // per-host rate limiting — block until window opens (matches indigo's waitForLimiter) // blocking here stalls the websocket reader → TCP backpressure → PDS slows down - switch (sub.rate_limiter.waitForAllow(sub.shutdown)) { + switch (sub.rate_limiter.waitForAllow(sub.shutdown, io)) { .allowed => {}, .sec => { _ = sub.bc.stats.rate_limited.fetchAdd(1, .monotonic); @@ -477,12 +498,13 @@ const FrameHandler = struct { break :blk if (d) |s| std.hash.Wyhash.hash(0, s) else sub.options.host_id; }; const duped = sub.allocator.dupe(u8, data) catch return; - const t0 = std.time.nanoTimestamp(); + const t0 = nanoTimestamp(io); if (pool.submit(did_key, .{ .data = duped, .host_id = sub.options.host_id, .hostname = sub.options.hostname, .allocator = sub.allocator, + .io = sub.io, .bc = sub.bc, .validator = sub.validator, .persist = sub.persist, @@ -492,7 +514,7 @@ const FrameHandler = struct { // pool accepted — advance cursor past this frame _ = sub.bc.stats.pool_queued_bytes.fetchAdd(duped.len, .monotonic); if (upstream_seq) |s| sub.last_upstream_seq = s; - if (std.time.nanoTimestamp() - t0 > 1_000_000) { // >1ms = had to wait + if (nanoTimestamp(io) - t0 > 1_000_000) { // >1ms = had to wait _ = sub.bc.stats.pool_backpressure.fetchAdd(1, .monotonic); } } else { @@ -521,6 +543,8 @@ const FrameHandler = struct { is_account: bool, is_identity: bool, ) void { + const io = sub.io; + // extract DID: "repo" for commits, "did" for identity/account const did: ?[]const u8 = if (is_commit) payload.getString("repo") @@ -615,7 +639,7 @@ const FrameHandler = struct { // future-rev rejection if (zat.Tid.parse(incoming_rev)) |tid| { const rev_us: i64 = @intCast(tid.timestamp()); - const now_us = std.time.microTimestamp(); + const now_us = microTimestamp(io); const skew_us: i64 = sub.validator.config.rev_clock_skew * 1_000_000; if (rev_us > now_us + skew_us) { log.info("host {s}: dropping future rev uid={d} rev={s}", .{ @@ -711,8 +735,8 @@ const FrameHandler = struct { // persist (seq assignment) and broadcast, delivering out-of-order. if (sub.persist) |dp| { const relay_seq = blk: { - sub.bc.broadcast_order.lock(); - defer sub.bc.broadcast_order.unlock(); + sub.bc.broadcast_order.lockUncancelable(sub.io); + defer sub.bc.broadcast_order.unlock(sub.io); const seq = dp.persist(kind, uid, data) catch |err| { log.warn("persist failed: {s}", .{@errorName(err)}); @@ -883,38 +907,42 @@ test "sliding window interpolates previous count by elapsed time" { } test "waitForAllow blocks then allows after window advances" { + const io = std.testing.io; + // verify that waitForAllow returns a non-.allowed result when the limit was hit, // indicating it had to wait. We use a tiny limit so the fast path is exhausted. var rl: RateLimiter = .{ .sec_limit = .{ .raw = 1 }, .hour_limit = .{ .raw = 1000 }, .day_limit = .{ .raw = 10000 } }; var shutdown = std.atomic.Value(bool){ .raw = false }; // first call takes the fast path - try std.testing.expectEqual(RateLimiter.Result.allowed, rl.waitForAllow(&shutdown)); + try std.testing.expectEqual(RateLimiter.Result.allowed, rl.waitForAllow(&shutdown, io)); // second call must block (sec limit = 1), then return .sec after the window advances // this will sleep ~100ms+ until the sliding window allows it - const before = std.time.milliTimestamp(); - const result = rl.waitForAllow(&shutdown); - const elapsed = std.time.milliTimestamp() - before; + const before = milliTimestamp(io); + const result = rl.waitForAllow(&shutdown, io); + const elapsed = milliTimestamp(io) - before; try std.testing.expectEqual(RateLimiter.Result.sec, result); try std.testing.expect(elapsed >= 100); // must have slept at least one 100ms poll } test "waitForAllow respects shutdown" { + const io = std.testing.io; + var rl: RateLimiter = .{ .sec_limit = .{ .raw = 1 }, .hour_limit = .{ .raw = 1000 }, .day_limit = .{ .raw = 10000 } }; var shutdown = std.atomic.Value(bool){ .raw = false }; // exhaust the limit - _ = rl.waitForAllow(&shutdown); + _ = rl.waitForAllow(&shutdown, io); // set shutdown before the next call shutdown.store(true, .release); // should return immediately without blocking - const before = std.time.milliTimestamp(); - _ = rl.waitForAllow(&shutdown); - const elapsed = std.time.milliTimestamp() - before; + const before = milliTimestamp(io); + _ = rl.waitForAllow(&shutdown, io); + const elapsed = milliTimestamp(io) - before; try std.testing.expect(elapsed < 50); // should not have slept } diff --git a/src/thread_pool.zig b/src/thread_pool.zig index 06aaea5..c5debcd 100644 --- a/src/thread_pool.zig +++ b/src/thread_pool.zig @@ -6,6 +6,7 @@ //! items stored by value in pre-allocated ring buffer (zero alloc per submit). const std = @import("std"); +const Io = std.Io; const Allocator = std.mem.Allocator; pub fn ThreadPool(comptime T: type, comptime processFn: fn (*T) void) type { @@ -25,28 +26,32 @@ pub fn ThreadPool(comptime T: type, comptime processFn: fn (*T) void) type { head: u16 = 0, // next slot to read tail: u16 = 0, // next slot to write count: u16 = 0, - mutex: std.Thread.Mutex = .{}, - cond: std.Thread.Condition = .{}, // "not empty" — workers wait here - not_full: std.Thread.Condition = .{}, // "not full" — submitters wait here + mutex: Io.Mutex = Io.Mutex.init, + cond: Io.Condition = Io.Condition.init, // "not empty" — workers wait here + not_full: Io.Condition = Io.Condition.init, // "not full" — submitters wait here alive: bool = true, thread: ?std.Thread = null, + io: Io, }; workers: []Worker, allocator: Allocator, + io: Io, - pub fn init(allocator: Allocator, config: Config) !Self { + pub fn init(allocator: Allocator, config: Config, io: Io) !Self { const workers = try allocator.alloc(Worker, config.num_workers); for (workers) |*w| { w.* = .{ .queue = try allocator.alloc(T, config.queue_capacity), .capacity = config.queue_capacity, + .io = io, }; } const self = Self{ .workers = workers, .allocator = allocator, + .io = io, }; // spawn worker threads @@ -68,19 +73,22 @@ pub fn ThreadPool(comptime T: type, comptime processFn: fn (*T) void) type { const idx = key % self.workers.len; const w = &self.workers[idx]; - w.mutex.lock(); - defer w.mutex.unlock(); + w.mutex.lockUncancelable(w.io); + defer w.mutex.unlock(w.io); while (w.count == w.capacity) { if (stop.load(.acquire)) return false; - // poll every 100ms so we notice shutdown promptly - w.not_full.timedWait(&w.mutex, 100 * std.time.ns_per_ms) catch {}; + // poll: release mutex, sleep briefly, reacquire + // (Io.Condition has no timedWait — poll so stop check isn't starved) + w.mutex.unlock(w.io); + w.io.sleep(Io.Duration.fromMilliseconds(10), .awake) catch {}; + w.mutex.lockUncancelable(w.io); } w.queue[w.tail] = item; w.tail = @intCast((@as(u32, w.tail) + 1) % @as(u32, w.capacity)); w.count += 1; - w.cond.signal(); + w.cond.signal(w.io); return true; } @@ -88,11 +96,11 @@ pub fn ThreadPool(comptime T: type, comptime processFn: fn (*T) void) type { pub fn shutdown(self: *Self) void { // signal all workers to stop for (self.workers) |*w| { - w.mutex.lock(); + w.mutex.lockUncancelable(w.io); w.alive = false; - w.cond.signal(); - w.not_full.broadcast(); // wake any blocked submitters - w.mutex.unlock(); + w.cond.signal(w.io); + w.not_full.broadcast(w.io); // wake any blocked submitters + w.mutex.unlock(w.io); } // join all threads for (self.workers) |*w| { @@ -115,8 +123,8 @@ pub fn ThreadPool(comptime T: type, comptime processFn: fn (*T) void) type { pub fn pendingCount(self: *Self) usize { var total: usize = 0; for (self.workers) |*w| { - w.mutex.lock(); - defer w.mutex.unlock(); + w.mutex.lockUncancelable(w.io); + defer w.mutex.unlock(w.io); total += w.count; } return total; @@ -127,11 +135,11 @@ pub fn ThreadPool(comptime T: type, comptime processFn: fn (*T) void) type { var item: T = undefined; { - w.mutex.lock(); - defer w.mutex.unlock(); + w.mutex.lockUncancelable(w.io); + defer w.mutex.unlock(w.io); while (w.count == 0 and w.alive) { - w.cond.wait(&w.mutex); + w.cond.waitUncancelable(w.io, &w.mutex); } if (w.count == 0 and !w.alive) return; @@ -139,7 +147,7 @@ pub fn ThreadPool(comptime T: type, comptime processFn: fn (*T) void) type { item = w.queue[w.head]; w.head = @intCast((@as(u32, w.head) + 1) % @as(u32, w.capacity)); w.count -= 1; - w.not_full.signal(); // wake one blocked submitter + w.not_full.signal(w.io); // wake one blocked submitter } processFn(&item); @@ -170,7 +178,7 @@ test "basic submit and process" { .num_workers = 2, .queue_capacity = 64, .stack_size = 1 * 1024 * 1024, - }); + }, std.testing.io); // submit items for (0..10) |i| { @@ -193,28 +201,29 @@ test "per-key ordering preserved" { const Item = struct { seq: u32, results: *std.ArrayListUnmanaged(u32), - mutex: *std.Thread.Mutex, + mutex: *Io.Mutex, allocator: Allocator, + io: Io, }; const S = struct { fn process(item: *Item) void { - item.mutex.lock(); - defer item.mutex.unlock(); + item.mutex.lockUncancelable(item.io); + defer item.mutex.unlock(item.io); item.results.append(item.allocator, item.seq) catch {}; } }; var shutdown: std.atomic.Value(bool) = .{ .raw = false }; - var results: std.ArrayListUnmanaged(u32) = .{}; + var results: std.ArrayListUnmanaged(u32) = .empty; defer results.deinit(testing.allocator); - var mutex: std.Thread.Mutex = .{}; + var mutex: Io.Mutex = Io.Mutex.init; var pool = try ThreadPool(Item, S.process).init(testing.allocator, .{ .num_workers = 4, .queue_capacity = 64, .stack_size = 1 * 1024 * 1024, - }); + }, std.testing.io); // submit 20 items all with key=42 (same worker) for (0..20) |i| { @@ -223,6 +232,7 @@ test "per-key ordering preserved" { .results = &results, .mutex = &mutex, .allocator = testing.allocator, + .io = std.testing.io, }, &shutdown); try testing.expect(ok); } @@ -239,11 +249,12 @@ test "per-key ordering preserved" { test "submit blocks when queue full, succeeds after drain" { const Item = struct { counter: *std.atomic.Value(u32), + io: Io, }; const S = struct { fn process(item: *Item) void { // slow worker — gives time for queue to fill - std.posix.nanosleep(0, 5 * std.time.ns_per_ms); + item.io.sleep(Io.Duration.fromMilliseconds(5), .awake) catch {}; _ = item.counter.fetchAdd(1, .monotonic); } }; @@ -254,11 +265,11 @@ test "submit blocks when queue full, succeeds after drain" { .num_workers = 1, .queue_capacity = 4, .stack_size = 1 * 1024 * 1024, - }); + }, std.testing.io); // submit more items than capacity — submit blocks until slots open for (0..20) |_| { - const ok = pool.submit(0, .{ .counter = &counter }, &shutdown); + const ok = pool.submit(0, .{ .counter = &counter, .io = std.testing.io }, &shutdown); try testing.expect(ok); } @@ -272,12 +283,13 @@ test "submit blocks when queue full, succeeds after drain" { test "submit returns false on shutdown" { const Item = struct { stop: *std.atomic.Value(bool), + io: Io, }; const S = struct { fn process(item: *Item) void { // poll until shutdown — allows worker to exit promptly while (!item.stop.load(.acquire)) { - std.posix.nanosleep(0, 5 * std.time.ns_per_ms); + item.io.sleep(Io.Duration.fromMilliseconds(5), .awake) catch {}; } } }; @@ -287,16 +299,16 @@ test "submit returns false on shutdown" { .num_workers = 1, .queue_capacity = 2, .stack_size = 1 * 1024 * 1024, - }); + }, std.testing.io); // fill: 1 processing + 2 queued = capacity reached - _ = pool.submit(0, .{ .stop = &shutdown }, &shutdown); - _ = pool.submit(0, .{ .stop = &shutdown }, &shutdown); - _ = pool.submit(0, .{ .stop = &shutdown }, &shutdown); + _ = pool.submit(0, .{ .stop = &shutdown, .io = std.testing.io }, &shutdown); + _ = pool.submit(0, .{ .stop = &shutdown, .io = std.testing.io }, &shutdown); + _ = pool.submit(0, .{ .stop = &shutdown, .io = std.testing.io }, &shutdown); // signal shutdown — next submit should return false shutdown.store(true, .release); - const ok = pool.submit(0, .{ .stop = &shutdown }, &shutdown); + const ok = pool.submit(0, .{ .stop = &shutdown, .io = std.testing.io }, &shutdown); try testing.expect(!ok); pool.shutdown(); @@ -304,12 +316,11 @@ test "submit returns false on shutdown" { } test "pendingCount reflects queued items" { - const Item = struct { x: u32 }; + const Item = struct { x: u32, io: Io }; const S = struct { fn process(item: *Item) void { - _ = item; // slow worker so items accumulate - std.posix.nanosleep(0, 10 * std.time.ns_per_ms); + item.io.sleep(Io.Duration.fromMilliseconds(10), .awake) catch {}; } }; @@ -317,7 +328,7 @@ test "pendingCount reflects queued items" { .num_workers = 1, .queue_capacity = 64, .stack_size = 1 * 1024 * 1024, - }); + }, std.testing.io); // initially empty try testing.expectEqual(@as(usize, 0), pool.pendingCount()); @@ -342,7 +353,7 @@ test "shutdown drains remaining items" { .num_workers = 2, .queue_capacity = 64, .stack_size = 1 * 1024 * 1024, - }); + }, std.testing.io); for (0..30) |i| { _ = pool.submit(i, .{ .counter = &counter }, &shutdown); diff --git a/src/validator.zig b/src/validator.zig index 6f156af..e705d63 100644 --- a/src/validator.zig +++ b/src/validator.zig @@ -6,6 +6,7 @@ //! and queues background resolution. no frame is ever blocked on network I/O. const std = @import("std"); +const Io = std.Io; const zat = @import("zat"); const broadcaster = @import("broadcaster.zig"); const event_log_mod = @import("event_log.zig"); @@ -49,14 +50,15 @@ pub const Validator = struct { // DID → signing key cache (decoded, ready for verification) cache: lru.LruCache(CachedKey), // background resolve queue - queue: std.ArrayListUnmanaged([]const u8) = .{}, + queue: std.ArrayListUnmanaged([]const u8) = .empty, // in-flight set — prevents duplicate DID entries in the queue - queued_set: std.StringHashMapUnmanaged(void) = .{}, - queue_mutex: std.Thread.Mutex = .{}, - queue_cond: std.Thread.Condition = .{}, + queued_set: std.StringHashMapUnmanaged(void) = .empty, + queue_mutex: Io.Mutex = Io.Mutex.init, + queue_cond: Io.Condition = Io.Condition.init, resolver_threads: [max_resolver_threads]?std.Thread = .{null} ** max_resolver_threads, alive: std.atomic.Value(bool) = .{ .raw = true }, max_cache_size: u32 = 250_000, + io: Io, // pool of reusable resolvers for inline host authority checks. // frame workers acquire/release via atomic flag to avoid creating // a fresh resolver (and fresh TLS handshake) per call. @@ -69,22 +71,23 @@ pub const Validator = struct { const max_queue_size: usize = 100_000; const host_resolver_pool_size: usize = 4; - pub fn init(allocator: Allocator, stats: *broadcaster.Stats) Validator { - return initWithConfig(allocator, stats, .{}); + pub fn init(allocator: Allocator, stats: *broadcaster.Stats, io: Io) Validator { + return initWithConfig(allocator, stats, .{}, io); } - pub fn initWithConfig(allocator: Allocator, stats: *broadcaster.Stats, config: ValidatorConfig) Validator { + pub fn initWithConfig(allocator: Allocator, stats: *broadcaster.Stats, config: ValidatorConfig, io: Io) Validator { return .{ .allocator = allocator, .stats = stats, .config = config, - .cache = lru.LruCache(CachedKey).init(allocator, 250_000), + .cache = lru.LruCache(CachedKey).init(allocator, 250_000, io), + .io = io, }; } pub fn deinit(self: *Validator) void { self.alive.store(false, .release); - self.queue_cond.broadcast(); + self.queue_cond.broadcast(self.io); for (&self.resolver_threads) |*t| { if (t.*) |thread| { thread.join(); @@ -121,7 +124,7 @@ pub const Validator = struct { // init host authority resolver pool (reused across calls) for (&self.host_resolvers) |*r| { - r.* = zat.DidResolver.initWithOptions(self.allocator, .{}); + r.* = zat.DidResolver.initWithOptions(self.io, self.allocator, .{}); } for (&self.host_resolver_available) |*a| { a.store(true, .release); @@ -321,7 +324,7 @@ pub const Validator = struct { fn extractOps(self: *Validator, alloc: Allocator, payload: zat.cbor.Value) ?[]const zat.MstOperation { _ = self; const ops_array = payload.getArray("ops") orelse return null; - var ops: std.ArrayListUnmanaged(zat.MstOperation) = .{}; + var ops: std.ArrayListUnmanaged(zat.MstOperation) = .empty; for (ops_array) |op| { const action = op.getString("action") orelse continue; const path = op.getString("path") orelse continue; @@ -404,8 +407,8 @@ pub const Validator = struct { const duped = self.allocator.dupe(u8, did) catch return; - self.queue_mutex.lock(); - defer self.queue_mutex.unlock(); + self.queue_mutex.lockUncancelable(self.io); + defer self.queue_mutex.unlock(self.io); // skip if already queued (prevents unbounded queue growth) if (self.queued_set.contains(duped)) { @@ -424,20 +427,20 @@ pub const Validator = struct { return; }; self.queued_set.put(self.allocator, duped, {}) catch {}; - self.queue_cond.signal(); + self.queue_cond.signal(self.io); } fn resolveLoop(self: *Validator) void { - var resolver = zat.DidResolver.initWithOptions(self.allocator, .{ .keep_alive = true }); + var resolver = zat.DidResolver.initWithOptions(self.io, self.allocator, .{ .keep_alive = true }); defer resolver.deinit(); while (self.alive.load(.acquire)) { var did: ?[]const u8 = null; { - self.queue_mutex.lock(); - defer self.queue_mutex.unlock(); + self.queue_mutex.lockUncancelable(self.io); + defer self.queue_mutex.unlock(self.io); while (self.queue.items.len == 0 and self.alive.load(.acquire)) { - self.queue_cond.timedWait(&self.queue_mutex, 1 * std.time.ns_per_s) catch {}; + self.queue_cond.waitUncancelable(self.io, &self.queue_mutex); } if (self.queue.items.len > 0) { did = self.queue.orderedRemove(0); @@ -470,7 +473,7 @@ pub const Validator = struct { .key_type = public_key.key_type, .raw = undefined, .len = @intCast(public_key.raw.len), - .resolve_time = std.time.timestamp(), + .resolve_time = timestamp(self.io), }; @memcpy(cached.raw[0..public_key.raw.len], public_key.raw); @@ -509,14 +512,14 @@ pub const Validator = struct { /// resolve queue length (for diagnostics — non-blocking) pub fn resolveQueueLen(self: *Validator) usize { if (!self.queue_mutex.tryLock()) return 0; - defer self.queue_mutex.unlock(); + defer self.queue_mutex.unlock(self.io); return self.queue.items.len; } /// resolve dedup set size (for diagnostics — non-blocking) pub fn resolveQueuedSetCount(self: *Validator) u32 { if (!self.queue_mutex.tryLock()) return 0; - defer self.queue_mutex.unlock(); + defer self.queue_mutex.unlock(self.io); return self.queued_set.count(); } @@ -528,7 +531,7 @@ pub const Validator = struct { /// resolver dedup set hashmap backing capacity (for memory attribution — non-blocking) pub fn resolveQueuedSetCapacity(self: *Validator) u32 { if (!self.queue_mutex.tryLock()) return 0; - defer self.queue_mutex.unlock(); + defer self.queue_mutex.unlock(self.io); return self.queued_set.capacity(); } @@ -574,7 +577,7 @@ pub const Validator = struct { return i; } } - std.Thread.yield() catch {}; + self.io.sleep(Io.Duration.fromMilliseconds(1), .awake) catch {}; } return 0; // shutdown path — caller will exit soon } @@ -614,16 +617,25 @@ pub fn extractHostFromUrl(url: []const u8) ?[]const u8 { return rest; } -fn parseEnvInt(comptime T: type, key: []const u8, default: T) T { - const val = std.posix.getenv(key) orelse return default; +fn getenv(key: [*:0]const u8) ?[]const u8 { + const ptr = std.c.getenv(key) orelse return null; + return std.mem.sliceTo(ptr, 0); +} + +fn parseEnvInt(comptime T: type, key: [*:0]const u8, default: T) T { + const val = getenv(key) orelse return default; return std.fmt.parseInt(T, val, 10) catch default; } +fn timestamp(io: Io) i64 { + return @intCast(@divFloor(Io.Timestamp.now(io, .real).nanoseconds, std.time.ns_per_s)); +} + // --- tests --- test "validateCommit skips on cache miss" { var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); // build a commit payload using SDK @@ -642,7 +654,7 @@ test "validateCommit skips on cache miss" { test "validateCommit skips when no repo field" { var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); // payload without "repo" field @@ -658,7 +670,7 @@ test "validateCommit skips when no repo field" { test "checkCommitStructure rejects invalid DID" { var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ @@ -670,7 +682,7 @@ test "checkCommitStructure rejects invalid DID" { test "checkCommitStructure accepts valid commit" { var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ @@ -683,7 +695,7 @@ test "checkCommitStructure accepts valid commit" { test "validateSync skips on cache miss" { var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ @@ -701,7 +713,7 @@ test "validateSync skips on cache miss" { test "validateSync rejects invalid DID" { var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ @@ -717,7 +729,7 @@ test "validateSync rejects invalid DID" { test "validateSync rejects missing blocks" { var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ @@ -733,7 +745,7 @@ test "validateSync rejects missing blocks" { test "validateSync skips when no did field" { var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ @@ -748,7 +760,7 @@ test "validateSync skips when no did field" { test "LRU cache evicts least recently used" { var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); v.cache.capacity = 3; defer v.deinit(); @@ -773,7 +785,7 @@ test "LRU cache evicts least recently used" { test "checkCommitStructure rejects too many ops" { var stats = broadcaster.Stats{}; - var v = Validator.initWithConfig(std.testing.allocator, &stats, .{ .max_ops = 2 }); + var v = Validator.initWithConfig(std.testing.allocator, &stats, .{ .max_ops = 2 }, std.testing.io); defer v.deinit(); // build ops array with 3 items (over limit of 2) @@ -796,7 +808,7 @@ test "checkCommitStructure rejects too many ops" { test "spec: #commit blocks > 2,000,000 bytes rejected" { // lexicon maxLength for #commit blocks: 2,000,000 var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); // insert a fake cached key so we reach the blocks size check @@ -826,7 +838,7 @@ test "spec: #commit blocks > 2,000,000 bytes rejected" { test "spec: #commit blocks = 2,000,000 bytes accepted (boundary)" { // lexicon maxLength for #commit blocks: 2,000,000 — exactly at limit should pass size check var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const did = "did:plc:test123"; @@ -857,7 +869,7 @@ test "spec: #commit blocks = 2,000,000 bytes accepted (boundary)" { test "spec: #sync blocks > 10,000 bytes rejected" { // lexicon maxLength for #sync blocks: 10,000 var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ @@ -874,7 +886,7 @@ test "spec: #sync blocks > 10,000 bytes rejected" { test "spec: #sync blocks = 10,000 bytes accepted (boundary)" { // lexicon maxLength for #sync blocks: 10,000 — exactly at limit should pass size check var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); const payload: zat.cbor.Value = .{ .map = &.{ @@ -891,7 +903,7 @@ test "spec: #sync blocks = 10,000 bytes accepted (boundary)" { test "extractOps reads path field from firehose format" { var stats = broadcaster.Stats{}; - var v = Validator.initWithConfig(std.testing.allocator, &stats, .{ .verify_commit_diff = true }); + var v = Validator.initWithConfig(std.testing.allocator, &stats, .{ .verify_commit_diff = true }, std.testing.io); defer v.deinit(); // use arena since extractOps allocates an ArrayList internally @@ -926,7 +938,7 @@ test "extractOps reads path field from firehose format" { test "extractOps rejects malformed path without slash" { var stats = broadcaster.Stats{}; - var v = Validator.initWithConfig(std.testing.allocator, &stats, .{ .verify_commit_diff = true }); + var v = Validator.initWithConfig(std.testing.allocator, &stats, .{ .verify_commit_diff = true }, std.testing.io); defer v.deinit(); var arena = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -952,7 +964,7 @@ test "extractOps rejects malformed path without slash" { test "checkCommitStructure validates path field" { var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); // valid path @@ -988,7 +1000,7 @@ test "checkCommitStructure validates path field" { test "queueResolve deduplicates repeated DIDs" { var stats = broadcaster.Stats{}; - var v = Validator.init(std.testing.allocator, &stats); + var v = Validator.init(std.testing.allocator, &stats, std.testing.io); defer v.deinit(); // queue the same DID 100 times -- 2.51.2