diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e6ebde77..c3bdd5aa 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -67,9 +67,9 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Zig - uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 with: - version: 0.15.1 + version: 0.16.0 - name: Install Deno uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 @@ -132,9 +132,9 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Zig - uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 with: - version: 0.15.1 + version: 0.16.0 - name: Build FFI libraries run: cd ffi/zig && zig build @@ -170,9 +170,9 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Zig - uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 with: - version: 0.15.1 + version: 0.16.0 - name: Build FFI libraries run: cd ffi/zig && zig build diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 4704d7c7..4fd44eac 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -30,7 +30,7 @@ jobs: - name: Install Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 with: - version: 0.15.2 + version: 0.16.0 - name: Run core FFI fuzz tests run: | diff --git a/.github/workflows/lsp-dap-bsp.yml b/.github/workflows/lsp-dap-bsp.yml index dc6a5751..915cf9f8 100644 --- a/.github/workflows/lsp-dap-bsp.yml +++ b/.github/workflows/lsp-dap-bsp.yml @@ -93,14 +93,14 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - # The old step curled ziglang.org/builds/...0.15.2... — that path is the - # nightly dir, not release downloads, so it returned an error page and - # `tar` died ("File format not recognized"). Use the pinned action + - # .tool-versions' canonical 0.15.1, matching e2e.yml. + # Use the pinned action + .tool-versions' canonical Zig, matching + # e2e.yml. (A previous revision curled ziglang.org/builds/... — the + # nightly dir, not release downloads — and got an error page that + # `tar` rejected.) - name: Install Zig - uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 with: - version: 0.15.1 + version: 0.16.0 - name: Build LSP/DAP/BSP cartridge FFI run: | for cart in lsp-mcp dap-mcp bsp-mcp; do diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5577dc96..d888dbb0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: - name: Install Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 with: - version: 0.15.2 + version: 0.16.0 - name: Fetch cartridges # Cartridge source lives in the canonical registry, not this repo. Fetch diff --git a/.github/workflows/truthfulness.yml b/.github/workflows/truthfulness.yml index 8d820f2b..9e8127f3 100644 --- a/.github/workflows/truthfulness.yml +++ b/.github/workflows/truthfulness.yml @@ -68,9 +68,9 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Zig - uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 with: - version: 0.15.1 + version: 0.16.0 - name: Install jq run: sudo apt-get update && sudo apt-get install -y jq diff --git a/.github/workflows/zig-test.yml b/.github/workflows/zig-test.yml index ef5b0931..35c11edf 100644 --- a/.github/workflows/zig-test.yml +++ b/.github/workflows/zig-test.yml @@ -67,7 +67,7 @@ jobs: - name: Install Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 with: - version: 0.15.2 + version: 0.16.0 # Bundled cartridges/ was retired; per-cartridge FFI builds are gated # in hyperpolymath/boj-server-cartridges. This workflow now covers the diff --git a/.tool-versions b/.tool-versions index 3f22a199..98cffc17 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,4 +1,4 @@ -zig 0.15.1 +zig 0.16.0 elixir 1.18.4-otp-25 idris2 0.8.0 deno 2.7.14 diff --git a/ffi/zig/build.zig b/ffi/zig/build.zig index d0a15da8..ddbfc420 100644 --- a/ffi/zig/build.zig +++ b/ffi/zig/build.zig @@ -11,11 +11,20 @@ pub fn build(b: *std.Build) void { const optimize = b.standardOptimizeOption(.{}); // --- Catalogue module --- + const shim_mod = b.addModule("boj_cartridge_shim", .{ + .root_source_file = b.path("src/cartridge_shim.zig"), + .target = target, + .optimize = optimize, + // shim.getenv wraps std.c.getenv + .link_libc = true, + }); + const catalogue_mod = b.addModule("boj_catalogue", .{ .root_source_file = b.path("src/catalogue.zig"), .target = target, .optimize = optimize, }); + catalogue_mod.addImport("cartridge_shim", shim_mod); // --- Loader module --- const loader_mod = b.addModule("boj_loader", .{ @@ -23,6 +32,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + loader_mod.addImport("cartridge_shim", shim_mod); loader_mod.addImport("catalogue", catalogue_mod); // --- Static library (for zig adapter linking) --- @@ -50,6 +60,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = .ReleaseFast, }); + bench_mod.addImport("cartridge_shim", shim_mod); bench_mod.addImport("catalogue", catalogue_mod); const bench = b.addExecutable(.{ .name = "boj_bench", @@ -106,6 +117,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + guardian_mod.addImport("cartridge_shim", shim_mod); const guardian_lib = b.addLibrary(.{ .name = "boj_guardian", @@ -127,6 +139,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + federation_mod.addImport("cartridge_shim", shim_mod); const federation_lib = b.addLibrary(.{ .name = "boj_federation", @@ -163,6 +176,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + verisimdb_mod.addImport("cartridge_shim", shim_mod); const verisimdb_tests = b.addTest(.{ .root_module = verisimdb_mod, }); @@ -178,6 +192,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, .link_libc = true, }); + coprocessor_mod.addImport("cartridge_shim", shim_mod); const coprocessor_lib = b.addLibrary(.{ .name = "boj_coprocessor", @@ -199,6 +214,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + sla_mod.addImport("cartridge_shim", shim_mod); const sla_lib = b.addLibrary(.{ .name = "boj_sla", @@ -220,6 +236,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + community_mod.addImport("cartridge_shim", shim_mod); const community_lib = b.addLibrary(.{ .name = "boj_community", @@ -262,6 +279,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + sdp_mod.addImport("cartridge_shim", shim_mod); const sdp_lib = b.addLibrary(.{ .name = "boj_sdp", @@ -294,12 +312,6 @@ pub fn build(b: *std.Build) void { seams_step.dependOn(&run_seams_tests.step); // --- Cartridge shim tests (ADR-0006 helpers) --- - const shim_mod = b.addModule("boj_cartridge_shim", .{ - .root_source_file = b.path("src/cartridge_shim.zig"), - .target = target, - .optimize = optimize, - }); - const shim_tests = b.addTest(.{ .root_module = shim_mod, }); @@ -411,6 +423,7 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, }); + mcp_bench_mod.addImport("cartridge_shim", shim_mod); const bench_tests = b.addTest(.{ .root_module = mcp_bench_mod }); const run_bench = b.addRunArtifact(bench_tests); diff --git a/ffi/zig/src/bench.zig b/ffi/zig/src/bench.zig index 8829e061..214413fc 100644 --- a/ffi/zig/src/bench.zig +++ b/ffi/zig/src/bench.zig @@ -22,17 +22,16 @@ fn benchmarkFn(comptime name: []const u8, comptime func: anytype) void { func(); } - // Measure - var timer = std.time.Timer.start() catch { - std.debug.print("Timer unavailable\n", .{}); - return; - }; + // Measure (std.time.Timer was removed in Zig 0.16; the monotonic Io + // clock is the replacement) + const started = std.Io.Clock.Timestamp.now(shim.io(), .monotonic); for (0..BENCH_ITERS) |_| { func(); } - const elapsed_ns = timer.read(); + const ended = std.Io.Clock.Timestamp.now(shim.io(), .monotonic); + const elapsed_ns: u64 = @intCast(started.durationTo(ended).raw.nanoseconds); const per_op_ns = elapsed_ns / BENCH_ITERS; const ops_per_sec = if (per_op_ns > 0) @as(u64, 1_000_000_000) / per_op_ns else 0; @@ -252,3 +251,5 @@ pub fn main() !void { std.debug.print("═══════════════════════════════════════════════════════════════\n", .{}); std.debug.print("\n", .{}); } + +const shim = @import("cartridge_shim"); diff --git a/ffi/zig/src/cartridge_shim.zig b/ffi/zig/src/cartridge_shim.zig index cd83b44b..bdcc64a5 100644 --- a/ffi/zig/src/cartridge_shim.zig +++ b/ffi/zig/src/cartridge_shim.zig @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // // cartridge_shim.zig — Shared helpers for the ADR-0006 five-symbol // cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). @@ -94,8 +94,116 @@ pub fn writeResult( return RC_SUCCESS; } +// ── Shared runtime Io (Zig 0.16 compat) ────────────────────────────── +// +// Zig 0.16 moved blocking primitives and the wall clock onto the +// `std.Io` interface (`std.Thread.Mutex` and `std.time.*Timestamp` +// were removed from the stdlib). The shim owns one process-wide Io +// backed by `std.Io.Threaded` so cartridge code keeps drop-in call +// sites — `var m: shim.Mutex = .{}; m.lock(); defer m.unlock();` and +// `shim.milliTimestamp()` — without threading an Io handle through the +// C ABI. Cartridges that need richer Io (http, fs, net) should use +// this same `shim.io()` rather than constructing their own runtime. + +var shared_threaded: std.Io.Threaded = undefined; +var shared_io_state: std.atomic.Value(u8) = .init(0); // 0=uninit 1=initing 2=ready + +/// The process-wide `std.Io`, lazily initialised on first use. +/// Thread-safe: a single CAS winner runs `Threaded.init`; racing +/// callers yield until it is published. +pub fn io() std.Io { + if (shared_io_state.load(.acquire) != 2) { + if (shared_io_state.cmpxchgStrong(0, 1, .acq_rel, .acquire) == null) { + shared_threaded = std.Io.Threaded.init(std.heap.smp_allocator, .{}); + shared_io_state.store(2, .release); + } else { + while (shared_io_state.load(.acquire) != 2) std.Thread.yield() catch {}; + } + } + return shared_threaded.io(); +} + +/// Drop-in replacement for the removed `std.Thread.Mutex`, backed by +/// `std.Io.Mutex` over the shim's shared Io. Zero-initialisable: +/// `var m: shim.Mutex = .{};` +pub const Mutex = struct { + inner: std.Io.Mutex = .init, + + pub fn lock(m: *Mutex) void { + m.inner.lockUncancelable(io()); + } + + pub fn unlock(m: *Mutex) void { + m.inner.unlock(io()); + } + + pub fn tryLock(m: *Mutex) bool { + return m.inner.tryLock(); + } +}; + +/// Nanoseconds since the POSIX epoch (drop-in for the removed +/// `std.time.nanoTimestamp`). +pub fn nanoTimestamp() i128 { + const ts = std.Io.Clock.Timestamp.now(io(), .real); + return @intCast(ts.raw.nanoseconds); +} + +/// Milliseconds since the POSIX epoch (drop-in for the removed +/// `std.time.milliTimestamp`). +pub fn milliTimestamp() i64 { + return @intCast(@divTrunc(nanoTimestamp(), std.time.ns_per_ms)); +} + +/// Seconds since the POSIX epoch (drop-in for the removed +/// `std.time.timestamp`). +pub fn timestamp() i64 { + return @intCast(@divTrunc(nanoTimestamp(), std.time.ns_per_s)); +} + +/// Fill `buffer` with cryptographically secure random bytes (drop-in for +/// the removed `std.crypto.random.bytes`). +pub fn randomBytes(buffer: []u8) void { + io().random(buffer); +} + +/// Cryptographically secure random integer (drop-in for the removed +/// `std.crypto.random.int(T)`). +pub fn randomInt(comptime T: type) T { + var buf: [@sizeOf(T)]u8 = undefined; + io().random(&buf); + return @bitCast(buf); +} + +/// Process environment lookup (drop-in for the removed +/// `std.posix.getenv`). Only analysed when referenced; callers must link +/// libc (`root_module.link_libc = true` — the standard ffi build shape). +pub fn getenv(name: [*:0]const u8) ?[:0]const u8 { + const p = std.c.getenv(name) orelse return null; + return std.mem.sliceTo(p, 0); +} + // ── Tests ──────────────────────────────────────────────────────────── +test "Mutex: lock/unlock and tryLock round-trip" { + var m: Mutex = .{}; + m.lock(); + try std.testing.expect(!m.tryLock()); + m.unlock(); + try std.testing.expect(m.tryLock()); + m.unlock(); +} + +test "timestamps: monotone-ish and unit-consistent" { + const ns = nanoTimestamp(); + const ms = milliTimestamp(); + const s = timestamp(); + try std.testing.expect(ns > 0); + // Same instant expressed in three units must agree to within a step. + try std.testing.expect(@abs(@divTrunc(ns, std.time.ns_per_s) - s) <= 1); + try std.testing.expect(@abs(@divTrunc(ms, std.time.ms_per_s) - s) <= 1); +} + test "writeResult: body fits, writes and sets length" { var buf: [64]u8 = undefined; var len: usize = buf.len; diff --git a/ffi/zig/src/catalogue.zig b/ffi/zig/src/catalogue.zig index 16caf52d..3cf1b2cc 100644 --- a/ffi/zig/src/catalogue.zig +++ b/ffi/zig/src/catalogue.zig @@ -13,11 +13,11 @@ const std = @import("std"); // `std.atomic.Mutex` was removed from the standard library; its replacement is -// `std.Thread.Mutex`, whose lock/unlock surface is identical to the hand-rolled +// `shim.Mutex`, whose lock/unlock surface is identical to the hand-rolled // wrapper this replaces. The wrapper also busy-waited via `spinLoopHint`, burning -// a core under contention; `std.Thread.Mutex` parks the thread instead. 81 other +// a core under contention; `shim.Mutex` parks the thread instead. 81 other // files in this repo already use this form. -const Mutex = std.Thread.Mutex; +const Mutex = shim.Mutex; // ═══════════════════════════════════════════════════════════════════════ // Types (must match src/abi/Catalogue.idr encodings) @@ -772,3 +772,5 @@ test "menu JSON generation" { try std.testing.expect(std.mem.indexOf(u8, json, "\"mcp\"") != null); try std.testing.expect(std.mem.indexOf(u8, json, "\"rest\"") != null); } + +const shim = @import("cartridge_shim"); diff --git a/ffi/zig/src/community.zig b/ffi/zig/src/community.zig index bb5e9271..05ab27b3 100644 --- a/ffi/zig/src/community.zig +++ b/ffi/zig/src/community.zig @@ -16,11 +16,11 @@ const std = @import("std"); // `std.atomic.Mutex` was removed from the standard library; its replacement is -// `std.Thread.Mutex`, whose lock/unlock surface is identical to the hand-rolled +// `shim.Mutex`, whose lock/unlock surface is identical to the hand-rolled // wrapper this replaces. The wrapper also busy-waited via `spinLoopHint`, burning -// a core under contention; `std.Thread.Mutex` parks the thread instead. 81 other +// a core under contention; `shim.Mutex` parks the thread instead. 81 other // files in this repo already use this form. -const Mutex = std.Thread.Mutex; +const Mutex = shim.Mutex; // ═══════════════════════════════════════════════════════════════════════ // Constants @@ -123,7 +123,7 @@ fn submit( sub.hash_len = 64; sub.status = .submitted; - sub.submitted_at = std.time.timestamp(); + sub.submitted_at = shim.timestamp(); sub.active = true; submission_count += 1; @@ -145,7 +145,7 @@ fn setStatus(idx: usize, new_status: ReviewStatus) i32 { if (!valid) return -2; sub.status = new_status; - sub.reviewed_at = std.time.timestamp(); + sub.reviewed_at = shim.timestamp(); return 0; } @@ -369,3 +369,5 @@ test "community deinit resets" { boj_community_deinit(); try std.testing.expectEqual(@as(usize, 0), boj_community_count()); } + +const shim = @import("cartridge_shim"); diff --git a/ffi/zig/src/coprocessor.zig b/ffi/zig/src/coprocessor.zig index 6f4919ba..f73c0914 100644 --- a/ffi/zig/src/coprocessor.zig +++ b/ffi/zig/src/coprocessor.zig @@ -21,11 +21,11 @@ const std = @import("std"); // `std.atomic.Mutex` was removed from the standard library; its replacement is -// `std.Thread.Mutex`, whose lock/unlock surface is identical to the hand-rolled +// `shim.Mutex`, whose lock/unlock surface is identical to the hand-rolled // wrapper this replaces. The wrapper also busy-waited via `spinLoopHint`, burning -// a core under contention; `std.Thread.Mutex` parks the thread instead. 81 other +// a core under contention; `shim.Mutex` parks the thread instead. 81 other // files in this repo already use this form. -const Mutex = std.Thread.Mutex; +const Mutex = shim.Mutex; extern fn getenv(name: [*:0]const u8) ?[*:0]const u8; @@ -490,7 +490,7 @@ test "coprocessor has_accelerator" { test "coprocessor env-driven cuda detection" { // Save and set env - const had_env = std.posix.getenv("BOJ_CUDA_DEVICES") != null; + const had_env = shim.getenv("BOJ_CUDA_DEVICES") != null; if (!had_env) { // Can't easily set env in Zig tests, so just verify init works init(); @@ -510,3 +510,5 @@ test "coprocessor deinit resets state" { try std.testing.expectEqual(@as(usize, 0), boj_coprocessor_affinity_count()); try std.testing.expect(!initialised); } + +const shim = @import("cartridge_shim"); diff --git a/ffi/zig/src/federation.zig b/ffi/zig/src/federation.zig index c65b3532..e13498af 100644 --- a/ffi/zig/src/federation.zig +++ b/ffi/zig/src/federation.zig @@ -21,11 +21,29 @@ const std = @import("std"); // `std.atomic.Mutex` was removed from the standard library; its replacement is -// `std.Thread.Mutex`, whose lock/unlock surface is identical to the hand-rolled +// `shim.Mutex`, whose lock/unlock surface is identical to the hand-rolled // wrapper this replaces. The wrapper also busy-waited via `spinLoopHint`, burning -// a core under contention; `std.Thread.Mutex` parks the thread instead. 81 other +// a core under contention; `shim.Mutex` parks the thread instead. 81 other // files in this repo already use this form. -const Mutex = std.Thread.Mutex; +const Mutex = shim.Mutex; + +// `std.net` was removed in Zig 0.16; the networking surface now lives behind +// the `std.Io` interface as `std.Io.net`. The raw socket calls this file used +// (`std.posix.socket`/`bind`/`sendto`/`recvfrom`/`close`) were trimmed from +// the posix surface at the same time — `std.Io.net.Socket` replaces all five. +// Every call site below takes its `std.Io` from `shim.io()`, the process-wide +// `std.Io.Threaded` this repo already uses for the clock, mutexes, and CSPRNG. +const net = std.Io.net; + +// Non-blocking receive. `std.posix.recvfrom(..., MSG.DONTWAIT, ...)` is gone; +// `Socket.receiveTimeout` with a zero duration is the exact equivalent, because +// `std.Io.Threaded` issues `recvmsg(2)` with `MSG_DONTWAIT` first and only then +// falls back to `poll(2)` for the remainder of the timeout — which here is +// nothing. The "no packet queued" signal is renamed from `error.WouldBlock` to +// `error.Timeout`; both map to the same `0` return, so callers see no change. +const RECV_NONBLOCKING: std.Io.Timeout = .{ + .duration = .{ .raw = .zero, .clock = .awake }, +}; // ═══════════════════════════════════════════════════════════════════════ // Proven-hardened: Circuit breaker, retry, and rate limiter state @@ -361,8 +379,10 @@ var local_node_id_len: usize = 0; /// Our listen port (set by umoja_bind). var local_port: u16 = 0; -/// UDP socket file descriptor. -1 means not bound. -var udp_fd: std.posix.socket_t = -1; +/// UDP socket. `null` means not bound. (Was a bare `std.posix.socket_t` fd +/// sentinelled with -1; `std.Io.net.Socket` carries the handle plus the +/// resolved bound address, and has no spare sentinel value.) +var udp_socket: ?net.Socket = null; /// Whether the UDP socket is bound and ready. var socket_bound: bool = false; @@ -422,7 +442,7 @@ fn copyBounded(dst: []u8, src_ptr: [*]const u8, src_len: usize) usize { /// gossip peer selection influences which nodes converge first. fn prngNext() u32 { var buf: [4]u8 = undefined; - std.crypto.random.bytes(&buf); + shim.randomBytes(&buf); return std.mem.readInt(u32, &buf, .little); } @@ -515,7 +535,7 @@ fn sendToPeer(peer_idx: usize, buf: []const u8) c_int { if (!validPeer(peer_idx)) return -1; if (peer_idx >= MAX_PEERS) return -1; - const now = std.time.timestamp(); + const now = shim.timestamp(); var cb = &peer_circuit_breakers[peer_idx]; // Circuit breaker gate: check if sends are allowed to this peer. @@ -542,51 +562,50 @@ fn sendToPeerUnchecked(peer_idx: usize, buf: []const u8) c_int { if (!validPeer(peer_idx)) return -1; const peer = &peers[peer_idx]; - var addr: std.posix.sockaddr.in6 = std.mem.zeroes(std.posix.sockaddr.in6); - addr.family = std.posix.AF.INET6; - addr.port = std.mem.nativeToBig(u16, peer.port); // Parse IPv6 address from peer host string. const host_slice = peer.host[0..peer.host_len]; - const parsed = std.net.Ip6Address.parse(host_slice, peer.port) catch { + const parsed = net.Ip6Address.parse(host_slice, peer.port) catch { last_net_error = -2; return -1; }; - addr = parsed.sa; + const dest: net.IpAddress = .{ .ip6 = parsed }; - // SAFETY: @ptrCast from sockaddr.in6 to sockaddr is the standard POSIX - // pattern for passing typed socket addresses to sendto(2). The in6 struct - // is a superset of sockaddr and the size parameter ensures bounds safety. - const dest: *const std.posix.sockaddr = @ptrCast(&addr); - const sent = std.posix.sendto(udp_fd, buf, 0, dest, @sizeOf(std.posix.sockaddr.in6)) catch { + // `Socket.send` carries the destination address itself, so the hand-filled + // `sockaddr.in6` (family/port/@ptrCast to `sockaddr`) that sendto(2) + // required is gone; the address family and length are derived from the + // `IpAddress` union tag. + udp_socket.?.send(shim.io(), &dest, buf) catch { last_net_error = -3; return -1; }; - _ = sent; packets_sent += 1; return 0; } /// Try to receive one UDP packet (non-blocking). /// Returns the number of bytes received, 0 if nothing available, or -1 on error. -/// Stores the sender's address in the provided sockaddr. -fn recvPacket(buf: []u8, src_addr: *std.posix.sockaddr.in6) c_int { +/// Stores the sender's address in the provided address slot. +fn recvPacket(buf: []u8, src_addr: *net.Ip6Address) c_int { if (!socket_bound) return -1; - var addr6: std.posix.sockaddr.in6 = std.mem.zeroes(std.posix.sockaddr.in6); - var addr6_len: std.posix.socklen_t = @sizeOf(std.posix.sockaddr.in6); - - // SAFETY: @ptrCast from sockaddr.in6* to sockaddr* — standard POSIX recvfrom pattern. - const n = std.posix.recvfrom(udp_fd, buf, std.posix.MSG.DONTWAIT, @ptrCast(&addr6), &addr6_len) catch |err| { - if (err == error.WouldBlock) return 0; - last_net_error = -4; - return -1; + const msg = udp_socket.?.receiveTimeout(shim.io(), buf, RECV_NONBLOCKING) catch |err| switch (err) { + // Nothing queued. `MSG.DONTWAIT` used to surface this as + // `error.WouldBlock`; the zero-duration timeout surfaces it here. + error.Timeout => return 0, + else => { + last_net_error = -4; + return -1; + }, }; - src_addr.* = addr6; + // `IncomingMessage.from` is an `IpAddress` union. `Ip6Address.fromAny` + // re-maps an IPv4 sender into its IPv4-mapped IPv6 form, which is exactly + // what the old dual-stack `recvfrom` wrote into the `sockaddr.in6` slot. + src_addr.* = net.Ip6Address.fromAny(msg.from); packets_received += 1; - return @intCast(n); + return @intCast(msg.data.len); } /// Format an IPv6 address (16 bytes) into a human-readable string. @@ -647,11 +666,13 @@ fn formatIp6(raw: [16]u8, buf: []u8) usize { } /// Find or add a peer by IPv6 address and port. Returns peer index. -fn findOrAddPeerByAddr(addr: *const std.posix.sockaddr.in6) c_int { - // Format the raw IPv6 address bytes as a string. +fn findOrAddPeerByAddr(addr: *const net.Ip6Address) c_int { + // Format the raw IPv6 address bytes as a string. `Ip6Address.bytes` is the + // same big-endian [16]u8 the old `sockaddr.in6.addr` held, but `.port` is + // already native-endian, so the `bigToNative` conversion is gone. var addr_buf: [46]u8 = undefined; - const host_len = formatIp6(addr.addr, &addr_buf); - const port = std.mem.bigToNative(u16, addr.port); + const host_len = formatIp6(addr.bytes, &addr_buf); + const port = addr.port; if (host_len == 0 or host_len > MAX_HOST_LEN) return -1; @@ -680,38 +701,46 @@ fn umoja_bind_impl(port: u16) c_int { if (socket_bound) return -1; // already bound if (port == 0) return -1; - const fd = std.posix.socket(std.posix.AF.INET6, std.posix.SOCK.DGRAM, 0) catch { - last_net_error = -10; - return -1; - }; - - // Allow address reuse. - const one: c_int = 1; - std.posix.setsockopt(fd, std.posix.SOL.SOCKET, std.posix.SO.REUSEADDR, std.mem.asBytes(&one)) catch {}; - - // Bind to [::]:port. - var addr: std.posix.sockaddr.in6 = std.mem.zeroes(std.posix.sockaddr.in6); - addr.family = std.posix.AF.INET6; - addr.port = std.mem.nativeToBig(u16, port); - - // SAFETY: @ptrCast from sockaddr.in6* to sockaddr* — standard POSIX bind(2) pattern. - std.posix.bind(fd, @ptrCast(&addr), @sizeOf(std.posix.sockaddr.in6)) catch { - std.posix.close(fd); - last_net_error = -11; - return -1; + // Bind to [::]:port. `IpAddress.bind` fuses the old socket(2)+bind(2) + // pair into one call, so the two failures can no longer be reported + // separately by construction — the error set is split back out below to + // keep the -10 (socket creation) / -11 (bind) diagnostic codes meaningful. + // `ip6_only = false` is the default and preserves the dual-stack + // behaviour the old bare AF_INET6 socket had. + // + // The best-effort `SO_REUSEADDR` that used to sit between socket(2) and + // bind(2) is dropped: `BindOptions` exposes no such knob and the option is + // only meaningful *before* bind, so there is no longer a window in which to + // set it. For a unicast UDP socket its practical effect was nil (there is + // no TIME_WAIT for UDP, and sharing a port needs SO_REUSEPORT). + const bind_addr: net.IpAddress = .{ .ip6 = .unspecified(port) }; + const sock = bind_addr.bind(shim.io(), .{ .mode = .dgram, .ip6_only = false }) catch |err| switch (err) { + error.AddressInUse, + error.AddressUnavailable, + error.AddressFamilyUnsupported, + => { + last_net_error = -11; + return -1; + }, + else => { + last_net_error = -10; + return -1; + }, }; - // Set non-blocking via ioctl FIONBIO. + // Set non-blocking via ioctl FIONBIO. Receives already pass MSG_DONTWAIT + // (see RECV_NONBLOCKING), but this keeps sends non-blocking too, exactly + // as before. const fionbio: c_int = 1; const FIONBIO: u32 = 0x5421; - const ioctl_result = std.posix.system.ioctl(fd, FIONBIO, @intFromPtr(&fionbio)); + const ioctl_result = std.posix.system.ioctl(sock.handle, FIONBIO, @intFromPtr(&fionbio)); if (ioctl_result != 0) { - std.posix.close(fd); + sock.close(shim.io()); last_net_error = -12; return -1; } - udp_fd = fd; + udp_socket = sock; local_port = port; socket_bound = true; return 0; @@ -730,8 +759,10 @@ pub export fn umoja_bind(port: u16) c_int { fn umoja_unbind_impl() c_int { if (!socket_bound) return -1; - std.posix.close(udp_fd); - udp_fd = -1; + // `std.posix.close` was trimmed; `Socket.close` is the replacement and + // takes the same `std.Io` as every other call site here. + udp_socket.?.close(shim.io()); + udp_socket = null; local_port = 0; socket_bound = false; packets_sent = 0; @@ -812,7 +843,7 @@ pub export fn umoja_send_handshake(peer_idx: usize) c_int { const result = sendToPeer(peer_idx, buf[0..pkt_len]); if (result == 0) { peers[peer_idx].handshake_state = .pending; - peers[peer_idx].last_seen = std.time.timestamp(); + peers[peer_idx].last_seen = shim.timestamp(); } return result; } @@ -848,13 +879,13 @@ fn umoja_recv_and_process_impl() c_int { if (!socket_bound) return -1; var buf: [MAX_PACKET_LEN]u8 = undefined; - var src_addr: std.posix.sockaddr.in6 = std.mem.zeroes(std.posix.sockaddr.in6); + var src_addr: net.Ip6Address = .unspecified(0); const n = recvPacket(&buf, &src_addr); if (n <= 0) return n; // 0 = no data, -1 = error // Rate limiter gate: drop packets if bucket exhausted. - const now = std.time.timestamp(); + const now = shim.timestamp(); if (!inbound_rate_limiter.tryAcquire(now)) { packets_rate_limited += 1; return 0; // Silently drop — return 0 (no data) to caller. @@ -871,7 +902,7 @@ fn umoja_recv_and_process_impl() c_int { if (pkt.node_id.len > 0 and pkt.node_id.len <= MAX_NODE_ID_LEN) { peers[pidx].node_id_len = copyBounded(&peers[pidx].node_id, pkt.node_id.ptr, pkt.node_id.len); } - peers[pidx].last_seen = std.time.timestamp(); + peers[pidx].last_seen = shim.timestamp(); switch (pkt.tag) { PKT_DISCOVER => { @@ -962,15 +993,14 @@ pub export fn umoja_discover_udp( // Parse target address. const target_slice = target_ptr[0..target_len]; - const parsed_ip6 = std.net.Ip6Address.parse(target_slice, target_port) catch { + const parsed_ip6 = net.Ip6Address.parse(target_slice, target_port) catch { last_net_error = -20; return -1; }; + const dest: net.IpAddress = .{ .ip6 = parsed_ip6 }; // Send discovery packet. - // SAFETY: @ptrCast from sockaddr.in6* to sockaddr* — standard POSIX sendto(2) pattern. - const dest: *const std.posix.sockaddr = @ptrCast(&parsed_ip6.sa); - _ = std.posix.sendto(udp_fd, buf[0..pkt_len], 0, dest, @sizeOf(std.posix.sockaddr.in6)) catch { + udp_socket.?.send(shim.io(), &dest, buf[0..pkt_len]) catch { last_net_error = -21; return -1; }; @@ -1081,7 +1111,7 @@ pub export fn boj_federation_heartbeat(index: usize) c_int { defer mutex.unlock(); if (!validSlot(index)) return -1; - nodes[index].last_heartbeat = std.time.timestamp(); + nodes[index].last_heartbeat = shim.timestamp(); nodes[index].status = .alive; return 0; } @@ -1233,7 +1263,7 @@ fn umoja_add_peer_impl( peers[slot] = PeerNode{}; peers[slot].host_len = copyBounded(&peers[slot].host, host_ptr, host_len); peers[slot].port = port; - peers[slot].last_seen = std.time.timestamp(); + peers[slot].last_seen = shim.timestamp(); peers[slot].active = true; peer_count += 1; @@ -1291,7 +1321,7 @@ pub export fn umoja_gossip_round() c_int { defer mutex.unlock(); const maybe_idx = pickRandomPeer(); if (maybe_idx) |idx| { - peers[idx].last_seen = std.time.timestamp(); + peers[idx].last_seen = shim.timestamp(); gossip_round_count += 1; return @intCast(idx); } @@ -1313,7 +1343,7 @@ pub export fn umoja_receive_digest( @memcpy(&peers[peer_idx].catalogue_digest, digest_ptr[0..DIGEST_LEN]); peers[peer_idx].has_digest = true; - peers[peer_idx].last_seen = std.time.timestamp(); + peers[peer_idx].last_seen = shim.timestamp(); return 0; } @@ -1430,7 +1460,7 @@ pub export fn umoja_handshake(peer_idx: usize) c_int { if (!validPeer(peer_idx)) return -1; peers[peer_idx].handshake_state = .pending; - peers[peer_idx].last_seen = std.time.timestamp(); + peers[peer_idx].last_seen = shim.timestamp(); return 0; } @@ -1444,7 +1474,7 @@ pub export fn umoja_handshake_exchanged(peer_idx: usize) c_int { if (peers[peer_idx].handshake_state != .pending) return -1; peers[peer_idx].handshake_state = .exchanged; - peers[peer_idx].last_seen = std.time.timestamp(); + peers[peer_idx].last_seen = shim.timestamp(); return 0; } @@ -1491,7 +1521,7 @@ pub export fn umoja_heartbeat(peer_idx: usize) c_int { defer mutex.unlock(); if (!validPeer(peer_idx)) return -1; - peers[peer_idx].last_seen = std.time.timestamp(); + peers[peer_idx].last_seen = shim.timestamp(); return 0; } @@ -1631,12 +1661,12 @@ const QUIC_OVERHEAD: usize = 1 + NONCE_LEN + AEAD_TAG_LEN; /// a security regression disguised as graceful degradation. fn generateQuicKeypair() bool { // Use OS random for the secret key. - std.crypto.random.bytes(&quic_local_secret); + shim.randomBytes(&quic_local_secret); // Derive public key. Retry once on degenerate key (probability ~2^-128). quic_local_public = X25519.recoverPublicKey(quic_local_secret) catch { // Degenerate key — regenerate with fresh randomness. - std.crypto.random.bytes(&quic_local_secret); + shim.randomBytes(&quic_local_secret); quic_local_public = X25519.recoverPublicKey(quic_local_secret) catch { quic_keypair_valid = false; return false; // Two consecutive degenerate keys — hardware RNG failure. @@ -1747,7 +1777,7 @@ fn sendToPeerQuicAware(peer_idx: usize, buf: []const u8) c_int { if (!validPeer(peer_idx)) return -1; if (peer_idx >= MAX_PEERS) return -1; - const now = std.time.timestamp(); + const now = shim.timestamp(); var cb = &peer_circuit_breakers[peer_idx]; // Circuit breaker gate. @@ -1790,20 +1820,17 @@ fn sendToPeerRaw(peer_idx: usize, buf: []const u8) c_int { if (!validPeer(peer_idx)) return -1; const peer = &peers[peer_idx]; - var addr: std.posix.sockaddr.in6 = std.mem.zeroes(std.posix.sockaddr.in6); - addr.family = std.posix.AF.INET6; - addr.port = std.mem.nativeToBig(u16, peer.port); const host_slice = peer.host[0..peer.host_len]; - const parsed = std.net.Ip6Address.parse(host_slice, peer.port) catch { + const parsed = net.Ip6Address.parse(host_slice, peer.port) catch { last_net_error = -2; return -1; }; - addr = parsed.sa; + const dest: net.IpAddress = .{ .ip6 = parsed }; - // SAFETY: @ptrCast from sockaddr.in6* to sockaddr* — standard POSIX sendto(2) pattern. - const dest: *const std.posix.sockaddr = @ptrCast(&addr); - _ = std.posix.sendto(udp_fd, buf, 0, dest, @sizeOf(std.posix.sockaddr.in6)) catch { + // `Socket.send` takes the destination address directly — no hand-filled + // `sockaddr.in6` and no @ptrCast to `sockaddr`. + udp_socket.?.send(shim.io(), &dest, buf) catch { last_net_error = -3; return -1; }; @@ -1821,7 +1848,7 @@ pub export fn umoja_recv_and_process_quic() c_int { if (!socket_bound) return -1; var buf: [MAX_PACKET_LEN + QUIC_OVERHEAD]u8 = undefined; - var src_addr: std.posix.sockaddr.in6 = std.mem.zeroes(std.posix.sockaddr.in6); + var src_addr: net.Ip6Address = .unspecified(0); const n = recvPacket(@as([]u8, &buf), &src_addr); if (n <= 0) return n; @@ -1859,7 +1886,7 @@ pub export fn umoja_recv_and_process_quic() c_int { /// Process a cleartext (or decrypted) packet. Shared logic for both /// umoja_recv_and_process() and umoja_recv_and_process_quic(). -fn processPacket(raw: []const u8, src_addr: *std.posix.sockaddr.in6) c_int { +fn processPacket(raw: []const u8, src_addr: *net.Ip6Address) c_int { const pkt = parsePacket(raw) orelse return -1; const peer_result = findOrAddPeerByAddr(src_addr); @@ -1869,7 +1896,7 @@ fn processPacket(raw: []const u8, src_addr: *std.posix.sockaddr.in6) c_int { if (pkt.node_id.len > 0 and pkt.node_id.len <= MAX_NODE_ID_LEN) { peers[pidx].node_id_len = copyBounded(&peers[pidx].node_id, pkt.node_id.ptr, pkt.node_id.len); } - peers[pidx].last_seen = std.time.timestamp(); + peers[pidx].last_seen = shim.timestamp(); switch (pkt.tag) { PKT_DISCOVER => { @@ -1917,7 +1944,7 @@ fn processPacket(raw: []const u8, src_addr: *std.posix.sockaddr.in6) c_int { /// Handle an incoming QUIC key exchange packet. /// Format: [0x08][id_len:2][id:N][payload_len:2][public_key:32] -fn handleQuicKeyExchange(raw: []const u8, src_addr: *std.posix.sockaddr.in6) c_int { +fn handleQuicKeyExchange(raw: []const u8, src_addr: *net.Ip6Address) c_int { const pkt = parsePacket(raw) orelse return -1; if (pkt.payload.len != 32) return -1; // X25519 public key is 32 bytes @@ -1928,7 +1955,7 @@ fn handleQuicKeyExchange(raw: []const u8, src_addr: *std.posix.sockaddr.in6) c_i if (pkt.node_id.len > 0 and pkt.node_id.len <= MAX_NODE_ID_LEN) { peers[pidx].node_id_len = copyBounded(&peers[pidx].node_id, pkt.node_id.ptr, pkt.node_id.len); } - peers[pidx].last_seen = std.time.timestamp(); + peers[pidx].last_seen = shim.timestamp(); // Store peer's public key. @memcpy(&quic_sessions[pidx].remote_public, pkt.payload[0..32]); @@ -1936,7 +1963,7 @@ fn handleQuicKeyExchange(raw: []const u8, src_addr: *std.posix.sockaddr.in6) c_i // Generate per-session ephemeral keypair if not yet done. if (!quic_sessions[pidx].established) { - std.crypto.random.bytes(&quic_sessions[pidx].local_secret); + shim.randomBytes(&quic_sessions[pidx].local_secret); quic_sessions[pidx].local_public = X25519.recoverPublicKey(quic_sessions[pidx].local_secret) catch { return -1; }; @@ -1954,7 +1981,7 @@ fn handleQuicKeyExchange(raw: []const u8, src_addr: *std.posix.sockaddr.in6) c_i } /// Handle an incoming QUIC key reply packet. -fn handleQuicKeyReply(raw: []const u8, src_addr: *std.posix.sockaddr.in6) c_int { +fn handleQuicKeyReply(raw: []const u8, src_addr: *net.Ip6Address) c_int { const pkt = parsePacket(raw) orelse return -1; if (pkt.payload.len != 32) return -1; @@ -1965,7 +1992,7 @@ fn handleQuicKeyReply(raw: []const u8, src_addr: *std.posix.sockaddr.in6) c_int if (pkt.node_id.len > 0 and pkt.node_id.len <= MAX_NODE_ID_LEN) { peers[pidx].node_id_len = copyBounded(&peers[pidx].node_id, pkt.node_id.ptr, pkt.node_id.len); } - peers[pidx].last_seen = std.time.timestamp(); + peers[pidx].last_seen = shim.timestamp(); // Store peer's public key and derive shared secret. @memcpy(&quic_sessions[pidx].remote_public, pkt.payload[0..32]); @@ -2038,7 +2065,7 @@ pub export fn umoja_quic_key_exchange(peer_idx: usize) c_int { if (!quic_keypair_valid) return -1; // Generate per-session ephemeral keypair. - std.crypto.random.bytes(&quic_sessions[peer_idx].local_secret); + shim.randomBytes(&quic_sessions[peer_idx].local_secret); quic_sessions[peer_idx].local_public = X25519.recoverPublicKey(quic_sessions[peer_idx].local_secret) catch { return -1; }; @@ -2981,10 +3008,10 @@ test "quic encrypt and decrypt roundtrip" { var session_b = QuicPeerSession{}; // Generate keypairs for both sides. - std.crypto.random.bytes(&session_a.local_secret); + shim.randomBytes(&session_a.local_secret); session_a.local_public = X25519.recoverPublicKey(session_a.local_secret) catch unreachable; - std.crypto.random.bytes(&session_b.local_secret); + shim.randomBytes(&session_b.local_secret); session_b.local_public = X25519.recoverPublicKey(session_b.local_secret) catch unreachable; // Exchange public keys. @@ -3022,10 +3049,10 @@ test "quic decrypt fails with wrong key" { var session_wrong = QuicPeerSession{}; // Generate keypairs. - std.crypto.random.bytes(&session_a.local_secret); + shim.randomBytes(&session_a.local_secret); session_a.local_public = X25519.recoverPublicKey(session_a.local_secret) catch unreachable; - std.crypto.random.bytes(&session_wrong.local_secret); + shim.randomBytes(&session_wrong.local_secret); session_wrong.local_public = X25519.recoverPublicKey(session_wrong.local_secret) catch unreachable; // A encrypts with its own shared secret (self-loop for test). @@ -3065,7 +3092,7 @@ test "quic session established tracking" { try std.testing.expectEqual(@as(c_int, 0), umoja_quic_session_established(0)); // Manually establish. - std.crypto.random.bytes(&quic_sessions[0].local_secret); + shim.randomBytes(&quic_sessions[0].local_secret); quic_sessions[0].local_public = X25519.recoverPublicKey(quic_sessions[0].local_secret) catch unreachable; quic_sessions[0].remote_public = quic_sessions[0].local_public; quic_sessions[0].has_remote_key = true; @@ -3078,7 +3105,7 @@ test "quic nonce counter increments" { _ = boj_federation_init(); var session = QuicPeerSession{}; - std.crypto.random.bytes(&session.local_secret); + shim.randomBytes(&session.local_secret); session.local_public = X25519.recoverPublicKey(session.local_secret) catch unreachable; session.remote_public = session.local_public; session.has_remote_key = true; @@ -3101,7 +3128,7 @@ test "quic nonce produces unique ciphertexts" { _ = boj_federation_init(); var session = QuicPeerSession{}; - std.crypto.random.bytes(&session.local_secret); + shim.randomBytes(&session.local_secret); session.local_public = X25519.recoverPublicKey(session.local_secret) catch unreachable; session.remote_public = session.local_public; session.has_remote_key = true; @@ -3176,3 +3203,5 @@ test "quic federation init resets sessions" { try std.testing.expect(!quic_keypair_valid); try std.testing.expect(!quic_sessions[0].established); } + +const shim = @import("cartridge_shim"); diff --git a/ffi/zig/src/guardian.zig b/ffi/zig/src/guardian.zig index 0624cc1a..876c1360 100644 --- a/ffi/zig/src/guardian.zig +++ b/ffi/zig/src/guardian.zig @@ -23,11 +23,11 @@ const std = @import("std"); // `std.atomic.Mutex` was removed from the standard library; its replacement is -// `std.Thread.Mutex`, whose lock/unlock surface is identical to the hand-rolled +// `shim.Mutex`, whose lock/unlock surface is identical to the hand-rolled // wrapper this replaces. The wrapper also busy-waited via `spinLoopHint`, burning -// a core under contention; `std.Thread.Mutex` parks the thread instead. 81 other +// a core under contention; `shim.Mutex` parks the thread instead. 81 other // files in this repo already use this form. -const Mutex = std.Thread.Mutex; +const Mutex = shim.Mutex; // ═══════════════════════════════════════════════════════════════════════ // Constants @@ -205,7 +205,7 @@ fn copyBounded(dst: []u8, src_ptr: [*]const u8, src_len: usize) usize { /// Append a log entry (ring buffer, overwrites oldest). fn appendLog(severity: Severity, action: ActionType, msg_ptr: [*]const u8, msg_len: usize, cart_idx: i32) void { var entry = &log_entries[log_write_pos]; - entry.timestamp = std.time.timestamp(); + entry.timestamp = shim.timestamp(); entry.severity = severity; entry.action = action; entry.message_len = copyBounded(&entry.message, msg_ptr, msg_len); @@ -308,7 +308,7 @@ pub export fn boj_guardian_track( profiles[slot] = CartridgeProfile{}; profiles[slot].name_len = copyBounded(&profiles[slot].name, name_ptr, name_len); profiles[slot].pid = pid; - profiles[slot].mounted_at = std.time.timestamp(); + profiles[slot].mounted_at = shim.timestamp(); profiles[slot].active = true; profile_count += 1; @@ -353,7 +353,7 @@ pub export fn boj_guardian_update_resources( profiles[index].cpu_percent = cpu_percent; profiles[index].open_fds = open_fds; profiles[index].child_procs = child_procs; - profiles[index].uptime_seconds = std.time.timestamp() - profiles[index].mounted_at; + profiles[index].uptime_seconds = shim.timestamp() - profiles[index].mounted_at; // Check per-cartridge thresholds. if (memory_bytes > MAX_CARTRIDGE_MEMORY) { @@ -388,7 +388,7 @@ pub export fn boj_guardian_update_system( system_snapshot.total_processes = total_processes; system_snapshot.boj_processes = boj_processes; system_snapshot.load_average_100 = load_average_100; - system_snapshot.timestamp = std.time.timestamp(); + system_snapshot.timestamp = shim.timestamp(); // Compute overall severity. const cpu_sev = assessCpu(cpu_usage_percent); @@ -434,7 +434,7 @@ pub export fn boj_guardian_health_ok(index: usize) c_int { profiles[index].health_pings += 1; profiles[index].failed_pings = 0; - profiles[index].last_health_check = std.time.timestamp(); + profiles[index].last_health_check = shim.timestamp(); // If circuit was half-open, close it (recovery confirmed). if (profiles[index].circuit_state == .half_open) { @@ -454,7 +454,7 @@ pub export fn boj_guardian_health_fail(index: usize) c_int { defer mutex.unlock(); if (!initialised or index >= MAX_TRACKED or !profiles[index].active) return -1; - const now = std.time.timestamp(); + const now = shim.timestamp(); profiles[index].failed_pings += 1; profiles[index].last_health_check = now; @@ -489,7 +489,7 @@ pub export fn boj_guardian_check_recovery(index: usize) c_int { if (!initialised or index >= MAX_TRACKED or !profiles[index].active) return -1; if (profiles[index].circuit_state == .open) { - const now = std.time.timestamp(); + const now = shim.timestamp(); if (now - profiles[index].circuit_last_tripped >= profiles[index].circuit_cooldown) { profiles[index].circuit_state = .half_open; const msg = "Circuit breaker half-open (testing recovery)"; @@ -831,7 +831,7 @@ test "circuit breaker trip and recovery" { _ = boj_guardian_set_circuit_cooldown(idx, 1); // Simulate time passing by setting last_tripped far in the past. - profiles[idx].circuit_last_tripped = std.time.timestamp() - 100; + profiles[idx].circuit_last_tripped = shim.timestamp() - 100; const recovered = boj_guardian_check_recovery(idx); try std.testing.expectEqual(@as(c_int, @intFromEnum(CircuitState.half_open)), recovered); @@ -856,7 +856,7 @@ test "circuit breaker half-open probe failure re-opens" { try std.testing.expectEqual(@as(c_int, @intFromEnum(CircuitState.open)), boj_guardian_circuit_state(idx)); // Force half-open. - profiles[idx].circuit_last_tripped = std.time.timestamp() - 100; + profiles[idx].circuit_last_tripped = shim.timestamp() - 100; _ = boj_guardian_set_circuit_cooldown(idx, 1); _ = boj_guardian_check_recovery(idx); try std.testing.expectEqual(@as(c_int, @intFromEnum(CircuitState.half_open)), boj_guardian_circuit_state(idx)); @@ -961,3 +961,5 @@ test "invalid operations return errors" { const empty = ""; try std.testing.expectEqual(@as(c_int, -1), boj_guardian_track(empty.ptr, 0, 1)); } + +const shim = @import("cartridge_shim"); diff --git a/ffi/zig/src/loader.zig b/ffi/zig/src/loader.zig index 41bd1528..3d00aea3 100644 --- a/ffi/zig/src/loader.zig +++ b/ffi/zig/src/loader.zig @@ -14,11 +14,11 @@ const std = @import("std"); // `std.atomic.Mutex` was removed from the standard library; its replacement is -// `std.Thread.Mutex`, whose lock/unlock surface is identical to the hand-rolled +// `shim.Mutex`, whose lock/unlock surface is identical to the hand-rolled // wrapper this replaces. The wrapper also busy-waited via `spinLoopHint`, burning -// a core under contention; `std.Thread.Mutex` parks the thread instead. 81 other +// a core under contention; `shim.Mutex` parks the thread instead. 81 other // files in this repo already use this form. -const Mutex = std.Thread.Mutex; +const Mutex = shim.Mutex; const crypto = std.crypto; const fs = std.fs; @@ -60,23 +60,28 @@ pub const LoadError = error{ /// most buf.len bytes so `buf[0..n]` is always in-bounds. /// - File handle is closed via `defer` even on read errors. /// - Empty path is rejected before I/O to avoid platform-specific behaviour. -pub fn hashFile(path: []const u8) (LoadError || fs.File.OpenError || fs.File.ReadError)![HASH_LEN]u8 { +pub fn hashFile(path: []const u8) (LoadError || std.Io.File.OpenError || std.Io.File.ReadStreamingError)![HASH_LEN]u8 { // SAFETY: reject empty paths before any I/O if (path.len == 0) return LoadError.CannotReadBinary; - const file = fs.cwd().openFile(path, .{}) catch |err| switch (err) { + const file = std.Io.Dir.cwd().openFile(shim.io(), path, .{}) catch |err| switch (err) { error.FileNotFound, error.AccessDenied => return LoadError.CannotReadBinary, else => return err, }; - defer file.close(); + defer file.close(shim.io()); var hasher = crypto.hash.sha2.Sha256.init(.{}); var buf: [8192]u8 = undefined; while (true) { - const n = try file.read(&buf); + // 0.16 contract: end-of-stream is error.EndOfStream; a 0 return + // just means "no bytes this call" and is not terminal. + const n = file.readStreaming(shim.io(), &.{&buf}) catch |err| switch (err) { + error.EndOfStream => break, + else => return err, + }; if (n == 0) break; - // SAFETY: n <= buf.len guaranteed by file.read contract + // SAFETY: n <= buf.len guaranteed by the readStreaming contract hasher.update(buf[0..n]); } @@ -280,12 +285,12 @@ pub fn validateWasmModule(path: []const u8) !u64 { // SAFETY: reject empty paths before any I/O (bounds check) if (path.len == 0) return 0; - const file = fs.cwd().openFile(path, .{}) catch return 0; - defer file.close(); + const file = std.Io.Dir.cwd().openFile(shim.io(), path, .{}) catch return 0; + defer file.close(shim.io()); // Read the 8-byte WASM header. var header: [8]u8 = undefined; - const n = file.read(&header) catch return 0; + const n = file.readStreaming(shim.io(), &.{&header}) catch return 0; // SAFETY: need exactly 8 bytes for magic + version if (n < 8) return 0; @@ -296,7 +301,7 @@ pub fn validateWasmModule(path: []const u8) !u64 { if (!std.mem.eql(u8, header[4..8], &WASM_VERSION_1)) return 0; // Get file size. - const stat = file.stat() catch return 0; + const stat = file.stat(shim.io()) catch return 0; // SAFETY: a valid WASM module must be at least 8 bytes (header) if (stat.size < 8) return 0; return stat.size; @@ -468,13 +473,14 @@ test "hashFile on known content" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const file = try tmp_dir.dir.createFile("test_hash.bin", .{}); - try file.writeAll("abc"); - file.close(); + const file = try tmp_dir.dir.createFile(shim.io(), "test_hash.bin", .{}); + try file.writeStreamingAll(shim.io(), "abc"); + file.close(shim.io()); // Get the full path var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("test_hash.bin", &path_buf); + const path_len = try tmp_dir.dir.realPathFile(shim.io(), "test_hash.bin", &path_buf); + const path = path_buf[0..path_len]; const digest = try hashFile(path); const hex = hashToHex(digest); @@ -490,12 +496,13 @@ test "verifyHash returns true for matching hash" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const file = try tmp_dir.dir.createFile("verify_match.bin", .{}); - try file.writeAll("abc"); - file.close(); + const file = try tmp_dir.dir.createFile(shim.io(), "verify_match.bin", .{}); + try file.writeStreamingAll(shim.io(), "abc"); + file.close(shim.io()); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("verify_match.bin", &path_buf); + const path_len = try tmp_dir.dir.realPathFile(shim.io(), "verify_match.bin", &path_buf); + const path = path_buf[0..path_len]; const result = try verifyHash(path, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"); try std.testing.expect(result); @@ -505,12 +512,13 @@ test "verifyHash returns false for wrong hash" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const file = try tmp_dir.dir.createFile("verify_mismatch.bin", .{}); - try file.writeAll("abc"); - file.close(); + const file = try tmp_dir.dir.createFile(shim.io(), "verify_mismatch.bin", .{}); + try file.writeStreamingAll(shim.io(), "abc"); + file.close(shim.io()); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("verify_mismatch.bin", &path_buf); + const path_len = try tmp_dir.dir.realPathFile(shim.io(), "verify_mismatch.bin", &path_buf); + const path = path_buf[0..path_len]; const result = try verifyHash(path, "0000000000000000000000000000000000000000000000000000000000000000"); try std.testing.expect(!result); @@ -520,12 +528,13 @@ test "verifyHash returns false for wrong-length hash" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const file = try tmp_dir.dir.createFile("verify_badlen.bin", .{}); - try file.writeAll("abc"); - file.close(); + const file = try tmp_dir.dir.createFile(shim.io(), "verify_badlen.bin", .{}); + try file.writeStreamingAll(shim.io(), "abc"); + file.close(shim.io()); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("verify_badlen.bin", &path_buf); + const path_len = try tmp_dir.dir.realPathFile(shim.io(), "verify_badlen.bin", &path_buf); + const path = path_buf[0..path_len]; const result = try verifyHash(path, "tooshort"); try std.testing.expect(!result); @@ -545,14 +554,15 @@ test "validateWasmModule accepts valid WASM" { defer tmp_dir.cleanup(); // Create a minimal valid WASM file (8 bytes header + 1 byte body). - const file = try tmp_dir.dir.createFile("valid.wasm", .{}); - try file.writeAll(&WASM_MAGIC); - try file.writeAll(&WASM_VERSION_1); - try file.writeAll(&[_]u8{0x00}); // empty module body - file.close(); + const file = try tmp_dir.dir.createFile(shim.io(), "valid.wasm", .{}); + try file.writeStreamingAll(shim.io(), &WASM_MAGIC); + try file.writeStreamingAll(shim.io(), &WASM_VERSION_1); + try file.writeStreamingAll(shim.io(), &[_]u8{0x00}); // empty module body + file.close(shim.io()); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("valid.wasm", &path_buf); + const path_len = try tmp_dir.dir.realPathFile(shim.io(), "valid.wasm", &path_buf); + const path = path_buf[0..path_len]; const size = try validateWasmModule(path); try std.testing.expect(size == 9); // 8 header + 1 body @@ -562,12 +572,13 @@ test "validateWasmModule rejects non-WASM" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const file = try tmp_dir.dir.createFile("not_wasm.bin", .{}); - try file.writeAll("not a wasm file"); - file.close(); + const file = try tmp_dir.dir.createFile(shim.io(), "not_wasm.bin", .{}); + try file.writeStreamingAll(shim.io(), "not a wasm file"); + file.close(shim.io()); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("not_wasm.bin", &path_buf); + const path_len = try tmp_dir.dir.realPathFile(shim.io(), "not_wasm.bin", &path_buf); + const path = path_buf[0..path_len]; const size = try validateWasmModule(path); try std.testing.expectEqual(@as(u64, 0), size); @@ -577,12 +588,13 @@ test "validateWasmModule rejects too-short file" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const file = try tmp_dir.dir.createFile("short.wasm", .{}); - try file.writeAll(&[_]u8{ 0x00, 0x61 }); // Only 2 bytes - file.close(); + const file = try tmp_dir.dir.createFile(shim.io(), "short.wasm", .{}); + try file.writeStreamingAll(shim.io(), &[_]u8{ 0x00, 0x61 }); // Only 2 bytes + file.close(shim.io()); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("short.wasm", &path_buf); + const path_len = try tmp_dir.dir.realPathFile(shim.io(), "short.wasm", &path_buf); + const path = path_buf[0..path_len]; const size = try validateWasmModule(path); try std.testing.expectEqual(@as(u64, 0), size); @@ -595,14 +607,15 @@ test "WASM register and unregister" { defer tmp_dir.cleanup(); // Create a valid WASM file. - const file = try tmp_dir.dir.createFile("cart.wasm", .{}); - try file.writeAll(&WASM_MAGIC); - try file.writeAll(&WASM_VERSION_1); - try file.writeAll("cartridge body data"); - file.close(); + const file = try tmp_dir.dir.createFile(shim.io(), "cart.wasm", .{}); + try file.writeStreamingAll(shim.io(), &WASM_MAGIC); + try file.writeStreamingAll(shim.io(), &WASM_VERSION_1); + try file.writeStreamingAll(shim.io(), "cartridge body data"); + file.close(shim.io()); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("cart.wasm", &path_buf); + const path_len = try tmp_dir.dir.realPathFile(shim.io(), "cart.wasm", &path_buf); + const path = path_buf[0..path_len]; // Register. const slot = boj_wasm_register(path.ptr, path.len, 0); @@ -631,12 +644,13 @@ test "WASM register rejects invalid module" { var tmp_dir = std.testing.tmpDir(.{}); defer tmp_dir.cleanup(); - const file = try tmp_dir.dir.createFile("bad.wasm", .{}); - try file.writeAll("this is not wasm"); - file.close(); + const file = try tmp_dir.dir.createFile(shim.io(), "bad.wasm", .{}); + try file.writeStreamingAll(shim.io(), "this is not wasm"); + file.close(shim.io()); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("bad.wasm", &path_buf); + const path_len = try tmp_dir.dir.realPathFile(shim.io(), "bad.wasm", &path_buf); + const path = path_buf[0..path_len]; const slot = boj_wasm_register(path.ptr, path.len, 0); try std.testing.expectEqual(@as(c_int, -1), slot); @@ -648,21 +662,23 @@ test "WASM validate export" { defer tmp_dir.cleanup(); // Valid WASM. - const good = try tmp_dir.dir.createFile("good.wasm", .{}); - try good.writeAll(&WASM_MAGIC); - try good.writeAll(&WASM_VERSION_1); - try good.writeAll("body"); - good.close(); + const good = try tmp_dir.dir.createFile(shim.io(), "good.wasm", .{}); + try good.writeStreamingAll(shim.io(), &WASM_MAGIC); + try good.writeStreamingAll(shim.io(), &WASM_VERSION_1); + try good.writeStreamingAll(shim.io(), "body"); + good.close(shim.io()); // Invalid file. - const bad = try tmp_dir.dir.createFile("bad.txt", .{}); - try bad.writeAll("hello"); - bad.close(); + const bad = try tmp_dir.dir.createFile(shim.io(), "bad.txt", .{}); + try bad.writeStreamingAll(shim.io(), "hello"); + bad.close(shim.io()); var good_path: [std.fs.max_path_bytes]u8 = undefined; - const gp = try tmp_dir.dir.realpath("good.wasm", &good_path); + const gp_len = try tmp_dir.dir.realPathFile(shim.io(), "good.wasm", &good_path); + const gp = good_path[0..gp_len]; var bad_path: [std.fs.max_path_bytes]u8 = undefined; - const bp = try tmp_dir.dir.realpath("bad.txt", &bad_path); + const bp_len = try tmp_dir.dir.realPathFile(shim.io(), "bad.txt", &bad_path); + const bp = bad_path[0..bp_len]; try std.testing.expectEqual(@as(c_int, 1), boj_wasm_validate(gp.ptr, gp.len)); try std.testing.expectEqual(@as(c_int, 0), boj_wasm_validate(bp.ptr, bp.len)); @@ -700,12 +716,13 @@ test "loader verify checks file hash" { defer tmp_dir.cleanup(); // Create a file with known content. - const f = try tmp_dir.dir.createFile("verify-test.bin", .{}); - try f.writeAll("hello world"); - f.close(); + const f = try tmp_dir.dir.createFile(shim.io(), "verify-test.bin", .{}); + try f.writeStreamingAll(shim.io(), "hello world"); + f.close(shim.io()); var path_buf: [std.fs.max_path_bytes]u8 = undefined; - const path = try tmp_dir.dir.realpath("verify-test.bin", &path_buf); + const path_len = try tmp_dir.dir.realPathFile(shim.io(), "verify-test.bin", &path_buf); + const path = path_buf[0..path_len]; // Compute expected SHA-256 of "hello world". var hasher = std.crypto.hash.sha2.Sha256.init(.{}); @@ -729,3 +746,5 @@ test "loader verify checks file hash" { const short = "abc"; try std.testing.expectEqual(@as(c_int, -1), boj_loader_verify(path.ptr, path.len, short.ptr, short.len)); } + +const shim = @import("cartridge_shim"); diff --git a/ffi/zig/src/sdp.zig b/ffi/zig/src/sdp.zig index fbcde370..d5f7556f 100644 --- a/ffi/zig/src/sdp.zig +++ b/ffi/zig/src/sdp.zig @@ -17,11 +17,11 @@ const std = @import("std"); // `std.atomic.Mutex` was removed from the standard library; its replacement is -// `std.Thread.Mutex`, whose lock/unlock surface is identical to the hand-rolled +// `shim.Mutex`, whose lock/unlock surface is identical to the hand-rolled // wrapper this replaces. The wrapper also busy-waited via `spinLoopHint`, burning -// a core under contention; `std.Thread.Mutex` parks the thread instead. 81 other +// a core under contention; `shim.Mutex` parks the thread instead. 81 other // files in this repo already use this form. -const Mutex = std.Thread.Mutex; +const Mutex = shim.Mutex; // ═══════════════════════════════════════════════════════════════════════ // Constants @@ -114,7 +114,7 @@ fn findPeer(id_ptr: [*]const u8, id_len: usize) ?usize { fn isBanned(id_ptr: [*]const u8, id_len: usize) bool { const actual = @min(id_len, NODE_ID_LEN); - const now = std.time.timestamp(); + const now = shim.timestamp(); for (banned[0..ban_count]) |b| { if (b.id_len == actual and std.mem.eql(u8, b.node_id[0..actual], id_ptr[0..actual])) @@ -138,7 +138,7 @@ fn banPeer(id_ptr: [*]const u8, id_len: usize, reason: u8) void { var b = &banned[ban_count]; @memcpy(b.node_id[0..actual], id_ptr[0..actual]); b.id_len = actual; - b.banned_at = std.time.timestamp(); + b.banned_at = shim.timestamp(); b.reason = reason; ban_count += 1; } @@ -326,3 +326,5 @@ test "sdp deinit resets" { try std.testing.expectEqual(@as(usize, 0), boj_sdp_peer_count()); try std.testing.expect(!sdp_enabled); } + +const shim = @import("cartridge_shim"); diff --git a/ffi/zig/src/sla.zig b/ffi/zig/src/sla.zig index a91d66dd..ead13a24 100644 --- a/ffi/zig/src/sla.zig +++ b/ffi/zig/src/sla.zig @@ -16,11 +16,11 @@ const std = @import("std"); // `std.atomic.Mutex` was removed from the standard library; its replacement is -// `std.Thread.Mutex`, whose lock/unlock surface is identical to the hand-rolled +// `shim.Mutex`, whose lock/unlock surface is identical to the hand-rolled // wrapper this replaces. The wrapper also busy-waited via `spinLoopHint`, burning -// a core under contention; `std.Thread.Mutex` parks the thread instead. 81 other +// a core under contention; `shim.Mutex` parks the thread instead. 81 other // files in this repo already use this form. -const Mutex = std.Thread.Mutex; +const Mutex = shim.Mutex; // ═══════════════════════════════════════════════════════════════════════ // Constants @@ -92,7 +92,7 @@ var mutex: Mutex = .{}; fn init() void { sla_count = 0; system_sla = .{}; - system_sla.start_time = std.time.timestamp(); + system_sla.start_time = shim.timestamp(); cartridge_slas = [_]CartridgeSla{.{}} ** MAX_CARTRIDGES; initialised = true; } @@ -117,7 +117,7 @@ fn registerCartridge(name_ptr: [*]const u8, name_len: usize, tier: SlaTier) i32 sla.name_len = actual; sla.tier = tier; sla.active = true; - sla.mounted_at = std.time.timestamp(); + sla.mounted_at = shim.timestamp(); sla_count += 1; system_sla.cartridges_tracked = sla_count; return @as(i32, @intCast(sla_count - 1)); @@ -424,3 +424,5 @@ test "sla deinit resets state" { try std.testing.expectEqual(@as(usize, 0), boj_sla_cartridge_count()); try std.testing.expectEqual(@as(u64, 0), boj_sla_total_requests()); } + +const shim = @import("cartridge_shim"); diff --git a/ffi/zig/src/verisimdb.zig b/ffi/zig/src/verisimdb.zig index 53935135..80527f94 100644 --- a/ffi/zig/src/verisimdb.zig +++ b/ffi/zig/src/verisimdb.zig @@ -17,11 +17,11 @@ const std = @import("std"); // `std.atomic.Mutex` was removed from the standard library; its replacement is -// `std.Thread.Mutex`, whose lock/unlock surface is identical to the hand-rolled +// `shim.Mutex`, whose lock/unlock surface is identical to the hand-rolled // wrapper this replaces. The wrapper also busy-waited via `spinLoopHint`, burning -// a core under contention; `std.Thread.Mutex` parks the thread instead. 81 other +// a core under contention; `shim.Mutex` parks the thread instead. 81 other // files in this repo already use this form. -const Mutex = std.Thread.Mutex; +const Mutex = shim.Mutex; const Allocator = std.mem.Allocator; // ═══════════════════════════════════════════════════════════════════════ @@ -141,7 +141,7 @@ fn resolveBaseUrl() [MAX_ENDPOINT_LEN]u8 { return endpoint; } // Try the environment variable (read outside of mutex — no global state mutation). - const env_val = std.posix.getenv("VERISIMDB_URL"); + const env_val = shim.getenv("VERISIMDB_URL"); if (env_val) |url| { var buf: [MAX_ENDPOINT_LEN]u8 = [_]u8{0} ** MAX_ENDPOINT_LEN; const len = @min(url.len, MAX_ENDPOINT_LEN); @@ -237,17 +237,20 @@ const HttpResult = struct { /// Execute a curl command and return success/failure + stdout body. /// Caller must NOT hold the mutex (this blocks on a subprocess). fn curlExec(argv: []const []const u8) HttpResult { - const result = std.process.Child.run(.{ - .allocator = std.heap.page_allocator, + const result = std.process.run(std.heap.page_allocator, shim.io(), .{ .argv = argv, - .max_output_bytes = MAX_HTTP_RESPONSE, + .stdout_limit = .limited(MAX_HTTP_RESPONSE), + .stderr_limit = .limited(MAX_HTTP_RESPONSE), }) catch { return HttpResult{ .ok = false, .body = [_]u8{0} ** MAX_HTTP_RESPONSE, .body_len = 0 }; }; defer std.heap.page_allocator.free(result.stdout); defer std.heap.page_allocator.free(result.stderr); - const success = (result.term == .Exited and result.term.Exited == 0); + const success = switch (result.term) { + .exited => |code| code == 0, + else => false, + }; var body: [MAX_HTTP_RESPONSE]u8 = [_]u8{0} ** MAX_HTTP_RESPONSE; const copy_len = @min(result.stdout.len, MAX_HTTP_RESPONSE); @@ -405,7 +408,7 @@ pub export fn verisimdb_store_put( if (findEntry(key_ptr, key_len)) |idx| { entries[idx].value = [_]u8{0} ** MAX_VALUE_LEN; entries[idx].value_len = copyBounded(&entries[idx].value, value_ptr, value_len); - entries[idx].updated_at = std.time.timestamp(); + entries[idx].updated_at = shim.timestamp(); writes += 1; } else { // Find a free slot. @@ -417,7 +420,7 @@ pub export fn verisimdb_store_put( entries[i].active = true; entries[i].key_len = copyBounded(&entries[i].key, key_ptr, key_len); entries[i].value_len = copyBounded(&entries[i].value, value_ptr, value_len); - entries[i].updated_at = std.time.timestamp(); + entries[i].updated_at = shim.timestamp(); entry_count += 1; writes += 1; found_slot = true; @@ -484,7 +487,7 @@ pub export fn verisimdb_store_get( entries[i].active = true; entries[i].key_len = copyBounded(&entries[i].key, key_ptr, key_len); entries[i].value_len = copyBounded(&entries[i].value, val.ptr, val.len); - entries[i].updated_at = std.time.timestamp(); + entries[i].updated_at = shim.timestamp(); entry_count += 1; break; } @@ -812,3 +815,5 @@ test "resolveBaseUrl uses endpoint when set" { try std.testing.expectEqualSlices(u8, "http://custom:9090", resolved[0..ep.len]); verisimdb_store_deinit(); } + +const shim = @import("cartridge_shim"); diff --git a/tests/aspect_security_test.zig b/tests/aspect_security_test.zig index cf60641a..98d79448 100644 --- a/tests/aspect_security_test.zig +++ b/tests/aspect_security_test.zig @@ -146,14 +146,14 @@ test "ASPECT: valid JSON parses without error" { } test "ASPECT: deeply nested JSON (depth 50) parses without crash" { - // Build a 50-level nested JSON string. + // Build a 50-level nested JSON string. (std.io.fixedBufferStream was + // removed in Zig 0.16; std.Io.Writer.fixed is the replacement.) var buf: [4096]u8 = undefined; - var fbs = std.io.fixedBufferStream(&buf); - const w = fbs.writer(); + var w = std.Io.Writer.fixed(&buf); for (0..50) |_| try w.writeAll("{\"n\":"); try w.writeAll("1"); for (0..50) |_| try w.writeAll("}"); - const json_str = fbs.getWritten(); + const json_str = w.buffered(); const parsed = try std.json.parseFromSlice( std.json.Value, std.testing.allocator, json_str, .{ .max_value_len = 4096 }, ); diff --git a/tests/fixtures/cartridges/feedback-mcp/ffi/cartridge_shim.zig b/tests/fixtures/cartridges/feedback-mcp/ffi/cartridge_shim.zig index cd83b44b..bdcc64a5 100644 --- a/tests/fixtures/cartridges/feedback-mcp/ffi/cartridge_shim.zig +++ b/tests/fixtures/cartridges/feedback-mcp/ffi/cartridge_shim.zig @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 -// Copyright (c) Jonathan D.A. Jewell +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) // // cartridge_shim.zig — Shared helpers for the ADR-0006 five-symbol // cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`). @@ -94,8 +94,116 @@ pub fn writeResult( return RC_SUCCESS; } +// ── Shared runtime Io (Zig 0.16 compat) ────────────────────────────── +// +// Zig 0.16 moved blocking primitives and the wall clock onto the +// `std.Io` interface (`std.Thread.Mutex` and `std.time.*Timestamp` +// were removed from the stdlib). The shim owns one process-wide Io +// backed by `std.Io.Threaded` so cartridge code keeps drop-in call +// sites — `var m: shim.Mutex = .{}; m.lock(); defer m.unlock();` and +// `shim.milliTimestamp()` — without threading an Io handle through the +// C ABI. Cartridges that need richer Io (http, fs, net) should use +// this same `shim.io()` rather than constructing their own runtime. + +var shared_threaded: std.Io.Threaded = undefined; +var shared_io_state: std.atomic.Value(u8) = .init(0); // 0=uninit 1=initing 2=ready + +/// The process-wide `std.Io`, lazily initialised on first use. +/// Thread-safe: a single CAS winner runs `Threaded.init`; racing +/// callers yield until it is published. +pub fn io() std.Io { + if (shared_io_state.load(.acquire) != 2) { + if (shared_io_state.cmpxchgStrong(0, 1, .acq_rel, .acquire) == null) { + shared_threaded = std.Io.Threaded.init(std.heap.smp_allocator, .{}); + shared_io_state.store(2, .release); + } else { + while (shared_io_state.load(.acquire) != 2) std.Thread.yield() catch {}; + } + } + return shared_threaded.io(); +} + +/// Drop-in replacement for the removed `std.Thread.Mutex`, backed by +/// `std.Io.Mutex` over the shim's shared Io. Zero-initialisable: +/// `var m: shim.Mutex = .{};` +pub const Mutex = struct { + inner: std.Io.Mutex = .init, + + pub fn lock(m: *Mutex) void { + m.inner.lockUncancelable(io()); + } + + pub fn unlock(m: *Mutex) void { + m.inner.unlock(io()); + } + + pub fn tryLock(m: *Mutex) bool { + return m.inner.tryLock(); + } +}; + +/// Nanoseconds since the POSIX epoch (drop-in for the removed +/// `std.time.nanoTimestamp`). +pub fn nanoTimestamp() i128 { + const ts = std.Io.Clock.Timestamp.now(io(), .real); + return @intCast(ts.raw.nanoseconds); +} + +/// Milliseconds since the POSIX epoch (drop-in for the removed +/// `std.time.milliTimestamp`). +pub fn milliTimestamp() i64 { + return @intCast(@divTrunc(nanoTimestamp(), std.time.ns_per_ms)); +} + +/// Seconds since the POSIX epoch (drop-in for the removed +/// `std.time.timestamp`). +pub fn timestamp() i64 { + return @intCast(@divTrunc(nanoTimestamp(), std.time.ns_per_s)); +} + +/// Fill `buffer` with cryptographically secure random bytes (drop-in for +/// the removed `std.crypto.random.bytes`). +pub fn randomBytes(buffer: []u8) void { + io().random(buffer); +} + +/// Cryptographically secure random integer (drop-in for the removed +/// `std.crypto.random.int(T)`). +pub fn randomInt(comptime T: type) T { + var buf: [@sizeOf(T)]u8 = undefined; + io().random(&buf); + return @bitCast(buf); +} + +/// Process environment lookup (drop-in for the removed +/// `std.posix.getenv`). Only analysed when referenced; callers must link +/// libc (`root_module.link_libc = true` — the standard ffi build shape). +pub fn getenv(name: [*:0]const u8) ?[:0]const u8 { + const p = std.c.getenv(name) orelse return null; + return std.mem.sliceTo(p, 0); +} + // ── Tests ──────────────────────────────────────────────────────────── +test "Mutex: lock/unlock and tryLock round-trip" { + var m: Mutex = .{}; + m.lock(); + try std.testing.expect(!m.tryLock()); + m.unlock(); + try std.testing.expect(m.tryLock()); + m.unlock(); +} + +test "timestamps: monotone-ish and unit-consistent" { + const ns = nanoTimestamp(); + const ms = milliTimestamp(); + const s = timestamp(); + try std.testing.expect(ns > 0); + // Same instant expressed in three units must agree to within a step. + try std.testing.expect(@abs(@divTrunc(ns, std.time.ns_per_s) - s) <= 1); + try std.testing.expect(@abs(@divTrunc(ms, std.time.ms_per_s) - s) <= 1); +} + test "writeResult: body fits, writes and sets length" { var buf: [64]u8 = undefined; var len: usize = buf.len; diff --git a/tests/fixtures/cartridges/feedback-mcp/ffi/feedback_ffi.zig b/tests/fixtures/cartridges/feedback-mcp/ffi/feedback_ffi.zig index eef8283b..7e14d047 100644 --- a/tests/fixtures/cartridges/feedback-mcp/ffi/feedback_ffi.zig +++ b/tests/fixtures/cartridges/feedback-mcp/ffi/feedback_ffi.zig @@ -71,7 +71,7 @@ var channels: [MAX_CHANNELS]ChannelSlot = [_]ChannelSlot{.{ .neutral_count = 0, }} ** MAX_CHANNELS; -var mutex: std.Thread.Mutex = .{}; +var mutex: shim.Mutex = .{}; /// Validate a state transition (matches Idris2 canTransition). fn isValidTransition(from: FeedbackState, to: FeedbackState) bool { diff --git a/tests/mcp_bench.zig b/tests/mcp_bench.zig index 86926026..0e11abbd 100644 --- a/tests/mcp_bench.zig +++ b/tests/mcp_bench.zig @@ -28,13 +28,13 @@ test "BENCH: JSON-RPC request serialisation (1 000 iters) completes < 200 ms" { const req = Request{}; var buf: [512]u8 = undefined; - const start = std.time.nanoTimestamp(); + const start = shim.nanoTimestamp(); for (0..ITERATIONS) |_| { _ = std.fmt.bufPrint(&buf, \\{{"jsonrpc":"{s}","id":{d},"method":"{s}","tool":"{s}","sql":"{s}"}} , .{ req.jsonrpc, req.id, req.method, req.tool, req.sql }) catch {}; } - const elapsed_ns: u64 = @intCast(std.time.nanoTimestamp() - start); + const elapsed_ns: u64 = @intCast(shim.nanoTimestamp() - start); const elapsed_ms = elapsed_ns / 1_000_000; std.debug.print(" Serialisation: {}ms total, {d:.3}ms/req\n", .{ @@ -52,7 +52,7 @@ test "BENCH: JSON-RPC response deserialisation (1 000 iters) completes < 200 ms" \\{"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"ok"}]}} ; - const start = std.time.nanoTimestamp(); + const start = shim.nanoTimestamp(); for (0..ITERATIONS) |_| { const parsed = std.json.parseFromSlice( std.json.Value, @@ -62,7 +62,7 @@ test "BENCH: JSON-RPC response deserialisation (1 000 iters) completes < 200 ms" ) catch continue; parsed.deinit(); } - const elapsed_ns: u64 = @intCast(std.time.nanoTimestamp() - start); + const elapsed_ns: u64 = @intCast(shim.nanoTimestamp() - start); const elapsed_ms = elapsed_ns / 1_000_000; std.debug.print(" Deserialisation: {}ms total, {d:.3}ms/req\n", .{ @@ -84,15 +84,17 @@ test "BENCH: cartridge name iteration over sample entries (10 000 iters) < 50 ms "lsp-mcp", }; - const start = std.time.nanoTimestamp(); + const start = shim.nanoTimestamp(); var total: usize = 0; for (0..10_000) |_| { for (names) |n| total += n.len; } - const elapsed_ns: u64 = @intCast(std.time.nanoTimestamp() - start); + const elapsed_ns: u64 = @intCast(shim.nanoTimestamp() - start); const elapsed_ms = elapsed_ns / 1_000_000; std.debug.print(" Listing: {}ms total, {} chars/iter\n", .{ elapsed_ms, total / 10_000 }); try testing.expect(elapsed_ms < 50); try testing.expect(total > 0); } + +const shim = @import("cartridge_shim");