Files
logos-protocol/tests/protocol/test_deferred_subscription.cpp

279 lines
11 KiB
C++
Raw Permalink Normal View History

// 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);
}
// ── 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);
}