mirror of
https://github.com/logos-co/logos-protocol.git
synced 2026-08-30 13:31:12 +00:00
An EMPTY event name means "every event on this object". LogosObject::onEvent
has always honoured it -- RemoteEventHelper appends the callbacks registered
under QString() to every dispatch, and PlainEventSubSharingTest.
ANamedAndAWildcardSubscriberEachGetOneCopy already pins that on the plain
transport. onEventWhenAvailable refused it.
There was no reason for the refusal, and I looked for one before removing it:
* the guard is a single `objectName.isEmpty() || eventName.isEmpty() ||
!callback` line from the original commit (#47), whose message never
mentions wildcards;
* nothing anywhere asserted the refusal;
* the registry already carries empty event names -- whenObjectAvailable()
adds its readiness entries with exactly that, so add(), takeMatching(),
pending() and reviveArmed() have always handled them;
* the arm path is `handle->onEvent(e.eventName, e.callback)`, which passes
the name straight through, so the wildcard needs no code of its own.
It was a category error: an empty objectName and a null callback are unusable,
while an empty eventName is meaningful. Lumping the three together silently
denied the deferred path to every hand-rolled wildcard subscriber, leaving them
on exactly the one-shot requestObject() + onEvent() this class exists to
replace. logoscore's `watch <module>` with no --event is one such caller, and
had to route around it through whenObjectAvailable().
pendingEventSubscriptions() now renders a wildcard as `<module>::(any)` rather
than a truncated `<module>::`.
Three tests, in the style of the file: subscribe-before-publish (firing two
DIFFERENT event names, because one would pass for a subscription that merely
matched the empty string against nothing), its publish-first control, and the
refusals that REMAIN -- pinned so widening the guard cannot quietly widen it
further, including that a refusal still ANSWERS via onArmed(false) rather than
going quiet.
Negative control: with the tests present and the guard restored, both wildcard
tests fail and the refusal test still passes. With the change, the full suite
is green (540 tests).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
477 lines
20 KiB
C++
477 lines
20 KiB
C++
// Deferred acquisition + deferred event subscription: the layer that makes a
|
|
// subscription survive a module that is not reachable YET.
|
|
//
|
|
// The defect these pin: 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 which run while the dependency's host
|
|
// process has been spawned but has not called listen(). The old code returned
|
|
// nullptr/false there and never asked again: method calls kept working (they
|
|
// reach the replica by a path that never asks), events silently never arrived.
|
|
//
|
|
// Three properties are pinned here:
|
|
// 1. the acquire is NON-BLOCKING (it must not sit in waitForSource),
|
|
// 2. it arms when the module shows up afterwards,
|
|
// 3. lp_subscribe -- the C ABI every C++/Nim/Rust module and UI backend
|
|
// reaches events through -- goes through the same path.
|
|
//
|
|
// Every case has a published-first control, because a red test with no control
|
|
// cannot distinguish "the defect" from "the fixture is mis-wired".
|
|
|
|
#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 "module_proxy.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();
|
|
}
|
|
|
|
class EchoProvider : public LogosProviderObject {
|
|
public:
|
|
EventCallback emitFn;
|
|
QVariant callMethod(const QString& method, const QVariantList& args) override {
|
|
if (method == QLatin1String("echo") && !args.isEmpty()) return args.first();
|
|
return 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("echo_module"); }
|
|
QString providerVersion() const override { return QStringLiteral("1.0.0"); }
|
|
};
|
|
|
|
// The provider half, brought up on demand so a test controls WHEN the module
|
|
// starts listening.
|
|
struct Publisher {
|
|
EchoProvider echo;
|
|
ModuleProxy proxy;
|
|
RemoteTransportHost host;
|
|
explicit Publisher(const QString& moduleName)
|
|
: proxy(&echo), host(LogosInstance::id(moduleName))
|
|
{
|
|
proxy.saveToken(QStringLiteral("caller"), QStringLiteral("tok"));
|
|
host.publishObject(moduleName, &proxy);
|
|
}
|
|
};
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
} // anonymous namespace
|
|
|
|
class DeferredSubscriptionTest : public ::testing::Test {
|
|
protected:
|
|
void SetUp() override { ensureApp(); LogosModeConfig::setMode(LogosMode::Remote); }
|
|
};
|
|
|
|
// ── transport layer ──────────────────────────────────────────────────────────
|
|
|
|
// The acquire itself must return promptly against a module that is NOT there.
|
|
// The pre-existing requestObject() sits in QRemoteObjectReplica::waitForSource()
|
|
// for the full timeout here; this path must not.
|
|
TEST_F(DeferredSubscriptionTest, RequestObjectWhenAvailable_AbsentModule_DoesNotBlock)
|
|
{
|
|
const QString url = LogosInstance::id("async_absent_module");
|
|
RemoteTransportConnection conn(url);
|
|
ASSERT_TRUE(conn.connectToHost());
|
|
|
|
std::atomic<int> delivered{0};
|
|
QElapsedTimer t; t.start();
|
|
const bool accepted = conn.requestObjectWhenAvailable(
|
|
"async_absent_module", [&](LogosObject* obj) { if (obj) obj->release(); delivered.fetch_add(1); });
|
|
const qint64 elapsed = t.elapsed();
|
|
|
|
EXPECT_TRUE(accepted);
|
|
EXPECT_LT(elapsed, 250) << "requestObjectWhenAvailable blocked for " << elapsed << " ms";
|
|
// Contract: never delivered synchronously.
|
|
EXPECT_EQ(delivered.load(), 0);
|
|
}
|
|
|
|
// It arms when the module appears afterwards.
|
|
TEST_F(DeferredSubscriptionTest, RequestObjectWhenAvailable_ArmsAfterPublish)
|
|
{
|
|
const QString mod = QStringLiteral("async_late_module");
|
|
RemoteTransportConnection conn(LogosInstance::id(mod));
|
|
ASSERT_TRUE(conn.connectToHost());
|
|
|
|
LogosObject* got = nullptr;
|
|
ASSERT_TRUE(conn.requestObjectWhenAvailable(mod, [&](LogosObject* obj) { got = obj; }));
|
|
|
|
pump(300);
|
|
ASSERT_EQ(got, nullptr) << "delivered a handle for a module that was never published";
|
|
|
|
Publisher pub(mod);
|
|
for (int i = 0; i < 100 && !got; ++i) pump(50);
|
|
|
|
ASSERT_NE(got, nullptr) << "handle never arrived after the module was published";
|
|
got->release();
|
|
}
|
|
|
|
// Control for the two above: when the module is already up, the same call still
|
|
// defers delivery to the event loop (never synchronous) and still delivers.
|
|
TEST_F(DeferredSubscriptionTest, RequestObjectWhenAvailable_AlreadyPublished_Control)
|
|
{
|
|
const QString mod = QStringLiteral("async_ready_module");
|
|
Publisher pub(mod);
|
|
|
|
RemoteTransportConnection conn(LogosInstance::id(mod));
|
|
ASSERT_TRUE(conn.connectToHost());
|
|
|
|
LogosObject* got = nullptr;
|
|
ASSERT_TRUE(conn.requestObjectWhenAvailable(mod, [&](LogosObject* obj) { got = obj; }));
|
|
EXPECT_EQ(got, nullptr) << "delivered synchronously -- re-entrancy hazard on the QtRO read stack";
|
|
|
|
for (int i = 0; i < 100 && !got; ++i) pump(50);
|
|
ASSERT_NE(got, nullptr);
|
|
got->release();
|
|
}
|
|
|
|
// ── consumer layer ───────────────────────────────────────────────────────────
|
|
|
|
// The whole point: subscribe first, load the module second, still get events.
|
|
TEST_F(DeferredSubscriptionTest, OnEventWhenAvailable_SubscribeBeforePublish_Delivers)
|
|
{
|
|
const QString mod = QStringLiteral("sub_late_module");
|
|
TokenManager::instance().saveToken(mod, QStringLiteral("tok"));
|
|
auto client = std::make_unique<LogosAPIClient>(mod, QStringLiteral("caller"),
|
|
&TokenManager::instance());
|
|
|
|
std::atomic<int> received{0};
|
|
QVariantList last;
|
|
client->onEventWhenAvailable(mod, QStringLiteral("ev0"),
|
|
[&](const QString&, const QVariantList& d) { last = d; received.fetch_add(1); });
|
|
|
|
// Registered, not armed -- and it SAYS so rather than vanishing.
|
|
EXPECT_FALSE(client->pendingEventSubscriptions().isEmpty());
|
|
|
|
pump(300);
|
|
Publisher pub(mod);
|
|
|
|
// Events are not buffered by QtRO, so re-fire while waiting for the arm.
|
|
for (int i = 0; i < 200 && received.load() == 0; ++i) {
|
|
if (pub.echo.emitFn) pub.echo.emitFn(QStringLiteral("ev0"), QVariantList{ 42 });
|
|
pump(50);
|
|
}
|
|
|
|
ASSERT_GE(received.load(), 1) << "deferred subscription never armed";
|
|
ASSERT_EQ(last.size(), 1);
|
|
EXPECT_EQ(last[0].toInt(), 42);
|
|
EXPECT_TRUE(client->pendingEventSubscriptions().isEmpty()) << "armed but still reported pending";
|
|
}
|
|
|
|
// Control: published first. Must be green regardless of the fix.
|
|
TEST_F(DeferredSubscriptionTest, OnEventWhenAvailable_PublishBeforeSubscribe_Control)
|
|
{
|
|
const QString mod = QStringLiteral("sub_ready_module");
|
|
Publisher pub(mod);
|
|
|
|
TokenManager::instance().saveToken(mod, QStringLiteral("tok"));
|
|
auto client = std::make_unique<LogosAPIClient>(mod, QStringLiteral("caller"),
|
|
&TokenManager::instance());
|
|
|
|
std::atomic<int> received{0};
|
|
client->onEventWhenAvailable(mod, QStringLiteral("ev0"),
|
|
[&](const QString&, const QVariantList&) { received.fetch_add(1); });
|
|
|
|
for (int i = 0; i < 200 && received.load() == 0; ++i) {
|
|
if (pub.echo.emitFn) pub.echo.emitFn(QStringLiteral("ev0"), QVariantList{ 7 });
|
|
pump(50);
|
|
}
|
|
EXPECT_GE(received.load(), 1);
|
|
}
|
|
|
|
// An EMPTY event name is the WILDCARD -- every event on the object -- and it
|
|
// has to reach the deferred path like any other subscription.
|
|
//
|
|
// It used to be refused by the same guard that rejects an empty object name
|
|
// and a null callback, which is a category error: those two are unusable,
|
|
// while an empty event name is meaningful and the plain onEvent() has always
|
|
// honoured it. The refusal silently denied the deferred path to every
|
|
// hand-rolled wildcard subscriber -- logoscore's `watch <module>` with no
|
|
// --event among them -- leaving exactly the one-shot subscription this class
|
|
// exists to replace.
|
|
//
|
|
// Two DIFFERENT event names are fired on purpose. One would pass for a
|
|
// subscription that merely matched the empty string against nothing; catching
|
|
// both is what distinguishes a wildcard from an accident.
|
|
TEST_F(DeferredSubscriptionTest, OnEventWhenAvailable_Wildcard_SubscribeBeforePublish_Delivers)
|
|
{
|
|
const QString mod = QStringLiteral("sub_wildcard_late_module");
|
|
TokenManager::instance().saveToken(mod, QStringLiteral("tok"));
|
|
auto client = std::make_unique<LogosAPIClient>(mod, QStringLiteral("caller"),
|
|
&TokenManager::instance());
|
|
|
|
std::atomic<int> alpha{0};
|
|
std::atomic<int> beta{0};
|
|
const quint64 id = client->onEventWhenAvailable(mod, QString(),
|
|
[&](const QString& name, const QVariantList&) {
|
|
if (name == QLatin1String("alpha")) alpha.fetch_add(1);
|
|
if (name == QLatin1String("beta")) beta.fetch_add(1);
|
|
});
|
|
ASSERT_NE(id, 0u) << "the wildcard was refused outright";
|
|
|
|
// Registered, not armed -- and it names itself as the wildcard rather than
|
|
// rendering a truncated "mod::".
|
|
const QStringList pending = client->pendingEventSubscriptions();
|
|
ASSERT_FALSE(pending.isEmpty());
|
|
EXPECT_EQ(pending.first().toStdString(),
|
|
(mod + QStringLiteral("::(any)")).toStdString())
|
|
<< pending.join(", ").toStdString();
|
|
|
|
pump(300);
|
|
Publisher pub(mod);
|
|
|
|
// Events are not buffered by QtRO, so re-fire while waiting for the arm.
|
|
for (int i = 0; i < 200 && (alpha.load() == 0 || beta.load() == 0); ++i) {
|
|
if (pub.echo.emitFn) {
|
|
pub.echo.emitFn(QStringLiteral("alpha"), QVariantList{ 1 });
|
|
pub.echo.emitFn(QStringLiteral("beta"), QVariantList{ 2 });
|
|
}
|
|
pump(50);
|
|
}
|
|
|
|
EXPECT_GE(alpha.load(), 1) << "wildcard subscription missed 'alpha'";
|
|
EXPECT_GE(beta.load(), 1) << "wildcard subscription missed 'beta'";
|
|
EXPECT_TRUE(client->pendingEventSubscriptions().isEmpty())
|
|
<< "armed but still reported pending";
|
|
}
|
|
|
|
// Control: published first. Must be green regardless of the guard, so a red
|
|
// wildcard test above cannot be blamed on the fixture.
|
|
TEST_F(DeferredSubscriptionTest, OnEventWhenAvailable_Wildcard_PublishBeforeSubscribe_Control)
|
|
{
|
|
const QString mod = QStringLiteral("sub_wildcard_ready_module");
|
|
Publisher pub(mod);
|
|
|
|
TokenManager::instance().saveToken(mod, QStringLiteral("tok"));
|
|
auto client = std::make_unique<LogosAPIClient>(mod, QStringLiteral("caller"),
|
|
&TokenManager::instance());
|
|
|
|
std::atomic<int> seen{0};
|
|
ASSERT_NE(client->onEventWhenAvailable(mod, QString(),
|
|
[&](const QString&, const QVariantList&) { seen.fetch_add(1); }), 0u);
|
|
|
|
for (int i = 0; i < 200 && seen.load() == 0; ++i) {
|
|
if (pub.echo.emitFn) pub.echo.emitFn(QStringLiteral("alpha"), QVariantList{ 1 });
|
|
pump(50);
|
|
}
|
|
EXPECT_GE(seen.load(), 1);
|
|
}
|
|
|
|
// The refusals that REMAIN, pinned so widening the guard to admit the wildcard
|
|
// cannot quietly widen it further. Both arguments are unusable rather than
|
|
// meaningful: there is no object to acquire, and nothing to deliver to.
|
|
TEST_F(DeferredSubscriptionTest, OnEventWhenAvailable_StillRefusesUnusableArguments)
|
|
{
|
|
const QString mod = QStringLiteral("sub_refusal_module");
|
|
TokenManager::instance().saveToken(mod, QStringLiteral("tok"));
|
|
auto client = std::make_unique<LogosAPIClient>(mod, QStringLiteral("caller"),
|
|
&TokenManager::instance());
|
|
|
|
// A refusal must ANSWER, not go quiet: onArmed(false) is how a caller that
|
|
// passed a bad argument finds out at all.
|
|
bool armedAnswer = true;
|
|
bool armedCalled = false;
|
|
EXPECT_EQ(client->onEventWhenAvailable(QString(), QStringLiteral("ev0"),
|
|
[](const QString&, const QVariantList&) {},
|
|
[&](bool ok) { armedCalled = true; armedAnswer = ok; }), 0u)
|
|
<< "an empty object name must be refused";
|
|
EXPECT_TRUE(armedCalled);
|
|
EXPECT_FALSE(armedAnswer);
|
|
|
|
EXPECT_EQ(client->onEventWhenAvailable(mod, QStringLiteral("ev0"), nullptr), 0u)
|
|
<< "a null callback must be refused";
|
|
|
|
// ...and nothing was left behind by either refusal.
|
|
EXPECT_TRUE(client->pendingEventSubscriptions().isEmpty())
|
|
<< client->pendingEventSubscriptions().join(", ").toStdString();
|
|
}
|
|
|
|
// ── C ABI (lp_subscribe) ─────────────────────────────────────────────────────
|
|
//
|
|
// This is the half that a LogosQmlBridge-only fix would have missed: a ui_qml
|
|
// package with a C++/Nim/Rust backend subscribes from INSIDE ui-host through
|
|
// the generated `dep.on<Event>()` wrapper -> logos::qt::subscribe ->
|
|
// lp_subscribe, which used to return nullptr in exactly this window.
|
|
TEST_F(DeferredSubscriptionTest, LpSubscribe_BeforePublish_Delivers)
|
|
{
|
|
const QString mod = QStringLiteral("lp_late_module");
|
|
|
|
lp_client* client = lp_client_create(mod.toUtf8().constData(), "caller", nullptr, nullptr);
|
|
ASSERT_NE(client, nullptr);
|
|
|
|
std::atomic<int> received{0};
|
|
lp_subscription* sub = lp_subscribe(
|
|
client, "ev0",
|
|
[](const char*, const char*, void* ud) {
|
|
static_cast<std::atomic<int>*>(ud)->fetch_add(1);
|
|
},
|
|
&received);
|
|
|
|
ASSERT_NE(sub, nullptr) << "lp_subscribe refused a subscription for a module that is "
|
|
"not reachable yet -- it will never be retried";
|
|
|
|
pump(300);
|
|
Publisher pub(mod);
|
|
|
|
for (int i = 0; i < 200 && received.load() == 0; ++i) {
|
|
if (pub.echo.emitFn) pub.echo.emitFn(QStringLiteral("ev0"), QVariantList{ 1 });
|
|
pump(50);
|
|
}
|
|
EXPECT_GE(received.load(), 1) << "lp_subscribe subscription never armed";
|
|
|
|
lp_unsubscribe(sub);
|
|
lp_client_destroy(client);
|
|
}
|
|
|
|
// Control for the case above.
|
|
TEST_F(DeferredSubscriptionTest, LpSubscribe_AfterPublish_Control)
|
|
{
|
|
const QString mod = QStringLiteral("lp_ready_module");
|
|
Publisher pub(mod);
|
|
|
|
lp_client* client = lp_client_create(mod.toUtf8().constData(), "caller", nullptr, nullptr);
|
|
ASSERT_NE(client, nullptr);
|
|
|
|
std::atomic<int> received{0};
|
|
lp_subscription* sub = lp_subscribe(
|
|
client, "ev0",
|
|
[](const char*, const char*, void* ud) {
|
|
static_cast<std::atomic<int>*>(ud)->fetch_add(1);
|
|
},
|
|
&received);
|
|
ASSERT_NE(sub, nullptr);
|
|
|
|
for (int i = 0; i < 200 && received.load() == 0; ++i) {
|
|
if (pub.echo.emitFn) pub.echo.emitFn(QStringLiteral("ev0"), QVariantList{ 1 });
|
|
pump(50);
|
|
}
|
|
EXPECT_GE(received.load(), 1);
|
|
|
|
lp_unsubscribe(sub);
|
|
lp_client_destroy(client);
|
|
}
|
|
|
|
// ── The probe must not free a replica QtRO is still holding ──────────────────
|
|
//
|
|
// Regression for the use-after-free introduced by e9f82ac and fixed by 09f684f.
|
|
//
|
|
// tryAcquireNow() used to acquire a dynamic replica and, when it was not
|
|
// already Valid, DELETE it. 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.
|
|
//
|
|
// WHY IT TAKES MORE THAN ONE SUBSCRIPTION. The first probe owns the only
|
|
// implementation and takes it down with itself, so a single subscription is
|
|
// harmless. It needs a second whose implementation is pinned by an in-flight
|
|
// PendingAcquire before a freed facade can outlive the implementation holding a
|
|
// pointer to it. A consumer that subscribes once sees nothing wrong; a view
|
|
// that registers every event it cares about up front dies. That asymmetry is
|
|
// why this went unnoticed, and it is why the control below matters as much as
|
|
// the case.
|
|
//
|
|
// WHERE IT CRASHES. Not at the subscribe call -- in the event loop, one turn
|
|
// later, when the handshake lands and QtRO walks the list. So the failure mode
|
|
// of this test is the BINARY DYING (SIGBUS / BUS_ADRALN on arm64, SIGSEGV
|
|
// elsewhere), not a failed assertion. A green run is the whole signal.
|
|
//
|
|
// The pumping at the end is therefore not incidental: without an event-loop
|
|
// turn after the subscriptions, the pre-fix code passes this test.
|
|
TEST_F(DeferredSubscriptionTest, ManySubscriptionsBeforeArm_DoNotFreeAReplicaQtRoStillHolds)
|
|
{
|
|
const QString mod = QStringLiteral("probe_churn_module");
|
|
|
|
// Published FIRST and never pumped before subscribing: the host exists, so
|
|
// an acquire can pin an implementation, but the replica cannot have reached
|
|
// Valid yet -- which is exactly the window every probe lands in.
|
|
Publisher pub(mod);
|
|
|
|
TokenManager::instance().saveToken(mod, QStringLiteral("tok"));
|
|
auto client = std::make_unique<LogosAPIClient>(mod, QStringLiteral("caller"),
|
|
&TokenManager::instance());
|
|
|
|
// 24 is comfortably past the ~14 at which the original crash reproduced,
|
|
// and far enough past 1 that the "one probe is harmless" case cannot mask it.
|
|
constexpr int kSubscriptions = 24;
|
|
std::atomic<int> received{0};
|
|
for (int i = 0; i < kSubscriptions; ++i) {
|
|
const quint64 id = client->onEventWhenAvailable(
|
|
mod, QStringLiteral("ev%1").arg(i),
|
|
[&](const QString&, const QVariantList&) { received.fetch_add(1); });
|
|
ASSERT_NE(id, 0u) << "subscription " << i << " was refused";
|
|
}
|
|
|
|
// The turn that used to kill the process.
|
|
for (int i = 0; i < 200 && received.load() == 0; ++i) {
|
|
if (pub.echo.emitFn) pub.echo.emitFn(QStringLiteral("ev0"), QVariantList{ 1 });
|
|
pump(50);
|
|
}
|
|
|
|
// Surviving to here is the regression assertion. Delivery is asserted too,
|
|
// so that a build which somehow armed nothing at all cannot pass by being
|
|
// quietly inert.
|
|
EXPECT_GE(received.load(), 1) << "no subscription ever armed";
|
|
EXPECT_TRUE(client->pendingEventSubscriptions().isEmpty())
|
|
<< "still reporting pending after arming";
|
|
}
|
|
|
|
// Control: ONE subscription in the same window. Green both before and after the
|
|
// fix -- it is here to pin that the case above needs the churn, not merely a
|
|
// deferred subscribe, so a future reader cannot conclude the whole path was
|
|
// broken.
|
|
TEST_F(DeferredSubscriptionTest, OneSubscriptionBeforeArm_Control)
|
|
{
|
|
const QString mod = QStringLiteral("probe_single_module");
|
|
Publisher pub(mod);
|
|
|
|
TokenManager::instance().saveToken(mod, QStringLiteral("tok"));
|
|
auto client = std::make_unique<LogosAPIClient>(mod, QStringLiteral("caller"),
|
|
&TokenManager::instance());
|
|
|
|
std::atomic<int> received{0};
|
|
ASSERT_NE(client->onEventWhenAvailable(mod, QStringLiteral("ev0"),
|
|
[&](const QString&, const QVariantList&) { received.fetch_add(1); }), 0u);
|
|
|
|
for (int i = 0; i < 200 && received.load() == 0; ++i) {
|
|
if (pub.echo.emitFn) pub.echo.emitFn(QStringLiteral("ev0"), QVariantList{ 1 });
|
|
pump(50);
|
|
}
|
|
EXPECT_GE(received.load(), 1);
|
|
}
|