diff --git a/CHANGELOG.md b/CHANGELOG.md index a4f9c10..8906848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,19 @@ All notable changes to this project are documented in this file. where `-install_name` requires `-dynamiclib`. ### Added +- **`{.ffiStatic.}`**: exports a context-independent proc — no library param, and + no `ctx` in its wrapper, so a host can call a stateless utility (key generation, + parsing, a version string) without constructing the library + ([#134](https://github.com/logos-messaging/nim-ffi/issues/134)). Wired for both + the `cbor` and `c` ABIs across all four backends: the C header emits + `_static_(...)`, C++ and Rust an associated function on the ctx type + taking the `timeout` a method reads from its ctx. Handlers run on the library's + *static context*, created on the first such call and held for the rest of the + process, so that call starts a thread pair nothing tears down — + `destroyFFIContext` refuses it; `destroyStaticFFIContext` is the Nim-side + teardown for process shutdown and tests, with no foreign equivalent. An + `{.ffiHandle.}` parameter or return is rejected at macro time: a handle belongs + to the context that created it, which a static proc cannot reach. - `{.ffi.}` now accepts an `enum` type, emitting a native enum in every target (C `enum`, C++ `enum class`, Rust enum, CDDL string choice). Values cross the wire as the text `$value` yields — the associated string if declared, else the @@ -69,8 +82,8 @@ All notable changes to this project are documented in this file. `##` comment now changes the generated bindings, so `nimble check_bindings` flags them stale until regenerated; an undocumented proc still generates byte-identical output. -- FFI annotations (`{.ffi.}`, `{.ffiCtor.}`, `{.ffiDtor.}`, `{.ffiEvent.}`, - `{.ffiHandle.}`, `{.ffiRaw.}`) that expand after `genBindings()` now produce a +- FFI annotations (`{.ffi.}`, `{.ffiStatic.}`, `{.ffiCtor.}`, `{.ffiDtor.}`, + `{.ffiEvent.}`, `{.ffiHandle.}`, `{.ffiRaw.}`) that expand after `genBindings()` now produce a loud compile error instead of being silently dropped from the generated bindings. - **C binding generator** (`-d:targetLang=c`): emits a header-only C binding diff --git a/README.md b/README.md index a704df1..89ccd85 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ requires "https://github.com/logos-messaging/nim-ffi >= 0.2.0" - You **declare a library** once with `declareLibrary(name, LibType)`. - You **annotate procs and types** with pragmas (`{.ffi.}`, `{.ffiCtor.}`, - `{.ffiDtor.}`, `{.ffiEvent.}`). + `{.ffiDtor.}`, `{.ffiStatic.}`, `{.ffiEvent.}`). - You **call `genBindings()` last**, which emits the foreign bindings. By default, every request/response crosses the boundary as a single CBOR blob @@ -78,6 +78,7 @@ The generated C export names are the snake_case form of the proc names, e.g. | `declareLibrary(name, LibType[, defaultABIFormat])` | call | Registers the library, its state type, and the default wire format. Must run before any annotation. | | `{.ffi.}` on a `type` | `object`, `enum` | Registers the type for binding generation; it serializes via the library's ABI format (CBOR by default). Enums are CBOR-only — see below. | | `{.ffi.}` on a `proc` | proc | Exposes a method. First param is the library value, then typed params; returns `Future[Result[T, string]]`. | +| `{.ffiStatic.}` | proc | Exposes a context-independent proc: no library param, and its wrapper takes no ctx — see below. | | `{.ffiCtor.}` | proc | The constructor. Returns `Future[Result[LibType, string]]`; creates the FFI context. | | `{.ffiDtor.}` | proc | The destructor. Exactly one param `(x: LibType)`; tears the context down. | | `{.ffiEvent[: "wire_name"].}` | proc (empty body) | A library-initiated callback. Call the proc from any `{.ffi.}` handler to fire it. The wire name is optional — see below. | @@ -175,6 +176,44 @@ Every `{.ffi.}` / `{.ffiCtor.}` proc must have an explicit `return ok(...)` without awaiting). The `Result`'s error string is delivered to the foreign caller as the failure message. +### Context-independent procs + +A `{.ffi.}` proc is a method: it takes the library value, and its wrapper takes a +`ctx`, so the host must construct the library to call it. A stateless utility +shouldn't have to pay for that. Annotate it `{.ffiStatic.}` instead — drop the +library param, and the ctx disappears from the generated wrapper: + +```nim +proc counterParse*(text: string): Future[Result[BumpRequest, string]] {.ffiStatic.} = + return ok(BumpRequest(by: text.parseInt())) +``` + +```c +/* {.ffi.} method */ counter_ctx_bump(ctx, &req, on_reply, ud); +/* {.ffiStatic.} — no ctx */ counter_static_parse(text, on_reply, ud); +``` + +The wrapper is `_static_`, not `_`, for the same reason a +method's is `_ctx_`: `_` is the raw symbol the dylib +exports. In C++ and Rust a static is an associated function on the ctx type +(`EchoCtx::lib_version()`), taking the `timeout` a method reads from its ctx. + +The handler still needs an FFI thread, so it runs on the library's **static +context**: created on the first `{.ffiStatic.}` call, then alive for the rest of +the process — no ctx owns it, so nothing tears its thread pair down, and it holds +one of the pool's slots. It has no `myLib`, which is why a static proc cannot +take the library value. + +There is no foreign teardown for it. From Nim, `destroyStaticFFIContext(pool)` +stops the thread pair and frees the slot; it is only sound once nothing will call +a `{.ffiStatic.}` proc again, so it is meant for process shutdown and tests. + +The macro rejects an `{.ffiHandle.}` parameter or return: a handle is registered +in the context that created it, which a static proc cannot reach. Under +`abi = c` a static replies with a `string` or an `{.ffi.}` object type — a scalar +return is wired only for an all-scalar `{.ffi.}` method, which rides the +[CBOR-free fast path](#abi-format) through the ctx a static doesn't have. + ### The result callback contract Each request carries a result callback. It receives one of these status codes diff --git a/examples/echo/c_abi_bindings/echo.h b/examples/echo/c_abi_bindings/echo.h index 27a821c..0365f73 100644 --- a/examples/echo/c_abi_bindings/echo.h +++ b/examples/echo/c_abi_bindings/echo.h @@ -38,9 +38,17 @@ typedef struct { typedef struct { ShoutRequest req; } EchoShoutReq; +typedef struct { + uint8_t _placeholder; /* C forbids empty structs */ +} EchoLibVersionReq; +typedef struct { + ShoutRequest req; +} EchoShoutAnonReq; typedef void (*EchoShoutReplyFn)(int err_code, const ShoutResponse* reply, const char* err_msg, void* user_data); typedef void (*EchoVersionReplyFn)(int err_code, const char* reply, const char* err_msg, void* user_data); +typedef void (*EchoLibVersionReplyFn)(int err_code, const char* reply, const char* err_msg, void* user_data); +typedef void (*EchoShoutAnonReplyFn)(int err_code, const ShoutResponse* reply, const char* err_msg, void* user_data); typedef void (*EchoCreateRawFn)(int err_code, const char* ctx_addr, const char* err_msg, void* user_data); /* Raw reply of a scalar-fast-path export: `msg`/`len` are bytes (a string @@ -70,6 +78,8 @@ void* echo_create(const EchoCreateCtorReq* req, EchoCreateRawFn on_created, void int echo_shout(void* ctx, EchoShoutReplyFn on_reply, void* user_data, const EchoShoutReq* req); /** Returns the library's version string. */ int echo_version(void* ctx, EchoScalarRawFn callback, void* user_data); +int echo_lib_version(EchoLibVersionReplyFn on_reply, void* user_data, const EchoLibVersionReq* req); +int echo_shout_anon(EchoShoutAnonReplyFn on_reply, void* user_data, const EchoShoutAnonReq* req); /** Releases the echo context. */ int echo_destroy(void* ctx); @@ -181,4 +191,17 @@ static inline int echo_ctx_version(const EchoCtx* ctx, EchoVersionReplyFn on_rep return echo_version(ctx->ptr, echo_version_scalar_reply, box); } +static inline int echo_static_lib_version(EchoLibVersionReplyFn on_reply, void* user_data) { + EchoLibVersionReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + return echo_lib_version(on_reply, user_data, &ffi_req); +} + +static inline int echo_static_shout_anon(const ShoutRequest* req, EchoShoutAnonReplyFn on_reply, void* user_data) { + EchoShoutAnonReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + ffi_req.req = *req; + return echo_shout_anon(on_reply, user_data, &ffi_req); +} + #endif /* NIM_FFI_LIB_ECHO_C_ABI_H_INCLUDED */ diff --git a/examples/echo/c_bindings/echo.h b/examples/echo/c_bindings/echo.h index 4fa7f11..6ff9410 100644 --- a/examples/echo/c_bindings/echo.h +++ b/examples/echo/c_bindings/echo.h @@ -31,6 +31,12 @@ typedef struct { typedef struct { char _nimffi_empty; /* C forbids empty structs */ } EchoVersionReq; +typedef struct { + char _nimffi_empty; /* C forbids empty structs */ +} EchoLibVersionReq; +typedef struct { + ShoutRequest req; +} EchoShoutAnonReq; static inline CborError echo_enc_EchoConfig( CborEncoder* e, const EchoConfig* v) { @@ -191,6 +197,47 @@ static inline CborError echo_dec_EchoVersionReq( (void)out; return cbor_value_advance(it); } +static inline CborError echo_enc_EchoLibVersionReq( + CborEncoder* e, const EchoLibVersionReq* v) { + (void)v; + CborEncoder m; + CborError err = cbor_encoder_create_map(e, &m, 0); + if (err) return err; + return cbor_encoder_close_container(e, &m); +} +static inline CborError echo_dec_EchoLibVersionReq( + CborValue* it, EchoLibVersionReq* out) { + if (!cbor_value_is_map(it)) return CborErrorImproperValue; + (void)out; + return cbor_value_advance(it); +} +static inline CborError echo_enc_EchoShoutAnonReq( + CborEncoder* e, const EchoShoutAnonReq* 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 = echo_enc_ShoutRequest(&m, &v->req); + if (err) return err; + return cbor_encoder_close_container(e, &m); +} +static inline CborError echo_dec_EchoShoutAnonReq( + CborValue* it, EchoShoutAnonReq* out) { + 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 = echo_dec_ShoutRequest(&field, &out->req); + if (err) return err; + return cbor_value_advance(it); +} +static inline void echo_free_EchoShoutAnonReq(EchoShoutAnonReq* v) { + if (!v) return; + echo_free_ShoutRequest(&v->req); +} /* ============================================================ */ /* C ABI declarations (symbols exported by the Nim dylib) */ @@ -205,6 +252,8 @@ void* echo_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback call int echo_shout(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); /** Returns the library's version string. */ int echo_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +int echo_lib_version(FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +int echo_shout_anon(FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); /** Releases the echo context. */ int echo_destroy(void* ctx); uint64_t echo_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data); @@ -218,6 +267,8 @@ int echo_remove_event_listener(void* ctx, uint64_t listener_id); static inline CborError echo_encv_EchoCreateCtorReq(CborEncoder* e, const void* v) { return echo_enc_EchoCreateCtorReq(e, (const EchoCreateCtorReq*)v); } static inline CborError echo_encv_EchoShoutReq(CborEncoder* e, const void* v) { return echo_enc_EchoShoutReq(e, (const EchoShoutReq*)v); } static inline CborError echo_encv_EchoVersionReq(CborEncoder* e, const void* v) { return echo_enc_EchoVersionReq(e, (const EchoVersionReq*)v); } +static inline CborError echo_encv_EchoLibVersionReq(CborEncoder* e, const void* v) { return echo_enc_EchoLibVersionReq(e, (const EchoLibVersionReq*)v); } +static inline CborError echo_encv_EchoShoutAnonReq(CborEncoder* e, const void* v) { return echo_enc_EchoShoutAnonReq(e, (const EchoShoutAnonReq*)v); } static inline CborError echo_decv_ShoutResponse(CborValue* it, void* v) { return echo_dec_ShoutResponse(it, (ShoutResponse*)v); } static inline CborError echo_decv_Str(CborValue* it, void* v) { return nimffi_dec_str(it, (NimFfiStr*)v); } @@ -434,4 +485,127 @@ static inline int echo_ctx_version(const EchoCtx* ctx, EchoVersionReplyFn on_rep return 0; } +typedef void (*EchoLibVersionReplyFn)(int err_code, const NimFfiStr* reply, const char* err_msg, void* user_data); +typedef struct { EchoLibVersionReplyFn fn; void* user_data; } EchoLibVersionCallBox; +static void echo_lib_version_reply_trampoline(int ret, const char* msg, size_t len, void* ud) { + EchoLibVersionCallBox* box = (EchoLibVersionCallBox*)ud; + /* Non-terminal progress ping: keep the box for the terminal reply. */ + if (ret == NIMFFI_RET_STALE_WARN) return; + if (!box->fn) { + free(box); + return; + } + if (ret != 0) { + char* em = nimffi_dup_cstr_n(msg ? msg : "", msg ? len : 0); + box->fn(ret, NULL, em ? em : "FFI call failed", box->user_data); + free(em); + free(box); + return; + } + char* err = NULL; + NimFfiStr out; + memset(&out, 0, sizeof(out)); + int dec = nimffi_decode_from_buf(echo_decv_Str, (const uint8_t*)msg, len, &out, &err); + if (dec != 0) { + box->fn(-1, NULL, err ? err : "decode failed", box->user_data); + free(err); + nimffi_free_str(&out); + free(box); + return; + } + box->fn(NIMFFI_RET_OK, &out, NULL, box->user_data); + nimffi_free_str(&out); + free(box); +} +static inline int echo_static_lib_version(EchoLibVersionReplyFn on_reply, void* user_data) { + EchoLibVersionReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + uint8_t* req_buf = NULL; + size_t req_len = 0; + char* err = NULL; + if (nimffi_encode_to_buf(echo_encv_EchoLibVersionReq, &ffi_req, &req_buf, &req_len, &err) != 0) { + if (on_reply) on_reply(-1, NULL, err ? err : "encode failed", user_data); + free(err); + return -1; + } + EchoLibVersionCallBox* box = (EchoLibVersionCallBox*)malloc(sizeof(EchoLibVersionCallBox)); + if (!box) { + free(req_buf); + if (on_reply) on_reply(-1, NULL, "out of memory", user_data); + return -1; + } + box->fn = on_reply; + box->user_data = user_data; + int ret = echo_lib_version(echo_lib_version_reply_trampoline, box, req_buf, req_len); + free(req_buf); + if (ret == NIMFFI_RET_MISSING_CALLBACK) { + if (on_reply) on_reply(-1, NULL, "RET_MISSING_CALLBACK (internal error)", user_data); + free(box); + return -1; + } + return 0; +} + +typedef void (*EchoShoutAnonReplyFn)(int err_code, const ShoutResponse* reply, const char* err_msg, void* user_data); +typedef struct { EchoShoutAnonReplyFn fn; void* user_data; } EchoShoutAnonCallBox; +static void echo_shout_anon_reply_trampoline(int ret, const char* msg, size_t len, void* ud) { + EchoShoutAnonCallBox* box = (EchoShoutAnonCallBox*)ud; + /* Non-terminal progress ping: keep the box for the terminal reply. */ + if (ret == NIMFFI_RET_STALE_WARN) return; + if (!box->fn) { + free(box); + return; + } + if (ret != 0) { + char* em = nimffi_dup_cstr_n(msg ? msg : "", msg ? len : 0); + box->fn(ret, NULL, em ? em : "FFI call failed", box->user_data); + free(em); + free(box); + return; + } + char* err = NULL; + ShoutResponse out; + memset(&out, 0, sizeof(out)); + int dec = nimffi_decode_from_buf(echo_decv_ShoutResponse, (const uint8_t*)msg, len, &out, &err); + if (dec != 0) { + box->fn(-1, NULL, err ? err : "decode failed", box->user_data); + free(err); + echo_free_ShoutResponse(&out); + free(box); + return; + } + box->fn(NIMFFI_RET_OK, &out, NULL, box->user_data); + echo_free_ShoutResponse(&out); + free(box); +} +static inline int echo_static_shout_anon(const ShoutRequest* req, EchoShoutAnonReplyFn on_reply, void* user_data) { + EchoShoutAnonReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + ffi_req.req = *req; + uint8_t* req_buf = NULL; + size_t req_len = 0; + char* err = NULL; + if (nimffi_encode_to_buf(echo_encv_EchoShoutAnonReq, &ffi_req, &req_buf, &req_len, &err) != 0) { + if (on_reply) on_reply(-1, NULL, err ? err : "encode failed", user_data); + free(err); + return -1; + } + EchoShoutAnonCallBox* box = (EchoShoutAnonCallBox*)malloc(sizeof(EchoShoutAnonCallBox)); + if (!box) { + free(req_buf); + if (on_reply) on_reply(-1, NULL, "out of memory", user_data); + return -1; + } + box->fn = on_reply; + box->user_data = user_data; + int ret = echo_shout_anon(echo_shout_anon_reply_trampoline, box, req_buf, req_len); + free(req_buf); + if (ret == NIMFFI_RET_MISSING_CALLBACK) { + if (on_reply) on_reply(-1, NULL, "RET_MISSING_CALLBACK (internal error)", user_data); + free(box); + return -1; + } + return 0; +} + #endif /* NIM_FFI_LIB_ECHO_H_INCLUDED */ diff --git a/examples/echo/cpp_bindings/echo.hpp b/examples/echo/cpp_bindings/echo.hpp index db98b76..b4acea8 100644 --- a/examples/echo/cpp_bindings/echo.hpp +++ b/examples/echo/cpp_bindings/echo.hpp @@ -430,6 +430,40 @@ inline CborError decode_cbor(CborValue& it, EchoVersionReq&) { return cbor_value_advance(&it); } +struct EchoLibVersionReq { +}; +inline CborError encode_cbor(CborEncoder& e, const EchoLibVersionReq&) { + 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, EchoLibVersionReq&) { + if (!cbor_value_is_map(&it)) return CborErrorImproperValue; + return cbor_value_advance(&it); +} + +struct EchoShoutAnonReq { + ShoutRequest req; +}; +inline CborError encode_cbor(CborEncoder& e, const EchoShoutAnonReq& 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, EchoShoutAnonReq& 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); +} + // ============================================================ // C FFI declarations // ============================================================ @@ -443,6 +477,8 @@ void* echo_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback call int echo_shout(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); /** Returns the library's version string. */ int echo_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +int echo_lib_version(FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +int echo_shout_anon(FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); /** Releases the echo context. */ int echo_destroy(void* ctx); uint64_t echo_add_event_listener(void* ctx, const char* event_name, FFICallback callback, void* user_data); @@ -608,6 +644,38 @@ public: return std::async(std::launch::async, [this]() { return this->version(); }); } + static Result lib_version(std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + const auto ffi_req_ = EchoLibVersionReq{}; + auto ffi_enc_ = encodeCborFFI(ffi_req_); + if (ffi_enc_.isErr()) return Result::err(ffi_enc_.error()); + const auto& ffi_req_bytes_ = ffi_enc_.value(); + auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + return echo_lib_version(cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size()); + }, timeout); + if (ffi_raw_.isErr()) return Result::err(ffi_raw_.error()); + return decodeCborFFI(ffi_raw_.value()); + } + + static std::future> lib_versionAsync(std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + return std::async(std::launch::async, [timeout]() { return lib_version(timeout); }); + } + + static Result shout_anon(const ShoutRequest& req, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + const auto ffi_req_ = EchoShoutAnonReq{req}; + auto ffi_enc_ = encodeCborFFI(ffi_req_); + if (ffi_enc_.isErr()) return Result::err(ffi_enc_.error()); + const auto& ffi_req_bytes_ = ffi_enc_.value(); + auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + return echo_shout_anon(cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size()); + }, timeout); + if (ffi_raw_.isErr()) return Result::err(ffi_raw_.error()); + return decodeCborFFI(ffi_raw_.value()); + } + + static std::future> shout_anonAsync(const ShoutRequest& req, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + return std::async(std::launch::async, [req, timeout]() { return shout_anon(req, timeout); }); + } + private: void* ptr_; std::chrono::milliseconds timeout_; diff --git a/examples/echo/echo.nim b/examples/echo/echo.nim index 991e370..e8f55f4 100644 --- a/examples/echo/echo.nim +++ b/examples/echo/echo.nim @@ -44,6 +44,16 @@ proc echoVersion*(e: Echo): Future[Result[string, string]] {.ffi.} = ## Returns the library's version string. return ok("nim-echo v0.1.0") +# No `Echo` param, so the wrappers take no ctx. +proc echoLibVersion*(): Future[Result[string, string]] {.ffiStatic.} = + return ok("nim-echo v0.1.0") + +proc echoShoutAnon*( + req: ShoutRequest +): Future[Result[ShoutResponse, string]] {.ffiStatic.} = + await sleepAsync(1.milliseconds) + return ok(ShoutResponse(shouted: req.text.toUpperAscii, prefix: "")) + proc echo_destroy*(e: Echo) {.ffiDtor.} = ## Releases the echo context. discard diff --git a/examples/timer/c_bindings/my_timer.h b/examples/timer/c_bindings/my_timer.h index 470d84c..45e453a 100644 --- a/examples/timer/c_bindings/my_timer.h +++ b/examples/timer/c_bindings/my_timer.h @@ -92,6 +92,9 @@ typedef struct { typedef struct { char _nimffi_empty; /* C forbids empty structs */ } MyTimerVersionReq; +typedef struct { + char _nimffi_empty; /* C forbids empty structs */ +} MyTimerLibVersionReq; typedef struct { ComplexRequest req; } MyTimerComplexReq; @@ -734,6 +737,20 @@ static inline CborError my_timer_dec_MyTimerVersionReq( (void)out; return cbor_value_advance(it); } +static inline CborError my_timer_enc_MyTimerLibVersionReq( + CborEncoder* e, const MyTimerLibVersionReq* v) { + (void)v; + CborEncoder m; + CborError err = cbor_encoder_create_map(e, &m, 0); + if (err) return err; + return cbor_encoder_close_container(e, &m); +} +static inline CborError my_timer_dec_MyTimerLibVersionReq( + CborValue* it, MyTimerLibVersionReq* out) { + if (!cbor_value_is_map(it)) return CborErrorImproperValue; + (void)out; + return cbor_value_advance(it); +} static inline CborError my_timer_enc_MyTimerComplexReq( CborEncoder* e, const MyTimerComplexReq* v) { CborEncoder m; @@ -821,6 +838,7 @@ void* my_timer_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback int my_timer_echo(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); /** Returns the library's version string. */ int my_timer_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +int my_timer_lib_version(FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); int my_timer_complex(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); /** Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. */ int my_timer_schedule(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); @@ -837,6 +855,7 @@ int my_timer_remove_event_listener(void* ctx, uint64_t listener_id); static inline CborError my_timer_encv_MyTimerCreateCtorReq(CborEncoder* e, const void* v) { return my_timer_enc_MyTimerCreateCtorReq(e, (const MyTimerCreateCtorReq*)v); } static inline CborError my_timer_encv_MyTimerEchoReq(CborEncoder* e, const void* v) { return my_timer_enc_MyTimerEchoReq(e, (const MyTimerEchoReq*)v); } static inline CborError my_timer_encv_MyTimerVersionReq(CborEncoder* e, const void* v) { return my_timer_enc_MyTimerVersionReq(e, (const MyTimerVersionReq*)v); } +static inline CborError my_timer_encv_MyTimerLibVersionReq(CborEncoder* e, const void* v) { return my_timer_enc_MyTimerLibVersionReq(e, (const MyTimerLibVersionReq*)v); } static inline CborError my_timer_encv_MyTimerComplexReq(CborEncoder* e, const void* v) { return my_timer_enc_MyTimerComplexReq(e, (const MyTimerComplexReq*)v); } static inline CborError my_timer_encv_MyTimerScheduleReq(CborEncoder* e, const void* v) { return my_timer_enc_MyTimerScheduleReq(e, (const MyTimerScheduleReq*)v); } static inline CborError my_timer_decv_EchoResponse(CborValue* it, void* v) { return my_timer_dec_EchoResponse(it, (EchoResponse*)v); } @@ -1251,4 +1270,65 @@ static inline int my_timer_ctx_schedule(const MyTimerCtx* ctx, const JobSpec* jo return 0; } +typedef void (*MyTimerLibVersionReplyFn)(int err_code, const NimFfiStr* reply, const char* err_msg, void* user_data); +typedef struct { MyTimerLibVersionReplyFn fn; void* user_data; } MyTimerLibVersionCallBox; +static void my_timer_lib_version_reply_trampoline(int ret, const char* msg, size_t len, void* ud) { + MyTimerLibVersionCallBox* box = (MyTimerLibVersionCallBox*)ud; + /* Non-terminal progress ping: keep the box for the terminal reply. */ + if (ret == NIMFFI_RET_STALE_WARN) return; + if (!box->fn) { + free(box); + return; + } + if (ret != 0) { + char* em = nimffi_dup_cstr_n(msg ? msg : "", msg ? len : 0); + box->fn(ret, NULL, em ? em : "FFI call failed", box->user_data); + free(em); + free(box); + return; + } + char* err = NULL; + NimFfiStr out; + memset(&out, 0, sizeof(out)); + int dec = nimffi_decode_from_buf(my_timer_decv_Str, (const uint8_t*)msg, len, &out, &err); + if (dec != 0) { + box->fn(-1, NULL, err ? err : "decode failed", box->user_data); + free(err); + nimffi_free_str(&out); + free(box); + return; + } + box->fn(NIMFFI_RET_OK, &out, NULL, box->user_data); + nimffi_free_str(&out); + free(box); +} +static inline int my_timer_static_lib_version(MyTimerLibVersionReplyFn on_reply, void* user_data) { + MyTimerLibVersionReq ffi_req; + memset(&ffi_req, 0, sizeof(ffi_req)); + uint8_t* req_buf = NULL; + size_t req_len = 0; + char* err = NULL; + if (nimffi_encode_to_buf(my_timer_encv_MyTimerLibVersionReq, &ffi_req, &req_buf, &req_len, &err) != 0) { + if (on_reply) on_reply(-1, NULL, err ? err : "encode failed", user_data); + free(err); + return -1; + } + MyTimerLibVersionCallBox* box = (MyTimerLibVersionCallBox*)malloc(sizeof(MyTimerLibVersionCallBox)); + if (!box) { + free(req_buf); + if (on_reply) on_reply(-1, NULL, "out of memory", user_data); + return -1; + } + box->fn = on_reply; + box->user_data = user_data; + int ret = my_timer_lib_version(my_timer_lib_version_reply_trampoline, box, req_buf, req_len); + free(req_buf); + if (ret == NIMFFI_RET_MISSING_CALLBACK) { + if (on_reply) on_reply(-1, NULL, "RET_MISSING_CALLBACK (internal error)", user_data); + free(box); + return -1; + } + return 0; +} + #endif /* NIM_FFI_LIB_MY_TIMER_H_INCLUDED */ diff --git a/examples/timer/cddl_bindings/my_timer.cddl b/examples/timer/cddl_bindings/my_timer.cddl index 7adbd70..516384c 100644 --- a/examples/timer/cddl_bindings/my_timer.cddl +++ b/examples/timer/cddl_bindings/my_timer.cddl @@ -19,6 +19,7 @@ ScheduleResult = { jobId: tstr, willRunCount: int, firstRunAtMs: int, effectiveB MyTimerCreateCtorReq = { config: TimerConfig } MyTimerEchoReq = { req: EchoRequest } MyTimerVersionReq = { } +MyTimerLibVersionReq = { } MyTimerComplexReq = { req: ComplexRequest } MyTimerScheduleReq = { job: JobSpec, retry: RetryPolicy, schedule: ScheduleConfig } @@ -38,6 +39,10 @@ my_timer_echo-response = EchoResponse my_timer_version-request = MyTimerVersionReq my_timer_version-response = tstr +; my_timer_lib_version (ffiStatic) +my_timer_lib_version-request = MyTimerLibVersionReq +my_timer_lib_version-response = tstr + ; my_timer_complex (ffi) my_timer_complex-request = MyTimerComplexReq my_timer_complex-response = ComplexResponse diff --git a/examples/timer/cpp_bindings/my_timer.hpp b/examples/timer/cpp_bindings/my_timer.hpp index ded2cbf..57b71b4 100644 --- a/examples/timer/cpp_bindings/my_timer.hpp +++ b/examples/timer/cpp_bindings/my_timer.hpp @@ -705,6 +705,19 @@ inline CborError decode_cbor(CborValue& it, MyTimerVersionReq&) { return cbor_value_advance(&it); } +struct MyTimerLibVersionReq { +}; +inline CborError encode_cbor(CborEncoder& e, const MyTimerLibVersionReq&) { + 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, MyTimerLibVersionReq&) { + if (!cbor_value_is_map(&it)) return CborErrorImproperValue; + return cbor_value_advance(&it); +} + struct MyTimerComplexReq { ComplexRequest req; }; @@ -772,6 +785,7 @@ void* my_timer_create(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback int my_timer_echo(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); /** Returns the library's version string. */ int my_timer_version(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); +int my_timer_lib_version(FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); int my_timer_complex(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); /** Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. */ int my_timer_schedule(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len); @@ -995,6 +1009,22 @@ public: return std::async(std::launch::async, [this, job, retry, schedule]() { return this->schedule(job, retry, schedule); }); } + static Result lib_version(std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + const auto ffi_req_ = MyTimerLibVersionReq{}; + auto ffi_enc_ = encodeCborFFI(ffi_req_); + if (ffi_enc_.isErr()) return Result::err(ffi_enc_.error()); + const auto& ffi_req_bytes_ = ffi_enc_.value(); + auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + return my_timer_lib_version(cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size()); + }, timeout); + if (ffi_raw_.isErr()) return Result::err(ffi_raw_.error()); + return decodeCborFFI(ffi_raw_.value()); + } + + static std::future> lib_versionAsync(std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + return std::async(std::launch::async, [timeout]() { return lib_version(timeout); }); + } + private: struct ListenerBase { virtual ~ListenerBase() = default; diff --git a/examples/timer/rust_bindings/examples/main.rs b/examples/timer/rust_bindings/examples/main.rs index 132b809..f52e1e9 100644 --- a/examples/timer/rust_bindings/examples/main.rs +++ b/examples/timer/rust_bindings/examples/main.rs @@ -7,6 +7,9 @@ use std::sync::mpsc; use std::time::Duration; fn main() -> Result<(), String> { + // `myTimerLibVersion` is {.ffiStatic.}: an associated fn, no ctx needed. + println!("lib version: {}", MyTimerCtx::lib_version(Duration::from_secs(5))?); + let ctx = MyTimerCtx::create( TimerConfig { name: "rust-sync-demo".into() }, Duration::from_secs(5), diff --git a/examples/timer/rust_bindings/src/api.rs b/examples/timer/rust_bindings/src/api.rs index fb05b7c..cf02916 100644 --- a/examples/timer/rust_bindings/src/api.rs +++ b/examples/timer/rust_bindings/src/api.rs @@ -305,4 +305,22 @@ impl MyTimerCtx { decode_cbor::(&raw_bytes) } + pub fn lib_version(timeout: Duration) -> Result { + let req = MyTimerLibVersionReq {}; + let req_bytes = encode_cbor(&req)?; + let raw_bytes = ffi_call_sync(timeout, |cb, ud| unsafe { + ffi::my_timer_lib_version(cb, ud, req_bytes.as_ptr(), req_bytes.len()) + })?; + decode_cbor::(&raw_bytes) + } + + pub async fn lib_version_async(timeout: Duration) -> Result { + let req = MyTimerLibVersionReq {}; + let req_bytes = encode_cbor(&req)?; + let raw_bytes = ffi_call_async(timeout, move |cb, ud| unsafe { + ffi::my_timer_lib_version(cb, ud, req_bytes.as_ptr(), req_bytes.len()) + }).await?; + decode_cbor::(&raw_bytes) + } + } diff --git a/examples/timer/rust_bindings/src/ffi.rs b/examples/timer/rust_bindings/src/ffi.rs index 3a6cf73..188ce74 100644 --- a/examples/timer/rust_bindings/src/ffi.rs +++ b/examples/timer/rust_bindings/src/ffi.rs @@ -15,6 +15,7 @@ extern "C" { pub fn my_timer_echo(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; /// Returns the library's version string. pub fn my_timer_version(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; + pub fn my_timer_lib_version(callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; pub fn my_timer_complex(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; /// Three object-typed params (`job`, `retry`, `schedule`) packed into one CBOR envelope. pub fn my_timer_schedule(ctx: *mut c_void, callback: FFICallback, user_data: *mut c_void, req_cbor: *const u8, req_cbor_len: usize) -> c_int; diff --git a/examples/timer/rust_bindings/src/types.rs b/examples/timer/rust_bindings/src/types.rs index 8d5c9b7..21c2a5a 100644 --- a/examples/timer/rust_bindings/src/types.rs +++ b/examples/timer/rust_bindings/src/types.rs @@ -109,6 +109,9 @@ pub struct MyTimerEchoReq { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MyTimerVersionReq {} +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MyTimerLibVersionReq {} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MyTimerComplexReq { pub req: ComplexRequest, diff --git a/examples/timer/timer.nim b/examples/timer/timer.nim index fb83133..c405952 100644 --- a/examples/timer/timer.nim +++ b/examples/timer/timer.nim @@ -65,6 +65,10 @@ proc myTimerVersion*(timer: MyTimer): Future[Result[string, string]] {.ffi.} = ## Returns the library's version string. return ok(TimerVersion) +# No `timer` param, so its wrapper takes no ctx. +proc myTimerLibVersion*(): Future[Result[string, string]] {.ffiStatic.} = + return ok("nim-timer v0.1.0") + proc myTimerComplex*( timer: MyTimer, req: ComplexRequest ): Future[Result[ComplexResponse, string]] {.ffi.} = diff --git a/ffi/codegen/c.nim b/ffi/codegen/c.nim index e4a2b5b..4568594 100644 --- a/ffi/codegen/c.nim +++ b/ffi/codegen/c.nim @@ -677,12 +677,15 @@ proc emitListenerApi( lines.add("}") lines.add("") -proc emitMethod( +proc emitProcWrapper( lines: var seq[string], reg: var CTypeReg, ctxType, libType, libName: string, m: FFIProcMeta, ) = + ## Reply trampoline + wrapper: `_ctx_`, or `_static_` for a + ## static; `_` itself is the raw symbol the dylib exports. + let isStatic = m.isStatic() let stripped = stripLibPrefix(m.procName, libName) let reqName = reqStructName(m) let retC = cReturnType(reg, m) @@ -722,7 +725,11 @@ proc emitMethod( lines.add("}") let head = - "static inline int " & libName & "_ctx_" & stripped & "(const " & ctxType & "* ctx, " + if isStatic: + "static inline int " & libName & "_static_" & stripped & "(" + else: + "static inline int " & libName & "_ctx_" & stripped & "(const " & ctxType & + "* ctx, " let sig = if params.len > 0: head & params.join(", ") & ", " & fnType & " on_reply, void* user_data) {" @@ -757,8 +764,9 @@ proc emitMethod( lines.add(" }") lines.add(" box->fn = on_reply;") lines.add(" box->user_data = user_data;") + let ctxArg = if isStatic: "" else: "ctx->ptr, " lines.add( - " int ret = " & m.procName & "(ctx->ptr, " & tramp & ", box, req_buf, req_len);" + " int ret = " & m.procName & "(" & ctxArg & tramp & ", box, req_buf, req_len);" ) lines.add(" free(req_buf);") lines.add(" if (ret == NIMFFI_RET_MISSING_CALLBACK) {") @@ -787,7 +795,7 @@ proc newCTypeReg( proc monomorphiseAll( reg: var CTypeReg, types: seq[FFITypeMeta], - procs, methods: seq[FFIProcMeta], + procs, replyProcs: seq[FFIProcMeta], events: seq[FFIEventMeta], ): tuple[reqTypes, respTypes: seq[string]] = ## Runs every type, Req, return type and event payload through ensureCType, @@ -801,8 +809,8 @@ proc monomorphiseAll( discard ensureCType(reg, n) reqTypes.add(n) var respTypes: seq[string] = @[] - for m in methods: - respTypes.add(cReturnType(reg, m)) + for p in replyProcs: + respTypes.add(cReturnType(reg, p)) for ev in events: discard ensureCType(reg, ev.payloadTypeName) return (reqTypes, respTypes) @@ -852,12 +860,12 @@ proc generateCLibHeader*( ## The `.h` header: library structs, monomorphised codecs and async API. let classified = classifyProcs(procs) let ctors = classified.ctors - let methods = classified.methods let libType = libTypeName(ctors, libName) let ctxType = libType & "Ctx" var reg = newCTypeReg(libName, libType, types, procs) - let (reqTypes, respTypes) = monomorphiseAll(reg, types, procs, methods, events) + let (reqTypes, respTypes) = + monomorphiseAll(reg, types, procs, classified.replyProcs(), events) let guard = "NIM_FFI_LIB_" & libName.toUpperAscii() & "_H_INCLUDED" var lines: seq[string] = @[] @@ -894,6 +902,11 @@ proc generateCLibHeader*( "int " & p.procName & "(void* ctx, FFICallback callback, void* user_data, " & "const uint8_t* req_cbor, size_t req_cbor_len);" ) + of FFIKind.STATIC: + lines.add( + "int " & p.procName & "(FFICallback callback, void* user_data, " & + "const uint8_t* req_cbor, size_t req_cbor_len);" + ) of FFIKind.CTOR: lines.add( "void* " & p.procName & "(const uint8_t* req_cbor, size_t req_cbor_len, " & @@ -943,8 +956,8 @@ proc generateCLibHeader*( emitConstructors(lines, reg, ctxType, libType, libName, ctors) emitDestructor(lines, ctxType, libName, classified.dtor, events) emitListenerApi(lines, ctxType, libType, libName, events) - for m in methods: - emitMethod(lines, reg, ctxType, libType, libName, m) + for m in classified.replyProcs(): + emitProcWrapper(lines, reg, ctxType, libType, libName, m) lines.add("#endif /* " & guard & " */") return lines.join("\n") & "\n" @@ -1198,6 +1211,12 @@ proc emitAbiExternDecls( "int " & p.procName & "(void* ctx, " & info.fnType & " on_reply, void* user_data, const " & reqStructName(p) & "* req);" ) + of FFIKind.STATIC: + let info = abiMethodReplyInfo(reg, libType, p) + lines.add( + "int " & p.procName & "(" & info.fnType & " on_reply, void* user_data, const " & + reqStructName(p) & "* req);" + ) of FFIKind.CTOR: lines.add( "void* " & p.procName & "(const " & reqStructName(p) & "* req, " & createRawFn & @@ -1304,18 +1323,23 @@ proc emitAbiCtxAndCtor( lines.add("}") lines.add("") -proc emitAbiMethod( +proc emitAbiProcWrapper( lines: var seq[string], reg: var AbiReg, ctxType, libName, libType: string, m: FFIProcMeta, ) = + let isStatic = m.isStatic() let stripped = stripLibPrefix(m.procName, m.libName) let reqStruct = reqStructName(m) let info = abiMethodReplyInfo(reg, libType, m) let (params, assigns) = abiReqParamsAndAssigns(reg, m.extraParams) let head = - "static inline int " & libName & "_ctx_" & stripped & "(const " & ctxType & "* ctx, " + if isStatic: + "static inline int " & libName & "_static_" & stripped & "(" + else: + "static inline int " & libName & "_ctx_" & stripped & "(const " & ctxType & + "* ctx, " let sig = if params.len > 0: head & params.join(", ") & ", " & info.fnType & " on_reply, void* user_data) {" @@ -1327,7 +1351,10 @@ proc emitAbiMethod( lines.add(" memset(&ffi_req, 0, sizeof(ffi_req));") for a in assigns: lines.add(a) - lines.add(" return " & m.procName & "(ctx->ptr, on_reply, user_data, &ffi_req);") + let ctxArg = if isStatic: "" else: "ctx->ptr, " + lines.add( + " return " & m.procName & "(" & ctxArg & "on_reply, user_data, &ffi_req);" + ) lines.add("}") lines.add("") @@ -1499,7 +1526,7 @@ proc generateCAbiLibHeader*( lines.add(decl) lines.add("") - emitAbiReplyTypedefs(lines, reg, libType, classified.methods) + emitAbiReplyTypedefs(lines, reg, libType, classified.replyProcs()) lines.add("") emitAbiExternDecls(lines, reg, libName, libType, procs) @@ -1507,11 +1534,12 @@ proc generateCAbiLibHeader*( emitAbiCtxAndCtor(lines, reg, libName, libType, ctxType, classified.ctors) # abi = c has no events, so the destructor is the CBOR one minus the listener sweep. emitDestructor(lines, ctxType, libName, classified.dtor, @[]) - for m in classified.methods: + # A static is never scalar-fast-path (`isScalarOnly` gates on FFIKind.FFI). + for m in classified.replyProcs(): if m.scalarFastPath: emitAbiScalarMethod(lines, reg, ctxType, libName, libType, m) else: - emitAbiMethod(lines, reg, ctxType, libName, libType, m) + emitAbiProcWrapper(lines, reg, ctxType, libName, libType, m) lines.add("#endif /* " & guard & " */") return lines.join("\n") & "\n" diff --git a/ffi/codegen/c_cpp_common.nim b/ffi/codegen/c_cpp_common.nim index b23484c..3a26c8e 100644 --- a/ffi/codegen/c_cpp_common.nim +++ b/ffi/codegen/c_cpp_common.nim @@ -1,6 +1,6 @@ ## Helpers shared by the C/C++ binding generators (cpp.nim, c.nim). -import std/[strutils, options] +import std/strutils import ./meta, ./string_helpers proc stripLibPrefix*(procName, libName: string): string = @@ -18,25 +18,6 @@ proc reqStructName*(p: FFIProcMeta): string = else: camel & "Req" -type ClassifiedProcs* = object - ctors*: seq[FFIProcMeta] - methods*: seq[FFIProcMeta] - dtor*: Option[FFIProcMeta] - -proc classifyProcs*(procs: seq[FFIProcMeta]): ClassifiedProcs = - ## Splits the registry into constructors, methods and the first destructor. - var c: ClassifiedProcs - for p in procs: - case p.kind - of FFIKind.CTOR: - c.ctors.add(p) - of FFIKind.FFI: - c.methods.add(p) - of FFIKind.DTOR: - if c.dtor.isNone(): - c.dtor = some(p) - c - proc libTypeName*(ctors: seq[FFIProcMeta], libName: string): string = ## The library type name, from the first ctor or derived from `libName`. if ctors.len > 0: diff --git a/ffi/codegen/cddl.nim b/ffi/codegen/cddl.nim index 62cadd7..c05f870 100644 --- a/ffi/codegen/cddl.nim +++ b/ffi/codegen/cddl.nim @@ -110,7 +110,7 @@ proc responseRule(p: FFIProcMeta): string = of FFIKind.DTOR: # Dtor payload is a CBOR null sentinel. "nil" - of FFIKind.FFI: + of FFIKind.FFI, FFIKind.STATIC: if p.returnRidesAsPtr(): "uint" else: @@ -166,6 +166,7 @@ proc generateCddlSchema*( of FFIKind.CTOR: "ctor" of FFIKind.DTOR: "dtor" of FFIKind.FFI: "ffi" + of FFIKind.STATIC: "ffiStatic" L.add("; " & p.procName & " (" & kindTag & ")") L.add(renderDocComment(p.doc, "", "; ")) if p.kind != FFIKind.DTOR: diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index 2b52a31..b806c68 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -6,6 +6,9 @@ import ./meta, ./string_helpers, ./c_cpp_common, ./types_ir, ./consts ## Fixed 64-bit wire type for any Nim `ptr T` / `pointer`. const CppPtrType* = "uint64_t" +## Trailing param of every call that can't inherit a ctx's `timeout_`. +const CppTimeoutParam = "std::chrono::milliseconds timeout = std::chrono::seconds{30}" + const HeaderPreludeTpl = staticRead("templates/cpp/header_prelude.hpp.tpl") ResultTpl = staticRead("templates/cpp/result.hpp.tpl") @@ -319,6 +322,11 @@ proc generateCppHeader*( "int $1(void* ctx, FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);" % [p.procName] ) + of FFIKind.STATIC: + lines.add( + "int $1(FFICallback callback, void* user_data, const uint8_t* req_cbor, size_t req_cbor_len);" % + [p.procName] + ) of FFIKind.CTOR: lines.add( "void* $1(const uint8_t* req_cbor, size_t req_cbor_len, FFICallback callback, void* user_data);" % @@ -341,7 +349,6 @@ proc generateCppHeader*( let classified = classifyProcs(procs) let ctors = classified.ctors - let methods = classified.methods let ctxTypeName = libTypeName(ctors, libName) & "Ctx" lines.add("// ============================================================") @@ -363,12 +370,11 @@ proc generateCppHeader*( nimTypeToCpp(ep.typeName) ctorParams.add("const $1& $2" % [cppType, ep.name]) epNames.add(ep.name) - let timeoutParam = "std::chrono::milliseconds timeout = std::chrono::seconds{30}" let ctorParamsWithTimeout = if ctorParams.len > 0: - ctorParams.join(", ") & ", " & timeoutParam + ctorParams.join(", ") & ", " & CppTimeoutParam else: - timeoutParam + CppTimeoutParam let reqInit = cppBracedInit(reqName, epNames) @@ -444,7 +450,9 @@ proc generateCppHeader*( emitEventDispatcher(lines, ctxTypeName, libName, events) - for m in methods: + # A static has no ctx to inherit `timeout_` from, so it takes its own `timeout`. + for m in classified.replyProcs(): + let isStatic = m.isStatic() let methodName = stripLibPrefix(m.procName, libName) let retCppType = if m.returnRidesAsPtr(): @@ -463,14 +471,21 @@ proc generateCppHeader*( nimTypeToCpp(ep.typeName) methParams.add("const $1& $2" % [cppType, ep.name]) methParamNames.add(ep.name) - let methParamsStr = methParams.join(", ") let methParamNamesStr = methParamNames.join(", ") + let methParamsStr = + if not isStatic: + methParams.join(", ") + elif methParams.len > 0: + methParams.join(", ") & ", " & CppTimeoutParam + else: + CppTimeoutParam let reqInit = cppBracedInit(reqName, methParamNames) let methRet = "Result<$1>" % [retCppType] lines.add(renderMemberDocComment(m.doc)) - lines.add(" $1 $2($3) const {" % [methRet, methodName, methParamsStr]) + let decl = if isStatic: " static $1 $2($3) {" else: " $1 $2($3) const {" + lines.add(decl % [methRet, methodName, methParamsStr]) lines.add(" const auto ffi_req_ = $1;" % [reqInit]) lines.add(" auto ffi_enc_ = encodeCborFFI(ffi_req_);") lines.add( @@ -478,35 +493,46 @@ proc generateCppHeader*( ) lines.add(" const auto& ffi_req_bytes_ = ffi_enc_.value();") lines.add(" auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) {") + let ctxArg = if isStatic: "" else: "ptr_, " lines.add( - " return $1(ptr_, cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size());" % - [m.procName] + " return $1($2cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size());" % + [m.procName, ctxArg] ) - lines.add(" }, timeout_);") + lines.add(" }, $1);" % [if isStatic: "timeout" else: "timeout_"]) lines.add( " if (ffi_raw_.isErr()) return $1::err(ffi_raw_.error());" % [methRet] ) lines.add(" return decodeCborFFI<$1>(ffi_raw_.value());" % [retCppType]) lines.add(" }") lines.add("") - # `this->methodName(...)` so a same-named param can't shadow the call target. + + # A method calls `this->methodName(...)` so a same-named param can't shadow + # the call target; a static has no `this` and forwards its own `timeout`. + let staticArgs = + if methParamNames.len > 0: + methParamNamesStr & ", timeout" + else: + "timeout" + let asyncArgs = if isStatic: staticArgs else: methParamNamesStr + let asyncCapture = + if isStatic: + staticArgs + elif methParamNamesStr.len > 0: + "this, " & methParamNamesStr + else: + "this" + let asyncDecl = + if isStatic: + " static std::future<$1> $2Async($3) {" + else: + " std::future<$1> $2Async($3) const {" lines.add(renderMemberDocComment(m.doc)) - if methParamsStr.len > 0: - lines.add( - " std::future<$1> $2Async($3) const {" % [methRet, methodName, methParamsStr] - ) - lines.add( - " return std::async(std::launch::async, [this, $1]() { return this->$2($3); });" % - [methParamNamesStr, methodName, methParamNamesStr] - ) - lines.add(" }") - else: - lines.add(" std::future<$1> $2Async() const {" % [methRet, methodName]) - lines.add( - " return std::async(std::launch::async, [this]() { return this->$1(); });" % - [methodName] - ) - lines.add(" }") + lines.add(asyncDecl % [methRet, methodName, methParamsStr]) + lines.add( + " return std::async(std::launch::async, [$1]() { return $2$3($4); });" % + [asyncCapture, (if isStatic: "" else: "this->"), methodName, asyncArgs] + ) + lines.add(" }") lines.add("") lines.add("private:") diff --git a/ffi/codegen/meta.nim b/ffi/codegen/meta.nim index 7886d9a..a421547 100644 --- a/ffi/codegen/meta.nim +++ b/ffi/codegen/meta.nim @@ -1,7 +1,7 @@ ## Compile-time metadata types for FFI binding generation, populated by the ## {.ffiCtor.}/{.ffi.} macros and consumed by codegen. -import std/strutils +import std/[strutils, options] type ABIFormat* {.pure.} = enum @@ -20,6 +20,7 @@ type FFI CTOR DTOR + STATIC ## `{.ffiStatic.}`: context-independent, its wrapper takes no `ctx` FFIProcMeta* = object procName*: string @@ -146,6 +147,42 @@ var ffiEnumTypeNames* {.compileTime.}: seq[string] proc isFFIEnumTypeName*(name: string): bool {.compileTime.} = name in ffiEnumTypeNames +func isStatic*(p: FFIProcMeta): bool = + p.kind == FFIKind.STATIC + +type ClassifiedProcs* = object + ctors*: seq[FFIProcMeta] + methods*: seq[FFIProcMeta] + statics*: seq[FFIProcMeta] + dtor*: Option[FFIProcMeta] + +func classifyProcs*(procs: seq[FFIProcMeta]): ClassifiedProcs = + ## Splits the registry into constructors, methods, statics and the first destructor. + var c: ClassifiedProcs + for p in procs: + case p.kind + of FFIKind.CTOR: + c.ctors.add(p) + of FFIKind.FFI: + c.methods.add(p) + of FFIKind.STATIC: + c.statics.add(p) + of FFIKind.DTOR: + if c.dtor.isNone(): + c.dtor = some(p) + c + +func dtorProcName*(c: ClassifiedProcs): string = + ## The destructor's proc name, or "" when the library has no destructor. + if c.dtor.isSome(): + c.dtor.get().procName + else: + "" + +func replyProcs*(c: ClassifiedProcs): seq[FFIProcMeta] = + ## Procs that reply with a decoded value: methods and statics. + c.methods & c.statics + proc ridesAsPtr*(ep: FFIParamMeta): bool = ## True if the param crosses the wire as an opaque uint64 (raw ptr or handle). ep.isPtr or ep.isHandle diff --git a/ffi/codegen/rust.nim b/ffi/codegen/rust.nim index 4efe46e..eba0d13 100644 --- a/ffi/codegen/rust.nim +++ b/ffi/codegen/rust.nim @@ -185,9 +185,9 @@ proc generateFFIRs*(procs: seq[FFIProcMeta]): string = var params: seq[string] = @[] lines.add(renderMemberDocComment(p.doc)) case p.kind - of FFIKind.FFI: - # Method-style: ctx first. - params.add("ctx: *mut c_void") + of FFIKind.FFI, FFIKind.STATIC: + if not p.isStatic(): + params.add("ctx: *mut c_void") params.add("callback: FFICallback") params.add("user_data: *mut c_void") params.add("req_cbor: *const u8") @@ -304,18 +304,9 @@ proc generateApiRs*( ## Requests/responses are CBOR (ciborium); errors are raw UTF-8 strings. var lines: seq[string] = @[] - var ctors: seq[FFIProcMeta] = @[] - var methods: seq[FFIProcMeta] = @[] - var dtorProcName = "" - for p in procs: - case p.kind - of FFIKind.CTOR: - ctors.add(p) - of FFIKind.FFI: - methods.add(p) - of FFIKind.DTOR: - if dtorProcName.len == 0: - dtorProcName = p.procName + let classified = classifyProcs(procs) + let ctors = classified.ctors + let dtorProcName = classified.dtorProcName var libTypeName = "" if ctors.len > 0: @@ -700,7 +691,9 @@ proc generateApiRs*( lines.add(" }") lines.add("") - for m in methods: + # A static is an associated fn: no `&self` to read `timeout` from, so it takes one. + for m in classified.replyProcs(): + let isStatic = m.isStatic() let methodName = stripLibPrefix(m.procName, libName) let retRustType = nimTypeToRust(m.returnTypeName) let reqName = reqStructName(m) @@ -716,11 +709,15 @@ proc generateApiRs*( nimTypeToRust(ep.typeName) paramsList.add("$1: $2" % [snake, rustType]) fieldInits.add(snake) + if isStatic: + paramsList.add("timeout: Duration") let paramsStr = - if paramsList.len > 0: - ", " & paramsList.join(", ") + if isStatic: + paramsList.join(", ") + elif paramsList.len > 0: + "&self, " & paramsList.join(", ") else: - "" + "&self" let reqLit = if fieldInits.len > 0: @@ -729,18 +726,22 @@ proc generateApiRs*( reqName & " {}" let retTypeForApi = if m.returnRidesAsPtr(): RustPtrType else: retRustType + let timeoutExpr = if isStatic: "timeout" else: "self.timeout" + let ctxArg = if isStatic: "" else: "self.ptr, " lines.add(renderMemberDocComment(m.doc)) lines.add( - " pub fn $1(&self$2) -> Result<$3, String> {" % + " pub fn $1($2) -> Result<$3, String> {" % [methodName, paramsStr, retTypeForApi] ) lines.add(" let req = $1;" % [reqLit]) lines.add(" let req_bytes = encode_cbor(&req)?;") - lines.add(" let raw_bytes = ffi_call_sync(self.timeout, |cb, ud| unsafe {") lines.add( - " ffi::$1(self.ptr, cb, ud, req_bytes.as_ptr(), req_bytes.len())" % - [m.procName] + " let raw_bytes = ffi_call_sync($1, |cb, ud| unsafe {" % [timeoutExpr] + ) + lines.add( + " ffi::$1($2cb, ud, req_bytes.as_ptr(), req_bytes.len())" % + [m.procName, ctxArg] ) lines.add(" })?;") lines.add(" decode_cbor::<$1>(&raw_bytes)" % [retTypeForApi]) @@ -750,18 +751,21 @@ proc generateApiRs*( # async method: ptr cast to usize (Copy + Send) keeps the move closure and returned future Send for multi-threaded tokio runtimes. lines.add(renderMemberDocComment(m.doc)) lines.add( - " pub async fn $1_async(&self$2) -> Result<$3, String> {" % + " pub async fn $1_async($2) -> Result<$3, String> {" % [methodName, paramsStr, retTypeForApi] ) lines.add(" let req = $1;" % [reqLit]) lines.add(" let req_bytes = encode_cbor(&req)?;") - lines.add(" let ptr = self.ptr as usize;") + if not isStatic: + lines.add(" let ptr = self.ptr as usize;") lines.add( - " let raw_bytes = ffi_call_async(self.timeout, move |cb, ud| unsafe {" + " let raw_bytes = ffi_call_async($1, move |cb, ud| unsafe {" % [ + timeoutExpr + ] ) lines.add( - " ffi::$1(ptr as *mut c_void, cb, ud, req_bytes.as_ptr(), req_bytes.len())" % - [m.procName] + " ffi::$1($2cb, ud, req_bytes.as_ptr(), req_bytes.len())" % + [m.procName, if isStatic: "" else: "ptr as *mut c_void, "] ) lines.add(" }).await?;") lines.add(" decode_cbor::<$1>(&raw_bytes)" % [retTypeForApi]) diff --git a/ffi/ffi_context.nim b/ffi/ffi_context.nim index 7584f84..c3ccfc0 100644 --- a/ffi/ffi_context.nim +++ b/ffi/ffi_context.nim @@ -83,11 +83,6 @@ proc deinitContextResources*[T](ctx: ptr FFIContext[T]): Result[void, string] = closeAndNil(ctx.eventThreadExitSignal) ok() -proc cleanUpResources[T](ctx: ptr FFIContext[T]): Result[void, string] = - defer: - freeShared(ctx) - ctx.deinitContextResources() - template newSignalOrErr(field: untyped, name: string) = field = ThreadSignalPtr.new().valueOr: return err("couldn't create ThreadSignalPtr: " & name & ": " & $error) @@ -112,7 +107,8 @@ proc initContextResources*[T](ctx: ptr FFIContext[T]): Result[void, string] = var success = false defer: if not success: - ctx.cleanUpResources().isOkOr: + # `ctx` is a pool slot the caller owns; close what was opened, never free it. + ctx.deinitContextResources().isOkOr: error "failed to clean up resources after createFFIContext failure", error = error @@ -186,10 +182,3 @@ proc stopAndJoinThreads*[T](ctx: ptr FFIContext[T]): Result[void, string] = ?ctx.eventThreadExitSignal.waitExitOrErr("event thread", ThreadExitTimeout) joinThread(ctx.eventThread) ok() - -proc clearContext[T](ctx: ptr FFIContext[T]): Result[void, string] = - ctx.stopAndJoinThreads().isOkOr: - return err("clearContext: " & $error) - ctx.cleanUpResources().isOkOr: - return err("cleanUpResources failed: " & $error) - ok() diff --git a/ffi/ffi_context_pool.nim b/ffi/ffi_context_pool.nim index ac8ab0c..6c0f62d 100644 --- a/ffi/ffi_context_pool.nim +++ b/ffi/ffi_context_pool.nim @@ -1,13 +1,26 @@ -import std/atomics +import std/[atomics, sysatomics] import results import ./ffi_context const MaxFFIContexts* = 32 -type FFIContextPool*[T] = object - ## Fixed pool. Bounds ThreadSignalPtr fds at MaxFFIContexts * 2. - slots: array[MaxFFIContexts, FFIContext[T]] - inUse: array[MaxFFIContexts, Atomic[bool]] +type + StaticCtxState = enum + ## Lifecycle of the pool's `{.ffiStatic.}` context; see `staticFFIContext`. + StaticCtxNone + StaticCtxCreating + StaticCtxDestroying + StaticCtxReady + + FFIContextPool*[T] = object + ## Fixed pool of FFI contexts, plus the one `{.ffiStatic.}` context. + # Each live context holds 5 ThreadSignalPtrs — one fd each on Linux, two (a + # socketpair) elsewhere. Under refc a destroyed context cannot close them + # (see `deinitContextResources`), so churn leaks fds unbounded. + slots: array[MaxFFIContexts, FFIContext[T]] + inUse: array[MaxFFIContexts, Atomic[bool]] + staticCtx: Atomic[pointer] + staticState: Atomic[StaticCtxState] proc acquireSlot[T](pool: var FFIContextPool[T]): Result[ptr FFIContext[T], string] = for i in 0 ..< MaxFFIContexts: @@ -32,10 +45,19 @@ proc createFFIContext*[T]( return err("createFFIContext: initContextResources failed: " & $error) ok(ctx) +proc isStaticCtx[T](pool: var FFIContextPool[T], ctx: ptr FFIContext[T]): bool = + ## True while `ctx` is the pool's static context, including mid-teardown. + # `staticCtx` is cleared only once the slot is released, so matching on the + # pointer covers `Destroying` too. + pool.staticCtx.load() == cast[pointer](ctx) + proc destroyFFIContext*[T]( pool: var FFIContextPool[T], ctx: ptr FFIContext[T] ): Result[void, string] = ## On thread-exit timeout the slot is leaked; closing live-thread resources is unsafe. + # Destroying it would release the slot while `staticState` still points at it. + if pool.isStaticCtx(ctx): + return err("destroyFFIContext(pool): the {.ffiStatic.} context outlives every ctx") ctx.stopAndJoinThreads().isOkOr: return err("destroyFFIContext(pool): " & $error) # Required: next acquisition would otherwise re-init a live lock (UB). @@ -45,6 +67,52 @@ proc destroyFFIContext*[T]( return err("destroyFFIContext(pool): " & $error) ok() +proc staticFFIContext*[T]( + pool: var FFIContextPool[T] +): Result[ptr FFIContext[T], string] = + ## The pool's `{.ffiStatic.}` context, created on first use: a static proc has + ## no ctx of its own, but its handler still needs an FFI thread. + # Holds its slot until `destroyStaticFFIContext`, so `pool` must outlive its + # threads: only call this on the global `declareLibrary` emits. `myLib` stays + # the zero value. A failed create resets to `StaticCtxNone` so waiters retry. + while true: + case pool.staticState.load() + of StaticCtxReady: + return ok(cast[ptr FFIContext[T]](pool.staticCtx.load())) + of StaticCtxCreating, StaticCtxDestroying: + cpuRelax() + of StaticCtxNone: + var expected = StaticCtxNone + if not pool.staticState.compareExchange(expected, StaticCtxCreating): + continue + let ctx = pool.createFFIContext().valueOr: + pool.staticState.store(StaticCtxNone) + return err("staticFFIContext: " & error) + pool.staticCtx.store(cast[pointer](ctx)) + pool.staticState.store(StaticCtxReady) + return ok(ctx) + +proc destroyStaticFFIContext*[T](pool: var FFIContextPool[T]): Result[void, string] = + ## Teardown counterpart to `staticFFIContext`: stops the static context's + ## threads and frees its slot. A no-op when there is no static context. + # Claiming `Ready -> Destroying` serialises concurrent teardowns; it does not + # make teardown safe against a static call already in flight. + var expected = StaticCtxReady + if not pool.staticState.compareExchange(expected, StaticCtxDestroying): + return ok() + let ctx = cast[ptr FFIContext[T]](pool.staticCtx.load()) + ctx.stopAndJoinThreads().isOkOr: + # Threads are still live: leak the slot rather than free resources under them. + pool.staticState.store(StaticCtxReady) + return err("destroyStaticFFIContext: " & $error) + let deinitRes = ctx.deinitContextResources() + pool.releaseSlot(ctx) + pool.staticCtx.store(nil) + pool.staticState.store(StaticCtxNone) + deinitRes.isOkOr: + return err("destroyStaticFFIContext: " & $error) + ok() + proc isValidCtx*[T](pool: var FFIContextPool[T], ctx: pointer): bool = ## Rejects nil / dangling pointers at the API boundary. if ctx.isNil(): diff --git a/ffi/internal/c_macro_helpers.nim b/ffi/internal/c_macro_helpers.nim index 59ed6e6..a134f71 100644 --- a/ffi/internal/c_macro_helpers.nim +++ b/ffi/internal/c_macro_helpers.nim @@ -540,6 +540,7 @@ type CAbiKind = enum cakMethod cakCtor + cakStatic CAbiSpec = object kind: CAbiKind @@ -561,18 +562,20 @@ proc copyTypes(types: seq[NimNode]): seq[NimNode] {.compileTime.} = res.add(t.copyNimTree()) res -proc registerCAbiMethod*( +proc registerCAbiProc*( + isStatic: bool, exportName: string, libType, envelope: NimNode, paramNames: seq[string], paramTypes: seq[NimNode], respType, handler: NimNode, ) {.compileTime.} = - ## Record an `abi = c` method for `flushCAbiDispatch`. Nodes are `copyNimTree` - ## frozen: reusing the Req section's originals (bound to `nnkSym`) would ICE. + ## Record an `abi = c` method (or `{.ffiStatic.}` proc) for `flushCAbiDispatch`. + ## Nodes are `copyNimTree` frozen: reusing the Req section's originals (bound to + ## `nnkSym`) would ICE. cAbiSpecs.add( CAbiSpec( - kind: cakMethod, + kind: if isStatic: cakStatic else: cakMethod, exportName: exportName, libType: libType.copyNimTree(), envelope: envelope.copyNimTree(), @@ -590,7 +593,7 @@ proc registerCAbiCtor*( paramTypes: seq[NimNode], handler: NimNode, ) {.compileTime.} = - ## Record an `abi = c` ctor for `flushCAbiDispatch`; see `registerCAbiMethod` + ## Record an `abi = c` ctor for `flushCAbiDispatch`; see `registerCAbiProc` ## for why nodes are `copyNimTree` frozen. cAbiSpecs.add( CAbiSpec( @@ -703,13 +706,48 @@ proc stringTrampBody(boxName: NimNode): NimNode = except CatchableError as e: box.fn(RET_ERR, "".cstring, e.msg.cstring, box.ud) -proc exportedMethodProc( - spec: CAbiSpec, boxName, envWire, trampName, poolIdent, cbType: NimNode +proc ctxBindingGuard( + poolIdent, emptyReply, ctxIdent: NimNode, isStatic: bool +): NimNode {.compileTime.} = + ## Prologue that binds `ctxIdent`: a method validates the ctx it was handed, a + ## static resolves the library's shared one. + if not isStatic: + return quote: + if onReply.isNil(): + return RET_MISSING_CALLBACK + if not `poolIdent`.isValidCtx(cast[pointer](`ctxIdent`)): + onReply( + RET_ERR, `emptyReply`, "ctx is not a valid FFI context".cstring, userData + ) + return RET_ERR + let guard = quote: + if onReply.isNil(): + return RET_MISSING_CALLBACK + let `ctxIdent` = `poolIdent`.staticFFIContext().valueOr: + let errStr = "ffiStatic: " & error + onReply(RET_ERR, `emptyReply`, errStr.cstring, userData) + return RET_ERR + # A static call may be the host's first entry into the library. Raw AST, not + # `quote`: `when declared` over an undeclared symbol inside `quote` ICEs. + guard.insert( + 0, + nnkWhenStmt.newTree( + nnkElifBranch.newTree( + newCall(ident("declared"), ident("initializeLibrary")), + newStmtList(newCall(ident("initializeLibrary"))), + ) + ), + ) + guard + +proc exportedProc( + spec: CAbiSpec, + boxName, envWire, trampName, poolIdent, cbType: NimNode, + isStatic: bool, ): NimNode = # No `foreignThreadGc`: `cwireUnpack`/`cwirePack` alloc on the calling thread (already GC-registered); wrapping would free its live ORC heap. let envName = spec.envelope - let libFFICtx = - nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), spec.libType)) + let ctxIdent = ident("ctx") # String reply: empty non-nil cstring on error; object reply: nil ptr gated by err_code. let emptyReply = if isStringType(spec.respType): @@ -717,11 +755,6 @@ proc exportedMethodProc( else: newNilLit() let body = quote: - if onReply.isNil(): - return RET_MISSING_CALLBACK - if not `poolIdent`.isValidCtx(cast[pointer](ctx)): - onReply(RET_ERR, `emptyReply`, "ctx is not a valid FFI context".cstring, userData) - return RET_ERR var ownedWire: `envWire` cwirePack(ownedWire, cwireUnpack(req[])) let ownedCopy = cwireOwnedCopy(ownedWire) @@ -739,7 +772,7 @@ proc exportedMethodProc( ) let sendRes = try: - ffi_context.sendRequestToFFIThread(ctx, reqPtr) + ffi_context.sendRequestToFFIThread(`ctxIdent`, reqPtr) except Exception as e: Result[void, string].err("sendRequestToFFIThread exception: " & e.msg) if sendRes.isErr(): @@ -750,16 +783,26 @@ proc exportedMethodProc( onReply(RET_ERR, `emptyReply`, sendRes.error.cstring, userData) return RET_ERR return RET_OK + + let fullBody = ctxBindingGuard(poolIdent, emptyReply, ctxIdent, isStatic) + for stmt in body: + fullBody.add(stmt) + + var params = @[ + ident("cint"), + newIdentDefs(ident("onReply"), cbType), + newIdentDefs(ident("userData"), ident("pointer")), + newIdentDefs(ident("req"), nnkPtrTy.newTree(envWire)), + ] + if not isStatic: + let libFFICtx = + nnkPtrTy.newTree(nnkBracketExpr.newTree(ident("FFIContext"), spec.libType)) + params.insert(newIdentDefs(ctxIdent, libFFICtx), 1) + newProc( name = ident($envName & "CAbiExport"), - params = @[ - ident("cint"), - newIdentDefs(ident("ctx"), libFFICtx), - newIdentDefs(ident("onReply"), cbType), - newIdentDefs(ident("userData"), ident("pointer")), - newIdentDefs(ident("req"), nnkPtrTy.newTree(envWire)), - ], - body = body, + params = params, + body = fullBody, pragmas = nnkPragma.newTree( ident("dynlib"), nnkExprColonExpr.newTree(ident("exportc"), newStrLitNode(spec.exportName)), @@ -874,26 +917,30 @@ proc flushCAbiDispatch*(): NimNode {.compileTime.} = sink.add(boxTypeDef(boxName, cbType)) sink.add(replyTrampProc(trampName, stringTrampBody(boxName))) sink.add(exportedCtorProc(spec, boxName, envWire, trampName, poolIdent, cbType)) - of cakMethod: + of cakMethod, cakStatic: + let isStatic = spec.kind == cakStatic let rt = spec.respType if isStringType(rt): let cbType = cAbiCbType(ident("cstring")) sink.add(boxTypeDef(boxName, cbType)) sink.add(replyTrampProc(trampName, stringTrampBody(boxName))) sink.add( - exportedMethodProc(spec, boxName, envWire, trampName, poolIdent, cbType) + exportedProc(spec, boxName, envWire, trampName, poolIdent, cbType, isStatic) ) - elif rt.kind == nnkIdent: + # `isKnownFFIType`, not just `nnkIdent`: a bare `int` is an ident too, and + # would otherwise reach for a `int_CWire` companion that is never emitted. + elif rt.kind == nnkIdent and isKnownFFIType($rt): let respWire = ident(cwireTypeName($rt)) let cbType = cAbiCbType(nnkPtrTy.newTree(respWire)) sink.add(boxTypeDef(boxName, cbType)) sink.add(replyTrampProc(trampName, objectTrampBody(boxName, respWire))) sink.add( - exportedMethodProc(spec, boxName, envWire, trampName, poolIdent, cbType) + exportedProc(spec, boxName, envWire, trampName, poolIdent, cbType, isStatic) ) else: error( "abi = c: unsupported response type for proc '" & spec.exportName & "': " & - rt.repr & " (only object and string returns are wired)" + rt.repr & " — reply with a `string` or an `{.ffi.}` object type. " & + "A scalar return is wired only for an all-scalar `{.ffi.}` method." ) sink diff --git a/ffi/internal/ffi_macro.nim b/ffi/internal/ffi_macro.nim index e46fda2..2915811 100644 --- a/ffi/internal/ffi_macro.nim +++ b/ffi/internal/ffi_macro.nim @@ -271,11 +271,13 @@ proc unpackHandleField*( return err(`errPrefix` & error) cast[`userType`](ffiH) -proc cExportedParams(ctxType: NimNode): seq[NimNode] = - ## C-exported wrapper param list (cint; ctx, callback, userData, reqCbor, reqCborLen). +proc cExportedParams(ctxType: NimNode, withCtx = true): seq[NimNode] = + ## C-exported wrapper param list (cint; ctx, callback, userData, reqCbor, + ## reqCborLen). A `{.ffiStatic.}` wrapper drops the leading `ctx`. var params: seq[NimNode] = @[] params.add(ident("cint")) - params.add(newIdentDefs(ident("ctx"), ctxType)) + if withCtx: + params.add(newIdentDefs(ident("ctx"), ctxType)) params.add(newIdentDefs(ident("callback"), ident("FFICallBack"))) params.add(newIdentDefs(ident("userData"), ident("pointer"))) params.add(newIdentDefs(ident("reqCbor"), nnkPtrTy.newTree(ident("byte")))) @@ -807,44 +809,37 @@ macro ffiConst*(args: varargs[untyped]): untyped = echo stmts.repr return stmts -macro ffi*(args: varargs[untyped]): untyped = - ## Simplified FFI macro for procs or types: a type registers for binding gen; a - ## proc takes a library-type param plus optional Nim params, returns - ## Future[Result[RetType, string]], and gets a C wrapper taking one CBOR buffer. - requireBeforeGenBindings("`.ffi.`") - # Annotated node is the last vararg; leading args are `"abi = ..."` specs. - let prc = args[^1] - let abiFormat = resolveFFISpecs(args[0 ..^ 2]) - - # A value type stands alone (no library required); its `c` companion is emitted later by `genBindings()`, since a type-pragma macro can only return a TypeDef. - if prc.kind == nnkTypeDef: - gateFFITypeABIFormat(abiFormat, "`.ffi.` type") - var cleanTypeDef = prc.copyNimTree() - if cleanTypeDef[0].kind == nnkPragmaExpr: - cleanTypeDef[0] = cleanTypeDef[0][0] - return registerFFITypeInfo(cleanTypeDef, abiFormat) - - requireLibraryDeclared("`.ffi.`") +proc buildFFIProc( + prc: NimNode, abiFormat: ABIFormat, isStatic: bool +): NimNode {.compileTime.} = + ## Shared body of `{.ffi.}` and `{.ffiStatic.}`. A static has no library receiver: + ## its wire params start at param 1 and its C wrapper binds the static context. + let where = if isStatic: "`.ffiStatic.`" else: "`.ffi.`" let procName = prc[0] let formalParams = prc[3] let bodyNode = prc[^1] - if formalParams.len < 2: + if not isStatic and formalParams.len < 2: error("`.ffi.` procs require at least 1 parameter (the library type)") - let firstParam = formalParams[1] - let recvName = firstParam[0] - let recvType = firstParam[1] - let firstIsHandle = isHandleType(recvType) - if firstIsHandle and currentLibType.len == 0: + var recvName, recvType: NimNode = newEmptyNode() + var firstIsHandle = false + if not isStatic: + let firstParam = formalParams[1] + recvName = firstParam[0] + recvType = firstParam[1] + firstIsHandle = isHandleType(recvType) + if (firstIsHandle or isStatic) and currentLibType.len == 0: + let why = + if isStatic: " takes no library param" else: " has an {.ffiHandle.} receiver" error( - "`.ffi.` proc " & $procName & " has an {.ffiHandle.} receiver but no " & - "library is declared; call declareLibrary(name, LibType) first" + where & " proc " & $procName & why & " but no library is declared; " & + "call declareLibrary(name, LibType) first" ) - # A handle receiver carries no library type, so fall back to the declared one. + # Neither carries a library type, so fall back to the declared one. let libTypeName = - if firstIsHandle: + if firstIsHandle or isStatic: ident(currentLibType) else: recvType @@ -852,31 +847,43 @@ macro ffi*(args: varargs[untyped]): untyped = let retTypeNode = formalParams[0] if retTypeNode.kind == nnkEmpty: error( - "`.ffi.` proc must have an explicit return type Future[Result[RetType, string]]" + where & " proc must have an explicit return type Future[Result[RetType, string]]" ) if retTypeNode.kind != nnkBracketExpr or $retTypeNode[0] != "Future": error( - "`.ffi.` return type must be Future[Result[RetType, string]], got: " & + where & " return type must be Future[Result[RetType, string]], got: " & retTypeNode.repr ) let resultInner = retTypeNode[1] if resultInner.kind != nnkBracketExpr or $resultInner[0] != "Result": error( - "`.ffi.` return type must be Future[Result[RetType, string]], got: " & + where & " return type must be Future[Result[RetType, string]], got: " & retTypeNode.repr ) let resultRetType = resultInner[1] - rejectRawPtrType(resultRetType, "`.ffi.` proc " & $procName & " return type") + rejectRawPtrType(resultRetType, where & " proc " & $procName & " return type") + # An {.ffiHandle.} lives in one ctx's registry, which a static proc cannot reach. + if isStatic and isHandleType(resultRetType): + error( + where & " proc " & $procName & " returns the {.ffiHandle.} type " & $resultRetType & + "; a handle belongs to a context. Make it an `{.ffi.}` method instead." + ) # A handle receiver rides the wire; a value-type lib receiver binds to ctx.myLib. var extraParamNames: seq[string] = @[] var extraParamTypes: seq[NimNode] = @[] - let wireStart = if firstIsHandle: 1 else: 2 + let wireStart = if isStatic or firstIsHandle: 1 else: 2 for i in wireStart ..< formalParams.len: let p = formalParams[i] for j in 0 ..< p.len - 2: - rejectRawPtrType(p[^2], "`.ffi.` proc " & $procName & " parameter " & $p[j]) + rejectRawPtrType(p[^2], where & " proc " & $procName & " parameter " & $p[j]) + if isStatic and isHandleType(p[^2]): + error( + where & " proc " & $procName & " takes the {.ffiHandle.} parameter " & $p[j] & + ": " & $p[^2] & "; a handle belongs to a context. " & + "Make it an `{.ffi.}` method instead." + ) extraParamNames.add($p[j]) extraParamTypes.add(p[^2]) @@ -927,7 +934,7 @@ macro ffi*(args: varargs[untyped]): untyped = let procMeta = FFIProcMeta( procName: cExportName, libName: currentLibName, - kind: FFIKind.FFI, + kind: if isStatic: FFIKind.STATIC else: FFIKind.FFI, libTypeName: $libTypeName, extraParams: wireParamMetas, returnTypeName: retTn, @@ -954,6 +961,20 @@ macro ffi*(args: varargs[untyped]): untyped = callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData) return RET_ERR + proc buildStaticCtxGuard(): NimNode = + ## Binds the library's static context; a static call may be the host's first + ## entry, hence `initializeLibrary`. + # `ctxIdent` is substituted so the send below sees it (`quote` gensyms). + let ctxIdent = ident("ctx") + quote: + initializeLibrary() + if callback.isNil(): + return RET_MISSING_CALLBACK + let `ctxIdent` = `poolIdent`.staticFFIContext().valueOr: + let errStr = "ffiStatic: " & error + callback(RET_ERR, unsafeAddr errStr[0], cast[csize_t](errStr.len), userData) + return RET_ERR + proc buildSendAndReply(reqPtrIdent: NimNode): NimNode = ## Hands `reqPtrIdent` to the FFI thread and maps the outcome to a C return code. let sendResIdent = genSym(nskLet, "sendRes") @@ -988,8 +1009,10 @@ macro ffi*(args: varargs[untyped]): untyped = ## Reproduces the user's exact signature so it stays callable from Nim. var helperParams = newSeq[NimNode]() helperParams.add(retTypeNode) - helperParams.add(newIdentDefs(recvName, recvType)) - for i in 2 ..< formalParams.len: + let helperStart = if isStatic: 1 else: 2 + if not isStatic: + helperParams.add(newIdentDefs(recvName, recvType)) + for i in helperStart ..< formalParams.len: let p = formalParams[i] for j in 0 ..< p.len - 2: helperParams.add(newIdentDefs(p[j], p[^2])) @@ -1015,7 +1038,7 @@ macro ffi*(args: varargs[untyped]): untyped = lambdaParams.add(newIdentDefs(ident(extraParamNames[i]), extraParamTypes[i])) let helperCall = newTree(nnkCall, userProcName) - if not firstIsHandle: + if not firstIsHandle and not isStatic: let ctxMyLib = newDotExpr(newTree(nnkDerefExpr, ctxHandlerName), ident("myLib")) helperCall.add(newTree(nnkDerefExpr, ctxMyLib)) for name in extraParamNames: @@ -1040,10 +1063,17 @@ macro ffi*(args: varargs[untyped]): untyped = `lambdaNode` # C-exported wrapper: (ctx, callback, userData, reqCbor, reqCborLen). - let exportedParams = cExportedParams(ctxType) + let exportedParams = cExportedParams(ctxType, withCtx = not isStatic) let ffiBody = newStmtList() - ffiBody.add buildCtxGuard() + # Flattened: the guard's `let ctx` must be a sibling of the send to be in scope. + let guard = + if isStatic: + buildStaticCtxGuard() + else: + buildCtxGuard() + for stmt in guard: + ffiBody.add(stmt) let reqPtrIdent = genSym(nskLet, "reqPtr") ffiBody.add quote do: @@ -1064,9 +1094,9 @@ macro ffi*(args: varargs[untyped]): untyped = buildProcessFFIRequestProc(reqTypeName, handlerParam, lambdaNode, ABIFormat.C), addNewRequestToRegistry(reqTypeName, handlerParam, resultRetType, ABIFormat.C), ) - registerCAbiMethod( - cExportName, libTypeName, reqTypeName, extraParamNames, extraParamTypes, - resultRetType, handler, + registerCAbiProc( + isStatic, cExportName, libTypeName, reqTypeName, extraParamNames, + extraParamTypes, resultRetType, handler, ) return newStmtList(helperProc, buildRequestType(reqTypeName, lambdaNode)) @@ -1101,6 +1131,38 @@ macro ffi*(args: varargs[untyped]): untyped = echo stmts.repr return stmts +macro ffi*(args: varargs[untyped]): untyped = + ## Simplified FFI macro for procs or types: a type registers for binding gen; a + ## proc takes a library-type param plus optional Nim params, returns + ## Future[Result[RetType, string]], and gets a C wrapper taking one CBOR buffer. + requireBeforeGenBindings("`.ffi.`") + # Annotated node is the last vararg; leading args are `"abi = ..."` specs. + let prc = args[^1] + let abiFormat = resolveFFISpecs(args[0 ..^ 2]) + + # A value type stands alone (no library required); its `c` companion is emitted later by `genBindings()`, since a type-pragma macro can only return a TypeDef. + if prc.kind == nnkTypeDef: + gateFFITypeABIFormat(abiFormat, "`.ffi.` type") + var cleanTypeDef = prc.copyNimTree() + if cleanTypeDef[0].kind == nnkPragmaExpr: + cleanTypeDef[0] = cleanTypeDef[0][0] + return registerFFITypeInfo(cleanTypeDef, abiFormat) + + requireLibraryDeclared("`.ffi.`") + return buildFFIProc(prc, abiFormat, isStatic = false) + +macro ffiStatic*(args: varargs[untyped]): untyped = + ## Context-independent `{.ffi.}`: no library receiver, and no `ctx` in the C + ## wrapper, so a host calls it without constructing the library. + requireBeforeGenBindings("`.ffiStatic.`") + requireLibraryDeclared("`.ffiStatic.`") + let prc = args[^1] + let abiFormat = resolveFFISpecs(args[0 ..^ 2]) + gateABIFormat(abiFormat, "`.ffiStatic.` proc") + if prc.kind notin {nnkProcDef, nnkFuncDef}: + error("`.ffiStatic.` must be applied to a proc definition") + return buildFFIProc(prc, abiFormat, isStatic = true) + proc buildCtorRequestType( reqTypeName: NimNode, paramNames: seq[string], paramTypes: seq[NimNode] ): NimNode = diff --git a/tests/e2e/c_abi/test_echo_c_abi.c b/tests/e2e/c_abi/test_echo_c_abi.c index e153b09..b9398a6 100644 --- a/tests/e2e/c_abi/test_echo_c_abi.c +++ b/tests/e2e/c_abi/test_echo_c_abi.c @@ -118,12 +118,35 @@ static void test_version(EchoCtx* ctx) { assert(strcmp(w.text_a, "nim-echo v0.1.0") == 0); } +/* No EchoCtx: this call creates the library's static context. Runs before + * make_ctx() so nothing else has initialised the Nim runtime first. */ +static void test_static_no_ctx(void) { + ReplyWaiter v; + memset(&v, 0, sizeof(v)); + echo_static_lib_version(on_version, &v); + wait_done(&v.done); + assert(v.err_code == 0); + assert(strcmp(v.text_a, "nim-echo v0.1.0") == 0); + + ReplyWaiter s; + memset(&s, 0, sizeof(s)); + ShoutRequest req = {"hello"}; + echo_static_shout_anon(&req, on_shout, &s); + wait_done(&s.done); + assert(s.err_code == 0); + assert(strcmp(s.text_a, "HELLO") == 0); + assert(strcmp(s.text_b, "") == 0); +} + int main(void) { + test_static_no_ctx(); EchoCtx* ctx = make_ctx(); test_shout(ctx); test_shout_too_long(ctx); test_version(ctx); assert(echo_ctx_destroy(ctx) == NIMFFI_RET_OK); + /* A static still works after every ctx is gone: its context is its own. */ + test_static_no_ctx(); printf("all abi=c echo e2e checks passed\n"); return 0; } diff --git a/tests/e2e/cpp/test_timer_e2e.cpp b/tests/e2e/cpp/test_timer_e2e.cpp index 469de9c..27e8958 100644 --- a/tests/e2e/cpp/test_timer_e2e.cpp +++ b/tests/e2e/cpp/test_timer_e2e.cpp @@ -247,6 +247,38 @@ TEST(TimerE2E, CrossLibrary) { EXPECT_EQ(e.shouted, "X-ECHO: ASYNC-E"); } +// No EchoCtx is constructed anywhere in this test. +TEST(TimerE2E, StaticProcNeedsNoContext) { + EXPECT_EQ(mustOk(EchoCtx::lib_version()), "nim-echo v0.1.0"); + + const auto resp = mustOk(EchoCtx::shout_anon(ShoutRequest{"hello"})); + EXPECT_EQ(resp.shouted, "HELLO"); + EXPECT_EQ(resp.prefix, ""); +} + +// The static context is created once, on demand, and shared by every caller. +TEST(TimerE2E, StaticProcConcurrentFirstCall) { + constexpr int kThreads = 8; + + std::vector>> futs; + futs.reserve(kThreads); + for (int i = 0; i < kThreads; ++i) { + futs.push_back(EchoCtx::shout_anonAsync(ShoutRequest{"race" + std::to_string(i)})); + } + for (int i = 0; i < kThreads; ++i) { + EXPECT_EQ(mustOk(futs[i].get()).shouted, "RACE" + std::to_string(i)); + } +} + +// A static call and a ctx call must not disturb each other's state. +TEST(TimerE2E, StaticProcCoexistsWithContext) { + auto ctx = mustOk(EchoCtx::create(EchoConfig{"WITH-CTX"})); + + EXPECT_EQ(mustOk(ctx->shout(ShoutRequest{"a"})).prefix, "WITH-CTX"); + EXPECT_EQ(mustOk(EchoCtx::shout_anon(ShoutRequest{"b"})).prefix, ""); + EXPECT_EQ(mustOk(ctx->shout(ShoutRequest{"c"})).prefix, "WITH-CTX"); +} + // Chained async calls A->B->C must preserve ordering and payload across hops. TEST(TimerE2E, TriplePipeline) { auto ctx = makeCtx("pipeline"); diff --git a/tests/unit/fixtures/ffistatic_abi_c_scalar_fixture.nim b/tests/unit/fixtures/ffistatic_abi_c_scalar_fixture.nim new file mode 100644 index 0000000..9d9e3d2 --- /dev/null +++ b/tests/unit/fixtures/ffistatic_abi_c_scalar_fixture.nim @@ -0,0 +1,22 @@ +## Must fail: no `abi = c` reply shape for a static's scalar return, and the error +## must say so rather than die on an undeclared `int_CWire`. + +import ffi, chronos + +type ScalarLib = object + base: int + +declareLibrary("staticscalar", ScalarLib, defaultABIFormat = "c") + +type ScalarConfig {.ffi.} = object + base: int + +proc staticscalarCreate*( + cfg: ScalarConfig +): Future[Result[ScalarLib, string]] {.ffiCtor.} = + return ok(ScalarLib(base: cfg.base)) + +proc staticscalarAdd*(a: int, b: int): Future[Result[int, string]] {.ffiStatic.} = + return ok(a + b) + +genBindings() diff --git a/tests/unit/fixtures/ffistatic_handle_param_fixture.nim b/tests/unit/fixtures/ffistatic_handle_param_fixture.nim new file mode 100644 index 0000000..577bc73 --- /dev/null +++ b/tests/unit/fixtures/ffistatic_handle_param_fixture.nim @@ -0,0 +1,16 @@ +## Must fail: `{.ffiStatic.}` handle param (see tests/unit/test_ffistatic_reject.nim). + +import ffi, chronos + +type StaticLib = object + base: int + +declareLibrary("staticrej", StaticLib) + +type Session {.ffiHandle.} = ref object + id: int + +proc staticrejBad*(s: Session): Future[Result[int, string]] {.ffiStatic.} = + return ok(s.id) + +genBindings() diff --git a/tests/unit/fixtures/ffistatic_handle_return_fixture.nim b/tests/unit/fixtures/ffistatic_handle_return_fixture.nim new file mode 100644 index 0000000..c6c795c --- /dev/null +++ b/tests/unit/fixtures/ffistatic_handle_return_fixture.nim @@ -0,0 +1,16 @@ +## Must fail: `{.ffiStatic.}` handle return (see tests/unit/test_ffistatic_reject.nim). + +import ffi, chronos + +type StaticLib = object + base: int + +declareLibrary("staticrej", StaticLib) + +type Session {.ffiHandle.} = ref object + id: int + +proc staticrejBad*(): Future[Result[Session, string]] {.ffiStatic.} = + return ok(Session(id: 1)) + +genBindings() diff --git a/tests/unit/fixtures/ffistatic_ok_fixture.nim b/tests/unit/fixtures/ffistatic_ok_fixture.nim new file mode 100644 index 0000000..4bccca5 --- /dev/null +++ b/tests/unit/fixtures/ffistatic_ok_fixture.nim @@ -0,0 +1,20 @@ +## Must compile: the same shapes, with no handle crossing a static's boundary. + +import ffi, chronos + +type StaticLib = object + base: int + +declareLibrary("staticrej", StaticLib) + +type Session {.ffiHandle.} = ref object + id: int + +proc staticrejFine*(n: int): Future[Result[int, string]] {.ffiStatic.} = + return ok(n + 1) + +proc staticrejOpen*(lib: StaticLib): Future[Result[Session, string]] {.ffi.} = + ## A handle is fine on a method: it lives in the caller's own context. + return ok(Session(id: lib.base)) + +genBindings() diff --git a/tests/unit/test_c_abi_codegen.nim b/tests/unit/test_c_abi_codegen.nim index 8df85d0..a83faf7 100644 --- a/tests/unit/test_c_abi_codegen.nim +++ b/tests/unit/test_c_abi_codegen.nim @@ -77,6 +77,14 @@ suite "generateCAbiLibHeader": returnTypeName: "float32", scalarFastPath: true, ), + FFIProcMeta( + procName: "timer_parse", + libName: "timer", + kind: FFIKind.STATIC, + libTypeName: "Timer", + extraParams: @[param("req", "EchoRequest")], + returnTypeName: "EchoResponse", + ), FFIProcMeta( procName: "timer_destroy", libName: "timer", @@ -180,6 +188,17 @@ static inline int timer_ctx_destroy(TimerCtx* ctx) { header check "if (n == SIZE_MAX) return NULL;" in header + test "a static's raw symbol and wrapper both drop the ctx": + check "int timer_parse(TimerParseReplyFn on_reply, void* user_data, " & + "const TimerParseReq* req);" in header + check "timer_static_parse(const EchoRequest* req, TimerParseReplyFn on_reply, void* user_data)" in + header + check "return timer_parse(on_reply, user_data, &ffi_req);" in header + + test "a static replies through the same typed ReplyFn surface as a method": + check "typedef void (*TimerParseReplyFn)(int err_code, const EchoResponse* reply," in + header + test "events are rejected (CBOR-only for now)": expect ValueError: discard generateCAbiLibHeader( diff --git a/tests/unit/test_c_codegen.nim b/tests/unit/test_c_codegen.nim index 972a8f5..c8504ae 100644 --- a/tests/unit/test_c_codegen.nim +++ b/tests/unit/test_c_codegen.nim @@ -161,6 +161,65 @@ static inline int timer_ctx_destroy(TimerCtx* ctx) { test "an empty request envelope still encodes a (zero-length) map": check "_nimffi_empty" in header +suite "generateCLibHeader: context-independent procs": + setup: + let procs = @[ + FFIProcMeta( + procName: "timer_create", + libName: "timer", + kind: FFIKind.CTOR, + libTypeName: "Timer", + extraParams: @[param("config", "EchoRequest")], + returnTypeName: "Timer", + ), + FFIProcMeta( + procName: "timer_version", + libName: "timer", + kind: FFIKind.FFI, + libTypeName: "Timer", + extraParams: @[], + returnTypeName: "string", + ), + FFIProcMeta( + procName: "timer_parse", + libName: "timer", + kind: FFIKind.STATIC, + libTypeName: "Timer", + extraParams: @[param("req", "EchoRequest")], + returnTypeName: "EchoResponse", + ), + ] + let types = @[ + FFITypeMeta(name: "EchoRequest", fields: @[field("m", "string")]), + FFITypeMeta(name: "EchoResponse", fields: @[field("echoed", "string")]), + ] + let header = generateCLibHeader(procs, types, "timer") + + test "the static's raw symbol takes no ctx": + check "int timer_parse(FFICallback callback, void* user_data, " & + "const uint8_t* req_cbor, size_t req_cbor_len);" in header + + test "its wrapper is _static_-namespaced and takes neither ctx nor timeout": + check "timer_static_parse(const EchoRequest* req, TimerParseReplyFn on_reply, void* user_data)" in + header + check "timer_ctx_parse(" notin header + + test "the wrapper calls the raw symbol without a ctx argument": + check "timer_parse(timer_parse_reply_trampoline, box, req_buf, req_len);" in header + + test "a static gets the same reply machinery as a method": + check "typedef void (*TimerParseReplyFn)(int err_code, const EchoResponse* reply, " & + "const char* err_msg, void* user_data);" in header + check "TimerParseCallBox" in header + check "timer_parse_reply_trampoline(" in header + + test "its return type is monomorphised into the codecs": + check "timer_decv_EchoResponse" in header + + test "methods keep their ctx": + check "int timer_version(void* ctx, FFICallback callback" in header + check "timer_ctx_version(const TimerCtx* ctx," in header + suite "generateCLibHeader: events": setup: let procs = @[ diff --git a/tests/unit/test_ffi_context.nim b/tests/unit/test_ffi_context.nim index 247142e..41bdedf 100644 --- a/tests/unit/test_ffi_context.nim +++ b/tests/unit/test_ffi_context.nim @@ -109,6 +109,12 @@ registerReqFFI(HeavyRefAllocRequest, lib: ptr TestLib): await sleepAsync(10.milliseconds) return ok("heavy-done") +# Global, as declareLibrary emits it: a static ctx's threads may outlive any scope. +# One pool for every slot-accounting case below — under refc a destroyed context +# can't close its five ThreadSignalPtrs, so a second 32-slot fill would put the +# suite over the 1024-fd limit. +var staticPool: FFIContextPool[TestLib] + suite "FFIContextPool": test "create and destroy via pool succeeds": var pool: FFIContextPool[TestLib] @@ -129,18 +135,48 @@ suite "FFIContextPool": check pool.destroyFFIContext(ctx2).isOk() check ctx1 == ctx2 - test "pool exhaustion returns error": - var pool: FFIContextPool[TestLib] - var ctxs: array[MaxFFIContexts, ptr FFIContext[TestLib]] - for i in 0 ..< MaxFFIContexts: - ctxs[i] = pool.createFFIContext().valueOr: - for j in 0 ..< i: - discard pool.destroyFFIContext(ctxs[j]) - assert false, "createFFIContext(pool) failed at slot " & $i & ": " & $error - return - check pool.createFFIContext().isErr() - for i in 0 ..< MaxFFIContexts: - discard pool.destroyFFIContext(ctxs[i]) + # Each static case tears its pool back down on every exit path: left running + # under refc the threads race later suites' allocation and GC (macOS SIGSEGV). + test "staticFFIContext returns one shared context and refuses destruction": + defer: + check staticPool.destroyStaticFFIContext().isOk() + let first = staticPool.staticFFIContext().valueOr: + assert false, "staticFFIContext failed: " & $error + return + check staticPool.staticFFIContext().tryGet() == first + # Occupies a pool slot like any other context. + check staticPool.isValidCtx(first) + check staticPool.destroyFFIContext(first).isErr() + # Still live, and still the same context. + check staticPool.staticFFIContext().tryGet() == first + + test "pool exhaustion errors and leaves staticFFIContext retryable": + var filler: seq[ptr FFIContext[TestLib]] + defer: + check staticPool.destroyStaticFFIContext().isOk() + for c in filler: + check staticPool.destroyFFIContext(c).isOk() + + check staticPool.staticFFIContext().isOk() + var c = staticPool.createFFIContext() + while c.isOk(): + filler.add(c.tryGet()) + c = staticPool.createFFIContext() + # The static ctx holds a slot, so only MaxFFIContexts-1 were left. + check filler.len == MaxFFIContexts - 1 + check staticPool.createFFIContext().isErr() + + # Hand the static ctx's slot straight to a plain one, so the retry below has + # to fail on a genuinely full pool. + check staticPool.destroyStaticFFIContext().isOk() + let reclaimed = staticPool.createFFIContext().valueOr: + assert false, "createFFIContext(pool) failed on the freed static slot: " & $error + return + filler.add(reclaimed) + # No slot free: the create fails and must reset the state, not latch it. + check staticPool.staticFFIContext().isErr() + check staticPool.destroyFFIContext(filler.pop()).isOk() + check staticPool.staticFFIContext().isOk() test "requests are processed via pool context": var pool: FFIContextPool[TestLib] diff --git a/tests/unit/test_ffistatic_reject.nim b/tests/unit/test_ffistatic_reject.nim new file mode 100644 index 0000000..fcf9b97 --- /dev/null +++ b/tests/unit/test_ffistatic_reject.nim @@ -0,0 +1,48 @@ +## Asserts `{.ffiStatic.}` rejects an {.ffiHandle.} param/return and an `abi = c` +## scalar return. Each fixture compiles in a child `nim check` so its expected +## failure is an assertion rather than this file's own compile error. + +import std/[os, osproc, strutils, compilesettings] +import unittest2 + +const + fixtureDir = currentSourcePath().parentDir() / "fixtures" + nimExe = getCurrentCompilerExe() + ffiSearchPaths = querySettingSeq(searchPaths) + +proc checkFixture(name: string): tuple[output: string, exitCode: int] = + let cacheDir = getTempDir() / "ffi_ffistatic_reject_cache" / name + var cmd = quoteShell(nimExe) & " check --hints:off --warnings:off" + for p in ffiSearchPaths: + cmd.add(" --path:" & quoteShell(p)) + cmd.add(" --nimcache:" & quoteShell(cacheDir)) + cmd.add(" " & quoteShell(fixtureDir / (name & "_fixture.nim"))) + execCmdEx(cmd) + +suite "{.ffiStatic.} rejects handles at macro time": + test "an {.ffiHandle.} parameter fails the build, naming the proc and the fix": + let (output, code) = checkFixture("ffistatic_handle_param") + check code != 0 + check output.contains("staticrejBad") + check output.contains("Session") + check output.contains("`{.ffi.}` method instead") + + test "an {.ffiHandle.} return fails the build, naming the proc and the fix": + let (output, code) = checkFixture("ffistatic_handle_return") + check code != 0 + check output.contains("staticrejBad") + check output.contains("Session") + check output.contains("`{.ffi.}` method instead") + + test "the same shapes without handles compile": + let (output, code) = checkFixture("ffistatic_ok") + check code == 0 + check not output.contains("Error") + +suite "{.ffiStatic.} rejects an abi = c scalar return": + test "the error names the proc and the wired reply shapes, not int_CWire": + let (output, code) = checkFixture("ffistatic_abi_c_scalar") + check code != 0 + check output.contains("staticscalar_add") + check output.contains("unsupported response type") + check not output.contains("int_CWire")