Merge master into feat/sdk-codegen-b4-qt-host

Brings in the hot-reload fix (#24). module-builder's B4 branch pins this
runtime for view-interface-abi, so without this merge that pin would walk
master's bump backward and lose the fix.
This commit is contained in:
Dario Gabriel Lipicar
2026-08-19 15:44:50 -03:00
6 changed files with 220 additions and 0 deletions
+10
View File
@@ -144,6 +144,16 @@ public:
// True once the view module's replica is Valid (source meta received).
Q_INVOKABLE bool isViewModuleReady(const QString& moduleName) const;
// Re-emit viewModuleReadyChanged for every replica that is already Valid.
//
// Replicas outlive the QML engine (they are parented here, with
// CppOwnership), so a view rebuilt against a fresh engine — hot reload —
// gets the cached replica back from module() without any state transition.
// Its Connections were created after the original Valid edge and would
// otherwise wait forever for a signal that already fired. Hosts call this
// once the new object tree is complete.
void replayViewModuleState();
// Watch a QRemoteObjectPendingCall returned by a replica slot call and
// invoke callbacks with the result. Replaces QtRemoteObjects.watch() so
// QML plugins don't need to import QtRemoteObjects (keeping the sandbox
+9
View File
@@ -401,6 +401,15 @@ bool LogosQmlBridge::isViewModuleReady(const QString& moduleName) const
return false;
}
void LogosQmlBridge::replayViewModuleState()
{
for (auto it = m_replicas.cbegin(); it != m_replicas.cend(); ++it) {
auto* rep = qobject_cast<QRemoteObjectReplica*>(it.value());
if (rep && rep->state() == QRemoteObjectReplica::Valid)
emit viewModuleReadyChanged(it.key(), true);
}
}
void LogosQmlBridge::setViewModuleSocket(const QString& moduleName,
const QString& socketName)
{
+37
View File
@@ -185,3 +185,40 @@ target_link_libraries(test_logos_qml_bridge_calls PRIVATE
)
add_test(NAME LogosQmlBridgeCallsTests COMMAND test_logos_qml_bridge_calls)
# Readiness replay for QML hot reload. m_replicas is only ever populated through
# module(), which loads a factory plugin — so covering the branch where replay
# meets a genuinely Valid replica needs a real factory plugin and a real
# published source, not just the empty/dropped maps the unit suite can build.
add_library(test_replica_factory_plugin MODULE test_replica_factory_plugin.cpp)
target_link_libraries(test_replica_factory_plugin PRIVATE
Qt6::Core
Qt6::RemoteObjects
)
target_include_directories(test_replica_factory_plugin PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../include
)
add_executable(test_logos_qml_bridge_replay
test_logos_qml_bridge_replay.cpp
)
target_link_libraries(test_logos_qml_bridge_replay PRIVATE
logos_view_module_runtime
Qt6::Core
Qt6::Test
Qt6::Qml
Qt6::RemoteObjects
logos-qt-sdk::logos_qt_sdk
logos-cpp-sdk::logos_headers
logos-protocol::logos_protocol
)
# The bridge takes a filesystem path to the factory plugin, so the test needs
# to know where CMake put it.
target_compile_definitions(test_logos_qml_bridge_replay PRIVATE
TEST_REPLICA_FACTORY_PLUGIN="$<TARGET_FILE:test_replica_factory_plugin>"
)
add_dependencies(test_logos_qml_bridge_replay test_replica_factory_plugin)
add_test(NAME LogosQmlBridgeReplayTests COMMAND test_logos_qml_bridge_replay)
+21
View File
@@ -325,6 +325,27 @@ private slots:
// still knows the socket so it can reacquire.
QVERIFY(bridge.hasViewModuleSocket("foo"));
}
// ── Readiness replay (hot reload) ────────────────────────────────────
void replayViewModuleState_noReplicas_emitsNothing()
{
LogosQmlBridge bridge(nullptr);
QSignalSpy spy(&bridge, &LogosQmlBridge::viewModuleReadyChanged);
bridge.replayViewModuleState();
QCOMPARE(spy.count(), 0);
}
void replayViewModuleState_afterCrash_staysSilent()
{
LogosQmlBridge bridge(nullptr);
bridge.setViewModuleSocket("foo", "sock-1");
bridge.notifyViewModuleCrashed("foo");
QSignalSpy spy(&bridge, &LogosQmlBridge::viewModuleReadyChanged);
bridge.replayViewModuleState();
QCOMPARE(spy.count(), 0);
}
};
QTEST_GUILESS_MAIN(TestLogosQmlBridge)
+106
View File
@@ -0,0 +1,106 @@
// Readiness replay against a REAL Valid replica.
//
// replayViewModuleState() exists for QML hot reload: the host rebuilds the view
// on a fresh QQmlEngine while the backend process keeps running, so the new
// object tree's Connections are created after viewModuleReadyChanged already
// fired and the cached replica — Valid, and staying Valid — never transitions
// again to give them a second chance. The view then waits forever for readiness
// it missed.
//
// The unit suite can only reach the negative half of that (empty map, dropped
// map), where the replay loop never meets a Valid replica and so cannot catch a
// regression in the branch that matters. This one publishes a real source over
// QtRO, acquires through the bridge's own factory-plugin path, waits for the
// replica to actually reach Valid, and then pins what replay emits.
#include "LogosQmlBridge.h"
#include <QCoreApplication>
#include <QRemoteObjectHost>
#include <QSignalSpy>
#include <QTest>
#include <QUrl>
namespace {
// The remoted source. A property is not incidental: a source with no members
// still publishes, but giving the replica something to receive keeps this
// close to a real view module's backend.
class TestViewSource : public QObject {
Q_OBJECT
Q_PROPERTY(int counter READ counter WRITE setCounter NOTIFY counterChanged)
public:
int counter() const { return m_counter; }
void setCounter(int value)
{
if (m_counter == value) return;
m_counter = value;
emit counterChanged(m_counter);
}
signals:
void counterChanged(int value);
private:
int m_counter = 0;
};
constexpr int kReadyTimeoutMs = 10000;
} // namespace
class TestLogosQmlBridgeReplay : public QObject {
Q_OBJECT
private slots:
void replayViewModuleState_validReplica_reemitsReady()
{
// Socket name carries the pid so a leftover socket from a crashed run
// cannot make this test bind to — or acquire from — the wrong host.
const QString socket =
QStringLiteral("lvmr-replay-%1").arg(QCoreApplication::applicationPid());
TestViewSource source;
QRemoteObjectHost host(QUrl(QStringLiteral("local:") + socket));
QVERIFY(host.enableRemoting(&source, QStringLiteral("test_view")));
LogosQmlBridge bridge(nullptr);
bridge.setViewModuleSocket(QStringLiteral("test_view"), socket);
bridge.setViewReplicaPlugin(QStringLiteral("test_view"),
QStringLiteral(TEST_REPLICA_FACTORY_PLUGIN));
QSignalSpy readySpy(&bridge, &LogosQmlBridge::viewModuleReadyChanged);
QObject* replica = bridge.module(QStringLiteral("test_view"));
QVERIFY(replica != nullptr);
// Acquisition is asynchronous: the replica is Default until the source
// meta arrives over the socket.
QVERIFY(readySpy.wait(kReadyTimeoutMs));
QVERIFY(bridge.isViewModuleReady(QStringLiteral("test_view")));
// ── The reload. A fresh engine asks for the same module and gets the
// cached replica back; on its own that is silent, which is the bug.
readySpy.clear();
QCOMPARE(bridge.module(QStringLiteral("test_view")), replica);
QCOMPARE(readySpy.count(), 0);
// ── What the host calls once the new object tree is complete.
bridge.replayViewModuleState();
QCOMPARE(readySpy.count(), 1);
QCOMPARE(readySpy.first().at(0).toString(), QStringLiteral("test_view"));
QCOMPARE(readySpy.first().at(1).toBool(), true);
// Replay is a report of current state, not a one-shot: a view reloaded
// twice must be told twice.
readySpy.clear();
bridge.replayViewModuleState();
QCOMPARE(readySpy.count(), 1);
QCOMPARE(readySpy.first().at(1).toBool(), true);
}
};
QTEST_MAIN(TestLogosQmlBridgeReplay)
#include "test_logos_qml_bridge_replay.moc"
+37
View File
@@ -0,0 +1,37 @@
// A minimal LogosViewReplicaFactory plugin for the replay test.
//
// The bridge only ever populates m_replicas through module(), which loads a
// factory plugin via QPluginLoader and asks it for a replica — so a test that
// needs a genuinely Valid replica in that map has to come in the same way a
// real view module does. This stands in for the repc-generated factory a view
// module ships: it hands back a dynamic replica, which reaches
// QRemoteObjectReplica::Valid against a published source exactly like a typed
// one does. Only the metaobject differs, and the replay loop does not look at
// it — it qobject_casts to QRemoteObjectReplica and reads state().
#include "LogosViewReplicaFactory.h"
#include <QObject>
#include <QRemoteObjectDynamicReplica>
#include <QRemoteObjectNode>
#include <QtPlugin>
class TestReplicaFactory : public QObject, public LogosViewReplicaFactory {
Q_OBJECT
Q_PLUGIN_METADATA(IID LogosViewReplicaFactory_iid)
Q_INTERFACES(LogosViewReplicaFactory)
public:
QObject* acquire(QRemoteObjectNode* node) override
{
if (!node) return nullptr;
return node->acquireDynamic(QStringLiteral("test_view"));
}
const QMetaObject* replicaMetaObject() const override
{
return &QRemoteObjectDynamicReplica::staticMetaObject;
}
};
#include "test_replica_factory_plugin.moc"