Skip to content

Commit c8f04e8

Browse files
Fix ABI contract: align result codes, add linear handle tracking (#57)
* fix(test): make property suite compile under pinned Zig 0.15.2 @typeinfo(...).@"enum".fields is comptime-only in 0.15.2; the three runtime 'for' loops over enum fields (P7, P7b, P13) now use 'inline for'. The suite had never compiled under the pinned toolchain (it is also not run in CI), so the recorded test counts never included its 14 tests. New verified baseline: 80 unit + 39 integration + 5 smoke + 14 property. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TaWWedv6VQqeZaPvc94keN * fix(abi)!: unify result-code contract across Zig and Idris2 (18 codes, machine-checked) The two sides of the 'contractual' result-code mapping had silently diverged: Zig GsaResult codes 5-12 (not_initialized/timeout/...) encoded different concepts than Types.idr Result codes 5-12 (AlreadyConsumed/ ResourceLeaked/...). Nothing cross-checked them, so 124 green tests shipped a broken cross-language contract. - Types.idr keeps its stable 0-12 encoding and gains the five Zig-side failure modes as codes 13-17 (NotInitialized, ProtocolError, IoError, PermissionDenied, NotFound); all total functions extended. - GsaResult now mirrors Result name-for-name and value-for-value, including the linear-resource codes (already_consumed/resource_leaked/ double_free) the multi-handle model needs. - New scripts/gen_result_codes.zig parses Types.idr's resultToInt clauses into result_codes_expected.zig (same pattern as gen_abi_expected.zig); a comptime block next to GsaResult turns any name/value/cardinality drift into a compile error (verified by injecting a deliberate mismatch). - Call sites retargeted: parse_error -> config_parse_error, VeriSimDB failures -> verisimdb_unavailable. - The old 'values match Idris2 ABI' test asserted Zig literals against the Zig enum; renamed to a sanity check, with the generated table as the real contract. BREAKING CHANGE: integer values of result codes 5-12 changed; consumers holding numeric codes must rebuild against the new table. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TaWWedv6VQqeZaPvc94keN * fix(abi): make the probe-handle lifecycle real and correct the Idris wrappers The proven-ABI story was connected to nothing: gossamer_gsa_probe returned a bare result code (never a handle), gossamer_gsa_close_handle was a no-op (_ = handle;), and every Foreign.idr wrapper assumed negative error codes the Zig side never emits. So the Idris linear-type guarantees (AlreadyConsumed/DoubleFree) had no runtime counterpart. Zig side (main.zig, probe.zig, abi_serde.zig): - GsaHandle now issues monotonic handle ids (>= FIRST_HANDLE_ID = 1000, disjoint from the 0-17 result-code space so a success value can never be read as an error code). - gossamer_gsa_probe returns the handle id; gossamer_gsa_close_handle validates it, frees the binding, and reports double_free on a repeat close and not_found for a stray id, via a bounded tombstone ring. - gossamer_gsa_apply_config validates the handle (already_consumed if the handle was closed) instead of ignoring it. - Two new tests cover open/use/close/double-close/unknown and the bounded tombstone ring. Idris side (Foreign.idr): - New resultOf/parseResultCode drop the bogus negation: a code of 0 is Ok, 1-17 is that GsaResult (Zig never negates — grep-verified). - probe classifies its return by magnitude (>= firstHandleId = handle). - prim__serverAction and prim__getLogs retyped from Int to String to match the real C signatures (both return char*, not codes/arrays); wrappers updated (serverAction returns the JSON string, getLogs splits lines). - fingerprint now consumes the handle it produces (no leak); storeOctad, addProfile, applyConfig, checkHealth reworked to honest behaviour with scope notes where an Idris-side struct emitter is the documented follow-up. Ephapax (Bridge.eph): probe success checks widened to >= 1000; code-returning FFI checks tightened to != 0 to match the positive-code convention. CI: new abi-contract.yml regenerates both expected tables and fails on drift, runs the comptime cross-check via zig build, and type-checks the Idris2 package (was never compiled in CI — the reason this drift shipped). cross-platform.yml now also runs the smoke and property suites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TaWWedv6VQqeZaPvc94keN --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4f8ed4f commit c8f04e8

17 files changed

Lines changed: 627 additions & 140 deletions

.claude/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ panic-attack assail .
4242
Gossamer GUI (Ephapax .eph) -- src/core/, src/gui/panels/
4343
| IPC (gossamer:// protocol)
4444
Zig FFI (libgsa.so + gsa CLI) -- src/interface/ffi/src/ (11 modules)
45-
| C ABI (13 result codes)
45+
| C ABI (18 result codes)
4646
Idris2 ABI (Types/Foreign/Layout) -- src/interface/abi/
4747
| REST (port 8090)
4848
VeriSimDB (8-modality octads) -- container/verisimdb/

.github/workflows/abi-contract.yml

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# ABI contract gate — keeps the Zig FFI and the Idris2 model in lockstep.
3+
#
4+
# Two things drift silently if unchecked (and did: result codes 5-12 once
5+
# encoded different concepts on each side):
6+
# 1. The generated expected-tables (result codes, struct layout) must match
7+
# what the generators produce from Types.idr / Layout.idr right now.
8+
# 2. The Idris2 model itself must type-check.
9+
# The Zig comptime cross-checks (abi_layout.zig, the GsaResult block in
10+
# main.zig) turn any table drift into a compile error, so `zig build` is the
11+
# enforcement; this workflow regenerates first so a stale committed table
12+
# fails loudly instead of masking a change.
13+
14+
name: ABI Contract
15+
16+
on:
17+
push:
18+
branches: [main, 'claude/**']
19+
pull_request:
20+
branches: [main]
21+
22+
permissions:
23+
contents: read
24+
25+
jobs:
26+
zig-contract:
27+
name: Zig ↔ Idris tables in sync
28+
runs-on: ubuntu-latest
29+
timeout-minutes: 15
30+
steps:
31+
- name: Checkout
32+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
33+
34+
- name: Install Zig 0.15.2
35+
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
36+
with:
37+
version: 0.15.2
38+
39+
- name: Regenerate expected tables from the Idris2 model
40+
run: |
41+
zig run scripts/gen_abi_expected.zig
42+
zig run scripts/gen_result_codes.zig
43+
44+
- name: Fail on drift (committed tables must match the model)
45+
run: |
46+
git diff --exit-code \
47+
src/interface/ffi/src/abi_layout_expected.zig \
48+
src/interface/ffi/src/result_codes_expected.zig \
49+
|| { echo "::error::Generated ABI tables are stale. Run: zig run scripts/gen_abi_expected.zig && zig run scripts/gen_result_codes.zig"; exit 1; }
50+
51+
- name: Build (runs the comptime GsaResult ↔ Types.idr cross-check)
52+
working-directory: src/interface/ffi
53+
run: zig build
54+
55+
idris-typecheck:
56+
name: Idris2 model type-checks
57+
runs-on: ubuntu-latest
58+
timeout-minutes: 20
59+
steps:
60+
- name: Checkout
61+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
62+
63+
- name: Install Idris2
64+
run: sudo apt-get update && sudo apt-get install -y idris2
65+
66+
- name: Type-check the ABI package
67+
working-directory: src/interface/abi
68+
run: idris2 --typecheck gsa-abi.ipkg

.github/workflows/cross-platform.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,11 @@ jobs:
6060
if: matrix.run-integration
6161
working-directory: src/interface/ffi
6262
run: zig build test-integration
63+
64+
- name: Smoke Tests
65+
working-directory: src/interface/ffi
66+
run: zig build test-smoke
67+
68+
- name: Property Tests
69+
working-directory: src/interface/ffi
70+
run: zig build test-property

docs/INDEX.adoc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ _You build GSA, extend it, or contribute code across the Gossamer GUI → Zig FF
3737
| `docs/developer/ABI-FFI-README.adoc` | live | Idris2 ABI + Zig FFI standard; C/Idris2/Rust/Julia examples (strong)
3838
| `TOPOLOGY.md` | live | System architecture + data flow
3939
| `docs/decisions/` | live | Architecture decision records (RSR adoption)
40-
| FFI module reference | *planned* | Per-module docs for the 11 FFI modules (signatures, the 13 result codes, examples)
40+
| FFI module reference | *planned* | Per-module docs for the 11 FFI modules (signatures, the 18 result codes, examples)
4141
| Panel extension guide | *planned* | The PanLL clade system; registering a new panel; inheriting base clades
4242
| Game-profile authoring | *thin* | Expand the README snippet: field types, validation, port/protocol, debugging
4343
| Config-format guide | *planned* | How to add a parser to the 8-format `config_extract`

scripts/gen_result_codes.zig

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
// gen_result_codes.zig — extract the canonical result-code contract from the
5+
// Idris2 model (src/interface/abi/Types.idr) and emit the Zig source file
6+
// (src/interface/ffi/src/result_codes_expected.zig) consumed by the comptime
7+
// cross-check next to GsaResult in main.zig.
8+
//
9+
// Types.idr is the single source of truth: its `resultToInt` clauses define
10+
// the stable (constructor, code) pairs. This tool lifts them into Zig so the
11+
// compiler can assert GsaResult agrees name-for-name and value-for-value —
12+
// the drift this closes shipped once already (codes 5-12 diverged silently).
13+
//
14+
// Run from the repository root:
15+
// zig run scripts/gen_result_codes.zig
16+
// CI re-runs this and `git diff --exit-code`s the result (abi-contract job).
17+
18+
const std = @import("std");
19+
20+
const TYPES_IDR = "src/interface/abi/Types.idr";
21+
const OUT_ZIG = "src/interface/ffi/src/result_codes_expected.zig";
22+
23+
// Constructors whose Zig field name is not the mechanical CamelCase ->
24+
// snake_case conversion. A constructor missing from both this table and the
25+
// mechanical rule fails the build of the *generator*, forcing a conscious
26+
// mapping decision for every new code.
27+
const Override = struct { idris: []const u8, zig: []const u8 };
28+
const overrides = [_]Override{
29+
.{ .idris = "Error", .zig = "err" },
30+
.{ .idris = "VeriSimDBUnavailable", .zig = "verisimdb_unavailable" },
31+
};
32+
33+
const HEADER =
34+
\\// SPDX-License-Identifier: MPL-2.0
35+
\\// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
36+
\\//
37+
\\// @generated by scripts/gen_result_codes.zig from src/interface/abi/Types.idr
38+
\\// DO NOT EDIT. Regenerate with: zig run scripts/gen_result_codes.zig
39+
\\//
40+
\\// Canonical result-code contract lifted from the Idris2 model
41+
\\// (GSA.ABI.Types.resultToInt). Consumed by the comptime block next to
42+
\\// GsaResult in main.zig to assert the two enums agree.
43+
\\
44+
\\pub const ResultCode = struct {
45+
\\ idris_ctor: []const u8,
46+
\\ zig_field: []const u8,
47+
\\ code: c_int,
48+
\\};
49+
\\
50+
\\pub const all = [_]ResultCode{
51+
\\
52+
;
53+
54+
fn zigFieldFor(alloc: std.mem.Allocator, ctor: []const u8) ![]const u8 {
55+
for (overrides) |o| {
56+
if (std.mem.eql(u8, o.idris, ctor)) return try alloc.dupe(u8, o.zig);
57+
}
58+
// Mechanical CamelCase -> snake_case (no consecutive-capital handling:
59+
// constructors needing that must go in `overrides`).
60+
var out = std.array_list.Managed(u8).init(alloc);
61+
errdefer out.deinit();
62+
for (ctor, 0..) |c, i| {
63+
if (std.ascii.isUpper(c)) {
64+
if (i != 0) try out.append('_');
65+
try out.append(std.ascii.toLower(c));
66+
} else {
67+
try out.append(c);
68+
}
69+
}
70+
return out.toOwnedSlice();
71+
}
72+
73+
pub fn main() !void {
74+
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
75+
defer _ = gpa.deinit();
76+
const alloc = gpa.allocator();
77+
78+
const src = try std.fs.cwd().readFileAlloc(alloc, TYPES_IDR, 4 * 1024 * 1024);
79+
defer alloc.free(src);
80+
81+
var out = std.array_list.Managed(u8).init(alloc);
82+
defer out.deinit();
83+
try out.appendSlice(HEADER);
84+
85+
var count: usize = 0;
86+
var lines = std.mem.splitScalar(u8, src, '\n');
87+
while (lines.next()) |line| {
88+
const trimmed = std.mem.trim(u8, line, " \r\t");
89+
if (!std.mem.startsWith(u8, trimmed, "resultToInt ")) continue;
90+
if (std.mem.indexOfScalar(u8, trimmed, ':') != null) continue; // type signature
91+
92+
var it = std.mem.tokenizeAny(u8, trimmed, " \t");
93+
_ = it.next(); // "resultToInt"
94+
const ctor = it.next() orelse return error.BadClause;
95+
const eq = it.next() orelse return error.BadClause;
96+
if (!std.mem.eql(u8, eq, "=")) return error.BadClause;
97+
const code = try std.fmt.parseInt(c_int, it.next() orelse return error.BadClause, 10);
98+
99+
const field = try zigFieldFor(alloc, ctor);
100+
defer alloc.free(field);
101+
try out.writer().print(
102+
" .{{ .idris_ctor = \"{s}\", .zig_field = \"{s}\", .code = {d} }},\n",
103+
.{ ctor, field, code },
104+
);
105+
count += 1;
106+
}
107+
try out.appendSlice("};\n");
108+
109+
if (count == 0) return error.NoClausesFound;
110+
111+
try std.fs.cwd().writeFile(.{ .sub_path = OUT_ZIG, .data = out.items });
112+
std.debug.print("wrote {s} ({d} result codes)\n", .{ OUT_ZIG, count });
113+
}

src/core/Bridge.eph

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ fn handleProbeServer(payload: String, caps: &GsaCapSet) -> String =
5252
jsonError("Missing 'host' in probe request")
5353
else {
5454
let result = __ffi("gossamer_gsa_probe", host, port) in
55-
if result < 0 then
55+
if result < 1000 then
5656
jsonError("Probe failed: " ++ __ffi("gossamer_gsa_last_error"))
5757
else {
5858
-- Fingerprint succeeded, extract config
@@ -85,7 +85,7 @@ fn handleAddServer(payload: String, caps: &GsaCapSet) -> String =
8585
fn handleRemoveServer(payload: String, caps: &GsaCapSet) -> String =
8686
let server_id = jsonField(payload, "server_id") in
8787
let result = __ffi("gossamer_gsa_verisimdb_delete", server_id) in
88-
if result < 0 then
88+
if result != 0 then
8989
jsonError("Failed to remove: " ++ __ffi("gossamer_gsa_last_error"))
9090
else
9191
jsonOk("{\"server_id\":\"" ++ server_id ++ "\",\"removed\":true}")
@@ -138,7 +138,7 @@ fn handleSetConfig(payload: String, caps: &GsaCapSet) -> String =
138138
else {
139139
-- Apply changes via FFI (writes to server, updates VeriSimDB)
140140
let result = __ffi("gossamer_gsa_apply_config", server_id, changes) in
141-
if result < 0 then
141+
if result != 0 then
142142
jsonError("Config apply failed: " ++ __ffi("gossamer_gsa_last_error"))
143143
else {
144144
-- Record provenance
@@ -158,7 +158,7 @@ fn handleRevertConfig(payload: String, caps: &GsaCapSet) -> String =
158158
jsonError("No stored config for: " ++ server_id)
159159
else {
160160
let result = __ffi("gossamer_gsa_apply_stored_config", server_id, stored) in
161-
if result < 0 then
161+
if result != 0 then
162162
jsonError("Revert failed: " ++ __ffi("gossamer_gsa_last_error"))
163163
else {
164164
let prov = "{\"event_type\":\"config_revert\",\"actor\":\"gsa-gui\",\"description\":\"Config reverted to stored version\"}" in
@@ -220,7 +220,7 @@ fn handleGetLogs(payload: String, caps: &GsaCapSet) -> String =
220220
-- Get overall health status of all managed servers.
221221
fn handleGetHealth(payload: String, caps: &GsaCapSet) -> String =
222222
let result = __ffi("gossamer_gsa_verisimdb_health") in
223-
if result < 0 then
223+
if result != 0 then
224224
jsonError("VeriSimDB health check failed")
225225
else {
226226
let drift_status = __ffi("gossamer_gsa_verisimdb_drift_all") in
@@ -279,7 +279,7 @@ fn handleRollbackConfig(payload: String, caps: &GsaCapSet) -> String =
279279
else {
280280
-- Apply historical config
281281
let result = __ffi("gossamer_gsa_apply_stored_config", server_id, historical) in
282-
if result < 0 then
282+
if result != 0 then
283283
jsonError("Rollback failed: " ++ __ffi("gossamer_gsa_last_error"))
284284
else {
285285
let prov = "{\"event_type\":\"config_rollback\",\"actor\":\"gsa-gui\",\"description\":\"Rolled back to version " ++ version ++ "\"}" in
@@ -506,7 +506,7 @@ fn handleAddProfile(payload: String) -> String =
506506
jsonError("Missing 'a2ml' profile definition")
507507
else {
508508
let result = __ffi("gossamer_gsa_add_profile", a2ml) in
509-
if result < 0 then
509+
if result != 0 then
510510
jsonError("Profile registration failed: " ++ __ffi("gossamer_gsa_last_error"))
511511
else
512512
jsonOk("{\"registered\":true}")
@@ -574,7 +574,7 @@ fn handleNexusStageFiles(payload: String, caps: &GsaCapSet) -> String =
574574
let env_json = "{\"STEAM_USER\":\"" ++ steam_user ++
575575
"\",\"PROVISION_TARGET_HOST\":\"" ++ target_host ++ "\"}" in
576576
let result = __ffi("gossamer_gsa_run_script", "scripts/steam-stage.sh", profile_id, env_json, steam_pass) in
577-
if result < 0 then
577+
if result != 0 then
578578
jsonError("Staging failed: " ++ __ffi("gossamer_gsa_last_error"))
579579
else {
580580
let prov = "{\"event_type\":\"nexus_stage\",\"actor\":\"gsa-gui\",\"description\":\"SteamCMD stage for " ++ profile_id ++ " on " ++ target_host ++ "\"}" in
@@ -605,7 +605,7 @@ fn handleNexusProvision(payload: String, caps: &GsaCapSet) -> String =
605605
let env_json = "{\"PROVISION_TARGET_HOST\":\"" ++ target_host ++
606606
"\",\"PROVISION_SSH_USER\":\"" ++ ssh_user ++ "\"}" in
607607
let result = __ffi("gossamer_gsa_run_script", "scripts/provision-server.sh", profile_id, env_json, "") in
608-
if result < 0 then
608+
if result != 0 then
609609
jsonError("Provisioner failed: " ++ __ffi("gossamer_gsa_last_error"))
610610
else {
611611
-- Register in VeriSimDB
@@ -637,7 +637,7 @@ fn handleNexusVerify(payload: String, caps: &GsaCapSet) -> String =
637637
let vdb_check = __ffi("gossamer_gsa_verisimdb_query",
638638
"SELECT id FROM octads WHERE id = '" ++ server_id ++ "'") in
639639
let registered = if vdb_check != "" then "true" else "false" in
640-
if probe < 0 then
640+
if probe < 1000 then
641641
-- Not yet reachable — partial ok (provisioned but not responding yet)
642642
jsonOk("{\"ok\":false,\"verisimdb_registered\":" ++ registered ++
643643
",\"error\":\"Server not yet responding on UDP " ++ game_port ++ "\"}")

0 commit comments

Comments
 (0)