feat(c): CBOR-free transport for non-scalar abi = c procs#137
Open
gmelodie wants to merge 1 commit into
Open
Conversation
gmelodie
force-pushed
the
feat/non-scalar-fast-path
branch
2 times, most recently
from
July 17, 2026 12:02
2633076 to
73b9068
Compare
gmelodie
force-pushed
the
feat/non-scalar-fast-path
branch
from
July 17, 2026 12:12
73b9068 to
842b836
Compare
gmelodie
marked this pull request as ready for review
July 17, 2026 12:12
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the second half of #131: "Additionally, implement CBOR-free path for non-scalar procs too".
Non-scalar
abi = cprocs already presented a CBOR-free foreign surface — the_CWireflat structs from #87 are the C ABI — but every call still paid a full CBOR round trip internally, on both legs of the FFI-thread hop:cwireUnpack(req[])→cborEncodeShared→ hop →cborDecodePtr→ handler →cborEncode→ hop →cborDecodePtr→cwirePackOnly the scalar fast path (#106/#110/#124) was genuinely CBOR-free. This PR carries the packed
_CWirestruct itself across the hop instead, so CBOR is gone from theabi = cpath in both directions:malloc'd owned copy (cwireOwnedCopy) which the FFI thread unpacks and frees.cwireUnpackdeep-copies into Nim memory (strings via$), so freeing the wire right after the unpack is safe; the envelope buffer itself still goes withdeleteRequest._CWire(cwireStructBytes) and astringreturn as raw UTF-8 (ffiRawRetBytes). The reply trampoline hands the struct's address straight to the caller, thencwireFrees the buffers it owns.The foreign ABI is unchanged:
check_bindingspasses, so the generated headers and exported dylib symbols are byte-identical. This is purely an internal transport swap.Emission order
The
_CWirecompanions are emitted bygenBindings()at the bottom of the user's module, but handlers expand at each{.ffi.}annotation above it — so a handler namingShoutResponse_CWirewould reference a type Nim has not seen yet (confirmed in the macro dump: theEchoShoutReqhandler lands at line ~114,ShoutResponse_CWireat ~257). The Req type must also precede its own_CWire, so the dependency is genuinely circular in one file.abi = ctherefore splits emission: the Req envelope type stays at the annotation site, whileprocessFFIRequest+ the registry assignment are built there but stashed onCAbiSpec.handlerand flushed byflushCAbiDispatchright afterensureCWireForFields. This is the same mechanism theabi = cctor path already used, now shared by both.registerReqFFIkeeps its CBOR behaviour untouched for its existing callers intests/unit/test_gc_compat.nim,test_event_thread.nim,test_event_dispatch.nimandtests/bench/, and no new macros are added to the public API.Affected areas
ffi/ffi_thread_request.nim:isScalar→rawReply, generalised from "scalar fast path" to "CBOR-free request", since anabi = cstringreply of""is also a real empty value rather than CBOR's "no value" sentinel.initFromOwnedSharedgains arawReplyparameter;ffiScalarRetBytes→ffiRawRetBytes(same behaviour, now shared with theabi = cstring reply).ffi/internal/ffi_macro.nim:reqDecodePreambleandreplyEncodeselect the payload shape per ABI;buildProcessFFIRequestProc/addNewRequestToRegistry/buildCtorProcessFFIRequestProc/addCtorRequestToRegistrytake anABIFormat.abi = cctors no longer emit the CBOR-onlyffiNewReq.ffi/internal/c_macro_helpers.nim: the exported method/ctor wrappers pack an owned wire instead ofcborEncodeShared; the reply trampolines read the raw image instead ofcborDecodePtr.cwireTypeName/isStringTypeexported forffi_macro.ffi/internal/c_wire.nim: addscwireAllocBuf/cwireFreeBuf,cwireStructBytes,cwireOwnedCopy.tests/unit/test_c_abi_dispatch.nim: new; drives anabi = clibrary through a real FFI thread.Impact on library users
No API or ABI change — additive and internal. Generated headers, exported symbols and the reply/error ownership contract are identical (
check_bindingsis green). Non-scalarabi = ccalls simply stop encoding and decoding CBOR on each leg of the hop.The renames (
isScalar→rawReply,ffiScalarRetBytes→ffiRawRetBytes) touch symbols that are exported but internal to the request plumbing; no example or generated binding referenced them.Risk assessment
The real risk is memory ownership, since request buffers are now allocated on the calling thread and freed on the FFI thread.
dataLenis notsizeof(<Req>_CWire)before casting, mirroring the size check the reply trampoline applies. Without it a short payload would be an out-of-bounds read of everycstringfield.sendRequestToFFIThreaddeleteRequests on theeventQueueStuckand reentrancy paths, anddeleteRequestonly frees the payload buffer itself — not the buffers a_CWirestruct points at. The wrappers thereforecwireFreethe wire they still alias on that path; on success the FFI thread's unpack owns that free instead. (The CBOR blob was a single allocation, so this hazard is new to this PR.)cwireOwnedCopyreturns nil rather thancopyMeming into a failedmalloc, and the wrappers surface it asRET_ERR. Note the generatedcwirePacksites still write through uncheckedcwireAllocBuf/cwireAllocStrresults — that is the pre-existingalloc.nimpattern repo-wide and is left for a separate change._CWireseq/Option payload buffers moved fromallocSharedto libcmalloc(cwireAllocBuf), matching the rationaleffi/alloc.nimalready documents for the request envelope: libc is process-global, so a foreign caller thread that exits before the FFI thread frees cannot dangle. To be precise about the evidence: reverting this toallocShareddid not reproduce a failure in the new tests, so this is consistency withalloc.nim's stated rule rather than a fix for an observed crash — the tests keep the calling thread alive, so they do not exercise the exiting-producer case the comment warns about.cwireOwnedCopyusescopyMemrather than assignment because the destination is rawmallocmemory and an assignment would run ORC's copy hooks over uninitialised bytes.Verification
nimble test(full unit suite),nimble test_c_e2e,nimble test_c_abi_e2e,nimble test_cpp_e2e(20/20),nimble check_bindings— all pass.NIM_FFI_SAN=asan-ubsan nimble test_c_abi_e2e_sanitized— clean, no leaks or UB. Sanitizer presence confirmed in the linked binary (the task defaults toNIM_FFI_SAN=none, which silently proves nothing).-d:ffiDumpMacrosoutput: zerocborEncode/cborDecodein theabi = cdispatch, replaced bycwireUnpack/cwireOwnedCopy/cwireStructBytes/ffiRawRetBytes.tests/unit/test_c_abi_dispatch.nim(4 tests) drivesseq[string]+Option[string]request fields across the hop through a real FFI thread, covering the object reply, the raw-UTF-8 string reply, the empty-seq/nonecase, and the handler error path. The echo example only hasstringfields, so seq/Option payload buffers crossing threads had no end-to-end coverage before.Known coverage gap: no test exercises the rejected-send path (
eventQueueStuck/ reentrancy), so thecwireFree-on-failure fix above is reasoned from the ownership contract rather than observed under ASAN.References
_CWirecodec core (flat scalars + strings) whose structs are now the transport.abi = cprocs #106, feat: CBOR-free scalar fast path for abi=c procs #110, feat(codegen): scalar-fast-path bindings for the abi = c header #124 — the scalar fast path, previously the only CBOR-free path.ctarget.The other half of #131 — supporting CBOR and
abi = csimultaneously in one library (thelibWireFormatmismatch guard inffi/codegen/c.nim) — is not addressed here and needs a decision on the export-symbol collision, since both ABIs currentlyexportcthe same name with incompatible signatures.