Files
logos-protocol/tests/protocol/test_event_delivery_matrix.cpp
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

878 lines
39 KiB
C++

// The event-delivery regression matrix.
//
// WHY THIS FILE EXISTS. Event delivery in this codebase has broken silently
// three separate times, each time in a cell nothing covered: a one-shot
// subscription that refused a module which was merely not up YET; a deferred
// subscription that never armed on the three transports whose tests were all
// written in Remote mode; a plain-transport host that accepted a Subscribe for
// an unpublished object and dropped it on the floor. Every one of them looked
// like success from the caller's side. A single happy-path test per layer
// cannot catch that class of defect, because the defect IS the happy path
// reporting success.
//
// So this pins the product, not a sample of it:
//
// TRANSPORT x PROVIDER x CONSUMER x TIMING
// --------- -------- -------- ------
// qt_remote Qt-native onEventWhenAvailable publish-then-subscribe
// qt_local universal/std lp_subscribe (C ABI) subscribe-then-publish
// plain (TCP) unload -> reload
// reconnect
// unsubscribe
//
// The two provider kinds are not decoration: a Qt module stores the Qt-side
// callback verbatim, while every Rust cdylib / Nim / universal C++ module emits
// through setEventListenerStdBridge, a different conversion with its own
// history of dropping payloads. The two consumer paths are not decoration
// either: QML reaches events through onEventWhenAvailable, and every non-Qt
// module and ui_qml backend reaches them through lp_subscribe.
//
// mock is deliberately NOT in the matrix: MockLogosObject::onEvent is a
// documented no-op, so mock mode delivers no events by design. It gets its own
// case at the bottom pinning what it DOES have to do — arm immediately and
// never sit pending — because a regression that left mock subscriptions
// permanently deferred would otherwise be invisible.
//
// EVERY delivery case has a control that is green independently of any of these
// fixes. A red test with no control cannot tell "the defect" from "the fixture
// is mis-wired", and a count of zero with nothing to compare it against is not
// evidence.
#include <gtest/gtest.h>
#include "logos_api_client.h"
#include "logos_instance.h"
#include "logos_mode.h"
#include "logos_object.h"
#include "logos_protocol.h"
#include "logos_provider_interface.h"
#include "logos_transport_config.h"
#include "module_proxy.h"
#include "plain_transport_host.h"
#include "local_transport.h"
#include "remote_transport.h"
#include "token_manager.h"
#include <QCoreApplication>
#include <QElapsedTimer>
#include <QJsonArray>
#include <QString>
#include <QVariantList>
#include <atomic>
#include <chrono>
#include <memory>
#include <string>
#include <thread>
namespace {
QCoreApplication* ensureApp() {
static int argc = 0;
static char* argv[] = { nullptr };
if (!QCoreApplication::instance())
new QCoreApplication(argc, argv);
return QCoreApplication::instance();
}
void pump(int ms) {
const auto end = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
while (std::chrono::steady_clock::now() < end) {
QCoreApplication::processEvents();
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
// ── providers ────────────────────────────────────────────────────────────────
// A Qt-native module: setEventListener keeps the Qt-side callback as-is. This
// is what a C++/Qt module built with logos-qt-sdk looks like to ModuleProxy.
class QtProvider : public LogosProviderObject {
public:
EventCallback emitFn;
QVariant callMethod(const QString& m, const QVariantList& a) override {
return (m == QLatin1String("echo") && !a.isEmpty()) ? a.first() : QVariant();
}
bool informModuleToken(const QString&, const QString&) override { return true; }
QJsonArray getMethods() override { return QJsonArray{}; }
void setEventListener(EventCallback cb) override { emitFn = std::move(cb); }
void init(void*) override {}
QString providerName() const override { return QStringLiteral("qt_provider"); }
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
};
// A universal module: it only knows std types, and its events reach Qt through
// setEventListenerStdBridge -> setEventListenerStd, serialising the payload as
// JSON. Every Rust cdylib, Nim and universal C++ module emits through this
// path, so a defect that only affects it would be invisible to a Qt-only test.
class UniversalProvider : public LogosProviderObject {
public:
UniversalEventCallback stdEmitFn;
QVariant callMethod(const QString& m, const QVariantList& a) override {
return callMethodStdBridge(m, a);
}
bool informModuleToken(const QString&, const QString&) override { return true; }
QJsonArray getMethods() override { return QJsonArray{}; }
void setEventListener(EventCallback cb) override {
setEventListenerStdBridge(std::move(cb));
}
void setEventListenerStd(UniversalEventCallback cb) override {
stdEmitFn = std::move(cb);
}
void init(void*) override {}
QString providerName() const override { return QStringLiteral("universal_provider"); }
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
};
enum class ProviderKind { Qt, Universal };
enum class TransportKind { QtRemote, QtLocal, Plain };
const char* nameOf(ProviderKind p) { return p == ProviderKind::Qt ? "QtProvider" : "UniversalProvider"; }
// Does a subscription to an ABSENT module sit in the consumer's pending set?
//
// Stated per transport rather than discovered at runtime, because the whole
// point is to catch a change in it. The two answers are both correct and they
// arm the subscription in genuinely different places:
//
// qt_remote DEFERS. No listener on the socket, so there is no handle to
// attach to; the consumer holds the subscription and the QtRO
// replica arms it when the peer appears.
// qt_local DEFERS. The module is not in the in-process PluginRegistry, so
// requestObject() returns null and the retry tick owns the arm.
// plain DOES NOT. requestObject() hands back a handle for any name over
// a live connection, so the subscription arms immediately and the
// waiting happens one layer down: PlainTransportHost keeps the
// sink until the object is published. If this ever flips to true,
// that host-side hold has been lost and subscribe-before-publish
// is silently broken again on TCP/TLS.
bool defersWhenModuleAbsent(TransportKind t) { return t != TransportKind::Plain; }
// Does a subscription to a module that is ALREADY up arm before the call
// returns? Everywhere except qt_remote, yes — and it has to.
//
// Those transports have no deferred acquire, so if the first attempt is left to
// the 250 ms retry tick, every event the module emits in that window is
// dropped. The C ABI used to attach synchronously and deliver them, so making
// this asynchronous would not remove the silent event loss, it would relocate
// it. qt_remote is the exception on purpose: its acquire completes on a later
// event-loop turn because delivering inline would run user code on QtRO's read
// stack, which is a documented crash in this codebase, not a style preference.
bool armsSynchronouslyWhenPresent(TransportKind t) { return t != TransportKind::QtRemote; }
// Is a subscription live the instant it is made, on a module that is already
// reachable? Only where registering it is LOCAL to this process:
// qt_remote attaches a callback to a replica this node already holds
// qt_local connects to the provider's Qt signal, in-process
// plain NO -- onSubscribe travels to the host as a wire frame, so an
// event emitted before that frame lands reaches nobody. That is
// inherent to a network transport and predates deferral: the old
// path sent the identical frame. Delivery must still HAPPEN, just
// not instantly, which is what the plain leg below asserts.
bool subscriptionIsLocallyRegistered(TransportKind t) { return t != TransportKind::Plain; }
const char* nameOf(TransportKind t) {
switch (t) {
case TransportKind::QtRemote: return "qt_remote";
case TransportKind::QtLocal: return "qt_local";
case TransportKind::Plain: return "plain_tcp";
}
return "?";
}
// ── the module under test, brought up and taken down on demand ───────────────
//
// One class per transport would triple the file for no gain; the differences
// are three lines each. What "down" MEANS differs per transport and is stated
// where it differs:
// qt_remote no host listening on the module's socket at all
// qt_local not registered in the in-process PluginRegistry
// plain host listening, object not published (the realistic case: a
// host process serves several modules and publishes them as they
// finish initialising)
class Module {
public:
Module(TransportKind transport, ProviderKind provider, QString name)
: m_transport(transport), m_name(std::move(name))
{
if (provider == ProviderKind::Qt) {
m_qt = std::make_unique<QtProvider>();
m_proxy = std::make_unique<ModuleProxy>(m_qt.get());
} else {
m_universal = std::make_unique<UniversalProvider>();
m_proxy = std::make_unique<ModuleProxy>(m_universal.get());
}
m_proxy->saveToken(QStringLiteral("caller"), QStringLiteral("tok"));
if (m_transport == TransportKind::Plain) {
LogosTransportConfig cfg;
cfg.protocol = LogosProtocol::Tcp;
cfg.host = "127.0.0.1";
cfg.port = 0; // ephemeral
m_plainHost = std::make_unique<logos::plain::PlainTransportHost>(cfg);
m_plainStarted = m_plainHost->start();
const QString endpoint = m_plainHost->endpoint();
m_port = endpoint.mid(endpoint.lastIndexOf(':') + 1).toUShort();
}
}
~Module() { takeDown(); }
bool hostReady() const {
return m_transport != TransportKind::Plain || (m_plainStarted && m_port != 0);
}
void bringUp()
{
if (m_up) return;
switch (m_transport) {
case TransportKind::QtRemote:
m_remoteHost = std::make_unique<RemoteTransportHost>(LogosInstance::id(m_name));
m_remoteHost->publishObject(m_name, m_proxy.get());
break;
case TransportKind::QtLocal:
m_localHost = std::make_unique<LocalTransportHost>();
m_localHost->publishObject(m_name, m_proxy.get());
break;
case TransportKind::Plain:
m_plainHost->publishObject(m_name, m_proxy.get());
break;
}
m_up = true;
}
void takeDown()
{
if (!m_up) return;
switch (m_transport) {
case TransportKind::QtRemote: m_remoteHost.reset(); break;
case TransportKind::QtLocal: m_localHost->unpublishObject(m_name); m_localHost.reset(); break;
case TransportKind::Plain: m_plainHost->unpublishObject(m_name); break;
}
m_up = false;
}
// True once the provider has been handed its event listener — i.e. the
// module can actually emit. Firing before this point emits into nothing,
// which would make a test measure its own setup.
bool canEmit() const
{
return m_qt ? static_cast<bool>(m_qt->emitFn)
: static_cast<bool>(m_universal->stdEmitFn);
}
// Emit `ev` carrying a single integer. The two providers reach the wire by
// different routes on purpose (see the class comments above).
void emitEvent(const QString& ev, int payload)
{
if (!canEmit()) return;
if (m_qt) m_qt->emitFn(ev, QVariantList{ payload });
else m_universal->stdEmitFn(ev.toStdString(), "[" + std::to_string(payload) + "]");
}
// The transport config a consumer needs to reach this module. Empty (the
// process default) for everything but plain, whose port is ephemeral.
LogosTransportConfig clientConfig() const
{
LogosTransportConfig cfg;
if (m_transport == TransportKind::Plain) {
cfg.protocol = LogosProtocol::Tcp;
cfg.host = "127.0.0.1";
cfg.port = m_port;
}
return cfg;
}
// The same thing in the JSON form lp_client_create takes; nullptr means
// "process default", which is right for everything but plain.
std::string lpEndpoint() const
{
if (m_transport != TransportKind::Plain) return std::string();
return "{\"protocol\":\"tcp\",\"host\":\"127.0.0.1\",\"port\":"
+ std::to_string(m_port) + "}";
}
const QString& name() const { return m_name; }
private:
TransportKind m_transport;
QString m_name;
bool m_up = false;
std::unique_ptr<QtProvider> m_qt;
std::unique_ptr<UniversalProvider> m_universal;
std::unique_ptr<ModuleProxy> m_proxy;
std::unique_ptr<RemoteTransportHost> m_remoteHost;
std::unique_ptr<LocalTransportHost> m_localHost;
std::unique_ptr<logos::plain::PlainTransportHost> m_plainHost;
bool m_plainStarted = false;
uint16_t m_port = 0;
};
// Re-fire on a cadence rather than once. NO transport here buffers events, so a
// single shot would turn every test into a race on whether the emit happened to
// land after the subscription armed — measuring the harness, not the protocol.
// Returns true as soon as the counter moves.
bool fireUntilDelivered(Module& mod, const QString& ev, int payload,
std::atomic<int>& counter, int budgetMs)
{
QElapsedTimer t; t.start();
while (counter.load() == 0 && t.elapsed() < budgetMs) {
mod.emitEvent(ev, payload);
pump(50);
}
return counter.load() > 0;
}
bool pumpUntil(const std::function<bool()>& done, int budgetMs)
{
QElapsedTimer t; t.start();
while (!done() && t.elapsed() < budgetMs) pump(20);
return done();
}
std::unique_ptr<LogosAPIClient> makeClient(const Module& mod)
{
TokenManager::instance().saveToken(mod.name(), QStringLiteral("tok"));
const LogosTransportConfig cfg = mod.clientConfig();
return std::make_unique<LogosAPIClient>(mod.name(), QStringLiteral("caller"),
&TokenManager::instance(), cfg, cfg);
}
lp_client* makeLpClient(const Module& mod)
{
const std::string ep = mod.lpEndpoint();
const char* epArg = ep.empty() ? nullptr : ep.c_str();
return lp_client_create(mod.name().toUtf8().constData(), "caller", epArg, epArg);
}
std::string lpPending(lp_client* client)
{
char* json = lp_pending_subscriptions(client);
std::string out = json ? json : "<null>";
lp_string_free(json);
return out;
}
} // namespace
// ─────────────────────────────────────────────────────────────────────────────
struct MatrixCase {
TransportKind transport;
ProviderKind provider;
};
class EventDeliveryMatrix : public ::testing::TestWithParam<MatrixCase> {
protected:
void SetUp() override
{
ensureApp();
// The mode is process-global and is read when the consumer is built, so
// it has to be set before any client in the case is constructed.
LogosModeConfig::setMode(GetParam().transport == TransportKind::QtLocal
? LogosMode::Local
: LogosMode::Remote);
}
void TearDown() override { LogosModeConfig::setMode(LogosMode::Remote); }
// Distinct per case AND per test: LogosInstance::id and PluginRegistry are
// process-global, so a shared name lets one case attach to another case's
// still-live host and pass for the wrong reason.
QString moduleName(const char* stem) const
{
return QStringLiteral("m_%1_%2_%3")
.arg(QLatin1String(nameOf(GetParam().transport)))
.arg(GetParam().provider == ProviderKind::Qt ? "qt" : "uni")
.arg(QLatin1String(stem));
}
Module makeModule(const char* stem) const
{
return Module(GetParam().transport, GetParam().provider, moduleName(stem));
}
};
// ── CONTROL: the module is already up when the subscription is made ──────────
//
// Green before and after every fix in this area. If this is ever red, the
// fixture is broken and no other verdict in the file means anything.
TEST_P(EventDeliveryMatrix, PublishThenSubscribe_Delivers)
{
Module mod = makeModule("ctl");
ASSERT_TRUE(mod.hostReady());
mod.bringUp();
auto client = makeClient(mod);
std::atomic<int> got{0};
QVariantList payload;
const quint64 id = client->onEventWhenAvailable(mod.name(), QStringLiteral("ev"),
[&](const QString&, const QVariantList& d) { payload = d; got.fetch_add(1); });
ASSERT_NE(id, 0u);
// Pinned BEFORE any pumping: on the transports with no deferred acquire the
// subscription must be live the moment the call returns, or the events the
// module emits before the first retry tick are silently lost.
if (armsSynchronouslyWhenPresent(GetParam().transport)) {
EXPECT_EQ(client->eventSubscriptionState(id), LogosSubscriptionState::Armed)
<< "an already-present module was deferred to the retry tick -- every "
"event emitted in that window is dropped";
EXPECT_TRUE(client->pendingEventSubscriptions().isEmpty());
}
ASSERT_TRUE(pumpUntil([&] { return mod.canEmit(); }, 5000)) << "provider never got its listener";
ASSERT_TRUE(fireUntilDelivered(mod, QStringLiteral("ev"), 42, got, 10000));
ASSERT_EQ(payload.size(), 1);
EXPECT_EQ(payload[0].toInt(), 42);
}
// ── THE DEFECT: subscribe first, the module shows up second ──────────────────
//
// A UI plugin's Component.onCompleted, a module's init(), a backend's
// onContextReady() all run here. The pre-fix code answered "not connected" and
// never asked again.
TEST_P(EventDeliveryMatrix, SubscribeThenPublish_Delivers)
{
Module mod = makeModule("late");
ASSERT_TRUE(mod.hostReady());
// NOT brought up yet.
auto client = makeClient(mod);
std::atomic<int> got{0};
const quint64 id = client->onEventWhenAvailable(mod.name(), QStringLiteral("ev"),
[&](const QString&, const QVariantList&) { got.fetch_add(1); });
ASSERT_NE(id, 0u);
// Registered, and it SAYS so rather than vanishing. That durable record is
// half the fix: the old failure was indistinguishable from success.
EXPECT_NE(client->eventSubscriptionState(id), LogosSubscriptionState::Unknown);
pump(300);
mod.bringUp();
ASSERT_TRUE(pumpUntil([&] { return mod.canEmit(); }, 5000));
ASSERT_TRUE(fireUntilDelivered(mod, QStringLiteral("ev"), 7, got, 10000))
<< "a subscription made before the module appeared never armed";
EXPECT_EQ(client->eventSubscriptionState(id), LogosSubscriptionState::Armed);
EXPECT_TRUE(client->pendingEventSubscriptions().isEmpty()) << "armed but still reported pending";
}
// ── The C ABI, same two cases ────────────────────────────────────────────────
//
// lp_subscribe is how every non-Qt module and every ui_qml backend inside
// ui-host reaches events. A fix that only covered the Qt consumer would leave
// all of them broken, which is exactly what happened the first time.
TEST_P(EventDeliveryMatrix, LpSubscribe_PublishThenSubscribe_Control)
{
Module mod = makeModule("lpctl");
ASSERT_TRUE(mod.hostReady());
mod.bringUp();
lp_client* client = makeLpClient(mod);
ASSERT_NE(client, nullptr);
std::atomic<int> got{0};
lp_subscription* sub = lp_subscribe(client, "ev",
[](const char*, const char*, void* ud) { static_cast<std::atomic<int>*>(ud)->fetch_add(1); },
&got);
ASSERT_NE(sub, nullptr);
ASSERT_TRUE(pumpUntil([&] { return mod.canEmit(); }, 5000));
EXPECT_TRUE(fireUntilDelivered(mod, QStringLiteral("ev"), 1, got, 10000));
lp_unsubscribe(sub);
lp_client_destroy(client);
}
TEST_P(EventDeliveryMatrix, LpSubscribe_SubscribeThenPublish_Delivers)
{
Module mod = makeModule("lplate");
ASSERT_TRUE(mod.hostReady());
lp_client* client = makeLpClient(mod);
ASSERT_NE(client, nullptr);
std::atomic<int> got{0};
lp_subscription* sub = lp_subscribe(client, "ev",
[](const char*, const char*, void* ud) { static_cast<std::atomic<int>*>(ud)->fetch_add(1); },
&got);
ASSERT_NE(sub, nullptr) << "lp_subscribe refused a module that is merely not up yet";
pump(300);
mod.bringUp();
ASSERT_TRUE(pumpUntil([&] { return mod.canEmit(); }, 5000));
EXPECT_TRUE(fireUntilDelivered(mod, QStringLiteral("ev"), 1, got, 10000))
<< "lp_subscribe subscription never armed";
lp_unsubscribe(sub);
lp_client_destroy(client);
}
// ── The module goes away and comes back ──────────────────────────────────────
//
// The package manager's core flow, and the shape that used to silently kill an
// armed subscription: the handle for the object goes invalid, and replacing it
// released the event helper every armed subscription was attached to.
TEST_P(EventDeliveryMatrix, SubscriptionSurvivesUnloadAndReload)
{
Module mod = makeModule("reload");
ASSERT_TRUE(mod.hostReady());
mod.bringUp();
auto client = makeClient(mod);
std::atomic<int> got{0};
const quint64 id = client->onEventWhenAvailable(mod.name(), QStringLiteral("ev"),
[&](const QString&, const QVariantList&) { got.fetch_add(1); });
ASSERT_NE(id, 0u);
ASSERT_TRUE(pumpUntil([&] { return mod.canEmit(); }, 5000));
ASSERT_TRUE(fireUntilDelivered(mod, QStringLiteral("ev"), 1, got, 10000)) << "control leg never delivered";
mod.takeDown();
pump(300);
mod.bringUp();
std::atomic<int> after{0};
// Re-subscribing is NOT part of the contract under test — the point is that
// the ORIGINAL subscription still fires.
ASSERT_TRUE(pumpUntil([&] { return mod.canEmit(); }, 5000));
QElapsedTimer t; t.start();
while (after.load() == 0 && t.elapsed() < 10000) {
const int before = got.load();
mod.emitEvent(QStringLiteral("ev"), 2);
pump(50);
if (got.load() > before) after.fetch_add(1);
}
EXPECT_GT(after.load(), 0)
<< "the subscription was silently killed by the module reload -- it still "
"reports healthy, which is the failure shape this whole area exists to remove";
EXPECT_NE(client->eventSubscriptionState(id), LogosSubscriptionState::Unknown);
}
// ── Cancellation actually un-registers ───────────────────────────────────────
//
// An unsubscribed-while-pending subscription used to stay in the registry
// forever: holding the retry timer up, emitting the 3 s / 60 s watchdog
// warnings about a subscription nobody wanted, and showing in the very
// diagnostics this design leans on to be credible.
TEST_P(EventDeliveryMatrix, CancelWhilePending_LeavesTheRegistry)
{
Module mod = makeModule("cancel");
ASSERT_TRUE(mod.hostReady());
// Never brought up: the subscription stays pending.
auto client = makeClient(mod);
std::atomic<int> got{0};
const quint64 id = client->onEventWhenAvailable(mod.name(), QStringLiteral("ev"),
[&](const QString&, const QVariantList&) { got.fetch_add(1); });
ASSERT_NE(id, 0u);
// Control: it has to be TRACKED for un-tracking it to mean anything. Which
// of the two live states it is in is a property of the transport, pinned
// here so a change to it is a test failure rather than a surprise.
const LogosSubscriptionState expected = defersWhenModuleAbsent(GetParam().transport)
? LogosSubscriptionState::Pending
: LogosSubscriptionState::Armed;
ASSERT_EQ(client->eventSubscriptionState(id), expected);
ASSERT_EQ(client->pendingEventSubscriptions().isEmpty(),
expected == LogosSubscriptionState::Armed);
EXPECT_TRUE(client->cancelEventSubscription(id));
EXPECT_EQ(client->eventSubscriptionState(id), LogosSubscriptionState::Unknown);
EXPECT_TRUE(client->pendingEventSubscriptions().isEmpty());
EXPECT_FALSE(client->cancelEventSubscription(id)) << "cancelling twice should report 'not known'";
}
// lp_unsubscribe has to reach that same cancellation, or the C ABI leaks a
// registry entry per subscription for the life of the process.
TEST_P(EventDeliveryMatrix, LpUnsubscribeWhilePending_LeavesTheRegistry)
{
Module mod = makeModule("lpcancel");
ASSERT_TRUE(mod.hostReady());
lp_client* client = makeLpClient(mod);
ASSERT_NE(client, nullptr);
std::atomic<int> got{0};
lp_subscription* sub = lp_subscribe(client, "ev",
[](const char*, const char*, void* ud) { static_cast<std::atomic<int>*>(ud)->fetch_add(1); },
&got);
ASSERT_NE(sub, nullptr);
// Control: on the transports that defer, the C ABI must be able to SEE the
// pending subscription. That visibility is the point — it did not exist
// before, which is why a subscription that silently never armed was
// undetectable from Rust, Nim or a universal C++ module.
const std::string expectedPending =
defersWhenModuleAbsent(GetParam().transport)
? "[\"" + mod.name().toStdString() + "::ev\"]"
: "[]";
EXPECT_EQ(lpPending(client), expectedPending);
lp_unsubscribe(sub);
// Un-registration is EVENTUAL by design — it is posted to the owner thread
// rather than done under a lock that thread's delivery callback also takes,
// which would deadlock. So pump; what must hold is that it drains, not that
// it drained by the time lp_unsubscribe returned.
EXPECT_TRUE(pumpUntil([&] { return lpPending(client) == "[]"; }, 5000))
<< "lp_unsubscribe left the entry in the registry: it keeps the retry timer "
"alive and keeps warning about a subscription nobody wants. Still pending: "
<< lpPending(client);
// The ABI's own promise, and the half that IS observable on every transport
// including the ones that armed immediately: bring the module up, fire
// repeatedly, and the cancelled callback must stay silent. Without this the
// plain leg above asserts "[] before, [] after" and proves nothing.
mod.bringUp();
ASSERT_TRUE(pumpUntil([&] { return mod.canEmit(); }, 5000));
for (int i = 0; i < 20; ++i) { mod.emitEvent(QStringLiteral("ev"), 9); pump(25); }
EXPECT_EQ(got.load(), 0) << "the callback fired after lp_unsubscribe returned";
lp_client_destroy(client);
}
// ── A reconnect must re-arm, or say it could not ─────────────────────────────
//
// reconnect() tears the connection down and rebuilds it, so every handle a
// subscription holds is dead. Re-arming is one half; the other is that the
// retry machinery is running again afterwards — leaving it stopped is a
// subscription that is both dead AND silent, which is strictly worse than the
// bug it replaced.
TEST_P(EventDeliveryMatrix, ReconnectReArmsAnArmedSubscription)
{
Module mod = makeModule("recon");
ASSERT_TRUE(mod.hostReady());
mod.bringUp();
auto client = makeClient(mod);
std::atomic<int> got{0};
const quint64 id = client->onEventWhenAvailable(mod.name(), QStringLiteral("ev"),
[&](const QString&, const QVariantList&) { got.fetch_add(1); });
ASSERT_NE(id, 0u);
ASSERT_TRUE(pumpUntil([&] { return mod.canEmit(); }, 5000));
ASSERT_TRUE(fireUntilDelivered(mod, QStringLiteral("ev"), 1, got, 10000))
<< "control leg never delivered -- nothing after this means anything";
ASSERT_TRUE(client->reconnect());
std::atomic<int> after{0};
QElapsedTimer t; t.start();
while (after.load() == 0 && t.elapsed() < 10000) {
const int before = got.load();
mod.emitEvent(QStringLiteral("ev"), 3);
pump(50);
if (got.load() > before) after.fetch_add(1);
}
EXPECT_GT(after.load(), 0)
<< "reconnect() left the subscription permanently dead. It is also SILENT: "
"the watchdog only runs from the retry tick, so nothing ever says so.";
}
// The case the reconnect path actually gets wrong: reconnecting while the
// module is DOWN. Re-arming immediately is easy — the object is still there. If
// it is not, the subscription goes back into the pending set, and unless the
// retry machinery is running again it sits there forever: no retry, and no
// watchdog either, because the watchdog only speaks from the retry tick. That
// is a subscription which is dead AND silent, strictly worse than the "not
// connected" warning it replaced.
TEST_P(EventDeliveryMatrix, ReconnectWhileModuleIsDown_StillReArmsWhenItReturns)
{
Module mod = makeModule("recondown");
ASSERT_TRUE(mod.hostReady());
mod.bringUp();
auto client = makeClient(mod);
std::atomic<int> got{0};
const quint64 id = client->onEventWhenAvailable(mod.name(), QStringLiteral("ev"),
[&](const QString&, const QVariantList&) { got.fetch_add(1); });
ASSERT_NE(id, 0u);
ASSERT_TRUE(pumpUntil([&] { return mod.canEmit(); }, 5000));
ASSERT_TRUE(fireUntilDelivered(mod, QStringLiteral("ev"), 1, got, 10000))
<< "control leg never delivered -- nothing after this means anything";
mod.takeDown();
pump(200);
ASSERT_TRUE(client->reconnect());
pump(200);
mod.bringUp();
std::atomic<int> after{0};
ASSERT_TRUE(pumpUntil([&] { return mod.canEmit(); }, 5000));
QElapsedTimer t; t.start();
while (after.load() == 0 && t.elapsed() < 10000) {
const int before = got.load();
mod.emitEvent(QStringLiteral("ev"), 4);
pump(50);
if (got.load() > before) after.fetch_add(1);
}
EXPECT_GT(after.load(), 0)
<< "a subscription that was pending across a reconnect never armed again";
}
// ── the call-then-subscribe shape ────────────────────────────────────────────
//
// The most common real consumer: talk to a module, then subscribe to it 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 immediately
// after was delivered. Deferring to the next event-loop turn silently drops it
// -- the same event loss this area exists to remove, in a narrower window. The
// event is fired ONCE, synchronously, with no pumping in between, because
// re-firing would hide exactly the gap under test.
TEST_P(EventDeliveryMatrix, SubscribeRightAfterACall_DeliversImmediately)
{
Module mod = makeModule("aftercall");
ASSERT_TRUE(mod.hostReady());
mod.bringUp();
auto client = makeClient(mod);
// A real call first: that is what leaves the consumer already talking to
// the module, which is the state the regression needs (on qt_remote it is
// what drives the node's replica for this object to Valid).
//
// ASYNC deliberately. A synchronous call would deadlock on the plain
// transport in this harness: PlainTransportHost::onCall dispatches to the
// ModuleProxy's thread, which here is the calling thread, so the call would
// sit until its timeout waiting for an event loop it is itself blocking.
// That is a property of the fixture, not of the product — the plain suite's
// own LiveHost puts the proxy on a worker thread for the same reason.
std::atomic<int> called{0};
client->invokeRemoteMethodAsync(mod.name(), QStringLiteral("echo"), QVariantList() << 5,
LogosAPIClient::AsyncResultCallback([&](QVariant) { called.fetch_add(1); }));
ASSERT_TRUE(pumpUntil([&] { return called.load() > 0; }, 15000))
<< "control: the preceding call never completed, so the consumer was never "
"'already talking to the module' and the case below tests nothing";
ASSERT_TRUE(pumpUntil([&] { return mod.canEmit(); }, 10000))
<< "control: the provider never came up, so nothing below means anything";
std::atomic<int> got{0};
const quint64 id = client->onEventWhenAvailable(mod.name(), QStringLiteral("ev"),
[&](const QString&, const QVariantList&) { got.fetch_add(1); });
ASSERT_NE(id, 0u);
if (subscriptionIsLocallyRegistered(GetParam().transport)) {
// Fire ONCE, right now, without returning to the event loop first.
// Re-firing would hide exactly the gap under test.
mod.emitEvent(QStringLiteral("ev"), 1);
pump(500);
EXPECT_GE(got.load(), 1)
<< "an event emitted immediately after subscribing to an ALREADY-REACHABLE "
"module was dropped: the subscription had not armed yet. A consumer that "
"calls a module and then subscribes is the common shape, and it used to "
"arm synchronously.";
} else {
// plain: the subscribe frame has to reach the host first, so instant
// delivery was never on offer. What must hold is that it arms shortly
// and delivers -- i.e. the deferral did not break it.
EXPECT_TRUE(fireUntilDelivered(mod, QStringLiteral("ev"), 1, got, 10000))
<< "subscribing right after a call never armed at all on this transport";
}
}
INSTANTIATE_TEST_SUITE_P(
AllTransportsAndProviders, EventDeliveryMatrix,
::testing::Values(
MatrixCase{TransportKind::QtRemote, ProviderKind::Qt},
MatrixCase{TransportKind::QtRemote, ProviderKind::Universal},
MatrixCase{TransportKind::QtLocal, ProviderKind::Qt},
MatrixCase{TransportKind::QtLocal, ProviderKind::Universal},
MatrixCase{TransportKind::Plain, ProviderKind::Qt},
MatrixCase{TransportKind::Plain, ProviderKind::Universal}),
[](const ::testing::TestParamInfo<MatrixCase>& i) {
return std::string(nameOf(i.param.transport)) + "_" + nameOf(i.param.provider);
});
// ── mock: the one transport that cannot deliver ──────────────────────────────
//
// MockLogosObject::onEvent is a no-op by design, so no delivery test belongs in
// the matrix above. What mock DOES have to do is arm promptly and stop
// reporting itself pending — its requestObject() always succeeds, so a
// subscription that stayed deferred there would mean the registry had stopped
// making its one synchronous attempt, which is the regression that silently
// dropped every event emitted in the first 250 ms on qt_local and plain too.
TEST(EventDeliveryMock, SubscriptionArmsImmediatelyAndDoesNotLinger)
{
ensureApp();
LogosModeConfig::setMode(LogosMode::Mock);
TokenManager::instance().saveToken(QStringLiteral("mock_ev_module"), QStringLiteral("tok"));
auto client = std::make_unique<LogosAPIClient>(QStringLiteral("mock_ev_module"),
QStringLiteral("caller"),
&TokenManager::instance());
const quint64 id = client->onEventWhenAvailable(
QStringLiteral("mock_ev_module"), QStringLiteral("ev"),
[](const QString&, const QVariantList&) {});
EXPECT_NE(id, 0u);
EXPECT_EQ(client->eventSubscriptionState(id), LogosSubscriptionState::Armed)
<< "mock's requestObject() always succeeds, so this must arm without waiting "
"for a retry tick";
EXPECT_TRUE(client->pendingEventSubscriptions().isEmpty());
LogosModeConfig::setMode(LogosMode::Remote);
}
// ── the non-blocking guarantee ───────────────────────────────────────────────
//
// Structural, not budgeted: nothing on this path may block, because it is
// called from the GUI thread during startup. The way this fix decays is
// somebody "just retrying requestObject()" from the timer, which on qt_remote
// means a 250 ms socket probe plus waitForSource()'s nested event loop.
//
// WHAT THE acquireCount ASSERTION DOES AND DOES NOT PROVE. It catches a retry
// that polls qt_remote's blocking requestObject() in the ordinary case, which
// is the likely regression. It cannot catch the narrow one: the poll is only
// reachable at all when the transport DECLINES a deferred acquire while still
// reporting connected, which happens only if acquireDynamic() returns null —
// not forcible from outside the transport. That case is held shut by
// construction instead: beginAcquire() calls requestObject() only when the
// transport reports it has no deferred acquire, and RemoteTransportConnection
// implements that interface, so it can never give that answer. The invariant
// lives in the control flow, not in this test.
TEST(EventDeliveryNonBlocking, SubscribingToAnAbsentModuleReturnsImmediately)
{
ensureApp();
LogosModeConfig::setMode(LogosMode::Remote);
TokenManager::instance().saveToken(QStringLiteral("never_module"), QStringLiteral("tok"));
auto client = std::make_unique<LogosAPIClient>(QStringLiteral("never_module"),
QStringLiteral("caller"),
&TokenManager::instance());
QElapsedTimer t; t.start();
const quint64 id = client->onEventWhenAvailable(
QStringLiteral("never_module"), QStringLiteral("ev"),
[](const QString&, const QVariantList&) {});
const qint64 subscribeMs = t.elapsed();
EXPECT_NE(id, 0u);
EXPECT_LT(subscribeMs, 250) << "onEventWhenAvailable blocked for " << subscribeMs << " ms";
// And it stays non-blocking while pending: the retry must never reach
// qt_remote's requestObject(). Measured by keeping the loop responsive.
RemoteTransportConnection::resetAcquireCount();
int beats = 0;
QElapsedTimer beat; beat.start();
qint64 worstGapMs = 0, last = 0;
while (beat.elapsed() < 3000) {
pump(20);
++beats;
worstGapMs = qMax(worstGapMs, beat.elapsed() - last);
last = beat.elapsed();
}
// Deliberately a floor, not a rate. This counts pump(20) iterations inside a
// fixed wall-clock window, so on a loaded machine it measures the machine.
// The assertion that means something is the GAP below.
EXPECT_GT(beats, 5) << "the event loop did not run at all while a subscription was pending";
EXPECT_LT(worstGapMs, 400) << "worst event-loop gap " << worstGapMs
<< " ms -- something on the retry path is blocking";
EXPECT_EQ(RemoteTransportConnection::acquireCount(), 0)
<< "the retry called qt_remote's BLOCKING requestObject(); on that transport "
"the deferred acquire is the only legal path";
}