Files
logos-cpp-sdk/cpp/logos_lp_client.h
Dario LipicarandClaude Opus 5 692af756cc fix: bound the argument count above; and an lp reply it cannot read (#150)
* fix(cdylib): bound the argument count above, not just below

An EXTRA argument was dropped and the call succeeded. The generated dispatch
guarded `args.size() < minArgs` and nothing bounded the other direction, on
every method at every arity -- measured on both providers of two different
contracts, through three consumer surfaces, as conformance case family
`failure/B/arity/too-many`.

Arity is the one part of a contract a caller cannot verify for itself. A
method that gains or loses a parameter upstream answered a stale caller with a
plausible value instead of a refusal, and the caller had no way to tell which
contract it had just talked to.

Two arms, not one, which is why the conformance table registered them as two
defects:

  * The ordinary arm gets `args.size() > maxArgs`, where maxArgs is the
    DECLARED parameter count rather than the required one -- bounding at
    minArgs would reject a caller who legitimately supplies a trailing
    optional. When the two coincide the message keeps the exact-count wording;
    when they differ it says `at most`, because claiming a count the method
    does not require would be wrong in the other direction.
  * A ZERO-parameter method had no gate at all -- not the same guard with
    minArgs = 0, a different code path, since `args.size() < 0` is unsigned and
    was skipped as dead. The upper bound is emitted unconditionally, so it lands
    here too.

The guard sits ABOVE the `md.derived` branch, so the generated identity
dispatch inherits it: `version("junk")` used to answer "1.0.0" with status ok,
which is worse than answering nothing -- a correct-looking reply to a call that
should have been refused.

Two existing tests asserted the old behaviour and are updated, not deleted:
`WrongArgumentCountReportsInvalidArgs` forbade any `args.size() >` in the
output, and `ZeroArgumentMethodEmitsNoArityGate` asserted a zero-arg method
emitted no `invalid_args` at all. That second assertion WAS the defect, written
down as a guarantee. Both now pin the bound, and two cases are added for the
arms they did not reach: a trailing optional widening the upper bound, and the
derived identity dispatch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(lp): a reply it cannot read must not become a provider refusal

The lp half of the same defect logos-qt-sdk fixes on the Qt consumer surface.
`jsonToStdResult` returned a default-constructed StdLogosResult for any
non-object reply, and that default -- `success = false`, empty error -- is
byte-for-byte what a provider sends when it REFUSES a call.

Every other lenient decode in this file bottoms out at a value no provider
means as an answer: "" for a string, 0 for a number, {} for a map. This one
did not. So "I could not read this reply" and "you were rejected" were the same
StdLogosResult, and no caller could separate them.

Fixed here as well as on the Qt side deliberately, and in the same change:
logos-qt-sdk's bare_scalar_slots_stay_lenient warns that tightening a scalar
decode on one surface without the other makes them diverge, and it is right.
The bare scalars stay lenient on both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:51:09 -03:00

386 lines
18 KiB
C++

#pragma once
// Qt-FREE typed consumer client over the logos-protocol C ABI (lp_*).
//
// This is the std/C++ analog of rust-sdk's PluginProxy: it lets a module's
// generated typed wrappers call other modules and subscribe to their events
// WITHOUT touching Qt. The only dependency is logos-protocol's `extern "C"`
// surface (logos_protocol.h) — Qt stays confined to the QRO transport inside
// logos-protocol and to the generated Qt-plugin glue, never the module's own
// translation units.
//
// The generated `<Dep>` wrappers (ApiStyle::Lp) hold a `logos::LpClient` and
// marshal std args -> nlohmann JSON -> lp_invoke -> JSON -> std return. Event
// subscriptions go through lp_subscribe and are owned by an RAII
// `LpSubscription` (mirrors rust-sdk's EventSubscription: unsubscribes on
// destruction so the callback never fires after the owner is gone).
#include <atomic>
#include <cstdint>
#include <functional>
#include <string>
#include <utility>
#include <nlohmann/json.hpp>
#include <vector>
#include "logos_protocol.h" // lp_* C ABI
#include "logos_call_error.h" // logos::CallError
#include "logos_json.h" // LogosMap / LogosList aliases
#include "logos_codec.h" // logos::bytesToJson, b64UrlDecode, isTaggedBytes
#include "logos_result.h" // StdLogosResult
namespace logos {
// JSON -> std helpers used by the generated ApiStyle::Lp wrappers to decode
// return values and event payloads. Lenient: a type mismatch yields the
// default-constructed value (mirrors the Qt path's default-on-failure).
inline std::vector<std::string> jsonToStringVec(const nlohmann::json& j) {
std::vector<std::string> out;
if (j.is_array())
for (const auto& e : j)
if (e.is_string()) out.push_back(e.get<std::string>());
return out;
}
// Binary payloads travel in the canonical tagged form
// {"_bytes": "<base64url, unpadded>"}. Encoding is logos::bytesToJson from
// logos_codec.h — the one canonical definition. Decoding on this path is NOT
// the codec's: bytesFromJson throws and bytesFromJsonLenient also accepts a
// plain string, a number and an int array, whereas every `lp` decoder here is
// documented to yield the default-constructed value on a mismatch. So the lp
// decode keeps its own deliberately-narrow spelling, next to jsonToStringVec
// which has exactly the same contract.
inline std::vector<uint8_t> jsonToBytes(const nlohmann::json& j) {
if (!isTaggedBytes(j)) return {};
return b64UrlDecode(j["_bytes"].get<std::string>());
}
// A reply that is NOT a result object is a decode failure, and must not be
// spelled as a default-constructed StdLogosResult.
//
// Every other lenient decode in this file bottoms out at a value no provider
// means as an answer -- "" for a string, 0 for a number, {} for a map. This one
// does not: `success = false` with an empty error is exactly what a provider
// sends when it REFUSES a call, so returning the default made "I could not read
// this reply" indistinguishable from "you were rejected". Measured on the Qt
// twin of this function (logos::jsonToLogosResult, same shape, same opening
// `if (!is_object)`), where a wrapper bound to a provider with a different
// return shape answered a refusal to every input including the well-formed
// ones.
//
// The leniency of the BARE SCALARS above is untouched and deliberate --
// logos-qt-sdk's tests/qt-generator bare_scalar_slots_stay_lenient pins it on
// the other surface, and tightening that is a decision about every scalar
// return of every module. This is the one type whose default is a message.
inline StdLogosResult jsonToStdResult(const nlohmann::json& j) {
StdLogosResult r;
if (!j.is_object()) {
r.success = false;
r.error = std::string("expected a result object, got ") + j.type_name();
return r;
}
if (j.contains("success") && j["success"].is_boolean()) r.success = j["success"].get<bool>();
if (j.contains("value")) r.value = j["value"];
if (j.contains("error") && j["error"].is_string()) r.error = j["error"].get<std::string>();
return r;
}
// RAII handle for an lp_subscription. Owns the subscription and the heap
// callback box; unsubscribes (after which no further callbacks fire) and
// frees the box on destruction. Move-only.
class LpSubscription {
public:
LpSubscription() = default;
LpSubscription(lp_subscription* sub, void* cbBox, void (*deleter)(void*))
: m_sub(sub), m_cbBox(cbBox), m_deleter(deleter) {}
LpSubscription(LpSubscription&& o) noexcept { moveFrom(o); }
LpSubscription& operator=(LpSubscription&& o) noexcept {
if (this != &o) { reset(); moveFrom(o); }
return *this;
}
LpSubscription(const LpSubscription&) = delete;
LpSubscription& operator=(const LpSubscription&) = delete;
~LpSubscription() { reset(); }
bool valid() const { return m_sub != nullptr; }
private:
void moveFrom(LpSubscription& o) {
m_sub = o.m_sub; m_cbBox = o.m_cbBox; m_deleter = o.m_deleter;
o.m_sub = nullptr; o.m_cbBox = nullptr; o.m_deleter = nullptr;
}
void reset() {
if (m_sub) { lp_unsubscribe(m_sub); m_sub = nullptr; }
if (m_cbBox && m_deleter) { m_deleter(m_cbBox); m_cbBox = nullptr; }
}
lp_subscription* m_sub = nullptr;
void* m_cbBox = nullptr;
void (*m_deleter)(void*) = nullptr;
};
// Qt-free typed client for one target module. The lp_client is created lazily
// on first use, on behalf of `origin` (the calling module's name, baked by the
// generated umbrella), over the process-default transport with the automatic
// capability/token flow that logos-protocol provides.
class LpClient {
public:
LpClient(std::string target, std::string origin)
: m_target(std::move(target)), m_origin(std::move(origin)) {}
~LpClient() {
if (lp_client* c = m_client.load(std::memory_order_acquire))
lp_client_destroy(c);
}
LpClient(const LpClient&) = delete;
LpClient& operator=(const LpClient&) = delete;
// Blocking call. `args` is a JSON array. Returns the result JSON value
// (null on failure); fills `err` when non-null.
//
// `timeout_ms <= 0` selects the protocol default (the C ABI's rule), which
// is what every caller got before the parameter existed — so adding it
// changes nothing for them. It exists because the Qt-typed consumer surface
// takes a `Timeout` on every async overload and had nowhere to put it: a
// wrapper delegating here silently dropped the caller's timeout.
nlohmann::json invoke(const std::string& method,
const nlohmann::json& args,
CallError* err,
int timeout_ms = 0) {
lp_client* c = ensure();
if (!c) {
if (err) { err->code = "object_unavailable";
err->message = "could not create client for " + m_target;
err->origin = m_target; }
return nullptr;
}
const std::string argsStr = args.dump();
char* outRes = nullptr;
char* outErr = nullptr;
const int rc = lp_invoke(c, method.c_str(), argsStr.c_str(), timeout_ms, &outRes, &outErr);
nlohmann::json result; // null
if (rc == LP_OK) {
if (err) err->clear();
if (outRes) {
auto parsed = nlohmann::json::parse(outRes, nullptr, /*allow_exceptions=*/false);
if (!parsed.is_discarded()) result = std::move(parsed);
}
} else {
fillErr(err, outErr, rc);
}
if (outRes) lp_string_free(outRes);
if (outErr) lp_string_free(outErr);
return result;
}
// Async call. `cb` fires exactly once with the result JSON (null on
// failure / parse error). Safe to call from any thread.
void invokeAsync(const std::string& method,
const nlohmann::json& args,
std::function<void(nlohmann::json)> cb,
int timeout_ms = 0) {
lp_client* c = ensure();
if (!c) { if (cb) cb(nullptr); return; }
auto* box = new std::function<void(nlohmann::json)>(std::move(cb));
const std::string argsStr = args.dump();
lp_invoke_async(c, method.c_str(), argsStr.c_str(), timeout_ms,
&LpClient::resultTrampoline, box);
}
// Async call carrying the error — the async twin of invoke()'s `err`
// out-parameter, and what the generated `<name>AsyncResult` wrappers are
// built on. `cb` fires exactly once; on failure the JSON is null and the
// CallError is populated from the C ABI's canonical {code, message, origin}
// object.
//
// Why this exists next to invokeAsync rather than replacing it: invokeAsync
// collapses the C ABI's failure form (`ok == 0` with `json` set to the error
// object) into a bare JSON null, which is also what a successful call
// returning nothing delivers. That is fine for a callback that only takes a
// value and has nowhere to put an error, and useless for one that does.
//
// A DISTINCT NAME, not an overload of invokeAsync: two std::function
// parameters differing only in arity are ambiguous for a generic lambda —
// the same hazard that made the generator spell `<name>AsyncResult` as its
// own name instead of an overload of `<name>Async`.
//
// Safe to call from any thread. logos-qt-sdk's LpBridge::invokeAsyncResult
// is this function with a private second lp_client bolted on because this
// one did not exist; it can now delegate here and drop that connection.
void invokeAsyncResult(const std::string& method,
const nlohmann::json& args,
std::function<void(nlohmann::json, const CallError&)> cb,
int timeout_ms = 0) {
if (!cb) return;
lp_client* c = ensure();
if (!c) {
cb(nlohmann::json(),
callErrorObjectUnavailable(m_target, "could not create client for " + m_target));
return;
}
auto* box = new ResultErrBox(std::move(cb));
const std::string argsStr = args.dump();
const int rc = lp_invoke_async(c, method.c_str(), argsStr.c_str(), timeout_ms,
&LpClient::resultErrorTrampoline, box);
if (rc != LP_OK) {
// A synchronous refusal does NOT call back (the C ABI's rule), so
// the completion is this function's to make — `cb` still has to fire
// exactly once, which is the whole contract a caller schedules on.
ResultErrBox fn = std::move(*box);
delete box;
fn(nlohmann::json(),
callErrorCallFailed(m_target, "lp_invoke_async refused the call (rc="
+ std::to_string(rc) + ")"));
}
}
// The target's method list, as the JSON the host reports. Empty on
// failure. Invoke-without-introspect is what makes a by-name call an
// escape hatch rather than an API: a caller that cannot ask what exists
// can only guess, and a wrong guess fails at runtime like a typo.
nlohmann::json getMethods() {
lp_client* c = ensure();
if (!c) return nlohmann::json();
char* out = lp_get_methods(c);
if (!out) return nlohmann::json();
auto parsed = nlohmann::json::parse(out, nullptr, /*allow_exceptions=*/false);
lp_string_free(out);
return parsed.is_discarded() ? nlohmann::json() : parsed;
}
// Subscribe to `event`. The payload is delivered as a JSON array. The
// returned handle owns the subscription — keep it alive (the generated
// wrapper stores it) for as long as you want the callback to fire.
LpSubscription subscribe(const std::string& event,
std::function<void(nlohmann::json)> cb) {
lp_client* c = ensure();
if (!c) return {};
auto* box = new std::function<void(nlohmann::json)>(std::move(cb));
lp_subscription* sub = lp_subscribe(c, event.c_str(), &LpClient::eventTrampoline, box);
if (!sub) { delete box; return {}; }
return LpSubscription(sub, box, &LpClient::deleteBox);
}
private:
using Box = std::function<void(nlohmann::json)>;
using ResultErrBox = std::function<void(nlohmann::json, const CallError&)>;
// Create-once, and never while holding a lock.
//
// Two threads reach a dep's FIRST call concurrently more often than the
// lazy-init shape suggests: a concurrency:"multi" module runs its handlers
// on concurrent QThreads, and any module with a worker of its own (an HTTP
// handler, a chain-sync pump) races that worker against the dispatch
// thread. The plain `if (!m_client) m_client = lp_client_create(...)` this
// replaces was a data race on m_client, and leaked whichever client lost.
//
// A mutex around the whole 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 (logos_protocol.cpp's runOnQtMainThread). A
// worker holding the lock across that waits for the main thread — while the
// main thread, reaching this same ensure() from an inbound call, waits for
// the lock and so never returns to the event loop that would run the
// construction. That trades a data race for a deadlock.
//
// So construct OUTSIDE any lock and publish with a CAS. Both racers may
// build a client; exactly one is ever published, and the loser destroys its
// own. That is safe and cheap: lp_client_destroy may be called from any
// thread and defers the teardown to the owner thread (logos_protocol.h),
// and construction has no effect at the target — the capability handshake
// is lazy, inside invokeRemoteMethod — so a discarded client mints no token
// and leaves no trace.
//
// A failed create is deliberately NOT latched: the next call retries, which
// is what the pre-CAS version did.
lp_client* ensure() {
if (lp_client* c = m_client.load(std::memory_order_acquire))
return c;
lp_client* fresh = lp_client_create(m_target.c_str(), m_origin.c_str(), nullptr, nullptr);
// Creation failed — report whatever is published (usually null, but a
// racer may have succeeded meanwhile) rather than caching the failure.
if (!fresh) return m_client.load(std::memory_order_acquire);
lp_client* expected = nullptr;
if (m_client.compare_exchange_strong(expected, fresh,
std::memory_order_acq_rel,
std::memory_order_acquire))
return fresh;
lp_client_destroy(fresh); // lost the publish race
return expected;
}
static void resultTrampoline(int ok, const char* json, void* ud) {
auto* fn = static_cast<Box*>(ud);
nlohmann::json r; // null
if (ok && json) {
auto parsed = nlohmann::json::parse(json, nullptr, false);
if (!parsed.is_discarded()) r = std::move(parsed);
}
(*fn)(std::move(r));
delete fn; // result callback fires exactly once
}
// The error-aware twin of resultTrampoline. `ok == 0` means `json` is the
// canonical error object rather than a value, so the value is dropped and
// the error decoded; a malformed/absent one still yields a NON-ok
// CallError, because reporting ok() for a call the ABI said failed is the
// one outcome this trampoline exists to prevent.
static void resultErrorTrampoline(int ok, const char* json, void* ud) {
auto* fn = static_cast<ResultErrBox*>(ud);
nlohmann::json parsed; // null
if (json) {
auto p = nlohmann::json::parse(json, nullptr, /*allow_exceptions=*/false);
if (!p.is_discarded()) parsed = std::move(p);
}
CallError err;
if (!ok) {
err = callErrorCallFailed("", "lp_invoke_async failed");
if (parsed.is_object()) {
if (parsed.contains("code") && parsed["code"].is_string())
err.code = parsed["code"].get<std::string>();
if (parsed.contains("message") && parsed["message"].is_string())
err.message = parsed["message"].get<std::string>();
if (parsed.contains("origin") && parsed["origin"].is_string())
err.origin = parsed["origin"].get<std::string>();
}
parsed = nlohmann::json();
}
(*fn)(std::move(parsed), err);
delete fn; // result callback fires exactly once
}
static void eventTrampoline(const char* /*eventName*/, const char* dataJson, void* ud) {
auto* fn = static_cast<Box*>(ud);
nlohmann::json r = nlohmann::json::array();
if (dataJson) {
auto parsed = nlohmann::json::parse(dataJson, nullptr, false);
if (!parsed.is_discarded()) r = std::move(parsed);
}
(*fn)(std::move(r));
}
static void deleteBox(void* p) { delete static_cast<Box*>(p); }
static void fillErr(CallError* err, const char* errJson, int rc) {
if (!err) return;
err->code = "call_failed";
err->message = "lp_invoke failed (rc=" + std::to_string(rc) + ")";
err->origin.clear();
if (errJson) {
auto j = nlohmann::json::parse(errJson, nullptr, false);
if (!j.is_discarded() && j.is_object()) {
if (j.contains("code") && j["code"].is_string()) err->code = j["code"].get<std::string>();
if (j.contains("message") && j["message"].is_string()) err->message = j["message"].get<std::string>();
if (j.contains("origin") && j["origin"].is_string()) err->origin = j["origin"].get<std::string>();
}
}
}
std::string m_target;
std::string m_origin;
// Published exactly once by ensure(); read from any thread.
std::atomic<lp_client*> m_client{nullptr};
};
} // namespace logos