test(cpp-e2e): add multi-context, cross-library, pipeline, stress tests (#30) (#42)

This commit is contained in:
Gabriel Cruz 2026-05-26 09:18:12 -03:00 committed by GitHub
parent 6a7e4616fd
commit 436c0d760b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 970 additions and 102 deletions

View File

@ -0,0 +1,123 @@
cmake_minimum_required(VERSION 3.14)
project(echo_cpp_bindings CXX C)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Locate the repository root (contains ffi.nimble)
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()
get_filename_component(NIM_SRC
"${CMAKE_CURRENT_SOURCE_DIR}/../echo.nim"
ABSOLUTE)
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")
# MSVC consumers link against the `.lib` import library, not the DLL.
# MinGW's ld emits one when asked via `--out-implib`; the resulting COFF
# archive is readable by MSVC's link.exe.
set(NIM_IMPLIB_FILE "${REPO_ROOT}/echo.lib")
else()
set(NIM_LIB_FILE "${REPO_ROOT}/libecho.so")
endif()
# On Windows the default Nim toolchain (mingw gcc) doesn't emit an import
# library unless told to. Without it, MSVC consumers can't resolve any
# symbol exported by the DLL at link time.
set(NIM_IMPLIB_PASSL "")
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
set(NIM_IMPLIB_PASSL "--passL:-Wl,--out-implib,${NIM_IMPLIB_FILE}")
endif()
add_custom_command(
OUTPUT "${NIM_LIB_FILE}"
COMMAND "${NIM_EXECUTABLE}" c
--mm:orc
-d:chronicles_log_level=WARN
--app:lib
--noMain
"--nimMainPrefix:libecho"
${NIM_IMPLIB_PASSL}
"-o:${NIM_LIB_FILE}"
"${NIM_SRC}"
WORKING_DIRECTORY "${REPO_ROOT}"
DEPENDS "${NIM_SRC}"
BYPRODUCTS "${NIM_IMPLIB_FILE}"
COMMENT "Compiling Nim library libecho"
VERBATIM
)
add_custom_target(echo_nim_lib ALL DEPENDS "${NIM_LIB_FILE}")
# On Windows, an `IMPORTED SHARED` target needs IMPORTED_IMPLIB pointing at
# the `.lib` import library so MSVC's `link.exe` can resolve symbols. The
# Visual Studio multi-config generator did not pick up `IMPORTED_IMPLIB`
# nor per-config `IMPORTED_IMPLIB_<CONFIG>` variants and emitted
# `echo-NOTFOUND.obj` into every link line. Side-step the IMPORTED
# machinery on Windows by exposing the import library through a plain
# INTERFACE library that links the `.lib` by path.
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
add_library(echo INTERFACE)
target_link_libraries(echo INTERFACE "${NIM_IMPLIB_FILE}")
else()
add_library(echo SHARED IMPORTED GLOBAL)
set_target_properties(echo PROPERTIES IMPORTED_LOCATION "${NIM_LIB_FILE}")
endif()
add_dependencies(echo echo_nim_lib)
# Absolute path to the runtime library (DLL/dylib/so). Exposed via the cache
# so consumers in other directories (e.g. tests/e2e/cpp) can stage the DLL
# next to their executable on Windows.
set(echo_RUNTIME_LIB "${NIM_LIB_FILE}" CACHE INTERNAL
"Absolute path to the echo runtime library")
# TinyCBOR (vendored at ffi/codegen/templates/cpp/vendor/tinycbor)
# Guarded so two sibling cpp_bindings dirs in one parent project don't redefine
# the `tinycbor` target.
set(TINYCBOR_SRC_DIR "${REPO_ROOT}/ffi/codegen/templates/cpp/vendor")
if(NOT TARGET tinycbor)
add_library(tinycbor STATIC
"${TINYCBOR_SRC_DIR}/tinycbor/cborencoder.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborencoder_close_container_checked.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborparser.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborparser_dup_string.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborerrorstrings.c"
)
target_include_directories(tinycbor PUBLIC
"${TINYCBOR_SRC_DIR}" # consumer uses #include <tinycbor/cbor.h>
"${TINYCBOR_SRC_DIR}/tinycbor" # internal _p.h includes resolve here
)
set_property(TARGET tinycbor PROPERTY C_STANDARD 99)
set_property(TARGET tinycbor PROPERTY POSITION_INDEPENDENT_CODE ON)
endif()
add_library(echo_headers INTERFACE)
target_include_directories(echo_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(echo_headers INTERFACE echo tinycbor)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.cpp")
add_executable(echo_example main.cpp)
target_link_libraries(echo_example PRIVATE echo_headers)
add_dependencies(echo_example echo_nim_lib)
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
add_custom_command(TARGET echo_example POST_BUILD
COMMAND "${CMAKE_COMMAND}" -E copy_if_different
"${echo_RUNTIME_LIB}"
"$<TARGET_FILE_DIR:echo_example>"
COMMENT "Staging echo.dll next to echo_example.exe")
endif()
endif()

View File

@ -0,0 +1,473 @@
#pragma once
#include <string>
#include <cstdint>
#include <chrono>
#include <stdexcept>
#include <mutex>
#include <condition_variable>
#include <memory>
#include <functional>
#include <future>
#include <vector>
#include <optional>
#include <type_traits>
#include <cstring>
extern "C" {
#include <tinycbor/cbor.h>
}
// ── encode_cbor overloads (primitives + containers) ─────────────────────
// Per-struct encode_cbor / decode_cbor are emitted by cpp.nim next to each
// generated struct; these helpers cover the leaf types they defer into.
// Guarded so two nim-ffi headers can share a translation unit.
#ifndef NIM_FFI_CBOR_HELPERS_HPP_INCLUDED
#define NIM_FFI_CBOR_HELPERS_HPP_INCLUDED
inline CborError encode_cbor(CborEncoder& e, bool v) {
return cbor_encode_boolean(&e, v);
}
inline CborError encode_cbor(CborEncoder& e, int64_t v) {
return cbor_encode_int(&e, v);
}
inline CborError encode_cbor(CborEncoder& e, int32_t v) {
return cbor_encode_int(&e, static_cast<int64_t>(v));
}
inline CborError encode_cbor(CborEncoder& e, uint64_t v) {
return cbor_encode_uint(&e, v);
}
inline CborError encode_cbor(CborEncoder& e, double v) {
return cbor_encode_double(&e, v);
}
inline CborError encode_cbor(CborEncoder& e, const std::string& v) {
return cbor_encode_text_string(&e, v.data(), v.size());
}
template<typename T>
inline CborError encode_cbor(CborEncoder& e, const std::vector<T>& v) {
CborEncoder arr;
CborError err = cbor_encoder_create_array(&e, &arr, v.size());
if (err) return err;
for (const auto& item : v) {
err = encode_cbor(arr, item);
if (err) return err;
}
return cbor_encoder_close_container(&e, &arr);
}
template<typename T>
inline CborError encode_cbor(CborEncoder& e, const std::optional<T>& v) {
if (!v) return cbor_encode_null(&e);
return encode_cbor(e, *v);
}
// ── decode_cbor overloads ───────────────────────────────────────────────
inline CborError decode_cbor(CborValue& it, bool& out) {
if (!cbor_value_is_boolean(&it)) return CborErrorImproperValue;
CborError err = cbor_value_get_boolean(&it, &out);
if (err) return err;
return cbor_value_advance(&it);
}
inline CborError decode_cbor(CborValue& it, int64_t& out) {
if (!cbor_value_is_integer(&it)) return CborErrorImproperValue;
CborError err = cbor_value_get_int64_checked(&it, &out);
if (err) return err;
return cbor_value_advance(&it);
}
inline CborError decode_cbor(CborValue& it, int32_t& out) {
int64_t tmp = 0;
CborError err = decode_cbor(it, tmp);
if (err) return err;
out = static_cast<int32_t>(tmp);
return CborNoError;
}
inline CborError decode_cbor(CborValue& it, uint64_t& out) {
if (!cbor_value_is_unsigned_integer(&it)) return CborErrorImproperValue;
CborError err = cbor_value_get_uint64(&it, &out);
if (err) return err;
return cbor_value_advance(&it);
}
inline CborError decode_cbor(CborValue& it, double& out) {
if (cbor_value_is_double(&it)) {
CborError err = cbor_value_get_double(&it, &out);
if (err) return err;
return cbor_value_advance(&it);
}
if (cbor_value_is_float(&it)) {
float f = 0.0f;
CborError err = cbor_value_get_float(&it, &f);
if (err) return err;
out = static_cast<double>(f);
return cbor_value_advance(&it);
}
return CborErrorImproperValue;
}
inline CborError decode_cbor(CborValue& it, std::string& out) {
if (!cbor_value_is_text_string(&it)) return CborErrorImproperValue;
size_t len = 0;
CborError err = cbor_value_get_string_length(&it, &len);
if (err) return err;
out.resize(len);
err = cbor_value_copy_text_string(&it, out.empty() ? nullptr : &out[0], &len, nullptr);
if (err) return err;
return cbor_value_advance(&it);
}
template<typename T>
inline CborError decode_cbor(CborValue& it, std::vector<T>& out) {
if (!cbor_value_is_array(&it)) return CborErrorImproperValue;
size_t len = 0;
CborError err = cbor_value_get_array_length(&it, &len);
if (err) return err;
out.clear();
out.resize(len);
CborValue inner;
err = cbor_value_enter_container(&it, &inner);
if (err) return err;
for (size_t i = 0; i < len; ++i) {
err = decode_cbor(inner, out[i]);
if (err) return err;
}
return cbor_value_leave_container(&it, &inner);
}
template<typename T>
inline CborError decode_cbor(CborValue& it, std::optional<T>& out) {
if (cbor_value_is_null(&it)) {
out = std::nullopt;
return cbor_value_advance(&it);
}
T tmp{};
CborError err = decode_cbor(it, tmp);
if (err) return err;
out = std::move(tmp);
return CborNoError;
}
// ── Public entry points ─────────────────────────────────────────────────
template<typename T>
inline std::vector<std::uint8_t> encodeCborFFI(const T& value) {
// Start with a generous 4 KiB buffer; double on overflow until it fits.
std::vector<std::uint8_t> buf(4096);
while (true) {
CborEncoder enc;
cbor_encoder_init(&enc, buf.data(), buf.size(), 0);
CborError err = encode_cbor(enc, value);
if (err == CborNoError) {
const size_t used = cbor_encoder_get_buffer_size(&enc, buf.data());
buf.resize(used);
return buf;
}
if (err == CborErrorOutOfMemory) {
const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc);
buf.resize(buf.size() + (extra > 0 ? extra : buf.size()));
continue;
}
throw std::runtime_error(std::string("FFI CBOR encode failed: ") +
cbor_error_string(err));
}
}
template<typename T>
inline T decodeCborFFI(const std::vector<std::uint8_t>& bytes) {
CborParser parser;
CborValue it;
CborError err = cbor_parser_init(bytes.data(), bytes.size(), 0, &parser, &it);
if (err != CborNoError) {
throw std::runtime_error(std::string("FFI CBOR parse init failed: ") +
cbor_error_string(err));
}
T out{};
err = decode_cbor(it, out);
if (err != CborNoError) {
throw std::runtime_error(std::string("FFI CBOR decode failed: ") +
cbor_error_string(err));
}
return out;
}
#endif // NIM_FFI_CBOR_HELPERS_HPP_INCLUDED
// ============================================================
// User-declared FFI types
// ============================================================
struct EchoConfig {
std::string prefix;
};
inline CborError encode_cbor(CborEncoder& e, const EchoConfig& v) {
CborEncoder m;
CborError err = cbor_encoder_create_map(&e, &m, 1);
if (err) return err;
err = cbor_encode_text_stringz(&m, "prefix"); if (err) return err;
err = encode_cbor(m, v.prefix); if (err) return err;
return cbor_encoder_close_container(&e, &m);
}
inline CborError decode_cbor(CborValue& it, EchoConfig& v) {
if (!cbor_value_is_map(&it)) return CborErrorImproperValue;
CborValue field;
CborError err;
err = cbor_value_map_find_value(&it, "prefix", &field); if (err) return err;
if (!cbor_value_is_valid(&field)) return CborErrorImproperValue;
err = decode_cbor(field, v.prefix); if (err) return err;
return cbor_value_advance(&it);
}
struct ShoutRequest {
std::string text;
};
inline CborError encode_cbor(CborEncoder& e, const ShoutRequest& v) {
CborEncoder m;
CborError err = cbor_encoder_create_map(&e, &m, 1);
if (err) return err;
err = cbor_encode_text_stringz(&m, "text"); if (err) return err;
err = encode_cbor(m, v.text); if (err) return err;
return cbor_encoder_close_container(&e, &m);
}
inline CborError decode_cbor(CborValue& it, ShoutRequest& v) {
if (!cbor_value_is_map(&it)) return CborErrorImproperValue;
CborValue field;
CborError err;
err = cbor_value_map_find_value(&it, "text", &field); if (err) return err;
if (!cbor_value_is_valid(&field)) return CborErrorImproperValue;
err = decode_cbor(field, v.text); if (err) return err;
return cbor_value_advance(&it);
}
struct ShoutResponse {
std::string shouted;
std::string prefix;
};
inline CborError encode_cbor(CborEncoder& e, const ShoutResponse& v) {
CborEncoder m;
CborError err = cbor_encoder_create_map(&e, &m, 2);
if (err) return err;
err = cbor_encode_text_stringz(&m, "shouted"); if (err) return err;
err = encode_cbor(m, v.shouted); if (err) return err;
err = cbor_encode_text_stringz(&m, "prefix"); if (err) return err;
err = encode_cbor(m, v.prefix); if (err) return err;
return cbor_encoder_close_container(&e, &m);
}
inline CborError decode_cbor(CborValue& it, ShoutResponse& v) {
if (!cbor_value_is_map(&it)) return CborErrorImproperValue;
CborValue field;
CborError err;
err = cbor_value_map_find_value(&it, "shouted", &field); if (err) return err;
if (!cbor_value_is_valid(&field)) return CborErrorImproperValue;
err = decode_cbor(field, v.shouted); if (err) return err;
err = cbor_value_map_find_value(&it, "prefix", &field); if (err) return err;
if (!cbor_value_is_valid(&field)) return CborErrorImproperValue;
err = decode_cbor(field, v.prefix); if (err) return err;
return cbor_value_advance(&it);
}
// ============================================================
// Per-proc request envelopes (CBOR encoded on the wire)
// ============================================================
struct EchoCreateCtorReq {
EchoConfig config;
};
inline CborError encode_cbor(CborEncoder& e, const EchoCreateCtorReq& v) {
CborEncoder m;
CborError err = cbor_encoder_create_map(&e, &m, 1);
if (err) return err;
err = cbor_encode_text_stringz(&m, "config"); if (err) return err;
err = encode_cbor(m, v.config); if (err) return err;
return cbor_encoder_close_container(&e, &m);
}
inline CborError decode_cbor(CborValue& it, EchoCreateCtorReq& v) {
if (!cbor_value_is_map(&it)) return CborErrorImproperValue;
CborValue field;
CborError err;
err = cbor_value_map_find_value(&it, "config", &field); if (err) return err;
if (!cbor_value_is_valid(&field)) return CborErrorImproperValue;
err = decode_cbor(field, v.config); if (err) return err;
return cbor_value_advance(&it);
}
struct EchoShoutReq {
ShoutRequest req;
};
inline CborError encode_cbor(CborEncoder& e, const EchoShoutReq& v) {
CborEncoder m;
CborError err = cbor_encoder_create_map(&e, &m, 1);
if (err) return err;
err = cbor_encode_text_stringz(&m, "req"); if (err) return err;
err = encode_cbor(m, v.req); if (err) return err;
return cbor_encoder_close_container(&e, &m);
}
inline CborError decode_cbor(CborValue& it, EchoShoutReq& v) {
if (!cbor_value_is_map(&it)) return CborErrorImproperValue;
CborValue field;
CborError err;
err = cbor_value_map_find_value(&it, "req", &field); if (err) return err;
if (!cbor_value_is_valid(&field)) return CborErrorImproperValue;
err = decode_cbor(field, v.req); if (err) return err;
return cbor_value_advance(&it);
}
struct EchoVersionReq {
};
inline CborError encode_cbor(CborEncoder& e, const EchoVersionReq&) {
CborEncoder m;
CborError err = cbor_encoder_create_map(&e, &m, 0);
if (err) return err;
return cbor_encoder_close_container(&e, &m);
}
inline CborError decode_cbor(CborValue& it, EchoVersionReq&) {
if (!cbor_value_is_map(&it)) return CborErrorImproperValue;
return cbor_value_advance(&it);
}
// ============================================================
// C FFI declarations
// ============================================================
extern "C" {
typedef void (*FFICallback)(int ret, const char* msg, size_t len, void* user_data);
void* echo_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback callback, void* user_data);
int echo_shout(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);
int echo_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);
int echo_destroy(void* ctx);
void echo_set_event_callback(void* ctx, FFICallback callback, void* user_data);
} // extern "C"
// ============================================================
// Synchronous call helper
// ============================================================
// Guarded so two nim-ffi headers can share a translation unit.
#ifndef NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED
#define NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED
namespace {
struct FFICallState_ {
std::mutex mtx;
std::condition_variable cv;
bool done{false};
bool ok{false};
std::vector<std::uint8_t> bytes;
std::string err;
};
inline void ffi_cb_(int ret, const char* msg, size_t len, void* ud) {
// ffi_call_ heap-allocated a shared_ptr and passed its address as ud;
// take ownership here so it's freed on every exit path.
std::unique_ptr<std::shared_ptr<FFICallState_>> handle(
static_cast<std::shared_ptr<FFICallState_>*>(ud));
FFICallState_& s = **handle;
std::lock_guard<std::mutex> lock(s.mtx);
s.ok = (ret == 0);
if (msg && len > 0) {
const auto* p = reinterpret_cast<const std::uint8_t*>(msg);
if (s.ok) s.bytes.assign(p, p + len);
else s.err.assign(msg, len);
}
s.done = true;
s.cv.notify_one();
}
inline std::vector<std::uint8_t> ffi_call_(std::function<int(FFICallback, void*)> f,
std::chrono::milliseconds timeout) {
auto state = std::make_shared<FFICallState_>();
auto* cb_ref = new std::shared_ptr<FFICallState_>(state);
const int ret = f(ffi_cb_, cb_ref);
if (ret == 2) {
delete cb_ref;
throw std::runtime_error("RET_MISSING_CALLBACK (internal error)");
}
std::unique_lock<std::mutex> lock(state->mtx);
const bool fired = state->cv.wait_for(lock, timeout, [&]{ return state->done; });
if (!fired)
throw std::runtime_error("FFI call timed out after " + std::to_string(timeout.count()) + "ms");
if (!state->ok)
throw std::runtime_error(state->err);
return state->bytes;
}
} // anonymous namespace
#endif // NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED
// ============================================================
// High-level C++ context class
// ============================================================
class EchoCtx {
public:
static std::unique_ptr<EchoCtx> create(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) {
const auto ffi_req_ = EchoCreateCtorReq{config};
const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_);
const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) {
(void)echo_create(ffi_req_bytes_.data(), ffi_req_bytes_.size(), cb, ud);
return 0;
}, timeout);
const auto addr_str = decodeCborFFI<std::string>(ffi_raw_);
try {
const auto addr = std::stoull(addr_str);
return std::unique_ptr<EchoCtx>(new EchoCtx(reinterpret_cast<void*>(static_cast<uintptr_t>(addr)), timeout));
} catch (const std::exception&) {
throw std::runtime_error("FFI create returned non-numeric address: " + addr_str);
}
}
static std::future<std::unique_ptr<EchoCtx>> createAsync(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) {
return std::async(std::launch::async, [config, timeout]() { return create(config, timeout); });
}
// Special-member policy: this class owns a echo context, which in
// turn owns the library's worker thread(s) and internal state. Moving
// such an object out from under a caller silently tears that state
// down and is easy to misuse (e.g. storing in a container that
// relocates its elements). It also has no clean analogue in the other
// binding languages we generate. So copies and moves are both
// deleted; ownership is transferred via EchoCtx::create returning a
// std::unique_ptr<EchoCtx>. The destructor still releases the
// context.
~EchoCtx() {
if (ptr_) {
echo_destroy(ptr_);
ptr_ = nullptr;
}
}
EchoCtx(const EchoCtx&) = delete;
EchoCtx& operator=(const EchoCtx&) = delete;
EchoCtx(EchoCtx&&) = delete;
EchoCtx& operator=(EchoCtx&&) = delete;
ShoutResponse shout(const ShoutRequest& req) const {
const auto ffi_req_ = EchoShoutReq{req};
const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_);
const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) {
return echo_shout(ptr_, cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size());
}, timeout_);
return decodeCborFFI<ShoutResponse>(ffi_raw_);
}
std::future<ShoutResponse> shoutAsync(const ShoutRequest& req) const {
return std::async(std::launch::async, [this, req]() { return this->shout(req); });
}
std::string version() const {
const auto ffi_req_ = EchoVersionReq{};
const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_);
const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) {
return echo_version(ptr_, cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size());
}, timeout_);
return decodeCborFFI<std::string>(ffi_raw_);
}
std::future<std::string> versionAsync() const {
return std::async(std::launch::async, [this]() { return this->version(); });
}
private:
void* ptr_;
std::chrono::milliseconds timeout_;
explicit EchoCtx(void* p, std::chrono::milliseconds t) : ptr_(p), timeout_(t) {}
};

38
examples/echo/echo.nim Normal file
View File

@ -0,0 +1,38 @@
## Second nim-ffi example library — loaded alongside examples/timer in the
## C++ CrossLibrary e2e test to prove two libs coexist in one process.
import ffi, chronos, strutils
type Echo = object
prefix: string
declareLibrary("echo", Echo)
type EchoConfig {.ffi.} = object
prefix: string
type ShoutRequest {.ffi.} = object
text: string
type ShoutResponse {.ffi.} = object
shouted: string
prefix: string
proc echoCreate*(config: EchoConfig): Future[Result[Echo, string]] {.ffiCtor.} =
await sleepAsync(1.milliseconds)
return ok(Echo(prefix: config.prefix))
proc echoShout*(
e: Echo, req: ShoutRequest
): Future[Result[ShoutResponse, string]] {.ffi.} =
await sleepAsync(1.milliseconds)
let upper = req.text.toUpperAscii
return ok(ShoutResponse(shouted: e.prefix & ": " & upper, prefix: e.prefix))
proc echoVersion*(e: Echo): Future[Result[string, string]] {.ffi.} =
return ok("nim-echo v0.1.0")
proc echo_destroy*(e: Echo) {.ffiDtor.} =
discard
genBindings()

22
examples/echo/echo.nimble Normal file
View File

@ -0,0 +1,22 @@
version = "0.1.0"
packageName = "echo"
author = "Institute of Free Technology"
description = "Second nim-ffi example library, used as the cross-library partner of the timer example in C++ e2e tests"
license = "MIT or Apache License 2.0"
requires "nim >= 2.2.4"
requires "chronos"
requires "chronicles"
requires "taskpools"
requires "https://github.com/logos-messaging/nim-ffi >= 0.2.0"
const nimFlags = "--mm:orc -d:chronicles_log_level=WARN"
task build, "Compile the echo library":
exec "nim c " & nimFlags &
" --app:lib --noMain --nimMainPrefix:libecho echo.nim"
task genbindings_cpp, "Generate C++ bindings for the echo example":
exec "nim c " & nimFlags & " --app:lib --noMain --nimMainPrefix:libecho" &
" -d:ffiGenBindings -d:targetLang=cpp" & " -d:ffiOutputDir=cpp_bindings" &
" -d:ffiSrcPath=echo.nim" & " -o:/dev/null echo.nim"

View File

@ -1,4 +1,4 @@
; CDDL schema for `timer` — auto-generated from ../timer.nim
; CDDL schema for `my_timer` — auto-generated from ../timer.nim
; Wire format: CBOR (RFC 8949). Errors return raw UTF-8 (not CBOR) and
; are intentionally absent from this schema.
@ -8,38 +8,39 @@ EchoRequest = { message: tstr, delayMs: int }
EchoResponse = { echoed: tstr, timerName: tstr }
ComplexRequest = { messages: [* EchoRequest], tags: [* tstr], note: tstr / nil, retries: int / nil }
ComplexResponse = { summary: tstr, itemCount: int, hasNote: bool }
EchoEvent = { message: tstr, echoCount: int }
JobSpec = { name: tstr, payload: [* tstr], priority: int }
RetryPolicy = { maxAttempts: int, backoffMs: int, retryOn: [* tstr] }
ScheduleConfig = { startAtMs: int, intervalMs: int, jitter: int / nil }
ScheduleResult = { jobId: tstr, willRunCount: int, firstRunAtMs: int, effectiveBackoffMs: int }
; ─── Request envelopes (one CBOR blob per request) ────────────────
TimerCreateCtorReq = { config: TimerConfig }
TimerEchoReq = { req: EchoRequest }
TimerVersionReq = { }
TimerComplexReq = { req: ComplexRequest }
TimerScheduleReq = { job: JobSpec, retry: RetryPolicy, schedule: ScheduleConfig }
MyTimerCreateCtorReq = { config: TimerConfig }
MyTimerEchoReq = { req: EchoRequest }
MyTimerVersionReq = { }
MyTimerComplexReq = { req: ComplexRequest }
MyTimerScheduleReq = { job: JobSpec, retry: RetryPolicy, schedule: ScheduleConfig }
; ─── Procs ─────────────────────────────────────────────────────────
; timer_create (ctor)
timer_create-request = TimerCreateCtorReq
timer_create-response = tstr
; my_timer_create (ctor)
my_timer_create-request = MyTimerCreateCtorReq
my_timer_create-response = tstr
; timer_echo (ffi)
timer_echo-request = TimerEchoReq
timer_echo-response = EchoResponse
; my_timer_echo (ffi)
my_timer_echo-request = MyTimerEchoReq
my_timer_echo-response = EchoResponse
; timer_version (ffi)
timer_version-request = TimerVersionReq
timer_version-response = tstr
; my_timer_version (ffi)
my_timer_version-request = MyTimerVersionReq
my_timer_version-response = tstr
; timer_complex (ffi)
timer_complex-request = TimerComplexReq
timer_complex-response = ComplexResponse
; my_timer_complex (ffi)
my_timer_complex-request = MyTimerComplexReq
my_timer_complex-response = ComplexResponse
; timer_schedule (ffi)
timer_schedule-request = TimerScheduleReq
timer_schedule-response = ScheduleResult
; my_timer_schedule (ffi)
my_timer_schedule-request = MyTimerScheduleReq
my_timer_schedule-response = ScheduleResult
; timer_destroy (dtor)
timer_destroy-response = nil
; my_timer_destroy (dtor)
my_timer_destroy-response = nil

View File

@ -61,7 +61,7 @@ add_custom_command(
COMMENT "Compiling Nim library libmy_timer"
VERBATIM
)
add_custom_target(nim_lib ALL DEPENDS "${NIM_LIB_FILE}")
add_custom_target(my_timer_nim_lib ALL DEPENDS "${NIM_LIB_FILE}")
# On Windows, an `IMPORTED SHARED` target needs IMPORTED_IMPLIB pointing at
# the `.lib` import library so MSVC's `link.exe` can resolve symbols. The
@ -77,7 +77,7 @@ else()
add_library(my_timer SHARED IMPORTED GLOBAL)
set_target_properties(my_timer PROPERTIES IMPORTED_LOCATION "${NIM_LIB_FILE}")
endif()
add_dependencies(my_timer nim_lib)
add_dependencies(my_timer my_timer_nim_lib)
# Absolute path to the runtime library (DLL/dylib/so). Exposed via the cache
# so consumers in other directories (e.g. tests/e2e/cpp) can stage the DLL
@ -86,34 +86,38 @@ set(my_timer_RUNTIME_LIB "${NIM_LIB_FILE}" CACHE INTERNAL
"Absolute path to the my_timer runtime library")
# TinyCBOR (vendored at ffi/codegen/templates/cpp/vendor/tinycbor)
# Guarded so two sibling cpp_bindings dirs in one parent project don't redefine
# the `tinycbor` target.
set(TINYCBOR_SRC_DIR "${REPO_ROOT}/ffi/codegen/templates/cpp/vendor")
add_library(tinycbor STATIC
"${TINYCBOR_SRC_DIR}/tinycbor/cborencoder.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborencoder_close_container_checked.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborparser.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborparser_dup_string.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborerrorstrings.c"
)
target_include_directories(tinycbor PUBLIC
"${TINYCBOR_SRC_DIR}" # consumer uses #include <tinycbor/cbor.h>
"${TINYCBOR_SRC_DIR}/tinycbor" # internal _p.h includes resolve here
)
set_property(TARGET tinycbor PROPERTY C_STANDARD 99)
set_property(TARGET tinycbor PROPERTY POSITION_INDEPENDENT_CODE ON)
if(NOT TARGET tinycbor)
add_library(tinycbor STATIC
"${TINYCBOR_SRC_DIR}/tinycbor/cborencoder.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborencoder_close_container_checked.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborparser.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborparser_dup_string.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborerrorstrings.c"
)
target_include_directories(tinycbor PUBLIC
"${TINYCBOR_SRC_DIR}" # consumer uses #include <tinycbor/cbor.h>
"${TINYCBOR_SRC_DIR}/tinycbor" # internal _p.h includes resolve here
)
set_property(TARGET tinycbor PROPERTY C_STANDARD 99)
set_property(TARGET tinycbor PROPERTY POSITION_INDEPENDENT_CODE ON)
endif()
add_library(my_timer_headers INTERFACE)
target_include_directories(my_timer_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries(my_timer_headers INTERFACE my_timer tinycbor)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.cpp")
add_executable(example main.cpp)
target_link_libraries(example PRIVATE my_timer_headers)
add_dependencies(example nim_lib)
add_executable(my_timer_example main.cpp)
target_link_libraries(my_timer_example PRIVATE my_timer_headers)
add_dependencies(my_timer_example my_timer_nim_lib)
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
add_custom_command(TARGET example POST_BUILD
add_custom_command(TARGET my_timer_example POST_BUILD
COMMAND "${CMAKE_COMMAND}" -E copy_if_different
"${my_timer_RUNTIME_LIB}"
"$<TARGET_FILE_DIR:example>"
COMMENT "Staging my_timer.dll next to example.exe")
"$<TARGET_FILE_DIR:my_timer_example>"
COMMENT "Staging my_timer.dll next to my_timer_example.exe")
endif()
endif()

View File

@ -18,8 +18,10 @@ extern "C" {
// ── encode_cbor overloads (primitives + containers) ─────────────────────
// Per-struct encode_cbor / decode_cbor are emitted by cpp.nim next to each
// generated struct. These helpers cover the leaf types and container shapes
// the struct emitters defer into.
// generated struct; these helpers cover the leaf types they defer into.
// Guarded so two nim-ffi headers can share a translation unit.
#ifndef NIM_FFI_CBOR_HELPERS_HPP_INCLUDED
#define NIM_FFI_CBOR_HELPERS_HPP_INCLUDED
inline CborError encode_cbor(CborEncoder& e, bool v) {
return cbor_encode_boolean(&e, v);
@ -185,6 +187,8 @@ inline T decodeCborFFI(const std::vector<std::uint8_t>& bytes) {
return out;
}
#endif // NIM_FFI_CBOR_HELPERS_HPP_INCLUDED
// ============================================================
// User-declared FFI types
// ============================================================
@ -633,6 +637,9 @@ void my_timer_set_event_callback(void* ctx, FFICallback callback, void* user_dat
// ============================================================
// Synchronous call helper
// ============================================================
// Guarded so two nim-ffi headers can share a translation unit.
#ifndef NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED
#define NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED
namespace {
@ -683,6 +690,8 @@ inline std::vector<std::uint8_t> ffi_call_(std::function<int(FFICallback, void*)
} // anonymous namespace
#endif // NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED
// ============================================================
// High-level C++ context class
// ============================================================

View File

@ -1,10 +1,10 @@
import std/[atomics, tables]
import std/[atomics, sysatomics, tables]
import chronos, chronicles
import
ffi/internal/[ffi_library, ffi_macro],
ffi/[alloc, ffi_types, ffi_context, ffi_context_pool, ffi_thread_request, cbor_serial]
export atomics, tables
export atomics, sysatomics, tables
export chronos, chronicles
export
atomics, alloc, ffi_library, ffi_macro, ffi_types, ffi_context, ffi_context_pool,

View File

@ -93,6 +93,7 @@ task test_serial, "Run CBOR codec unit tests":
task test_cpp_e2e, "Build and run the C++ end-to-end tests for the timer example":
# Regenerate the C++ bindings so the suite always runs against fresh codegen.
runOrQuit "nimble genbindings_cpp"
runOrQuit "nimble genbindings_cpp_echo"
runOrQuit "cmake -S tests/e2e/cpp -B tests/e2e/cpp/build"
runOrQuit "cmake --build tests/e2e/cpp/build"
runOrQuit "ctest --test-dir tests/e2e/cpp/build --output-on-failure"
@ -120,6 +121,7 @@ task test_cpp_e2e_sanitized, "Build and run the C++ e2e tests with a sanitizer (
let mm = getEnv("NIM_FFI_MM", "orc")
let san = getEnv("NIM_FFI_SAN", "none")
runOrQuit "nimble genbindings_cpp"
runOrQuit "nimble genbindings_cpp_echo"
runOrQuit "cmake -S tests/e2e/cpp -B tests/e2e/cpp/build" &
" -DNIM_FFI_MM=" & mm &
" -DNIM_FFI_SANITIZER=" & san
@ -166,6 +168,20 @@ task genbindings_cpp, "Generate C++ bindings for the timer example":
" -d:ffiSrcPath=../timer.nim" &
" -o:/dev/null examples/timer/timer.nim"
task genbindings_cpp_echo, "Generate C++ bindings for the echo example":
exec "nim c " & nimFlagsOrc &
" --app:lib --noMain --nimMainPrefix:libecho" &
" -d:ffiGenBindings -d:targetLang=cpp" &
" -d:ffiOutputDir=examples/echo/cpp_bindings" &
" -d:ffiSrcPath=../echo.nim" &
" -o:/dev/null examples/echo/echo.nim"
exec "nim c " & nimFlagsRefc &
" --app:lib --noMain --nimMainPrefix:libecho" &
" -d:ffiGenBindings -d:targetLang=cpp" &
" -d:ffiOutputDir=examples/echo/cpp_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 --" &

View File

@ -61,7 +61,7 @@ add_custom_command(
COMMENT "Compiling Nim library lib{{LIB}}"
VERBATIM
)
add_custom_target(nim_lib ALL DEPENDS "${NIM_LIB_FILE}")
add_custom_target({{LIB}}_nim_lib ALL DEPENDS "${NIM_LIB_FILE}")
# On Windows, an `IMPORTED SHARED` target needs IMPORTED_IMPLIB pointing at
# the `.lib` import library so MSVC's `link.exe` can resolve symbols. The
@ -77,7 +77,7 @@ else()
add_library({{LIB}} SHARED IMPORTED GLOBAL)
set_target_properties({{LIB}} PROPERTIES IMPORTED_LOCATION "${NIM_LIB_FILE}")
endif()
add_dependencies({{LIB}} nim_lib)
add_dependencies({{LIB}} {{LIB}}_nim_lib)
# Absolute path to the runtime library (DLL/dylib/so). Exposed via the cache
# so consumers in other directories (e.g. tests/e2e/cpp) can stage the DLL
@ -86,34 +86,38 @@ set({{LIB}}_RUNTIME_LIB "${NIM_LIB_FILE}" CACHE INTERNAL
"Absolute path to the {{LIB}} runtime library")
# ── TinyCBOR (vendored at ffi/codegen/templates/cpp/vendor/tinycbor) ─────────
# Guarded so two sibling cpp_bindings dirs in one parent project don't redefine
# the `tinycbor` target.
set(TINYCBOR_SRC_DIR "${REPO_ROOT}/ffi/codegen/templates/cpp/vendor")
add_library(tinycbor STATIC
"${TINYCBOR_SRC_DIR}/tinycbor/cborencoder.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborencoder_close_container_checked.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborparser.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborparser_dup_string.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborerrorstrings.c"
)
target_include_directories(tinycbor PUBLIC
"${TINYCBOR_SRC_DIR}" # consumer uses #include <tinycbor/cbor.h>
"${TINYCBOR_SRC_DIR}/tinycbor" # internal _p.h includes resolve here
)
set_property(TARGET tinycbor PROPERTY C_STANDARD 99)
set_property(TARGET tinycbor PROPERTY POSITION_INDEPENDENT_CODE ON)
if(NOT TARGET tinycbor)
add_library(tinycbor STATIC
"${TINYCBOR_SRC_DIR}/tinycbor/cborencoder.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborencoder_close_container_checked.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborparser.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborparser_dup_string.c"
"${TINYCBOR_SRC_DIR}/tinycbor/cborerrorstrings.c"
)
target_include_directories(tinycbor PUBLIC
"${TINYCBOR_SRC_DIR}" # consumer uses #include <tinycbor/cbor.h>
"${TINYCBOR_SRC_DIR}/tinycbor" # internal _p.h includes resolve here
)
set_property(TARGET tinycbor PROPERTY C_STANDARD 99)
set_property(TARGET tinycbor PROPERTY POSITION_INDEPENDENT_CODE ON)
endif()
add_library({{LIB}}_headers INTERFACE)
target_include_directories({{LIB}}_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}")
target_link_libraries({{LIB}}_headers INTERFACE {{LIB}} tinycbor)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.cpp")
add_executable(example main.cpp)
target_link_libraries(example PRIVATE {{LIB}}_headers)
add_dependencies(example nim_lib)
add_executable({{LIB}}_example main.cpp)
target_link_libraries({{LIB}}_example PRIVATE {{LIB}}_headers)
add_dependencies({{LIB}}_example {{LIB}}_nim_lib)
if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
add_custom_command(TARGET example POST_BUILD
add_custom_command(TARGET {{LIB}}_example POST_BUILD
COMMAND "${CMAKE_COMMAND}" -E copy_if_different
"${{{LIB}}_RUNTIME_LIB}"
"$<TARGET_FILE_DIR:example>"
COMMENT "Staging {{LIB}}.dll next to example.exe")
"$<TARGET_FILE_DIR:{{LIB}}_example>"
COMMENT "Staging {{LIB}}.dll next to {{LIB}}_example.exe")
endif()
endif()

View File

@ -1,7 +1,9 @@
// ── encode_cbor overloads (primitives + containers) ─────────────────────
// Per-struct encode_cbor / decode_cbor are emitted by cpp.nim next to each
// generated struct. These helpers cover the leaf types and container shapes
// the struct emitters defer into.
// generated struct; these helpers cover the leaf types they defer into.
// Guarded so two nim-ffi headers can share a translation unit.
#ifndef NIM_FFI_CBOR_HELPERS_HPP_INCLUDED
#define NIM_FFI_CBOR_HELPERS_HPP_INCLUDED
inline CborError encode_cbor(CborEncoder& e, bool v) {
return cbor_encode_boolean(&e, v);
@ -166,3 +168,5 @@ inline T decodeCborFFI(const std::vector<std::uint8_t>& bytes) {
}
return out;
}
#endif // NIM_FFI_CBOR_HELPERS_HPP_INCLUDED

View File

@ -1,6 +1,9 @@
// ============================================================
// Synchronous call helper
// ============================================================
// Guarded so two nim-ffi headers can share a translation unit.
#ifndef NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED
#define NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED
namespace {
@ -50,3 +53,5 @@ inline std::vector<std::uint8_t> ffi_call_(std::function<int(FFICallback, void*)
}
} // anonymous namespace
#endif // NIM_FFI_SYNC_CALL_HELPER_HPP_INCLUDED

View File

@ -1,4 +1,4 @@
import std/[macros, atomics], strformat, chronicles, chronos
import std/[macros, atomics, sysatomics], strformat, chronicles, chronos
import ../codegen/meta
macro declareLibraryBase*(libraryName: static[string]): untyped =
@ -53,10 +53,15 @@ macro declareLibraryBase*(libraryName: static[string]): untyped =
)
res.add(procDef)
# Create: var initialized: Atomic[bool]
let atomicType = nnkBracketExpr.newTree(ident("Atomic"), ident("bool"))
# Create: var initState: Atomic[int]
# 0 = not started, 1 = in progress (some thread is running nimMainName),
# 2 = done. A boolean flag flipped before nimMainName runs would let a
# second concurrent caller skip past the gate while module init was
# still in flight — on Windows that surfaces as "WSAStartup failed"
# from chronos's later async dispatcher init on a watchdog thread.
let atomicType = nnkBracketExpr.newTree(ident("Atomic"), ident("int"))
let varStmt = nnkVarSection.newTree(
nnkIdentDefs.newTree(ident("initialized"), atomicType, newEmptyNode())
nnkIdentDefs.newTree(ident("initState"), atomicType, newEmptyNode())
)
res.add(varStmt)
@ -74,12 +79,23 @@ macro declareLibraryBase*(libraryName: static[string]): untyped =
let initializeLibraryProc = quote:
proc `procName`*() {.exported.} =
if not initialized.exchange(true):
## Every Nim library needs to call `<yourprefix>NimMain` once exactly,
## to initialize the Nim runtime.
## Being `<yourprefix>` the value given in the optional
## compilation flag --nimMainPrefix:yourprefix
## Every Nim library needs to call `<yourprefix>NimMain` once exactly,
## to initialize the Nim runtime.
## Being `<yourprefix>` the value given in the optional
## compilation flag --nimMainPrefix:yourprefix.
##
## Concurrent callers must NOT proceed past nimMainName until it has
## fully returned: chronos's module-level globalInit (which calls
## WSAStartup on Windows) runs as part of nimMainName, and a thread
## that races past would later see "WSAStartup failed" when its
## watchdog spins up a chronos dispatcher.
var expected: int = 0
if initState.compareExchange(expected, 1):
`nimMainName`()
initState.store(2)
else:
while initState.load() != 2:
cpuRelax()
when declared(setupForeignThreadGc):
setupForeignThreadGc()
when declared(nimGC_setStackBottom):

View File

@ -8,12 +8,16 @@ project(nim_ffi_cpp_e2e CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Reuse the timer cpp_bindings (compiles libmy_timer + exposes the
# my_timer_headers INTERFACE target)
get_filename_component(_cpp_bindings_dir
# Reuse the timer + echo cpp_bindings (exposing my_timer_headers / echo_headers)
get_filename_component(_timer_cpp_bindings_dir
"${CMAKE_CURRENT_SOURCE_DIR}/../../../examples/timer/cpp_bindings"
ABSOLUTE)
add_subdirectory("${_cpp_bindings_dir}" cpp_bindings_build)
add_subdirectory("${_timer_cpp_bindings_dir}" timer_cpp_bindings_build)
get_filename_component(_echo_cpp_bindings_dir
"${CMAKE_CURRENT_SOURCE_DIR}/../../../examples/echo/cpp_bindings"
ABSOLUTE)
add_subdirectory("${_echo_cpp_bindings_dir}" echo_cpp_bindings_build)
# GoogleTest via FetchContent
include(FetchContent)
@ -29,32 +33,34 @@ FetchContent_MakeAvailable(googletest)
enable_testing()
add_executable(timer_e2e_tests test_timer_e2e.cpp)
target_link_libraries(timer_e2e_tests PRIVATE my_timer_headers GTest::gtest_main)
add_dependencies(timer_e2e_tests nim_lib)
target_link_libraries(timer_e2e_tests PRIVATE my_timer_headers echo_headers GTest::gtest_main)
add_dependencies(timer_e2e_tests my_timer_nim_lib echo_nim_lib)
if(NIM_FFI_SAN_CFLAGS)
target_compile_options(timer_e2e_tests PRIVATE ${NIM_FFI_SAN_CFLAGS})
target_link_options(timer_e2e_tests PRIVATE ${NIM_FFI_SAN_LFLAGS})
endif()
# The Nim-built shared library has install_name `@rpath/libmy_timer.dylib`
# (set by `declareLibrary` on macOS for portability). The test binary must
# therefore know where to find that dylib at load time embed the build-tree
# directory of the IMPORTED `my_timer` target as an rpath. On Windows the
# `my_timer` target is a plain INTERFACE library (no IMPORTED_LOCATION) and
# we stage the DLL next to the exe via POST_BUILD instead.
# Nim-built dylibs use `@rpath/lib*.dylib` install_names on macOS and Linux, so embed
# each IMPORTED target's build-tree dir as an rpath. Windows has no rpath
# stage the DLLs next to the exe in the POST_BUILD branch below instead.
if(NOT CMAKE_SYSTEM_NAME STREQUAL "Windows")
get_target_property(_my_timer_loc my_timer IMPORTED_LOCATION)
get_filename_component(_my_timer_dir "${_my_timer_loc}" DIRECTORY)
get_target_property(_echo_loc echo IMPORTED_LOCATION)
get_filename_component(_echo_dir "${_echo_loc}" DIRECTORY)
set_target_properties(timer_e2e_tests PROPERTIES
BUILD_RPATH "${_my_timer_dir}"
INSTALL_RPATH "${_my_timer_dir}")
BUILD_RPATH "${_my_timer_dir};${_echo_dir}"
INSTALL_RPATH "${_my_timer_dir};${_echo_dir}")
else()
add_custom_command(TARGET timer_e2e_tests POST_BUILD
COMMAND "${CMAKE_COMMAND}" -E copy_if_different
"${my_timer_RUNTIME_LIB}"
"$<TARGET_FILE_DIR:timer_e2e_tests>"
COMMENT "Staging my_timer.dll next to timer_e2e_tests.exe")
COMMAND "${CMAKE_COMMAND}" -E copy_if_different
"${echo_RUNTIME_LIB}"
"$<TARGET_FILE_DIR:timer_e2e_tests>"
COMMENT "Staging my_timer.dll + echo.dll next to timer_e2e_tests.exe")
endif()
# Per-sanitizer runtime options: halt and exit non-zero on any report so

View File

@ -3,24 +3,31 @@
These tests validate that a Nim FFI library exported with `nim-ffi`'s C++
codegen is usable from a real C++ consumer. They drive the `my_timer` example
through its auto-generated `my_timer.hpp` bindings (constructor, sync method,
async methods, complex types with optional fields, multiple contexts) and
assert the round-tripped values.
async methods, complex types with optional fields, multiple contexts, error
propagation, async pipelines, short-lived-thread stress, concurrent hammer)
and assert the round-tripped values. The `CrossLibrary` test additionally
loads `examples/echo`'s `echo.hpp` alongside the timer to prove two
independent nim-ffi libraries coexist in one process with no symbol clash
and no shared global state.
## Layout
The suite reuses the generated bindings instead of duplicating the Nim build
glue:
- `CMakeLists.txt``add_subdirectory`s `examples/timer/cpp_bindings`, which
compiles `libmy_timer` and exposes the `my_timer_headers` INTERFACE target.
Fetches GoogleTest and registers tests with CTest via `gtest_discover_tests`.
- `CMakeLists.txt``add_subdirectory`s both
`examples/timer/cpp_bindings` (compiles `libmy_timer`, exposes
`my_timer_headers`) and `examples/echo/cpp_bindings` (compiles
`libecho`, exposes `echo_headers`). Fetches GoogleTest and registers
tests with CTest via `gtest_discover_tests`.
- `test_timer_e2e.cpp` — the test cases.
## Running
```sh
# 1. Generate the C++ bindings (writes examples/timer/cpp_bindings/)
nimble genbindings_cpp
# 1. Generate the C++ bindings for both example libraries
nimble genbindings_cpp # → examples/timer/cpp_bindings/
nimble genbindings_cpp_echo # → examples/echo/cpp_bindings/
# 2. Configure + build + run the tests
cmake -S tests/e2e/cpp -B tests/e2e/cpp/build

View File

@ -5,8 +5,11 @@
// the full FFI round-trip — CBOR encode -> Nim FFI thread -> chronos -> CBOR
// decode -> C++ — to validate that a binding produced by `nimble
// genbindings_cpp` is callable end-to-end from C++.
// The CrossLibrary test also loads `examples/echo/cpp_bindings` to prove
// two nim-ffi libraries can coexist in one process.
#include "my_timer.hpp"
#include "echo.hpp"
#include <atomic>
#include <chrono>
@ -132,6 +135,143 @@ TEST(TimerE2E, IndependentContextsKeepTheirOwnState) {
EXPECT_EQ(rB.timerName, "beta");
}
// N contexts keep independent state; an error on one must not poison siblings.
// Empty JobSpec.name is the chosen error trigger: schedule() returns
// err("job name must not be empty"), which the bindings rethrow as
// std::runtime_error carrying the exact string.
TEST(TimerE2E, MultiContextIsolation) {
constexpr int kCtxCount = 5;
std::vector<std::unique_ptr<MyTimerCtx>> ctxs;
ctxs.reserve(kCtxCount);
for (int i = 0; i < kCtxCount; ++i) {
ctxs.push_back(makeCtx("iso-" + std::to_string(i)));
}
for (int i = 0; i < kCtxCount; ++i) {
const auto resp = ctxs[i]->echo(EchoRequest{"ping", 0});
EXPECT_EQ(resp.echoed, "ping");
EXPECT_EQ(resp.timerName, "iso-" + std::to_string(i));
}
const auto bad = JobSpec{/*name*/ "", /*payload*/ {}, /*priority*/ 0};
const auto retry = RetryPolicy{1, 10, {}};
const auto sched = ScheduleConfig{0, 0, std::nullopt};
try {
(void)ctxs[2]->schedule(bad, retry, sched);
FAIL() << "expected schedule() to throw on empty job name";
} catch (const std::runtime_error& ex) {
EXPECT_STREQ(ex.what(), "job name must not be empty");
}
const auto recovered = ctxs[2]->echo(EchoRequest{"after-err", 0});
EXPECT_EQ(recovered.echoed, "after-err");
EXPECT_EQ(recovered.timerName, "iso-2");
for (int i = 0; i < kCtxCount; ++i) {
if (i == 2) continue;
const auto resp = ctxs[i]->echo(EchoRequest{"still-here", 0});
EXPECT_EQ(resp.echoed, "still-here");
EXPECT_EQ(resp.timerName, "iso-" + std::to_string(i));
}
}
// Two nim-ffi libraries in one process must not share state or symbols.
TEST(TimerE2E, CrossLibrary) {
auto timerCtx = MyTimerCtx::create(TimerConfig{"x-timer"});
auto echoCtx = EchoCtx::create(EchoConfig{"X-ECHO"});
EXPECT_EQ(timerCtx->version(), "nim-timer v0.1.0");
EXPECT_EQ(echoCtx->version(), "nim-echo v0.1.0");
const auto timerResp = timerCtx->echo(EchoRequest{"hello", 0});
EXPECT_EQ(timerResp.echoed, "hello");
EXPECT_EQ(timerResp.timerName, "x-timer");
const auto echoResp = echoCtx->shout(ShoutRequest{"hello"});
EXPECT_EQ(echoResp.shouted, "X-ECHO: HELLO");
EXPECT_EQ(echoResp.prefix, "X-ECHO");
for (int i = 0; i < 4; ++i) {
const auto t = timerCtx->echo(EchoRequest{"t" + std::to_string(i), 0});
const auto e = echoCtx->shout(ShoutRequest{"e" + std::to_string(i)});
EXPECT_EQ(t.timerName, "x-timer");
EXPECT_EQ(e.prefix, "X-ECHO");
}
auto tFut = timerCtx->echoAsync(EchoRequest{"async-t", 30});
auto eFut = echoCtx->shoutAsync(ShoutRequest{"async-e"});
const auto t = tFut.get();
const auto e = eFut.get();
EXPECT_EQ(t.echoed, "async-t");
EXPECT_EQ(t.timerName, "x-timer");
EXPECT_EQ(e.shouted, "X-ECHO: ASYNC-E");
}
// Chained async calls A->B->C must preserve ordering and payload across hops.
TEST(TimerE2E, TriplePipeline) {
auto ctx = makeCtx("pipeline");
auto pipeline = std::async(std::launch::async, [&ctx]() {
auto a = ctx->echoAsync(EchoRequest{"A", 20}).get();
auto b = ctx->echoAsync(EchoRequest{a.echoed + "->B", 10}).get();
auto c = ctx->echoAsync(EchoRequest{b.echoed + "->C", 5}).get();
return c;
});
const auto final = pipeline.get();
EXPECT_EQ(final.echoed, "A->B->C");
EXPECT_EQ(final.timerName, "pipeline");
}
// Per-thread context create -> one call -> destroy churns the FFI context pool.
TEST(TimerE2E, StressShortLivedPerThreadContext) {
constexpr int kThreads = 16;
std::vector<std::thread> workers;
std::atomic<int> errors{0};
workers.reserve(kThreads);
for (int t = 0; t < kThreads; ++t) {
workers.emplace_back([&, t] {
try {
auto ctx = makeCtx("short-" + std::to_string(t));
const auto resp = ctx->echo(EchoRequest{"hi", 0});
if (resp.echoed != "hi") ++errors;
if (resp.timerName != "short-" + std::to_string(t)) ++errors;
} catch (const std::exception&) {
++errors;
}
});
}
for (auto& w : workers) w.join();
EXPECT_EQ(errors.load(), 0);
}
// Many short-lived threads, one shared context: exercises the multi-producer
// SPSC request-queue path (where TSan would catch producer-side races).
TEST(TimerE2E, StressShortLivedSharedContext) {
constexpr int kThreads = 32;
auto shared = makeCtx("shared-short");
std::vector<std::thread> workers;
std::atomic<int> errors{0};
workers.reserve(kThreads);
for (int t = 0; t < kThreads; ++t) {
workers.emplace_back([&, t] {
try {
const auto resp = shared->echo(EchoRequest{"x" + std::to_string(t), 0});
if (resp.echoed != "x" + std::to_string(t)) ++errors;
if (resp.timerName != "shared-short") ++errors;
} catch (const std::exception&) {
++errors;
}
});
}
for (auto& w : workers) w.join();
EXPECT_EQ(errors.load(), 0);
}
// Concurrency workload for ThreadSanitizer: many threads hammering both a
// shared context (multi-producer into the same SPSC request queue — where
// producer-side races would live) and per-thread contexts (validates