diff --git a/examples/echo/cpp_bindings/echo.hpp b/examples/echo/cpp_bindings/echo.hpp index 9281f09..c6f50d0 100644 --- a/examples/echo/cpp_bindings/echo.hpp +++ b/examples/echo/cpp_bindings/echo.hpp @@ -14,7 +14,7 @@ #include #include #include -#include +#include #include #include #include @@ -24,10 +24,73 @@ #include #include #include +#include extern "C" { #include } +// ============================================================ +// Result — exception-free error channel +// ============================================================ +// The generated bindings never throw: every fallible entry point (create, +// instance methods, and their *Async futures) returns a Result. Callers +// branch on isOk()/isErr() (or the explicit bool conversion) and read +// value()/error(). This mirrors the Nim side's Result[T, string] and keeps +// us off C++23's std::expected. +#ifndef NIM_FFI_RESULT_HPP_INCLUDED +#define NIM_FFI_RESULT_HPP_INCLUDED + +template +class Result { + std::optional value_; + std::string error_; +public: + static Result ok(T value) { + Result r; + r.value_ = std::move(value); + return r; + } + static Result err(std::string message) { + Result r; + r.error_ = std::move(message); + return r; + } + bool isOk() const { return value_.has_value(); } + bool isErr() const { return !value_.has_value(); } + explicit operator bool() const { return isOk(); } + const T& value() const { assert(value_.has_value() && "Result::value() called on err Result — check isOk() first"); return *value_; } + T& value() { assert(value_.has_value() && "Result::value() called on err Result — check isOk() first"); return *value_; } + const T& operator*() const { assert(value_.has_value() && "Result::operator*() called on err Result — check isOk() first"); return *value_; } + const T* operator->() const { assert(value_.has_value() && "Result::operator->() called on err Result — check isOk() first"); return &*value_; } + T&& take() { assert(value_.has_value() && "Result::take() called on err Result — check isOk() first"); return std::move(*value_); } + const std::string& error() const { assert(!value_.has_value() && "Result::error() called on ok Result — check isErr() first"); return error_; } +}; + +template <> +class Result { + bool ok_ = true; + std::string error_; +public: + static Result ok() { + Result r; + r.ok_ = true; + return r; + } + static Result err(std::string message) { + Result r; + r.ok_ = false; + r.error_ = std::move(message); + return r; + } + Result() = default; + bool isOk() const { return ok_; } + bool isErr() const { return !ok_; } + explicit operator bool() const { return isOk(); } + const std::string& error() const { assert(!ok_ && "Result::error() called on ok Result — check isErr() first"); return error_; } +}; + +#endif // NIM_FFI_RESULT_HPP_INCLUDED + // ── encode_cbor overloads (primitives + containers) ───────────────────── // Per-struct encode_cbor / decode_cbor are emitted by cpp.nim next to each // generated struct; these helpers cover the leaf types they defer into. @@ -159,7 +222,7 @@ inline CborError decode_cbor(CborValue& it, std::optional& out) { // ── Public entry points ───────────────────────────────────────────────── template -inline std::vector encodeCborFFI(const T& value) { +inline Result> encodeCborFFI(const T& value) { // Start with a generous 4 KiB buffer; double on overflow until it fits. std::vector buf(4096); while (true) { @@ -169,34 +232,34 @@ inline std::vector encodeCborFFI(const T& value) { if (err == CborNoError) { const size_t used = cbor_encoder_get_buffer_size(&enc, buf.data()); buf.resize(used); - return buf; + return Result>::ok(std::move(buf)); } if (err == CborErrorOutOfMemory) { const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc); buf.resize(buf.size() + (extra > 0 ? extra : buf.size())); continue; } - throw std::runtime_error(std::string("FFI CBOR encode failed: ") + - cbor_error_string(err)); + return Result>::err( + std::string("FFI CBOR encode failed: ") + cbor_error_string(err)); } } template -inline T decodeCborFFI(const std::vector& bytes) { +inline Result decodeCborFFI(const std::vector& bytes) { CborParser parser; CborValue it; CborError err = cbor_parser_init(bytes.data(), bytes.size(), 0, &parser, &it); if (err != CborNoError) { - throw std::runtime_error(std::string("FFI CBOR parse init failed: ") + - cbor_error_string(err)); + return Result::err(std::string("FFI CBOR parse init failed: ") + + cbor_error_string(err)); } T out{}; err = decode_cbor(it, out); if (err != CborNoError) { - throw std::runtime_error(std::string("FFI CBOR decode failed: ") + - cbor_error_string(err)); + return Result::err(std::string("FFI CBOR decode failed: ") + + cbor_error_string(err)); } - return out; + return Result::ok(std::move(out)); } #endif // NIM_FFI_CBOR_HELPERS_HPP_INCLUDED @@ -384,22 +447,25 @@ inline void ffi_cb_(int ret, const char* msg, size_t len, void* ud) { s.cv.notify_one(); } -inline std::vector ffi_call_(std::function f, - std::chrono::milliseconds timeout) { +inline Result> ffi_call_( + std::function f, + std::chrono::milliseconds timeout) { + using Bytes = std::vector; auto state = std::make_shared(); auto* cb_ref = new std::shared_ptr(state); const int ret = f(ffi_cb_, cb_ref); if (ret == 2) { delete cb_ref; - throw std::runtime_error("RET_MISSING_CALLBACK (internal error)"); + return Result::err("RET_MISSING_CALLBACK (internal error)"); } std::unique_lock lock(state->mtx); const bool fired = state->cv.wait_for(lock, timeout, [&]{ return state->done; }); if (!fired) - throw std::runtime_error("FFI call timed out after " + std::to_string(timeout.count()) + "ms"); + return Result::err("FFI call timed out after " + + std::to_string(timeout.count()) + "ms"); if (!state->ok) - throw std::runtime_error(state->err); - return state->bytes; + return Result::err(state->err); + return Result::ok(std::move(state->bytes)); } } // anonymous namespace @@ -412,23 +478,30 @@ inline std::vector ffi_call_(std::function create(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + static Result> create(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { const auto ffi_req_ = EchoCreateCtorReq{config}; - const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_); - const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + 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) { (void)echo_create(ffi_req_bytes_.data(), ffi_req_bytes_.size(), cb, ud); return 0; }, timeout); - const auto addr_str = decodeCborFFI(ffi_raw_); - try { - const auto addr = std::stoull(addr_str); - return std::unique_ptr(new EchoCtx(reinterpret_cast(static_cast(addr)), timeout)); - } catch (const std::exception&) { - throw std::runtime_error("FFI create returned non-numeric address: " + addr_str); + if (ffi_raw_.isErr()) return Result>::err(ffi_raw_.error()); + auto ffi_addr_ = decodeCborFFI(ffi_raw_.value()); + if (ffi_addr_.isErr()) return Result>::err(ffi_addr_.error()); + const auto& addr_str = ffi_addr_.value(); + std::uint64_t addr = 0; + const char* addr_begin = addr_str.data(); + const char* addr_end = addr_begin + addr_str.size(); + const auto fc_ = std::from_chars(addr_begin, addr_end, addr); + if (fc_.ec != std::errc() || fc_.ptr != addr_end) { + return Result>::err("FFI create returned non-numeric address: " + addr_str); } + return Result>::ok(std::unique_ptr(new EchoCtx(reinterpret_cast(static_cast(addr)), timeout))); } - static std::future> createAsync(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + static std::future>> createAsync(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { return std::async(std::launch::async, [config, timeout]() { return create(config, timeout); }); } @@ -453,29 +526,35 @@ public: EchoCtx(EchoCtx&&) = delete; EchoCtx& operator=(EchoCtx&&) = delete; - ShoutResponse shout(const ShoutRequest& req) const { + Result shout(const ShoutRequest& req) const { const auto ffi_req_ = EchoShoutReq{req}; - const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_); - const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + 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(ptr_, cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size()); }, timeout_); - return decodeCborFFI(ffi_raw_); + if (ffi_raw_.isErr()) return Result::err(ffi_raw_.error()); + return decodeCborFFI(ffi_raw_.value()); } - std::future shoutAsync(const ShoutRequest& req) const { + std::future> shoutAsync(const ShoutRequest& req) const { return std::async(std::launch::async, [this, req]() { return this->shout(req); }); } - std::string version() const { + Result version() const { const auto ffi_req_ = EchoVersionReq{}; - const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_); - const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + 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_version(ptr_, cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size()); }, timeout_); - return decodeCborFFI(ffi_raw_); + if (ffi_raw_.isErr()) return Result::err(ffi_raw_.error()); + return decodeCborFFI(ffi_raw_.value()); } - std::future versionAsync() const { + std::future> versionAsync() const { return std::async(std::launch::async, [this]() { return this->version(); }); } diff --git a/examples/timer/cpp_bindings/main.cpp b/examples/timer/cpp_bindings/main.cpp index 5a9e7ea..3ab361d 100644 --- a/examples/timer/cpp_bindings/main.cpp +++ b/examples/timer/cpp_bindings/main.cpp @@ -5,121 +5,142 @@ #include #include +// The generated bindings never throw: every call returns a Result. We +// branch on isErr() and read value()/error() instead of using try/catch. int main() { - try { - auto ctx = MyTimerCtx::create(TimerConfig{"cpp-demo"}); - std::cout << "[1] Context created\n"; - - auto versionFuture = ctx->versionAsync(); - auto echo1Future = ctx->echoAsync(EchoRequest{"hello from C++", 200}); - auto echo2Future = ctx->echoAsync(EchoRequest{"second C++ request", 50}); - - auto version = versionFuture.get(); - std::cout << "[2] Version: " << version << "\n"; - - auto echo = echo1Future.get(); - std::cout << "[3] Echo 1: echoed=" << echo.echoed - << ", timerName=" << echo.timerName << "\n"; - - auto echo2 = echo2Future.get(); - std::cout << "[4] Echo 2: echoed=" << echo2.echoed - << ", timerName=" << echo2.timerName << "\n"; - - auto complexReq = ComplexRequest{ - std::vector{EchoRequest{"one", 10}, EchoRequest{"two", 20}}, - std::vector{"fast", "async"}, - std::optional("extra note"), - std::optional(3) - }; - - auto complexFuture = ctx->complexAsync(complexReq); - auto complex = complexFuture.get(); - std::cout << "[5] Complex: summary=" << complex.summary - << ", itemCount=" << complex.itemCount - << ", hasNote=" << complex.hasNote << "\n"; - - // Each parameter is its own generated C++ struct. The nim-ffi - // macro packs all three into one CBOR envelope on the wire — at - // the call site, this is just a typed method invocation. - auto job = JobSpec{ - /*name*/ "nightly-rollup", - /*payload*/ std::vector{"rollup", "v2"}, - /*priority*/ 10, - }; - auto retry = RetryPolicy{ - /*maxAttempts*/ 3, - /*backoffMs*/ 500, - /*retryOn*/ std::vector{"timeout", "5xx"}, - }; - auto schedule = ScheduleConfig{ - /*startAtMs*/ 1000, - /*intervalMs*/ 15000, - /*jitter*/ std::optional(250), - }; - - auto scheduleFuture = ctx->scheduleAsync(job, retry, schedule); - auto scheduleRes = scheduleFuture.get(); - std::cout << "[6] Schedule (3 complex params): jobId=" << scheduleRes.jobId - << ", willRunCount=" << scheduleRes.willRunCount - << ", firstRunAtMs=" << scheduleRes.firstRunAtMs - << ", effectiveBackoffMs=" << scheduleRes.effectiveBackoffMs - << "\n"; - - // Each `{.ffiEvent.}` declared on the Nim side gets a typed - // registration method — `addOnEchoFiredListener(handler)` here. - // A second `addEventListener` overload registers a catch-all - // wildcard listener that receives every event as raw envelope - // bytes plus the FFI return code. Both fire from the lib's - // dispatch thread, so synchronise via std::promise / atomics. - std::promise echoEvtPromise; - auto echoEvtFuture = echoEvtPromise.get_future(); - const auto typedHandle = ctx->addOnEchoFiredListener( - [&](const EchoEvent& evt) { echoEvtPromise.set_value(evt); }); - - std::atomic wildcardHits{0}; - // Wildcard listener receives every event with the wire `eventId` - // pre-extracted plus a span view over the raw CBOR envelope - // bytes (zero-copy; valid only for the duration of this call). - // Dispatch on `eventId` and use `decodeEventPayload` to lift - // the payload into a typed value without hand-parsing CBOR. - const auto wildcardHandle = ctx->addEventListener( - [&](int retCode, const std::string& eventId, - std::span envelope) { - wildcardHits.fetch_add(1); - std::cout << "[7] wildcard event: retCode=" << retCode - << ", eventId=" << eventId - << ", envelope bytes=" << envelope.size() << "\n"; - if (retCode != 0) return; - if (eventId == "on_echo_fired") { - EchoEvent decoded{}; - if (decodeEventPayload(envelope, decoded)) { - std::cout << " decoded EchoEvent: message=" - << decoded.message - << ", echoCount=" << decoded.echoCount << "\n"; - } - } - }); - - ctx->echo(EchoRequest{"event-demo", 1}); - const auto evt = echoEvtFuture.get(); - std::cout << "[7] typed event onEchoFired: message=" << evt.message - << ", echoCount=" << evt.echoCount - << ", wildcardHits=" << wildcardHits.load() << "\n"; - - // Drop the typed listener — only the wildcard fires for the - // follow-up echo. Sleep briefly to give the lib thread time to - // deliver before we tear the ctx down. - ctx->removeEventListener(typedHandle); - ctx->echo(EchoRequest{"event-demo-after-remove", 1}); - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - std::cout << "[7] after removeEventListener: wildcardHits=" - << wildcardHits.load() << "\n"; - ctx->removeEventListener(wildcardHandle); - - std::cout << "\nDone.\n"; - } catch (const std::exception& ex) { - std::cerr << "Error: " << ex.what() << "\n"; + auto ctxRes = MyTimerCtx::create(TimerConfig{"cpp-demo"}); + if (ctxRes.isErr()) { + std::cerr << "Error: " << ctxRes.error() << "\n"; return 1; } + auto ctx = std::move(ctxRes.value()); + std::cout << "[1] Context created\n"; + + auto versionFuture = ctx->versionAsync(); + auto echo1Future = ctx->echoAsync(EchoRequest{"hello from C++", 200}); + auto echo2Future = ctx->echoAsync(EchoRequest{"second C++ request", 50}); + + auto version = versionFuture.get(); + if (version.isErr()) { + std::cerr << "Error: " << version.error() << "\n"; + return 1; + } + std::cout << "[2] Version: " << version.value() << "\n"; + + auto echo = echo1Future.get(); + if (echo.isErr()) { + std::cerr << "Error: " << echo.error() << "\n"; + return 1; + } + std::cout << "[3] Echo 1: echoed=" << echo->echoed + << ", timerName=" << echo->timerName << "\n"; + + auto echo2 = echo2Future.get(); + if (echo2.isErr()) { + std::cerr << "Error: " << echo2.error() << "\n"; + return 1; + } + std::cout << "[4] Echo 2: echoed=" << echo2->echoed + << ", timerName=" << echo2->timerName << "\n"; + + auto complexReq = ComplexRequest{ + std::vector{EchoRequest{"one", 10}, EchoRequest{"two", 20}}, + std::vector{"fast", "async"}, + std::optional("extra note"), + std::optional(3) + }; + + auto complex = ctx->complexAsync(complexReq).get(); + if (complex.isErr()) { + std::cerr << "Error: " << complex.error() << "\n"; + return 1; + } + std::cout << "[5] Complex: summary=" << complex->summary + << ", itemCount=" << complex->itemCount + << ", hasNote=" << complex->hasNote << "\n"; + + // ── 6. Call with three complex parameters ───────────────────── + // Each parameter is its own generated C++ struct. The nim-ffi + // macro packs all three into one CBOR envelope on the wire — at + // the call site, this is just a typed method invocation. + auto job = JobSpec{ + /*name*/ "nightly-rollup", + /*payload*/ std::vector{"rollup", "v2"}, + /*priority*/ 10, + }; + auto retry = RetryPolicy{ + /*maxAttempts*/ 3, + /*backoffMs*/ 500, + /*retryOn*/ std::vector{"timeout", "5xx"}, + }; + auto schedule = ScheduleConfig{ + /*startAtMs*/ 1000, + /*intervalMs*/ 15000, + /*jitter*/ std::optional(250), + }; + + auto scheduleRes = ctx->scheduleAsync(job, retry, schedule).get(); + if (scheduleRes.isErr()) { + std::cerr << "Error: " << scheduleRes.error() << "\n"; + return 1; + } + std::cout << "[6] Schedule (3 complex params): jobId=" << scheduleRes->jobId + << ", willRunCount=" << scheduleRes->willRunCount + << ", firstRunAtMs=" << scheduleRes->firstRunAtMs + << ", effectiveBackoffMs=" << scheduleRes->effectiveBackoffMs + << "\n"; + + // Each `{.ffiEvent.}` declared on the Nim side gets a typed + // registration method — `addOnEchoFiredListener(handler)` here. + // A second `addEventListener` overload registers a catch-all + // wildcard listener that receives every event as raw envelope + // bytes plus the FFI return code. Both fire from the lib's + // dispatch thread, so synchronise via std::promise / atomics. + std::promise echoEvtPromise; + auto echoEvtFuture = echoEvtPromise.get_future(); + const auto typedHandle = ctx->addOnEchoFiredListener( + [&](const EchoEvent& evt) { echoEvtPromise.set_value(evt); }); + + std::atomic wildcardHits{0}; + // Wildcard listener receives every event with the wire `eventId` + // pre-extracted plus a span view over the raw CBOR envelope + // bytes (zero-copy; valid only for the duration of this call). + // Dispatch on `eventId` and use `decodeEventPayload` to lift + // the payload into a typed value without hand-parsing CBOR. + const auto wildcardHandle = ctx->addEventListener( + [&](int retCode, const std::string& eventId, + std::span envelope) { + wildcardHits.fetch_add(1); + std::cout << "[7] wildcard event: retCode=" << retCode + << ", eventId=" << eventId + << ", envelope bytes=" << envelope.size() << "\n"; + if (retCode != 0) return; + if (eventId == "on_echo_fired") { + EchoEvent decoded{}; + if (decodeEventPayload(envelope, decoded)) { + std::cout << " decoded EchoEvent: message=" + << decoded.message + << ", echoCount=" << decoded.echoCount << "\n"; + } + } + }); + + ctx->echo(EchoRequest{"event-demo", 1}); + const auto evt = echoEvtFuture.get(); + std::cout << "[7] typed event onEchoFired: message=" << evt.message + << ", echoCount=" << evt.echoCount + << ", wildcardHits=" << wildcardHits.load() << "\n"; + + // Drop the typed listener — only the wildcard fires for the + // follow-up echo. Sleep briefly to give the lib thread time to + // deliver before we tear the ctx down. + ctx->removeEventListener(typedHandle); + ctx->echo(EchoRequest{"event-demo-after-remove", 1}); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + std::cout << "[7] after removeEventListener: wildcardHits=" + << wildcardHits.load() << "\n"; + ctx->removeEventListener(wildcardHandle); + + std::cout << "\nDone.\n"; return 0; } diff --git a/examples/timer/cpp_bindings/my_timer.hpp b/examples/timer/cpp_bindings/my_timer.hpp index 7523504..8d1fc3d 100644 --- a/examples/timer/cpp_bindings/my_timer.hpp +++ b/examples/timer/cpp_bindings/my_timer.hpp @@ -14,7 +14,7 @@ #include #include #include -#include +#include #include #include #include @@ -24,12 +24,75 @@ #include #include #include +#include extern "C" { #include } #include #include +// ============================================================ +// Result — exception-free error channel +// ============================================================ +// The generated bindings never throw: every fallible entry point (create, +// instance methods, and their *Async futures) returns a Result. Callers +// branch on isOk()/isErr() (or the explicit bool conversion) and read +// value()/error(). This mirrors the Nim side's Result[T, string] and keeps +// us off C++23's std::expected. +#ifndef NIM_FFI_RESULT_HPP_INCLUDED +#define NIM_FFI_RESULT_HPP_INCLUDED + +template +class Result { + std::optional value_; + std::string error_; +public: + static Result ok(T value) { + Result r; + r.value_ = std::move(value); + return r; + } + static Result err(std::string message) { + Result r; + r.error_ = std::move(message); + return r; + } + bool isOk() const { return value_.has_value(); } + bool isErr() const { return !value_.has_value(); } + explicit operator bool() const { return isOk(); } + const T& value() const { assert(value_.has_value() && "Result::value() called on err Result — check isOk() first"); return *value_; } + T& value() { assert(value_.has_value() && "Result::value() called on err Result — check isOk() first"); return *value_; } + const T& operator*() const { assert(value_.has_value() && "Result::operator*() called on err Result — check isOk() first"); return *value_; } + const T* operator->() const { assert(value_.has_value() && "Result::operator->() called on err Result — check isOk() first"); return &*value_; } + T&& take() { assert(value_.has_value() && "Result::take() called on err Result — check isOk() first"); return std::move(*value_); } + const std::string& error() const { assert(!value_.has_value() && "Result::error() called on ok Result — check isErr() first"); return error_; } +}; + +template <> +class Result { + bool ok_ = true; + std::string error_; +public: + static Result ok() { + Result r; + r.ok_ = true; + return r; + } + static Result err(std::string message) { + Result r; + r.ok_ = false; + r.error_ = std::move(message); + return r; + } + Result() = default; + bool isOk() const { return ok_; } + bool isErr() const { return !ok_; } + explicit operator bool() const { return isOk(); } + const std::string& error() const { assert(!ok_ && "Result::error() called on ok Result — check isErr() first"); return error_; } +}; + +#endif // NIM_FFI_RESULT_HPP_INCLUDED + // ── encode_cbor overloads (primitives + containers) ───────────────────── // Per-struct encode_cbor / decode_cbor are emitted by cpp.nim next to each // generated struct; these helpers cover the leaf types they defer into. @@ -161,7 +224,7 @@ inline CborError decode_cbor(CborValue& it, std::optional& out) { // ── Public entry points ───────────────────────────────────────────────── template -inline std::vector encodeCborFFI(const T& value) { +inline Result> encodeCborFFI(const T& value) { // Start with a generous 4 KiB buffer; double on overflow until it fits. std::vector buf(4096); while (true) { @@ -171,34 +234,34 @@ inline std::vector encodeCborFFI(const T& value) { if (err == CborNoError) { const size_t used = cbor_encoder_get_buffer_size(&enc, buf.data()); buf.resize(used); - return buf; + return Result>::ok(std::move(buf)); } if (err == CborErrorOutOfMemory) { const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc); buf.resize(buf.size() + (extra > 0 ? extra : buf.size())); continue; } - throw std::runtime_error(std::string("FFI CBOR encode failed: ") + - cbor_error_string(err)); + return Result>::err( + std::string("FFI CBOR encode failed: ") + cbor_error_string(err)); } } template -inline T decodeCborFFI(const std::vector& bytes) { +inline Result decodeCborFFI(const std::vector& bytes) { CborParser parser; CborValue it; CborError err = cbor_parser_init(bytes.data(), bytes.size(), 0, &parser, &it); if (err != CborNoError) { - throw std::runtime_error(std::string("FFI CBOR parse init failed: ") + - cbor_error_string(err)); + return Result::err(std::string("FFI CBOR parse init failed: ") + + cbor_error_string(err)); } T out{}; err = decode_cbor(it, out); if (err != CborNoError) { - throw std::runtime_error(std::string("FFI CBOR decode failed: ") + - cbor_error_string(err)); + return Result::err(std::string("FFI CBOR decode failed: ") + + cbor_error_string(err)); } - return out; + return Result::ok(std::move(out)); } #endif // NIM_FFI_CBOR_HELPERS_HPP_INCLUDED @@ -685,22 +748,25 @@ inline void ffi_cb_(int ret, const char* msg, size_t len, void* ud) { s.cv.notify_one(); } -inline std::vector ffi_call_(std::function f, - std::chrono::milliseconds timeout) { +inline Result> ffi_call_( + std::function f, + std::chrono::milliseconds timeout) { + using Bytes = std::vector; auto state = std::make_shared(); auto* cb_ref = new std::shared_ptr(state); const int ret = f(ffi_cb_, cb_ref); if (ret == 2) { delete cb_ref; - throw std::runtime_error("RET_MISSING_CALLBACK (internal error)"); + return Result::err("RET_MISSING_CALLBACK (internal error)"); } std::unique_lock lock(state->mtx); const bool fired = state->cv.wait_for(lock, timeout, [&]{ return state->done; }); if (!fired) - throw std::runtime_error("FFI call timed out after " + std::to_string(timeout.count()) + "ms"); + return Result::err("FFI call timed out after " + + std::to_string(timeout.count()) + "ms"); if (!state->ok) - throw std::runtime_error(state->err); - return state->bytes; + return Result::err(state->err); + return Result::ok(std::move(state->bytes)); } } // anonymous namespace @@ -726,23 +792,30 @@ inline bool decodeEventPayload(std::span envelope, T& out) { class MyTimerCtx { public: - static std::unique_ptr create(const TimerConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + static Result> create(const TimerConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { const auto ffi_req_ = MyTimerCreateCtorReq{config}; - const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_); - const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + 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) { (void)my_timer_create(ffi_req_bytes_.data(), ffi_req_bytes_.size(), cb, ud); return 0; }, timeout); - const auto addr_str = decodeCborFFI(ffi_raw_); - try { - const auto addr = std::stoull(addr_str); - return std::unique_ptr(new MyTimerCtx(reinterpret_cast(static_cast(addr)), timeout)); - } catch (const std::exception&) { - throw std::runtime_error("FFI create returned non-numeric address: " + addr_str); + if (ffi_raw_.isErr()) return Result>::err(ffi_raw_.error()); + auto ffi_addr_ = decodeCborFFI(ffi_raw_.value()); + if (ffi_addr_.isErr()) return Result>::err(ffi_addr_.error()); + const auto& addr_str = ffi_addr_.value(); + std::uint64_t addr = 0; + const char* addr_begin = addr_str.data(); + const char* addr_end = addr_begin + addr_str.size(); + const auto fc_ = std::from_chars(addr_begin, addr_end, addr); + if (fc_.ec != std::errc() || fc_.ptr != addr_end) { + return Result>::err("FFI create returned non-numeric address: " + addr_str); } + return Result>::ok(std::unique_ptr(new MyTimerCtx(reinterpret_cast(static_cast(addr)), timeout))); } - static std::future> createAsync(const TimerConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { + static std::future>> createAsync(const TimerConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) { return std::async(std::launch::async, [config, timeout]() { return create(config, timeout); }); } @@ -797,55 +870,67 @@ public: return rc == 0; } - EchoResponse echo(const EchoRequest& req) const { + Result echo(const EchoRequest& req) const { const auto ffi_req_ = MyTimerEchoReq{req}; - const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_); - const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + 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_echo(ptr_, cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size()); }, timeout_); - return decodeCborFFI(ffi_raw_); + if (ffi_raw_.isErr()) return Result::err(ffi_raw_.error()); + return decodeCborFFI(ffi_raw_.value()); } - std::future echoAsync(const EchoRequest& req) const { + std::future> echoAsync(const EchoRequest& req) const { return std::async(std::launch::async, [this, req]() { return this->echo(req); }); } - std::string version() const { + Result version() const { const auto ffi_req_ = MyTimerVersionReq{}; - const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_); - const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + 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_version(ptr_, cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size()); }, timeout_); - return decodeCborFFI(ffi_raw_); + if (ffi_raw_.isErr()) return Result::err(ffi_raw_.error()); + return decodeCborFFI(ffi_raw_.value()); } - std::future versionAsync() const { + std::future> versionAsync() const { return std::async(std::launch::async, [this]() { return this->version(); }); } - ComplexResponse complex(const ComplexRequest& req) const { + Result complex(const ComplexRequest& req) const { const auto ffi_req_ = MyTimerComplexReq{req}; - const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_); - const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + 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_complex(ptr_, cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size()); }, timeout_); - return decodeCborFFI(ffi_raw_); + if (ffi_raw_.isErr()) return Result::err(ffi_raw_.error()); + return decodeCborFFI(ffi_raw_.value()); } - std::future complexAsync(const ComplexRequest& req) const { + std::future> complexAsync(const ComplexRequest& req) const { return std::async(std::launch::async, [this, req]() { return this->complex(req); }); } - ScheduleResult schedule(const JobSpec& job, const RetryPolicy& retry, const ScheduleConfig& schedule) const { + Result schedule(const JobSpec& job, const RetryPolicy& retry, const ScheduleConfig& schedule) const { const auto ffi_req_ = MyTimerScheduleReq{job, retry, schedule}; - const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_); - const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) { + 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_schedule(ptr_, cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size()); }, timeout_); - return decodeCborFFI(ffi_raw_); + if (ffi_raw_.isErr()) return Result::err(ffi_raw_.error()); + return decodeCborFFI(ffi_raw_.value()); } - std::future scheduleAsync(const JobSpec& job, const RetryPolicy& retry, const ScheduleConfig& schedule) const { + std::future> scheduleAsync(const JobSpec& job, const RetryPolicy& retry, const ScheduleConfig& schedule) const { return std::async(std::launch::async, [this, job, retry, schedule]() { return this->schedule(job, retry, schedule); }); } diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index 32100b2..467b26b 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -15,6 +15,7 @@ const CppPtrType* = "uint64_t" ## reflected in the generated bindings without touching this codegen. const HeaderPreludeTpl = staticRead("templates/cpp/header_prelude.hpp.tpl") + ResultTpl = staticRead("templates/cpp/result.hpp.tpl") CborHelpersTpl = staticRead("templates/cpp/cbor_helpers.hpp.tpl") SyncCallHelperTpl = staticRead("templates/cpp/sync_call_helper.hpp.tpl") ContextRuleOf5Tpl = staticRead("templates/cpp/context_rule_of_5.hpp.tpl") @@ -339,6 +340,11 @@ proc generateCppHeader*( lines.add("#include ") lines.add("#include ") + # Result is the exception-free return channel used by every generated + # entry point. It must precede the CBOR helpers and sync-call helper below, + # which now hand their failures back as Result rather than throwing. + lines.add(ResultTpl) + # CBOR primitive / container helpers must precede the per-struct codecs # below, because each emitted `encode_cbor`/`decode_cbor(T)` calls the # generic overloads for the struct's fields (std::string, std::vector, @@ -519,32 +525,43 @@ proc generateCppHeader*( # context owns library threads, so we forbid copy/move on the class # itself (see ContextRuleOf5Tpl) and hand out ownership through a # smart pointer that callers can move, store in containers, etc. + let createRet = "Result>" % [ctxTypeName] lines.add( - " static std::unique_ptr<$1> create($2) {" % - [ctxTypeName, ctorParamsWithTimeout] + " static $1 create($2) {" % [createRet, ctorParamsWithTimeout] ) lines.add(" const auto ffi_req_ = $1;" % [reqInit]) - lines.add(" const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_);") - lines.add(" const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) {") + lines.add(" auto ffi_enc_ = encodeCborFFI(ffi_req_);") + lines.add(" if (ffi_enc_.isErr()) return $1::err(ffi_enc_.error());" % [createRet]) + lines.add(" const auto& ffi_req_bytes_ = ffi_enc_.value();") + lines.add(" auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) {") lines.add( " (void)$1(ffi_req_bytes_.data(), ffi_req_bytes_.size(), cb, ud);" % [ctor.procName] ) lines.add(" return 0;") lines.add(" }, timeout);") - lines.add(" const auto addr_str = decodeCborFFI(ffi_raw_);") - lines.add(" try {") - lines.add(" const auto addr = std::stoull(addr_str);") - # Use `new` directly (not std::make_unique) so the ctor can stay private. + lines.add(" if (ffi_raw_.isErr()) return $1::err(ffi_raw_.error());" % [createRet]) + lines.add(" auto ffi_addr_ = decodeCborFFI(ffi_raw_.value());") + lines.add(" if (ffi_addr_.isErr()) return $1::err(ffi_addr_.error());" % [createRet]) + lines.add(" const auto& addr_str = ffi_addr_.value();") + # Parse the ctx address without exceptions: std::stoull would throw on a + # non-numeric payload, so use std::from_chars and surface the failure as + # an err() Result instead. + lines.add(" std::uint64_t addr = 0;") + lines.add(" const char* addr_begin = addr_str.data();") + lines.add(" const char* addr_end = addr_begin + addr_str.size();") + lines.add(" const auto fc_ = std::from_chars(addr_begin, addr_end, addr);") + lines.add(" if (fc_.ec != std::errc() || fc_.ptr != addr_end) {") lines.add( - " return std::unique_ptr<$1>(new $1(reinterpret_cast(static_cast(addr)), timeout));" % - [ctxTypeName] - ) - lines.add(" } catch (const std::exception&) {") - lines.add( - " throw std::runtime_error(\"FFI create returned non-numeric address: \" + addr_str);" + " return $1::err(\"FFI create returned non-numeric address: \" + addr_str);" % + [createRet] ) lines.add(" }") + # Use `new` directly (not std::make_unique) so the ctor can stay private. + lines.add( + " return $1::ok(std::unique_ptr<$2>(new $2(reinterpret_cast(static_cast(addr)), timeout)));" % + [createRet, ctxTypeName] + ) lines.add(" }") lines.add("") @@ -559,7 +576,7 @@ proc generateCppHeader*( else: "timeout" lines.add( - " static std::future> createAsync($2) {" % + " static std::future>> createAsync($2) {" % [ctxTypeName, ctorParamsWithTimeout] ) lines.add( @@ -602,18 +619,22 @@ proc generateCppHeader*( let reqInit = cppBracedInit(reqName, methParamNames) + let methRet = "Result<$1>" % [retCppType] # Use a single-underscore-suffixed local for the Req envelope so it can't # shadow a method parameter whose name happens to be `req` (or similar). - lines.add(" $1 $2($3) const {" % [retCppType, methodName, methParamsStr]) + lines.add(" $1 $2($3) const {" % [methRet, methodName, methParamsStr]) lines.add(" const auto ffi_req_ = $1;" % [reqInit]) - lines.add(" const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_);") - lines.add(" const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) {") + lines.add(" auto ffi_enc_ = encodeCborFFI(ffi_req_);") + lines.add(" if (ffi_enc_.isErr()) return $1::err(ffi_enc_.error());" % [methRet]) + lines.add(" const auto& ffi_req_bytes_ = ffi_enc_.value();") + lines.add(" auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) {") lines.add( " return $1(ptr_, cb, ud, ffi_req_bytes_.data(), ffi_req_bytes_.size());" % [m.procName] ) lines.add(" }, timeout_);") - lines.add(" return decodeCborFFI<$1>(ffi_raw_);" % [retCppType]) + 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("") # The async wrapper calls the sync method via `this->methodName(...)` so @@ -623,7 +644,7 @@ proc generateCppHeader*( if methParamsStr.len > 0: lines.add( " std::future<$1> $2Async($3) const {" % - [retCppType, methodName, methParamsStr] + [methRet, methodName, methParamsStr] ) lines.add( " return std::async(std::launch::async, [this, $1]() { return this->$2($3); });" % @@ -631,7 +652,7 @@ proc generateCppHeader*( ) lines.add(" }") else: - lines.add(" std::future<$1> $2Async() const {" % [retCppType, methodName]) + lines.add(" std::future<$1> $2Async() const {" % [methRet, methodName]) lines.add( " return std::async(std::launch::async, [this]() { return this->$1(); });" % [methodName] diff --git a/ffi/codegen/templates/cpp/cbor_helpers.hpp.tpl b/ffi/codegen/templates/cpp/cbor_helpers.hpp.tpl index a3a3574..595cc17 100644 --- a/ffi/codegen/templates/cpp/cbor_helpers.hpp.tpl +++ b/ffi/codegen/templates/cpp/cbor_helpers.hpp.tpl @@ -129,7 +129,7 @@ inline CborError decode_cbor(CborValue& it, std::optional& out) { // ── Public entry points ───────────────────────────────────────────────── template -inline std::vector encodeCborFFI(const T& value) { +inline Result> encodeCborFFI(const T& value) { // Start with a generous 4 KiB buffer; double on overflow until it fits. std::vector buf(4096); while (true) { @@ -139,34 +139,34 @@ inline std::vector encodeCborFFI(const T& value) { if (err == CborNoError) { const size_t used = cbor_encoder_get_buffer_size(&enc, buf.data()); buf.resize(used); - return buf; + return Result>::ok(std::move(buf)); } if (err == CborErrorOutOfMemory) { const size_t extra = cbor_encoder_get_extra_bytes_needed(&enc); buf.resize(buf.size() + (extra > 0 ? extra : buf.size())); continue; } - throw std::runtime_error(std::string("FFI CBOR encode failed: ") + - cbor_error_string(err)); + return Result>::err( + std::string("FFI CBOR encode failed: ") + cbor_error_string(err)); } } template -inline T decodeCborFFI(const std::vector& bytes) { +inline Result decodeCborFFI(const std::vector& bytes) { CborParser parser; CborValue it; CborError err = cbor_parser_init(bytes.data(), bytes.size(), 0, &parser, &it); if (err != CborNoError) { - throw std::runtime_error(std::string("FFI CBOR parse init failed: ") + - cbor_error_string(err)); + return Result::err(std::string("FFI CBOR parse init failed: ") + + cbor_error_string(err)); } T out{}; err = decode_cbor(it, out); if (err != CborNoError) { - throw std::runtime_error(std::string("FFI CBOR decode failed: ") + - cbor_error_string(err)); + return Result::err(std::string("FFI CBOR decode failed: ") + + cbor_error_string(err)); } - return out; + return Result::ok(std::move(out)); } #endif // NIM_FFI_CBOR_HELPERS_HPP_INCLUDED diff --git a/ffi/codegen/templates/cpp/header_prelude.hpp.tpl b/ffi/codegen/templates/cpp/header_prelude.hpp.tpl index 99ff4a9..6180dd4 100644 --- a/ffi/codegen/templates/cpp/header_prelude.hpp.tpl +++ b/ffi/codegen/templates/cpp/header_prelude.hpp.tpl @@ -14,7 +14,7 @@ #include #include #include -#include +#include #include #include #include @@ -24,6 +24,7 @@ #include #include #include +#include extern "C" { #include } diff --git a/ffi/codegen/templates/cpp/result.hpp.tpl b/ffi/codegen/templates/cpp/result.hpp.tpl new file mode 100644 index 0000000..d58884c --- /dev/null +++ b/ffi/codegen/templates/cpp/result.hpp.tpl @@ -0,0 +1,61 @@ +// ============================================================ +// Result — exception-free error channel +// ============================================================ +// The generated bindings never throw: every fallible entry point (create, +// instance methods, and their *Async futures) returns a Result. Callers +// branch on isOk()/isErr() (or the explicit bool conversion) and read +// value()/error(). This mirrors the Nim side's Result[T, string] and keeps +// us off C++23's std::expected. +#ifndef NIM_FFI_RESULT_HPP_INCLUDED +#define NIM_FFI_RESULT_HPP_INCLUDED + +template +class Result { + std::optional value_; + std::string error_; +public: + static Result ok(T value) { + Result r; + r.value_ = std::move(value); + return r; + } + static Result err(std::string message) { + Result r; + r.error_ = std::move(message); + return r; + } + bool isOk() const { return value_.has_value(); } + bool isErr() const { return !value_.has_value(); } + explicit operator bool() const { return isOk(); } + const T& value() const { assert(value_.has_value() && "Result::value() called on err Result — check isOk() first"); return *value_; } + T& value() { assert(value_.has_value() && "Result::value() called on err Result — check isOk() first"); return *value_; } + const T& operator*() const { assert(value_.has_value() && "Result::operator*() called on err Result — check isOk() first"); return *value_; } + const T* operator->() const { assert(value_.has_value() && "Result::operator->() called on err Result — check isOk() first"); return &*value_; } + T&& take() { assert(value_.has_value() && "Result::take() called on err Result — check isOk() first"); return std::move(*value_); } + const std::string& error() const { assert(!value_.has_value() && "Result::error() called on ok Result — check isErr() first"); return error_; } +}; + +template <> +class Result { + bool ok_ = true; + std::string error_; +public: + static Result ok() { + Result r; + r.ok_ = true; + return r; + } + static Result err(std::string message) { + Result r; + r.ok_ = false; + r.error_ = std::move(message); + return r; + } + Result() = default; + bool isOk() const { return ok_; } + bool isErr() const { return !ok_; } + explicit operator bool() const { return isOk(); } + const std::string& error() const { assert(!ok_ && "Result::error() called on ok Result — check isErr() first"); return error_; } +}; + +#endif // NIM_FFI_RESULT_HPP_INCLUDED diff --git a/ffi/codegen/templates/cpp/sync_call_helper.hpp.tpl b/ffi/codegen/templates/cpp/sync_call_helper.hpp.tpl index affd5f8..bfe833a 100644 --- a/ffi/codegen/templates/cpp/sync_call_helper.hpp.tpl +++ b/ffi/codegen/templates/cpp/sync_call_helper.hpp.tpl @@ -34,22 +34,25 @@ inline void ffi_cb_(int ret, const char* msg, size_t len, void* ud) { s.cv.notify_one(); } -inline std::vector ffi_call_(std::function f, - std::chrono::milliseconds timeout) { +inline Result> ffi_call_( + std::function f, + std::chrono::milliseconds timeout) { + using Bytes = std::vector; auto state = std::make_shared(); auto* cb_ref = new std::shared_ptr(state); const int ret = f(ffi_cb_, cb_ref); if (ret == 2) { delete cb_ref; - throw std::runtime_error("RET_MISSING_CALLBACK (internal error)"); + return Result::err("RET_MISSING_CALLBACK (internal error)"); } std::unique_lock lock(state->mtx); const bool fired = state->cv.wait_for(lock, timeout, [&]{ return state->done; }); if (!fired) - throw std::runtime_error("FFI call timed out after " + std::to_string(timeout.count()) + "ms"); + return Result::err("FFI call timed out after " + + std::to_string(timeout.count()) + "ms"); if (!state->ok) - throw std::runtime_error(state->err); - return state->bytes; + return Result::err(state->err); + return Result::ok(std::move(state->bytes)); } } // anonymous namespace diff --git a/tests/e2e/cpp/test_timer_e2e.cpp b/tests/e2e/cpp/test_timer_e2e.cpp index 83eeca9..a181d65 100644 --- a/tests/e2e/cpp/test_timer_e2e.cpp +++ b/tests/e2e/cpp/test_timer_e2e.cpp @@ -7,6 +7,11 @@ // genbindings_cpp` is callable end-to-end from C++. // The CrossLibrary test also loads `examples/echo/cpp_bindings` to prove // two nim-ffi libraries can coexist in one process. +// +// The generated bindings never throw: every call returns a Result. The +// `mustOk` helper below unwraps a Result and fails the test (without +// aborting) when it carries an error, so single-threaded tests read as if +// the value came back directly. #include "my_timer.hpp" #include "echo.hpp" @@ -23,8 +28,20 @@ namespace { +// Unwrap a Result in a single-threaded test context. On error it records a +// non-fatal gtest failure and returns a default-constructed T so the caller +// can keep going (subsequent expectations will fail loudly). +template +T mustOk(Result r) { + if (r.isErr()) { + ADD_FAILURE() << "unexpected FFI error: " << r.error() << " line: " << __LINE__; + return T{}; + } + return r.take(); +} + std::unique_ptr makeCtx(const std::string& name = "e2e") { - return MyTimerCtx::create(TimerConfig{name}); + return mustOk(MyTimerCtx::create(TimerConfig{name})); } } // namespace @@ -38,19 +55,19 @@ TEST(TimerE2E, CreateAndDestroy) { TEST(TimerE2E, VersionSync) { auto ctx = makeCtx("version-sync"); - const auto v = ctx->version(); + const auto v = mustOk(ctx->version()); EXPECT_EQ(v, "nim-timer v0.1.0"); } TEST(TimerE2E, VersionAsync) { auto ctx = makeCtx("version-async"); auto fut = ctx->versionAsync(); - EXPECT_EQ(fut.get(), "nim-timer v0.1.0"); + EXPECT_EQ(mustOk(fut.get()), "nim-timer v0.1.0"); } TEST(TimerE2E, EchoRoundTripsMessageAndTimerName) { auto ctx = makeCtx("echo-ctx"); - const auto resp = ctx->echo(EchoRequest{"hello", 10}); + const auto resp = mustOk(ctx->echo(EchoRequest{"hello", 10})); EXPECT_EQ(resp.echoed, "hello"); EXPECT_EQ(resp.timerName, "echo-ctx"); } @@ -60,7 +77,7 @@ TEST(TimerE2E, EchoHonoursDelay) { constexpr int delayMs = 150; const auto start = std::chrono::steady_clock::now(); - const auto resp = ctx->echo(EchoRequest{"waited", delayMs}); + const auto resp = mustOk(ctx->echo(EchoRequest{"waited", delayMs})); const auto elapsed = std::chrono::duration_cast( std::chrono::steady_clock::now() - start).count(); @@ -76,9 +93,9 @@ TEST(TimerE2E, ConcurrentAsyncCallsAreIndependent) { auto f2 = ctx->echoAsync(EchoRequest{"two", 40}); auto f3 = ctx->echoAsync(EchoRequest{"three", 20}); - const auto r3 = f3.get(); - const auto r2 = f2.get(); - const auto r1 = f1.get(); + const auto r3 = mustOk(f3.get()); + const auto r2 = mustOk(f2.get()); + const auto r1 = mustOk(f1.get()); EXPECT_EQ(r1.echoed, "one"); EXPECT_EQ(r2.echoed, "two"); @@ -97,7 +114,7 @@ TEST(TimerE2E, ComplexWithOptionalNotePresent) { std::optional(2), }; - const auto resp = ctx->complex(req); + const auto resp = mustOk(ctx->complex(req)); EXPECT_EQ(resp.itemCount, 2); EXPECT_TRUE(resp.hasNote); EXPECT_NE(resp.summary.find("note=a note"), std::string::npos) @@ -115,7 +132,7 @@ TEST(TimerE2E, ComplexWithOptionalNoteAbsent) { std::nullopt, }; - const auto resp = ctx->complex(req); + const auto resp = mustOk(ctx->complex(req)); EXPECT_EQ(resp.itemCount, 0); EXPECT_FALSE(resp.hasNote); EXPECT_NE(resp.summary.find("note="), std::string::npos) @@ -128,8 +145,8 @@ TEST(TimerE2E, IndependentContextsKeepTheirOwnState) { auto ctxA = makeCtx("alpha"); auto ctxB = makeCtx("beta"); - const auto rA = ctxA->echo(EchoRequest{"x", 5}); - const auto rB = ctxB->echo(EchoRequest{"x", 5}); + const auto rA = mustOk(ctxA->echo(EchoRequest{"x", 5})); + const auto rB = mustOk(ctxB->echo(EchoRequest{"x", 5})); EXPECT_EQ(rA.timerName, "alpha"); EXPECT_EQ(rB.timerName, "beta"); @@ -137,8 +154,8 @@ TEST(TimerE2E, IndependentContextsKeepTheirOwnState) { // N contexts keep independent state; an error on one must not poison siblings. // Empty JobSpec.name is the chosen error trigger: schedule() returns -// err("job name must not be empty"), which the bindings rethrow as -// std::runtime_error carrying the exact string. +// err("job name must not be empty"), which the bindings surface as an +// err() Result carrying the exact string. TEST(TimerE2E, MultiContextIsolation) { constexpr int kCtxCount = 5; std::vector> ctxs; @@ -148,7 +165,7 @@ TEST(TimerE2E, MultiContextIsolation) { } for (int i = 0; i < kCtxCount; ++i) { - const auto resp = ctxs[i]->echo(EchoRequest{"ping", 0}); + const auto resp = mustOk(ctxs[i]->echo(EchoRequest{"ping", 0})); EXPECT_EQ(resp.echoed, "ping"); EXPECT_EQ(resp.timerName, "iso-" + std::to_string(i)); } @@ -156,20 +173,17 @@ TEST(TimerE2E, MultiContextIsolation) { const auto bad = JobSpec{/*name*/ "", /*payload*/ {}, /*priority*/ 0}; const auto retry = RetryPolicy{1, 10, {}}; const auto sched = ScheduleConfig{0, 0, std::nullopt}; - try { - (void)ctxs[2]->schedule(bad, retry, sched); - FAIL() << "expected schedule() to throw on empty job name"; - } catch (const std::runtime_error& ex) { - EXPECT_STREQ(ex.what(), "job name must not be empty"); - } + const auto scheduleRes = ctxs[2]->schedule(bad, retry, sched); + ASSERT_TRUE(scheduleRes.isErr()) << "expected schedule() to fail on empty job name"; + EXPECT_EQ(scheduleRes.error(), "job name must not be empty"); - const auto recovered = ctxs[2]->echo(EchoRequest{"after-err", 0}); + const auto recovered = mustOk(ctxs[2]->echo(EchoRequest{"after-err", 0})); EXPECT_EQ(recovered.echoed, "after-err"); EXPECT_EQ(recovered.timerName, "iso-2"); for (int i = 0; i < kCtxCount; ++i) { if (i == 2) continue; - const auto resp = ctxs[i]->echo(EchoRequest{"still-here", 0}); + const auto resp = mustOk(ctxs[i]->echo(EchoRequest{"still-here", 0})); EXPECT_EQ(resp.echoed, "still-here"); EXPECT_EQ(resp.timerName, "iso-" + std::to_string(i)); } @@ -177,31 +191,31 @@ TEST(TimerE2E, MultiContextIsolation) { // Two nim-ffi libraries in one process must not share state or symbols. TEST(TimerE2E, CrossLibrary) { - auto timerCtx = MyTimerCtx::create(TimerConfig{"x-timer"}); - auto echoCtx = EchoCtx::create(EchoConfig{"X-ECHO"}); + auto timerCtx = mustOk(MyTimerCtx::create(TimerConfig{"x-timer"})); + auto echoCtx = mustOk(EchoCtx::create(EchoConfig{"X-ECHO"})); - EXPECT_EQ(timerCtx->version(), "nim-timer v0.1.0"); - EXPECT_EQ(echoCtx->version(), "nim-echo v0.1.0"); + EXPECT_EQ(mustOk(timerCtx->version()), "nim-timer v0.1.0"); + EXPECT_EQ(mustOk(echoCtx->version()), "nim-echo v0.1.0"); - const auto timerResp = timerCtx->echo(EchoRequest{"hello", 0}); + const auto timerResp = mustOk(timerCtx->echo(EchoRequest{"hello", 0})); EXPECT_EQ(timerResp.echoed, "hello"); EXPECT_EQ(timerResp.timerName, "x-timer"); - const auto echoResp = echoCtx->shout(ShoutRequest{"hello"}); + const auto echoResp = mustOk(echoCtx->shout(ShoutRequest{"hello"})); EXPECT_EQ(echoResp.shouted, "X-ECHO: HELLO"); EXPECT_EQ(echoResp.prefix, "X-ECHO"); for (int i = 0; i < 4; ++i) { - const auto t = timerCtx->echo(EchoRequest{"t" + std::to_string(i), 0}); - const auto e = echoCtx->shout(ShoutRequest{"e" + std::to_string(i)}); + const auto t = mustOk(timerCtx->echo(EchoRequest{"t" + std::to_string(i), 0})); + const auto e = mustOk(echoCtx->shout(ShoutRequest{"e" + std::to_string(i)})); EXPECT_EQ(t.timerName, "x-timer"); EXPECT_EQ(e.prefix, "X-ECHO"); } auto tFut = timerCtx->echoAsync(EchoRequest{"async-t", 30}); auto eFut = echoCtx->shoutAsync(ShoutRequest{"async-e"}); - const auto t = tFut.get(); - const auto e = eFut.get(); + const auto t = mustOk(tFut.get()); + const auto e = mustOk(eFut.get()); EXPECT_EQ(t.echoed, "async-t"); EXPECT_EQ(t.timerName, "x-timer"); EXPECT_EQ(e.shouted, "X-ECHO: ASYNC-E"); @@ -212,9 +226,9 @@ TEST(TimerE2E, TriplePipeline) { auto ctx = makeCtx("pipeline"); auto pipeline = std::async(std::launch::async, [&ctx]() { - auto a = ctx->echoAsync(EchoRequest{"A", 20}).get(); - auto b = ctx->echoAsync(EchoRequest{a.echoed + "->B", 10}).get(); - auto c = ctx->echoAsync(EchoRequest{b.echoed + "->C", 5}).get(); + auto a = mustOk(ctx->echoAsync(EchoRequest{"A", 20}).get()); + auto b = mustOk(ctx->echoAsync(EchoRequest{a.echoed + "->B", 10}).get()); + auto c = mustOk(ctx->echoAsync(EchoRequest{b.echoed + "->C", 5}).get()); return c; }); @@ -224,6 +238,8 @@ TEST(TimerE2E, TriplePipeline) { } // Per-thread context create -> one call -> destroy churns the FFI context pool. +// Worker threads avoid gtest assertion macros (not thread-safe) and report via +// the atomic `errors` counter instead. TEST(TimerE2E, StressShortLivedPerThreadContext) { constexpr int kThreads = 16; @@ -233,14 +249,13 @@ TEST(TimerE2E, StressShortLivedPerThreadContext) { for (int t = 0; t < kThreads; ++t) { workers.emplace_back([&, t] { - try { - auto ctx = makeCtx("short-" + std::to_string(t)); - const auto resp = ctx->echo(EchoRequest{"hi", 0}); - if (resp.echoed != "hi") ++errors; - if (resp.timerName != "short-" + std::to_string(t)) ++errors; - } catch (const std::exception&) { - ++errors; - } + auto ctxRes = MyTimerCtx::create(TimerConfig{"short-" + std::to_string(t)}); + if (ctxRes.isErr()) { ++errors; return; } + auto ctx = std::move(ctxRes.value()); + const auto resp = ctx->echo(EchoRequest{"hi", 0}); + if (resp.isErr()) { ++errors; return; } + if (resp->echoed != "hi") ++errors; + if (resp->timerName != "short-" + std::to_string(t)) ++errors; }); } for (auto& w : workers) w.join(); @@ -259,13 +274,10 @@ TEST(TimerE2E, StressShortLivedSharedContext) { for (int t = 0; t < kThreads; ++t) { workers.emplace_back([&, t] { - try { - const auto resp = shared->echo(EchoRequest{"x" + std::to_string(t), 0}); - if (resp.echoed != "x" + std::to_string(t)) ++errors; - if (resp.timerName != "shared-short") ++errors; - } catch (const std::exception&) { - ++errors; - } + const auto resp = shared->echo(EchoRequest{"x" + std::to_string(t), 0}); + if (resp.isErr()) { ++errors; return; } + if (resp->echoed != "x" + std::to_string(t)) ++errors; + if (resp->timerName != "shared-short") ++errors; }); } for (auto& w : workers) w.join(); @@ -289,14 +301,17 @@ TEST(TimerE2E, ThreadedHammer) { for (int t = 0; t < kThreads; ++t) { workers.emplace_back([&, t] { - auto own = makeCtx("hammer-t" + std::to_string(t)); + auto ownRes = MyTimerCtx::create(TimerConfig{"hammer-t" + std::to_string(t)}); + if (ownRes.isErr()) { ++errors; return; } + auto own = std::move(ownRes.value()); for (int i = 0; i < kIters; ++i) { if ((i & 1) == 0) { const auto r = shared->echo(EchoRequest{"s", 0}); - if (r.echoed != "s") ++errors; + if (r.isErr() || r->echoed != "s") ++errors; } else { auto f = own->echoAsync(EchoRequest{"a", 1}); - if (f.get().echoed != "a") ++errors; + const auto r = f.get(); + if (r.isErr() || r->echoed != "a") ++errors; } } }); @@ -322,7 +337,7 @@ TEST(TimerE2E, TypedEventFiresAfterEcho) { [&](const EchoEvent& evt) { evtPromise.set_value(evt); }); ASSERT_NE(handle.id, 0u) << "addOnEchoFiredListener returned zero id"; - const auto resp = ctx->echo(EchoRequest{"event-msg", 1}); + const auto resp = mustOk(ctx->echo(EchoRequest{"event-msg", 1})); EXPECT_EQ(resp.echoed, "event-msg"); const auto status = evtFuture.wait_for(std::chrono::seconds(2));