From a7cba1a4a305e1dc27db2c5602bae108b7cde4f9 Mon Sep 17 00:00:00 2001 From: Gabriel Cruz Date: Thu, 9 Jul 2026 15:06:42 -0300 Subject: [PATCH] feat: generated sync wrappers --- .github/workflows/ci.yml | 18 + .github/workflows/tests-sanitized.yml | 3 + .gitignore | 1 + examples/echo/c_bindings/CMakeLists.txt | 6 +- examples/echo/c_bindings/echo.h | 163 +++++++++ examples/echo/c_bindings/nim_ffi_sync.h | 193 +++++++++++ examples/timer/c_bindings/CMakeLists.txt | 6 +- examples/timer/c_bindings/README.md | 49 ++- examples/timer/c_bindings/main.c | 195 +++-------- examples/timer/c_bindings/my_timer.h | 267 ++++++++++++++ examples/timer/c_bindings/nim_ffi_sync.h | 193 +++++++++++ ffi.nimble | 8 + ffi/codegen/c.nim | 200 ++++++++++- ffi/codegen/templates/c/CMakeLists.txt.tpl | 6 +- .../templates/c/sync_call_helper.h.tpl | 192 +++++++++++ tests/e2e/c/test_timer_e2e.c | 148 +++++++- tests/e2e/rust/Cargo.lock | 230 ++++++++++++ tests/e2e/rust/Cargo.toml | 15 + tests/e2e/rust/README.md | 28 ++ tests/e2e/rust/build.rs | 22 ++ tests/e2e/rust/tests/e2e.rs | 326 ++++++++++++++++++ tests/unit/test_c_codegen.nim | 25 +- 22 files changed, 2115 insertions(+), 179 deletions(-) create mode 100644 examples/echo/c_bindings/nim_ffi_sync.h create mode 100644 examples/timer/c_bindings/nim_ffi_sync.h create mode 100644 ffi/codegen/templates/c/sync_call_helper.h.tpl create mode 100644 tests/e2e/rust/Cargo.lock create mode 100644 tests/e2e/rust/Cargo.toml create mode 100644 tests/e2e/rust/README.md create mode 100644 tests/e2e/rust/build.rs create mode 100644 tests/e2e/rust/tests/e2e.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd25502..733acaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,9 @@ jobs: nimble-version: ${{ needs.versions.outputs.nimble }} cpp-e2e: + # Native-binding e2e across the OS matrix. C++ runs on every OS; the C and + # Rust suites (added below) run Linux-only, so the job id stays `cpp-e2e` + # for the required-check name even though it now covers all three backends. # Codegen output doesn't vary with mm, so we matrix over OS and Nim only. # Windows runs MSVC by default and may surface codegen tweaks needed in # the generated CMake (e.g. /EHsc, dllexport) — track follow-ups as bugs @@ -176,6 +179,21 @@ jobs: shell: bash run: nimble test_c_abi_e2e -y + # CBOR C bindings: exercises the async callback API and the blocking + # `_sync` wrappers. Linux-only (codegen is platform-checked by + # check-bindings; one OS is enough for the runtime round-trip). + - name: Run C e2e tests + if: matrix.label == 'Linux' + shell: bash + run: nimble test_c_e2e -y + + # Rust bindings: drives the generated blocking + tokio-async wrappers + # through the full FFI round-trip. Linux-only for the same reason. + - name: Run Rust e2e tests + if: matrix.label == 'Linux' + shell: bash + run: nimble test_rust_e2e -y + check-bindings: # Single OS is enough — codegen output is platform-independent; the Nim # matrix catches version-sensitive output (the PR #39 drift class). diff --git a/.github/workflows/tests-sanitized.yml b/.github/workflows/tests-sanitized.yml index b740d9f..d265cfb 100644 --- a/.github/workflows/tests-sanitized.yml +++ b/.github/workflows/tests-sanitized.yml @@ -102,5 +102,8 @@ jobs: - name: Run C++ e2e tests (${{ inputs.sanitizer }}) run: nimble test_cpp_e2e_sanitized -y + - name: Run C e2e tests (${{ inputs.sanitizer }}) + run: nimble test_c_e2e_sanitized -y + - name: Run abi=c C e2e test (${{ inputs.sanitizer }}) run: nimble test_c_abi_e2e_sanitized -y diff --git a/.gitignore b/.gitignore index b8dbe58..69316f4 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ examples/**/cpp_bindings/build/ # Cargo build artifacts (rust clients / harnesses) examples/**/rust_client/target/ +tests/e2e/rust/target/ # Development plans (local only — match PLAN.md and any `*-plan.md` notes) PLAN.md diff --git a/examples/echo/c_bindings/CMakeLists.txt b/examples/echo/c_bindings/CMakeLists.txt index bdc4246..d42d73f 100644 --- a/examples/echo/c_bindings/CMakeLists.txt +++ b/examples/echo/c_bindings/CMakeLists.txt @@ -28,9 +28,9 @@ find_package(Threads REQUIRED) add_library(echo_headers INTERFACE) target_include_directories(echo_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}") target_link_libraries(echo_headers INTERFACE echo tinycbor Threads::Threads) -# The generated header is async (no blocking helper), but consumer code that -# waits on a result callback typically uses nanosleep / pthreads, which need a -# POSIX feature level that strict `-std=c11` hides. Define it for consumers. +# The generated `_sync` wrappers block on a pthread condvar, and consumer code +# that polls a result callback typically uses nanosleep — both need a POSIX +# feature level that strict `-std=c11` hides. Define it for consumers. target_compile_definitions(echo_headers INTERFACE _POSIX_C_SOURCE=200809L) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.c") diff --git a/examples/echo/c_bindings/echo.h b/examples/echo/c_bindings/echo.h index d68f69d..71af6fd 100644 --- a/examples/echo/c_bindings/echo.h +++ b/examples/echo/c_bindings/echo.h @@ -1,6 +1,7 @@ #ifndef NIM_FFI_LIB_ECHO_H_INCLUDED #define NIM_FFI_LIB_ECHO_H_INCLUDED #include "nim_ffi_cbor.h" +#include "nim_ffi_sync.h" /* ============================================================ */ /* Generated types (user-declared + per-proc request envelopes) */ @@ -287,6 +288,67 @@ static inline int echo_ctx_create(const EchoConfig* config, EchoCreateFn on_crea return 0; } +static inline int echo_ctx_create_sync(const EchoConfig* config, EchoCtx** out, char* err_buf, size_t err_len, uint32_t timeout_ms) { + EchoCreateCtorReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + ffi_req.config = *config; + uint8_t* req_buf = NULL; + size_t req_len = 0; + char* enc_err = NULL; + if (nimffi_encode_to_buf(echo_encv_EchoCreateCtorReq, &ffi_req, &req_buf, &req_len, &enc_err) != 0) { + nimffi_copy_err(err_buf, err_len, enc_err ? enc_err : "encode failed"); + free(enc_err); + return -1; + } + NimFfiSyncState* st = nimffi_sync_state_new(); + if (!st) { + free(req_buf); + nimffi_copy_err(err_buf, err_len, "out of memory"); + return -1; + } + (void)echo_create(req_buf, req_len, nimffi_sync_cb, st); + free(req_buf); + if (!nimffi_sync_wait(st, timeout_ms)) { + nimffi_copy_err(err_buf, err_len, "FFI create timed out"); + nimffi_sync_state_release(st); + return -1; + } + if (!st->ok) { + nimffi_copy_err(err_buf, err_len, st->err ? st->err : "FFI create failed"); + int rc = st->ret_code ? st->ret_code : -1; + nimffi_sync_state_release(st); + return rc; + } + NimFfiStr addr; + memset(&addr, 0, sizeof(addr)); + char* dec_err = NULL; + if (nimffi_decode_from_buf(echo_decv_Str, st->bytes, st->bytes_len, &addr, &dec_err) != 0) { + nimffi_copy_err(err_buf, err_len, dec_err ? dec_err : "decode failed"); + free(dec_err); + nimffi_sync_state_release(st); + return -1; + } + char* endp = NULL; + unsigned long long a = addr.data ? strtoull(addr.data, &endp, 10) : 0; + bool ok = addr.data && addr.len > 0 && endp && *endp == '\0'; + nimffi_free_str(&addr); + if (!ok) { + nimffi_copy_err(err_buf, err_len, "FFI create returned non-numeric address"); + nimffi_sync_state_release(st); + return -1; + } + EchoCtx* c = (EchoCtx*)calloc(1, sizeof(EchoCtx)); + if (!c) { + nimffi_copy_err(err_buf, err_len, "out of memory"); + nimffi_sync_state_release(st); + return -1; + } + c->ptr = (void*)(uintptr_t)a; + *out = c; + nimffi_sync_state_release(st); + return 0; +} + static inline void echo_ctx_destroy(EchoCtx* ctx) { if (!ctx) return; if (ctx->ptr) { echo_destroy(ctx->ptr); ctx->ptr = NULL; } @@ -353,6 +415,57 @@ static inline int echo_ctx_shout(const EchoCtx* ctx, const ShoutRequest* req, Ec return 0; } +static inline int echo_ctx_shout_sync(const EchoCtx* ctx, const ShoutRequest* req, ShoutResponse* out, char* err_buf, size_t err_len, uint32_t timeout_ms) { + EchoShoutReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + ffi_req.req = *req; + uint8_t* req_buf = NULL; + size_t req_len = 0; + char* enc_err = NULL; + if (nimffi_encode_to_buf(echo_encv_EchoShoutReq, &ffi_req, &req_buf, &req_len, &enc_err) != 0) { + nimffi_copy_err(err_buf, err_len, enc_err ? enc_err : "encode failed"); + free(enc_err); + return -1; + } + NimFfiSyncState* st = nimffi_sync_state_new(); + if (!st) { + free(req_buf); + nimffi_copy_err(err_buf, err_len, "out of memory"); + return -1; + } + int ret = echo_shout(ctx->ptr, nimffi_sync_cb, st, req_buf, req_len); + free(req_buf); + if (ret == NIMFFI_RET_MISSING_CALLBACK) { + nimffi_copy_err(err_buf, err_len, "RET_MISSING_CALLBACK (internal error)"); + nimffi_sync_state_release(st); + nimffi_sync_state_release(st); + return -1; + } + if (!nimffi_sync_wait(st, timeout_ms)) { + nimffi_copy_err(err_buf, err_len, "FFI call timed out"); + nimffi_sync_state_release(st); + return -1; + } + if (!st->ok) { + nimffi_copy_err(err_buf, err_len, st->err ? st->err : "FFI call failed"); + int rc = st->ret_code ? st->ret_code : -1; + nimffi_sync_state_release(st); + return rc; + } + memset(out, 0, sizeof(*out)); + char* dec_err = NULL; + int dec = nimffi_decode_from_buf(echo_decv_ShoutResponse, st->bytes, st->bytes_len, out, &dec_err); + if (dec != 0) { + nimffi_copy_err(err_buf, err_len, dec_err ? dec_err : "decode failed"); + free(dec_err); + echo_free_ShoutResponse(out); + nimffi_sync_state_release(st); + return -1; + } + nimffi_sync_state_release(st); + return 0; +} + typedef void (*EchoVersionReplyFn)(int err_code, const NimFfiStr* reply, const char* err_msg, void* user_data); typedef struct { EchoVersionReplyFn fn; void* user_data; } EchoVersionCallBox; static void echo_version_reply_trampoline(int ret, const char* msg, size_t len, void* ud) { @@ -412,4 +525,54 @@ static inline int echo_ctx_version(const EchoCtx* ctx, EchoVersionReplyFn on_rep return 0; } +static inline int echo_ctx_version_sync(const EchoCtx* ctx, NimFfiStr* out, char* err_buf, size_t err_len, uint32_t timeout_ms) { + EchoVersionReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + uint8_t* req_buf = NULL; + size_t req_len = 0; + char* enc_err = NULL; + if (nimffi_encode_to_buf(echo_encv_EchoVersionReq, &ffi_req, &req_buf, &req_len, &enc_err) != 0) { + nimffi_copy_err(err_buf, err_len, enc_err ? enc_err : "encode failed"); + free(enc_err); + return -1; + } + NimFfiSyncState* st = nimffi_sync_state_new(); + if (!st) { + free(req_buf); + nimffi_copy_err(err_buf, err_len, "out of memory"); + return -1; + } + int ret = echo_version(ctx->ptr, nimffi_sync_cb, st, req_buf, req_len); + free(req_buf); + if (ret == NIMFFI_RET_MISSING_CALLBACK) { + nimffi_copy_err(err_buf, err_len, "RET_MISSING_CALLBACK (internal error)"); + nimffi_sync_state_release(st); + nimffi_sync_state_release(st); + return -1; + } + if (!nimffi_sync_wait(st, timeout_ms)) { + nimffi_copy_err(err_buf, err_len, "FFI call timed out"); + nimffi_sync_state_release(st); + return -1; + } + if (!st->ok) { + nimffi_copy_err(err_buf, err_len, st->err ? st->err : "FFI call failed"); + int rc = st->ret_code ? st->ret_code : -1; + nimffi_sync_state_release(st); + return rc; + } + memset(out, 0, sizeof(*out)); + char* dec_err = NULL; + int dec = nimffi_decode_from_buf(echo_decv_Str, st->bytes, st->bytes_len, out, &dec_err); + if (dec != 0) { + nimffi_copy_err(err_buf, err_len, dec_err ? dec_err : "decode failed"); + free(dec_err); + nimffi_free_str(out); + nimffi_sync_state_release(st); + return -1; + } + nimffi_sync_state_release(st); + return 0; +} + #endif /* NIM_FFI_LIB_ECHO_H_INCLUDED */ diff --git a/examples/echo/c_bindings/nim_ffi_sync.h b/examples/echo/c_bindings/nim_ffi_sync.h new file mode 100644 index 0000000..ac5f5c5 --- /dev/null +++ b/examples/echo/c_bindings/nim_ffi_sync.h @@ -0,0 +1,193 @@ +#ifndef NIM_FFI_SYNC_HELPER_H_INCLUDED +#define NIM_FFI_SYNC_HELPER_H_INCLUDED +/* Blocking-call helper shared by the generated _ctx__sync wrappers. + * Turns the async callback ABI into a synchronous request/reply: submit the + * call, block on a condition variable until its single callback fires (or the + * timeout elapses), then hand the raw CBOR payload / error text back so the + * generated wrapper can decode it into a caller-owned out-param. + * + * The state is heap-allocated and reference-counted (2: the waiter and the + * callback). A callback that fires after the caller has already timed out thus + * writes into a still-live object and drops the last reference cleanly — no + * use-after-free, no leak. Uses pthreads on POSIX and SRWLOCK/CONDITION_VARIABLE + * on Win32, the same platform split the example programs use. */ +#include "nim_ffi_cbor.h" + +#if defined(_WIN32) +# include +#else +# include +# include +# include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { +#if defined(_WIN32) + SRWLOCK lock; + CONDITION_VARIABLE cv; +#else + pthread_mutex_t mtx; + pthread_cond_t cv; +#endif + int done; + int ok; + int ret_code; /* raw `ret` from the callback (non-zero on error) */ + uint8_t* bytes; /* owned copy of the CBOR reply payload on success */ + size_t bytes_len; + char* err; /* owned NUL-terminated error text on failure */ + int refs; /* guarded by lock/mtx; the release reaching 0 frees */ +} NimFfiSyncState; + +static inline NimFfiSyncState* nimffi_sync_state_new(void) { + NimFfiSyncState* s = (NimFfiSyncState*)calloc(1, sizeof(NimFfiSyncState)); + if (!s) { + return NULL; + } + s->refs = 2; +#if defined(_WIN32) + InitializeSRWLock(&s->lock); + InitializeConditionVariable(&s->cv); +#else + if (pthread_mutex_init(&s->mtx, NULL) != 0) { + free(s); + return NULL; + } + if (pthread_cond_init(&s->cv, NULL) != 0) { + pthread_mutex_destroy(&s->mtx); + free(s); + return NULL; + } +#endif + return s; +} + +static inline void nimffi_sync_state_release(NimFfiSyncState* s) { + if (!s) { + return; + } + int r; +#if defined(_WIN32) + AcquireSRWLockExclusive(&s->lock); + r = --s->refs; + ReleaseSRWLockExclusive(&s->lock); +#else + pthread_mutex_lock(&s->mtx); + r = --s->refs; + pthread_mutex_unlock(&s->mtx); +#endif + if (r != 0) { + return; + } +#if !defined(_WIN32) + pthread_mutex_destroy(&s->mtx); + pthread_cond_destroy(&s->cv); +#endif + free(s->bytes); + free(s->err); + free(s); +} + +/* FFICallback-conforming sink: copies the reply/error out of the borrowed + * msg/len buffer (owned by the binding only for this call), wakes the waiter, + * then drops the callback's reference. */ +static inline void nimffi_sync_cb(int ret, const char* msg, size_t len, void* ud) { + NimFfiSyncState* s = (NimFfiSyncState*)ud; +#if defined(_WIN32) + AcquireSRWLockExclusive(&s->lock); +#else + pthread_mutex_lock(&s->mtx); +#endif + s->ret_code = ret; + s->ok = (ret == 0); + if (msg && len > 0) { + if (s->ok) { + uint8_t* p = (uint8_t*)malloc(len); + if (p) { + memcpy(p, msg, len); + s->bytes = p; + s->bytes_len = len; + } + } else { + s->err = nimffi_dup_cstr_n(msg, len); + } + } + s->done = 1; +#if defined(_WIN32) + WakeConditionVariable(&s->cv); + ReleaseSRWLockExclusive(&s->lock); +#else + pthread_cond_signal(&s->cv); + pthread_mutex_unlock(&s->mtx); +#endif + nimffi_sync_state_release(s); +} + +/* Blocks until the callback marks the state done or `timeout_ms` elapses. + * Returns 1 if the reply landed, 0 on timeout. The deadline is absolute so a + * spurious wakeup cannot extend the wait past the requested budget. */ +static inline int nimffi_sync_wait(NimFfiSyncState* s, uint32_t timeout_ms) { + int done; +#if defined(_WIN32) + ULONGLONG start = GetTickCount64(); + AcquireSRWLockExclusive(&s->lock); + while (!s->done) { + ULONGLONG elapsed = GetTickCount64() - start; + if (elapsed >= timeout_ms) { + break; + } + if (!SleepConditionVariableSRW(&s->cv, &s->lock, + (DWORD)(timeout_ms - elapsed), 0)) { + if (GetLastError() == ERROR_TIMEOUT) { + break; + } + } + } + done = s->done; + ReleaseSRWLockExclusive(&s->lock); +#else + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + deadline.tv_sec += (time_t)(timeout_ms / 1000u); + deadline.tv_nsec += (long)(timeout_ms % 1000u) * 1000000L; + if (deadline.tv_nsec >= 1000000000L) { + deadline.tv_sec += 1; + deadline.tv_nsec -= 1000000000L; + } + pthread_mutex_lock(&s->mtx); + while (!s->done) { + if (pthread_cond_timedwait(&s->cv, &s->mtx, &deadline) == ETIMEDOUT) { + break; + } + } + done = s->done; + pthread_mutex_unlock(&s->mtx); +#endif + return done; +} + +/* Bounded copy of `src` into `buf`, always NUL-terminating when err_len > 0. */ +static inline void nimffi_copy_err(char* buf, size_t err_len, const char* src) { + if (!buf || err_len == 0) { + return; + } + if (!src) { + src = ""; + } + size_t n = strlen(src); + if (n >= err_len) { + n = err_len - 1; + } + memcpy(buf, src, n); + buf[n] = '\0'; +} + +#ifdef __cplusplus +} +#endif + +#endif /* NIM_FFI_SYNC_HELPER_H_INCLUDED */ + diff --git a/examples/timer/c_bindings/CMakeLists.txt b/examples/timer/c_bindings/CMakeLists.txt index 016a04c..ab6f68d 100644 --- a/examples/timer/c_bindings/CMakeLists.txt +++ b/examples/timer/c_bindings/CMakeLists.txt @@ -28,9 +28,9 @@ find_package(Threads REQUIRED) 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 Threads::Threads) -# The generated header is async (no blocking helper), but consumer code that -# waits on a result callback typically uses nanosleep / pthreads, which need a -# POSIX feature level that strict `-std=c11` hides. Define it for consumers. +# The generated `_sync` wrappers block on a pthread condvar, and consumer code +# that polls a result callback typically uses nanosleep — both need a POSIX +# feature level that strict `-std=c11` hides. Define it for consumers. target_compile_definitions(my_timer_headers INTERFACE _POSIX_C_SOURCE=200809L) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.c") diff --git a/examples/timer/c_bindings/README.md b/examples/timer/c_bindings/README.md index 9b42d2e..32e5c24 100644 --- a/examples/timer/c_bindings/README.md +++ b/examples/timer/c_bindings/README.md @@ -6,6 +6,9 @@ This folder contains **auto-generated C bindings** for the `my_timer` Nim library. It is generated from `../timer.nim` and provides: - `my_timer.h`: header-only C binding (`MyTimerCtx` + `my_timer_ctx_*` API) +- `nim_ffi_prelude.h` / `nim_ffi_cbor.h` / `nim_ffi_sync.h`: shared, library- + agnostic headers (owned string/byte types, leaf CBOR codecs, and the blocking + `_sync` call helper) - `main.c`: example executable demonstrating how to use the bindings - `CMakeLists.txt`: build configuration that compiles the Nim library, the vendored TinyCBOR, and the C example @@ -36,12 +39,34 @@ cmake --build build ./build/my_timer_example ``` +## Blocking API (`_sync`) + +Every method and the constructor also get a blocking `_sync` variant. It +submits the request, waits up to `timeout_ms` for the reply, deep-copies the +result into a caller-owned out-param and returns 0 — or fills `err_buf` and +returns non-zero. This is what `main.c` uses: + +```c +EchoResponse resp = {0}; +char err[256]; +if (my_timer_ctx_echo_sync(ctx, &req, &resp, err, sizeof(err), 5000) != 0) { + fprintf(stderr, "echo failed: %s\n", err); + return 1; +} +printf("echoed: %s\n", resp.echoed.data); +my_timer_free_EchoResponse(&resp); /* release the owned reply */ +``` + +The out-param is yours: release any owned reply fields (strings, sequences) +with the generated `my_timer_free_()` helper — or `nimffi_free_str` for a +bare string reply. + ## Asynchronous API -Every method and the constructor take a typed **result callback** and return -immediately. The callback fires exactly once — synchronously if the request -fails to even submit, otherwise from the Nim dispatch thread when the reply -arrives: +Every method and the constructor also take a typed **result callback** and +return immediately. The callback fires exactly once — synchronously if the +request fails to even submit, otherwise from the Nim dispatch thread when the +reply arrives: ```c static void on_echo(int err_code, const EchoResponse* reply, @@ -53,18 +78,20 @@ static void on_echo(int err_code, const EchoResponse* reply, my_timer_ctx_echo(ctx, &req, on_echo, /*user_data=*/NULL); ``` -See `main.c` for the full pattern, including a small `wait_done()` poll helper -that turns each async call back into a sequential step. +Library-initiated **events** are always asynchronous — they arrive on the Nim +dispatch thread via `my_timer_ctx_add_on__listener`. ## Memory Ownership - Request-side strings/sequences are *borrowed* — wrap C strings with `nimffi_str(...)`; the binding never frees them. -- Reply values and error strings passed into a result callback are **owned by - the binding** and valid only for the duration of that callback. The caller - never frees them — copy out anything you need to keep before returning. -- A `MyTimerCtx*` delivered to the constructor callback is the exception: - ownership transfers to you, and you release it with `my_timer_ctx_destroy()`. +- A `_sync` out-param is **owned by the caller** on success — release owned + fields with the generated `my_timer_free_()` helper. +- Reply values and error strings passed into an *async* result callback are + **owned by the binding** and valid only for the duration of that callback. + The caller never frees them — copy out anything you need before returning. +- A `MyTimerCtx*` (from `_sync`'s out-param or the constructor callback) is + yours either way: release it with `my_timer_ctx_destroy()`. ## Do Not Edit diff --git a/examples/timer/c_bindings/main.c b/examples/timer/c_bindings/main.c index 0c73bbd..8d8a53d 100644 --- a/examples/timer/c_bindings/main.c +++ b/examples/timer/c_bindings/main.c @@ -2,15 +2,12 @@ #include #include -#if defined(__STDC_NO_ATOMICS__) -# error "C11 atomics required (or provide a mutex/condvar fallback)" -#endif -#include +/* Uses the generated blocking `_sync` API: each call submits, waits up to + * `timeout_ms`, deep-copies the reply into a caller-owned out-param (released + * with the generated _free_() helper) and returns 0, or fills `err` + * and returns non-zero — no hand-rolled waiters. Events still arrive async on + * the dispatch thread, so the typed listener records into globals below. */ -/* The `done` flags below are written from the library's dispatch thread and - * polled from main, so they cross a thread boundary — atomics, not `volatile`, - * give the visibility guarantee. sleep_ms wraps the platform nap so the demo - * builds on Windows too. */ #if defined(_WIN32) # include static void sleep_ms(unsigned ms) { Sleep(ms); } @@ -22,152 +19,51 @@ static void sleep_ms(unsigned ms) { } #endif -/* The generated bindings are asynchronous: each call takes a result callback - * and returns immediately. The reply and any error string handed to that - * callback are owned by the binding and valid only while the callback runs — - * the caller never frees them; it copies out whatever it wants to keep. This - * demo turns each async call back into a sequential step by polling a `done` - * flag (the same pattern the typed event listener already uses). */ +#define TIMEOUT_MS 5000 -/* Poll up to ~5s for a callback to fire. Returns false if it never did, so the - * caller can report a stuck call instead of treating it as an empty success. */ -static bool wait_done(atomic_int* done) { - for (int i = 0; i < 500 && !atomic_load(done); i++) { - sleep_ms(10); - } - return atomic_load(done) != 0; -} - -static atomic_int g_echo_count = 0; +static int g_echo_count; static char g_echo_message[256]; static void on_echo_fired(const EchoEvent* evt, void* user_data) { (void)user_data; - atomic_store(&g_echo_count, (int)evt->echoCount); + g_echo_count = (int)evt->echoCount; snprintf(g_echo_message, sizeof(g_echo_message), "%s", evt->message.data ? evt->message.data : ""); } -typedef struct { - atomic_int done; - int err_code; - MyTimerCtx* ctx; - char err[256]; -} CreateWaiter; - -static void on_created(int ec, MyTimerCtx* ctx, const char* em, void* ud) { - CreateWaiter* w = (CreateWaiter*)ud; - w->err_code = ec; - w->ctx = ctx; - if (em) snprintf(w->err, sizeof(w->err), "%s", em); - atomic_store(&w->done, 1); -} - -/* Generic reply sink: each step copies the fields it cares about out of its - * typed reply into these slots (text_a/text_b for strings, num_a/num_b for - * integers, flag for a boolean) before the binding reclaims the reply. */ -typedef struct { - atomic_int done; - int err_code; - char err[256]; - char text_a[256]; - char text_b[256]; - long long num_a; - long long num_b; - int flag; -} ReplyWaiter; - -static void on_version(int ec, const NimFfiStr* reply, const char* em, void* ud) { - ReplyWaiter* w = (ReplyWaiter*)ud; - w->err_code = ec; - if (reply && reply->data) snprintf(w->text_a, sizeof(w->text_a), "%s", reply->data); - if (em) snprintf(w->err, sizeof(w->err), "%s", em); - atomic_store(&w->done, 1); -} - -static void on_echo(int ec, const EchoResponse* reply, const char* em, void* ud) { - ReplyWaiter* w = (ReplyWaiter*)ud; - w->err_code = ec; - if (reply) { - if (reply->echoed.data) - snprintf(w->text_a, sizeof(w->text_a), "%s", reply->echoed.data); - if (reply->timerName.data) - snprintf(w->text_b, sizeof(w->text_b), "%s", reply->timerName.data); - } - if (em) snprintf(w->err, sizeof(w->err), "%s", em); - atomic_store(&w->done, 1); -} - -static void on_complex(int ec, const ComplexResponse* reply, const char* em, void* ud) { - ReplyWaiter* w = (ReplyWaiter*)ud; - w->err_code = ec; - if (reply) { - w->num_a = (long long)reply->itemCount; - w->flag = (int)reply->hasNote; - if (reply->summary.data) - snprintf(w->text_a, sizeof(w->text_a), "%s", reply->summary.data); - } - if (em) snprintf(w->err, sizeof(w->err), "%s", em); - atomic_store(&w->done, 1); -} - -static void on_schedule(int ec, const ScheduleResult* reply, const char* em, void* ud) { - ReplyWaiter* w = (ReplyWaiter*)ud; - w->err_code = ec; - if (reply) { - w->num_a = (long long)reply->willRunCount; - w->num_b = (long long)reply->firstRunAtMs; - if (reply->jobId.data) - snprintf(w->text_a, sizeof(w->text_a), "%s", reply->jobId.data); - } - if (em) snprintf(w->err, sizeof(w->err), "%s", em); - atomic_store(&w->done, 1); -} - -/* Fire an async call, block until its callback lands, and bail to cleanup on a - * timeout or error. Relies on `ctx` being in scope for that cleanup — these - * steps all run against the one context created in main(). */ -#define RUN(call, w) \ - do { \ - memset(&(w), 0, sizeof(w)); \ - call; \ - const char* run_err = NULL; \ - if (!wait_done(&(w).done)) \ - run_err = "FFI call did not complete"; \ - else if ((w).err_code != 0) \ - run_err = (w).err[0] ? (w).err : "unknown"; \ - if (run_err) { \ - fprintf(stderr, "Error: %s\n", run_err); \ - my_timer_ctx_destroy(ctx); \ - return 1; \ - } \ +/* Run a blocking `_sync` call; on failure print the error it wrote into `err`, + * tear the context down and bail. `ctx` and `err` are in scope in main(). */ +#define RUN(call) \ + do { \ + if ((call) != 0) { \ + fprintf(stderr, "Error: %s\n", err); \ + my_timer_ctx_destroy(ctx); \ + return 1; \ + } \ } while (0) int main(void) { - CreateWaiter cw; - memset(&cw, 0, sizeof(cw)); + char err[256] = {0}; + MyTimerCtx* ctx = NULL; TimerConfig config = {nimffi_str("c-demo")}; - my_timer_ctx_create(&config, on_created, &cw); - if (!wait_done(&cw.done) || cw.err_code != 0 || !cw.ctx) { - fprintf(stderr, "Error: %s\n", - cw.err[0] ? cw.err : "create did not complete"); + if (my_timer_ctx_create_sync(&config, &ctx, err, sizeof(err), TIMEOUT_MS) != 0) { + fprintf(stderr, "Error: %s\n", err); return 1; } - MyTimerCtx* ctx = cw.ctx; printf("[1] Context created\n"); - ReplyWaiter w; - RUN(my_timer_ctx_version(ctx, on_version, &w), w); - printf("[2] Version: %s\n", w.text_a); + NimFfiStr version = {0}; + RUN(my_timer_ctx_version_sync(ctx, &version, err, sizeof(err), TIMEOUT_MS)); + printf("[2] Version: %s\n", version.data ? version.data : ""); + nimffi_free_str(&version); EchoRequest echo_req = {nimffi_str("hello from C"), 50}; - RUN(my_timer_ctx_echo(ctx, &echo_req, on_echo, &w), w); - printf("[3] Echo: echoed=%s, timerName=%s\n", w.text_a, w.text_b); + EchoResponse echo = {0}; + RUN(my_timer_ctx_echo_sync(ctx, &echo_req, &echo, err, sizeof(err), TIMEOUT_MS)); + printf("[3] Echo: echoed=%s, timerName=%s\n", echo.echoed.data, echo.timerName.data); + my_timer_free_EchoResponse(&echo); - EchoRequest items[2] = { - {nimffi_str("one"), 10}, - {nimffi_str("two"), 20}, - }; + EchoRequest items[2] = {{nimffi_str("one"), 10}, {nimffi_str("two"), 20}}; NimFfiStr tags[2] = {nimffi_str("fast"), nimffi_str("c")}; ComplexRequest complex_req; complex_req.messages.data = items; @@ -179,9 +75,12 @@ int main(void) { complex_req.retries.has_value = true; complex_req.retries.value = 3; - RUN(my_timer_ctx_complex(ctx, &complex_req, on_complex, &w), w); - printf("[4] Complex: summary=%s, itemCount=%lld, hasNote=%d\n", w.text_a, w.num_a, - w.flag); + ComplexResponse complex = {0}; + RUN(my_timer_ctx_complex_sync(ctx, &complex_req, &complex, err, sizeof(err), + TIMEOUT_MS)); + printf("[4] Complex: summary=%s, itemCount=%lld, hasNote=%d\n", complex.summary.data, + (long long)complex.itemCount, (int)complex.hasNote); + my_timer_free_ComplexResponse(&complex); NimFfiStr job_payload[2] = {nimffi_str("rollup"), nimffi_str("v2")}; JobSpec job; @@ -203,21 +102,23 @@ int main(void) { schedule.jitter.has_value = true; schedule.jitter.value = 250; - RUN(my_timer_ctx_schedule(ctx, &job, &retry, &schedule, on_schedule, &w), w); + ScheduleResult sched = {0}; + RUN(my_timer_ctx_schedule_sync(ctx, &job, &retry, &schedule, &sched, err, + sizeof(err), TIMEOUT_MS)); printf("[5] Schedule: jobId=%s, willRunCount=%lld, firstRunAtMs=%lld\n", - w.text_a, w.num_a, w.num_b); + sched.jobId.data, (long long)sched.willRunCount, + (long long)sched.firstRunAtMs); + my_timer_free_ScheduleResult(&sched); - uint64_t handle = - my_timer_ctx_add_on_echo_fired_listener(ctx, on_echo_fired, NULL); + uint64_t handle = my_timer_ctx_add_on_echo_fired_listener(ctx, on_echo_fired, NULL); EchoRequest evt_req = {nimffi_str("event-demo"), 1}; - memset(&w, 0, sizeof(w)); - my_timer_ctx_echo(ctx, &evt_req, on_echo, &w); - wait_done(&w.done); + EchoResponse evt_echo = {0}; + RUN(my_timer_ctx_echo_sync(ctx, &evt_req, &evt_echo, err, sizeof(err), TIMEOUT_MS)); + my_timer_free_EchoResponse(&evt_echo); /* The event fires from the library's dispatch thread; give it a moment. */ sleep_ms(500); - printf("[6] typed event onEchoFired: message=%s, echoCount=%d\n", - g_echo_message, atomic_load(&g_echo_count)); - + printf("[6] typed event onEchoFired: message=%s, echoCount=%d\n", g_echo_message, + g_echo_count); my_timer_ctx_remove_event_listener(ctx, handle); my_timer_ctx_destroy(ctx); diff --git a/examples/timer/c_bindings/my_timer.h b/examples/timer/c_bindings/my_timer.h index 119f614..457ca1a 100644 --- a/examples/timer/c_bindings/my_timer.h +++ b/examples/timer/c_bindings/my_timer.h @@ -1,6 +1,7 @@ #ifndef NIM_FFI_LIB_MY_TIMER_H_INCLUDED #define NIM_FFI_LIB_MY_TIMER_H_INCLUDED #include "nim_ffi_cbor.h" +#include "nim_ffi_sync.h" /* ============================================================ */ /* Generated types (user-declared + per-proc request envelopes) */ @@ -894,6 +895,67 @@ static inline int my_timer_ctx_create(const TimerConfig* config, MyTimerCreateFn return 0; } +static inline int my_timer_ctx_create_sync(const TimerConfig* config, MyTimerCtx** out, char* err_buf, size_t err_len, uint32_t timeout_ms) { + MyTimerCreateCtorReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + ffi_req.config = *config; + uint8_t* req_buf = NULL; + size_t req_len = 0; + char* enc_err = NULL; + if (nimffi_encode_to_buf(my_timer_encv_MyTimerCreateCtorReq, &ffi_req, &req_buf, &req_len, &enc_err) != 0) { + nimffi_copy_err(err_buf, err_len, enc_err ? enc_err : "encode failed"); + free(enc_err); + return -1; + } + NimFfiSyncState* st = nimffi_sync_state_new(); + if (!st) { + free(req_buf); + nimffi_copy_err(err_buf, err_len, "out of memory"); + return -1; + } + (void)my_timer_create(req_buf, req_len, nimffi_sync_cb, st); + free(req_buf); + if (!nimffi_sync_wait(st, timeout_ms)) { + nimffi_copy_err(err_buf, err_len, "FFI create timed out"); + nimffi_sync_state_release(st); + return -1; + } + if (!st->ok) { + nimffi_copy_err(err_buf, err_len, st->err ? st->err : "FFI create failed"); + int rc = st->ret_code ? st->ret_code : -1; + nimffi_sync_state_release(st); + return rc; + } + NimFfiStr addr; + memset(&addr, 0, sizeof(addr)); + char* dec_err = NULL; + if (nimffi_decode_from_buf(my_timer_decv_Str, st->bytes, st->bytes_len, &addr, &dec_err) != 0) { + nimffi_copy_err(err_buf, err_len, dec_err ? dec_err : "decode failed"); + free(dec_err); + nimffi_sync_state_release(st); + return -1; + } + char* endp = NULL; + unsigned long long a = addr.data ? strtoull(addr.data, &endp, 10) : 0; + bool ok = addr.data && addr.len > 0 && endp && *endp == '\0'; + nimffi_free_str(&addr); + if (!ok) { + nimffi_copy_err(err_buf, err_len, "FFI create returned non-numeric address"); + nimffi_sync_state_release(st); + return -1; + } + MyTimerCtx* c = (MyTimerCtx*)calloc(1, sizeof(MyTimerCtx)); + if (!c) { + nimffi_copy_err(err_buf, err_len, "out of memory"); + nimffi_sync_state_release(st); + return -1; + } + c->ptr = (void*)(uintptr_t)a; + *out = c; + nimffi_sync_state_release(st); + return 0; +} + static inline void my_timer_ctx_destroy(MyTimerCtx* ctx) { if (!ctx) return; if (ctx->ptr) { my_timer_destroy(ctx->ptr); ctx->ptr = NULL; } @@ -996,6 +1058,57 @@ static inline int my_timer_ctx_echo(const MyTimerCtx* ctx, const EchoRequest* re return 0; } +static inline int my_timer_ctx_echo_sync(const MyTimerCtx* ctx, const EchoRequest* req, EchoResponse* out, char* err_buf, size_t err_len, uint32_t timeout_ms) { + MyTimerEchoReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + ffi_req.req = *req; + uint8_t* req_buf = NULL; + size_t req_len = 0; + char* enc_err = NULL; + if (nimffi_encode_to_buf(my_timer_encv_MyTimerEchoReq, &ffi_req, &req_buf, &req_len, &enc_err) != 0) { + nimffi_copy_err(err_buf, err_len, enc_err ? enc_err : "encode failed"); + free(enc_err); + return -1; + } + NimFfiSyncState* st = nimffi_sync_state_new(); + if (!st) { + free(req_buf); + nimffi_copy_err(err_buf, err_len, "out of memory"); + return -1; + } + int ret = my_timer_echo(ctx->ptr, nimffi_sync_cb, st, req_buf, req_len); + free(req_buf); + if (ret == NIMFFI_RET_MISSING_CALLBACK) { + nimffi_copy_err(err_buf, err_len, "RET_MISSING_CALLBACK (internal error)"); + nimffi_sync_state_release(st); + nimffi_sync_state_release(st); + return -1; + } + if (!nimffi_sync_wait(st, timeout_ms)) { + nimffi_copy_err(err_buf, err_len, "FFI call timed out"); + nimffi_sync_state_release(st); + return -1; + } + if (!st->ok) { + nimffi_copy_err(err_buf, err_len, st->err ? st->err : "FFI call failed"); + int rc = st->ret_code ? st->ret_code : -1; + nimffi_sync_state_release(st); + return rc; + } + memset(out, 0, sizeof(*out)); + char* dec_err = NULL; + int dec = nimffi_decode_from_buf(my_timer_decv_EchoResponse, st->bytes, st->bytes_len, out, &dec_err); + if (dec != 0) { + nimffi_copy_err(err_buf, err_len, dec_err ? dec_err : "decode failed"); + free(dec_err); + my_timer_free_EchoResponse(out); + nimffi_sync_state_release(st); + return -1; + } + nimffi_sync_state_release(st); + return 0; +} + typedef void (*MyTimerVersionReplyFn)(int err_code, const NimFfiStr* reply, const char* err_msg, void* user_data); typedef struct { MyTimerVersionReplyFn fn; void* user_data; } MyTimerVersionCallBox; static void my_timer_version_reply_trampoline(int ret, const char* msg, size_t len, void* ud) { @@ -1055,6 +1168,56 @@ static inline int my_timer_ctx_version(const MyTimerCtx* ctx, MyTimerVersionRepl return 0; } +static inline int my_timer_ctx_version_sync(const MyTimerCtx* ctx, NimFfiStr* out, char* err_buf, size_t err_len, uint32_t timeout_ms) { + MyTimerVersionReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + uint8_t* req_buf = NULL; + size_t req_len = 0; + char* enc_err = NULL; + if (nimffi_encode_to_buf(my_timer_encv_MyTimerVersionReq, &ffi_req, &req_buf, &req_len, &enc_err) != 0) { + nimffi_copy_err(err_buf, err_len, enc_err ? enc_err : "encode failed"); + free(enc_err); + return -1; + } + NimFfiSyncState* st = nimffi_sync_state_new(); + if (!st) { + free(req_buf); + nimffi_copy_err(err_buf, err_len, "out of memory"); + return -1; + } + int ret = my_timer_version(ctx->ptr, nimffi_sync_cb, st, req_buf, req_len); + free(req_buf); + if (ret == NIMFFI_RET_MISSING_CALLBACK) { + nimffi_copy_err(err_buf, err_len, "RET_MISSING_CALLBACK (internal error)"); + nimffi_sync_state_release(st); + nimffi_sync_state_release(st); + return -1; + } + if (!nimffi_sync_wait(st, timeout_ms)) { + nimffi_copy_err(err_buf, err_len, "FFI call timed out"); + nimffi_sync_state_release(st); + return -1; + } + if (!st->ok) { + nimffi_copy_err(err_buf, err_len, st->err ? st->err : "FFI call failed"); + int rc = st->ret_code ? st->ret_code : -1; + nimffi_sync_state_release(st); + return rc; + } + memset(out, 0, sizeof(*out)); + char* dec_err = NULL; + int dec = nimffi_decode_from_buf(my_timer_decv_Str, st->bytes, st->bytes_len, out, &dec_err); + if (dec != 0) { + nimffi_copy_err(err_buf, err_len, dec_err ? dec_err : "decode failed"); + free(dec_err); + nimffi_free_str(out); + nimffi_sync_state_release(st); + return -1; + } + nimffi_sync_state_release(st); + return 0; +} + typedef void (*MyTimerComplexReplyFn)(int err_code, const ComplexResponse* reply, const char* err_msg, void* user_data); typedef struct { MyTimerComplexReplyFn fn; void* user_data; } MyTimerComplexCallBox; static void my_timer_complex_reply_trampoline(int ret, const char* msg, size_t len, void* ud) { @@ -1115,6 +1278,57 @@ static inline int my_timer_ctx_complex(const MyTimerCtx* ctx, const ComplexReque return 0; } +static inline int my_timer_ctx_complex_sync(const MyTimerCtx* ctx, const ComplexRequest* req, ComplexResponse* out, char* err_buf, size_t err_len, uint32_t timeout_ms) { + MyTimerComplexReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + ffi_req.req = *req; + uint8_t* req_buf = NULL; + size_t req_len = 0; + char* enc_err = NULL; + if (nimffi_encode_to_buf(my_timer_encv_MyTimerComplexReq, &ffi_req, &req_buf, &req_len, &enc_err) != 0) { + nimffi_copy_err(err_buf, err_len, enc_err ? enc_err : "encode failed"); + free(enc_err); + return -1; + } + NimFfiSyncState* st = nimffi_sync_state_new(); + if (!st) { + free(req_buf); + nimffi_copy_err(err_buf, err_len, "out of memory"); + return -1; + } + int ret = my_timer_complex(ctx->ptr, nimffi_sync_cb, st, req_buf, req_len); + free(req_buf); + if (ret == NIMFFI_RET_MISSING_CALLBACK) { + nimffi_copy_err(err_buf, err_len, "RET_MISSING_CALLBACK (internal error)"); + nimffi_sync_state_release(st); + nimffi_sync_state_release(st); + return -1; + } + if (!nimffi_sync_wait(st, timeout_ms)) { + nimffi_copy_err(err_buf, err_len, "FFI call timed out"); + nimffi_sync_state_release(st); + return -1; + } + if (!st->ok) { + nimffi_copy_err(err_buf, err_len, st->err ? st->err : "FFI call failed"); + int rc = st->ret_code ? st->ret_code : -1; + nimffi_sync_state_release(st); + return rc; + } + memset(out, 0, sizeof(*out)); + char* dec_err = NULL; + int dec = nimffi_decode_from_buf(my_timer_decv_ComplexResponse, st->bytes, st->bytes_len, out, &dec_err); + if (dec != 0) { + nimffi_copy_err(err_buf, err_len, dec_err ? dec_err : "decode failed"); + free(dec_err); + my_timer_free_ComplexResponse(out); + nimffi_sync_state_release(st); + return -1; + } + nimffi_sync_state_release(st); + return 0; +} + typedef void (*MyTimerScheduleReplyFn)(int err_code, const ScheduleResult* reply, const char* err_msg, void* user_data); typedef struct { MyTimerScheduleReplyFn fn; void* user_data; } MyTimerScheduleCallBox; static void my_timer_schedule_reply_trampoline(int ret, const char* msg, size_t len, void* ud) { @@ -1177,4 +1391,57 @@ static inline int my_timer_ctx_schedule(const MyTimerCtx* ctx, const JobSpec* jo return 0; } +static inline int my_timer_ctx_schedule_sync(const MyTimerCtx* ctx, const JobSpec* job, const RetryPolicy* retry, const ScheduleConfig* schedule, ScheduleResult* out, char* err_buf, size_t err_len, uint32_t timeout_ms) { + MyTimerScheduleReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + ffi_req.job = *job; + ffi_req.retry = *retry; + ffi_req.schedule = *schedule; + uint8_t* req_buf = NULL; + size_t req_len = 0; + char* enc_err = NULL; + if (nimffi_encode_to_buf(my_timer_encv_MyTimerScheduleReq, &ffi_req, &req_buf, &req_len, &enc_err) != 0) { + nimffi_copy_err(err_buf, err_len, enc_err ? enc_err : "encode failed"); + free(enc_err); + return -1; + } + NimFfiSyncState* st = nimffi_sync_state_new(); + if (!st) { + free(req_buf); + nimffi_copy_err(err_buf, err_len, "out of memory"); + return -1; + } + int ret = my_timer_schedule(ctx->ptr, nimffi_sync_cb, st, req_buf, req_len); + free(req_buf); + if (ret == NIMFFI_RET_MISSING_CALLBACK) { + nimffi_copy_err(err_buf, err_len, "RET_MISSING_CALLBACK (internal error)"); + nimffi_sync_state_release(st); + nimffi_sync_state_release(st); + return -1; + } + if (!nimffi_sync_wait(st, timeout_ms)) { + nimffi_copy_err(err_buf, err_len, "FFI call timed out"); + nimffi_sync_state_release(st); + return -1; + } + if (!st->ok) { + nimffi_copy_err(err_buf, err_len, st->err ? st->err : "FFI call failed"); + int rc = st->ret_code ? st->ret_code : -1; + nimffi_sync_state_release(st); + return rc; + } + memset(out, 0, sizeof(*out)); + char* dec_err = NULL; + int dec = nimffi_decode_from_buf(my_timer_decv_ScheduleResult, st->bytes, st->bytes_len, out, &dec_err); + if (dec != 0) { + nimffi_copy_err(err_buf, err_len, dec_err ? dec_err : "decode failed"); + free(dec_err); + my_timer_free_ScheduleResult(out); + nimffi_sync_state_release(st); + return -1; + } + nimffi_sync_state_release(st); + return 0; +} + #endif /* NIM_FFI_LIB_MY_TIMER_H_INCLUDED */ diff --git a/examples/timer/c_bindings/nim_ffi_sync.h b/examples/timer/c_bindings/nim_ffi_sync.h new file mode 100644 index 0000000..ac5f5c5 --- /dev/null +++ b/examples/timer/c_bindings/nim_ffi_sync.h @@ -0,0 +1,193 @@ +#ifndef NIM_FFI_SYNC_HELPER_H_INCLUDED +#define NIM_FFI_SYNC_HELPER_H_INCLUDED +/* Blocking-call helper shared by the generated _ctx__sync wrappers. + * Turns the async callback ABI into a synchronous request/reply: submit the + * call, block on a condition variable until its single callback fires (or the + * timeout elapses), then hand the raw CBOR payload / error text back so the + * generated wrapper can decode it into a caller-owned out-param. + * + * The state is heap-allocated and reference-counted (2: the waiter and the + * callback). A callback that fires after the caller has already timed out thus + * writes into a still-live object and drops the last reference cleanly — no + * use-after-free, no leak. Uses pthreads on POSIX and SRWLOCK/CONDITION_VARIABLE + * on Win32, the same platform split the example programs use. */ +#include "nim_ffi_cbor.h" + +#if defined(_WIN32) +# include +#else +# include +# include +# include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { +#if defined(_WIN32) + SRWLOCK lock; + CONDITION_VARIABLE cv; +#else + pthread_mutex_t mtx; + pthread_cond_t cv; +#endif + int done; + int ok; + int ret_code; /* raw `ret` from the callback (non-zero on error) */ + uint8_t* bytes; /* owned copy of the CBOR reply payload on success */ + size_t bytes_len; + char* err; /* owned NUL-terminated error text on failure */ + int refs; /* guarded by lock/mtx; the release reaching 0 frees */ +} NimFfiSyncState; + +static inline NimFfiSyncState* nimffi_sync_state_new(void) { + NimFfiSyncState* s = (NimFfiSyncState*)calloc(1, sizeof(NimFfiSyncState)); + if (!s) { + return NULL; + } + s->refs = 2; +#if defined(_WIN32) + InitializeSRWLock(&s->lock); + InitializeConditionVariable(&s->cv); +#else + if (pthread_mutex_init(&s->mtx, NULL) != 0) { + free(s); + return NULL; + } + if (pthread_cond_init(&s->cv, NULL) != 0) { + pthread_mutex_destroy(&s->mtx); + free(s); + return NULL; + } +#endif + return s; +} + +static inline void nimffi_sync_state_release(NimFfiSyncState* s) { + if (!s) { + return; + } + int r; +#if defined(_WIN32) + AcquireSRWLockExclusive(&s->lock); + r = --s->refs; + ReleaseSRWLockExclusive(&s->lock); +#else + pthread_mutex_lock(&s->mtx); + r = --s->refs; + pthread_mutex_unlock(&s->mtx); +#endif + if (r != 0) { + return; + } +#if !defined(_WIN32) + pthread_mutex_destroy(&s->mtx); + pthread_cond_destroy(&s->cv); +#endif + free(s->bytes); + free(s->err); + free(s); +} + +/* FFICallback-conforming sink: copies the reply/error out of the borrowed + * msg/len buffer (owned by the binding only for this call), wakes the waiter, + * then drops the callback's reference. */ +static inline void nimffi_sync_cb(int ret, const char* msg, size_t len, void* ud) { + NimFfiSyncState* s = (NimFfiSyncState*)ud; +#if defined(_WIN32) + AcquireSRWLockExclusive(&s->lock); +#else + pthread_mutex_lock(&s->mtx); +#endif + s->ret_code = ret; + s->ok = (ret == 0); + if (msg && len > 0) { + if (s->ok) { + uint8_t* p = (uint8_t*)malloc(len); + if (p) { + memcpy(p, msg, len); + s->bytes = p; + s->bytes_len = len; + } + } else { + s->err = nimffi_dup_cstr_n(msg, len); + } + } + s->done = 1; +#if defined(_WIN32) + WakeConditionVariable(&s->cv); + ReleaseSRWLockExclusive(&s->lock); +#else + pthread_cond_signal(&s->cv); + pthread_mutex_unlock(&s->mtx); +#endif + nimffi_sync_state_release(s); +} + +/* Blocks until the callback marks the state done or `timeout_ms` elapses. + * Returns 1 if the reply landed, 0 on timeout. The deadline is absolute so a + * spurious wakeup cannot extend the wait past the requested budget. */ +static inline int nimffi_sync_wait(NimFfiSyncState* s, uint32_t timeout_ms) { + int done; +#if defined(_WIN32) + ULONGLONG start = GetTickCount64(); + AcquireSRWLockExclusive(&s->lock); + while (!s->done) { + ULONGLONG elapsed = GetTickCount64() - start; + if (elapsed >= timeout_ms) { + break; + } + if (!SleepConditionVariableSRW(&s->cv, &s->lock, + (DWORD)(timeout_ms - elapsed), 0)) { + if (GetLastError() == ERROR_TIMEOUT) { + break; + } + } + } + done = s->done; + ReleaseSRWLockExclusive(&s->lock); +#else + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + deadline.tv_sec += (time_t)(timeout_ms / 1000u); + deadline.tv_nsec += (long)(timeout_ms % 1000u) * 1000000L; + if (deadline.tv_nsec >= 1000000000L) { + deadline.tv_sec += 1; + deadline.tv_nsec -= 1000000000L; + } + pthread_mutex_lock(&s->mtx); + while (!s->done) { + if (pthread_cond_timedwait(&s->cv, &s->mtx, &deadline) == ETIMEDOUT) { + break; + } + } + done = s->done; + pthread_mutex_unlock(&s->mtx); +#endif + return done; +} + +/* Bounded copy of `src` into `buf`, always NUL-terminating when err_len > 0. */ +static inline void nimffi_copy_err(char* buf, size_t err_len, const char* src) { + if (!buf || err_len == 0) { + return; + } + if (!src) { + src = ""; + } + size_t n = strlen(src); + if (n >= err_len) { + n = err_len - 1; + } + memcpy(buf, src, n); + buf[n] = '\0'; +} + +#ifdef __cplusplus +} +#endif + +#endif /* NIM_FFI_SYNC_HELPER_H_INCLUDED */ + diff --git a/ffi.nimble b/ffi.nimble index 7988a8f..7c56fdb 100644 --- a/ffi.nimble +++ b/ffi.nimble @@ -151,6 +151,12 @@ task test_c_e2e, "Build and run the C end-to-end tests for the timer example": runOrQuit "cmake --build tests/e2e/c/build --config Debug" runOrQuit "ctest --test-dir tests/e2e/c/build --output-on-failure -C Debug" +task test_rust_e2e, "Build and run the Rust end-to-end tests for the timer example": + # Regenerate the Rust bindings so the suite always runs against fresh codegen, + # then drive both the blocking and tokio-async wrappers via `cargo test`. + runOrQuit "nimble genbindings_rust" + runOrQuit "cargo test --manifest-path tests/e2e/rust/Cargo.toml" + task test_c_abi_e2e, "Build and run the CBOR-free abi=c C end-to-end test (echo)": # Regenerate the abi=c bindings so the suite always runs against fresh codegen. runOrQuit "nimble genbindings_c_abi_echo" @@ -286,9 +292,11 @@ task check_bindings_c, "Verify checked-in C bindings match Nim source": exec "git diff --exit-code --" & " examples/timer/c_bindings/my_timer.h" & " examples/timer/c_bindings/nim_ffi_prelude.h" & " examples/timer/c_bindings/nim_ffi_cbor.h" & + " examples/timer/c_bindings/nim_ffi_sync.h" & " examples/timer/c_bindings/CMakeLists.txt" & " examples/echo/c_bindings/echo.h" & " examples/echo/c_bindings/nim_ffi_prelude.h" & " examples/echo/c_bindings/nim_ffi_cbor.h" & + " examples/echo/c_bindings/nim_ffi_sync.h" & " examples/echo/c_bindings/CMakeLists.txt" task check_bindings_c_abi, "Verify checked-in abi=c C bindings match Nim source": diff --git a/ffi/codegen/c.nim b/ffi/codegen/c.nim index f841995..df4665c 100644 --- a/ffi/codegen/c.nim +++ b/ffi/codegen/c.nim @@ -22,13 +22,15 @@ const CPtrType* = "uint64_t" const HeaderPreludeTpl = staticRead("templates/c/header_prelude.h.tpl") CborHelpersTpl = staticRead("templates/c/cbor_helpers.h.tpl") + SyncCallHelperTpl = staticRead("templates/c/sync_call_helper.h.tpl") CMakeListsTpl = staticRead("templates/c/CMakeLists.txt.tpl") # Shared headers written alongside the library header. Their names match the - # include guards baked into the templates and the `#include` the cbor header - # emits for the prelude. + # include guards baked into the templates and the `#include` each emits for + # the header it depends on. PreludeHeaderName* = "nim_ffi_prelude.h" CborHeaderName* = "nim_ffi_cbor.h" + SyncHeaderName* = "nim_ffi_sync.h" const scalarCInfoTable: array[ScalarKind, tuple[cType, suffix: string]] = [ skBool: ("bool", "bool"), @@ -447,6 +449,41 @@ proc emitReplyTrampolineHead(lines: var seq[string], tramp, boxType, fallback: s lines.add(" return;") lines.add(" }") +proc emitSyncSubmitPrologue( + lines: var seq[string], libName, reqName: string, assigns: seq[string] +) = + ## Shared opening of both `_sync` wrappers: build the per-proc Req envelope, + ## CBOR-encode it, and allocate the blocking-call state — bailing with + ## `err_buf` set on encode or allocation failure. Leaves `req_buf`/`req_len` + ## and `st` in scope for the caller to submit and wait on. + lines.add(" " & reqName & " ffi_req;") + lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));") + for a in assigns: + lines.add(a) + lines.add(" uint8_t* req_buf = NULL;") + lines.add(" size_t req_len = 0;") + lines.add(" char* enc_err = NULL;") + lines.add( + " if (nimffi_encode_to_buf(" & libName & "_encv_" & cToken(reqName) & + ", &ffi_req, &req_buf, &req_len, &enc_err) != 0) {" + ) + lines.add( + " nimffi_copy_err(err_buf, err_len, enc_err ? enc_err : \"encode failed\");" + ) + lines.add(" free(enc_err);") + lines.add(" return -1;") + lines.add(" }") + lines.add(" NimFfiSyncState* st = nimffi_sync_state_new();") + lines.add(" if (!st) {") + lines.add(" free(req_buf);") + lines.add(" nimffi_copy_err(err_buf, err_len, \"out of memory\");") + lines.add(" return -1;") + lines.add(" }") + +proc emitConstructorSync( + lines: var seq[string], reg: var CTypeReg, ctxType, libName: string, ctor: FFIProcMeta +) + proc emitConstructors( lines: var seq[string], reg: var CTypeReg, @@ -546,6 +583,85 @@ proc emitConstructors( lines.add(" return 0;") lines.add("}") lines.add("") + emitConstructorSync(lines, reg, ctxType, libName, ctor) + +proc emitConstructorSync( + lines: var seq[string], + reg: var CTypeReg, + ctxType, libName: string, + ctor: FFIProcMeta, +) = + ## Blocking companion to `_ctx_create`: submits the ctor request, waits up + ## to `timeout_ms` for the address callback, and hands the caller an owned + ## context via `*out`. Returns 0 on success; non-zero with `err_buf` filled on + ## failure. The caller releases the context with `_ctx_destroy`. + let reqName = reqStructName(ctor) + let (params, assigns) = buildReqParams(reg, ctor.extraParams) + let head = "static inline int " & libName & "_ctx_create_sync(" + let tail = ctxType & "** out, char* err_buf, size_t err_len, uint32_t timeout_ms) {" + lines.add( + if params.len > 0: + head & params.join(", ") & ", " & tail + else: + head & tail + ) + emitSyncSubmitPrologue(lines, libName, reqName, assigns) + lines.add(" (void)" & ctor.procName & "(req_buf, req_len, nimffi_sync_cb, st);") + lines.add(" free(req_buf);") + lines.add(" if (!nimffi_sync_wait(st, timeout_ms)) {") + lines.add(" nimffi_copy_err(err_buf, err_len, \"FFI create timed out\");") + lines.add(" nimffi_sync_state_release(st);") + lines.add(" return -1;") + lines.add(" }") + lines.add(" if (!st->ok) {") + lines.add( + " nimffi_copy_err(err_buf, err_len, st->err ? st->err : \"FFI create failed\");" + ) + lines.add(" int rc = st->ret_code ? st->ret_code : -1;") + lines.add(" nimffi_sync_state_release(st);") + lines.add(" return rc;") + lines.add(" }") + lines.add(" NimFfiStr addr;") + lines.add(" memset(&addr, 0, sizeof(addr));") + lines.add(" char* dec_err = NULL;") + lines.add( + " if (nimffi_decode_from_buf(" & libName & + "_decv_Str, st->bytes, st->bytes_len, &addr, &dec_err) != 0) {" + ) + lines.add( + " nimffi_copy_err(err_buf, err_len, dec_err ? dec_err : \"decode failed\");" + ) + lines.add(" free(dec_err);") + lines.add(" nimffi_sync_state_release(st);") + lines.add(" return -1;") + lines.add(" }") + lines.add(" char* endp = NULL;") + lines.add( + " unsigned long long a = addr.data ? strtoull(addr.data, &endp, 10) : 0;" + ) + lines.add(" bool ok = addr.data && addr.len > 0 && endp && *endp == '\\0';") + lines.add(" nimffi_free_str(&addr);") + lines.add(" if (!ok) {") + lines.add( + " nimffi_copy_err(err_buf, err_len, \"FFI create returned non-numeric address\");" + ) + lines.add(" nimffi_sync_state_release(st);") + lines.add(" return -1;") + lines.add(" }") + lines.add( + " " & ctxType & "* c = (" & ctxType & "*)calloc(1, sizeof(" & ctxType & "));" + ) + lines.add(" if (!c) {") + lines.add(" nimffi_copy_err(err_buf, err_len, \"out of memory\");") + lines.add(" nimffi_sync_state_release(st);") + lines.add(" return -1;") + lines.add(" }") + lines.add(" c->ptr = (void*)(uintptr_t)a;") + lines.add(" *out = c;") + lines.add(" nimffi_sync_state_release(st);") + lines.add(" return 0;") + lines.add("}") + lines.add("") proc emitDestructor( lines: var seq[string], @@ -720,6 +836,77 @@ proc emitMethod( lines.add("}") lines.add("") +proc emitMethodSync( + lines: var seq[string], reg: var CTypeReg, ctxType, libName: string, m: FFIProcMeta +) = + ## Blocking companion to `_ctx_`: submits the request, waits up to + ## `timeout_ms` for the reply, and decodes it into a caller-owned `*out`. + ## Returns 0 on success; non-zero with `err_buf` filled on failure. When the + ## reply owns heap memory the caller releases it with the generated + ## `_free_()` helper. + let stripped = stripLibPrefix(m.procName, libName) + let reqName = reqStructName(m) + let retC = cReturnType(reg, m) + let retFree = freeFn(reg, retC) + let (params, assigns) = buildReqParams(reg, m.extraParams) + + let head = + "static inline int " & libName & "_ctx_" & stripped & "_sync(const " & ctxType & + "* ctx, " + let tail = retC & "* out, char* err_buf, size_t err_len, uint32_t timeout_ms) {" + lines.add( + if params.len > 0: + head & params.join(", ") & ", " & tail + else: + head & tail + ) + emitSyncSubmitPrologue(lines, libName, reqName, assigns) + lines.add( + " int ret = " & m.procName & "(ctx->ptr, nimffi_sync_cb, st, req_buf, req_len);" + ) + lines.add(" free(req_buf);") + lines.add(" if (ret == NIMFFI_RET_MISSING_CALLBACK) {") + lines.add( + " nimffi_copy_err(err_buf, err_len, \"RET_MISSING_CALLBACK (internal error)\");" + ) + lines.add(" nimffi_sync_state_release(st);") + lines.add(" nimffi_sync_state_release(st);") + lines.add(" return -1;") + lines.add(" }") + lines.add(" if (!nimffi_sync_wait(st, timeout_ms)) {") + lines.add(" nimffi_copy_err(err_buf, err_len, \"FFI call timed out\");") + lines.add(" nimffi_sync_state_release(st);") + lines.add(" return -1;") + lines.add(" }") + lines.add(" if (!st->ok) {") + lines.add( + " nimffi_copy_err(err_buf, err_len, st->err ? st->err : \"FFI call failed\");" + ) + lines.add(" int rc = st->ret_code ? st->ret_code : -1;") + lines.add(" nimffi_sync_state_release(st);") + lines.add(" return rc;") + lines.add(" }") + lines.add(" memset(out, 0, sizeof(*out));") + lines.add(" char* dec_err = NULL;") + lines.add( + " int dec = nimffi_decode_from_buf(" & libName & "_decv_" & cToken(retC) & + ", st->bytes, st->bytes_len, out, &dec_err);" + ) + lines.add(" if (dec != 0) {") + lines.add( + " nimffi_copy_err(err_buf, err_len, dec_err ? dec_err : \"decode failed\");" + ) + lines.add(" free(dec_err);") + if retFree.len > 0: + lines.add(" " & retFree & "(out);") + lines.add(" nimffi_sync_state_release(st);") + lines.add(" return -1;") + lines.add(" }") + lines.add(" nimffi_sync_state_release(st);") + lines.add(" return 0;") + lines.add("}") + lines.add("") + proc newCTypeReg( libName, libType: string, types: seq[FFITypeMeta], procs: seq[FFIProcMeta] ): CTypeReg = @@ -769,6 +956,12 @@ func generateCCborHeader*(): string = ## agnostic, so it too is emitted verbatim. CborHelpersTpl & "\n" +func generateCSyncHeader*(): string = + ## The `nim_ffi_sync.h` shared header: the blocking-call helper backing the + ## generated `_sync` wrappers. Includes the cbor header and is library- + ## agnostic, so it is emitted verbatim. + SyncCallHelperTpl & "\n" + proc generateCLibHeader*( procs: seq[FFIProcMeta], types: seq[FFITypeMeta], @@ -791,6 +984,7 @@ proc generateCLibHeader*( lines.add("#ifndef " & guard) lines.add("#define " & guard) lines.add("#include \"" & CborHeaderName & "\"") + lines.add("#include \"" & SyncHeaderName & "\"") lines.add("") lines.add("/* ============================================================ */") @@ -869,6 +1063,7 @@ proc generateCLibHeader*( emitListenerApi(lines, ctxType, libType, libName, events) for m in methods: emitMethod(lines, reg, ctxType, libType, libName, m) + emitMethodSync(lines, reg, ctxType, libName, m) lines.add("#endif /* " & guard & " */") lines.join("\n") & "\n" @@ -888,6 +1083,7 @@ proc generateCBindings*( createDir(outputDir) writeFile(outputDir / PreludeHeaderName, generateCPreludeHeader()) writeFile(outputDir / CborHeaderName, generateCCborHeader()) + writeFile(outputDir / SyncHeaderName, generateCSyncHeader()) writeFile( outputDir / (libName & ".h"), generateCLibHeader(procs, types, libName, events) ) diff --git a/ffi/codegen/templates/c/CMakeLists.txt.tpl b/ffi/codegen/templates/c/CMakeLists.txt.tpl index 091a575..14ac795 100644 --- a/ffi/codegen/templates/c/CMakeLists.txt.tpl +++ b/ffi/codegen/templates/c/CMakeLists.txt.tpl @@ -28,9 +28,9 @@ find_package(Threads REQUIRED) add_library({{LIB}}_headers INTERFACE) target_include_directories({{LIB}}_headers INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}") target_link_libraries({{LIB}}_headers INTERFACE {{LIB}} tinycbor Threads::Threads) -# The generated header is async (no blocking helper), but consumer code that -# waits on a result callback typically uses nanosleep / pthreads, which need a -# POSIX feature level that strict `-std=c11` hides. Define it for consumers. +# The generated `_sync` wrappers block on a pthread condvar, and consumer code +# that polls a result callback typically uses nanosleep — both need a POSIX +# feature level that strict `-std=c11` hides. Define it for consumers. target_compile_definitions({{LIB}}_headers INTERFACE _POSIX_C_SOURCE=200809L) if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/main.c") diff --git a/ffi/codegen/templates/c/sync_call_helper.h.tpl b/ffi/codegen/templates/c/sync_call_helper.h.tpl new file mode 100644 index 0000000..a3838c5 --- /dev/null +++ b/ffi/codegen/templates/c/sync_call_helper.h.tpl @@ -0,0 +1,192 @@ +#ifndef NIM_FFI_SYNC_HELPER_H_INCLUDED +#define NIM_FFI_SYNC_HELPER_H_INCLUDED +/* Blocking-call helper shared by the generated _ctx__sync wrappers. + * Turns the async callback ABI into a synchronous request/reply: submit the + * call, block on a condition variable until its single callback fires (or the + * timeout elapses), then hand the raw CBOR payload / error text back so the + * generated wrapper can decode it into a caller-owned out-param. + * + * The state is heap-allocated and reference-counted (2: the waiter and the + * callback). A callback that fires after the caller has already timed out thus + * writes into a still-live object and drops the last reference cleanly — no + * use-after-free, no leak. Uses pthreads on POSIX and SRWLOCK/CONDITION_VARIABLE + * on Win32, the same platform split the example programs use. */ +#include "nim_ffi_cbor.h" + +#if defined(_WIN32) +# include +#else +# include +# include +# include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { +#if defined(_WIN32) + SRWLOCK lock; + CONDITION_VARIABLE cv; +#else + pthread_mutex_t mtx; + pthread_cond_t cv; +#endif + int done; + int ok; + int ret_code; /* raw `ret` from the callback (non-zero on error) */ + uint8_t* bytes; /* owned copy of the CBOR reply payload on success */ + size_t bytes_len; + char* err; /* owned NUL-terminated error text on failure */ + int refs; /* guarded by lock/mtx; the release reaching 0 frees */ +} NimFfiSyncState; + +static inline NimFfiSyncState* nimffi_sync_state_new(void) { + NimFfiSyncState* s = (NimFfiSyncState*)calloc(1, sizeof(NimFfiSyncState)); + if (!s) { + return NULL; + } + s->refs = 2; +#if defined(_WIN32) + InitializeSRWLock(&s->lock); + InitializeConditionVariable(&s->cv); +#else + if (pthread_mutex_init(&s->mtx, NULL) != 0) { + free(s); + return NULL; + } + if (pthread_cond_init(&s->cv, NULL) != 0) { + pthread_mutex_destroy(&s->mtx); + free(s); + return NULL; + } +#endif + return s; +} + +static inline void nimffi_sync_state_release(NimFfiSyncState* s) { + if (!s) { + return; + } + int r; +#if defined(_WIN32) + AcquireSRWLockExclusive(&s->lock); + r = --s->refs; + ReleaseSRWLockExclusive(&s->lock); +#else + pthread_mutex_lock(&s->mtx); + r = --s->refs; + pthread_mutex_unlock(&s->mtx); +#endif + if (r != 0) { + return; + } +#if !defined(_WIN32) + pthread_mutex_destroy(&s->mtx); + pthread_cond_destroy(&s->cv); +#endif + free(s->bytes); + free(s->err); + free(s); +} + +/* FFICallback-conforming sink: copies the reply/error out of the borrowed + * msg/len buffer (owned by the binding only for this call), wakes the waiter, + * then drops the callback's reference. */ +static inline void nimffi_sync_cb(int ret, const char* msg, size_t len, void* ud) { + NimFfiSyncState* s = (NimFfiSyncState*)ud; +#if defined(_WIN32) + AcquireSRWLockExclusive(&s->lock); +#else + pthread_mutex_lock(&s->mtx); +#endif + s->ret_code = ret; + s->ok = (ret == 0); + if (msg && len > 0) { + if (s->ok) { + uint8_t* p = (uint8_t*)malloc(len); + if (p) { + memcpy(p, msg, len); + s->bytes = p; + s->bytes_len = len; + } + } else { + s->err = nimffi_dup_cstr_n(msg, len); + } + } + s->done = 1; +#if defined(_WIN32) + WakeConditionVariable(&s->cv); + ReleaseSRWLockExclusive(&s->lock); +#else + pthread_cond_signal(&s->cv); + pthread_mutex_unlock(&s->mtx); +#endif + nimffi_sync_state_release(s); +} + +/* Blocks until the callback marks the state done or `timeout_ms` elapses. + * Returns 1 if the reply landed, 0 on timeout. The deadline is absolute so a + * spurious wakeup cannot extend the wait past the requested budget. */ +static inline int nimffi_sync_wait(NimFfiSyncState* s, uint32_t timeout_ms) { + int done; +#if defined(_WIN32) + ULONGLONG start = GetTickCount64(); + AcquireSRWLockExclusive(&s->lock); + while (!s->done) { + ULONGLONG elapsed = GetTickCount64() - start; + if (elapsed >= timeout_ms) { + break; + } + if (!SleepConditionVariableSRW(&s->cv, &s->lock, + (DWORD)(timeout_ms - elapsed), 0)) { + if (GetLastError() == ERROR_TIMEOUT) { + break; + } + } + } + done = s->done; + ReleaseSRWLockExclusive(&s->lock); +#else + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + deadline.tv_sec += (time_t)(timeout_ms / 1000u); + deadline.tv_nsec += (long)(timeout_ms % 1000u) * 1000000L; + if (deadline.tv_nsec >= 1000000000L) { + deadline.tv_sec += 1; + deadline.tv_nsec -= 1000000000L; + } + pthread_mutex_lock(&s->mtx); + while (!s->done) { + if (pthread_cond_timedwait(&s->cv, &s->mtx, &deadline) == ETIMEDOUT) { + break; + } + } + done = s->done; + pthread_mutex_unlock(&s->mtx); +#endif + return done; +} + +/* Bounded copy of `src` into `buf`, always NUL-terminating when err_len > 0. */ +static inline void nimffi_copy_err(char* buf, size_t err_len, const char* src) { + if (!buf || err_len == 0) { + return; + } + if (!src) { + src = ""; + } + size_t n = strlen(src); + if (n >= err_len) { + n = err_len - 1; + } + memcpy(buf, src, n); + buf[n] = '\0'; +} + +#ifdef __cplusplus +} +#endif + +#endif /* NIM_FFI_SYNC_HELPER_H_INCLUDED */ diff --git a/tests/e2e/c/test_timer_e2e.c b/tests/e2e/c/test_timer_e2e.c index e6939e8..a0f074b 100644 --- a/tests/e2e/c/test_timer_e2e.c +++ b/tests/e2e/c/test_timer_e2e.c @@ -3,12 +3,12 @@ * multi-parameter requests, the error channel and the typed event listener — * and aborts (non-zero exit) on the first failure so ctest reports it. * - * The binding is asynchronous: every call takes a result callback and the - * reply/error are owned by the binding and valid only for the duration of that - * callback. So each callback *copies out* what it needs into a waiter struct, - * and the test polls a `done` flag (the same volatile-flag pattern the event - * listener uses) to turn each async call back into a sequential check. The - * caller never frees reply data or error strings — that is the whole point. */ + * Both call surfaces are covered. The async API takes a result callback whose + * reply/error are owned by the binding and valid only for that callback, so the + * checks below *copy out* what they need into a waiter struct and poll a `done` + * flag to sequence each call. The blocking `_sync` API (further down) collapses + * that dance into one call whose out-param is caller-owned and freed with the + * generated helper. */ #include "my_timer.h" #include #include @@ -249,6 +249,133 @@ static void test_event(MyTimerCtx* ctx) { assert(my_timer_ctx_remove_event_listener(ctx, handle) == true); } +/* ── Blocking `_sync` path ────────────────────────────────────────────────── + * The generated `_sync` wrappers collapse the submit + wait + copy-out dance + * above into a single blocking call: on success `*out` is caller-owned and + * released with the generated free helper; on failure the error text lands in + * `err`. These mirror the async checks to prove both surfaces round-trip. */ + +#define SYNC_TIMEOUT_MS 5000 + +static MyTimerCtx* make_ctx_sync(void) { + MyTimerCtx* ctx = NULL; + char err[256] = {0}; + TimerConfig config = {nimffi_str("c-e2e-sync")}; + int rc = my_timer_ctx_create_sync(&config, &ctx, err, sizeof(err), SYNC_TIMEOUT_MS); + if (rc != 0) { + fprintf(stderr, "create_sync failed: %s\n", err[0] ? err : "?"); + } + assert(rc == 0); + assert(ctx != NULL); + return ctx; +} + +static void test_version_sync(MyTimerCtx* ctx) { + NimFfiStr version = {0}; + char err[256] = {0}; + int rc = my_timer_ctx_version_sync(ctx, &version, err, sizeof(err), SYNC_TIMEOUT_MS); + assert(rc == 0); + assert(version.data != NULL); + assert(strcmp(version.data, "nim-timer v0.1.0") == 0); + nimffi_free_str(&version); +} + +static void test_echo_sync(MyTimerCtx* ctx) { + EchoRequest req = {nimffi_str("hello-sync"), 10}; + EchoResponse resp = {0}; + char err[256] = {0}; + int rc = my_timer_ctx_echo_sync(ctx, &req, &resp, err, sizeof(err), SYNC_TIMEOUT_MS); + assert(rc == 0); + assert(resp.echoed.data && strcmp(resp.echoed.data, "hello-sync") == 0); + assert(resp.timerName.data && strcmp(resp.timerName.data, "c-e2e-sync") == 0); + my_timer_free_EchoResponse(&resp); +} + +static void test_complex_sync(MyTimerCtx* ctx) { + EchoRequest items[2] = {{nimffi_str("one"), 1}, {nimffi_str("two"), 2}}; + NimFfiStr tags[2] = {nimffi_str("a"), nimffi_str("b")}; + ComplexRequest req; + req.messages.data = items; + req.messages.len = 2; + req.tags.data = tags; + req.tags.len = 2; + req.note.has_value = true; + req.note.value = nimffi_str("note"); + req.retries.has_value = false; + req.retries.value = 0; + + ComplexResponse resp = {0}; + char err[256] = {0}; + int rc = + my_timer_ctx_complex_sync(ctx, &req, &resp, err, sizeof(err), SYNC_TIMEOUT_MS); + assert(rc == 0); + assert(resp.itemCount == 2); + assert(resp.hasNote == true); + assert(resp.summary.data && strstr(resp.summary.data, "note=note") != NULL); + my_timer_free_ComplexResponse(&resp); +} + +static void test_schedule_ok_sync(MyTimerCtx* ctx) { + NimFfiStr payload[1] = {nimffi_str("p")}; + JobSpec job; + job.name = nimffi_str("rollup"); + job.payload.data = payload; + job.payload.len = 1; + job.priority = 1; + + NimFfiStr retry_on[1] = {nimffi_str("timeout")}; + RetryPolicy retry; + retry.maxAttempts = 3; + retry.backoffMs = 100; + retry.retryOn.data = retry_on; + retry.retryOn.len = 1; + + ScheduleConfig sched; + sched.startAtMs = 1000; + sched.intervalMs = 0; + sched.jitter.has_value = false; + sched.jitter.value = 0; + + ScheduleResult resp = {0}; + char err[256] = {0}; + int rc = my_timer_ctx_schedule_sync(ctx, &job, &retry, &sched, &resp, err, + sizeof(err), SYNC_TIMEOUT_MS); + assert(rc == 0); + assert(resp.jobId.data && strcmp(resp.jobId.data, "c-e2e-sync:rollup") == 0); + assert(resp.willRunCount == 1); + my_timer_free_ScheduleResult(&resp); +} + +static void test_schedule_error_sync(MyTimerCtx* ctx) { + NimFfiStr payload[1] = {nimffi_str("p")}; + JobSpec job; + job.name = nimffi_str(""); /* empty name → handler returns err */ + job.payload.data = payload; + job.payload.len = 1; + job.priority = 1; + + NimFfiStr retry_on[1] = {nimffi_str("timeout")}; + RetryPolicy retry; + retry.maxAttempts = 3; + retry.backoffMs = 100; + retry.retryOn.data = retry_on; + retry.retryOn.len = 1; + + ScheduleConfig sched; + sched.startAtMs = 0; + sched.intervalMs = 0; + sched.jitter.has_value = false; + sched.jitter.value = 0; + + ScheduleResult resp = {0}; + char err[256] = {0}; + int rc = my_timer_ctx_schedule_sync(ctx, &job, &retry, &sched, &resp, err, + sizeof(err), SYNC_TIMEOUT_MS); + assert(rc != 0); + assert(err[0] != '\0'); + assert(strstr(err, "job name") != NULL); +} + int main(void) { MyTimerCtx* ctx = make_ctx(); test_version(ctx); @@ -258,6 +385,15 @@ int main(void) { test_schedule_error(ctx); test_event(ctx); my_timer_ctx_destroy(ctx); + + MyTimerCtx* sctx = make_ctx_sync(); + test_version_sync(sctx); + test_echo_sync(sctx); + test_complex_sync(sctx); + test_schedule_ok_sync(sctx); + test_schedule_error_sync(sctx); + my_timer_ctx_destroy(sctx); + printf("all C e2e checks passed\n"); return 0; } diff --git a/tests/e2e/rust/Cargo.lock b/tests/e2e/rust/Cargo.lock new file mode 100644 index 0000000..d1c2068 --- /dev/null +++ b/tests/e2e/rust/Cargo.lock @@ -0,0 +1,230 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "my_timer" +version = "0.1.0" +dependencies = [ + "ciborium", + "flume", + "serde", + "tokio", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rust_e2e" +version = "0.1.0" +dependencies = [ + "my_timer", + "tokio", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/tests/e2e/rust/Cargo.toml b/tests/e2e/rust/Cargo.toml new file mode 100644 index 0000000..5dbf69c --- /dev/null +++ b/tests/e2e/rust/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "rust_e2e" +version = "0.1.0" +edition = "2021" +publish = false + +# End-to-end tests for the generated Rust `my_timer` crate. Depends on the same +# `rust_bindings` crate the examples use; its build.rs compiles the Nim dylib, +# so `cargo test` here drives the full CBOR round-trip (Rust -> Nim FFI thread -> +# chronos -> Rust) over both the blocking and the tokio-async wrappers. +[dependencies] +my_timer = { path = "../../../examples/timer/rust_bindings" } + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "sync"] } diff --git a/tests/e2e/rust/README.md b/tests/e2e/rust/README.md new file mode 100644 index 0000000..566c78c --- /dev/null +++ b/tests/e2e/rust/README.md @@ -0,0 +1,28 @@ +# Rust end-to-end tests + +End-to-end tests for the auto-generated Rust `my_timer` crate (from +`examples/timer/rust_bindings`), the Rust counterpart to `tests/e2e/c` and +`tests/e2e/cpp`. + +They link the same `rust_bindings` crate the examples use — its `build.rs` +compiles the Nim shared library — and exercise the full FFI round-trip (CBOR +encode → Nim FFI thread → chronos → CBOR decode) over **both** generated call +surfaces: + +- the **blocking** wrappers (`ctx.version()`, `ctx.echo(..)`, `ctx.schedule(..)`), + which block on a condition variable until the reply lands, and +- the **tokio-async** wrappers (`ctx.version_async().await`, …), which wake the + awaiting task from the FFI callback without blocking a runtime thread. + +## Running + +```sh +# From the repo root +nimble test_rust_e2e + +# Or directly +cd tests/e2e/rust && cargo test +``` + +To regenerate the `rust_bindings` crate under test, run `nimble genbindings_rust` +from the repo root. diff --git a/tests/e2e/rust/build.rs b/tests/e2e/rust/build.rs new file mode 100644 index 0000000..81af28e --- /dev/null +++ b/tests/e2e/rust/build.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; + +// The `my_timer` bindings' build.rs compiles the Nim shared library into the +// repo root and adds it to the link search path — but a dependency's +// `rustc-link-arg` does not propagate to this crate's test binaries, so they +// would fail to find `libmy_timer.so` at runtime. Embed an rpath to the repo +// root here so `cargo test` works without setting LD_LIBRARY_PATH. (Windows has +// no rpath; there the loader finds the DLL via PATH / the working directory.) +fn main() { + if cfg!(target_os = "windows") { + return; + } + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let mut repo_root = manifest; + while !repo_root.join("ffi.nimble").exists() { + assert!( + repo_root.pop(), + "could not locate repo root (no ffi.nimble ancestor)" + ); + } + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", repo_root.display()); +} diff --git a/tests/e2e/rust/tests/e2e.rs b/tests/e2e/rust/tests/e2e.rs new file mode 100644 index 0000000..bb1f2d6 --- /dev/null +++ b/tests/e2e/rust/tests/e2e.rs @@ -0,0 +1,326 @@ +//! End-to-end tests for the generated Rust `my_timer` bindings. +//! +//! Mirrors the C and C++ e2e suites: constructor, methods, nested seq/Option +//! payloads, multi-parameter requests, the error channel and the typed event +//! listener. Both generated call surfaces are covered — the blocking wrappers +//! (`ctx.echo(..)`) and the tokio-async wrappers (`ctx.echo_async(..).await`). + +use std::sync::mpsc; +use std::time::Duration; + +use my_timer::{ + ComplexRequest, EchoEvent, EchoRequest, JobSpec, MyTimerCtx, RetryPolicy, ScheduleConfig, + TimerConfig, +}; + +const TIMEOUT: Duration = Duration::from_secs(5); + +fn make_ctx(name: &str) -> MyTimerCtx { + MyTimerCtx::create(TimerConfig { name: name.into() }, TIMEOUT).expect("create failed") +} + +// ── Blocking path ─────────────────────────────────────────────────────────── + +#[test] +fn create_and_version_blocking() { + let ctx = make_ctx("version-blocking"); + assert_eq!(ctx.version().expect("version failed"), "nim-timer v0.1.0"); +} + +#[test] +fn echo_round_trips_message_and_timer_name() { + let ctx = make_ctx("echo-ctx"); + let resp = ctx + .echo(EchoRequest { + message: "hello".into(), + delay_ms: 10, + }) + .expect("echo failed"); + assert_eq!(resp.echoed, "hello"); + assert_eq!(resp.timer_name, "echo-ctx"); +} + +#[test] +fn echo_honours_delay() { + let ctx = make_ctx("echo-delay"); + let start = std::time::Instant::now(); + let resp = ctx + .echo(EchoRequest { + message: "waited".into(), + delay_ms: 150, + }) + .expect("echo failed"); + let elapsed = start.elapsed(); + assert_eq!(resp.echoed, "waited"); + assert!( + elapsed >= Duration::from_millis(130), + "echo returned too early: {elapsed:?}" + ); +} + +#[test] +fn complex_with_optional_note_present() { + let ctx = make_ctx("complex-1"); + let resp = ctx + .complex(ComplexRequest { + messages: vec![ + EchoRequest { + message: "a".into(), + delay_ms: 1, + }, + EchoRequest { + message: "b".into(), + delay_ms: 2, + }, + ], + tags: vec!["tag1".into(), "tag2".into()], + note: Some("a note".into()), + retries: Some(2), + }) + .expect("complex failed"); + assert_eq!(resp.item_count, 2); + assert!(resp.has_note); + assert!( + resp.summary.contains("note=a note"), + "summary: {}", + resp.summary + ); + assert!( + resp.summary.contains("retries=2"), + "summary: {}", + resp.summary + ); +} + +#[test] +fn complex_with_optional_note_absent() { + let ctx = make_ctx("complex-2"); + let resp = ctx + .complex(ComplexRequest { + messages: vec![], + tags: vec![], + note: None, + retries: None, + }) + .expect("complex failed"); + assert_eq!(resp.item_count, 0); + assert!(!resp.has_note); + assert!( + resp.summary.contains("note="), + "summary: {}", + resp.summary + ); +} + +#[test] +fn schedule_three_complex_params() { + let ctx = make_ctx("schedule-ctx"); + let resp = ctx + .schedule( + JobSpec { + name: "nightly-rollup".into(), + payload: vec!["rollup".into(), "v2".into()], + priority: 10, + }, + RetryPolicy { + max_attempts: 3, + backoff_ms: 500, + retry_on: vec!["timeout".into(), "5xx".into()], + }, + ScheduleConfig { + start_at_ms: 1_000, + interval_ms: 15_000, + jitter: Some(250), + }, + ) + .expect("schedule failed"); + assert_eq!(resp.job_id, "schedule-ctx:nightly-rollup"); + assert!(resp.will_run_count >= 1); +} + +// The error channel: an empty JobSpec.name makes the handler return +// err("job name must not be empty"), surfaced as an Err(String). +#[test] +fn schedule_empty_name_is_an_error() { + let ctx = make_ctx("schedule-err"); + let res = ctx.schedule( + JobSpec { + name: "".into(), + payload: vec![], + priority: 0, + }, + RetryPolicy { + max_attempts: 1, + backoff_ms: 10, + retry_on: vec![], + }, + ScheduleConfig { + start_at_ms: 0, + interval_ms: 0, + jitter: None, + }, + ); + let err = res.expect_err("expected schedule to fail on empty job name"); + assert_eq!(err, "job name must not be empty"); +} + +// Independent contexts keep their own state; an error on one must not poison it. +#[test] +fn independent_contexts_keep_their_own_state() { + let a = make_ctx("alpha"); + let b = make_ctx("beta"); + assert_eq!( + a.echo(EchoRequest { + message: "x".into(), + delay_ms: 5 + }) + .unwrap() + .timer_name, + "alpha" + ); + assert_eq!( + b.echo(EchoRequest { + message: "x".into(), + delay_ms: 5 + }) + .unwrap() + .timer_name, + "beta" + ); + + // Trigger an error on `a`, then prove both contexts still work. + let _ = a.schedule( + JobSpec { + name: "".into(), + payload: vec![], + priority: 0, + }, + RetryPolicy { + max_attempts: 1, + backoff_ms: 10, + retry_on: vec![], + }, + ScheduleConfig { + start_at_ms: 0, + interval_ms: 0, + jitter: None, + }, + ); + assert_eq!( + a.echo(EchoRequest { + message: "again".into(), + delay_ms: 0 + }) + .unwrap() + .echoed, + "again" + ); + assert_eq!( + b.echo(EchoRequest { + message: "still".into(), + delay_ms: 0 + }) + .unwrap() + .timer_name, + "beta" + ); +} + +// ── Async path ────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn create_and_version_async() { + let ctx = MyTimerCtx::new_async( + TimerConfig { + name: "version-async".into(), + }, + TIMEOUT, + ) + .await + .expect("new_async failed"); + assert_eq!( + ctx.version_async().await.expect("version_async failed"), + "nim-timer v0.1.0" + ); +} + +#[tokio::test] +async fn concurrent_async_calls_are_independent() { + let ctx = make_ctx("concurrent"); + let (r1, r2, r3) = tokio::join!( + ctx.echo_async(EchoRequest { + message: "one".into(), + delay_ms: 80 + }), + ctx.echo_async(EchoRequest { + message: "two".into(), + delay_ms: 40 + }), + ctx.echo_async(EchoRequest { + message: "three".into(), + delay_ms: 20 + }), + ); + assert_eq!(r1.unwrap().echoed, "one"); + assert_eq!(r2.unwrap().echoed, "two"); + assert_eq!(r3.unwrap().echoed, "three"); +} + +// Chained async calls A->B->C preserve ordering and payload across hops. +#[tokio::test] +async fn triple_pipeline_async() { + let ctx = make_ctx("pipeline"); + let a = ctx + .echo_async(EchoRequest { + message: "A".into(), + delay_ms: 20, + }) + .await + .unwrap(); + let b = ctx + .echo_async(EchoRequest { + message: format!("{}->B", a.echoed), + delay_ms: 10, + }) + .await + .unwrap(); + let c = ctx + .echo_async(EchoRequest { + message: format!("{}->C", b.echoed), + delay_ms: 5, + }) + .await + .unwrap(); + assert_eq!(c.echoed, "A->B->C"); + assert_eq!(c.timer_name, "pipeline"); +} + +// ── Typed events ──────────────────────────────────────────────────────────── + +#[test] +fn typed_event_fires_after_echo() { + let ctx = make_ctx("events"); + let (tx, rx) = mpsc::channel::(); + let handle = ctx.add_on_echo_fired_listener(move |evt: &EchoEvent| { + let _ = tx.send(evt.clone()); + }); + assert_ne!(handle.id, 0, "listener registration returned zero id"); + + ctx.echo(EchoRequest { + message: "event-msg".into(), + delay_ms: 1, + }) + .expect("echo failed"); + + let evt = rx + .recv_timeout(Duration::from_secs(2)) + .expect("event never arrived"); + assert_eq!(evt.message, "event-msg"); + assert_eq!(evt.echo_count, 1); + + assert!(ctx.remove_event_listener(handle)); + assert!( + !ctx.remove_event_listener(handle), + "double remove must report false" + ); +} diff --git a/tests/unit/test_c_codegen.nim b/tests/unit/test_c_codegen.nim index f6bdeed..b29c01c 100644 --- a/tests/unit/test_c_codegen.nim +++ b/tests/unit/test_c_codegen.nim @@ -143,10 +143,17 @@ suite "generateCLibHeader: ABI declarations and context API": check "timer_ctx_create(const EchoRequest* config, TimerCreateFn on_created, void* user_data)" in header - test "no blocking sync-call machinery or per-call timeout survives": - check "nimffi_wait_result" notin header - check "NimFfiCallState" notin header - check "timeout_ms" notin header + test "a blocking _sync wrapper is emitted alongside each async method": + check "#include \"nim_ffi_sync.h\"" in header + check "timer_ctx_version_sync(const TimerCtx* ctx, NimFfiStr* out, char* err_buf, size_t err_len, uint32_t timeout_ms)" in + header + # the wrapper drives the shared blocking helper and decodes into the out-param + check "nimffi_sync_state_new()" in header + check "nimffi_sync_wait(st, timeout_ms)" in header + + test "the constructor gets a blocking _sync wrapper handing back an owned ctx": + check "timer_ctx_create_sync(const EchoRequest* config, TimerCtx** out, char* err_buf, size_t err_len, uint32_t timeout_ms)" in + header test "an empty request envelope still encodes a (zero-length) map": check "_nimffi_empty" in header @@ -278,7 +285,17 @@ suite "shared headers: prelude and cbor split": check "nimffi_enc_str" in cbor check "nimffi_decode_from_buf" in cbor + test "the sync header carries the blocking-call helper and pulls in the cbor header": + let sync = generateCSyncHeader() + check "#include \"nim_ffi_cbor.h\"" in sync + check "NimFfiSyncState" in sync + check "nimffi_sync_wait(" in sync + # the platform split the example programs use: pthreads on POSIX, SRWLOCK on Win32 + check "pthread_cond_timedwait" in sync + check "SleepConditionVariableSRW" in sync + test "each generated file is independently include-guarded": check "NIM_FFI_PRELUDE_H_INCLUDED" in generateCPreludeHeader() check "NIM_FFI_CBOR_HELPERS_H_INCLUDED" in generateCCborHeader() + check "NIM_FFI_SYNC_HELPER_H_INCLUDED" in generateCSyncHeader() check "NIM_FFI_LIB_TIMER_H_INCLUDED" in generateCLibHeader(@[], @[], "timer")