diff --git a/cpp/deferred_delete_guard.h b/cpp/deferred_delete_guard.h new file mode 100644 index 0000000..55f60fc --- /dev/null +++ b/cpp/deferred_delete_guard.h @@ -0,0 +1,75 @@ +#pragma once + +#include +#include +#include +#include +#include + +// RAII scope guard that swallows QEvent::DeferredDelete events application-wide +// while active, then re-posts them when it goes out of scope. +// +// Intended use: wrap any code path that spins a nested Qt event loop from +// inside a QML signal handler (typically a synchronous RPC via +// QConnectedReplicaImplementation::waitForSource). Without the guard, Qt's +// event dispatcher processes posted DeferredDelete events during the nested +// loop, and if any of those events destroys an ancestor of the QML item +// whose signal handler is on the call stack, QQmlData::destroyed aborts +// with: +// +// QQmlData::destroyed called qFatal — "Object destroyed while one of +// its QML signal handlers is in progress" +// +// The guard doesn't lose events — they're held in a QPointer list +// and re-posted via deleteLater() when the guard is destroyed, so they +// land on the next event-loop iteration, after the outer signal handler +// has unwound. +// +// Scope: installed on QCoreApplication::instance(), so it sees posted +// events destined for main-thread objects only. That matches where this +// crash happens (QML is main-thread). Cost is one virtual eventFilter call +// per event during the guard's active window (milliseconds to seconds); +// negligible in practice. +// +// QPointer guards against the held target being deleted through another +// path while the guard is active — re-posting deleteLater on a null +// QPointer is a no-op. +class DeferredDeleteGuard : public QObject { +public: + DeferredDeleteGuard() { + if (auto* app = QCoreApplication::instance()) { + app->installEventFilter(this); + } + } + + ~DeferredDeleteGuard() override { + if (auto* app = QCoreApplication::instance()) { + app->removeEventFilter(this); + } + // Re-arm held deletions. Each call posts a fresh DeferredDelete + // that will be delivered on the next event-loop iteration, which + // — by the time we're being destructed — is after the caller's + // signal handler has returned. + for (const QPointer& target : std::as_const(m_held)) { + if (target) target->deleteLater(); + } + } + + DeferredDeleteGuard(const DeferredDeleteGuard&) = delete; + DeferredDeleteGuard& operator=(const DeferredDeleteGuard&) = delete; + + // For tests: number of DeferredDelete events held so far. + int heldCount() const { return static_cast(m_held.size()); } + +protected: + bool eventFilter(QObject* watched, QEvent* event) override { + if (event && event->type() == QEvent::DeferredDelete) { + m_held.append(QPointer(watched)); + return true; // swallow — will be re-posted on dtor + } + return false; + } + +private: + QList> m_held; +}; diff --git a/cpp/implementations/qt_remote/remote_logos_object.h b/cpp/implementations/qt_remote/remote_logos_object.h new file mode 100644 index 0000000..c23b6e6 --- /dev/null +++ b/cpp/implementations/qt_remote/remote_logos_object.h @@ -0,0 +1,45 @@ +#pragma once +#include "logos_object.h" +#include + +// Concrete LogosObject that communicates via Qt Remote Objects. +// +// The constructor takes the replica as QObject* so that this header does not +// pull in QRemoteObjects headers into every translation unit that instantiates +// the class. At runtime the pointer always points to a QRemoteObjectReplica. +// +// m_helper is also stored as QObject* for the same reason; the implementation +// in remote_transport.cpp casts it to RemoteEventHelper* where needed. +class RemoteLogosObject : public LogosObject { +public: + explicit RemoteLogosObject(QObject* replica); + ~RemoteLogosObject() override; + + QVariant callMethod(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int timeoutMs) override; + + void callMethodAsync(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int timeoutMs, + AsyncResultCallback callback) override; + + bool informModuleToken(const QString& authToken, + const QString& moduleName, + const QString& token, + int timeoutMs) override; + + void onEvent(const QString& eventName, EventCallback callback) override; + void disconnectEvents() override; + void emitEvent(const QString& eventName, const QVariantList& data) override; + QJsonArray getMethods() override; + + void release() override; + quintptr id() const override; + +private: + QObject* m_replica; + QObject* m_helper; +}; diff --git a/cpp/implementations/qt_remote/remote_transport.cpp b/cpp/implementations/qt_remote/remote_transport.cpp index 64604ef..cce9076 100644 --- a/cpp/implementations/qt_remote/remote_transport.cpp +++ b/cpp/implementations/qt_remote/remote_transport.cpp @@ -1,4 +1,6 @@ #include "remote_transport.h" +#include "remote_logos_object.h" +#include "../../deferred_delete_guard.h" #include #include #include @@ -41,205 +43,215 @@ private: } // anonymous namespace -class RemoteLogosObject : public LogosObject { -public: - explicit RemoteLogosObject(QObject* replica) - : m_replica(replica), m_helper(nullptr) - { - qDebug() << "[LogosObject] Created RemoteLogosObject wrapping QRemoteObjectReplica" << reinterpret_cast(replica); - } +// ── RemoteLogosObject method definitions ───────────────────────────────────── - ~RemoteLogosObject() override { - qDebug() << "[LogosObject] Destroying RemoteLogosObject" << reinterpret_cast(m_replica); - delete m_helper; - } +RemoteLogosObject::RemoteLogosObject(QObject* replica) + : m_replica(replica), m_helper(nullptr) +{ + qDebug() << "[LogosObject] Created RemoteLogosObject wrapping QRemoteObjectReplica" << reinterpret_cast(replica); +} - QVariant callMethod(const QString& authToken, - const QString& methodName, - const QVariantList& args, - int timeoutMs) override - { - if (!m_replica) { - qWarning() << "RemoteLogosObject: Cannot call method on null replica"; - return QVariant(); - } - qDebug() << "[LogosObject] RemoteLogosObject::callMethod" << methodName << "args:" << args.size(); - - QRemoteObjectPendingCall pendingCall; - bool success = QMetaObject::invokeMethod( - m_replica, - "callRemoteMethod", - Qt::DirectConnection, - Q_RETURN_ARG(QRemoteObjectPendingCall, pendingCall), - Q_ARG(QString, authToken), - Q_ARG(QString, methodName), - Q_ARG(QVariantList, args) - ); - - if (!success) { - qWarning() << "RemoteLogosObject: Failed to invoke callRemoteMethod on replica"; - return QVariant(); - } - - pendingCall.waitForFinished(timeoutMs); - - if (!pendingCall.isFinished() || pendingCall.error() != QRemoteObjectPendingCall::NoError) { - qWarning() << "RemoteLogosObject: callRemoteMethod failed or timed out:" << pendingCall.error(); - return QVariant(); - } - - return pendingCall.returnValue(); - } - - void callMethodAsync(const QString& authToken, - const QString& methodName, - const QVariantList& args, - int timeoutMs, - AsyncResultCallback callback) override - { - if (!callback) return; - if (!m_replica) { - QTimer::singleShot(0, [callback]() { callback(QVariant()); }); - return; - } - - qDebug() << "[LogosObject] RemoteLogosObject::callMethodAsync" << methodName << "args:" << args.size(); - - QRemoteObjectPendingCall pendingCall; - bool success = QMetaObject::invokeMethod( - m_replica, - "callRemoteMethod", - Qt::DirectConnection, - Q_RETURN_ARG(QRemoteObjectPendingCall, pendingCall), - Q_ARG(QString, authToken), - Q_ARG(QString, methodName), - Q_ARG(QVariantList, args) - ); - - if (!success) { - qWarning() << "RemoteLogosObject: Failed to invoke callRemoteMethod on replica (async)"; - QTimer::singleShot(0, [callback]() { callback(QVariant()); }); - return; - } - - auto* watcher = new QRemoteObjectPendingCallWatcher(pendingCall); - - // Timeout timer -- parented to the watcher so it is auto-deleted - // when the watcher is destroyed, preventing late timeout callbacks. - auto* timer = new QTimer(watcher); - timer->setSingleShot(true); - - // Success handler -- delivers result on the consumer's thread - QObject::connect(watcher, &QRemoteObjectPendingCallWatcher::finished, - watcher, [callback, timer](QRemoteObjectPendingCallWatcher* w) { - timer->stop(); // cancel timeout - QVariant result; - if (w->error() == QRemoteObjectPendingCall::NoError) { - result = w->returnValue(); - } else { - qWarning() << "RemoteLogosObject: async callMethod error:" << w->error(); - } - callback(result); - w->deleteLater(); - }, Qt::QueuedConnection); - - // Timeout handler -- stops the watcher and delivers empty result - QObject::connect(timer, &QTimer::timeout, watcher, [watcher, callback]() { - qWarning() << "RemoteLogosObject: async callMethod timed out"; - callback(QVariant()); - watcher->deleteLater(); // also destroys the timer (child) - }); - - timer->start(timeoutMs); - } - - bool informModuleToken(const QString& authToken, - const QString& moduleName, - const QString& token, - int timeoutMs) override - { - if (!m_replica) { - qWarning() << "RemoteLogosObject: Cannot call informModuleToken on null replica"; - return false; - } - - QRemoteObjectPendingCall pendingCall; - bool success = QMetaObject::invokeMethod( - m_replica, - "informModuleToken", - Qt::DirectConnection, - Q_RETURN_ARG(QRemoteObjectPendingCall, pendingCall), - Q_ARG(QString, authToken), - Q_ARG(QString, moduleName), - Q_ARG(QString, token) - ); - - if (!success) { - qWarning() << "RemoteLogosObject: Failed to invoke informModuleToken on replica"; - return false; - } - - pendingCall.waitForFinished(timeoutMs); - - if (!pendingCall.isFinished() || pendingCall.error() != QRemoteObjectPendingCall::NoError) { - qWarning() << "RemoteLogosObject: informModuleToken failed or timed out:" << pendingCall.error(); - return false; - } - - return pendingCall.returnValue().toBool(); - } - - void onEvent(const QString& eventName, EventCallback callback) override - { - if (!m_replica) return; - - qDebug() << "[LogosObject] RemoteLogosObject::onEvent subscribing to event:" << eventName; - if (!m_helper) { - m_helper = new RemoteEventHelper(); - QObject::connect(m_replica, SIGNAL(eventResponse(QString,QVariantList)), - m_helper, SLOT(onEventResponse(QString,QVariantList))); - qDebug() << "[LogosObject] RemoteLogosObject: connected EventHelper to QRemoteObjectReplica signals (IPC)"; - } - m_helper->addCallback(eventName, std::move(callback)); - } - - void disconnectEvents() override - { - delete m_helper; - m_helper = nullptr; - } - - void emitEvent(const QString& eventName, const QVariantList& data) override - { - if (!m_replica) return; - qDebug() << "[LogosObject] RemoteLogosObject::emitEvent" << eventName << "data:" << data.size() << "items (via IPC)"; - QMetaObject::invokeMethod(m_replica, "eventResponse", - Qt::QueuedConnection, - Q_ARG(QString, eventName), - Q_ARG(QVariantList, data)); - } - - QJsonArray getMethods() override - { - // Remote introspection not implemented — callers should use - // the local module inspection tools (lm) instead. - return QJsonArray(); - } - - void release() override - { - disconnectEvents(); - delete m_replica; +RemoteLogosObject::~RemoteLogosObject() +{ + qDebug() << "[LogosObject] Destroying RemoteLogosObject" << reinterpret_cast(m_replica); + delete m_helper; + // Release the replica via deleteLater so QRemoteObjectNode's + // destroyed-signal wiring gets to deregister us from its internal + // name→replica map. Without this, every invokeRemoteMethod leaks + // a QRemoteObjectDynamicReplica on the node; the accumulated + // entries collide when a later acquireDynamic for the same name + // walks the map, and QRemoteObjectNodePrivate::setReplicaImplementation + // ends up dereferencing a stale vtable pointer. + if (m_replica) { + m_replica->deleteLater(); m_replica = nullptr; - delete this; + } +} + +QVariant RemoteLogosObject::callMethod(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int timeoutMs) +{ + if (!m_replica) { + qWarning() << "RemoteLogosObject: Cannot call method on null replica"; + return QVariant(); + } + qDebug() << "[LogosObject] RemoteLogosObject::callMethod" << methodName << "args:" << args.size(); + + QRemoteObjectPendingCall pendingCall; + bool success = QMetaObject::invokeMethod( + m_replica, + "callRemoteMethod", + Qt::DirectConnection, + Q_RETURN_ARG(QRemoteObjectPendingCall, pendingCall), + Q_ARG(QString, authToken), + Q_ARG(QString, methodName), + Q_ARG(QVariantList, args) + ); + + if (!success) { + qWarning() << "RemoteLogosObject: Failed to invoke callRemoteMethod on replica"; + return QVariant(); } - quintptr id() const override { return reinterpret_cast(m_replica); } + pendingCall.waitForFinished(timeoutMs); -private: - QObject* m_replica; - RemoteEventHelper* m_helper; -}; + if (!pendingCall.isFinished() || pendingCall.error() != QRemoteObjectPendingCall::NoError) { + qWarning() << "RemoteLogosObject: callRemoteMethod failed or timed out:" << pendingCall.error(); + return QVariant(); + } + + return pendingCall.returnValue(); +} + +void RemoteLogosObject::callMethodAsync(const QString& authToken, + const QString& methodName, + const QVariantList& args, + int timeoutMs, + AsyncResultCallback callback) +{ + if (!callback) return; + if (!m_replica) { + QTimer::singleShot(0, [callback]() { callback(QVariant()); }); + return; + } + + qDebug() << "[LogosObject] RemoteLogosObject::callMethodAsync" << methodName << "args:" << args.size(); + + QRemoteObjectPendingCall pendingCall; + bool success = QMetaObject::invokeMethod( + m_replica, + "callRemoteMethod", + Qt::DirectConnection, + Q_RETURN_ARG(QRemoteObjectPendingCall, pendingCall), + Q_ARG(QString, authToken), + Q_ARG(QString, methodName), + Q_ARG(QVariantList, args) + ); + + if (!success) { + qWarning() << "RemoteLogosObject: Failed to invoke callRemoteMethod on replica (async)"; + QTimer::singleShot(0, [callback]() { callback(QVariant()); }); + return; + } + + auto* watcher = new QRemoteObjectPendingCallWatcher(pendingCall); + + // Timeout timer -- parented to the watcher so it is auto-deleted + // when the watcher is destroyed, preventing late timeout callbacks. + auto* timer = new QTimer(watcher); + timer->setSingleShot(true); + + // Success handler -- delivers result on the consumer's thread + QObject::connect(watcher, &QRemoteObjectPendingCallWatcher::finished, + watcher, [callback, timer](QRemoteObjectPendingCallWatcher* w) { + timer->stop(); // cancel timeout + QVariant result; + if (w->error() == QRemoteObjectPendingCall::NoError) { + result = w->returnValue(); + } else { + qWarning() << "RemoteLogosObject: async callMethod error:" << w->error(); + } + callback(result); + w->deleteLater(); + }, Qt::QueuedConnection); + + // Timeout handler -- stops the watcher and delivers empty result + QObject::connect(timer, &QTimer::timeout, watcher, [watcher, callback]() { + qWarning() << "RemoteLogosObject: async callMethod timed out"; + callback(QVariant()); + watcher->deleteLater(); // also destroys the timer (child) + }); + + timer->start(timeoutMs); +} + +bool RemoteLogosObject::informModuleToken(const QString& authToken, + const QString& moduleName, + const QString& token, + int timeoutMs) +{ + if (!m_replica) { + qWarning() << "RemoteLogosObject: Cannot call informModuleToken on null replica"; + return false; + } + + QRemoteObjectPendingCall pendingCall; + bool success = QMetaObject::invokeMethod( + m_replica, + "informModuleToken", + Qt::DirectConnection, + Q_RETURN_ARG(QRemoteObjectPendingCall, pendingCall), + Q_ARG(QString, authToken), + Q_ARG(QString, moduleName), + Q_ARG(QString, token) + ); + + if (!success) { + qWarning() << "RemoteLogosObject: Failed to invoke informModuleToken on replica"; + return false; + } + + pendingCall.waitForFinished(timeoutMs); + + if (!pendingCall.isFinished() || pendingCall.error() != QRemoteObjectPendingCall::NoError) { + qWarning() << "RemoteLogosObject: informModuleToken failed or timed out:" << pendingCall.error(); + return false; + } + + return pendingCall.returnValue().toBool(); +} + +void RemoteLogosObject::onEvent(const QString& eventName, EventCallback callback) +{ + if (!m_replica) return; + + qDebug() << "[LogosObject] RemoteLogosObject::onEvent subscribing to event:" << eventName; + if (!m_helper) { + m_helper = new RemoteEventHelper(); + QObject::connect(m_replica, SIGNAL(eventResponse(QString,QVariantList)), + m_helper, SLOT(onEventResponse(QString,QVariantList))); + qDebug() << "[LogosObject] RemoteLogosObject: connected EventHelper to QRemoteObjectReplica signals (IPC)"; + } + static_cast(m_helper)->addCallback(eventName, std::move(callback)); +} + +void RemoteLogosObject::disconnectEvents() +{ + delete m_helper; + m_helper = nullptr; +} + +void RemoteLogosObject::emitEvent(const QString& eventName, const QVariantList& data) +{ + if (!m_replica) return; + qDebug() << "[LogosObject] RemoteLogosObject::emitEvent" << eventName << "data:" << data.size() << "items (via IPC)"; + QMetaObject::invokeMethod(m_replica, "eventResponse", + Qt::QueuedConnection, + Q_ARG(QString, eventName), + Q_ARG(QVariantList, data)); +} + +QJsonArray RemoteLogosObject::getMethods() +{ + // Remote introspection not implemented — callers should use + // the local module inspection tools (lm) instead. + return QJsonArray(); +} + +void RemoteLogosObject::release() +{ + disconnectEvents(); + delete m_replica; + m_replica = nullptr; + delete this; +} + +quintptr RemoteLogosObject::id() const +{ + return reinterpret_cast(m_replica); +} // ── RemoteTransportHost ────────────────────────────────────────────────────── @@ -356,6 +368,14 @@ LogosObject* RemoteTransportConnection::requestObject(const QString& objectName, qDebug() << "RemoteTransportConnection: Requesting object:" << objectName << "at" << QTime::currentTime().toString("hh:mm:ss.zzz"); + // The DeferredDeleteGuard covers both acquireDynamic() and + // waitForSource() — either one can spin a nested event loop, and we + // must hold DeferredDelete events for the full duration so a QML + // delegate rebuild during the sync wait can't destroy the Button + // whose onClicked is currently on the call stack. See the guard + // definition above for the full rationale. + DeferredDeleteGuard guard; + QRemoteObjectReplica* replica = m_node->acquireDynamic(objectName); if (!replica) { qWarning() << "RemoteTransportConnection: Failed to acquire replica for:" << objectName; @@ -364,7 +384,11 @@ LogosObject* RemoteTransportConnection::requestObject(const QString& objectName, if (!replica->waitForSource(timeoutMs)) { qWarning() << "RemoteTransportConnection: Timeout waiting for replica:" << objectName; - delete replica; + // deleteLater, not delete — raw-deleting a replica that the + // QRemoteObjectNode still tracks leaves a dangling entry in the + // node's name→replica map and corrupts future acquireDynamic calls + // for the same name. See RemoteLogosObject dtor. + replica->deleteLater(); return nullptr; } diff --git a/tests/sdk/CMakeLists.txt b/tests/sdk/CMakeLists.txt index cc81003..d4e985b 100644 --- a/tests/sdk/CMakeLists.txt +++ b/tests/sdk/CMakeLists.txt @@ -1,7 +1,7 @@ add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../../cpp ${CMAKE_CURRENT_BINARY_DIR}/logos_sdk) add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/../../cpp-generator ${CMAKE_CURRENT_BINARY_DIR}/logos_generator) -find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test) +find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test Qml) # --- Generate dispatch code for the sample provider fixture --- set(SAMPLE_HEADER ${CMAKE_CURRENT_SOURCE_DIR}/fixtures/sample_provider.h) @@ -40,6 +40,7 @@ add_executable(sdk_tests test_async_calls.cpp test_provider_dispatch.cpp test_std_string_overloads.cpp + test_qml_sync_rpc_crash.cpp fixtures/sample_provider.cpp ${GENERATED_DISPATCH} ) @@ -47,6 +48,7 @@ add_executable(sdk_tests target_include_directories(sdk_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/fixtures + ${CMAKE_CURRENT_SOURCE_DIR}/../../cpp ${GENERATED_DIR} ) @@ -54,8 +56,60 @@ target_link_libraries(sdk_tests PRIVATE logos_sdk GTest::gtest Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Qml Qt${QT_VERSION_MAJOR}::RemoteObjects Qt${QT_VERSION_MAJOR}::Test ) +# The QML integration tests (QmlSyncRpcCrashTest) need to locate Qt's +# bundled QML modules (QtQml.Base) at runtime. Under nix the default +# QmlImportsPath isn't always propagated into unwrapped test binaries, +# so we discover the directory at configure time from the qtdeclarative +# install root (derivable from Qt6Qml_DIR) and pass it to the test as a +# compile definition. The test then calls engine.addImportPath() with it. +# +# Qt6Qml_DIR typically points at +# /lib/cmake/Qt6Qml +# On nix/Linux the QML imports are at +# /lib/qt-6/qml +# On macOS (framework builds) the same layout is used — qtdeclarative +# also ships lib/qt-6/qml alongside the frameworks. +set(_qt_qml_import_path "") +if(DEFINED Qt${QT_VERSION_MAJOR}Qml_DIR) + get_filename_component(_qtdecl_root + "${Qt${QT_VERSION_MAJOR}Qml_DIR}/../../.." ABSOLUTE) + foreach(_candidate + "${_qtdecl_root}/lib/qt-6/qml" + "${_qtdecl_root}/qml" + "${_qtdecl_root}/lib/qml") + if(EXISTS "${_candidate}/QtQml") + set(_qt_qml_import_path "${_candidate}") + break() + endif() + endforeach() +endif() + +# Fallback: derive from the Qt6::Qml library path by walking up. +if(NOT _qt_qml_import_path) + get_target_property(_qt_qml_lib Qt${QT_VERSION_MAJOR}::Qml LOCATION) + set(_probe "${_qt_qml_lib}") + foreach(_i RANGE 10) + get_filename_component(_probe "${_probe}" DIRECTORY) + if(EXISTS "${_probe}/lib/qt-6/qml/QtQml") + set(_qt_qml_import_path "${_probe}/lib/qt-6/qml") + break() + endif() + if(EXISTS "${_probe}/qml/QtQml") + set(_qt_qml_import_path "${_probe}/qml") + break() + endif() + endforeach() +endif() + +message(STATUS "sdk_tests QML import path: ${_qt_qml_import_path}") + +target_compile_definitions(sdk_tests PRIVATE + QT_QML_TEST_IMPORT_PATH="${_qt_qml_import_path}" +) + gtest_discover_tests(sdk_tests) diff --git a/tests/sdk/test_qml_sync_rpc_crash.cpp b/tests/sdk/test_qml_sync_rpc_crash.cpp new file mode 100644 index 0000000..7452b0d --- /dev/null +++ b/tests/sdk/test_qml_sync_rpc_crash.cpp @@ -0,0 +1,141 @@ +// Integration test for the DeferredDeleteGuard fix in RemoteLogosObject. +// +// Root cause being guarded against: +// A QML signal handler calls a C++ slot that transitively spins a nested +// QEventLoop (via QRemoteObjectReplica::waitForSource). If a deleteLater +// was posted on an ancestor of the handler's QML object before the nested +// loop started, Qt delivers the DeferredDelete inside the nested loop and +// destroys the ancestor while the signal handler is still on the call stack. +// QQmlData::destroyed detects this and aborts with: +// +// "Object destroyed while one of its QML signal handlers is in progress" +// +// The fix: wrap acquireDynamic + waitForSource in a DeferredDeleteGuard. +// The guard intercepts all DeferredDelete events for the duration of the +// nested loop and re-posts them after it exits, so they fire only once the +// outer signal handler has fully returned. +// +// What this test proves: a QML signal handler can post deleteLater on its +// own root and then call into code that spins a nested event loop (exactly +// as RemoteTransportConnection::requestObject does in production) without +// triggering the qFatal. Removing the DeferredDeleteGuard line from Bridge +// below reproduces the original crash. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "deferred_delete_guard.h" +#include "implementations/qt_remote/remote_logos_object.h" + +// --------------------------------------------------------------------------- +// Q_OBJECT helpers — must live at namespace scope for moc. +// --------------------------------------------------------------------------- + +// Minimal source exposed over QRemoteObjects. The method body is irrelevant; +// we only need the acquireDynamic + waitForSource handshake to spin a nested +// event loop. +class MinimalSource : public QObject { + Q_OBJECT +public: + Q_INVOKABLE QString ping() { return QStringLiteral("pong"); } +}; + +// Bridge invoked from inside a QML signal handler. +// +// Simulates the production crash path: the handler posts deleteLater on its +// own root object (ancestor), then acquires a RemoteLogosObject — which +// internally calls waitForSource and spins a nested event loop. +// +// DeferredDeleteGuard is the fix. Without it, the DeferredDelete for ancestor +// fires inside the nested loop, destroying a QML-bound object while its +// signal handler is still on the call stack and causing a qFatal. With the +// guard the delete is swallowed for the duration of the nested loop and +// re-posted immediately after, so it fires safely outside the handler. +class Bridge : public QObject { + Q_OBJECT + QRemoteObjectNode* m_node; +public: + explicit Bridge(QRemoteObjectNode* node, QObject* parent = nullptr) + : QObject(parent), m_node(node) {} + + Q_INVOKABLE void triggerCrashScenario(QObject* ancestor) { + if (ancestor) ancestor->deleteLater(); + + DeferredDeleteGuard guard; // ← remove this to reproduce the crash + auto* replica = m_node->acquireDynamic(QStringLiteral("test_source")); + if (replica) { + replica->waitForSource(1000); // spins nested event loop + RemoteLogosObject wrapped(replica); + // wrapped.~RemoteLogosObject() calls replica->deleteLater(), + // also intercepted by the guard still in scope above. + } + } +}; + +// --------------------------------------------------------------------------- +// Test +// --------------------------------------------------------------------------- + +TEST(QmlSyncRpcCrashTest, GuardPreventsQFatalDuringQmlSignalHandler) { + const QString url = QStringLiteral("local:ddg_test"); + QRemoteObjectHost host(QUrl{url}); + MinimalSource source; + host.enableRemoting(&source, QStringLiteral("test_source")); + + QRemoteObjectNode node; + node.connectToNode(QUrl{url}); + QTest::qWait(100); // let socket negotiation settle + + Bridge bridge(&node); + QQmlEngine engine; + + // Ensure Qt's bundled QML modules are findable. Under nix the default + // QmlImportsPath isn't always propagated into unwrapped test binaries; + // we inject the path discovered at configure time via CMake. +#ifdef QT_QML_TEST_IMPORT_PATH + engine.addImportPath(QStringLiteral(QT_QML_TEST_IMPORT_PATH)); +#endif + engine.addImportPath(QLibraryInfo::path(QLibraryInfo::QmlImportsPath)); + engine.rootContext()->setContextProperty(QStringLiteral("bridge"), &bridge); + + QQmlComponent component(&engine); + component.setData(R"QML( + import QtQml 2.0 + QtObject { + id: root + signal kick() + onKick: { bridge.triggerCrashScenario(root) } + } + )QML", QUrl()); + ASSERT_TRUE(component.isReady()) << component.errorString().toStdString(); + + QObject* root = component.create(); + ASSERT_NE(root, nullptr) << component.errorString().toStdString(); + + // Emitting kick() marks root as having an active QML signal handler. + // The handler calls Bridge::triggerCrashScenario, which posts + // deleteLater on root and then spins a nested event loop. Without + // DeferredDeleteGuard the process would qFatal here; with it we reach + // the line below without crashing. + QMetaObject::invokeMethod(root, "kick", Qt::DirectConnection); + + SUCCEED(); // reaching this line is the positive result + + // Drain re-posted deferred deletes so teardown is clean. + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); +} + +#include "test_qml_sync_rpc_crash.moc"