From ecc18b498e6464e0729a7380ac99800964c22b07 Mon Sep 17 00:00:00 2001 From: Kazuyoshi Yoshimi Date: Sat, 17 Oct 2026 10:20:55 +0900 Subject: [PATCH 1/2] docs(spec): C ABI state struct runtime layout check (#246, design) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for #246: each library exports X_state_abi_version() and X_state_size() from the BIND(C) type; the C-ABI drivers cross-check them against the header macro and sizeof(X_state_t); the Python wrappers refuse a library whose layout differs from their ctypes struct (missing symbols = pre-#246 library, size or version mismatch = rebuild). The size-negotiated _v2 getter is deferred. Decisions requested in §7. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TA2KahTs3qLMZL6yjSsWwT --- ...6-10-17-c-abi-state-layout-check-design.md | 272 ++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-17-c-abi-state-layout-check-design.md diff --git a/docs/superpowers/specs/2026-10-17-c-abi-state-layout-check-design.md b/docs/superpowers/specs/2026-10-17-c-abi-state-layout-check-design.md new file mode 100644 index 000000000..4c0371a1f --- /dev/null +++ b/docs/superpowers/specs/2026-10-17-c-abi-state-layout-check-design.md @@ -0,0 +1,272 @@ +# C ABI state structs — runtime layout check (design, #246) + +**Status**: design, pre-implementation, awaiting one decision (§7) +**Date**: 2026-10-17 +**Tracking issue**: #246 (raised by the Codex review of #233) +**Related**: #187 / #193 (AJRFT, the first `tr_state_t` / `tot_state_t` +growth), #233 (fp global scalars, open, grows `fp_state_t`), #244 (fp +regress mirror, depends on #233), #245 / #248 (`fp_get_state` extents) + +## 1. Problem + +Every module exposes one fixed-layout struct, filled by +`X_get_state(X_state_t*)`: + +| module | struct | header | Fortran mirror | version macro today | +|---|---|---|---|---| +| tr | `tr_state_t` | `tr/tr_api.h` | `tr/tr_state.f90` `tr_state_c` | `TR_STATE_ABI_VERSION 2` (compile-time only) | +| tot | `tot_state_t` | `tot/tot_api.h` | `tot/tot_state.f90` `tot_state_c` | `TOT_STATE_ABI_VERSION 2` (compile-time only) | +| eq | `eq_state_t` | `eq/eq_api.h` | `eq/eq_state.f90` | none | +| fp | `fp_state_t` | `fp/fp_api.h` | `fp/fp_state.f90` | none | +| ti | `ti_state_t` | `ti/ti_api.h` | `ti/ti_state.f90` | none | +| wr | `wr_state_t` | `wr/wr_api.h` | `wr/wr_state.f90` | none | +| wrx | `wrx_state_t` | `wrx/wrx_api.h` | `wrx/wrx_state.f90` | none | + +The only rule is "append at the end, bump the macro where one exists, +rebuild `libXapi.so` in the same commit". Nothing checks it at run time, +so a layout change fails silently in two ways: + +- **old caller, new library**: `X_get_state` writes the appended members + past the caller's shorter allocation (56 bytes in #233's case, during + the unconditional zeroing, even on an error return); +- **new caller, old library**: the appended members are never written, + the ctypes struct is zero-initialised, and the wrapper, the MCP server + and the Web Client receive plausible `0.0` values with no indication + that the library lacks the field. + +The Python `test_size_matches_header_math` tests only check ctypes +arithmetic against numbers typed into the test; nothing compares the +ctypes layout with the `BIND(C)` type that the library actually fills. + +## 2. Goals + +1. Both failure modes are refused **before any struct is passed**, with + an error that names the fix ("rebuild `libXapi.so`" or "update the + wrapper"). +2. One source of truth for the layout version, checked mechanically + between Fortran, the C header and Python. +3. Same mechanism in all seven modules and in the mono image, with no + change to the existing `X_get_state` signature or to any struct. +4. Small enough to land as one PR per layer (Fortran+header, Python, + docs) or one PR in total. + +Non-goals: making old callers *work* with new libraries (no versioned +getters, no per-field presence flags), and dynamic sizing of the arrays +(the `X_MAX_*` capacities stay compile-time). + +## 3. Options considered + +**(1) Size-negotiated getter** `int X_get_state_v2(X_state_t*, size_t +sizeof_state)`: the library fills `min(given, sizeof(X_state_t))` bytes +and returns a distinct code when the caller's struct is smaller. Precise, +and it protects a raw C caller that never asks for a version. Cost: a +second getter per module to keep forever (or a deprecation cycle for the +first), a new error code, and the wrapper still needs a version query to +tell "smaller because old" from "smaller because wrong". + +**(2) Runtime layout query**: each library exports its struct version +and `sizeof`; the wrapper compares both with its own at load time. Cost: +two trivial functions per module. It does not protect a C caller that +ignores the query, but every in-tree C consumer rebuilds from the header +and the only downstream consumer (the TASK Web Client, via the MCP +servers) goes through the Python wrappers. + +**Recommendation: (2) now, (1) only if an out-of-tree C consumer +appears.** (2) alone meets goals 1–3: a pre-#246 library has no query +symbol (new caller / old library → refused at load), and a library whose +struct grew reports a larger size than the wrapper's (old caller / new +library → refused at load). + +## 4. Design (option 2) + +### 4.1 Fortran: version and size next to the type + +In each `X_state.f90`, next to `TYPE, BIND(C) :: X_state_c`: + +```fortran + ! Layout version of X_state_c / X_state_t. Bump together with + ! X_STATE_ABI_VERSION in X_api.h whenever a field is added, removed or + ! reordered; the C-ABI driver fails when the two drift (§4.3). + INTEGER(C_INT), PARAMETER :: X_STATE_ABI_VERSION = 1 ! 2 for tr, tot +``` + +In each `X_api.f90`, two `BIND(C)` functions (no lifecycle check, no +side effects, callable before `X_init`): + +```fortran + FUNCTION X_api_state_abi_version() RESULT(v) BIND(C, NAME="X_state_abi_version") + INTEGER(C_INT) :: v + v = X_STATE_ABI_VERSION + END FUNCTION + + FUNCTION X_api_state_size() RESULT(n) BIND(C, NAME="X_state_size") + INTEGER(C_SIZE_T) :: n + TYPE(X_state_c) :: probe + n = INT(C_SIZEOF(probe), C_SIZE_T) ! or STORAGE_SIZE(probe)/8 + END FUNCTION +``` + +The size comes from the `BIND(C)` type, so the library, not the header, +is authoritative. `probe` is a local of up to ~5 MB for eq (the type is +large); if a compiler objects to the stack frame, `STORAGE_SIZE` of a +dummy with `TYPE(X_state_c), INTENT(IN) :: t` needs no object at all — +pick whichever gfortran 13 and the `-fcheck=all` build accept and use +it in all seven. + +Mono image: `libtotapi_mono.so` co-links every module's objects, so it +exports all fourteen symbols without any tot-level addition; the +version is per module, not per image. The `libtotapi_mono_inspect` +target gets one more `nm` line asserting the seven `_state_size` +symbols are present. + +### 4.2 C header: macro mirrors the PARAMETER + +```c +#define X_STATE_ABI_VERSION 1 /* mirror of X_state.f90 */ +int X_state_abi_version(void); /* the library's value */ +size_t X_state_size(void); /* sizeof the library's X_state_t */ +``` + +tr and tot already have the macro at 2 with a History block; eq, fp, +ti, wr, wrx get it at 1 with the same block. Rule text (one paragraph, +same in all seven headers): bump the PARAMETER and the macro together +in the commit that changes the struct; rebuild the library; the wrapper +refuses any other combination. + +### 4.3 C-ABI drivers: Fortran vs header cross-check + +In each module's `tests/c_abi/test_negative.c` (runs before init, which +is exactly when these must work): + +```c +EXPECT_EQ(X_state_abi_version(), X_STATE_ABI_VERSION, "abi-version-matches-header"); +EXPECT_EQ((long) X_state_size(), (long) sizeof(X_state_t), "state-size-matches-header"); +``` + +This is the Fortran `BIND(C)`-type vs C-struct check the issue asks +for. `X_api_check_so` (dlopen path) gets the same two lines so the +shared library, not only the static link, is checked. + +### 4.4 Python: one helper, called from every `_ffi.py` + +New `python/_abi_check.py` (next to `_runtime_mode.py`, importable by +all seven wrappers through the existing `sys.path` setup): + +```python +def check_state_layout(lib, *, prefix, struct, expected_version, lib_name): + """Refuse a library whose X_state_t layout differs from `struct`.""" + try: + fn_v = getattr(lib, f"{prefix}_state_abi_version") + fn_s = getattr(lib, f"{prefix}_state_size") + except AttributeError as exc: + raise AbiMismatchError( + f"{lib_name} does not export {prefix}_state_abi_version / " + f"{prefix}_state_size: it predates the layout check (#246); " + f"rebuild it (`make -C {prefix} {lib_name}`)") from exc + fn_v.restype = ctypes.c_int; fn_v.argtypes = [] + fn_s.restype = ctypes.c_size_t; fn_s.argtypes = [] + have_v, have_s = fn_v(), fn_s() + want_s = ctypes.sizeof(struct) + if have_v != expected_version or have_s != want_s: + raise AbiMismatchError( + f"{lib_name} state layout is v{have_v} / {have_s} bytes, this " + f"wrapper expects v{expected_version} / {want_s} bytes; rebuild " + f"the library or update python/{prefix}lib/_ffi.py") +``` + +`AbiMismatchError` subclasses each wrapper's base error (`TrlibError`, +…) through a small adapter so existing `except XlibError` handlers keep +working; concretely the helper raises a plain `RuntimeError` subclass +and each `_ffi.py` re-raises as its own `XlibError`. Called from +`_apply_prototypes` (where the `try/except AttributeError` pattern for +optional symbols already lives), so it runs once per `load_library`, +before any struct is passed. `X_STATE_ABI_VERSION` is a constant in each +`_ffi.py`, next to the `X_MAX_*` constants that already mirror the +header. + +Mono routing needs nothing extra: a wrapper routed to +`libtotapi_mono.so` checks its own module's pair in that image. + +### 4.5 Tests + +Per wrapper, in `python/Xlib/tests/test_ffi.py` (real library; skipped +when the `.so` is absent, as the sibling tests are): + +- `test_library_layout_matches_wrapper`: `load_library()` succeeds and + `X_state_size()` equals `ctypes.sizeof(XStateC)`, `X_state_abi_version()` + equals the wrapper constant. +- `test_stale_wrapper_layout_is_refused`: a ctypes struct with the last + field removed, passed to the helper → `AbiMismatchError` naming both + sizes (the "old caller / new library" case). +- `test_version_drift_is_refused`: the real struct with + `expected_version + 1` → refused (a reorder that keeps the size). +- `test_library_without_the_symbols_is_refused`: a stub object without + the two attributes → the "predates the layout check" error (the "new + caller / old library" case). + +The helper itself gets `python/tests/test_abi_check.py` with the same +four cases on a fake library, so the logic is tested once without any +`.so`. + +C side: §4.3. CI runs only `tot_api_check_all` today +(`.github/workflows/python-tests.yml:244`); the six per-module drivers +run locally (`make -C X X_api_check_all`, which on macOS fails to link +for tr because of the missing graphics library, while `X_api_check_so` +links everywhere). The PR adds the six `X_api_check_so` targets to the +pytest job so the dlopen-path cross-check runs on Linux for every +module; the static-link drivers stay local. + +### 4.6 Docs + +- `docs/*-library/architecture.md` (seven): a short "State struct + layout" rule: append only; bump PARAMETER + macro together; rebuild; + the wrapper refuses a mismatch; what the error looks like. +- `docs/sphinx/modules/tr/{en,ja}/extending-tr.md` step 5: "bump the + Fortran PARAMETER and the header macro together; the C driver fails + when they drift" (the existing text mentions the macro only). +- `python/Xlib/README.md` (seven): one line under the load section: + the library must match the wrapper's layout; the error names the fix. +- CHANGELOG: Added. + +## 5. Sequencing with #233 / #244 + +#233 (external, open) appends seven doubles to `fp_state_t`. Two +orderings: + +- **#246 first (proposed)**: fp starts at version 1 with today's + layout; #233 rebases, bumps `FP_STATE_ABI_VERSION` 1 → 2 in both + places, and its wrapper test asserts version 2. One comment on #233 + says so. #244 follows #233 unchanged. +- #233 first: #246 starts fp at 1 with the grown layout and #233 needs + no change. #233 is waiting on its author for a totlib fix and docs, + so this ordering is not in our hands. + +Either way the two cannot both merge without the second rebasing; the +conflict is a one-line macro and a test constant. + +## 6. Work breakdown + +| step | files | size | +|---|---|---| +| A. Fortran + header, 7 modules | `X_state.f90`, `X_api.f90`, `X_api.h` | ~15 lines each | +| B. C drivers | 7 × `tests/c_abi/test_negative.c`, `X_api_check_so` drivers | ~4 lines each | +| C. Python helper + 7 `_ffi.py` + errors | `python/_abi_check.py`, `python/Xlib/_ffi.py`, `python/Xlib/errors.py` | helper ~40 lines, ~10 per wrapper | +| D. Tests | `python/tests/test_abi_check.py`, 7 × `test_ffi.py` | ~30 + 7 × ~40 lines | +| E. Docs + CHANGELOG + mono inspect line | as §4.6 | text | + +One PR is feasible (all mechanical, same shape ×7); if split, A+B first +(no behaviour change), then C+D (the refusal appears), then E. + +Scope boundary for the sign-off rule: `X_state.f90` / `X_api.f90` / +`X_api.h` are the L-2 C-ABI layer, not module physics; no `trprep` / +`eqcalc` / … change. + +## 7. Decisions requested + +1. **Mechanism**: option 2 only (runtime version + size, wrapper refuses + a mismatch), or option 2 plus the `_v2` size-negotiated getter from + the start? Recommendation: 2 only. +2. **Ordering with #233**: land #246 first and ask #233 to bump fp to + version 2 (recommendation), or wait for #233? +3. **Packaging**: one PR for A–E, or A+B / C+D / E as three? + Recommendation: one PR, reviewed as one diff. From 7be25ef63fb0a245987fdabf8d73e8bc076124c4 Mon Sep 17 00:00:00 2001 From: Kazuyoshi Yoshimi Date: Sat, 17 Oct 2026 10:31:41 +0900 Subject: [PATCH 2/2] feat(abi): every library reports its state-struct layout; wrappers refuse a mismatch (#246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each module fills one fixed-layout struct through X_get_state and the only rule was "append, bump the macro, rebuild in the same commit"; nothing checked it at run time, so a library and a wrapper from different commits failed silently (fields written past the struct, or appended fields read as 0.0). Fortran is the source of truth: X_STATE_ABI_VERSION is a PARAMETER in X_state.f90 (tr, tot stay at 2; eq fp ti wr wrx start at 1), exported by X_state_abi_version(); X_state_size() exports C_SIZEOF of the BIND(C) type; both callable before X_init. X_api.h mirrors the macro and the prototypes. The C-ABI drivers (test_negative.c before init, test_run_so.c through dlopen) fail when either differs from the header. The Python wrappers call the shared python/_abi_check.py from load_library, before any struct is passed, and raise XlibAbiError when the library exports neither symbol, or its version or size differs from XStateC. CI runs the six per-module dlopen drivers; libtotapi_mono_inspect gates the twelve symbols. Tests: helper (7 cases) + per-wrapper TestLibraryLayout (4 ×7). Docs: architecture.md ×7, README ×7, extending-tr en/ja, CHANGELOG. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01TA2KahTs3qLMZL6yjSsWwT --- .github/workflows/python-tests.yml | 21 +++- CHANGELOG.md | 17 +++ docs/eq-library/architecture.md | 27 ++++- docs/fp-library/architecture.md | 27 ++++- docs/sphinx/modules/tr/en/extending-tr.md | 17 ++- docs/sphinx/modules/tr/ja/extending-tr.md | 15 ++- ...6-10-17-c-abi-state-layout-check-design.md | 38 +++--- docs/ti-library/architecture.md | 27 ++++- docs/tot-library/architecture.md | 27 ++++- docs/tr-library/architecture.md | 27 ++++- docs/wr-library/architecture.md | 27 ++++- docs/wrx-library/architecture.md | 27 ++++- eq/eq_api.f90 | 28 ++++- eq/eq_api.h | 23 ++++ eq/eq_state.f90 | 8 ++ eq/tests/c_abi/test_negative.c | 16 +++ eq/tests/c_abi/test_run_so.c | 24 ++++ fp/fp_api.f90 | 23 ++++ fp/fp_api.h | 23 ++++ fp/fp_state.f90 | 8 ++ fp/tests/c_abi/test_negative.c | 16 +++ fp/tests/c_abi/test_run_so.c | 24 ++++ python/_abi_check.py | 101 ++++++++++++++++ python/eqlib/README.md | 12 ++ python/eqlib/__init__.py | 2 + python/eqlib/_ffi.py | 18 +++ python/eqlib/errors.py | 11 ++ python/eqlib/tests/test_ffi.py | 59 +++++++++ python/fplib/README.md | 12 ++ python/fplib/__init__.py | 2 + python/fplib/_ffi.py | 18 +++ python/fplib/errors.py | 10 ++ python/fplib/tests/test_ffi.py | 59 +++++++++ python/tests/test_abi_check.py | 114 ++++++++++++++++++ python/tilib/README.md | 12 ++ python/tilib/__init__.py | 2 + python/tilib/_ffi.py | 18 +++ python/tilib/errors.py | 11 ++ python/tilib/tests/test_ffi.py | 59 +++++++++ python/totlib/README.md | 12 ++ python/totlib/__init__.py | 2 + python/totlib/_ffi.py | 18 +++ python/totlib/errors.py | 11 ++ python/totlib/tests/test_ffi.py | 59 +++++++++ python/trlib/README.md | 12 ++ python/trlib/__init__.py | 2 + python/trlib/_ffi.py | 18 +++ python/trlib/errors.py | 10 ++ python/trlib/tests/test_ffi.py | 59 +++++++++ python/wrlib/README.md | 12 ++ python/wrlib/__init__.py | 2 + python/wrlib/_ffi.py | 19 +++ python/wrlib/errors.py | 10 ++ python/wrlib/tests/test_ffi.py | 59 +++++++++ python/wrxlib/README.md | 12 ++ python/wrxlib/__init__.py | 2 + python/wrxlib/_ffi.py | 18 +++ python/wrxlib/errors.py | 10 ++ python/wrxlib/tests/test_ffi.py | 59 +++++++++ ti/tests/c_abi/test_negative.c | 16 +++ ti/tests/c_abi/test_run_so.c | 24 ++++ ti/ti_api.f90 | 23 ++++ ti/ti_api.h | 23 ++++ ti/ti_state.f90 | 8 ++ tot/Makefile | 12 ++ tot/tests/c_abi/test_negative.c | 16 +++ tot/tests/c_abi/test_run_so.c | 24 ++++ tot/tot_api.f90 | 23 ++++ tot/tot_api.h | 14 +++ tot/tot_state.f90 | 8 ++ tr/tests/c_abi/test_negative.c | 16 +++ tr/tests/c_abi/test_run_so.c | 24 ++++ tr/tr_api.f90 | 23 ++++ tr/tr_api.h | 14 +++ tr/tr_state.f90 | 8 ++ wr/tests/c_abi/test_negative.c | 16 +++ wr/tests/c_abi/test_run_so.c | 24 ++++ wr/wr_api.f90 | 23 ++++ wr/wr_api.h | 23 ++++ wr/wr_state.f90 | 8 ++ wrx/tests/c_abi/test_negative.c | 16 +++ wrx/tests/c_abi/test_run_so.c | 24 ++++ wrx/wrx_api.f90 | 23 ++++ wrx/wrx_api.h | 23 ++++ wrx/wrx_state.f90 | 8 ++ 85 files changed, 1838 insertions(+), 42 deletions(-) create mode 100644 python/_abi_check.py create mode 100644 python/tests/test_abi_check.py diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index e1408ff45..19758be64 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -178,6 +178,20 @@ jobs: echo "::endgroup::" done + - name: Run per-module dlopen C ABI drivers (X_api_check_so, #246) + # Each driver dlopens libXapi.so, checks X_state_abi_version() and + # X_state_size() against X_api.h (the Fortran BIND(C) type vs the + # C struct, #246) and runs the init/run/get_state/finalize cycle. + # The static-link drivers (X_api_check_all) stay local: tr's does + # not link on macOS and they need the non-PIC chain; the dlopen + # path is what the Python wrappers use. + run: | + for mod in tr fp ti wr wrx eq; do + echo "::group::${mod}_api_check_so" + make -C "$mod" "${mod}_api_check_so" + echo "::endgroup::" + done + - name: Run orchestrator Layer 2 + Layer 4 C ABI checks (tot_api_check_all) # Phase L-6 composite target. Builds and runs every C ABI driver: # tot_api_check -> test_smoke + test_param (Layer 3 dispatcher) @@ -410,8 +424,8 @@ jobs: run: | make -C tot libtotapi_mono.so - - name: Inspect libtotapi_mono.so (8-bpsd + no-lib*api.so-deps hard-gated) - # libtotapi_mono_inspect runs TWO hard-gates plus several + - name: Inspect libtotapi_mono.so (8-bpsd + 12-layout-syms + no-lib*api.so-deps hard-gated) + # libtotapi_mono_inspect runs THREE hard-gates plus several # observational checks: # # HARD GATE 1: BPSD storage symbol count == 8 (one per bpsd module @@ -419,6 +433,9 @@ jobs: # trmatrix + trsource). If the per-module dedup ever fails # and we get 5×8=40 (or any other count), the build fails. # + # HARD GATE 1b: the 12 X_state_abi_version / X_state_size symbols + # (eq tr fp ti wrx tot) are exported (#246). + # # HARD GATE 2: No `lib*api.so` runtime dep in the .so's NEEDED # list (Linux `ldd`) / load commands (macOS `otool -L`). The # whole point of the mono build is that it has no per-module diff --git a/CHANGELOG.md b/CHANGELOG.md index f8978f4db..b5faaaebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,23 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/). unnoticed. The `make.header` heredoc and the `mtxp/make.mtxp` copy that those two jobs carried as KEEP-IN-SYNC steps are now the composite action `.github/actions/provision-build-env`, shared by all three. +- **C ABI: every library reports its state-struct layout, and the Python + wrappers refuse a mismatch (#246).** `X_state_abi_version()` returns + `X_STATE_ABI_VERSION` (now a `PARAMETER` in `X_state.f90`, mirrored by + the `#define` in `X_api.h`; tr and tot stay at 2, eq / fp / ti / wr / wrx + start at 1) and `X_state_size()` the `sizeof` of the `BIND(C)` type, both + callable before `X_init`, for tr / fp / eq / ti / wr / wrx / tot and the + mono image. The C-ABI drivers (`test_negative.c`, `test_run_so.c`) fail + when either differs from the header; `python/Xlib/_ffi.py::load_library` + raises `XlibAbiError` when the library's version or size differs from + `XStateC`, or when the library exports neither symbol (built before this + change), naming the fix. CI runs the six per-module dlopen drivers + (`X_api_check_so`) and `libtotapi_mono_inspect` checks the twelve symbols. + A library and a wrapper from different commits used to fail silently: + a longer library struct wrote past the wrapper's allocation, a shorter + one left the appended fields as plausible zeros. The check protects + callers from this change on; a caller older than it never asks, and + `X_get_state` still takes no buffer size. ### Changed diff --git a/docs/eq-library/architecture.md b/docs/eq-library/architecture.md index f34002cf0..ccd2c9978 100644 --- a/docs/eq-library/architecture.md +++ b/docs/eq-library/architecture.md @@ -71,13 +71,13 @@ parameters (`KNAMEQ`, `KNAMEQ2`, `KNAMWR`, `KNAMWM`, `KNAMFP`, |---|---|---| | 0. Regression baseline | `test_run/baselines/eq_*/metrics.json` | Phase L-0 ground truth (`eq_iter01`, `eq_tst2`) | | 1. Fortran backend | `eq/eq_api.f90`, `eq_param_registry.f90`, `equnit`, `eqcalc`, ... | Numerics, shared with the `eq` binary | -| 2. C ABI | `eq/eq_api.h` + BIND(C) exports in `eq_api.f90` | 6 functions, stable contract | +| 2. C ABI | `eq/eq_api.h` + BIND(C) exports in `eq_api.f90` | 6 functions + 2 layout queries (#246), stable contract | | 3. Shared library | `eq/libeqapi.so` | `make -C eq libeqapi.so`, links `lib*_pic.a` | | 4. Python FFI | `python/eqlib/_ffi.py` | `ctypes` layout mirror of `eq_state_t` | | 5. High-level wrapper | `python/eqlib/eqlib.py`, `state.py`, `errors.py` | Context manager + dataclass + exceptions | | 6. User scripts | `python/eqlib/examples/*.py`, user notebooks | | -### 6 C ABI entry points +### 6 C ABI entry points (+ 2 layout queries, #246) From `eq/eq_api.h`: @@ -216,6 +216,29 @@ Mirrors the tr spec's §12.2 for the EQ module: by the registry. Always use `set_param("PSIB[i]", v)` for `i ∈ 0..5`. +## State struct layout rule (#246) + +`eq_state_t` (`eq/eq_api.h`) and `eq_state_c` (`eq/eq_state.f90`) are +one fixed layout, filled by `eq_get_state`. Changing it: + +1. append the field at the end of both (never reorder, never remove); +2. bump `EQ_STATE_ABI_VERSION` **in both files** (the `#define` and the + `PARAMETER`; currently `1`); +3. mirror the field in `python/eqlib/_ffi.py` (`EqStateC._fields_`) and + `state.py`; +4. rebuild `libeqapi.so` in the same commit. + +The library exports its own view: `eq_state_abi_version()` returns the +PARAMETER and `eq_state_size()` the `sizeof` of the BIND(C) type, both +callable before `eq_init`. `eq/tests/c_abi/test_negative.c` and +`test_run_so.c` fail when either differs from the header, and +`python/eqlib/_ffi.py::load_library` refuses a library whose version or +size differs from `EqStateC`, or that exports neither symbol (built +before #246), with `EqlibAbiError` naming the fix. Both silent +failure modes of a stale pair are therefore caught at load time: a longer +library struct would have written past the wrapper's allocation, a +shorter one would have left the appended fields as plausible zeros. + ## References - Design spec: `docs/superpowers/specs/2026-04-17-tr-library-design.md` diff --git a/docs/fp-library/architecture.md b/docs/fp-library/architecture.md index 308284fd8..23e094035 100644 --- a/docs/fp-library/architecture.md +++ b/docs/fp-library/architecture.md @@ -66,13 +66,13 @@ diagram and per-phase status. |---|---|---| | 0. Regression baseline | `test_run/baselines/fp_{iter01,jt60,dt1}/` | Phase 0 ground truth (tol `1e-10`) | | 1. Fortran backend | `fp/fp_api.f90`, `fp_param_registry.f90`, `fpprep`, `fploop`, ... | Numerics, shared with `fp` binary | -| 2. C ABI | `fp/fp_api.h` + BIND(C) exports in `fp_api.f90` | 5 functions, stable contract | +| 2. C ABI | `fp/fp_api.h` + BIND(C) exports in `fp_api.f90` | 5 functions + 2 layout queries (#246), stable contract | | 3. Shared library | `fp/libfpapi.so` | `make -C fp libs_pic && make -C fp libfpapi.so` | | 4. Python FFI | `python/fplib/_ffi.py` | `ctypes` layout mirror of `fp_state_t` | | 5. High-level wrapper | `python/fplib/fplib.py`, `state.py`, `errors.py` | Context manager + dataclass | | 6. User scripts | `python/fplib/examples/*.py`, user notebooks | | -### 5 C ABI entry points +### 5 C ABI entry points (+ 2 layout queries, #246) From `fp/fp_api.h`: @@ -225,6 +225,29 @@ Mirrors the tr spec's §12.2 for the FP module: - **Single instance per process.** FP globals are module-level state; two concurrent `Fplib()` instances share FPCOMM. +## State struct layout rule (#246) + +`fp_state_t` (`fp/fp_api.h`) and `fp_state_c` (`fp/fp_state.f90`) are +one fixed layout, filled by `fp_get_state`. Changing it: + +1. append the field at the end of both (never reorder, never remove); +2. bump `FP_STATE_ABI_VERSION` **in both files** (the `#define` and the + `PARAMETER`; currently `1`); +3. mirror the field in `python/fplib/_ffi.py` (`FpStateC._fields_`) and + `state.py`; +4. rebuild `libfpapi.so` in the same commit. + +The library exports its own view: `fp_state_abi_version()` returns the +PARAMETER and `fp_state_size()` the `sizeof` of the BIND(C) type, both +callable before `fp_init`. `fp/tests/c_abi/test_negative.c` and +`test_run_so.c` fail when either differs from the header, and +`python/fplib/_ffi.py::load_library` refuses a library whose version or +size differs from `FpStateC`, or that exports neither symbol (built +before #246), with `FplibAbiError` naming the fix. Both silent +failure modes of a stale pair are therefore caught at load time: a longer +library struct would have written past the wrapper's allocation, a +shorter one would have left the appended fields as plausible zeros. + ## References - Design spec: `docs/superpowers/specs/2026-04-17-tr-library-design.md` diff --git a/docs/sphinx/modules/tr/en/extending-tr.md b/docs/sphinx/modules/tr/en/extending-tr.md index 9945e59ee..c77d71dbe 100644 --- a/docs/sphinx/modules/tr/en/extending-tr.md +++ b/docs/sphinx/modules/tr/en/extending-tr.md @@ -126,11 +126,20 @@ consumers may need to be rebuilt or version-checked. - **Per-radius / per-species profile loops** at `:321-330` — for a new array field, follow the `RN` / `RT` / `AJ` / `QP` loop pattern. -5. **Bump `TR_STATE_ABI_VERSION`** at `tr/tr_api.h:38`. - The current value is `2`; bump to the next integer - (currently `3`). +5. **Bump `TR_STATE_ABI_VERSION` in both places**: the + `#define` in `tr/tr_api.h` and the `PARAMETER` in + `tr/tr_state.f90` (#246). The current value is `2`; + bump to the next integer (currently `3`). The library + exports the PARAMETER through `tr_state_abi_version()` + and its `sizeof` through `tr_state_size()`; + `tr/tests/c_abi/test_negative.c` fails when the two + values drift from the header, and `python/trlib/_ffi.py` + refuses a `libtrapi.so` whose version or size differs + from `TrStateC` (`TrlibAbiError`), so a forgotten bump + or a stale build surfaces at load time instead of as + silent zeros. 6. **Mirror in the ctypes side** at - `python/trlib/_ffi.py:94-120` — append a tuple to + `python/trlib/_ffi.py` (`TrStateC._fields_`) — append a tuple to `TrStateC._fields_`. Use the same field order as the BIND-C struct so the two layouts stay byte-compatible. 7. **Surface in the Python `TrState` dataclass** at diff --git a/docs/sphinx/modules/tr/ja/extending-tr.md b/docs/sphinx/modules/tr/ja/extending-tr.md index f77e91778..9e897ee6e 100644 --- a/docs/sphinx/modules/tr/ja/extending-tr.md +++ b/docs/sphinx/modules/tr/ja/extending-tr.md @@ -120,11 +120,18 @@ numbering 規約 (`tr/trcoef_turbulence.f90:392-398` の - **per-radius / per-species profile loops** が `:321-330` — 新配列 field は `RN` / `RT` / `AJ` / `QP` の loop パターンに従う. -5. **`TR_STATE_ABI_VERSION` を bump する.** - `tr/tr_api.h:38` で. 現値は `2`; 次の整数 (現在の - 流れだと `3`) に上げる. +5. **`TR_STATE_ABI_VERSION` を 2 箇所で bump する.** + `tr/tr_api.h` の `#define` と `tr/tr_state.f90` の + `PARAMETER` (#246). 現値は `2`; 次の整数 (現在の流れだと + `3`) に上げる. ライブラリは PARAMETER を + `tr_state_abi_version()` で, `sizeof` を `tr_state_size()` + で export し, `tr/tests/c_abi/test_negative.c` は header と + ずれると落ちる. `python/trlib/_ffi.py` は version か size が + `TrStateC` と違う `libtrapi.so` を load 時に拒否する + (`TrlibAbiError`) ので, bump 忘れや古いビルドは黙った 0 では + なく load 時のエラーとして現れる. 6. **ctypes 側にミラー.** - `python/trlib/_ffi.py:94-120` の `TrStateC._fields_` に + `python/trlib/_ffi.py` (`TrStateC._fields_`) の `TrStateC._fields_` に tuple を append. BIND-C struct と同じ field 順を使う ことで両 layout が byte-compatible に保たれる. 7. **Python `TrState` dataclass に表面化.** diff --git a/docs/superpowers/specs/2026-10-17-c-abi-state-layout-check-design.md b/docs/superpowers/specs/2026-10-17-c-abi-state-layout-check-design.md index 4c0371a1f..50dfbf92d 100644 --- a/docs/superpowers/specs/2026-10-17-c-abi-state-layout-check-design.md +++ b/docs/superpowers/specs/2026-10-17-c-abi-state-layout-check-design.md @@ -1,6 +1,6 @@ # C ABI state structs — runtime layout check (design, #246) -**Status**: design, pre-implementation, awaiting one decision (§7) +**Status**: implemented (option 2 only, one PR, #246 first); §7 records the decisions taken on 2026-10-17 **Date**: 2026-10-17 **Tracking issue**: #246 (raised by the Codex review of #233) **Related**: #187 / #193 (AJRFT, the first `tr_state_t` / `tot_state_t` @@ -72,10 +72,16 @@ and the only downstream consumer (the TASK Web Client, via the MCP servers) goes through the Python wrappers. **Recommendation: (2) now, (1) only if an out-of-tree C consumer -appears.** (2) alone meets goals 1–3: a pre-#246 library has no query -symbol (new caller / old library → refused at load), and a library whose -struct grew reports a larger size than the wrapper's (old caller / new -library → refused at load). +appears.** (2) meets goals 1–3 for every caller that runs the check, +i.e. every wrapper and C driver from this change on: a pre-#246 library +has no query symbol (new caller / old library → refused at load), and a +library whose struct grew reports a larger size than the wrapper's +(old caller / new library → refused at load). What (2) cannot do is +protect a caller that predates it: a wrapper or C program older than +this change never asks, and `X_get_state` still takes no buffer size, +so that pairing can still write past the caller's struct. The only such +callers are this repository's own, which rebuild with the library; an +out-of-tree C consumer that cannot be rebuilt is the case for (1). ## 4. Design (option 2) @@ -107,17 +113,17 @@ side effects, callable before `X_init`): ``` The size comes from the `BIND(C)` type, so the library, not the header, -is authoritative. `probe` is a local of up to ~5 MB for eq (the type is -large); if a compiler objects to the stack frame, `STORAGE_SIZE` of a -dummy with `TYPE(X_state_c), INTENT(IN) :: t` needs no object at all — -pick whichever gfortran 13 and the `-fcheck=all` build accept and use -it in all seven. - -Mono image: `libtotapi_mono.so` co-links every module's objects, so it -exports all fourteen symbols without any tot-level addition; the -version is per module, not per image. The `libtotapi_mono_inspect` -target gets one more `nm` line asserting the seven `_state_size` -symbols are present. +is authoritative. `probe` is a local of 20 to 106 KB (wr 20448, tr 72128, +tot 72144, wrx 70424, eq 80816, ti 105640 bytes); above gfortran's +default `-fmax-stack-var-size` (64 KiB) it is placed in static storage, +never written, so there is no stack concern. `C_SIZEOF` is what the seven +modules use. + +Mono image: `libtotapi_mono.so` co-links the objects of eq, tr, fp, ti, +wrx and tot (wr is not part of it), so it exports those twelve symbols +without any tot-level addition; the version is per module, not per +image. The `libtotapi_mono_inspect` target gets a gate asserting the +twelve symbols are present. ### 4.2 C header: macro mirrors the PARAMETER diff --git a/docs/ti-library/architecture.md b/docs/ti-library/architecture.md index 07920be3e..1f875975a 100644 --- a/docs/ti-library/architecture.md +++ b/docs/ti-library/architecture.md @@ -65,13 +65,13 @@ diagram and per-phase status. |---|---|---| | 0. Regression baseline | `test_run/baselines/ti_*/metrics.json` | Phase 0 ground truth (tol `1e-10`) | | 1. Fortran backend | `ti/ti_api.f90`, `ti_param_registry.f90`, `tiprep`, `tiexec`, ... | Numerics, shared with `ti` binary | -| 2. C ABI | `ti/ti_api.h` + BIND(C) exports in `ti_api.f90` | 5 functions, stable contract | +| 2. C ABI | `ti/ti_api.h` + BIND(C) exports in `ti_api.f90` | 5 functions + 2 layout queries (#246), stable contract | | 3. Shared library | `ti/libtiapi.so` | `make -C ti libtiapi.so` (target lands in L-4) | | 4. Python FFI | `python/tilib/_ffi.py` | `ctypes` layout mirror of `ti_state_t` | | 5. High-level wrapper | `python/tilib/tilib.py`, `state.py`, `errors.py` | Context manager + dataclass | | 6. User scripts | `python/tilib/examples/*.py`, user notebooks | | -### 5 C ABI entry points +### 5 C ABI entry points (+ 2 layout queries, #246) From `ti/ti_api.h`: @@ -173,6 +173,29 @@ Mirrors the tr spec's §12.2 for the TI module: in `ti_param_registry.f90`; the L-6 fixtures list them under `UNREGISTERED_KEYS` and fall back to `ti_init` defaults. +## State struct layout rule (#246) + +`ti_state_t` (`ti/ti_api.h`) and `ti_state_c` (`ti/ti_state.f90`) are +one fixed layout, filled by `ti_get_state`. Changing it: + +1. append the field at the end of both (never reorder, never remove); +2. bump `TI_STATE_ABI_VERSION` **in both files** (the `#define` and the + `PARAMETER`; currently `1`); +3. mirror the field in `python/tilib/_ffi.py` (`TiStateC._fields_`) and + `state.py`; +4. rebuild `libtiapi.so` in the same commit. + +The library exports its own view: `ti_state_abi_version()` returns the +PARAMETER and `ti_state_size()` the `sizeof` of the BIND(C) type, both +callable before `ti_init`. `ti/tests/c_abi/test_negative.c` and +`test_run_so.c` fail when either differs from the header, and +`python/tilib/_ffi.py::load_library` refuses a library whose version or +size differs from `TiStateC`, or that exports neither symbol (built +before #246), with `TiLibAbiError` naming the fix. Both silent +failure modes of a stale pair are therefore caught at load time: a longer +library struct would have written past the wrapper's allocation, a +shorter one would have left the appended fields as plausible zeros. + ## References - Design spec: `docs/superpowers/specs/2026-04-17-tr-library-design.md` diff --git a/docs/tot-library/architecture.md b/docs/tot-library/architecture.md index 035037a1a..8d5cb2e8c 100644 --- a/docs/tot-library/architecture.md +++ b/docs/tot-library/architecture.md @@ -82,13 +82,13 @@ namespace. |---|---|---| | 0. Regression baseline | `test_run/baselines/tot_*/metrics.json` | Phase L-0 ground truth (`tot_iter01`, `tot_demo2014`) | | 1. Fortran orchestrator | `tot/tot_api.f90`, `tot_param_registry.f90`, `tot_state.f90`, `totexec` | Numerics / fan-out, shared with the `tot` binary | -| 2. C ABI | `tot/tot_api.h` + BIND(C) exports in `tot_api.f90` | 6 functions, stable contract, namespaced names | +| 2. C ABI | `tot/tot_api.h` + BIND(C) exports in `tot_api.f90` | 6 functions + 2 layout queries (#246), stable contract, namespaced names | | 3. Shared library | `tot/libtotapi.so` | `make -C tot libtotapi.so`, links the 6 PIC archives + deps | | 4. Python FFI | `python/totlib/_ffi.py` | `ctypes` mirror of `tot_state_t`, `RTLD_LAZY` loader, namespace constants | | 5. High-level wrapper | `python/totlib/totlib.py`, `state.py`, `errors.py` | Context manager, dataclass, namespace guard, exception hierarchy | | 6. User scripts | `python/totlib/examples/*.py`, user notebooks | Quickstart, sweep, state-dump | -### 6 C ABI entry points +### 6 C ABI entry points (+ 2 layout queries, #246) From `tot/tot_api.h`: @@ -243,6 +243,29 @@ Mirrors the tr spec's §12.2 for the TOT module: L-7 plan are deferred; this PR ships only the documentation / examples subset. +## State struct layout rule (#246) + +`tot_state_t` (`tot/tot_api.h`) and `tot_state_c` (`tot/tot_state.f90`) are +one fixed layout, filled by `tot_get_state`. Changing it: + +1. append the field at the end of both (never reorder, never remove); +2. bump `TOT_STATE_ABI_VERSION` **in both files** (the `#define` and the + `PARAMETER`; currently `2`); +3. mirror the field in `python/totlib/_ffi.py` (`TotStateC._fields_`) and + `state.py`; +4. rebuild `libtotapi.so` in the same commit. + +The library exports its own view: `tot_state_abi_version()` returns the +PARAMETER and `tot_state_size()` the `sizeof` of the BIND(C) type, both +callable before `tot_init`. `tot/tests/c_abi/test_negative.c` and +`test_run_so.c` fail when either differs from the header, and +`python/totlib/_ffi.py::load_library` refuses a library whose version or +size differs from `TotStateC`, or that exports neither symbol (built +before #246), with `TotlibAbiError` naming the fix. Both silent +failure modes of a stale pair are therefore caught at load time: a longer +library struct would have written past the wrapper's allocation, a +shorter one would have left the appended fields as plausible zeros. + ## References - Design spec: `docs/superpowers/specs/2026-04-17-tr-library-design.md` diff --git a/docs/tr-library/architecture.md b/docs/tr-library/architecture.md index d9b2ed1ba..4c71c493f 100644 --- a/docs/tr-library/architecture.md +++ b/docs/tr-library/architecture.md @@ -62,13 +62,13 @@ per-phase status. |---|---|---| | 0. Regression baseline | `test_run/baselines/tr_*.json` | Phase 0 ground truth (tol `1e-10`) | | 1. Fortran backend | `tr/tr_api.f90`, `tr_param_registry.f90`, `trloop`, `trcalc`, ... | Numerics, shared with `tr2` | -| 2. C ABI | `tr/tr_api.h` + BIND(C) exports in `tr_api.f90` | 5 functions, stable contract | +| 2. C ABI | `tr/tr_api.h` + BIND(C) exports in `tr_api.f90` | 5 functions + 2 layout queries (#246), stable contract | | 3. Shared library | `tr/libtrapi.so` | `make -C tr libtrapi.so`, links `lib*_pic.a` | | 4. Python FFI | `python/trlib/_ffi.py` | `ctypes` layout mirror of `tr_state_t` | | 5. High-level wrapper | `python/trlib/trlib.py`, `state.py`, `errors.py` | Context manager + dataclass | | 6. User scripts | `python/trlib/examples/*.py`, user notebooks | | -### 5 C ABI entry points +### 5 C ABI entry points (+ 2 layout queries, #246) From `tr/tr_api.h`: @@ -143,6 +143,29 @@ Return codes (`enum tr_error`): | `python/trlib/README.md` with examples | L-7 | visual review + `python3 -m py_compile examples/*.py` | | `run_tests.sh` trlib_* wired | L-6 | `grep trlib_ test_run/test_definitions.conf` | +## State struct layout rule (#246) + +`tr_state_t` (`tr/tr_api.h`) and `tr_state_c` (`tr/tr_state.f90`) are +one fixed layout, filled by `tr_get_state`. Changing it: + +1. append the field at the end of both (never reorder, never remove); +2. bump `TR_STATE_ABI_VERSION` **in both files** (the `#define` and the + `PARAMETER`; currently `2`); +3. mirror the field in `python/trlib/_ffi.py` (`TrStateC._fields_`) and + `state.py`; +4. rebuild `libtrapi.so` in the same commit. + +The library exports its own view: `tr_state_abi_version()` returns the +PARAMETER and `tr_state_size()` the `sizeof` of the BIND(C) type, both +callable before `tr_init`. `tr/tests/c_abi/test_negative.c` and +`test_run_so.c` fail when either differs from the header, and +`python/trlib/_ffi.py::load_library` refuses a library whose version or +size differs from `TrStateC`, or that exports neither symbol (built +before #246), with `TrlibAbiError` naming the fix. Both silent +failure modes of a stale pair are therefore caught at load time: a longer +library struct would have written past the wrapper's allocation, a +shorter one would have left the appended fields as plausible zeros. + ## References - Design spec: `docs/superpowers/specs/2026-04-17-tr-library-design.md` diff --git a/docs/wr-library/architecture.md b/docs/wr-library/architecture.md index e16d21aaa..79eba5690 100644 --- a/docs/wr-library/architecture.md +++ b/docs/wr-library/architecture.md @@ -65,13 +65,13 @@ diagram and per-phase status. |---|---|---| | 0. Regression baseline | `test_run/baselines/wr_*/metrics.json` | Phase 0 ground truth (tol `1e-10`) | | 1. Fortran backend | `wr/wr_api.f90`, `wr_param_registry.f90`, `wr_setup`, `wr_exec`, ... | Numerics, shared with `wr` binary | -| 2. C ABI | `wr/wr_api.h` + BIND(C) exports in `wr_api.f90` | 5 functions, stable contract | +| 2. C ABI | `wr/wr_api.h` + BIND(C) exports in `wr_api.f90` | 5 functions + 2 layout queries (#246), stable contract | | 3. Shared library | `wr/libwrapi.so` | `make -C wr libwrapi.so`, links `lib*_pic.a` | | 4. Python FFI | `python/wrlib/_ffi.py` | `ctypes` layout mirror of `wr_state_t` | | 5. High-level wrapper | `python/wrlib/wrlib.py`, `state.py`, `errors.py` | Context manager + dataclass | | 6. User scripts | `python/wrlib/examples/*.py`, user notebooks | | -### 5 C ABI entry points +### 5 C ABI entry points (+ 2 layout queries, #246) From `wr/wr_api.h`: @@ -199,6 +199,29 @@ Mirrors the tr spec's §12.2 for the WR module: - **String parameters not yet wired** — no character-valued namelist keys are currently registered (design spec §4.3 deferral). +## State struct layout rule (#246) + +`wr_state_t` (`wr/wr_api.h`) and `wr_state_c` (`wr/wr_state.f90`) are +one fixed layout, filled by `wr_get_state`. Changing it: + +1. append the field at the end of both (never reorder, never remove); +2. bump `WR_STATE_ABI_VERSION` **in both files** (the `#define` and the + `PARAMETER`; currently `1`); +3. mirror the field in `python/wrlib/_ffi.py` (`WrStateC._fields_`) and + `state.py`; +4. rebuild `libwrapi.so` in the same commit. + +The library exports its own view: `wr_state_abi_version()` returns the +PARAMETER and `wr_state_size()` the `sizeof` of the BIND(C) type, both +callable before `wr_init`. `wr/tests/c_abi/test_negative.c` and +`test_run_so.c` fail when either differs from the header, and +`python/wrlib/_ffi.py::load_library` refuses a library whose version or +size differs from `WrStateC`, or that exports neither symbol (built +before #246), with `WrlibAbiError` naming the fix. Both silent +failure modes of a stale pair are therefore caught at load time: a longer +library struct would have written past the wrapper's allocation, a +shorter one would have left the appended fields as plausible zeros. + ## References - Design spec: `docs/superpowers/specs/2026-04-17-tr-library-design.md` diff --git a/docs/wrx-library/architecture.md b/docs/wrx-library/architecture.md index 4c5c1e594..7e34fb86f 100644 --- a/docs/wrx-library/architecture.md +++ b/docs/wrx-library/architecture.md @@ -74,13 +74,13 @@ limitation that gates `wrx_run` behind `WRX_RUN_OK=1`. |---|---|---| | 0. Regression baseline | `test_run/baselines/wrx_*/metrics.json` | Phase 0 ground truth (tol `1e-10`) | | 1. Fortran backend | `wrx/wrx_api.f90`, `wrx_param_registry.f90`, `wr_setup`, `wr_exec`, ... | Numerics, shared with `wrx` binary | -| 2. C ABI | `wrx/wrx_api.h` + BIND(C) exports in `wrx_api.f90` | 5 functions, stable contract | +| 2. C ABI | `wrx/wrx_api.h` + BIND(C) exports in `wrx_api.f90` | 5 functions + 2 layout queries (#246), stable contract | | 3. Shared library | `wrx/libwrxapi.so` | `make -C wrx libwrxapi.so`, links `lib*_pic.a` (no `libgrf`) | | 4. Python FFI | `python/wrxlib/_ffi.py` | `ctypes` layout mirror of `wrx_state_t` | | 5. High-level wrapper | `python/wrxlib/wrxlib.py`, `state.py`, `errors.py` | Context manager + dataclass | | 6. User scripts | `python/wrxlib/examples/*.py`, user notebooks | | -### 5 C ABI entry points +### 5 C ABI entry points (+ 2 layout queries, #246) From `wrx/wrx_api.h`: @@ -269,6 +269,29 @@ Mirrors the tr spec's §12.2 for the WRX module: namelist keys are currently registered (design spec §4.3 deferral). +## State struct layout rule (#246) + +`wrx_state_t` (`wrx/wrx_api.h`) and `wrx_state_c` (`wrx/wrx_state.f90`) are +one fixed layout, filled by `wrx_get_state`. Changing it: + +1. append the field at the end of both (never reorder, never remove); +2. bump `WRX_STATE_ABI_VERSION` **in both files** (the `#define` and the + `PARAMETER`; currently `1`); +3. mirror the field in `python/wrxlib/_ffi.py` (`WrxStateC._fields_`) and + `state.py`; +4. rebuild `libwrxapi.so` in the same commit. + +The library exports its own view: `wrx_state_abi_version()` returns the +PARAMETER and `wrx_state_size()` the `sizeof` of the BIND(C) type, both +callable before `wrx_init`. `wrx/tests/c_abi/test_negative.c` and +`test_run_so.c` fail when either differs from the header, and +`python/wrxlib/_ffi.py::load_library` refuses a library whose version or +size differs from `WrxStateC`, or that exports neither symbol (built +before #246), with `WrxlibAbiError` naming the fix. Both silent +failure modes of a stale pair are therefore caught at load time: a longer +library struct would have written past the wrapper's allocation, a +shorter one would have left the appended fields as plausible zeros. + ## References - Design spec: `docs/superpowers/specs/2026-04-17-tr-library-design.md` diff --git a/eq/eq_api.f90 b/eq/eq_api.f90 index 12964d9eb..954e6258f 100644 --- a/eq/eq_api.f90 +++ b/eq/eq_api.f90 @@ -2,8 +2,9 @@ ! ! Phase L-2/L-3: C ABI entry points for libeqapi. ! -! Eight BIND(C) functions are exposed (eq_validate and eq_save are -! described where they are defined, at the end of the module): +! Ten BIND(C) functions are exposed (eq_validate, eq_save and the layout +! queries eq_state_abi_version / eq_state_size are described where they +! are defined, at the end of the module): ! ! eq_init -> marks the library as initialized (idempotent) ! and returns EQ_OK. Does NOT touch the legacy @@ -41,6 +42,7 @@ MODULE eq_api USE, INTRINSIC :: ISO_C_BINDING + USE eq_state, ONLY: EQ_STATE_ABI_VERSION USE eq_state, ONLY: eq_state_c, & EQ_MAX_NRGM, EQ_MAX_NZGM, EQ_MAX_NPSM, & EQ_MAX_NRM, EQ_MAX_NTHM, EQ_MAX_NSUM, & @@ -79,6 +81,9 @@ MODULE eq_api ! counts as they stand, over fields computed for the old ones). LOGICAL, SAVE :: g_has_equilibrium = .FALSE. + ! #246: layout query, callable before eq_init. + PUBLIC :: eq_api_state_abi_version, eq_api_state_size + CONTAINS !------------------------------------------------------------------- @@ -580,4 +585,23 @@ FUNCTION eq_api_save() RESULT(ierr) BIND(C, NAME="eq_save") ierr = EQ_OK END FUNCTION eq_api_save + !------------------------------------------------------------------- + ! eq_state_abi_version / eq_state_size : layout query (#246). + ! + ! No lifecycle check and no side effect; callable before eq_init. + ! A caller compares both with its own sizeof(eq_state_t) and its copy + ! of EQ_STATE_ABI_VERSION before passing a struct to eq_get_state; + ! python/eqlib/_ffi.py refuses the library when either differs. + !------------------------------------------------------------------- + FUNCTION eq_api_state_abi_version() RESULT(v) BIND(C, NAME="eq_state_abi_version") + INTEGER(C_INT) :: v + v = EQ_STATE_ABI_VERSION + END FUNCTION eq_api_state_abi_version + + FUNCTION eq_api_state_size() RESULT(n) BIND(C, NAME="eq_state_size") + INTEGER(C_SIZE_T) :: n + TYPE(eq_state_c) :: probe + n = C_SIZEOF(probe) + END FUNCTION eq_api_state_size + END MODULE eq_api diff --git a/eq/eq_api.h b/eq/eq_api.h index aed3b7854..b4b76edb6 100644 --- a/eq/eq_api.h +++ b/eq/eq_api.h @@ -1,6 +1,8 @@ #ifndef EQ_API_H #define EQ_API_H +#include /* size_t for eq_state_size (#246) */ + #ifdef __cplusplus extern "C" { #endif @@ -40,6 +42,20 @@ extern "C" { #define EQ_MAX_NTHM 2049 #define EQ_MAX_NSUM 1343 +/* + * eq_state_t ABI version. Bumped whenever a field is added/removed/reordered + * in eq_state_t (i.e. whenever sizeof(eq_state_t) changes), together with + * the EQ_STATE_ABI_VERSION PARAMETER in eq_state.f90 that the library + * exports through eq_state_abi_version(); tests/c_abi/test_negative.c + * fails when the two drift. In-tree consumers (the C-ABI drivers, + * python/eqlib/_ffi.py) rebuild from this header and check the library's + * version and sizeof at load time (#246). + * + * History: + * 1 -> initial layout + */ +#define EQ_STATE_ABI_VERSION 1 + /* Error codes returned by every eq_* entry point. */ enum eq_error { EQ_OK = 0, @@ -114,6 +130,13 @@ int eq_set_param_str(const char* name, const char* value); int eq_get_state(eq_state_t* state); int eq_finalize(void); +/* Layout query (#246): the library's EQ_STATE_ABI_VERSION and + sizeof(eq_state_t), callable before eq_init. Compare both with this + header's values before passing a struct to eq_get_state; a mismatch + means the library and this header come from different commits. */ +int eq_state_abi_version(void); +size_t eq_state_size(void); + /* * Issue #143: pre-run parameter validation API. Pilot module = eq. * diff --git a/eq/eq_state.f90 b/eq/eq_state.f90 index cce4f41b0..c8512cc06 100644 --- a/eq/eq_state.f90 +++ b/eq/eq_state.f90 @@ -21,6 +21,7 @@ MODULE eq_state USE, INTRINSIC :: ISO_C_BINDING IMPLICIT NONE PRIVATE + PUBLIC :: EQ_STATE_ABI_VERSION PUBLIC :: eq_state_c, & EQ_MAX_NRGM, EQ_MAX_NZGM, EQ_MAX_NPSM, & EQ_MAX_NRM, EQ_MAX_NTHM, EQ_MAX_NSUM, & @@ -41,6 +42,13 @@ MODULE eq_state INTEGER(C_INT), PARAMETER :: EQ_MAX_NTHM = 2049 INTEGER(C_INT), PARAMETER :: EQ_MAX_NSUM = 1343 + ! Layout version of eq_state_c / eq_state_t (#246). Bump it together + ! with EQ_STATE_ABI_VERSION in eq_api.h whenever a field is added, + ! removed or reordered; eq_state_abi_version() exports it and the C-ABI + ! driver fails when the two drift. eq_state_size() exports the + ! BIND(C) size, so the library, not the header, is authoritative. + INTEGER(C_INT), PARAMETER :: EQ_STATE_ABI_VERSION = 1 + TYPE, BIND(C) :: eq_state_c ! Grid dimensions (mirrors NRGMAX/NZGMAX/NPSMAX/NRMAX/NTHMAX/NSUMAX ! in eqcom1.inc EQPRN2/EQPRN3). diff --git a/eq/tests/c_abi/test_negative.c b/eq/tests/c_abi/test_negative.c index 0e75ed11f..8b6f3f370 100644 --- a/eq/tests/c_abi/test_negative.c +++ b/eq/tests/c_abi/test_negative.c @@ -51,6 +51,22 @@ } while (0) int main(void) { + /* ---- 0: layout query must match this header (#246) ---------- */ + { + int v = eq_state_abi_version(); + size_t n = eq_state_size(); + if (v != EQ_STATE_ABI_VERSION) { + fprintf(stderr, "FAIL abi-version: library reports %d, eq_api.h says %d\n", + v, EQ_STATE_ABI_VERSION); + return (__LINE__); + } + if (n != sizeof(eq_state_t)) { + fprintf(stderr, "FAIL state-size: library reports %lu, sizeof(eq_state_t) is %lu\n", + (unsigned long) n, (unsigned long) sizeof(eq_state_t)); + return (__LINE__); + } + } + int rc; eq_state_t s; diff --git a/eq/tests/c_abi/test_run_so.c b/eq/tests/c_abi/test_run_so.c index 798775d11..8dbb64a60 100644 --- a/eq/tests/c_abi/test_run_so.c +++ b/eq/tests/c_abi/test_run_so.c @@ -19,6 +19,8 @@ * 1..8 = step that failed * 90 = dlopen failed * 91 = dlsym failed (one of the 6 entry points missing) + * 92 = eq_state_abi_version / eq_state_size missing (library predates #246) + * 93 = layout version or size differs from eq_api.h (#246) */ #include #include @@ -68,6 +70,28 @@ int main(void) { return 91; } + /* #246: the layout query must agree with this header before any + * eq_state_t is passed. 92 = symbols missing (library predates + * #246), 93 = version or size differs (library and header from + * different commits). */ + typedef int (*eq_abi_version_fn)(void); + typedef size_t (*eq_state_size_fn)(void); + eq_abi_version_fn f_abi = (eq_abi_version_fn) dlsym(h, "eq_state_abi_version"); + eq_state_size_fn f_size = (eq_state_size_fn) dlsym(h, "eq_state_size"); + if (!f_abi || !f_size) { + fprintf(stderr, "dlsym missing eq_state_abi_version / eq_state_size (#246)\n"); + dlclose(h); + return 92; + } + if (f_abi() != EQ_STATE_ABI_VERSION || f_size() != sizeof(eq_state_t)) { + fprintf(stderr, + "layout mismatch: library v%d / %lu bytes, eq_api.h v%d / %lu bytes\n", + f_abi(), (unsigned long) f_size(), + EQ_STATE_ABI_VERSION, (unsigned long) sizeof(eq_state_t)); + dlclose(h); + return 93; + } + int rc; eq_state_t st; diff --git a/fp/fp_api.f90 b/fp/fp_api.f90 index 7bb1b7148..66e38c096 100644 --- a/fp/fp_api.f90 +++ b/fp/fp_api.f90 @@ -30,6 +30,7 @@ MODULE fp_api USE, INTRINSIC :: ISO_C_BINDING + USE fp_state, ONLY: FP_STATE_ABI_VERSION USE fp_state, ONLY: fp_state_c, FP_MAX_NRMAX, FP_MAX_NSAMAX USE fpcomm, ONLY: rkind, & NRMAX, NSAMAX, NPMAX, NTHMAX, NTG2, TIMEFP, & @@ -66,6 +67,9 @@ MODULE fp_api ! -1 = never prepared. See fp_param_generation in the registry. INTEGER, SAVE :: g_prep_generation = -1 + ! #246: layout query, callable before fp_init. + PUBLIC :: fp_api_state_abi_version, fp_api_state_size + CONTAINS !------------------------------------------------------------------- @@ -376,4 +380,23 @@ FUNCTION fp_api_finalize() RESULT(ierr) BIND(C, NAME="fp_finalize") ierr = FP_OK END FUNCTION fp_api_finalize + !------------------------------------------------------------------- + ! fp_state_abi_version / fp_state_size : layout query (#246). + ! + ! No lifecycle check and no side effect; callable before fp_init. + ! A caller compares both with its own sizeof(fp_state_t) and its copy + ! of FP_STATE_ABI_VERSION before passing a struct to fp_get_state; + ! python/fplib/_ffi.py refuses the library when either differs. + !------------------------------------------------------------------- + FUNCTION fp_api_state_abi_version() RESULT(v) BIND(C, NAME="fp_state_abi_version") + INTEGER(C_INT) :: v + v = FP_STATE_ABI_VERSION + END FUNCTION fp_api_state_abi_version + + FUNCTION fp_api_state_size() RESULT(n) BIND(C, NAME="fp_state_size") + INTEGER(C_SIZE_T) :: n + TYPE(fp_state_c) :: probe + n = C_SIZEOF(probe) + END FUNCTION fp_api_state_size + END MODULE fp_api diff --git a/fp/fp_api.h b/fp/fp_api.h index 38a122466..1b6d96701 100644 --- a/fp/fp_api.h +++ b/fp/fp_api.h @@ -1,6 +1,8 @@ #ifndef FP_API_H #define FP_API_H +#include /* size_t for fp_state_size (#246) */ + #ifdef __cplusplus extern "C" { #endif @@ -37,6 +39,20 @@ extern "C" { #define FP_MAX_NRMAX 100 #define FP_MAX_NSAMAX 8 +/* + * fp_state_t ABI version. Bumped whenever a field is added/removed/reordered + * in fp_state_t (i.e. whenever sizeof(fp_state_t) changes), together with + * the FP_STATE_ABI_VERSION PARAMETER in fp_state.f90 that the library + * exports through fp_state_abi_version(); tests/c_abi/test_negative.c + * fails when the two drift. In-tree consumers (the C-ABI drivers, + * python/fplib/_ffi.py) rebuild from this header and check the library's + * version and sizeof at load time (#246). + * + * History: + * 1 -> initial layout + */ +#define FP_STATE_ABI_VERSION 1 + /* Error codes returned by every fp_* entry point. */ enum fp_error { FP_OK = 0, @@ -68,6 +84,13 @@ int fp_set_param_str(const char* name, const char* value); int fp_get_state(fp_state_t* state); int fp_finalize(void); +/* Layout query (#246): the library's FP_STATE_ABI_VERSION and + sizeof(fp_state_t), callable before fp_init. Compare both with this + header's values before passing a struct to fp_get_state; a mismatch + means the library and this header come from different commits. */ +int fp_state_abi_version(void); +size_t fp_state_size(void); + #ifdef __cplusplus } #endif diff --git a/fp/fp_state.f90 b/fp/fp_state.f90 index 080d9656a..3325ed4b7 100644 --- a/fp/fp_state.f90 +++ b/fp/fp_state.f90 @@ -18,11 +18,19 @@ MODULE fp_state USE, INTRINSIC :: ISO_C_BINDING IMPLICIT NONE PRIVATE + PUBLIC :: FP_STATE_ABI_VERSION PUBLIC :: fp_state_c, FP_MAX_NRMAX, FP_MAX_NSAMAX INTEGER(C_INT), PARAMETER :: FP_MAX_NRMAX = 100 INTEGER(C_INT), PARAMETER :: FP_MAX_NSAMAX = 8 + ! Layout version of fp_state_c / fp_state_t (#246). Bump it together + ! with FP_STATE_ABI_VERSION in fp_api.h whenever a field is added, + ! removed or reordered; fp_state_abi_version() exports it and the C-ABI + ! driver fails when the two drift. fp_state_size() exports the + ! BIND(C) size, so the library, not the header, is authoritative. + INTEGER(C_INT), PARAMETER :: FP_STATE_ABI_VERSION = 1 + TYPE, BIND(C) :: fp_state_c INTEGER(C_INT) :: nrmax INTEGER(C_INT) :: nsamax diff --git a/fp/tests/c_abi/test_negative.c b/fp/tests/c_abi/test_negative.c index 0de450449..dc1ed34b5 100644 --- a/fp/tests/c_abi/test_negative.c +++ b/fp/tests/c_abi/test_negative.c @@ -45,6 +45,22 @@ } while (0) int main(void) { + /* ---- 0: layout query must match this header (#246) ---------- */ + { + int v = fp_state_abi_version(); + size_t n = fp_state_size(); + if (v != FP_STATE_ABI_VERSION) { + fprintf(stderr, "FAIL abi-version: library reports %d, fp_api.h says %d\n", + v, FP_STATE_ABI_VERSION); + return (__LINE__); + } + if (n != sizeof(fp_state_t)) { + fprintf(stderr, "FAIL state-size: library reports %lu, sizeof(fp_state_t) is %lu\n", + (unsigned long) n, (unsigned long) sizeof(fp_state_t)); + return (__LINE__); + } + } + int rc; fp_state_t s; diff --git a/fp/tests/c_abi/test_run_so.c b/fp/tests/c_abi/test_run_so.c index d48ef4a23..4669a5f4c 100644 --- a/fp/tests/c_abi/test_run_so.c +++ b/fp/tests/c_abi/test_run_so.c @@ -16,6 +16,8 @@ * 1..12 = step that failed (mirrors test_run.c numbering) * 90 = dlopen failed * 91 = dlsym failed (one of the 5 entry points missing) + * 92 = fp_state_abi_version / fp_state_size missing (library predates #246) + * 93 = layout version or size differs from fp_api.h (#246) */ #include #include @@ -62,6 +64,28 @@ int main(void) { return 91; } + /* #246: the layout query must agree with this header before any + * fp_state_t is passed. 92 = symbols missing (library predates + * #246), 93 = version or size differs (library and header from + * different commits). */ + typedef int (*fp_abi_version_fn)(void); + typedef size_t (*fp_state_size_fn)(void); + fp_abi_version_fn f_abi = (fp_abi_version_fn) dlsym(h, "fp_state_abi_version"); + fp_state_size_fn f_size = (fp_state_size_fn) dlsym(h, "fp_state_size"); + if (!f_abi || !f_size) { + fprintf(stderr, "dlsym missing fp_state_abi_version / fp_state_size (#246)\n"); + dlclose(h); + return 92; + } + if (f_abi() != FP_STATE_ABI_VERSION || f_size() != sizeof(fp_state_t)) { + fprintf(stderr, + "layout mismatch: library v%d / %lu bytes, fp_api.h v%d / %lu bytes\n", + f_abi(), (unsigned long) f_size(), + FP_STATE_ABI_VERSION, (unsigned long) sizeof(fp_state_t)); + dlclose(h); + return 93; + } + /* Mirror the test_run.c cycle so the .so is proven to be a drop-in * replacement for the static libfp.a link path. */ int rc; diff --git a/python/_abi_check.py b/python/_abi_check.py new file mode 100644 index 000000000..bcd62dc1a --- /dev/null +++ b/python/_abi_check.py @@ -0,0 +1,101 @@ +"""Refuse a libapi.so whose state-struct layout differs from the wrapper's. + +Every module's C ABI fills one fixed-layout struct (``tr_state_t``, ...), +and the only rule for changing it is "append, bump the version, rebuild in +the same commit". Nothing used to check that at run time, so a library +and a wrapper from different commits failed silently (#246): a library +with a longer struct wrote past the wrapper's allocation, a library with a +shorter one left the appended fields as plausible zeros. + +Since #246 each library exports two functions, callable before init:: + + int _state_abi_version(void); /* _STATE_ABI_VERSION */ + size_t _state_size(void); /* sizeof(_state_t) */ + +:func:`check_state_layout` compares both with the wrapper's ctypes struct +and its copy of the version constant, and raises :class:`AbiMismatchError` +on any difference, including the two symbols being absent (a library +built before #246). Each wrapper's ``_ffi.load_library`` calls it right +after ``ctypes.CDLL``, before ``_apply_prototypes`` and before any struct +is passed, and re-raises as its own error class so ``except libError`` +keeps working. +""" +from __future__ import annotations + +import ctypes +from typing import Any + + +class AbiMismatchError(RuntimeError): + """The library's state-struct layout is not the one this wrapper has. + + Attributes carry what was compared so a caller can report precisely: + ``library_version`` / ``library_size`` are ``None`` when the library + does not export the query at all. + """ + + def __init__(self, message: str, *, prefix: str, lib_name: str, + wrapper_version: int, wrapper_size: int, + library_version: int | None, library_size: int | None): + super().__init__(message) + self.prefix = prefix + self.lib_name = lib_name + self.wrapper_version = wrapper_version + self.wrapper_size = wrapper_size + self.library_version = library_version + self.library_size = library_size + + +def check_state_layout(lib: Any, *, prefix: str, struct: type, + expected_version: int, lib_name: str) -> None: + """Raise :class:`AbiMismatchError` unless ``lib`` fills ``struct``'s layout. + + ``lib`` is a loaded ``ctypes.CDLL`` (or anything with the same + attribute access, for tests). ``prefix`` is the C symbol prefix + (``"tr"``, ``"fp"``, ...), ``struct`` the wrapper's ``ctypes.Structure`` + subclass, ``expected_version`` the wrapper's copy of + ``_STATE_ABI_VERSION``, and ``lib_name`` the file name used in the + message (``"libtrapi.so"``). + + On success the two query symbols are left with their prototypes + attached, so a caller may call them again (``lib.tr_state_size()``). + """ + wrapper_size = ctypes.sizeof(struct) + # The rebuild hint: the mono image is built under tot, everything + # else under its own module directory. + if lib_name.startswith("libtotapi_mono"): + rebuild = f"`make -C tot {lib_name}`" + else: + rebuild = f"`make -C {prefix} {lib_name}`" + try: + fn_version = getattr(lib, f"{prefix}_state_abi_version") + fn_size = getattr(lib, f"{prefix}_state_size") + except AttributeError as exc: + raise AbiMismatchError( + f"{lib_name} does not export {prefix}_state_abi_version / " + f"{prefix}_state_size: it was built before the layout check " + f"(#246) and may not match this wrapper's {struct.__name__} " + f"(v{expected_version}, {wrapper_size} bytes). Rebuild it: " + f"{rebuild}.", + prefix=prefix, lib_name=lib_name, + wrapper_version=expected_version, wrapper_size=wrapper_size, + library_version=None, library_size=None, + ) from exc + fn_version.restype = ctypes.c_int + fn_version.argtypes = [] + fn_size.restype = ctypes.c_size_t + fn_size.argtypes = [] + library_version = int(fn_version()) + library_size = int(fn_size()) + if library_version != expected_version or library_size != wrapper_size: + raise AbiMismatchError( + f"{lib_name} state layout is v{library_version} / " + f"{library_size} bytes; this wrapper's {struct.__name__} is " + f"v{expected_version} / {wrapper_size} bytes. The library and " + f"the wrapper come from different commits: rebuild the library " + f"({rebuild}) or update " + f"python/{prefix}lib/_ffi.py.", + prefix=prefix, lib_name=lib_name, + wrapper_version=expected_version, wrapper_size=wrapper_size, + library_version=library_version, library_size=library_size, + ) diff --git a/python/eqlib/README.md b/python/eqlib/README.md index 73725cdd8..1c01fd096 100644 --- a/python/eqlib/README.md +++ b/python/eqlib/README.md @@ -101,6 +101,18 @@ See `examples/` for runnable scripts: All three accept `--dry-run` so you can validate argument parsing on a machine without `libeqapi.so` built. +## Library layout check (#246) + +`load_library()` asks `libeqapi.so` for its `eq_state_t` layout +(`eq_state_abi_version()`, `eq_state_size()`) and refuses it with +`EqlibAbiError` when the version or the size differs from this wrapper's +`EqStateC`, or when the library exports neither symbol (built before +#246). The message names the fix: rebuild the library +(`make -C eq libeqapi.so`) or update the wrapper. A library and a +wrapper from different commits used to fail silently (fields written +past the struct, or appended fields read as `0.0`). A wrapper older +than #246 does not ask, so keep the two from the same commit. + ## API reference ### `Eq(lib_path: str | None = None)` diff --git a/python/eqlib/__init__.py b/python/eqlib/__init__.py index aba8eed4e..06b3f29e7 100644 --- a/python/eqlib/__init__.py +++ b/python/eqlib/__init__.py @@ -28,6 +28,7 @@ EqlibNotInitializedError, EqlibCalculationFailedError, EqlibNotImplementedError, + EqlibAbiError, EqLibError, EqLibInvalidParam, EqLibNotInitialized, @@ -48,6 +49,7 @@ "EqlibNotInitializedError", "EqlibCalculationFailedError", "EqlibNotImplementedError", + "EqlibAbiError", "EqLibError", "EqLibInvalidParam", "EqLibNotInitialized", diff --git a/python/eqlib/_ffi.py b/python/eqlib/_ffi.py index 2c5bce0e8..e7810f1ad 100644 --- a/python/eqlib/_ffi.py +++ b/python/eqlib/_ffi.py @@ -23,8 +23,11 @@ from pathlib import Path from typing import Optional +from _abi_check import AbiMismatchError, check_state_layout from _runtime_mode import mono_lib_path +from .errors import EqlibAbiError + # Optional numpy (we never require it; state.py uses lists). try: import numpy as _np # noqa: F401 @@ -45,6 +48,12 @@ EQ_MAX_NTHM = 2049 EQ_MAX_NSUM = 1343 +# eq_state_t layout version; mirrors EQ_STATE_ABI_VERSION in eq/eq_api.h +# and eq/eq_state.f90. load_library() refuses a library whose +# eq_state_abi_version() / eq_state_size() differ from this and from +# ctypes.sizeof(EqStateC) (#246). +EQ_STATE_ABI_VERSION = 1 + # --------------------------------------------------------------------- # Error codes (kept in sync with eq_api.h::enum eq_error). @@ -279,6 +288,15 @@ def load_library(path: Optional[str] = None) -> ctypes.CDLL: # back to the numeric constant 1 which matches glibc dlfcn.h. mode = getattr(ctypes, "RTLD_LAZY", _RTLD_LAZY) lib = ctypes.CDLL(str(p), mode=mode) + # #246: refuse a library whose eq_state_t layout is not EqStateC's, + # before any struct is passed. A library built before #246 has no + # query symbols and is refused too (it may or may not match). + try: + check_state_layout(lib, prefix="eq", struct=EqStateC, + expected_version=EQ_STATE_ABI_VERSION, + lib_name=os.path.basename(str(p))) + except AbiMismatchError as exc: + raise EqlibAbiError(str(exc)) from exc return _apply_prototypes(lib) diff --git a/python/eqlib/errors.py b/python/eqlib/errors.py index 88f908bac..b7dde6dcf 100644 --- a/python/eqlib/errors.py +++ b/python/eqlib/errors.py @@ -87,6 +87,7 @@ def raise_for_rc(func: str, rc: int) -> None: "EqlibNotInitializedError", "EqlibCalculationFailedError", "EqlibNotImplementedError", + "EqlibAbiError", "EqLibError", "EqLibInvalidParam", "EqLibNotInitialized", @@ -95,3 +96,13 @@ def raise_for_rc(func: str, rc: int) -> None: "raise_for_rc", "raise_for_ierr", ] + +class EqlibAbiError(EqlibError): + """libeqapi.so's eq_state_t layout is not the one this wrapper has (#246). + + Raised by :func:`eqlib._ffi.load_library` before any struct is passed: + the library does not export ``eq_state_abi_version`` / ``eq_state_size`` + (built before the layout check), or its version or sizeof differs from + ``eqlib._ffi.EqStateC``. The message names the fix (rebuild the + library, or update the wrapper). + """ diff --git a/python/eqlib/tests/test_ffi.py b/python/eqlib/tests/test_ffi.py index 3be65ccbe..c2db9ce00 100644 --- a/python/eqlib/tests/test_ffi.py +++ b/python/eqlib/tests/test_ffi.py @@ -163,5 +163,64 @@ def test_prototypes_applied(self): ) +class TestLibraryLayout(unittest.TestCase): + """#246: the library's eq_state_t layout must be EqStateC's. + + ``load_library`` compares ``eq_state_abi_version()`` / ``eq_state_size()`` + with ``EQ_STATE_ABI_VERSION`` / ``ctypes.sizeof(EqStateC)`` and raises + ``EqlibAbiError`` on any difference. The guards run in ``setUp``: the + ``.so`` is a mutable precondition. + """ + + def setUp(self): + if not _ffi._default_lib_path().exists(): + self.skipTest(f"libeqapi.so not built at {_ffi._default_lib_path()}") + self.lib = _ffi.load_library() + + def test_library_layout_matches_wrapper(self): + self.assertEqual(self.lib.eq_state_abi_version(), _ffi.EQ_STATE_ABI_VERSION) + self.assertEqual(self.lib.eq_state_size(), ctypes.sizeof(_ffi.EqStateC)) + + def test_stale_wrapper_layout_is_refused(self): + """Old wrapper, new library: one field short, the library would + write past the allocation. Refused before any struct is passed.""" + from _abi_check import AbiMismatchError, check_state_layout + + class Stale(ctypes.Structure): + _fields_ = _ffi.EqStateC._fields_[:-1] + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(self.lib, prefix="eq", struct=Stale, + expected_version=_ffi.EQ_STATE_ABI_VERSION, + lib_name="libeqapi.so") + self.assertEqual(ctx.exception.library_size, ctypes.sizeof(_ffi.EqStateC)) + self.assertEqual(ctx.exception.wrapper_size, ctypes.sizeof(Stale)) + + def test_version_drift_is_refused_through_load_library(self): + """A reorder keeps the size; the version tells. Exercised through + load_library so the wrapper's own error class is what callers see.""" + from unittest import mock + from eqlib.errors import EqlibAbiError + + with mock.patch.object(_ffi, "EQ_STATE_ABI_VERSION", + _ffi.EQ_STATE_ABI_VERSION + 1): + with self.assertRaises(EqlibAbiError) as ctx: + _ffi.load_library() + self.assertIn("different commits", str(ctx.exception)) + + def test_library_without_the_query_is_refused(self): + """A library built before #246 exports neither symbol.""" + from _abi_check import AbiMismatchError, check_state_layout + + class _NoQuery: + pass + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(_NoQuery(), prefix="eq", struct=_ffi.EqStateC, + expected_version=_ffi.EQ_STATE_ABI_VERSION, + lib_name="libeqapi.so") + self.assertIn("before the layout check", str(ctx.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/python/fplib/README.md b/python/fplib/README.md index 8e054dcb2..2e211c1ec 100644 --- a/python/fplib/README.md +++ b/python/fplib/README.md @@ -114,6 +114,18 @@ See `examples/` for runnable scripts: - `examples/parameter_sweep.py` — 3×3 RR × BB grid - `examples/state_dump.py` — single run, full `FpState.to_dict()` as JSON +## Library layout check (#246) + +`load_library()` asks `libfpapi.so` for its `fp_state_t` layout +(`fp_state_abi_version()`, `fp_state_size()`) and refuses it with +`FplibAbiError` when the version or the size differs from this wrapper's +`FpStateC`, or when the library exports neither symbol (built before +#246). The message names the fix: rebuild the library +(`make -C fp libfpapi.so`) or update the wrapper. A library and a +wrapper from different commits used to fail silently (fields written +past the struct, or appended fields read as `0.0`). A wrapper older +than #246 does not ask, so keep the two from the same commit. + ## API reference ### `Fplib(lib_path: str | None = None)` diff --git a/python/fplib/__init__.py b/python/fplib/__init__.py index ddbad8e82..438e2ca0c 100644 --- a/python/fplib/__init__.py +++ b/python/fplib/__init__.py @@ -23,6 +23,7 @@ FplibNotInitError, FplibCalcFailedError, FplibNotImplementedError, + FplibAbiError, FplibOverflowError, # Aliases kept for spec-style naming. FpLibError, @@ -43,6 +44,7 @@ "FplibNotInitError", "FplibCalcFailedError", "FplibNotImplementedError", + "FplibAbiError", "FplibOverflowError", "FpLibError", "FpLibInvalidParam", diff --git a/python/fplib/_ffi.py b/python/fplib/_ffi.py index 1828ff2ea..135e4db1d 100644 --- a/python/fplib/_ffi.py +++ b/python/fplib/_ffi.py @@ -23,8 +23,11 @@ from pathlib import Path from typing import Optional +from _abi_check import AbiMismatchError, check_state_layout from _runtime_mode import mono_lib_path +from .errors import FplibAbiError + # Optional numpy (we never require it; state.py uses lists). try: import numpy as _np # noqa: F401 @@ -39,6 +42,12 @@ FP_MAX_NRMAX = 100 FP_MAX_NSAMAX = 8 +# fp_state_t layout version; mirrors FP_STATE_ABI_VERSION in fp/fp_api.h +# and fp/fp_state.f90. load_library() refuses a library whose +# fp_state_abi_version() / fp_state_size() differ from this and from +# ctypes.sizeof(FpStateC) (#246). +FP_STATE_ABI_VERSION = 1 + # --------------------------------------------------------------------- # Error codes (kept in sync with fp_api.h::enum fp_error). @@ -165,6 +174,15 @@ def load_library(path: Optional[str] = None) -> ctypes.CDLL: # back to the numeric constant 1 which matches glibc dlfcn.h. mode = getattr(ctypes, "RTLD_LAZY", _RTLD_LAZY) lib = ctypes.CDLL(str(p), mode=mode) + # #246: refuse a library whose fp_state_t layout is not FpStateC's, + # before any struct is passed. A library built before #246 has no + # query symbols and is refused too (it may or may not match). + try: + check_state_layout(lib, prefix="fp", struct=FpStateC, + expected_version=FP_STATE_ABI_VERSION, + lib_name=os.path.basename(str(p))) + except AbiMismatchError as exc: + raise FplibAbiError(str(exc)) from exc return _apply_prototypes(lib) diff --git a/python/fplib/errors.py b/python/fplib/errors.py index 4e93bd672..7898e3537 100644 --- a/python/fplib/errors.py +++ b/python/fplib/errors.py @@ -67,3 +67,13 @@ def raise_for_rc(func: str, rc: int) -> None: # Also export under the plan-draft name for parity with older docs. raise_for_code = raise_for_rc + +class FplibAbiError(FplibError): + """libfpapi.so's fp_state_t layout is not the one this wrapper has (#246). + + Raised by :func:`fplib._ffi.load_library` before any struct is passed: + the library does not export ``fp_state_abi_version`` / ``fp_state_size`` + (built before the layout check), or its version or sizeof differs from + ``fplib._ffi.FpStateC``. The message names the fix (rebuild the + library, or update the wrapper). + """ diff --git a/python/fplib/tests/test_ffi.py b/python/fplib/tests/test_ffi.py index c41aa12f2..3b18ead9c 100644 --- a/python/fplib/tests/test_ffi.py +++ b/python/fplib/tests/test_ffi.py @@ -142,5 +142,64 @@ def test_prototypes_applied(self): ) +class TestLibraryLayout(unittest.TestCase): + """#246: the library's fp_state_t layout must be FpStateC's. + + ``load_library`` compares ``fp_state_abi_version()`` / ``fp_state_size()`` + with ``FP_STATE_ABI_VERSION`` / ``ctypes.sizeof(FpStateC)`` and raises + ``FplibAbiError`` on any difference. The guards run in ``setUp``: the + ``.so`` is a mutable precondition. + """ + + def setUp(self): + if not _ffi._default_lib_path().exists(): + self.skipTest(f"libfpapi.so not built at {_ffi._default_lib_path()}") + self.lib = _ffi.load_library() + + def test_library_layout_matches_wrapper(self): + self.assertEqual(self.lib.fp_state_abi_version(), _ffi.FP_STATE_ABI_VERSION) + self.assertEqual(self.lib.fp_state_size(), ctypes.sizeof(_ffi.FpStateC)) + + def test_stale_wrapper_layout_is_refused(self): + """Old wrapper, new library: one field short, the library would + write past the allocation. Refused before any struct is passed.""" + from _abi_check import AbiMismatchError, check_state_layout + + class Stale(ctypes.Structure): + _fields_ = _ffi.FpStateC._fields_[:-1] + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(self.lib, prefix="fp", struct=Stale, + expected_version=_ffi.FP_STATE_ABI_VERSION, + lib_name="libfpapi.so") + self.assertEqual(ctx.exception.library_size, ctypes.sizeof(_ffi.FpStateC)) + self.assertEqual(ctx.exception.wrapper_size, ctypes.sizeof(Stale)) + + def test_version_drift_is_refused_through_load_library(self): + """A reorder keeps the size; the version tells. Exercised through + load_library so the wrapper's own error class is what callers see.""" + from unittest import mock + from fplib.errors import FplibAbiError + + with mock.patch.object(_ffi, "FP_STATE_ABI_VERSION", + _ffi.FP_STATE_ABI_VERSION + 1): + with self.assertRaises(FplibAbiError) as ctx: + _ffi.load_library() + self.assertIn("different commits", str(ctx.exception)) + + def test_library_without_the_query_is_refused(self): + """A library built before #246 exports neither symbol.""" + from _abi_check import AbiMismatchError, check_state_layout + + class _NoQuery: + pass + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(_NoQuery(), prefix="fp", struct=_ffi.FpStateC, + expected_version=_ffi.FP_STATE_ABI_VERSION, + lib_name="libfpapi.so") + self.assertIn("before the layout check", str(ctx.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/python/tests/test_abi_check.py b/python/tests/test_abi_check.py new file mode 100644 index 000000000..eb523e945 --- /dev/null +++ b/python/tests/test_abi_check.py @@ -0,0 +1,114 @@ +"""python/_abi_check.py: the layout check that every wrapper runs at load (#246). + +No shared library is needed: a stand-in object plays the ``ctypes.CDLL``. +The real libraries are covered by ``TestLibraryLayout`` in each wrapper's +``tests/test_ffi.py``. +""" +from __future__ import annotations + +import ctypes +import sys +import unittest +from pathlib import Path + +PYTHON_ROOT = Path(__file__).resolve().parents[1] +if str(PYTHON_ROOT) not in sys.path: + sys.path.insert(0, str(PYTHON_ROOT)) + +from _abi_check import AbiMismatchError, check_state_layout # noqa: E402 + + +class _State(ctypes.Structure): + _fields_ = [("nt", ctypes.c_int), ("x", ctypes.c_double * 4)] + + +class _Fn: + """A ctypes-function stand-in: callable, accepts restype/argtypes.""" + + def __init__(self, value): + self._value = value + self.restype = None + self.argtypes = None + + def __call__(self): + return self._value + + +class _Lib: + def __init__(self, version=None, size=None): + if version is not None: + self.xx_state_abi_version = _Fn(version) + if size is not None: + self.xx_state_size = _Fn(size) + + +def _check(lib, version=3): + check_state_layout(lib, prefix="xx", struct=_State, + expected_version=version, lib_name="libxxapi.so") + + +class TestCheckStateLayout(unittest.TestCase): + def test_matching_library_passes_and_attaches_prototypes(self): + lib = _Lib(version=3, size=ctypes.sizeof(_State)) + _check(lib) + self.assertIs(lib.xx_state_abi_version.restype, ctypes.c_int) + self.assertIs(lib.xx_state_size.restype, ctypes.c_size_t) + self.assertEqual(lib.xx_state_abi_version.argtypes, []) + + def test_library_without_the_symbols_is_refused(self): + with self.assertRaises(AbiMismatchError) as ctx: + _check(_Lib()) + err = ctx.exception + self.assertIsNone(err.library_version) + self.assertIsNone(err.library_size) + self.assertEqual(err.wrapper_size, ctypes.sizeof(_State)) + self.assertIn("before the layout check", str(err)) + self.assertIn("make -C xx libxxapi.so", str(err)) + + def test_library_with_only_one_symbol_is_refused(self): + with self.assertRaises(AbiMismatchError): + _check(_Lib(version=3)) + + def test_larger_library_struct_is_refused(self): + """Old wrapper, new library: the library would write past the struct.""" + with self.assertRaises(AbiMismatchError) as ctx: + _check(_Lib(version=3, size=ctypes.sizeof(_State) + 56)) + err = ctx.exception + self.assertEqual(err.library_size, ctypes.sizeof(_State) + 56) + self.assertEqual(err.wrapper_size, ctypes.sizeof(_State)) + self.assertIn(str(ctypes.sizeof(_State) + 56), str(err)) + self.assertIn(str(ctypes.sizeof(_State)), str(err)) + + def test_smaller_library_struct_is_refused(self): + """New wrapper, old library: appended fields would stay zero.""" + with self.assertRaises(AbiMismatchError): + _check(_Lib(version=3, size=ctypes.sizeof(_State) - 8)) + + def test_version_drift_with_equal_size_is_refused(self): + """A reorder keeps the size; only the version tells.""" + with self.assertRaises(AbiMismatchError) as ctx: + _check(_Lib(version=4, size=ctypes.sizeof(_State))) + self.assertEqual(ctx.exception.library_version, 4) + self.assertEqual(ctx.exception.wrapper_version, 3) + + def test_rebuild_hint_names_the_module_directory(self): + with self.assertRaises(AbiMismatchError) as ctx: + _check(_Lib()) + self.assertIn("`make -C xx libxxapi.so`", str(ctx.exception)) + + def test_rebuild_hint_for_the_mono_image_points_at_tot(self): + """A wrapper routed to libtotapi_mono.so must not say `make -C eq + libtotapi_mono.so`: the mono image is built under tot.""" + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(_Lib(version=4, size=ctypes.sizeof(_State)), + prefix="xx", struct=_State, expected_version=3, + lib_name="libtotapi_mono.so") + self.assertIn("`make -C tot libtotapi_mono.so`", str(ctx.exception)) + self.assertNotIn("make -C xx", str(ctx.exception)) + + def test_error_is_a_runtime_error(self): + self.assertTrue(issubclass(AbiMismatchError, RuntimeError)) + + +if __name__ == "__main__": + unittest.main() diff --git a/python/tilib/README.md b/python/tilib/README.md index 826ff407d..e58872565 100644 --- a/python/tilib/README.md +++ b/python/tilib/README.md @@ -97,6 +97,18 @@ See `examples/` for runnable scripts: - `examples/parameter_sweep.py` — 3×3 NRMAX × BB grid - `examples/state_dump.py` — single run, full `TiState.to_dict()` as JSON +## Library layout check (#246) + +`load_library()` asks `libtiapi.so` for its `ti_state_t` layout +(`ti_state_abi_version()`, `ti_state_size()`) and refuses it with +`TilibAbiError` when the version or the size differs from this wrapper's +`TiStateC`, or when the library exports neither symbol (built before +#246). The message names the fix: rebuild the library +(`make -C ti libtiapi.so`) or update the wrapper. A library and a +wrapper from different commits used to fail silently (fields written +past the struct, or appended fields read as `0.0`). A wrapper older +than #246 does not ask, so keep the two from the same commit. + ## API reference ### `TiLib(lib_path: str | None = None)` diff --git a/python/tilib/__init__.py b/python/tilib/__init__.py index 3e242de78..30c598931 100644 --- a/python/tilib/__init__.py +++ b/python/tilib/__init__.py @@ -23,6 +23,7 @@ TilibStateError, TilibRunError, TilibNotImplementedError, + TilibAbiError, # Aliases kept for spec-style naming. TiLibError, TiLibInvalidParam, @@ -42,6 +43,7 @@ "TilibStateError", "TilibRunError", "TilibNotImplementedError", + "TilibAbiError", "TiLibError", "TiLibInvalidParam", "TiLibNotInitialized", diff --git a/python/tilib/_ffi.py b/python/tilib/_ffi.py index 51a40c7b3..d6f80dd95 100644 --- a/python/tilib/_ffi.py +++ b/python/tilib/_ffi.py @@ -23,8 +23,11 @@ from pathlib import Path from typing import Optional +from _abi_check import AbiMismatchError, check_state_layout from _runtime_mode import mono_lib_path +from .errors import TilibAbiError + # Optional numpy (we never require it; state.py uses lists). try: import numpy as _np # noqa: F401 @@ -39,6 +42,12 @@ TI_MAX_NRMAX = 200 TI_MAX_NSA_MAX = 20 +# ti_state_t layout version; mirrors TI_STATE_ABI_VERSION in ti/ti_api.h +# and ti/ti_state.f90. load_library() refuses a library whose +# ti_state_abi_version() / ti_state_size() differ from this and from +# ctypes.sizeof(TiStateC) (#246). +TI_STATE_ABI_VERSION = 1 + # --------------------------------------------------------------------- # Error codes (kept in sync with ti_api.h::enum ti_error). @@ -167,6 +176,15 @@ def load_library(path: Optional[str] = None) -> ctypes.CDLL: # back to the numeric constant 1 which matches glibc dlfcn.h. mode = getattr(ctypes, "RTLD_LAZY", _RTLD_LAZY) lib = ctypes.CDLL(str(p), mode=mode) + # #246: refuse a library whose ti_state_t layout is not TiStateC's, + # before any struct is passed. A library built before #246 has no + # query symbols and is refused too (it may or may not match). + try: + check_state_layout(lib, prefix="ti", struct=TiStateC, + expected_version=TI_STATE_ABI_VERSION, + lib_name=os.path.basename(str(p))) + except AbiMismatchError as exc: + raise TilibAbiError(str(exc)) from exc return _apply_prototypes(lib) diff --git a/python/tilib/errors.py b/python/tilib/errors.py index 4b8675bb0..a3e52f794 100644 --- a/python/tilib/errors.py +++ b/python/tilib/errors.py @@ -69,6 +69,7 @@ def raise_for_ierr(func: str, ierr: int) -> None: "TilibStateError", "TilibRunError", "TilibNotImplementedError", + "TilibAbiError", "TiLibError", "TiLibInvalidParam", "TiLibNotInitialized", @@ -76,3 +77,13 @@ def raise_for_ierr(func: str, ierr: int) -> None: "TiLibNotImplemented", "raise_for_ierr", ] + +class TilibAbiError(TilibError): + """libtiapi.so's ti_state_t layout is not the one this wrapper has (#246). + + Raised by :func:`tilib._ffi.load_library` before any struct is passed: + the library does not export ``ti_state_abi_version`` / ``ti_state_size`` + (built before the layout check), or its version or sizeof differs from + ``tilib._ffi.TiStateC``. The message names the fix (rebuild the + library, or update the wrapper). + """ diff --git a/python/tilib/tests/test_ffi.py b/python/tilib/tests/test_ffi.py index 6a3cb8f24..958a7b1ef 100644 --- a/python/tilib/tests/test_ffi.py +++ b/python/tilib/tests/test_ffi.py @@ -152,5 +152,64 @@ def test_prototypes_applied(self): ) +class TestLibraryLayout(unittest.TestCase): + """#246: the library's ti_state_t layout must be TiStateC's. + + ``load_library`` compares ``ti_state_abi_version()`` / ``ti_state_size()`` + with ``TI_STATE_ABI_VERSION`` / ``ctypes.sizeof(TiStateC)`` and raises + ``TilibAbiError`` on any difference. The guards run in ``setUp``: the + ``.so`` is a mutable precondition. + """ + + def setUp(self): + if not _ffi._default_lib_path().exists(): + self.skipTest(f"libtiapi.so not built at {_ffi._default_lib_path()}") + self.lib = _ffi.load_library() + + def test_library_layout_matches_wrapper(self): + self.assertEqual(self.lib.ti_state_abi_version(), _ffi.TI_STATE_ABI_VERSION) + self.assertEqual(self.lib.ti_state_size(), ctypes.sizeof(_ffi.TiStateC)) + + def test_stale_wrapper_layout_is_refused(self): + """Old wrapper, new library: one field short, the library would + write past the allocation. Refused before any struct is passed.""" + from _abi_check import AbiMismatchError, check_state_layout + + class Stale(ctypes.Structure): + _fields_ = _ffi.TiStateC._fields_[:-1] + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(self.lib, prefix="ti", struct=Stale, + expected_version=_ffi.TI_STATE_ABI_VERSION, + lib_name="libtiapi.so") + self.assertEqual(ctx.exception.library_size, ctypes.sizeof(_ffi.TiStateC)) + self.assertEqual(ctx.exception.wrapper_size, ctypes.sizeof(Stale)) + + def test_version_drift_is_refused_through_load_library(self): + """A reorder keeps the size; the version tells. Exercised through + load_library so the wrapper's own error class is what callers see.""" + from unittest import mock + from tilib.errors import TilibAbiError + + with mock.patch.object(_ffi, "TI_STATE_ABI_VERSION", + _ffi.TI_STATE_ABI_VERSION + 1): + with self.assertRaises(TilibAbiError) as ctx: + _ffi.load_library() + self.assertIn("different commits", str(ctx.exception)) + + def test_library_without_the_query_is_refused(self): + """A library built before #246 exports neither symbol.""" + from _abi_check import AbiMismatchError, check_state_layout + + class _NoQuery: + pass + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(_NoQuery(), prefix="ti", struct=_ffi.TiStateC, + expected_version=_ffi.TI_STATE_ABI_VERSION, + lib_name="libtiapi.so") + self.assertIn("before the layout check", str(ctx.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/python/totlib/README.md b/python/totlib/README.md index 3e65afcaf..1a0ac95a2 100644 --- a/python/totlib/README.md +++ b/python/totlib/README.md @@ -106,6 +106,18 @@ See `examples/` for runnable scripts (all support `--dry-run`): - `examples/parameter_sweep.py` — `eq:RR` × `eq:BB` grid - `examples/state_dump.py` — single run, `TotState.to_dict()` as JSON +## Library layout check (#246) + +`load_library()` asks `libtotapi.so` for its `tot_state_t` layout +(`tot_state_abi_version()`, `tot_state_size()`) and refuses it with +`TotlibAbiError` when the version or the size differs from this wrapper's +`TotStateC`, or when the library exports neither symbol (built before +#246). The message names the fix: rebuild the library +(`make -C tot libtotapi.so`) or update the wrapper. A library and a +wrapper from different commits used to fail silently (fields written +past the struct, or appended fields read as `0.0`). A wrapper older +than #246 does not ask, so keep the two from the same commit. + ## API reference ### `Tot(lib_path: str | None = None)` diff --git a/python/totlib/__init__.py b/python/totlib/__init__.py index bf7b364bf..3bd912d5c 100644 --- a/python/totlib/__init__.py +++ b/python/totlib/__init__.py @@ -39,6 +39,7 @@ TotlibNotInitializedError, TotlibCalculationFailedError, TotlibNotImplementedError, + TotlibAbiError, TotLibError, TotLibInvalidParam, TotLibNotInitialized, @@ -66,6 +67,7 @@ "TotlibNotInitializedError", "TotlibCalculationFailedError", "TotlibNotImplementedError", + "TotlibAbiError", "TotLibError", "TotLibInvalidParam", "TotLibNotInitialized", diff --git a/python/totlib/_ffi.py b/python/totlib/_ffi.py index 4000cc028..79bfdbb42 100644 --- a/python/totlib/_ffi.py +++ b/python/totlib/_ffi.py @@ -25,8 +25,11 @@ from pathlib import Path from typing import Optional +from _abi_check import AbiMismatchError, check_state_layout from _runtime_mode import mono_lib_path +from .errors import TotlibAbiError + # Optional numpy (we never require it; state.py uses lists). try: import numpy as _np # noqa: F401 @@ -41,6 +44,12 @@ TOT_MAX_NRMAX = 500 TOT_MAX_NSMAX = 8 +# tot_state_t layout version; mirrors TOT_STATE_ABI_VERSION in tot/tot_api.h +# and tot/tot_state.f90. load_library() refuses a library whose +# tot_state_abi_version() / tot_state_size() differ from this and from +# ctypes.sizeof(TotStateC) (#246). +TOT_STATE_ABI_VERSION = 2 + # --------------------------------------------------------------------- # Error codes (kept in sync with tot_api.h::enum tot_error). @@ -221,6 +230,15 @@ def load_library(path: Optional[str] = None) -> ctypes.CDLL: # back to the numeric constant 1 which matches glibc dlfcn.h. mode = getattr(ctypes, "RTLD_LAZY", _RTLD_LAZY) lib = ctypes.CDLL(str(p), mode=mode) + # #246: refuse a library whose tot_state_t layout is not TotStateC's, + # before any struct is passed. A library built before #246 has no + # query symbols and is refused too (it may or may not match). + try: + check_state_layout(lib, prefix="tot", struct=TotStateC, + expected_version=TOT_STATE_ABI_VERSION, + lib_name=os.path.basename(str(p))) + except AbiMismatchError as exc: + raise TotlibAbiError(str(exc)) from exc return _apply_prototypes(lib) diff --git a/python/totlib/errors.py b/python/totlib/errors.py index 3b0b7ff51..d104c16c7 100644 --- a/python/totlib/errors.py +++ b/python/totlib/errors.py @@ -129,6 +129,7 @@ def __init__(self, message, partial_result, failed_step_index, failed_module): "TotlibNotInitializedError", "TotlibCalculationFailedError", "TotlibNotImplementedError", + "TotlibAbiError", "TotLibError", "TotLibInvalidParam", "TotLibNotInitialized", @@ -142,3 +143,13 @@ def __init__(self, message, partial_result, failed_step_index, failed_module): "raise_for_rc", "raise_for_ierr", ] + +class TotlibAbiError(TotlibError): + """libtotapi.so's tot_state_t layout is not the one this wrapper has (#246). + + Raised by :func:`totlib._ffi.load_library` before any struct is passed: + the library does not export ``tot_state_abi_version`` / ``tot_state_size`` + (built before the layout check), or its version or sizeof differs from + ``totlib._ffi.TotStateC``. The message names the fix (rebuild the + library, or update the wrapper). + """ diff --git a/python/totlib/tests/test_ffi.py b/python/totlib/tests/test_ffi.py index 713828085..fbd2b77a0 100644 --- a/python/totlib/tests/test_ffi.py +++ b/python/totlib/tests/test_ffi.py @@ -180,5 +180,64 @@ def test_set_param_unknown_prefix_rejected(self): self.assertEqual(rc, _ffi.TOT_ERR_INVALID) +class TestLibraryLayout(unittest.TestCase): + """#246: the library's tot_state_t layout must be TotStateC's. + + ``load_library`` compares ``tot_state_abi_version()`` / ``tot_state_size()`` + with ``TOT_STATE_ABI_VERSION`` / ``ctypes.sizeof(TotStateC)`` and raises + ``TotlibAbiError`` on any difference. The guards run in ``setUp``: the + ``.so`` is a mutable precondition. + """ + + def setUp(self): + if not _ffi._default_lib_path().exists(): + self.skipTest(f"libtotapi.so not built at {_ffi._default_lib_path()}") + self.lib = _ffi.load_library() + + def test_library_layout_matches_wrapper(self): + self.assertEqual(self.lib.tot_state_abi_version(), _ffi.TOT_STATE_ABI_VERSION) + self.assertEqual(self.lib.tot_state_size(), ctypes.sizeof(_ffi.TotStateC)) + + def test_stale_wrapper_layout_is_refused(self): + """Old wrapper, new library: one field short, the library would + write past the allocation. Refused before any struct is passed.""" + from _abi_check import AbiMismatchError, check_state_layout + + class Stale(ctypes.Structure): + _fields_ = _ffi.TotStateC._fields_[:-1] + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(self.lib, prefix="tot", struct=Stale, + expected_version=_ffi.TOT_STATE_ABI_VERSION, + lib_name="libtotapi.so") + self.assertEqual(ctx.exception.library_size, ctypes.sizeof(_ffi.TotStateC)) + self.assertEqual(ctx.exception.wrapper_size, ctypes.sizeof(Stale)) + + def test_version_drift_is_refused_through_load_library(self): + """A reorder keeps the size; the version tells. Exercised through + load_library so the wrapper's own error class is what callers see.""" + from unittest import mock + from totlib.errors import TotlibAbiError + + with mock.patch.object(_ffi, "TOT_STATE_ABI_VERSION", + _ffi.TOT_STATE_ABI_VERSION + 1): + with self.assertRaises(TotlibAbiError) as ctx: + _ffi.load_library() + self.assertIn("different commits", str(ctx.exception)) + + def test_library_without_the_query_is_refused(self): + """A library built before #246 exports neither symbol.""" + from _abi_check import AbiMismatchError, check_state_layout + + class _NoQuery: + pass + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(_NoQuery(), prefix="tot", struct=_ffi.TotStateC, + expected_version=_ffi.TOT_STATE_ABI_VERSION, + lib_name="libtotapi.so") + self.assertIn("before the layout check", str(ctx.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/python/trlib/README.md b/python/trlib/README.md index bab22bd31..514ce30d4 100644 --- a/python/trlib/README.md +++ b/python/trlib/README.md @@ -97,6 +97,18 @@ See `examples/` for runnable scripts: - `examples/parameter_sweep.py` — RR / BB grid - `examples/state_dump.py` — single run, full `TrState.to_dict()` as JSON +## Library layout check (#246) + +`load_library()` asks `libtrapi.so` for its `tr_state_t` layout +(`tr_state_abi_version()`, `tr_state_size()`) and refuses it with +`TrlibAbiError` when the version or the size differs from this wrapper's +`TrStateC`, or when the library exports neither symbol (built before +#246). The message names the fix: rebuild the library +(`make -C tr libtrapi.so`) or update the wrapper. A library and a +wrapper from different commits used to fail silently (fields written +past the struct, or appended fields read as `0.0`). A wrapper older +than #246 does not ask, so keep the two from the same commit. + ## API reference ### `Trlib(lib_path: str | None = None)` diff --git a/python/trlib/__init__.py b/python/trlib/__init__.py index 1941712fe..f95e036e4 100644 --- a/python/trlib/__init__.py +++ b/python/trlib/__init__.py @@ -23,6 +23,7 @@ TrlibStateError, TrlibRunError, TrlibNotImplementedError, + TrlibAbiError, # Aliases kept for spec-style naming. TrLibError, TrLibInvalidParam, @@ -43,6 +44,7 @@ "TrlibStateError", "TrlibRunError", "TrlibNotImplementedError", + "TrlibAbiError", "TrLibError", "TrLibInvalidParam", "TrLibNotInitialized", diff --git a/python/trlib/_ffi.py b/python/trlib/_ffi.py index 686e8ad25..9585932a8 100644 --- a/python/trlib/_ffi.py +++ b/python/trlib/_ffi.py @@ -23,8 +23,11 @@ from pathlib import Path from typing import Optional +from _abi_check import AbiMismatchError, check_state_layout from _runtime_mode import mono_lib_path +from .errors import TrlibAbiError + # Optional numpy (we never require it; state.py uses lists). try: import numpy as _np # noqa: F401 @@ -39,6 +42,12 @@ TR_MAX_NRMAX = 500 TR_MAX_NSMAX = 8 +# tr_state_t layout version; mirrors TR_STATE_ABI_VERSION in tr/tr_api.h +# and tr/tr_state.f90. load_library() refuses a library whose +# tr_state_abi_version() / tr_state_size() differ from this and from +# ctypes.sizeof(TrStateC) (#246). +TR_STATE_ABI_VERSION = 2 + # --------------------------------------------------------------------- # Error codes (kept in sync with tr_api.h::enum tr_error). @@ -252,6 +261,15 @@ def load_library(path: Optional[str] = None) -> ctypes.CDLL: # back to the numeric constant 1 which matches glibc dlfcn.h. mode = getattr(ctypes, "RTLD_LAZY", _RTLD_LAZY) lib = ctypes.CDLL(str(p), mode=mode) + # #246: refuse a library whose tr_state_t layout is not TrStateC's, + # before any struct is passed. A library built before #246 has no + # query symbols and is refused too (it may or may not match). + try: + check_state_layout(lib, prefix="tr", struct=TrStateC, + expected_version=TR_STATE_ABI_VERSION, + lib_name=os.path.basename(str(p))) + except AbiMismatchError as exc: + raise TrlibAbiError(str(exc)) from exc return _apply_prototypes(lib) diff --git a/python/trlib/errors.py b/python/trlib/errors.py index 4ca232b27..e02d0c216 100644 --- a/python/trlib/errors.py +++ b/python/trlib/errors.py @@ -60,3 +60,13 @@ def raise_for_ierr(func: str, ierr: int) -> None: return cls = _CODE_MAP.get(int(ierr), TrlibError) raise cls(f"{func}: ierr={ierr}") + +class TrlibAbiError(TrlibError): + """libtrapi.so's tr_state_t layout is not the one this wrapper has (#246). + + Raised by :func:`trlib._ffi.load_library` before any struct is passed: + the library does not export ``tr_state_abi_version`` / ``tr_state_size`` + (built before the layout check), or its version or sizeof differs from + ``trlib._ffi.TrStateC``. The message names the fix (rebuild the + library, or update the wrapper). + """ diff --git a/python/trlib/tests/test_ffi.py b/python/trlib/tests/test_ffi.py index 38c1ee26c..bf0dd620b 100644 --- a/python/trlib/tests/test_ffi.py +++ b/python/trlib/tests/test_ffi.py @@ -142,5 +142,64 @@ def test_prototypes_applied(self): ) +class TestLibraryLayout(unittest.TestCase): + """#246: the library's tr_state_t layout must be TrStateC's. + + ``load_library`` compares ``tr_state_abi_version()`` / ``tr_state_size()`` + with ``TR_STATE_ABI_VERSION`` / ``ctypes.sizeof(TrStateC)`` and raises + ``TrlibAbiError`` on any difference. The guards run in ``setUp``: the + ``.so`` is a mutable precondition. + """ + + def setUp(self): + if not _ffi._default_lib_path().exists(): + self.skipTest(f"libtrapi.so not built at {_ffi._default_lib_path()}") + self.lib = _ffi.load_library() + + def test_library_layout_matches_wrapper(self): + self.assertEqual(self.lib.tr_state_abi_version(), _ffi.TR_STATE_ABI_VERSION) + self.assertEqual(self.lib.tr_state_size(), ctypes.sizeof(_ffi.TrStateC)) + + def test_stale_wrapper_layout_is_refused(self): + """Old wrapper, new library: one field short, the library would + write past the allocation. Refused before any struct is passed.""" + from _abi_check import AbiMismatchError, check_state_layout + + class Stale(ctypes.Structure): + _fields_ = _ffi.TrStateC._fields_[:-1] + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(self.lib, prefix="tr", struct=Stale, + expected_version=_ffi.TR_STATE_ABI_VERSION, + lib_name="libtrapi.so") + self.assertEqual(ctx.exception.library_size, ctypes.sizeof(_ffi.TrStateC)) + self.assertEqual(ctx.exception.wrapper_size, ctypes.sizeof(Stale)) + + def test_version_drift_is_refused_through_load_library(self): + """A reorder keeps the size; the version tells. Exercised through + load_library so the wrapper's own error class is what callers see.""" + from unittest import mock + from trlib.errors import TrlibAbiError + + with mock.patch.object(_ffi, "TR_STATE_ABI_VERSION", + _ffi.TR_STATE_ABI_VERSION + 1): + with self.assertRaises(TrlibAbiError) as ctx: + _ffi.load_library() + self.assertIn("different commits", str(ctx.exception)) + + def test_library_without_the_query_is_refused(self): + """A library built before #246 exports neither symbol.""" + from _abi_check import AbiMismatchError, check_state_layout + + class _NoQuery: + pass + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(_NoQuery(), prefix="tr", struct=_ffi.TrStateC, + expected_version=_ffi.TR_STATE_ABI_VERSION, + lib_name="libtrapi.so") + self.assertIn("before the layout check", str(ctx.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/python/wrlib/README.md b/python/wrlib/README.md index 1f1da05d4..d8f0be155 100644 --- a/python/wrlib/README.md +++ b/python/wrlib/README.md @@ -108,6 +108,18 @@ See `examples/` for runnable scripts: - `examples/parameter_sweep.py` — 3×3 RFIN × ANGPHIN grid - `examples/state_dump.py` — single run, full `WrState.to_dict()` as JSON +## Library layout check (#246) + +`load_library()` asks `libwrapi.so` for its `wr_state_t` layout +(`wr_state_abi_version()`, `wr_state_size()`) and refuses it with +`WrlibAbiError` when the version or the size differs from this wrapper's +`WrStateC`, or when the library exports neither symbol (built before +#246). The message names the fix: rebuild the library +(`make -C wr libwrapi.so`) or update the wrapper. A library and a +wrapper from different commits used to fail silently (fields written +past the struct, or appended fields read as `0.0`). A wrapper older +than #246 does not ask, so keep the two from the same commit. + ## API reference ### `Wrlib(lib_path: str | None = None)` diff --git a/python/wrlib/__init__.py b/python/wrlib/__init__.py index ab55c4a87..d132e0d20 100644 --- a/python/wrlib/__init__.py +++ b/python/wrlib/__init__.py @@ -23,6 +23,7 @@ WrlibStateError, WrlibRunError, WrlibNotImplementedError, + WrlibAbiError, # Aliases kept for spec-style naming. WrLibError, WrLibInvalidParam, @@ -41,6 +42,7 @@ "WrlibStateError", "WrlibRunError", "WrlibNotImplementedError", + "WrlibAbiError", "WrLibError", "WrLibInvalidParam", "WrLibNotInitialized", diff --git a/python/wrlib/_ffi.py b/python/wrlib/_ffi.py index 678b8035d..1bc7c00be 100644 --- a/python/wrlib/_ffi.py +++ b/python/wrlib/_ffi.py @@ -21,6 +21,10 @@ from pathlib import Path from typing import Optional +from _abi_check import AbiMismatchError, check_state_layout + +from .errors import WrlibAbiError + # Optional numpy (we never require it; state.py uses lists). try: import numpy as _np # noqa: F401 @@ -37,6 +41,12 @@ WR_MAX_NRLMAX = 400 WR_MAX_NRAY_EQ = 9 +# wr_state_t layout version; mirrors WR_STATE_ABI_VERSION in wr/wr_api.h +# and wr/wr_state.f90. load_library() refuses a library whose +# wr_state_abi_version() / wr_state_size() differ from this and from +# ctypes.sizeof(WrStateC) (#246). +WR_STATE_ABI_VERSION = 1 + # --------------------------------------------------------------------- # Error codes (kept in sync with wr_api.h::enum wr_error). @@ -159,6 +169,15 @@ def load_library(path: Optional[str] = None) -> ctypes.CDLL: # back to the numeric constant 1 which matches glibc dlfcn.h. mode = getattr(ctypes, "RTLD_LAZY", _RTLD_LAZY) lib = ctypes.CDLL(str(p), mode=mode) + # #246: refuse a library whose wr_state_t layout is not WrStateC's, + # before any struct is passed. A library built before #246 has no + # query symbols and is refused too (it may or may not match). + try: + check_state_layout(lib, prefix="wr", struct=WrStateC, + expected_version=WR_STATE_ABI_VERSION, + lib_name=os.path.basename(str(p))) + except AbiMismatchError as exc: + raise WrlibAbiError(str(exc)) from exc return _apply_prototypes(lib) diff --git a/python/wrlib/errors.py b/python/wrlib/errors.py index 21753c1f0..3355d84f6 100644 --- a/python/wrlib/errors.py +++ b/python/wrlib/errors.py @@ -60,3 +60,13 @@ def raise_for_ierr(func: str, ierr: int) -> None: return cls = _CODE_MAP.get(int(ierr), WrlibError) raise cls(f"{func}: ierr={ierr}") + +class WrlibAbiError(WrlibError): + """libwrapi.so's wr_state_t layout is not the one this wrapper has (#246). + + Raised by :func:`wrlib._ffi.load_library` before any struct is passed: + the library does not export ``wr_state_abi_version`` / ``wr_state_size`` + (built before the layout check), or its version or sizeof differs from + ``wrlib._ffi.WrStateC``. The message names the fix (rebuild the + library, or update the wrapper). + """ diff --git a/python/wrlib/tests/test_ffi.py b/python/wrlib/tests/test_ffi.py index e4e23bb5d..1279c2fce 100644 --- a/python/wrlib/tests/test_ffi.py +++ b/python/wrlib/tests/test_ffi.py @@ -239,5 +239,64 @@ def test_unknown_param_rejected(self): lib.wr_finalize() +class TestLibraryLayout(unittest.TestCase): + """#246: the library's wr_state_t layout must be WrStateC's. + + ``load_library`` compares ``wr_state_abi_version()`` / ``wr_state_size()`` + with ``WR_STATE_ABI_VERSION`` / ``ctypes.sizeof(WrStateC)`` and raises + ``WrlibAbiError`` on any difference. The guards run in ``setUp``: the + ``.so`` is a mutable precondition. + """ + + def setUp(self): + if not _ffi._default_lib_path().exists(): + self.skipTest(f"libwrapi.so not built at {_ffi._default_lib_path()}") + self.lib = _ffi.load_library() + + def test_library_layout_matches_wrapper(self): + self.assertEqual(self.lib.wr_state_abi_version(), _ffi.WR_STATE_ABI_VERSION) + self.assertEqual(self.lib.wr_state_size(), ctypes.sizeof(_ffi.WrStateC)) + + def test_stale_wrapper_layout_is_refused(self): + """Old wrapper, new library: one field short, the library would + write past the allocation. Refused before any struct is passed.""" + from _abi_check import AbiMismatchError, check_state_layout + + class Stale(ctypes.Structure): + _fields_ = _ffi.WrStateC._fields_[:-1] + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(self.lib, prefix="wr", struct=Stale, + expected_version=_ffi.WR_STATE_ABI_VERSION, + lib_name="libwrapi.so") + self.assertEqual(ctx.exception.library_size, ctypes.sizeof(_ffi.WrStateC)) + self.assertEqual(ctx.exception.wrapper_size, ctypes.sizeof(Stale)) + + def test_version_drift_is_refused_through_load_library(self): + """A reorder keeps the size; the version tells. Exercised through + load_library so the wrapper's own error class is what callers see.""" + from unittest import mock + from wrlib.errors import WrlibAbiError + + with mock.patch.object(_ffi, "WR_STATE_ABI_VERSION", + _ffi.WR_STATE_ABI_VERSION + 1): + with self.assertRaises(WrlibAbiError) as ctx: + _ffi.load_library() + self.assertIn("different commits", str(ctx.exception)) + + def test_library_without_the_query_is_refused(self): + """A library built before #246 exports neither symbol.""" + from _abi_check import AbiMismatchError, check_state_layout + + class _NoQuery: + pass + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(_NoQuery(), prefix="wr", struct=_ffi.WrStateC, + expected_version=_ffi.WR_STATE_ABI_VERSION, + lib_name="libwrapi.so") + self.assertIn("before the layout check", str(ctx.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/python/wrxlib/README.md b/python/wrxlib/README.md index 797eff87d..020f3241e 100644 --- a/python/wrxlib/README.md +++ b/python/wrxlib/README.md @@ -152,6 +152,18 @@ All three accept `--dry-run` to validate argument parsing and import wiring without invoking the FFI (useful for smoke runs without the shared library present). +## Library layout check (#246) + +`load_library()` asks `libwrxapi.so` for its `wrx_state_t` layout +(`wrx_state_abi_version()`, `wrx_state_size()`) and refuses it with +`WrxlibAbiError` when the version or the size differs from this wrapper's +`WrxStateC`, or when the library exports neither symbol (built before +#246). The message names the fix: rebuild the library +(`make -C wrx libwrxapi.so`) or update the wrapper. A library and a +wrapper from different commits used to fail silently (fields written +past the struct, or appended fields read as `0.0`). A wrapper older +than #246 does not ask, so keep the two from the same commit. + ## API reference ### `Wrxlib(lib_path: str | None = None)` diff --git a/python/wrxlib/__init__.py b/python/wrxlib/__init__.py index df7418868..e470c28e1 100644 --- a/python/wrxlib/__init__.py +++ b/python/wrxlib/__init__.py @@ -25,6 +25,7 @@ WrxlibStateError, WrxlibRunError, WrxlibNotImplementedError, + WrxlibAbiError, # Aliases kept for spec-style naming. WrxLibError, WrxLibInvalidParam, @@ -44,6 +45,7 @@ "WrxlibStateError", "WrxlibRunError", "WrxlibNotImplementedError", + "WrxlibAbiError", "WrxLibError", "WrxLibInvalidParam", "WrxLibNotInitialized", diff --git a/python/wrxlib/_ffi.py b/python/wrxlib/_ffi.py index 81eb49c88..3e1a18e22 100644 --- a/python/wrxlib/_ffi.py +++ b/python/wrxlib/_ffi.py @@ -23,8 +23,11 @@ from pathlib import Path from typing import List, Optional +from _abi_check import AbiMismatchError, check_state_layout from _runtime_mode import mono_lib_path +from .errors import WrxlibAbiError + # Optional numpy (we never require it; state.py uses lists). try: import numpy as _np # noqa: F401 @@ -41,6 +44,12 @@ WRX_MAX_NRSMAX = 256 WRX_MAX_NRLMAX = 256 +# wrx_state_t layout version; mirrors WRX_STATE_ABI_VERSION in wrx/wrx_api.h +# and wrx/wrx_state.f90. load_library() refuses a library whose +# wrx_state_abi_version() / wrx_state_size() differ from this and from +# ctypes.sizeof(WrxStateC) (#246). +WRX_STATE_ABI_VERSION = 1 + # --------------------------------------------------------------------- # Error codes (kept in sync with wrx_api.h::enum wrx_error). @@ -184,6 +193,15 @@ def load_library(path: Optional[str] = None) -> ctypes.CDLL: # back to the numeric constant 1 which matches glibc dlfcn.h. mode = getattr(ctypes, "RTLD_LAZY", _RTLD_LAZY) lib = ctypes.CDLL(str(p), mode=mode) + # #246: refuse a library whose wrx_state_t layout is not WrxStateC's, + # before any struct is passed. A library built before #246 has no + # query symbols and is refused too (it may or may not match). + try: + check_state_layout(lib, prefix="wrx", struct=WrxStateC, + expected_version=WRX_STATE_ABI_VERSION, + lib_name=os.path.basename(str(p))) + except AbiMismatchError as exc: + raise WrxlibAbiError(str(exc)) from exc return _apply_prototypes(lib) diff --git a/python/wrxlib/errors.py b/python/wrxlib/errors.py index 45013f836..c5437c1e5 100644 --- a/python/wrxlib/errors.py +++ b/python/wrxlib/errors.py @@ -64,3 +64,13 @@ def raise_for_ierr(func: str, ierr: int) -> None: # Alias: matches the naming used by some sibling packages (fplib, trlib). raise_for_rc = raise_for_ierr + +class WrxlibAbiError(WrxlibError): + """libwrxapi.so's wrx_state_t layout is not the one this wrapper has (#246). + + Raised by :func:`wrxlib._ffi.load_library` before any struct is passed: + the library does not export ``wrx_state_abi_version`` / ``wrx_state_size`` + (built before the layout check), or its version or sizeof differs from + ``wrxlib._ffi.WrxStateC``. The message names the fix (rebuild the + library, or update the wrapper). + """ diff --git a/python/wrxlib/tests/test_ffi.py b/python/wrxlib/tests/test_ffi.py index d4580dad7..d58ddb640 100644 --- a/python/wrxlib/tests/test_ffi.py +++ b/python/wrxlib/tests/test_ffi.py @@ -226,5 +226,64 @@ def test_prototypes_applied(self): ) +class TestLibraryLayout(unittest.TestCase): + """#246: the library's wrx_state_t layout must be WrxStateC's. + + ``load_library`` compares ``wrx_state_abi_version()`` / ``wrx_state_size()`` + with ``WRX_STATE_ABI_VERSION`` / ``ctypes.sizeof(WrxStateC)`` and raises + ``WrxlibAbiError`` on any difference. The guards run in ``setUp``: the + ``.so`` is a mutable precondition. + """ + + def setUp(self): + if not _ffi._default_lib_path().exists(): + self.skipTest(f"libwrxapi.so not built at {_ffi._default_lib_path()}") + self.lib = _ffi.load_library() + + def test_library_layout_matches_wrapper(self): + self.assertEqual(self.lib.wrx_state_abi_version(), _ffi.WRX_STATE_ABI_VERSION) + self.assertEqual(self.lib.wrx_state_size(), ctypes.sizeof(_ffi.WrxStateC)) + + def test_stale_wrapper_layout_is_refused(self): + """Old wrapper, new library: one field short, the library would + write past the allocation. Refused before any struct is passed.""" + from _abi_check import AbiMismatchError, check_state_layout + + class Stale(ctypes.Structure): + _fields_ = _ffi.WrxStateC._fields_[:-1] + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(self.lib, prefix="wrx", struct=Stale, + expected_version=_ffi.WRX_STATE_ABI_VERSION, + lib_name="libwrxapi.so") + self.assertEqual(ctx.exception.library_size, ctypes.sizeof(_ffi.WrxStateC)) + self.assertEqual(ctx.exception.wrapper_size, ctypes.sizeof(Stale)) + + def test_version_drift_is_refused_through_load_library(self): + """A reorder keeps the size; the version tells. Exercised through + load_library so the wrapper's own error class is what callers see.""" + from unittest import mock + from wrxlib.errors import WrxlibAbiError + + with mock.patch.object(_ffi, "WRX_STATE_ABI_VERSION", + _ffi.WRX_STATE_ABI_VERSION + 1): + with self.assertRaises(WrxlibAbiError) as ctx: + _ffi.load_library() + self.assertIn("different commits", str(ctx.exception)) + + def test_library_without_the_query_is_refused(self): + """A library built before #246 exports neither symbol.""" + from _abi_check import AbiMismatchError, check_state_layout + + class _NoQuery: + pass + + with self.assertRaises(AbiMismatchError) as ctx: + check_state_layout(_NoQuery(), prefix="wrx", struct=_ffi.WrxStateC, + expected_version=_ffi.WRX_STATE_ABI_VERSION, + lib_name="libwrxapi.so") + self.assertIn("before the layout check", str(ctx.exception)) + + if __name__ == "__main__": unittest.main() diff --git a/ti/tests/c_abi/test_negative.c b/ti/tests/c_abi/test_negative.c index 5e202e555..693288a78 100644 --- a/ti/tests/c_abi/test_negative.c +++ b/ti/tests/c_abi/test_negative.c @@ -41,6 +41,22 @@ } while (0) int main(void) { + /* ---- 0: layout query must match this header (#246) ---------- */ + { + int v = ti_state_abi_version(); + size_t n = ti_state_size(); + if (v != TI_STATE_ABI_VERSION) { + fprintf(stderr, "FAIL abi-version: library reports %d, ti_api.h says %d\n", + v, TI_STATE_ABI_VERSION); + return (__LINE__); + } + if (n != sizeof(ti_state_t)) { + fprintf(stderr, "FAIL state-size: library reports %lu, sizeof(ti_state_t) is %lu\n", + (unsigned long) n, (unsigned long) sizeof(ti_state_t)); + return (__LINE__); + } + } + int rc; ti_state_t s; diff --git a/ti/tests/c_abi/test_run_so.c b/ti/tests/c_abi/test_run_so.c index 3a8e8a018..5f410bf93 100644 --- a/ti/tests/c_abi/test_run_so.c +++ b/ti/tests/c_abi/test_run_so.c @@ -16,6 +16,8 @@ * 1..8 = step that failed (same numbering as test_run.c) * 90 = dlopen failed * 91 = dlsym failed (one of the 5 entry points missing) + * 92 = ti_state_abi_version / ti_state_size missing (library predates #246) + * 93 = layout version or size differs from ti_api.h (#246) */ #include #include @@ -62,6 +64,28 @@ int main(void) { return 91; } + /* #246: the layout query must agree with this header before any + * ti_state_t is passed. 92 = symbols missing (library predates + * #246), 93 = version or size differs (library and header from + * different commits). */ + typedef int (*ti_abi_version_fn)(void); + typedef size_t (*ti_state_size_fn)(void); + ti_abi_version_fn f_abi = (ti_abi_version_fn) dlsym(h, "ti_state_abi_version"); + ti_state_size_fn f_size = (ti_state_size_fn) dlsym(h, "ti_state_size"); + if (!f_abi || !f_size) { + fprintf(stderr, "dlsym missing ti_state_abi_version / ti_state_size (#246)\n"); + dlclose(h); + return 92; + } + if (f_abi() != TI_STATE_ABI_VERSION || f_size() != sizeof(ti_state_t)) { + fprintf(stderr, + "layout mismatch: library v%d / %lu bytes, ti_api.h v%d / %lu bytes\n", + f_abi(), (unsigned long) f_size(), + TI_STATE_ABI_VERSION, (unsigned long) sizeof(ti_state_t)); + dlclose(h); + return 93; + } + /* Mirror the test_run.c cycle so the .so is proven to be a drop-in * replacement for the static libti.a link path. */ int rc; diff --git a/ti/ti_api.f90 b/ti/ti_api.f90 index c6e0e9dac..d9c334c90 100644 --- a/ti/ti_api.f90 +++ b/ti/ti_api.f90 @@ -27,6 +27,7 @@ MODULE ti_api USE, INTRINSIC :: ISO_C_BINDING + USE ti_state, ONLY: TI_STATE_ABI_VERSION USE ti_state, ONLY: ti_state_c, TI_MAX_NRMAX, TI_MAX_NSA_MAX USE ticomm, ONLY: rkind, & NRMAX, NSMAX, NT, T, NTMAX, & @@ -64,6 +65,9 @@ MODULE ti_api ! -1 = never prepared. See ti_param_generation in the registry. INTEGER, SAVE :: g_prep_generation = -1 + ! #246: layout query, callable before ti_init. + PUBLIC :: ti_api_state_abi_version, ti_api_state_size + CONTAINS !------------------------------------------------------------------- @@ -323,4 +327,23 @@ FUNCTION ti_api_finalize() RESULT(ierr) BIND(C, NAME="ti_finalize") ierr = TI_OK END FUNCTION ti_api_finalize + !------------------------------------------------------------------- + ! ti_state_abi_version / ti_state_size : layout query (#246). + ! + ! No lifecycle check and no side effect; callable before ti_init. + ! A caller compares both with its own sizeof(ti_state_t) and its copy + ! of TI_STATE_ABI_VERSION before passing a struct to ti_get_state; + ! python/tilib/_ffi.py refuses the library when either differs. + !------------------------------------------------------------------- + FUNCTION ti_api_state_abi_version() RESULT(v) BIND(C, NAME="ti_state_abi_version") + INTEGER(C_INT) :: v + v = TI_STATE_ABI_VERSION + END FUNCTION ti_api_state_abi_version + + FUNCTION ti_api_state_size() RESULT(n) BIND(C, NAME="ti_state_size") + INTEGER(C_SIZE_T) :: n + TYPE(ti_state_c) :: probe + n = C_SIZEOF(probe) + END FUNCTION ti_api_state_size + END MODULE ti_api diff --git a/ti/ti_api.h b/ti/ti_api.h index 8dd875175..5762a6cc1 100644 --- a/ti/ti_api.h +++ b/ti/ti_api.h @@ -1,6 +1,8 @@ #ifndef TI_API_H #define TI_API_H +#include /* size_t for ti_state_size (#246) */ + #ifdef __cplusplus extern "C" { #endif @@ -21,6 +23,20 @@ extern "C" { #define TI_MAX_NRMAX 200 #define TI_MAX_NSA_MAX 20 +/* + * ti_state_t ABI version. Bumped whenever a field is added/removed/reordered + * in ti_state_t (i.e. whenever sizeof(ti_state_t) changes), together with + * the TI_STATE_ABI_VERSION PARAMETER in ti_state.f90 that the library + * exports through ti_state_abi_version(); tests/c_abi/test_negative.c + * fails when the two drift. In-tree consumers (the C-ABI drivers, + * python/tilib/_ffi.py) rebuild from this header and check the library's + * version and sizeof at load time (#246). + * + * History: + * 1 -> initial layout + */ +#define TI_STATE_ABI_VERSION 1 + /* Error codes returned by every ti_* entry point. */ enum ti_error { TI_OK = 0, @@ -53,6 +69,13 @@ int ti_set_param(const char* name, double value); int ti_get_state(ti_state_t* state); int ti_finalize(void); +/* Layout query (#246): the library's TI_STATE_ABI_VERSION and + sizeof(ti_state_t), callable before ti_init. Compare both with this + header's values before passing a struct to ti_get_state; a mismatch + means the library and this header come from different commits. */ +int ti_state_abi_version(void); +size_t ti_state_size(void); + #ifdef __cplusplus } #endif diff --git a/ti/ti_state.f90 b/ti/ti_state.f90 index eedbc7506..8a8c658bf 100644 --- a/ti/ti_state.f90 +++ b/ti/ti_state.f90 @@ -18,11 +18,19 @@ MODULE ti_state USE, INTRINSIC :: ISO_C_BINDING IMPLICIT NONE PRIVATE + PUBLIC :: TI_STATE_ABI_VERSION PUBLIC :: ti_state_c, TI_MAX_NRMAX, TI_MAX_NSA_MAX INTEGER(C_INT), PARAMETER :: TI_MAX_NRMAX = 200 INTEGER(C_INT), PARAMETER :: TI_MAX_NSA_MAX = 20 + ! Layout version of ti_state_c / ti_state_t (#246). Bump it together + ! with TI_STATE_ABI_VERSION in ti_api.h whenever a field is added, + ! removed or reordered; ti_state_abi_version() exports it and the C-ABI + ! driver fails when the two drift. ti_state_size() exports the + ! BIND(C) size, so the library, not the header, is authoritative. + INTEGER(C_INT), PARAMETER :: TI_STATE_ABI_VERSION = 1 + TYPE, BIND(C) :: ti_state_c INTEGER(C_INT) :: nt INTEGER(C_INT) :: nrmax diff --git a/tot/Makefile b/tot/Makefile index 1c540eced..53f1e92bd 100644 --- a/tot/Makefile +++ b/tot/Makefile @@ -718,6 +718,18 @@ libtotapi_mono_inspect: libtotapi_mono.so else \ echo " OK: 8 unique bpsd storage symbols (one per bpsd module type)"; \ fi + @echo "=== HARD GATE 1b: every module's layout query must be exported (#246: X_state_abi_version + X_state_size for eq tr fp ti wrx tot) ===" + @missing=""; for m in eq tr fp ti wrx tot; do \ + for f in state_abi_version state_size; do \ + nm libtotapi_mono.so | grep -qE "[ _]$${m}_$${f}$$" || missing="$$missing $${m}_$${f}"; \ + done; \ + done; \ + if [ -n "$$missing" ]; then \ + echo " FAIL: missing layout-query symbols:$$missing"; \ + exit 1; \ + else \ + echo " OK: 12 layout-query symbols exported"; \ + fi @echo "=== HARD GATE 2: no per-module lib*api.so runtime deps (the mono build must be self-contained) ===" @deps=$$( ( ldd libtotapi_mono.so 2>/dev/null || otool -L libtotapi_mono.so ) \ | grep -E 'lib(eq|tr|fp|ti|wr|wrx|tot)api\.so' \ diff --git a/tot/tests/c_abi/test_negative.c b/tot/tests/c_abi/test_negative.c index 63660ea8f..c59d7380e 100644 --- a/tot/tests/c_abi/test_negative.c +++ b/tot/tests/c_abi/test_negative.c @@ -56,6 +56,22 @@ } while (0) int main(void) { + /* ---- 0: layout query must match this header (#246) ---------- */ + { + int v = tot_state_abi_version(); + size_t n = tot_state_size(); + if (v != TOT_STATE_ABI_VERSION) { + fprintf(stderr, "FAIL abi-version: library reports %d, tot_api.h says %d\n", + v, TOT_STATE_ABI_VERSION); + return (__LINE__); + } + if (n != sizeof(tot_state_t)) { + fprintf(stderr, "FAIL state-size: library reports %lu, sizeof(tot_state_t) is %lu\n", + (unsigned long) n, (unsigned long) sizeof(tot_state_t)); + return (__LINE__); + } + } + int rc; tot_state_t s; diff --git a/tot/tests/c_abi/test_run_so.c b/tot/tests/c_abi/test_run_so.c index 32807a896..501fffcad 100644 --- a/tot/tests/c_abi/test_run_so.c +++ b/tot/tests/c_abi/test_run_so.c @@ -22,6 +22,8 @@ * 1..8 = step that failed * 90 = dlopen failed * 91 = dlsym failed (one of the 6 entry points missing) + * 92 = tot_state_abi_version / tot_state_size missing (library predates #246) + * 93 = layout version or size differs from tot_api.h (#246) */ #include #include @@ -72,6 +74,28 @@ int main(void) { return 91; } + /* #246: the layout query must agree with this header before any + * tot_state_t is passed. 92 = symbols missing (library predates + * #246), 93 = version or size differs (library and header from + * different commits). */ + typedef int (*tot_abi_version_fn)(void); + typedef size_t (*tot_state_size_fn)(void); + tot_abi_version_fn f_abi = (tot_abi_version_fn) dlsym(h, "tot_state_abi_version"); + tot_state_size_fn f_size = (tot_state_size_fn) dlsym(h, "tot_state_size"); + if (!f_abi || !f_size) { + fprintf(stderr, "dlsym missing tot_state_abi_version / tot_state_size (#246)\n"); + dlclose(h); + return 92; + } + if (f_abi() != TOT_STATE_ABI_VERSION || f_size() != sizeof(tot_state_t)) { + fprintf(stderr, + "layout mismatch: library v%d / %lu bytes, tot_api.h v%d / %lu bytes\n", + f_abi(), (unsigned long) f_size(), + TOT_STATE_ABI_VERSION, (unsigned long) sizeof(tot_state_t)); + dlclose(h); + return 93; + } + int rc; tot_state_t st; diff --git a/tot/tot_api.f90 b/tot/tot_api.f90 index 57dcd2e15..e0a827950 100644 --- a/tot/tot_api.f90 +++ b/tot/tot_api.f90 @@ -58,6 +58,7 @@ MODULE tot_api USE, INTRINSIC :: ISO_C_BINDING + USE tot_state, ONLY: TOT_STATE_ABI_VERSION USE tot_state, ONLY: tot_state_c, TOT_MAX_NRMAX, TOT_MAX_NSMAX USE tot_param_registry, ONLY: tot_param_set, tot_param_set_str ! Per-module APIs (libraryized in L-3). We rename each *_api_* @@ -107,6 +108,9 @@ MODULE tot_api LOGICAL, SAVE :: g_fp_present = .FALSE. LOGICAL, SAVE :: g_wr_present = .FALSE. + ! #246: layout query, callable before tot_init. + PUBLIC :: tot_api_state_abi_version, tot_api_state_size + CONTAINS !------------------------------------------------------------------- @@ -466,6 +470,25 @@ SUBROUTINE c_string_to_fortran(c_str, f_str) END DO END SUBROUTINE c_string_to_fortran + !------------------------------------------------------------------- + ! tot_state_abi_version / tot_state_size : layout query (#246). + ! + ! No lifecycle check and no side effect; callable before tot_init. + ! A caller compares both with its own sizeof(tot_state_t) and its copy + ! of TOT_STATE_ABI_VERSION before passing a struct to tot_get_state; + ! python/totlib/_ffi.py refuses the library when either differs. + !------------------------------------------------------------------- + FUNCTION tot_api_state_abi_version() RESULT(v) BIND(C, NAME="tot_state_abi_version") + INTEGER(C_INT) :: v + v = TOT_STATE_ABI_VERSION + END FUNCTION tot_api_state_abi_version + + FUNCTION tot_api_state_size() RESULT(n) BIND(C, NAME="tot_state_size") + INTEGER(C_SIZE_T) :: n + TYPE(tot_state_c) :: probe + n = C_SIZEOF(probe) + END FUNCTION tot_api_state_size + END MODULE tot_api !------------------------------------------------------------------- diff --git a/tot/tot_api.h b/tot/tot_api.h index e8002cefe..3beceb241 100644 --- a/tot/tot_api.h +++ b/tot/tot_api.h @@ -1,6 +1,8 @@ #ifndef TOT_API_H #define TOT_API_H +#include /* size_t for tot_state_size (#246) */ + #ifdef __cplusplus extern "C" { #endif @@ -53,6 +55,11 @@ extern "C" { * it bumps. In-tree consumers (python/totlib/_ffi.py and any future * tot_api_check_* helpers) all rebuild from this header, so they are * kept in sync automatically by the build system. + * Since #246 the same value is a PARAMETER in tot_state.f90, exported by + * tot_state_abi_version() together with sizeof via tot_state_size(); bump + * both places together. tests/c_abi/test_negative.c fails when they + * drift, and python/totlib/_ffi.py refuses a library whose version or + * size differs from its struct at load time. * * History: * 1 -> initial layout (Phase L-2) @@ -116,6 +123,13 @@ int tot_set_param_str(const char* name, const char* value); int tot_get_state(tot_state_t* state); int tot_finalize(void); +/* Layout query (#246): the library's TOT_STATE_ABI_VERSION and + sizeof(tot_state_t), callable before tot_init. Compare both with this + header's values before passing a struct to tot_get_state; a mismatch + means the library and this header come from different commits. */ +int tot_state_abi_version(void); +size_t tot_state_size(void); + /* * tot_is_mono : introspection — am I the monolithic libtotapi*.so? * diff --git a/tot/tot_state.f90 b/tot/tot_state.f90 index a41b8f4c7..308977440 100644 --- a/tot/tot_state.f90 +++ b/tot/tot_state.f90 @@ -22,6 +22,7 @@ MODULE tot_state USE, INTRINSIC :: ISO_C_BINDING IMPLICIT NONE PRIVATE + PUBLIC :: TOT_STATE_ABI_VERSION PUBLIC :: tot_state_c, & TOT_MAX_NRMAX, TOT_MAX_NSMAX, & TOT_TR_PRESENT, TOT_TI_PRESENT, & @@ -41,6 +42,13 @@ MODULE tot_state INTEGER(C_INT), PARAMETER :: TOT_FP_PRESENT = 3 INTEGER(C_INT), PARAMETER :: TOT_WR_PRESENT = 4 + ! Layout version of tot_state_c / tot_state_t (#246). Bump it together + ! with TOT_STATE_ABI_VERSION in tot_api.h whenever a field is added, + ! removed or reordered; tot_state_abi_version() exports it and the C-ABI + ! driver fails when the two drift. tot_state_size() exports the + ! BIND(C) size, so the library, not the header, is authoritative. + INTEGER(C_INT), PARAMETER :: TOT_STATE_ABI_VERSION = 2 + TYPE, BIND(C) :: tot_state_c ! --- orchestrator presence flags --------------------------------- ! 0 = module not initialized (or not compiled into this build) diff --git a/tr/tests/c_abi/test_negative.c b/tr/tests/c_abi/test_negative.c index 77a7b43a4..a79b5aa67 100644 --- a/tr/tests/c_abi/test_negative.c +++ b/tr/tests/c_abi/test_negative.c @@ -40,6 +40,22 @@ } while (0) int main(void) { + /* ---- 0: layout query must match this header (#246) ---------- */ + { + int v = tr_state_abi_version(); + size_t n = tr_state_size(); + if (v != TR_STATE_ABI_VERSION) { + fprintf(stderr, "FAIL abi-version: library reports %d, tr_api.h says %d\n", + v, TR_STATE_ABI_VERSION); + return (__LINE__); + } + if (n != sizeof(tr_state_t)) { + fprintf(stderr, "FAIL state-size: library reports %lu, sizeof(tr_state_t) is %lu\n", + (unsigned long) n, (unsigned long) sizeof(tr_state_t)); + return (__LINE__); + } + } + int rc; tr_state_t s; diff --git a/tr/tests/c_abi/test_run_so.c b/tr/tests/c_abi/test_run_so.c index b016bf9fa..630f054bd 100644 --- a/tr/tests/c_abi/test_run_so.c +++ b/tr/tests/c_abi/test_run_so.c @@ -16,6 +16,8 @@ * 1..8 = step that failed (same numbering as test_run.c) * 90 = dlopen failed * 91 = dlsym failed (one of the 5 entry points missing) + * 92 = tr_state_abi_version / tr_state_size missing (library predates #246) + * 93 = layout version or size differs from tr_api.h (#246) */ #include #include @@ -62,6 +64,28 @@ int main(void) { return 91; } + /* #246: the layout query must agree with this header before any + * tr_state_t is passed. 92 = symbols missing (library predates + * #246), 93 = version or size differs (library and header from + * different commits). */ + typedef int (*tr_abi_version_fn)(void); + typedef size_t (*tr_state_size_fn)(void); + tr_abi_version_fn f_abi = (tr_abi_version_fn) dlsym(h, "tr_state_abi_version"); + tr_state_size_fn f_size = (tr_state_size_fn) dlsym(h, "tr_state_size"); + if (!f_abi || !f_size) { + fprintf(stderr, "dlsym missing tr_state_abi_version / tr_state_size (#246)\n"); + dlclose(h); + return 92; + } + if (f_abi() != TR_STATE_ABI_VERSION || f_size() != sizeof(tr_state_t)) { + fprintf(stderr, + "layout mismatch: library v%d / %lu bytes, tr_api.h v%d / %lu bytes\n", + f_abi(), (unsigned long) f_size(), + TR_STATE_ABI_VERSION, (unsigned long) sizeof(tr_state_t)); + dlclose(h); + return 93; + } + /* Mirror the test_run.c cycle so the .so is proven to be a drop-in * replacement for the static libtr2.a link path. */ int rc; diff --git a/tr/tr_api.f90 b/tr/tr_api.f90 index 6670cb7a0..f3c4027e0 100644 --- a/tr/tr_api.f90 +++ b/tr/tr_api.f90 @@ -26,6 +26,7 @@ MODULE tr_api USE, INTRINSIC :: ISO_C_BINDING USE, INTRINSIC :: IEEE_ARITHMETIC, ONLY: IEEE_IS_FINITE + USE tr_state, ONLY: TR_STATE_ABI_VERSION USE tr_state, ONLY: tr_state_c, TR_MAX_NRMAX, TR_MAX_NSMAX, & tr_diag_entry_c, & TR_DIAG_PARAM_LEN, TR_DIAG_MSG_LEN, & @@ -75,6 +76,9 @@ MODULE tr_api ! -1 = never prepared. See tr_param_generation in the registry. INTEGER, SAVE :: g_prep_generation = -1 + ! #246: layout query, callable before tr_init. + PUBLIC :: tr_api_state_abi_version, tr_api_state_size + CONTAINS !------------------------------------------------------------------- @@ -639,4 +643,23 @@ END SUBROUTINE add END SUBROUTINE species_profile_violations + !------------------------------------------------------------------- + ! tr_state_abi_version / tr_state_size : layout query (#246). + ! + ! No lifecycle check and no side effect; callable before tr_init. + ! A caller compares both with its own sizeof(tr_state_t) and its copy + ! of TR_STATE_ABI_VERSION before passing a struct to tr_get_state; + ! python/trlib/_ffi.py refuses the library when either differs. + !------------------------------------------------------------------- + FUNCTION tr_api_state_abi_version() RESULT(v) BIND(C, NAME="tr_state_abi_version") + INTEGER(C_INT) :: v + v = TR_STATE_ABI_VERSION + END FUNCTION tr_api_state_abi_version + + FUNCTION tr_api_state_size() RESULT(n) BIND(C, NAME="tr_state_size") + INTEGER(C_SIZE_T) :: n + TYPE(tr_state_c) :: probe + n = C_SIZEOF(probe) + END FUNCTION tr_api_state_size + END MODULE tr_api diff --git a/tr/tr_api.h b/tr/tr_api.h index 2724670dd..69e8f907e 100644 --- a/tr/tr_api.h +++ b/tr/tr_api.h @@ -1,6 +1,8 @@ #ifndef TR_API_H #define TR_API_H +#include /* size_t for tr_state_size (#246) */ + #ifdef __cplusplus extern "C" { #endif @@ -30,6 +32,11 @@ extern "C" { * against any cached layout assumption and recompile when it bumps. In-tree * consumers (tot_api_check_*, python/trlib/_ffi.py) all rebuild from this * header, so they are kept in sync automatically by the build system. + * Since #246 the same value is a PARAMETER in tr_state.f90, exported by + * tr_state_abi_version() together with sizeof via tr_state_size(); bump + * both places together. tests/c_abi/test_negative.c fails when they + * drift, and python/trlib/_ffi.py refuses a library whose version or + * size differs from its struct at load time. * * History: * 1 -> initial layout (Phase L-2) @@ -95,6 +102,13 @@ int tr_set_param_str(const char* name, const char* value); int tr_get_state(tr_state_t* state); int tr_finalize(void); +/* Layout query (#246): the library's TR_STATE_ABI_VERSION and + sizeof(tr_state_t), callable before tr_init. Compare both with this + header's values before passing a struct to tr_get_state; a mismatch + means the library and this header come from different commits. */ +int tr_state_abi_version(void); +size_t tr_state_size(void); + /* * Return codes: * TR_OK (0): *ndiag_out == 0; state validates cleanly diff --git a/tr/tr_state.f90 b/tr/tr_state.f90 index 5e66f4652..df3f73dd1 100644 --- a/tr/tr_state.f90 +++ b/tr/tr_state.f90 @@ -18,6 +18,7 @@ MODULE tr_state USE, INTRINSIC :: ISO_C_BINDING IMPLICIT NONE PRIVATE + PUBLIC :: TR_STATE_ABI_VERSION PUBLIC :: tr_state_c, TR_MAX_NRMAX, TR_MAX_NSMAX, & tr_diag_entry_c, & TR_DIAG_PARAM_LEN, TR_DIAG_MSG_LEN, & @@ -40,6 +41,13 @@ MODULE tr_state INTEGER(C_INT), PARAMETER :: TR_DIAG_FILE_MISSING = 4 INTEGER(C_INT), PARAMETER :: TR_DIAG_MISSING_REQUIRED = 5 + ! Layout version of tr_state_c / tr_state_t (#246). Bump it together + ! with TR_STATE_ABI_VERSION in tr_api.h whenever a field is added, + ! removed or reordered; tr_state_abi_version() exports it and the C-ABI + ! driver fails when the two drift. tr_state_size() exports the + ! BIND(C) size, so the library, not the header, is authoritative. + INTEGER(C_INT), PARAMETER :: TR_STATE_ABI_VERSION = 2 + TYPE, BIND(C) :: tr_state_c INTEGER(C_INT) :: nt INTEGER(C_INT) :: nrmax diff --git a/wr/tests/c_abi/test_negative.c b/wr/tests/c_abi/test_negative.c index 96fb15a1e..5bc22e138 100644 --- a/wr/tests/c_abi/test_negative.c +++ b/wr/tests/c_abi/test_negative.c @@ -63,6 +63,22 @@ } while (0) int main(void) { + /* ---- 0: layout query must match this header (#246) ---------- */ + { + int v = wr_state_abi_version(); + size_t n = wr_state_size(); + if (v != WR_STATE_ABI_VERSION) { + fprintf(stderr, "FAIL abi-version: library reports %d, wr_api.h says %d\n", + v, WR_STATE_ABI_VERSION); + return (__LINE__); + } + if (n != sizeof(wr_state_t)) { + fprintf(stderr, "FAIL state-size: library reports %lu, sizeof(wr_state_t) is %lu\n", + (unsigned long) n, (unsigned long) sizeof(wr_state_t)); + return (__LINE__); + } + } + int rc; wr_state_t s; diff --git a/wr/tests/c_abi/test_run_so.c b/wr/tests/c_abi/test_run_so.c index 3f6866bc3..c15a6f873 100644 --- a/wr/tests/c_abi/test_run_so.c +++ b/wr/tests/c_abi/test_run_so.c @@ -16,6 +16,8 @@ * 1..8 = step that failed (same numbering as test_run.c) * 90 = dlopen failed * 91 = dlsym failed (one of the 5 entry points missing) + * 92 = wr_state_abi_version / wr_state_size missing (library predates #246) + * 93 = layout version or size differs from wr_api.h (#246) * * Mirrors tr/tests/c_abi/test_run_so.c (Phase L-4, PR #35). */ @@ -74,6 +76,28 @@ int main(void) { return 91; } + /* #246: the layout query must agree with this header before any + * wr_state_t is passed. 92 = symbols missing (library predates + * #246), 93 = version or size differs (library and header from + * different commits). */ + typedef int (*wr_abi_version_fn)(void); + typedef size_t (*wr_state_size_fn)(void); + wr_abi_version_fn f_abi = (wr_abi_version_fn) dlsym(h, "wr_state_abi_version"); + wr_state_size_fn f_size = (wr_state_size_fn) dlsym(h, "wr_state_size"); + if (!f_abi || !f_size) { + fprintf(stderr, "dlsym missing wr_state_abi_version / wr_state_size (#246)\n"); + dlclose(h); + return 92; + } + if (f_abi() != WR_STATE_ABI_VERSION || f_size() != sizeof(wr_state_t)) { + fprintf(stderr, + "layout mismatch: library v%d / %lu bytes, wr_api.h v%d / %lu bytes\n", + f_abi(), (unsigned long) f_size(), + WR_STATE_ABI_VERSION, (unsigned long) sizeof(wr_state_t)); + dlclose(h); + return 93; + } + /* Mirror the test_run.c cycle so the .so is proven to be a drop-in * replacement for the static libwr.a link path. */ int rc; diff --git a/wr/wr_api.f90 b/wr/wr_api.f90 index 11a4c8339..4dc8e6540 100644 --- a/wr/wr_api.f90 +++ b/wr/wr_api.f90 @@ -34,6 +34,7 @@ MODULE wr_api USE, INTRINSIC :: ISO_C_BINDING + USE wr_state, ONLY: WR_STATE_ABI_VERSION USE wr_state, ONLY: wr_state_c, & WR_MAX_NRAYMAX, WR_MAX_NRSMAX, WR_MAX_NRLMAX, & WR_MAX_NRAY_EQ @@ -75,6 +76,9 @@ MODULE wr_api EXTERNAL :: EQINIT, EQCHEK + ! #246: layout query, callable before wr_init. + PUBLIC :: wr_api_state_abi_version, wr_api_state_size + CONTAINS !------------------------------------------------------------------- @@ -396,4 +400,23 @@ SUBROUTINE wr_apply_namelist_checks(ierr) ierr = ic_ierr END SUBROUTINE wr_apply_namelist_checks + !------------------------------------------------------------------- + ! wr_state_abi_version / wr_state_size : layout query (#246). + ! + ! No lifecycle check and no side effect; callable before wr_init. + ! A caller compares both with its own sizeof(wr_state_t) and its copy + ! of WR_STATE_ABI_VERSION before passing a struct to wr_get_state; + ! python/wrlib/_ffi.py refuses the library when either differs. + !------------------------------------------------------------------- + FUNCTION wr_api_state_abi_version() RESULT(v) BIND(C, NAME="wr_state_abi_version") + INTEGER(C_INT) :: v + v = WR_STATE_ABI_VERSION + END FUNCTION wr_api_state_abi_version + + FUNCTION wr_api_state_size() RESULT(n) BIND(C, NAME="wr_state_size") + INTEGER(C_SIZE_T) :: n + TYPE(wr_state_c) :: probe + n = C_SIZEOF(probe) + END FUNCTION wr_api_state_size + END MODULE wr_api diff --git a/wr/wr_api.h b/wr/wr_api.h index 63a133ad0..002cc6862 100644 --- a/wr/wr_api.h +++ b/wr/wr_api.h @@ -1,6 +1,8 @@ #ifndef WR_API_H #define WR_API_H +#include /* size_t for wr_state_size (#246) */ + #ifdef __cplusplus extern "C" { #endif @@ -30,6 +32,20 @@ extern "C" { #define WR_MAX_NRLMAX 400 #define WR_MAX_NRAY_EQ 9 +/* + * wr_state_t ABI version. Bumped whenever a field is added/removed/reordered + * in wr_state_t (i.e. whenever sizeof(wr_state_t) changes), together with + * the WR_STATE_ABI_VERSION PARAMETER in wr_state.f90 that the library + * exports through wr_state_abi_version(); tests/c_abi/test_negative.c + * fails when the two drift. In-tree consumers (the C-ABI drivers, + * python/wrlib/_ffi.py) rebuild from this header and check the library's + * version and sizeof at load time (#246). + * + * History: + * 1 -> initial layout + */ +#define WR_STATE_ABI_VERSION 1 + /* Error codes returned by every wr_* entry point. */ enum wr_error { WR_OK = 0, @@ -60,6 +76,13 @@ int wr_set_param(const char* name, double value); int wr_get_state(wr_state_t* state); int wr_finalize(void); +/* Layout query (#246): the library's WR_STATE_ABI_VERSION and + sizeof(wr_state_t), callable before wr_init. Compare both with this + header's values before passing a struct to wr_get_state; a mismatch + means the library and this header come from different commits. */ +int wr_state_abi_version(void); +size_t wr_state_size(void); + #ifdef __cplusplus } #endif diff --git a/wr/wr_state.f90 b/wr/wr_state.f90 index f5a7d20e9..71e6b79e3 100644 --- a/wr/wr_state.f90 +++ b/wr/wr_state.f90 @@ -20,6 +20,7 @@ MODULE wr_state USE, INTRINSIC :: ISO_C_BINDING IMPLICIT NONE PRIVATE + PUBLIC :: WR_STATE_ABI_VERSION PUBLIC :: wr_state_c, & WR_MAX_NRAYMAX, WR_MAX_NRSMAX, WR_MAX_NRLMAX, WR_MAX_NRAY_EQ @@ -31,6 +32,13 @@ MODULE wr_state INTEGER(C_INT), PARAMETER :: WR_MAX_NRLMAX = 400 INTEGER(C_INT), PARAMETER :: WR_MAX_NRAY_EQ = 9 + ! Layout version of wr_state_c / wr_state_t (#246). Bump it together + ! with WR_STATE_ABI_VERSION in wr_api.h whenever a field is added, + ! removed or reordered; wr_state_abi_version() exports it and the C-ABI + ! driver fails when the two drift. wr_state_size() exports the + ! BIND(C) size, so the library, not the header, is authoritative. + INTEGER(C_INT), PARAMETER :: WR_STATE_ABI_VERSION = 1 + TYPE, BIND(C) :: wr_state_c ! Runtime dimensions (<= WR_MAX_*). INTEGER(C_INT) :: nraymax diff --git a/wrx/tests/c_abi/test_negative.c b/wrx/tests/c_abi/test_negative.c index e0c806cc5..0f5bcfbd8 100644 --- a/wrx/tests/c_abi/test_negative.c +++ b/wrx/tests/c_abi/test_negative.c @@ -51,6 +51,22 @@ } while (0) int main(void) { + /* ---- 0: layout query must match this header (#246) ---------- */ + { + int v = wrx_state_abi_version(); + size_t n = wrx_state_size(); + if (v != WRX_STATE_ABI_VERSION) { + fprintf(stderr, "FAIL abi-version: library reports %d, wrx_api.h says %d\n", + v, WRX_STATE_ABI_VERSION); + return (__LINE__); + } + if (n != sizeof(wrx_state_t)) { + fprintf(stderr, "FAIL state-size: library reports %lu, sizeof(wrx_state_t) is %lu\n", + (unsigned long) n, (unsigned long) sizeof(wrx_state_t)); + return (__LINE__); + } + } + int rc; /* wrx_state_t is ~70KB; stack-allocating risks overflow on threads * or under valgrind's main-stack tracker. BSS (static) is pre-zeroed diff --git a/wrx/tests/c_abi/test_run_so.c b/wrx/tests/c_abi/test_run_so.c index 6157f35ae..96bb55f62 100644 --- a/wrx/tests/c_abi/test_run_so.c +++ b/wrx/tests/c_abi/test_run_so.c @@ -16,6 +16,8 @@ * 1..8 = step that failed (same numbering as test_run.c) * 90 = dlopen failed * 91 = dlsym failed (one of the 5 entry points missing) + * 92 = wrx_state_abi_version / wrx_state_size missing (library predates #246) + * 93 = layout version or size differs from wrx_api.h (#246) * * NOTE: Mirrors wrx/tests/c_abi/test_run.c (not wr/tests/c_abi/ * test_run_so.c). Like the static test_run.c, we deliberately skip @@ -89,6 +91,28 @@ int main(void) { return 91; } + /* #246: the layout query must agree with this header before any + * wrx_state_t is passed. 92 = symbols missing (library predates + * #246), 93 = version or size differs (library and header from + * different commits). */ + typedef int (*wrx_abi_version_fn)(void); + typedef size_t (*wrx_state_size_fn)(void); + wrx_abi_version_fn f_abi = (wrx_abi_version_fn) dlsym(h, "wrx_state_abi_version"); + wrx_state_size_fn f_size = (wrx_state_size_fn) dlsym(h, "wrx_state_size"); + if (!f_abi || !f_size) { + fprintf(stderr, "dlsym missing wrx_state_abi_version / wrx_state_size (#246)\n"); + dlclose(h); + return 92; + } + if (f_abi() != WRX_STATE_ABI_VERSION || f_size() != sizeof(wrx_state_t)) { + fprintf(stderr, + "layout mismatch: library v%d / %lu bytes, wrx_api.h v%d / %lu bytes\n", + f_abi(), (unsigned long) f_size(), + WRX_STATE_ABI_VERSION, (unsigned long) sizeof(wrx_state_t)); + dlclose(h); + return 93; + } + int rc; /* wrx_state_t is ~70KB; stack-allocating risks overflow on threads * or under valgrind's main-stack tracker. BSS (static) is pre-zeroed diff --git a/wrx/wrx_api.f90 b/wrx/wrx_api.f90 index fa9d50985..5273411e1 100644 --- a/wrx/wrx_api.f90 +++ b/wrx/wrx_api.f90 @@ -33,6 +33,7 @@ MODULE wrx_api USE, INTRINSIC :: ISO_C_BINDING + USE wrx_state, ONLY: WRX_STATE_ABI_VERSION USE wrx_state, ONLY: wrx_state_c, WRX_MAX_NRAYMAX, WRX_MAX_NSAMAX, & WRX_MAX_NRSMAX, WRX_MAX_NRLMAX USE wrcomm, ONLY: rkind, & @@ -76,6 +77,9 @@ MODULE wrx_api EXTERNAL EQINIT, EQCHEK, EQFINI, GSOPEN, GSCLOS + ! #246: layout query, callable before wrx_init. + PUBLIC :: wrx_api_state_abi_version, wrx_api_state_size + CONTAINS !------------------------------------------------------------------- @@ -452,4 +456,23 @@ SUBROUTINE wrx_apply_namelist_checks(ierr) ierr = ic_ierr END SUBROUTINE wrx_apply_namelist_checks + !------------------------------------------------------------------- + ! wrx_state_abi_version / wrx_state_size : layout query (#246). + ! + ! No lifecycle check and no side effect; callable before wrx_init. + ! A caller compares both with its own sizeof(wrx_state_t) and its copy + ! of WRX_STATE_ABI_VERSION before passing a struct to wrx_get_state; + ! python/wrxlib/_ffi.py refuses the library when either differs. + !------------------------------------------------------------------- + FUNCTION wrx_api_state_abi_version() RESULT(v) BIND(C, NAME="wrx_state_abi_version") + INTEGER(C_INT) :: v + v = WRX_STATE_ABI_VERSION + END FUNCTION wrx_api_state_abi_version + + FUNCTION wrx_api_state_size() RESULT(n) BIND(C, NAME="wrx_state_size") + INTEGER(C_SIZE_T) :: n + TYPE(wrx_state_c) :: probe + n = C_SIZEOF(probe) + END FUNCTION wrx_api_state_size + END MODULE wrx_api diff --git a/wrx/wrx_api.h b/wrx/wrx_api.h index 229e6abac..4aa62b088 100644 --- a/wrx/wrx_api.h +++ b/wrx/wrx_api.h @@ -1,6 +1,8 @@ #ifndef WRX_API_H #define WRX_API_H +#include /* size_t for wrx_state_size (#246) */ + #ifdef __cplusplus extern "C" { #endif @@ -46,6 +48,20 @@ extern "C" { #define WRX_MAX_NRSMAX 256 #define WRX_MAX_NRLMAX 256 +/* + * wrx_state_t ABI version. Bumped whenever a field is added/removed/reordered + * in wrx_state_t (i.e. whenever sizeof(wrx_state_t) changes), together with + * the WRX_STATE_ABI_VERSION PARAMETER in wrx_state.f90 that the library + * exports through wrx_state_abi_version(); tests/c_abi/test_negative.c + * fails when the two drift. In-tree consumers (the C-ABI drivers, + * python/wrxlib/_ffi.py) rebuild from this header and check the library's + * version and sizeof at load time (#246). + * + * History: + * 1 -> initial layout + */ +#define WRX_STATE_ABI_VERSION 1 + /* Error codes returned by every wrx_* entry point. */ enum wrx_error { WRX_OK = 0, @@ -94,6 +110,13 @@ int wrx_set_param(const char* name, double value); int wrx_get_state(wrx_state_t* state); int wrx_finalize(void); +/* Layout query (#246): the library's WRX_STATE_ABI_VERSION and + sizeof(wrx_state_t), callable before wrx_init. Compare both with this + header's values before passing a struct to wrx_get_state; a mismatch + means the library and this header come from different commits. */ +int wrx_state_abi_version(void); +size_t wrx_state_size(void); + #ifdef __cplusplus } #endif diff --git a/wrx/wrx_state.f90 b/wrx/wrx_state.f90 index 92a411f7d..1ff169646 100644 --- a/wrx/wrx_state.f90 +++ b/wrx/wrx_state.f90 @@ -27,6 +27,7 @@ MODULE wrx_state USE, INTRINSIC :: ISO_C_BINDING IMPLICIT NONE PRIVATE + PUBLIC :: WRX_STATE_ABI_VERSION PUBLIC :: wrx_state_c, & WRX_MAX_NRAYMAX, WRX_MAX_NSAMAX, & WRX_MAX_NRSMAX, WRX_MAX_NRLMAX @@ -36,6 +37,13 @@ MODULE wrx_state INTEGER(C_INT), PARAMETER :: WRX_MAX_NRSMAX = 256 INTEGER(C_INT), PARAMETER :: WRX_MAX_NRLMAX = 256 + ! Layout version of wrx_state_c / wrx_state_t (#246). Bump it together + ! with WRX_STATE_ABI_VERSION in wrx_api.h whenever a field is added, + ! removed or reordered; wrx_state_abi_version() exports it and the C-ABI + ! driver fails when the two drift. wrx_state_size() exports the + ! BIND(C) size, so the library, not the header, is authoritative. + INTEGER(C_INT), PARAMETER :: WRX_STATE_ABI_VERSION = 1 + TYPE, BIND(C) :: wrx_state_c ! --- runtime dims --- INTEGER(C_INT) :: nraymax