From a7a4fb7bcbe373f7210ea11d3cf958baaec4a49d Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Sat, 23 May 2026 00:32:53 +0100 Subject: [PATCH 1/3] fix: marshal provider events onto the source thread ModuleProxy's event listener emitted eventResponse directly on whatever thread the module fired the event from (its worker/FFI thread). QtRemoteObjects then serialized and sent the event from that foreign thread, racing the source socket against a method reply being sent from the source thread, which silently dropped the reply. This is why a method that emits an event mid-call never returns to the caller (e.g. delivery_module start(), which emits connectionStateChanged as the node connects) while a method that emits nothing (createNode) returns fine. Marshal the emission onto the ModuleProxy's own thread via a queued invocation so events and method replies are serialized on the single thread QtRemoteObjects expects to own the source. Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/module_proxy.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/cpp/module_proxy.cpp b/cpp/module_proxy.cpp index 102b389..8b2dc7b 100644 --- a/cpp/module_proxy.cpp +++ b/cpp/module_proxy.cpp @@ -9,7 +9,19 @@ ModuleProxy::ModuleProxy(LogosProviderObject* provider, QObject* parent) if (m_provider) { m_provider->setEventListener([this](const QString& eventName, const QVariantList& data) { qDebug() << "[LogosProviderObject] ModuleProxy: forwarding event" << eventName << "as Qt signal"; - emit eventResponse(eventName, data); + // Module events fire on the module's worker/FFI thread, not on this + // QObject's (the remoting source's) thread. Emitting eventResponse + // directly here runs QtRemoteObjects' source serialization on that + // foreign thread, racing the source socket against a method reply + // being sent from the source thread — which silently drops the + // reply. That is why start(), which emits connectionStateChanged + // mid-call, never returns to the caller while createNode (no event) + // does. Marshal the emission onto this object's thread so events + // and replies are serialized on the single thread QtRemoteObjects + // expects to own the source. + QMetaObject::invokeMethod(this, [this, eventName, data]() { + emit eventResponse(eventName, data); + }, Qt::QueuedConnection); }); qDebug() << "[LogosProviderObject] ModuleProxy: created, wrapping LogosProviderObject" << m_provider->providerName(); From 4bd747a0f1e31e1542c30033041d3bd5fd4c344b Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Sat, 23 May 2026 00:40:06 +0100 Subject: [PATCH 2/3] docs: generalize the threading comment --- cpp/module_proxy.cpp | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/cpp/module_proxy.cpp b/cpp/module_proxy.cpp index 8b2dc7b..5ed5c0c 100644 --- a/cpp/module_proxy.cpp +++ b/cpp/module_proxy.cpp @@ -9,16 +9,14 @@ ModuleProxy::ModuleProxy(LogosProviderObject* provider, QObject* parent) if (m_provider) { m_provider->setEventListener([this](const QString& eventName, const QVariantList& data) { qDebug() << "[LogosProviderObject] ModuleProxy: forwarding event" << eventName << "as Qt signal"; - // Module events fire on the module's worker/FFI thread, not on this - // QObject's (the remoting source's) thread. Emitting eventResponse - // directly here runs QtRemoteObjects' source serialization on that - // foreign thread, racing the source socket against a method reply - // being sent from the source thread — which silently drops the - // reply. That is why start(), which emits connectionStateChanged - // mid-call, never returns to the caller while createNode (no event) - // does. Marshal the emission onto this object's thread so events - // and replies are serialized on the single thread QtRemoteObjects - // expects to own the source. + // Events may be fired from any thread (e.g. a module's worker/FFI + // thread), but this object is the QtRemoteObjects source and must be + // driven from its own thread. Emitting directly from a foreign + // thread runs QtRO's source serialization there, racing the source + // socket against a reply being sent from the source thread, which + // can silently drop the reply. Marshal the emission onto this + // object's thread so events and replies stay serialized on the + // single thread QtRO expects to own the source. QMetaObject::invokeMethod(this, [this, eventName, data]() { emit eventResponse(eventName, data); }, Qt::QueuedConnection); From 04e4ac9eed91c6ffa74e7e2614cafc5ecda1cadd Mon Sep 17 00:00:00 2001 From: Igor Sirotin Date: Sat, 23 May 2026 09:45:14 +0100 Subject: [PATCH 3/3] fix: use AutoConnection so same-thread emits stay synchronous QueuedConnection deferred every emission, breaking same-thread callers that emit-then-assert and crashing when a queued lambda outlived the object. AutoConnection invokes synchronously when already on the source thread and only queues cross-thread emissions (the actual fix); passing 'this' as context cancels a queued call if the object is destroyed first. --- cpp/module_proxy.cpp | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/cpp/module_proxy.cpp b/cpp/module_proxy.cpp index 5ed5c0c..2bccc1f 100644 --- a/cpp/module_proxy.cpp +++ b/cpp/module_proxy.cpp @@ -14,12 +14,15 @@ ModuleProxy::ModuleProxy(LogosProviderObject* provider, QObject* parent) // driven from its own thread. Emitting directly from a foreign // thread runs QtRO's source serialization there, racing the source // socket against a reply being sent from the source thread, which - // can silently drop the reply. Marshal the emission onto this - // object's thread so events and replies stay serialized on the - // single thread QtRO expects to own the source. + // can silently drop the reply. AutoConnection keeps same-thread + // callers synchronous (the common case) and only queues the + // emission when it arrives from another thread, so events and + // replies stay serialized on the thread QtRO expects to own the + // source. Passing `this` as the context also cancels a queued + // emission if this object is destroyed first. QMetaObject::invokeMethod(this, [this, eventName, data]() { emit eventResponse(eventName, data); - }, Qt::QueuedConnection); + }, Qt::AutoConnection); }); qDebug() << "[LogosProviderObject] ModuleProxy: created, wrapping LogosProviderObject" << m_provider->providerName();