Skip to content

Commit fe509cd

Browse files
hyperpolymathclaude
andcommitted
fix(ffi): wire boj-health .so and fix Zig ElfDynLib data-segment bug
boj-invoke was built as musl-static, causing Zig to select ElfDynLib (its own ELF loader) instead of DlDynLib (real dlopen). ElfDynLib in Zig ≤0.15.2 has a bug: writable PT_LOAD segments are memcpy'd from file offset 0 instead of ph.p_offset, loading garbage into the data segment of any .so whose writable segment doesn't start at file offset 0. Fix: target x86_64-linux-gnu for boj-invoke (forces DlDynLib / real dlopen). Also lands the boj-health ADR-0006 reference cartridge: - boj_health_ffi.zig: five symbols, C clock_gettime, std_options suppresses the Zig segfault handler to avoid overwriting boj-invoke's handler - build.zig: library named libboj_health.so (matches router name_to_lib/1) - cartridge.json: full schema, three tools (status/ping/version) Full chain verified: HTTP → Elixir router → boj-invoke → dlopen → .so Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0cba9a4 commit fe509cd

4 files changed

Lines changed: 209 additions & 1 deletion

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"$schema": "https://boj.dev/schemas/cartridge/v1.json",
3+
"spdx": "PMPL-1.0-or-later",
4+
"copyright": "Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>",
5+
"name": "boj-health",
6+
"version": "0.1.0",
7+
"description": "BoJ server self-health cartridge — status, ping, and uptime queries. Self-contained Zig FFI (.so) reference implementation: no external services required.",
8+
"domain": "infrastructure",
9+
"tier": "Ayo",
10+
"protocols": ["MCP"],
11+
"auth": {
12+
"method": "none",
13+
"env_var": null,
14+
"credential_source": null
15+
},
16+
"api": {
17+
"base_url": "local://boj-health",
18+
"content_type": "application/json"
19+
},
20+
"ffi": {
21+
"so_path": "ffi/zig-out/lib/libboj_health.so",
22+
"abi_version": "ADR-0006",
23+
"symbols": ["boj_cartridge_init", "boj_cartridge_deinit", "boj_cartridge_name", "boj_cartridge_version", "boj_cartridge_invoke"]
24+
},
25+
"tools": [
26+
{
27+
"name": "boj_health_status",
28+
"description": "Return BoJ server health status and version",
29+
"inputSchema": {
30+
"type": "object",
31+
"properties": {}
32+
}
33+
},
34+
{
35+
"name": "boj_health_ping",
36+
"description": "Ping the BoJ health cartridge — always returns pong",
37+
"inputSchema": {
38+
"type": "object",
39+
"properties": {}
40+
}
41+
},
42+
{
43+
"name": "boj_health_version",
44+
"description": "Return boj-health cartridge version string",
45+
"inputSchema": {
46+
"type": "object",
47+
"properties": {}
48+
}
49+
}
50+
]
51+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// boj-health cartridge — ADR-0006 five-symbol Zig FFI implementation.
5+
//
6+
// Reference implementation: no external services, no env vars required.
7+
// Demonstrates the full Idris2 ABI → Zig FFI → boj-invoke → Elixir chain.
8+
//
9+
// Tools:
10+
// boj_health_status — JSON health blob: ok, version, uptime_ms
11+
// boj_health_ping — always {pong: true}
12+
// boj_health_version — version string only
13+
//
14+
// Runtime note: boj-invoke targets x86_64-linux-gnu (glibc) so it uses
15+
// DlDynLib (real dlopen). This .so can therefore safely use link_libc = true
16+
// (glibc) without a musl/glibc clash. The std_options override prevents
17+
// this .so from overwriting boj-invoke's SIGSEGV handler at dlopen time.
18+
19+
const std = @import("std");
20+
const shim = @import("cartridge_shim");
21+
22+
// Use the C clock_gettime directly — straightforward, no Zig TLS involved.
23+
const c = @cImport({
24+
@cInclude("time.h");
25+
});
26+
27+
// Suppress Zig's debug segfault signal handler so this .so does not
28+
// overwrite boj-invoke's handler when dlopened into the host Zig binary.
29+
pub const std_options: std.Options = .{
30+
.enable_segfault_handler = false,
31+
};
32+
33+
var init_time_ms: i64 = 0;
34+
var init_done: bool = false;
35+
36+
// ─── Five-symbol ADR-0006 ABI ────────────────────────────────────────────────
37+
38+
export fn boj_cartridge_name() callconv(.c) [*:0]const u8 {
39+
return "boj-health";
40+
}
41+
42+
export fn boj_cartridge_version() callconv(.c) [*:0]const u8 {
43+
return "0.1.0";
44+
}
45+
46+
export fn boj_cartridge_init() callconv(.c) c_int {
47+
var ts: c.struct_timespec = undefined;
48+
_ = c.clock_gettime(c.CLOCK_MONOTONIC, &ts);
49+
init_time_ms = ts.tv_sec * 1000 + @divTrunc(ts.tv_nsec, 1_000_000);
50+
init_done = true;
51+
return 0;
52+
}
53+
54+
export fn boj_cartridge_deinit() callconv(.c) void {
55+
init_done = false;
56+
}
57+
58+
export fn boj_cartridge_invoke(
59+
tool_name: [*c]const u8,
60+
json_args: [*c]const u8,
61+
out_buf: [*c]u8,
62+
in_out_len: [*c]usize,
63+
) callconv(.c) i32 {
64+
_ = json_args;
65+
if (shim.invokeArgsNull(tool_name, out_buf, in_out_len)) return shim.RC_BAD_ARGS;
66+
67+
if (shim.toolIs(tool_name, "boj_health_ping")) {
68+
return shim.writeResult(out_buf, in_out_len, "{\"pong\":true}");
69+
}
70+
71+
if (shim.toolIs(tool_name, "boj_health_version")) {
72+
return shim.writeResult(out_buf, in_out_len, "{\"version\":\"0.1.0\"}");
73+
}
74+
75+
if (shim.toolIs(tool_name, "boj_health_status")) {
76+
var ts: c.struct_timespec = undefined;
77+
_ = c.clock_gettime(c.CLOCK_MONOTONIC, &ts);
78+
const now_ms: i64 = ts.tv_sec * 1000 + @divTrunc(ts.tv_nsec, 1_000_000);
79+
const uptime: i64 = if (init_done) now_ms - init_time_ms else 0;
80+
81+
var buf: [256]u8 = undefined;
82+
const body = std.fmt.bufPrint(&buf,
83+
"{{\"ok\":true,\"version\":\"0.1.0\",\"uptime_ms\":{d},\"cartridge\":\"boj-health\"}}",
84+
.{uptime},
85+
) catch return shim.RC_RUNTIME_ERROR;
86+
return shim.writeResult(out_buf, in_out_len, body);
87+
}
88+
89+
return shim.RC_UNKNOWN_TOOL;
90+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
//
4+
// boj-health cartridge FFI build — produces libboj_health.so
5+
//
6+
// link_libc = true: boj-invoke targets x86_64-linux-gnu (glibc) and uses
7+
// DlDynLib (real dlopen). A glibc-linked .so is therefore fully compatible —
8+
// dlopen loads it into the glibc process and resolves libc symbols against
9+
// the already-loaded libc.so.6 with no duplication.
10+
11+
const std = @import("std");
12+
13+
pub fn build(b: *std.Build) void {
14+
const target = b.standardTargetOptions(.{});
15+
const optimize = b.standardOptimizeOption(.{});
16+
17+
const shim_mod = b.addModule("cartridge_shim", .{
18+
.root_source_file = b.path("../../../ffi/zig/src/cartridge_shim.zig"),
19+
.target = target,
20+
.optimize = optimize,
21+
});
22+
23+
const ffi_mod = b.createModule(.{
24+
.root_source_file = b.path("boj_health_ffi.zig"),
25+
.target = target,
26+
.optimize = optimize,
27+
.link_libc = true,
28+
});
29+
ffi_mod.addImport("cartridge_shim", shim_mod);
30+
31+
const lib = b.addLibrary(.{
32+
.name = "boj_health",
33+
.root_module = ffi_mod,
34+
.linkage = .dynamic,
35+
});
36+
b.installArtifact(lib);
37+
38+
const lib_static = b.addLibrary(.{
39+
.name = "boj_health",
40+
.root_module = b.createModule(.{
41+
.root_source_file = b.path("boj_health_ffi.zig"),
42+
.target = target,
43+
.optimize = optimize,
44+
.link_libc = true,
45+
}),
46+
.linkage = .static,
47+
});
48+
lib_static.root_module.addImport("cartridge_shim", shim_mod);
49+
b.installArtifact(lib_static);
50+
51+
// Unit tests for the shim helpers used here.
52+
const tests = b.addTest(.{ .root_module = ffi_mod });
53+
const run_tests = b.addRunArtifact(tests);
54+
const test_step = b.step("test", "Run boj-health FFI unit tests");
55+
test_step.dependOn(&run_tests.step);
56+
}

ffi/zig/build.zig

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,20 @@ pub fn build(b: *std.Build) void {
6262
bench_step.dependOn(&bench_run.step);
6363

6464
// --- boj-invoke CLI (skinny Phase 2 per ADR-0005) ---
65+
//
66+
// Must target x86_64-linux-gnu (glibc) so std.DynLib resolves to
67+
// DlDynLib (real dlopen(3)). Without this, Zig selects ElfDynLib —
68+
// its own ELF loader — which has a bug (Zig ≤0.15.2): writable
69+
// segments are copied from file offset 0 instead of ph.p_offset,
70+
// so any .so with a non-zero data segment offset loads garbage.
71+
const invoke_target = b.resolveTargetQuery(.{
72+
.cpu_arch = .x86_64,
73+
.os_tag = .linux,
74+
.abi = .gnu,
75+
});
6576
const invoke_mod = b.addModule("boj_invoke", .{
6677
.root_source_file = b.path("src/boj_invoke_cli.zig"),
67-
.target = target,
78+
.target = invoke_target,
6879
.optimize = optimize,
6980
});
7081
invoke_mod.link_libc = true; // route std.DynLib through real dlopen(3)

0 commit comments

Comments
 (0)