Skip to content

fix(ffi): regenerate the drifted cbindgen header and gate it in CI - #277

Open
YuanYuYuan wants to merge 2 commits into
mainfrom
ci/ffi-header-fresh
Open

fix(ffi): regenerate the drifted cbindgen header and gate it in CI#277
YuanYuYuan wants to merge 2 commits into
mainfrom
ci/ffi-header-fresh

Conversation

@YuanYuYuan

@YuanYuYuan YuanYuYuan commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

crates/hiroz-go/hiroz/hiroz_ffi.h is generated by cbindgen from crates/hiroz/src/ffi/ and committed, because cgo needs it at build time. Nothing in CI regenerated it, so it drifted — and the drift was not confined to documentation.

The committed header was missing the namespace_ field that CContextConfig has carried since it was added:

 typedef struct hiroz_context_config_t {
   ...
   bool enable_logging;
+  const char *namespace_;
 } hiroz_context_config_t;

cgo sizes its struct from this header. sizeof(hiroz_context_config_t) is 80 bytes as committed and 88 bytes as generated, so crates/hiroz-go/hiroz/context.go:133's var cfg C.hiroz_context_config_t allocates 80 bytes, and crates/hiroz/src/ffi/context.rs:84 reads cfg.namespace at offset 80 — past the end of the allocation — then dereferences it as a C string when the garbage there is non-null. That path is reached by any Go caller that sets an advanced-config option.

Key Changes

  • Regenerate the header. Restores the namespace_ field above, KEEP_ALL_CACHE_DEPTH, 11 parameter-type constants, and corrected doc text for DEFAULT_HISTORY_DEPTH and hiroz_service_client_wait_for_service.
  • Add rust-cbindgen to commonBuildInputs. It was in no dev shell, which is the root cause: CI could not have regenerated the header even if something had asked it to. Placed beside go, since that header is what cgo compiles against.
  • Add check-ffi-header to scripts/test-pure-rust.nu, mirroring check-python-stubs, which gates the generated Python stubs the same way. It deletes the header, forces build.rs to rerun, rebuilds with --features ffi, and fails on any git status --porcelain output.
  • Add an ffi-header-fresh CI job mirroring python-stubs-fresh.

Two details are load-bearing rather than incidental:

  • The header is deleted before regenerating. A build that generates nothing would otherwise leave the committed copy in place and pass, which is the failure mode being fixed rather than a new check. With the file absent, that surfaces as a D entry.
  • cbindgen's presence is asserted explicitly. crates/hiroz/build.rs:55-61 degrades a missing cbindgen to a non-fatal cargo:warning=, so without this the build would succeed, write nothing, and the check would report on whatever was already in the tree.

It runs as its own nix-based job because go-tests has no nix environment, and cbindgen must come from the pinned dev shell: 0.29.4 emits a CDR_HEADER_LE constant that 0.29.3 does not, so an unpinned cbindgen would itself read as drift. The version used is printed in the log so that case is diagnosable rather than mysterious.

What fails without this

The header on main is stale today. Running the new check against it fails:

$ nu scripts/test-pure-rust.nu check-ffi-header
cbindgen: cbindgen 0.29.3
 M crates/hiroz-go/hiroz/hiroz_ffi.h
[... 52-line diff, including the namespace_ field ...]
❌ Test failed: check-ffi-header
generated FFI header is stale -- run `cargo build -p hiroz --features ffi` and commit crates/hiroz-go/hiroz/hiroz_ffi.h
rc=1

The size mismatch it corresponds to, compiled from each version of the header:

old.h: sizeof(hiroz_context_config_t) = 80
new.h: sizeof(hiroz_context_config_t) = 88

Verified in three directions, so the detector is known to detect rather than merely never to fire:

input result
stale header (main as-is) fail — names the drift and prints the diff
regenerated header (this PR) pass
cbindgen absent from PATH fail — "would pass without verifying anything"; header not left deleted

Notes

Addresses item 3 of #270 and the two follow-ups recorded in its second comment. The rest of #270 — the 22 missing_safety_doc contracts, the serialize.rs:81 cast, linting the module under -D warnings, and the RawPublisher::publish_bytes regression test — is untouched and still open.

Independent of #273: that PR adds a build step to go-tests, which this does not modify. Merge simulation is clean against main, #273, #250 and #255.

The nixfmt --check deviation in flake.nix (two extraShellHook = '''' occurrences at lines 396 and 511) is pre-existing on main and outside this diff; left alone.

Breaking Changes

Yes, for C and Go consumers — they must rebuild. sizeof(hiroz_context_config_t) changes from 80 to 88.

Appending the field preserves the offsets of every pre-existing member, so source compiled against the new header is compatible. It does not preserve ABI: a caller still linked against the 80-byte definition passes an 80-byte allocation to a library that reads namespace_ at offset 80, which is the out-of-bounds read described above. The header comment inherited from the Rust source calls this "ABI compatibility"; that is too strong, and the accurate statement is offset compatibility plus a mandatory rebuild.

Rebuilding is what corrects it, and cgo does so automatically for the in-tree Go bindings. Any out-of-tree C consumer holding a copy of the old header must regenerate it.

None to the Rust API.

The committed header lacked the namespace_ field that CContextConfig has
carried since it was added. cgo sizes its struct from this file, so Go
allocated 80 bytes where the Rust side reads 88 and dereferenced
cfg.namespace past the end of the allocation on every advanced-config
ContextBuilder.Build().

Regenerated with the cbindgen the flake pins (0.29.3). Also restores 11
parameter-type constants, KEEP_ALL_CACHE_DEPTH, and corrected doc text.

Refs #270
Nothing regenerated the header in CI, so its drift from the Rust structs
was invisible. cbindgen was absent from every dev shell, and build.rs
degrades its absence to a non-fatal cargo:warning=, so enabling the ffi
feature alone would not have caught this.

Adds rust-cbindgen to commonBuildInputs and a check-ffi-header check that
deletes the header, regenerates it, and fails on any diff -- mirroring
check-python-stubs, which gates the generated Python stubs the same way.
The deletion is what makes a build that generates nothing fail instead of
reporting on the committed copy; cbindgen's absence is checked explicitly
so that path cannot pass vacuously either.

Runs as its own nix-based job because go-tests has no nix environment and
cbindgen must come from the pinned dev shell -- 0.29.4 emits a constant
0.29.3 does not, so an unpinned version would itself read as drift.

Closes item 3 of #270

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Regenerates the committed FFI header and adds CI drift detection.

Changes:

  • Updates the C header with the missing context namespace field and constants.
  • Adds cbindgen and a header freshness script.
  • Adds a dedicated CI freshness job.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
scripts/test-pure-rust.nu Adds the regeneration check.
flake.nix Adds cbindgen to build inputs.
crates/hiroz-go/hiroz/hiroz_ffi.h Regenerates the C FFI surface.
.github/workflows/ci.yml Runs the freshness check in CI.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/hiroz-go/hiroz/hiroz_ffi.h
Comment thread flake.nix
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants