Skip to content

Commit b866c2f

Browse files
committed
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
1 parent 4cf2cc2 commit b866c2f

9 files changed

Lines changed: 345 additions & 92 deletions

File tree

.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`

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)