diff --git a/examples/echo/cpp_bindings/CMakeLists.txt b/examples/echo/cpp_bindings/CMakeLists.txt new file mode 100644 index 0000000..4f605cf --- /dev/null +++ b/examples/echo/cpp_bindings/CMakeLists.txt @@ -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_` 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_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}" + "$" + COMMENT "Staging echo.dll next to echo_example.exe") + endif() +endif() diff --git a/examples/echo/cpp_bindings/echo.hpp b/examples/echo/cpp_bindings/echo.hpp new file mode 100644 index 0000000..d864346 --- /dev/null +++ b/examples/echo/cpp_bindings/echo.hpp @@ -0,0 +1,473 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +extern "C" { +#include +} + +// ── 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(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 +inline CborError encode_cbor(CborEncoder& e, const std::vector& 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 +inline CborError encode_cbor(CborEncoder& e, const std::optional& 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(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(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 +inline CborError decode_cbor(CborValue& it, std::vector& 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 +inline CborError decode_cbor(CborValue& it, std::optional& 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 +inline std::vector encodeCborFFI(const T& value) { + // Start with a generous 4 KiB buffer; double on overflow until it fits. + std::vector 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 +inline T decodeCborFFI(const std::vector& 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 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> handle( + static_cast*>(ud)); + FFICallState_& s = **handle; + + std::lock_guard lock(s.mtx); + s.ok = (ret == 0); + if (msg && len > 0) { + const auto* p = reinterpret_cast(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 ffi_call_(std::function f, + std::chrono::milliseconds timeout) { + auto state = std::make_shared(); + auto* cb_ref = new std::shared_ptr(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 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 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(ffi_raw_); + try { + const auto addr = std::stoull(addr_str); + return std::unique_ptr(new EchoCtx(reinterpret_cast(static_cast(addr)), timeout)); + } catch (const std::exception&) { + throw std::runtime_error("FFI create returned non-numeric address: " + addr_str); + } + } + + static std::future> 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. 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(ffi_raw_); + } + + std::future 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(ffi_raw_); + } + + std::future 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) {} +}; diff --git a/examples/echo/echo.nim b/examples/echo/echo.nim new file mode 100644 index 0000000..b031925 --- /dev/null +++ b/examples/echo/echo.nim @@ -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() diff --git a/examples/echo/echo.nimble b/examples/echo/echo.nimble new file mode 100644 index 0000000..7dd665a --- /dev/null +++ b/examples/echo/echo.nimble @@ -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" diff --git a/examples/timer/cddl_bindings/timer.cddl b/examples/timer/cddl_bindings/my_timer.cddl similarity index 55% rename from examples/timer/cddl_bindings/timer.cddl rename to examples/timer/cddl_bindings/my_timer.cddl index cf5517c..295441f 100644 --- a/examples/timer/cddl_bindings/timer.cddl +++ b/examples/timer/cddl_bindings/my_timer.cddl @@ -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 diff --git a/examples/timer/cpp_bindings/CMakeLists.txt b/examples/timer/cpp_bindings/CMakeLists.txt index c08e11e..607a2b1 100644 --- a/examples/timer/cpp_bindings/CMakeLists.txt +++ b/examples/timer/cpp_bindings/CMakeLists.txt @@ -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_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_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}" - "$" - COMMENT "Staging my_timer.dll next to example.exe") + "$" + COMMENT "Staging my_timer.dll next to my_timer_example.exe") endif() endif() diff --git a/examples/timer/cpp_bindings/my_timer.hpp b/examples/timer/cpp_bindings/my_timer.hpp index f707626..2c5d9a3 100644 --- a/examples/timer/cpp_bindings/my_timer.hpp +++ b/examples/timer/cpp_bindings/my_timer.hpp @@ -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& 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 ffi_call_(std::function - "${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_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}" - "$" - COMMENT "Staging {{LIB}}.dll next to example.exe") + "$" + COMMENT "Staging {{LIB}}.dll next to {{LIB}}_example.exe") endif() endif() diff --git a/ffi/codegen/templates/cpp/cbor_helpers.hpp.tpl b/ffi/codegen/templates/cpp/cbor_helpers.hpp.tpl index 9212775..a3a3574 100644 --- a/ffi/codegen/templates/cpp/cbor_helpers.hpp.tpl +++ b/ffi/codegen/templates/cpp/cbor_helpers.hpp.tpl @@ -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& bytes) { } return out; } + +#endif // NIM_FFI_CBOR_HELPERS_HPP_INCLUDED diff --git a/ffi/codegen/templates/cpp/sync_call_helper.hpp.tpl b/ffi/codegen/templates/cpp/sync_call_helper.hpp.tpl index 574aac4..affd5f8 100644 --- a/ffi/codegen/templates/cpp/sync_call_helper.hpp.tpl +++ b/ffi/codegen/templates/cpp/sync_call_helper.hpp.tpl @@ -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 ffi_call_(std::functionNimMain` once exactly, - ## to initialize the Nim runtime. - ## Being `` the value given in the optional - ## compilation flag --nimMainPrefix:yourprefix + ## Every Nim library needs to call `NimMain` once exactly, + ## to initialize the Nim runtime. + ## Being `` 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): diff --git a/tests/e2e/cpp/CMakeLists.txt b/tests/e2e/cpp/CMakeLists.txt index 9742a87..7aa3959 100644 --- a/tests/e2e/cpp/CMakeLists.txt +++ b/tests/e2e/cpp/CMakeLists.txt @@ -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}" "$" - COMMENT "Staging my_timer.dll next to timer_e2e_tests.exe") + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${echo_RUNTIME_LIB}" + "$" + 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 diff --git a/tests/e2e/cpp/README.md b/tests/e2e/cpp/README.md index e26a656..4376af8 100644 --- a/tests/e2e/cpp/README.md +++ b/tests/e2e/cpp/README.md @@ -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 diff --git a/tests/e2e/cpp/test_timer_e2e.cpp b/tests/e2e/cpp/test_timer_e2e.cpp index 8119175..147d600 100644 --- a/tests/e2e/cpp/test_timer_e2e.cpp +++ b/tests/e2e/cpp/test_timer_e2e.cpp @@ -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 #include @@ -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> 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 workers; + std::atomic 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 workers; + std::atomic 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