mirror of
https://github.com/logos-messaging/nim-ffi.git
synced 2026-07-16 21:10:19 +00:00
feat: CBOR-free abi=c C bindings (-d:targetLang=c_abi) (#109)
This commit is contained in:
parent
c91287ba22
commit
5eaa799b7d
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@ -168,6 +168,14 @@ jobs:
|
||||
fi
|
||||
nimble test_cpp_e2e -y
|
||||
|
||||
# The CBOR-free abi=c C binding (issue #105). Linux-only: the generated
|
||||
# header is platform-checked by the check-bindings job, and the runtime
|
||||
# path only needs one OS to exercise the flat-struct dispatch.
|
||||
- name: Run abi=c C e2e test
|
||||
if: matrix.label == 'Linux'
|
||||
shell: bash
|
||||
run: nimble test_c_abi_e2e -y
|
||||
|
||||
check-bindings:
|
||||
# Single OS is enough — codegen output is platform-independent; the Nim
|
||||
# matrix catches version-sensitive output (the PR #39 drift class).
|
||||
|
||||
3
.github/workflows/tests-sanitized.yml
vendored
3
.github/workflows/tests-sanitized.yml
vendored
@ -101,3 +101,6 @@ jobs:
|
||||
|
||||
- name: Run C++ e2e tests (${{ inputs.sanitizer }})
|
||||
run: nimble test_cpp_e2e_sanitized -y
|
||||
|
||||
- name: Run abi=c C e2e test (${{ inputs.sanitizer }})
|
||||
run: nimble test_c_abi_e2e_sanitized -y
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@ -62,9 +62,22 @@ All notable changes to this project are documented in this file.
|
||||
- `c` (flat C-struct) ABI **codec**: every `{.ffi: "abi = c".}` type gets a
|
||||
`<T>_CWire` companion plus `cwirePack` / `cwireUnpack` / `cwireFree`. This
|
||||
first slice covers the flat path — POD scalars and `string` (as `cstring`);
|
||||
composite fields follow. The `c` proc-dispatch path and the foreign (C++ /
|
||||
Rust) generators are still pending, so `c` remains rejected on
|
||||
proc/ctor/dtor/event annotations for now.
|
||||
composite fields follow. (The `c` proc-dispatch path and its CBOR-free C
|
||||
generator landed later in this release — see the `-d:targetLang=c_abi` entry
|
||||
below; `c` events remain CBOR-only.)
|
||||
- **CBOR-free `abi = c` C binding generator** (`-d:targetLang=c_abi`): emits a
|
||||
single self-contained `<lib>.h` whose flat `_CWire` structs *are* the C ABI,
|
||||
so the C consumer passes native structs and links no CBOR at all (contrast
|
||||
the CBOR `-d:targetLang=c` backend). The `c` proc-dispatch path is now wired
|
||||
end-to-end: the generated exported wrappers `cwireUnpack` the request into a
|
||||
Nim object, reuse the existing CBOR thread transport internally, and a Nim
|
||||
reply trampoline `cwirePack`s the response back into a flat struct for the
|
||||
caller's typed callback. `abiCodegenImplemented` now accepts `c` for
|
||||
proc/ctor/dtor annotations (events remain CBOR-only). New
|
||||
`examples/echo/c_abi_bindings/` (checked in beside the CBOR `c_bindings/` for
|
||||
comparison), `nimble genbindings_c_abi_echo` / `check_bindings_c_abi` /
|
||||
`test_c_abi_e2e` / `test_c_abi_e2e_sanitized` tasks, and a `tests/e2e/c_abi`
|
||||
ctest harness ([#105](https://github.com/logos-messaging/nim-ffi/issues/105)).
|
||||
- `tests/bench/bench_codec.nim` (+ `nimble bench_codec`): a single-process
|
||||
microbenchmark comparing the `cbor` and `c` codecs across payload shapes,
|
||||
isolating codec cost from the (identical) thread/callback round-trip.
|
||||
|
||||
72
examples/echo/c_abi_bindings/CMakeLists.txt
Normal file
72
examples/echo/c_abi_bindings/CMakeLists.txt
Normal file
@ -0,0 +1,72 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(echo_c_abi_bindings C)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
# The CBOR-free `abi = c` binding links no TinyCBOR — the flat structs in the
|
||||
# generated header are the ABI. Only the Nim dylib is built.
|
||||
|
||||
set(_search_dir "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
set(REPO_ROOT "")
|
||||
foreach(_i RANGE 10)
|
||||
if(EXISTS "${_search_dir}/ffi.nimble")
|
||||
set(REPO_ROOT "${_search_dir}")
|
||||
break()
|
||||
endif()
|
||||
get_filename_component(_search_dir "${_search_dir}" DIRECTORY)
|
||||
endforeach()
|
||||
if("${REPO_ROOT}" STREQUAL "")
|
||||
message(FATAL_ERROR "Cannot find repo root (no ffi.nimble in any ancestor)")
|
||||
endif()
|
||||
|
||||
# Extra `nim c` arguments (e.g. a `-d:` that flips a shared example source to
|
||||
# `abi = c`). A library that declares `defaultABIFormat = "c"` needs none.
|
||||
set(NIM_FFI_EXTRA_ARGS "" CACHE STRING "Extra nim c args when building the dylib")
|
||||
|
||||
find_program(NIM_EXECUTABLE nim REQUIRED)
|
||||
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
|
||||
set(NIM_LIB_FILE "${REPO_ROOT}/libecho.dylib")
|
||||
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
|
||||
set(NIM_LIB_FILE "${REPO_ROOT}/echo.dll")
|
||||
else()
|
||||
set(NIM_LIB_FILE "${REPO_ROOT}/libecho.so")
|
||||
endif()
|
||||
|
||||
get_filename_component(NIM_SRC "${CMAKE_CURRENT_SOURCE_DIR}/../echo.nim" ABSOLUTE)
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT "${NIM_LIB_FILE}"
|
||||
COMMAND "${NIM_EXECUTABLE}" c
|
||||
--mm:orc
|
||||
-d:chronicles_log_level=WARN
|
||||
--app:lib
|
||||
--noMain
|
||||
"--nimMainPrefix:libecho"
|
||||
${NIM_FFI_EXTRA_ARGS}
|
||||
"-o:${NIM_LIB_FILE}"
|
||||
"${NIM_SRC}"
|
||||
WORKING_DIRECTORY "${REPO_ROOT}"
|
||||
DEPENDS "${NIM_SRC}"
|
||||
COMMENT "Compiling Nim library libecho (abi = c)"
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target(echo_nim_lib ALL DEPENDS "${NIM_LIB_FILE}")
|
||||
|
||||
add_library(echo SHARED IMPORTED GLOBAL)
|
||||
set_target_properties(echo PROPERTIES IMPORTED_LOCATION "${NIM_LIB_FILE}")
|
||||
add_dependencies(echo echo_nim_lib)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
add_library(echo_headers INTERFACE)
|
||||
target_include_directories(echo_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
target_link_libraries(echo_headers INTERFACE echo Threads::Threads)
|
||||
target_compile_definitions(echo_headers INTERFACE _POSIX_C_SOURCE=200809L)
|
||||
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.c")
|
||||
add_executable(echo_example main.c)
|
||||
target_link_libraries(echo_example PRIVATE echo_headers)
|
||||
add_dependencies(echo_example echo_nim_lib)
|
||||
endif()
|
||||
27
examples/echo/c_abi_bindings/README.md
Normal file
27
examples/echo/c_abi_bindings/README.md
Normal file
@ -0,0 +1,27 @@
|
||||
# echo — CBOR-free `abi = c` C bindings
|
||||
|
||||
Generated by `nimble genbindings_c_abi_echo` (`-d:targetLang=c_abi`), this is the
|
||||
**pure C ABI** rendering of the echo library, meant to be read side-by-side with
|
||||
the CBOR rendering in [`../c_bindings/`](../c_bindings/) (issue #105).
|
||||
|
||||
| | `../c_bindings/` (`-d:targetLang=c`) | this dir (`-d:targetLang=c_abi`) |
|
||||
|---|---|---|
|
||||
| Wire format on the C side | CBOR | flat C structs (no serialization) |
|
||||
| Third-party dependency | vendored TinyCBOR | none |
|
||||
| Files | `echo.h` + `nim_ffi_cbor.h` + `nim_ffi_prelude.h` | one self-contained `echo.h` |
|
||||
| String type | `NimFfiStr` (owned) | `const char*` (borrowed for the call) |
|
||||
|
||||
Both talk to the *same* unmodified Nim dylib API shape (`echo_ctx_create`,
|
||||
`echo_ctx_shout`, …) and share the async, binding-owned callback contract. The
|
||||
difference is only the ABI at the boundary: here the macro-generated `_CWire`
|
||||
structs (`ShoutRequest { const char* text; }`, …) are passed as native C structs
|
||||
and the Nim side converts them to/from Nim objects. CBOR is still used, but only
|
||||
as an internal transport between the exported wrapper and the FFI worker thread —
|
||||
it never appears in this header.
|
||||
|
||||
The library is built with `-d:ffiEchoAbiC`, which flips the shared
|
||||
`examples/echo/echo.nim` source to `declareLibrary(..., defaultABIFormat = "c")`.
|
||||
A library that always wants the C ABI would set that in its own `declareLibrary`
|
||||
call and need no extra flag.
|
||||
|
||||
Run the end-to-end test with `nimble test_c_abi_e2e`.
|
||||
110
examples/echo/c_abi_bindings/echo.h
Normal file
110
examples/echo/c_abi_bindings/echo.h
Normal file
@ -0,0 +1,110 @@
|
||||
#ifndef NIM_FFI_LIB_ECHO_C_ABI_H_INCLUDED
|
||||
#define NIM_FFI_LIB_ECHO_C_ABI_H_INCLUDED
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define NIMFFI_RET_OK 0
|
||||
#define NIMFFI_RET_ERR 1
|
||||
#define NIMFFI_RET_MISSING_CALLBACK 2
|
||||
|
||||
/* Flat wire structs — the C ABI. Strings are borrowed, NUL-terminated
|
||||
`const char*` valid only for the duration of the call they cross. */
|
||||
typedef struct {
|
||||
const char* prefix;
|
||||
} EchoConfig;
|
||||
typedef struct {
|
||||
const char* text;
|
||||
} ShoutRequest;
|
||||
typedef struct {
|
||||
const char* shouted;
|
||||
const char* prefix;
|
||||
} ShoutResponse;
|
||||
typedef struct {
|
||||
EchoConfig config;
|
||||
} EchoCreateCtorReq;
|
||||
typedef struct {
|
||||
ShoutRequest req;
|
||||
} EchoShoutReq;
|
||||
|
||||
typedef void (*EchoShoutReplyFn)(int err_code, const ShoutResponse* reply, const char* err_msg, void* user_data);
|
||||
|
||||
typedef void (*EchoCreateRawFn)(int err_code, const char* ctx_addr, const char* err_msg, void* user_data);
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
void* echo_create(const EchoCreateCtorReq* req, EchoCreateRawFn on_created, void* user_data);
|
||||
int echo_shout(void* ctx, EchoShoutReplyFn on_reply, void* user_data, const EchoShoutReq* req);
|
||||
int echo_destroy(void* ctx);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} /* extern "C" */
|
||||
#endif
|
||||
|
||||
/* High-level context wrapper */
|
||||
typedef struct {
|
||||
void* ptr;
|
||||
} EchoCtx;
|
||||
|
||||
typedef void (*EchoCreateFn)(int err_code, EchoCtx* ctx, const char* err_msg, void* user_data);
|
||||
typedef struct { EchoCreateFn fn; void* user_data; } EchoCreateBox;
|
||||
static void echo_create_trampoline(int ret, const char* ctx_addr, const char* err_msg, void* ud) {
|
||||
EchoCreateBox* box = (EchoCreateBox*)ud;
|
||||
if (!box) return;
|
||||
if (!box->fn) { free(box); return; }
|
||||
if (ret != 0) {
|
||||
box->fn(ret, NULL, err_msg ? err_msg : "FFI create failed", box->user_data);
|
||||
free(box);
|
||||
return;
|
||||
}
|
||||
char* endp = NULL;
|
||||
unsigned long long a = ctx_addr ? strtoull(ctx_addr, &endp, 10) : 0;
|
||||
bool ok = ctx_addr && *ctx_addr && endp && *endp == '\0';
|
||||
if (!ok) {
|
||||
box->fn(-1, NULL, "FFI create returned non-numeric address", box->user_data);
|
||||
free(box);
|
||||
return;
|
||||
}
|
||||
EchoCtx* ctx = (EchoCtx*)calloc(1, sizeof(EchoCtx));
|
||||
if (!ctx) {
|
||||
box->fn(-1, NULL, "out of memory", box->user_data);
|
||||
free(box);
|
||||
return;
|
||||
}
|
||||
ctx->ptr = (void*)(uintptr_t)a;
|
||||
box->fn(NIMFFI_RET_OK, ctx, NULL, box->user_data);
|
||||
free(box);
|
||||
}
|
||||
|
||||
static inline int echo_ctx_create(const EchoConfig* config, EchoCreateFn on_created, void* user_data) {
|
||||
EchoCreateCtorReq ffi_req;
|
||||
memset(&ffi_req, 0, sizeof(ffi_req));
|
||||
ffi_req.config = *config;
|
||||
EchoCreateBox* box = (EchoCreateBox*)malloc(sizeof(EchoCreateBox));
|
||||
if (!box) {
|
||||
if (on_created) on_created(-1, NULL, "out of memory", user_data);
|
||||
return -1;
|
||||
}
|
||||
box->fn = on_created;
|
||||
box->user_data = user_data;
|
||||
(void)echo_create(&ffi_req, echo_create_trampoline, box);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static inline void echo_ctx_destroy(EchoCtx* ctx) {
|
||||
if (!ctx) return;
|
||||
if (ctx->ptr) { echo_destroy(ctx->ptr); ctx->ptr = NULL; }
|
||||
free(ctx);
|
||||
}
|
||||
|
||||
static inline int echo_ctx_shout(const EchoCtx* ctx, const ShoutRequest* req, EchoShoutReplyFn on_reply, void* user_data) {
|
||||
EchoShoutReq ffi_req;
|
||||
memset(&ffi_req, 0, sizeof(ffi_req));
|
||||
ffi_req.req = *req;
|
||||
return echo_shout(ctx->ptr, on_reply, user_data, &ffi_req);
|
||||
}
|
||||
|
||||
#endif /* NIM_FFI_LIB_ECHO_C_ABI_H_INCLUDED */
|
||||
@ -6,7 +6,13 @@ import ffi, chronos, strutils
|
||||
type Echo = object
|
||||
prefix: string
|
||||
|
||||
declareLibrary("echo", Echo)
|
||||
# `-d:ffiEchoAbiC` builds the CBOR-free `abi = c` variant (flat `_CWire` structs
|
||||
# on the wire); the default is the CBOR ABI. The same source drives both the
|
||||
# `c_bindings/` (CBOR) and `c_abi_bindings/` (flat) example outputs.
|
||||
when defined(ffiEchoAbiC):
|
||||
declareLibrary("echo", Echo, defaultABIFormat = "c")
|
||||
else:
|
||||
declareLibrary("echo", Echo)
|
||||
|
||||
type EchoConfig {.ffi.} = object
|
||||
prefix: string
|
||||
|
||||
47
ffi.nimble
47
ffi.nimble
@ -84,6 +84,19 @@ proc applyTsanSuppressions() =
|
||||
elif "suppressions=" notin existing:
|
||||
putEnv("TSAN_OPTIONS", existing & ":suppressions=" & suppPath)
|
||||
|
||||
proc removeStaleEchoLib() =
|
||||
## The CBOR and `abi = c` echo e2e suites both compile examples/echo/echo.nim
|
||||
## to the same repo-root `libecho.so`, differing only by `-d:ffiEchoAbiC`.
|
||||
## CMake keys the dylib rebuild on echo.nim's mtime, not the ABI flag, so a
|
||||
## `libecho.so` left by an earlier CBOR e2e step is silently reused by the
|
||||
## abi=c build — the flat-struct C caller then reaches the CBOR entry points
|
||||
## (wrong arity/ABI) and segfaults. Delete it first to force a fresh rebuild
|
||||
## with the right ABI.
|
||||
for name in ["libecho.so", "libecho.dylib", "echo.dll"]:
|
||||
let path = thisDir() / name
|
||||
if fileExists(path):
|
||||
rmFile(path)
|
||||
|
||||
task buildffi, "Compile the library":
|
||||
exec "nim c " & nimFlagsOrc & " --app:lib --noMain ffi.nim"
|
||||
|
||||
@ -138,6 +151,14 @@ task test_c_e2e, "Build and run the C end-to-end tests for the timer example":
|
||||
runOrQuit "cmake --build tests/e2e/c/build --config Debug"
|
||||
runOrQuit "ctest --test-dir tests/e2e/c/build --output-on-failure -C Debug"
|
||||
|
||||
task test_c_abi_e2e, "Build and run the CBOR-free abi=c C end-to-end test (echo)":
|
||||
# Regenerate the abi=c bindings so the suite always runs against fresh codegen.
|
||||
runOrQuit "nimble genbindings_c_abi_echo"
|
||||
removeStaleEchoLib()
|
||||
runOrQuit "cmake -S tests/e2e/c_abi -B tests/e2e/c_abi/build"
|
||||
runOrQuit "cmake --build tests/e2e/c_abi/build --config Debug"
|
||||
runOrQuit "ctest --test-dir tests/e2e/c_abi/build --output-on-failure -C Debug"
|
||||
|
||||
task test_sanitized,
|
||||
"Run all unit tests under a sanitizer (NIM_FFI_SAN) and mm (NIM_FFI_MM)":
|
||||
let san = getEnv("NIM_FFI_SAN", "none")
|
||||
@ -169,6 +190,16 @@ task test_c_e2e_sanitized,
|
||||
runOrQuit "cmake --build tests/e2e/c/build --config Debug -j"
|
||||
runOrQuit "ctest --test-dir tests/e2e/c/build --output-on-failure -C Debug"
|
||||
|
||||
task test_c_abi_e2e_sanitized,
|
||||
"Build and run the abi=c C e2e test with a sanitizer (NIM_FFI_SAN)":
|
||||
let san = getEnv("NIM_FFI_SAN", "none")
|
||||
runOrQuit "nimble genbindings_c_abi_echo"
|
||||
removeStaleEchoLib()
|
||||
runOrQuit "cmake -S tests/e2e/c_abi -B tests/e2e/c_abi/build" & " -DNIM_FFI_SANITIZER=" &
|
||||
san
|
||||
runOrQuit "cmake --build tests/e2e/c_abi/build --config Debug -j"
|
||||
runOrQuit "ctest --test-dir tests/e2e/c_abi/build --output-on-failure -C Debug"
|
||||
|
||||
task genbindings_example, "Generate Rust bindings for the timer example":
|
||||
exec "nim c " & nimFlagsOrc &
|
||||
" --app:lib --noMain --nimMainPrefix:libmy_timer -d:ffiGenBindings -o:/dev/null examples/timer/timer.nim"
|
||||
@ -227,6 +258,16 @@ task genbindings_c_echo, "Generate C bindings for the echo example":
|
||||
" -d:ffiGenBindings -d:targetLang=c" & " -d:ffiOutputDir=examples/echo/c_bindings" &
|
||||
" -d:ffiSrcPath=../echo.nim" & " -o:/dev/null examples/echo/echo.nim"
|
||||
|
||||
task genbindings_c_abi_echo, "Generate CBOR-free abi=c C bindings for the echo example":
|
||||
exec "nim c " & nimFlagsOrc & " --app:lib --noMain --nimMainPrefix:libecho" &
|
||||
" -d:ffiEchoAbiC -d:ffiGenBindings -d:targetLang=c_abi" &
|
||||
" -d:ffiOutputDir=examples/echo/c_abi_bindings" & " -d:ffiSrcPath=../echo.nim" &
|
||||
" -o:/dev/null examples/echo/echo.nim"
|
||||
exec "nim c " & nimFlagsRefc & " --app:lib --noMain --nimMainPrefix:libecho" &
|
||||
" -d:ffiEchoAbiC -d:ffiGenBindings -d:targetLang=c_abi" &
|
||||
" -d:ffiOutputDir=examples/echo/c_abi_bindings" & " -d:ffiSrcPath=../echo.nim" &
|
||||
" -o:/dev/null examples/echo/echo.nim"
|
||||
|
||||
task check_bindings_rust, "Verify checked-in Rust bindings match Nim source":
|
||||
exec "nimble genbindings_rust"
|
||||
exec "git diff --exit-code --" & " examples/timer/rust_bindings/Cargo.toml" &
|
||||
@ -250,7 +291,13 @@ task check_bindings_c, "Verify checked-in C bindings match Nim source":
|
||||
" examples/echo/c_bindings/nim_ffi_cbor.h" &
|
||||
" examples/echo/c_bindings/CMakeLists.txt"
|
||||
|
||||
task check_bindings_c_abi, "Verify checked-in abi=c C bindings match Nim source":
|
||||
exec "nimble genbindings_c_abi_echo"
|
||||
exec "git diff --exit-code --" & " examples/echo/c_abi_bindings/echo.h" &
|
||||
" examples/echo/c_abi_bindings/CMakeLists.txt"
|
||||
|
||||
task check_bindings, "Verify all checked-in example bindings match Nim source":
|
||||
exec "nimble check_bindings_rust"
|
||||
exec "nimble check_bindings_cpp"
|
||||
exec "nimble check_bindings_c"
|
||||
exec "nimble check_bindings_c_abi"
|
||||
|
||||
@ -44,6 +44,18 @@ proc dealloc*(p: cstring) {.inline.} =
|
||||
if not p.isNil():
|
||||
c_free(cast[pointer](p))
|
||||
|
||||
proc allocBox*(size: int): pointer =
|
||||
## `c_malloc` block for a cross-thread callback box (allocated on the foreign
|
||||
## caller thread, freed on the FFI thread). Uses libc for the same
|
||||
## thread-lifetime safety reason as the rest of this module. Free with
|
||||
## `freeBox`.
|
||||
c_malloc(csize_t(size))
|
||||
|
||||
proc freeBox*(p: pointer) =
|
||||
## Releases a block from `allocBox`. Nil-safe.
|
||||
if not p.isNil():
|
||||
c_free(p)
|
||||
|
||||
proc allocSharedSeq*[T](s: seq[T]): SharedSeq[T] =
|
||||
if s.len == 0:
|
||||
return (cast[ptr UncheckedArray[T]](nil), 0)
|
||||
|
||||
443
ffi/codegen/c_abi.nim
Normal file
443
ffi/codegen/c_abi.nim
Normal file
@ -0,0 +1,443 @@
|
||||
## CBOR-free C99 binding generator for the nim-ffi framework (`-d:targetLang=c_abi`).
|
||||
## Where the `c` backend speaks CBOR on the wire (vendoring TinyCBOR), this one
|
||||
## emits a single self-contained header whose flat structs *are* the C ABI:
|
||||
## they mirror the macro-generated `*_CWire` layout byte-for-byte, so the C
|
||||
## consumer passes native structs and links no CBOR at all. The Nim dylib
|
||||
## converts flat struct ⇄ Nim object at the boundary (see the `abi = c` dispatch
|
||||
## in `ffi/internal/c_macro_helpers.nim`) and keeps CBOR purely as an internal
|
||||
## transport detail.
|
||||
##
|
||||
## Layout contract (must stay in lock-step with `c_macro_helpers.wireValueType`
|
||||
## / `wireFieldsFor`): `string`→`const char*`, `seq[T]`→`<wireT>* <f>_items` +
|
||||
## `ptrdiff_t <f>_len`, `Option[T]`→`<wireT>*` (NULL = none), nested `{.ffi.}`
|
||||
## type → its flat struct, `ptr`/`pointer`→`void*`, POD unchanged.
|
||||
|
||||
import std/[os, strutils, tables, sets]
|
||||
import ./meta, ./string_helpers, ./c_cpp_common, ./types_ir
|
||||
|
||||
const CPtrType = "void*"
|
||||
## Wire C type for any Nim `ptr T` / `pointer` (mirrors the `_CWire` `pointer`).
|
||||
|
||||
const CMakeListsTpl = staticRead("templates/c_abi/CMakeLists.txt.tpl")
|
||||
|
||||
func leafCTypeAbi(t: string): tuple[ok: bool, cType: string] =
|
||||
## Maps a Nim leaf type to the flat C type used in a wire struct. `ok` is
|
||||
## false for composites (seq/Option/user structs), handled separately.
|
||||
case t
|
||||
of "int", "int64":
|
||||
(true, "int64_t")
|
||||
of "int32":
|
||||
(true, "int32_t")
|
||||
of "int16":
|
||||
(true, "int16_t")
|
||||
of "int8":
|
||||
(true, "int8_t")
|
||||
of "uint", "uint64":
|
||||
(true, "uint64_t")
|
||||
of "uint32":
|
||||
(true, "uint32_t")
|
||||
of "uint16":
|
||||
(true, "uint16_t")
|
||||
of "uint8", "byte":
|
||||
(true, "uint8_t")
|
||||
of "bool":
|
||||
(true, "bool")
|
||||
of "float", "float64":
|
||||
(true, "double")
|
||||
of "float32":
|
||||
(true, "float")
|
||||
of "pointer":
|
||||
(true, CPtrType)
|
||||
of "string", "cstring":
|
||||
(true, "const char*")
|
||||
else:
|
||||
(false, "")
|
||||
|
||||
type AbiReg = object
|
||||
typeTable: Table[string, FFITypeMeta] ## user structs + synthetic Req structs
|
||||
emitted: HashSet[string] ## struct names already emitted
|
||||
decls: seq[string] ## struct typedefs, dependency order
|
||||
|
||||
proc ensureAbiStruct(reg: var AbiReg, typeName: string)
|
||||
|
||||
proc wireValueCType(reg: var AbiReg, nimType: string): string =
|
||||
## Flat C type for a value-position field (everything except a top-level
|
||||
## `seq`, which splits into two fields — see `fieldDecls`).
|
||||
let t = nimType.strip()
|
||||
if t.startsWith("ptr ") or t == "pointer":
|
||||
return CPtrType
|
||||
let leaf = leafCTypeAbi(t)
|
||||
if leaf.ok:
|
||||
return leaf.cType
|
||||
var optInner = genericInnerType(t, "Option[")
|
||||
if optInner.len == 0:
|
||||
optInner = genericInnerType(t, "Maybe[")
|
||||
if optInner.len > 0:
|
||||
return wireValueCType(reg, optInner.strip()) & "*"
|
||||
if genericInnerType(t, "seq[").len > 0:
|
||||
raise newException(
|
||||
ValueError,
|
||||
"abi = c: `seq` has no single-field wire form, so it can't nest inside " &
|
||||
"another container: " & t,
|
||||
)
|
||||
if genericInnerType(t, "array[").len > 0:
|
||||
raise newException(
|
||||
ValueError, "abi = c: array fields are not yet supported by the C backend: " & t
|
||||
)
|
||||
if t in reg.typeTable:
|
||||
ensureAbiStruct(reg, t)
|
||||
return t
|
||||
raise newException(ValueError, "abi = c: unknown field type: " & t)
|
||||
|
||||
proc fieldDecls(reg: var AbiReg, name, nimType: string): seq[string] =
|
||||
## C struct member line(s) for one Nim field. A `seq[T]` becomes the
|
||||
## `<name>_items` pointer + `<name>_len` count pair; everything else is one
|
||||
## member.
|
||||
let seqInner = genericInnerType(nimType.strip(), "seq[")
|
||||
if seqInner.len > 0:
|
||||
let elemC = wireValueCType(reg, seqInner.strip())
|
||||
return @[elemC & "* " & name & "_items;", "ptrdiff_t " & name & "_len;"]
|
||||
@[wireValueCType(reg, nimType) & " " & name & ";"]
|
||||
|
||||
proc emitAbiStruct(reg: var AbiReg, t: FFITypeMeta) =
|
||||
var members: seq[string] = @[]
|
||||
for f in t.fields:
|
||||
for line in fieldDecls(reg, f.name, f.typeName):
|
||||
members.add(" " & line)
|
||||
if members.len == 0:
|
||||
members.add(" uint8_t _placeholder; /* C forbids empty structs */")
|
||||
reg.decls.add("typedef struct {\n" & members.join("\n") & "\n} " & t.name & ";")
|
||||
|
||||
proc ensureAbiStruct(reg: var AbiReg, typeName: string) =
|
||||
if typeName in reg.emitted:
|
||||
return
|
||||
reg.emitted.incl(typeName)
|
||||
if typeName in reg.typeTable:
|
||||
emitAbiStruct(reg, reg.typeTable[typeName])
|
||||
else:
|
||||
reg.decls.add("/* unknown type referenced: " & typeName & " */")
|
||||
|
||||
proc reqTypeMeta(p: FFIProcMeta): FFITypeMeta =
|
||||
## The per-proc Req envelope as an FFITypeMeta, mirroring the Nim macro. A
|
||||
## pointer/handle param rides as the opaque `pointer` wire type.
|
||||
var fields: seq[FFIFieldMeta] = @[]
|
||||
for ep in p.extraParams:
|
||||
let typeName = if ep.ridesAsPtr(): "pointer" else: ep.typeName
|
||||
fields.add(FFIFieldMeta(name: ep.name, typeName: typeName))
|
||||
FFITypeMeta(name: reqStructName(p), fields: fields)
|
||||
|
||||
proc newAbiReg(types: seq[FFITypeMeta], procs: seq[FFIProcMeta]): AbiReg =
|
||||
var reg = AbiReg()
|
||||
for t in types:
|
||||
reg.typeTable[t.name] = t
|
||||
for p in procs:
|
||||
if p.kind != FFIKind.DTOR:
|
||||
let rt = reqTypeMeta(p)
|
||||
reg.typeTable[rt.name] = rt
|
||||
reg
|
||||
|
||||
func paramByValue(nimType: string, ridesAsPtr: bool): bool =
|
||||
## Scalars / opaque pointers / string views pass by value; composite
|
||||
## aggregates (seq, Option, user structs) pass by const pointer.
|
||||
if ridesAsPtr:
|
||||
return true
|
||||
leafCTypeAbi(nimType.strip()).ok
|
||||
|
||||
proc reqParamsAndAssigns(
|
||||
reg: var AbiReg, extraParams: seq[FFIParamMeta]
|
||||
): tuple[params, assigns: seq[string]] =
|
||||
## The C parameter list + `ffi_req` field assignments shared by the ctor and
|
||||
## method wrappers: by-value params copy straight into the request struct,
|
||||
## by-const-pointer aggregates are dereferenced in.
|
||||
var params, assigns: seq[string] = @[]
|
||||
for ep in extraParams:
|
||||
let rides = ep.ridesAsPtr()
|
||||
let cType =
|
||||
if rides:
|
||||
CPtrType
|
||||
else:
|
||||
wireValueCType(reg, ep.typeName)
|
||||
if paramByValue(ep.typeName, rides):
|
||||
params.add(cType & " " & ep.name)
|
||||
assigns.add(" ffi_req." & ep.name & " = " & ep.name & ";")
|
||||
else:
|
||||
params.add("const " & cType & "* " & ep.name)
|
||||
assigns.add(" ffi_req." & ep.name & " = *" & ep.name & ";")
|
||||
(params, assigns)
|
||||
|
||||
proc methodReplyInfo(
|
||||
reg: var AbiReg, libType: string, m: FFIProcMeta
|
||||
): tuple[fnType, replyParam: string] =
|
||||
## The reply-callback typedef name plus the C type of its `reply` argument.
|
||||
## An object return hands back a `const <Struct>*`; a string return a
|
||||
## `const char*`. Both are the raw callback the dylib invokes directly.
|
||||
let pascal = snakeToPascalCase(stripLibPrefix(m.procName, m.libName))
|
||||
let fnType = libType & pascal & "ReplyFn"
|
||||
if m.returnRidesAsPtr():
|
||||
raise newException(
|
||||
ValueError,
|
||||
"abi = c: handle/pointer returns are not yet supported by the C backend: " &
|
||||
m.procName,
|
||||
)
|
||||
let rt = m.returnTypeName.strip()
|
||||
let replyParam =
|
||||
if rt == "string" or rt == "cstring":
|
||||
"const char*"
|
||||
elif leafCTypeAbi(rt).ok:
|
||||
"const " & leafCTypeAbi(rt).cType & "*"
|
||||
else:
|
||||
ensureAbiStruct(reg, rt)
|
||||
"const " & rt & "*"
|
||||
(fnType, replyParam)
|
||||
|
||||
proc emitReplyTypedefs(
|
||||
lines: var seq[string], reg: var AbiReg, libType: string, methods: seq[FFIProcMeta]
|
||||
) =
|
||||
for m in methods:
|
||||
let info = methodReplyInfo(reg, libType, m)
|
||||
lines.add(
|
||||
"typedef void (*" & info.fnType & ")(int err_code, " & info.replyParam &
|
||||
" reply, const char* err_msg, void* user_data);"
|
||||
)
|
||||
|
||||
proc emitExternDecls(
|
||||
lines: var seq[string],
|
||||
reg: var AbiReg,
|
||||
libName, libType: string,
|
||||
procs: seq[FFIProcMeta],
|
||||
) =
|
||||
let createRawFn = libType & "CreateRawFn"
|
||||
var haveCtor = false
|
||||
for p in procs:
|
||||
if p.kind == FFIKind.CTOR:
|
||||
haveCtor = true
|
||||
if haveCtor:
|
||||
lines.add(
|
||||
"typedef void (*" & createRawFn &
|
||||
")(int err_code, const char* ctx_addr, const char* err_msg, void* user_data);"
|
||||
)
|
||||
lines.add("#ifdef __cplusplus")
|
||||
lines.add("extern \"C\" {")
|
||||
lines.add("#endif")
|
||||
lines.add("")
|
||||
for p in procs:
|
||||
let reqStruct = reqStructName(p)
|
||||
case p.kind
|
||||
of FFIKind.FFI:
|
||||
let info = methodReplyInfo(reg, libType, p)
|
||||
lines.add(
|
||||
"int " & p.procName & "(void* ctx, " & info.fnType &
|
||||
" on_reply, void* user_data, const " & reqStruct & "* req);"
|
||||
)
|
||||
of FFIKind.CTOR:
|
||||
lines.add(
|
||||
"void* " & p.procName & "(const " & reqStruct & "* req, " & createRawFn &
|
||||
" on_created, void* user_data);"
|
||||
)
|
||||
of FFIKind.DTOR:
|
||||
lines.add("int " & p.procName & "(void* ctx);")
|
||||
lines.add("")
|
||||
lines.add("#ifdef __cplusplus")
|
||||
lines.add("} /* extern \"C\" */")
|
||||
lines.add("#endif")
|
||||
lines.add("")
|
||||
|
||||
proc emitCtxAndCtor(
|
||||
lines: var seq[string],
|
||||
reg: var AbiReg,
|
||||
libName, libType, ctxType: string,
|
||||
ctors: seq[FFIProcMeta],
|
||||
) =
|
||||
lines.add("typedef struct {")
|
||||
lines.add(" void* ptr;")
|
||||
lines.add("} " & ctxType & ";")
|
||||
lines.add("")
|
||||
if ctors.len == 0:
|
||||
return
|
||||
let createFn = libType & "CreateFn"
|
||||
let createBox = libType & "CreateBox"
|
||||
let createRawFn = libType & "CreateRawFn"
|
||||
let tramp = libName & "_create_trampoline"
|
||||
lines.add(
|
||||
"typedef void (*" & createFn & ")(int err_code, " & ctxType &
|
||||
"* ctx, const char* err_msg, void* user_data);"
|
||||
)
|
||||
lines.add(
|
||||
"typedef struct { " & createFn & " fn; void* user_data; } " & createBox & ";"
|
||||
)
|
||||
lines.add(
|
||||
"static void " & tramp &
|
||||
"(int ret, const char* ctx_addr, const char* err_msg, void* ud) {"
|
||||
)
|
||||
lines.add(" " & createBox & "* box = (" & createBox & "*)ud;")
|
||||
lines.add(" if (!box) return;")
|
||||
lines.add(" if (!box->fn) { free(box); return; }")
|
||||
lines.add(" if (ret != 0) {")
|
||||
lines.add(
|
||||
" box->fn(ret, NULL, err_msg ? err_msg : \"FFI create failed\", box->user_data);"
|
||||
)
|
||||
lines.add(" free(box);")
|
||||
lines.add(" return;")
|
||||
lines.add(" }")
|
||||
lines.add(" char* endp = NULL;")
|
||||
lines.add(" unsigned long long a = ctx_addr ? strtoull(ctx_addr, &endp, 10) : 0;")
|
||||
lines.add(" bool ok = ctx_addr && *ctx_addr && endp && *endp == '\\0';")
|
||||
lines.add(" if (!ok) {")
|
||||
lines.add(
|
||||
" box->fn(-1, NULL, \"FFI create returned non-numeric address\", box->user_data);"
|
||||
)
|
||||
lines.add(" free(box);")
|
||||
lines.add(" return;")
|
||||
lines.add(" }")
|
||||
lines.add(
|
||||
" " & ctxType & "* ctx = (" & ctxType & "*)calloc(1, sizeof(" & ctxType & "));"
|
||||
)
|
||||
lines.add(" if (!ctx) {")
|
||||
lines.add(" box->fn(-1, NULL, \"out of memory\", box->user_data);")
|
||||
lines.add(" free(box);")
|
||||
lines.add(" return;")
|
||||
lines.add(" }")
|
||||
lines.add(" ctx->ptr = (void*)(uintptr_t)a;")
|
||||
lines.add(" box->fn(NIMFFI_RET_OK, ctx, NULL, box->user_data);")
|
||||
lines.add(" free(box);")
|
||||
lines.add("}")
|
||||
lines.add("")
|
||||
for ctor in ctors:
|
||||
let reqStruct = reqStructName(ctor)
|
||||
let (params, assigns) = reqParamsAndAssigns(reg, ctor.extraParams)
|
||||
let head = "static inline int " & libName & "_ctx_create("
|
||||
let sig =
|
||||
if params.len > 0:
|
||||
head & params.join(", ") & ", " & createFn & " on_created, void* user_data) {"
|
||||
else:
|
||||
head & createFn & " on_created, void* user_data) {"
|
||||
lines.add(sig)
|
||||
lines.add(" " & reqStruct & " ffi_req;")
|
||||
lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));")
|
||||
for a in assigns:
|
||||
lines.add(a)
|
||||
lines.add(
|
||||
" " & createBox & "* box = (" & createBox & "*)malloc(sizeof(" & createBox &
|
||||
"));"
|
||||
)
|
||||
lines.add(" if (!box) {")
|
||||
lines.add(
|
||||
" if (on_created) on_created(-1, NULL, \"out of memory\", user_data);"
|
||||
)
|
||||
lines.add(" return -1;")
|
||||
lines.add(" }")
|
||||
lines.add(" box->fn = on_created;")
|
||||
lines.add(" box->user_data = user_data;")
|
||||
lines.add(" (void)" & ctor.procName & "(&ffi_req, " & tramp & ", box);")
|
||||
lines.add(" return 0;")
|
||||
lines.add("}")
|
||||
lines.add("")
|
||||
|
||||
proc emitDestructor(lines: var seq[string], ctxType, libName, dtorProcName: string) =
|
||||
lines.add("static inline void " & libName & "_ctx_destroy(" & ctxType & "* ctx) {")
|
||||
lines.add(" if (!ctx) return;")
|
||||
if dtorProcName.len > 0:
|
||||
lines.add(" if (ctx->ptr) { " & dtorProcName & "(ctx->ptr); ctx->ptr = NULL; }")
|
||||
lines.add(" free(ctx);")
|
||||
lines.add("}")
|
||||
lines.add("")
|
||||
|
||||
proc emitMethod(
|
||||
lines: var seq[string],
|
||||
reg: var AbiReg,
|
||||
ctxType, libName, libType: string,
|
||||
m: FFIProcMeta,
|
||||
) =
|
||||
let stripped = stripLibPrefix(m.procName, m.libName)
|
||||
let reqStruct = reqStructName(m)
|
||||
let info = methodReplyInfo(reg, libType, m)
|
||||
let (params, assigns) = reqParamsAndAssigns(reg, m.extraParams)
|
||||
let head =
|
||||
"static inline int " & libName & "_ctx_" & stripped & "(const " & ctxType & "* ctx, "
|
||||
let sig =
|
||||
if params.len > 0:
|
||||
head & params.join(", ") & ", " & info.fnType & " on_reply, void* user_data) {"
|
||||
else:
|
||||
head & info.fnType & " on_reply, void* user_data) {"
|
||||
lines.add(sig)
|
||||
lines.add(" " & reqStruct & " ffi_req;")
|
||||
lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));")
|
||||
for a in assigns:
|
||||
lines.add(a)
|
||||
lines.add(" return " & m.procName & "(ctx->ptr, on_reply, user_data, &ffi_req);")
|
||||
lines.add("}")
|
||||
lines.add("")
|
||||
|
||||
proc generateCAbiLibHeader*(
|
||||
procs: seq[FFIProcMeta],
|
||||
types: seq[FFITypeMeta],
|
||||
libName: string,
|
||||
events: seq[FFIEventMeta] = @[],
|
||||
): string =
|
||||
if events.len > 0:
|
||||
raise newException(
|
||||
ValueError, "abi = c: the C backend does not yet support {.ffiEvent.} listeners"
|
||||
)
|
||||
let classified = classifyProcs(procs)
|
||||
let libType = libTypeName(classified.ctors, libName)
|
||||
let ctxType = libType & "Ctx"
|
||||
|
||||
var reg = newAbiReg(types, procs)
|
||||
for t in types:
|
||||
ensureAbiStruct(reg, t.name)
|
||||
for p in procs:
|
||||
if p.kind != FFIKind.DTOR:
|
||||
ensureAbiStruct(reg, reqStructName(p))
|
||||
|
||||
let guard = "NIM_FFI_LIB_" & libName.toUpperAscii() & "_C_ABI_H_INCLUDED"
|
||||
var lines: seq[string] = @[]
|
||||
lines.add("#ifndef " & guard)
|
||||
lines.add("#define " & guard)
|
||||
lines.add("#include <stdint.h>")
|
||||
lines.add("#include <stddef.h>")
|
||||
lines.add("#include <stdbool.h>")
|
||||
lines.add("#include <stdlib.h>")
|
||||
lines.add("#include <string.h>")
|
||||
lines.add("")
|
||||
lines.add("#define NIMFFI_RET_OK 0")
|
||||
lines.add("#define NIMFFI_RET_ERR 1")
|
||||
lines.add("#define NIMFFI_RET_MISSING_CALLBACK 2")
|
||||
lines.add("")
|
||||
lines.add("/* Flat wire structs — the C ABI. Strings are borrowed, NUL-terminated")
|
||||
lines.add(" `const char*` valid only for the duration of the call they cross. */")
|
||||
for decl in reg.decls:
|
||||
lines.add(decl)
|
||||
lines.add("")
|
||||
|
||||
emitReplyTypedefs(lines, reg, libType, classified.methods)
|
||||
lines.add("")
|
||||
emitExternDecls(lines, reg, libName, libType, procs)
|
||||
|
||||
lines.add("/* High-level context wrapper */")
|
||||
emitCtxAndCtor(lines, reg, libName, libType, ctxType, classified.ctors)
|
||||
emitDestructor(lines, ctxType, libName, classified.dtorProcName)
|
||||
for m in classified.methods:
|
||||
emitMethod(lines, reg, ctxType, libName, libType, m)
|
||||
|
||||
lines.add("#endif /* " & guard & " */")
|
||||
lines.join("\n") & "\n"
|
||||
|
||||
proc generateCAbiCMakeLists*(libName, nimSrcRelPath: string): string =
|
||||
let src = nimSrcRelPath.replace("\\", "/")
|
||||
CMakeListsTpl.multiReplace(("{{LIB}}", libName), ("{{SRC}}", src))
|
||||
|
||||
proc generateCAbiBindings*(
|
||||
procs: seq[FFIProcMeta],
|
||||
types: seq[FFITypeMeta],
|
||||
libName: string,
|
||||
outputDir: string,
|
||||
nimSrcRelPath: string,
|
||||
events: seq[FFIEventMeta] = @[],
|
||||
) =
|
||||
createDir(outputDir)
|
||||
writeFile(
|
||||
outputDir / (libName & ".h"), generateCAbiLibHeader(procs, types, libName, events)
|
||||
)
|
||||
writeFile(
|
||||
outputDir / "CMakeLists.txt", generateCAbiCMakeLists(libName, nimSrcRelPath)
|
||||
)
|
||||
@ -73,9 +73,10 @@ var genBindingsEmitted* {.compileTime.}: bool = false
|
||||
var currentDefaultABIFormat* {.compileTime.}: ABIFormat = ABIFormat.Cbor
|
||||
|
||||
proc abiCodegenImplemented*(fmt: ABIFormat): bool =
|
||||
## Whether `fmt` has a working proc-dispatch path. Only `Cbor` does today; the
|
||||
## seam a future PR flips once the `c` dispatch path is wired.
|
||||
fmt == ABIFormat.Cbor
|
||||
## Whether `fmt` has a working proc-dispatch path. Both `Cbor` and `C` are
|
||||
## wired: `Cbor` rides the generic overloads, `C` rides the flat `_CWire`
|
||||
## companions (a CBOR-free foreign surface with CBOR transport internally).
|
||||
fmt in {ABIFormat.Cbor, ABIFormat.C}
|
||||
|
||||
proc overrideKey*(override: string): string =
|
||||
## Lowercased key of a `key = value` pragma override (the text before `=`),
|
||||
|
||||
72
ffi/codegen/templates/c_abi/CMakeLists.txt.tpl
Normal file
72
ffi/codegen/templates/c_abi/CMakeLists.txt.tpl
Normal file
@ -0,0 +1,72 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project({{LIB}}_c_abi_bindings C)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
# The CBOR-free `abi = c` binding links no TinyCBOR — the flat structs in the
|
||||
# generated header are the ABI. Only the Nim dylib is built.
|
||||
|
||||
set(_search_dir "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
set(REPO_ROOT "")
|
||||
foreach(_i RANGE 10)
|
||||
if(EXISTS "${_search_dir}/ffi.nimble")
|
||||
set(REPO_ROOT "${_search_dir}")
|
||||
break()
|
||||
endif()
|
||||
get_filename_component(_search_dir "${_search_dir}" DIRECTORY)
|
||||
endforeach()
|
||||
if("${REPO_ROOT}" STREQUAL "")
|
||||
message(FATAL_ERROR "Cannot find repo root (no ffi.nimble in any ancestor)")
|
||||
endif()
|
||||
|
||||
# Extra `nim c` arguments (e.g. a `-d:` that flips a shared example source to
|
||||
# `abi = c`). A library that declares `defaultABIFormat = "c"` needs none.
|
||||
set(NIM_FFI_EXTRA_ARGS "" CACHE STRING "Extra nim c args when building the dylib")
|
||||
|
||||
find_program(NIM_EXECUTABLE nim REQUIRED)
|
||||
|
||||
if(CMAKE_SYSTEM_NAME STREQUAL "Darwin")
|
||||
set(NIM_LIB_FILE "${REPO_ROOT}/lib{{LIB}}.dylib")
|
||||
elseif(CMAKE_SYSTEM_NAME STREQUAL "Windows")
|
||||
set(NIM_LIB_FILE "${REPO_ROOT}/{{LIB}}.dll")
|
||||
else()
|
||||
set(NIM_LIB_FILE "${REPO_ROOT}/lib{{LIB}}.so")
|
||||
endif()
|
||||
|
||||
get_filename_component(NIM_SRC "${CMAKE_CURRENT_SOURCE_DIR}/{{SRC}}" ABSOLUTE)
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT "${NIM_LIB_FILE}"
|
||||
COMMAND "${NIM_EXECUTABLE}" c
|
||||
--mm:orc
|
||||
-d:chronicles_log_level=WARN
|
||||
--app:lib
|
||||
--noMain
|
||||
"--nimMainPrefix:lib{{LIB}}"
|
||||
${NIM_FFI_EXTRA_ARGS}
|
||||
"-o:${NIM_LIB_FILE}"
|
||||
"${NIM_SRC}"
|
||||
WORKING_DIRECTORY "${REPO_ROOT}"
|
||||
DEPENDS "${NIM_SRC}"
|
||||
COMMENT "Compiling Nim library lib{{LIB}} (abi = c)"
|
||||
VERBATIM
|
||||
)
|
||||
add_custom_target({{LIB}}_nim_lib ALL DEPENDS "${NIM_LIB_FILE}")
|
||||
|
||||
add_library({{LIB}} SHARED IMPORTED GLOBAL)
|
||||
set_target_properties({{LIB}} PROPERTIES IMPORTED_LOCATION "${NIM_LIB_FILE}")
|
||||
add_dependencies({{LIB}} {{LIB}}_nim_lib)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
add_library({{LIB}}_headers INTERFACE)
|
||||
target_include_directories({{LIB}}_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}")
|
||||
target_link_libraries({{LIB}}_headers INTERFACE {{LIB}} Threads::Threads)
|
||||
target_compile_definitions({{LIB}}_headers INTERFACE _POSIX_C_SOURCE=200809L)
|
||||
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.c")
|
||||
add_executable({{LIB}}_example main.c)
|
||||
target_link_libraries({{LIB}}_example PRIVATE {{LIB}}_headers)
|
||||
add_dependencies({{LIB}}_example {{LIB}}_nim_lib)
|
||||
endif()
|
||||
@ -50,7 +50,7 @@ type
|
||||
structName*: proc(name: string): string {.noSideEffect, nimcall.}
|
||||
## nil ⇒ the user type name passes through unchanged
|
||||
|
||||
func genericInnerType(typeName, prefix: string): string =
|
||||
func genericInnerType*(typeName, prefix: string): string =
|
||||
## Inner type of a single-parameter generic `Prefix[Inner]`, e.g.
|
||||
## `genericInnerType("seq[int]", "seq[")` → `"int"`; "" if not that shape.
|
||||
if typeName.startsWith(prefix) and typeName.endsWith("]"):
|
||||
|
||||
@ -553,3 +553,377 @@ proc flushCWireCompanions*(): NimNode {.compileTime.} =
|
||||
if typeMeta.abiFormat == ABIFormat.C:
|
||||
ensureCWireFor(typeMeta.name, sink)
|
||||
sink
|
||||
|
||||
## abi = c proc dispatch. The foreign surface is CBOR-free — the flat `_CWire`
|
||||
## structs are the C ABI — but transport reuses the proven CBOR request path
|
||||
## internally: the generated exported wrapper `cwireUnpack`s the request into a
|
||||
## Nim object, `cborEncodeShared`s it onto the FFI thread, and a Nim reply
|
||||
## trampoline `cborDecode`s the reply and `cwirePack`s it back into a `_CWire`
|
||||
## struct delivered to the caller's typed callback. So the C consumer never
|
||||
## links CBOR, yet the whole thread/dispatch machinery is unchanged.
|
||||
##
|
||||
## All of this is emitted at `genBindings()` time (after `flushCWireCompanions`)
|
||||
## so the request-envelope companions and their `cwireUnpack` overloads are in
|
||||
## scope: the exported wrappers reference them, and a proc must follow the
|
||||
## procs it calls.
|
||||
|
||||
type
|
||||
CAbiKind = enum
|
||||
cakMethod
|
||||
cakCtor
|
||||
|
||||
CAbiSpec = object
|
||||
kind: CAbiKind
|
||||
exportName: string ## snake_case C symbol, e.g. "echo_shout"
|
||||
libType: NimNode ## library value type, e.g. `Echo`
|
||||
envelope: NimNode ## per-proc Req type, e.g. `EchoShoutReq`
|
||||
paramNames: seq[string] ## envelope field names (the extra params)
|
||||
paramTypes: seq[NimNode] ## envelope field types
|
||||
respType: NimNode ## method result T; empty for a ctor
|
||||
|
||||
var cAbiSpecs {.compileTime.}: seq[CAbiSpec]
|
||||
|
||||
proc copyTypes(types: seq[NimNode]): seq[NimNode] {.compileTime.} =
|
||||
var res: seq[NimNode] = @[]
|
||||
for t in types:
|
||||
res.add(t.copyNimTree())
|
||||
res
|
||||
|
||||
proc registerCAbiMethod*(
|
||||
exportName: string,
|
||||
libType, envelope: NimNode,
|
||||
paramNames: seq[string],
|
||||
paramTypes: seq[NimNode],
|
||||
respType: NimNode,
|
||||
) {.compileTime.} =
|
||||
## Record an `abi = c` method so `flushCAbiDispatch` can emit its wrapper.
|
||||
## Nodes are frozen with `copyNimTree` — the originals are shared with the Req
|
||||
## `type` section, which the compiler later binds to `nnkSym`, and a bound
|
||||
## type symbol reused in the generated body triggers a compiler ICE.
|
||||
cAbiSpecs.add(
|
||||
CAbiSpec(
|
||||
kind: cakMethod,
|
||||
exportName: exportName,
|
||||
libType: libType.copyNimTree(),
|
||||
envelope: envelope.copyNimTree(),
|
||||
paramNames: paramNames,
|
||||
paramTypes: copyTypes(paramTypes),
|
||||
respType: respType.copyNimTree(),
|
||||
)
|
||||
)
|
||||
|
||||
proc registerCAbiCtor*(
|
||||
exportName: string,
|
||||
libType, envelope: NimNode,
|
||||
paramNames: seq[string],
|
||||
paramTypes: seq[NimNode],
|
||||
) {.compileTime.} =
|
||||
## Record an `abi = c` constructor so `flushCAbiDispatch` can emit its wrapper.
|
||||
## See `registerCAbiMethod` for why the nodes are frozen with `copyNimTree`.
|
||||
cAbiSpecs.add(
|
||||
CAbiSpec(
|
||||
kind: cakCtor,
|
||||
exportName: exportName,
|
||||
libType: libType.copyNimTree(),
|
||||
envelope: envelope.copyNimTree(),
|
||||
paramNames: paramNames,
|
||||
paramTypes: copyTypes(paramTypes),
|
||||
respType: newEmptyNode(),
|
||||
)
|
||||
)
|
||||
|
||||
proc cdeclReplyPragma(): NimNode =
|
||||
nnkPragma.newTree(
|
||||
ident("cdecl"),
|
||||
ident("gcsafe"),
|
||||
nnkExprColonExpr.newTree(ident("raises"), nnkBracket.newTree()),
|
||||
)
|
||||
|
||||
proc cAbiCbType(replyType: NimNode): NimNode =
|
||||
## `proc(err: cint, reply: <replyType>, errMsg: cstring, ud: pointer)
|
||||
## {.cdecl, gcsafe, raises: [].}` — the caller's typed reply callback.
|
||||
let fp = nnkFormalParams.newTree(
|
||||
newEmptyNode(),
|
||||
newIdentDefs(ident("err"), ident("cint")),
|
||||
newIdentDefs(ident("reply"), replyType),
|
||||
newIdentDefs(ident("errMsg"), ident("cstring")),
|
||||
newIdentDefs(ident("ud"), ident("pointer")),
|
||||
)
|
||||
nnkProcTy.newTree(fp, cdeclReplyPragma())
|
||||
|
||||
proc boxTypeDef(boxName, cbType: NimNode): NimNode =
|
||||
## `type <boxName> = object` holding the caller's callback + user data, heap
|
||||
## boxed across the thread hand-off.
|
||||
let recList = nnkRecList.newTree(
|
||||
newIdentDefs(ident("fn"), cbType), newIdentDefs(ident("ud"), ident("pointer"))
|
||||
)
|
||||
let objTy = nnkObjectTy.newTree(newEmptyNode(), newEmptyNode(), recList)
|
||||
nnkTypeSection.newTree(nnkTypeDef.newTree(boxName, newEmptyNode(), objTy))
|
||||
|
||||
proc replyTrampProc(trampName, body: NimNode): NimNode =
|
||||
## A `FFICallBack`-shaped Nim proc: it runs on the FFI thread inside
|
||||
## `handleRes`' `foreignThreadGc`, converts the reply, and frees the box.
|
||||
newProc(
|
||||
name = trampName,
|
||||
params = @[
|
||||
newEmptyNode(),
|
||||
newIdentDefs(ident("ret"), ident("cint")),
|
||||
newIdentDefs(ident("msg"), nnkPtrTy.newTree(ident("cchar"))),
|
||||
newIdentDefs(ident("len"), ident("csize_t")),
|
||||
newIdentDefs(ident("ud"), ident("pointer")),
|
||||
],
|
||||
body = body,
|
||||
pragmas = cdeclReplyPragma(),
|
||||
)
|
||||
|
||||
proc objectTrampBody(boxName, respType, respWire: NimNode): NimNode =
|
||||
## Reply trampoline for an object return: recover the box, deliver a transport
|
||||
## error as a copied NUL-terminated string, else CBOR-decode the reply,
|
||||
## `cwirePack` it into the flat wire struct, hand a pointer to the caller, and
|
||||
## release the wire. `err_msg` is always a non-nil string; the `reply` struct
|
||||
## pointer is nil only on error, gated by a non-`RET_OK` `err_code`.
|
||||
quote:
|
||||
let box = cast[ptr `boxName`](ud)
|
||||
if box.isNil():
|
||||
return
|
||||
defer:
|
||||
freeBox(box)
|
||||
if box.fn.isNil():
|
||||
return
|
||||
try:
|
||||
if ret != RET_OK:
|
||||
var em = newString(int(len))
|
||||
if int(len) > 0:
|
||||
copyMem(addr em[0], msg, int(len))
|
||||
box.fn(ret, nil, em.cstring, box.ud)
|
||||
return
|
||||
let decoded =
|
||||
cborDecodePtr(cast[ptr UncheckedArray[byte]](msg), int(len), `respType`)
|
||||
if decoded.isErr():
|
||||
box.fn(RET_ERR, nil, decoded.error.cstring, box.ud)
|
||||
else:
|
||||
var wire: `respWire`
|
||||
cwirePack(wire, decoded.get())
|
||||
box.fn(RET_OK, addr wire, "".cstring, box.ud)
|
||||
cwireFree(wire)
|
||||
except CatchableError as e:
|
||||
box.fn(RET_ERR, nil, e.msg.cstring, box.ud)
|
||||
|
||||
proc stringTrampBody(boxName: NimNode): NimNode =
|
||||
## Reply trampoline for a `string` return (and the ctor's address string):
|
||||
## CBOR-decode the reply into a Nim string and hand its (NUL-terminated)
|
||||
## `cstring` to the caller for the duration of the call. Reply and error
|
||||
## strings are always non-nil empty strings on the paths they don't apply to,
|
||||
## so a consumer can `strlen`/print either unconditionally without a nil deref.
|
||||
quote:
|
||||
let box = cast[ptr `boxName`](ud)
|
||||
if box.isNil():
|
||||
return
|
||||
defer:
|
||||
freeBox(box)
|
||||
if box.fn.isNil():
|
||||
return
|
||||
try:
|
||||
if ret != RET_OK:
|
||||
var em = newString(int(len))
|
||||
if int(len) > 0:
|
||||
copyMem(addr em[0], msg, int(len))
|
||||
box.fn(ret, "".cstring, em.cstring, box.ud)
|
||||
return
|
||||
let decoded = cborDecodePtr(cast[ptr UncheckedArray[byte]](msg), int(len), string)
|
||||
if decoded.isErr():
|
||||
box.fn(RET_ERR, "".cstring, decoded.error.cstring, box.ud)
|
||||
else:
|
||||
let replyStr = decoded.get()
|
||||
box.fn(RET_OK, replyStr.cstring, "".cstring, box.ud)
|
||||
except CatchableError as e:
|
||||
box.fn(RET_ERR, "".cstring, e.msg.cstring, box.ud)
|
||||
|
||||
proc exportedMethodProc(
|
||||
spec: CAbiSpec, boxName, envWire, trampName, poolIdent, cbType: NimNode
|
||||
): NimNode =
|
||||
# `cwireUnpack` and `cborEncodeShared` run on the *calling* thread and allocate
|
||||
# GC memory, so the caller must be a GC-registered thread — which the dylib's
|
||||
# load thread already is. Deliberately NOT wrapped in `foreignThreadGc`: its
|
||||
# `tearDownForeignThreadGc` would destroy that thread's live ORC heap (a
|
||||
# use-after-free / nil-read crash), and unlike the CBOR path — which only
|
||||
# memcpy's bytes here — this path genuinely needs the heap intact.
|
||||
let envName = spec.envelope
|
||||
let libFFICtx =
|
||||
nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), spec.libType))
|
||||
# A string reply is an empty (non-nil) cstring on the error path, matching the
|
||||
# trampoline; an object reply is a nil struct pointer gated by `err_code`.
|
||||
let emptyReply =
|
||||
if isStringType(spec.respType):
|
||||
newDotExpr(newLit(""), ident("cstring"))
|
||||
else:
|
||||
newNilLit()
|
||||
let body = quote:
|
||||
if onReply.isNil():
|
||||
return RET_MISSING_CALLBACK
|
||||
if not `poolIdent`.isValidCtx(cast[pointer](ctx)):
|
||||
onReply(RET_ERR, `emptyReply`, "ctx is not a valid FFI context".cstring, userData)
|
||||
return RET_ERR
|
||||
var reqObj: `envName` = cwireUnpack(req[])
|
||||
let enc = cborEncodeShared(reqObj)
|
||||
let reqBuf = enc.data
|
||||
let reqBufLen = enc.len
|
||||
let box = cast[ptr `boxName`](allocBox(sizeof(`boxName`)))
|
||||
box.fn = onReply
|
||||
box.ud = userData
|
||||
let typeStr = $`envName`
|
||||
let reqPtr = FFIThreadRequest.initFromOwnedShared(
|
||||
`trampName`, box, typeStr.cstring, reqBuf, reqBufLen
|
||||
)
|
||||
let sendRes =
|
||||
try:
|
||||
ffi_context.sendRequestToFFIThread(ctx, reqPtr)
|
||||
except Exception as e:
|
||||
Result[void, string].err("sendRequestToFFIThread exception: " & e.msg)
|
||||
if sendRes.isErr():
|
||||
onReply(RET_ERR, `emptyReply`, sendRes.error.cstring, userData)
|
||||
return RET_ERR
|
||||
return RET_OK
|
||||
newProc(
|
||||
name = ident($envName & "CAbiExport"),
|
||||
params = @[
|
||||
ident("cint"),
|
||||
newIdentDefs(ident("ctx"), libFFICtx),
|
||||
newIdentDefs(ident("onReply"), cbType),
|
||||
newIdentDefs(ident("userData"), ident("pointer")),
|
||||
newIdentDefs(ident("req"), nnkPtrTy.newTree(envWire)),
|
||||
],
|
||||
body = body,
|
||||
pragmas = nnkPragma.newTree(
|
||||
ident("dynlib"),
|
||||
nnkExprColonExpr.newTree(ident("exportc"), newStrLitNode(spec.exportName)),
|
||||
ident("cdecl"),
|
||||
nnkExprColonExpr.newTree(ident("raises"), nnkBracket.newTree()),
|
||||
),
|
||||
)
|
||||
|
||||
proc exportedCtorProc(
|
||||
spec: CAbiSpec, boxName, envWire, trampName, poolIdent, cbType: NimNode
|
||||
): NimNode =
|
||||
let envName = spec.envelope
|
||||
# See exportedMethodProc: the request conversion allocates GC on the calling
|
||||
# thread, so no `foreignThreadGc` (its teardown would free that thread's heap).
|
||||
# `when declared(initializeLibrary): initializeLibrary()` — built as raw AST;
|
||||
# a `when` with an undeclared symbol inside `quote` trips a compiler ICE.
|
||||
let initGuard = nnkWhenStmt.newTree(
|
||||
nnkElifBranch.newTree(
|
||||
newCall(ident("declared"), ident("initializeLibrary")),
|
||||
newStmtList(newCall(ident("initializeLibrary"))),
|
||||
)
|
||||
)
|
||||
let body = quote:
|
||||
let ctxRes = `poolIdent`.createFFIContext()
|
||||
if ctxRes.isErr():
|
||||
if not onCreated.isNil():
|
||||
onCreated(
|
||||
RET_ERR,
|
||||
"".cstring,
|
||||
("ffiCtor: failed to create FFIContext: " & $ctxRes.error).cstring,
|
||||
userData,
|
||||
)
|
||||
return nil
|
||||
let ctx = ctxRes.get()
|
||||
var reqObj: `envName` = cwireUnpack(req[])
|
||||
let enc = cborEncodeShared(reqObj)
|
||||
let reqBuf = enc.data
|
||||
let reqBufLen = enc.len
|
||||
let box = cast[ptr `boxName`](allocBox(sizeof(`boxName`)))
|
||||
box.fn = onCreated
|
||||
box.ud = userData
|
||||
let typeStr = $`envName`
|
||||
let reqPtr = FFIThreadRequest.initFromOwnedShared(
|
||||
`trampName`, box, typeStr.cstring, reqBuf, reqBufLen
|
||||
)
|
||||
let sendRes =
|
||||
try:
|
||||
ctx.sendRequestToFFIThread(reqPtr)
|
||||
except Exception as e:
|
||||
Result[void, string].err("sendRequestToFFIThread exception: " & e.msg)
|
||||
if sendRes.isErr():
|
||||
if not onCreated.isNil():
|
||||
onCreated(RET_ERR, "".cstring, sendRes.error.cstring, userData)
|
||||
return nil
|
||||
return cast[pointer](ctx)
|
||||
body.insert(0, initGuard)
|
||||
newProc(
|
||||
name = ident($envName & "CAbiExport"),
|
||||
params = @[
|
||||
ident("pointer"),
|
||||
newIdentDefs(ident("req"), nnkPtrTy.newTree(envWire)),
|
||||
newIdentDefs(ident("onCreated"), cbType),
|
||||
newIdentDefs(ident("userData"), ident("pointer")),
|
||||
],
|
||||
body = body,
|
||||
pragmas = nnkPragma.newTree(
|
||||
ident("dynlib"),
|
||||
nnkExprColonExpr.newTree(ident("exportc"), newStrLitNode(spec.exportName)),
|
||||
ident("cdecl"),
|
||||
nnkExprColonExpr.newTree(ident("raises"), nnkBracket.newTree()),
|
||||
),
|
||||
)
|
||||
|
||||
proc ensureCWireForFields(
|
||||
sink: NimNode, typeName: string, names: seq[string], types: seq[NimNode]
|
||||
) {.compileTime.} =
|
||||
## Emit the `_CWire` companion + conversion procs for a synthetic per-proc Req
|
||||
## envelope (not a user `{.ffi.}` type, so it isn't in `ffiTypeRegistry`).
|
||||
## Nested user-type deps are already emitted by `flushCWireCompanions`.
|
||||
if isCWireEmitted(typeName):
|
||||
return
|
||||
var deps: seq[string] = @[]
|
||||
collectNestedFFITypes(types, deps)
|
||||
for dep in deps:
|
||||
ensureCWireFor(dep, sink)
|
||||
markCWireEmitted(typeName)
|
||||
let section = newNimNode(nnkTypeSection)
|
||||
section.add(buildCWireTypeDef(typeName, names, types))
|
||||
sink.add(section)
|
||||
for p in buildCWireProcs(typeName, names, types):
|
||||
sink.add(p)
|
||||
|
||||
proc flushCAbiDispatch*(): NimNode {.compileTime.} =
|
||||
## Emit the exported wrappers + reply trampolines for every registered
|
||||
## `abi = c` proc. Runs after `flushCWireCompanions` so nested companions and
|
||||
## their `cwireUnpack`/`cwirePack` overloads are already defined.
|
||||
let sink = newStmtList()
|
||||
for spec in cAbiSpecs:
|
||||
let envName = spec.envelope
|
||||
ensureCWireForFields(sink, $envName, spec.paramNames, spec.paramTypes)
|
||||
let envWire = ident(cwireTypeName($envName))
|
||||
let boxName = ident($envName & "CBox")
|
||||
let trampName = ident($envName & "CReply")
|
||||
let poolIdent = ident($spec.libType & "FFIPool")
|
||||
case spec.kind
|
||||
of cakCtor:
|
||||
let cbType = cAbiCbType(ident("cstring"))
|
||||
sink.add(boxTypeDef(boxName, cbType))
|
||||
sink.add(replyTrampProc(trampName, stringTrampBody(boxName)))
|
||||
sink.add(exportedCtorProc(spec, boxName, envWire, trampName, poolIdent, cbType))
|
||||
of cakMethod:
|
||||
let rt = spec.respType
|
||||
if isStringType(rt):
|
||||
let cbType = cAbiCbType(ident("cstring"))
|
||||
sink.add(boxTypeDef(boxName, cbType))
|
||||
sink.add(replyTrampProc(trampName, stringTrampBody(boxName)))
|
||||
sink.add(
|
||||
exportedMethodProc(spec, boxName, envWire, trampName, poolIdent, cbType)
|
||||
)
|
||||
elif rt.kind == nnkIdent:
|
||||
let respWire = ident(cwireTypeName($rt))
|
||||
let cbType = cAbiCbType(nnkPtrTy.newTree(respWire))
|
||||
sink.add(boxTypeDef(boxName, cbType))
|
||||
sink.add(replyTrampProc(trampName, objectTrampBody(boxName, rt, respWire)))
|
||||
sink.add(
|
||||
exportedMethodProc(spec, boxName, envWire, trampName, poolIdent, cbType)
|
||||
)
|
||||
else:
|
||||
error(
|
||||
"abi = c: unsupported response type for proc '" & spec.exportName & "': " &
|
||||
rt.repr & " (only object and string returns are wired)"
|
||||
)
|
||||
sink
|
||||
|
||||
@ -9,6 +9,7 @@ when defined(ffiGenBindings):
|
||||
import ../codegen/rust
|
||||
import ../codegen/cpp
|
||||
import ../codegen/c
|
||||
import ../codegen/c_abi
|
||||
import ../codegen/cddl
|
||||
|
||||
proc requireLibraryDeclared(where: string) {.compileTime.} =
|
||||
@ -954,9 +955,10 @@ macro ffi*(args: varargs[untyped]): untyped =
|
||||
abiFormat: abiFormat,
|
||||
)
|
||||
|
||||
# Qualifies for the CBOR-free fast path only if `abi = c`, every wire param +
|
||||
# return is scalar (`isScalarOnly`), and the args fit the inline slots. A
|
||||
# non-scalar `abi = c` proc stays gated — full C-wire dispatch is a follow-up.
|
||||
# Does this proc qualify for the CBOR-free scalar fast path? Only `abi = c`
|
||||
# opts in, and only when every wire param + the return is a plain scalar
|
||||
# (see `isScalarOnly`) and the args fit the inline slots. A non-scalar
|
||||
# `abi = c` proc rides the flat `_CWire` C-dispatch emitted by `asyncPath`.
|
||||
let scalarEligible =
|
||||
abiFormat == ABIFormat.C and isScalarOnly(procMeta) and
|
||||
extraParamNames.len <= MaxScalarArgs
|
||||
@ -1085,6 +1087,17 @@ macro ffi*(args: varargs[untyped]): untyped =
|
||||
|
||||
ffiProcRegistry.add(procMeta)
|
||||
|
||||
if abiFormat == ABIFormat.C:
|
||||
# The flat-struct exported wrapper + reply trampoline are emitted at
|
||||
# genBindings() time (see flushCAbiDispatch); the CBOR `ffiProc` is not.
|
||||
registerCAbiMethod(
|
||||
cExportName, libTypeName, reqTypeName, extraParamNames, extraParamTypes,
|
||||
resultRetType,
|
||||
)
|
||||
return newStmtList(
|
||||
helperProc, registerReq, registerRequestTimeout(reqTypeName, timeoutMs)
|
||||
)
|
||||
|
||||
return newStmtList(
|
||||
helperProc, registerReq, ffiProc, registerRequestTimeout(reqTypeName, timeoutMs)
|
||||
)
|
||||
@ -1109,9 +1122,6 @@ macro ffi*(args: varargs[untyped]): untyped =
|
||||
procMeta = procMeta,
|
||||
)
|
||||
|
||||
if abiFormat == ABIFormat.C and not scalarEligible:
|
||||
gateABIFormat(abiFormat, "`.ffi.` proc")
|
||||
|
||||
let stmts =
|
||||
if scalarEligible:
|
||||
scalarPath()
|
||||
@ -1534,16 +1544,31 @@ macro ffiCtor*(args: varargs[untyped]): untyped =
|
||||
when not declared(`poolIdent`):
|
||||
var `poolIdent`: FFIContextPool[`libTypeName`]
|
||||
|
||||
let stmts = newStmtList(
|
||||
typeDef,
|
||||
ffiNewReqProc,
|
||||
helperProc,
|
||||
processProc,
|
||||
addToReg,
|
||||
poolDecl,
|
||||
ffiProc,
|
||||
registerRequestTimeout(reqTypeName, timeoutMs),
|
||||
)
|
||||
let stmts =
|
||||
if abiFormat == ABIFormat.C:
|
||||
# The flat-struct exported wrapper is emitted at genBindings() time (see
|
||||
# flushCAbiDispatch); the CBOR `ffiProc` is not.
|
||||
registerCAbiCtor(cExportName, libTypeName, reqTypeName, paramNames, paramTypes)
|
||||
newStmtList(
|
||||
typeDef,
|
||||
ffiNewReqProc,
|
||||
helperProc,
|
||||
processProc,
|
||||
addToReg,
|
||||
poolDecl,
|
||||
registerRequestTimeout(reqTypeName, timeoutMs),
|
||||
)
|
||||
else:
|
||||
newStmtList(
|
||||
typeDef,
|
||||
ffiNewReqProc,
|
||||
helperProc,
|
||||
processProc,
|
||||
addToReg,
|
||||
poolDecl,
|
||||
ffiProc,
|
||||
registerRequestTimeout(reqTypeName, timeoutMs),
|
||||
)
|
||||
|
||||
when defined(ffiDumpMacros):
|
||||
echo stmts.repr
|
||||
@ -1720,6 +1745,11 @@ macro ffiEvent*(args: varargs[untyped]): untyped =
|
||||
let (wireName, abiSpecStart) = resolveEventWireName(leading, userProcName)
|
||||
let abiFormat = resolveABIFormat(leading[abiSpecStart ..^ 1])
|
||||
gateABIFormat(abiFormat, "`.ffiEvent.` proc")
|
||||
if abiFormat == ABIFormat.C:
|
||||
error(
|
||||
"`.ffiEvent.` proc: the `c` ABI does not yet support events; declare the " &
|
||||
"event with `abi = cbor` (events still ride CBOR internally)"
|
||||
)
|
||||
|
||||
let formalParams = prc[3]
|
||||
|
||||
@ -1839,15 +1869,21 @@ macro genBindings*(
|
||||
generateCBindings(
|
||||
genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry
|
||||
)
|
||||
of "c_abi":
|
||||
generateCAbiBindings(
|
||||
genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath, ffiEventRegistry
|
||||
)
|
||||
of "cddl":
|
||||
generateCddlBindings(genProcs, ffiTypeRegistry, libName, outputDir, nimSrcRelPath)
|
||||
else:
|
||||
error(
|
||||
"genBindings: unknown targetLang '" & lang &
|
||||
"'. Use 'rust', 'cpp', 'c', or 'cddl'."
|
||||
"'. Use 'rust', 'cpp', 'c', 'c_abi', or 'cddl'."
|
||||
)
|
||||
|
||||
let cwireCompanions = flushCWireCompanions()
|
||||
let emitted = flushCWireCompanions()
|
||||
for node in flushCAbiDispatch():
|
||||
emitted.add(node)
|
||||
when defined(ffiDumpMacros):
|
||||
echo cwireCompanions.repr
|
||||
cwireCompanions
|
||||
echo emitted.repr
|
||||
emitted
|
||||
|
||||
65
tests/e2e/c_abi/CMakeLists.txt
Normal file
65
tests/e2e/c_abi/CMakeLists.txt
Normal file
@ -0,0 +1,65 @@
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(echo_c_abi_e2e C)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_C_STANDARD_REQUIRED ON)
|
||||
|
||||
# Sanitizer plumbing (mirrors tests/e2e/c). asan-ubsan / tsan flow to both the
|
||||
# Nim dylib (via NIM_FFI_EXTRA_ARGS) and the C consumer (via compile/link opts).
|
||||
set(NIM_SAN_ARGS "")
|
||||
set(SAN_CFLAGS "")
|
||||
if(NIM_FFI_SANITIZER STREQUAL "asan-ubsan")
|
||||
set(NIM_SAN_ARGS -d:useMalloc "--passC:-fsanitize=address,undefined" "--passL:-fsanitize=address,undefined")
|
||||
set(SAN_CFLAGS -fsanitize=address,undefined -fno-omit-frame-pointer)
|
||||
elseif(NIM_FFI_SANITIZER STREQUAL "tsan")
|
||||
set(NIM_SAN_ARGS "--passC:-fsanitize=thread" "--passL:-fsanitize=thread")
|
||||
set(SAN_CFLAGS -fsanitize=thread -fno-omit-frame-pointer)
|
||||
endif()
|
||||
|
||||
# Force the shared echo example source into its `abi = c` variant and thread the
|
||||
# sanitizer args through to the dylib build. Must be `CACHE ... FORCE`: the
|
||||
# bindings subdir declares `NIM_FFI_EXTRA_ARGS` as a `CACHE` variable, and under
|
||||
# CMP0126 OLD (our `cmake_minimum_required(VERSION 3.14)`) that `set(... CACHE)`
|
||||
# deletes any same-named normal variable from this scope — so a plain `set` here
|
||||
# would be wiped and the dylib would build with the default CBOR ABI, mismatching
|
||||
# the flat `abi = c` header. Forcing the cache entry makes the child's non-FORCE
|
||||
# cache set a no-op instead.
|
||||
set(NIM_FFI_EXTRA_ARGS "-d:ffiEchoAbiC" ${NIM_SAN_ARGS}
|
||||
CACHE STRING "Extra nim c args when building the dylib" FORCE)
|
||||
set(ECHO_BINDINGS "${CMAKE_CURRENT_SOURCE_DIR}/../../../examples/echo/c_abi_bindings")
|
||||
add_subdirectory("${ECHO_BINDINGS}" echo_c_abi_build)
|
||||
|
||||
enable_testing()
|
||||
add_executable(test_echo_c_abi test_echo_c_abi.c)
|
||||
target_link_libraries(test_echo_c_abi PRIVATE echo_headers)
|
||||
add_dependencies(test_echo_c_abi echo_nim_lib)
|
||||
if(SAN_CFLAGS)
|
||||
target_compile_options(test_echo_c_abi PRIVATE ${SAN_CFLAGS})
|
||||
target_link_options(test_echo_c_abi PRIVATE ${SAN_CFLAGS})
|
||||
endif()
|
||||
|
||||
# Nim-built dylibs use `@rpath/lib*.so|dylib`; embed the imported lib's dir so
|
||||
# the test runs without LD_LIBRARY_PATH.
|
||||
get_target_property(_echo_loc echo IMPORTED_LOCATION)
|
||||
get_filename_component(_echo_dir "${_echo_loc}" DIRECTORY)
|
||||
set_target_properties(test_echo_c_abi PROPERTIES
|
||||
BUILD_RPATH "${_echo_dir}"
|
||||
INSTALL_RPATH "${_echo_dir}")
|
||||
|
||||
set(_san_test_env "")
|
||||
if(NIM_FFI_SANITIZER STREQUAL "asan-ubsan")
|
||||
list(APPEND _san_test_env
|
||||
"ASAN_OPTIONS=halt_on_error=1:abort_on_error=1:detect_leaks=1"
|
||||
"UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1"
|
||||
"LSAN_OPTIONS=suppressions=${CMAKE_CURRENT_SOURCE_DIR}/lsan.supp:print_suppressions=0")
|
||||
elseif(NIM_FFI_SANITIZER STREQUAL "tsan")
|
||||
get_filename_component(_repo_tsan_supp
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/../../../tsan.supp" ABSOLUTE)
|
||||
list(APPEND _san_test_env
|
||||
"TSAN_OPTIONS=halt_on_error=1:second_deadlock_stack=1:history_size=7:suppressions=${_repo_tsan_supp}")
|
||||
endif()
|
||||
|
||||
add_test(NAME echo_c_abi_e2e COMMAND test_echo_c_abi)
|
||||
if(_san_test_env)
|
||||
set_tests_properties(echo_c_abi_e2e PROPERTIES ENVIRONMENT "${_san_test_env}")
|
||||
endif()
|
||||
22
tests/e2e/c_abi/lsan.supp
Normal file
22
tests/e2e/c_abi/lsan.supp
Normal file
@ -0,0 +1,22 @@
|
||||
# LeakSanitizer suppressions for the Nim runtime.
|
||||
#
|
||||
# These are process-lifetime allocations freed implicitly at exit —
|
||||
# not real leaks. Add new entries here only with a comment justifying
|
||||
# why the leak is unavoidable, and only for symbols inside the Nim
|
||||
# standard library, chronos, or chronicles. Anything in our own code
|
||||
# (ffi/*) or the generated bindings must be fixed, not suppressed.
|
||||
|
||||
# Nim runtime initialisation — allocates global state freed at exit.
|
||||
leak:NimMain
|
||||
leak:PreMain
|
||||
leak:systemDatInit
|
||||
|
||||
# GC bootstrap — registers stack bottom / TLS slots once per thread.
|
||||
leak:nimGC_setStackBottom
|
||||
leak:initStackBottomWith
|
||||
leak:setupForeignThreadGc
|
||||
|
||||
# Async / logging library globals (event loop singletons, logger
|
||||
# registries) — owned for the process lifetime.
|
||||
leak:chronos
|
||||
leak:chronicles
|
||||
99
tests/e2e/c_abi/test_echo_c_abi.c
Normal file
99
tests/e2e/c_abi/test_echo_c_abi.c
Normal file
@ -0,0 +1,99 @@
|
||||
/* End-to-end test for the CBOR-free `abi = c` echo bindings. Unlike the CBOR C
|
||||
* backend, this header links no TinyCBOR: the flat structs in echo.h are the C
|
||||
* ABI, strings are plain borrowed `const char*`. The test drives the same
|
||||
* async, callback-per-call surface — constructor, an object-returning method
|
||||
* and teardown — copying out what each callback delivers (owned by the binding,
|
||||
* valid only for the call) and polling a `done` flag to sequence the async
|
||||
* calls. A string-returning method (echoVersion) rides the CBOR-free scalar
|
||||
* fast path instead of a flat `_CWire` wrapper, so it has no c_abi binding yet
|
||||
* (foreign codegen for the scalar shape is a follow-up) and isn't exercised
|
||||
* here. */
|
||||
#include "echo.h"
|
||||
#include <assert.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
/* The `done` flag is a C11 atomic: the callback fires on the FFI thread and
|
||||
* stores it with release ordering after filling the waiter's fields, and the
|
||||
* poller loads it with acquire ordering — so the field writes are visible
|
||||
* (and race-free under TSan) once `done` is seen set. */
|
||||
static void wait_done(atomic_int* done) {
|
||||
for (int i = 0; i < 500 && !atomic_load_explicit(done, memory_order_acquire); i++) {
|
||||
struct timespec t = {0, 10 * 1000 * 1000}; /* 10ms */
|
||||
nanosleep(&t, NULL);
|
||||
}
|
||||
assert(atomic_load_explicit(done, memory_order_acquire));
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
atomic_int done;
|
||||
int err_code;
|
||||
EchoCtx* ctx;
|
||||
char err[256];
|
||||
} CreateWaiter;
|
||||
|
||||
static void on_created(int ec, EchoCtx* ctx, const char* em, void* ud) {
|
||||
CreateWaiter* w = (CreateWaiter*)ud;
|
||||
w->err_code = ec;
|
||||
w->ctx = ctx;
|
||||
if (em) {
|
||||
snprintf(w->err, sizeof(w->err), "%s", em);
|
||||
}
|
||||
atomic_store_explicit(&w->done, 1, memory_order_release);
|
||||
}
|
||||
|
||||
static EchoCtx* make_ctx(void) {
|
||||
CreateWaiter w;
|
||||
memset(&w, 0, sizeof(w));
|
||||
EchoConfig config = {"c-abi"};
|
||||
echo_ctx_create(&config, on_created, &w);
|
||||
wait_done(&w.done);
|
||||
if (w.err_code != 0) {
|
||||
fprintf(stderr, "create failed: %s\n", w.err[0] ? w.err : "?");
|
||||
}
|
||||
assert(w.err_code == 0);
|
||||
assert(w.ctx != NULL);
|
||||
return w.ctx;
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
atomic_int done;
|
||||
int err_code;
|
||||
char err[256];
|
||||
char text_a[256];
|
||||
char text_b[256];
|
||||
} ReplyWaiter;
|
||||
|
||||
static void on_shout(int ec, const ShoutResponse* reply, const char* em, void* ud) {
|
||||
ReplyWaiter* w = (ReplyWaiter*)ud;
|
||||
w->err_code = ec;
|
||||
if (reply) {
|
||||
if (reply->shouted)
|
||||
snprintf(w->text_a, sizeof(w->text_a), "%s", reply->shouted);
|
||||
if (reply->prefix)
|
||||
snprintf(w->text_b, sizeof(w->text_b), "%s", reply->prefix);
|
||||
}
|
||||
if (em) snprintf(w->err, sizeof(w->err), "%s", em);
|
||||
atomic_store_explicit(&w->done, 1, memory_order_release);
|
||||
}
|
||||
|
||||
static void test_shout(EchoCtx* ctx) {
|
||||
ReplyWaiter w;
|
||||
memset(&w, 0, sizeof(w));
|
||||
ShoutRequest req = {"hello"};
|
||||
echo_ctx_shout(ctx, &req, on_shout, &w);
|
||||
wait_done(&w.done);
|
||||
assert(w.err_code == 0);
|
||||
assert(strcmp(w.text_a, "c-abi: HELLO") == 0);
|
||||
assert(strcmp(w.text_b, "c-abi") == 0);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
EchoCtx* ctx = make_ctx();
|
||||
test_shout(ctx);
|
||||
echo_ctx_destroy(ctx);
|
||||
printf("all abi=c echo e2e checks passed\n");
|
||||
return 0;
|
||||
}
|
||||
@ -122,10 +122,11 @@ suite "handler-timeout spec parsing (issue #93)":
|
||||
check requestTimeoutsMs["AbitestSlowReq".cstring] == 30000
|
||||
check not requestTimeoutsMs.hasKey("AbitestPingReq".cstring)
|
||||
|
||||
suite "ABI proc-dispatch readiness (why c is still gated on procs)":
|
||||
test "cbor proc-dispatch is wired; c proc-dispatch is gated":
|
||||
# This predicate is what the proc-form macros consult: `cbor` is wired
|
||||
# end-to-end, while `c` is recognized but gated pending its codec. It is the
|
||||
# single seam a future PR flips when the c codec and dispatch path land.
|
||||
suite "ABI proc-dispatch readiness":
|
||||
test "both cbor and c proc-dispatch are wired":
|
||||
# This predicate is what the proc-form macros consult. Both ABIs now have a
|
||||
# working dispatch path: `cbor` rides the generic overloads, `c` rides the
|
||||
# flat `_CWire` companions (a CBOR-free foreign surface, CBOR transport
|
||||
# internally). Events are the one `c` gap, gated separately in the macro.
|
||||
check abiCodegenImplemented(ABIFormat.Cbor)
|
||||
check not abiCodegenImplemented(ABIFormat.C)
|
||||
check abiCodegenImplemented(ABIFormat.C)
|
||||
|
||||
125
tests/unit/test_c_abi_codegen.nim
Normal file
125
tests/unit/test_c_abi_codegen.nim
Normal file
@ -0,0 +1,125 @@
|
||||
## Unit tests for the CBOR-free `abi = c` C binding generator. Drives
|
||||
## generateCAbiLibHeader directly against a synthetic registry (no macro
|
||||
## pipeline, no files written) and asserts on the emitted text — same approach
|
||||
## as test_c_codegen / test_cddl_codegen.
|
||||
|
||||
import std/strutils
|
||||
import unittest2
|
||||
import ffi/codegen/[meta, c_abi]
|
||||
|
||||
proc field(n, t: string): FFIFieldMeta =
|
||||
FFIFieldMeta(name: n, typeName: t)
|
||||
|
||||
proc param(n, t: string, isPtr = false): FFIParamMeta =
|
||||
FFIParamMeta(name: n, typeName: t, isPtr: isPtr)
|
||||
|
||||
suite "generateCAbiLibHeader":
|
||||
setup:
|
||||
let types = @[
|
||||
FFITypeMeta(
|
||||
name: "EchoRequest",
|
||||
fields: @[field("message", "string"), field("delayMs", "int")],
|
||||
),
|
||||
FFITypeMeta(name: "EchoResponse", fields: @[field("echoed", "string")]),
|
||||
FFITypeMeta(
|
||||
name: "ComplexRequest",
|
||||
fields:
|
||||
@[field("messages", "seq[EchoRequest]"), field("note", "Option[string]")],
|
||||
),
|
||||
]
|
||||
let procs = @[
|
||||
FFIProcMeta(
|
||||
procName: "timer_create",
|
||||
libName: "timer",
|
||||
kind: FFIKind.CTOR,
|
||||
libTypeName: "Timer",
|
||||
extraParams: @[param("config", "EchoRequest")],
|
||||
returnTypeName: "Timer",
|
||||
),
|
||||
FFIProcMeta(
|
||||
procName: "timer_echo",
|
||||
libName: "timer",
|
||||
kind: FFIKind.FFI,
|
||||
libTypeName: "Timer",
|
||||
extraParams: @[param("req", "ComplexRequest")],
|
||||
returnTypeName: "EchoResponse",
|
||||
),
|
||||
FFIProcMeta(
|
||||
procName: "timer_version",
|
||||
libName: "timer",
|
||||
kind: FFIKind.FFI,
|
||||
libTypeName: "Timer",
|
||||
extraParams: @[],
|
||||
returnTypeName: "string",
|
||||
),
|
||||
FFIProcMeta(
|
||||
procName: "timer_destroy",
|
||||
libName: "timer",
|
||||
kind: FFIKind.DTOR,
|
||||
libTypeName: "Timer",
|
||||
extraParams: @[],
|
||||
returnTypeName: "",
|
||||
),
|
||||
]
|
||||
let header = generateCAbiLibHeader(procs, types, "timer")
|
||||
|
||||
test "the header is self-contained and links no CBOR":
|
||||
check "#include <stdint.h>" in header
|
||||
check "nim_ffi_cbor.h" notin header
|
||||
check "CborError" notin header
|
||||
check "tinycbor" notin header.toLowerAscii()
|
||||
|
||||
test "flat wire structs mirror the _CWire layout":
|
||||
# string -> const char*; POD unchanged.
|
||||
check "const char* message;" in header
|
||||
check "int64_t delayMs;" in header
|
||||
# seq[T] -> <wireElem>* <f>_items + ptrdiff_t <f>_len (two fields).
|
||||
check "EchoRequest* messages_items;" in header
|
||||
check "ptrdiff_t messages_len;" in header
|
||||
# Option[string] -> pointer to the element wire type (NULL = none).
|
||||
check "const char** note;" in header
|
||||
# nested {.ffi.} type rides as its flat struct.
|
||||
check "EchoRequest config;" in header
|
||||
|
||||
test "per-proc Req envelopes are emitted as structs":
|
||||
check "} TimerEchoReq;" in header
|
||||
check "} TimerCreateCtorReq;" in header
|
||||
|
||||
test "exported symbols use the flat structs, not CBOR buffers":
|
||||
check "req_cbor" notin header
|
||||
check "const TimerEchoReq* req" in header
|
||||
check "void* timer_create(const TimerCreateCtorReq* req," in header
|
||||
check "int timer_destroy(void* ctx);" in header
|
||||
|
||||
test "object returns hand back a const struct pointer; string returns a cstring":
|
||||
check "typedef void (*TimerEchoReplyFn)(int err_code, const EchoResponse* reply," in
|
||||
header
|
||||
check "typedef void (*TimerVersionReplyFn)(int err_code, const char* reply," in
|
||||
header
|
||||
|
||||
test "constructor delivers its context address through a raw string callback":
|
||||
check "TimerCreateRawFn)(int err_code, const char* ctx_addr," in header
|
||||
check "strtoull(ctx_addr" in header
|
||||
|
||||
test "high-level context wrapper is emitted":
|
||||
check "} TimerCtx;" in header
|
||||
check "timer_ctx_create(" in header
|
||||
check "timer_ctx_echo(" in header
|
||||
check "timer_ctx_version(" in header
|
||||
check "timer_ctx_destroy(" in header
|
||||
|
||||
test "events are rejected (CBOR-only for now)":
|
||||
expect ValueError:
|
||||
discard generateCAbiLibHeader(
|
||||
procs,
|
||||
types,
|
||||
"timer",
|
||||
@[
|
||||
FFIEventMeta(
|
||||
wireName: "on_tick",
|
||||
nimProcName: "onTick",
|
||||
libName: "timer",
|
||||
payloadTypeName: "EchoResponse",
|
||||
)
|
||||
],
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user