chore: avoid throwing exceptions in C++ bindings (#46)

This commit is contained in:
Gabriel Cruz 2026-05-29 12:35:49 -03:00 committed by GitHub
parent e394166c46
commit 7ccf34591d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 576 additions and 290 deletions

View File

@ -14,7 +14,7 @@
#include <string>
#include <cstdint>
#include <chrono>
#include <stdexcept>
#include <charconv>
#include <mutex>
#include <condition_variable>
#include <memory>
@ -24,10 +24,73 @@
#include <optional>
#include <type_traits>
#include <cstring>
#include <cassert>
extern "C" {
#include <tinycbor/cbor.h>
}
// ============================================================
// Result<T> — exception-free error channel
// ============================================================
// The generated bindings never throw: every fallible entry point (create,
// instance methods, and their *Async futures) returns a Result<T>. 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 <typename T>
class Result {
std::optional<T> value_;
std::string error_;
public:
static Result<T> ok(T value) {
Result<T> r;
r.value_ = std::move(value);
return r;
}
static Result<T> err(std::string message) {
Result<T> 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<void> {
bool ok_ = true;
std::string error_;
public:
static Result<void> ok() {
Result<void> r;
r.ok_ = true;
return r;
}
static Result<void> err(std::string message) {
Result<void> 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<void>::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<T>& out) {
// ── Public entry points ─────────────────────────────────────────────────
template<typename T>
inline std::vector<std::uint8_t> encodeCborFFI(const T& value) {
inline Result<std::vector<std::uint8_t>> encodeCborFFI(const T& value) {
// Start with a generous 4 KiB buffer; double on overflow until it fits.
std::vector<std::uint8_t> buf(4096);
while (true) {
@ -169,34 +232,34 @@ inline std::vector<std::uint8_t> 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<std::vector<std::uint8_t>>::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<std::vector<std::uint8_t>>::err(
std::string("FFI CBOR encode failed: ") + cbor_error_string(err));
}
}
template<typename T>
inline T decodeCborFFI(const std::vector<std::uint8_t>& bytes) {
inline Result<T> decodeCborFFI(const std::vector<std::uint8_t>& bytes) {
CborParser parser;
CborValue it;
CborError err = cbor_parser_init(bytes.data(), bytes.size(), 0, &parser, &it);
if (err != CborNoError) {
throw std::runtime_error(std::string("FFI CBOR parse init failed: ") +
cbor_error_string(err));
return Result<T>::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<T>::err(std::string("FFI CBOR decode failed: ") +
cbor_error_string(err));
}
return out;
return Result<T>::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<std::uint8_t> ffi_call_(std::function<int(FFICallback, void*)> f,
std::chrono::milliseconds timeout) {
inline Result<std::vector<std::uint8_t>> ffi_call_(
std::function<int(FFICallback, void*)> f,
std::chrono::milliseconds timeout) {
using Bytes = std::vector<std::uint8_t>;
auto state = std::make_shared<FFICallState_>();
auto* cb_ref = new std::shared_ptr<FFICallState_>(state);
const int ret = f(ffi_cb_, cb_ref);
if (ret == 2) {
delete cb_ref;
throw std::runtime_error("RET_MISSING_CALLBACK (internal error)");
return Result<Bytes>::err("RET_MISSING_CALLBACK (internal error)");
}
std::unique_lock<std::mutex> lock(state->mtx);
const bool fired = state->cv.wait_for(lock, timeout, [&]{ return state->done; });
if (!fired)
throw std::runtime_error("FFI call timed out after " + std::to_string(timeout.count()) + "ms");
return Result<Bytes>::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<Bytes>::err(state->err);
return Result<Bytes>::ok(std::move(state->bytes));
}
} // anonymous namespace
@ -412,23 +478,30 @@ inline std::vector<std::uint8_t> ffi_call_(std::function<int(FFICallback, void*)
class EchoCtx {
public:
static std::unique_ptr<EchoCtx> create(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) {
static Result<std::unique_ptr<EchoCtx>> create(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) {
const auto ffi_req_ = EchoCreateCtorReq{config};
const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_);
const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) {
auto ffi_enc_ = encodeCborFFI(ffi_req_);
if (ffi_enc_.isErr()) return Result<std::unique_ptr<EchoCtx>>::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<std::string>(ffi_raw_);
try {
const auto addr = std::stoull(addr_str);
return std::unique_ptr<EchoCtx>(new EchoCtx(reinterpret_cast<void*>(static_cast<uintptr_t>(addr)), timeout));
} catch (const std::exception&) {
throw std::runtime_error("FFI create returned non-numeric address: " + addr_str);
if (ffi_raw_.isErr()) return Result<std::unique_ptr<EchoCtx>>::err(ffi_raw_.error());
auto ffi_addr_ = decodeCborFFI<std::string>(ffi_raw_.value());
if (ffi_addr_.isErr()) return Result<std::unique_ptr<EchoCtx>>::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<std::unique_ptr<EchoCtx>>::err("FFI create returned non-numeric address: " + addr_str);
}
return Result<std::unique_ptr<EchoCtx>>::ok(std::unique_ptr<EchoCtx>(new EchoCtx(reinterpret_cast<void*>(static_cast<uintptr_t>(addr)), timeout)));
}
static std::future<std::unique_ptr<EchoCtx>> createAsync(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) {
static std::future<Result<std::unique_ptr<EchoCtx>>> createAsync(const EchoConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) {
return std::async(std::launch::async, [config, timeout]() { return create(config, timeout); });
}
@ -453,29 +526,35 @@ public:
EchoCtx(EchoCtx&&) = delete;
EchoCtx& operator=(EchoCtx&&) = delete;
ShoutResponse shout(const ShoutRequest& req) const {
Result<ShoutResponse> shout(const ShoutRequest& req) const {
const auto ffi_req_ = EchoShoutReq{req};
const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_);
const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) {
auto ffi_enc_ = encodeCborFFI(ffi_req_);
if (ffi_enc_.isErr()) return Result<ShoutResponse>::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<ShoutResponse>(ffi_raw_);
if (ffi_raw_.isErr()) return Result<ShoutResponse>::err(ffi_raw_.error());
return decodeCborFFI<ShoutResponse>(ffi_raw_.value());
}
std::future<ShoutResponse> shoutAsync(const ShoutRequest& req) const {
std::future<Result<ShoutResponse>> shoutAsync(const ShoutRequest& req) const {
return std::async(std::launch::async, [this, req]() { return this->shout(req); });
}
std::string version() const {
Result<std::string> version() const {
const auto ffi_req_ = EchoVersionReq{};
const auto ffi_req_bytes_ = encodeCborFFI(ffi_req_);
const auto ffi_raw_ = ffi_call_([&](FFICallback cb, void* ud) {
auto ffi_enc_ = encodeCborFFI(ffi_req_);
if (ffi_enc_.isErr()) return Result<std::string>::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<std::string>(ffi_raw_);
if (ffi_raw_.isErr()) return Result<std::string>::err(ffi_raw_.error());
return decodeCborFFI<std::string>(ffi_raw_.value());
}
std::future<std::string> versionAsync() const {
std::future<Result<std::string>> versionAsync() const {
return std::async(std::launch::async, [this]() { return this->version(); });
}

View File

@ -5,121 +5,142 @@
#include <iostream>
#include <thread>
// The generated bindings never throw: every call returns a Result<T>. 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>{EchoRequest{"one", 10}, EchoRequest{"two", 20}},
std::vector<std::string>{"fast", "async"},
std::optional<std::string>("extra note"),
std::optional<int64_t>(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<std::string>{"rollup", "v2"},
/*priority*/ 10,
};
auto retry = RetryPolicy{
/*maxAttempts*/ 3,
/*backoffMs*/ 500,
/*retryOn*/ std::vector<std::string>{"timeout", "5xx"},
};
auto schedule = ScheduleConfig{
/*startAtMs*/ 1000,
/*intervalMs*/ 15000,
/*jitter*/ std::optional<int64_t>(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<EchoEvent> echoEvtPromise;
auto echoEvtFuture = echoEvtPromise.get_future();
const auto typedHandle = ctx->addOnEchoFiredListener(
[&](const EchoEvent& evt) { echoEvtPromise.set_value(evt); });
std::atomic<int> 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<T>` 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<const std::uint8_t> 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>{EchoRequest{"one", 10}, EchoRequest{"two", 20}},
std::vector<std::string>{"fast", "async"},
std::optional<std::string>("extra note"),
std::optional<int64_t>(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<std::string>{"rollup", "v2"},
/*priority*/ 10,
};
auto retry = RetryPolicy{
/*maxAttempts*/ 3,
/*backoffMs*/ 500,
/*retryOn*/ std::vector<std::string>{"timeout", "5xx"},
};
auto schedule = ScheduleConfig{
/*startAtMs*/ 1000,
/*intervalMs*/ 15000,
/*jitter*/ std::optional<int64_t>(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<EchoEvent> echoEvtPromise;
auto echoEvtFuture = echoEvtPromise.get_future();
const auto typedHandle = ctx->addOnEchoFiredListener(
[&](const EchoEvent& evt) { echoEvtPromise.set_value(evt); });
std::atomic<int> 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<T>` 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<const std::uint8_t> 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;
}

View File

@ -14,7 +14,7 @@
#include <string>
#include <cstdint>
#include <chrono>
#include <stdexcept>
#include <charconv>
#include <mutex>
#include <condition_variable>
#include <memory>
@ -24,12 +24,75 @@
#include <optional>
#include <type_traits>
#include <cstring>
#include <cassert>
extern "C" {
#include <tinycbor/cbor.h>
}
#include <unordered_map>
#include <span>
// ============================================================
// Result<T> — exception-free error channel
// ============================================================
// The generated bindings never throw: every fallible entry point (create,
// instance methods, and their *Async futures) returns a Result<T>. 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 <typename T>
class Result {
std::optional<T> value_;
std::string error_;
public:
static Result<T> ok(T value) {
Result<T> r;
r.value_ = std::move(value);
return r;
}
static Result<T> err(std::string message) {
Result<T> 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<void> {
bool ok_ = true;
std::string error_;
public:
static Result<void> ok() {
Result<void> r;
r.ok_ = true;
return r;
}
static Result<void> err(std::string message) {
Result<void> 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<void>::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<T>& out) {
// ── Public entry points ─────────────────────────────────────────────────
template<typename T>
inline std::vector<std::uint8_t> encodeCborFFI(const T& value) {
inline Result<std::vector<std::uint8_t>> encodeCborFFI(const T& value) {
// Start with a generous 4 KiB buffer; double on overflow until it fits.
std::vector<std::uint8_t> buf(4096);
while (true) {
@ -171,34 +234,34 @@ inline std::vector<std::uint8_t> 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<std::vector<std::uint8_t>>::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<std::vector<std::uint8_t>>::err(
std::string("FFI CBOR encode failed: ") + cbor_error_string(err));
}
}
template<typename T>
inline T decodeCborFFI(const std::vector<std::uint8_t>& bytes) {
inline Result<T> decodeCborFFI(const std::vector<std::uint8_t>& bytes) {
CborParser parser;
CborValue it;
CborError err = cbor_parser_init(bytes.data(), bytes.size(), 0, &parser, &it);
if (err != CborNoError) {
throw std::runtime_error(std::string("FFI CBOR parse init failed: ") +
cbor_error_string(err));
return Result<T>::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<T>::err(std::string("FFI CBOR decode failed: ") +
cbor_error_string(err));
}
return out;
return Result<T>::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<std::uint8_t> ffi_call_(std::function<int(FFICallback, void*)> f,
std::chrono::milliseconds timeout) {
inline Result<std::vector<std::uint8_t>> ffi_call_(
std::function<int(FFICallback, void*)> f,
std::chrono::milliseconds timeout) {
using Bytes = std::vector<std::uint8_t>;
auto state = std::make_shared<FFICallState_>();
auto* cb_ref = new std::shared_ptr<FFICallState_>(state);
const int ret = f(ffi_cb_, cb_ref);
if (ret == 2) {
delete cb_ref;
throw std::runtime_error("RET_MISSING_CALLBACK (internal error)");
return Result<Bytes>::err("RET_MISSING_CALLBACK (internal error)");
}
std::unique_lock<std::mutex> lock(state->mtx);
const bool fired = state->cv.wait_for(lock, timeout, [&]{ return state->done; });
if (!fired)
throw std::runtime_error("FFI call timed out after " + std::to_string(timeout.count()) + "ms");
return Result<Bytes>::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<Bytes>::err(state->err);
return Result<Bytes>::ok(std::move(state->bytes));
}
} // anonymous namespace
@ -726,23 +792,30 @@ inline bool decodeEventPayload(std::span<const std::uint8_t> envelope, T& out) {
class MyTimerCtx {
public:
static std::unique_ptr<MyTimerCtx> create(const TimerConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) {
static Result<std::unique_ptr<MyTimerCtx>> 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<std::unique_ptr<MyTimerCtx>>::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<std::string>(ffi_raw_);
try {
const auto addr = std::stoull(addr_str);
return std::unique_ptr<MyTimerCtx>(new MyTimerCtx(reinterpret_cast<void*>(static_cast<uintptr_t>(addr)), timeout));
} catch (const std::exception&) {
throw std::runtime_error("FFI create returned non-numeric address: " + addr_str);
if (ffi_raw_.isErr()) return Result<std::unique_ptr<MyTimerCtx>>::err(ffi_raw_.error());
auto ffi_addr_ = decodeCborFFI<std::string>(ffi_raw_.value());
if (ffi_addr_.isErr()) return Result<std::unique_ptr<MyTimerCtx>>::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<std::unique_ptr<MyTimerCtx>>::err("FFI create returned non-numeric address: " + addr_str);
}
return Result<std::unique_ptr<MyTimerCtx>>::ok(std::unique_ptr<MyTimerCtx>(new MyTimerCtx(reinterpret_cast<void*>(static_cast<uintptr_t>(addr)), timeout)));
}
static std::future<std::unique_ptr<MyTimerCtx>> createAsync(const TimerConfig& config, std::chrono::milliseconds timeout = std::chrono::seconds{30}) {
static std::future<Result<std::unique_ptr<MyTimerCtx>>> 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<EchoResponse> 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<EchoResponse>::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<EchoResponse>(ffi_raw_);
if (ffi_raw_.isErr()) return Result<EchoResponse>::err(ffi_raw_.error());
return decodeCborFFI<EchoResponse>(ffi_raw_.value());
}
std::future<EchoResponse> echoAsync(const EchoRequest& req) const {
std::future<Result<EchoResponse>> echoAsync(const EchoRequest& req) const {
return std::async(std::launch::async, [this, req]() { return this->echo(req); });
}
std::string version() const {
Result<std::string> 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<std::string>::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<std::string>(ffi_raw_);
if (ffi_raw_.isErr()) return Result<std::string>::err(ffi_raw_.error());
return decodeCborFFI<std::string>(ffi_raw_.value());
}
std::future<std::string> versionAsync() const {
std::future<Result<std::string>> versionAsync() const {
return std::async(std::launch::async, [this]() { return this->version(); });
}
ComplexResponse complex(const ComplexRequest& req) const {
Result<ComplexResponse> 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<ComplexResponse>::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<ComplexResponse>(ffi_raw_);
if (ffi_raw_.isErr()) return Result<ComplexResponse>::err(ffi_raw_.error());
return decodeCborFFI<ComplexResponse>(ffi_raw_.value());
}
std::future<ComplexResponse> complexAsync(const ComplexRequest& req) const {
std::future<Result<ComplexResponse>> 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<ScheduleResult> 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<ScheduleResult>::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<ScheduleResult>(ffi_raw_);
if (ffi_raw_.isErr()) return Result<ScheduleResult>::err(ffi_raw_.error());
return decodeCborFFI<ScheduleResult>(ffi_raw_.value());
}
std::future<ScheduleResult> scheduleAsync(const JobSpec& job, const RetryPolicy& retry, const ScheduleConfig& schedule) const {
std::future<Result<ScheduleResult>> 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); });
}

View File

@ -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 <unordered_map>")
lines.add("#include <span>")
# Result<T> 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<std::unique_ptr<$1>>" % [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<std::string>(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<std::string>(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<void*>(static_cast<uintptr_t>(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<void*>(static_cast<uintptr_t>(addr)), timeout)));" %
[createRet, ctxTypeName]
)
lines.add(" }")
lines.add("")
@ -559,7 +576,7 @@ proc generateCppHeader*(
else:
"timeout"
lines.add(
" static std::future<std::unique_ptr<$1>> createAsync($2) {" %
" static std::future<Result<std::unique_ptr<$1>>> 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]

View File

@ -129,7 +129,7 @@ inline CborError decode_cbor(CborValue& it, std::optional<T>& out) {
// ── Public entry points ─────────────────────────────────────────────────
template<typename T>
inline std::vector<std::uint8_t> encodeCborFFI(const T& value) {
inline Result<std::vector<std::uint8_t>> encodeCborFFI(const T& value) {
// Start with a generous 4 KiB buffer; double on overflow until it fits.
std::vector<std::uint8_t> buf(4096);
while (true) {
@ -139,34 +139,34 @@ inline std::vector<std::uint8_t> 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<std::vector<std::uint8_t>>::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<std::vector<std::uint8_t>>::err(
std::string("FFI CBOR encode failed: ") + cbor_error_string(err));
}
}
template<typename T>
inline T decodeCborFFI(const std::vector<std::uint8_t>& bytes) {
inline Result<T> decodeCborFFI(const std::vector<std::uint8_t>& bytes) {
CborParser parser;
CborValue it;
CborError err = cbor_parser_init(bytes.data(), bytes.size(), 0, &parser, &it);
if (err != CborNoError) {
throw std::runtime_error(std::string("FFI CBOR parse init failed: ") +
cbor_error_string(err));
return Result<T>::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<T>::err(std::string("FFI CBOR decode failed: ") +
cbor_error_string(err));
}
return out;
return Result<T>::ok(std::move(out));
}
#endif // NIM_FFI_CBOR_HELPERS_HPP_INCLUDED

View File

@ -14,7 +14,7 @@
#include <string>
#include <cstdint>
#include <chrono>
#include <stdexcept>
#include <charconv>
#include <mutex>
#include <condition_variable>
#include <memory>
@ -24,6 +24,7 @@
#include <optional>
#include <type_traits>
#include <cstring>
#include <cassert>
extern "C" {
#include <tinycbor/cbor.h>
}

View File

@ -0,0 +1,61 @@
// ============================================================
// Result<T> — exception-free error channel
// ============================================================
// The generated bindings never throw: every fallible entry point (create,
// instance methods, and their *Async futures) returns a Result<T>. 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 <typename T>
class Result {
std::optional<T> value_;
std::string error_;
public:
static Result<T> ok(T value) {
Result<T> r;
r.value_ = std::move(value);
return r;
}
static Result<T> err(std::string message) {
Result<T> 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<void> {
bool ok_ = true;
std::string error_;
public:
static Result<void> ok() {
Result<void> r;
r.ok_ = true;
return r;
}
static Result<void> err(std::string message) {
Result<void> 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<void>::error() called on ok Result — check isErr() first"); return error_; }
};
#endif // NIM_FFI_RESULT_HPP_INCLUDED

View File

@ -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<std::uint8_t> ffi_call_(std::function<int(FFICallback, void*)> f,
std::chrono::milliseconds timeout) {
inline Result<std::vector<std::uint8_t>> ffi_call_(
std::function<int(FFICallback, void*)> f,
std::chrono::milliseconds timeout) {
using Bytes = std::vector<std::uint8_t>;
auto state = std::make_shared<FFICallState_>();
auto* cb_ref = new std::shared_ptr<FFICallState_>(state);
const int ret = f(ffi_cb_, cb_ref);
if (ret == 2) {
delete cb_ref;
throw std::runtime_error("RET_MISSING_CALLBACK (internal error)");
return Result<Bytes>::err("RET_MISSING_CALLBACK (internal error)");
}
std::unique_lock<std::mutex> lock(state->mtx);
const bool fired = state->cv.wait_for(lock, timeout, [&]{ return state->done; });
if (!fired)
throw std::runtime_error("FFI call timed out after " + std::to_string(timeout.count()) + "ms");
return Result<Bytes>::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<Bytes>::err(state->err);
return Result<Bytes>::ok(std::move(state->bytes));
}
} // anonymous namespace

View File

@ -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<T>. 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<T> 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 <typename T>
T mustOk(Result<T> r) {
if (r.isErr()) {
ADD_FAILURE() << "unexpected FFI error: " << r.error() << " line: " << __LINE__;
return T{};
}
return r.take();
}
std::unique_ptr<MyTimerCtx> 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::milliseconds>(
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<int64_t>(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=<none>"), 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<std::unique_ptr<MyTimerCtx>> 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));