cmux-app-ffi: generate cmux_motion_ffi.h with cbindgen, fail on drift

Merged
TE
1d ago
+692-144

include/cmux_motion_ffi.h, the header the Mac app imports as CCmuxMotionFFI, is now generated by cbindgen from the Rust C ABI. A test fails, and prints the diff, when the committed header no longer matches what cbindgen generates.

Until now the header was hand-written next to src/motion.rs and src/hover.rs. If it disagreed with a Rust extern "C" signature or a #[repr(C)] layout, the result was undefined behavior that no Swift test catches. This comes from an ecosystem sweep that looked for mature outside tools to borrow where it's cheap, not to rewrite working code ("We always want to consider the option, consider the alternative."). The hot C-ABI paths stay on C, and iOS keeps UniFFI.

What changed

  • cmux-tui/crates/cmux-app-ffi/cbindgen.toml generates the header from src/motion.rs, src/hover.rs and cmux-motion's hover::c structs. The Rust doc comments become the C comments.
  • tests/c_header.rs regenerates the header and fails with a unified diff when the committed file differs. CMUX_APP_FFI_HEADER_BLESS=1 cargo test --test c_header rewrites it. rust-workspaces.yml already runs cargo test --locked on this workspace whenever the crate or a path dependency (such as cmux-motion) changes, so this adds no CI job.
  • cbindgen is a dev-dependency pinned at =0.29.2 in the committed Cargo.lock. There is no build.rs, so a build never rewrites a tracked file. The staticlib's own dependency versions are unchanged; the lock diff only adds cbindgen's tree and disambiguates syn.
  • The regenerated header is committed. No FFI signature or ABI_VERSION changed.
  • HoverRect::ZERO / ::NULL in cmux-motion get cbindgen:ignore (doc comments only). Without it, cbindgen copies them as CmuxHoverRect_* macros, and NULL's f64_INFINITY is not valid C. The test also fails on any macro other than the include guard, since this ABI has no constants.

Same ABI

  • The old and new headers declare the same 45 types and functions, compared after preprocessing and ignoring order. The only differences are ordering, comments, line wrapping and const double from[4] becoming const double *from (the same parameter type in C).
  • build-app-ffi.sh builds its exported-symbols list by grepping cmux_*( from this header. That grep gives the same 29 symbols for both headers.
  • The header path is unchanged: build-app-ffi.sh (headers_in) still copies cmux-app-ffi/include/cmux_motion_ffi.h into CCmuxMotionFFI.

Other FFI headers (not in this PR)

I tried the same recipe on the other three crates. None reproduce cleanly yet, so each would be its own follow-up:

  • cmux-rd-ffi: cbindgen emits anonymous typedef structs where the hand-written header has tagged ones, and stops on two conflicting NONE constants.
  • cmux-layout-reducer-ffi: its opaque handle types (Focus, Scroll, Mirror, Faces) would need renames in the config.
  • cmux-terminal-client: Option<extern fn> callback typedefs need explicit config.

Testing

Observed

  • cargo test --locked --test c_header: passes on the head.
  • Changing cmux_motion_ease's t: f64 to f32 in src/motion.rs made the test fail with the one-line -double … t / +float … t diff. After reverting, it passes again.
  • Removing the two cbindgen:ignore lines fails the test on unexpected macros in the header. Restoring them passes.
  • The header compiles with clang -fsyntax-only -std=c11 -Wall -Wextra.
  • cargo clippy --locked --all-targets -- -D warnings and cargo fmt --check in cmux-app-ffi are clean. prepush-gate.py reports no new violations.

Not run

  • No app build. Since the header's declarations are unchanged, the CCmuxAppFFI pin keeps its current release. The usual automated re-pin follows, because this changes files under cmux-app-ffi.

Changelog

none

šŸ¤– Generated with Claude Code

Agent session: agent session