mirror of
https://github.com/logos-co/logos-cpp-sdk.git
synced 2026-08-31 01:31:10 +00:00
* feat(lp): emit the AsyncResult twin, and fix the LpClient create race
Two related changes to the Qt-free (ApiStyle::Lp) consumer surface.
1. logos::LpClient::ensure() published its lazily-created lp_client through a
plain pointer with no synchronization. Two threads reach a dep's FIRST call
concurrently more often than the lazy-init shape suggests: a
concurrency:"multi" module dispatches handlers on concurrent QThreads, and
any module running a worker of its own (an HTTP handler, a chain-sync pump)
races that worker against the dispatch thread. So this was a data race, and
it leaked whichever client lost.
A mutex around the body is the obvious fix and the wrong one: for a Qt-affine
transport lp_client_create marshals construction onto the Qt main thread and
BLOCKS there, so a worker holding the lock would wait for the main thread
while the main thread, reaching the same ensure() from an inbound call, waits
for the lock — trading a data race for a deadlock. Construct outside any lock
and publish with a CAS instead; the loser destroys its own client, which
lp_client_destroy permits from any thread. A failed create is not latched.
2. `<name>AsyncResult` is now emitted for the lp surface, matching the Qt one.
It was withheld for a reason that belonged to the transport rather than the
emitter: lp_invoke_async used to hard-code `cb(1, ...)`, so an AsyncResult
over it would have reported ok() for a call to a module that was not even
loaded — an error channel that lies is worse than none. logos-protocol#40
fixed that, and the new logos::LpClient::invokeAsyncResult surfaces the
failure in C++.
The generated twin also folds a provider REJECTION: a provider that ran and
refused answers {"code": "dispatch_failed", ...} as its RESULT, not as a
transport error, so the decode would otherwise erase it into a default value.
The lp SYNC path folds it too now, as the Qt sync path already did — with no
qWarning fallback, since a Qt-free wrapper has no logger to fall back to.
lp `<name>Async` is deliberately unchanged: its callback takes the value
alone, exactly as on the Qt side.
Also unblocks logos-qt-sdk's LpBridge::invokeAsyncResult, which keeps a private
second lp_client only because logos::LpClient had no error-carrying async.
Verified: sdk tests 309/309 (incl. 9 new LpClient and 10 new generator tests),
generator-cli green, and the generated wrapper compiles under -Wall -Wextra
-Werror. The concurrency test was checked against the pre-fix header as a
control: 8 threads, 8 clients created, 7 leaked, threads disagreeing on which
client was the module's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(tests): stub lp_string_free, and keep the path that needs it live
The sdk_tests link failed on GCC/Linux with an undefined reference to
lp_string_free from logos::LpClient::getMethods(), while linking cleanly on
macOS/clang.
The stub set was genuinely missing lp_string_free. It went unnoticed because the
lp_get_methods stub returned NULL, which made getMethods()'s free call
unreachable: lp_get_methods is a non-inline extern "C" function DEFINED IN THE
SAME TU as the test, so clang may inline it, prove the pointer null and delete
the call — no reference, no link error. GCC kept the call, and the linker wanted
the symbol.
Adding the stub alone would fix the link and leave the trap: the free path would
still be dead, so the next compiler that keeps the call decides whether this
builds. So lp_get_methods now returns a real heap allocation, which is the ABI's
actual contract ("every char* RETURNED by this library is owned by the caller;
free it with lp_string_free"), and lp_string_free frees it and counts. The
single-threaded test asserts the count, so the ownership rule is pinned rather
than merely satisfied.
Verified by reproducing the failure on macOS with -O0 (which stops clang folding
the call away): the pre-fix file fails with exactly `"_lp_string_free",
referenced from: logos::LpClient::getMethods()`, and the fixed one links and
runs 9/9. Full check: 308/308.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
327 lines
12 KiB
C++
327 lines
12 KiB
C++
// logos::LpClient over STUBBED lp_* symbols — the two behaviours that are the
|
|
// wrapper's own rather than the transport's: when it creates its client, and how
|
|
// it decodes the C ABI's success/failure form.
|
|
//
|
|
// logos::LpClient creates its lp_client lazily, on whichever thread makes the
|
|
// first call through a generated `<dep>_api` wrapper. That thread is genuinely
|
|
// arbitrary and there can be more than one of it: a concurrency:"multi" module
|
|
// dispatches handlers on concurrent QThreads, and any module running a worker
|
|
// of its own (an HTTP handler, a chain-sync pump) races that worker against the
|
|
// dispatch thread on the very first call.
|
|
//
|
|
// Pre-fix, ensure() was `if (!m_client) m_client = lp_client_create(...)` — a
|
|
// data race on a plain pointer, with the losing racer's client leaked. This
|
|
// suite pins the replacement: construct outside any lock, publish with a CAS,
|
|
// and have the loser destroy its own client.
|
|
//
|
|
// The lp_* symbols below are LOCAL STUBS, not the real C ABI. sdk_tests
|
|
// deliberately does not link logos-protocol's library (see the CMakeLists),
|
|
// and the point here is the SDK-side logic, not the transport: the stubs let
|
|
// the test widen the create race, count exactly how many clients were built,
|
|
// published and destroyed, and drive lp_invoke_async through each of its
|
|
// documented outcomes — none of which the real library exposes.
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <mutex>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
#include "logos_lp_client.h"
|
|
|
|
namespace {
|
|
|
|
std::atomic<int> g_created{0};
|
|
std::atomic<int> g_destroyed{0};
|
|
// How many creates still have to fail before one is allowed to succeed. Models
|
|
// the deterministic-failure case (bad target/origin) the real
|
|
// lp_client_create() reports by returning NULL.
|
|
std::atomic<int> g_failNext{0};
|
|
// Widens the window between "created" and "published" so both racers really do
|
|
// build a client, rather than the test passing because one happened to win.
|
|
std::atomic<bool> g_slowCreate{false};
|
|
|
|
std::mutex g_seenMutex;
|
|
std::vector<lp_client*> g_seen; // the client each getMethods() call observed
|
|
std::atomic<int> g_stringsFreed{0};
|
|
|
|
// How the next lp_invoke_async should behave. Named for the C ABI outcome each
|
|
// one models, not for the test that uses it.
|
|
enum class AsyncStub {
|
|
Success, // ok != 0, `json` is the result value
|
|
FailWithError, // ok == 0, `json` is the canonical {code, message, origin}
|
|
FailMalformed, // ok == 0, `json` is not a usable error object
|
|
RefuseSync, // returns LP_ERR_INVALID_ARG and does NOT call back
|
|
};
|
|
AsyncStub g_asyncStub = AsyncStub::Success;
|
|
|
|
void resetStubs() {
|
|
g_created = 0;
|
|
g_destroyed = 0;
|
|
g_failNext = 0;
|
|
g_slowCreate = false;
|
|
g_asyncStub = AsyncStub::Success;
|
|
g_stringsFreed = 0;
|
|
std::lock_guard<std::mutex> lock(g_seenMutex);
|
|
g_seen.clear();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
extern "C" {
|
|
|
|
lp_client* lp_client_create(const char*, const char*, const char*, const char*) {
|
|
if (g_failNext.load() > 0 && g_failNext.fetch_sub(1) > 0) return nullptr;
|
|
g_created.fetch_add(1, std::memory_order_relaxed);
|
|
if (g_slowCreate.load()) std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
// lp_client is opaque; any distinct heap address stands in for one.
|
|
return reinterpret_cast<lp_client*>(new std::uintptr_t(0xC0FFEEu));
|
|
}
|
|
|
|
void lp_client_destroy(lp_client* client) {
|
|
g_destroyed.fetch_add(1, std::memory_order_relaxed);
|
|
delete reinterpret_cast<std::uintptr_t*>(client);
|
|
}
|
|
|
|
// The cheapest public LpClient method that goes through ensure().
|
|
//
|
|
// It returns a HEAP string the caller must hand back to lp_string_free, which
|
|
// is the real ABI contract — and stubbing it that way is load-bearing rather
|
|
// than cosmetic. This first returned NULL, which left getMethods()'s
|
|
// lp_string_free call unreachable: clang may inline this same-TU definition,
|
|
// prove the pointer null and delete the call, so a missing lp_string_free stub
|
|
// linked fine on macOS/clang and failed on GCC with an undefined reference.
|
|
// Returning a real allocation keeps that path live on every compiler.
|
|
char* lp_get_methods(lp_client* client) {
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_seenMutex);
|
|
g_seen.push_back(client);
|
|
}
|
|
char* out = static_cast<char*>(std::malloc(3));
|
|
std::memcpy(out, "[]", 3);
|
|
return out;
|
|
}
|
|
|
|
void lp_string_free(char* s) {
|
|
g_stringsFreed.fetch_add(1, std::memory_order_relaxed);
|
|
std::free(s);
|
|
}
|
|
|
|
int lp_invoke_async(lp_client*, const char*, const char*, int, lp_result_cb cb, void* ud) {
|
|
switch (g_asyncStub) {
|
|
case AsyncStub::Success:
|
|
cb(1, "\"hi\"", ud);
|
|
return LP_OK;
|
|
case AsyncStub::FailWithError:
|
|
cb(0, "{\"code\":\"object_unavailable\",\"message\":\"not loaded\",\"origin\":\"target\"}", ud);
|
|
return LP_OK;
|
|
case AsyncStub::FailMalformed:
|
|
cb(0, "not json at all", ud);
|
|
return LP_OK;
|
|
case AsyncStub::RefuseSync:
|
|
// The ABI's rule: a synchronous argument/handle rejection does NOT
|
|
// call back.
|
|
return LP_ERR_INVALID_ARG;
|
|
}
|
|
return LP_OK;
|
|
}
|
|
|
|
} // extern "C"
|
|
|
|
class LpClientEnsureTest : public ::testing::Test {
|
|
protected:
|
|
void SetUp() override { resetStubs(); }
|
|
|
|
static lp_client* soleSeenClient() {
|
|
std::lock_guard<std::mutex> lock(g_seenMutex);
|
|
return g_seen.empty() ? nullptr : g_seen.front();
|
|
}
|
|
};
|
|
|
|
TEST_F(LpClientEnsureTest, RepeatedCallsOnOneThreadBuildExactlyOneClient) {
|
|
{
|
|
logos::LpClient client("target", "origin");
|
|
for (int i = 0; i < 5; ++i) client.getMethods();
|
|
EXPECT_EQ(g_created.load(), 1);
|
|
EXPECT_EQ(g_destroyed.load(), 0);
|
|
// Every string the ABI handed out went back through lp_string_free —
|
|
// the ownership rule getMethods() has to honour, and the reason this
|
|
// stub returns a real allocation rather than NULL.
|
|
EXPECT_EQ(g_stringsFreed.load(), 5);
|
|
}
|
|
EXPECT_EQ(g_destroyed.load(), 1) << "the published client outlives every call, not the last one";
|
|
}
|
|
|
|
// The regression itself. Every thread must come away with the SAME client, and
|
|
// every client that was built but not published must have been destroyed —
|
|
// leaking one was the pre-fix behaviour whenever two threads first-called at
|
|
// once.
|
|
TEST_F(LpClientEnsureTest, ConcurrentFirstCallsPublishOneClientAndLeakNone) {
|
|
constexpr int kThreads = 8;
|
|
g_slowCreate = true;
|
|
|
|
{
|
|
logos::LpClient client("target", "origin");
|
|
|
|
std::atomic<int> ready{0};
|
|
std::atomic<bool> go{false};
|
|
std::vector<std::thread> threads;
|
|
threads.reserve(kThreads);
|
|
for (int i = 0; i < kThreads; ++i) {
|
|
threads.emplace_back([&]() {
|
|
ready.fetch_add(1);
|
|
while (!go.load(std::memory_order_acquire)) { /* spin: no sleep, keep the start tight */ }
|
|
client.getMethods();
|
|
});
|
|
}
|
|
while (ready.load() < kThreads) { /* spin */ }
|
|
go.store(true, std::memory_order_release);
|
|
for (std::thread& t : threads) t.join();
|
|
|
|
std::lock_guard<std::mutex> lock(g_seenMutex);
|
|
ASSERT_EQ(static_cast<int>(g_seen.size()), kThreads);
|
|
lp_client* published = g_seen.front();
|
|
ASSERT_NE(published, nullptr);
|
|
for (lp_client* seen : g_seen)
|
|
EXPECT_EQ(seen, published) << "threads disagreed about which client is the module's";
|
|
|
|
// Losers destroy their own before returning the published one, so by
|
|
// the time every thread has joined the books already balance.
|
|
EXPECT_EQ(g_destroyed.load(), g_created.load() - 1)
|
|
<< "created=" << g_created.load() << " destroyed=" << g_destroyed.load()
|
|
<< " — a client was built and neither published nor destroyed";
|
|
}
|
|
EXPECT_EQ(g_destroyed.load(), g_created.load()) << "the published client survived its owner";
|
|
}
|
|
|
|
// A create that fails is not cached: the real lp_client_create returns NULL for
|
|
// a bad target/origin, and latching that would turn one bad early call into a
|
|
// permanently dead dependency.
|
|
TEST_F(LpClientEnsureTest, AFailedCreateIsRetriedRatherThanLatched) {
|
|
logos::LpClient client("target", "origin");
|
|
|
|
g_failNext = 2;
|
|
client.getMethods();
|
|
client.getMethods();
|
|
EXPECT_EQ(g_created.load(), 0);
|
|
{
|
|
std::lock_guard<std::mutex> lock(g_seenMutex);
|
|
EXPECT_TRUE(g_seen.empty()) << "a NULL client must not reach lp_get_methods";
|
|
}
|
|
|
|
client.getMethods();
|
|
EXPECT_EQ(g_created.load(), 1);
|
|
EXPECT_NE(soleSeenClient(), nullptr);
|
|
}
|
|
|
|
// ─── invokeAsyncResult: the error-carrying async ────────────────────────────
|
|
//
|
|
// The generated `<name>AsyncResult` wrappers are built on this, so what it
|
|
// reports IS what the error channel reports. invokeAsync, next to it, has
|
|
// nowhere to put an error and collapses every failure into a bare JSON null —
|
|
// which is also a successful call that returned nothing. These pin the
|
|
// difference.
|
|
|
|
class LpClientAsyncResultTest : public LpClientEnsureTest {};
|
|
|
|
TEST_F(LpClientAsyncResultTest, SuccessDeliversTheValueAndAnOkError) {
|
|
logos::LpClient client("target", "origin");
|
|
g_asyncStub = AsyncStub::Success;
|
|
|
|
int calls = 0;
|
|
nlohmann::json got;
|
|
logos::CallError err;
|
|
err.code = "sentinel"; // must be overwritten, not merely left alone
|
|
client.invokeAsyncResult("m", nlohmann::json::array(), [&](nlohmann::json r, const logos::CallError& e) {
|
|
++calls; got = std::move(r); err = e;
|
|
});
|
|
|
|
EXPECT_EQ(calls, 1);
|
|
EXPECT_TRUE(err.ok()) << err.code;
|
|
EXPECT_EQ(got, nlohmann::json("hi"));
|
|
}
|
|
|
|
TEST_F(LpClientAsyncResultTest, FailureDeliversTheCanonicalErrorAndNoValue) {
|
|
logos::LpClient client("target", "origin");
|
|
g_asyncStub = AsyncStub::FailWithError;
|
|
|
|
int calls = 0;
|
|
nlohmann::json got = nlohmann::json("stale");
|
|
logos::CallError err;
|
|
client.invokeAsyncResult("m", nlohmann::json::array(), [&](nlohmann::json r, const logos::CallError& e) {
|
|
++calls; got = std::move(r); err = e;
|
|
});
|
|
|
|
EXPECT_EQ(calls, 1);
|
|
EXPECT_FALSE(err.ok());
|
|
EXPECT_EQ(err.code, "object_unavailable");
|
|
EXPECT_EQ(err.message, "not loaded");
|
|
EXPECT_EQ(err.origin, "target");
|
|
// The error object is NOT handed back as if it were a value — that is the
|
|
// distinction the plain invokeAsync path cannot make.
|
|
EXPECT_TRUE(got.is_null());
|
|
}
|
|
|
|
TEST_F(LpClientAsyncResultTest, AMalformedErrorObjectStillReportsNotOk) {
|
|
// Reporting ok() for a call the ABI said failed is the one outcome this
|
|
// entry point exists to prevent, so an undecodable error body must not
|
|
// degrade into success.
|
|
logos::LpClient client("target", "origin");
|
|
g_asyncStub = AsyncStub::FailMalformed;
|
|
|
|
int calls = 0;
|
|
logos::CallError err;
|
|
client.invokeAsyncResult("m", nlohmann::json::array(), [&](nlohmann::json, const logos::CallError& e) {
|
|
++calls; err = e;
|
|
});
|
|
|
|
EXPECT_EQ(calls, 1);
|
|
EXPECT_FALSE(err.ok());
|
|
EXPECT_EQ(err.code, "call_failed");
|
|
}
|
|
|
|
TEST_F(LpClientAsyncResultTest, ASynchronousRefusalStillCompletesExactlyOnce) {
|
|
// lp_invoke_async does NOT call back on LP_ERR_INVALID_ARG, so the
|
|
// completion is the wrapper's to make. A caller schedules on "fires exactly
|
|
// once"; silently never firing strands it.
|
|
logos::LpClient client("target", "origin");
|
|
g_asyncStub = AsyncStub::RefuseSync;
|
|
|
|
int calls = 0;
|
|
logos::CallError err;
|
|
client.invokeAsyncResult("m", nlohmann::json::array(), [&](nlohmann::json, const logos::CallError& e) {
|
|
++calls; err = e;
|
|
});
|
|
|
|
EXPECT_EQ(calls, 1);
|
|
EXPECT_FALSE(err.ok());
|
|
EXPECT_EQ(err.code, "call_failed");
|
|
EXPECT_EQ(err.origin, "target");
|
|
}
|
|
|
|
TEST_F(LpClientAsyncResultTest, AnUncreatableClientCompletesRatherThanHanging) {
|
|
logos::LpClient client("target", "origin");
|
|
g_failNext = 1000; // every create fails
|
|
|
|
int calls = 0;
|
|
logos::CallError err;
|
|
client.invokeAsyncResult("m", nlohmann::json::array(), [&](nlohmann::json, const logos::CallError& e) {
|
|
++calls; err = e;
|
|
});
|
|
|
|
EXPECT_EQ(calls, 1);
|
|
EXPECT_EQ(err.code, "object_unavailable");
|
|
EXPECT_EQ(err.origin, "target");
|
|
}
|
|
|
|
TEST_F(LpClientAsyncResultTest, ANullCallbackIsANoOpRatherThanACall) {
|
|
logos::LpClient client("target", "origin");
|
|
client.invokeAsyncResult("m", nlohmann::json::array(), nullptr);
|
|
EXPECT_EQ(g_created.load(), 0) << "a callback-less call must not even build a client";
|
|
}
|