Add marmot-c C ABI bindings for the Marmot runtime#789
Conversation
Open the burrow to every language: a stable C ABI over marmot-uniffi so
consumers without a UniFFI runtime (C/C++, Zig, Nim, Go, Odin, Lua, PHP)
can drive the Marmot runtime directly.
- cdylib + staticlib, cbindgen-generated checked-in include/marmot.h
- #[repr(C)] mirrors built from the marmot-uniffi ...Ffi types, so the C
shape can never drift from the Swift/Kotlin surface
- owned pointers freed only by their matching marmot_*_free; inputs
borrowed; embedded tokio runtime drives async work via blocking calls;
subscriptions expose blocking next + callbacks
- memory-safety scaffolding: alloc-audit test feature, catch_unwind at
every boundary, typed MarmotStatus + thread-local last-error
- c-bindings.sh packaging, c-smoke.sh, seven worked example consumers
(smoke.{c,zig,nim,go,odin,lua,php}) run in CI, and a marmotc-v* release
track
Closes #328
|
Ready to review this PR? Stage has broken it down into 10 individual chapters for you: Chapters generated by Stage for commit 43e035a on Jul 9, 2026 4:04am UTC. |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughThis PR adds a new ChangesMarmot C ABI Bindings
Estimated code review effort: 5 (Critical) | ~150 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 golangci-lint (2.12.2)level=error msg="[linters_context] typechecking error: pattern ./...: directory prefix . does not contain main module or its selected dependencies" Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
🧹 Nitpick comments (2)
.github/workflows/ci.yml (1)
129-135: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winPin the
cbindgenversion used for the header diff gate.
cargo install cbindgen --lockedpulls whatever is currently published on crates.io. cbindgen's output formatting can change across releases, so this gate can start failing (or silently drift) on unrelated PRs whenever a new cbindgen version ships, rather than only when the ABI actually changes.♻️ Proposed fix: pin an explicit version
- cargo install cbindgen --locked + cargo install cbindgen --version <pinned-version> --lockedPlease confirm the intended pinned
cbindgenversion (and thatcrates/marmot-c/cbindgen.toml's syntax is compatible with it), since this repo's Cargo manifests weren't in the current review context.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.github/workflows/ci.yml around lines 129 - 135, The header diff gate currently installs cbindgen without a version, so its output can drift when crates.io releases change. Update the CI job in the workflow’s “Header diff gate” step to install a specific cbindgen version instead of using cargo install cbindgen --locked, and make sure the pinned version is compatible with crates/marmot-c/cbindgen.toml. Use the existing cbindgen invocation and git diff check unchanged after pinning the version.crates/marmot-c/examples/smoke.php (1)
121-141: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valuePHPStan can't resolve the
@var \FFI&MarmotFfiintersection type.A real
\FFIinstance never actually implements the hand-authoredMarmotFfiinterface (it only exists for@methoddocumentation), so PHPStan correctly reports this intersection as unresolvable at every use site. It's harmless at runtime but means these annotations don't buy real type-checking and will keep surfacing as PHPStan errors.Also applies to: 309-322
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/marmot-c/examples/smoke.php` around lines 121 - 141, The `@var` annotations using the \FFI&MarmotFfi intersection in smoke.php are not resolvable by PHPStan because the runtime FFI instance does not actually implement MarmotFfi. Update the type annotations around the FFI setup in the client constructor/open flow to use a PHPStan-resolvable type that still documents the available methods, and remove the invalid intersection at each affected use site including the later block around the other reported lines.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.github/workflows/c-bindings.yml:
- Around line 47-48: The release workflow’s rust cache step currently enables
cache restore/write behavior by default, which can reuse untrusted artifacts.
Update the Cache Cargo artifacts step in the .github workflow to disable cache
writes and avoid cross-branch reuse on the tag-triggered release path, using the
Swatinem/rust-cache configuration options on that step so the published
libmarmot_c.so/.a artifacts are built without trusting prior cache contents.
In `@crates/marmot-c/examples/smoke.nim`:
- Around line 178-185: The relay argument arrays are built from temporary Nim
string views via cstring, so they may not stay valid for the FFI calls. Update
the open helper and the matching identity-creation path to use owned C string
storage that survives until after marmot_client_new and marmot_create_identity
return, such as allocCStringArray with a corresponding deallocation afterward.
Keep the fix localized around the existing open proc and the create-identity
code that builds relay arrays.
In `@crates/marmot-c/examples/smoke.php`:
- Around line 289-307: marshalStrings() is allocating relay buffers with
owned:false, which leaks memory because nothing ever frees them. Update the
buffer allocation in marshalStrings() to use PHP-owned CData so the $buffers
pinning works as intended, and keep the existing array of backing buffers tied
to the FFI call via marshalStrings()/FFI::addr() usage. Ensure the fix preserves
the current char*[] marshaling behavior while removing the manual-lifetime
mismatch.
In `@crates/marmot-c/src/subscriptions.rs`:
- Around line 251-258: The subscription handle created in this path can leak
when write_out returns Err because ownership never transfers, so explicitly free
the boxed handle on the error branch before returning the status. Update the
same error-path cleanup pattern in the other marmot_subscribe_* constructors and
in marmot_watch_agent_text_stream, using the local handle/boxed(handle) flow in
each function to ensure failed write_out calls do not leak memory.
- Around line 121-135: The callback registration flow in install and
spawn_callback_pump allows a new pump to start before the existing callback
check, so a rejected registration can still invoke callback(user_data) or
consume items before task.abort() takes effect. Update the callback setup so the
callback slot is reserved under the mutex before any task is spawned, for
example by moving pump creation behind install or letting install accept a
closure and spawn only after the slot is successfully claimed. Keep the logic
anchored around install, callback_task, and spawn_callback_pump so failed
registrations cannot start work.
In `@crates/marmot-c/src/types/event.rs`:
- Around line 424-427: Wrap marmot_event_free in ffi_guard so the exported C
free path cannot unwind across the FFI boundary. Update marmot_event_free to
invoke free_boxed through ffi_guard, and apply the same pattern to the other
marmot_*_free functions under src/types/ so all exported frees use the shared
panic guard.
In `@crates/marmot-c/src/types/group.rs`:
- Around line 822-826: The roundtrip tests in group_record_deep_roundtrip and
the other similar tests in group.rs are gating memory::audit::test_lock() behind
#[cfg(feature = "alloc-audit")], which contradicts the documented locking
contract. Update those tests to take the test_lock guard unconditionally,
matching the pattern used in common.rs and relay.rs, while keeping the
alloc-audit-only allocation checks feature-gated as they are.
In `@crates/marmot-c/src/types/media.rs`:
- Around line 372-375: The exported free functions in the media FFI layer are
missing the same panic fence used by other C entry points. Update
marmot_media_upload_result_free, marmot_media_download_result_free, and
marmot_media_record_list_free in media.rs to route their bodies through
ffi_guard, matching the extern "C" wrappers used elsewhere in lib.rs, while
keeping the existing free_boxed cleanup behavior inside the guard.
In `@crates/marmot-c/src/types/notification.rs`:
- Around line 141-146: Wrap the exported free function
marmot_notification_settings_free in ffi_guard, matching the marmot_client_free
pattern, so any panic from free_boxed is contained before crossing the C ABI.
Update the body to invoke free_boxed through ffi_guard and keep the unsafe
extern "C" signature unchanged, using the existing FFI guard helper in this
module or nearby FFI code.
In `@crates/marmot-c/src/types/push.rs`:
- Around line 106-109: Wrap the exported C free functions in ffi_guard so panics
cannot unwind across the FFI boundary. Update marmot_push_registration_free and
marmot_group_push_debug_info_free in the push.rs module to call free_boxed from
inside ffi_guard, matching the pattern used by the other exported free helpers
in this crate. Keep the existing unsafe extern "C" signatures and only change
the function bodies to route the deep-free work through ffi_guard.
---
Nitpick comments:
In @.github/workflows/ci.yml:
- Around line 129-135: The header diff gate currently installs cbindgen without
a version, so its output can drift when crates.io releases change. Update the CI
job in the workflow’s “Header diff gate” step to install a specific cbindgen
version instead of using cargo install cbindgen --locked, and make sure the
pinned version is compatible with crates/marmot-c/cbindgen.toml. Use the
existing cbindgen invocation and git diff check unchanged after pinning the
version.
In `@crates/marmot-c/examples/smoke.php`:
- Around line 121-141: The `@var` annotations using the \FFI&MarmotFfi
intersection in smoke.php are not resolvable by PHPStan because the runtime FFI
instance does not actually implement MarmotFfi. Update the type annotations
around the FFI setup in the client constructor/open flow to use a
PHPStan-resolvable type that still documents the available methods, and remove
the invalid intersection at each affected use site including the later block
around the other reported lines.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: fe634a61-cf9a-4fa6-9b99-0fd35b462424
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (58)
.github/workflows/c-bindings.yml.github/workflows/ci.ymlAGENTS.mdCargo.tomlJustfilecrates/marmot-c/.gitignorecrates/marmot-c/AGENTS.mdcrates/marmot-c/CHANGELOG.mdcrates/marmot-c/CLAUDE.mdcrates/marmot-c/Cargo.tomlcrates/marmot-c/README.mdcrates/marmot-c/c-bindings.shcrates/marmot-c/c-smoke.shcrates/marmot-c/cbindgen.tomlcrates/marmot-c/examples/smoke.ccrates/marmot-c/examples/smoke.gocrates/marmot-c/examples/smoke.luacrates/marmot-c/examples/smoke.nimcrates/marmot-c/examples/smoke.odincrates/marmot-c/examples/smoke.phpcrates/marmot-c/examples/smoke.zigcrates/marmot-c/include/marmot.hcrates/marmot-c/marmot-c.pc.incrates/marmot-c/src/commands/account.rscrates/marmot-c/src/commands/agent_stream.rscrates/marmot-c/src/commands/audit.rscrates/marmot-c/src/commands/chat_list.rscrates/marmot-c/src/commands/directory.rscrates/marmot-c/src/commands/group.rscrates/marmot-c/src/commands/media.rscrates/marmot-c/src/commands/message.rscrates/marmot-c/src/commands/mod.rscrates/marmot-c/src/commands/notification.rscrates/marmot-c/src/commands/push.rscrates/marmot-c/src/commands/relay.rscrates/marmot-c/src/commands/subscription.rscrates/marmot-c/src/commands/timeline.rscrates/marmot-c/src/lib.rscrates/marmot-c/src/memory.rscrates/marmot-c/src/status.rscrates/marmot-c/src/subscriptions.rscrates/marmot-c/src/types/account.rscrates/marmot-c/src/types/agent_stream.rscrates/marmot-c/src/types/audit.rscrates/marmot-c/src/types/chat_list.rscrates/marmot-c/src/types/common.rscrates/marmot-c/src/types/event.rscrates/marmot-c/src/types/group.rscrates/marmot-c/src/types/markdown.rscrates/marmot-c/src/types/media.rscrates/marmot-c/src/types/message.rscrates/marmot-c/src/types/mod.rscrates/marmot-c/src/types/notification.rscrates/marmot-c/src/types/push.rscrates/marmot-c/src/types/relay.rscrates/marmot-c/src/types/timeline.rscrates/marmot-uniffi/src/lib.rsrelease.md
Keep the burrow airtight after review: - fmt: collapse the status.rs ExternalSigner match arm - CI: set save-if: false on the tag-triggered release cache so a poisoned PR cache can't be baked into published libmarmot_c artifacts - subscriptions: reserve the callback slot before spawning the pump (no pump races the receiver then gets aborted), and free the boxed handle when the out-pointer write fails - panic-safety: wrap all 55 exported marmot_*_free bodies in free_guard (catch_unwind) so a deep-free panic never unwinds into C - tests: take the alloc-audit lock unconditionally to match its contract - examples/nim: use allocCStringArray so relay arrays outlive the FFI call instead of dangling loop-local cstrings under ARC/ORC - examples/php: own the marshalled relay buffers so PHP frees them
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/marmot-c/examples/smoke.php (1)
293-296: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winSilence the PHPStan by-ref type mismatch on
$buffers.PHPStan flags line 305:
$ffi->new()is typedFFI\CData|null, so appending to$bufferswidens it beyond the declaredlist<\FFI\CData>in-type. Add a@param-outtag to declare the out-type explicitly.🩹 Proposed PHPDoc fix
* `@param` list<string> $strings - * `@param` list<\FFI\CData> $buffers out-param: backing buffers to pin + * `@param` list<\FFI\CData> $buffers out-param: backing buffers to pin + * `@param-out` list<\FFI\CData> $buffers */🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/marmot-c/examples/smoke.php` around lines 293 - 296, The PHPStan warning is caused by the by-ref $buffers parameter in marshalStrings being treated as only list<\FFI\CData> while the method appends results from $ffi->new(), which can be FFI\CData|null. Update the PHPDoc on marshalStrings to declare the out-type for $buffers explicitly with a `@param-out` tag so PHPStan understands the post-call shape, keeping the existing by-ref behavior and the current method signature intact.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@crates/marmot-c/examples/smoke.php`:
- Around line 293-296: The PHPStan warning is caused by the by-ref $buffers
parameter in marshalStrings being treated as only list<\FFI\CData> while the
method appends results from $ffi->new(), which can be FFI\CData|null. Update the
PHPDoc on marshalStrings to declare the out-type for $buffers explicitly with a
`@param-out` tag so PHPStan understands the post-call shape, keeping the existing
by-ref behavior and the current method signature intact.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ca52b6db-47dc-49bb-b7cb-0ff203704ba4
📒 Files selected for processing (20)
.github/workflows/c-bindings.ymlcrates/marmot-c/examples/smoke.nimcrates/marmot-c/examples/smoke.phpcrates/marmot-c/src/memory.rscrates/marmot-c/src/status.rscrates/marmot-c/src/subscriptions.rscrates/marmot-c/src/types/account.rscrates/marmot-c/src/types/agent_stream.rscrates/marmot-c/src/types/audit.rscrates/marmot-c/src/types/chat_list.rscrates/marmot-c/src/types/common.rscrates/marmot-c/src/types/event.rscrates/marmot-c/src/types/group.rscrates/marmot-c/src/types/markdown.rscrates/marmot-c/src/types/media.rscrates/marmot-c/src/types/message.rscrates/marmot-c/src/types/notification.rscrates/marmot-c/src/types/push.rscrates/marmot-c/src/types/relay.rscrates/marmot-c/src/types/timeline.rs
🚧 Files skipped from review as they are similar to previous changes (19)
- crates/marmot-c/src/types/common.rs
- crates/marmot-c/src/status.rs
- crates/marmot-c/src/types/notification.rs
- .github/workflows/c-bindings.yml
- crates/marmot-c/src/types/account.rs
- crates/marmot-c/src/types/timeline.rs
- crates/marmot-c/src/memory.rs
- crates/marmot-c/src/types/agent_stream.rs
- crates/marmot-c/src/types/event.rs
- crates/marmot-c/src/types/relay.rs
- crates/marmot-c/src/types/chat_list.rs
- crates/marmot-c/src/types/media.rs
- crates/marmot-c/src/types/markdown.rs
- crates/marmot-c/src/types/push.rs
- crates/marmot-c/src/types/audit.rs
- crates/marmot-c/src/types/message.rs
- crates/marmot-c/examples/smoke.nim
- crates/marmot-c/src/types/group.rs
- crates/marmot-c/src/subscriptions.rs
Fill a parity gap from the upstream rebase. The group record already carries image_hash_hex; this adds the fetch/verify/decrypt call that turns it into pixels. - marmot_download_group_blossom_image → owned byte buffer + out_len - marmot_bytes_free for byte-buffer returns (panic-guarded, NULL-safe) - deliver_bytes helper with unit tests (pair write, empty->NULL, NULL-out rejection, error mapping) - C example exercises the command + NULL-safe free - document external-signer accounts as a deliberate deferred gap (needs a callback vtable) in AGENTS.md
Lock in the review fixes so they can't silently regress: - SubscriptionCore: block_next returns data / closed / times out; install reserves the slot before spawning and rejects a second callback without running its spawn closure; accepts again after clear - free_guard swallows a panic from a free/Drop path (never unwinds into C)
Address pre-merge review: - Callback user_data lifetime docs were unsafe. clear_callback and *_subscription_free call JoinHandle::abort (non-blocking), so a callback can still be running after they return. The old docs implied user_data could be freed right after clear/free. Rewrote the module contract, the set_callback Safety notes, the clear_callback docs, and all eight free-fn docs to state the real rule: cancellation is a request, not a barrier; user_data must outlive every callback, and the caller must use its own synchronization to know none is in flight. Making abort blocking would risk deadlock (the pump may be inside the callback), so the wait stays with the caller. - MarmotStatus: give the discriminant a fixed cross-platform width. Use #[repr(i32)] (a bare C enum's width is implementation-defined). Note: #[repr(C, i32)] is only valid on enums with fields; on this fieldless enum it is a compile error, and repr(i32) yields the same int32_t-backed header emission. Regenerated marmot.h. - CI: pin cbindgen to 0.29.4 in the header diff gate so a newer release can't reformat the checked-in header and fail unrelated PRs.
The #[repr(i32)] switch made cgo map MarmotStatus to int32 instead of uint32, so smoke.go's uint32-typed check() no longer compiled on a clean build (a local Go build cache had masked it). Type check() and the status comparisons as C.MarmotStatus so the example is agnostic to whether cgo sees the enum as signed or unsigned.
Marmots who can't pull in a UniFFI runtime had no way to talk to the Marmot runtime. This adds
marmot-c: a stable C ABI overmarmot-uniffi, so C/C++ and raw-FFI callers (Zig, Nim, Go, Odin, Lua, PHP) can drive it directly.What's in it
cdylib+staticlibbuild, with a cbindgen-generatedinclude/marmot.hchecked into the repo and diff-gated in CI.#[repr(C)]mirror built from themarmot-uniffi...Ffitypes, so the C shape stays in lockstep with the Swift/Kotlin surface. Values cross as owned pointers freed only by their matchingmarmot_*_free; inputs are borrowed and never retained.*_nextreads or callback registration.alloc-audittest feature that proves every deep-free releases what it allocated,catch_unwindat every boundary so a panic never crosses into C, and a typedMarmotStatuscode per error variant with a thread-local last-error channel.Verification
c-smoke.shbuilds and runs seven worked example consumers, one per language, each driving the same slice of the runtime: lifecycle, markdown parsing, offline reads, the error taxonomy, and best-effort identity. The C, Zig, and Nim examples also walk the recursive Markdown tagged-union tree. Together they cover both linkage models (static archive and shared object) and both binding styles (header import and hand-declared decls). The C example runs under valgrind in CI.Closes #328
Summary by CodeRabbit
marmotc-v<version>tag releases.marmot.h.marmotc-v*track.