From 7764b2f43b21b0dcc8e34bd4ec16e7b4018d5b22 Mon Sep 17 00:00:00 2001 From: Ivan FB Date: Mon, 11 May 2026 21:44:53 +0200 Subject: [PATCH] =?UTF-8?q?fix=20C++=20"async"=20methods=20spawn=20a=20thr?= =?UTF-8?q?ead=20per=20call=20that=20then=20blocks=20on=20a=20condvar=20(c?= =?UTF-8?q?pp.nim:362=E2=80=93377).=20std::async(std::launch::async,=20...?= =?UTF-8?q?)=20forces=20a=20fresh=20thread,=20and=20the=20body=20runs=20?= =?UTF-8?q?=20=20the=20blocking=20ffi=5Fcall=5F=20which=20waits=20on=20a?= =?UTF-8?q?=20condvar=20with=20the=20user's=20timeout=20(default=2030s).?= =?UTF-8?q?=20Under=20load=20this=20is=20a=20thread-explosion=20factory,?= =?UTF-8?q?=20and=20the=20name=20"async"=20is=20misleading=20=E2=80=94=20t?= =?UTF-8?q?he=20=20=20Rust=20side=20has=20real=20async=20via=20tokio=20one?= =?UTF-8?q?shot,=20but=20the=20C++=20side=20has=20fake=20async.=20If=20tru?= =?UTF-8?q?e=20async=20isn't=20reachable=20in=20C++=20without=20coroutines?= =?UTF-8?q?,=20fine,=20but=20at=20least=20pool=20the=20threads=20or=20=20?= =?UTF-8?q?=20document=20this=20is=20just=20a=20convenience=20wrapper.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/nim_timer/cpp_bindings/nimtimer.hpp | 93 ++++++++++++- ffi/codegen/cpp.nim | 138 +++++++++++++++---- 2 files changed, 198 insertions(+), 33 deletions(-) diff --git a/examples/nim_timer/cpp_bindings/nimtimer.hpp b/examples/nim_timer/cpp_bindings/nimtimer.hpp index b753a4d..77af079 100644 --- a/examples/nim_timer/cpp_bindings/nimtimer.hpp +++ b/examples/nim_timer/cpp_bindings/nimtimer.hpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -148,10 +149,63 @@ inline std::string ffi_call_(std::function f, return state->msg; } +// True-async helpers: std::promise + std::future mirror the Rust +// tokio::sync::oneshot design -- the FFI callback completes the promise +// directly, so the returned future becomes ready without ever blocking a +// thread. The C ABI trampoline cannot be a template, so the per-T completion +// logic is type-erased into a std::function held inside FfiAsyncState_. +struct FfiAsyncState_ { + std::function complete; +}; + +inline void ffi_cb_async_(int ret, const char* msg, size_t /*len*/, void* ud) { + auto* state = static_cast(ud); + state->complete(ret, msg); + delete state; +} + +template +inline std::future ffi_call_async_(std::function f) { + auto promise = std::make_shared>(); + auto future = promise->get_future(); + auto* state = new FfiAsyncState_{ + [promise](int ret, const char* msg) { + const std::string s = msg ? std::string(msg) : std::string{}; + try { + if (ret == 0) { + if constexpr (std::is_same_v) { + promise->set_value(s); + } else if constexpr (std::is_same_v) { + promise->set_value(deserializeFfiResult(s)); + } else { + promise->set_value(deserializeFfiResult(s)); + } + } else { + promise->set_exception(std::make_exception_ptr(std::runtime_error(s))); + } + } catch (...) { + promise->set_exception(std::current_exception()); + } + } + }; + const int ret = f(ffi_cb_async_, state); + if (ret == 2) { + delete state; + throw std::runtime_error("RET_MISSING_CALLBACK (internal error)"); + } + return future; +} + } // anonymous namespace // ============================================================ // High-level C++ context class +// +// Async methods (createAsync / Async) return a std::future +// that becomes ready when the Nim callback fires. No thread is +// spawned for the wait: the FFI callback completes the underlying +// std::promise directly, mirroring the Rust tokio::oneshot path. +// Apply timeouts via future.wait_for(...) on the caller's side. // ============================================================ class NimTimerCtx { @@ -170,7 +224,30 @@ public: } 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); }); + const auto config_json = serializeFfiArg(config); + auto promise = std::make_shared>(); + auto future = promise->get_future(); + auto* state = new FfiAsyncState_{ + [promise, timeout](int ret, const char* msg) { + const std::string s = msg ? std::string(msg) : std::string{}; + try { + if (ret == 0) { + const auto addr = std::stoull(s); + promise->set_value(NimTimerCtx(reinterpret_cast(static_cast(addr)), timeout)); + } else { + promise->set_exception(std::make_exception_ptr(std::runtime_error(s))); + } + } catch (...) { + promise->set_exception(std::current_exception()); + } + } + }; + const int ret = nimtimer_create(config_json.c_str(), ffi_cb_async_, state); + if (ret == 2) { + delete state; + throw std::runtime_error("RET_MISSING_CALLBACK (internal error)"); + } + return future; } ~NimTimerCtx() { @@ -205,7 +282,10 @@ public: } std::future echoAsync(const EchoRequest& req) const { - return std::async(std::launch::async, [this, req]() { return echo(req); }); + const auto req_json = serializeFfiArg(req); + return ffi_call_async_([&](FfiCallback cb, void* ud) { + return nimtimer_echo(ptr_, cb, ud, req_json.c_str()); + }); } std::string version() const { @@ -216,7 +296,9 @@ public: } std::future versionAsync() const { - return std::async(std::launch::async, [this]() { return version(); }); + return ffi_call_async_([&](FfiCallback cb, void* ud) { + return nimtimer_version(ptr_, cb, ud); + }); } ComplexResponse complex(const ComplexRequest& req) const { @@ -228,7 +310,10 @@ public: } std::future complexAsync(const ComplexRequest& req) const { - return std::async(std::launch::async, [this, req]() { return complex(req); }); + const auto req_json = serializeFfiArg(req); + return ffi_call_async_([&](FfiCallback cb, void* ud) { + return nimtimer_complex(ptr_, cb, ud, req_json.c_str()); + }); } private: diff --git a/ffi/codegen/cpp.nim b/ffi/codegen/cpp.nim index 920901f..652af2f 100644 --- a/ffi/codegen/cpp.nim +++ b/ffi/codegen/cpp.nim @@ -57,6 +57,7 @@ proc generateCppHeader*( lines.add("#include ") lines.add("#include ") lines.add("#include ") + lines.add("#include ") lines.add("#include ") lines.add("#include ") lines.add("#include ") @@ -220,6 +221,60 @@ proc generateCppHeader*( lines.add(" return state->msg;") lines.add("}") lines.add("") + + # ── True-async helpers ──────────────────────────────────────────────────── + # std::promise + std::future mirror the Rust tokio::sync::oneshot + # design: the FFI callback completes the promise directly, so the returned + # future becomes ready without ever blocking a thread. + # + # Type erasure: the C ABI callback (ffi_cb_async_) cannot be a template, + # so the per-T completion logic is stored in a std::function held inside + # FfiAsyncState_. The trampoline just dispatches to it and frees the state. + lines.add("struct FfiAsyncState_ {") + lines.add(" std::function complete;") + lines.add("};") + lines.add("") + lines.add("inline void ffi_cb_async_(int ret, const char* msg, size_t /*len*/, void* ud) {") + lines.add(" auto* state = static_cast(ud);") + lines.add(" state->complete(ret, msg);") + lines.add(" delete state;") + lines.add("}") + lines.add("") + # Generic per-T helper. JSON deserialization happens in the callback (the + # same Nim/chronos thread that already runs the blocking path's notify); + # this keeps the returned future correctly waitable via wait_for/wait_until. + lines.add("template") + lines.add("inline std::future ffi_call_async_(std::function f) {") + lines.add(" auto promise = std::make_shared>();") + lines.add(" auto future = promise->get_future();") + lines.add(" auto* state = new FfiAsyncState_{") + lines.add(" [promise](int ret, const char* msg) {") + lines.add(" const std::string s = msg ? std::string(msg) : std::string{};") + lines.add(" try {") + lines.add(" if (ret == 0) {") + lines.add(" if constexpr (std::is_same_v) {") + lines.add(" promise->set_value(s);") + lines.add(" } else if constexpr (std::is_same_v) {") + lines.add(" promise->set_value(deserializeFfiResult(s));") + lines.add(" } else {") + lines.add(" promise->set_value(deserializeFfiResult(s));") + lines.add(" }") + lines.add(" } else {") + lines.add(" promise->set_exception(std::make_exception_ptr(std::runtime_error(s)));") + lines.add(" }") + lines.add(" } catch (...) {") + lines.add(" promise->set_exception(std::current_exception());") + lines.add(" }") + lines.add(" }") + lines.add(" };") + lines.add(" const int ret = f(ffi_cb_async_, state);") + lines.add(" if (ret == 2) {") + lines.add(" delete state;") + lines.add(" throw std::runtime_error(\"RET_MISSING_CALLBACK (internal error)\");") + lines.add(" }") + lines.add(" return future;") + lines.add("}") + lines.add("") lines.add("} // anonymous namespace") lines.add("") @@ -238,6 +293,12 @@ proc generateCppHeader*( lines.add("// ============================================================") lines.add("// High-level C++ context class") + lines.add("//") + lines.add("// Async methods (createAsync / Async) return a std::future") + lines.add("// that becomes ready when the Nim callback fires. No thread is") + lines.add("// spawned for the wait: the FFI callback completes the underlying") + lines.add("// std::promise directly, mirroring the Rust tokio::oneshot path.") + lines.add("// Apply timeouts via future.wait_for(...) on the caller's side.") lines.add("// ============================================================") lines.add("") lines.add("class $1 {" % [ctxTypeName]) @@ -281,21 +342,45 @@ proc generateCppHeader*( lines.add(" }") lines.add("") - # -- createAsync() factory: uses actual param types, not hardcoded -- - let captureList = - if epNames.len > 0: epNames.join(", ") & ", timeout" - else: "timeout" - let callList = - if epNames.len > 0: epNames.join(", ") & ", timeout" - else: "timeout" + # -- createAsync() factory: true async via std::promise; no thread is + # spawned, the FFI callback constructs the Ctx and completes the promise. lines.add( " static std::future<$1> createAsync($2) {" % [ctxTypeName, ctorParamsWithTimeout] ) + for ep in ctor.extraParams: + lines.add(" const auto $1_json = serializeFfiArg($1);" % [ep.name]) + lines.add(" auto promise = std::make_shared>();" % [ctxTypeName]) + lines.add(" auto future = promise->get_future();") + lines.add(" auto* state = new FfiAsyncState_{") + lines.add(" [promise, timeout](int ret, const char* msg) {") + lines.add(" const std::string s = msg ? std::string(msg) : std::string{};") + lines.add(" try {") + lines.add(" if (ret == 0) {") + lines.add(" const auto addr = std::stoull(s);") lines.add( - " return std::async(std::launch::async, [$1]() { return create($2); });" % - [captureList, callList] + " promise->set_value($1(reinterpret_cast(static_cast(addr)), timeout));" % + [ctxTypeName] ) + lines.add(" } else {") + lines.add(" promise->set_exception(std::make_exception_ptr(std::runtime_error(s)));") + lines.add(" }") + lines.add(" } catch (...) {") + lines.add(" promise->set_exception(std::current_exception());") + lines.add(" }") + lines.add(" }") + lines.add(" };") + var ctorCallArgs: seq[string] = @[] + for ep in ctor.extraParams: + ctorCallArgs.add("$1_json.c_str()" % [ep.name]) + ctorCallArgs.add("ffi_cb_async_") + ctorCallArgs.add("state") + lines.add(" const int ret = $1($2);" % [ctor.procName, ctorCallArgs.join(", ")]) + lines.add(" if (ret == 2) {") + lines.add(" delete state;") + lines.add(" throw std::runtime_error(\"RET_MISSING_CALLBACK (internal error)\");") + lines.add(" }") + lines.add(" return future;") lines.add(" }") lines.add("") @@ -334,12 +419,9 @@ proc generateCppHeader*( let retCppType = nimTypeToCpp(m.returnTypeName) var methParams: seq[string] = @[] - var methParamNames: seq[string] = @[] for ep in m.extraParams: methParams.add("const $1& $2" % [nimTypeToCpp(ep.typeName), ep.name]) - methParamNames.add(ep.name) let methParamsStr = methParams.join(", ") - let methParamNamesStr = methParamNames.join(", ") lines.add(" $1 $2($3) const {" % [retCppType, methodName, methParamsStr]) for ep in m.extraParams: @@ -356,23 +438,21 @@ proc generateCppHeader*( lines.add(" return deserializeFfiResult<$1>(raw);" % [retCppType]) lines.add(" }") lines.add("") - if methParamsStr.len > 0: - lines.add( - " std::future<$1> $2Async($3) const {" % - [retCppType, methodName, methParamsStr] - ) - lines.add( - " return std::async(std::launch::async, [this, $1]() { return $2($3); });" % - [methParamNamesStr, methodName, methParamNamesStr] - ) - lines.add(" }") - else: - lines.add(" std::future<$1> $2Async() const {" % [retCppType, methodName]) - lines.add( - " return std::async(std::launch::async, [this]() { return $1(); });" % - [methodName] - ) - lines.add(" }") + # -- Async: true async via std::promise; the FFI callback + # completes the promise on the Nim thread. No std::thread is spawned. + lines.add( + " std::future<$1> $2Async($3) const {" % + [retCppType, methodName, methParamsStr] + ) + for ep in m.extraParams: + lines.add(" const auto $1_json = serializeFfiArg($1);" % [ep.name]) + lines.add(" return ffi_call_async_<$1>([&](FfiCallback cb, void* ud) {" % [retCppType]) + var asyncCallArgs = @["ptr_", "cb", "ud"] + for ep in m.extraParams: + asyncCallArgs.add("$1_json.c_str()" % [ep.name]) + lines.add(" return $1($2);" % [m.procName, asyncCallArgs.join(", ")]) + lines.add(" });") + lines.add(" }") lines.add("") lines.add("private:")