Files
logos-protocol/cpp/logos_protocol.cpp
T
Dario LipicarandClaude Opus 5 07b0fb1c64 fix: make event subscriptions survive a module that is not reachable yet (#47)
* fix: make isConnected() mean connected, and stop the log claiming it

QRemoteObjectNode::connectToNode() returns false only when the URL
SCHEME is unregistered -- it never contacts the peer. Our registry URLs
are COMPUTED rather than discovered (logos_instance.h:
local:logos_<module>_<instanceId>), so they are identical whether or not
the module exists. Latching m_connected from that return therefore made
isConnected() answer "yes" for modules that were never loaded, which made
every `if (!client->isConnected()) return;` guard in the codebase DEAD
CODE.

Callers then paid a 20 s waitForSource per call, twice over, because the
token handshake tries capability_module first. Measured in Basecamp with
package_manager absent: ~417 s of blocked GUI thread on macOS and 361 s
on Linux before the window appeared, and over 900 s under load. Not a
Windows bug -- the Windows port merely exposed it.

isConnected() now also requires a listener at the endpoint. For `local:`
that is a direct socket / named-pipe probe, which costs microseconds
precisely in the case that used to cost 20 seconds; any other scheme
keeps its previous behaviour.

Two logging changes, because the diagnostics cost more than the defect:
"Successfully connected to registry" asserted a connection that often did
not exist and sent three separate investigations to the wrong place -- it
now says a connect attempt started and makes no claim about the peer.
And requestObject warns BEFORE a doomed wait instead of going silent for
20 s and then reporting failure.

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

* fix: let event subscriptions survive a module that is not reachable yet

requestObject() answers "is the module there RIGHT NOW", and every
subscriber in this codebase asks at the one moment the answer is no: a
module's init(), a UI backend's onContextReady(), a QML view's
Component.onCompleted. All of those run while the dependency's host
process has been spawned but has not called listen() yet. The subscriber
then gave up permanently -- lp_subscribe returned nullptr with no log at
all, and callers turned that into a `false` the documented example
discards. Method calls kept working through the same window because
acquireCachedObject() reaches the replica by a path that never asks, so
the symptom was "events are broken", not "the subscription never
happened".

1238316 (isConnected() means connected) is what made this deterministic
rather than lucky, and it must not be reverted -- it removed ~417 s
(macOS) / 361 s (Linux) of blocked GUI thread at Basecamp startup. So
the subscription becomes deferrable instead.

  - LogosTransportAsyncAcquire: a sibling interface (dynamic_cast, like
    LogosObjectErrorChannel) so LogosTransportConnection's installed
    vtable is unchanged. requestObjectWhenAvailable() registers interest
    and returns; it never blocks and never spins a nested event loop.
  - qt_remote implements it by acquiring a dynamic replica before the
    peer exists -- legal, free, and armed by the node's existing 250 ms
    reconnect loop, so it adds no polling. Delivery is deferred one
    event-loop turn because stateChanged fires from inside onClientRead
    (the refresh_balances re-entrancy SIGSEGV).
  - LogosAPIConsumer::onEventWhenAvailable() holds the pending
    subscriptions, arms them when the object appears, shares ONE handle
    per object (separate from the call cache, so a call re-acquiring a
    stale handle cannot silently kill a live subscription), and re-arms
    them after reconnect(). Unbounded in time on purpose -- a module can
    be installed mid-session -- but bounded in noise: one warning at 3 s,
    one at 60 s, a log line when it arms, and a loud abandon when the
    transport proves it impossible.
  - lp_subscribe routes through it, which fixes the same defect for
    every C++/Nim/Rust module and UI backend without touching qt-sdk or
    any generated code.

tests/protocol/test_deferred_subscription.cpp pins all three layers,
each with a published-first control so a red case cannot be a mis-wired
fixture.

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

* fix: close the remaining silent-failure holes in deferred event subscriptions

The deferred-subscription registry from the previous commit fixed the reported
defect, but review found six ways it could still lose a subscription without
saying so — five in the registry itself, one in the plain transport's host — and
every one of them lived in a cell with no test. All of its tests ran in Remote
mode; three of the four transports had none at all.

Registry (cpp/logos_api_consumer.cpp):

* An already-present module was deferred to the first 250 ms tick on every
  transport without a deferred acquire, and every event emitted in that window
  was dropped. lp_subscribe used to attach synchronously and deliver them, so
  this relocated the silent event loss rather than removing it. startAcquire()
  now reports which of three answers the transport gave, and only an
  Unsupported answer takes the one synchronous requestObject() — which is also
  what keeps that call structurally away from qt_remote, whose requestObject()
  enters waitForSource()'s nested event loop even at timeout 0. Previously that
  invariant lived in a comment, and tick() could reach it whenever
  acquireDynamic() returned null.

* reconnected() put every armed subscription back in the pending set but never
  restarted the timer, which takeMatching() had stopped when they armed. Since
  tick() is the sole driver of both the retry and the watchdog, a reconnect left
  the subscription dead AND silent — quieter than the "not connected" warning it
  replaced.

* armAgainst() released a stale handle while entries were still attached to its
  event helper. Those entries stayed in m_armed, never fired again, and reported
  as healthy. They are now revived and re-armed against the new handle.

* The retry timer ran forever at the 5 s cap with nothing to do. It now stops
  once every pending entry has an acquire in flight and has said everything it
  will say, and restarts when that changes.

* A cancelled subscription had no way to leave the registry, so lp_unsubscribe
  left it holding the timer up and warning about a subscription nobody wanted.
  onEventWhenAvailable() now returns an id; cancelEventSubscription() and
  eventSubscriptionState() are its counterparts, and lp_unsubscribe uses them.

Plain transport (cpp/implementations/plain/plain_transport_host.cpp):

* onSubscribe() dropped a Subscribe for an object that was not published YET —
  which is exactly when consumers subscribe — and the consumer could not know,
  because requestObject() had already succeeded. Publishing also overwrote the
  sink table wholesale, so a republish took every subscriber down with it. The
  sinks now live in a table keyed independently of publication.

Also adds lp_pending_subscriptions() to the C ABI. The Qt consumer has had this
visibility all along and the C ABI had none, which is why a subscription that
silently never armed was undetectable from Rust, Nim or a universal C++ module.

tests/protocol/test_event_delivery_matrix.cpp pins the product rather than a
sample of it: 3 transports x 2 provider kinds (Qt-native and universal/std, which
reach the wire by different conversions) x 2 consumer paths (onEventWhenAvailable
and lp_subscribe) x 6 timings, plus mock and the non-blocking guard. Every
delivery case has a control that is green independently of these fixes.

One thing that is NOT fixed and is now stated in the contract: arming is not
retroactive and no transport buffers, so a module that emits a one-shot "ready"
event synchronously inside its own init() can still be missed. That window is
inherent to the transport — the blocking requestObject() this replaced had it
too — but "subscriptions survive a late module" is not "no event can be missed".

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

* docs: name the QtRO invariant the stale-handle revive rests on

* test(events): state what the non-blocking guard can and cannot catch

The acquireCount assertion catches a retry that polls qt_remote's blocking
requestObject() in the ordinary case. It cannot reach the narrow one -- the
poll is only reachable when the transport declines a deferred acquire while
still reporting connected, which needs acquireDynamic() to return null and is
not forcible from outside. That case is held shut by control flow instead, and
saying so is better than leaving a reader to assume the test covers it.

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

* fix: make the async-acquire contract and lp_subscribe's return honest

Both from review on #47, both real.

The LogosTransportAsyncAcquire contract promised that a true return means
onReady "WILL be invoked exactly once". It will not: RemoteTransportConnection
parents every in-flight PendingAcquire to m_pendingAcquires, which is reset at
the top of the destructor and rebuilt on reconnect, so an accepted request is
cancelled silently with no callback whenever the connection it belongs to goes
away. The contract now says AT MOST once, names both cancellation triggers, and
states what a caller has to do about them — re-issue after a reconnect, or carry
its own deadline. It also records that the layer above already does the first,
which is why a subscription made through onEventWhenAvailable() survives
something the raw transport call does not. That asymmetry is the reason to
prefer the consumer API, and it was previously implicit.

lp_subscribe returned a non-null lp_subscription even when onEventWhenAvailable
refused and returned 0, leaving the caller with a handle that can never fire
while the ABI documents NULL as the one signal that the arguments were refused.
It now checks sub->id and returns nullptr.

That second one is defensive rather than a live bug, and the code says so: the
guard at the top of lp_subscribe already rejects an empty event name and a null
callback, and lp_client_create rejects an empty target, so the three inputs that
make onEventWhenAvailable() return 0 cannot all arrive there today. No test
drives it. The two contracts simply have to agree, and one of them changing is
how they would stop agreeing.

374/374 green.

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

* fix: stop lp_unsubscribe deadlocking, without dereferencing a freed client

lp_unsubscribe took ownerGuard->mutex and, while holding it, called
cancelEventSubscription(), which marshals to the owner thread with a BLOCKING
queued connection. The delivery callback lp_subscribe installs runs ON that
thread and takes subGuard->mutex then clientGuard->mutex — and clientGuard IS
ownerGuard, both assigned from client->guard. Lock-order inversion. It also hung
outright once the owner's event loop had stopped, which is exactly when a
language binding drops its subscription handle.

The first attempt at this dropped the guard entirely and checked `alive` inside
the posted lambda. That was a use-after-free: QMetaObject::invokeMethod
dereferences the target (it reads object->thread()) before the lambda can run,
and lp_client_destroy sets alive=false and deletes the client synchronously —
so the check was unreachable on the exact ordering lp_subscription's own comment
documents as supported. Proven rather than argued: with MallocScribble=1, a test
that destroys the client before unsubscribing segfaulted 6/6 with the guard
removed and passed 6/6 with it restored.

So the guard is held across the POST and not across the cancel. Both halves are
load-bearing, and the distinction is the whole fix: posting never waits on the
owner thread, so holding the mutex across it cannot invert; only the blocking
marshal ever had to move.

Consequence, now stated in the ABI header: un-registration is EVENTUAL. The
callback-will-not-fire guarantee stays synchronous and unconditional, but
lp_pending_subscriptions() may still list a just-cancelled subscription until the
owner thread runs, and if the client is destroyed first the cancellation never
runs at all — correct, since the registry died with it. The matrix test now
pumps for the drain instead of asserting it happened synchronously.

374/374 green.

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

* fix: arm a subscription immediately when the module is already reachable

Deferral introduced a narrower version of the loss it removed. The common
consumer shape is a call followed by a subscription in the same function --
wallet-ui's backend calls get_chains() and subscribes on the next line, the
tutorial's C++ UI backend does the same. Before deferral the generated Qt
wrapper acquired synchronously, so the subscription was live before on()
returned and an event emitted straight after was delivered. Holding it until the
next event-loop turn silently drops that event.

Measured on the generated-wrapper harness: 1/1 delivered pre-migration, 0/1
after, over 3 runs.

LogosTransportAsyncAcquire gains tryAcquireNow(): hand back a handle ONLY if
that costs nothing -- for qt_remote, a replica that is already Valid, which is
exactly the state a prior call leaves behind since QtRO shares one replica
implementation per object name on a node. It must never block, never spin a
nested event loop and never wait on a peer; "not immediately available" is an
answer and the caller falls back to the deferred path. Default returns nullptr,
so a transport that cannot answer cheaply simply does not.

Delivering inline here is safe for the reason the never-synchronous rule exists:
that rule protects against re-entering the transport's READ stack from a
stateChanged callback. tryAcquireNow runs on the subscriber's own stack.

The new matrix case fires ONCE, synchronously, with no pumping in between --
re-firing would hide the exact gap under test -- and states the transport
difference rather than papering over it. Subscription registration is local on
qt_remote (attach to a held replica) and qt_local (connect an in-process
signal), so delivery there must be instant. On plain it is a wire frame to the
host, so instant delivery was never on offer and never was before this change
either; that leg asserts it still arms and delivers.

Also de-flaked EventDeliveryNonBlocking: its heartbeat COUNT over a fixed
wall-clock window measures the machine, not the code. The gap assertion is the
one that means something; the count is now only a floor.

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

* fix: stop tryAcquireNow leaving a dangling facade in QtRO's connect list

e9f82ac introduced a use-after-free. tryAcquireNow() acquired a dynamic replica
and, when it was not already Valid, deleted it. That is not safe: QtRO shares one
replica IMPLEMENTATION per object name per node, and while that implementation is
still waiting for the source's metaobject it records every facade built on it as a
RAW pointer in QConnectedReplicaImplementation::m_parentsNeedingConnect.
~QRemoteObjectReplica is an empty body, so destroying a facade never deregisters
it, and the implementation dereferences the whole list when the class definition
arrives.

So each probe of an unreachable module left one dangling pointer behind.

WHY IT HID. The first probe owns the only implementation and takes it down with
itself, so a single subscription is harmless. It needs a second subscription whose
implementation is pinned by an in-flight PendingAcquire before a freed facade can
outlive its implementation. A consumer subscribing once sees nothing; the QML
plugin shape -- a view registering every event it cares about up front -- dies.

REPRODUCED, 4 runs of 4, serially as well as in parallel, in
logos-view-module-runtime's existing suite (unchanged from master, and green there
against this same protocol checkout):

  LogosQmlBridge: subscription accepted for "echo_module" :: "ev13"
  Received signal 10 (SIGBUS), code 1, for address 0x5a

SIGBUS code 1 is BUS_ADRALN -- a misaligned atomic access on a garbage base read
out of a recycled heap block, in the event loop rather than at the call site,
which is why it reads as a mystery crash rather than as a subscription bug.

PROVEN, before writing this fix, by commenting out that single `delete replica`:
the same suite went 4 failures -> 6/6 with no other change. With this fix: 6/6.

THE FIX IS TO PARK, NOT TO FREE. One probe per object name, parented to
m_pendingAcquires -- which both the destructor and reconnect() already destroy
BEFORE the node, so the implementations die in the same breath and freeing them
there is safe. Ownership transfers out only when the replica reaches Valid, by
which point the implementation is configured and is no longer holding the facade.
It costs one idle replica per name until it goes Valid or the connection dies.

AND REMOVE THE MULTIPLIER: beginAcquire() probed on EVERY add(), ahead of
startAcquire() and therefore ahead of the m_acquiring one-acquire-per-object
guard. tick() already applies that filter; beginAcquire() was the one caller that
did not, which is what turned one probe per module into one per subscription.
While an acquire is in flight its PendingAcquire already holds a replica and will
arm every waiting entry at once, so the probe buys nothing there.

Not QML-specific: lp_subscribe reaches the same entry point.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:42:50 -03:00

624 lines
24 KiB
C++

#include "logos_protocol.h"
#include "logos_api_client.h"
#include "logos_call_error.h"
#include "logos_json_convert.h"
#include "logos_mode.h"
#include "logos_object.h"
#include "logos_thread_marshal.h"
#include "logos_transport_config.h"
#include "logos_transport_config_json.h"
#include "logos_transport_factory.h"
#include "logos_types.h"
#include "token_manager.h"
#include <nlohmann/json.hpp>
#include <QCoreApplication>
#include <QDebug>
#include <QJsonArray>
#include <QJsonDocument>
#include <QMetaType>
#include <QString>
#include <QThread>
#include <QVariant>
#include <QVariantList>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <mutex>
#include <string>
namespace {
// Heap-copy a std::string for handing across the C boundary.
// Counterpart of lp_string_free (which is plain free()).
char* lpStrdup(const std::string& s)
{
char* out = static_cast<char*>(std::malloc(s.size() + 1));
if (!out) return nullptr;
std::memcpy(out, s.data(), s.size() + 1);
return out;
}
std::string makeErrorJson(const char* code, const std::string& message,
const std::string& origin)
{
nlohmann::json e;
e["code"] = code;
e["message"] = message;
e["origin"] = origin;
return e.dump();
}
// Parse a single-transport JSON object (the lp_* shape) by reusing the
// transport-set parser (which expects an array). NULL / empty / "null"
// fall back to the process default.
bool parseTransportJson(const char* transport_json, LogosTransportConfig& out)
{
if (!transport_json || !*transport_json
|| std::strcmp(transport_json, "null") == 0) {
out = LogosTransportConfigGlobal::getDefault();
return true;
}
const LogosTransportSet set = logos::transportSetFromJsonString(
std::string("[") + transport_json + "]");
if (set.empty()) return false; // parse error (parser yields empty set)
out = set.front();
return true;
}
// Callback guard shared between an lp handle and its in-flight callbacks.
// Invocations hold the mutex while calling user code; teardown takes the
// mutex and clears `alive`, so once lp_client_destroy / lp_unsubscribe
// returns, no further user callback can fire (the cancellation contract in
// logos_protocol.h). recursive_mutex so a callback may itself unsubscribe.
struct CbGuard {
std::recursive_mutex mutex;
bool alive = true;
};
Timeout lpTimeout(int timeout_ms)
{
return timeout_ms > 0 ? Timeout(timeout_ms) : Timeout();
}
// Parse args_json (NULL → empty array) into a QVariantList.
// Returns false (+fills error) when args_json is not a JSON array.
bool parseArgs(const char* args_json, const QString& origin,
QVariantList& out, std::string& error)
{
if (!args_json || !*args_json) return true;
nlohmann::json parsed = nlohmann::json::parse(args_json, nullptr,
/*allow_exceptions=*/false);
if (parsed.is_discarded() || !parsed.is_array()) {
error = makeErrorJson("invalid_args",
"args_json must be a JSON array",
origin.toStdString());
return false;
}
out = logos::nlohmannArgsToQVariantList(parsed);
return true;
}
} // namespace
struct lp_client {
LogosAPIClient* client = nullptr;
QString target;
QString origin;
std::shared_ptr<CbGuard> guard;
};
struct lp_subscription {
std::shared_ptr<CbGuard> guard;
// Enough to un-register from the consumer's pending registry on
// lp_unsubscribe. The client guard is what makes that safe: a subscription
// can outlive lp_client_destroy, and dereferencing `owner` then would be a
// use-after-free.
LogosAPIClient* owner = nullptr;
std::shared_ptr<CbGuard> ownerGuard;
quint64 id = 0;
};
struct lp_provider {
std::string moduleName;
std::string transportSetJson;
lp_dispatch_cb dispatch = nullptr;
lp_getmethods_cb getMethods = nullptr;
lp_token_cb onToken = nullptr;
void* userData = nullptr;
};
extern "C" {
/* ---------------------------------------------------------------- version */
const char* lp_protocol_version(void)
{
return LOGOS_PROTOCOL_VERSION_STRING;
}
int lp_protocol_abi_major(void)
{
return LOGOS_PROTOCOL_VERSION_MAJOR;
}
/* ----------------------------------------------------------------- memory */
void lp_string_free(char* s)
{
std::free(s);
}
/* ----------------------------------------------------- mode / transports */
int lp_set_mode(const char* mode)
{
if (!mode) return LP_ERR_INVALID_ARG;
if (std::strcmp(mode, "remote") == 0) {
LogosModeConfig::setMode(LogosMode::Remote);
} else if (std::strcmp(mode, "local") == 0) {
LogosModeConfig::setMode(LogosMode::Local);
} else if (std::strcmp(mode, "mock") == 0) {
LogosModeConfig::setMode(LogosMode::Mock);
} else {
return LP_ERR_INVALID_ARG;
}
return LP_OK;
}
const char* lp_get_mode(void)
{
switch (LogosModeConfig::getMode()) {
case LogosMode::Local: return "local";
case LogosMode::Mock: return "mock";
case LogosMode::Remote: break;
}
return "remote";
}
int lp_set_default_transport(const char* transport_json)
{
if (!transport_json) return LP_ERR_INVALID_ARG;
LogosTransportConfig cfg;
if (!parseTransportJson(transport_json, cfg)) return LP_ERR_INVALID_ARG;
LogosTransportConfigGlobal::setDefault(cfg);
return LP_OK;
}
/* ---------------------------------------------------------------- clients */
lp_client* lp_client_create(const char* target_module,
const char* origin_module,
const char* target_transport_json,
const char* capability_transport_json)
{
if (!target_module || !*target_module || !origin_module) return nullptr;
LogosTransportConfig targetCfg;
LogosTransportConfig capabilityCfg;
if (!parseTransportJson(target_transport_json, targetCfg)) return nullptr;
if (!parseTransportJson(capability_transport_json, capabilityCfg)) return nullptr;
// Same registration LogosAPI's constructor performs — lp-only consumers
// never construct a LogosAPI, so do it here (idempotent).
qRegisterMetaType<LogosResult>("LogosResult");
auto* handle = new lp_client();
handle->target = QString::fromUtf8(target_module);
handle->origin = QString::fromUtf8(origin_module);
handle->guard = std::make_shared<CbGuard>();
// No QObject parent: the handle owns the client.
//
// Construction picks the owner thread every later call marshals onto
// (logos::runOnOwnerThread), and for a Qt-affine transport it also picks
// the thread that owns the QRemoteObjectNode and its QLocalSocket. Those
// only work on a thread running a Qt event loop, so we construct on the Qt
// main thread rather than on whichever thread happened to call first.
//
// Callers reach lp_client_create through a lazily-created wrapper (the
// generated bind_<iface>() → LpClient::ensure()), so "whichever thread
// called first" is genuinely arbitrary: a module whose first outbound call
// comes from an HTTP handler used to bind its whole transport to that
// worker thread. The worker only pumps events while blocked inside a call,
// so replica acquisition never completed and every call burned its full
// 20s timeout — silently, since a failed acquire returns an empty result.
// The Qt path never had this: LogosAPI::getClient marshals construction to
// the LogosAPI's thread, which is the main thread. This gives the lp path
// the same anchor.
//
// Plain (Tcp/TcpSsl) and mock transports are Qt-free and thread-agnostic —
// they keep the calling thread, so a worker-thread consumer stays off the
// main thread's back.
const bool qtAffine = LogosTransportFactory::needsQtEventLoop(targetCfg)
|| LogosTransportFactory::needsQtEventLoop(capabilityCfg);
auto construct = [&]() -> LogosAPIClient* {
return new LogosAPIClient(handle->target, handle->origin,
&TokenManager::instance(),
targetCfg, capabilityCfg);
};
if (qtAffine && !QCoreApplication::instance()) {
// Nothing to anchor to. The transport will misbehave for the reasons
// above; say so once rather than let it surface as a mute timeout.
qWarning() << "lp_client_create: creating a Qt-affine client for"
<< handle->target
<< "with no QCoreApplication — the QtRO transport needs a "
"Qt event loop; use a plain (tcp) transport in Qt-free "
"hosts";
}
handle->client = qtAffine ? logos::runOnQtMainThread(construct) : construct();
return handle;
}
void lp_client_destroy(lp_client* client)
{
if (!client) return;
{
// Block until no user callback is mid-flight, then forbid new ones.
std::lock_guard<std::recursive_mutex> lock(client->guard->mutex);
client->guard->alive = false;
}
// The client and its consumers own Qt transport objects (for QtRO: a node
// and its QLocalSocket, with socket notifiers) that belong to the owner
// thread. Destroying them from another thread makes Qt disable a notifier
// cross-thread and closes the fd under the owner's event dispatcher, which
// faults. Foreign-thread destroys are real: any binding that keeps a client
// share inside a worker (an event subscription moved into a Rust worker
// thread, say) runs this on that worker when the last share drops.
//
// deleteLater() hands the destruction to the owner thread, matching the
// marshaling every call path already does (logos::runOnOwnerThread). A
// *blocking* marshal is not usable here: the owner thread is typically the
// module's dispatch thread, and it may be blocked joining the very worker
// running this destroy — that would deadlock. Deferring instead is
// invisible to callers because the guard above, not the delete, is what
// enforces the ABI's "no callbacks after this returns" contract.
//
// If the owner's event loop never runs again (a process already tearing
// down), the deferred delete never fires and the client leaks. That is the
// deliberate trade: a leak at exit beats a crash.
if (client->client) {
if (client->client->thread() == QThread::currentThread())
delete client->client;
else
client->client->deleteLater();
}
delete client;
}
/* ----------------------------------------------------------------- invoke */
int lp_invoke(lp_client* client,
const char* method,
const char* args_json,
int timeout_ms,
char** out_result_json,
char** out_error_json)
{
if (out_result_json) *out_result_json = nullptr;
if (out_error_json) *out_error_json = nullptr;
if (!client || !client->client || !method || !*method) {
if (out_error_json)
*out_error_json = lpStrdup(makeErrorJson(
"invalid_arg", "client and method are required", ""));
return LP_ERR_INVALID_ARG;
}
QVariantList args;
std::string error;
if (!parseArgs(args_json, client->origin, args, error)) {
if (out_error_json) *out_error_json = lpStrdup(error);
return LP_ERR_INVALID_ARG;
}
logos::CallError callErr;
const QVariant result = client->client->invokeRemoteMethod(
client->target, QString::fromUtf8(method), args, lpTimeout(timeout_ms), &callErr);
if (!callErr.ok()) {
if (out_error_json)
*out_error_json = lpStrdup(makeErrorJson(
callErr.code.c_str(), callErr.message, callErr.origin));
return LP_ERR_UNAVAILABLE;
}
if (out_result_json)
*out_result_json = lpStrdup(logos::qvariantToNlohmann(result).dump());
return LP_OK;
}
int lp_invoke_async(lp_client* client,
const char* method,
const char* args_json,
int timeout_ms,
lp_result_cb cb,
void* user_data)
{
if (!client || !client->client || !method || !*method || !cb)
return LP_ERR_INVALID_ARG;
QVariantList args;
std::string error;
if (!parseArgs(args_json, client->origin, args, error))
return LP_ERR_INVALID_ARG;
std::shared_ptr<CbGuard> guard = client->guard;
// A TWO-argument lambda: invocable only as LogosAPIClient's
// AsyncResultErrorCallback, so it binds to the CallError-aware overload and
// never to the value-only one sitting next to it. That overload is what
// makes `ok == 0` reachable at all — this used to subscribe with the
// value-only one and hard-code cb(1, ...), so a call to a module that is
// not loaded reached the callback as a SUCCESS carrying a default value,
// contradicting both lp_result_cb's documented contract and the sync twin
// lp_invoke (which returns LP_ERR_UNAVAILABLE + out_error_json).
//
// The failure shape is deliberately identical to lp_invoke's
// out_error_json — the same makeErrorJson({code, message, origin}) — so the
// two entry points report the same event the same way, and a caller can
// parse one decoder for both.
client->client->invokeRemoteMethodAsync(
client->target, QString::fromUtf8(method), args,
[guard, cb, user_data](QVariant result, const logos::CallError& err) {
std::lock_guard<std::recursive_mutex> lock(guard->mutex);
if (!guard->alive) return; // client destroyed: drop the result
if (!err.ok()) {
const std::string json = makeErrorJson(err.code.c_str(),
err.message, err.origin);
cb(0, json.c_str(), user_data);
return;
}
const std::string json = logos::qvariantToNlohmann(result).dump();
cb(1, json.c_str(), user_data);
},
lpTimeout(timeout_ms));
return LP_OK;
}
/* ------------------------------------------------------------- subscribe */
lp_subscription* lp_subscribe(lp_client* client,
const char* event_name,
lp_event_cb cb,
void* user_data)
{
if (!client || !client->client || !event_name || !*event_name || !cb)
return nullptr;
// Deliberately NOT requestObject() + onEvent().
//
// That pair asks "is the target module reachable at this instant?", and
// every caller that reaches here asks it at the worst possible instant: a
// module's init(), a UI backend's onContextReady(), a generated
// `dep.onSomething(...)` wrapper — all of which run while the dependency's
// host process has been spawned but has not called listen() yet. The old
// code returned nullptr there, the generated wrapper turned that into a
// `false` its documented example discards, and the subscription was never
// attempted again for the life of the process: method calls worked, events
// silently never arrived.
//
// onEventWhenAvailable() returns a handle that arms when the module shows
// up (including a mid-session install), warns once when it defers, logs
// when it arms, and says so loudly if it ever becomes impossible.
auto* sub = new lp_subscription();
sub->guard = std::make_shared<CbGuard>();
sub->owner = client->client;
sub->ownerGuard = client->guard;
std::shared_ptr<CbGuard> subGuard = sub->guard;
std::shared_ptr<CbGuard> clientGuard = client->guard;
sub->id = client->client->onEventWhenAvailable(
client->target, QString::fromUtf8(event_name),
[subGuard, clientGuard, cb, user_data](const QString& name,
const QVariantList& data) {
std::lock_guard<std::recursive_mutex> subLock(subGuard->mutex);
if (!subGuard->alive) return; // unsubscribed
std::lock_guard<std::recursive_mutex> clientLock(clientGuard->mutex);
if (!clientGuard->alive) return; // client destroyed
nlohmann::json payload = nlohmann::json::array();
for (const QVariant& v : data)
payload.push_back(logos::qvariantToNlohmann(v));
const std::string json = payload.dump();
const QByteArray nameUtf8 = name.toUtf8();
cb(nameUtf8.constData(), json.c_str(), user_data);
});
if (!sub->id) {
// The consumer refused the arguments (empty object/event name, or a
// null callback). Returning the handle anyway would hand the caller
// something that can never fire, while the ABI documents NULL as the
// one signal that the arguments were refused — a silent dead
// subscription, which is the exact failure this whole change removes.
//
// Defensive, and not reachable today: the guard at the top of this
// function already rejects an empty event name and a null callback, and
// lp_client_create rejects an empty target, so the three inputs that
// make onEventWhenAvailable() return 0 cannot all arrive here. Hence no
// test drives it — the two contracts simply have to agree, and one of
// them changing is how they would stop agreeing.
delete sub;
return nullptr;
}
return sub;
}
void lp_unsubscribe(lp_subscription* sub)
{
if (!sub) return;
{
// The underlying transport keeps its listener; this guard makes it
// inert, which is what the ABI promises ("the callback will not
// fire again").
std::lock_guard<std::recursive_mutex> lock(sub->guard->mutex);
sub->guard->alive = false;
}
// Stop the consumer tracking it too. Without this an unsubscribed-while-
// pending subscription stays in the registry forever: it holds the retry
// timer up, keeps emitting the 3 s / 60 s "still not reachable" warnings
// about a subscription nobody wants, and shows up in the
// pendingEventSubscriptions() diagnostics this whole change relies on for
// its own credibility.
if (sub->id && sub->owner && sub->ownerGuard) {
// POSTED, and deliberately NOT under ownerGuard->mutex.
//
// cancelEventSubscription() marshals to the owner thread with a
// BLOCKING queued connection, and the delivery callback installed by
// lp_subscribe takes this very mutex ON that thread (ownerGuard and the
// callback's clientGuard are the same CbGuard). Taking it here and then
// waiting for the owner thread is a lock-order inversion that
// deadlocks; and it hangs outright once the owner's event loop has
// stopped, which is exactly when a Rust EventSubscription drops.
//
// Posting instead: the lambda runs ON the owner thread, so the marshal
// inside cancelEventSubscription() becomes a direct call, and taking
// the guard there cannot wait on anyone. Qt drops posted events for a
// destroyed QObject, so a client torn down before delivery simply means
// the cancel never runs — which is correct, since the registry died
// with it.
auto ownerGuard = sub->ownerGuard;
LogosAPIClient* owner = sub->owner;
const quint64 id = sub->id;
std::lock_guard<std::recursive_mutex> ownerLock(ownerGuard->mutex);
if (ownerGuard->alive) {
// The guard is held across the POST but not across the cancel.
// That distinction is the whole fix, and both halves are load-bearing:
//
// - It must be HELD here, because QMetaObject::invokeMethod
// dereferences `owner` (it reads object->thread()) before the
// lambda can run, so an `alive` check inside the lambda is
// unreachable — lp_client_destroy sets alive=false and deletes
// the client synchronously, and this struct's own contract says
// a subscription may outlive it. Checking inside was a
// use-after-free.
// - It must NOT be held across cancelEventSubscription(), which
// marshals to the owner thread with a BLOCKING queued connection
// while that thread's delivery callback takes this same mutex —
// a lock-order inversion that deadlocks, and hangs outright once
// that event loop has stopped.
//
// Posting never waits on the owner thread, so holding the mutex
// across it cannot invert. Only the blocking marshal had to move.
QMetaObject::invokeMethod(owner, [ownerGuard, owner, id]() {
std::lock_guard<std::recursive_mutex> lock(ownerGuard->mutex);
if (ownerGuard->alive)
owner->cancelEventSubscription(id);
}, Qt::QueuedConnection);
}
}
delete sub;
}
char* lp_pending_subscriptions(lp_client* client)
{
if (!client || !client->client) return nullptr;
nlohmann::json out = nlohmann::json::array();
for (const QString& entry : client->client->pendingEventSubscriptions())
out.push_back(entry.toStdString());
return lpStrdup(out.dump());
}
/* ------------------------------------------------------------ introspect */
char* lp_get_methods(lp_client* client)
{
if (!client || !client->client) return nullptr;
LogosObject* object = client->client->requestObject(client->target);
if (!object) return nullptr;
const QJsonArray methods = object->getMethods();
const QByteArray json =
QJsonDocument(methods).toJson(QJsonDocument::Compact);
return lpStrdup(std::string(json.constData(),
static_cast<size_t>(json.size())));
}
/* ----------------------------------------------------------------- tokens */
char* lp_token_get(const char* module_name)
{
if (!module_name) return nullptr;
const QString token =
TokenManager::instance().getToken(QString::fromUtf8(module_name));
if (token.isEmpty()) return nullptr;
return lpStrdup(token.toStdString());
}
int lp_token_save(const char* module_name, const char* token)
{
if (!module_name || !token) return LP_ERR_INVALID_ARG;
TokenManager::instance().saveToken(QString::fromUtf8(module_name),
QString::fromUtf8(token));
return LP_OK;
}
int lp_inform_module_token(lp_client* client,
const char* auth_token,
const char* module_name,
const char* token)
{
if (!client || !client->client || !auth_token || !module_name || !token)
return LP_ERR_INVALID_ARG;
const bool ok = client->client->informModuleToken(
QString::fromUtf8(auth_token), QString::fromUtf8(module_name),
QString::fromUtf8(token));
return ok ? LP_OK : LP_ERR_INTERNAL;
}
/* -------------------------------------------------- provider (groundwork) */
lp_provider* lp_provider_create(const char* module_name,
const char* transport_set_json)
{
if (!module_name || !*module_name) return nullptr;
auto* provider = new lp_provider();
provider->moduleName = module_name;
provider->transportSetJson =
transport_set_json ? transport_set_json : "[]";
return provider;
}
void lp_provider_destroy(lp_provider* provider)
{
delete provider;
}
int lp_provider_register(lp_provider* provider,
lp_dispatch_cb dispatch,
lp_getmethods_cb get_methods,
lp_token_cb on_token,
void* user_data)
{
if (!provider || !dispatch) return LP_ERR_INVALID_ARG;
provider->dispatch = dispatch;
provider->getMethods = get_methods;
provider->onToken = on_token;
provider->userData = user_data;
return LP_OK;
}
int lp_provider_emit_event(lp_provider* provider,
const char* event_name,
const char* data_json)
{
(void)event_name;
(void)data_json;
if (!provider) return LP_ERR_INVALID_ARG;
// Groundwork only: serving a provider over the transports through the
// C ABI lands with the common cdylib module-impl ABI (module authoring
// phase). The registered callbacks above define the contract today.
return LP_ERR_UNSUPPORTED;
}
int lp_provider_save_token(lp_provider* provider,
const char* module_name,
const char* token)
{
(void)module_name;
(void)token;
if (!provider) return LP_ERR_INVALID_ARG;
return LP_ERR_UNSUPPORTED;
}
} // extern "C"