Skip to content

Commit 79fc8a8

Browse files
feat!: port the core FFI tree to Zig 0.16.0 (#305)
## PR B of the BoJ 0.16 campaign Completes the toolchain convergence for boj-server's own Zig code, matching the cartridge sweep in [boj-server-cartridges#109](hyperpolymath/boj-server-cartridges#109). The canonical shim `ffi/zig/src/cartridge_shim.zig` is now **byte-identical to the registry's template copy** and owns the 0.16 compat layer — 0.16 removed `std.Thread.Mutex`, `std.time.*Timestamp`, `std.time.Timer`, `std.crypto.random` and `std.posix.getenv` outright: | Removed | Shim replacement | |---|---| | `std.Thread.Mutex` | `shim.Mutex` over `std.Io.Mutex` | | `std.time.{nano,milli,}Timestamp`, `Timer` | `shim.*Timestamp` / the Io clock | | `std.crypto.random.*` | `shim.randomBytes` / `shim.randomInt` | | `std.posix.getenv` | `shim.getenv` | | — | `shim.io()`: one process-wide `std.Io.Threaded` | ### The three ports that weren't mechanical - **`loader.zig`** — `std.fs.File` → `std.Io.File`. **0.16 signals end-of-stream with `error.EndOfStream`; a 0-byte return no longer means EOF**, so the `hashFile` read loop would have spun or truncated silently. Caught by its own tests. Also `realpath` → `realPathFile` (returns a length now). - **`federation.zig`** — `std.net` → `std.Io.net`; `socket`+`bind` fuse into `IpAddress.bind`. Non-blocking recv is `receiveTimeout` with a zero `Io.Clock.Duration` on the `.awake` clock — a true `MSG_DONTWAIT` equivalent (confirmed by reading `Io/Threaded.zig`: it issues `recvmsg(MSG_DONTWAIT)` first and only then polls). **`SO_REUSEADDR` is dropped** — `BindOptions` has no equivalent and the option is only meaningful *before* bind, which is no longer a separate call. Harmless for unicast UDP (no TIME_WAIT; port sharing would need `SO_REUSEPORT`). - **`verisimdb.zig`** — `process.Child.run` → `std.process.run`, `Term` tags lowercased. The old `term.Exited` access would *panic* on a signal-killed child; the new `switch` returns the same error instead. ### Pins `.tool-versions` 0.15.1 → 0.16.0 and every `setup-zig` pin → 0.16.0. The three **archived** `goto-bus-stop/setup-zig` uses (truthfulness, lsp-dap-bsp, e2e) migrate to the `mlugg` action this repo already pins elsewhere. ### Verification - `cd ffi/zig && zig build test` → **316/316 tests across 41 steps** - E2E fixture cartridge → **17/17** - All 26 workflows YAML-parse Beyond the suite, the federation receive path was exercised directly (loopback heartbeat received, sender address round-trips to the same peer slot, QUIC key-exchange tag) because its tests are defensively written and green alone wouldn't prove it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 78c4a05 commit 79fc8a8

23 files changed

Lines changed: 552 additions & 255 deletions

‎.github/workflows/e2e.yml‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ jobs:
6767
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
6868

6969
- name: Install Zig
70-
uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2
70+
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
7171
with:
72-
version: 0.15.1
72+
version: 0.16.0
7373

7474
- name: Install Deno
7575
uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4
@@ -132,9 +132,9 @@ jobs:
132132
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
133133

134134
- name: Install Zig
135-
uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2
135+
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
136136
with:
137-
version: 0.15.1
137+
version: 0.16.0
138138

139139
- name: Build FFI libraries
140140
run: cd ffi/zig && zig build
@@ -170,9 +170,9 @@ jobs:
170170
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
171171

172172
- name: Install Zig
173-
uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2
173+
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
174174
with:
175-
version: 0.15.1
175+
version: 0.16.0
176176

177177
- name: Build FFI libraries
178178
run: cd ffi/zig && zig build

‎.github/workflows/fuzz.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
- name: Install Zig
3131
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
3232
with:
33-
version: 0.15.2
33+
version: 0.16.0
3434

3535
- name: Run core FFI fuzz tests
3636
run: |

‎.github/workflows/lsp-dap-bsp.yml‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -93,14 +93,14 @@ jobs:
9393
timeout-minutes: 15
9494
steps:
9595
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
96-
# The old step curled ziglang.org/builds/...0.15.2... — that path is the
97-
# nightly dir, not release downloads, so it returned an error page and
98-
# `tar` died ("File format not recognized"). Use the pinned action +
99-
# .tool-versions' canonical 0.15.1, matching e2e.yml.
96+
# Use the pinned action + .tool-versions' canonical Zig, matching
97+
# e2e.yml. (A previous revision curled ziglang.org/builds/... — the
98+
# nightly dir, not release downloads — and got an error page that
99+
# `tar` rejected.)
100100
- name: Install Zig
101-
uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2
101+
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
102102
with:
103-
version: 0.15.1
103+
version: 0.16.0
104104
- name: Build LSP/DAP/BSP cartridge FFI
105105
run: |
106106
for cart in lsp-mcp dap-mcp bsp-mcp; do

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ jobs:
3030
- name: Install Zig
3131
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
3232
with:
33-
version: 0.15.2
33+
version: 0.16.0
3434

3535
- name: Fetch cartridges
3636
# Cartridge source lives in the canonical registry, not this repo. Fetch

‎.github/workflows/truthfulness.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ jobs:
6868
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
6969

7070
- name: Install Zig
71-
uses: goto-bus-stop/setup-zig@9566bb3e8749893055694249726756f25e099b30 # v2
71+
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
7272
with:
73-
version: 0.15.1
73+
version: 0.16.0
7474

7575
- name: Install jq
7676
run: sudo apt-get update && sudo apt-get install -y jq

‎.github/workflows/zig-test.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ jobs:
6767
- name: Install Zig
6868
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2
6969
with:
70-
version: 0.15.2
70+
version: 0.16.0
7171

7272
# Bundled cartridges/ was retired; per-cartridge FFI builds are gated
7373
# in hyperpolymath/boj-server-cartridges. This workflow now covers the

‎.tool-versions‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
zig 0.15.1
1+
zig 0.16.0
22
elixir 1.18.4-otp-25
33
idris2 0.8.0
44
deno 2.7.14

‎ffi/zig/build.zig‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,28 @@ pub fn build(b: *std.Build) void {
1111
const optimize = b.standardOptimizeOption(.{});
1212

1313
// --- Catalogue module ---
14+
const shim_mod = b.addModule("boj_cartridge_shim", .{
15+
.root_source_file = b.path("src/cartridge_shim.zig"),
16+
.target = target,
17+
.optimize = optimize,
18+
// shim.getenv wraps std.c.getenv
19+
.link_libc = true,
20+
});
21+
1422
const catalogue_mod = b.addModule("boj_catalogue", .{
1523
.root_source_file = b.path("src/catalogue.zig"),
1624
.target = target,
1725
.optimize = optimize,
1826
});
27+
catalogue_mod.addImport("cartridge_shim", shim_mod);
1928

2029
// --- Loader module ---
2130
const loader_mod = b.addModule("boj_loader", .{
2231
.root_source_file = b.path("src/loader.zig"),
2332
.target = target,
2433
.optimize = optimize,
2534
});
35+
loader_mod.addImport("cartridge_shim", shim_mod);
2636
loader_mod.addImport("catalogue", catalogue_mod);
2737

2838
// --- Static library (for zig adapter linking) ---
@@ -50,6 +60,7 @@ pub fn build(b: *std.Build) void {
5060
.target = target,
5161
.optimize = .ReleaseFast,
5262
});
63+
bench_mod.addImport("cartridge_shim", shim_mod);
5364
bench_mod.addImport("catalogue", catalogue_mod);
5465
const bench = b.addExecutable(.{
5566
.name = "boj_bench",
@@ -106,6 +117,7 @@ pub fn build(b: *std.Build) void {
106117
.target = target,
107118
.optimize = optimize,
108119
});
120+
guardian_mod.addImport("cartridge_shim", shim_mod);
109121

110122
const guardian_lib = b.addLibrary(.{
111123
.name = "boj_guardian",
@@ -127,6 +139,7 @@ pub fn build(b: *std.Build) void {
127139
.target = target,
128140
.optimize = optimize,
129141
});
142+
federation_mod.addImport("cartridge_shim", shim_mod);
130143

131144
const federation_lib = b.addLibrary(.{
132145
.name = "boj_federation",
@@ -163,6 +176,7 @@ pub fn build(b: *std.Build) void {
163176
.target = target,
164177
.optimize = optimize,
165178
});
179+
verisimdb_mod.addImport("cartridge_shim", shim_mod);
166180
const verisimdb_tests = b.addTest(.{
167181
.root_module = verisimdb_mod,
168182
});
@@ -178,6 +192,7 @@ pub fn build(b: *std.Build) void {
178192
.optimize = optimize,
179193
.link_libc = true,
180194
});
195+
coprocessor_mod.addImport("cartridge_shim", shim_mod);
181196

182197
const coprocessor_lib = b.addLibrary(.{
183198
.name = "boj_coprocessor",
@@ -199,6 +214,7 @@ pub fn build(b: *std.Build) void {
199214
.target = target,
200215
.optimize = optimize,
201216
});
217+
sla_mod.addImport("cartridge_shim", shim_mod);
202218

203219
const sla_lib = b.addLibrary(.{
204220
.name = "boj_sla",
@@ -220,6 +236,7 @@ pub fn build(b: *std.Build) void {
220236
.target = target,
221237
.optimize = optimize,
222238
});
239+
community_mod.addImport("cartridge_shim", shim_mod);
223240

224241
const community_lib = b.addLibrary(.{
225242
.name = "boj_community",
@@ -262,6 +279,7 @@ pub fn build(b: *std.Build) void {
262279
.target = target,
263280
.optimize = optimize,
264281
});
282+
sdp_mod.addImport("cartridge_shim", shim_mod);
265283

266284
const sdp_lib = b.addLibrary(.{
267285
.name = "boj_sdp",
@@ -294,12 +312,6 @@ pub fn build(b: *std.Build) void {
294312
seams_step.dependOn(&run_seams_tests.step);
295313

296314
// --- Cartridge shim tests (ADR-0006 helpers) ---
297-
const shim_mod = b.addModule("boj_cartridge_shim", .{
298-
.root_source_file = b.path("src/cartridge_shim.zig"),
299-
.target = target,
300-
.optimize = optimize,
301-
});
302-
303315
const shim_tests = b.addTest(.{
304316
.root_module = shim_mod,
305317
});
@@ -411,6 +423,7 @@ pub fn build(b: *std.Build) void {
411423
.target = target,
412424
.optimize = optimize,
413425
});
426+
mcp_bench_mod.addImport("cartridge_shim", shim_mod);
414427
const bench_tests = b.addTest(.{ .root_module = mcp_bench_mod });
415428
const run_bench = b.addRunArtifact(bench_tests);
416429

‎ffi/zig/src/bench.zig‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,17 +22,16 @@ fn benchmarkFn(comptime name: []const u8, comptime func: anytype) void {
2222
func();
2323
}
2424

25-
// Measure
26-
var timer = std.time.Timer.start() catch {
27-
std.debug.print("Timer unavailable\n", .{});
28-
return;
29-
};
25+
// Measure (std.time.Timer was removed in Zig 0.16; the monotonic Io
26+
// clock is the replacement)
27+
const started = std.Io.Clock.Timestamp.now(shim.io(), .monotonic);
3028

3129
for (0..BENCH_ITERS) |_| {
3230
func();
3331
}
3432

35-
const elapsed_ns = timer.read();
33+
const ended = std.Io.Clock.Timestamp.now(shim.io(), .monotonic);
34+
const elapsed_ns: u64 = @intCast(started.durationTo(ended).raw.nanoseconds);
3635
const per_op_ns = elapsed_ns / BENCH_ITERS;
3736
const ops_per_sec = if (per_op_ns > 0) @as(u64, 1_000_000_000) / per_op_ns else 0;
3837

@@ -252,3 +251,5 @@ pub fn main() !void {
252251
std.debug.print("═══════════════════════════════════════════════════════════════\n", .{});
253252
std.debug.print("\n", .{});
254253
}
254+
255+
const shim = @import("cartridge_shim");

‎ffi/zig/src/cartridge_shim.zig‎

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// SPDX-License-Identifier: MPL-2.0
2-
// Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
33
//
44
// cartridge_shim.zig — Shared helpers for the ADR-0006 five-symbol
55
// cartridge ABI (`boj_cartridge_init / deinit / name / version / invoke`).
@@ -94,8 +94,116 @@ pub fn writeResult(
9494
return RC_SUCCESS;
9595
}
9696

97+
// ── Shared runtime Io (Zig 0.16 compat) ──────────────────────────────
98+
//
99+
// Zig 0.16 moved blocking primitives and the wall clock onto the
100+
// `std.Io` interface (`std.Thread.Mutex` and `std.time.*Timestamp`
101+
// were removed from the stdlib). The shim owns one process-wide Io
102+
// backed by `std.Io.Threaded` so cartridge code keeps drop-in call
103+
// sites — `var m: shim.Mutex = .{}; m.lock(); defer m.unlock();` and
104+
// `shim.milliTimestamp()` — without threading an Io handle through the
105+
// C ABI. Cartridges that need richer Io (http, fs, net) should use
106+
// this same `shim.io()` rather than constructing their own runtime.
107+
108+
var shared_threaded: std.Io.Threaded = undefined;
109+
var shared_io_state: std.atomic.Value(u8) = .init(0); // 0=uninit 1=initing 2=ready
110+
111+
/// The process-wide `std.Io`, lazily initialised on first use.
112+
/// Thread-safe: a single CAS winner runs `Threaded.init`; racing
113+
/// callers yield until it is published.
114+
pub fn io() std.Io {
115+
if (shared_io_state.load(.acquire) != 2) {
116+
if (shared_io_state.cmpxchgStrong(0, 1, .acq_rel, .acquire) == null) {
117+
shared_threaded = std.Io.Threaded.init(std.heap.smp_allocator, .{});
118+
shared_io_state.store(2, .release);
119+
} else {
120+
while (shared_io_state.load(.acquire) != 2) std.Thread.yield() catch {};
121+
}
122+
}
123+
return shared_threaded.io();
124+
}
125+
126+
/// Drop-in replacement for the removed `std.Thread.Mutex`, backed by
127+
/// `std.Io.Mutex` over the shim's shared Io. Zero-initialisable:
128+
/// `var m: shim.Mutex = .{};`
129+
pub const Mutex = struct {
130+
inner: std.Io.Mutex = .init,
131+
132+
pub fn lock(m: *Mutex) void {
133+
m.inner.lockUncancelable(io());
134+
}
135+
136+
pub fn unlock(m: *Mutex) void {
137+
m.inner.unlock(io());
138+
}
139+
140+
pub fn tryLock(m: *Mutex) bool {
141+
return m.inner.tryLock();
142+
}
143+
};
144+
145+
/// Nanoseconds since the POSIX epoch (drop-in for the removed
146+
/// `std.time.nanoTimestamp`).
147+
pub fn nanoTimestamp() i128 {
148+
const ts = std.Io.Clock.Timestamp.now(io(), .real);
149+
return @intCast(ts.raw.nanoseconds);
150+
}
151+
152+
/// Milliseconds since the POSIX epoch (drop-in for the removed
153+
/// `std.time.milliTimestamp`).
154+
pub fn milliTimestamp() i64 {
155+
return @intCast(@divTrunc(nanoTimestamp(), std.time.ns_per_ms));
156+
}
157+
158+
/// Seconds since the POSIX epoch (drop-in for the removed
159+
/// `std.time.timestamp`).
160+
pub fn timestamp() i64 {
161+
return @intCast(@divTrunc(nanoTimestamp(), std.time.ns_per_s));
162+
}
163+
164+
/// Fill `buffer` with cryptographically secure random bytes (drop-in for
165+
/// the removed `std.crypto.random.bytes`).
166+
pub fn randomBytes(buffer: []u8) void {
167+
io().random(buffer);
168+
}
169+
170+
/// Cryptographically secure random integer (drop-in for the removed
171+
/// `std.crypto.random.int(T)`).
172+
pub fn randomInt(comptime T: type) T {
173+
var buf: [@sizeOf(T)]u8 = undefined;
174+
io().random(&buf);
175+
return @bitCast(buf);
176+
}
177+
178+
/// Process environment lookup (drop-in for the removed
179+
/// `std.posix.getenv`). Only analysed when referenced; callers must link
180+
/// libc (`root_module.link_libc = true` — the standard ffi build shape).
181+
pub fn getenv(name: [*:0]const u8) ?[:0]const u8 {
182+
const p = std.c.getenv(name) orelse return null;
183+
return std.mem.sliceTo(p, 0);
184+
}
185+
97186
// ── Tests ────────────────────────────────────────────────────────────
98187

188+
test "Mutex: lock/unlock and tryLock round-trip" {
189+
var m: Mutex = .{};
190+
m.lock();
191+
try std.testing.expect(!m.tryLock());
192+
m.unlock();
193+
try std.testing.expect(m.tryLock());
194+
m.unlock();
195+
}
196+
197+
test "timestamps: monotone-ish and unit-consistent" {
198+
const ns = nanoTimestamp();
199+
const ms = milliTimestamp();
200+
const s = timestamp();
201+
try std.testing.expect(ns > 0);
202+
// Same instant expressed in three units must agree to within a step.
203+
try std.testing.expect(@abs(@divTrunc(ns, std.time.ns_per_s) - s) <= 1);
204+
try std.testing.expect(@abs(@divTrunc(ms, std.time.ms_per_s) - s) <= 1);
205+
}
206+
99207
test "writeResult: body fits, writes and sets length" {
100208
var buf: [64]u8 = undefined;
101209
var len: usize = buf.len;

0 commit comments

Comments
 (0)