mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-31 05:51:08 +00:00
* fix(startup): publish a token-only handshake surface before a module initializes A module's initializer is synchronous and routinely calls out — a Qt module's initLogos, a cdylib's context-ready hook — including capability_module's requestModule, which capability answers by pushing a token back to that same module. The module's business object is published only once the initializer returns, so that push had nothing to reach: capability waited for a source that could not appear until the initializer returned, and the initializer could not return until capability answered. On Linux this wedged UI startup until the standalone app's 10s ui-host deadline expired and the view never rendered. Adds a second, deliberately tiny surface — ModuleHandshakeProxy, published under logos::handshakeObjectName(name) — carrying informModuleToken and nothing else. It forwards to the ModuleProxy that owns the token store, so a grant delivered early is the one the business object honours later, with the same authorization. The business object's publish timing is UNCHANGED, which is the point: a caller of a real method still blocks at acquire until the module is genuinely ready, exactly as it always has. An earlier attempt published the business object early and refused calls during init; that quietly turned a call that used to wait and succeed into one that returned empty, which old consumers cannot even detect. informModuleToken_module now tries the handshake surface first (short probe) and falls back to the business object, so modules built before this surface existed are reached exactly as they are today. It also reuses the cached handle instead of acquiring a fresh replica per grant, and takes a timeout (default unchanged). No wire change, no ABI change, no reply-shape change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(startup): do not treat a handshake refusal as the final answer The handshake surface is published before the target's initializer runs, so a target whose token store is seeded BY that initializer refuses a push that arrives first. Returning that refusal to the caller handed it an empty grant it could not distinguish from a real denial: measured on Linux, the first requestModule for wallet_backend_module came back empty in 29 of 34 runs, and never once in the pre-surface baseline. Fall through to the business object instead, which is what the caller got before this surface existed. The business object is published only once the initializer has returned, by which point the store is populated. The wait is bounded by the caller's own budget -- capability_module passes 3000 ms, not the 20 s default that made the original deadlock fatal -- so this cannot reintroduce the wedge. The companion change in logos-qt-sdk seeds the trust anchor before publishing, which removes the refusal at its source; this is the safety net for hosts and modules that do not. Also adds the regression test that would have caught this: the existing case seeds "core" before pushing, which is exactly the state that does NOT hold in the window the surface covers, so it asserted the surface works under a precondition production never met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(startup): marshal the token push, and stop re-probing a missing handshake Two review findings from Copilot, both verified against the code before acting. 1. Thread affinity. informModuleToken_module was one of only two entry points in LogosAPIClient that did not wrap in logos::runOnOwnerThread -- requestObject, both invokeRemoteMethod forms and onEvent all do. The missing marshal is inherited, but THIS change is what made it reachable: the method used to take an uncached requestObject() + release() and touch no shared state, and routing it through acquireCachedObject put it on m_objectCache, which is declared single-threaded and holds thread-affine QtRO handles. Now marshalled, matching its four siblings. The 3-arg informModuleToken has the same gap but still uses an uncached handle and predates this work, so it is deliberately left alone rather than widened into this fix; noted at the call site. 2. No negative cache on the handshake probe. acquireCachedObject caches successes only, so a module built before the handshake surface existed failed the probe on EVERY grant -- and on QtRO that failure is a blocking waitForSource, i.e. 250 ms of dead time per token, forever. Remember the absence and go straight to the business object; cleared by clearObjectCache() so a reconnect, or a module reloaded from a build that has the surface, is re-probed rather than written off permanently. (The review attributed this cost to the Local/Plain adapters rejecting a non-ModuleProxy object. Checked per transport: plain is unaffected -- its token push is nameless fire-and-forget and it never had the acquire deadlock -- and on qt_local requestObject ignores timeoutMs entirely, so the cost there is a spurious warning, not 250 ms. The real cost is the missing negative cache, on QtRO.) The same review's ABI-break and name-collision findings were measured and do not apply: logos_protocol is a static archive with zero undefined imports of these symbols anywhere in the built stack, and object names are scoped to a per-module socket rather than a global registry. Both answered in-thread. 290/290 protocol tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(startup): exercise the handshake surface over a real transport The existing handshake cases call ModuleHandshakeProxy directly, with no transport underneath. That is what let a whole class of defect through: the surface is only useful if a transport will PUBLISH a token-only QObject and a consumer can ACQUIRE it by the derived name, and a direct-call test can see neither half. The adapter survey prompted by review found qt_local silently rejects a non-ModuleProxy on acquire while still reporting a successful publish -- invisible to every test in the suite. These run on the transport the production stack actually uses (QtRO, the LogosTransportConfig default), and model the startup window honestly: the handshake object is published and the business object deliberately is NOT, because it does not exist until the initializer returns. That window is the entire reason the surface exists and is the one state the direct-call tests could never represent. TokenReachesAModuleWhoseBusinessObjectIsNotPublishedYet the pre-init window end to end: publish -> probe by derived name -> acquire -> push lands on the provider. AnUnseededAnchorRefusesEvenThoughTheSurfaceIsReachable the transport-level twin of the gate test: proves the refusal measured in production (29 of 34 app runs) is the gate rejecting the push, not the transport failing to deliver it -- the provider is never reached. ALegacyModuleFallsBackAndIsNotReProbed a module with no handshake surface still gets its token, and the missing surface is probed ONCE. Timed rather than functional, so it was falsified before being trusted: with the negative cache removed the suite fails on exactly this case and no other. 293/293 protocol tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
333 lines
18 KiB
C++
333 lines
18 KiB
C++
#ifndef LOGOS_API_CLIENT_H
|
|
#define LOGOS_API_CLIENT_H
|
|
|
|
#include <QObject>
|
|
#include <QString>
|
|
#include <QVariant>
|
|
#include <QVariantList>
|
|
#include <QMap>
|
|
#include <functional>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
#include "logos_call_error.h"
|
|
#include "logos_mode.h"
|
|
#include "logos_transport_config.h"
|
|
#include <nlohmann/json.hpp>
|
|
|
|
class LogosAPI;
|
|
class LogosAPIConsumer;
|
|
class LogosObject;
|
|
class TokenManager;
|
|
|
|
/**
|
|
* @brief LogosAPIClient provides a high-level interface for remote method calls
|
|
*
|
|
* This class serves as a facade over LogosAPIConsumer, providing a clean interface
|
|
* for applications that need to call remote methods and handle events.
|
|
*/
|
|
class LogosAPIClient : public QObject
|
|
{
|
|
Q_OBJECT
|
|
|
|
public:
|
|
/**
|
|
* @brief Construct a client with explicit transports for both the
|
|
* target module *and* `capability_module`.
|
|
*
|
|
* Two transports because the SDK's auto-`requestModule` flow inside
|
|
* invokeRemoteMethod{,Async} dials `capability_module` to fetch a
|
|
* per-target token. When the daemon advertises capability_module on
|
|
* a different transport from the target (e.g. CLI on host →
|
|
* core_service over TCP, but capability_module also over TCP on a
|
|
* sibling port), the auto-dial must use the right one. Pre-building
|
|
* the consumer once in the constructor (see m_capability_consumer)
|
|
* keeps the hot path free of per-call lookups.
|
|
*/
|
|
LogosAPIClient(const QString& module_to_talk_to,
|
|
const QString& origin_module,
|
|
TokenManager* token_manager,
|
|
const LogosTransportConfig& target_transport,
|
|
const LogosTransportConfig& capability_transport,
|
|
QObject *parent = nullptr);
|
|
|
|
/**
|
|
* @brief No-transport constructor — both target and
|
|
* capability_module use the process-global default
|
|
* (LocalSocket) via LogosTransportConfigGlobal::getDefault().
|
|
*/
|
|
explicit LogosAPIClient(const QString& module_to_talk_to,
|
|
const QString& origin_module,
|
|
TokenManager* token_manager,
|
|
QObject *parent = nullptr);
|
|
~LogosAPIClient();
|
|
|
|
/**
|
|
* @brief Request a LogosObject handle by name
|
|
* @return LogosObject* handle, or nullptr if failed
|
|
*/
|
|
LogosObject* requestObject(const QString& objectName, Timeout timeout = Timeout());
|
|
|
|
bool isConnected() const;
|
|
QString registryUrl() const;
|
|
bool reconnect();
|
|
|
|
QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName,
|
|
const QVariantList& args = QVariantList(), Timeout timeout = Timeout());
|
|
|
|
/**
|
|
* @brief invokeRemoteMethod with an explicit error out-channel.
|
|
*
|
|
* Fills *err with the canonical {code, message, origin} call error when
|
|
* the failure is detectable (today: "object_unavailable" when the target
|
|
* object cannot be acquired); cleared on success. Generated typed client
|
|
* wrappers call this overload and throw logos::LogosCallError so callers
|
|
* can distinguish a failed call from a legitimately default-valued
|
|
* result.
|
|
*/
|
|
QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName,
|
|
const QVariantList& args, Timeout timeout, logos::CallError* err);
|
|
|
|
QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName,
|
|
const QVariant& arg, Timeout timeout = Timeout());
|
|
|
|
QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName,
|
|
const QVariant& arg1, const QVariant& arg2, Timeout timeout = Timeout());
|
|
|
|
QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName,
|
|
const QVariant& arg1, const QVariant& arg2, const QVariant& arg3, Timeout timeout = Timeout());
|
|
|
|
QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName,
|
|
const QVariant& arg1, const QVariant& arg2, const QVariant& arg3,
|
|
const QVariant& arg4, Timeout timeout = Timeout());
|
|
|
|
QVariant invokeRemoteMethod(const QString& objectName, const QString& methodName,
|
|
const QVariant& arg1, const QVariant& arg2, const QVariant& arg3,
|
|
const QVariant& arg4, const QVariant& arg5, Timeout timeout = Timeout());
|
|
|
|
// const char* overloads — resolve ambiguity for string literals
|
|
QVariant invokeRemoteMethod(const char* objectName, const char* methodName,
|
|
const QVariantList& args = QVariantList(), Timeout timeout = Timeout())
|
|
{ return invokeRemoteMethod(QString(objectName), QString(methodName), args, timeout); }
|
|
|
|
QVariant invokeRemoteMethod(const char* objectName, const char* methodName,
|
|
const QVariant& arg, Timeout timeout = Timeout())
|
|
{ return invokeRemoteMethod(QString(objectName), QString(methodName), arg, timeout); }
|
|
|
|
QVariant invokeRemoteMethod(const char* objectName, const char* methodName,
|
|
const QVariant& arg1, const QVariant& arg2, Timeout timeout = Timeout())
|
|
{ return invokeRemoteMethod(QString(objectName), QString(methodName), arg1, arg2, timeout); }
|
|
|
|
QVariant invokeRemoteMethod(const char* objectName, const char* methodName,
|
|
const QVariant& arg1, const QVariant& arg2, const QVariant& arg3, Timeout timeout = Timeout())
|
|
{ return invokeRemoteMethod(QString(objectName), QString(methodName), arg1, arg2, arg3, timeout); }
|
|
|
|
QVariant invokeRemoteMethod(const char* objectName, const char* methodName,
|
|
const QVariant& arg1, const QVariant& arg2, const QVariant& arg3,
|
|
const QVariant& arg4, Timeout timeout = Timeout())
|
|
{ return invokeRemoteMethod(QString(objectName), QString(methodName), arg1, arg2, arg3, arg4, timeout); }
|
|
|
|
QVariant invokeRemoteMethod(const char* objectName, const char* methodName,
|
|
const QVariant& arg1, const QVariant& arg2, const QVariant& arg3,
|
|
const QVariant& arg4, const QVariant& arg5, Timeout timeout = Timeout())
|
|
{ return invokeRemoteMethod(QString(objectName), QString(methodName), arg1, arg2, arg3, arg4, arg5, timeout); }
|
|
|
|
// std::string overloads — thin wrappers that convert internally
|
|
QVariant invokeRemoteMethod(const std::string& objectName, const std::string& methodName,
|
|
const QVariantList& args = QVariantList(), Timeout timeout = Timeout())
|
|
{ return invokeRemoteMethod(QString::fromStdString(objectName), QString::fromStdString(methodName), args, timeout); }
|
|
|
|
QVariant invokeRemoteMethod(const std::string& objectName, const std::string& methodName,
|
|
const QVariant& arg, Timeout timeout = Timeout())
|
|
{ return invokeRemoteMethod(QString::fromStdString(objectName), QString::fromStdString(methodName), arg, timeout); }
|
|
|
|
QVariant invokeRemoteMethod(const std::string& objectName, const std::string& methodName,
|
|
const QVariant& arg1, const QVariant& arg2, Timeout timeout = Timeout())
|
|
{ return invokeRemoteMethod(QString::fromStdString(objectName), QString::fromStdString(methodName), arg1, arg2, timeout); }
|
|
|
|
QVariant invokeRemoteMethod(const std::string& objectName, const std::string& methodName,
|
|
const QVariant& arg1, const QVariant& arg2, const QVariant& arg3, Timeout timeout = Timeout())
|
|
{ return invokeRemoteMethod(QString::fromStdString(objectName), QString::fromStdString(methodName), arg1, arg2, arg3, timeout); }
|
|
|
|
QVariant invokeRemoteMethod(const std::string& objectName, const std::string& methodName,
|
|
const QVariant& arg1, const QVariant& arg2, const QVariant& arg3,
|
|
const QVariant& arg4, Timeout timeout = Timeout())
|
|
{ return invokeRemoteMethod(QString::fromStdString(objectName), QString::fromStdString(methodName), arg1, arg2, arg3, arg4, timeout); }
|
|
|
|
QVariant invokeRemoteMethod(const std::string& objectName, const std::string& methodName,
|
|
const QVariant& arg1, const QVariant& arg2, const QVariant& arg3,
|
|
const QVariant& arg4, const QVariant& arg5, Timeout timeout = Timeout())
|
|
{ return invokeRemoteMethod(QString::fromStdString(objectName), QString::fromStdString(methodName), arg1, arg2, arg3, arg4, arg5, timeout); }
|
|
|
|
// const char* onEvent overload
|
|
void onEvent(LogosObject* originObject, const char* eventName,
|
|
std::function<void(const QString&, const QVariantList&)> callback)
|
|
{ onEvent(originObject, QString(eventName), callback); }
|
|
|
|
// std::string onEvent overloads
|
|
void onEvent(LogosObject* originObject, const std::string& eventName,
|
|
std::function<void(const QString&, const QVariantList&)> callback)
|
|
{ onEvent(originObject, QString::fromStdString(eventName), callback); }
|
|
|
|
void onEvent(LogosObject* originObject, const std::string& eventName,
|
|
std::function<void(const std::string&, const QVariantList&)> callback)
|
|
{
|
|
onEvent(originObject, QString::fromStdString(eventName),
|
|
[cb = std::move(callback)](const QString& name, const QVariantList& args) {
|
|
cb(name.toStdString(), args);
|
|
});
|
|
}
|
|
|
|
void onEvent(LogosObject* originObject, const char* eventName,
|
|
std::function<void(const std::string&, const QVariantList&)> callback)
|
|
{
|
|
onEvent(originObject, QString(eventName),
|
|
[cb = std::move(callback)](const QString& name, const QVariantList& args) {
|
|
cb(name.toStdString(), args);
|
|
});
|
|
}
|
|
|
|
using AsyncResultCallback = std::function<void(QVariant)>;
|
|
|
|
/**
|
|
* @brief Async callback with an explicit error out-channel.
|
|
*
|
|
* Mirrors the sync `invokeRemoteMethod(..., CallError*)` overload. Set to
|
|
* code="object_unavailable" when the target object cannot be acquired,
|
|
* cleared on success. Callers that need to distinguish acquire failure
|
|
* from a legitimately empty QVariant result should use this overload.
|
|
*/
|
|
using AsyncResultErrorCallback = std::function<void(QVariant, const logos::CallError&)>;
|
|
|
|
void invokeRemoteMethodAsync(const QString& objectName, const QString& methodName,
|
|
const QVariantList& args, AsyncResultCallback callback,
|
|
Timeout timeout = Timeout());
|
|
|
|
/**
|
|
* @brief invokeRemoteMethodAsync with an explicit error out-channel.
|
|
* See AsyncResultErrorCallback docs above.
|
|
*/
|
|
void invokeRemoteMethodAsync(const QString& objectName, const QString& methodName,
|
|
const QVariantList& args, AsyncResultErrorCallback callback,
|
|
Timeout timeout = Timeout());
|
|
|
|
void invokeRemoteMethodAsync(const QString& objectName, const QString& methodName,
|
|
const QVariant& arg, AsyncResultCallback callback,
|
|
Timeout timeout = Timeout());
|
|
|
|
void invokeRemoteMethodAsync(const QString& objectName, const QString& methodName,
|
|
const QVariant& arg1, const QVariant& arg2,
|
|
AsyncResultCallback callback, Timeout timeout = Timeout());
|
|
|
|
void invokeRemoteMethodAsync(const QString& objectName, const QString& methodName,
|
|
const QVariant& arg1, const QVariant& arg2, const QVariant& arg3,
|
|
AsyncResultCallback callback, Timeout timeout = Timeout());
|
|
|
|
void invokeRemoteMethodAsync(const QString& objectName, const QString& methodName,
|
|
const QVariant& arg1, const QVariant& arg2, const QVariant& arg3,
|
|
const QVariant& arg4, AsyncResultCallback callback,
|
|
Timeout timeout = Timeout());
|
|
|
|
void invokeRemoteMethodAsync(const QString& objectName, const QString& methodName,
|
|
const QVariant& arg1, const QVariant& arg2, const QVariant& arg3,
|
|
const QVariant& arg4, const QVariant& arg5,
|
|
AsyncResultCallback callback, Timeout timeout = Timeout());
|
|
|
|
/**
|
|
* @brief Register an event listener via LogosObject's callback mechanism
|
|
* @param originObject The LogosObject that will emit the event
|
|
* @param eventName The name of the event to listen for
|
|
* @param callback Function to call when the event is triggered
|
|
*/
|
|
void onEvent(LogosObject* originObject, const QString& eventName,
|
|
std::function<void(const QString&, const QVariantList&)> callback);
|
|
|
|
/**
|
|
* @brief Emit an event on a LogosObject (for plugins that act as event sources)
|
|
* @param object The LogosObject to emit the event on
|
|
* @param eventName The name of the event
|
|
* @param data The event data
|
|
*/
|
|
void onEventResponse(LogosObject* object, const QString& eventName, const QVariantList& data);
|
|
|
|
/**
|
|
* @brief Backward-compatible overload for QObject-based plugins.
|
|
*
|
|
* Old-API plugins call onEventResponse(this, ...) where `this` is a QObject*.
|
|
* This overload invokes the eventResponse signal on the QObject via QMetaObject.
|
|
*/
|
|
void onEventResponse(QObject* object, const QString& eventName, const QVariantList& data);
|
|
|
|
bool informModuleToken(const QString& authToken, const QString& moduleName, const QString& token);
|
|
bool informModuleToken(const char* authToken, const char* moduleName, const char* token)
|
|
{ return informModuleToken(QString(authToken), QString(moduleName), QString(token)); }
|
|
bool informModuleToken(const std::string& authToken, const std::string& moduleName, const std::string& token);
|
|
bool informModuleToken_module(const QString& authToken, const QString& originModule, const QString& moduleName, const QString& token, int timeoutMs = 20000);
|
|
|
|
TokenManager* getTokenManager() const;
|
|
QString getToken(const QString& module_name);
|
|
|
|
// nlohmann::json overloads — args is a JSON array, result is a JSON value.
|
|
// These convert between nlohmann::json and QVariant internally so callers
|
|
// never need to touch Qt JSON types.
|
|
nlohmann::json invokeRemoteMethod(const std::string& objectName,
|
|
const std::string& methodName,
|
|
const nlohmann::json& args,
|
|
Timeout timeout = Timeout());
|
|
|
|
// nlohmann::json event callback overload — data arrives as a json array.
|
|
void onEvent(LogosObject* originObject, const std::string& eventName,
|
|
std::function<void(const std::string&, const nlohmann::json&)> callback);
|
|
|
|
private:
|
|
// requestModule handshake against capability_module + cache the minted token
|
|
// in the shared TokenManager. Returns the token ("" on failure). Factors out
|
|
// the first-exchange logic so both the initial fetch and the
|
|
// rejection-driven re-exchange share one path. (Private method — no effect on
|
|
// the ABI-sensitive data layout below.)
|
|
QString mintAndCacheToken(const QString& objectName);
|
|
|
|
// Async invoke with a bounded retry budget backing the public
|
|
// invokeRemoteMethodAsync overloads. On a provider rejection sentinel it
|
|
// drops the stale token and re-enters itself with retriesLeft-1, so the retry
|
|
// coalesces through the same m_pendingHandshakes machinery.
|
|
void invokeRemoteMethodAsyncImpl(const QString& objectName, const QString& methodName,
|
|
const QVariantList& args, AsyncResultErrorCallback callback,
|
|
Timeout timeout, int retriesLeft);
|
|
|
|
// ABI note: this private layout is consumed by every plugin that
|
|
// statically links libsdk. Adding a new field in the middle of
|
|
// this section shifts the offsets of subsequent fields and
|
|
// SILENTLY breaks any plugin compiled before the change — it
|
|
// reads m_token_manager at the wrong offset and segfaults on
|
|
// the first cross-process call. New private members MUST be
|
|
// appended to the end. (Long-term cure: pimpl this class so
|
|
// sizeof / offsets become opaque to consumers.)
|
|
LogosAPIConsumer* m_consumer;
|
|
QMap<QString, QString> m_tokens;
|
|
TokenManager* m_token_manager;
|
|
QString m_origin_module;
|
|
// Pre-built consumer for the auto-`requestModule` token-fetch path
|
|
// in invokeRemoteMethod{,Async}. Constructed once with the right
|
|
// transport (see the two-transport ctor) so the hot path doesn't
|
|
// chase a back-pointer to LogosAPI just to look up the transport
|
|
// registry. Null only when `m_consumer` itself is for
|
|
// capability_module (no recursion). In-class default to nullptr
|
|
// so any old constructor that doesn't list this field still
|
|
// leaves a defined value.
|
|
LogosAPIConsumer* m_capability_consumer = nullptr;
|
|
|
|
// Per-target queue of continuations waiting on an in-flight async
|
|
// requestModule handshake. The FIRST async call to an un-tokened target
|
|
// starts exactly one handshake; concurrent calls to the same target queue
|
|
// here and all drain with the single minted token when it resolves. Without
|
|
// this coalescing a fan-out of N first-calls fires N racing handshakes whose
|
|
// distinct tokens overwrite each other on the target — so already-dispatched
|
|
// calls carry a superseded token and get rejected. Touched only on the
|
|
// owner thread (invokeRemoteMethodAsync marshals there), so it needs no
|
|
// lock. Appended last per the ABI note above; defaults to empty.
|
|
QMap<QString, std::vector<std::function<void(const QString&)>>> m_pendingHandshakes;
|
|
};
|
|
|
|
#endif // LOGOS_API_CLIENT_H
|