Summary
When a plugin emits a Qt event via LogosProviderBase::emitEvent from inside a Q_INVOKABLE slot that is currently being dispatched over QRO, the slot's reply packet can fail to reach the caller before the latter's QRemoteObjectPendingCall::waitForFinished deadline. The caller times out and surfaces METHOD_FAILED even though the plugin's slot ran to completion and returned a valid value. On ubuntu-latest GitHub Actions runners (4 vCPU shared, x86_64) the bug reproduces 100 %; on fast local hardware (Apple-M-series, Lima VM) it reproduces 0 %.
Workaround status
A plugin-local workaround for chat-module is in flight on logos-co/logos-chat-module#24 (add_integration_tests): every emitEvent from the libchat C callbacks is deferred via QMetaObject::invokeMethod(..., Qt::QueuedConnection), with a per-instance QObject member on ChatModuleImpl serving as the receiver so Qt drops pending queued metacalls when the impl is destroyed (preventing use-after-free on teardown). This unblocks the chat-module e2e suite on ubuntu-latest (~10.9 s green; 2/2 attempts).
The workaround is plugin-local: it does not address the underlying SDK issue, does not protect other plugins, and is intended to be removed in a follow-up once an upstream SDK fix lands. This issue tracks that upstream fix.
Reproduction
PR logos-co/logos-chat-module#24 (add_integration_tests, SHA 87ea9ff) — tests/e2e/test_two_users_chat.py::test_two_users_can_chat ERRORs at the first call chat_module initChat with exit 4 / MethodError after approximately 20 s. Plugin-side trace in the daemon container's docker log shows ChatModuleImpl: Chat context created successfully at +5 ms after the slot entry, proving initChat ran to completion and returned true. The consumer-side daemon trace ends at Debug: [LogosObject] RemoteLogosObject::callMethod "initChat" args: 1, followed by 20 s of silence and the timeout return.
A bisect against test_basic_module_cpp.returnTrue (no re-entrant emit, otherwise-identical CI environment in the same chat-module test harness) passes in ~1.1 s, demonstrating that the QRO transport itself is healthy.
A plugin-local mitigation in the chat module (deferring every emitEvent from the libchat C callbacks through QMetaObject::invokeMethod(..., Qt::QueuedConnection)) restores test_two_users_can_chat to ~10.9 s green on ubuntu-latest. The plugin-local fix is a workaround; this issue proposes the upstream SDK fix.
Root cause (cross-process)
The plugin host's main thread receives an incoming RPC over QRO and dispatches it on the source side via Q_INVOKABLE ModuleProxy::callRemoteMethod (cpp/module_proxy.cpp:41). The proxy calls into the plugin via m_provider->callMethod(...) (cpp/module_proxy.cpp:57). For Q_OBJECT-wrapped plugins, this further dispatches through QtProviderObject::invokeMethodByArgCount (cpp/qt_provider_object.cpp:140-155) using Qt::DirectConnection; for universal C++ plugins (e.g. chat-module), the dispatch goes through generated *_dispatch.cpp code which invokes the impl method directly. In either case the plugin's LOGOS_METHOD runs synchronously on the host's main thread.
That method then synchronously invokes a C-API library (e.g. libchat::chat_new) which synchronously fires a C callback which synchronously calls LogosProviderBase::emitEvent → which the SDK wires to ModuleProxy::ModuleProxy's listener lambda (cpp/module_proxy.cpp:11-14) which synchronously emits eventResponse as a Qt signal:
m_provider->setEventListener([this](const QString& eventName, const QVariantList& data) {
qDebug() << ...;
emit eventResponse(eventName, data); // ← cross-process emit while
// the source slot is still
// on the stack
});
Under Qt::AutoConnection, the receiver (QRO source's own machinery on the same thread) resolves to Qt::DirectConnection, keeping the chain synchronous. The QRO source's reply packet for the original callRemoteMethod slot sits in QLocalSocket's send buffer, unflushed, until the entire emit chain unwinds and the host returns to its event loop. On slow runners (ubuntu-latest) the cumulative cross-process work the emit chain triggers (event delivery back from the daemon's listener subscription, etc.) backs up the write window enough to exceed the caller's 20 s waitForFinished deadline.
Proposed fix
Defer the eventResponse emit in ModuleProxy::ModuleProxy's listener lambda through a queued metacall on this. This fires after the current QRO dispatch stack has fully unwound, letting QLocalSocket flush the slot's reply first.
Sketch:
// cpp/module_proxy.cpp
ModuleProxy::ModuleProxy(LogosProviderObject* provider, QObject* parent)
: QObject(parent), m_provider(provider)
{
if (m_provider) {
QPointer<ModuleProxy> self(this);
m_provider->setEventListener([self](const QString& eventName, const QVariantList& data) {
if (!self) return;
QMetaObject::invokeMethod(self.data(), [self, eventName, data]() {
if (!self) return;
emit self->eventResponse(eventName, data);
}, Qt::QueuedConnection);
});
// …
}
}
QPointer<ModuleProxy> plus the two null-guards (at post time and at dispatch time) cover proxy destruction between post and dispatch (plugin unload, daemon shutdown). The same change should be evaluated for QtProviderObject::onWrappedEventResponse (cpp/qt_provider_object.cpp:206-211), which is the parallel QObject-wrapped emit path with the same shape.
Summary
When a plugin emits a Qt event via
LogosProviderBase::emitEventfrom inside aQ_INVOKABLEslot that is currently being dispatched over QRO, the slot's reply packet can fail to reach the caller before the latter'sQRemoteObjectPendingCall::waitForFinisheddeadline. The caller times out and surfacesMETHOD_FAILEDeven though the plugin's slot ran to completion and returned a valid value. Onubuntu-latestGitHub Actions runners (4 vCPU shared, x86_64) the bug reproduces 100 %; on fast local hardware (Apple-M-series, Lima VM) it reproduces 0 %.Workaround status
A plugin-local workaround for chat-module is in flight on
logos-co/logos-chat-module#24(add_integration_tests): everyemitEventfrom the libchat C callbacks is deferred viaQMetaObject::invokeMethod(..., Qt::QueuedConnection), with a per-instanceQObjectmember onChatModuleImplserving as the receiver so Qt drops pending queued metacalls when the impl is destroyed (preventing use-after-free on teardown). This unblocks the chat-module e2e suite onubuntu-latest(~10.9 s green; 2/2 attempts).The workaround is plugin-local: it does not address the underlying SDK issue, does not protect other plugins, and is intended to be removed in a follow-up once an upstream SDK fix lands. This issue tracks that upstream fix.
Reproduction
PR
logos-co/logos-chat-module#24(add_integration_tests, SHA87ea9ff) —tests/e2e/test_two_users_chat.py::test_two_users_can_chatERRORs at the firstcall chat_module initChatwith exit 4 /MethodErrorafter approximately 20 s. Plugin-side trace in the daemon container's docker log showsChatModuleImpl: Chat context created successfullyat +5 ms after the slot entry, provinginitChatran to completion and returnedtrue. The consumer-side daemon trace ends atDebug: [LogosObject] RemoteLogosObject::callMethod "initChat" args: 1, followed by 20 s of silence and the timeout return.A bisect against
test_basic_module_cpp.returnTrue(no re-entrant emit, otherwise-identical CI environment in the same chat-module test harness) passes in ~1.1 s, demonstrating that the QRO transport itself is healthy.A plugin-local mitigation in the chat module (deferring every
emitEventfrom the libchat C callbacks throughQMetaObject::invokeMethod(..., Qt::QueuedConnection)) restorestest_two_users_can_chatto ~10.9 s green onubuntu-latest. The plugin-local fix is a workaround; this issue proposes the upstream SDK fix.Root cause (cross-process)
The plugin host's main thread receives an incoming RPC over QRO and dispatches it on the source side via
Q_INVOKABLE ModuleProxy::callRemoteMethod(cpp/module_proxy.cpp:41). The proxy calls into the plugin viam_provider->callMethod(...)(cpp/module_proxy.cpp:57). For Q_OBJECT-wrapped plugins, this further dispatches throughQtProviderObject::invokeMethodByArgCount(cpp/qt_provider_object.cpp:140-155) usingQt::DirectConnection; for universal C++ plugins (e.g. chat-module), the dispatch goes through generated*_dispatch.cppcode which invokes the impl method directly. In either case the plugin'sLOGOS_METHODruns synchronously on the host's main thread.That method then synchronously invokes a C-API library (e.g.
libchat::chat_new) which synchronously fires a C callback which synchronously callsLogosProviderBase::emitEvent→ which the SDK wires toModuleProxy::ModuleProxy's listener lambda (cpp/module_proxy.cpp:11-14) which synchronouslyemitseventResponseas a Qt signal:Under
Qt::AutoConnection, the receiver (QRO source's own machinery on the same thread) resolves toQt::DirectConnection, keeping the chain synchronous. The QRO source's reply packet for the originalcallRemoteMethodslot sits inQLocalSocket's send buffer, unflushed, until the entire emit chain unwinds and the host returns to its event loop. On slow runners (ubuntu-latest) the cumulative cross-process work the emit chain triggers (event delivery back from the daemon's listener subscription, etc.) backs up the write window enough to exceed the caller's 20 swaitForFinisheddeadline.Proposed fix
Defer the
eventResponseemit inModuleProxy::ModuleProxy's listener lambda through a queued metacall onthis. This fires after the current QRO dispatch stack has fully unwound, lettingQLocalSocketflush the slot's reply first.Sketch:
QPointer<ModuleProxy>plus the two null-guards (at post time and at dispatch time) cover proxy destruction between post and dispatch (plugin unload, daemon shutdown). The same change should be evaluated forQtProviderObject::onWrappedEventResponse(cpp/qt_provider_object.cpp:206-211), which is the parallel QObject-wrapped emit path with the same shape.