Skip to content

Commit 37bd237

Browse files
Make the Zig FFI compile + memory-safety fixes + CI (W1-6) (#38)
## What & why The Zig C-ABI FFI in `ffi/zig` did not compile and had no CI, so it could rot undetected while every other check stayed green. `main.zig` declared `Handle` as `opaque {}` yet gave it fields and `allocator.create(Handle)`d it (both hard Zig compile errors), `integration_test.zig` re-declared its own conflicting `Handle`, and `build.zig` referenced a missing header and bench file. This makes the FFI compile, memory-safe, and CI-checked. **The FFI is EXPERIMENTAL:** the exported operations validate their arguments and enforce handle liveness but are PLACEHOLDERS — they are NOT backed by the Julia statistical core, and no Idris2 ABI is wired up. That is deliberate and called out in a status comment block at the top of `main.zig`; the documentation reframe is tracked separately (W2-4). This PR does not invent a Julia bridge. ## Changes - **`ffi/zig/src/main.zig`** — `Handle` is now a real internal `struct` (crossing the C ABI only as an opaque `*Handle`). Added a `magic` liveness cookie (`HANDLE_MAGIC`) that is poisoned (`HANDLE_FREED`) before the block is released, and checked on every entry point that dereferences a handle. Double-free and use-after-free are now safe: a second `statistikles_free` returns `.invalid_param` instead of a second `destroy()`; operations on a freed handle return an error rather than invoking UB. Fixed the `statistikles_last_error` ownership leak — error messages are program-lifetime string literals (static storage), documented as a borrowed, never-freed pointer. Added `export fn statistikles_abi_version() callconv(.C) u32` returning a monotonic ABI number alongside the existing semantic version string. - **`ffi/zig/test/integration_test.zig`** — imports the ONE shared `Handle`/`Result` from the root module (via the `statistikles` module wired in `build.zig`) instead of re-declaring them, so the tests and library can never drift on the ABI. The double-free test asserts the safe-error behaviour (`.invalid_param`), not merely "should not crash". - **`ffi/zig/build.zig`** — removed the dead references to the missing `include/statistikles.h` and `bench/bench.zig`; wires `src/main.zig` in as the `statistikles` module for the integration tests; `zig build test` runs both the unit and integration test suites. - **`.github/workflows/zig.yml`** (NEW) — SHA-pinned `actions/checkout` (`9c091bb2…` = v7.0.0) + SHA-pinned `mlugg/setup-zig` (`d1434d08…` = v2.2.1) pinning Zig **0.13.0**, running `zig build test --summary all` in `ffi/zig`. Matches the repo's SHA-pinning style; `permissions: read-all`; step-level path/branch triggers only. ## Zig version Built and CI-checked against **Zig 0.13.0**. The setup environment shipped Zig 0.16.0, whose std Io redesign is incompatible with this code's dialect, so the workflow pins 0.13.0 (the version `build.zig` / the source target) via `mlugg/setup-zig`. ## Verification Local (WSL Debian, Zig 0.13.0 binary), `zig build test --summary all` in `ffi/zig`: ``` Build Summary: 5/5 steps succeeded; 24/24 tests passed ``` The 24 tests include the memory-safety cases: double-free returns `.invalid_param` (not UB), operations on a freed handle are rejected, `free(null)` is a safe no-op, and `last_error` returns the same stable static pointer across calls (no leak). CI runs the identical `zig build test` on `ubuntu-latest`. ## Skipped / out of scope Real Julia-backed FFI semantics, the Idris2 ABI, and the documentation reframe (W2-4) are intentionally out of scope per the work order. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 77c999e commit 37bd237

5 files changed

Lines changed: 313 additions & 148 deletions

File tree

.github/workflows/zig.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
#
4+
# Statistikles — Zig FFI Build + Test
5+
#
6+
# The C-ABI FFI in ffi/zig is EXPERIMENTAL (placeholder ops, not yet backed by
7+
# the Julia core), but it must compile and its memory-safety tests must pass so
8+
# it cannot silently rot. Builds and tests against Zig 0.13.0 — the version the
9+
# FFI's build.zig / source dialect targets.
10+
11+
name: Zig FFI
12+
13+
on:
14+
push:
15+
branches: [main]
16+
paths:
17+
- 'ffi/zig/**'
18+
- '.github/workflows/zig.yml'
19+
pull_request:
20+
branches: [main]
21+
paths:
22+
- 'ffi/zig/**'
23+
- '.github/workflows/zig.yml'
24+
workflow_dispatch:
25+
26+
permissions: read-all
27+
28+
concurrency:
29+
group: zig-${{ github.ref }}
30+
cancel-in-progress: true
31+
32+
jobs:
33+
zig-ffi:
34+
name: Zig FFI — build + test
35+
runs-on: ubuntu-latest
36+
timeout-minutes: 15
37+
38+
steps:
39+
- name: Checkout
40+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
41+
42+
- name: Setup Zig 0.13.0
43+
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
44+
with:
45+
version: 0.13.0
46+
47+
- name: Build + test FFI
48+
working-directory: ffi/zig
49+
run: zig build test --summary all

ffi/zig/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Zig build outputs
2+
.zig-cache/
3+
zig-cache/
4+
zig-out/

ffi/zig/build.zig

Lines changed: 29 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,28 @@
11
// SPDX-License-Identifier: MPL-2.0
22
// Copyright (c) Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
33
// STATISTIKLES FFI Build Configuration
4+
//
5+
// Built and CI-checked against Zig 0.13.0.
46

57
const std = @import("std");
68

79
pub fn build(b: *std.Build) void {
810
const target = b.standardTargetOptions(.{});
911
const optimize = b.standardOptimizeOption(.{});
1012

11-
// Shared library (.so, .dylib, .dll)
13+
// Shared library (.so, .dylib, .dll). The version must be passed here so the
14+
// versioned-filename fields are computed at creation time (setting
15+
// `lib.version` afterwards leaves them null and crashes installArtifact).
1216
const lib = b.addSharedLibrary(.{
1317
.name = "statistikles",
1418
.root_source_file = b.path("src/main.zig"),
1519
.target = target,
1620
.optimize = optimize,
21+
.version = .{ .major = 0, .minor = 1, .patch = 0 },
1722
});
18-
19-
// Set version
20-
lib.version = .{ .major = 0, .minor = 1, .patch = 0 };
23+
// The FFI uses std.heap.c_allocator (malloc/free) so it interops with C
24+
// consumers and `statistikles_free_string`; that requires libc.
25+
lib.linkLibC();
2126

2227
// Static library (.a)
2328
const lib_static = b.addStaticLibrary(.{
@@ -26,42 +31,46 @@ pub fn build(b: *std.Build) void {
2631
.target = target,
2732
.optimize = optimize,
2833
});
34+
lib_static.linkLibC();
2935

3036
// Install artifacts
3137
b.installArtifact(lib);
3238
b.installArtifact(lib_static);
3339

34-
// Generate header file for C compatibility
35-
const header = b.addInstallHeader(
36-
b.path("include/statistikles.h"),
37-
"statistikles.h",
38-
);
39-
b.getInstallStep().dependOn(&header.step);
40-
41-
// Unit tests
40+
// Unit tests (main.zig's own test blocks)
4241
const lib_tests = b.addTest(.{
4342
.root_source_file = b.path("src/main.zig"),
4443
.target = target,
4544
.optimize = optimize,
4645
});
46+
lib_tests.linkLibC();
4747

4848
const run_lib_tests = b.addRunArtifact(lib_tests);
4949

50-
const test_step = b.step("test", "Run library tests");
51-
test_step.dependOn(&run_lib_tests.step);
52-
53-
// Integration tests
50+
// Integration tests. Import src/main.zig as the `statistikles` module so the
51+
// tests exercise the SAME Handle/Result types the library exports (one
52+
// shared definition, no re-declared opaque types that can drift).
5453
const integration_tests = b.addTest(.{
5554
.root_source_file = b.path("test/integration_test.zig"),
5655
.target = target,
5756
.optimize = optimize,
5857
});
59-
60-
integration_tests.linkLibrary(lib);
58+
integration_tests.root_module.addAnonymousImport("statistikles", .{
59+
.root_source_file = b.path("src/main.zig"),
60+
.target = target,
61+
.optimize = optimize,
62+
});
63+
integration_tests.linkLibC();
6164

6265
const run_integration_tests = b.addRunArtifact(integration_tests);
6366

64-
const integration_test_step = b.step("test-integration", "Run integration tests");
67+
// `zig build test` runs BOTH the unit and integration tests.
68+
const test_step = b.step("test", "Run library + integration tests");
69+
test_step.dependOn(&run_lib_tests.step);
70+
test_step.dependOn(&run_integration_tests.step);
71+
72+
// Keep a dedicated integration-only step for convenience.
73+
const integration_test_step = b.step("test-integration", "Run integration tests only");
6574
integration_test_step.dependOn(&run_integration_tests.step);
6675

6776
// Documentation
@@ -70,26 +79,12 @@ pub fn build(b: *std.Build) void {
7079
.target = target,
7180
.optimize = .Debug,
7281
});
82+
docs.linkLibC();
7383

7484
const docs_step = b.step("docs", "Generate documentation");
7585
docs_step.dependOn(&b.addInstallDirectory(.{
7686
.source_dir = docs.getEmittedDocs(),
7787
.install_dir = .prefix,
7888
.install_subdir = "docs",
7989
}).step);
80-
81-
// Benchmark (if needed)
82-
const bench = b.addExecutable(.{
83-
.name = "statistikles-bench",
84-
.root_source_file = b.path("bench/bench.zig"),
85-
.target = target,
86-
.optimize = .ReleaseFast,
87-
});
88-
89-
bench.linkLibrary(lib);
90-
91-
const run_bench = b.addRunArtifact(bench);
92-
93-
const bench_step = b.step("bench", "Run benchmarks");
94-
bench_step.dependOn(&run_bench.step);
9590
}

0 commit comments

Comments
 (0)